diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d1fd873..65fc735bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # Same craft-fonts commit as release.yml. - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 timeout-minutes: 90 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index d6b897cec..70a310d38 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -27,4 +27,4 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2af7a9e3e..622ace75c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,20 +11,64 @@ on: default: "" permissions: - contents: write + contents: read concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + release: + needs: git-sources + permissions: + contents: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: app-name: designcraft app-title: DesignCraft - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/ASSETS.md b/ASSETS.md index 407a26c92..356309cee 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -8,6 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts | +| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts | | `crates/ui-egui/src/i18n/uk.rs` (332 common phrases) | VectorCraft contributors | https://github.com/storytold/vectorcraft/blob/01f329aa99fa0c9517311c7b6cf7233ffc207742/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted for DesignCraft context; remaining Ukrainian phrases are original or credited below | | `crates/ui-egui/src/i18n/uk.rs` (68 common phrases) | PhotoCraft contributors | https://github.com/storytold/photocraft/blob/ed057bdc6fd89a858c5e0ea4eab158a2a7052dde/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary | | `crates/ui-egui/src/i18n/uk.rs` (37 common phrases) | WordCraft contributors | https://github.com/storytold/wordcraft/blob/7fb7b013591e2593fbd038f90bbc535be318e5a3/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary | diff --git a/Cargo.lock b/Cargo.lock index e75e8d5a5..19899a2f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,66 +4,60 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -236,6 +231,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -446,12 +442,48 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -494,6 +526,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -683,13 +724,29 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -701,6 +758,39 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -756,6 +846,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -766,6 +862,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -779,7 +885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -792,7 +898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -802,12 +908,18 @@ version = "20.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" dependencies = [ - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "foreign-types", "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -817,6 +929,24 @@ dependencies = [ "libm", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "craft-atomic" version = "0.1.0" @@ -841,7 +971,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "bytemuck", "hayro", @@ -854,20 +984,33 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ + "craft-winprint-gdi", + "miniz_oxide 0.8.9", + "roxmltree 0.20.0", "tempfile", + "winprint", + "winsafe", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ "egui", "serde", ] +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -877,6 +1020,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -917,6 +1072,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -929,6 +1094,30 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1132,8 +1321,10 @@ dependencies = [ "craft-cms", "designcraft-color", "designcraft-fonts", + "flate2", "image", "log", + "quick-xml", "resvg", "tiff", "zune-core", @@ -1256,8 +1447,7 @@ dependencies = [ "tempfile", "toml", "unicode-bidi", - "unicode-bidi-mirroring", - "unicode-segmentation", + "web-time", "zip", ] @@ -1293,6 +1483,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1353,10 +1553,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "ecolor" -version = "0.36.2" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1364,9 +1569,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1388,6 +1592,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1399,9 +1604,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1419,9 +1623,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1439,31 +1642,34 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1475,9 +1681,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1502,13 +1707,26 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -1538,9 +1756,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1556,16 +1773,17 @@ dependencies = [ "self_cell", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu 0.2.0", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1656,12 +1874,34 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.10" @@ -1694,6 +1934,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmt-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" +dependencies = [ + "fmt-derive-proc", +] + +[[package]] +name = "fmt-derive-proc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fmv-macos-events" version = "0.1.0" @@ -1711,6 +1973,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1794,6 +2062,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1830,6 +2114,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -1843,12 +2133,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1868,6 +2171,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1887,8 +2203,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1919,7 +2238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1929,22 +2248,6 @@ dependencies = [ "vello_common 0.0.9", ] -[[package]] -name = "glifo" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" -dependencies = [ - "bytemuck", - "foldhash", - "hashbrown 0.17.1", - "log", - "peniko", - "skrifa 0.44.0", - "smallvec", - "vello_common 0.1.0", -] - [[package]] name = "glifo" version = "0.3.0" @@ -1952,7 +2255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -2063,6 +2366,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2100,6 +2422,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2108,7 +2439,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2117,13 +2448,13 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] name = "hayro" version = "0.7.1" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "bytemuck", "hayro-interpret", @@ -2143,8 +2474,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "brotli", "hayro-postscript", @@ -2153,14 +2483,14 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "bitflags 2.13.2", "hayro-cmap", "hayro-syntax", "kurbo", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash 2.1.3", "siphasher", "skrifa 0.47.0", @@ -2171,7 +2501,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2195,7 +2525,7 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "flate2", "hayro-ccitt", @@ -2225,6 +2555,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2237,12 +2573,137 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -2397,6 +2858,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[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" @@ -2548,20 +3015,20 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ - "base64", + "base64 0.22.1", "bumpalo", "flate2", "float-cmp", @@ -2698,6 +3165,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "memchr" version = "2.8.3" @@ -2722,6 +3195,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minicov" version = "0.3.8" @@ -2752,6 +3231,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2781,6 +3271,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "30.0.1" @@ -2855,6 +3351,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3229,6 +3734,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3267,6 +3778,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "ouroboros" version = "0.18.5" @@ -3284,7 +3805,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "proc-macro2-diagnostics", "quote", @@ -3332,6 +3853,25 @@ dependencies = [ "ryu", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "peniko" version = "0.6.1" @@ -3351,14 +3891,35 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] @@ -3369,7 +3930,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -3378,8 +3949,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3394,6 +3978,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pic-scale" version = "0.7.12" @@ -3555,6 +4148,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -3593,7 +4210,7 @@ dependencies = [ "bit-vec 0.8.0", "bitflags 2.13.2", "num-traits", - "rand", + "rand 0.9.5", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -3635,6 +4252,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -3663,7 +4337,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -3673,9 +4358,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -3685,13 +4376,28 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -3843,6 +4549,49 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resvg" version = "0.47.0" @@ -3896,6 +4645,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "roots" version = "0.0.8" @@ -3964,6 +4727,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -4006,6 +4844,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -4015,6 +4859,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4027,6 +4880,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -4039,6 +4915,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -4082,6 +4964,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -4102,6 +4995,40 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -4118,6 +5045,175 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "sigstore-bundle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-crypto" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.10.1", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.21", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-rekor" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "chrono", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "rand 0.10.3", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-types" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-verify" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" +dependencies = [ + "base64 0.22.1", + "chrono", + "cms", + "const-oid", + "hex", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4281,6 +5377,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -4290,6 +5396,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4322,6 +5438,27 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -4334,6 +5471,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.16.1" @@ -4344,6 +5487,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -4366,6 +5519,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -4377,6 +5539,38 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4504,13 +5698,63 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", ] [[package]] @@ -4564,6 +5808,51 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -4596,6 +5885,23 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4614,6 +5920,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -4700,6 +6012,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4718,7 +6042,7 @@ version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -4791,21 +6115,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "vello_common" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" -dependencies = [ - "bytemuck", - "fearless_simd", - "guillotiere", - "log", - "peniko", - "smallvec", - "thiserror 2.0.21", -] - [[package]] name = "vello_common" version = "0.2.0" @@ -4835,18 +6144,6 @@ dependencies = [ "vello_common 0.0.8", ] -[[package]] -name = "vello_cpu" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" -dependencies = [ - "bytemuck", - "glifo 0.2.0", - "hashbrown 0.17.1", - "vello_common 0.1.0", -] - [[package]] name = "vello_cpu" version = "0.2.0" @@ -4889,6 +6186,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +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" @@ -5164,6 +6476,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -5445,6 +6766,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -5640,7 +6972,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "git+https://github.com/storytold/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -5651,7 +6983,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -5696,6 +7028,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "xml", +] + [[package]] name = "winresource" version = "0.1.31" @@ -5718,6 +7071,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write-fonts" version = "0.48.1" @@ -5769,6 +7140,41 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.5", +] + [[package]] name = "xcursor" version = "0.3.11" @@ -5794,6 +7200,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "xml-rs" version = "0.8.29" @@ -5995,6 +7407,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" @@ -6121,3 +7553,23 @@ 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_extras" +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" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 8a83e0463..4819a9b9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared reader and writer; publish the immutable revision before this consumer. -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.1.0", default-features = false } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } @@ -79,18 +79,20 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # Atomic file replacement for saves and exports (craft-libs). craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" } # File › Print through the system print spooler (craft-libs). -craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } +craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } # ICC colour management: working spaces, transforms, black point compensation (craft-libs). craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" } # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -egui = "0.36" -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] } +egui = "0.37" +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" +# Monotonic clock on native and web (`Instant`); screenshot settlement must not use wall time. +web-time = "1" rfd = "0.17" arboard = { version = "3.6", default-features = false } @@ -122,9 +124,24 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.8.2" } -winit = { git = "https://github.com/storytold/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } -hayro = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.1" } -hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.0" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.2" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.3.0" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } +hayro = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.1" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.0" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" } +hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0" } +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" } diff --git a/NOTICE b/NOTICE index f1e997156..fc46bcdf3 100644 --- a/NOTICE +++ b/NOTICE @@ -92,14 +92,15 @@ No Adobe icons, images or artwork are included. PDF backend source -The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2 and hayro-jbig2 0.3.0 -are fetched from https://github.com/storytold/craft-libs at revision -89dcbb3060377a26c99a6d80caa16084366704c4. The four source snapshots were transferred +The patched hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0 and +hayro-cmap 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision +9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef. The first four source snapshots were transferred from https://github.com/storytold/pdfcraft at revision -7437bde378f43856eada40af25b983812e615e8a, then patched in craft-libs. Their upstream project is +7437bde378f43856eada40af25b983812e615e8a and hayro-cmap at revision +a2ec27b4775f53c40cad3456936d9406611e3a8d, then patched in craft-libs. Their upstream project is https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each crate's upstream licence files. The patch ledger is at -https://github.com/storytold/craft-libs/blob/89dcbb3060377a26c99a6d80caa16084366704c4/vendor/README.craft-reader.md. +https://github.com/storytold/craft-libs/blob/9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef/vendor/README.craft-reader.md. Per-crate vendor/SOURCE.json files record transferred and local source hashes; asset origins and unchanged per-file hashes are recorded separately in ASSETS.md. @@ -113,3 +114,21 @@ Assets already bundled through Hayro's default features retain their licences: non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt. Adobe and contributor names do not endorse or promote this product. Per-file hashes and sources are recorded in ASSETS.md. + +Bundled egui font icons + +The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' +emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is +included through monochrome_emoji_fonts. Their upstream project is +https://github.com/jslegers/emoji-icon-font; both use the same exact copyright +and permission notice below. + +MIT License + +Copyright (c) 2014 John Slegers + +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. diff --git a/ROADMAP.md b/ROADMAP.md index e4d94421d..e8b1788a0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,6 +11,14 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites + leave document/undo state intact. Null trailer entries remain unencrypted, and unsupported + all-content crops retain the whole page rather than silently clipping hidden layers. +- **2026-10-23** · PDF layer visibility variants retain catalog/configuration entries and full + object references; bounded incremental updates replace the existing catalog. Synthetic + regression tests cover metadata, output-intent references, nonzero generations and + forward Prev links; repeated cross-reference offsets are rejected explicitly. No readiness percentage change. + ## Status (2026-10-01) **Working today** @@ -350,12 +358,30 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change. +- **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change. +- **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change. +- **2026-10-24** · Type › Notes › Convert to Note (`note.convertFromText {author?}`): as in InDesign, the selected text leaves the story and becomes the text of a new note, whose anchor takes its place; Convert Note to Text puts it back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table. No readiness percentage change. +- **2026-10-23** · Effects dialog: with Use Global Light on, the Angle field (and the Bevel and Emboss one) stays editable and shows the document's global light angle; a change there sets the global light for every effect that uses it, applied with the effects in one undo step (`object.effects` takes `globalLight`). Before, the field was greyed out and the angle couldn't be changed in the dialog. No readiness percentage change. +- **2026-10-24** · Story panel (Window › Type & Tables › Story): Optical Margin Alignment and its size for the stories of the text selection or the selected text frames, disabled without either; `type.selectionAttrs` reports them as `storyOptions`, null where the stories differ. The Paragraph panel menu has Ignore Optical Margin in place of the per-paragraph Optical Margin Alignment switch, which stays in `type.para`, and Paragraph Style Options › Indents and Spacing has Ignore Optical Margin (#947). No readiness percentage change. +- **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change. +- **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change. +- **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change. +- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. +- **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. +- **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. +- **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. +- **2026-10-24** · Text frames: a custom baseline grid (Text Frame Options › Baseline Options) from an IDML file sets lines from its start by its increment. The composer read the two values in the other order, so lines landed on the wrong grid. No readiness percentage change. - **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change. +- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change. +- **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change. +- **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. +- **2026-10-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. @@ -371,6 +397,11 @@ Newest first. - **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. - **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change. - **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change. +- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. +- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. +- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change. +- **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. +- **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. @@ -378,14 +409,8 @@ Newest first. - **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change. - **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change. -- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. -- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. - **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. -- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. -- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change. -- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change. - **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. -- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/apps/designcraft-cli/src/automation_args.rs b/apps/designcraft-cli/src/automation_args.rs index 52ef34f20..2ad9af786 100644 --- a/apps/designcraft-cli/src/automation_args.rs +++ b/apps/designcraft-cli/src/automation_args.rs @@ -3,30 +3,22 @@ use std::path::PathBuf; +#[derive(Default)] pub struct AutomationArgs { - pub read_root: Option, - pub write_root: Option, - read_given: bool, - write_given: bool, + pub read_roots: Vec, + pub write_roots: Vec, } impl AutomationArgs { - pub fn new(read_root: Option, write_root: Option) -> Self { - Self { read_root, write_root, read_given: false, write_given: false } - } - - /// Returns whether this argument is a root flag. A second occurrence of a flag is an error, - /// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed. + /// Returns whether this argument is a root flag. Each flag may be given more than once (#1552), + /// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder, none replaces one. pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result { let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value))); - let (root, given) = match flag { - "--automation-read-root" => (&mut self.read_root, &mut self.read_given), - "--automation-write-root" => (&mut self.write_root, &mut self.write_given), + let roots = match flag { + "--automation-read-root" => &mut self.read_roots, + "--automation-write-root" => &mut self.write_roots, _ => return Ok(false), }; - if *given { - return Err(format!("{flag} given twice")); - } let value = match inline { Some(value) => value.to_owned(), None => { @@ -40,8 +32,7 @@ impl AutomationArgs { if value.is_empty() { return Err(format!("{flag} needs a non-empty folder")); } - *root = Some(value.into()); - *given = true; + roots.push(value.into()); Ok(true) } } @@ -51,40 +42,43 @@ mod tests { use super::*; #[test] - fn roots_are_taken_once_and_other_arguments_are_left() { - let mut roots = AutomationArgs::new(None, None); + fn roots_add_up_and_other_arguments_are_left() { + let mut roots = AutomationArgs::default(); let mut args = ["a folder".to_owned(), "doc.designcraft".to_owned()].into_iter(); assert!(roots.consume("--automation-read-root", &mut args).unwrap()); assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); assert!(!roots.consume("--sample", &mut args).unwrap()); - assert_eq!(roots.read_root, Some("a folder".into())); - assert_eq!(roots.write_root, Some("out".into())); + assert_eq!(roots.read_roots, [PathBuf::from("a folder")]); + assert_eq!(roots.write_roots, [PathBuf::from("out")]); assert_eq!(args.next().as_deref(), Some("doc.designcraft")); - assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); - assert_eq!(roots.read_root, Some("a folder".into())); + // #1552: a flag given again adds a folder, in either spelling, and keeps the first. + assert!(roots.consume("--automation-read-root=other", &mut args).unwrap()); + assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap()); + assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]); + assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]); } #[test] fn invalid_operands_leave_roots_unchanged() { for flag in ["--automation-read-root", "--automation-write-root"] { for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] { - let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into())); + let mut roots = AutomationArgs { read_roots: vec!["read".into()], write_roots: vec!["write".into()] }; let mut args = operand.map(str::to_owned).into_iter(); assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); - assert_eq!(roots.read_root, Some("read".into())); - assert_eq!(roots.write_root, Some("write".into())); + assert_eq!(roots.read_roots, [PathBuf::from("read")]); + assert_eq!(roots.write_roots, [PathBuf::from("write")]); } - let mut roots = AutomationArgs::new(None, None); + let mut roots = AutomationArgs::default(); assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); } } #[test] fn explicit_dash_folder_and_relative_dash_folder_are_valid() { - let mut roots = AutomationArgs::new(None, None); + let mut roots = AutomationArgs::default(); assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap()); assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap()); - assert_eq!(roots.read_root, Some("-folder".into())); - assert_eq!(roots.write_root, Some("./-folder".into())); + assert_eq!(roots.read_roots, [PathBuf::from("-folder")]); + assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]); } } diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 0c0a15267..d631f5671 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -10,7 +10,7 @@ //! # run a command script (crates/engine/src/script.rs): `$N.path` references //! designcraft-cli app [--port PORT] COMMAND [JSON] # run a command in the running app (designcraft --control PORT) //! designcraft-cli app [--port PORT] --method METHOD [JSON] # any control-channel method (ui.screenshot, ui.render, …) -//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR] +//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]... //! # MCP server over stdio (docs/mcp.md); the roots confine the files it reads and writes //! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document //! designcraft-cli bench FILE [--runs N] # the same measurements on one document @@ -39,7 +39,7 @@ use designcraft_engine::Session; use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). -const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version"; +const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version"; /// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] @@ -124,7 +124,7 @@ fn main() -> ExitCode { Some("bench") => report(perf::bench(&args[1..])), Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))), Some("links") => { - use designcraft_engine::links::*; + use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE}; outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); ExitCode::SUCCESS } @@ -170,7 +170,7 @@ fn mcp(args: &[String]) -> Result<(), String> { use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr}; let mut connect: Option = None; let mut sample = false; - let mut roots_args = automation_args::AutomationArgs::new(None, None); + let mut roots_args = automation_args::AutomationArgs::default(); let mut it = args.iter().cloned(); while let Some(a) = it.next() { if roots_args.consume(&a, &mut it)? { @@ -181,12 +181,14 @@ fn mcp(args: &[String]) -> Result<(), String> { "--sample" => sample = true, other => { return Err(format!( - "unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR])" + "unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...)" )); } } } - let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?; + let read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect(); + let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect(); + let roots = AutomationRoots::new(&read, &write)?; if roots.is_some() && connect.is_some() { return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \ so drop --connect (or use --connect without the roots)" @@ -209,8 +211,7 @@ so drop --connect (or use --connect without the roots)" if let Some(roots) = roots { // Every thread of this process, not only the server's: nothing reaches past the roots. file_access::confine_process(roots.clone()); - let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); - eprintln!("designcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); + eprintln!("designcraft-cli: files confined: {}", roots.describe()); h = h.with_automation_roots(Some(roots)); } Box::new(h) @@ -263,7 +264,7 @@ impl Export { /// A name with another extension is an error: it used to get a PNG of the page. One with no /// extension still does (`--export /dev/stdout`). fn of(out: &str) -> Result { - let ext = std::path::Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_lowercase); + let ext = std::path::Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_lowercase); Ok(match ext.as_deref() { None | Some("png" | "jpg" | "jpeg") => Export::Image, Some("pdf") => Export::Pdf, @@ -345,33 +346,7 @@ fn run(args: &[String]) -> Result<(), String> { } "--export" => { let out = val()?; - match Export::of(&out)? { - Export::Image => export(&mut s, &out, page, scale)?, - Export::Pdf => { - let mut p = pdf_opts.clone(); - p["path"] = json!(out); - let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?; - eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]); - for w in r["warnings"].as_array().into_iter().flatten() { - eprintln!("warning: {}", w.as_str().unwrap_or_default()); - } - } - Export::Document => { - s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?; - eprintln!("saved {out}"); - } - Export::Pptx => { - let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?; - eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]); - for w in r["warnings"].as_array().into_iter().flatten() { - eprintln!("warning: {}", w.as_str().unwrap_or_default()); - } - } - Export::Command(cmd) => { - let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?; - eprintln!("wrote {out} ({} bytes)", r["bytes"]); - } - } + handle_export(&mut s, &out, page, scale, &pdf_opts)?; } "--all-pages" => { let dir = val()?; @@ -387,6 +362,37 @@ fn run(args: &[String]) -> Result<(), String> { Ok(()) } +fn handle_export(s: &mut Session, out: &str, page: usize, scale: f64, pdf_opts: &Value) -> Result<(), String> { + match Export::of(out)? { + Export::Image => export(s, out, page, scale)?, + Export::Pdf => { + let mut p = pdf_opts.clone(); + p["path"] = json!(out); + let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]); + for w in r["warnings"].as_array().into_iter().flatten() { + eprintln!("warning: {}", w.as_str().unwrap_or_default()); + } + } + Export::Document => { + s.execute("file.saveAs", &json!({"path": out})).map_err(|e| e.to_string())?; + eprintln!("saved {out}"); + } + Export::Pptx => { + let r = s.execute("file.exportPptx", &json!({"path": out})).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} slides, {} bytes)", r["slides"], r["bytes"]); + for w in r["warnings"].as_array().into_iter().flatten() { + eprintln!("warning: {}", w.as_str().unwrap_or_default()); + } + } + Export::Command(cmd) => { + let r = s.execute(cmd, &json!({"path": out})).map_err(|e| e.to_string())?; + eprintln!("wrote {out} ({} bytes)", r["bytes"]); + } + } + Ok(()) +} + /// A page as PNG, or as JPEG for `.jpg` and `.jpeg`. fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), String> { let st = s.doc().map_err(|e| e.to_string())?; diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index bed57cfcc..0c665f7e7 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -335,7 +335,7 @@ fn row(rows: &mut Vec, name: impl Into, ms: f64, budget: Option Result, String> { let mut rows = vec![]; - let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get); let chars: usize = doc.stories.values().map(|s| s.text.len()).sum(); // Budget: 250 ms per 300k characters (MT), scaled to this document. let compose_budget = 250.0 * (chars as f64 / 300_000.0).max(1.0); @@ -532,7 +532,7 @@ fn print(title: &str, rows: &[Row], noisy: bool) -> Result<(), String> { } fn header() -> (usize, bool) { - let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get); let load = load_average(); let noisy = load.is_some_and(|l| l > cores as f64 * 0.75); outln!( diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs index 173a605f4..02536382a 100644 --- a/apps/designcraft-cli/tests/it/cli.rs +++ b/apps/designcraft-cli/tests/it/cli.rs @@ -401,6 +401,97 @@ fn mcp_automation_roots_confine_the_server() { let _ = std::fs::remove_dir_all(base); } +/// #1552: each root flag may be given more than once. A path inside any root of its kind passes; +/// the roots' common parent, a sibling whose name starts like a root's, `..` out of a root, the +/// other kind's roots and a link out of a root stay refused. +#[test] +fn mcp_automation_roots_take_several_folders() { + let base = std::env::temp_dir().join(format!("dc-cli-roots-several-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "shared", "out", "scratch", "outside", "in2", "scratch2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + // Resolved, as the server shows the roots (`/var` is `/private/var` on macOS). + let base = std::fs::canonicalize(base).unwrap(); + for f in ["in/a.designcraft", "shared/b.designcraft", "outside/secret.designcraft", "parent.designcraft", "in2/c.designcraft"] { + make_document(&base.join(f)); + } + // Windows' canonical `\\?\` form is not a prefix under which `..` is a path component: + // traversal requests use the ordinary spelling, while the other requests keep the resolved one. + let plain = |path: &std::path::Path| -> String { + let text = path.to_string_lossy(); + if !cfg!(windows) { + return text.into_owned(); + } + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}"); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => rest.to_owned(), + None => text.into_owned(), + } + }; + let p = |f: &str| base.join(f).to_str().unwrap().to_string(); + let sep = std::path::MAIN_SEPARATOR; + let up = |root: &str, rest: &str| format!("{}{sep}..{sep}{rest}", plain(&base.join(root))); + let calls = [ + ("open_document", serde_json::json!({"path": p("in/a.designcraft")})), + ("open_document", serde_json::json!({"path": p("shared/b.designcraft")})), + // The roots' common parent is not a root. + ("open_document", serde_json::json!({"path": p("parent.designcraft")})), + ("open_document", serde_json::json!({"path": up("shared", &format!("outside{sep}secret.designcraft"))})), + // `in2` only starts like `in`. + ("open_document", serde_json::json!({"path": p("in2/c.designcraft")})), + // `..` from one read root into another stays inside. + ("open_document", serde_json::json!({"path": up("shared", &format!("in{sep}a.designcraft"))})), + ("save_document", serde_json::json!({"path": p("out/x.designcraft")})), + ("save_document", serde_json::json!({"path": p("scratch/y.designcraft")})), + ("save_document", serde_json::json!({"path": p("z.designcraft")})), + ("save_document", serde_json::json!({"path": p("scratch2/z.designcraft")})), + // Read and write stay separate: a read root is not a write root, nor the other way round. + ("save_document", serde_json::json!({"path": p("shared/w.designcraft")})), + ("open_document", serde_json::json!({"path": p("out/x.designcraft")})), + ("export_png", serde_json::json!({"path": up("scratch", &format!("outside{sep}p.png"))})), + ]; + let read2 = format!("--automation-read-root={}", p("shared")); + let args = ["--automation-read-root", &p("in"), &read2, "--automation-write-root", &p("out"), "--automation-write-root", &p("scratch")]; + let (success, stderr, replies) = mcp_session(&args, &calls); + assert!(success, "{stderr}"); + let confined = format!( + "files confined: read {}, {}; write {}, {}", + plain(&base.join("in")), + plain(&base.join("shared")), + plain(&base.join("out")), + plain(&base.join("scratch")) + ); + assert!(stderr.contains(&confined), "expected {confined:?} in {stderr:?}"); + let errors: Vec = replies.iter().map(|r| r["result"]["isError"] == true).collect(); + assert_eq!(errors, [false, false, true, true, true, false, false, false, true, true, true, true, true], "{replies:?}"); + for (i, kind) in [(2, "read"), (3, "read"), (4, "read"), (8, "write"), (9, "write"), (10, "write"), (11, "read"), (12, "write")] { + assert!(text(&replies[i]).contains(&format!("outside the {kind} roots ")), "{i}: {}", text(&replies[i])); + } + assert!(base.join("out/x.designcraft").is_file() && base.join("scratch/y.designcraft").is_file()); + for f in ["z.designcraft", "scratch2/z.designcraft", "shared/w.designcraft", "outside/p.png"] { + assert!(!base.join(f).exists(), "{f} was written outside the write roots"); + } + + // A link in one root that leads out of all of them is outside, for reading and for writing. + #[cfg(unix)] + { + std::os::unix::fs::symlink(base.join("outside/secret.designcraft"), base.join("shared/leak.designcraft")).unwrap(); + std::os::unix::fs::symlink(base.join("outside"), base.join("scratch/away")).unwrap(); + let calls = [ + ("open_document", serde_json::json!({"path": p("shared/leak.designcraft")})), + ("save_document", serde_json::json!({"path": p("scratch/away/x.designcraft")})), + ]; + let (_, _, replies) = mcp_session(&args, &calls); + assert!(text(&replies[0]).contains("outside the read roots"), "{}", text(&replies[0])); + assert!(text(&replies[1]).contains("outside the write roots"), "{}", text(&replies[1])); + assert!(!base.join("outside/x.designcraft").exists()); + } + let _ = std::fs::remove_dir_all(base); +} + #[test] fn mcp_automation_root_flags_are_checked_up_front() { let base = std::env::temp_dir().join(format!("dc-cli-roots-args-{}", std::process::id())); @@ -419,9 +510,11 @@ fn mcp_automation_root_flags_are_checked_up_front() { // The flags confine the in-process server, so they cannot be combined with --connect. fails(&["--connect", "7979", "--automation-read-root", dir], "--connect"); fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect"); - fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice"); - fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice"); - fails(&["--automation-read-root", dir, "--automation-read-root=x"], "given twice"); + // #1552: a flag may be given again, but every folder it names must exist and be given. + fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); + fails(&["--automation-write-root", missing.to_str().unwrap(), "--automation-write-root", dir], "cannot open the automation write root"); + fails(&["--automation-write-root", dir, "--automation-write-root"], "needs a folder"); + fails(&["--automation-read-root", dir, "--automation-read-root="], "needs a non-empty folder"); fails(&["--automation-read-root"], "needs a folder"); fails(&["--automation-write-root", "--sample"], "needs a folder"); fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index 27d236a2f..a8914f09d 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -350,9 +350,14 @@ struct WebShell { impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let graphics = self.graphics.clone(); - let _ = graphics.run_if_healthy(|| { - let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - for f in dropped { + let _ = graphics.run_if_healthy(|| self.app.logic(ctx)); + } + + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + let _ = self.graphics.run_if_healthy(|| { + // Dropped files belong to this host iteration's raw input, not to stale logic input: + // read them here once, before the batch is saved as pending for a hidden pass. + for f in std::mem::take(&mut raw.dropped_files) { let request = self.app.import_request("drop"); let inbox = self.inbox.clone(); let ctx = ctx.clone(); @@ -367,14 +372,8 @@ impl eframe::App for WebShell { } }); } - self.app.logic(ctx); - }); - } - - fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - let _ = self.graphics.run_if_healthy(|| { designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused() || self.app.canvas_ime()); - self.app.raw_input_hook(raw); + self.app.raw_input_hook(ctx, raw); }); } diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 7dc83bc55..387bb5fdf 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [dependencies] designcraft-engine = { workspace = true } designcraft-ui-egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "clipboard"] } egui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index e694fe84f..da84db264 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -51,39 +51,45 @@ struct App { impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.startup.enter(); - if let Some(note) = self.startup.take_notice() { - self.app.status(note); - } - // The third call: two frames went through `ui`, so the first was presented and the driver - // survived it (a fault at the first present is what the fallback in `gpu` is for). - if self.frames == 2 { - self.startup.presented(); - } - self.frames = self.frames.saturating_add(1); - if u64::from(self.frames) >= gpu::STARTUP_FRAMES - && let Some(device) = self.log_errors_after_startup.take() - { - gpu_limits::log_uncaptured_errors(&device); - } - #[cfg(target_os = "macos")] - { - if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() { - self.menu = Some(native_menu::NativeMenu::install(&mut self.app, ctx)); - } - if let Some(m) = &mut self.menu { - m.poll(&mut self.app, ctx); - } - // Noted for a start again after a graphics failure, which reopens them (`gpu::restart`). - self.startup.opening(apple_events::poll(&self.apple_events, &mut self.app, ctx)); - } + // Queue/deadline work only. A hidden `run_logic` tick also reports pass index zero, so + // startup counters and native-menu polling must not live here. self.app.logic(ctx); self.startup.leave(); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.app.raw_input_hook(raw); + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.app.raw_input_hook(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.startup.enter(); + // `App::ui` is the only signal that a real layout pass ran. A discarded multipass first + // pass reports index 0 once, so these once-per-frame counters/menu polls still run once. + if ui.ctx().current_pass_index() == 0 { + if let Some(note) = self.startup.take_notice() { + self.app.status(note); + } + // The third UI callback: two frames went through `ui`, so the first was presented and + // the driver survived it (a fault at the first present is what `gpu` falls back for). + if self.frames == 2 { + self.startup.presented(); + } + self.frames = self.frames.saturating_add(1); + if u64::from(self.frames) >= gpu::STARTUP_FRAMES + && let Some(device) = self.log_errors_after_startup.take() + { + gpu_limits::log_uncaptured_errors(&device); + } + #[cfg(target_os = "macos")] + { + if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() { + self.menu = Some(native_menu::NativeMenu::install(&mut self.app, ui.ctx())); + } + if let Some(m) = &mut self.menu { + m.poll(&mut self.app, ui.ctx()); + } + // Noted for a start again after a graphics failure, which reopens them (`gpu::restart`). + self.startup.opening(apple_events::poll(&self.apple_events, &mut self.app, ui.ctx())); + } + } self.app.ui(ui); self.startup.leave(); #[cfg(target_os = "macos")] @@ -256,7 +262,7 @@ const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "* /// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one. /// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it. fn suggested_extension(name: &str) -> Option<&str> { - std::path::Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace)) + std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace)) } /// The file a save dialog's answer writes: `picked` with the suggested extension added when it has @@ -407,6 +413,9 @@ fn main() -> std::process::ExitCode { .with_fullsize_content_view(true) .with_titlebar_shown(false) .with_title_shown(false), + // DesignCraft's own FontDb supplies script-specific system fallbacks; egui's system-font + // provider would put its discovery ahead of that order and change line metrics. + system_font_fallback: false, ..Default::default() }; options.viewport = options.viewport.with_app_id(APP_ID); diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index 1ac9437d1..8996debe5 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -65,10 +65,11 @@ impl NativeMenu { // muda keeps the first handler for the life of the process, so this channel serves every // menu bar built later (`build_bar`). let (tx, events) = channel(); - let repaint = ctx.clone(); + let notify = app.native_action_notifier(ctx.clone()); MenuEvent::set_event_handler(Some(move |e: MenuEvent| { - let _ = tx.send(e); - repaint.request_repaint(); + if tx.send(e).is_ok() { + notify(); + } })); Self::build_bar(app, events) } @@ -170,11 +171,14 @@ impl NativeMenu { } pub fn poll(&mut self, app: &mut DesignApp, ctx: &egui::Context) { - while let Ok(ev) = self.events.try_recv() { - if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) { - menus::activate_native(app, ctx, &cmd.clone(), ¶ms.clone()); + app.poll_native_actions(ctx, || { + loop { + let ev = self.events.try_recv().ok()?; + if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) { + return Some((cmd.clone(), params.clone())); + } } - } + }); // Interface Language and Show CJK Features (set in Preferences or by `app.language`, or put // back by Cancel): the system menu bar follows at once, like the panels (the new menu // replaces the old one as the application's menu before that one is dropped). diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 3be294ae5..1a1374cd3 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -17,7 +17,7 @@ pub mod lab; use std::borrow::Cow; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock}; use serde::{Deserialize, Serialize}; @@ -201,7 +201,7 @@ static USER: RwLock>> = RwLock::new(Vec::new()); fn builtin_rgb_cache(name: &str) -> Option> { static CACHE: Mutex>> = Mutex::new(Vec::new()); - let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut c = CACHE.lock().unwrap_or_else(PoisonError::into_inner); if let Some(p) = c.iter().find(|p| p.name == name) { return Some(p.clone()); } @@ -211,7 +211,7 @@ fn builtin_rgb_cache(name: &str) -> Option> { } fn user_profile(name: &str) -> Option> { - USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned() + USER.read().unwrap_or_else(PoisonError::into_inner).iter().find(|p| p.name == name).cloned() } /// The default CMYK space: Coated FOGRA39, or Generic CMYK should that profile be unusable. @@ -255,7 +255,7 @@ pub fn profiles() -> Vec { .map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true }) .chain([COATED_FOGRA39, GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true })) .collect(); - for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() { + for p in USER.read().unwrap_or_else(PoisonError::into_inner).iter() { v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false }); } v @@ -275,7 +275,7 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result Arc { let key = format!("{:?}|{:?}|{:?}|{}", self.settings, proof.target, proof.intent, proof.simulate_paper); - let mut cache = PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut cache = PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner); if let Some((_, l)) = cache.iter().find(|(k, _)| *k == key) { return l.clone(); } @@ -538,7 +538,7 @@ impl Cms { static PROOF_CACHE: Mutex)>> = Mutex::new(Vec::new()); fn clear_proof_cache() { - PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()).clear(); + PROOF_CACHE.lock().unwrap_or_else(PoisonError::into_inner).clear(); } fn cmyk4(v: Option>) -> Option<[f32; 4]> { @@ -704,10 +704,10 @@ pub fn generation() -> u64 { /// The active colour-management context (Edit → Color Settings). pub fn active() -> Arc { - if let Some(c) = ACTIVE.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + if let Some(c) = ACTIVE.read().unwrap_or_else(PoisonError::into_inner).as_ref() { return c.clone(); } - let mut w = ACTIVE.write().unwrap_or_else(|e| e.into_inner()); + let mut w = ACTIVE.write().unwrap_or_else(PoisonError::into_inner); w.get_or_insert_with(|| Arc::new(Cms::default())).clone() } @@ -721,7 +721,7 @@ pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> { let changed = active().settings != *settings; RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed); CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed); - *ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(cms)); + *ACTIVE.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(cms)); if changed { GENERATION.fetch_add(1, Ordering::Relaxed); } diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 0dff34702..08027f379 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -48,6 +48,9 @@ pub struct Spacing { pub hyph_zone: f64, /// Optical margin alignment: punctuation hangs outside the measure (counted as extra width). pub optical: bool, + /// The type size the hang amounts are set for (Story ▸ Optical Margin Alignment's size); + /// `None`: each glyph's own size, so the hang is [`hang`]'s proportion of its advance. + pub optical_size: Option, /// Korean text breaks between syllables instead of at spaces. pub korean_char_breaks: bool, /// 0: minimum adjustment; 1: push in; 2: push out first; 3: push out only. @@ -72,6 +75,7 @@ impl Default for Spacing { ragged_stretch: 24.0, hyph_zone: 0.0, optical: false, + optical_size: None, korean_char_breaks: false, kinsoku_priority: 0, } @@ -295,7 +299,7 @@ fn cjk_break(a: &Glyph, b: &Glyph, sp: &Spacing) -> bool { } pub fn is_forced(c: char) -> bool { - use designcraft_doc::story::*; + use designcraft_doc::story::{FORCED_LINE_BREAK, is_break_char}; c == FORCED_LINE_BREAK || is_break_char(c) } @@ -318,16 +322,32 @@ pub fn hang(c: char) -> (f64, f64) { } } -fn hang_left(g: &Glyph) -> f64 { - hang(g.ch).0 * g.adv +/// How much more than [`hang`]'s proportions `g` hangs: the hang is set for +/// [`Spacing::optical_size`] and grows with it, relative to the glyph's own size. +pub(crate) fn optical_scale(g: &Glyph, sp: &Spacing) -> f64 { + match sp.optical_size { + Some(s) if s.is_finite() && g.size.is_finite() && g.size > 0.0 => s / g.size, + _ => 1.0, + } +} + +/// Optical hang of `g` at the start of a line (0 without optical margin alignment). +pub(crate) fn hang_left(g: &Glyph, sp: &Spacing) -> f64 { + if sp.optical { hang(g.ch).0 * g.adv * optical_scale(g, sp) } else { 0.0 } +} + +/// Optical hang of `g` at the end of a line (0 without optical margin alignment). +pub(crate) fn hang_right(g: &Glyph, sp: &Spacing) -> f64 { + if sp.optical { hang(g.ch).1 * g.adv * optical_scale(g, sp) } else { 0.0 } } fn right_hang(g: &Glyph, sp: &Spacing) -> f64 { - g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 }) - g.moji.end + g.cjk_hang.max(hang_right(g, sp)) - g.moji.end } -fn hang_right(g: &Glyph) -> f64 { - hang(g.ch).1 * g.adv +/// The part of a hyphen after `g` (of width `hw`) that hangs past the line's end. +fn hyphen_hang(g: &Glyph, hw: f64, sp: &Spacing) -> f64 { + if sp.optical { hw * hang('-').1 * optical_scale(g, sp) } else { 0.0 } } /// Advance of the hyphen glyph that would follow `g` (cached per face). @@ -428,15 +448,13 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S let mut p = Item::penalty(hw, sp.hyphen_penalty, true); p.auto = true; p.zone_from = last_space; - if sp.optical { - p.hang = hw * hang('-').1; - } + p.hang = hyphen_hang(g, hw, sp); push_inword(&mut it, &mut ig, p, i + 1, sp); continue; } let (st, sh) = sp.box_elastic(g); let mut bx = Item::boxed(g.adv, st, sh); - bx.hang = (if sp.optical { hang_left(g) } else { 0.0 }) - g.moji.start; + bx.hang = hang_left(g, sp) - g.moji.start; bx.start_elastic = crate::mojikumi::start_elastic(g, sp.justify, sp.kinsoku_priority != 3); it.push(bx); ig.push(i); @@ -457,9 +475,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S let mut p = Item::penalty(hw, sp.hyphen_penalty, true); p.auto = true; p.zone_from = last_space; - if sp.optical { - p.hang = hw * hang('-').1; - } + p.hang = hyphen_hang(g, hw, sp); push_inword(&mut it, &mut ig, p, i + 1, sp); } else if emergency_after.get(i) == Some(&true) { let mut p = Item::penalty(0.0, EMERGENCY_PENALTY, false); @@ -876,7 +892,7 @@ pub fn greedy( let may_hyphenate = |hyphens: u32| sp.hyphen_limit == 0 || hyphens < sp.hyphen_limit; while start < n { let w = width(line).max(1.0); - let mut x = (if sp.optical { -hang_left(&glyphs[start]) } else { 0.0 }) + glyphs[start].moji.start; + let mut x = -hang_left(&glyphs[start], sp) + glyphs[start].moji.start; let mut shrink = crate::mojikumi::start_elastic(&glyphs[start], sp.justify, sp.kinsoku_priority != 3)[1]; let mut last_natural: Option<(usize, bool)> = None; let mut last_ok: Option<(usize, bool)> = None; // (break position = end glyph, hyphen) @@ -939,7 +955,8 @@ pub fn greedy( last_natural = last_ok; } } else if hyph_after[i] && may_hyphenate(hyphens) { - let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 }; + let hw = hyphen_width(g, &mut hy_cache); + let hy = (hw - hyphen_hang(g, hw, sp)).max(0.0); if x + hy <= w + shrink + FIT_EPS { last_ok = Some((i + 1, true)); } diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 77bb30f95..66122ccf4 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -3,11 +3,12 @@ //! An entry is valid while the inputs it was composed from are the *same allocations*: the story //! `Arc` (and its revision), the styles `Arc`, the frame items' `Arc`s and the `Arc`s of every //! wrapping item on the frames' spreads, and the same for every story whose anchored objects wrap -//! its text ([`crate::wrap::neighbours`]). Entries hold clones of those `Arc`s, so the pointers +//! its text ([`crate::wrap::neighbours`]). An anchored frame's entry also records where its host +//! story's composition places it. Entries hold clones of those `Arc`s, so the pointers //! can't be reused by other allocations while the entry lives. use std::collections::HashMap; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, PoisonError}; use designcraft_doc::{Document, Item, Story, StoryId, Styles}; @@ -99,11 +100,12 @@ impl Cache { /// [`Cache::get`] for any [`ShownOn`]. pub fn get_on(&self, doc: &Document, sid: StoryId, on: ShownOn) -> Arc { - self.get_with(doc, sid, on, true) + self.get_with(doc, sid, on, true, 0) } - /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set. - fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool) -> Arc { + /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for + /// a story `depth` hosts down from the story asked for. + fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize) -> Arc { let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) }; let (mut sig, mut keep_items) = signature(doc, story); let neighbours = if cross { crate::wrap::neighbours(doc, sid) } else { Vec::new() }; @@ -117,6 +119,24 @@ impl Cache { keep_stories.push(ns.clone()); } } + // Anchored frames compose where their host story places them, so the host is composed + // first and the placements are part of the signature. + let placed = if depth < crate::placed::MAX_DEPTH { + crate::placed::placements(doc, sid, &mut |host| self.get_with(doc, host, ShownOn::Own, true, depth + 1)) + } else { + Vec::new() + }; + for (id, p) in &placed { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (id, p.spread, p.xf.as_coeffs().map(f64::to_bits)).hash(&mut h); + // The placed spread's pages give the frame its page, side and page rect. + for pg in doc.spread(p.spread).map_or(&[][..], |sp| &sp.pages[..]) { + let m = &pg.margins; + (pg.side as u8, [pg.x, pg.width, pg.height, m.top, m.bottom, m.inside, m.outside].map(f64::to_bits)).hash(&mut h); + } + sig.push(h.finish() as usize); + } let key = (sid, on, !neighbours.is_empty()); // The key holds the page's index, which a renumbering doesn't change: its name (section // start number, style, prefix) is part of the signature. @@ -186,7 +206,7 @@ impl Cache { } // Advanced Type sizes super/subscripts. let a = doc.settings.advanced_type; - sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].map(|v| v.to_bits() as usize)); + sig.extend([a.superscript_size, a.superscript_position, a.subscript_size, a.subscript_position].into_iter().flat_map(bits)); // Missing glyphs: fallback fonts or the font's box. sig.push(usize::from(doc.settings.glyph_fallback)); // Named lists continue from earlier stories. @@ -196,7 +216,7 @@ impl Cache { sig.push(std::hash::Hasher::finish(&h) as usize); } { - let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner); g.1 += 1; let stamp = g.1; if let Some(e) = g.0.get_mut(&key) @@ -209,14 +229,14 @@ impl Cache { // Parent-page items are composed per page: markers show its name, variables need its index. let page = page.filter(|_| with_vars); let running = running.then(|| self.running_index(doc)); - let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n)).collect(); + let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n, depth)).collect(); let out = Arc::new(crate::wrap::compose_among( doc, sid, - &ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, + &ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)), placed }, &others.iter().map(|o| &**o).collect::>(), )); - let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner); let stamp = g.1; if g.0.len() > 4096 { g.0.retain(|_, e| stamp - e.stamp < 64); @@ -228,7 +248,7 @@ impl Cache { && cross && let Some(old) = replaced { - self.previous.lock().unwrap_or_else(|e| e.into_inner()).insert(sid, (old.sig, old.out)); + self.previous.lock().unwrap_or_else(PoisonError::into_inner).insert(sid, (old.sig, old.out)); } out } @@ -241,7 +261,7 @@ impl Cache { // Only the geometry/story part of the signature (variables etc. add to it). let matches = |s: &[usize]| s.len() >= sig.len() && s[..sig.len()] == sig[..]; { - let g = self.map.lock().unwrap_or_else(|e| e.into_inner()); + let g = self.map.lock().unwrap_or_else(PoisonError::into_inner); for cross in [true, false] { if let Some(e) = g.0.get(&(sid, ShownOn::Own, cross)) && matches(&e.sig) @@ -250,7 +270,7 @@ impl Cache { } } } - let g = self.previous.lock().unwrap_or_else(|e| e.into_inner()); + let g = self.previous.lock().unwrap_or_else(PoisonError::into_inner); g.get(&sid).filter(|(s, _)| matches(s)).map(|(_, c)| c.clone()) } @@ -295,49 +315,56 @@ impl Cache { /// Story `sid` composed for the wrap of its anchored objects in other stories: without other /// stories' anchored objects (no cycles), and outside the cache when it needs the document-wide /// indexes (they compose every story). - fn neighbour(&self, doc: &Document, sid: StoryId) -> Arc { + fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize) -> Arc { let indexed = doc.stories.get(&sid).is_some_and(|st| crate::xref::has_xrefs(st) || (crate::vars::has_vars(st) && crate::vars::has_running(doc, st))); if indexed { Arc::new(crate::wrap::compose_among(doc, sid, &ComposeOptions::default(), &[])) } else { - self.get_with(doc, sid, ShownOn::Own, false) + self.get_with(doc, sid, ShownOn::Own, false, depth) } } pub fn clear(&self) { - self.map.lock().unwrap_or_else(|e| e.into_inner()).0.clear(); - *self.running.lock().unwrap_or_else(|e| e.into_inner()) = None; - *self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = None; + self.map.lock().unwrap_or_else(PoisonError::into_inner).0.clear(); + *self.running.lock().unwrap_or_else(PoisonError::into_inner) = None; + *self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = None; } /// Anchor pages for cross-references (rebuilt when the document changes). pub fn xref_index(&self, doc: &Document) -> Arc { let sig = doc_signature(doc); - if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) + if let Some((s, r)) = &*self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) && *s == sig { return r.clone(); } let r = Arc::new(crate::xref::XrefIndex::build(doc, &|sid| self.get(doc, sid, None))); - *self.xrefs.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone())); + *self.xrefs.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone())); r } /// The running-header index for `doc` (rebuilt when the document changes). pub fn running_index(&self, doc: &Document) -> Arc { let sig = doc_signature(doc); - if let Some((s, r)) = &*self.running.lock().unwrap_or_else(|e| e.into_inner()) + if let Some((s, r)) = &*self.running.lock().unwrap_or_else(PoisonError::into_inner) && *s == sig { return r.clone(); } let r = Arc::new(RunningIndex::build(doc, &|sid| self.get(doc, sid, None))); - *self.running.lock().unwrap_or_else(|e| e.into_inner()) = Some((sig, r.clone())); + *self.running.lock().unwrap_or_else(PoisonError::into_inner) = Some((sig, r.clone())); r } } +/// Both halves of `v`'s bits: a 32-bit `usize` (wasm) would drop the high half, where whole +/// numbers keep their differences. +fn bits(v: f64) -> [usize; 2] { + let b = v.to_bits(); + [(b & 0xffff_ffff) as usize, (b >> 32) as usize] +} + fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) { // The document's font scope: another document's fonts of the same name set it differently. // Sections name the pages under the frames (page-number and section markers). @@ -348,9 +375,9 @@ fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) Arc::as_ptr(&doc.styles) as usize, sections_hash(doc) as usize, doc.font_scope as usize, - g.start.to_bits() as usize, - g.increment.to_bits() as usize, + usize::from(g.relative_to == designcraft_doc::GridRelative::TopMargin), ]; + sig.extend([g.start, g.increment].into_iter().flat_map(bits)); let mut keep = Vec::new(); let mut spreads = Vec::new(); for f in &story.frames { @@ -373,6 +400,12 @@ fn signature(doc: &Document, story: &Arc) -> (Vec, Vec>) sig.push(loc.top()); if !spreads.contains(&loc.spread) { spreads.push(loc.spread); + // The page under a frame (found by x) gives its grid origin, page rect and side. + for p in &sp.pages { + let m = &p.margins; + sig.extend([p.x, p.width, p.height, m.top, m.bottom, m.inside, m.outside].into_iter().flat_map(bits)); + sig.push(p.side as usize); + } for it in &sp.items { let mut wraps = false; it.walk(&mut |i| wraps |= i.has_wrap()); diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 1d7e0e6a9..6f9e8f9e3 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -23,6 +23,13 @@ use designcraft_doc::story; pub const MAX_PATTERN: usize = 4096; /// Backtracking steps one search may take before it gives up. pub const BACKTRACK_LIMIT: usize = 200_000; +/// Matches one GREP style may contribute to one paragraph. +/// +/// [`BACKTRACK_LIMIT`] bounds a single search, not a scan: `find_all` pays it again for every +/// match, so a pattern tuned just under the limit cost `BACKTRACK_LIMIT × matches` per paragraph +/// on every relayout (#1598). A paragraph cannot usefully carry more styled runs than this, and +/// a scan that reaches it stops with what it has. +pub const MAX_MATCHES_PER_PARAGRAPH: usize = 4096; /// Compiled program size cap (bytes) for the underlying regex engine. const SIZE_LIMIT: usize = 4 << 20; @@ -378,6 +385,17 @@ impl Grep { self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect() } + /// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it + /// doesn't count toward a GREP style's cap. + pub fn find_nonempty(&self, text: &str, limit: usize) -> Result>, GrepError> { + self.re + .find_iter(text) + .filter(|m| !m.as_ref().is_ok_and(|m| m.range().is_empty())) + .take(limit) + .map(|m| m.map(|m| m.range()).map_err(run_err)) + .collect() + } + /// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see /// [`translate_replacement`]). Stops after the first match when `first_only`. pub fn replacements(&self, text: &str, template: &str, first_only: bool) -> Result, String)>, GrepError> { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ebf1e377b..6b6a61618 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -23,6 +23,7 @@ pub mod optical; mod outline; mod overlay; mod parabox; +mod placed; mod ruby; pub mod shape; pub mod shatai; @@ -50,6 +51,7 @@ use crate::notes::Notes; pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret}; pub use crate::outline::FrameOutline; use crate::parabox::ParaBox; +pub use crate::placed::Placement; use crate::shape::{Glyph, StyleTable, SubstCtx}; pub use crate::table::{BorderHit, PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_border, hit_cell, hit_cell_with}; @@ -583,6 +585,9 @@ pub struct FrameSpec { pub grid: Option<(f64, f64)>, /// The frame is on a left page (for towards/away-from-spine alignment). pub left_page: bool, + /// The frame is on a right page of a facing spread (spine-relative anchored objects mirror + /// there; a single page has no spine). + pub right_page: bool, /// The page and its margins in the frame's inner space (custom anchored objects). pub page_rect: Option<(Rect, Rect)>, } @@ -624,6 +629,9 @@ pub struct ComposeOptions { pub label: Option, /// Anchor pages for cross-references (needed when the story contains cross-references). pub xrefs: Option>, + /// Where the story's anchored frames sit on their spreads; an anchored frame not listed is + /// composed where it is stored, on no page. + pub placed: Vec<(ItemId, Placement)>, } /// Nesting depth past which wrap obstacles inside groups are not collected. @@ -656,18 +664,34 @@ fn wrap_obstacles(it: &Item, parent: Affine, inv: Affine, skip: ItemId, left_pag /// Build the frame specs of a story from the document (geometry, wrap, page names, grid). pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { + frame_specs_placed(doc, sid, &[]) +} + +/// [`frame_specs`] with anchored frames where their host story placed them (`placed`). +pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId, Placement)]) -> Vec { let Some(st) = doc.story(sid) else { return vec![] }; let mut out = Vec::with_capacity(st.frames.len()); for &fid in &st.frames { let loc = doc.find(fid); let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue }; let Some(tf) = item.text_frame() else { continue }; - let xf = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY) * item.xf; + // An anchored frame takes its page, page side, page rect and grid from where it is placed. + let placement = if loc.is_none() { placed.iter().find(|(id, _)| *id == fid).map(|(_, p)| *p) } else { None }; + let parent = match (&loc, placement) { + (Some(l), _) => doc.parent_xf(l), + (None, Some(p)) => p.xf, + (None, None) => Affine::IDENTITY, + }; + let xf = parent * item.xf; + // Where the frame is on the spread (a group's transform included), to find its page. + let center_x = parent.transform_rect_bbox(item.bounds()).center().x; let inv = xf.inverse(); let mut exclusions = Vec::new(); - let spread_ref = loc.as_ref().map(|l| l.spread); + let spread_ref = loc.as_ref().map(|l| l.spread).or(placement.map(|p| p.spread)); let spread = spread_ref.and_then(|r| doc.spread(r)); + // Page items wrap the text of frames on the spread, not of anchored frames. if !tf.options.ignore_wrap + && loc.is_some() && let Some(sp) = spread { let left = |x: f64| sp.page_at_x(x).and_then(|pi| sp.pages.get(pi)).is_some_and(|p| p.side == designcraft_doc::PageSide::Left); @@ -686,36 +710,45 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 }); } - let (page_name, page, left_page) = match (spread_ref, spread) { + let (page_name, page, side) = match (spread_ref, spread) { (Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => { - let c = item.bounds().center(); - let pi = sp.page_at_x(c.x).unwrap_or(0); + let pi = sp.page_at_x(center_x).unwrap_or(0); let abs = doc.first_page_of_spread(si) + pi; - (Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).is_some_and(|p| p.side == designcraft_doc::PageSide::Left)) + (Some(doc.page_name(abs)), Some(abs), sp.pages.get(pi).map(|p| p.side)) } (Some(designcraft_doc::SpreadRef::Parent(_)), Some(sp)) => { let prefix = sp.parent.as_ref().map(|p| p.prefix.clone()).unwrap_or_else(|| "A".into()); // The side of the parent page under the frame, so a folio there aligns to the spine of its own page. - let under = sp.page_at_x(item.bounds().center().x).and_then(|pi| sp.pages.get(pi)); - let left = under.is_some_and(|p| p.side == designcraft_doc::PageSide::Left); - (Some(prefix), None, left) + let under = sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi)); + (Some(prefix), None, under.map(|p| p.side)) } - _ => (None, None, false), + _ => (None, None, None), }; + let left_page = side == Some(designcraft_doc::PageSide::Left); + let right_page = side == Some(designcraft_doc::PageSide::Right); let section_marker = page.map(|abs| section_marker(doc, abs)); - // The page under the frame and its margins, in inner space (bounding boxes). - let page_rect = match (spread_ref, spread) { - (_, Some(sp)) => sp - .page_at_x(item.bounds().center().x) - .and_then(|pi| sp.pages.get(pi)) - .map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))), - _ => None, - }; + // The page under the frame, and its bounds and margins in inner space (bounding boxes). + let under = spread.and_then(|sp| sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi))); + let page_rect = under.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))); let g = &doc.settings.baseline_grid; - let (inc, start) = tf.options.baseline_grid.unwrap_or((g.increment, g.start)); // Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only). let ty = xf.translation().y; - let grid = (inc > 0.0).then_some((start - ty, inc)); + let (start, inc) = match tf.options.baseline_grid { + None => (under.map_or(g.start, |pg| g.origin(pg)), g.increment), + Some(f) => { + use designcraft_doc::FrameGridRelative as R; + // The frame's own edges are in inner space; `ty` takes them to spread y as it takes + // the grid back, so a frame-relative grid keeps its place in a turned frame. + let from = match f.relative_to { + R::TopOfPage => 0.0, + R::TopMargin => under.map_or(0.0, |pg| pg.margins.top), + R::TopOfFrame => ty + item.inner_bounds().y0, + R::TopInset => ty + item.text_area().y0, + }; + (from + f.start, f.increment) + } + }; + let grid = (inc > 0.0 && inc.is_finite() && start.is_finite()).then_some((start - ty, inc)); // Type on a path: one line as long as the path (from the start offset). let (area, opts) = match &tf.options.path { Some(pt) => { @@ -773,6 +806,7 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { page, grid, left_page, + right_page, page_rect, }); } @@ -781,10 +815,23 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { /// Compose a story of `doc` through its frames, with the text wrap of page items and of anchored /// objects ([`wrap`]). +/// Anchored frames are composed where their host story places them (unless `opts` places them). pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory { + compose_story_at(doc, sid, opts, 0) +} + +/// [`compose_story`] for a story `depth` hosts down from the story asked for. +fn compose_story_at(doc: &Document, sid: StoryId, opts: &ComposeOptions, depth: usize) -> ComposedStory { let others: Vec = wrap::neighbours(doc, sid).into_iter().map(|o| wrap::compose_among(doc, o, &ComposeOptions::default(), &[])).collect(); - wrap::compose_among(doc, sid, opts, &others.iter().collect::>()) + let others: Vec<&ComposedStory> = others.iter().collect(); + if opts.placed.is_empty() && depth < placed::MAX_DEPTH { + let placed = placed::placements(doc, sid, &mut |host| Arc::new(compose_story_at(doc, host, &ComposeOptions::default(), depth + 1))); + if !placed.is_empty() { + return wrap::compose_among(doc, sid, &ComposeOptions { placed, ..opts.clone() }, &others); + } + } + wrap::compose_among(doc, sid, opts, &others) } const DEFAULT_TAB: f64 = 36.0; @@ -861,6 +908,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut info: Vec = Vec::with_capacity(np); let mut force_col = vec![false; np]; let mut line_cap: Vec> = vec![None; np]; + // Hyphenate Across Column off: the hyphenation points (story bytes) a paragraph is re-laid + // without, because a line ending at one of them ended a column or frame. Each re-lay leaves + // out one more point of the paragraph, so they are bounded by its points; the cap only guards + // against keep options clearing them over and over. + let mut no_hyph: Vec> = vec![Vec::new(); np]; + let mut hyph_relays = 0usize; let mut restores = 0usize; let mut pi = 0; let var_story = vars::has_vars(story); @@ -900,7 +953,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C && let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -910,7 +963,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C && let Some(j) = balance_end(&mut trial, &mut limits, &cur, &cols, frames, &out, np, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -964,7 +1017,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) { if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -1010,9 +1063,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; if !story.endnotes.is_empty() { // Endnote reference numbers continue through the document. - let first = doc.endnote_start(story.id) + story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32; + let first = doc.endnote_start(story.id).saturating_add(story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32); for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::ENDNOTE_REF).enumerate() { - sub.notes.insert(prange.start + i, doc.endnote_options.label(first + k as u32)); + sub.notes.insert(prange.start + i, doc.endnote_options.label(first.saturating_add(k as u32))); } } if !story.objects.is_empty() { @@ -1083,7 +1136,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let cols_here = &cols[cur.fi]; let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns); let col = SplitCfg::of(&pp).map_or(col, |c| c.sub_rect(col, 0)); - let spacing = spacing_for(&pp, base_chars.size); + let spacing = spacing_for(&pp, base_chars.size, story); let mut lines: Vec<(std::ops::Range, String)> = Vec::new(); for _ in 0..3 { // The same drop cap, spacing, hyphenation and breaker as the layout below. @@ -1099,7 +1152,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; apply_desired_spacing(&mut gl, &pp); mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi); - let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + let mut hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + suppress_hyphens(&mut hy, &gl, no_hyph.get(pi).map_or(&[], Vec::as_slice)); let breaks = if pp.composer.single_line() || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { let tab = |j: usize, x: f64, i: usize| { let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 }; @@ -1234,9 +1288,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C bidi::resolve_mirroring(&mut glyphs, &bidi_info); apply_desired_spacing(&mut glyphs, &pp); mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi); - let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + let mut hyph_after = + hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + suppress_hyphens(&mut hyph_after, &glyphs, no_hyph.get(pi).map_or(&[], Vec::as_slice)); let base_size = base_chars.size; - let spacing = spacing_for(&pp, base_size); + let spacing = spacing_for(&pp, base_size, story); // An empty paragraph takes its height from its paragraph mark's character format. let mark_chars = if glyphs.is_empty() { std::borrow::Cow::Owned(doc.styles.resolve_char(&base_chars, story.char_format_of(prange.start))) @@ -1341,7 +1397,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if !t.done { trial = Some(t); balance_runs += 1; - rewind(key.1, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + key.1, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = key.1; continue 'paras; } @@ -1377,6 +1444,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut line_no = 0usize; // Paragraph line number of the first line in the current column. let mut col_first_line = 0usize; + // The story byte at which the last line set ended at an automatic hyphenation point. + let mut hyphen_at: Option = None; let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft; @@ -1394,7 +1463,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if cur.fi >= frames.len() { if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -1621,6 +1690,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C 0.0 }; let fits = baseline + fit_descent <= col.y1 - reserve + 0.01 && !capped; + if !fits + && !pp.hyph_across_column + && line_no > col_first_line + && let Some(at) = hyphen_at + && hyph_relays < story.text.len().saturating_add(64).saturating_mul(restores.saturating_add(1)) + { + hyph_relays += 1; + no_hyph[pi].push(at); + rewind(pi, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); + continue 'paras; + } if !fits { if cur.can_next_sub() { g0 = s; @@ -1642,7 +1722,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; if let Some(j) = j { split_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + j, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = j; continue 'paras; } @@ -1675,7 +1766,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C j } }; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + j, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = j; continue 'paras; } @@ -1787,7 +1889,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r) && let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para) { - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + j, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = j; continue 'paras; } @@ -1819,6 +1932,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C cur.last_descent = desc; cur.pending = 0.0; cur.grid_next = grid_end.map(|end| (baseline, end)); + hyphen_at = + e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len); line_no += 1; // A column / frame / page break inside the paragraph: the rest of it continues in the // new column/frame/page, re-broken there. (A break that ends the paragraph moves the @@ -1946,7 +2061,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if !t.done { split_trial = Some(t); split_runs += 1; - rewind(key.2, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + key.2, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = key.2; continue 'paras; } @@ -2004,6 +2130,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C object_point, ref_point, keep_within_column, + spine_relative, } => { use designcraft_doc::anchored::AnchorRelative as R; let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area); @@ -2015,15 +2142,21 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)), }; let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]); - let (rfx, rfy) = frac(*ref_point); - let (ofx, ofy) = frac(*object_point); + let (mut rfx, rfy) = frac(*ref_point); + let (mut ofx, ofy) = frac(*object_point); + let mut dx = *x_offset; + // The settings describe a left-hand page; a right-hand page mirrors them + // across the spine. + if *spine_relative && f.right_page { + (rfx, ofx, dx) = (1.0 - rfx, 1.0 - ofx, -dx); + } let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx); let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy); let mut y = ry + y_offset - h * ofy; if *keep_within_column { y = y.clamp(col.y0, (col.y1 - h).max(col.y0)); } - Point::new(rx + x_offset - w * ofx, y) + Point::new(rx + dx - w * ofx, y) } }; } @@ -2170,8 +2303,22 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio out } +/// Optical margin alignment for a paragraph of `story`: (on, the size the hang is set for). +/// Ignore Optical Margin turns it off; the story's setting, when on, applies to every other +/// paragraph; otherwise the paragraph's own switch hangs at each glyph's own size. +fn optical_for(pp: &ParaProps, story: &Story) -> (bool, Option) { + if pp.ignore_optical_margin { + return (false, None); + } + match story.optical_margin.hang_size() { + Some(size) => (true, Some(size)), + None => (pp.optical_margin, None), + } +} + /// Breaker parameters from the paragraph's settings. -fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { +fn spacing_for(pp: &ParaProps, base_size: f64, story: &Story) -> Spacing { + let (optical, optical_size) = optical_for(pp, story); Spacing { justify: pp.align.is_justified(), word_min: pp.word_space_min, @@ -2189,7 +2336,8 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { // The zone is the Single-line Composer's (unjustified text only), as in InDesign: the // Paragraph Composer weighs every break of the paragraph instead. hyph_zone: if pp.align.is_justified() || !pp.composer.single_line() { 0.0 } else { pp.hyph_zone }, - optical: pp.optical_margin, + optical, + optical_size, korean_char_breaks: pp.korean_char_breaks, kinsoku_priority: match pp.kinsoku_type.as_str() { "KinsokuPushInFirst" => 1, @@ -2304,9 +2452,11 @@ fn rewind( list_counter: &mut ListCounter, force_col: &mut [bool], line_cap: &mut [Option], + no_hyph: &mut [Vec], ) { force_col.iter_mut().skip(j + 1).for_each(|f| *f = false); line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None); + no_hyph.iter_mut().skip(j + 1).for_each(Vec::clear); if let Some(s) = snaps.get(j) { s.restore(out, cur, list_counter); } @@ -3313,8 +3463,8 @@ fn layout_line( if (sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None) && !has_tab { let first = line.iter().find(|g| g.adv > 0.0 && !g.is_space()); let lastg = line.iter().rev().find(|g| g.adv > 0.0 && !g.is_space()); - let hl = if sp.optical { first.map_or(0.0, |g| breaker::hang(g.ch).0 * g.adv) } else { 0.0 }; - let optical_right = if sp.optical { lastg.map_or(0.0, |g| breaker::hang(g.ch).1 * g.adv) } else { 0.0 }; + let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp)); + let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp)); let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang); let cjk_right = if pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular { cjk_right.min((line.iter().map(|g| g.adv).sum::() - measure).max(0.0)) @@ -4006,6 +4156,18 @@ fn foreign_ranges( .collect() } +/// Leave out the hyphenation points after the glyphs that end at story bytes `at`. +fn suppress_hyphens(hy: &mut [bool], glyphs: &[Glyph], at: &[usize]) { + if at.is_empty() { + return; + } + for (h, g) in hy.iter_mut().zip(glyphs) { + if *h && at.contains(&(g.byte + g.len)) { + *h = false; + } + } +} + fn hyphenation_points( text: &str, glyphs: &[Glyph], @@ -4026,7 +4188,7 @@ fn hyphenation_points( // Words repeat a lot: remember their points per set of limits. static CACHE: Mutex), Box<[usize]>>)>> = Mutex::new(Vec::new()); let key = (lim.min_word, lim.after_first, lim.before_last, lim.capitalized); - let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let idx = match guard.iter().position(|e| e.0 == key) { Some(i) => i, None => { @@ -4527,7 +4689,7 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option { // count as still inside the note. let over = |r: &(f64, f64, f64)| x >= r.1 - 0.01 && x < r.2 - 0.01; let candidates = rows.iter().filter(|r| over(r)).filter(|r| if up { r.0 < caret_y - ROW_Y } else { r.0 > caret_y + ROW_Y }); - if up { candidates.map(|r| r.0).max_by(|a, b| a.total_cmp(b)) } else { candidates.map(|r| r.0).min_by(|a, b| a.total_cmp(b)) } + if up { candidates.map(|r| r.0).max_by(f64::total_cmp) } else { candidates.map(|r| r.0).min_by(f64::total_cmp) } } #[cfg(test)] @@ -4649,6 +4811,7 @@ mod column_allocation_safety_tests { page: None, grid: None, left_page: false, + right_page: false, page_rect: None, outline: None, } diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index e72ecde02..75d143928 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -72,7 +72,7 @@ pub(crate) struct Notes { pub key: Option, pub placed: Vec, /// Composed footnote text by (index, label, width). - cache: HashMap<(usize, String, u64), (Arc, NoteMetrics)>, + cache: HashMap<(usize, String, u64, bool, bool), (Arc, NoteMetrics)>, notes: Vec>, /// Footnotes span the columns of their frame. span: bool, @@ -130,7 +130,7 @@ impl Notes { self.num = o.start_at; } self.numbers[k] = self.num; - self.num += 1; + self.num = self.num.saturating_add(1); sub.notes.insert(self.refs[k], o.label(self.numbers[k], false)); } sub.note_position = o.ref_position; @@ -153,7 +153,8 @@ impl Notes { fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc, NoteMetrics) { let o = &doc.footnote_options; let label = format!("{}{}", o.label(self.numbers[k], true), o.separator); - let key = (k, label.clone(), w.to_bits()); + // The page side sets towards/away-from-spine alignment and spine-relative anchored objects. + let key = (k, label.clone(), w.to_bits(), f.left_page, f.right_page); if let Some(c) = self.cache.get(&key) { return c.clone(); } @@ -169,6 +170,7 @@ impl Notes { page: f.page, grid: None, left_page: f.left_page, + right_page: f.right_page, page_rect: None, }; let nopts = ComposeOptions { @@ -178,6 +180,7 @@ impl Notes { running: opts.running.clone(), label: Some(label), xrefs: opts.xrefs.clone(), + placed: Vec::new(), }; let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts); let lines = cs.frames.first().map(|ft| ft.lines.as_slice()).unwrap_or_default(); diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index d65460949..b09ef908e 100644 --- a/crates/compose/src/overlay.rs +++ b/crates/compose/src/overlay.rs @@ -9,9 +9,42 @@ use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil}; use crate::grep::{Grep, GrepOptions}; +/// Patterns kept compiled at once. A document's GREP styles are few; the cache grew without +/// bound across every pattern a session ever composed (#1598). +const MAX_CACHED_PATTERNS: usize = 64; + thread_local! { - /// Compiled GREP styles by pattern; None for one that doesn't compile or gave up on a search. - static PATTERNS: RefCell>> = RefCell::new(HashMap::new()); + /// Compiled GREP styles by pattern, with the tick they were last used at; `None` for one that + /// doesn't compile or gave up on a search. Bounded to [`MAX_CACHED_PATTERNS`], least recently + /// used evicted first. + static PATTERNS: RefCell, u64)>> = RefCell::new(HashMap::new()); + /// Counts cache uses, so the least recently used entry can be found. + static TICK: RefCell = const { RefCell::new(0) }; +} + +/// The compiled pattern for `pattern`, compiling it on first use and evicting the least recently +/// used entry once the cache is full. `f` is given `None` when the pattern does not compile. +fn with_pattern(pattern: &str, f: impl FnOnce(&mut Option) -> R) -> R { + let now = TICK.with(|t| { + let mut t = t.borrow_mut(); + *t += 1; + *t + }); + PATTERNS.with(|p| { + let mut p = p.borrow_mut(); + if !p.contains_key(pattern) + && p.len() >= MAX_CACHED_PATTERNS + && let Some(oldest) = p.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()) + { + p.remove(&oldest); + } + let slot = p.entry(pattern.to_string()).or_insert_with(|| { + let re = Grep::new(pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {pattern:?} skipped: {e}")).ok(); + (re, now) + }); + slot.1 = now; + f(&mut slot.0) + }) } fn is_event(u: &NestedUntil, c: char, prev: Option, next: Option) -> bool { @@ -71,14 +104,15 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], if g.style.is_empty() || g.pattern.is_empty() { continue; } - PATTERNS.with(|p| { - let mut p = p.borrow_mut(); - let slot = p.entry(g.pattern.clone()).or_insert_with(|| { - Grep::new(&g.pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {:?} skipped: {e}", g.pattern)).ok() - }); + with_pattern(&g.pattern, |slot| { let Some(re) = slot else { return }; - match re.find_all(text.get(range.start..end).unwrap_or(""), usize::MAX) { - Ok(ms) => out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))), + match re.find_nonempty(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) { + Ok(ms) => { + if ms.len() == crate::grep::MAX_MATCHES_PER_PARAGRAPH { + log::warn!("GREP style {:?} matched more than {} times in one paragraph; the rest are not styled", g.pattern, ms.len()); + } + out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))); + } Err(e) => { log::warn!("GREP style {:?} disabled: {e}", g.pattern); *slot = None; @@ -93,6 +127,40 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], mod tests { use super::*; + /// A pattern that can match nothing still styles a paragraph longer than the cap: empty + /// matches don't count toward it. + #[test] + fn empty_matches_do_not_use_up_the_grep_cap() { + let text = format!("{}a", "b".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 100)); + let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a*".into() }], 0); + assert_eq!(o.len(), 1, "the one non-empty match at the end is styled"); + } + + /// A GREP style's scan is bounded two ways (#1598): the matches one paragraph can take, and + /// the patterns kept compiled at once. + #[test] + fn a_grep_scan_and_the_pattern_cache_are_bounded() { + // More matches in one paragraph than the cap: the scan stops with what it has instead of + // paying the backtrack limit again for each one. + let text = "a ".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 500); + let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a".into() }], 0); + assert_eq!(o.len(), crate::grep::MAX_MATCHES_PER_PARAGRAPH, "the scan stops at the cap"); + + // The cache holds at most MAX_CACHED_PATTERNS, and the one just used is not the one + // dropped: a document's own styles stay compiled however many patterns a session saw. + PATTERNS.with(|p| p.borrow_mut().clear()); + for i in 0..MAX_CACHED_PATTERNS + 20 { + let pattern = format!("x{i}y"); + overlays("x1y", 0..3, &[], &[GrepStyle { style: "S".into(), pattern }], 0); + } + let (len, has_last) = PATTERNS.with(|p| { + let p = p.borrow(); + (p.len(), p.contains_key(&format!("x{}y", MAX_CACHED_PATTERNS + 19))) + }); + assert_eq!(len, MAX_CACHED_PATTERNS, "the cache is bounded"); + assert!(has_last, "the most recently used pattern is still cached"); + } + #[test] fn nested_and_grep_ranges() { let text = "Q: what is 42? A drop of 7 words.\n".to_string(); diff --git a/crates/compose/src/parabox.rs b/crates/compose/src/parabox.rs index b8b58cb44..f6b90e127 100644 --- a/crates/compose/src/parabox.rs +++ b/crates/compose/src/parabox.rs @@ -122,6 +122,45 @@ pub(crate) fn draw(out: &mut ComposedStory, boxes: &[Option], frames: & } } +/// The shading box for one part of a paragraph run, if it has any area. +fn shading_deco(s: &Shading, part: &Part<'_>, frames: &[FrameSpec], first_part: bool, last_part: bool) -> Option { + let r = inner_rect(&part.lines, s.width, s.top, s.bottom)?; + let [t, l, bo, rr] = s.offsets; + let mut rect = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo); + if s.clip + && let Some(f) = frames.get(part.fi) + { + rect = rect.intersect(frame_rect(f)); + } + if rect.width() > 0.0 && rect.height() > 0.0 { + // Corners stay square where the box is cut by a split. + let corners = cut_corners(s.corners, !first_part, !last_part); + let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4])); + return Some(Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting }); + } + None +} + +/// The border strokes (and plain edges) for one part of a paragraph run, pushed onto `decos`. +fn border_decos(bd: &Border, part: &Part<'_>, first_part: bool, last_part: bool, decos: &mut Vec<(usize, bool, Deco)>) { + let Some(r) = inner_rect(&part.lines, bd.width, bd.top, bd.bottom) else { return }; + let [t, l, bo, rr] = bd.offsets; + let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo); + let open_top = !first_part && !bd.display_if_splits; + let open_bottom = !last_part && !bd.display_if_splits; + for (path, gap) in border_paths(bd, o, open_top, open_bottom) { + let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) }; + decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false })); + } + if is_plain(bd, open_top, open_bottom) { + decos.extend( + plain_edges(bd, o, open_top, open_bottom) + .into_iter() + .map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })), + ); + } +} + /// The decorations of paragraphs `paras` sharing box `b`: (frame, drawn under the rest, deco). fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive, b: &ParaBox, frames: &[FrameSpec]) -> Vec<(usize, bool, Deco)> { let mut parts: Vec> = Vec::new(); @@ -136,40 +175,12 @@ fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive, b: & for (k, part) in parts.iter().enumerate() { let (first_part, last_part) = (k == 0, k + 1 == n); if let Some(s) = &b.shading - && let Some(r) = inner_rect(&part.lines, s.width, s.top, s.bottom) + && let Some(deco) = shading_deco(s, part, frames, first_part, last_part) { - let [t, l, bo, rr] = s.offsets; - let mut rect = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo); - if s.clip - && let Some(f) = frames.get(part.fi) - { - rect = rect.intersect(frame_rect(f)); - } - if rect.width() > 0.0 && rect.height() > 0.0 { - // Corners stay square where the box is cut by a split. - let corners = cut_corners(s.corners, !first_part, !last_part); - let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4])); - decos.push((part.fi, true, Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting })); - } + decos.push((part.fi, true, deco)); } - if let Some(bd) = &b.border - && let Some(r) = inner_rect(&part.lines, bd.width, bd.top, bd.bottom) - { - let [t, l, bo, rr] = bd.offsets; - let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo); - let open_top = !first_part && !bd.display_if_splits; - let open_bottom = !last_part && !bd.display_if_splits; - for (path, gap) in border_paths(bd, o, open_top, open_bottom) { - let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) }; - decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false })); - } - if is_plain(bd, open_top, open_bottom) { - decos.extend( - plain_edges(bd, o, open_top, open_bottom) - .into_iter() - .map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })), - ); - } + if let Some(bd) = &b.border { + border_decos(bd, part, first_part, last_part, &mut decos); } } decos diff --git a/crates/compose/src/placed.rs b/crates/compose/src/placed.rs new file mode 100644 index 000000000..200ff6e3c --- /dev/null +++ b/crates/compose/src/placed.rs @@ -0,0 +1,84 @@ +//! Where anchored text frames sit on their spread. +//! +//! An anchored frame is not a spread item: its host story's composition places it on a line. Its +//! own story is composed against that placement (page, page side, page rect, baseline grid), so +//! the host story is composed first. + +use std::sync::Arc; + +use designcraft_doc::{Document, Item, ItemId, SpreadRef, StoryId}; +use designcraft_geom::Affine; + +use crate::{ComposedStory, FrameText}; + +/// Hosts nested deeper than this (a note anchored in a note anchored in …, or stories anchored in +/// each other in a cycle) are composed where they are stored. +pub(crate) const MAX_DEPTH: usize = 8; + +/// An anchored item's parent space on its spread, where its host story's composition placed it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub spread: SpreadRef, + /// The item's parent space → spread space. + pub xf: Affine, +} + +/// The placements of `sid`'s anchored frames, from their host stories composed by `host`. +pub(crate) fn placements(doc: &Document, sid: StoryId, host: &mut dyn FnMut(StoryId) -> Arc) -> Vec<(ItemId, Placement)> { + let Some(st) = doc.story(sid) else { return Vec::new() }; + let mut anchored = None; + let mut out = Vec::new(); + for &fid in &st.frames { + if doc.find(fid).is_some() { + continue; + } + let items = anchored.get_or_insert_with(|| doc.anchored_items()); + // A frame anchored in its own story would need its own composition to place it. + let Some(owner) = items.iter().find_map(|(owner, it)| (it.id == fid).then_some(*owner)).filter(|o| *o != sid) else { continue }; + if let Some(p) = locate(doc, &host(owner), fid) { + out.push((fid, p)); + } + } + out +} + +/// Where `cs` (a host story's composition) places item `fid`: in its frames' anchored objects, +/// footnotes and table cells. +fn locate(doc: &Document, cs: &ComposedStory, fid: ItemId) -> Option { + cs.frames.iter().find_map(|ft| { + let loc = doc.find(ft.frame)?; + let frame = doc.item_at(&loc)?; + let xf = in_frame(ft, doc.parent_xf(&loc) * doc.text_xf(frame), fid, 0)?; + Some(Placement { spread: loc.spread, xf }) + }) +} + +/// The parent space of `fid` among the objects of `ft`, whose text space maps to the spread by +/// `xf`. +fn in_frame(ft: &FrameText, xf: Affine, fid: ItemId, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + let objects = ft.objects.iter().find_map(|o| in_item(&o.object.item, xf * Affine::translate(o.origin.to_vec2()), fid, 0)); + objects + .or_else(|| ft.notes.iter().find_map(|n| in_frame(n.text.frames.first()?, xf * Affine::translate(n.origin.to_vec2()), fid, depth + 1))) + .or_else(|| { + ft.tables + .iter() + .flat_map(|t| &t.cells) + .find_map(|c| in_frame(c.text.frames.first()?, xf * Affine::translate(c.origin.to_vec2()), fid, depth + 1)) + }) +} + +/// The parent space of `fid` in `it` (it or a group member), `it`'s parent space mapping to the +/// spread by `parent`. +fn in_item(it: &Item, parent: Affine, fid: ItemId, depth: usize) -> Option { + if it.id == fid { + return Some(parent); + } + if depth > crate::MAX_WRAP_DEPTH { + return None; + } + let xf = parent * it.xf; + it.children().iter().find_map(|c| in_item(c, xf, fid, depth + 1)) +} diff --git a/crates/compose/src/ruby.rs b/crates/compose/src/ruby.rs index 1afea6b0d..47c48a728 100644 --- a/crates/compose/src/ruby.rs +++ b/crates/compose/src/ruby.rs @@ -71,6 +71,36 @@ fn tcy_char(c: char, roman: bool) -> bool { c.is_ascii_digit() || (roman && c.is_ascii_alphabetic()) } +/// Tate-chu-yoko in vertical ruby: each short run of digits (and Latin letters) is set in one +/// ruby em across the line, scaled down to fit when the spec asks for it. +fn apply_tcy(glyphs: &mut [RubyGlyph], spec: &RubySpec, size: f64) { + let roman = spec.auto_tcy_include_roman; + let mut i = 0; + while i < glyphs.len() { + let j = i + glyphs.get(i..).unwrap_or_default().iter().take_while(|g| tcy_char(g.ch, roman)).count(); + if j == i { + i += 1; + continue; + } + if u32::try_from(j - i).is_ok_and(|n| n <= spec.auto_tcy_digits) + && let Some(run) = glyphs.get_mut(i..j) + { + let total: f64 = run.iter().map(|g| g.adv).sum(); + let f = if spec.auto_tcy_auto_scale && total > size && total > 0.0 { size / total } else { 1.0 }; + let mut across = -total * f / 2.0; + for (n, g) in run.iter_mut().enumerate() { + g.tcy = Some([size / 2.0, across + g.x * f, size]); + across += g.adv * f; + g.sx *= f; + g.x = 0.0; + g.adv = if n == 0 { size } else { 0.0 }; + g.joined = n > 0; + } + } + i = j; + } +} + /// Shape `text` as the ruby of parent text `parent_size` points big. fn shape_ruby(db: &ScopedFonts<'_>, parent: FaceRef, text: &str, spec: &RubySpec, parent_size: f64, fallback: bool, vertical: bool) -> Shaped { let size = spec.size_for(parent_size); @@ -96,31 +126,7 @@ fn shape_ruby(db: &ScopedFonts<'_>, parent: FaceRef, text: &str, spec: &RubySpec .collect(); // Tate-chu-yoko: short runs of digits (and Latin letters) take one ruby em along the line. if vertical && spec.auto_tcy_digits > 0 { - let roman = spec.auto_tcy_include_roman; - let mut i = 0; - while i < glyphs.len() { - let j = i + glyphs.get(i..).unwrap_or_default().iter().take_while(|g| tcy_char(g.ch, roman)).count(); - if j == i { - i += 1; - continue; - } - if u32::try_from(j - i).is_ok_and(|n| n <= spec.auto_tcy_digits) - && let Some(run) = glyphs.get_mut(i..j) - { - let total: f64 = run.iter().map(|g| g.adv).sum(); - let f = if spec.auto_tcy_auto_scale && total > size && total > 0.0 { size / total } else { 1.0 }; - let mut across = -total * f / 2.0; - for (n, g) in run.iter_mut().enumerate() { - g.tcy = Some([size / 2.0, across + g.x * f, size]); - across += g.adv * f; - g.sx *= f; - g.x = 0.0; - g.adv = if n == 0 { size } else { 0.0 }; - g.joined = n > 0; - } - } - i = j; - } + apply_tcy(&mut glyphs, spec, size); } // Pen positions: a joined glyph sits at its group's start. let mut pen = 0.0; diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index ffbc4857d..0f739e16e 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -950,10 +950,10 @@ static WORD_CACHE: RwLock>)>> = RwLock::new(Ve const WORD_CACHE_MAX: usize = 50_000; fn word_map(key: WordKey) -> Arc> { - if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(|e| e.into_inner()).iter().find(|e| e.0 == key) { + if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().find(|e| e.0 == key) { return m.clone(); } - let mut w = WORD_CACHE.write().unwrap_or_else(|e| e.into_inner()); + let mut w = WORD_CACHE.write().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some((_, m)) = w.iter().find(|e| e.0 == key) { return m.clone(); } diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 940e0aa77..9a41a546e 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -121,7 +121,7 @@ impl CellLineExtent { } /// Compose a cell story at width `w` (content space, top-left at the origin). -fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left_page: bool) -> CellLayout { +fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout { let spec = FrameSpec { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), @@ -134,6 +134,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left page: opts.page, grid: None, left_page, + right_page, page_rect: None, }; let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); @@ -200,7 +201,7 @@ pub(crate) fn place_table( let (nr, nc) = (table.nrows(), table.ncols()); let owners = table.owners(); let is_owner = |r: usize, c: usize| owners[r * nc + c] == (r, c); - let left_page = frames[cur.fi].left_page; + let side = (frames[cur.fi].left_page, frames[cur.fi].right_page); // Compose every owner cell. let mut layouts: Vec> = Vec::with_capacity(nr * nc); for r in 0..nr { @@ -212,7 +213,7 @@ pub(crate) fn place_table( let cell = &table.cells[r * nc + c]; let cs = (cell.col_span.max(1) as usize).min(nc - c); let w = colx[c + cs] - colx[c] - cell.insets[1] - cell.insets[3]; - layouts.push(Some(compose_cell(doc, cell, w, opts, left_page))); + layouts.push(Some(compose_cell(doc, cell, w, opts, side))); } } // Row heights. @@ -342,6 +343,26 @@ pub(crate) fn place_table( true } +/// A cell's text cut to the lines that fit `clip_height`, with where the overset text begins. +/// Uses the same baseline/bounds test as auto-growth, and retains a prefix: overset text cannot +/// reappear after a line that did not fit. +fn clip_overset(lay: &CellLayout, clip_height: f64) -> Arc { + let Some(f) = lay.text.frames.first() else { return lay.text.clone() }; + let keep = lay.line_heights.iter().take_while(|h| **h <= clip_height + 0.01).count(); + let overset_at = f.lines.get(keep).map(|l| l.range.start); + let mut t = (*lay.text).clone(); + if let Some(frame) = t.frames.first_mut() { + frame.lines.truncate(keep); + frame.objects.retain(|o| o.line < keep); + frame.tables.retain(|table| table.line < keep); + frame.range.end = frame.lines.last().map_or(frame.range.start, |l| l.range.end); + } + if let Some(at) = overset_at { + t.overset_at = Some(at); + } + Arc::new(t) +} + /// Build one fragment from its rows and push it (with its glyph-less line) to the current frame. #[allow(clippy::too_many_arguments)] fn emit( @@ -406,24 +427,7 @@ fn emit( _ => 0.0, }; let overset = space < -0.01 || lay.text.is_overset(); - let mut text = lay.text.clone(); - if overset && let Some(f) = text.frames.first() { - // Use the same baseline/bounds test as auto-growth, and retain a prefix: - // overset text cannot reappear after a line that did not fit. - let keep = lay.line_heights.iter().take_while(|h| **h <= clip.height() + 0.01).count(); - let overset_at = f.lines.get(keep).map(|l| l.range.start); - let mut t = (*text).clone(); - if let Some(frame) = t.frames.first_mut() { - frame.lines.truncate(keep); - frame.objects.retain(|o| o.line < keep); - frame.tables.retain(|table| table.line < keep); - frame.range.end = frame.lines.last().map_or(frame.range.start, |l| l.range.end); - } - if let Some(at) = overset_at { - t.overset_at = Some(at); - } - text = Arc::new(t); - } + let text = if overset { clip_overset(lay, clip.height()) } else { lay.text.clone() }; cells.push(PlacedCell { row: r, col: c, @@ -813,7 +817,7 @@ mod height_tests { } fn measure(cell: &Cell, width: f64) -> CellLayout { - compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), false) + compose_cell(&Document::new(&NewDocument::default()), cell, width, &ComposeOptions::default(), (false, false)) } fn compose_table(table: Table, height: f64) -> ComposedStory { @@ -1061,6 +1065,7 @@ mod height_tests { object_point: 0, ref_point: 0, keep_within_column: false, + spine_relative: false, }), ); assert!((measure(&cell, 92.0).content_h - original).abs() < 1e-6); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index b73806d27..90b4ac3c4 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1013,6 +1013,92 @@ fn hyphenation_zone_limits_ragged_hyphens() { assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}"); } +#[test] +fn hyphenate_across_column_off_ends_no_column_with_a_hyphen() { + let text = CORPUS.join(" "); + // Column-end lines that are hyphenated, over frame heights that end the first column at each + // line in turn. + let ends = |across: bool, composer: designcraft_doc::Composer| { + let mut ends = 0; + let mut lines = 0; + for h in (30..400).step_by(3) { + let para = ParaAttrs { + align: Some(Align::Left), + hyph_weight: Some(0.0), + hyph_across_column: Some(across), + composer: Some(composer), + ..Default::default() + }; + let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 240.0, h as f64), para); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let col0: Vec<&Line> = all_lines(&cs).into_iter().filter(|l| l.column == 0).collect(); + ends += usize::from(col0.last().is_some_and(|l| l.hyphenated)); + lines += col0.len(); + // The frame's last line too, when the text goes on (overset). + let last = all_lines(&cs).into_iter().last(); + ends += usize::from(cs.is_overset() && last.is_some_and(|l| l.hyphenated && !l.last_in_para)); + } + (ends, lines) + }; + for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] { + let (on, lines_on) = ends(true, c); + let (off, lines_off) = ends(false, c); + // With it on, columns do end in hyphens here; with it off, none does, and the columns + // still fill. + assert!(on > 3, "{c:?}: {on}"); + assert_eq!(off, 0, "{c:?}"); + assert!(lines_off * 10 >= lines_on * 9, "{c:?}: {lines_off} {lines_on}"); + } +} + +#[test] +fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() { + // The first line in a character style of another colour (the same widths): when its hyphen is + // left out at a column end, the style still covers exactly the first line as set. + let text = CORPUS.join(" "); + let compose = |w: f64, h: f64, across: bool| { + let para = ParaAttrs { + align: Some(Align::Left), + hyph_weight: Some(0.0), + hyph_across_column: Some(across), + nested_line_styles: Some(vec![designcraft_doc::NestedLineStyle { style: "Lead".into(), lines: 1 }]), + ..Default::default() + }; + let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, w, h), para); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let chars = designcraft_doc::CharAttrs { fill: Some("[Registration]".into()), ..Default::default() }; + std::sync::Arc::make_mut(&mut d.styles).character.push(designcraft_doc::CharacterStyle { + name: "Lead".into(), + based_on: None, + chars, + shortcut: String::new(), + }); + compose_story(&d, sid, &ComposeOptions::default()) + }; + let mut checked = 0; + for w in (160..320).step_by(10) { + for h in (12..40).step_by(2) { + // Where, with the setting on, a hyphenated first line ends column 1. + let on = compose(w as f64, h as f64, true); + let lines = all_lines(&on); + if !(lines.iter().filter(|l| l.column == 0).count() == 1 && lines[0].hyphenated) { + continue; + } + let cs = compose(w as f64, h as f64, false); + let lines = all_lines(&cs); + assert!(!lines[0].hyphenated, "{w}×{h}"); + let lead = |l: &Line| { + l.glyphs.iter().filter(|g| g.len > 0 && g.visible).map(|g| cs.styles[g.style as usize].fill == "[Registration]").collect::>() + }; + assert!(lead(lines[0]).iter().all(|&r| r), "{w}×{h}: first line"); + assert!(lead(lines[1]).iter().all(|&r| !r), "{w}×{h}: second line"); + checked += 1; + } + } + assert!(checked > 3, "{checked}"); +} + #[test] fn discretionary_hyphen_replaces_automatic_points() { let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee"; @@ -1150,7 +1236,7 @@ fn nonbreaking_space_alone_fills_a_justified_line() { /// Story of `n` one-line filler paragraphs followed by `extra` paragraphs, in a 2-column frame. fn keep_doc(fillers: usize, extra: &[&str], height: f64) -> (Document, StoryId, Vec) { let mut parts: Vec = (0..fillers).map(|k| format!("Filler {k}")).collect(); - parts.extend(extra.iter().map(|s| s.to_string())); + parts.extend(extra.iter().map(ToString::to_string)); let text = parts.join("\n"); let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 400.0, height), ParaAttrs::default()); d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; @@ -1272,6 +1358,56 @@ fn optical_margin_hangs_punctuation() { assert!(lines.iter().all(|l| l.end_x <= l.x1 + 12.0)); } +/// How far the opening quote of a one-line paragraph at `text_size` hangs into the left margin, +/// with the story's Optical Margin Alignment `story` (on, size) and the paragraph's attributes. +fn quote_hang(text_size: f64, story: Option, para: ParaAttrs) -> f64 { + let text = "“Quoted” words."; + let first_x = |story: Option, para: ParaAttrs| { + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 400.0, 200.0), para); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..text.len(), |f| f.over.size = Some(text_size)); + if let Some(size) = story { + st.optical_margin = designcraft_doc::OpticalMargin { on: true, size }; + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 1); + lines[0].glyphs[0].x + }; + first_x(None, ParaAttrs::default()) - first_x(story, para) +} + +#[test] +fn story_optical_margin_hang_scales_with_its_size() { + let at12 = quote_hang(12.0, Some(12.0), ParaAttrs::default()); + let at24 = quote_hang(12.0, Some(24.0), ParaAttrs::default()); + assert!(at12 > 1.0, "{at12}"); + assert!((at24 - 2.0 * at12).abs() < 0.01, "{at12} {at24}"); + // The size is the type size the hang is set for: 24 pt text at 24 hangs like 12 at 12, twice as far. + let big = quote_hang(24.0, Some(24.0), ParaAttrs::default()); + assert!((big - 2.0 * at12).abs() < 0.01, "{at12} {big}"); +} + +#[test] +fn ignore_optical_margin_paragraph_does_not_hang() { + let ignored = ParaAttrs { ignore_optical_margin: Some(true), optical_margin: Some(true), ..Default::default() }; + assert_eq!(quote_hang(12.0, Some(12.0), ignored.clone()), 0.0); + assert_eq!(quote_hang(12.0, None, ignored), 0.0); +} + +#[test] +fn paragraph_optical_margin_hangs_at_the_text_size() { + // Documents saved before the story setting existed: the paragraph's switch hangs each + // character by its own size, not the story's default 12 pt. + let own = ParaAttrs { optical_margin: Some(true), ..Default::default() }; + let at24 = quote_hang(24.0, None, own.clone()); + assert!((at24 - quote_hang(24.0, Some(24.0), ParaAttrs::default())).abs() < 0.01); + assert!((at24 - 2.0 * quote_hang(12.0, None, own)).abs() < 0.01); + // The story setting, when on, sets the size. + let story = quote_hang(24.0, Some(12.0), ParaAttrs { optical_margin: Some(true), ..Default::default() }); + assert!((story - at24 / 2.0).abs() < 0.01, "{story} {at24}"); +} + // ---------- golden paragraph corpus ---------- /// Original prose (written for this corpus) with a realistic mix of short and long words. @@ -2752,6 +2888,7 @@ fn custom_anchors_do_not_change_bottom_baseline_fitting() { object_point: 0, ref_point: 0, keep_within_column: false, + spine_relative: false, }; d.story_mut(sid).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, pos)); let cs = compose_story(&d, sid, &ComposeOptions::default()); @@ -6610,7 +6747,7 @@ fn roman_word_break_breaks_inside_words_without_hyphens() { fn grid_doc(text: &str, para: ParaAttrs, edit: impl FnOnce(&mut Document, StoryId)) -> (Vec, (f64, f64)) { let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), para); if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { - tf.options.baseline_grid = Some((12.0, 0.0)); + tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(0.0, 12.0)); } edit(&mut d, sid); // Vertical frames have no baseline grid. @@ -6740,6 +6877,7 @@ fn anchor_box(d: &mut Document, sid: StoryId, at: usize, x: f64, y: f64, y_rel: object_point: 0, ref_point: 0, keep_within_column: false, + spine_relative: false, }; d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(it, position)); } @@ -6808,6 +6946,7 @@ fn anchored_object_wraps_with_its_contour_and_side() { object_point: 0, ref_point: 0, keep_within_column: false, + spine_relative: false, }; d.story_mut(a).unwrap().insert_object(0, designcraft_doc::AnchoredObject::new(it, position)); let cs = compose_story(&d, b, &ComposeOptions::default()); @@ -6821,6 +6960,70 @@ fn anchored_object_wraps_with_its_contour_and_side() { assert!(top > mid + 5.0, "top {top}, middle {mid}"); } +/// Relative to Spine: a marginal note set up for a left-hand page (its right edge 12 pt left of +/// the column) mirrors on the right-hand page (its left edge 12 pt right of the column), so the +/// notes of a story that runs across a spread all sit in the outside margin. Placed as on a +/// left-hand page, the right page's note sat in the inside margin and reached across the spine. +#[test] +fn spine_relative_anchored_notes_stay_in_the_outside_margin_on_both_pages() { + use designcraft_doc::anchored::AnchorRelative as R; + let compose_spread = |spine_relative: bool| { + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + let lid = d.default_layer(); + let sp = &d.spreads[1]; + let (l, r) = (sp.pages[0].clone(), sp.pages[1].clone()); + assert_eq!((l.side, r.side), (designcraft_doc::PageSide::Left, designcraft_doc::PageSide::Right)); + // Body frames leave a 120 pt outside margin for the notes. + let left_rect = Rect::new(l.x + 120.0, 36.0, l.x + l.width - 36.0, 120.0); + let right_rect = Rect::new(r.x + 36.0, 36.0, r.x + r.width - 120.0, 600.0); + let text = [LOREM; 3].join(" "); + let (lf, sid) = d.add_text_frame(SpreadRef::Doc(1), left_rect, lid, &text, ParaFormat::default()).unwrap(); + let (rf, _) = d.add_text_frame(SpreadRef::Doc(1), right_rect, lid, "", ParaFormat::default()).unwrap(); + d.thread(lf, rf).unwrap(); + let plain = compose_story(&d, sid, &ComposeOptions::default()); + let second_line = |fid| plain.frame(fid).unwrap().lines[1].range.start; + let (at_left, at_right) = (second_line(lf), second_line(rf)); + // Top-right corner of the note 12 pt left of the column's top-left, on the anchor's baseline. + let position = designcraft_doc::AnchorPosition::Custom { + x_relative: R::ColumnEdge, + y_relative: R::Anchor, + x_offset: -12.0, + y_offset: 0.0, + object_point: 2, + ref_point: 0, + keep_within_column: false, + spine_relative, + }; + for at in [at_right, at_left] { + let note = designcraft_doc::Item::new( + ItemId(d.alloc()), + lid, + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 72.0, 40.0)), + ); + d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position.clone())); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + // Per page: the note's left and right edges, the column, and the page's left and right edges. + let placed = |fid, page: &designcraft_doc::Page| { + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.objects.len(), 1); + let o = &ft.objects[0]; + ((o.origin.x, o.origin.x + o.size.0), ft.columns[0], (page.x, page.x + page.width)) + }; + (placed(lf, &l), placed(rf, &r)) + }; + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + + let (((lx0, lx1), lcol, (lp0, _)), ((rx0, rx1), rcol, (_, rp1))) = compose_spread(true); + assert!(near(lx1, lcol.x0 - 12.0) && lx0 >= lp0, "left page: note {lx0}..{lx1}, column {lcol:?}"); + assert!(near(rx0, rcol.x1 + 12.0) && rx1 <= rp1, "right page: note {rx0}..{rx1}, column {rcol:?}"); + + // Without Relative to Spine both pages take the settings as written. + let (_, ((rx0, rx1), rcol, _)) = compose_spread(false); + assert!(near(rx1, rcol.x0 - 12.0), "right page as written: note {rx0}..{rx1}, column {rcol:?}"); +} + const OBJECT_LEN: usize = designcraft_doc::OBJECT_MARK.len_utf8(); #[test] @@ -7199,6 +7402,158 @@ fn cache_recomposes_when_the_baseline_grid_changes() { assert!((la.baseline - lb.baseline).abs() > 1.0, "{} {}", la.baseline, lb.baseline); } +/// Two single pages 618 pt tall, top margins 36 and 48, each with an overset 11/13 story aligned +/// to a baseline grid (start 0, increment 13) and a frame filling its margin box (bottom 582). +fn grid_margin_doc(relative_to: designcraft_doc::GridRelative) -> (Document, [StoryId; 2]) { + let mut d = Document::new(&NewDocument { + height: 618.0, + pages: 2, + facing_pages: false, + margins: designcraft_doc::Margins::uniform(36.0), + ..Default::default() + }); + Arc::make_mut(&mut d.spreads[1]).pages[0].margins.top = 48.0; + Arc::make_mut(&mut d.spreads[1]).pages[0].local_margins = true; + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 13.0; + d.settings.baseline_grid.relative_to = relative_to; + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let text = [LOREM; 12].join(" "); + let mut sids = Vec::new(); + for (si, top) in [(0, 36.0), (1, 48.0)] { + let (_, sid) = d.add_text_frame(SpreadRef::Doc(si), Rect::new(36.0, top, 336.0, 582.0), lid, &text, para.clone()).unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].chars = + designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() }; + sids.push(sid); + } + (d, [sids[0], sids[1]]) +} + +/// Page y of a frame's baselines (the frame's inner space is its page's: it has no transform). +fn page_baselines(d: &Document, sid: StoryId) -> Vec { + let cs = compose_story(d, sid, &ComposeOptions::default()); + assert!(cs.is_overset()); + cs.frames[0].lines.iter().map(|l| l.baseline).collect() +} + +#[test] +fn baseline_grid_relative_to_the_top_margin_starts_at_each_pages_margin() { + use designcraft_doc::GridRelative; + let (d, [p1, p2]) = grid_margin_doc(GridRelative::TopMargin); + // Grid lines at 36 + 13n on page 1: 42 lines from 49 to 582. + let lines = page_baselines(&d, p1); + assert!((lines[0] - 49.0).abs() < 1e-6, "{lines:?}"); + assert!(lines.iter().enumerate().all(|(n, y)| (y - (49.0 + 13.0 * n as f64)).abs() < 1e-6), "{lines:?}"); + assert_eq!(lines.len(), 42); + // Page 2's own margin: lines at 48 + 13n. + let lines = page_baselines(&d, p2); + assert!((lines[0] - 61.0).abs() < 1e-6, "{lines:?}"); + // From the top of the page the lines are at 13n: the first at 52, and one line fewer. + let (d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage); + let lines = page_baselines(&d, p1); + assert!((lines[0] - 52.0).abs() < 1e-6, "{lines:?}"); + assert_eq!(lines.len(), 41); +} + +/// A frame's custom grid (start 10, increment 13) measured from the page top, the top margin +/// (36), the frame top (100) or the top inset (6 below it). +#[test] +fn frame_baseline_grid_relative_to_page_margin_frame_or_inset() { + use designcraft_doc::{FrameBaselineGrid, FrameGridRelative as R}; + let first_baseline = |relative_to: Option| { + let mut d = Document::new(&NewDocument { margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() }); + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 100.0, 336.0, 400.0), lid, "Grid", para).unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].chars = + designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() }; + let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap(); + tf.options.inset = [6.0, 0.0, 0.0, 0.0]; + tf.options.baseline_grid = relative_to.map(|r| FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(10.0, 13.0) }); + // A document grid that matches none of the frame grids. + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 0.0; + compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline + }; + // Without a grid the first baseline sits an ascent below the top inset. + let natural = first_baseline(None); + assert!(natural > 106.0 && natural < 120.0, "{natural}"); + for (r, origin) in [(R::TopOfPage, 10.0), (R::TopMargin, 46.0), (R::TopOfFrame, 110.0), (R::TopInset, 116.0)] { + let want = (0..).map(|n| origin + 13.0 * f64::from(n)).find(|y| *y >= natural - 1e-9).unwrap(); + let got = first_baseline(Some(r)); + assert!((got - want).abs() < 1e-6, "{r:?}: first baseline {got}, want {want} (natural {natural})"); + } +} + +/// A group's transform moves a frame from the left page of a spread to the right one: the grid +/// starts at the right page's top margin. +#[test] +fn baseline_grid_follows_a_grouped_frame_to_its_page() { + use designcraft_doc::{Content, GridRelative, Item, ItemId, Shape}; + let mut d = Document::new(&NewDocument { pages: 3, facing_pages: true, margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() }); + Arc::make_mut(&mut d.spreads[1]).pages[1].margins.top = 48.0; + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 13.0; + d.settings.baseline_grid.relative_to = GridRelative::TopMargin; + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(36.0, 36.0, 336.0, 582.0), lid, LOREM, para).unwrap(); + let sp = Arc::make_mut(&mut d.spreads[1]); + let at = sp.items.iter().position(|it| it.id == fid).unwrap(); + let frame = sp.items.remove(at); + let right = sp.pages[1].x; + let mut g = Item::new(ItemId(d.alloc()), lid, Shape::Group, designcraft_geom::PathData::default()); + g.content = Content::Group { items: vec![frame] }; + g.xf = designcraft_geom::Affine::translate((right, 0.0)); + Arc::make_mut(&mut d.spreads[1]).items.push(Arc::new(g)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let first = cs.frames[0].lines[0].baseline; + assert!((first - 61.0).abs() < 1e-6, "{first}"); +} + +#[test] +fn cache_recomposes_when_the_grid_reference_or_a_top_margin_changes() { + use designcraft_doc::GridRelative; + let (mut d, [p1, _]) = grid_margin_doc(GridRelative::TopOfPage); + let cache = Cache::new(); + let a = cache.get(&d, p1, None); + d.settings.baseline_grid.relative_to = GridRelative::TopMargin; + let b = cache.get(&d, p1, None); + assert!(!Arc::ptr_eq(&a, &b)); + assert!((a.frames[0].lines[0].baseline - b.frames[0].lines[0].baseline).abs() > 1.0); + Arc::make_mut(&mut d.spreads[0]).pages[0].margins.top = 40.0; + let c = cache.get(&d, p1, None); + assert!(!Arc::ptr_eq(&b, &c)); + assert!((b.frames[0].lines[0].baseline - c.frames[0].lines[0].baseline).abs() > 1.0); +} + +/// Pages trading places under a frame: the frame is on the other page, with its top margin. +#[test] +fn cache_recomposes_when_the_page_under_a_frame_changes() { + use designcraft_doc::GridRelative; + let mut d = Document::new(&NewDocument { pages: 3, facing_pages: true, margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() }); + Arc::make_mut(&mut d.spreads[1]).pages[1].margins.top = 48.0; + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 13.0; + d.settings.baseline_grid.relative_to = GridRelative::TopMargin; + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let x = d.spreads[1].pages[0].x; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(1), Rect::new(x + 36.0, 36.0, x + 336.0, 582.0), lid, LOREM, para).unwrap(); + let cache = Cache::new(); + let a = cache.get(&d, sid, None); + let sp = Arc::make_mut(&mut d.spreads[1]); + let (x0, x1) = (sp.pages[0].x, sp.pages[1].x); + (sp.pages[0].x, sp.pages[1].x) = (x1, x0); + let b = cache.get(&d, sid, None); + assert!(!Arc::ptr_eq(&a, &b)); + assert_eq!(b.frames[0].lines[0].baseline, compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline); + assert!((a.frames[0].lines[0].baseline - b.frames[0].lines[0].baseline).abs() > 1.0); +} + #[test] fn cache_recomposes_when_a_wrap_layer_is_hidden() { let (mut d, sid, _) = doc_with(&[LOREM; 2].join(" "), Rect::new(0.0, 0.0, 300.0, 800.0), ParaAttrs::default()); @@ -7324,6 +7679,25 @@ fn parent_page_frames_know_which_side_of_the_spine_they_are_on() { assert!(!frame_specs(&d, right_story)[0].left_page); } +#[test] +fn a_grouped_frame_takes_the_side_of_the_page_its_group_moves_it_to() { + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + let lid = d.default_layer(); + let (l, r) = (d.spreads[1].pages[0].clone(), d.spreads[1].pages[1].clone()); + let rect = Rect::new(l.x + 36.0, 36.0, l.x + l.width - 36.0, 300.0); + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, "Text", ParaFormat::default()).unwrap(); + // The frame's own coordinates are on the left page; its group's transform puts it on the right one. + let mut g = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Group, designcraft_geom::PathData::default()); + g.xf = designcraft_geom::Affine::translate((r.x - l.x, 0.0)); + let sp = Arc::make_mut(&mut d.spreads[1]); + let at = sp.items.iter().position(|it| it.id == fid).unwrap(); + g.content = designcraft_doc::Content::Group { items: vec![sp.items.remove(at)] }; + sp.items.push(Arc::new(g)); + let spec = &frame_specs(&d, sid)[0]; + assert!(spec.right_page && !spec.left_page); + assert_eq!(spec.page, Some(2)); +} + /// A right-to-left paragraph's first-line indent belongs at its start side, the right. Applied on /// the left it moved the first line the wrong way — out of the frame for the hanging indent a /// numbered list uses (right indent 10 mm, first line −10 mm), while the wrapped lines sat where @@ -7365,3 +7739,91 @@ fn an_rtl_first_line_indent_is_at_the_right_edge() { assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0])); assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1])); } + +/// A marginal note (an anchored text frame, Only Align First Line to Grid, 9/10) hangs 15 pt +/// above its anchor's baseline on a right-hand page. It composes against the document grid where +/// it is placed: its first line moves down onto the grid line, the second follows 10 pt below, +/// and its page is the host's. Composed where it was stored, it snapped to a grid measured from +/// its own top edge and sat on no page. +#[test] +fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() { + use designcraft_doc::anchored::AnchorRelative as R; + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + d.settings.baseline_grid.start = 36.0; + d.settings.baseline_grid.increment = 10.0; + let lid = d.default_layer(); + let page = d.spreads[1].pages[1].clone(); + assert_eq!(page.side, designcraft_doc::PageSide::Right); + // Body lines at 46, 56, 66, … (on the grid) while the frame top is at 36. + let body = "One.\nTwo.\nThree.\nFour."; + let rect = Rect::new(page.x + 108.0, 36.0, page.x + page.width - 36.0, 400.0); + let (bf, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, body, ParaFormat::default()).unwrap(); + d.item_mut(bf).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading; + let nine_ten = |f: &mut designcraft_doc::story::CharFormat| { + f.over.size = Some(9.0); + f.over.leading = Some(designcraft_doc::Leading::Points(10.0)); + }; + d.story_mut(sid).unwrap().format_chars(0..body.len(), nine_ten); + // The note: its own story in a 60 × 40 frame anchored in the third line. + let (nid, nsid) = (ItemId(d.alloc()), StoryId(d.alloc())); + let text = "Note one\u{2028}note two"; + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::FirstLineOnly), ..Default::default() }, ..Default::default() }; + let mut story = Story::with_text(nsid, text, para); + story.format_chars(0..text.len(), nine_ten); + story.frames.push(nid); + d.stories.insert(nsid, Arc::new(story)); + let mut note = + designcraft_doc::Item::new(nid, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 40.0))); + note.content = Content::Text(designcraft_doc::TextFrame { story: nsid, options: Default::default() }); + let position = designcraft_doc::AnchorPosition::Custom { + x_relative: R::TextFrame, + y_relative: R::Anchor, + x_offset: -72.0, + y_offset: -15.0, + object_point: 0, + ref_point: 0, + keep_within_column: false, + spine_relative: false, + }; + let at = body.find("Three").unwrap(); + d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position)); + + let cache = Cache::new(); + let on_grid = |y: f64| on_grid(y, (36.0, 10.0)); + // (anchor line baseline, note baselines), in spread space. + let baselines = |d: &Document, cs: &ComposedStory| { + let host = compose_story(d, sid, &ComposeOptions::default()); + let ft = host.frame(bf).unwrap(); + let o = &ft.objects[0]; + let fxf = d.item(bf).unwrap().xf; + let anchor = (fxf * Point::new(0.0, ft.lines[o.line].baseline)).y; + let nxf = fxf * Affine::translate(o.origin.to_vec2()) * d.anchored_item(nid).unwrap().xf; + (anchor, all_lines(cs).iter().map(|l| (nxf * Point::new(0.0, l.baseline)).y).collect::>()) + }; + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + + let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None)); + assert!(near(anchor, 66.0), "anchor baseline {anchor}"); + assert_eq!(lines.len(), 2); + assert!(near(lines[0], anchor) && near(lines[1], anchor + 10.0), "note baselines {lines:?}, anchor {anchor}"); + assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines); + // The note is on the host's right-hand page, with that page's rectangle. + let placed = placed::placements(&d, nsid, &mut |h| Arc::new(compose_story(&d, h, &ComposeOptions::default()))); + let spec = &frame_specs_placed(&d, nsid, &placed)[0]; + assert_eq!((spec.page, spec.right_page, spec.left_page), (Some(2), true, false)); + assert!(spec.page_rect.is_some()); + + // Host moved down 5 pt: the anchor is off the grid, and the note's first line moves down from + // 10 pt above it onto the grid line 5 pt above it. The cache composes the note again. + let xf = d.item(bf).unwrap().xf; + d.item_mut(bf).unwrap().xf = Affine::translate((0.0, 5.0)) * xf; + let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None)); + assert!(near(anchor, 71.0), "anchor baseline {anchor}"); + assert!(on_grid(lines[0]) && near(lines[0], anchor - 5.0) && near(lines[1], lines[0] + 10.0), "note baselines {lines:?}, anchor {anchor}"); + assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines); + + // The host's page turns into a left-hand page: the note is composed again for that side. + let before = cache.get(&d, nsid, None); + Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left; + assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None))); +} diff --git a/crates/compose/src/warichu.rs b/crates/compose/src/warichu.rs index 133e392ee..8418faac4 100644 --- a/crates/compose/src/warichu.rs +++ b/crates/compose/src/warichu.rs @@ -93,6 +93,21 @@ fn line_count(len: usize, want: usize, before: usize, after: usize) -> usize { 1 } +/// The per-line counts recovered from the split table, or `None` when they don't cover all `m`. +fn trace_counts(take: &[Vec], n: usize, m: usize) -> Option> { + let mut counts = Vec::with_capacity(n); + let mut s = 0_usize; + for lines in (1..=n).rev() { + let t = take[lines][s]; + if t == 0 || s + t > m { + return None; + } + counts.push(t); + s += t; + } + (s == m).then_some(counts) +} + /// Glyph counts for `n` lines. Minimises the widest line, and the first and last lines keep their /// break minimums. One line when no split satisfies them. fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec { @@ -142,17 +157,7 @@ fn split_even(advs: &[f64], n: usize, before: usize, after: usize) -> Vec if !best[n][0].is_finite() { return vec![m]; } - let mut counts = Vec::with_capacity(n); - let mut s = 0_usize; - for lines in (1..=n).rev() { - let t = take[lines][s]; - if t == 0 || s + t > m { - return vec![m]; - } - counts.push(t); - s += t; - } - if s != m { vec![m] } else { counts } + trace_counts(&take, n, m).unwrap_or_else(|| vec![m]) } fn restore(line: &mut [PlacedGlyph], members: &[usize], saved: &[(f64, f64, f64, f64)]) { @@ -217,13 +222,31 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f restore(line, members, &saved); return 0.0; } - let rows = counts.len().max(1); let small = st.size * scale; // 0 is the default (one small em). Any other value, including a negative one, is added to // that em and then clamped so the rows cannot swap order. // Bounded, so a hostile value can't move a row to an infinite or huge coordinate. let spacing = if st.warichu_line_spacing.is_finite() { st.warichu_line_spacing.clamp(-16.0 * st.size, 16.0 * st.size) } else { 0.0 }; let gap = if spacing == 0.0 { small } else { (small + spacing).max(0.0) }; + position_warichu_rows(line, members, &counts, &widths, RowLayout { block, x0: old_x0, gap }, st); + let delta = block - old_span; + shift_non_member_glyphs(line, members, &old_xs, old_x0, old_right, delta, scale); + delta +} + +#[derive(Clone, Copy)] +struct RowLayout { + block: f64, + x0: f64, + gap: f64, +} + +fn position_warichu_rows(line: &mut [PlacedGlyph], members: &[usize], counts: &[usize], widths: &[f64], layout: RowLayout, st: &RunStyle) { + let block = layout.block; + let old_x0 = layout.x0; + let gap = layout.gap; + + let rows = counts.len().max(1); let mut off = 0_usize; for (li, &c) in counts.iter().enumerate() { let w = widths[li]; @@ -242,7 +265,9 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f } off += c; } - let delta = block - old_span; +} + +fn shift_non_member_glyphs(line: &mut [PlacedGlyph], members: &[usize], old_xs: &[f64], old_x0: f64, old_right: f64, delta: f64, scale: f64) { let mut placed: Vec<(f64, f64)> = old_xs.iter().enumerate().map(|(i, ox)| (*ox, line[members[i]].x)).collect(); placed.sort_by(|a, b| a.0.total_cmp(&b.0)); let mut member_at = vec![false; line.len()]; @@ -265,7 +290,6 @@ fn layout(styles: &[RunStyle], line: &mut [PlacedGlyph], members: &[usize]) -> f g.x = anchor.1 + (g.x - anchor.0) * scale; } } - delta } #[cfg(test)] diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 57657213f..e1cdc5fa7 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -182,7 +182,7 @@ fn settled(a: &[Vec], b: &[Vec]) -> bool { /// [`neighbours`]) and of its own. pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, others: &[&ComposedStory]) -> ComposedStory { let Some(st) = doc.story(sid) else { return ComposedStory { story: sid, ..Default::default() } }; - let mut specs = crate::frame_specs(doc, sid); + let mut specs = crate::frame_specs_placed(doc, sid, &opts.placed); if !others.is_empty() { let mut obs = Vec::new(); for cs in others { diff --git a/crates/doc/src/anchored.rs b/crates/doc/src/anchored.rs index c1392f313..74fc8818f 100644 --- a/crates/doc/src/anchored.rs +++ b/crates/doc/src/anchored.rs @@ -60,6 +60,11 @@ pub enum AnchorPosition { /// Keep Within Top/Bottom Column Boundaries. #[serde(default)] keep_within_column: bool, + /// Relative to Spine: the settings describe a left-hand page, and on a right-hand page + /// the reference point, the object point and the X offset mirror horizontally, so an + /// object in the outside margin stays in the outside margin. + #[serde(default)] + spine_relative: bool, }, } diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 077637672..e2237fe56 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -371,7 +371,7 @@ fn locale_code(words: &[&str]) -> Option { /// from documents, so at most [`MAX_TAGS`] are kept; past that a new tag is unknown (`None`). fn intern_tag(tag: String) -> Option<&'static str> { const MAX_TAGS: usize = 512; - static TAGS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + static TAGS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); let mut tags = TAGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(t) = tags.iter().find(|t| **t == tag) { return Some(t); @@ -650,6 +650,34 @@ pub const MAX_TAB_LEADER: usize = 8; /// Stops closer than this share a position. pub const SAME_TAB_POSITION: f64 = 0.01; +/// Smallest type size the engine accepts, in points. +pub const MIN_TYPE_SIZE: f64 = 0.1; +/// Largest type size the engine accepts, in points, as InDesign's own field allows. +pub const MAX_TYPE_SIZE: f64 = 1296.0; +/// Largest measure an imported indent, spacing or offset can carry, in points: the furthest tab +/// stop, which is the widest page. +pub const MAX_MEASURE: f64 = MAX_TAB_POSITION; + +/// A type size read from a file, or `None` when it is not a size the engine can mean. +/// +/// A file can hold any number, including `NaN`, `inf`, `0`, a negative or `1e300`. Every importer +/// had its own spelling of this test, or only checked that the value was finite -- so tagged text +/// took `` and set a 1e300 pt size on the text (#1593). A size outside the range is +/// dropped rather than clamped, so the text keeps the size its style gives it. +pub fn imported_type_size(pt: f64) -> Option { + (pt.is_finite() && (MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&pt)).then_some(pt) +} + +/// An indent, spacing or offset read from a file, clamped to ±[`MAX_MEASURE`]; `None` when the +/// value is not finite. +/// +/// Unlike a type size this is clamped rather than dropped: a file that says "indent everything" +/// still indents to the widest page the engine has, where dropping the value would quietly +/// restore the style's own indent and hide what the file asked for (#1593). +pub fn imported_measure(pt: f64) -> Option { + pt.is_finite().then(|| pt.clamp(-MAX_MEASURE, MAX_MEASURE)) +} + impl TabStop { /// A finite position clamped to `0..=MAX_TAB_POSITION`. pub fn clamp_position(v: f64) -> Result { @@ -1270,8 +1298,17 @@ attr_set! { number_expression: String = "^#.^t".into(), /// Text after the bullet (metacharacters expand as in [`expand_list_text`]). list_separator: String = "\t".into(), + /// The hanging indent was set by the Bulleted or Numbered List button, which takes it away + /// again with the list (an indent the paragraph had before stays). + list_hang: bool = false, balance_ragged: bool = false, + /// Hang punctuation outside the measure in this paragraph, at each character's own size + /// (DesignCraft's own per-paragraph switch; a story with Optical Margin Alignment on + /// overrides it with the story's size). optical_margin: bool = false, + /// Ignore Optical Margin: no hang in this paragraph, whatever the story or + /// `optical_margin` say. + ignore_optical_margin: bool = false, /// None uses the legacy built-in rules; an empty set explicitly disables kinsoku. kinsoku: Option = None => "deserialize_explicit_value", /// Preserved spacing-table reference; compatibility audit reports unsupported composition. diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 43b65144a..53308e1b8 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -318,15 +318,16 @@ impl Document { let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0); let after = other.frames.split_off(pos_to); let before = std::mem::take(&mut other.frames); - let (vertical, direction) = (other.vertical, other.direction); + let (vertical, direction, optical_margin) = (other.vertical, other.direction, other.optical_margin); let offset; { let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) }; - // The story keeps its own direction, even when it was empty and takes the other's text. - let own = (st.vertical, st.direction); + // The story keeps its own direction and options, even when it was empty and takes the + // other's text. + let own = (st.vertical, st.direction, st.optical_margin); st.append_story(other); - (st.vertical, st.direction) = own; + (st.vertical, st.direction, st.optical_margin) = own; let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); for (k, f) in after.iter().enumerate() { st.frames.insert(at + k, *f); @@ -345,6 +346,7 @@ impl Document { ns.frames = before.clone(); ns.vertical = vertical; ns.direction = direction; + ns.optical_margin = optical_margin; for f in &before { if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { t.story = nid; @@ -526,12 +528,12 @@ impl Document { /// stays with the first part and may become overset). pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> { let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; - let (tail, vertical, direction) = { + let (tail, vertical, direction, optical_margin) = { let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?; let tail = st.frames.split_off(p + 1); st.rev = st.rev.wrapping_add(1); - (tail, st.vertical, st.direction) + (tail, st.vertical, st.direction, st.optical_margin) }; if tail.is_empty() { return Ok(()); @@ -541,6 +543,7 @@ impl Document { ns.frames = tail.clone(); ns.vertical = vertical; ns.direction = direction; + ns.optical_margin = optical_margin; self.stories.insert(nid, Arc::new(ns)); for f in tail { if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) { @@ -554,20 +557,21 @@ impl Document { /// `frame` stays on the page with a new empty story. pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> { let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; - let (vertical, direction) = { + let (vertical, direction, optical_margin) = { let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; if st.frames.len() < 2 { return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame"))); } st.frames.retain(|f| *f != frame); st.rev = st.rev.wrapping_add(1); - (st.vertical, st.direction) + (st.vertical, st.direction, st.optical_margin) }; let nid = StoryId(self.alloc()); let mut ns = Story::new(nid); ns.frames = vec![frame]; ns.vertical = vertical; ns.direction = direction; + ns.optical_margin = optical_margin; self.stories.insert(nid, Arc::new(ns)); let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?; t.story = nid; diff --git a/crates/doc/src/endnotes.rs b/crates/doc/src/endnotes.rs index da213d08c..b15bb745c 100644 --- a/crates/doc/src/endnotes.rs +++ b/crates/doc/src/endnotes.rs @@ -147,7 +147,7 @@ impl crate::Document { if st.id == sid { break; } - n += st.endnotes.len() as u32; + n = n.saturating_add(st.endnotes.len() as u32); } n } @@ -173,7 +173,7 @@ impl crate::Document { let at = out.len(); out.insert_with(at, ¬e.text.text[r].replace('\n', " "), f.clone()); } - n += 1; + n = n.saturating_add(1); } } out diff --git a/crates/doc/src/index.rs b/crates/doc/src/index.rs index 9f56c42ea..826464329 100644 --- a/crates/doc/src/index.rs +++ b/crates/doc/src/index.rs @@ -100,6 +100,25 @@ fn sort_key(s: &str) -> String { if k.is_empty() { s.to_lowercase() } else { k } } +/// Record one reference's see-target or page on its topic node. +fn record_ref(n: &mut Node, res: &Resolved) { + match &res.r.range { + IndexRange::See(t) => n.see.push(t.clone()), + IndexRange::SeeAlso(t) => { + n.see_also.push(t.clone()); + if let Some(p) = res.pages { + n.pages.push(p); + } + } + IndexRange::SuppressPageRange => {} + _ => { + if let Some(p) = res.pages { + n.pages.push(p); + } + } + } +} + /// The index as paragraphs `(style, text)`; `page_name` formats an absolute page. pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> String) -> Vec<(String, String)> { let mut root: BTreeMap = BTreeMap::new(); @@ -114,21 +133,7 @@ pub fn build(spec: &IndexSpec, refs: &[Resolved], page_name: &dyn Fn(usize) -> S .map(|(i, t)| (format!("{}\u{0}{}", sort_key(res.r.sort.get(i).filter(|s| !s.is_empty()).unwrap_or(t)), t.trim()), t.trim().to_string())) .collect(); let n = descend(&mut root, &keys); - match &res.r.range { - IndexRange::See(t) => n.see.push(t.clone()), - IndexRange::SeeAlso(t) => { - n.see_also.push(t.clone()); - if let Some(p) = res.pages { - n.pages.push(p); - } - } - IndexRange::SuppressPageRange => {} - _ => { - if let Some(p) = res.pages { - n.pages.push(p); - } - } - } + record_ref(n, res); } let mut out = Vec::new(); if !spec.title.is_empty() { diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index cfb13dc53..31d3a08d2 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -452,12 +452,96 @@ pub struct TextFrameOptions { pub column_rule_bottom_inset: f64, /// Use a custom baseline grid for this frame. #[serde(skip_serializing_if = "Option::is_none")] - pub baseline_grid: Option<(f64, f64)>, + pub baseline_grid: Option, /// Type on a Path: the text runs along the item's path instead of filling it. #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, } +/// A text frame's custom baseline grid. Saved as `[start, increment]` when it starts from the +/// top of the page, otherwise as `[start, increment, relativeTo]`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameBaselineGrid { + /// The first grid line, down from the line `relative_to` names. + pub start: f64, + /// The distance between grid lines; 0 or less turns the grid off. + pub increment: f64, + pub relative_to: FrameGridRelative, +} + +impl FrameBaselineGrid { + /// A grid measured from the top of the page. + pub fn new(start: f64, increment: f64) -> Self { + FrameBaselineGrid { start, increment, relative_to: FrameGridRelative::TopOfPage } + } +} + +/// What a frame's custom baseline grid's start is measured from. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FrameGridRelative { + #[default] + #[serde(rename = "page")] + TopOfPage, + #[serde(rename = "margin")] + TopMargin, + #[serde(rename = "frame")] + TopOfFrame, + #[serde(rename = "inset")] + TopInset, +} + +impl FrameGridRelative { + pub const ALL: [FrameGridRelative; 4] = + [FrameGridRelative::TopOfPage, FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset]; + + /// The name commands and native files use: page, margin, frame or inset. + pub fn name(self) -> &'static str { + match self { + FrameGridRelative::TopOfPage => "page", + FrameGridRelative::TopMargin => "margin", + FrameGridRelative::TopOfFrame => "frame", + FrameGridRelative::TopInset => "inset", + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|r| r.name() == name) + } +} + +impl Serialize for FrameBaselineGrid { + fn serialize(&self, s: S) -> Result { + // Files saved before Relative To existed hold two elements; a page-relative grid keeps that form. + match self.relative_to { + FrameGridRelative::TopOfPage => (self.start, self.increment).serialize(s), + r => (self.start, self.increment, r).serialize(s), + } + } +} + +impl<'de> Deserialize<'de> for FrameBaselineGrid { + fn deserialize>(d: D) -> Result { + struct Grid; + impl<'de> serde::de::Visitor<'de> for Grid { + type Value = FrameBaselineGrid; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("[start, increment] or [start, increment, relativeTo]") + } + fn visit_seq>(self, mut a: A) -> Result { + use serde::de::Error; + let start = a.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?; + let increment = a.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?; + let relative_to = a.next_element()?.unwrap_or_default(); + if a.next_element::()?.is_some() { + return Err(A::Error::invalid_length(4, &self)); + } + Ok(FrameBaselineGrid { start, increment, relative_to }) + } + } + d.deserialize_seq(Grid) + } +} + /// Type on a Path Options. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -1662,6 +1746,28 @@ mod tests { assert_eq!(*c, ClippingPath::new(ClippingKind::AlphaChannel, PathData::default())); } + #[test] + fn frame_baseline_grid_saves_as_start_then_increment() { + // A file saved before Relative To existed: the grid starts from the top of the page and + // saves back unchanged. + let v = serde_json::json!({"baselineGrid": [6.0, 14.0]}); + let o = serde_json::from_value::(v).unwrap(); + assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0, relative_to: FrameGridRelative::TopOfPage })); + assert_eq!(serde_json::to_value(&o).unwrap()["baselineGrid"], serde_json::json!([6.0, 14.0])); + for r in [FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset] { + let o = TextFrameOptions { + baseline_grid: Some(FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(6.0, 14.0) }), + ..Default::default() + }; + let v = serde_json::to_value(&o).unwrap(); + assert_eq!(v["baselineGrid"], serde_json::json!([6.0, 14.0, r.name()])); + assert_eq!(serde_json::from_value::(v).unwrap().baseline_grid, o.baseline_grid); + } + for bad in [serde_json::json!([6.0]), serde_json::json!([6.0, 14.0, "frame", 1]), serde_json::json!([6.0, 14.0, "spine"])] { + assert!(serde_json::from_value::(serde_json::json!({"baselineGrid": bad})).is_err()); + } + } + #[test] fn bounds_and_text_area() { let mut it = Item::new(ItemId(1), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0))); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b2d3b4f4c..a509fdd8b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -147,6 +147,17 @@ impl Default for BaselineGrid { } } +impl BaselineGrid { + /// The first grid line on `page`, measured down from the page top: `start` from the top of + /// the page or from its top margin. The other lines follow every `increment` below it. + pub fn origin(&self, page: &page::Page) -> f64 { + match self.relative_to { + GridRelative::TopOfPage => self.start, + GridRelative::TopMargin => self.start + page.margins.top, + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct DocumentGrid { diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index c4d83969a..0811c549c 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -262,7 +262,7 @@ impl crate::Document { let Some(me) = self.story(sid) else { return o.start_at }; let mine = key(me); let before: usize = self.stories.values().filter(|st| st.id != sid && !st.notes.is_empty() && key(st) < mine).map(|st| st.notes.len()).sum(); - o.start_at + before as u32 + o.start_at.saturating_add(before as u32) } /// Absolute page an item sits on (its center), None on parent pages / pasteboard-only spreads. diff --git a/crates/doc/src/otf.rs b/crates/doc/src/otf.rs index 5e246b8bf..64c976bad 100644 --- a/crates/doc/src/otf.rs +++ b/crates/doc/src/otf.rs @@ -53,7 +53,7 @@ pub fn figures(list: &[String]) -> &'static str { pub fn set_figures(list: &mut Vec, id: &str) -> bool { let Some((_, _, tags, _)) = FIGURES.iter().find(|f| f.0 == id) else { return false }; list.retain(|t| !matches!(t.as_str(), "tnum" | "pnum" | "lnum" | "onum")); - list.extend(tags.iter().map(|t| t.to_string())); + list.extend(tags.iter().map(ToString::to_string)); true } diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index e3a0e39ee..05adcb35f 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -125,6 +125,9 @@ pub struct Story { /// right to left, in every frame of the thread. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub vertical: bool, + /// Story ▸ Optical Margin Alignment. + #[serde(default, skip_serializing_if = "OpticalMargin::is_default")] + pub optical_margin: OpticalMargin, /// Bumped on every edit (composition cache key). #[serde(default)] pub rev: u64, @@ -160,6 +163,45 @@ pub struct Story { pub file_link: Option, } +/// Story ▸ Optical Margin Alignment: punctuation and the edges of some letters hang outside the +/// frame's edges, in every paragraph of the story except those with Ignore Optical Margin. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct OpticalMargin { + pub on: bool, + /// The type size (points) the hang amounts are set for: text of this size hangs by the + /// built-in proportions of each character's advance, and the hang grows with this size. + pub size: f64, +} + +impl Default for OpticalMargin { + fn default() -> Self { + OpticalMargin { on: false, size: Self::DEFAULT_SIZE } + } +} + +impl OpticalMargin { + pub const DEFAULT_SIZE: f64 = 12.0; + /// InDesign's range for the size. + pub const MIN_SIZE: f64 = 0.1; + pub const MAX_SIZE: f64 = 1296.0; + + /// `v` clamped to the size range, or `None` when it isn't a finite number. + pub fn clamp_size(v: f64) -> Option { + v.is_finite().then(|| v.clamp(Self::MIN_SIZE, Self::MAX_SIZE)) + } + + /// The size the story's text hangs for, when the setting is on (a stored size outside the + /// range is clamped, a non-finite one reads as the default). + pub fn hang_size(&self) -> Option { + self.on.then(|| Self::clamp_size(self.size).unwrap_or(Self::DEFAULT_SIZE)) + } + + pub fn is_default(&self) -> bool { + *self == Self::default() + } +} + /// A linked text file: where a placed story came from, what the file held, and the Word Import /// Options that brought it in, so Update Link can import it again the same way. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -201,6 +243,7 @@ impl Story { frames: vec![], direction: crate::TextDirection::LeftToRight, vertical: false, + optical_margin: OpticalMargin::default(), rev: 0, tables: BTreeMap::new(), notes: Vec::new(), @@ -596,6 +639,22 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + #[test] + fn optical_margin_is_optional_in_saved_stories() { + let plain = serde_json::to_value(st("Text")).unwrap(); + assert!(plain.get("opticalMargin").is_none()); + let old: Story = serde_json::from_value(plain).unwrap(); + assert_eq!(old.optical_margin, OpticalMargin { on: false, size: 12.0 }); + let mut s = st("Text"); + s.optical_margin = OpticalMargin { on: true, size: 18.0 }; + let back: Story = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap(); + assert_eq!(back.optical_margin, s.optical_margin); + // A hostile stored size is clamped where it is used. + assert_eq!(OpticalMargin { on: true, size: 1e9 }.hang_size(), Some(OpticalMargin::MAX_SIZE)); + assert_eq!(OpticalMargin { on: true, size: -3.0 }.hang_size(), Some(OpticalMargin::MIN_SIZE)); + assert_eq!(OpticalMargin { on: false, size: 18.0 }.hang_size(), None); + } + #[test] fn paragraphs_track_newlines() { let mut s = st("one\ntwo"); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index e96aac4b7..d80b1d475 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -1040,6 +1040,15 @@ impl Table { if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) { return Err(format!("table {}: header/footer rows out of order", self.id)); } + self.check_spans()?; + for cell in &self.cells { + cell.text.check()?; + } + Ok(()) + } + + /// Every merged cell stays inside the grid and no two merged cells overlap. + fn check_spans(&self) -> Result<(), String> { let mut seen = vec![false; self.cells.len()]; for r in 0..self.nrows() { for c in 0..self.ncols() { @@ -1064,9 +1073,6 @@ impl Table { } } } - for cell in &self.cells { - cell.text.check()?; - } Ok(()) } } diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index 6d09f89dd..1e00585ca 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Insert Anchored Object…", ["Object", "Anchored Object"], None, - "{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?} — moves the items into the text", + "{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)} — moves the items into the text", has_doc, insert ), @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Anchored Object Options…", ["Object", "Anchored Object"], None, - "{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?}", + "{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?, spineRelative? (custom: the settings describe a left-hand page and mirror on a right-hand page)}", has_doc, options ), @@ -80,11 +80,18 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result { } ("custom", b) => { use designcraft_doc::anchored::AnchorRelative as R; - let (xr0, yr0, xo0, yo0, op0, rp0, k0) = match b { - AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => { - (*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column) - } - _ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false), + let (xr0, yr0, xo0, yo0, op0, rp0, k0, sr0) = match b { + AnchorPosition::Custom { + x_relative, + y_relative, + x_offset, + y_offset, + object_point, + ref_point, + keep_within_column, + spine_relative, + } => (*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column, *spine_relative), + _ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false, false), }; let rel = |k: &str, d: R| -> Result { match p.get(k) { @@ -102,6 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result { object_point: pt("objectPoint", op0), ref_point: pt("refPoint", rp0), keep_within_column: p.get("keepWithinColumn").and_then(Value::as_bool).unwrap_or(k0), + spine_relative: p.get("spineRelative").and_then(Value::as_bool).unwrap_or(sr0), } } (o, _) => return Err(bad("anchored", format!("unknown position `{o}`"))), @@ -316,5 +324,12 @@ mod custom_tests { matches!(obj.position, designcraft_doc::AnchorPosition::Custom { x_offset, object_point: 2, .. } if x_offset == -6.0), "IDML Anchored position" ); + // Relative to Spine keeps the other custom settings. + s.execute("anchored.options", &json!({"story": sid.0, "index": 0, "spineRelative": true})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert!(matches!( + st.objects[0].position, + designcraft_doc::AnchorPosition::Custom { spine_relative: true, x_offset, object_point: 2, .. } if x_offset == -6.0 + )); } } diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 4a65137cf..18f554d57 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -130,7 +130,7 @@ impl ChapterBatch { fn writable_chapter(s: &Session, path: &str, command: &'static str) -> Result<()> { use std::path::Path; - if Path::new(path).extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) { + if Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|ext| ext.eq_ignore_ascii_case("idml")) { return Err(bad(command, format!("cannot change imported IDML chapter {path}; convert it to a native DesignCraft document first"))); } let target = crate::io::canonicalize(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index b0165d9d3..d4b2235e9 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -254,7 +254,7 @@ fn find_markers(text: &str) -> Vec<(usize, usize, String)> { continue; } } - i += text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1); + i += text[i..].chars().next().map(char::len_utf8).unwrap_or(1); } out } diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index 38b543ff7..c07e2fff4 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -617,7 +617,7 @@ fn marker_ranges(text: &str, marker: &str) -> Vec<(usize, usize)> { i = end; continue; } - i = i.saturating_add(text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1)); + i = i.saturating_add(text[i..].chars().next().map(char::len_utf8).unwrap_or(1)); } out } diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index 61ad6224e..c76c37f1e 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -379,7 +379,7 @@ fn sniff(name: &str, text: &str) -> Delimiter { } pub fn extension(name: &str) -> String { - Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() + Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() } struct HeaderDelimiterCandidate { diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 98849eae1..5cee93730 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; -use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, HyperlinkDest, SourceStatus}; +use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, HyperlinkDest, Selection, SourceStatus, StoryId, TextSel}; use serde_json::{Value, json}; use super::parse::{self, parse_csv}; @@ -790,6 +790,30 @@ fn tiling_rows_first_bottom_inset_and_refusals() { assert_eq!(s.documents().len(), before); } +/// An IME delete-surrounding while a record is previewed edits the template, not the filled +/// record, and its undo step restores the template. +#[test] +fn an_ime_delete_ends_a_preview_and_edits_the_template() { + let mut s = Session::new(); + new_doc(&mut s, 1); + let f = text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let sid = StoryId(f["story"].as_u64().unwrap()); + select_csv(&mut s, "Name\nAda\nGrace\n"); + exec(&mut s, "data.placeholder.add", json!({"field": "Name"})); + assert_eq!(texts(&s), vec!["<>".to_string()]); + exec(&mut s, "data.preview", json!({"record": 2})); + assert_eq!(texts(&s), vec!["Grace".to_string()], "the preview shows the record"); + // The caret was placed in the preview, at the end of the filled text. + s.doc_mut().unwrap().selection = Selection::text(TextSel { story: sid, anchor: 5, focus: 5, frame: None, cell: None }); + let m = s.ime_delete_surrounding(1, 0, false).unwrap().expect("the delete changed the template"); + assert!(m.inserted.is_empty()); + assert!(s.doc().unwrap().preview_stash.is_none(), "the preview ended before the edit"); + assert_eq!(texts(&s), vec!["<>".to_string()], "the template lost the character, not the filled record"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(texts(&s), vec!["<>".to_string()], "Undo restores the template, never the preview record"); + assert!(s.doc().unwrap().preview_stash.is_none()); +} + #[test] fn update_remove_preview_inline_rows_and_old_files() { let dir = scratch("update"); diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 5b210da90..564816ee8 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -2,11 +2,11 @@ use std::sync::Arc; -use designcraft_doc::{EditorialNote, NOTE_MARK, StoryId}; +use designcraft_doc::{ANCHOR_MARK, ENDNOTE_REF, EditorialNote, FOOTNOTE_REF, INDEX_MARK, NOTE_MARK, OBJECT_MARK, StoryId, TABLE_ANCHOR, XREF_MARK}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param}; -use crate::Result; +use crate::{Result, Session}; pub fn specs() -> Vec { vec![ @@ -37,6 +37,15 @@ pub fn specs() -> Vec { Ok(json!({"story": ts.story.0, "id": id})) }) }), + cmd!( + "note.convertFromText", + "Convert to Note", + ["Type", "Notes"], + None, + "{author?} → {story, id}. The selected text moves into a new note anchored where it was.", + has_text_range, + convert_from_text + ), cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| { let (sid, id) = target(p)?; let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string)); @@ -93,9 +102,76 @@ pub fn specs() -> Vec { } Ok(Value::Array(out)) }), + cmd!( + noundo "note.previous", + "Previous Note", + ["Type", "Notes"], + None, + "{} → {story, id, pos}. The caret moves after the anchor of the story's previous note.", + has_previous, + |s, _| go_to(s, false) + ), + cmd!( + noundo "note.next", + "Next Note", + ["Type", "Notes"], + None, + "{} → {story, id, pos}. The caret moves after the anchor of the story's next note.", + has_next, + |s, _| go_to(s, true) + ), ] } +/// Characters whose payload lives outside the story text: a note made from text that holds one +/// would lose it. +const MARKS: [char; 8] = [NOTE_MARK, ENDNOTE_REF, FOOTNOTE_REF, OBJECT_MARK, INDEX_MARK, ANCHOR_MARK, XREF_MARK, TABLE_ANCHOR]; + +fn has_text_range(s: &Session) -> std::result::Result<(), String> { + has_text(s)?; + match s.active().and_then(|d| d.selection.text) { + Some(t) if t.cell.is_some() => Err("notes go in story text".into()), + Some(t) if t.is_caret() => Err("select the text to convert".into()), + _ => Ok(()), + } +} + +/// Type › Notes › Convert to Note: the selected text leaves the story and becomes the text of a +/// new note, whose anchor takes the text's place. +fn convert_from_text(s: &mut Session, p: &Value) -> Result { + const ID: &str = "note.convertFromText"; + let ts = s.doc()?.selection.text.ok_or_else(|| bad(ID, "select the text to convert"))?; + if ts.cell.is_some() { + return Err(bad(ID, "notes go in story text")); + } + let r = ts.range(); + if r.is_empty() { + return Err(bad(ID, "select the text to convert")); + } + let author = str_param(p, "author").unwrap_or("").to_string(); + s.edit(|d, sel| { + let story = d.story_mut(ts.story).ok_or_else(|| bad(ID, "no such story"))?; + let text = story.text.get(r.clone()).ok_or_else(|| bad(ID, "the selection is outside the story"))?.to_string(); + if text.contains(MARKS) { + return Err(bad(ID, "the selection holds a table, an anchored object, a note, a footnote, an endnote or a marker")); + } + let fmt = story.format_after(r.start).clone(); + story.delete(r.clone()); + story.insert_with(r.start, &NOTE_MARK.to_string(), fmt); + story.fix_marks(); + let k = story.text[..r.start].matches(NOTE_MARK).count(); + let n = story.editorial.get_mut(k).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?; + let n = Arc::make_mut(n); + (n.text, n.author) = (text, author); + let id = n.id; + if let Some(t) = &mut sel.text { + let c = r.start + NOTE_MARK.len_utf8(); + (t.anchor, t.focus) = (c, c); + } + Ok(json!({"story": ts.story.0, "id": id})) + }) +} + fn target(p: &Value) -> Result<(StoryId, u64)> { let sid = p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("note", "`story` required"))?; let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("note", "`id` required"))?; @@ -108,6 +184,53 @@ fn anchor(st: &designcraft_doc::Story, id: u64) -> Option { st.text.match_indices(NOTE_MARK).nth(k).map(|(i, _)| i) } +/// The anchor offset and id of the note before or after the selection `t` in its story. Going +/// back skips the note whose anchor the caret directly follows, so repeated steps reach every note. +fn neighbor(st: &designcraft_doc::Story, t: &designcraft_doc::TextSel, forward: bool) -> Option<(usize, u64)> { + let r = t.range(); + let mut anchors = st.text.match_indices(NOTE_MARK).enumerate().map(|(k, (i, _))| (k, i)); + let (k, at) = if forward { anchors.find(|&(_, i)| i >= r.end) } else { anchors.filter(|&(_, i)| i + NOTE_MARK.len_utf8() < r.start).last() }?; + Some((at, st.editorial.get(k)?.id)) +} + +fn has_note(s: &crate::Session, forward: bool) -> std::result::Result<(), String> { + has_text(s)?; + match s.active().and_then(|d| Some((d, d.selection.text?))) { + Some((_, t)) if t.cell.is_some() => Err("notes go in story text".into()), + Some((d, t)) if d.doc.story(t.story).and_then(|st| neighbor(st, &t, forward)).is_some() => Ok(()), + _ => Err(no_note(forward).into()), + } +} + +fn has_previous(s: &crate::Session) -> std::result::Result<(), String> { + has_note(s, false) +} + +fn has_next(s: &crate::Session) -> std::result::Result<(), String> { + has_note(s, true) +} + +fn no_note(forward: bool) -> &'static str { + if forward { "no later note in this story" } else { "no earlier note in this story" } +} + +/// Type › Notes › Previous Note and Next Note: from the insertion point, the caret moves after +/// the anchor of the story's previous or next note. +fn go_to(s: &mut crate::Session, forward: bool) -> Result { + let id = if forward { "note.next" } else { "note.previous" }; + let st = s.doc_mut()?; + let t = st.selection.text.ok_or_else(|| bad(id, "place the insertion point in text"))?; + if t.cell.is_some() { + return Err(bad(id, "notes go in story text")); + } + let (at, note) = st.doc.story(t.story).and_then(|x| neighbor(x, &t, forward)).ok_or_else(|| bad(id, no_note(forward)))?; + let pos = at + NOTE_MARK.len_utf8(); + st.selection.text = Some(designcraft_doc::TextSel::caret(t.story, pos)); + st.bump_revision(); + s.typing_format = None; + Ok(json!({"story": t.story.0, "id": note, "pos": pos})) +} + #[cfg(test)] mod tests { use serde_json::json; @@ -136,6 +259,46 @@ mod tests { assert!(s.doc().unwrap().doc.stories[&sid].editorial.is_empty()); } + #[test] + fn previous_and_next_note_step_through_the_story_s_notes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two three."})).unwrap(); + let len = designcraft_doc::NOTE_MARK.len_utf8(); + let caret = |s: &mut Session, at: usize| s.execute("text.select", &json!({"story": r["story"], "anchor": at, "focus": at})).unwrap(); + caret(&mut s, 3); + let first = s.execute("note.new", &json!({"text": "a"})).unwrap(); + caret(&mut s, 7 + len); + let second = s.execute("note.new", &json!({"text": "b"})).unwrap(); + caret(&mut s, 0); + let disabled = |s: &mut Session, id: &str| matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))); + assert!(disabled(&mut s, "note.previous"), "no note before the start"); + let n = s.execute("note.next", &json!({})).unwrap(); + assert_eq!((n["id"].clone(), n["pos"].clone()), (first["id"].clone(), json!(3 + len))); + let n = s.execute("note.next", &json!({})).unwrap(); + assert_eq!((n["id"].clone(), n["pos"].clone()), (second["id"].clone(), json!(7 + 2 * len))); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (7 + 2 * len, 7 + 2 * len), "the caret follows the anchor"); + assert!(disabled(&mut s, "note.next"), "no note after the last"); + // From just after the second anchor, Previous Note goes to the first one. + let n = s.execute("note.previous", &json!({})).unwrap(); + assert_eq!(n["id"], first["id"]); + assert!(disabled(&mut s, "note.previous")); + // From the middle of the story, each direction finds its neighbor. + caret(&mut s, 5 + len); + assert_eq!(s.execute("note.previous", &json!({})).unwrap()["id"], first["id"]); + caret(&mut s, 5 + len); + assert_eq!(s.execute("note.next", &json!({})).unwrap()["id"], second["id"]); + // Formatting set at the insertion point does not survive the move. + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + assert!(s.typing_format.is_some()); + s.execute("note.previous", &json!({})).unwrap(); + assert!(s.typing_format.is_none(), "note.previous"); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("note.next", &json!({})).unwrap(); + assert!(s.typing_format.is_none(), "note.next"); + } + #[test] fn notes_insert_into_a_story_whose_markers_lack_payloads() { let mut s = Session::new(); @@ -159,4 +322,59 @@ mod tests { assert_eq!(st.endnotes[1].text.text, "An endnote."); assert_eq!(st.notes[1].text.text, "A footnote."); } + + #[test] + fn selected_text_converts_to_a_note_at_its_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two\nthree four."})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let mark = designcraft_doc::NOTE_MARK.len_utf8(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + s.execute("note.new", &json!({"text": "earlier"})).unwrap(); + // "two\nthree " across the paragraph return, selected backwards. + let (a, b) = (3 + mark + 1, 3 + mark + 1 + "two\nthree ".len()); + s.execute("text.select", &json!({"story": r["story"], "anchor": b, "focus": a})).unwrap(); + let n = s.execute("note.convertFromText", &json!({"author": "Ed"})).unwrap(); + let st = &s.doc().unwrap().doc.stories[&sid]; + st.check().unwrap(); + assert_eq!(st.text, format!("One{m} {m}four.", m = designcraft_doc::NOTE_MARK)); + assert_eq!(st.paras.len(), 1, "the paragraph return went into the note"); + let list = s.execute("note.list", &json!({})).unwrap(); + assert_eq!(list[0]["text"], "earlier"); + assert_eq!((list[1]["id"].clone(), list[1]["at"].clone()), (n["id"].clone(), json!(a))); + assert_eq!((list[1]["text"].as_str(), list[1]["author"].as_str()), (Some("two\nthree "), Some("Ed"))); + let caret = s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus)); + assert_eq!(caret, Some((a + mark, a + mark)), "the insertion point follows the anchor"); + s.execute("note.convertToText", &json!({"story": n["story"], "id": n["id"]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.stories[&sid].text, format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let st = &s.doc().unwrap().doc.stories[&sid]; + assert_eq!((st.text.as_str(), st.editorial.len()), (format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK).as_str(), 1)); + } + + #[test] + fn convert_to_note_needs_selected_story_text_without_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Draft copy."})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": r["story"], "anchor": 5, "focus": 5})).unwrap(); + let disabled = |r| matches!(r, Err(crate::EngineError::Disabled(..))); + assert!(disabled(s.execute("note.convertFromText", &json!({}))), "an insertion point selects no text"); + s.execute("note.new", &json!({"text": "keep"})).unwrap(); + let before = s.doc().unwrap().doc.stories[&sid].clone(); + let all = before.len(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": all})).unwrap(); + let refused = s.execute("note.convertFromText", &json!({})); + assert!(matches!(refused, Err(crate::EngineError::BadParams { .. })), "a note inside the selection would be lost"); + assert_eq!(s.doc().unwrap().doc.stories[&sid], before); + s.execute("text.select", &json!({"story": r["story"], "anchor": all, "focus": all})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.insert", &json!({"text": "cell", "raw": true})).unwrap(); + s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); + assert!(s.doc().unwrap().selection.text.is_some_and(|t| t.cell.is_some() && !t.is_caret())); + assert!(disabled(s.execute("note.convertFromText", &json!({}))), "notes go in story text"); + } } diff --git a/crates/engine/src/cmd/endnotes.rs b/crates/engine/src/cmd/endnotes.rs index 7bcae744b..fb3bf64ce 100644 --- a/crates/engine/src/cmd/endnotes.rs +++ b/crates/engine/src/cmd/endnotes.rs @@ -50,7 +50,7 @@ pub fn specs() -> Vec { for st in d.stories.values().filter(|st| !st.endnotes.is_empty()) { let first = d.endnote_start(st.id); for (k, n) in st.endnotes.iter().enumerate() { - out.push(json!({"story": st.id.0, "id": n.id, "number": first + k as u32, "text": n.text.text})); + out.push(json!({"story": st.id.0, "id": n.id, "number": first.saturating_add(k as u32), "text": n.text.text})); } } out.sort_by_key(|v| v["number"].as_u64()); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c644284d1..33cea7ed0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -166,7 +166,7 @@ fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result> { /// the file opens from the file manager. A path without a file name is left for the write to fail. pub fn with_document_extension(path: &str) -> String { let p = std::path::Path::new(path); - let ext = p.extension().and_then(|e| e.to_str()); + let ext = p.extension().and_then(std::ffi::OsStr::to_str); if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) { return path.to_string(); } @@ -231,11 +231,24 @@ fn new_document_settings(s: &Session, p: &Value) -> Result { Ok(nd) } +/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a +/// paragraph's last word or across a column. Opened and imported files keep their own values, +/// which is why `ParaProps` keeps the IDML defaults (on). +fn new_document(nd: &NewDocument) -> Document { + let mut d = Document::new(nd); + if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) { + st.para.hyph_capitalized = Some(false); + st.para.hyph_last_word = Some(false); + st.para.hyph_across_column = Some(false); + } + d +} + fn file_new(s: &mut Session, p: &Value) -> Result { let mut nd = new_document_settings(s, p)?; s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); - let d = Document::new(&nd); + let d = new_document(&nd); let i = s.add_document(DocState::new(d, None)); Ok(json!({"index": i})) } @@ -259,7 +272,7 @@ impl Session { self.untitled += 1; } nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); - let st = DocState::new(Document::new(&nd), None); + let st = DocState::new(new_document(&nd), None); let uid = st.uid; match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { Some((i, d)) => { @@ -469,7 +482,7 @@ fn open_path(s: &mut Session, p: &Value) -> Result { let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; // The content decides first: a file's extension may be missing or wrong. The extension // then routes files whose content isn't recognised to the reader that explains why. - let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase(); + let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase(); let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{"); if designcraft_idml::is_idml(&bytes) || (!native && (super::interchange::is_indd(path, &bytes) || ext == "idml")) { return super::interchange::open_idml(s, p); @@ -2124,3 +2137,35 @@ mod recent_tests { assert!(old.recent_files.is_empty(), "older prefs.json without the list still load"); } } + +#[cfg(test)] +mod hyphenation_default_tests { + use std::sync::Arc; + + use designcraft_doc::BASIC_PARAGRAPH; + use designcraft_doc::build::NewDocument; + use serde_json::json; + + use crate::Session; + + fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) { + let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default()); + (pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column) + } + + #[test] + fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false)); + + let mut d = designcraft_doc::Document::new(&NewDocument::default()); + let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap(); + st.para.hyph_capitalized = Some(true); + st.para.hyph_last_word = Some(true); + st.para.hyph_across_column = Some(true); + let b64 = super::base64_encode(&designcraft_idml::export_idml(&d)); + s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap(); + assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true)); + } +} diff --git a/crates/engine/src/cmd/flex.rs b/crates/engine/src/cmd/flex.rs index 9b0f56b16..8d7117858 100644 --- a/crates/engine/src/cmd/flex.rs +++ b/crates/engine/src/cmd/flex.rs @@ -82,44 +82,49 @@ pub(crate) fn sync(d: &mut Document) { } } -/// InDesign's detection when objects become a container: objects in a row (or column) keep it, -/// with Auto size and Space Between at their own spacing; others wrap in a row of their width. -fn detect(bounds: &[Rect]) -> FlexLayout { - let tol = 0.5; - let in_line = |row: bool| { - let mut v: Vec<&Rect> = bounds.iter().collect(); - v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) }); - let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol }); - let gaps: Vec = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect(); - (apart && bounds.len() > 1).then(|| gaps.iter().sum::() / gaps.len().max(1) as f64) - }; +fn in_line_gap(bounds: &[Rect], row: bool, tol: f64) -> Option { + let mut v: Vec<&Rect> = bounds.iter().collect(); + v.sort_by(|a, b| if row { a.x0.total_cmp(&b.x0) } else { a.y0.total_cmp(&b.y0) }); + let apart = v.windows(2).all(|w| if row { w[1].x0 >= w[0].x1 - tol } else { w[1].y0 >= w[0].y1 - tol }); + let gaps: Vec = v.windows(2).map(|w| if row { w[1].x0 - w[0].x1 } else { w[1].y0 - w[0].y1 }).collect(); + (apart && bounds.len() > 1).then(|| gaps.iter().sum::() / gaps.len().max(1) as f64) +} + +fn detect_align(bounds: &[Rect], row: bool, tol: f64) -> FlexAlign { let same = |f: &dyn Fn(&Rect) -> f64| bounds.windows(2).all(|w| (f(&w[0]) - f(&w[1])).abs() <= tol); - let align = |row: bool| { - let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) = - if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) }; - if same(a) { - FlexAlign::Start - } else if same(c) { - FlexAlign::Center - } else if same(e) { - FlexAlign::End - } else { - FlexAlign::Start - } - }; - let auto = |gap: f64, row: bool| FlexLayout { + let (a, c, e): (&dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64, &dyn Fn(&Rect) -> f64) = + if row { (&|r| r.y0, &|r| r.center().y, &|r| r.y1) } else { (&|r| r.x0, &|r| r.center().x, &|r| r.x1) }; + if same(a) { + FlexAlign::Start + } else if same(c) { + FlexAlign::Center + } else if same(e) { + FlexAlign::End + } else { + FlexAlign::Start + } +} + +fn auto_flex(gap: f64, row: bool, align: FlexAlign) -> FlexLayout { + FlexLayout { direction: if row { FlexDirection::Row } else { FlexDirection::Column }, justify: FlexJustify::SpaceBetween, - align: align(row), + align, width: FlexSize::Auto, height: FlexSize::Auto, gap_h: if row { gap.max(0.0) } else { 0.0 }, gap_v: if row { 0.0 } else { gap.max(0.0) }, ..Default::default() - }; - match (in_line(true), in_line(false)) { - (Some(gap), _) => auto(gap, true), - (None, Some(gap)) => auto(gap, false), + } +} + +/// InDesign's detection when objects become a container: objects in a row (or column) keep it, +/// with Auto size and Space Between at their own spacing; others wrap in a row of their width. +fn detect(bounds: &[Rect]) -> FlexLayout { + let tol = 0.5; + match (in_line_gap(bounds, true, tol), in_line_gap(bounds, false, tol)) { + (Some(gap), _) => auto_flex(gap, true, detect_align(bounds, true, tol)), + (None, Some(gap)) => auto_flex(gap, false, detect_align(bounds, false, tol)), _ => FlexLayout { wrap: true, height: FlexSize::Auto, ..Default::default() }, } } diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index bf0e011a0..098779f49 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -512,7 +512,10 @@ mod tests { let (idml, _) = linked_idml(link, false).unwrap(); let (doc, seen) = crate::io::touched(|| import(&idml, Some(&package)).unwrap()); // Beside the package, and the link itself where it is on this computer. - assert!(seen.iter().all(|p| p.starts_with(&dir) || p == std::path::Path::new(link)), "{link}: {seen:?}"); + // A rooted local path such as /dev/zero acquires the package's drive on Windows. + let local = std::path::Path::new(link); + let external = (local.has_root() && !link.starts_with(r"\\")).then(|| std::path::absolute(dir.join(local)).unwrap()); + assert!(seen.iter().all(|p| p.starts_with(&dir) || Some(p) == external.as_ref()), "{link}: {seen:?}"); let asset = doc.assets.values().next().unwrap(); assert!(asset.data.is_empty(), "{link}"); assert_eq!(super::super::links::status(asset, None), "missing", "{link}"); diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 6623b6cb8..ba3fef4f6 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -144,7 +144,7 @@ pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool let Some(link) = &a.link else { return false }; #[cfg(not(target_arch = "wasm32"))] { - crate::io::document_path(link, document).map_or(true, |path| !crate::io::exists(path)) + !crate::io::document_path(link, document).is_ok_and(crate::io::exists) } #[cfg(target_arch = "wasm32")] { @@ -396,8 +396,23 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi #[cfg(not(target_arch = "wasm32"))] fn reload(path: impl AsRef) -> Result<(Vec, (u32, u32))> { + reload_capped(path, super::interchange::MAX_LINKED_FILE_BYTES) +} + +/// A linked graphic read again from disk, and its pixel size, reading at most `cap` bytes. +/// +/// How large a linked file is belongs to whoever wrote the document, not to whoever opens it: the +/// path comes out of the document, and Update, Relink and Relink Folder all follow it. An +/// unbounded `read` of a link pointing at a multi-GB file exhausted memory (#1596). The cap is +/// the one packaging already applies to a linked file, and a file over it is refused by name +/// rather than read. +#[cfg(not(target_arch = "wasm32"))] +fn reload_capped(path: impl AsRef, cap: u64) -> Result<(Vec, (u32, u32))> { let path = path.as_ref(); - let b = crate::io::read_file(path).map_err(EngineError::Other)?; + let b = crate::io::read_capped(path, cap).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + if b.len() as u64 > cap { + return Err(bad("links", format!("{}: the linked file is larger than {} MB", path.display(), cap / (1024 * 1024)))); + } if designcraft_render::is_pdf(&b) { designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?; } @@ -480,7 +495,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { continue; } let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone())); - let mut name = old.file_name().map(|n| n.to_os_string()).unwrap_or_else(|| a.name.clone().into()); + let mut name = old.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_else(|| a.name.clone().into()); if let Some(e) = &ext { name = std::path::Path::new(&name).with_extension(e).into_os_string(); } @@ -490,8 +505,18 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { Some(dir) => Some(dir.clone()), None => a.link.as_deref().and_then(|l| crate::io::document_path(l, document).ok()).and_then(|p| p.parent().map(Path::to_path_buf)), }; - let plain_name = name.to_str().is_some_and(|n| !n.contains(['/', '\\']) && craft_docpath::is_local(n)); - let cand = folder.filter(|_| plain_name).map(|f| f.join(&name)); + let plain_name = name.to_str().filter(|n| !n.contains(['/', '\\'])); + // A name that resolves to another file name (a `file:` URL such as `file:%2Ftmp%2Fx.png`, + // a drive-relative `C:x.png`) or a folder the stored-path rules read differently (`a\b` + // on Unix, `alias/..` through a symlink) could lead outside the folder, so only the name + // itself, directly in the folder as the file system resolves it, counts. The link keeps + // the folder as given. + let cand = folder.zip(plain_name).and_then(|(f, n)| { + let c = crate::io::folder_path(n, &f).ok()?; + let real = |p: &Path| crate::io::canonicalize(p).ok(); + let same_folder = c.parent().and_then(real).is_some_and(|p| real(&f) == Some(p)); + (c.file_name().is_some_and(|c| c == n) && same_folder).then_some(c) + }); if let Some(cand) = cand.filter(|c| crate::io::exists(c)) { todo.push((a.id, cand)); } else { @@ -574,6 +599,31 @@ mod tests { designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png() } + /// A linked file's size is the document's choice, so a link is read under a cap (#1596). + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_linked_file_over_the_cap_is_refused_instead_of_read() { + let dir = std::env::temp_dir().join(format!("dc-link-cap-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("big.png"); + let bytes = png(40, 40); + std::fs::write(&path, &bytes).unwrap(); + + // Under the cap the file is read and measured as before. + let (read, px) = reload_capped(&path, bytes.len() as u64).expect("a file within the cap is read"); + assert_eq!((read.len(), px), (bytes.len(), (40, 40))); + + // Over it the file is refused by name, and the error says what the limit is. + let err = reload_capped(&path, (bytes.len() - 1) as u64).expect_err("a file over the cap is refused"); + let msg = err.to_string(); + assert!(msg.contains("big.png") && msg.contains("larger than"), "the error names the file and the limit: {msg}"); + + // The real cap is the one packaging uses, so an ordinary graphic still loads. + assert!(reload(&path).is_ok(), "a normal linked graphic is unaffected"); + let _ = std::fs::remove_dir_all(&dir); + } + fn pdf(sizes: &[(u32, u32)]) -> Vec { let mut s = Session::new(); s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap(); @@ -808,7 +858,7 @@ mod tests { assert_eq!(a.pixels, Some((60, 20))); assert_eq!(a.mime, "application/postscript"); assert_eq!(a.data.as_slice(), eps(30).as_bytes()); - let moved = dir.join("new/replacement.eps"); + let moved = dir.join("new").join("replacement.eps"); std::fs::rename(&replacement, &moved).unwrap(); assert_eq!(s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap()["relinked"], 1); assert_eq!(s.doc().unwrap().doc.assets[&aid].link.as_deref(), moved.to_str()); diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 9f8824870..74570f3fd 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -1,13 +1,37 @@ //! Type › Bulleted & Numbered Lists › Define Lists: named lists whose numbers can continue from //! story to story. Paragraphs join one with `listName` (and restart with `startAt`). -use designcraft_doc::NumberedList; +use designcraft_doc::{ListType, NumberedList}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, str_param}; +use crate::{Result, Session}; + +/// The hanging indent a list gets from the Bulleted and Numbered List buttons, as InDesign's +/// default (Left Indent 0.25 in, First Line Indent −0.25 in): the bullet or number at the margin, +/// the text after its tab at the left indent. +pub const LIST_HANG: f64 = 18.0; pub fn specs() -> Vec { vec![ + cmd!( + "list.bullets", + "Apply Bullets", + ["Type", "Bulleted & Numbered Lists"], + None, + "{on?: bool (default: off when every selected paragraph has bullets, else on)} — the Bulleted List button: paragraphs with no indents get the default hanging indent (left 18 pt, first line −18 pt); taking bullets off removes that indent if it is still as applied", + has_text_or_frames, + |s, p| apply_list(s, p, ListType::Bullets) + ), + cmd!( + "list.numbers", + "Apply Numbers", + ["Type", "Bulleted & Numbered Lists"], + None, + "{on?: bool (default: off when every selected paragraph is numbered, else on)} — the Numbered List button, as list.bullets", + has_text_or_frames, + |s, p| apply_list(s, p, ListType::Numbers) + ), cmd!( "list.define", "Define Lists…", @@ -48,12 +72,181 @@ pub fn specs() -> Vec { ] } +/// Bullets or numbers on (or off) for the selected paragraphs, as InDesign's Bulleted and +/// Numbered List buttons: one undo step. Values equal to the paragraph style's are left to the +/// style rather than set locally, so a paragraph back to its style shows no override. +fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result { + let name = if kind == ListType::Bullets { "list.bullets" } else { "list.numbers" }; + let on = match p.get("on") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(name, "`on` must be true or false"))?), + }; + let targets = super::text::format_targets_pub(s); + if targets.is_empty() { + return Err(bad(name, "select text or a text frame")); + } + // Off when every selected paragraph already has this list, as the button shows it pressed. + let on = on.unwrap_or_else(|| !s.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind))); + s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let span = para_span(&st.text, &t.range); + st.format_paras(span, |pf| { + let (now, _) = styles.resolve_para(pf); + // Off takes away this list only: a paragraph with the other list keeps it. + if !on && now.list_type != kind { + return; + } + let (style, _) = styles.resolve_para_style(&pf.style); + // The hanging indent this button set (and nothing has changed since). + let own_hang = pf.para.list_hang == Some(true) && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG; + // A value: left to the style when it gives the same, else set here. + let mut set = |key: &str, v: Value, same: bool| { + let _ = pf.para.set_json(key, if same { &Value::Null } else { &v }); + }; + let list = if on { kind } else { ListType::None }; + set("listType", serde_json::to_value(list).unwrap_or(Value::Null), style.list_type == list); + if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 { + set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG); + set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG); + set("listHang", json!(true), false); + } else if !on && now.list_type == kind { + // Only the hanging indent set here: one the paragraph had before, or its style + // gives, stays. + if own_hang { + set("leftIndent", json!(0.0), style.left_indent == 0.0); + set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0); + } + set("listHang", Value::Null, true); + } + }); + } + Ok(json!({"on": on})) + }) +} + +/// The range whose paragraphs a list applies to: a selection ending where a paragraph starts (after +/// a return) doesn't reach into that paragraph. The story's last paragraph, empty after a final +/// return, stays in when the range runs to the end (a whole frame). +fn para_span(text: &str, range: &std::ops::Range) -> std::ops::Range { + let end = range.end.max(range.start); + if end > range.start && end < text.len() && text.as_bytes().get(end - 1) == Some(&b'\n') { range.start..end - 1 } else { range.start..end } +} + +/// Whether every paragraph of `targets` has list `kind`. +fn all_have(d: &designcraft_doc::Document, targets: &[super::text::Target], kind: ListType) -> bool { + targets.iter().all(|t| { + d.text_story(t.story, t.cell).is_some_and(|st| { + let span = para_span(&st.text, &t.range); + let (a, b) = (st.para_at(span.start), st.para_at(span.end)); + st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind)) + }) + }) +} + +impl Session { + /// Whether the Bulleted or Numbered List button (and Type › Apply Bullets or Numbers) shows + /// pressed: every paragraph the list commands apply to has list `kind`, through the selected + /// text, table cells or text frames. + pub fn selection_has_list(&self, kind: ListType) -> bool { + let targets = super::text::format_targets_pub(self); + !targets.is_empty() && self.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind)) + } +} + #[cfg(test)] mod tests { use serde_json::json; use crate::Session; + /// The Bulleted List button, as InDesign's: bullets with the default hanging indent (the + /// bullet at the margin, the text 18 pt in); again, bullets and that indent go, back to the + /// style. Numbers swap in keeping the indent; paragraphs with their own indents keep them. + #[test] + fn list_buttons_apply_the_default_hanging_indent() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 9})).unwrap(); + let para = |s: &Session, i: usize| { + let d = &s.doc().unwrap().doc; + let st = d.story(designcraft_doc::StoryId(sid)).unwrap(); + (d.styles.resolve_para(&st.paras[i]).0, st.paras[i].para.clone()) + }; + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true); + for i in 0..2 { + let (p, _) = para(&s, i); + assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::Bullets, 18.0, -18.0)); + } + assert_eq!(para(&s, 2).0.list_type, designcraft_doc::ListType::None, "the paragraph outside the selection"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The number of a bullet's tab: the text starts at the left indent. + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let line = &cs.frames[0].lines[0]; + let text_x = line.glyphs.iter().filter(|g| g.len > 0).map(|g| g.x).next().unwrap(); + assert!((text_x - line.x0 - 18.0).abs() < 0.5, "the text at the hanging indent: {} from {}", text_x, line.x0); + // Numbers instead: the indent stays. + s.execute("list.numbers", &json!({})).unwrap(); + let (p, _) = para(&s, 0); + assert_eq!((p.list_type, p.left_indent), (designcraft_doc::ListType::Numbers, 18.0)); + // Numbers off: list and hanging indent go, and nothing is left set on the paragraph. + assert_eq!(s.execute("list.numbers", &json!({})).unwrap()["on"], false); + let (p, own) = para(&s, 0); + assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 0.0, 0.0)); + assert_eq!(own, designcraft_doc::ParaAttrs::default(), "back to the style"); + // A paragraph with its own indents keeps them. + s.execute("type.para", &json!({"attrs": {"leftIndent": 36}})).unwrap(); + s.execute("list.bullets", &json!({"on": true})).unwrap(); + let (p, _) = para(&s, 0); + assert_eq!((p.left_indent, p.first_line_indent), (36.0, 0.0)); + s.execute("list.bullets", &json!({"on": false})).unwrap(); + assert_eq!(para(&s, 0).0.left_indent, 36.0); + assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err()); + } + + /// Pressed means every targeted paragraph has the list: a selection reaching an unbulleted + /// paragraph, or a frame with one, isn't. Explicitly off, a list button takes away its own + /// list only: a bulleted paragraph keeps bullets and indent when Numbers go off. + #[test] + fn a_list_is_pressed_and_turned_off_for_its_own_paragraphs() { + use designcraft_doc::ListType; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs\nMilk\nFlour"})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + let select = |s: &mut Session, a: usize, b: usize| { + s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); + }; + select(&mut s, 0, 3); + s.execute("list.bullets", &json!({})).unwrap(); + assert!(s.selection_has_list(ListType::Bullets)); + assert!(!s.selection_has_list(ListType::Numbers)); + // From the bulleted Eggs into the plain Milk: not pressed, and a press bullets both. + select(&mut s, 0, 7); + assert!(!s.selection_has_list(ListType::Bullets)); + // The frame selected: its whole story, Flour unbulleted. + s.execute("selection.set", &json!({"ids": [f["id"]]})).unwrap(); + assert!(!s.selection_has_list(ListType::Bullets)); + // Eggs bulleted, Milk numbered; Numbers off over both leaves Eggs' bullets and indent. + select(&mut s, 5, 7); + s.execute("list.numbers", &json!({"on": true})).unwrap(); + select(&mut s, 0, 7); + s.execute("list.numbers", &json!({"on": false})).unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(designcraft_doc::StoryId(sid)).unwrap(); + let p = |i: usize| d.styles.resolve_para(&st.paras[i]).0; + assert_eq!((p(0).list_type, p(0).left_indent, p(0).first_line_indent), (ListType::Bullets, 18.0, -18.0)); + assert_eq!((p(1).list_type, p(1).left_indent), (ListType::None, 0.0)); + // With no text or frame selected, nothing is pressed. + s.execute("selection.set", &json!({"ids": []})).unwrap(); + assert!(!s.selection_has_list(ListType::Bullets)); + } + /// The digit the story's first line starts with (its list number). fn digit(g: &designcraft_compose::PlacedGlyph) -> char { let face = g.face.get(); @@ -99,4 +292,74 @@ mod tests { s.execute("type.para", &json!({"attrs": {"startAt": null}})).unwrap(); assert_eq!(first_char(&s, stories[1]), '4'); } + + /// A selection ending right after a return (the whole first paragraph, as a triple click selects + /// it) applies the list to that paragraph only, and the button shows it pressed. + #[test] + fn a_selection_ending_at_a_paragraph_start_leaves_that_paragraph_out() { + use designcraft_doc::ListType; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s + .execute( + "frame.create", + &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs +Milk +Flour +"}), + ) + .unwrap(); + let sid = f["story"].as_u64().unwrap(); + let kinds = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.story(designcraft_doc::StoryId(sid)).unwrap().paras.iter().map(|p| d.styles.resolve_para(p).0.list_type).collect::>() + }; + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + assert!(!s.selection_has_list(ListType::Bullets)); + assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true); + assert_eq!(kinds(&s), [ListType::Bullets, ListType::None, ListType::None, ListType::None]); + assert!(s.selection_has_list(ListType::Bullets), "pressed for the paragraph selected"); + // The whole frame still takes in its last, empty paragraph. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 16})).unwrap(); + s.execute("list.numbers", &json!({"on": true})).unwrap(); + assert_eq!(kinds(&s), [ListType::Numbers; 4]); + } + + /// A paragraph's own hanging indent, the same as the button's, stays when the list goes off: + /// the button takes away only the indent it set. + #[test] + fn a_paragraphs_own_hanging_indent_stays_when_the_list_goes_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.para", &json!({"attrs": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap(); + s.execute("list.bullets", &json!({"on": true})).unwrap(); + s.execute("list.bullets", &json!({"on": false})).unwrap(); + let d = &s.doc().unwrap().doc; + let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0]; + let p = d.styles.resolve_para(pf).0; + assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0)); + assert_eq!(pf.para.list_hang, None, "nothing of the list left"); + } + + /// A hanging indent the paragraph style gives stays when the list goes off. + #[test] + fn a_style_hanging_indent_stays_when_the_list_goes_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Hang", "para": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Hang"})).unwrap(); + s.execute("list.bullets", &json!({"on": true})).unwrap(); + s.execute("list.bullets", &json!({"on": false})).unwrap(); + let d = &s.doc().unwrap().doc; + let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0]; + let p = d.styles.resolve_para(pf).0; + assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0)); + assert_eq!(pf.para, designcraft_doc::ParaAttrs::default(), "nothing left set on the paragraph"); + } } diff --git a/crates/engine/src/cmd/load_styles.rs b/crates/engine/src/cmd/load_styles.rs index 19a5a6794..b89b42fa5 100644 --- a/crates/engine/src/cmd/load_styles.rs +++ b/crates/engine/src/cmd/load_styles.rs @@ -201,9 +201,9 @@ fn plan(doc: &Document, src: &Document, paragraph: Option<&[String]>, character: Ok((para, chars)) } -fn load(s: &mut Session, p: &Value) -> Result { - let src = source(p, ID)?; - let conflict = match p.get("conflicts") { +/// The `conflicts` parameter: how a style that differs under the same name is handled. +fn conflict_param(p: &Value) -> Result { + Ok(match p.get("conflicts") { None | Some(Value::Null) => Conflict::Redefine, Some(v) => match v.as_str() { Some("redefine") => Conflict::Redefine, @@ -211,7 +211,37 @@ fn load(s: &mut Session, p: &Value) -> Result { Some("autoRename") => Conflict::AutoRename, _ => return Err(bad(ID, "`conflicts` must be redefine, useExisting or autoRename")), }, + }) +} + +/// Give the loaded style `ours` the export tag the source had for `theirs` (`kind` is `p` or `c`). +fn carry_export_tag(src: &Styles, dst: &mut Styles, kind: char, theirs: &str, ours: &str) { + let tag = format!("{kind}:{ours}"); + match src.export_tags.get(&format!("{kind}:{theirs}")) { + Some(t) => dst.export_tags.insert(tag, t.clone()), + None => dst.export_tags.remove(&tag), }; +} + +/// A loaded style and one the document keeps can name each other as their base. +fn check_based_on_cycles(styles: &Styles, loaded: &[(bool, String)]) -> Result<()> { + for (is_para, name) in loaded { + let looped = if *is_para { + styles.para(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.para_based_on_cycles(name, b)) + } else { + styles.char_style(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.char_based_on_cycles(name, b)) + }; + if looped { + let kind = if *is_para { "paragraph" } else { "character" }; + return Err(bad(ID, format!("{kind} style `{name}` would be based on itself; load the style it is based on as well"))); + } + } + Ok(()) +} + +fn load(s: &mut Session, p: &Value) -> Result { + let src = source(p, ID)?; + let conflict = conflict_param(p)?; let (paragraph, character) = (names(p, "paragraph")?, names(p, "character")?); let doc = s.doc()?.doc.clone(); let (mut para, mut chars) = plan(&doc, &src, paragraph.as_deref(), character.as_deref(), conflict)?; @@ -231,11 +261,7 @@ fn load(s: &mut Session, p: &Value) -> Result { p.nested_line_styles.iter_mut().flatten().for_each(|l| chars.follow(&mut l.style)); swatches.extend(char_swatches(&style.chars).chain(para_swatches(&style.para)).cloned()); lists.extend(style.para.list_name.iter().cloned()); - let tag = format!("p:{}", style.name); - match src.styles.export_tags.get(&format!("p:{}", theirs.name)) { - Some(t) => styles.export_tags.insert(tag, t.clone()), - None => styles.export_tags.remove(&tag), - }; + carry_export_tag(&src.styles, &mut styles, 'p', &theirs.name, &style.name); loaded.push((true, style.name.clone())); match styles.para_mut(&style.name) { Some(m) if action == Action::Replace => { @@ -256,11 +282,7 @@ fn load(s: &mut Session, p: &Value) -> Result { chars.follow(&mut style.name); style.based_on.iter_mut().for_each(|n| chars.follow(n)); swatches.extend(char_swatches(&style.chars).cloned()); - let tag = format!("c:{}", style.name); - match src.styles.export_tags.get(&format!("c:{}", theirs.name)) { - Some(t) => styles.export_tags.insert(tag, t.clone()), - None => styles.export_tags.remove(&tag), - }; + carry_export_tag(&src.styles, &mut styles, 'c', &theirs.name, &style.name); loaded.push((false, style.name.clone())); match styles.char_style_mut(&style.name) { Some(m) if action == Action::Replace => { @@ -275,18 +297,7 @@ fn load(s: &mut Session, p: &Value) -> Result { } } } - // A loaded style and one the document keeps can name each other as their base. - for (is_para, name) in &loaded { - let looped = if *is_para { - styles.para(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.para_based_on_cycles(name, b)) - } else { - styles.char_style(name).and_then(|s| s.based_on.as_deref()).is_some_and(|b| styles.char_based_on_cycles(name, b)) - }; - if looped { - let kind = if *is_para { "paragraph" } else { "character" }; - return Err(bad(ID, format!("{kind} style `{name}` would be based on itself; load the style it is based on as well"))); - } - } + check_based_on_cycles(&styles, &loaded)?; let swatches = missing_swatches(&doc, &src, swatches); let lists: Vec = { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1d6294ea6..afc09c8e9 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -6,7 +6,7 @@ mod articles; pub mod book; mod buttons; pub(crate) mod captions; -mod changes; +pub(crate) mod changes; mod color_theme; mod colorsettings; mod conditions; diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index 9318458de..03dde5c40 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -221,4 +221,15 @@ mod tests { // Caret is now in footnote text: no nested footnotes. assert!(s.execute("footnote.insert", &json!({})).is_err()); } + + #[test] + fn huge_start_at_does_not_overflow() { + let (mut s, sid) = session_with_text(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap(); + s.execute("footnote.insert", &json!({"text": "x"})).unwrap(); + s.execute("footnote.options", &json!({"startAt": 4294967295u32})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, None); + assert_eq!(cs.frames[0].notes.len(), 1); + } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 87f601604..5c0e7e814 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -584,7 +584,7 @@ pub fn specs() -> Vec { "Effects", [], None, - "{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, ids?} — every effect at once, one undo step", + "{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, globalLight? (the document's global light angle, as an Angle field with Use Global Light sets it), ids?} — every effect at once, one undo step", has_selection, effects_all ), @@ -598,7 +598,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -2293,8 +2293,16 @@ fn effects_all(s: &mut Session, p: &Value) -> Result { Some(BlendMode::parse(raw).ok_or_else(|| bad(ID, format!("unknown blend mode `{raw}`")))?) } }; + // The document's global light angle, changed from an Angle field with Use Global Light on. + let global_light = match p.get("globalLight") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|a| a.is_finite()).ok_or_else(|| bad(ID, "globalLight must be a number"))?), + }; let ids = targets(s, p)?; s.edit(|d, _| { + if let Some(a) = global_light { + d.settings.global_light = (a + 180.0).rem_euclid(360.0) - 180.0; + } let mut changed = 0; for id in &ids { let Some(it) = d.item_mut(*id) else { continue }; @@ -2506,6 +2514,41 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { as u8, ), }; + // `Some(None)` turns the frame's custom baseline grid off. + let custom_grid = match p.get("baselineGrid") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::Object(g)) => { + let num = |key: &str, lo: f64, hi: f64| match g.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (lo..=hi).contains(&x) => Ok(Some(x)), + _ => Err(bad(ID, format!("baselineGrid.{key} must be a number of points from {lo} to {hi}, not {v}"))), + }, + }; + let start = num("start", -15552.0, 15552.0)?; + let increment = num("increment", 0.0, 15552.0)?; + let relative_to = match g.get("relativeTo") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_str() + .and_then(designcraft_doc::FrameGridRelative::from_name) + .ok_or_else(|| bad(ID, format!("baselineGrid.relativeTo must be page|margin|frame|inset, not {v}")))?, + ), + }; + Some(Some((start, increment, relative_to))) + } + Some(v) => return Err(bad(ID, format!("baselineGrid must be an object or null, not {v}"))), + }; + // A frame without a custom grid starts from the document's. + let doc_grid = { + let g = &st.doc.settings.baseline_grid; + let relative_to = match g.relative_to { + designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage, + designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin, + }; + designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to } + }; let rule_color = match p.get("columnRuleColor") { None | Some(Value::Null) => None, Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()), @@ -2596,6 +2639,17 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = rule_bottom { o.column_rule_bottom_inset = v; } + match custom_grid { + Some(Some((start, increment, relative_to))) => { + let mut g = o.baseline_grid.unwrap_or(doc_grid); + g.start = start.unwrap_or(g.start); + g.increment = increment.unwrap_or(g.increment); + g.relative_to = relative_to.unwrap_or(g.relative_to); + o.baseline_grid = Some(g); + } + Some(None) => o.baseline_grid = None, + None => {} + } // Fixed Width columns set the frame's width: n columns, the gutters between them and // the left and right insets. let fixed_width = o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth && o.column_width > 0.0; @@ -4099,6 +4153,26 @@ mod light_tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(angle(&s), 45.0); } + + /// The Effects dialog's Angle with Use Global Light on changes the global light, with the + /// effects, in one undo step. + #[test] + fn effects_sets_the_global_light_with_the_effects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.dropShadow", &json!({"on": true, "globalLight": true, "ids": [b]})).unwrap(); + let before = s.doc().unwrap().doc.settings.global_light; + s.execute("object.effects", &json!({"ids": [a], "effects": {"dropShadow": {"on": true, "globalLight": true}}, "globalLight": 30})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.global_light, 30.0); + let other = &d.item(ItemId(b)).unwrap().effects.drop_shadow; + assert_eq!(d.light_angle(other.angle, other.global_light), 30.0, "every shadow on Global Light follows"); + assert!(s.execute("object.effects", &json!({"ids": [a], "globalLight": "up"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.global_light, before, "one undo step"); + } } #[cfg(test)] @@ -5609,3 +5683,33 @@ mod fill_tint_tests { assert!(stroke_of(&s, b).is_none()); } } + +#[cfg(test)] +mod frame_baseline_grid_tests { + use super::*; + use designcraft_doc::{FrameBaselineGrid, FrameGridRelative}; + + #[test] + fn text_frame_options_set_a_custom_baseline_grid_relative_to() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let grid = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).and_then(|t| t.options.baseline_grid); + // Turning it on with only Relative To takes the document grid's start and increment. + let doc = s.doc().unwrap().doc.settings.baseline_grid.clone(); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "frame"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: doc.start, increment: doc.increment, relative_to: FrameGridRelative::TopOfFrame })); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"start": 10, "increment": 13, "relativeTo": "inset"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopInset })); + // Fields not given keep the frame's. + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "margin"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopMargin })); + for bad in [json!({"relativeTo": "spine"}), json!({"start": f64::MAX}), json!({"increment": -1}), json!(3)] { + assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": bad})).is_err(), "{bad}"); + } + assert_eq!(grid(&s).map(|g| g.relative_to), Some(FrameGridRelative::TopMargin)); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": null})).unwrap(); + assert_eq!(grid(&s), None); + } +} diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index d0ed58b03..ffc69b39e 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -907,13 +907,12 @@ mod tests { let outside = base.join("separate"); std::fs::create_dir_all(&outside).unwrap(); let redirected = base.join("Package"); - match symlink_dir(&outside, &redirected) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { - let _ = std::fs::remove_dir_all(&base); - return; - } - Err(e) => panic!("could not create directory redirect: {e}"), + // A symbolic link needs Developer Mode or elevation (os error 1314 without them); a + // directory junction needs neither, and `is_redirect` treats both as redirects. + if symlink_dir(&outside, &redirected).is_err() && !make_junction(&outside, &redirected) { + eprintln!("skipped: directory redirects can't be made here"); + let _ = std::fs::remove_dir_all(&base); + return; } let mut s = Session::new(); s.execute("file.new", &json!({"title": "Annual"})).unwrap(); @@ -922,4 +921,17 @@ mod tests { std::fs::remove_dir(&redirected).unwrap(); let _ = std::fs::remove_dir_all(&base); } + + /// Makes `link` a directory junction to `target` with `mklink /J`, and reports whether it was made. + #[cfg(windows)] + fn make_junction(target: &Path, link: &Path) -> bool { + use std::os::windows::process::CommandExt; + + // Windows paths can't hold `"`, so quoting each one keeps `&` and spaces in it literal for cmd. + std::process::Command::new("cmd") + .args(["/D", "/C"]) + .raw_arg(format!("mklink /J \"{}\" \"{}\"", link.display(), target.display())) + .output() + .is_ok_and(|output| output.status.success()) + } } diff --git a/crates/engine/src/cmd/paste_svg.rs b/crates/engine/src/cmd/paste_svg.rs index 54513be53..92e37b6f1 100644 --- a/crates/engine/src/cmd/paste_svg.rs +++ b/crates/engine/src/cmd/paste_svg.rs @@ -27,7 +27,7 @@ pub fn is_svg_markup(text: &str) -> bool { designcraft_images::is_svg(text.as_bytes()) } -/// Nesting deeper than this is flattened into its parent (hostile input can't exhaust the stack). +/// Cap conversion nesting too; the shared image parser bounds XML before usvg runs. const MAX_DEPTH: usize = 64; /// A converted SVG node, in points. @@ -426,13 +426,16 @@ mod tests { #[test] fn deeply_nested_groups_do_not_exhaust_the_stack() { let mut s = session(); + let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); let depth = 2000; let svg = format!( r#"{}{}"#, "".repeat(depth), "".repeat(depth) ); - // usvg itself may refuse a document this deep; either way, no crash. - let _ = s.execute("edit.pasteSvg", &json!({"svg": svg})); + assert!(s.execute("edit.pasteSvg", &json!({"svg": svg})).is_err()); + assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); } } diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index c8badf533..937fe81c0 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -42,6 +42,22 @@ pub fn specs() -> Vec { if ids.iter().all(|id| placed_pdf(s, *id).is_none()) { return Err(bad("object.layerOptions", "select a placed PDF")); } + // Validate every selected PDF before editing any item or recording undo. + // Repeated placements of the same source need only one rewrite check. + if !hidden.is_empty() { + let mut checked = std::collections::HashSet::new(); + for id in &ids { + if let Some(data) = placed_pdf(s, *id) + && checked.insert(std::sync::Arc::as_ptr(&data)) + && designcraft_render::pdf_hide_layers(&data, &hidden).is_none() + { + return Err(bad( + "object.layerOptions", + "the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF", + )); + } + } + } s.edit(|d, _| { for id in &ids { if let Some(it) = d.item_mut(*id) { @@ -63,31 +79,277 @@ pub(crate) mod tests { /// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half). pub(crate) fn two_layer_pdf() -> Vec { + two_layer_pdf_generation(0) + } + + fn two_layer_pdf_generation(generation: i32) -> Vec { + two_layer_pdf_options(generation, "", "") + } + + fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec { let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; let objs = [ - "<>>>>>".to_string(), + format!( + "<>>>>>" + ), "<>".to_string(), "<>>>/Contents 4 0 R>>".to_string(), format!("<>\nstream\n{content}\nendstream", content.len()), "<>".to_string(), "<>".to_string(), + "<>\nstream\n\nendstream".to_string(), + "<>".to_string(), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); for (i, o) in objs.iter().enumerate() { offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes()); } let x = out.len(); let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); for o in offs { - t.push_str(&format!("{o:010} 00000 n\r\n")); + t.push_str(&format!("{o:010} {generation:05} n\r\n")); } - t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); + t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); out.extend_from_slice(t.as_bytes()); out } + #[test] + fn pdf_layer_update_preserves_catalog_configuration_and_generations() { + for generation in [0, 1] { + let original = two_layer_pdf_generation(generation); + let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + let appended = std::str::from_utf8(&updated[original.len()..]).unwrap(); + for token in [ + "/Lang(en-GB)".to_string(), + format!("/Metadata 7 {generation} R"), + format!("/OutputIntents[8 {generation} R]"), + format!("/Locked[5 {generation} R]"), + "/Name(Default)".into(), + format!("/Root 1 {generation} R"), + ] { + assert!(appended.contains(&token), "missing {token}: {appended}"); + } + assert!(!appended.contains("9999000")); + let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap(); + assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible)); + } + } + + #[test] + fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() { + let original = two_layer_pdf(); + let text = std::str::from_utf8(&original).unwrap(); + let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); + let mut stream_pdf = original[..offset].to_vec(); + let mut entries = vec![0, 0, 0, 0, 0, 255, 255]; + for number in 1..=9 { + let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() }; + entries.push(1); + entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes()); + entries.extend_from_slice(&[0, 0]); + } + stream_pdf.extend_from_slice(format!("9 0 obj\n<>\nstream\n", entries.len()).as_bytes()); + stream_pdf.extend_from_slice(&entries); + stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); + let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + for unsupported in ["/Encrypt 7 0 R", "/Encrypt << /Filter /Standard >>", "/XRefStm 0"] { + let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9")); + assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none()); + } + } + + #[test] + fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() { + let original = two_layer_pdf(); + let text = std::str::from_utf8(&original).unwrap(); + let first = original.len(); + let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<>\n"); + let main = first + link(0).len(); + let mut forward = original.clone(); + forward.extend_from_slice(link(main).as_bytes()); + let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); + forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes()); + forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); + let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + let mut cyclic = original; + cyclic.extend_from_slice(link(first).as_bytes()); + cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); + assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none()); + } + + fn add_pdf_revision(bytes: &mut Vec) { + let text = std::str::from_utf8(bytes).unwrap(); + let prev: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); + let xref = bytes.len(); + bytes.extend_from_slice( + format!("xref\n0 1\n0000000000 65535 f\r\ntrailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes(), + ); + } + + fn place(s: &mut Session, bytes: &[u8], x: f64) -> serde_json::Value { + s.execute("file.place", &json!({"base64": super::super::file::base64_encode(bytes), "name": "layers.pdf", "x": x, "y": 0, "width": 100})) + .unwrap()["id"] + .clone() + } + + fn pixel(s: &Session, x: u32) -> [u8; 4] { + let mut renderer = designcraft_render::Renderer::new(); + renderer.threads = 0; + renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 50) + } + + #[test] + fn null_encryption_entry_keeps_layer_editing_rendering_and_export_working() { + for generation in [0, 1] { + let bytes = two_layer_pdf_options(generation, "", "/Encrypt null"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + let id = place(&mut s, &bytes, 0.0); + s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); + assert_eq!(pixel(&s, 25), [0, 0, 255, 255]); + assert_eq!(pixel(&s, 75), [255, 255, 255, 255]); + s.execute("file.exportPdf", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pixel(&s, 75), [255, 0, 0, 255]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(pixel(&s, 75), [255, 255, 255, 255]); + } + } + + #[test] + fn unrewritable_layer_choices_are_rejected_atomically_without_an_undo_step() { + let mut unsupported = two_layer_pdf(); + for _ in 0..32 { + add_pdf_revision(&mut unsupported); + } + assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2); + assert!(designcraft_render::pdf_hide_layers(&unsupported, &["Red".into()]).is_none()); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap(); + let good = place(&mut s, &two_layer_pdf(), 0.0); + let bad = place(&mut s, &unsupported, 100.0); + s.execute("object.layerOptions", &json!({"ids": [good], "hidden": ["Blue"]})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.doc().unwrap().history.redo.is_empty()); + let before = s.execute("file.serialize", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let redo = s.doc().unwrap().history.redo.len(); + for ids in [json!([good, bad]), json!([bad, good])] { + let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err(); + assert!(err.to_string().contains("could not be hidden")); + assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(s.doc().unwrap().history.redo.len(), redo); + for id in [&good, &bad] { + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], true); + } + } + assert_eq!(pixel(&s, 75), [255, 0, 0, 255]); + assert_eq!(pixel(&s, 175), [255, 0, 0, 255]); + // A persisted/relinked choice can be unrewritable. Reset must still work. + let bad_id = serde_json::from_value(bad.clone()).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_hidden_layers = vec!["Red".into()]; + assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "stale hidden content must not reappear on canvas"); + assert!(s.execute("file.exportPdf", &json!({})).is_err()); + s.execute("object.layerOptions", &json!({"ids": [good, bad], "hidden": []})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": bad})).unwrap()[1]["visible"], true); + s.execute("file.exportPdf", &json!({})).unwrap(); + } + + #[test] + fn unreadable_non_null_prev_entries_refuse_layer_rewrites() { + for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] { + let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}")); + assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}"); + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}"); + } + for trailer in ["", "/Prev null"] { + let bytes = two_layer_pdf_options(0, "", trailer); + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}"); + } + let mut revised = two_layer_pdf(); + let prev = + std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::().unwrap(); + add_pdf_revision(&mut revised); + let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>")); + assert!(real_prev.contains(&format!("/Prev {prev}.0>>"))); + for bytes in [revised, real_prev.into_bytes()] { + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work"); + } + } + + #[test] + fn fake_null_trailer_cannot_bypass_hybrid_rejection() { + for real_guard in ["", "/XRefStm 0"] { + let bytes = two_layer_pdf_options(0, "", real_guard); + let text = std::str::from_utf8(&bytes).unwrap(); + for fake in ["/Encrypt null", ""] { + let comment = format!("% trailer <>\n"); + let inside = text.replacen("xref\n", &format!("xref\n{comment}"), 1); + assert!(inside.contains(&format!("xref\n{comment}"))); + // Repair-only comments inside a table are always refused, guard or not. + assert_eq!(designcraft_render::pdf_layers(inside.as_bytes()).len(), 2); + assert!(designcraft_render::pdf_hide_layers(inside.as_bytes(), &["Red".into()]).is_none()); + // Before xref or after the dictionary, the real trailer decides. + let leading = text.replacen("xref\n", &format!("{comment}xref\n"), 1); + let trailing = text.replacen("startxref\n", &format!("{comment}startxref\n"), 1); + for source in [leading, trailing] { + assert_eq!(designcraft_render::pdf_layers(source.as_bytes()).len(), 2); + assert_eq!(designcraft_render::pdf_hide_layers(source.as_bytes(), &["Red".into()]).is_some(), real_guard.is_empty()); + } + } + } + } + + #[test] + fn content_all_still_measures_layerless_and_stub_properties() { + use designcraft_doc::PdfCrop; + for props in ["", "/OCProperties null", "/OCProperties<<>>", "/OCProperties<>>>"] { + let content = "0 0 1 rg 0 0 50 100 re f"; + let objs = [ + format!("<>"), + "<>".into(), + "<>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); + for at in offsets { + bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + assert!(designcraft_render::pdf_layers(&bytes).is_empty()); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (50.0, 100.0), "{props}"); + } + } + + #[test] + fn content_all_keeps_the_whole_page_when_layer_rewriting_is_unsupported() { + use designcraft_doc::PdfCrop; + let mut bytes = two_layer_pdf_options(0, "/BaseState/OFF/ON[5 0 R]", ""); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0)); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (100.0, 100.0)); + for _ in 0..32 { + add_pdf_revision(&mut bytes); + } + assert!(designcraft_render::pdf_hide_layers(&bytes, &[]).is_none()); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0)); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap(), ([0.0, 0.0, 1.0, 1.0], (100.0, 100.0))); + } + #[test] fn placed_pdf_layers_hide() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 19d64c1fc..1846f3e78 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -348,17 +348,19 @@ mod placed_vector_tests { /// file is still missing. #[test] fn a_missing_link_shown_by_its_preview_is_still_reported() { + let dir = tempfile::tempdir().unwrap(); + let missing_path = dir.path().join("missing dir").join("photo.jpg"); let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png(); s.execute("file.place", &json!({"base64": base64_encode(&png), "name": "photo.jpg", "x": 72, "y": 72, "width": 20})).unwrap(); let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let asset = doc.assets.values_mut().next().unwrap(); - std::sync::Arc::make_mut(asset).link = Some("/definitely/missing dir/photo.jpg".into()); + std::sync::Arc::make_mut(asset).link = Some(missing_path.to_str().unwrap().into()); let r = s.execute("preflight.run", &json!({})).unwrap(); let missing: Vec<&str> = r["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "missingLink").map(|i| i["message"].as_str().unwrap()).collect(); - assert_eq!(missing, ["Missing link: /definitely/missing dir/photo.jpg"]); + assert_eq!(missing, [format!("Missing link: {}", missing_path.display())]); } } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 0bb280780..8283dcf0a 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -84,7 +84,10 @@ mod tests { assert!(command.contains(&"print-scaling=auto-fit")); assert!(!command.iter().any(|a| a.starts_with("sides="))); } else { - assert_eq!(command[0], "powershell"); + assert_eq!( + command, + ["xps-print", "printer=Office", "copies=2", "collate=true", "duplex=(queue default)", "scaling=shrink-to-fit", "color=color"] + ); } assert!(r["bytes"].as_u64().unwrap() > 500); assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err()); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..ab24a05d8 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -302,7 +302,7 @@ pub fn specs() -> Vec { "New Paragraph Style…", [], None, - "{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}", + "{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, ignoreOpticalMargin, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}", has_doc, create_para ), @@ -311,7 +311,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", + "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", has_doc, edit_para ), diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 063be89ac..e62fd1344 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1273,6 +1273,35 @@ fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option Result<()> { + const ID: &str = "table.resizeBorder"; + if let Some(fid) = super::id_param(p, "frame") { + let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?; + if it.text_frame().map(|f| f.story) != Some(story) { + return Err(bad(ID, "the table is not in that frame's story")); + } + if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) { + return Err(bad(ID, "the frame or its layer is locked")); + } + } + Ok(()) +} + +/// The change in track size: `delta`, or `size` measured from the current size `cur`. +fn border_delta(p: &Value, cur: f64) -> Result { + const ID: &str = "table.resizeBorder"; + let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) { + (Some(d), _) => d, + (None, Some(sz)) => sz - cur, + (None, None) => return Err(bad(ID, "`delta` or `size` required")), + }; + if !delta.is_finite() { + return Err(bad(ID, "`delta`/`size` must be a finite number")); + } + Ok(delta) +} + fn resize_border(s: &mut Session, p: &Value) -> Result { const ID: &str = "table.resizeBorder"; let column = match str_param(p, "axis") { @@ -1284,15 +1313,7 @@ fn resize_border(s: &mut Session, p: &Value) -> Result { let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false); let g = target(s, p, ID)?; let st = s.doc()?; - if let Some(fid) = super::id_param(p, "frame") { - let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?; - if it.text_frame().map(|f| f.story) != Some(g.story) { - return Err(bad(ID, "the table is not in that frame's story")); - } - if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) { - return Err(bad(ID, "the frame or its layer is locked")); - } - } + check_border_frame(st, p, g.story)?; let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; let sizes: Vec = if column { t.columns.iter().map(|c| c.width).collect() @@ -1301,14 +1322,7 @@ fn resize_border(s: &mut Session, p: &Value) -> Result { (0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect() }; let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?; - let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) { - (Some(d), _) => d, - (None, Some(sz)) => sz - cur, - (None, None) => return Err(bad(ID, "`delta` or `size` required")), - }; - if !delta.is_finite() { - return Err(bad(ID, "`delta`/`size` must be a finite number")); - } + let delta = border_delta(p, cur)?; let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?; let size = new.get(index).copied().unwrap_or(cur); let scaled = keep && index + 1 == sizes.len(); @@ -1751,7 +1765,7 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: O ts.borders = Default::default(); } if let Some(a) = p.get("altRows").and_then(Value::as_object) { - let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32); + let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| u32::try_from(v).unwrap_or(u32::MAX)); let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string(); let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32); ts.alt_rows = Some(designcraft_doc::AltFills { @@ -2466,3 +2480,22 @@ mod resize_border_tests { assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err()); } } + +#[cfg(test)] +mod style_alt_rows_tests { + use serde_json::json; + + use super::table_style_fields; + + #[test] + fn style_alt_rows_counts_saturate() { + let mut ts = designcraft_doc::TableStyle::default(); + let p = json!({"altRows": {"first": 4294967297u64, "next": 4294967296u64, "skipFirst": 4294967295u64, "skipLast": 3}}); + table_style_fields(&mut ts, &p, None); + let a = ts.alt_rows.unwrap(); + assert_eq!(a.first, u32::MAX, "first"); + assert_eq!(a.next, u32::MAX, "next"); + assert_eq!(a.skip_first, u32::MAX, "skip_first"); + assert_eq!(a.skip_last, 3, "skip_last"); + } +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index dbfbcfe15..406557ffb 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -72,6 +72,15 @@ pub fn specs() -> Vec { ok() }), cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert), + cmd!( + "text.deleteSurrounding", + "Delete Surrounding Text", + [], + None, + "{before?: int, after?: int, bytes?: bool, commit?: string} — removes the text around the selection (an input method's delete-surrounding), keeps the selection and its direction, and with `commit` types that text over it: one undo step", + has_text, + delete_surrounding + ), cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete), cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret), cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| { @@ -104,6 +113,15 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "story.options", + "Story Options", + [], + None, + "{story?, frame?: a text frame of the story (default: the stories of the text selection or the selected frames), opticalMargin?: bool — Optical Margin Alignment: punctuation and letter edges hang outside the frame in every paragraph of the story except those with ignoreOpticalMargin, opticalMarginSize?: pt (0.1–1296, default 12) — the type size the hang amounts are set for} → {stories}", + has_doc, + story_options + ), cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?); let text = super::text_param(p, "text"); @@ -182,7 +200,7 @@ pub fn specs() -> Vec { "Paragraph Formatting", [], None, - "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", + "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, opticalMargin?: bool (hang at each character's own size; a story with story.options opticalMargin overrides it), ignoreOpticalMargin?: bool (no hang in the paragraph), tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", has_text_or_frames, |s, p| format_paras(s, p.get("attrs").unwrap_or(p)) ), @@ -374,7 +392,7 @@ pub fn specs() -> Vec { "Selection Attributes", [], None, - "{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)", + "{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)", has_doc, selection_attrs ), @@ -758,6 +776,71 @@ fn insert(s: &mut Session, p: &Value) -> Result { result } +/// The input method's delete-surrounding: remove `before`/`after` characters (or bytes) around +/// the selection, keeping the selection itself and its direction, then optionally type `commit` +/// over it. One undo step for the whole batch, through the normal command pipeline (preview +/// restoration, panic guard, undo, auto-size and the journal), unlike a private edit. +fn delete_surrounding(s: &mut Session, p: &Value) -> Result { + let count = |key: &str| -> Result { + match p.get(key).and_then(Value::as_u64) { + Some(v) => usize::try_from(v).map_err(|_| bad("text.deleteSurrounding", format!("{key} is out of range"))), + None => Ok(0), + } + }; + let before = count("before")?; + let after = count("after")?; + let bytes = p.get("bytes").and_then(Value::as_bool).unwrap_or(false); + let commit = designcraft_tools::ime_commit_text(p.get("commit").and_then(Value::as_str).unwrap_or("")); + // What the request removes, from the committed text as it is now: the ranges exclude the + // selection and any pre-edit (a pre-edit is only a preview, never in the story). + let (before_range, after_range, tracking, length_before) = { + let st = s.doc()?; + let t = st.selection.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?; + let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let (before_range, after_range) = crate::ime_delete_ranges(&story.text, t.anchor, t.focus, before, after, bytes) + .ok_or_else(|| bad("text.deleteSurrounding", "the IME text selection is not on character boundaries"))?; + (before_range, after_range, st.doc.settings.track_changes && t.cell.is_none(), story.text.len()) + }; + s.edit(|d, sel| { + let t = sel.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?; + let story = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + if !after_range.is_empty() { + if tracking { + crate::cmd::changes::mark_deleted(story, after_range.clone()); + } else { + story.delete(after_range.clone()); + } + } + let mut shift = 0usize; + if !before_range.is_empty() { + if tracking { + // Tracked changes keep the text and mark it deleted: the selection does not move. + let new_end = crate::cmd::changes::mark_deleted(story, before_range.clone()); + shift = before_range.end.saturating_sub(new_end); + } else { + shift = before_range.len(); + story.delete(before_range.clone()); + } + } + sel.text = Some(TextSel { anchor: t.anchor.saturating_sub(shift), focus: t.focus.saturating_sub(shift), ..t }); + Ok(()) + })?; + if !commit.is_empty() { + insert(s, &json!({"text": commit}))?; + } + let st = s.doc()?; + let (anchor, focus) = st.selection.text.map_or((0, 0), |t| (t.anchor, t.focus)); + let length_after = st.selection.text.and_then(|t| st.doc.text_story(t.story, t.cell)).map_or(0, |story| story.text.len()); + Ok(json!({ + "deletedBefore": [before_range.start, before_range.end], + "deletedAfter": [after_range.start, after_range.end], + "lengthBefore": length_before, + "lengthAfter": length_after, + "anchor": anchor, + "focus": focus, + })) +} + fn smart_quotes(s: &Session, text: &str) -> String { if !text.contains(['"', '\'']) { return text.to_string(); @@ -991,11 +1074,11 @@ fn sentence_bounds(s: &str, i: usize) -> (usize, usize) { (pa + start, pb) } -/// Type ▸ Story Direction. -fn story_direction(s: &mut Session, p: &Value) -> Result { - let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; +/// The stories of the text selection, the selected cells and the selected text frames. +fn selected_stories(s: &Session) -> Result> { let st = s.doc()?; let mut stories: Vec = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect(); + stories.dedup(); for id in &st.selection.items { if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) && !stories.contains(&tf.story) @@ -1003,6 +1086,54 @@ fn story_direction(s: &mut Session, p: &Value) -> Result { stories.push(tf.story); } } + Ok(stories) +} + +/// Type ▸ Story: the story-wide options (Optical Margin Alignment and its size). +fn story_options(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "story.options"; + let on = match p.get("opticalMargin") { + None => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(CMD, "`opticalMargin` must be a bool"))?), + }; + let size = match p.get("opticalMarginSize") { + None => None, + Some(v) => Some( + v.as_f64() + .and_then(designcraft_doc::OpticalMargin::clamp_size) + .ok_or_else(|| bad(CMD, "`opticalMarginSize` must be a finite number of points"))?, + ), + }; + if on.is_none() && size.is_none() { + return Err(bad(CMD, "nothing to set: give opticalMargin or opticalMarginSize")); + } + let stories = if p.get("story").is_some() || p.get("frame").is_some() { + vec![story_of(s, p).ok_or_else(|| bad(CMD, "no such story"))?] + } else { + selected_stories(s)? + }; + if stories.is_empty() { + return Err(bad(CMD, "no story: select text or a text frame, or give story or frame")); + } + s.edit(|d, _| { + for sid in &stories { + let st = d.story_mut(*sid).ok_or(designcraft_doc::DocError::NoStory(*sid))?; + let mut om = st.optical_margin; + om.on = on.unwrap_or(om.on); + om.size = size.unwrap_or(om.size); + if om != st.optical_margin { + st.optical_margin = om; + st.rev = st.rev.wrapping_add(1); + } + } + Ok(json!({"stories": stories.len()})) + }) +} + +/// Type ▸ Story Direction. +fn story_direction(s: &mut Session, p: &Value) -> Result { + let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; + let stories = selected_stories(s)?; s.edit(|d, _| { set_story_direction(d, &stories, vertical); Ok(json!({"stories": stories.len()})) @@ -1836,12 +1967,30 @@ fn selection_attrs(s: &mut Session, p: &Value) -> Result { "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, "selectedText": selected.trim()}); + let mut stories = selected_stories(s)?; + if stories.is_empty() { + stories.push(sid); + } + out["storyOptions"] = story_options_of(&st.doc, &stories); if bool_or(p, "mixed", false) { out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets)); } Ok(out) } +/// The story options of `stories`, as story.options names them; a value that differs between +/// the stories is null. +fn story_options_of(doc: &designcraft_doc::Document, stories: &[StoryId]) -> Value { + let mut values = stories.iter().filter_map(|sid| doc.story(*sid)).map(|st| st.optical_margin); + let Some(first) = values.next() else { return json!({"opticalMargin": null, "opticalMarginSize": null}) }; + let (mut on, mut size) = (Some(first.on), Some(first.size)); + for om in values { + on = on.filter(|v| *v == om.on); + size = size.filter(|v| *v == om.size); + } + json!({"opticalMargin": on, "opticalMarginSize": size}) +} + /// The paragraph attributes whose resolved values differ across the paragraphs of `targets`. fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec { let mut first: Option> = None; @@ -2105,7 +2254,7 @@ fn open_type_features(s: &mut Session, _p: &Value) -> Result { continue; } let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect(); - let name = if names.len() == 1 { names.first().map(|n| n.to_string()) } else { None }; + let name = if names.len() == 1 { names.first().map(ToString::to_string) } else { None }; let (applied, available) = measure(&tag); sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available})); } @@ -3280,6 +3429,90 @@ mod bidi_caret_tests { } } +#[cfg(test)] +mod story_options_tests { + use super::*; + + fn first_x(s: &Session, sid: StoryId) -> f64 { + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + cs.frames[0].lines[0].glyphs[0].x + } + + fn story(s: &Session, sid: StoryId) -> designcraft_doc::OpticalMargin { + s.doc().unwrap().doc.story(sid).unwrap().optical_margin + } + + #[test] + fn story_options_set_optical_margin_with_one_undo_step_each() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let flush = first_x(&s, sid); + // The selected frame's story. + s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); + assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 }); + let hung = first_x(&s, sid); + assert!(hung < flush - 1.0, "{hung} {flush}"); + s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 24})).unwrap(); + assert_eq!(story(&s, sid).size, 24.0); + assert!(first_x(&s, sid) < hung - 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 }); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!story(&s, sid).on); + assert_eq!(first_x(&s, sid), flush); + // Hostile sizes. + s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 1e9})).unwrap(); + assert_eq!(story(&s, sid).size, designcraft_doc::OpticalMargin::MAX_SIZE); + for bad in [json!({"opticalMarginSize": "big"}), json!({"opticalMargin": 1}), json!({}), json!({"story": 999_999, "opticalMargin": true})] { + assert!(s.execute("story.options", &bad).is_err(), "{bad}"); + } + } + + #[test] + fn ignore_optical_margin_through_type_para_and_paragraph_styles() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let flush = first_x(&s, sid); + s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); + assert!(first_x(&s, sid) < flush - 1.0); + let ignored = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.ignore_optical_margin; + s.execute("type.para", &json!({"attrs": {"ignoreOpticalMargin": true}})).unwrap(); + assert_eq!(ignored(&s), Some(true)); + assert_eq!(first_x(&s, sid), flush); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ignored(&s), None); + s.execute("style.paragraph.create", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": true}})).unwrap(); + let resolved = |s: &Session| s.doc().unwrap().doc.styles.resolve_para_style("Flush").0.ignore_optical_margin; + assert!(resolved(&s)); + s.execute("style.paragraph.edit", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": false}})).unwrap(); + assert!(!resolved(&s)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(resolved(&s)); + s.execute("style.paragraph.apply", &json!({"name": "Flush"})).unwrap(); + assert_eq!(first_x(&s, sid), flush); + } + + #[test] + fn selection_attrs_report_the_story_options_with_differences_as_null() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "One"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Two"})).unwrap(); + let options = |s: &mut Session| s.execute("type.selectionAttrs", &json!({})).unwrap()["storyOptions"].clone(); + assert_eq!(options(&mut s), json!({"opticalMargin": false, "opticalMarginSize": 12.0})); + s.execute("story.options", &json!({"story": b["story"], "opticalMargin": true, "opticalMarginSize": 18})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": true, "opticalMarginSize": 18.0}), "the selected frame's story"); + s.execute("selection.set", &json!({"ids": [a["id"], b["id"]]})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": null})); + s.execute("story.options", &json!({"opticalMarginSize": 18})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": 18.0})); + } +} + #[cfg(test)] mod arabic_tests { use super::*; diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index d2e513430..17f0671e6 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -73,9 +73,256 @@ pub fn specs() -> Vec { s.execute("text.insert", &json!({"text": c.to_string()})) } ), + cmd!( + "variables.convertToText", + "Convert Variable to Text", + ["Type", "Text Variables"], + None, + "{} — the variable instances in the selected text become plain text of the value each shows on its page; the variables stay defined", + has_selected_instance, + convert_selection + ), + cmd!( + "variables.convertAllToText", + "Convert to Text", + [], + None, + "{name} — every instance of the variable in the document becomes plain text of the value it shows on its page (Text Variables › Convert to Text); the variable stays defined", + has_doc, + convert_all + ), ] } +/// The selected text holds a variable instance (InDesign: select the variable, then Convert +/// Variable to Text). The endnote story is rebuilt from the stories' endnotes, so an edit there +/// would not last. +fn has_selected_instance(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + let st = s.active().ok_or("no document")?; + let t = st.selection.text.ok_or("no text selected")?; + let held = st.doc.endnote_story != Some(t.story) + && st + .doc + .text_story(t.story, t.cell) + .and_then(|x| x.text.get(t.range())) + .is_some_and(|x| x.chars().any(|c| designcraft_doc::vars::var_index(c).is_some())); + if held { Ok(()) } else { Err("no text variable selected".into()) } +} + +/// The page story byte `pos` is shown on, which a variable's value depends on: overset text takes +/// the page of the story's last frame; parent pages and the pasteboard have none. +fn shown_page(d: &designcraft_doc::Document, cs: &designcraft_compose::ComposedStory, pos: usize) -> Option { + designcraft_compose::vars::page_of(d, cs, pos).or_else(|| cs.frames.last().and_then(|f| d.page_of_item(f.frame))) +} + +/// Values of every variable on the pages asked for, as composition shows them (running headers +/// included), computed once per page. +struct PageValues<'a> { + doc: &'a designcraft_doc::Document, + running: std::sync::Arc, + pages: std::collections::HashMap, Vec>, +} + +impl PageValues<'_> { + fn get(&mut self, i: usize, page: Option) -> String { + let (doc, running) = (self.doc, &self.running); + let v = self.pages.entry(page).or_insert_with(|| designcraft_compose::vars::values(doc, page, Some(running))); + v.get(i).cloned().unwrap_or_default() + } +} + +/// Byte offsets of the variable instances in `range` of `text` (of variable `which`, or any), +/// with their variable index. +fn instances(text: &str, range: std::ops::Range, which: Option) -> Vec<(usize, usize)> { + let Some(t) = text.get(range.clone()) else { return Vec::new() }; + t.char_indices() + .filter_map(|(k, c)| designcraft_doc::vars::var_index(c).filter(|i| which.is_none_or(|w| w == *i)).map(|i| (range.start + k, i))) + .collect() +} + +/// Replaces the instances at `at` (byte, variable) in `st` with `value(byte)`, last first so the +/// earlier offsets hold. Returns the change in length before each replaced byte, in text order. +fn replace_instances(st: &mut designcraft_doc::Story, at: &[(usize, usize)], value: &dyn Fn(usize) -> String) -> Vec<(usize, isize)> { + let mut grown = Vec::new(); + for &(pos, i) in at.iter().rev() { + let len = var_char(i).map_or(0, char::len_utf8); + let v = value(pos); + st.replace(pos..pos + len, &v); + grown.push((pos, v.len() as isize - len as isize)); + } + grown.reverse(); + grown +} + +/// Moves byte `b` of a story by the growth of the instances replaced before it. +fn shifted(b: usize, grown: &[(usize, isize)]) -> usize { + let d: isize = grown.iter().filter(|(pos, _)| *pos < b).map(|(_, g)| g).sum(); + b.saturating_add_signed(d) +} + +/// Replaces every instance of variable `i` in `st`, its table cells and its notes with `value`. +/// Returns the growth of `st`'s own text, as [`replace_instances`]. +fn replace_nested(st: &mut designcraft_doc::Story, i: usize, value: &str, depth: usize) -> Vec<(usize, isize)> { + if depth > MAX_DEPTH { + return Vec::new(); + } + let at = instances(&st.text, 0..st.text.len(), Some(i)); + let grown = replace_instances(st, &at, &|_| value.to_string()); + let mut nested = false; + for t in st.tables.values_mut() { + if t.cells.iter().any(|c| holds(&c.text, i, depth + 1)) { + for c in &mut std::sync::Arc::make_mut(t).cells { + replace_nested(&mut c.text, i, value, depth + 1); + } + nested = true; + } + } + for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + if holds(¬e.text, i, depth + 1) { + replace_nested(&mut std::sync::Arc::make_mut(note).text, i, value, depth + 1); + nested = true; + } + } + if nested { + st.rev = st.rev.wrapping_add(1); + } + grown +} + +/// Whether `st` or text nested in it holds an instance of variable `i`. +fn holds(st: &designcraft_doc::Story, i: usize, depth: usize) -> bool { + depth <= MAX_DEPTH + && (st.text.chars().any(|c| designcraft_doc::vars::var_index(c) == Some(i)) + || st.tables.values().any(|t| t.cells.iter().any(|c| holds(&c.text, i, depth + 1))) + || st.notes.iter().chain(st.endnotes.iter()).any(|n| holds(&n.text, i, depth + 1))) +} + +/// Byte offset in `st` of the anchor of table `id`. +fn table_anchor(st: &designcraft_doc::Story, id: u64) -> Option { + let (pi, _) = st.paras.iter().enumerate().find(|(_, p)| p.table == Some(id))?; + let r = st.para_ranges().into_iter().nth(pi)?; + Some(r.start + st.text.get(r)?.find(designcraft_doc::story::TABLE_ANCHOR)?) +} + +fn convert_selection(s: &mut Session, _: &Value) -> Result { + const ID: &str = "variables.convertToText"; + let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?; + let range = t.range(); + let (at, values) = { + let d = &s.doc()?.doc; + let st = d.story(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let text = d.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + // Text in a cell or a footnote is on the page of its table's or note's anchor. + let anchor = match t.cell { + None => None, + Some(c) if c.table == designcraft_doc::FOOTNOTE_TABLE => st.note_anchor(c.row as u64), + Some(c) => table_anchor(st, c.table), + }; + let cs = s.cache.get(d, t.story, None); + let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() }; + let at = instances(&text.text, range.clone(), None); + let values: std::collections::HashMap = + at.iter().map(|&(pos, i)| (pos, pv.get(i, shown_page(d, &cs, anchor.unwrap_or(pos))))).collect(); + (at, values) + }; + if at.is_empty() { + return Err(bad(ID, "no text variable selected")); + } + s.edit(|d, sel| { + let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let grown = replace_instances(st, &at, &|pos| values.get(&pos).cloned().unwrap_or_default()); + // The converted text stays selected. + sel.text = Some(designcraft_doc::TextSel { anchor: range.start, focus: shifted(range.end, &grown), ..t }); + Ok(json!({"converted": at.len()})) + }) +} + +/// What converting one story's instances of a variable writes: the value for each instance in +/// its own text, and for the text of each table (by id), footnote and endnote, the value on the +/// page of its anchor. +struct StoryPlan { + sid: designcraft_doc::StoryId, + own: Vec<(usize, String)>, + tables: Vec<(u64, String)>, + notes: Vec, + endnotes: Vec, +} + +fn convert_all(s: &mut Session, p: &Value) -> Result { + const ID: &str = "variables.convertAllToText"; + let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?; + let (i, plans) = { + let d = &s.doc()?.doc; + let i = d.variable(name).ok_or_else(|| bad(ID, format!("no variable `{name}`")))?; + let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() }; + let mut plans = Vec::new(); + for (sid, st) in &d.stories { + if Some(*sid) == d.endnote_story || !holds(st, i, 0) { + continue; + } + let cs = s.cache.get(d, *sid, None); + let mut value = |pos: usize| pv.get(i, shown_page(d, &cs, pos)); + let own = instances(&st.text, 0..st.text.len(), Some(i)).into_iter().map(|(pos, _)| (pos, value(pos))).collect(); + let tables = st.tables.keys().map(|id| (*id, table_anchor(st, *id).map(&mut value).unwrap_or_default())).collect(); + let notes = st.text.match_indices(designcraft_doc::FOOTNOTE_REF).map(|(k, _)| value(k)).collect(); + let endnotes = st.text.match_indices(designcraft_doc::ENDNOTE_REF).map(|(k, _)| value(k)).collect(); + plans.push(StoryPlan { sid: *sid, own, tables, notes, endnotes }); + } + (i, plans) + }; + s.edit(|d, sel| { + for plan in &plans { + // A selection in a table cell or a footnote keeps its place in that text: it is + // converted first, so the loop below finds nothing left there. + if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid) + && let Some(c) = t.cell + { + let value = match c.footnote_id() { + Some(id) => d.story(plan.sid).and_then(|st| st.notes.iter().position(|n| n.id == id)).and_then(|k| plan.notes.get(k)), + None => plan.tables.iter().find(|(id, _)| *id == c.table).map(|(_, v)| v), + }; + if let Some(v) = value + && let Some(text) = d.text_story_mut(plan.sid, Some(c)) + { + let grown = replace_nested(text, i, v, 1); + t.anchor = shifted(t.anchor, &grown); + t.focus = shifted(t.focus, &grown); + } + } + let st = d.story_mut(plan.sid).ok_or(designcraft_doc::DocError::NoStory(plan.sid))?; + let at: Vec<(usize, usize)> = plan.own.iter().map(|(pos, _)| (*pos, i)).collect(); + let grown = replace_instances(st, &at, &|pos| plan.own.iter().find(|(k, _)| *k == pos).map(|(_, v)| v.clone()).unwrap_or_default()); + let mut nested = false; + for (id, v) in &plan.tables { + if let Some(t) = st.tables.get_mut(id) + && t.cells.iter().any(|c| holds(&c.text, i, 1)) + { + for c in &mut std::sync::Arc::make_mut(t).cells { + replace_nested(&mut c.text, i, v, 1); + } + nested = true; + } + } + for (note, v) in st.notes.iter_mut().zip(&plan.notes).chain(st.endnotes.iter_mut().zip(&plan.endnotes)) { + if holds(¬e.text, i, 1) { + replace_nested(&mut std::sync::Arc::make_mut(note).text, i, v, 1); + nested = true; + } + } + if nested { + st.rev = st.rev.wrapping_add(1); + } + // A selection in the story keeps its place in the text. + if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid && t.cell.is_none()) { + t.anchor = shifted(t.anchor, &grown); + t.focus = shifted(t.focus, &grown); + } + } + Ok(Value::Null) + }) +} + /// Nesting limit for tables in cells and notes (hostile documents). const MAX_DEPTH: usize = 32; @@ -265,6 +512,180 @@ mod tests { assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it"); } + /// Three pages with "Alpha" in the Heading style on page 1 and "Epsilon" on page 3, a + /// `Chapter Head` running header on Heading, and a text frame on pages 1 and 3 that each hold + /// "|" (two instances). Returns the two frames' stories. + fn running_heads() -> (Session, designcraft_doc::StoryId, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + let frame = |s: &mut Session, page: usize, y: f64, text: &str| { + let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, y, 400, y + 60.0], "content": "text", "text": text})).unwrap(); + s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story + }; + for (page, text) in [(0, "Alpha"), (2, "Epsilon")] { + let sid = frame(&mut s, page, 200.0, text); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + } + s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let both = format!("{c}|{c}"); + let (p1, p3) = (frame(&mut s, 0, 400.0, &both), frame(&mut s, 2, 400.0, &both)); + (s, p1, p3) + } + + /// Convert Variable to Text turns the selected instance into the value it shows on its own + /// page (Epsilon on page 3, not page 1's Alpha); the other instance and the variable stay. + #[test] + fn convert_variable_to_text_uses_the_instances_page() { + let (mut s, p1, p3) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let second = c.len_utf8() + 1; + s.execute("text.select", &json!({"story": p3.0, "anchor": second, "focus": second + c.len_utf8()})).unwrap(); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.story(p3).unwrap().text, format!("{c}|Epsilon"), "only the selected instance"); + assert_eq!(d.story(p1).unwrap().text, format!("{c}|{c}")); + assert!(d.variable("Chapter Head").is_some(), "the variable stays defined"); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (second, second + "Epsilon".len()), "the converted text stays selected"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(p3).unwrap().text, format!("{c}|{c}"), "one undo step"); + } + + /// The menu item is enabled only for a selection that holds an instance: not a caret, not + /// text without one. + #[test] + fn convert_variable_to_text_needs_a_selected_instance() { + let (mut s, p1, _) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let bar = c.len_utf8(); + let enabled = |s: &Session| (crate::find_command("variables.convertToText").unwrap().enabled)(s).is_ok(); + for (anchor, focus) in [(0, 0), (bar, bar + 1)] { + s.execute("text.select", &json!({"story": p1.0, "anchor": anchor, "focus": focus})).unwrap(); + assert!(!enabled(&s), "{anchor}..{focus}"); + } + s.execute("text.select", &json!({"story": p1.0, "anchor": 0, "focus": bar})).unwrap(); + assert!(enabled(&s)); + } + + /// A story threaded from a frame on page 1, which holds only "Lead", to a frame on page 3, + /// where the caret is left at the end of "Body". A cell's or a note's own byte 0 read in this + /// story is on page 1, its anchor on page 3. + fn threaded(s: &mut Session) -> designcraft_doc::StoryId { + let sp1 = s.doc().unwrap().doc.page_loc(0).unwrap().0; + let sp3 = s.doc().unwrap().doc.page_loc(2).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp1, "rect": [72, 500, 400, 520], "content": "text", "text": "Lead\nBody"})).unwrap(); + let from = r["id"].as_u64().unwrap(); + s.execute("text.thread", &json!({"from": from, "rect": [72, 500, 400, 700], "spread": sp3})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let sid = d.item(designcraft_doc::ItemId(from)).unwrap().text_frame().unwrap().story; + let cs = s.cache.get(&d, sid, None); + let page = |pos| designcraft_compose::vars::page_of(&d, &cs, pos); + assert_eq!((page(0), page("Lead\nB".len())), (Some(0), Some(2)), "Lead on page 1, Body on page 3"); + s.execute("text.select", &json!({"story": sid.0, "anchor": "Lead\nBody".len(), "focus": "Lead\nBody".len()})).unwrap(); + sid + } + + /// An instance selected in a table cell becomes the value on the page of the table's anchor. + #[test] + fn convert_variable_to_text_in_a_table_cell() { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap(); + let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap(); + let cell = designcraft_doc::CellAddr { table, row: 0, col: 0 }; + s.doc_mut().unwrap().selection = + designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: c.len_utf8(), frame: None, cell: Some(cell) }); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.text_story(sid, Some(cell)).unwrap().text, "Epsilon"); + } + + /// An instance selected in a footnote becomes the value on the page of the note's reference. + #[test] + fn convert_variable_to_text_in_a_footnote() { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap(); + let id = s.doc().unwrap().doc.story(sid).unwrap().notes[0].id; + let note = designcraft_doc::CellAddr::footnote(id); + let at = s.doc().unwrap().doc.text_story(sid, Some(note)).unwrap().text.find(c).unwrap(); + s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { + story: sid, + anchor: at, + focus: at + c.len_utf8(), + frame: None, + cell: Some(note), + }); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let text = &d.text_story(sid, Some(note)).unwrap().text; + assert!(!text.contains(c) && text.ends_with("Epsilon"), "{text:?}"); + } + + /// Text Variables › Convert to Text turns every instance of the variable into the value on + /// its page, in table cells, footnotes and endnotes on the page of their anchor, and keeps the + /// variable. + #[test] + fn convert_all_instances_to_text() { + let (mut s, p1, p3) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + let at_end = |s: &mut Session| { + let n = s.doc().unwrap().doc.story(sid).unwrap().len(); + s.execute("text.select", &json!({"story": sid.0, "anchor": n, "focus": n})).unwrap(); + }; + s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap(); + at_end(&mut s); + s.execute("endnote.insert", &json!({"text": c.to_string()})).unwrap(); + at_end(&mut s); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap(); + s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.story(p1).unwrap().text, "Alpha|Alpha"); + assert_eq!(d.story(p3).unwrap().text, "Epsilon|Epsilon"); + let st = d.story(sid).unwrap(); + assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "Epsilon", "table cell"); + assert!(st.notes[0].text.text.ends_with("Epsilon"), "footnote: {:?}", st.notes[0].text.text); + assert!(st.endnotes[0].text.text.ends_with("Epsilon"), "endnote: {:?}", st.endnotes[0].text.text); + assert!(d.variable("Chapter Head").is_some(), "the variable stays defined"); + } + + /// A caret in a table cell or a footnote after an instance stays after the value Convert to + /// Text writes there, so typing lands after it. + #[test] + fn convert_all_keeps_the_caret_in_a_cell_or_a_footnote() { + for footnote in [false, true] { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + let text = format!("{c}|"); + let at = if footnote { + s.execute("footnote.insert", &json!({"text": text})).unwrap(); + designcraft_doc::CellAddr::footnote(s.doc().unwrap().doc.story(sid).unwrap().notes[0].id) + } else { + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap(); + let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap(); + designcraft_doc::CellAddr { table, row: 0, col: 0 } + }; + let n = s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text.len(); + s.doc_mut().unwrap().selection = + designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: n, focus: n, frame: None, cell: Some(at) }); + s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap(); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + let text = &s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text; + assert!(text.ends_with("Epsilon|x"), "footnote {footnote}: {text:?}"); + } + } + /// Deleting a variable flattens its instances in table cells too and moves the instances of /// later variables down, so cells keep showing the same text. #[test] diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 4f9a17c6f..7fb1bc5c2 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -1,15 +1,17 @@ //! The folders automation may read and write. //! //! `designcraft-cli mcp --automation-read-root --automation-write-root ` confines the -//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command -//! reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! files an agent's commands reach; each flag may be given more than once (#1552). [`AutomationRoots`] +//! holds the folders of each kind. While they are in force, every path a command reads goes through +//! [`check_read`] and every path it writes through [`check_write`]: //! //! - The path is made absolute against the working directory, as the file system reads it. Its //! links are followed: for a file that doesn't exist yet, the links of its nearest existing //! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so //! is a link that leads nowhere). -//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside -//! `/work`), without regard to case on Windows. +//! - The result must lie inside one of the roots of its kind, compared by whole components +//! (`/work2` isn't inside `/work`), without regard to case on Windows. Two roots don't make their +//! common parent one. //! - Read and write access are separate: a root given alone grants only its own kind of access. //! //! Roots are in force on a thread inside [`confine`], and in the whole process after @@ -32,11 +34,11 @@ pub enum Access { Write, } -/// The folders automation may read and write; a root left out grants no access of its kind. +/// The folders automation may read and write; a kind with no root grants no access of its kind. #[derive(Debug)] pub struct AutomationRoots { - read: Option, - write: Option, + read: Vec, + write: Vec, } #[derive(Debug)] @@ -62,47 +64,61 @@ impl Root { } impl AutomationRoots { - /// The roots `read` and `write`, which must be existing folders; `None` when neither is given - /// (nothing is confined then). - pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { - if read.is_none() && write.is_none() { + /// The roots `read` and `write`, which must be existing folders (one given twice counts once); + /// `None` when neither kind has any (nothing is confined then). + pub fn new(read: &[&Path], write: &[&Path]) -> Result>, String> { + if read.is_empty() && write.is_empty() { return Ok(None); } - let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; - let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; - Ok(Some(Arc::new(Self { read, write }))) + let open = |paths: &[&Path], access: Access| -> Result, String> { + let mut roots: Vec = Vec::with_capacity(paths.len()); + for path in paths { + let root = Root::open(path, access)?; + if !roots.iter().any(|r| r.resolved == root.resolved) { + roots.push(root); + } + } + Ok(roots) + }; + Ok(Some(Arc::new(Self { read: open(read, Access::Read)?, write: open(write, Access::Write)? }))) } - /// The read root, absolute with its links followed. - pub fn read_root(&self) -> Option<&Path> { - self.read.as_ref().map(|r| r.shown.as_path()) + /// The roots for people: `read A, B; write C` (`none` for a kind without any), absolute with + /// their links followed. + pub fn describe(&self) -> String { + let list = |roots: &[Root]| { + if roots.is_empty() { + return "none".to_string(); + } + roots.iter().map(|r| r.shown.display().to_string()).collect::>().join(", ") + }; + format!("read {}; write {}", list(&self.read), list(&self.write)) } - /// The write root, absolute with its links followed. - pub fn write_root(&self) -> Option<&Path> { - self.write.as_ref().map(|r| r.shown.as_path()) - } - - /// May the file or folder at `path` be read (or written)? `Err` says why not. + /// May the file or folder at `path` be read (or written)? It must lie inside one of the roots + /// of that kind. `Err` says why not. pub fn check(&self, path: impl AsRef, access: Access) -> Result<(), String> { let path = path.as_ref(); - let root = match access { - Access::Read => self.read.as_ref(), - Access::Write => self.write.as_ref(), + let roots = match access { + Access::Read => &self.read, + Access::Write => &self.write, }; - let Some(Root { resolved: folder, shown }) = root else { + if roots.is_empty() { return match access { Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {}", path.display())), Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {}", path.display())), }; - }; - if inside(&resolve(path)?, folder) { + } + let resolved = resolve(path)?; + if roots.iter().any(|r| inside(&resolved, &r.resolved)) { return Ok(()); } - let root = shown.display(); - match access { - Access::Read => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())), - Access::Write => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())), + let shown: Vec = roots.iter().map(|r| r.shown.display().to_string()).collect(); + match (shown.as_slice(), access) { + ([root], Access::Read) => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())), + ([root], Access::Write) => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())), + (_, Access::Read) => Err(format!("automation path rejected: outside the read roots {}: {}", shown.join(", "), path.display())), + (_, Access::Write) => Err(format!("automation path rejected: outside the write roots {}: {}", shown.join(", "), path.display())), } } } @@ -279,12 +295,24 @@ mod tests { } std::fs::write(base.join("in/a.designcraft"), "x").unwrap(); std::fs::write(base.join("outside/secret.designcraft"), "x").unwrap(); - let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap(); (base, roots) } fn s(p: PathBuf) -> String { - p.to_string_lossy().into_owned() + let text = p.to_string_lossy(); + if !cfg!(windows) { + return text.into_owned(); + } + // A canonical path on Windows has a `\\?\` prefix, under which `..` is not a name: + // send ordinary paths, as the roots show them. + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}"); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => rest.to_owned(), + None => text.into_owned(), + } } #[test] @@ -339,18 +367,18 @@ mod tests { #[test] fn a_missing_root_is_an_error_and_none_is_no_confinement() { let (base, _) = folders("missing"); - let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err(); assert!(e.starts_with("cannot open the automation read root"), "{e}"); - let e = AutomationRoots::new(None, Some(&base.join("in/a.designcraft"))).unwrap_err(); + let e = AutomationRoots::new(&[], &[&base.join("in/a.designcraft")]).unwrap_err(); assert!( e.starts_with("cannot open the automation write root") && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), "{e}" ); - assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); - assert!(AutomationRoots::new(None, None).unwrap().is_none()); + assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(&[], &[]).unwrap().is_none()); // One root alone grants only its own access. - let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let read_only = AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap(); let e = read_only.check(s(base.join("in/x.designcraft")), Access::Write).unwrap_err(); assert!(e.contains("write authority is absent"), "{e}"); let _ = std::fs::remove_dir_all(base); @@ -387,11 +415,125 @@ mod tests { assert_eq!(roots.check(s(base.join("in/alias/x.designcraft")), Access::Read), Ok(())); // A root given through a link is the folder it leads to. assert!(made(&base.join("in"), &base.join("in-link"))); - let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + let linked = AutomationRoots::new(&[&base.join("in-link")], &[]).unwrap().unwrap(); assert_eq!(linked.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); let _ = std::fs::remove_dir_all(base); } + /// `in` and `shared` (read roots), `out` and `scratch` (write roots) beside `outside`, the + /// siblings `in2` and `scratch2` whose names start like a root's, and a file in their parent. + /// The folder is returned resolved (`/var` is `/private/var` on macOS), as the roots show it, so + /// a path that passes only because it was resolved is the case being tested and nothing else. + fn several(name: &str) -> (PathBuf, Arc) { + let (base, _) = folders(name); + let base = std::fs::canonicalize(base).unwrap(); + for d in ["shared", "scratch", "scratch2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + for f in ["shared/b.designcraft", "in2/c.designcraft", "parent.designcraft"] { + std::fs::write(base.join(f), "x").unwrap(); + } + let roots = AutomationRoots::new(&[&base.join("in"), &base.join("shared")], &[&base.join("out"), &base.join("scratch")]).unwrap().unwrap(); + (base, roots) + } + + /// #1552: several roots of a kind: a path inside any of them passes, one inside none is refused + /// naming them all, and read and write stay separate. + #[test] + fn several_roots_of_a_kind_each_grant_their_access() { + let (base, roots) = several("several"); + assert_eq!(roots.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(s(base.join("shared/b.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(s(base.join("out/x.png")), Access::Write), Ok(())); + assert_eq!(roots.check(s(base.join("scratch/new/x.png")), Access::Write), Ok(())); + let e = roots.check(s(base.join("outside/secret.designcraft")), Access::Read).unwrap_err(); + let listed = format!("outside the read roots {}, {}: ", s(base.join("in")), s(base.join("shared"))); + assert!(e.starts_with("automation path rejected: ") && e.contains(&listed), "{e}"); + assert!(roots.check(s(base.join("outside/x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + // Two sibling roots don't make their parent a root. + assert!(roots.check(s(base.join("parent.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.clone()), Access::Read).is_err()); + assert!(roots.check(s(base.join("x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + // Read and write stay separate: a read root is not a write root, nor the other way round. + assert!(roots.check(s(base.join("shared/b.designcraft")), Access::Write).unwrap_err().contains("outside the write roots")); + assert!(roots.check(s(base.join("in/x.png")), Access::Write).is_err()); + assert!(roots.check(s(base.join("scratch/x.png")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("out")), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn several_roots_compare_whole_folder_names() { + let (base, roots) = several("several-prefix"); + assert!(roots.check(s(base.join("in2/c.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"), "in2 is not inside in"); + assert!(roots.check(format!("{}2/x.designcraft", s(base.join("shared"))), Access::Read).is_err()); + assert!( + roots.check(s(base.join("scratch2/x.png")), Access::Write).unwrap_err().contains("outside the write roots"), + "scratch2 is not inside scratch" + ); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_between_several_roots() { + let (base, roots) = several("several-dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = |root: &str, rest: &str| format!("{}{sep}..{sep}{rest}", s(base.join(root))); + // `..` out of one root is outside, also when it lands next to another root. + assert!(roots.check(up("shared", &format!("outside{sep}secret.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(up("shared", "parent.designcraft"), Access::Read).is_err()); + assert!(roots.check(up("scratch", &format!("outside{sep}x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + // `..` from one root into another of the same kind stays inside. + assert_eq!(roots.check(up("shared", &format!("in{sep}a.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(up("scratch", &format!("out{sep}x.png")), Access::Write), Ok(())); + // ...but not into a root of the other kind. + assert!(roots.check(up("scratch", &format!("in{sep}x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + assert!(roots.check(up("shared", &format!("out{sep}x.png")), Access::Read).unwrap_err().contains("outside the read roots")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn several_roots_are_each_checked_and_counted_once() { + let (base, _) = folders("several-open"); + // A missing folder among several is an error, wherever it stands. + for read in [[base.join("in"), base.join("nope")], [base.join("nope"), base.join("in")]] { + let e = AutomationRoots::new(&[&read[0], &read[1]], &[]).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root") && e.contains("nope"), "{e}"); + } + let e = AutomationRoots::new(&[&base.join("in")], &[&base.join("out"), &base.join("in/a.designcraft")]).unwrap_err(); + assert!(e.starts_with("cannot open the automation write root"), "{e}"); + // A folder given twice (here once through `..`) counts once. + let twice = AutomationRoots::new(&[&base.join("in"), &base.join("in/sub/.."), &base.join("in")], &[]).unwrap().unwrap(); + let real = std::fs::canonicalize(&base).unwrap(); + assert_eq!(twice.describe(), format!("read {}; write none", s(real.join("in")))); + let e = twice.check(s(base.join("outside/x")), Access::Read).unwrap_err(); + assert!(e.contains("outside the read root ") && !e.contains("roots"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + /// A link inside one root that leads into another root of the same kind stays inside; one that + /// leads out of all of them, or into a root of the other kind, is outside. + #[cfg(unix)] + #[test] + fn links_between_several_roots() { + let (base, roots) = several("several-links"); + let link = |target: &str, at: &str| std::os::unix::fs::symlink(base.join(target), base.join(at)).unwrap(); + link("shared/b.designcraft", "in/to-shared.designcraft"); + link("outside/secret.designcraft", "shared/leak.designcraft"); + link("", "shared/up"); + link("out", "in/to-out"); + link("outside", "scratch/away"); + link("in", "scratch/to-in"); + assert_eq!(roots.check(s(base.join("in/to-shared.designcraft")), Access::Read), Ok(())); + assert!(roots.check(s(base.join("shared/leak.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("shared/up/outside/secret.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("shared/up/parent.designcraft")), Access::Read).is_err()); + assert!(roots.check(s(base.join("in/to-out/x.png")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("scratch/away/x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + assert!(roots.check(s(base.join("scratch/to-in/x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + let _ = std::fs::remove_dir_all(base); + } + #[cfg(windows)] #[test] fn windows_ignores_case_and_refuses_devices() { diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 2ac6bb63c..6395c3594 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session}; pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { payload .downcast_ref::<&str>() - .map(|s| s.to_string()) + .map(ToString::to_string) .or_else(|| payload.downcast_ref::().cloned()) .unwrap_or_else(|| "unknown panic".into()) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5378a411e..b493edf35 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -21,8 +21,7 @@ use std::path::{Path, PathBuf}; /// refused path is never handed to the file system; callers treat it as a missing file. pub fn document_path(stored: &str, document: Option<&Path>) -> Result { let document = document.map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf())); - let path = craft_docpath::resolve(stored, document.as_deref())?; - if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) } + craft_docpath::resolve_absolute(stored, document.as_deref()) } /// [`document_path`] for a path stored in a file inside `folder` (a data file's image names). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d16dc9dd1..c7e4f9275 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -33,7 +33,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use designcraft_compose as compose; pub use designcraft_doc as doc; pub use designcraft_tools as tools; -pub use tooling::{SnapView, UiRequest, ViewInfo}; +pub use tooling::{ImeMutation, ImeTextContext, SnapView, UiRequest, ViewInfo, ime_delete_ranges}; #[derive(Debug, thiserror::Error)] pub enum EngineError { @@ -101,6 +101,9 @@ pub struct DocState { /// Editing parent spreads (Pages panel double-click on a parent). pub editing_parents: bool, pub uid: u64, + /// Changes whenever this document state is recreated, even if [`Self::uid`] is preserved + /// (File › Revert keeps the uid for recovery but replaces the document). + pub identity_epoch: u64, /// Recovery entries this state actually opened or published, for repeated-open and cleanup. /// Bounded, deduplicated checkpoint addresses; never inferred from uid alone. pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>, @@ -145,6 +148,7 @@ impl DocState { let active_layer = doc.default_layer(); let doc = Arc::new(doc); let revision = next_revision(); + let identity_epoch = NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); DocState { saved_doc: doc.clone(), doc, @@ -156,7 +160,8 @@ impl DocState { active_layer, interaction: None, editing_parents: false, - uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + uid: identity_epoch, + identity_epoch, recovery_entries: Vec::new(), preview_stash: None, preview_record: None, @@ -542,17 +547,7 @@ impl Session { // passes that its command's description doesn't name fails the test. #[cfg(test)] check_params(id, params)?; - if let Err(e) = (spec.enabled)(self) { - // A command that takes `ids` acts on the objects the call names, so the selection - // doesn't matter: with a text caret, nothing selected, or a selection the command - // can't act on, `{"ids": [5]}` still runs. - let named = if cmd::selection_reason(&e) { cmd::named_targets(spec, params) } else { None }; - let Some(ids) = named else { return Err(EngineError::Disabled(id.into(), e)) }; - let st = self.doc()?; - if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) { - return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0))); - } - } + self.check_enabled(spec, id, params)?; // Preview is temporary. Save, merge, and every other edit restore the template first. // Queries stay read-only so the panel can poll fields during a preview. `data.preview` // itself is the one command that fills the live document. @@ -598,11 +593,38 @@ impl Session { st.doc = Arc::new(d); } } - // Auto-size frames fit the text the command changed, in the same undo step. + self.autosize_changed(spec, before.as_ref()); + self.keep_guide_selection_valid(before.as_ref(), id); + self.record_undo_step(spec, before, nested); + if spec.journal { + self.journal.push((id.to_string(), params.clone())); + } + self.discard_stale_find_scope(); + Ok(r) + } + + /// Fails with `Disabled` unless the command is enabled or names objects it can act on. + fn check_enabled(&self, spec: &CommandSpec, id: &str, params: &Value) -> Result<()> { + if let Err(e) = (spec.enabled)(self) { + // A command that takes `ids` acts on the objects the call names, so the selection + // doesn't matter: with a text caret, nothing selected, or a selection the command + // can't act on, `{"ids": [5]}` still runs. + let named = if cmd::selection_reason(&e) { cmd::named_targets(spec, params) } else { None }; + let Some(ids) = named else { return Err(EngineError::Disabled(id.into(), e)) }; + let st = self.doc()?; + if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) { + return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0))); + } + } + Ok(()) + } + + /// Auto-size frames fit the text the command changed, in the same undo step. + fn autosize_changed(&mut self, spec: &CommandSpec, before: Option<&(u64, Arc, Selection)>) { if spec.undoable && let Some(st) = self.active_mut() { - let prev = before.as_ref().filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, _)| d.as_ref()); + let prev = before.filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, _)| d.as_ref()); if prev.is_none_or(|p| !std::ptr::eq(p, st.doc.as_ref())) { let todo = autosize::candidates(&st.doc, prev); if !todo.is_empty() { @@ -614,13 +636,6 @@ impl Session { } } } - self.keep_guide_selection_valid(before.as_ref(), id); - self.record_undo_step(spec, before, nested); - if spec.journal { - self.journal.push((id.to_string(), params.clone())); - } - self.discard_stale_find_scope(); - Ok(r) } /// Drops selected guides the command `id` made stale, against the document as it will be diff --git a/crates/engine/src/math.rs b/crates/engine/src/math.rs index 224420953..d4aaf2600 100644 --- a/crates/engine/src/math.rs +++ b/crates/engine/src/math.rs @@ -59,7 +59,7 @@ impl MBox { } } -#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(test, derive(Debug, PartialEq))] enum Node { /// A character run: identifiers italic, numbers and symbols upright. Sym(String, Class), @@ -79,6 +79,51 @@ enum Node { Fenced(String, Box, String), } +// Empty a node before field drop glue can follow its children recursively. This also covers +// replaced scripts and partially built parser continuations during unwinding. +impl Node { + fn detach_children(&mut self, pending: &mut Vec) { + fn detach(child: &mut Node, pending: &mut Vec) { + pending.push(std::mem::replace(child, Node::Group(Vec::new()))); + } + match self { + Self::Group(nodes) => pending.append(nodes), + Self::Frac(a, b) => { + detach(a, pending); + detach(b, pending); + } + Self::Sqrt(index, body) => { + if let Some(index) = index { + detach(index, pending); + } + detach(body, pending); + } + Self::Scripts { base, sup, sub } => { + detach(base, pending); + if let Some(sup) = sup { + detach(sup, pending); + } + if let Some(sub) = sub { + detach(sub, pending); + } + } + Self::Fenced(_, body, _) => detach(body, pending), + Self::Sym(_, _) | Self::Text(_) | Self::Space(_) | Self::Big(_) => {} + } + } +} + +impl Drop for Node { + fn drop(&mut self) { + let mut pending = Vec::new(); + self.detach_children(&mut pending); + while let Some(mut node) = pending.pop() { + node.detach_children(&mut pending); + // Its own Drop and boxed field glue now see only empty leaves, never this subtree. + } + } +} + #[derive(Clone, Copy, Debug, PartialEq)] enum Class { Ident, @@ -160,15 +205,13 @@ fn big_op(name: &str) -> Option<&'static str> { }) } -/// Deepest expression nesting parsed. Recursive descent must not overflow the stack on hostile -/// input (`\frac\frac…`, `{{{{…`, `\left(\left(…`); past the cap groups stop descending. +/// Deepest logical expression nesting parsed. Heap continuations keep this full budget safe on +/// the default native thread stack; past the cap groups retain their existing empty result. const MAX_NESTING: usize = 256; struct Parser<'a> { s: &'a [char], i: usize, - /// Enclosing group depth, at most [`MAX_NESTING`]. - depth: usize, } impl Parser<'_> { @@ -225,21 +268,6 @@ impl Parser<'_> { } out } - fn arg(&mut self) -> Node { - if self.depth >= MAX_NESTING { - return Node::Group(Vec::new()); - } - self.depth += 1; - self.skip_ws(); - let n = if self.peek() == Some('{') { - self.i += 1; - Node::Group(self.list(Some('}'))) - } else { - self.atom().unwrap_or(Node::Group(vec![])) - }; - self.depth -= 1; - n - } fn delimiter(&mut self) -> String { self.skip_ws(); match self.peek() { @@ -265,166 +293,243 @@ impl Parser<'_> { None => String::new(), } } - fn atom(&mut self) -> Option { - self.skip_ws(); - let c = self.peek()?; - self.i += 1; - Some(match c { - '{' => Node::Group(self.list(Some('}'))), - '\\' => { - let name = self.command(); - match name.as_str() { - "frac" | "dfrac" | "tfrac" => { - let a = self.arg(); - let b = self.arg(); - Node::Frac(Box::new(a), Box::new(b)) - } - "sqrt" => { - self.skip_ws(); - let idx = (self.peek() == Some('[')).then(|| { - self.i += 1; - let mut s = String::new(); - while let Some(c) = self.peek() { - self.i += 1; - if c == ']' { - break; - } - s.push(c); - } - Box::new(parse_at(&s, self.depth + 1)) - }); - let a = self.arg(); - Node::Sqrt(idx, Box::new(a)) - } - "text" | "mathrm" | "textrm" | "operatorname" => Node::Text(self.raw_group()), - "sin" | "cos" | "tan" | "log" | "ln" | "exp" | "lim" | "max" | "min" => Node::Text(name), - "," => Node::Space(3.0 / 18.0), - ":" | ">" => Node::Space(4.0 / 18.0), - ";" => Node::Space(5.0 / 18.0), - "quad" => Node::Space(1.0), - "qquad" => Node::Space(2.0), - "!" => Node::Space(-3.0 / 18.0), - " " => Node::Space(0.25), - "left" => { - let open = self.delimiter(); - let body = self.list_until_right(); - let close = self.delimiter(); - Node::Fenced(open, Box::new(Node::Group(body)), close) - } - "{" => Node::Sym("{".into(), Class::Punct), - "}" => Node::Sym("}".into(), Class::Punct), - "%" | "$" | "#" | "&" | "_" => Node::Sym(name, Class::Plain), - n => match (big_op(n), symbol(n)) { - (Some(op), _) => Node::Big(op.into()), - (None, Some((s, cl))) => Node::Sym(s.into(), cl), - _ => Node::Text(format!("\\{n}")), - }, - } - } - c if c.is_ascii_digit() || c == '.' => { - let mut s = c.to_string(); - while let Some(d) = self.peek().filter(|d| d.is_ascii_digit() || *d == '.') { - s.push(d); - self.i += 1; - } - Node::Sym(s, Class::Number) - } - c if c.is_alphabetic() => Node::Sym(c.to_string(), Class::Ident), - '+' => Node::Sym("+".into(), Class::Bin), - '-' => Node::Sym("−".into(), Class::Bin), - '*' => Node::Sym("∗".into(), Class::Bin), - '=' | '<' | '>' => Node::Sym(c.to_string(), Class::Rel), - ',' | ';' => Node::Sym(c.to_string(), Class::Punct), - '\'' => Node::Sym("′".into(), Class::Plain), - c => Node::Sym(c.to_string(), Class::Plain), - }) +} + +/// Continuations carry parser state on the heap instead of in native call frames. +enum ParseWork<'a> { + List { end: Option, depth: usize }, + Sequence { kind: SequenceKind, depth: usize, out: Vec }, + Append { kind: SequenceKind, depth: usize, out: Vec, group: bool }, + Arg { depth: usize }, + Atom { depth: usize }, + Script { kind: SequenceKind, depth: usize, out: Vec, up: bool }, + FractionNumerator { depth: usize }, + FractionDenominator { numerator: Node }, + SqrtIndex { outer: Parser<'a>, depth: usize }, + SqrtArgument { index: Option> }, + Fence { open: String }, + UntilRight { depth: usize }, + One { depth: usize }, + OneAtom { depth: usize }, +} + +#[derive(Clone, Copy)] +enum SequenceKind { + List(Option), + Fence, + One, +} + +/// Repeated scripts replace only their own slot, preserving the base and the opposite slot. +fn attach_script(mut base: Node, argument: Node, up: bool) -> Node { + if let Node::Scripts { sup, sub, .. } = &mut base { + let slot = if up { sup } else { sub }; + *slot = Some(Box::new(argument)); + base + } else if up { + Node::Scripts { base: Box::new(base), sup: Some(Box::new(argument)), sub: None } + } else { + Node::Scripts { base: Box::new(base), sup: None, sub: Some(Box::new(argument)) } } - fn list(&mut self, end: Option) -> Vec { - if self.depth >= MAX_NESTING { - return Vec::new(); - } - self.depth += 1; - let mut out: Vec = Vec::new(); - loop { - self.skip_ws(); - match self.peek() { - None => break, - Some(c) if Some(c) == end => { - self.i += 1; - break; +} + +impl<'a> Parser<'a> { + fn parse(&mut self, depth: usize) -> Node { + let mut work = vec![ParseWork::List { end: None, depth }]; + let mut value = Node::Group(Vec::new()); + while let Some(next) = work.pop() { + match next { + ParseWork::List { end, depth } => { + if depth >= MAX_NESTING { + value = Node::Group(Vec::new()); + } else { + work.push(ParseWork::Sequence { kind: SequenceKind::List(end), depth: depth + 1, out: Vec::new() }); + } } - Some('^') | Some('_') => { - let up = self.peek() == Some('^'); + ParseWork::Sequence { kind, depth, mut out } => { + self.skip_ws(); + let Some(c) = self.peek() else { + value = Node::Group(out); + continue; + }; + if let SequenceKind::List(Some(end)) = kind + && c == end + { + self.i += 1; + value = Node::Group(out); + continue; + } + if matches!(kind, SequenceKind::Fence) + && self.s.get(self.i..).is_some_and(|s| s.starts_with(&['\\', 'r', 'i', 'g', 'h', 't'])) + && !self.s.get(self.i + 6).is_some_and(char::is_ascii_alphabetic) + { + self.i += 6; + value = Node::Group(out); + continue; + } + if (c == '^' || c == '_') && !matches!(kind, SequenceKind::Fence) { + self.i += 1; + work.push(ParseWork::Script { kind, depth, out, up: c == '^' }); + work.push(ParseWork::Arg { depth }); + } else if matches!(kind, SequenceKind::One) { + value = Node::Group(out); + } else { + let group = matches!(kind, SequenceKind::Fence); + work.push(ParseWork::Append { kind, depth, out: std::mem::take(&mut out), group }); + work.push(if group { ParseWork::One { depth: depth + 1 } } else { ParseWork::Atom { depth } }); + } + } + ParseWork::Append { kind, depth, mut out, group } => { + if group { + if let Node::Group(nodes) = &mut value { + out.append(nodes); + } + } else { + out.push(std::mem::replace(&mut value, Node::Group(Vec::new()))); + } + work.push(ParseWork::Sequence { kind, depth, out }); + } + ParseWork::Arg { depth } => { + if depth >= MAX_NESTING { + value = Node::Group(Vec::new()); + continue; + } + self.skip_ws(); + if self.peek() == Some('{') { + self.i += 1; + work.push(ParseWork::List { end: Some('}'), depth: depth + 1 }); + } else { + work.push(ParseWork::Atom { depth: depth + 1 }); + } + } + ParseWork::Script { kind, depth, mut out, up } => { + let argument = std::mem::replace(&mut value, Node::Group(Vec::new())); + let base = out.pop().unwrap_or(Node::Group(Vec::new())); + out.push(attach_script(base, argument, up)); + work.push(ParseWork::Sequence { kind, depth, out }); + } + ParseWork::FractionNumerator { depth } => { + let numerator = std::mem::replace(&mut value, Node::Group(Vec::new())); + work.push(ParseWork::FractionDenominator { numerator }); + work.push(ParseWork::Arg { depth }); + } + ParseWork::FractionDenominator { numerator } => { + value = Node::Frac(Box::new(numerator), Box::new(std::mem::replace(&mut value, Node::Group(Vec::new())))); + } + ParseWork::SqrtIndex { outer, depth } => { + *self = outer; + let index = Some(Box::new(std::mem::replace(&mut value, Node::Group(Vec::new())))); + work.push(ParseWork::SqrtArgument { index }); + work.push(ParseWork::Arg { depth }); + } + ParseWork::SqrtArgument { index } => { + value = Node::Sqrt(index, Box::new(std::mem::replace(&mut value, Node::Group(Vec::new())))); + } + ParseWork::Fence { open } => { + let close = self.delimiter(); + value = Node::Fenced(open, Box::new(std::mem::replace(&mut value, Node::Group(Vec::new()))), close); + } + ParseWork::UntilRight { depth } => { + if depth >= MAX_NESTING { + value = Node::Group(Vec::new()); + } else { + work.push(ParseWork::Sequence { kind: SequenceKind::Fence, depth, out: Vec::new() }); + } + } + ParseWork::One { depth } => { + work.push(ParseWork::OneAtom { depth }); + work.push(ParseWork::Atom { depth }); + } + ParseWork::OneAtom { depth } => { + let out = vec![std::mem::replace(&mut value, Node::Group(Vec::new()))]; + work.push(ParseWork::Sequence { kind: SequenceKind::One, depth, out }); + } + ParseWork::Atom { depth } => { + self.skip_ws(); + let Some(c) = self.peek() else { + value = Node::Group(Vec::new()); + continue; + }; self.i += 1; - let a = self.arg(); - let base = out.pop().unwrap_or(Node::Group(vec![])); - let node = match base { - Node::Scripts { base, sup, sub } => { - if up { - Node::Scripts { base, sup: Some(Box::new(a)), sub } - } else { - Node::Scripts { base, sup, sub: Some(Box::new(a)) } + match c { + '{' => work.push(ParseWork::List { end: Some('}'), depth }), + '\\' => { + let name = self.command(); + match name.as_str() { + "frac" | "dfrac" | "tfrac" => { + work.push(ParseWork::FractionNumerator { depth }); + work.push(ParseWork::Arg { depth }); + } + "sqrt" => { + self.skip_ws(); + if self.peek() == Some('[') { + self.i += 1; + let start = self.i; + let mut end = start; + // Keep the existing first-']' index grammar, including EOF. + while let Some(c) = self.peek() { + self.i += 1; + if c == ']' { + break; + } + end = self.i; + } + let inner = self.s.get(start..end).unwrap_or_default(); + let outer = std::mem::replace(self, Parser { s: inner, i: 0 }); + work.push(ParseWork::SqrtIndex { outer, depth }); + work.push(ParseWork::List { end: None, depth: depth + 1 }); + } else { + work.push(ParseWork::SqrtArgument { index: None }); + work.push(ParseWork::Arg { depth }); + } + } + "left" => { + let open = self.delimiter(); + work.push(ParseWork::Fence { open }); + work.push(ParseWork::UntilRight { depth }); + } + "text" | "mathrm" | "textrm" | "operatorname" => value = Node::Text(self.raw_group()), + "sin" | "cos" | "tan" | "log" | "ln" | "exp" | "lim" | "max" | "min" => value = Node::Text(name), + "," => value = Node::Space(3.0 / 18.0), + ":" | ">" => value = Node::Space(4.0 / 18.0), + ";" => value = Node::Space(5.0 / 18.0), + "quad" => value = Node::Space(1.0), + "qquad" => value = Node::Space(2.0), + "!" => value = Node::Space(-3.0 / 18.0), + " " => value = Node::Space(0.25), + "{" => value = Node::Sym("{".into(), Class::Punct), + "}" => value = Node::Sym("}".into(), Class::Punct), + "%" | "$" | "#" | "&" | "_" => value = Node::Sym(name, Class::Plain), + n => { + value = match (big_op(n), symbol(n)) { + (Some(op), _) => Node::Big(op.into()), + (None, Some((s, cl))) => Node::Sym(s.into(), cl), + _ => Node::Text(format!("\\{n}")), + } + } } } - b => Node::Scripts { base: Box::new(b), sup: up.then(|| Box::new(a.clone())), sub: (!up).then(|| Box::new(a)) }, - }; - out.push(node); - } - Some(_) => { - if let Some(a) = self.atom() { - out.push(a); + c if c.is_ascii_digit() || c == '.' => { + let mut s = c.to_string(); + while let Some(d) = self.peek().filter(|d| d.is_ascii_digit() || *d == '.') { + s.push(d); + self.i += 1; + } + value = Node::Sym(s, Class::Number); + } + c if c.is_alphabetic() => value = Node::Sym(c.to_string(), Class::Ident), + '+' => value = Node::Sym("+".into(), Class::Bin), + '-' => value = Node::Sym("−".into(), Class::Bin), + '*' => value = Node::Sym("∗".into(), Class::Bin), + '<'..='>' => value = Node::Sym(c.to_string(), Class::Rel), + ',' | ';' => value = Node::Sym(c.to_string(), Class::Punct), + '\'' => value = Node::Sym("′".into(), Class::Plain), + c => value = Node::Sym(c.to_string(), Class::Plain), } } } } - self.depth -= 1; - out - } - fn list_until_right(&mut self) -> Vec { - if self.depth >= MAX_NESTING { - return Vec::new(); - } - let mut out = Vec::new(); - loop { - self.skip_ws(); - if self.peek().is_none() { - break; - } - if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) && !self.s.get(self.i + 6).is_some_and(|c| c.is_ascii_alphabetic()) { - self.i += 6; - break; - } - let mut p = Parser { s: self.s, i: self.i, depth: self.depth + 1 }; - let one = p.list_one(); - self.i = p.i; - out.extend(one); - } - out - } - /// One atom plus any scripts on it. - fn list_one(&mut self) -> Vec { - let mut out = Vec::new(); - if let Some(a) = self.atom() { - out.push(a); - } - loop { - self.skip_ws(); - match self.peek() { - Some('^') | Some('_') => { - let up = self.peek() == Some('^'); - self.i += 1; - let a = self.arg(); - let base = out.pop().unwrap_or(Node::Group(vec![])); - out.push(match base { - Node::Scripts { base, sub, .. } if up => Node::Scripts { base, sup: Some(Box::new(a)), sub }, - Node::Scripts { base, sup, .. } => Node::Scripts { base, sup, sub: Some(Box::new(a)) }, - b => Node::Scripts { base: Box::new(b), sup: up.then(|| Box::new(a.clone())), sub: (!up).then(|| Box::new(a)) }, - }); - } - _ => break, - } - } - out + value } } @@ -434,8 +539,7 @@ fn parse(src: &str) -> Node { fn parse_at(src: &str, depth: usize) -> Node { let chars: Vec = src.chars().collect(); - let mut p = Parser { s: &chars, i: 0, depth }; - Node::Group(p.list(None)) + Parser { s: &chars, i: 0 }.parse(depth) } /// Typesetting fonts and metrics. @@ -484,174 +588,251 @@ fn hbox(parts: Vec<(MBox, f64)>) -> MBox { out } -fn layout(f: &Fonts, n: &Node, size: f64) -> MBox { - let axis = size * 0.25; - match n { - Node::Sym(s, cl) => text_box(f, s, size, *cl == Class::Ident), - Node::Text(s) => text_box(f, s, size, false), - Node::Space(em) => MBox { w: em * size, ..Default::default() }, - Node::Group(list) => { - let mut parts = Vec::new(); - let mut prev: Option = None; - for (k, item) in list.iter().enumerate() { - let cl = match item { - Node::Sym(_, c) => Some(*c), - Node::Scripts { base, .. } => match &**base { - Node::Sym(_, c) => Some(*c), - _ => None, - }, - _ => None, - }; - // A leading + or − (or after another operator) is a sign, not a binary operator. - let cl = if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) { - Some(Class::Plain) - } else { - cl - }; - let gap = |c: Option| match c { - Some(Class::Bin) => size * 4.0 / 18.0, - Some(Class::Rel) => size * 5.0 / 18.0, - _ => 0.0, - }; - let mut before = gap(cl).max(gap(prev)); - if k == 0 { - before = 0.0; - } - if prev == Some(Class::Punct) { - before = before.max(size * 3.0 / 18.0); - } - parts.push((layout(f, item, size), before)); - prev = cl; - } - hbox(parts) - } - Node::Frac(a, b) => { - let s2 = size * 0.85; - let (na, nb) = (layout(f, a, s2), layout(f, b, s2)); - let w = na.w.max(nb.w) + size * 0.2; - let t = (size * 0.06).max(0.4); - let gap = size * 0.15; - let num_base = -(axis + t / 2.0 + gap + na.desc); - let den_base = -axis + t / 2.0 + gap + nb.asc; - let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() }; - out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t }); - let (wa, wb) = (na.w, nb.w); - out.els.extend(na.shifted((w - wa) / 2.0, num_base)); - out.els.extend(nb.shifted((w - wb) / 2.0, den_base)); - out - } - Node::Sqrt(idx, a) => { - let inner = layout(f, a, size); - let t = (size * 0.05).max(0.4); - let gap = size * 0.12; - let top = inner.asc + gap + t; - let bottom = inner.desc; - let hook = size * 0.55; - let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() }; - // The radical: a short rise, the long stroke down, then up to the bar. - out.els.push(Elem::Line { - pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)], - w: t, - }); - let iw = inner.w; - let _ = iw; - out.els.extend(inner.shifted(hook, 0.0)); - if let Some(i) = idx { - let ib = layout(f, i, size * 0.55); - let (ia, iw2) = (ib.asc, ib.w); - out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1)); - out.asc = out.asc.max(axis + size * 0.1 + ia); - } - out - } - Node::Big(op) => { - let mut b = text_box(f, op, size * 1.5, false); - b.asc = size * 1.05; - b.desc = size * 0.35; - for e in &mut b.els { - if let Elem::Text { y, .. } = e { - // Centre the glyph on the math axis. - *y = size * 0.32; - } - } - b - } - Node::Scripts { base, sup, sub } => { - let big = matches!(&**base, Node::Big(_)); - let b = layout(f, base, size); - let s2 = size * 0.7; - let (bw, basc, bdesc) = (b.w, b.asc, b.desc); - let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els }; - if big { - // Limits above and below. - let mut w = bw; - if let Some(sp) = sup { - let sb = layout(f, sp, s2); - let y = -basc - size * 0.1 - sb.desc; - w = w.max(sb.w); - let (sw, sa) = (sb.w, sb.asc); - out.els.extend(sb.shifted((bw - sw) / 2.0, y)); - out.asc = out.asc.max(-y + sa); - } - if let Some(sbn) = sub { - let sb = layout(f, sbn, s2); - let y = bdesc + size * 0.1 + sb.asc; - w = w.max(sb.w); - let (sw, sd) = (sb.w, sb.desc); - out.els.extend(sb.shifted((bw - sw) / 2.0, y)); - out.desc = out.desc.max(y + sd); - } - // Centre the operator over the widest limit. - let dx = (w - bw) / 2.0; - let els = std::mem::take(&mut out.els); - out = MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) }; - return out; - } - let mut w = bw; - if let Some(sp) = sup { - let sb = layout(f, sp, s2); - let y = -(size * 0.42).max(basc - sb.asc * 0.6); - w = w.max(bw + sb.w + size * 0.05); - let sa = sb.asc; - out.els.extend(sb.shifted(bw + size * 0.05, y)); - out.asc = out.asc.max(-y + sa); - } - if let Some(sbn) = sub { - let sb = layout(f, sbn, s2); - let y = (size * 0.2).max(bdesc * 0.8); - w = w.max(bw + sb.w + size * 0.05); - let sd = sb.desc; - out.els.extend(sb.shifted(bw + size * 0.05, y)); - out.desc = out.desc.max(y + sd); - } - out.w = w; - out - } - Node::Fenced(open, body, close) => { - let inner = layout(f, body, size); - let h = (inner.asc + inner.desc).max(size); - let k = h / size; - let ds = size * k.max(1.0); - let mk = |s: &str| -> MBox { - if s.is_empty() { - return MBox::default(); - } - let mut b = text_box(f, s, ds, false); - // Scaled delimiters centred on the content. - let mid = (inner.desc - inner.asc) / 2.0; - for e in &mut b.els { - if let Elem::Text { y, .. } = e { - *y = mid + ds * 0.25; - } - } - b.asc = inner.asc.max(size * 0.72); - b.desc = inner.desc.max(size * 0.22); - b - }; - let (l, r) = (mk(open), mk(close)); - hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)]) +struct GroupLayout<'a> { + items: std::iter::Enumerate>, + parts: Vec<(MBox, f64)>, + prev: Option, + size: f64, +} + +struct ScriptLayout { + out: MBox, + bw: f64, + basc: f64, + bdesc: f64, + big: bool, + size: f64, +} + +impl ScriptLayout { + fn new(base: MBox, big: bool, size: f64) -> Self { + Self { bw: base.w, basc: base.asc, bdesc: base.desc, out: base, big, size } + } + fn add(&mut self, script: MBox, up: bool) { + let Self { out, bw, basc, bdesc, big, size } = self; + let y = if *big { + if up { -*basc - *size * 0.1 - script.desc } else { *bdesc + *size * 0.1 + script.asc } + } else if up { + -(*size * 0.42).max(*basc - script.asc * 0.6) + } else { + (*size * 0.2).max(*bdesc * 0.8) + }; + out.w = out.w.max(if *big { script.w } else { *bw + script.w + *size * 0.05 }); + let (sa, sd) = (script.asc, script.desc); + let x = if *big { (*bw - script.w) / 2.0 } else { *bw + *size * 0.05 }; + out.els.extend(script.shifted(x, y)); + if up { + out.asc = out.asc.max(-y + sa); + } else { + out.desc = out.desc.max(y + sd); } } + fn finish(mut self) -> MBox { + if self.big { + let dx = (self.out.w - self.bw) / 2.0; + let els = std::mem::take(&mut self.out.els); + self.out.els = MBox { els, ..Default::default() }.shifted(dx, 0.0); + } + self.out + } +} + +enum LayoutWork<'a> { + Visit(&'a Node, f64), + GroupNext(GroupLayout<'a>), + GroupAppend(GroupLayout<'a>, f64), + FractionNumerator(&'a Node, f64), + FractionDenominator(MBox, f64), + SqrtBody(Option<&'a Node>, f64), + SqrtIndex(MBox, f64), + ScriptBase { sup: Option<&'a Node>, sub: Option<&'a Node>, big: bool, size: f64 }, + ScriptSup { state: ScriptLayout, sub: Option<&'a Node> }, + ScriptSub(ScriptLayout), + Fence(&'a str, &'a str, f64), +} + +/// Postorder evaluation moves each child's box into its parent before visiting the next child. +/// No ancestor retains a second flattened copy, and native stack use is independent of nesting. +fn layout(f: &Fonts, n: &Node, size: f64) -> MBox { + let mut work = vec![LayoutWork::Visit(n, size)]; + let mut value = MBox::default(); + while let Some(next) = work.pop() { + match next { + LayoutWork::Visit(node, size) => match node { + Node::Sym(s, cl) => value = text_box(f, s, size, *cl == Class::Ident), + Node::Text(s) => value = text_box(f, s, size, false), + Node::Space(em) => value = MBox { w: em * size, ..Default::default() }, + Node::Group(list) => { + work.push(LayoutWork::GroupNext(GroupLayout { items: list.iter().enumerate(), parts: Vec::new(), prev: None, size })) + } + Node::Frac(a, b) => { + work.push(LayoutWork::FractionNumerator(b, size)); + work.push(LayoutWork::Visit(a, size * 0.85)); + } + Node::Sqrt(index, body) => { + work.push(LayoutWork::SqrtBody(index.as_deref(), size)); + work.push(LayoutWork::Visit(body, size)); + } + Node::Scripts { base, sup, sub } => { + work.push(LayoutWork::ScriptBase { sup: sup.as_deref(), sub: sub.as_deref(), big: matches!(&**base, Node::Big(_)), size }); + work.push(LayoutWork::Visit(base, size)); + } + Node::Big(op) => { + let mut b = text_box(f, op, size * 1.5, false); + b.asc = size * 1.05; + b.desc = size * 0.35; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + *y = size * 0.32; + } + } + value = b; + } + Node::Fenced(open, body, close) => { + work.push(LayoutWork::Fence(open, close, size)); + work.push(LayoutWork::Visit(body, size)); + } + }, + LayoutWork::GroupNext(mut group) => { + if let Some((k, item)) = group.items.next() { + let cl = match item { + Node::Sym(_, c) => Some(*c), + Node::Scripts { base, .. } => match &**base { + Node::Sym(_, c) => Some(*c), + _ => None, + }, + _ => None, + }; + let cl = if cl == Some(Class::Bin) && (k == 0 || matches!(group.prev, Some(Class::Bin | Class::Rel | Class::Punct))) { + Some(Class::Plain) + } else { + cl + }; + let gap = |c: Option| match c { + Some(Class::Bin) => group.size * 4.0 / 18.0, + Some(Class::Rel) => group.size * 5.0 / 18.0, + _ => 0.0, + }; + let mut before = gap(cl).max(gap(group.prev)); + if k == 0 { + before = 0.0; + } + if group.prev == Some(Class::Punct) { + before = before.max(group.size * 3.0 / 18.0); + } + group.prev = cl; + let size = group.size; + work.push(LayoutWork::GroupAppend(group, before)); + work.push(LayoutWork::Visit(item, size)); + } else { + value = hbox(group.parts); + } + } + LayoutWork::GroupAppend(mut group, before) => { + group.parts.push((std::mem::take(&mut value), before)); + work.push(LayoutWork::GroupNext(group)); + } + LayoutWork::FractionNumerator(denominator, size) => { + work.push(LayoutWork::FractionDenominator(std::mem::take(&mut value), size)); + work.push(LayoutWork::Visit(denominator, size * 0.85)); + } + LayoutWork::FractionDenominator(na, size) => { + let nb = std::mem::take(&mut value); + let axis = size * 0.25; + let w = na.w.max(nb.w) + size * 0.2; + let t = (size * 0.06).max(0.4); + let gap = size * 0.15; + let num_base = -(axis + t / 2.0 + gap + na.desc); + let den_base = -axis + t / 2.0 + gap + nb.asc; + let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() }; + out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t }); + let (wa, wb) = (na.w, nb.w); + out.els.extend(na.shifted((w - wa) / 2.0, num_base)); + out.els.extend(nb.shifted((w - wb) / 2.0, den_base)); + value = out; + } + LayoutWork::SqrtBody(index, size) => { + let inner = std::mem::take(&mut value); + let axis = size * 0.25; + let t = (size * 0.05).max(0.4); + let gap = size * 0.12; + let top = inner.asc + gap + t; + let bottom = inner.desc; + let hook = size * 0.55; + let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() }; + out.els.push(Elem::Line { + pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)], + w: t, + }); + out.els.extend(inner.shifted(hook, 0.0)); + if let Some(index) = index { + work.push(LayoutWork::SqrtIndex(out, size)); + work.push(LayoutWork::Visit(index, size * 0.55)); + } else { + value = out; + } + } + LayoutWork::SqrtIndex(mut out, size) => { + let ib = std::mem::take(&mut value); + let (ia, iw) = (ib.asc, ib.w); + let axis = size * 0.25; + let hook = size * 0.55; + out.els.extend(ib.shifted((hook * 0.3 - iw).max(0.0) - 0.0, -axis - size * 0.1)); + out.asc = out.asc.max(axis + size * 0.1 + ia); + value = out; + } + LayoutWork::ScriptBase { sup, sub, big, size } => { + let state = ScriptLayout::new(std::mem::take(&mut value), big, size); + if let Some(sup) = sup { + work.push(LayoutWork::ScriptSup { state, sub }); + work.push(LayoutWork::Visit(sup, size * 0.7)); + } else if let Some(sub) = sub { + work.push(LayoutWork::ScriptSub(state)); + work.push(LayoutWork::Visit(sub, size * 0.7)); + } else { + value = state.finish(); + } + } + LayoutWork::ScriptSup { mut state, sub } => { + state.add(std::mem::take(&mut value), true); + if let Some(sub) = sub { + let size = state.size; + work.push(LayoutWork::ScriptSub(state)); + work.push(LayoutWork::Visit(sub, size * 0.7)); + } else { + value = state.finish(); + } + } + LayoutWork::ScriptSub(mut state) => { + state.add(std::mem::take(&mut value), false); + value = state.finish(); + } + LayoutWork::Fence(open, close, size) => { + let inner = std::mem::take(&mut value); + let h = (inner.asc + inner.desc).max(size); + let k = h / size; + let ds = size * k.max(1.0); + let mk = |s: &str| -> MBox { + if s.is_empty() { + return MBox::default(); + } + let mut b = text_box(f, s, ds, false); + let mid = (inner.desc - inner.asc) / 2.0; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + *y = mid + ds * 0.25; + } + } + b.asc = inner.asc.max(size * 0.72); + b.desc = inner.desc.max(size * 0.22); + b + }; + let (l, r) = (mk(open), mk(close)); + value = hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)]); + } + } + } + value } /// Typeset `latex` at `size` points: SVG bytes and the size in points. @@ -706,18 +887,189 @@ pub fn to_svg(latex: &str, size: f64) -> (Vec, (f64, f64)) { mod tests { use super::*; + // Frozen pre-repair arithmetic; called only for shallow fixtures, never deep input. + fn recursive_layout_reference(f: &Fonts, n: &Node, size: f64) -> MBox { + let axis = size * 0.25; + match n { + Node::Sym(s, cl) => text_box(f, s, size, *cl == Class::Ident), + Node::Text(s) => text_box(f, s, size, false), + Node::Space(em) => MBox { w: em * size, ..Default::default() }, + Node::Group(list) => { + let mut parts = Vec::new(); + let mut prev: Option = None; + for (k, item) in list.iter().enumerate() { + let cl = match item { + Node::Sym(_, c) => Some(*c), + Node::Scripts { base, .. } => match &**base { + Node::Sym(_, c) => Some(*c), + _ => None, + }, + _ => None, + }; + // A leading + or − (or after another operator) is a sign, not a binary operator. + let cl = if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) { + Some(Class::Plain) + } else { + cl + }; + let gap = |c: Option| match c { + Some(Class::Bin) => size * 4.0 / 18.0, + Some(Class::Rel) => size * 5.0 / 18.0, + _ => 0.0, + }; + let mut before = gap(cl).max(gap(prev)); + if k == 0 { + before = 0.0; + } + if prev == Some(Class::Punct) { + before = before.max(size * 3.0 / 18.0); + } + parts.push((recursive_layout_reference(f, item, size), before)); + prev = cl; + } + hbox(parts) + } + Node::Frac(a, b) => { + let s2 = size * 0.85; + let (na, nb) = (recursive_layout_reference(f, a, s2), recursive_layout_reference(f, b, s2)); + let w = na.w.max(nb.w) + size * 0.2; + let t = (size * 0.06).max(0.4); + let gap = size * 0.15; + let num_base = -(axis + t / 2.0 + gap + na.desc); + let den_base = -axis + t / 2.0 + gap + nb.asc; + let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() }; + out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t }); + let (wa, wb) = (na.w, nb.w); + out.els.extend(na.shifted((w - wa) / 2.0, num_base)); + out.els.extend(nb.shifted((w - wb) / 2.0, den_base)); + out + } + Node::Sqrt(idx, a) => { + let inner = recursive_layout_reference(f, a, size); + let t = (size * 0.05).max(0.4); + let gap = size * 0.12; + let top = inner.asc + gap + t; + let bottom = inner.desc; + let hook = size * 0.55; + let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() }; + // The radical: a short rise, the long stroke down, then up to the bar. + out.els.push(Elem::Line { + pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)], + w: t, + }); + let iw = inner.w; + let _ = iw; + out.els.extend(inner.shifted(hook, 0.0)); + if let Some(i) = idx { + let ib = recursive_layout_reference(f, i, size * 0.55); + let (ia, iw2) = (ib.asc, ib.w); + out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1)); + out.asc = out.asc.max(axis + size * 0.1 + ia); + } + out + } + Node::Big(op) => { + let mut b = text_box(f, op, size * 1.5, false); + b.asc = size * 1.05; + b.desc = size * 0.35; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + // Centre the glyph on the math axis. + *y = size * 0.32; + } + } + b + } + Node::Scripts { base, sup, sub } => { + let big = matches!(&**base, Node::Big(_)); + let b = recursive_layout_reference(f, base, size); + let s2 = size * 0.7; + let (bw, basc, bdesc) = (b.w, b.asc, b.desc); + let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els }; + if big { + // Limits above and below. + let mut w = bw; + if let Some(sp) = sup { + let sb = recursive_layout_reference(f, sp, s2); + let y = -basc - size * 0.1 - sb.desc; + w = w.max(sb.w); + let (sw, sa) = (sb.w, sb.asc); + out.els.extend(sb.shifted((bw - sw) / 2.0, y)); + out.asc = out.asc.max(-y + sa); + } + if let Some(sbn) = sub { + let sb = recursive_layout_reference(f, sbn, s2); + let y = bdesc + size * 0.1 + sb.asc; + w = w.max(sb.w); + let (sw, sd) = (sb.w, sb.desc); + out.els.extend(sb.shifted((bw - sw) / 2.0, y)); + out.desc = out.desc.max(y + sd); + } + // Centre the operator over the widest limit. + let dx = (w - bw) / 2.0; + let els = std::mem::take(&mut out.els); + out = MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) }; + return out; + } + let mut w = bw; + if let Some(sp) = sup { + let sb = recursive_layout_reference(f, sp, s2); + let y = -(size * 0.42).max(basc - sb.asc * 0.6); + w = w.max(bw + sb.w + size * 0.05); + let sa = sb.asc; + out.els.extend(sb.shifted(bw + size * 0.05, y)); + out.asc = out.asc.max(-y + sa); + } + if let Some(sbn) = sub { + let sb = recursive_layout_reference(f, sbn, s2); + let y = (size * 0.2).max(bdesc * 0.8); + w = w.max(bw + sb.w + size * 0.05); + let sd = sb.desc; + out.els.extend(sb.shifted(bw + size * 0.05, y)); + out.desc = out.desc.max(y + sd); + } + out.w = w; + out + } + Node::Fenced(open, body, close) => { + let inner = recursive_layout_reference(f, body, size); + let h = (inner.asc + inner.desc).max(size); + let k = h / size; + let ds = size * k.max(1.0); + let mk = |s: &str| -> MBox { + if s.is_empty() { + return MBox::default(); + } + let mut b = text_box(f, s, ds, false); + // Scaled delimiters centred on the content. + let mid = (inner.desc - inner.asc) / 2.0; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + *y = mid + ds * 0.25; + } + } + b.asc = inner.asc.max(size * 0.72); + b.desc = inner.desc.max(size * 0.22); + b + }; + let (l, r) = (mk(open), mk(close)); + hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)]) + } + } + } + #[test] fn parses_the_common_constructs() { let n = parse(r"\frac{a^2+b}{\sqrt[3]{x}}"); - let Node::Group(v) = n else { panic!() }; + let Node::Group(v) = &n else { panic!() }; assert!(matches!(&v[0], Node::Frac(_, _))); let n = parse(r"\sum_{i=1}^{n} i^2 \le \alpha"); - let Node::Group(v) = n else { panic!() }; + let Node::Group(v) = &n else { panic!() }; assert!(matches!(&v[0], Node::Scripts { base, sup: Some(_), sub: Some(_) } if matches!(**base, Node::Big(_)))); assert!(v.iter().any(|x| *x == Node::Sym("≤".into(), Class::Rel))); assert!(v.iter().any(|x| *x == Node::Sym("α".into(), Class::Ident))); let n = parse(r"\left( \frac{1}{2} \right) + \text{if } x"); - let Node::Group(v) = n else { panic!() }; + let Node::Group(v) = &n else { panic!() }; assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "(" && c == ")")); assert!(v.contains(&Node::Text("if ".into()))); } @@ -726,19 +1078,19 @@ mod tests { #[test] fn right_prefixed_commands_do_not_close_a_fence() { let n = parse(r"\left( x \rightarrow y \right)"); - let Node::Group(v) = n else { panic!() }; + let Node::Group(v) = &n else { panic!() }; assert_eq!(v.len(), 1, "{v:?}"); let Node::Fenced(o, body, c) = &v[0] else { panic!("{v:?}") }; assert!(o == "(" && c == ")", "{v:?}"); let Node::Group(b) = &**body else { panic!("{v:?}") }; assert_eq!(*b, vec![Node::Sym("x".into(), Class::Ident), Node::Sym("→".into(), Class::Rel), Node::Sym("y".into(), Class::Ident)]); // An unknown `\right…` command stays inside the fence too. - let Node::Group(v) = parse(r"\left[ \rightfoo \right]") else { panic!() }; + let Node::Group(v) = &parse(r"\left[ \rightfoo \right]") else { panic!() }; assert_eq!(v.len(), 1, "{v:?}"); assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "[" && c == "]"), "{v:?}"); // Real closers still close it. for (src, close) in [(r"\left( x \right)", ")"), (r"\left( x \right.", ""), (r"\left( x \right)y", ")")] { - let Node::Group(v) = parse(src) else { panic!() }; + let Node::Group(v) = &parse(src) else { panic!() }; assert!(matches!(&v[0], Node::Fenced(_, _, c) if c == close), "{src}: {v:?}"); } } @@ -767,9 +1119,223 @@ mod tests { format!("{}x{}", "{".repeat(100_000), "}".repeat(100_000)), format!("{}x", r"\frac".repeat(100_000)), format!("{}x", r"\left(".repeat(100_000)), + format!("{}z{}", r"\sqrt{".repeat(1_024), "}".repeat(1_024)), + format!("{}z{}", "x^{".repeat(1_024), "}".repeat(1_024)), ] { - let (svg, _) = to_svg(&latex, 12.0); + let (svg, (w, h)) = to_svg(&latex, 12.0); assert!(!svg.is_empty()); + assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}"); } } + /// Count actual tree shape without recursively comparing or formatting a deep AST. + fn shape(root: &Node) -> ([usize; 6], usize) { + let mut counts = [0; 6]; // groups, fractions, roots, fences, scripts, sentinel z + let mut deepest = 0; + let mut work = vec![(root, 1)]; + while let Some((node, depth)) = work.pop() { + deepest = deepest.max(depth); + match node { + Node::Group(nodes) => { + counts[0] += 1; + work.extend(nodes.iter().map(|node| (node, depth + 1))); + } + Node::Frac(a, b) => { + counts[1] += 1; + work.extend([(a.as_ref(), depth + 1), (b.as_ref(), depth + 1)]); + } + Node::Sqrt(index, body) => { + counts[2] += 1; + work.push((body, depth + 1)); + if let Some(index) = index { + work.push((index, depth + 1)); + } + } + Node::Fenced(_, body, _) => { + counts[3] += 1; + work.push((body, depth + 1)); + } + Node::Scripts { base, sup, sub } => { + counts[4] += 1; + work.push((base, depth + 1)); + if let Some(sup) = sup { + work.push((sup, depth + 1)); + } + if let Some(sub) = sub { + work.push((sub, depth + 1)); + } + } + Node::Sym(s, _) if s == "z" => counts[5] += 1, + _ => {} + } + } + (counts, deepest) + } + + #[test] + fn the_full_256_budget_keeps_deep_valid_formulas_intact() { + assert_eq!(MAX_NESTING, 256, "preserve the existing expression capability"); + let groups = MAX_NESTING - 1; + let arguments = (MAX_NESTING - 1) / 2; + let index_groups = MAX_NESTING - 3; + let cases = [ + (format!("{}z{}", "{".repeat(groups), "}".repeat(groups)), [groups + 1, 0, 0, 0, 0, 1], groups + 2), + (format!("{}z{}", r"\left(".repeat(groups), r"\right)".repeat(groups)), [groups + 1, 0, 0, groups, 0, 1], groups * 2 + 2), + (format!("{}z{}", r"\frac{".repeat(arguments), "}{y}".repeat(arguments)), [arguments * 2 + 1, arguments, 0, 0, 0, 1], arguments * 2 + 2), + (format!("{}z{}", r"\sqrt{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, arguments, 0, 0, 1], arguments * 2 + 2), + (format!("{}z{}", "x^{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2), + (format!("{}z{}", "x_{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2), + (format!(r"\sqrt[{}z{}]{{y}}", "{".repeat(index_groups), "}".repeat(index_groups)), [index_groups + 3, 0, 1, 0, 0, 1], index_groups + 4), + ]; + for (latex, counts, depth) in cases { + assert_eq!(shape(&parse(&latex)), (counts, depth), "all constructs must remain nested, not flatten into siblings"); + let (svg, (w, h)) = to_svg(&latex, 12.0); + assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}"); + assert!(String::from_utf8(svg).unwrap().contains(">z<"), "the innermost content must render"); + } + } + + #[test] + fn cap_and_index_returns_preserve_their_exact_cursor_semantics() { + let chars: Vec<_> = "x".chars().collect(); + let mut p = Parser { s: &chars, i: 0 }; + assert_eq!(p.parse(MAX_NESTING - 1), Node::Group(vec![Node::Sym("x".into(), Class::Ident)])); + assert_eq!(p.i, 1); + let mut p = Parser { s: &chars, i: 0 }; + assert_eq!(p.parse(MAX_NESTING), Node::Group(vec![])); + assert_eq!(p.i, 0, "a capped List does not consume its input"); + // Both the index subparse and radicand still have their entire old depth allowance. + let source = r"\sqrt[x]{y}z"; + assert_eq!( + parse_at(source, MAX_NESTING - 4), + Node::Group(vec![ + Node::Sqrt( + Some(Box::new(Node::Group(vec![Node::Sym("x".into(), Class::Ident)]))), + Box::new(Node::Group(vec![Node::Sym("y".into(), Class::Ident)])) + ), + Node::Sym("z".into(), Class::Ident), + ]) + ); + // At this starting depth the index and braced body are capped. The outer cursor is + // restored after ']', then the old non-consuming body cap leaves y/}/z as siblings. + let chars: Vec<_> = source.chars().collect(); + let mut p = Parser { s: &chars, i: 0 }; + assert_eq!( + p.parse(MAX_NESTING - 2), + Node::Group(vec![ + Node::Sqrt(Some(Box::new(Node::Group(vec![]))), Box::new(Node::Group(vec![]))), + Node::Sym("y".into(), Class::Ident), + Node::Sym("}".into(), Class::Plain), + Node::Sym("z".into(), Class::Ident), + ]) + ); + assert_eq!(p.i, chars.len()); + // Do not change the permissive first-']' index grammar into balanced bracket parsing. + assert_eq!( + parse(r"\sqrt[x[y]z]q"), + Node::Group(vec![ + Node::Sqrt( + Some(Box::new(Node::Group(vec![ + Node::Sym("x".into(), Class::Ident), + Node::Sym("[".into(), Class::Plain), + Node::Sym("y".into(), Class::Ident) + ]))), + Box::new(Node::Sym("z".into(), Class::Ident)) + ), + Node::Sym("]".into(), Class::Plain), + Node::Sym("q".into(), Class::Ident), + ]) + ); + } + + #[test] + fn heap_continuations_preserve_small_grammar_and_script_precedence() { + let sym = |s: &str| Node::Sym(s.into(), Class::Ident); + assert_eq!( + parse("x^{a}_{b}^{c}_{d}"), + Node::Group(vec![Node::Scripts { + base: Box::new(sym("x")), + sup: Some(Box::new(Node::Group(vec![sym("c")]))), + sub: Some(Box::new(Node::Group(vec![sym("d")]))), + }]) + ); + assert_eq!( + parse(r"\left( ^x \right)"), + Node::Group(vec![Node::Fenced("(".into(), Box::new(Node::Group(vec![Node::Sym("^".into(), Class::Plain), sym("x")])), ")".into())]) + ); + assert_eq!(parse(r"\frac{x"), Node::Group(vec![Node::Frac(Box::new(Node::Group(vec![sym("x")])), Box::new(Node::Group(vec![])))])); + assert_eq!( + parse(r"\text{a{b}}\{\}\_\unknown"), + Node::Group(vec![ + Node::Text("a{b}".into()), + Node::Sym("{".into(), Class::Punct), + Node::Sym("}".into(), Class::Punct), + Node::Sym("_".into(), Class::Plain), + Node::Text(r"\unknown".into()) + ]) + ); + } + + #[test] + fn heap_layout_preserves_the_existing_element_order_and_geometry() { + let db = FontDb::global(); + let f = Fonts { upright: db.face(FAMILY, "Regular"), italic: db.face(FAMILY, "Italic"), fallback: db.face(FALLBACK, "Regular") }; + for latex in [ + "E = mc^2", + "x^{a}_{b}^{c}_{d}", + r"\frac{a^2+b}{\sqrt[3]{x}}", + r"\sum_{i=0}^{n} i^2", + r"\int_a^b x", + r"\left( x \rightarrow y \right) + \text{if } x", + r"\left. x \right]", + r"-x,+y;\quad z", + r"\sqrt[x[y]z]q", + r"\frac{x", + r"\left( ^x \right)", + ] { + let tree = parse(latex); + let actual = layout(&f, &tree, 12.0); + let expected = recursive_layout_reference(&f, &tree, 12.0); + assert_eq!((actual.w, actual.asc, actual.desc, actual.els), (expected.w, expected.asc, expected.desc, expected.els), "{latex}"); + } + } + + fn mixed_chain(depth: usize) -> Node { + let mut node = Node::Sym("z".into(), Class::Ident); + for i in 0..depth { + node = match i % 5 { + 0 => Node::Group(vec![node]), + 1 => Node::Frac(Box::new(node), Box::new(Node::Text("d".into()))), + 2 => Node::Sqrt(Some(Box::new(Node::Text("3".into()))), Box::new(node)), + 3 => Node::Scripts { base: Box::new(node), sup: Some(Box::new(Node::Text("p".into()))), sub: Some(Box::new(Node::Text("q".into()))) }, + _ => Node::Fenced("(".into(), Box::new(node), ")".into()), + }; + } + node + } + + #[test] + fn deep_nodes_drop_when_replaced_or_owned_by_unwinding_continuations() { + // Far beyond the parser budget: this directly proves the private destructor's traversal. + drop(mixed_chain(50_000)); + for up in [false, true] { + let old = Box::new(mixed_chain(50_000)); + let other = Box::new(Node::Text("other".into())); + let (sup, sub) = if up { (Some(old), Some(other)) } else { (Some(other), Some(old)) }; + let root = attach_script(Node::Scripts { base: Box::new(Node::Big("∑".into())), sup, sub }, Node::Text("new".into()), up); + let Node::Scripts { base, sup, sub } = &root else { panic!("script wrapper") }; + assert!(matches!(&**base, Node::Big(s) if s == "∑")); + let (replaced, retained) = if up { (sup, sub) } else { (sub, sup) }; + assert!(matches!(replaced.as_deref(), Some(Node::Text(s)) if s == "new")); + assert!(matches!(retained.as_deref(), Some(Node::Text(s)) if s == "other")); + } + let result = std::panic::catch_unwind(|| { + // Actual continuation variants own completed children while awaiting later input. + let _partial = [ + ParseWork::FractionDenominator { numerator: mixed_chain(25_000) }, + ParseWork::SqrtArgument { index: Some(Box::new(mixed_chain(25_000))) }, + ]; + panic!("exercise partial-parser-state unwinding"); + }); + assert!(result.is_err(), "the intentional panic must actually unwind the owned frames"); + } } diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index eed263f04..7ec62d073 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -408,7 +408,7 @@ fn remove_emptied(dir: &Path) { let path = e.path(); let name = e.file_name(); let name = name.to_string_lossy(); - let numbered = path.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.parse::().is_ok()); + let numbered = path.file_stem().and_then(std::ffi::OsStr::to_str).is_some_and(|s| s.parse::().is_ok()); // Metadata whose document has gone, and a file a crash left half written. if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) { left.push(path); @@ -726,8 +726,8 @@ fn occupied_uids(dir: &Path) -> Result> { for entry in entries { let entry = entry.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; let path = entry.path(); - if matches!(path.extension().and_then(|s| s.to_str()), Some("json" | "designcraft")) - && let Some(uid) = path.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse().ok()) + if matches!(path.extension().and_then(std::ffi::OsStr::to_str), Some("json" | "designcraft")) + && let Some(uid) = path.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse().ok()) { ids.insert(uid); } @@ -756,10 +756,10 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { let Ok(rd) = std::fs::read_dir(dir) else { return out }; for e in rd.flatten() { let p = e.path(); - if p.extension().and_then(|x| x.to_str()) != Some("designcraft") { + if p.extension().and_then(std::ffi::OsStr::to_str) != Some("designcraft") { continue; } - let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::().ok()) else { continue }; + let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::().ok()) else { continue }; // Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none. let meta = std::fs::read_to_string(p.with_extension("json")) .ok() diff --git a/crates/engine/src/tests_ime.rs b/crates/engine/src/tests_ime.rs index 266470914..73100c428 100644 --- a/crates/engine/src/tests_ime.rs +++ b/crates/engine/src/tests_ime.rs @@ -30,6 +30,47 @@ fn steps(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } +#[test] +fn ime_byte_deletion_rounds_inward_from_the_requested_count() { + // a: 0..1, é: 1..3, 😀: 3..7, b: 7..8. + let text = "aé😀b"; + assert_eq!(ime_delete_ranges(text, 7, 7, 4, 0, true), Some((3..7, 7..7)), "the whole emoji"); + assert_eq!(ime_delete_ranges(text, 7, 7, 6, 0, true), Some((1..7, 7..7)), "é and the emoji"); + assert_eq!(ime_delete_ranges(text, 7, 7, 3, 0, true), Some((7..7, 7..7)), "a count starting inside the emoji leaves it"); + assert_eq!(ime_delete_ranges(text, 7, 7, 100, 0, true), Some((0..7, 7..7)), "more bytes than the text takes it all"); + // The scan starts at the requested count, not at the start of the story. + let long = format!("{}é", "x".repeat(20_000)); + let end = long.len(); + assert_eq!(ime_delete_ranges(&long, end, end, 3, 0, true), Some((end - 3..end, end..end)), "x and é"); + assert_eq!(ime_delete_ranges(&long, end, end, 2, 0, true), Some((end - 2..end, end..end)), "the whole é"); + assert_eq!(ime_delete_ranges(&long, end, end, 1, 0, true), Some((end..end, end..end)), "one byte inside é leaves it"); +} + +#[test] +fn ime_delete_reports_the_ranges_it_applied() { + let mut s = session(); + let sid = typing_into(&mut s, "aé😀b"); + s.execute("text.select", &json!({"story": sid, "anchor": 1, "focus": 3})).unwrap(); + let m = s.ime_delete_surrounding(2, 4, true).unwrap().expect("the delete changed the text"); + assert_eq!(m.deleted_before, 0..1, "the byte before the selection"); + assert_eq!(m.deleted_after, 3..7, "the emoji after it"); + assert_eq!((m.anchor_before, m.focus_before), (1, 3)); + assert_eq!((m.anchor_after, m.focus_after), (0, 2), "the selection stays selected, shifted left"); + assert_eq!((m.text_len_before, m.text_len_after), (8, 3)); + assert!(m.inserted.is_empty()); + assert_eq!(text(&mut s, sid), "éb"); + // A request that removes nothing is reported as no mutation at all. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.ime_delete_surrounding(0, 0, false).unwrap().is_none()); + // A commit reports the text it typed and replaces the selection. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); + let m = s.ime_commit("日", ViewInfo::default()).unwrap().expect("the commit changed the text"); + assert_eq!(m.inserted, "日"); + assert!(m.deleted_before.is_empty() && m.deleted_after.is_empty()); + assert_eq!((m.anchor_after, m.focus_after), (3, 3)); + assert_eq!(text(&mut s, sid), "日b"); +} + #[test] fn ime_composition_shows_in_place_and_commits_as_one_step() { // Romaji → kana → conversion → commit, as the macOS Japanese IME sends it. diff --git a/crates/engine/src/tests_linked_paths.rs b/crates/engine/src/tests_linked_paths.rs index eb4c953ec..448ffedb9 100644 --- a/crates/engine/src/tests_linked_paths.rs +++ b/crates/engine/src/tests_linked_paths.rs @@ -105,3 +105,73 @@ fn a_relative_link_resolves_beside_the_document() { assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_relative_link_in_an_unsaved_document_is_missing() { + // With no document path there is nothing to resolve against; the working directory is not used. + let mut s = document_with_links(Path::new("doc.designcraft"), &["logo.png", "img/logo.png"]); + s.doc_mut().unwrap().path = None; + let (rows, seen) = crate::io::touched(|| s.execute("links.list", &json!({})).unwrap()); + assert!(seen.is_empty(), "{seen:?}"); + for row in rows.as_array().unwrap() { + assert_eq!(row["status"], "missing", "{row}"); + assert_eq!(row["refused"], "the path is not valid", "{row}"); + } +} + +#[cfg(unix)] +#[test] +fn relink_folder_never_leaves_the_chosen_folder() { + // A stored name with no separator can still be a `file:` URL that decodes to a path elsewhere. + let dir = folder("relink-outside"); + let chosen = dir.join("chosen"); + std::fs::create_dir_all(&chosen).unwrap(); + let outside = dir.join("outside.png"); + std::fs::write(&outside, png()).unwrap(); + let encoded = format!("file:{}", outside.to_string_lossy().replace('/', "%2F")); + let mut s = document_with_links(&dir.join("doc.designcraft"), &[&encoded]); + let (out, seen) = + crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap()); + assert_eq!(out["relinked"], 0, "{out}"); + assert!(!seen.contains(&outside), "{seen:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn relink_folder_takes_the_chosen_folder_as_one_folder() { + // `a\b` is one folder name on Unix; read as a stored path it would be `a/b`. + let dir = folder("relink-backslash"); + let chosen = dir.join("a\\b"); + std::fs::create_dir_all(&chosen).unwrap(); + std::fs::create_dir_all(dir.join("a/b")).unwrap(); + let elsewhere = dir.join("a/b/x.png"); + std::fs::write(&elsewhere, png()).unwrap(); + let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]); + let (out, seen) = + crate::io::touched(|| s.execute("links.relinkFolder", &json!({"dir": chosen.to_string_lossy(), "missingOnly": false})).unwrap()); + assert_eq!(out["relinked"], 0, "{out}"); + assert!(!seen.contains(&elsewhere), "{seen:?}"); + + // A folder given with `..` is still that folder. + let real = dir.join("real"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("x.png"), png()).unwrap(); + let roundabout = real.join("..").join("real"); + let out = s.execute("links.relinkFolder", &json!({"dir": roundabout.to_string_lossy(), "missingOnly": false})).unwrap(); + assert_eq!(out["relinked"], 1, "{out}"); + + // Through a symlink, `alias/..` is the link target's parent, not the folder holding `alias`. + let inner = dir.join("inner"); + std::fs::create_dir_all(inner.join("sub")).unwrap(); + std::os::unix::fs::symlink(inner.join("sub"), dir.join("alias")).unwrap(); + let beside_alias = dir.join("x.png"); + std::fs::write(&beside_alias, png()).unwrap(); + let mut s = document_with_links(&dir.join("doc.designcraft"), &["x.png"]); + let (out, seen) = crate::io::touched(|| { + s.execute("links.relinkFolder", &json!({"dir": dir.join("alias").join("..").to_string_lossy(), "missingOnly": false})).unwrap() + }); + assert_eq!(out["relinked"], 0, "{out}"); + assert!(!seen.contains(&beside_alias), "{seen:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests_roots.rs b/crates/engine/src/tests_roots.rs index 39ab5e57d..0f9b9654e 100644 --- a/crates/engine/src/tests_roots.rs +++ b/crates/engine/src/tests_roots.rs @@ -23,7 +23,7 @@ fn folders(name: &str) -> (PathBuf, Arc) { for d in ["in", "out", "outside"] { std::fs::create_dir_all(base.join(d)).unwrap(); } - let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap(); (base, roots) } diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 65d069bac..1ff7fbe5f 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1,12 +1,14 @@ //! Tool hosting: the active tool receives pointer/key events and its actions are executed here. +use std::ops::Range; use std::sync::Arc; +use designcraft_doc::{CellAddr, StoryId}; use designcraft_geom::Unit; pub use designcraft_tools::SnapView; use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolContext, ToolKey}; use serde::Serialize; -use serde_json::Value; +use serde_json::{Value, json}; use crate::{EngineError, HistoryEntry, Interaction, Result, Session, record_undo}; @@ -346,3 +348,294 @@ impl Session { } } } + +/// The committed text around the Type tool's IME caret. +/// +/// While an IME composition is active the marked text is only an interaction preview, so this +/// borrows the interaction's committed snapshot and selection instead of the previewed document. +/// `anchor` and `focus` are UTF-8 byte offsets into `text`; `focus` is the primary caret end. +#[derive(Clone, Copy, Debug)] +pub struct ImeTextContext<'a> { + /// Stable document identity (a new document gets a new value). + pub document_uid: u64, + /// Document-state identity: changes when the state is recreated even if `document_uid` is + /// preserved (File › Revert). + pub document_epoch: u64, + /// Story being edited, or the story that owns the edited table cell. + pub story: StoryId, + /// Edited table cell, when the caret is in one. + pub cell: Option, + /// Committed logical story text, without the current IME pre-edit. + pub text: &'a str, + /// Selection anchor (the end that stays put under Shift-arrow selection). + pub anchor: usize, + /// Primary selection end (the caret). + pub focus: usize, +} + +/// What an accepted IME mutation applied to the committed story text. +/// +/// The engine reports this from the whole story, not from the bounded outgoing IME window, so a +/// successful mutation can be described exactly even when the deleted text lies outside that +/// window. `deleted_before` and `deleted_after` are byte ranges of the text before the mutation; +/// `inserted` is the text the mutation typed at the selection. Invalid or unreportable mutations +/// return `None` rather than a guessed description. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImeMutation { + /// Stable document identity of the mutation. + pub document_uid: u64, + /// Document-state identity (see [`ImeTextContext::document_epoch`]). + pub document_epoch: u64, + /// Story (or the story owning the edited cell) that changed. + pub story: StoryId, + /// Edited table cell, when the caret was in one. + pub cell: Option, + /// Committed selection before and after the mutation (UTF-8 byte offsets). + pub anchor_before: usize, + pub focus_before: usize, + pub anchor_after: usize, + pub focus_after: usize, + /// Committed story text length before and after the mutation. + pub text_len_before: usize, + pub text_len_after: usize, + /// Text removed before and after the selection (empty when nothing was removed). + pub deleted_before: Range, + pub deleted_after: Range, + /// Text typed at the selection, after the commit normalization. + pub inserted: String, +} + +/// The identity, committed selection and text length at the IME caret. +#[derive(Clone, Copy, Debug)] +struct ImeState { + document_uid: u64, + document_epoch: u64, + story: StoryId, + cell: Option, + anchor: usize, + focus: usize, + text_len: usize, +} + +/// The ranges a successful `text.deleteSurrounding` reports it removed. +fn ime_ranges_from_command(r: &Value) -> Option<(Range, Range)> { + let range = |key: &str| { + let arr = r.get(key)?.as_array()?; + let start = usize::try_from(arr.first()?.as_u64()?).ok()?; + let end = usize::try_from(arr.get(1)?.as_u64()?).ok()?; + (start <= end).then_some(start..end) + }; + Some((range("deletedBefore")?, range("deletedAfter")?)) +} + +/// The byte ranges an IME delete-surrounding request removes around a selection. +/// +/// The returned pair is `(before_selection, after_selection)`. Counts exclude the selection +/// itself and the current pre-edit. Character counts are Unicode scalar values. Byte counts +/// round inward: a count that ends inside a character leaves that whole character. Invalid or +/// non-boundary selection offsets return `None` rather than guessed ranges. +pub fn ime_delete_ranges(text: &str, anchor: usize, focus: usize, before: usize, after: usize, bytes: bool) -> Option<(Range, Range)> { + if anchor > text.len() || focus > text.len() || !text.is_char_boundary(anchor) || !text.is_char_boundary(focus) { + return None; + } + let min = anchor.min(focus); + let max = anchor.max(focus); + if bytes { + // Start at the requested byte offset and step at most three bytes to the next character + // boundary: the scan is bounded by the count, not by the caret's offset in the story. + let from = min.saturating_sub(before).min(min); + let mut before_start = from; + while before_start < min && !text.is_char_boundary(before_start) { + before_start += 1; + } + let target = max.saturating_add(after).min(text.len()); + let mut after_end = max; + for (i, c) in text[max..].char_indices() { + let end = max + i + c.len_utf8(); + if end <= target { + after_end = end; + } else { + break; + } + } + Some((before_start..min, max..after_end)) + } else { + let before_start = if before == 0 { + min + } else { + let mut seen = 0usize; + let mut start = 0usize; + for (i, _) in text[..min].char_indices().rev() { + seen += 1; + start = i; + if seen == before { + break; + } + } + if seen < before { 0 } else { start } + }; + let mut after_end = max; + if after > 0 { + let mut seen = 0usize; + for (i, c) in text[max..].char_indices() { + seen += 1; + after_end = max + i + c.len_utf8(); + if seen == after { + break; + } + } + } + Some((before_start..min, max..after_end)) + } +} + +impl Session { + /// The committed text and selection at the Type tool's IME caret. + /// + /// Returns `None` without an insertion point, without a committed story, or while a + /// composition has no interaction snapshot to describe its committed text. The query never + /// copies the story. + pub fn ime_text_context(&self) -> Option> { + let st = self.active()?; + let (doc, ts) = if self.tool.composing() { + let it = st.interaction.as_ref()?; + (it.doc.as_ref(), it.selection.text?) + } else { + (st.doc.as_ref(), st.selection.text?) + }; + let story = doc.text_story(ts.story, ts.cell)?; + if ts.anchor > story.text.len() + || ts.focus > story.text.len() + || !story.text.is_char_boundary(ts.anchor) + || !story.text.is_char_boundary(ts.focus) + { + // A stale or corrupt insertion point: report unavailable rather than inventing offsets. + return None; + } + Some(ImeTextContext { + document_uid: st.uid, + document_epoch: st.identity_epoch, + story: ts.story, + cell: ts.cell, + text: &story.text, + anchor: ts.anchor, + focus: ts.focus, + }) + } + + /// Delete the text an IME asked to remove around the selection, as one undo step. + /// + /// The mutation goes through the `text.deleteSurrounding` command, so it restores a Data Merge + /// preview, records undo, auto-sizes, journals and is panic-guarded like any other edit. + /// The selection itself and any current pre-edit are excluded. Character counts are scalar + /// values; byte counts round inward to whole characters. A selection keeps its direction and + /// is remapped past the deleted text. + pub fn ime_delete_surrounding(&mut self, before: usize, after: usize, bytes: bool) -> Result> { + if !self.ime_deletion_changes_text(before, after, bytes)? { + return Ok(None); + } + let Some(pre) = self.ime_state() else { return Ok(None) }; + self.end_preedit_without_typing()?; + let r = self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes}))?; + Ok(self.ime_mutation_from_command(pre, &r, String::new())) + } + + /// The IME committed `text`: one `text.insert`, reported as the mutation it applied. + pub fn ime_commit(&mut self, text: &str, view: ViewInfo) -> Result> { + let Some(pre) = self.ime_state() else { return self.tool_ime_commit(text, view).map(|()| None) }; + self.tool_ime_commit(text, view)?; + let inserted = designcraft_tools::ime_commit_text(text); + // A commit replaces the selection: the removed ranges are empty at its two ends. + let (start, end) = (pre.anchor.min(pre.focus), pre.anchor.max(pre.focus)); + Ok(self.ime_mutation_from_states(pre, start..start, end..end, inserted)) + } + + /// Delete surrounding text and insert the IME commit in one undo step. + /// + /// This is the normal delete-surrounding-then-commit batch: both mutations are one + /// `text.deleteSurrounding` command, so one Undo restores the state before the batch. + pub fn ime_commit_after_delete(&mut self, text: &str, view: ViewInfo, before: usize, after: usize, bytes: bool) -> Result> { + if !self.ime_deletion_changes_text(before, after, bytes)? { + return self.ime_commit(text, view); + } + let Some(pre) = self.ime_state() else { return Ok(None) }; + self.end_preedit_without_typing()?; + let inserted = designcraft_tools::ime_commit_text(text); + let r = self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes, "commit": inserted.clone()}))?; + Ok(self.ime_mutation_from_command(pre, &r, inserted)) + } + + /// The identity, committed selection and text length of the IME caret's story. + fn ime_state(&self) -> Option { + let context = self.ime_text_context()?; + Some(ImeState { + document_uid: context.document_uid, + document_epoch: context.document_epoch, + story: context.story, + cell: context.cell, + anchor: context.anchor, + focus: context.focus, + text_len: context.text.len(), + }) + } + + /// Report the mutation the delete command applied, from its ranges and the states around it. + fn ime_mutation_from_command(&self, before: ImeState, r: &Value, inserted: String) -> Option { + let (deleted_before, deleted_after) = ime_ranges_from_command(r)?; + self.ime_mutation_from_states(before, deleted_before, deleted_after, inserted) + } + + fn ime_mutation_from_states( + &self, + before: ImeState, + deleted_before: Range, + deleted_after: Range, + inserted: String, + ) -> Option { + let after = self.ime_state()?; + // The mutation must have stayed in the story it began in. + if after.document_uid != before.document_uid + || after.document_epoch != before.document_epoch + || after.story != before.story + || after.cell != before.cell + { + return None; + } + Some(ImeMutation { + document_uid: before.document_uid, + document_epoch: before.document_epoch, + story: before.story, + cell: before.cell, + anchor_before: before.anchor, + focus_before: before.focus, + anchor_after: after.anchor, + focus_after: after.focus, + text_len_before: before.text_len, + text_len_after: after.text_len, + deleted_before, + deleted_after, + inserted, + }) + } + + fn end_preedit_without_typing(&mut self) -> Result<()> { + if !self.tool.composing() { + return Ok(()); + } + match self.with_ctx(ViewInfo::default(), |s, cx| s.tool.ime_preedit(cx, "", None)) { + Some(actions) => self.run_actions(actions), + None => { + self.tool = designcraft_tools::create(self.tool.id()); + Ok(()) + } + } + } + + fn ime_deletion_changes_text(&self, before: usize, after: usize, bytes: bool) -> Result { + let Some(context) = self.ime_text_context() else { return Ok(false) }; + let Some((before_range, after_range)) = ime_delete_ranges(context.text, context.anchor, context.focus, before, after, bytes) else { + return Err(EngineError::Other("the IME text selection is not on character boundaries".into())); + }; + Ok(!before_range.is_empty() || !after_range.is_empty()) + } +} diff --git a/crates/engine/tests/it/pdf_backend.rs b/crates/engine/tests/it/pdf_backend.rs index 6b4f93081..5344dc6f7 100644 --- a/crates/engine/tests/it/pdf_backend.rs +++ b/crates/engine/tests/it/pdf_backend.rs @@ -207,7 +207,9 @@ fn failed_layer_rewrite_aborts_export_instead_of_revealing_hidden_content() { session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); let placed = session.execute("file.place", &json!({"base64": base64_encode(&bytes), "name": "missing-layers.pdf", "x": 0, "y": 0, "width": 100})).unwrap(); - session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap(); + // Commands now reject unrewritable choices. Simulate stale persisted state after relinking. + let id: designcraft_doc::ItemId = serde_json::from_value(placed["id"].clone()).unwrap(); + std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().doc).item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()]; let before = session.execute("file.serialize", &json!({})).unwrap(); let result = session.execute("file.exportPdf", &json!({})); assert!(result.is_err(), "failed layer hiding must not export the original visible layers"); diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 1df759608..62de05cb7 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -450,11 +450,12 @@ fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap {} } - if c.underline { - out.push_str("text-decoration: underline; "); - } - if c.strikethrough { - out.push_str("text-decoration: line-through; "); + // One declaration: a later `text-decoration` would replace the earlier one. + match (c.underline, c.strikethrough) { + (true, true) => out.push_str("text-decoration: underline line-through; "), + (true, false) => out.push_str("text-decoration: underline; "), + (false, true) => out.push_str("text-decoration: line-through; "), + (false, false) => {} } if let Some(h) = hex(doc, &c.fill, c.fill_tint) { let _ = write!(out, "color: {h}; "); @@ -785,6 +786,39 @@ impl<'a, 'f> StoryWriter<'a, 'f> { close_blocks(&mut list, &mut pre, out); } + /// The markup for one run's text `raw` (starting at byte `a` of the story): anchored objects + /// and notes where their marks are. + fn run_markup(&mut self, st: &Story, raw: &str, a: usize, depth: usize, after: &mut String) -> String { + let doc = self.doc; + let mut t = String::new(); + let mut seg_start = 0; + // Index of the next anchored object: marks count from the story start, as + // `Story::object_at` counts them (counted once, at the run's first mark). + let mut mark: Option = None; + for (i, c) in raw.char_indices() { + if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK { + continue; + } + t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default())); + seg_start = i + c.len_utf8(); + if c == OBJECT_MARK { + let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count())); + mark = Some(k.saturating_add(1)); + if let Some(o) = st.objects.get(k) { + t.push_str(&self.object(o)); + } + } else if depth < MAX_DEPTH { + if c == FOOTNOTE_REF { + self.footnote(st, a + i, depth, &mut t, after); + } else { + self.endnote(st, a + i, depth, &mut t); + } + } + } + t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default())); + t + } + /// The runs of paragraph `para` of `st`; footnotes go to `after`. fn runs(&mut self, st: &Story, para: std::ops::Range, depth: usize, out: &mut String, after: &mut String) { let doc = self.doc; @@ -799,32 +833,7 @@ impl<'a, 'f> StoryWriter<'a, 'f> { continue; } let Some(raw) = st.text.get(a..b) else { continue }; - let mut t = String::new(); - let mut seg_start = 0; - // Index of the next anchored object: marks count from the story start, as - // `Story::object_at` counts them (counted once, at the run's first mark). - let mut mark: Option = None; - for (i, c) in raw.char_indices() { - if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK { - continue; - } - t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default())); - seg_start = i + c.len_utf8(); - if c == OBJECT_MARK { - let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count())); - mark = Some(k.saturating_add(1)); - if let Some(o) = st.objects.get(k) { - t.push_str(&self.object(o)); - } - } else if depth < MAX_DEPTH { - if c == FOOTNOTE_REF { - self.footnote(st, a + i, depth, &mut t, after); - } else { - self.endnote(st, a + i, depth, &mut t); - } - } - } - t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default())); + let t = self.run_markup(st, raw, a, depth, after); let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { let (tag, class) = export_mapping(&doc.styles, &f.style, true, &self.cc); (tag, format!(" class=\"{}\"", xml_attr(&class))) @@ -2223,4 +2232,21 @@ mod tests { let last = "
  • 1 End note
  • "; assert_eq!(link_across_chapters(last, "content2.xhtml", &files), "
  • 1 End note
  • "); } + + #[test] + fn char_css_joins_underline_and_strikethrough_in_one_declaration() { + let d = Document::new(&NewDocument::default()); + let css = |u: bool, s: bool| { + let c = CharProps { underline: u, strikethrough: s, ..CharProps::default() }; + let mut out = String::new(); + char_css(&d, &c, 12.0, &BTreeMap::new(), &mut out); + out + }; + let both = css(true, true); + assert_eq!(both.matches("text-decoration:").count(), 1, "{both}"); + assert!(both.contains("text-decoration: underline line-through;"), "{both}"); + assert!(css(true, false).contains("text-decoration: underline;")); + assert!(css(false, true).contains("text-decoration: line-through;")); + assert!(!css(false, false).contains("text-decoration")); + } } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 352e0952b..1aa4ec068 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, Mutex, PoisonError, RwLock}; use kurbo::BezPath; use skrifa::instance::{Location, LocationRef, Size}; @@ -148,12 +148,12 @@ pub struct FaceRef(&'static FontFace); impl FaceRef { /// The handle for a loaded face (memoised per face). pub fn of(face: &Arc) -> FaceRef { - static LEAKED: RwLock>> = RwLock::new(Vec::new()); + static LEAKED: RwLock>> = RwLock::new(Vec::new()); let id = face.id as usize; - if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) { + if let Some(Some(f)) = LEAKED.read().unwrap_or_else(PoisonError::into_inner).get(id) { return FaceRef(f); } - let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner()); + let mut w = LEAKED.write().unwrap_or_else(PoisonError::into_inner); if w.len() <= id { w.resize(id + 1, None); } @@ -579,7 +579,7 @@ fn script_chain(c: char) -> Option<&'static [&'static str]> { /// The Japanese fallback chain: the craft-fonts Japanese faces' families in document order /// (Mincho first; none without craft-fonts), then [`JAPANESE_FALLBACKS`]. fn japanese_chain() -> &'static [&'static str] { - static CHAIN: std::sync::OnceLock> = std::sync::OnceLock::new(); + static CHAIN: std::sync::OnceLock> = std::sync::OnceLock::new(); CHAIN.get_or_init(|| { let mut chain: Vec<&'static str> = Vec::new(); for f in crate::japanese_document_fonts() { @@ -1037,7 +1037,7 @@ pub fn same_style(a: &str, b: &str) -> bool { } fn norm(s: &str) -> String { - s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() + s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() } /// Weight implied by a style name. @@ -1067,12 +1067,20 @@ fn style_italic(style: &str) -> bool { } impl FontDb { - /// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first, - /// so they are the fallback for Japanese text after the requested and bundled fonts; empty - /// without `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`. + /// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first), + /// followed by Noto Sans Hebrew Regular and Noto Sans Devanagari Regular for document text. + /// The optional craft-fonts faces are absent without `CRAFT_FONTS_DIR`; system font scans + /// read `font_dirs`. Japanese fallback and UI ordering remain independent of these additions. pub fn with_font_dirs(font_dirs: Vec) -> Self { let mut faces = Vec::new(); - let craft = crate::japanese_document_fonts().into_iter().map(|f| f.bytes); + let craft = crate::japanese_document_fonts() + .into_iter() + .chain( + ["Noto Sans Hebrew", "Noto Sans Devanagari"] + .into_iter() + .filter_map(|family| crate::CRAFT_FONTS.iter().find(|f| f.family == family && f.style == "Regular")), + ) + .map(|f| f.bytes); for data in BUNDLED.iter().copied().chain(craft) { for (i, family, style, coords) in enumerate_faces(data) { if let Some(f) = make_face(FontBytes::Static(data), FontSource::Bundled, i, family, style, coords) { @@ -1107,7 +1115,7 @@ impl FontDb { pub fn set_system_fallback(&self, on: bool) { #[cfg(not(target_arch = "wasm32"))] { - self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on; + self.sys.lock().unwrap_or_else(PoisonError::into_inner).enabled = on; } #[cfg(target_arch = "wasm32")] let _ = on; @@ -1123,20 +1131,20 @@ impl FontDb { } fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { - self.faces.read().unwrap_or_else(|e| e.into_inner()) + self.faces.read().unwrap_or_else(PoisonError::into_inner) } /// The system font catalog, scanned first if it hasn't been yet. #[cfg(not(target_arch = "wasm32"))] fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec> { self.ensure_catalog(); - self.catalog.read().unwrap_or_else(|e| e.into_inner()) + self.catalog.read().unwrap_or_else(PoisonError::into_inner) } /// What the document with font scope `scope` sees: its own fonts ahead of the shared ones. /// Scope 0 (a document without fonts of its own) or a closed scope sees the shared fonts. pub fn scoped(&self, scope: u32) -> ScopedFonts<'_> { - let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(|e| e.into_inner()).get(&scope).cloned() }; + let own = if scope == 0 { None } else { self.scopes.read().unwrap_or_else(PoisonError::into_inner).get(&scope).cloned() }; ScopedFonts { db: self, own } } @@ -1187,7 +1195,7 @@ impl FontDb { /// family's bold without its regular), skipping a family and style another thread has added /// meanwhile. Returns the number added. fn insert_faces(&self, new: Vec) -> usize { - let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner()); + let mut faces = self.faces.write().unwrap_or_else(PoisonError::into_inner); let mut added = 0; for f in new { if !has_face(&faces, &f.family, &f.style) { @@ -1238,7 +1246,7 @@ impl FontDb { let count = first.unwrap_or_else(|| self.scan_font_dirs()); // Retry characters and CJK chain families that newly installed fonts can now cover. // Reset outside scan_font_dirs: a chain lookup can hold sys during the initial scan. - let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); sys.generation = sys.generation.wrapping_add(1); sys.misses.clear(); sys.chain_tried.clear(); @@ -1257,7 +1265,7 @@ impl FontDb { // Rescan first: a lookup after the faces are dropped finds the new files. let cataloged = self.load_system_fonts(); let changed: Vec = { - let mut files = self.installed_files.lock().unwrap_or_else(|e| e.into_inner()); + let mut files = self.installed_files.lock().unwrap_or_else(PoisonError::into_inner); let mut changed: Vec<_> = files.iter().filter(|(p, stamp)| file_stamp(p).as_ref() != Some(*stamp)).map(|(p, _)| p.clone()).collect(); changed.sort(); for p in &changed { @@ -1268,15 +1276,15 @@ impl FontDb { if !changed.is_empty() { let stale = |f: &FontFace| matches!(&f.source, FontSource::Installed(p) if changed.contains(p)); let mut dropped = std::collections::HashSet::new(); - self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| { + self.faces.write().unwrap_or_else(PoisonError::into_inner).retain(|f| { let keep = !stale(f); if !keep { dropped.insert(f.id); } keep }); - self.instances.write().unwrap_or_else(|e| e.into_inner()).retain(|(base, _), f| !dropped.contains(base) && !stale(f)); - self.outlines.lock().unwrap_or_else(|e| e.into_inner()).retain(|(face, _), _| !dropped.contains(face)); + self.instances.write().unwrap_or_else(PoisonError::into_inner).retain(|(base, _), f| !dropped.contains(base) && !stale(f)); + self.outlines.lock().unwrap_or_else(PoisonError::into_inner).retain(|(face, _), _| !dropped.contains(face)); log::debug!("reloading {} changed font files ({} faces)", changed.len(), dropped.len()); } ReloadedFonts { cataloged, changed } @@ -1291,7 +1299,7 @@ impl FontDb { fn read_installed(&self, path: &std::path::Path) -> Option> { let stamp = file_stamp(path)?; let data = std::fs::read(path).ok()?; - self.installed_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), stamp); + self.installed_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), stamp); Some(data) } @@ -1314,7 +1322,7 @@ impl FontDb { stack.push(p); continue; } - let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase); if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } @@ -1351,7 +1359,7 @@ impl FontDb { } let n = found.len(); log::debug!("cataloged {n} system font faces"); - *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found; + *self.catalog.write().unwrap_or_else(PoisonError::into_inner) = found; n } @@ -1399,7 +1407,7 @@ impl FontDb { let Ok(entries) = std::fs::read_dir(folder) else { return out }; let mut files: Vec = Vec::new(); for path in entries.flatten().map(|e| e.path()) { - let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase); if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } @@ -1421,7 +1429,7 @@ impl FontDb { let name = path.file_name().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()); // The listed length skips oversized files unread. let meta = std::fs::metadata(path).ok(); - let listed = meta.as_ref().map_or(0, |m| m.len()); + let listed = meta.as_ref().map_or(0, std::fs::Metadata::len); let faces = if listed > MAX_DOCUMENT_FONT_BYTES { Err(too_large()) } else if total.saturating_add(listed) > MAX_DOCUMENT_FONTS_TOTAL { @@ -1449,7 +1457,7 @@ impl FontDb { if !own.is_empty() { out.scope = NEXT_SCOPE.fetch_add(1, Ordering::Relaxed); out.faces = own.len(); - self.scopes.write().unwrap_or_else(|e| e.into_inner()).insert(out.scope, own.into()); + self.scopes.write().unwrap_or_else(PoisonError::into_inner).insert(out.scope, own.into()); } out } @@ -1466,7 +1474,7 @@ impl FontDb { room: u64, ) -> Result<(Vec>, u64), String> { use std::io::Read; - if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(|e| e.into_inner()).get(path) + if let Some((len, m, faces)) = self.document_files.lock().unwrap_or_else(PoisonError::into_inner).get(path) && (*len, *m) == (listed, modified) { return Ok((faces.clone(), listed)); @@ -1494,14 +1502,14 @@ impl FontDb { if faces.is_empty() { return Err("not a font DesignCraft can read".into()); } - self.document_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), (listed, modified, faces.clone())); + self.document_files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), (listed, modified, faces.clone())); Ok((faces, len)) } /// Close a document's font scope: its fonts are no longer found (in it or anywhere). Their /// files stay read, for the document opening again. pub fn close_scope(&self, scope: u32) { - self.scopes.write().unwrap_or_else(|e| e.into_inner()).remove(&scope); + self.scopes.write().unwrap_or_else(PoisonError::into_inner).remove(&scope); } /// The shared fonts' face for `family` + `style` (see [`ScopedFonts::face`]). @@ -1542,7 +1550,7 @@ impl FontDb { return false; } let generation = { - let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + let sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); if !sys.enabled || sys.misses.contains(&c) { return false; } @@ -1558,12 +1566,12 @@ impl FontDb { } } } - let mut paths: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect(); + let mut paths: Vec = self.catalog.read().unwrap_or_else(PoisonError::into_inner).iter().map(|e| e.path.clone()).collect(); paths.sort(); paths.dedup(); #[cfg(all(test, target_os = "linux"))] { - let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(|e| e.into_inner()).take(); + let hook = self.fallback_snapshot_hook.lock().unwrap_or_else(PoisonError::into_inner).take(); if let Some(hook) = hook { let _ = hook.reached.send(()); hook.resume.wait(); @@ -1580,7 +1588,7 @@ impl FontDb { return true; } } - let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + let mut sys = self.sys.lock().unwrap_or_else(PoisonError::into_inner); if sys.generation == generation { sys.misses.insert(c); } @@ -1619,12 +1627,12 @@ impl FontDb { /// disappears. pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { let key = (face.id, gid); - if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + if let Some(p) = self.outlines.lock().unwrap_or_else(PoisonError::into_inner).get(&key) { return p.clone(); } let drawn = font_outline(face, gid); let p = Arc::new(if gid == 0 && drawn.elements().is_empty() { self.missing_box(face).unwrap_or(drawn) } else { drawn }); - let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner()); + let mut cache = self.outlines.lock().unwrap_or_else(PoisonError::into_inner); if cache.len() >= OUTLINE_CACHE_MAX { cache.clear(); } @@ -1986,7 +1994,7 @@ impl ScopedFonts<'_> { return Some(base); } let key = (base.id, style.to_string()); - if let Some(f) = self.db.instances.read().unwrap_or_else(|e| e.into_inner()).get(&key) { + if let Some(f) = self.db.instances.read().unwrap_or_else(PoisonError::into_inner).get(&key) { return Some(f.clone()); } let mut coords = base.coords.clone(); @@ -2004,7 +2012,7 @@ impl ScopedFonts<'_> { } } let f = make_face(base.bytes.clone(), base.source.clone(), base.index, base.family.clone(), style.to_string(), coords)?; - Some(self.db.instances.write().unwrap_or_else(|e| e.into_inner()).entry(key).or_insert_with(|| Arc::new(f)).clone()) + Some(self.db.instances.write().unwrap_or_else(PoisonError::into_inner).entry(key).or_insert_with(|| Arc::new(f)).clone()) } fn find(&self, family: &str, style: &str) -> Option> { @@ -2077,7 +2085,7 @@ impl ScopedFonts<'_> { #[cfg(not(target_arch = "wasm32"))] if !self.db.is_loaded(family) { // Held while loading, so a parallel lookup waits for the font instead of passing it by. - let mut sys = self.db.sys.lock().unwrap_or_else(|e| e.into_inner()); + let mut sys = self.db.sys.lock().unwrap_or_else(PoisonError::into_inner); if sys.enabled && sys.chain_tried.insert(family) { self.db.load_cataloged(family); } @@ -2200,6 +2208,71 @@ mod tests_fallback; #[path = "tests_docfonts.rs"] mod tests_docfonts; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod native_gui_document_fonts_tests { + use super::*; + + #[test] + fn native_gui_canonical_document_faces_load_without_system_or_added_fonts() { + if crate::CRAFT_FONTS.is_empty() { + assert!(std::env::var_os("CRAFT_FONTS_REQUIRED").is_none(), "required canonical fonts were not embedded"); + return; // Optional-input builds retain their supported Latin-only database. + } + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let scope = db.scoped(0); + let japanese = crate::japanese_document_fonts(); + let previous: Vec<_> = BUNDLED + .iter() + .copied() + .chain(japanese.iter().map(|f| f.bytes)) + .flat_map(enumerate_faces) + .map(|(_, family, style, _)| (family, style)) + .collect(); + let loaded: Vec<_> = db.read_faces().iter().map(|f| (f.family.clone(), f.style.clone())).collect(); + assert!(loaded.len() >= previous.len(), "existing bundled/Japanese faces disappeared"); + assert_eq!(&loaded[..previous.len()], previous.as_slice(), "preserve all Latin and Japanese face ordering"); + for family in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] { + let face = db.face(family, "Regular"); + assert_eq!((face.family.as_str(), face.style.as_str(), &face.source), (family, "Regular", &FontSource::Bundled)); + } + assert!(!japanese.is_empty(), "the required central manifest includes the existing Japanese faces"); + assert!(japanese[0].family.contains("Mincho")); + assert_eq!(crate::japanese_ui_fonts(false)[0].family, "BIZ UDPGothic"); + assert_eq!(crate::japanese_ui_fonts(true)[0].style, "Bold"); + let latin = db.face(crate::DEFAULT_FAMILY, "Regular"); + for language in [Some("ja"), None] { + assert!(db.fallback_for('語', latin.id(), language).unwrap().family.contains("Mincho"), "Japanese fallback changed: {language:?}"); + } + let mut admitted = Vec::new(); + for (family, text) in [("Noto Sans Hebrew", "שלום עולם"), ("Noto Sans Devanagari", "नमस्ते दुनिया")] { + let embedded: Vec<_> = crate::CRAFT_FONTS.iter().filter(|f| f.family == family && f.style == "Regular").collect(); + assert_eq!(embedded.len(), 1, "exactly one canonical {family} Regular input"); + assert!(scope.has_family(family), "production database did not register {family}"); + assert!(scope.has_style(family, "Regular"), "{family} Regular"); + assert!(!scope.has_style(family, "Unavailable Native GUI Style")); + assert!(db.families().iter().any(|f| f == family), "family catalog omits {family}"); + assert!(scope.family_infos().iter().any(|f| f.family == family), "menu catalog omits {family}"); + let face = scope.face(family, "Regular"); + assert_eq!((face.family.as_str(), face.style.as_str(), &face.source), (family, "Regular", &FontSource::Bundled)); + assert_eq!(face.data(), embedded[0].bytes, "neither OS discovery nor an injected face may satisfy this contract"); + for c in text.chars().filter(|c| !c.is_whitespace()) { + assert_ne!(face.glyph_for(c), 0, "{family} lacks U+{:04X} ({c})", u32::from(c)); + let fallback = db.fallback_for(c, latin.id(), None).unwrap(); + assert_eq!((fallback.family.as_str(), fallback.style.as_str(), &fallback.source), (family, "Regular", &FontSource::Bundled)); + assert_eq!(fallback.data(), embedded[0].bytes, "document fallback must use the canonical {family} face for {c}"); + assert_ne!(fallback.glyph_for(c), 0); + } + let replacement = scope.face(family, "Unavailable Native GUI Style"); + assert_eq!((replacement.family.as_str(), replacement.style.as_str()), (family, "Regular"), "nearest-style policy stays truthful"); + admitted.push((family.to_string(), "Regular".to_string())); + } + assert_eq!(&loaded[previous.len()..], admitted.as_slice(), "only the two deliberate Regular tuples follow the existing document faces"); + assert!(!scope.has_family("Unavailable Native GUI Family")); + assert_eq!(scope.face("Unavailable Native GUI Family", "Regular").family, FALLBACK_FAMILY); + } +} + #[cfg(test)] #[path = "tests_vertical.rs"] mod tests_vertical; diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index b50234a8f..adfc4a2e4 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -281,9 +281,12 @@ impl PathData { for el in bp.elements() { match *el { PathEl::MoveTo(p) => { - if let Some(sp) = cur.take() + if let Some(mut sp) = cur.take() && !sp.anchors.is_empty() { + for a in &mut sp.anchors { + *a = Anchor::with_handles(a.p, a.h_in, a.h_out); + } subpaths.push(sp); } cur = Some(SubPath { anchors: vec![Anchor::corner(p)], closed: false }); @@ -418,6 +421,20 @@ mod tests { PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true)) } + #[test] + fn from_bezpath_detects_smooth_on_every_open_subpath() { + let mut bp = BezPath::new(); + for _ in 0..2 { + bp.move_to((0.0, 0.0)); + bp.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0)); + bp.curve_to((10.0, -10.0), (20.0, -10.0), (20.0, 0.0)); + } + let pd = PathData::from_bezpath(&bp); + assert_eq!(pd.subpaths.len(), 2); + assert_eq!(pd.subpaths[1].anchors[1].kind, AnchorKind::Smooth); + assert_eq!(pd.subpaths[0].anchors[1].kind, pd.subpaths[1].anchors[1].kind); + } + #[test] fn anchors_serialize_compactly_and_read_v1_maps() { let corner = Anchor::corner(Point::new(1.0, 2.0)); diff --git a/crates/geom/src/trace.rs b/crates/geom/src/trace.rs index b2d850ea9..292f82e87 100644 --- a/crates/geom/src/trace.rs +++ b/crates/geom/src/trace.rs @@ -5,12 +5,9 @@ use std::collections::HashMap; use crate::Point; -/// Closed outlines around the `true` cells of a `w`×`h` mask (row-major), simplified to within -/// `tol` pixels; outlines enclosing less than `min_area` pixels are dropped. Holes come out -/// with the opposite winding. -pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec> { +/// Pixel-border edges of the mask with the inside on the right (clockwise, y down), by start point. +fn border_edges(mask: &[bool], w: usize, h: usize) -> HashMap<(isize, isize), Vec<(isize, isize)>> { let inside = |x: isize, y: isize| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && mask[y as usize * w + x as usize]; - // Pixel-border edges with the inside on the right (clockwise, y down). let mut next: HashMap<(isize, isize), Vec<(isize, isize)>> = HashMap::new(); let mut add = |a: (isize, isize), b: (isize, isize)| next.entry(a).or_default().push(b); for y in 0..h as isize { @@ -32,16 +29,24 @@ pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec< } } } + next +} + +/// Closed outlines around the `true` cells of a `w`×`h` mask (row-major), simplified to within +/// `tol` pixels; outlines enclosing less than `min_area` pixels are dropped. Holes come out +/// with the opposite winding. +pub fn trace(mask: &[bool], w: usize, h: usize, tol: f64, min_area: f64) -> Vec> { + let mut next = border_edges(mask, w, h); let mut out = Vec::new(); let mut starts: Vec<(isize, isize)> = next.keys().copied().collect(); starts.sort(); for s in starts { - while let Some(first) = next.get_mut(&s).and_then(|v| v.pop()) { + while let Some(first) = next.get_mut(&s).and_then(Vec::pop) { let mut ring = vec![s]; let mut cur = first; while cur != s { ring.push(cur); - match next.get_mut(&cur).and_then(|v| v.pop()) { + match next.get_mut(&cur).and_then(Vec::pop) { Some(n) => cur = n, None => break, } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index df5a1a243..3a9dc778d 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -177,7 +177,7 @@ impl<'a> Ex<'a> { } fn assign_swatch_ids(&mut self) { - let mut used: HashSet = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(|s| s.to_string()).collect(); + let mut used: HashSet = ["Swatch/None", "Color/Paper", "Color/Black", "Color/Registration"].iter().map(ToString::to_string).collect(); self.swatch_ids.insert(swatch::NONE.into(), "Swatch/None".into()); self.swatch_ids.insert(swatch::PAPER.into(), "Color/Paper".into()); self.swatch_ids.insert(swatch::BLACK.into(), "Color/Black".into()); @@ -309,28 +309,7 @@ impl<'a> Ex<'a> { ); } for t in &d.styles.mojikumi_tables { - let mut e = El::new("MojikumiTable").attr("Self", format!("MojikumiTable/{}", escape_id(&t.name))).attr("Name", &t.name); - if !t.based_on.is_empty() { - e.set("BasedOnMojikumiSet", &t.based_on); - } - if !t.overrides.is_empty() { - let mut list = El::new("OverrideMojikumiAkiList"); - for r in &t.overrides { - list.push( - El::new("OverrideMojikumiAkiType") - .attr("TargetMojikumiClass", r.target_class) - .attr("SideMojikumiClass", r.side_class) - .attr("SideIsAfterTarget", bool_s(r.after)) - .attr("Minimum", num(r.minimum)) - .attr("Desired", num(r.desired)) - .attr("Maximum", num(r.maximum)) - .attr("CompressionPriority", r.priority) - .attr("AkiDoesNotFloat", bool_s(r.does_not_float)), - ); - } - e.push(El::new("Properties").child(list)); - } - root.push(e); + root.push(mojikumi_table_el(t)); } root.push(El::new("idPkg:Graphic").attr("src", "Resources/Graphic.xml")); root.push(El::new("idPkg:Fonts").attr("src", "Resources/Fonts.xml")); @@ -364,28 +343,7 @@ impl<'a> Ex<'a> { } // IDML lists layers back to front. for l in d.layers.iter().rev() { - let color = match names::layer_color_out(l.color) { - Some(n) => p("LayerColor", "enumeration", n), - None => { - let mut e = El::new("LayerColor").attr("type", "list"); - for c in l.color { - e.push(p("ListItem", "double", num(c as f64))); - } - e - } - }; - let el = El::new("Layer") - .attr("Self", uid(l.id.0)) - .attr("Name", &l.name) - .attr("Visible", bool_s(l.visible)) - .attr("Locked", bool_s(l.locked)) - .attr("IgnoreWrap", bool_s(l.suppress_wrap_when_hidden)) - .attr("ShowGuides", bool_s(l.show_guides)) - .attr("LockGuides", "false") - .attr("UI", "true") - .attr("Expendable", "true") - .attr("Printable", bool_s(l.printable)); - root.push(with_props(el, vec![color])); + root.push(layer_el(l)); } for (name, _) in masters { root.push(El::new("idPkg:MasterSpread").attr("src", name)); @@ -1570,6 +1528,7 @@ impl<'a> Ex<'a> { } n!(keep_with_next, "KeepWithNext"); b!(keep_lines_together, "KeepLinesTogether"); + b!(ignore_optical_margin, "IgnoreEdgeAlignment"); b!(keep_all_lines, "KeepAllLinesTogether"); n!(keep_first, "KeepFirstLines"); n!(keep_last, "KeepLastLines"); @@ -1828,33 +1787,7 @@ impl<'a> Ex<'a> { .attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy))) .child(margin_el(pg)); let mut pe = pe; - // Ruler guides, measured from the ruler origin written in the preferences: the - // spread's top left, the page's, or the spine (the import reads them the same way). - let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min); - let left = if left.is_finite() { left } else { 0.0 }; - let spine = sp.pages.iter().map(|p| p.x - left).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min); - let spine = if spine.is_finite() && facing { spine } else { 0.0 }; - let origin_x = match d.settings.ruler_origin { - designcraft_doc::RulerOrigin::Spread => left, - designcraft_doc::RulerOrigin::Page => pg.x, - designcraft_doc::RulerOrigin::Spine => left + spine, - }; - for g in &pg.guides { - let vertical = g.orientation == designcraft_doc::Orientation::Vertical; - let mut ge = El::new("Guide") - .attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count())) - .attr("Orientation", if vertical { "Vertical" } else { "Horizontal" }) - .attr("Location", num(if vertical { g.position - origin_x } else { g.position })) - .attr("FitToPage", bool_s(!g.spread)) - .attr("ViewThreshold", "5") - .attr("Locked", bool_s(g.locked)) - .attr("PageIndex", (first_page + i).to_string()) - .attr("GuideType", if g.liquid { "Liquid" } else { "Ruler" }); - if let Some(l) = g.layer { - ge.set("ItemLayer", uid(l.0)); - } - pe.push(ge); - } + push_page_guides(d, sp, pg, &mut pe, first_page + i); el.push(pe); } let origin = Affine::translate((-ox, -oy)); @@ -2012,12 +1945,21 @@ impl<'a> Ex<'a> { } if let Content::Text(t) = &it.content { el.push(text_frame_pref(&t.options, &self.sw(&t.options.column_rule_color))); - if let Some((start, inc)) = t.options.baseline_grid { + if let Some(g) = t.options.baseline_grid { el.push( El::new("BaselineFrameGridOption") .attr("UseCustomBaselineFrameGrid", "true") - .attr("StartingOffsetForBaselineFrameGrid", num(start)) - .attr("BaselineFrameGridIncrement", num(inc)), + .attr("StartingOffsetForBaselineFrameGrid", num(g.start)) + .attr( + "BaselineFrameGridRelativeOption", + match g.relative_to { + designcraft_doc::FrameGridRelative::TopOfPage => "TopOfPageOfBaselineFrameGridRelativeOption", + designcraft_doc::FrameGridRelative::TopMargin => "TopOfMarginOfBaselineFrameGridRelativeOption", + designcraft_doc::FrameGridRelative::TopOfFrame => "TopOfFrameOfBaselineFrameGridRelativeOption", + designcraft_doc::FrameGridRelative::TopInset => "TopOfInsetOfBaselineFrameGridRelativeOption", + }, + ) + .attr("BaselineFrameGridIncrement", num(g.increment)), ); } } @@ -2285,8 +2227,11 @@ impl<'a> Ex<'a> { .attr("StoryTitle", "$ID/"); el.push( El::new("StoryPreference") - .attr("OpticalMarginAlignment", "false") - .attr("OpticalMarginSize", "12") + .attr("OpticalMarginAlignment", bool_s(s.optical_margin.on)) + .attr( + "OpticalMarginSize", + num(designcraft_doc::OpticalMargin::clamp_size(s.optical_margin.size).unwrap_or(designcraft_doc::OpticalMargin::DEFAULT_SIZE)), + ) .attr("FrameType", "TextFrameType") .attr("StoryOrientation", if s.vertical { "Vertical" } else { "Horizontal" }) .attr( @@ -2414,6 +2359,7 @@ impl<'a> Ex<'a> { object_point, ref_point, keep_within_column, + spine_relative, } => El::new("AnchoredObjectSetting") .attr("AnchoredPosition", "Anchored") .attr("AnchorPoint", names::anchor_out(*object_point)) @@ -2424,7 +2370,8 @@ impl<'a> Ex<'a> { // IDML measures a left-aligned object's X offset leftwards. .attr("AnchorXoffset", num(if *ref_point % 3 == 0 { 0.0 - *x_offset } else { *x_offset })) .attr("AnchorYoffset", num(*y_offset)) - .attr("PinPosition", bool_s(*keep_within_column)), + .attr("PinPosition", bool_s(*keep_within_column)) + .attr("SpineRelative", bool_s(*spine_relative)), }; e.push(setting); out.push(Node::El(e)); @@ -3195,6 +3142,90 @@ fn metadata_xmp(d: &Document) -> String { ) } +/// The page's ruler guides, measured from the ruler origin written in the preferences. +fn push_page_guides(d: &Document, sp: &Spread, pg: &designcraft_doc::Page, pe: &mut El, page_index: usize) { + let facing = d.settings.facing_pages; + // Ruler guides, measured from the ruler origin written in the preferences: the + // spread's top left, the page's, or the spine (the import reads them the same way). + let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min); + let left = if left.is_finite() { left } else { 0.0 }; + let spine = sp.pages.iter().map(|p| p.x - left).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min); + let spine = if spine.is_finite() && facing { spine } else { 0.0 }; + let origin_x = match d.settings.ruler_origin { + designcraft_doc::RulerOrigin::Spread => left, + designcraft_doc::RulerOrigin::Page => pg.x, + designcraft_doc::RulerOrigin::Spine => left + spine, + }; + for g in &pg.guides { + let vertical = g.orientation == designcraft_doc::Orientation::Vertical; + let mut ge = El::new("Guide") + .attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count())) + .attr("Orientation", if vertical { "Vertical" } else { "Horizontal" }) + .attr("Location", num(if vertical { g.position - origin_x } else { g.position })) + .attr("FitToPage", bool_s(!g.spread)) + .attr("ViewThreshold", "5") + .attr("Locked", bool_s(g.locked)) + .attr("PageIndex", page_index.to_string()) + .attr("GuideType", if g.liquid { "Liquid" } else { "Ruler" }); + if let Some(l) = g.layer { + ge.set("ItemLayer", uid(l.0)); + } + pe.push(ge); + } +} + +/// A `MojikumiTable` element with its spacing overrides. +fn mojikumi_table_el(t: &designcraft_doc::cjk::MojikumiTable) -> El { + let mut e = El::new("MojikumiTable").attr("Self", format!("MojikumiTable/{}", escape_id(&t.name))).attr("Name", &t.name); + if !t.based_on.is_empty() { + e.set("BasedOnMojikumiSet", &t.based_on); + } + if !t.overrides.is_empty() { + let mut list = El::new("OverrideMojikumiAkiList"); + for r in &t.overrides { + list.push( + El::new("OverrideMojikumiAkiType") + .attr("TargetMojikumiClass", r.target_class) + .attr("SideMojikumiClass", r.side_class) + .attr("SideIsAfterTarget", bool_s(r.after)) + .attr("Minimum", num(r.minimum)) + .attr("Desired", num(r.desired)) + .attr("Maximum", num(r.maximum)) + .attr("CompressionPriority", r.priority) + .attr("AkiDoesNotFloat", bool_s(r.does_not_float)), + ); + } + e.push(El::new("Properties").child(list)); + } + e +} + +/// A `Layer` element with its colour property. +fn layer_el(l: &designcraft_doc::Layer) -> El { + let color = match names::layer_color_out(l.color) { + Some(n) => p("LayerColor", "enumeration", n), + None => { + let mut e = El::new("LayerColor").attr("type", "list"); + for c in l.color { + e.push(p("ListItem", "double", num(c as f64))); + } + e + } + }; + let el = El::new("Layer") + .attr("Self", uid(l.id.0)) + .attr("Name", &l.name) + .attr("Visible", bool_s(l.visible)) + .attr("Locked", bool_s(l.locked)) + .attr("IgnoreWrap", bool_s(l.suppress_wrap_when_hidden)) + .attr("ShowGuides", bool_s(l.show_guides)) + .attr("LockGuides", "false") + .attr("UI", "true") + .attr("Expendable", "true") + .attr("Printable", bool_s(l.printable)); + with_props(el, vec![color]) +} + /// A text variable definition with its type's settings element. fn text_variable_el(v: &designcraft_doc::vars::TextVariable) -> El { use designcraft_doc::vars::{Use, VarKind}; @@ -3336,6 +3367,10 @@ impl Ex<'_> { if let Some(v) = a.kenten_overprint_stroke { el.set("KentenOverprintStroke", crate::cjk::overprint_out(v)); } + self.shatai_chars(el, a); + } + + fn shatai_chars(&self, el: &mut El, a: &CharAttrs) { if let Some(v) = a.shatai_magnification { el.set("ShataiMagnification", num(v)); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index b57210576..1000638d3 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1641,28 +1641,7 @@ impl<'r> Importer<'r> { a.strikethrough_tint = tint; } } - // OpenType features (only when the element sets any). - let mut otf_list: Vec = Vec::new(); - let mut any = false; - for (tag, _, attr) in designcraft_doc::otf::TOGGLES { - if let Some(on) = e.boolean(attr) { - designcraft_doc::otf::set(&mut otf_list, tag, on); - any = true; - } - } - if let Some(fig) = e.prop("OTFFigureStyle") - && let Some((id, ..)) = designcraft_doc::otf::FIGURES.iter().find(|f| f.3 == fig.trim()) - { - designcraft_doc::otf::set_figures(&mut otf_list, id); - any = true; - } - if let Some(m) = e.num("OTFStylisticSets") { - designcraft_doc::otf::set_stylistic_sets(&mut otf_list, m.max(0.0) as u32); - any = true; - } - if any { - a.otf_features = Some(otf_list); - } + read_otf_features(e, &mut a); a.glyph_form = e.prop("GlyphForm"); a.no_break = e.boolean("NoBreak"); a.tate_chu_yoko = e.boolean("Tatechuyoko"); @@ -1841,6 +1820,7 @@ impl<'r> Importer<'r> { a.single_word_justify = e.prop("SingleWordJustification").and_then(|v| names::align_in(v.trim())); a.keep_with_next = u("KeepWithNext"); a.keep_lines_together = e.boolean("KeepLinesTogether"); + a.ignore_optical_margin = e.boolean("IgnoreEdgeAlignment"); a.keep_all_lines = e.boolean("KeepAllLinesTogether"); a.keep_first = u("KeepFirstLines"); a.keep_last = u("KeepLastLines"); @@ -2116,6 +2096,14 @@ impl<'r> Importer<'r> { let format = self.xref_format_names.get(&fmt).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into()); xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format })); } + let pref = e.find("StoryPreference"); + let optical_margin = designcraft_doc::OpticalMargin { + on: pref.and_then(|p| p.boolean("OpticalMarginAlignment")).unwrap_or(false), + size: pref + .and_then(|p| p.num("OpticalMarginSize")) + .and_then(designcraft_doc::OpticalMargin::clamp_size) + .unwrap_or(designcraft_doc::OpticalMargin::DEFAULT_SIZE), + }; let mut st = Story { id, text, @@ -2128,6 +2116,7 @@ impl<'r> Importer<'r> { designcraft_doc::TextDirection::LeftToRight }, vertical: e.find("StoryPreference").and_then(|p| p.get("StoryOrientation")) == Some("Vertical"), + optical_margin, rev: 0, tables, notes, @@ -2449,6 +2438,7 @@ impl<'r> Importer<'r> { object_point: names::anchor_in(s("AnchorPoint")), ref_point: row * 3 + col, keep_within_column: s("PinPosition") == "true", + spine_relative: s("SpineRelative") == "true", } } _ => designcraft_doc::AnchorPosition::Inline { y_offset: get("AnchorYoffset") }, @@ -2994,8 +2984,11 @@ impl<'r> Importer<'r> { if let Some(g) = self.with_style(e, "BaselineFrameGridOption") && g.get("UseCustomBaselineFrameGrid") == Some("true") { - options.baseline_grid = - Some((g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), g.num("BaselineFrameGridIncrement").unwrap_or(12.0))); + options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid { + start: g.num("StartingOffsetForBaselineFrameGrid").unwrap_or(0.0), + increment: g.num("BaselineFrameGridIncrement").unwrap_or(12.0), + relative_to: g.get("BaselineFrameGridRelativeOption").map(frame_grid_relative).unwrap_or_default(), + }); } it.content = Content::Text(TextFrame { story, options }); self.ctx.threads.push(( @@ -3536,6 +3529,19 @@ fn fit_columns(mut columns: Columns, margins: &Margins, page_width: f64) -> Colu columns } +/// A `BaselineFrameGridRelativeOption` value (`TopOfInsetOfBaselineFrameGridRelativeOption`, or a +/// short form such as `TopOfInset`); unknown values measure from the top of the page. +fn frame_grid_relative(v: &str) -> designcraft_doc::FrameGridRelative { + use designcraft_doc::FrameGridRelative as R; + let v = v.strip_suffix("OfBaselineFrameGridRelativeOption").unwrap_or(v); + match v { + "TopOfMargin" | "TopMargin" => R::TopMargin, + "TopOfFrame" => R::TopOfFrame, + "TopOfInset" | "TopInset" => R::TopInset, + _ => R::TopOfPage, + } +} + /// Text Frame Options from `TextFramePreference` elements: each attribute from the first that sets it. fn text_frame_options(prefs: &[&El]) -> TextFrameOptions { let e = Prefs(prefs); @@ -3888,6 +3894,31 @@ impl Importer<'_> { } } +/// OpenType features (only when the element sets any). +fn read_otf_features(e: &El, a: &mut CharAttrs) { + let mut otf_list: Vec = Vec::new(); + let mut any = false; + for (tag, _, attr) in designcraft_doc::otf::TOGGLES { + if let Some(on) = e.boolean(attr) { + designcraft_doc::otf::set(&mut otf_list, tag, on); + any = true; + } + } + if let Some(fig) = e.prop("OTFFigureStyle") + && let Some((id, ..)) = designcraft_doc::otf::FIGURES.iter().find(|f| f.3 == fig.trim()) + { + designcraft_doc::otf::set_figures(&mut otf_list, id); + any = true; + } + if let Some(m) = e.num("OTFStylisticSets") { + designcraft_doc::otf::set_stylistic_sets(&mut otf_list, m.max(0.0) as u32); + any = true; + } + if any { + a.otf_features = Some(otf_list); + } +} + #[cfg(test)] mod numeric_property_tests { use super::*; diff --git a/crates/idml/src/ruby.rs b/crates/idml/src/ruby.rs index 601792f6c..352da63a1 100644 --- a/crates/idml/src/ruby.rs +++ b/crates/idml/src/ruby.rs @@ -98,11 +98,45 @@ pub(crate) fn read(e: &El, a: &mut CharAttrs, mut swatch: impl FnMut(&str) -> St a.ruby_overprint_stroke = overprint("RubyOverprintStroke"); } +/// Ruby parent-text options (OpenType Pro, auto tate-chu-yoko, overhang, spacing, scaling). +fn write_parent_options(el: &mut El, a: &CharAttrs) { + let b = |v: bool| if v { "true" } else { "false" }; + if let Some(v) = a.ruby_open_type_pro { + el.set("RubyOpenTypePro", b(v)); + } + if let Some(v) = a.ruby_auto_tcy_digits { + el.set("RubyAutoTcyDigits", v); + } + if let Some(v) = a.ruby_auto_tcy_include_roman { + el.set("RubyAutoTcyIncludeRoman", b(v)); + } + if let Some(v) = a.ruby_auto_tcy_auto_scale { + el.set("RubyAutoTcyAutoScale", b(v)); + } + if let Some(v) = a.ruby_overhang { + el.set("RubyOverhang", b(v)); + } + if let Some(v) = a.ruby_overhang_amount.and_then(|v| name(OVERHANGS, v)) { + el.set("RubyParentOverhangAmount", v); + } + if let Some(v) = a.ruby_parent_spacing.and_then(|v| name(SPACINGS, v)) { + el.set("RubyParentSpacing", v); + } + if let Some(v) = a.ruby_auto_align { + el.set("RubyAutoAlign", b(v)); + } + if let Some(v) = a.ruby_auto_scaling { + el.set("RubyAutoScaling", b(v)); + } + if let Some(v) = a.ruby_scaling_min { + el.set("RubyParentScalingPercent", num(v * 100.0)); + } +} + /// Write the ruby settings set in `a`: attributes on `el`, fonts and colours as `props`. `sw` /// maps a swatch name to its IDML reference. Returns the ruby font (family, style) to list in /// the fonts part. pub(crate) fn write(el: &mut El, props: &mut Vec, a: &CharAttrs, sw: impl Fn(&str) -> String) -> Option<(String, String)> { - let b = |v: bool| if v { "true" } else { "false" }; let prop = |k: &str, ty: &str, v: String| El::new(k).attr("type", ty).text(v); if let Some(v) = a.ruby_type.and_then(|v| name(TYPES, v)) { el.set("RubyType", v); @@ -134,36 +168,7 @@ pub(crate) fn write(el: &mut El, props: &mut Vec, a: &CharAttrs, sw: impl Fn if let Some(v) = a.ruby_y_scale { el.set("RubyYScale", num(v * 100.0)); } - if let Some(v) = a.ruby_open_type_pro { - el.set("RubyOpenTypePro", b(v)); - } - if let Some(v) = a.ruby_auto_tcy_digits { - el.set("RubyAutoTcyDigits", v); - } - if let Some(v) = a.ruby_auto_tcy_include_roman { - el.set("RubyAutoTcyIncludeRoman", b(v)); - } - if let Some(v) = a.ruby_auto_tcy_auto_scale { - el.set("RubyAutoTcyAutoScale", b(v)); - } - if let Some(v) = a.ruby_overhang { - el.set("RubyOverhang", b(v)); - } - if let Some(v) = a.ruby_overhang_amount.and_then(|v| name(OVERHANGS, v)) { - el.set("RubyParentOverhangAmount", v); - } - if let Some(v) = a.ruby_parent_spacing.and_then(|v| name(SPACINGS, v)) { - el.set("RubyParentSpacing", v); - } - if let Some(v) = a.ruby_auto_align { - el.set("RubyAutoAlign", b(v)); - } - if let Some(v) = a.ruby_auto_scaling { - el.set("RubyAutoScaling", b(v)); - } - if let Some(v) = a.ruby_scaling_min { - el.set("RubyParentScalingPercent", num(v * 100.0)); - } + write_parent_options(el, a); for (k, c) in [("RubyFill", &a.ruby_fill), ("RubyStroke", &a.ruby_stroke)] { if let Some(c) = c { props.push(if c.is_empty() { prop(k, "string", TEXT_COLOR.into()) } else { prop(k, "object", sw(c.as_str())) }); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 35374a5e0..fcee91706 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -10,6 +10,8 @@ use super::*; mod assets; mod clipping; mod decorations; +mod facing_ltr; +mod frame_grid; mod index_sort; mod liquid_guides; mod object_styles; @@ -298,6 +300,7 @@ fn indesign_text_defaults() -> Vec { "HyphenateLadderLimit": "3" => 3, "0" => 0, |r| r.0.hyph_limit; "HyphenationZone": "36" => 36.0, "18" => 18.0, |r| r.0.hyph_zone; "HyphenateCapitalizedWords": "true" => true, "false" => false, |r| r.0.hyph_capitalized; + "IgnoreEdgeAlignment": "false" => false, "true" => true, |r| r.0.ignore_optical_margin; "HyphenateLastWord": "true" => true, "false" => false, |r| r.0.hyph_last_word; "HyphenateAcrossColumns": "true" => true, "false" => false, |r| r.0.hyph_across_column; "HyphenWeight": "5" => 0.5, "9" => 0.9, |r| r.0.hyph_weight; @@ -482,6 +485,57 @@ fn text_color_rules_round_trip() { assert_eq!(p.para.kashidas, Some(false)); } +fn optical_story(pref: &str) -> String { + format!( + r#" + + + + + “Hung” here.
    +
    + + “Flush” here. + +
    +
    "# + ) +} + +/// Story ▸ Optical Margin Alignment and its size, and Ignore Optical Margin on paragraphs and +/// paragraph styles, survive import and export. +#[test] +fn story_optical_margin_round_trips() { + let story = |d: &Document| d.stories.values().find(|s| s.text.starts_with("“Hung”")).cloned().unwrap(); + let d = import_idml_with(&fixture_with_story(&optical_story(r#"OpticalMarginAlignment="true" OpticalMarginSize="18""#)), &|_| None).unwrap(); + let s = story(&d); + assert_eq!(s.optical_margin, designcraft_doc::OpticalMargin { on: true, size: 18.0 }); + assert_eq!((s.paras[0].para.ignore_optical_margin, s.paras[1].para.ignore_optical_margin), (None, Some(true))); + let bytes = export_idml(&d); + let text = zip_text(&bytes, "Stories/"); + assert!(text.contains(r#"OpticalMarginAlignment="true" OpticalMarginSize="18""#), "{text}"); + assert!(text.contains(r#"IgnoreEdgeAlignment="true""#), "{text}"); + let back = story(&import_idml(&bytes).unwrap()); + assert_eq!(back.optical_margin, s.optical_margin); + assert_eq!(back.paras[1].para.ignore_optical_margin, Some(true)); + // Sizes outside InDesign's range are clamped; malformed ones and an absent preference are the default. + for (pref, want) in [ + (r#"OpticalMarginAlignment="true" OpticalMarginSize="1e9""#, (true, 1296.0)), + (r#"OpticalMarginAlignment="true" OpticalMarginSize="0""#, (true, 0.1)), + (r#"OpticalMarginAlignment="true" OpticalMarginSize="NaN""#, (true, 12.0)), + (r#"OpticalMarginAlignment="false""#, (false, 12.0)), + ] { + let d = import_idml_with(&fixture_with_story(&optical_story(pref)), &|_| None).unwrap(); + let om = story(&d).optical_margin; + assert_eq!((om.on, om.size), want, "{pref}"); + } + // On a paragraph style. + let d = import_idml_with(&fixture_with_styles(&keep_styles(r#"IgnoreEdgeAlignment="true""#)), &|_| None).unwrap(); + assert!(d.styles.resolve_para_style("Body").0.ignore_optical_margin); + let styles = zip_text(&export_idml(&d), "Resources/Styles.xml"); + assert!(styles.contains(r#"IgnoreEdgeAlignment="true""#), "{styles}"); +} + /// `[Basic Paragraph]` is InDesign's `$ID/NormalParagraphStyle`, both ways. #[test] fn basic_paragraph_is_normal_paragraph_style() { @@ -1354,6 +1408,7 @@ fn anchored_objects_take_unset_settings_from_object_styles() { object_point, ref_point: 6, keep_within_column, + spine_relative: false, }; let positions: Vec<_> = s.objects.iter().map(|o| o.position.clone()).collect(); assert_eq!( @@ -1366,6 +1421,44 @@ fn anchored_objects_take_unset_settings_from_object_styles() { ); } +/// Relative to Spine comes from the object style chain like the other anchored settings, an +/// item's own setting wins, and export writes it back. +#[test] +fn anchored_spine_relative_comes_from_object_styles_and_round_trips() { + use designcraft_doc::AnchorPosition; + let styles = STYLES.replace( + r#""#, + r#" + + ObjectStyle/Side"#, + ); + let rect = |style: &str, setting: &str| { + format!( + r#" + + + + + "# + ) + }; + let story = format!( + r#" + a{}{}{} + "#, + rect("Note", ""), + rect("Note", r#"SpineRelative="false""#), + rect("$ID/[None]", ""), + ); + let spine = |d: &Document| -> Vec { + let s = d.stories.values().find(|s| !s.objects.is_empty()).unwrap(); + s.objects.iter().map(|o| matches!(o.position, AnchorPosition::Custom { spine_relative: true, .. })).collect() + }; + let d = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + assert_eq!(spine(&d), [true, false, false]); + assert_eq!(spine(&import_idml(&export_idml(&d)).unwrap()), [true, false, false]); +} + /// IDML measures a left-aligned anchored object's X offset leftwards; the document model and /// composition measure every X offset rightwards. #[test] diff --git a/crates/idml/src/tests/facing_ltr.rs b/crates/idml/src/tests/facing_ltr.rs new file mode 100644 index 000000000..b1c322ecc --- /dev/null +++ b/crates/idml/src/tests/facing_ltr.rs @@ -0,0 +1,111 @@ +use designcraft_doc::PageSide::{self, Left, Right}; + +use super::*; + +const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0""#; + +/// A running head: a text frame across `x0..x1` (IDML spread x), 20 pt below the page top. +fn head(id: &str, story: &str, x0: f64, x1: f64) -> String { + let pts: String = [(x0, -280.0), (x0, -260.0), (x1, -260.0), (x1, -280.0)] + .iter() + .map(|(x, y)| format!(r#""#)) + .collect(); + format!( + r#"{pts}"# + ) +} + +fn story(id: &str, text: &str) -> String { + format!( + r#"{text}"# + ) +} + +/// Bound left to right with facing pages: the first page of a spread lies left of the spine +/// (except page 1, alone on the right). Every page has margins `Left=36 Right=120` (inside 36, +/// outside 120), the parent's left page a ruler guide at 108 and a "Left head" running head, its +/// right page a guide at 300 and a "Right head". Three pages: 1 alone, then 2–3. Rulers measure +/// from each page. +fn left_to_right_fixture() -> Vec { + let margins = r#""#; + let page = |id: &str, x: f64, applied: bool, guide: Option| { + let applied = if applied { r#" AppliedMaster="m1""# } else { "" }; + let guide = guide.map_or(String::new(), |at| format!(r#""#)); + format!(r#"{margins}{guide}"#) + }; + let master = format!( + r#"{}{}{}{}"#, + page("mp1", -400.0, false, Some(108.0)), + page("mp2", 0.0, false, Some(300.0)), + head("hl", "sl", -280.0, -36.0), + head("hr", "sr", 36.0, 280.0), + ); + let spread = |id: &str, pages: &[(&str, f64)]| { + let ps: String = pages.iter().map(|(p, x)| page(p, *x, true, None)).collect(); + format!(r#"{ps}"#, pages.len()) + }; + let map = format!( + r#""# + ); + let prefs = format!( + r#""# + ); + zip_files(&[ + ("designmap.xml", &map), + ("Resources/Preferences.xml", &prefs), + ("MasterSpreads/m1.xml", &master), + ("Spreads/sp0.xml", &spread("sp0", &[("p1", 0.0)])), + ("Spreads/sp1.xml", &spread("sp1", &[("p2", -400.0), ("p3", 0.0)])), + ("Stories/sl.xml", &story("sl", "Left head")), + ("Stories/sr.xml", &story("sr", "Right head")), + ]) +} + +/// The text of the running heads a document page shows from its parent page. +fn shown_heads(d: &Document, abs: usize) -> Vec { + let (ppi, ppage) = d.parent_page_for(abs).unwrap(); + let parent = &d.parents[ppi]; + parent + .items + .iter() + .filter(|it| parent.page_at_x(it.bounds().center().x) == Some(ppage)) + .filter_map(|it| d.story(it.text_frame()?.story).map(|s| s.text.clone())) + .collect() +} + +/// Ruler guides of the parent page a document page shows, as x from that parent page's left edge. +fn parent_guides(d: &Document, abs: usize) -> Vec { + let (ppi, ppage) = d.parent_page_for(abs).unwrap(); + let pp = &d.parents[ppi].pages[ppage]; + pp.guides.iter().map(|g| g.position - pp.x).collect() +} + +fn assert_right_pages_mirror_left_pages(d: &Document) { + assert!(d.settings.facing_pages && !d.settings.right_to_left_binding); + let sides: Vec = d.parents[0].pages.iter().map(|p| p.side).collect(); + assert_eq!(sides, vec![Left, Right]); + // Right pages: inside (36) on the left; left pages: outside (120) on the left. + for (abs, side, live, guide, text) in [ + (0, Right, (36.0, 280.0), 300.0, "Right head"), + (1, Left, (120.0, 364.0), 108.0, "Left head"), + (2, Right, (36.0, 280.0), 300.0, "Right head"), + ] { + let n = abs + 1; + let page = d.page(abs).unwrap(); + assert_eq!(page.side, side, "page {n}"); + let m = page.margin_rect(); + assert!((m.x0 - page.x - live.0).abs() < 1e-6 && (m.x1 - page.x - live.1).abs() < 1e-6, "page {n}: margins {m:?}"); + assert_eq!(d.parents[0].pages[d.parent_page_for(abs).unwrap().1].side, side, "page {n} shows the parent's {side:?} page"); + let guides = parent_guides(d, abs); + assert!(guides.len() == 1 && (guides[0] - guide).abs() < 1e-6, "page {n}: guides {guides:?}"); + assert_eq!(shown_heads(d, abs), vec![text.to_string()], "page {n}"); + } +} + +#[test] +fn left_to_right_right_pages_take_mirrored_margins_and_the_right_parent_page() { + let d = import_idml(&left_to_right_fixture()).unwrap(); + assert_right_pages_mirror_left_pages(&d); + let again = import_idml(&export_idml(&d)).unwrap(); + assert_right_pages_mirror_left_pages(&again); +} diff --git a/crates/idml/src/tests/frame_grid.rs b/crates/idml/src/tests/frame_grid.rs new file mode 100644 index 000000000..99f57b4ea --- /dev/null +++ b/crates/idml/src/tests/frame_grid.rs @@ -0,0 +1,98 @@ +use super::*; + +const NS: &str = r#"xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="14.0""#; + +/// A page with one text frame at its top edge whose custom baseline grid starts at 6 pt with a +/// 14 pt increment; its four paragraphs align to the grid. The frame's top is the page's top, so +/// the grid lines are 6 + n·14 pt down from the frame top whatever the grid is measured from. +fn frame_grid_doc() -> Document { + frame_grid_doc_relative(None) +} + +/// [`frame_grid_doc`] with `BaselineFrameGridRelativeOption` set to `relative` when given. +fn frame_grid_doc_relative(relative: Option<&str>) -> Document { + let relative = relative.map(|r| format!(r#" BaselineFrameGridRelativeOption="{r}""#)).unwrap_or_default(); + let designmap = format!( + r#" +"# + ); + let prefs = format!(r#""#); + let pts: String = [(50.0, 0.0), (50.0, 300.0), (250.0, 300.0), (250.0, 0.0)] + .iter() + .map(|(x, y)| format!(r#""#)) + .collect(); + let spread = format!( + r#" +{pts} +"# + ); + let story = format!( + r#"One
    Two
    Three
    Four
    "# + ); + let bytes = zip_files(&[ + ("designmap.xml", &designmap), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", &prefs), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", &story), + ]); + import_idml_with(&bytes, &|_| None).unwrap() +} + +/// Each composed line's baseline below the frame top. +fn baselines(d: &Document) -> Vec { + let sid = *d.stories.keys().next().unwrap(); + let top = d.spreads[0].items[0].text_area().y0; + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.baseline - top).collect() +} + +#[test] +fn custom_frame_grid_sets_lines_from_its_start_by_its_increment() { + let d = frame_grid_doc(); + let grid = d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid; + assert_eq!(grid, Some(designcraft_doc::FrameBaselineGrid::new(6.0, 14.0))); + let b = baselines(&d); + assert_eq!(b.len(), 4, "{b:?}"); + for y in &b { + let n = (y - 6.0) / 14.0; + assert!((n - n.round()).abs() < 1e-6 && n >= 0.0, "baseline {y} is on the 6 + n·14 grid: {b:?}"); + } + // The first line's ascent is past the 6 pt line, so it sits on the next one. + assert!((b[0] - 20.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn custom_frame_grid_round_trips() { + let d = frame_grid_doc(); + let bytes = export_idml(&d); + let spread = zip_text(&bytes, "Spreads/"); + assert!(spread.contains(r#"StartingOffsetForBaselineFrameGrid="6""#), "{spread}"); + assert!(spread.contains(r#"BaselineFrameGridIncrement="14""#), "{spread}"); + let back = import_idml(&bytes).unwrap(); + let grid = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid; + assert_eq!(grid(&back), grid(&d)); + assert_eq!(baselines(&back), baselines(&d)); +} + +#[test] +fn custom_frame_grid_relative_to_round_trips() { + use designcraft_doc::FrameGridRelative as R; + let relative = |d: &Document| d.spreads[0].items[0].text_frame().unwrap().options.baseline_grid.map(|g| g.relative_to); + for (attr, want) in [ + ("TopOfPageOfBaselineFrameGridRelativeOption", R::TopOfPage), + ("TopOfMarginOfBaselineFrameGridRelativeOption", R::TopMargin), + ("TopOfFrameOfBaselineFrameGridRelativeOption", R::TopOfFrame), + ("TopOfInsetOfBaselineFrameGridRelativeOption", R::TopInset), + ("TopOfInset", R::TopInset), + ("SomethingNew", R::TopOfPage), + ] { + let d = frame_grid_doc_relative(Some(attr)); + assert_eq!(relative(&d), Some(want), "{attr}"); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(relative(&back), Some(want), "{attr} after export"); + } + // A frame without the attribute measures from the top of the page. + assert_eq!(relative(&frame_grid_doc()), Some(R::TopOfPage)); +} diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index ae8dd0293..48dc5955d 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -14,6 +14,8 @@ designcraft-fonts = { workspace = true } image = { workspace = true } tiff = { workspace = true } resvg = { workspace = true } +quick-xml = { workspace = true } +flate2 = "1" log = { workspace = true } craft-cms = { workspace = true } zune-core = { version = "0.5", default-features = false } diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 1723145ea..8154f4bfe 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -77,8 +77,23 @@ fn svg_options() -> &'static usvg::Options<'static> { let font_resolver = usvg::FontResolver { select_font: select_font(), select_fallback: select_fallback() }; // Images inside an SVG come only from `data:` URLs: an `href` naming a file is not read // (it is left out, as usvg leaves out an image it can't load). - let image_href_resolver = - usvg::ImageHrefResolver { resolve_data: usvg::ImageHrefResolver::default_data_resolver(), resolve_string: Box::new(|_, _| None) }; + let default_data = usvg::ImageHrefResolver::default_data_resolver(); + let image_href_resolver = usvg::ImageHrefResolver { + resolve_data: Box::new(move |mime, data, options| { + let raster = matches!( + image::guess_format(&data), + Ok(image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::Gif | image::ImageFormat::WebP) + ); + if mime == "image/svg+xml" || (mime == "text/plain" && !raster) { + let xml = svg_data(&data)?; + svg_preflight(&xml)?; + default_data(mime, Arc::new(xml.into_owned()), options) + } else { + default_data(mime, data, options) + } + }), + resolve_string: Box::new(|_, _| None), + }; usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), font_resolver, image_href_resolver, ..Default::default() } }) } @@ -207,9 +222,69 @@ fn add_face(db: &mut Arc, face: Arc Option> { + use std::io::Read; + + if bytes.len() > MAX_SVG_BYTES { + return None; + } + if bytes.starts_with(&[0x1f, 0x8b]) { + let mut xml = Vec::new(); + flate2::read::GzDecoder::new(bytes).take(MAX_SVG_BYTES as u64 + 1).read_to_end(&mut xml).ok()?; + (xml.len() <= MAX_SVG_BYTES).then_some(std::borrow::Cow::Owned(xml)) + } else { + Some(std::borrow::Cow::Borrowed(bytes)) + } +} + +fn svg_preflight(bytes: &[u8]) -> Option<()> { + use quick_xml::events::Event; + + let text = std::str::from_utf8(bytes).ok()?; + let mut reader = quick_xml::Reader::from_str(text); + let mut depth = 0usize; + for _ in 0..MAX_SVG_EVENTS { + match reader.read_event().ok()? { + Event::Start(_) => { + depth += 1; + if depth > MAX_SVG_DEPTH { + return None; + } + } + Event::Empty(_) if depth >= MAX_SVG_DEPTH => return None, + Event::End(_) => depth = depth.checked_sub(1)?, + Event::DocType(doctype) => { + // A '[' inside a quoted external identifier is not an internal subset. + let mut quote = None; + for &byte in doctype.as_ref() { + match (quote, byte) { + (Some(q), b) if b == q => quote = None, + (None, b'\'' | b'"') => quote = Some(byte), + (None, b'[') => return None, + _ => {} + } + } + } + Event::Eof => return (depth == 0).then_some(()), + _ => {} + } + } + None +} + +/// Parse an SVG with at most 16 MiB encoded/decoded data, 100,000 XML events and +/// 64 nested elements. Reject internal DTD subsets before recursive conversion. pub fn svg_tree(bytes: &[u8]) -> Option { - usvg::Tree::from_data(bytes, svg_options()).ok() + let xml = svg_data(bytes)?; + svg_preflight(&xml)?; + usvg::Tree::from_data(&xml, svg_options()).ok() } /// Natural size of a placed graphic in points: rasters at 72 ppi (one pixel per point), PSDs @@ -503,6 +578,98 @@ mod tests { Hi "##; + fn nested_svg(groups: usize) -> String { + format!( + r#"{}{}"#, + "".repeat(groups), + "".repeat(groups) + ) + } + + fn gzip(bytes: &[u8]) -> Vec { + use std::io::Write; + + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() + } + + #[test] + fn svg_depth_is_checked_before_recursive_parsing() { + // Count the SVG root and leaf too; the boundary document still draws its path. + let accepted = nested_svg(MAX_SVG_DEPTH - 2); + let tree = svg_tree(accepted.as_bytes()).unwrap(); + assert!(!tree.root().children().is_empty()); + drop(tree); + for depth in [MAX_SVG_DEPTH - 1, 2000] { + let rejected = nested_svg(depth); + assert!(svg_tree(rejected.as_bytes()).is_none()); + assert!(natural_size(rejected.as_bytes()).is_none()); + assert!(render_svg(rejected.as_bytes(), 10).is_none()); + } + } + + #[test] + fn svg_depth_ignores_markup_in_comments_cdata_and_attributes() { + let markup = "".repeat(2000); + let svg = format!( + r#"{markup}"# + ); + assert!(svg_tree(svg.as_bytes()).is_some()); + } + + #[test] + fn svg_external_doctype_is_preserved_but_entity_markup_is_refused() { + let external = + format!(r#"{}"#, nested_svg(2)); + assert!(svg_tree(external.as_bytes()).is_some()); + let internal = r#"">]>&shape;"#; + assert!(svg_tree(internal.as_bytes()).is_none()); + } + + #[test] + fn svg_byte_and_event_limits_are_enforced() { + let mut bytes = nested_svg(0).into_bytes(); + bytes.resize(MAX_SVG_BYTES, b' '); + assert!(svg_tree(&bytes).is_some()); + bytes.push(b' '); + assert!(svg_tree(&bytes).is_none()); + + let document = |comments| { + format!( + r#"{}"#, + "".repeat(comments) + ) + }; + assert!(svg_tree(document(MAX_SVG_EVENTS - 4).as_bytes()).is_some()); + assert!(svg_tree(document(MAX_SVG_EVENTS - 3).as_bytes()).is_none()); + } + + #[test] + fn svgz_has_the_same_depth_and_decoded_size_limits() { + assert!(svg_tree(&gzip(nested_svg(2).as_bytes())).is_some()); + assert!(svg_tree(&gzip(nested_svg(2000).as_bytes())).is_none()); + let mut bytes = nested_svg(0).into_bytes(); + bytes.resize(MAX_SVG_BYTES + 1, b' '); + let compressed = gzip(&bytes); + assert!(compressed.len() < MAX_SVG_BYTES); + assert!(svg_tree(&compressed).is_none()); + assert!(svg_tree(b"\x1f\x8bnot gzip").is_none()); + } + + #[test] + fn nested_svg_data_images_cannot_bypass_the_depth_limit() { + let nested = nested_svg(2000); + let encoded = nested.bytes().map(|b| format!("%{b:02X}")).collect::(); + for mime in ["image/svg+xml", "text/plain"] { + let svg = format!( + r#""# + ); + // As with other unreadable data images, the parent survives without the image. + assert!(svg_tree(svg.as_bytes()).unwrap().root().children().is_empty()); + } + } + #[test] fn svg_sniff_size_and_render() { let b = SVG.as_bytes(); @@ -738,8 +905,8 @@ mod tests { impl tiff::encoder::colortype::ColorType for Palette { type Inner = u8; const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::RGBPalette; - const BITS_PER_SAMPLE: &'static [u16] = &[BITS]; - const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint]; + const BITS_PER_SAMPLE: &[u16] = &[BITS]; + const SAMPLE_FORMAT: &[tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint]; fn horizontal_predict(row: &[u8], result: &mut Vec) { result.extend_from_slice(row); } diff --git a/crates/images/src/psd.rs b/crates/images/src/psd.rs index db11eef5a..4006e0e30 100644 --- a/crates/images/src/psd.rs +++ b/crates/images/src/psd.rs @@ -81,6 +81,16 @@ impl Mode { /// The merged composite as straight RGBA8, `None` when the file is damaged or unsupported (32-bit, /// Multichannel, ZIP-compressed composite). +/// Whether the header's depth, channel count and size are all within what the decoder supports. +fn header_in_range(mode: Mode, depth: u16, channels: usize, (w, h): (u32, u32), max_side: u32) -> bool { + let depth_ok = match mode { + Mode::Bitmap => depth == 1, + Mode::Indexed => depth == 8, + _ => depth == 8 || depth == 16, + }; + (1..=56).contains(&channels) && channels >= mode.colour_channels() && (1..=max_side).contains(&w) && (1..=max_side).contains(&h) && depth_ok +} + pub(crate) fn decode(bytes: &[u8]) -> Option { let mut r = Reader { b: bytes }; if r.take(4)? != b"8BPS" { @@ -97,13 +107,8 @@ pub(crate) fn decode(bytes: &[u8]) -> Option { let depth = r.u16()?; let mode = Mode::parse(r.u16()?)?; let max_side = if psb { 300_000 } else { 30_000 }; - let depth_ok = match mode { - Mode::Bitmap => depth == 1, - Mode::Indexed => depth == 8, - _ => depth == 8 || depth == 16, - }; let colours = mode.colour_channels(); - if !(1..=56).contains(&channels) || channels < colours || !(1..=max_side).contains(&w) || !(1..=max_side).contains(&h) || !depth_ok { + if !header_in_range(mode, depth, channels, (w, h), max_side) { return None; } let (wu, hu) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); @@ -298,7 +303,7 @@ impl<'a> ImageData<'a> { } let mut counts = self.counts.chunks_exact(self.count_size).map(|c| c.iter().fold(0usize, |n, &b| n << 8 | usize::from(b))); // This channel starts after every earlier channel's rows. - let mut at = counts.by_ref().take(ch.checked_mul(self.height)?).try_fold(0usize, |at, n| at.checked_add(n))?; + let mut at = counts.by_ref().take(ch.checked_mul(self.height)?).try_fold(0usize, usize::checked_add)?; for _ in 0..self.height { let n = counts.next()?; let end = at.checked_add(n)?; diff --git a/crates/mcp/tests/it/automation_roots.rs b/crates/mcp/tests/it/automation_roots.rs index 871945920..8dcc3228d 100644 --- a/crates/mcp/tests/it/automation_roots.rs +++ b/crates/mcp/tests/it/automation_roots.rs @@ -51,7 +51,7 @@ fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) { std::fs::write(dir.join("tags.xml"), format!("

    {text}

    ")).unwrap(); } let (in_dir, out_dir) = (base.join("in"), base.join("out")); - let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap(); + let roots = AutomationRoots::new(read.then_some(in_dir.as_path()).as_slice(), write.then_some(out_dir.as_path()).as_slice()).unwrap(); let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots))); ok(&mut server, "new_document", json!({})); (server, base) @@ -339,7 +339,7 @@ fn without_roots_nothing_is_confined() { let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); designcraft_file(&base.join("a.designcraft"), 3, "Anywhere"); - let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap()))); + let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(&[], &[]).unwrap()))); ok(&mut server, "open_document", json!({"path": s(&base.join("a.designcraft"))})); assert_eq!(pages(&mut server), 3); ok(&mut server, "save_document", json!({"path": s(&base.join("b.designcraft"))})); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 34934b911..5d3ad0644 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -134,6 +134,25 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) write::guard(|| export_pdf_with_report_inner(doc, cache, opts)) } +/// Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X +/// or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and +/// Screen annotations aren't allowed in PDF/A. +fn add_form_fields(bytes: &mut Vec, warnings: &mut Vec, doc: &Document, sheets: &[Sheet], opts: &PdfOptions) { + let fields = crate::forms::collect(doc, sheets); + let media = if opts.media { crate::forms::collect_media(doc, sheets) } else { Vec::new() }; + if fields.is_empty() && media.is_empty() { + return; + } + match opts.standard { + Standard::PdfX4 => warnings.push("PDF/X-4: form fields and media were left out".into()), + Standard::PdfA2b => warnings.push("PDF/A-2b: form fields and media were left out".into()), + _ => match crate::forms::add_fields(&*bytes, &fields, &media) { + Some(b) => *bytes = b, + None => warnings.push("form fields and media couldn't be added to this PDF".into()), + }, + } +} + fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result { let sheets = sheets(doc, opts)?; let warnings = Vec::new(); @@ -235,23 +254,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions // group that got past it, with its page. crate::pdfx::cmyk_group_spaces(&mut bytes); } - // Form fields (Buttons and Forms) and, for interactive PDF, video and sound; not in PDF/X - // or PDF/A: this update comes after krilla's validator ran, and its NeedAppearances fields and - // Screen annotations aren't allowed in PDF/A. - let fields = crate::forms::collect(doc, &sheets); - let media = if opts.media { crate::forms::collect_media(doc, &sheets) } else { Vec::new() }; - if !fields.is_empty() || !media.is_empty() { - if opts.standard == Standard::PdfX4 { - warnings.push("PDF/X-4: form fields and media were left out".into()); - } else if opts.standard == Standard::PdfA2b { - warnings.push("PDF/A-2b: form fields and media were left out".into()); - } else { - match crate::forms::add_fields(&bytes, &fields, &media) { - Some(b) => bytes = b, - None => warnings.push("form fields and media couldn't be added to this PDF".into()), - } - } - } + add_form_fields(&mut bytes, &mut warnings, doc, &sheets, opts); if opts.standard == Standard::PdfX4 { // Output intent and PDF/X identification, then check the result. match crate::pdfx::make_pdfx4(&bytes, &title, created) { @@ -654,6 +657,27 @@ impl Exporter<'_> { self.doc.resolve_color(name, tint).map(|c| device(&c, self.rgb_only)) } + /// The parent pages' items on `layer` shown on page `pi` of spread `si`: the parent's bases + /// first, then the parent. + fn parent_items(&mut self, s: &mut Surface, si: usize, pi: usize, page: &designcraft_doc::Page, layer: &designcraft_doc::Layer, first: usize) { + let doc = self.doc; + for (ppi, ppage) in doc.shown_parent_pages(SpreadRef::Doc(si), pi) { + let Some(parent) = doc.parents.get(ppi) else { continue }; + let Some(ppg) = parent.pages.get(ppage) else { continue }; + let dx = page.x - ppg.x; + for it in &parent.items { + if it.layer != layer.id || page.overridden.contains(&it.id) { + continue; + } + // Parent items belong to the parent page they sit on. + if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 { + continue; + } + self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true); + } + } + } + fn spread(&mut self, s: &mut Surface, si: usize) { let doc = self.doc; let Some(sp) = doc.spreads.get(si) else { return }; @@ -663,24 +687,8 @@ impl Exporter<'_> { continue; } for (pi, page) in sp.pages.iter().enumerate() { - if !page.show_parent_items { - continue; - } - // The parent's bases first, then the parent. - for (ppi, ppage) in doc.shown_parent_pages(SpreadRef::Doc(si), pi) { - let Some(parent) = doc.parents.get(ppi) else { continue }; - let Some(ppg) = parent.pages.get(ppage) else { continue }; - let dx = page.x - ppg.x; - for it in &parent.items { - if it.layer != layer.id || page.overridden.contains(&it.id) { - continue; - } - // Parent items belong to the parent page they sit on. - if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 { - continue; - } - self.top_item(s, it, Affine::translate((dx, 0.0)), Some(first + pi), true); - } + if page.show_parent_items { + self.parent_items(s, si, pi, page, layer, first); } } for it in &sp.items { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index ad1123897..66512ac50 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -56,7 +56,7 @@ pub enum Standard { impl Standard { /// `none`, `x4` / `pdfx4` / `PDF/X-4`, `a2b` / `pdfa2b` / `PDF/A-2b`. pub fn parse(s: &str) -> Option { - let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + let k: String = s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); match k.as_str() { "" | "none" => Some(Self::None), "x4" | "pdfx4" | "pdfx42008" | "pdfx42010" => Some(Self::PdfX4), diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 2be38f97a..11d30602b 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -285,7 +285,7 @@ fn device_rgb(pdf: &[u8]) -> Vec { found .into_iter() .map(|(what, pages)| { - let list: Vec = pages.iter().take(10).map(|p| p.to_string()).collect(); + let list: Vec = pages.iter().take(10).map(ToString::to_string).collect(); let more = if pages.len() > 10 { ", …" } else { "" }; let s = if pages.len() == 1 { "" } else { "s" }; format!("{what} on page{s} {}{more}: under a CMYK output intent RGB needs an ICC profile", list.join(", ")) @@ -392,18 +392,7 @@ impl RgbScan { } b"BI" | b"EI" if !managed => { if let Some(Object::Stream(img)) = last { - let cs = img.dict().get::>(b"CS").or_else(|| img.dict().get::>(b"ColorSpace")); - let cs = match cs { - Some(Object::Name(n)) - if !matches!(n.as_ref(), b"DeviceRGB" | b"RGB" | b"G" | b"DeviceGray" | b"CMYK" | b"DeviceCMYK") => - { - res.get_color_space(&n) - } - cs => cs, - }; - if cs.is_some_and(|cs| is_device_rgb(&cs)) { - self.add("an RGB inline image".into()); - } + self.inline_image(&img, res); } } _ => {} @@ -411,6 +400,20 @@ impl RgbScan { } } + /// An inline image (`BI … EI`) in an RGB space, named directly or through the resources. + fn inline_image(&mut self, img: &Stream<'_>, res: &Resources<'_>) { + let cs = img.dict().get::>(b"CS").or_else(|| img.dict().get::>(b"ColorSpace")); + let cs = match cs { + Some(Object::Name(n)) if !matches!(n.as_ref(), b"DeviceRGB" | b"RGB" | b"G" | b"DeviceGray" | b"CMYK" | b"DeviceCMYK") => { + res.get_color_space(&n) + } + cs => cs, + }; + if cs.is_some_and(|cs| is_device_rgb(&cs)) { + self.add("an RGB inline image".into()); + } + } + fn image(&mut self, img: &Stream<'_>, managed: bool) { let id = img.obj_id(); if managed || !self.seen.insert(id) || img.dict().get::(b"ImageMask") == Some(true) { diff --git a/crates/pdf/src/pdfx/fonts.rs b/crates/pdf/src/pdfx/fonts.rs index 5dc149b7f..83178682a 100644 --- a/crates/pdf/src/pdfx/fonts.rs +++ b/crates/pdf/src/pdfx/fonts.rs @@ -33,7 +33,7 @@ pub(super) fn unembedded_fonts(pdf: &[u8]) -> Vec { let mut warnings: Vec = found .into_iter() .map(|(name, pages)| { - let list: Vec = pages.iter().take(10).map(|p| p.to_string()).collect(); + let list: Vec = pages.iter().take(10).map(ToString::to_string).collect(); let more = if pages.len() > 10 { ", …" } else { "" }; let s = if pages.len() == 1 { "" } else { "s" }; format!("the font {name} isn't embedded (page{s} {}{more}): PDF/X-4 needs every font used for visible text embedded", list.join(", ")) diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 1aaf435d9..7687bd2f8 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -303,6 +303,25 @@ impl Exporter<'_> { }); } + /// One table cell's graphic, or its text composed in the cell's own area. + fn table_cell(&mut self, s: &mut Surface, c: &designcraft_compose::PlacedCell) { + if let Some(g) = &c.graphic { + if let Some(clip) = to_path(&c.clip.to_path(0.1)) { + s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); + s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0)))); + self.graphic(s, g); + s.pop(); + s.pop(); + } + } else if let Some(cft) = c.text.frames.first() { + s.push_transform(&tf(Affine::translate(c.origin.to_vec2()))); + let area = std::mem::replace(&mut self.text_area, lines_area(cft)); + self.frame_text(s, &c.text, cft, &c.source); + self.text_area = area; + s.pop(); + } + } + /// Table fragments: cell fills, cell text (real text), edges and the border. fn tables(&mut self, s: &mut Surface, ft: &FrameText) { for t in &ft.tables { @@ -314,21 +333,7 @@ impl Exporter<'_> { } } for c in &t.cells { - if let Some(g) = &c.graphic { - if let Some(clip) = to_path(&c.clip.to_path(0.1)) { - s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); - s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0)))); - self.graphic(s, g); - s.pop(); - s.pop(); - } - } else if let Some(cft) = c.text.frames.first() { - s.push_transform(&tf(Affine::translate(c.origin.to_vec2()))); - let area = std::mem::replace(&mut self.text_area, lines_area(cft)); - self.frame_text(s, &c.text, cft, &c.source); - self.text_area = area; - s.pop(); - } + self.table_cell(s, c); } for seg in &t.strokes { let st = &seg.stroke; diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 79c476343..f6c2fa328 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -240,7 +240,7 @@ fn srgb_table() -> std::sync::Arc> { type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc>); static LUT: std::sync::Mutex> = std::sync::Mutex::new(None); let settings = designcraft_color::cms::active_settings(); - let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = LUT.lock().unwrap_or_else(std::sync::PoisonError::into_inner); match g.as_ref() { Some((s, t)) if *s == settings => t.clone(), _ => { diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 4a6052781..37606097d 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -56,7 +56,7 @@ fn size_of(pm: &Pixmap) -> usize { } fn with(f: impl FnOnce(&mut Cache) -> R) -> R { - let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let c = g.get_or_insert_with(Cache::default); // Decoding converts CMYK through the working space: new colour settings, new pixels. let colour = designcraft_color::cms::generation(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6823fc006..522e245cb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -277,6 +277,26 @@ pub fn default_threads() -> u16 { } } +/// An item's fill under its content; an overprinting fill multiplies into what is beneath. +fn draw_body_fill(ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine) { + if it.fill.is_none() { + return; + } + let doc = f.doc; + ctx.set_transform(f.view * xf); + let op = overprints(f, &it.fill.swatch, it.fill.tint, it.fill.overprint); + if op { + ctx.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None); + } + if set_fill_paint(ctx, doc, &it.fill, bp.bounding_box()) { + ctx.fill_path(bp); + } + ctx.reset_paint_transform(); + if op { + ctx.pop_layer(); + } +} + impl Renderer { pub fn new() -> Self { Self { @@ -627,22 +647,7 @@ impl Renderer { /// Fill, content and stroke of a leaf item (no opacity, blend or effects). pub(crate) fn draw_body(&mut self, ctx: &mut RenderContext, f: &Frame, it: &Item, bp: &BezPath, xf: Affine, page: ShownOn) { - let doc = f.doc; - // Fill. - if !it.fill.is_none() { - ctx.set_transform(f.view * xf); - let op = overprints(f, &it.fill.swatch, it.fill.tint, it.fill.overprint); - if op { - ctx.push_layer(None, Some(BlendMode::new(Mix::Multiply, Compose::SrcOver)), None, None, None); - } - if set_fill_paint(ctx, doc, &it.fill, bp.bounding_box()) { - ctx.fill_path(bp); - } - ctx.reset_paint_transform(); - if op { - ctx.pop_layer(); - } - } + draw_body_fill(ctx, f, it, bp, xf); // Content. match &it.content { Content::Graphic(g) => { @@ -875,14 +880,20 @@ impl Renderer { let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt(); let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen }; let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone()); - let data = if !hidden.is_empty() && is_pdf(&asset.data) { pdf_layers::layered(&asset.data, &hidden) } else { asset.data.clone() }; + let data = if !hidden.is_empty() && is_pdf(&asset.data) { + // Stale persisted/relinked choices must not reveal hidden source content. + let Some(variant) = pdf_layers::layered(&asset.data, &hidden) else { return }; + variant + } else { + asset.data.clone() + }; // A placed PDF cropped to a box: the whole page, placed so that the box spans graphic space. let [l, t, r, b] = shown_box(asset).unwrap_or([0.0, 0.0, 1.0, 1.0]); let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t)); let page = Rect::new(-l * page_w, -t * page_h, (1.0 - l) * page_w, (1.0 - t) * page_h); let Some(pm) = images::mip(&data, asset.page, page_w, on_screen) else { return }; // A coloured image is already in the plate's inks (its colours come from `color_of`). - let pm = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(|p| p.get())) { + let pm = match (colorize::colorized(f.doc, &g.fill, &asset.data, &pm), PLATE.with(std::cell::Cell::get)) { (Some(coloured), _) => coloured, (None, Some(plate)) => plate_pixmap(&pm, plate), (None, None) => pm, @@ -913,7 +924,7 @@ pub(crate) fn cmyk_table() -> std::sync::Arc> { type Lut = (designcraft_color::cms::ColorSettings, std::sync::Arc>); static LUT: std::sync::Mutex> = std::sync::Mutex::new(None); let settings = designcraft_color::cms::active_settings(); - let mut g = LUT.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = LUT.lock().unwrap_or_else(std::sync::PoisonError::into_inner); match g.as_ref() { Some((s, t)) if *s == settings => t.clone(), _ => { @@ -961,7 +972,7 @@ fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync: let key = (std::sync::Arc::as_ptr(pm) as usize, plate); let settings = designcraft_color::cms::active_settings(); { - let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some((previous, map, _)) = guard.as_ref() { if *previous == settings { if let Some((_, derived)) = map.get(&key) { @@ -994,7 +1005,7 @@ fn plate_pixmap(pm: &std::sync::Arc, plate: u8) -> std::sync: let out = std::sync::Arc::new(vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height())); let bytes = (pm.width() as usize).saturating_mul(pm.height() as usize).saturating_mul(8); if bytes <= BUDGET { - let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut guard = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let (previous, map, used) = guard.get_or_insert_with(|| (settings.clone(), Default::default(), 0)); if *previous != settings || used.saturating_add(bytes) > BUDGET { map.clear(); @@ -1024,7 +1035,7 @@ thread_local! { /// Screen indicators and placeholders are display sRGB, independent of the document's RGB /// working profile. Encode their inks only while drawing a process plate or CMYK blend pass. pub(crate) fn screen_color(rgb: [u8; 3], alpha: u8) -> peniko::Color { - if PLATE.with(|plate| plate.get()).is_none() { + if PLATE.with(std::cell::Cell::get).is_none() { return peniko::Color::from_rgba8(rgb[0], rgb[1], rgb[2], alpha); } let [c, m, y, k] = cmyk_in(&cmyk_table(), rgb.map(|v| v as f32 / 255.0)); @@ -1032,7 +1043,7 @@ pub(crate) fn screen_color(rgb: [u8; 3], alpha: u8) -> peniko::Color { } pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { - if let Some(p) = PLATE.with(|p| p.get()) { + if let Some(p) = PLATE.with(std::cell::Cell::get) { let cmyk = match *c { designcraft_color::Color::Cmyk { c, m, y, k } => [c, m, y, k], _ => c.to_cmyk_managed(designcraft_color::cms::Intent::RelativeColorimetric), @@ -1045,7 +1056,7 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color { let v = ink(p.min(3) as usize); return peniko::Color::from_rgba8(v, v, v, a); } - if RICH_BLACK.with(|r| r.get()) + if RICH_BLACK.with(std::cell::Cell::get) && let designcraft_color::Color::Cmyk { c: cc, m, y, k } = *c && cc <= 1e-3 && m <= 1e-3 @@ -1392,8 +1403,13 @@ pub fn pdf_crop_box(bytes: &[u8], page: usize, crop: designcraft_doc::PdfCrop) - PdfCrop::Crop => WHOLE, PdfCrop::ContentVisible => pdf_bounds::page_content_bounds(bytes, page).unwrap_or(WHOLE), PdfCrop::ContentAll => { - let all_on = pdf_hide_layers(bytes, &[]); - pdf_bounds::page_content_bounds(all_on.as_deref().unwrap_or(bytes), page).unwrap_or(WHOLE) + if pdf_layers::has_optional_content(&pdf) { + // If all-on rewriting is unsupported, a visible-only box could cut + // off hidden content. Keep the entire page conservatively instead. + pdf_hide_layers(bytes, &[]).and_then(|all_on| pdf_bounds::page_content_bounds(&all_on, page)).unwrap_or(WHOLE) + } else { + pdf_bounds::page_content_bounds(bytes, page).unwrap_or(WHOLE) + } } PdfCrop::Art | PdfCrop::Trim | PdfCrop::Bleed | PdfCrop::Media => { let media = p.media_box(); diff --git a/crates/render/src/pdf_layers.rs b/crates/render/src/pdf_layers.rs index bc6d864a7..3b323a505 100644 --- a/crates/render/src/pdf_layers.rs +++ b/crates/render/src/pdf_layers.rs @@ -6,20 +6,17 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use craft_pdf::syntax::Pdf; -use craft_pdf::syntax::object::{Array, Dict, Name, ObjectIdentifier}; +use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, ObjRef, Object, ObjectIdentifier}; -/// (object number, name, on by default) of each layer. -fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { +/// (object reference, name, on by default) of each layer. +fn ocgs(pdf: &Pdf) -> Vec<(ObjRef, String, bool)> { let xref = pdf.xref(); let Some(cat) = xref.get::>(xref.root_id()) else { return vec![] }; let Some(ocp) = cat.get::>(b"OCProperties") else { return vec![] }; let Some(list) = ocp.get::>(b"OCGs") else { return vec![] }; let cfg = ocp.get::>(b"D"); - let ids_in = |key: &[u8]| -> Vec { - cfg.as_ref() - .and_then(|c| c.get::>(key)) - .map(|a| a.raw_iter().filter_map(|o| o.as_obj_ref()).map(|r| r.obj_number).collect()) - .unwrap_or_default() + let ids_in = |key: &[u8]| -> Vec { + cfg.as_ref().and_then(|c| c.get::>(key)).map(|a| a.raw_iter().filter_map(|o| o.as_obj_ref()).collect()).unwrap_or_default() }; let base_off = cfg.as_ref().and_then(|c| c.get::>(b"BaseState")).is_some_and(|n| n.as_ref() == b"OFF"); let (on, off) = (ids_in(b"ON"), ids_in(b"OFF")); @@ -31,73 +28,283 @@ fn ocgs(pdf: &Pdf) -> Vec<(i32, String, bool)> { .get::>(b"Name") .map(|s| String::from_utf8_lossy(s.as_bytes()).to_string()) .unwrap_or_default(); - let visible = if base_off { on.contains(&r.obj_number) } else { !off.contains(&r.obj_number) }; - Some((r.obj_number, name, visible)) + let visible = if base_off { on.contains(&r) } else { !off.contains(&r) }; + Some((r, name, visible)) }) .collect() } +/// Whether interpreting this file can depend on optional-content state. Empty/stub +/// properties are layerless, but malformed group references with state overrides +/// still require a conservative all-content crop when rewriting is unavailable. +pub(crate) fn has_optional_content(pdf: &Pdf) -> bool { + let Some(cat) = pdf.xref().get::>(pdf.xref().root_id()) else { return false }; + let Some(ocp) = cat.get::>(b"OCProperties") else { return false }; + let nonempty = |dict: &Dict<'_>, key: &[u8]| dict.get::>(key).is_some_and(|a| a.raw_iter().next().is_some()); + nonempty(&ocp, b"OCGs") || ocp.get::>(b"D").is_some_and(|cfg| nonempty(&cfg, b"ON") || nonempty(&cfg, b"OFF")) +} + /// The layers of a placed PDF: (name, visible by default). pub fn pdf_layers(bytes: &[u8]) -> Vec<(String, bool)> { craft_pdf::open_slice(bytes, None).map(|p| ocgs(&p).into_iter().map(|(_, n, v)| (n, v)).collect()).unwrap_or_default() } -/// `bytes` with exactly the layers named in `hidden` off (the others on). `None` when the file -/// has no such layers or can't be read. +// Copy untouched dictionary entries as raw PDF tokens. In particular, do not reinterpret +// strings, numbers or unknown extension keys. The parser bounds container nesting. +fn patch_dict(dict: &Dict<'_>, updates: &[(&[u8], &[u8])]) -> Option> { + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + let data = if dict.is_empty() { b"<<>>".as_slice() } else { dict.data() }; + if data.len() > 4 * 1024 * 1024 { + return None; + } + let mut reader = Reader::new(data); + reader.forward_tag(b"<<")?; + let mut out = b"<<".to_vec(); + let mut ended = false; + for _ in 0..100_000 { + reader.skip_white_spaces_and_comments(); + if reader.forward_tag(b">>").is_some() { + ended = true; + break; + } + let start = reader.offset(); + let name = reader.read_without_context::>()?; + reader.skip_white_spaces_and_comments(); + reader.skip::>>(false)?; + if !updates.iter().any(|(key, _)| *key == name.as_ref()) { + out.push(b' '); + out.extend_from_slice(data.get(start..reader.offset())?); + } + } + if !ended { + return None; + } + for (key, value) in updates { + if !value.is_empty() { + out.extend_from_slice(b" /"); + out.extend_from_slice(key); + out.push(b' '); + out.extend_from_slice(value); + } + } + out.extend_from_slice(b" >>"); + Some(out) +} + +fn trailer_at(bytes: &[u8], offset: usize) -> Option> { + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + const MAX_TABLE_BYTES: usize = 4 * 1024 * 1024; + let data = bytes.get(offset..)?; + // Bound even whitespace/comment scanning and context-free dictionary parsing. + let mut reader = Reader::new(data.get(..data.len().min(MAX_TABLE_BYTES))?); + reader.skip_white_spaces_and_comments(); + if reader.forward_tag(b"xref").is_some() { + // Walk table syntax, never a raw substring: a repairable file may contain + // a fake "trailer" in a comment. Such a comment must not hide real guards. + let mut ended = false; + for _ in 0..100_000 { + if reader.offset() > MAX_TABLE_BYTES { + return None; + } + reader.skip_white_spaces(); + if reader.forward_tag(b"trailer").is_some() { + ended = true; + break; + } + reader.read_without_context::()?; // first object number + reader.skip_white_spaces(); + let count = reader.read_without_context::()?; + if count > MAX_TABLE_BYTES / 18 { + return None; + } + for _ in 0..count { + reader.skip_white_spaces(); + reader.read_without_context::()?; // offset or next free object + reader.skip_white_spaces(); + if reader.read_without_context::()? > 65535 { + return None; + } + reader.skip_white_spaces(); + reader.forward_tag(b"n").or_else(|| reader.forward_tag(b"f"))?; + if reader.offset() > MAX_TABLE_BYTES { + return None; + } + } + } + if !ended { + return None; + } + reader.skip_white_spaces(); + } else { + // An xref stream's dictionary also holds the trailer entries. + reader.read_without_context::()?; + reader.skip_white_spaces_and_comments(); + } + reader.read_without_context::>() +} + +// Null dictionary entries are absent in PDF. Keep references and malformed values +// fail-closed: trailer_at's dictionaries have no context for resolving references. +fn has_non_null_entry(dict: &Dict<'_>, key: &[u8]) -> bool { + dict.contains_key(key) && !matches!(dict.get_raw::>(key), Some(MaybeRef::NotRef(Object::Null(_)))) +} + +/// `bytes` with exactly the layers named in `hidden` off (the others on). +/// Returns `None` for unreadable, encrypted, hybrid-reference or over-budget updates. +/// Untouched catalog/configuration entries and full object references are preserved. pub fn pdf_hide_layers(bytes: &[u8], hidden: &[String]) -> Option> { + crate::check_pdf_input_size(bytes.len()).ok()?; let pdf = craft_pdf::open_slice(bytes, None).ok()?; let layers = ocgs(&pdf); if layers.is_empty() { return None; } - let xref = pdf.xref(); - let cat = xref.get::>(xref.root_id())?; - let pages = cat.get_ref(b"Pages")?; - let all: Vec = layers.iter().map(|(n, _, _)| format!("{n} 0 R")).collect(); - let off: Vec = layers.iter().filter(|(_, name, _)| hidden.contains(name)).map(|(n, _, _)| format!("{n} 0 R")).collect(); - // A new catalog under a fresh object number, with the original's page tree. - let root = 9_999_000; - let mut out = bytes.to_vec(); - if !out.ends_with(b"\n") { - out.push(b'\n'); + let tail = String::from_utf8_lossy(bytes.get(bytes.len().saturating_sub(1024)..)?); + let prev = tail.rsplit_once("startxref")?.1.split_whitespace().next()?.parse::().ok()?; + let trailer = trailer_at(bytes, prev)?; + // Do not copy decrypted dictionaries into an encrypted file, or ignore a hybrid + // xref table. Follow bounded Prev links because encryption can be inherited. + let mut offset = Some(prev); + let mut visited = std::collections::HashSet::new(); + for _ in 0..32 { + let Some(at) = offset else { break }; + if !visited.insert(at) { + return None; + } + let t = trailer_at(bytes, at)?; + if has_non_null_entry(&t, b"Encrypt") || has_non_null_entry(&t, b"XRefStm") { + return None; + } + offset = t.get::(b"Prev"); + if offset.is_none() && has_non_null_entry(&t, b"Prev") { + return None; + } } + if offset.is_some() { + return None; + } + let xref = pdf.xref(); + let root = xref.root_id(); + if root.obj_number <= 0 || !(0..=65535).contains(&root.gen_number) || trailer.get::(b"Size")? <= usize::try_from(root.obj_number).ok()? { + return None; + } + let cat = xref.get::>(root)?; + let ocp = cat.get::>(b"OCProperties")?; + let cfg = ocp.get::>(b"D"); + let mut off = b"[".to_vec(); + for (id, name, _) in &layers { + if hidden.contains(name) { + off.extend_from_slice(format!("{} {} R ", id.obj_number, id.gen_number).as_bytes()); + } + } + off.push(b']'); + let empty = Dict::empty(); + let cfg = patch_dict(cfg.as_ref().unwrap_or(&empty), &[(b"BaseState", b"/ON"), (b"ON", b"[]"), (b"OFF", &off)])?; + let ocp = patch_dict(&ocp, &[(b"D", &cfg)])?; + let catalog = patch_dict(&cat, &[(b"OCProperties", &ocp)])?; + let root_ref = format!("{} {} R", root.obj_number, root.gen_number); + let prev_value = prev.to_string(); + let trailer = patch_dict( + &trailer, + &[ + (b"Root", root_ref.as_bytes()), + (b"Prev", prev_value.as_bytes()), + (b"Type", b""), + (b"Length", b""), + (b"Filter", b""), + (b"DecodeParms", b""), + (b"W", b""), + (b"Index", b""), + ], + )?; + let mut out = bytes.to_vec(); + out.push(b'\n'); let at = out.len(); - out.extend_from_slice( - format!( - "{root} 0 obj\n<>>>>>\nendobj\n", - pages.obj_number, - pages.gen_number, - all.join(" "), - off.join(" ") - ) - .as_bytes(), - ); + // Redefine the existing catalog: no arbitrary fresh object number or Size increase. + out.extend_from_slice(format!("{} {} obj\n", root.obj_number, root.gen_number).as_bytes()); + out.extend_from_slice(&catalog); + out.extend_from_slice(b"\nendobj\n"); let xref_at = out.len(); - let prev = { - let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(1024)..]).to_string(); - tail.rsplit_once("startxref").and_then(|(_, t)| t.split_whitespace().next()).and_then(|n| n.parse::().ok())? - }; - out.extend_from_slice( - format!("xref\n{root} 1\n{at:010} 00000 n\r\ntrailer\n<>\nstartxref\n{xref_at}\n%%EOF\n", root + 1) - .as_bytes(), - ); + out.extend_from_slice(format!("xref\n{} 1\n{at:010} {:05} n\r\ntrailer\n", root.obj_number, root.gen_number).as_bytes()); + out.extend_from_slice(&trailer); + out.extend_from_slice(format!("\nstartxref\n{xref_at}\n%%EOF\n").as_bytes()); Some(out) } /// Shared copies of layer variants (by source address and hidden set). -pub(crate) fn layered(data: &Arc>, hidden: &[String]) -> Arc> { +pub(crate) fn layered(data: &Arc>, hidden: &[String]) -> Option>> { type Key = (usize, Vec); - static CACHE: Mutex>>>> = Mutex::new(None); + static CACHE: Mutex>, Option>>)>>> = Mutex::new(None); let key = (Arc::as_ptr(data) as usize, hidden.to_vec()); - let mut c = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut c = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let map = c.get_or_insert_with(HashMap::new); if let Some(v) = map.get(&key) { - return v.clone(); + return v.1.clone(); } if map.len() > 64 { map.clear(); } - let v = pdf_hide_layers(data, hidden).map(Arc::new).unwrap_or_else(|| data.clone()); - map.insert(key, v.clone()); + let v = pdf_hide_layers(data, hidden).map(Arc::new); + map.insert(key, (data.clone(), v.clone())); v } + +#[cfg(test)] +mod tests { + use super::*; + use craft_pdf::syntax::reader::{Reader, ReaderExt}; + + #[test] + fn trailer_lookup_does_not_take_guard_values_from_xref_comments() { + for guard in ["/Encrypt 12 0 R", "/XRefStm 123"] { + let bytes = format!("xref\n% trailer <>\n0 1\n0000000000 65535 f\r\ntrailer\n<>"); + assert!(trailer_at(bytes.as_bytes(), 0).is_none(), "repair-only table must fail closed: {guard}"); + } + } + + #[test] + fn trailer_lookup_accepts_whitespace_and_bounded_table_layouts() { + for eol in ["\n", "\r\n"] { + let bytes = format!(" \r\nxref{eol}0 1{eol}0000000000 65535 f{eol}7 1{eol}0000000123 00001 n{eol}trailer{eol}<>"); + assert_eq!(trailer_at(bytes.as_bytes(), 0).unwrap().get::(b"Size"), Some(9)); + } + assert!(trailer_at(b"xref\n0 1\n0000000000 65535 f\ntrailer% comment\n<< /Size 1 >>", 0).is_none()); + let mut whitespace = vec![b' '; 4 * 1024 * 1024]; + whitespace.extend_from_slice(b"xref\n0 0\ntrailer<<>>"); + assert!(trailer_at(&whitespace, 0).is_none()); + for bytes in [b"xref\n0 999999999\n".as_slice(), b"xref\n0 1\n0000000000 99999 f\ntrailer<<>>", b"xref\n0 1\n0000000000 65535 x\ntrailer<<>>"] + { + assert!(trailer_at(bytes, 0).is_none()); + } + } + + #[test] + fn null_trailer_entries_are_absent_but_references_stay_unsupported() { + for key in ["Encrypt", "XRefStm"] { + assert!(!has_non_null_entry(&Dict::empty(), key.as_bytes())); + for (value, allowed) in [("null", true), ("12 0 R", false), ("<< /Filter /Standard >>", false), ("false", false)] { + let input = format!("<>"); + let dict = Reader::new(input.as_bytes()).read_without_context::>().unwrap(); + assert_eq!(!has_non_null_entry(&dict, key.as_bytes()), allowed, "{key}: {value}"); + } + } + } + + #[test] + fn dictionary_patch_preserves_raw_values_and_bounds_updates() { + let data = b"<]/Base#53tate/OFF/Name(a(b)c)>>"; + let dict = Reader::new(data).read_without_context::>().unwrap(); + let out = patch_dict(&dict, &[(b"BaseState", b"/ON")]).unwrap(); + let text = std::str::from_utf8(&out).unwrap(); + assert!(text.contains("/Unknown[(x) 12 1 R ]")); + assert!(text.contains("/Name(a(b)c)")); + assert!(!text.contains("/OFF")); + assert!(text.contains("/BaseState /ON")); + assert_eq!(patch_dict(&Dict::empty(), &[(b"BaseState", b"/ON")]).unwrap(), b"<< /BaseState /ON >>"); + let mut many = b"<< /Big (".to_vec(); + many.resize(4 * 1024 * 1024 + 1, b'x'); + many.extend_from_slice(b") >>"); + let dict = Reader::new(&many).read_without_context::>().unwrap(); + assert!(patch_dict(&dict, &[]).is_none()); + } +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index a4fa80ad0..a9cdc5ce3 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -156,6 +156,22 @@ fn union(r: Option, b: Rect) -> Option { Some(r.map_or(b, |r| r.union(b))) } +/// Add a rule bar, joined onto the previous one when it continues the same stretch. +fn push_bar(bars: &mut Vec<(String, f32, Rect)>, color: &str, tint: f32, r: Rect) { + match bars.last_mut() { + Some((c, t, last)) + if c.as_str() == color + && *t == tint + && (last.y0 - r.y0).abs() < 1e-6 + && (last.y1 - r.y1).abs() < 1e-6 + && (r.x0 - last.x1).abs() < 0.5 => + { + last.x1 = last.x1.max(r.x1); + } + _ => bars.push((color.to_owned(), tint, r)), + } +} + fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs { let mut runs: Vec<(u32, BezPath)> = Vec::new(); let mut underlines: Vec<(String, f32, Rect)> = Vec::new(); @@ -171,18 +187,7 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line let r = rule.rect(g.x, g.x + g.adv, designcraft_compose::rule_baseline(style, l, g)); // One bar per stretch, as the PDF export draws them: a bar per glyph left // anti-aliased seams between the letters. - match bars.last_mut() { - Some((color, tint, last)) - if *color == rule.color - && *tint == rule.tint - && (last.y0 - r.y0).abs() < 1e-6 - && (last.y1 - r.y1).abs() < 1e-6 - && (r.x0 - last.x1).abs() < 0.5 => - { - last.x1 = last.x1.max(r.x1); - } - _ => bars.push((rule.color.clone(), rule.tint, r)), - } + push_bar(bars, &rule.color, rule.tint, r); } } // A tab carries the rule across its width but draws nothing itself. diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 87aaa3359..faa086435 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -186,8 +186,8 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs { if let Some(s) = r.on("strike") { a.strikethrough = Some(s); } - // Half-points; sizes outside the engine's 0.1–1296 pt range (or non-finite) are dropped. - if let Some(pt) = r.val("sz").and_then(|v| v.parse::().ok()).map(|sz| sz / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)) { + // Half-points; a size the engine cannot mean is dropped (#1593). + if let Some(pt) = r.val("sz").and_then(|v| v.parse::().ok()).map(|sz| sz / 2.0).and_then(designcraft_doc::attrs::imported_type_size) { a.size = Some(pt); } if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) { @@ -210,7 +210,8 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs { fn para_attrs(ppr: Option<&El>) -> ParaAttrs { let mut a = ParaAttrs::default(); let Some(p) = ppr else { return a }; - let tw = |v: Option<&str>| v.and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0); + // Twips, clamped: a file can hold 1e300, which became a 5e298 pt indent (#1593). + let tw = |v: Option<&str>| v.and_then(|v| v.parse::().ok()).map(|v| v / 20.0).and_then(designcraft_doc::attrs::imported_measure); if let Some(sp) = p.child("spacing") { a.space_before = tw(sp.attr("before")); a.space_after = tw(sp.attr("after")); @@ -633,6 +634,29 @@ mod tests { assert_eq!(part_capped(&mut zip, "word/missing.xml", 4096).unwrap(), None); } + /// A DOCX can hold any number where a measure belongs: `1e300` twips became a 5e298 pt + /// indent, and `w:sz` of `1e300` a 5e299 pt type size. Measures are clamped to the widest + /// page and a size the engine cannot mean is dropped (#1593). + #[test] + fn a_measure_or_size_a_file_cannot_mean_is_bounded() { + use designcraft_doc::attrs::MAX_MEASURE; + let doc = format!( + r#" + + big"# + ); + let i = import(&docx(&doc, "", "")).unwrap(); + let p = &i.story.paras[0].para; + for (what, v) in + [("space before", p.space_before), ("space after", p.space_after), ("left indent", p.left_indent), ("first line", p.first_line_indent)] + { + let v = v.expect("the value is kept, bounded"); + assert!(v.is_finite() && v.abs() <= MAX_MEASURE, "{what} is within the widest page: {v}"); + } + let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size; + assert_eq!(size, None, "a size the engine cannot mean is dropped, so the style's size stands"); + } + #[test] fn imports_styles_runs_footnotes_and_tables() { let styles = format!( diff --git a/crates/textimport/src/docx_numbering.rs b/crates/textimport/src/docx_numbering.rs index e3837e720..2a7d7873a 100644 --- a/crates/textimport/src/docx_numbering.rs +++ b/crates/textimport/src/docx_numbering.rs @@ -129,7 +129,8 @@ fn number_style(fmt: &str) -> Format { } fn parse_level(e: &El) -> Level { - let tw = |v: Option<&str>| v.and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0); + // Twips, clamped as elsewhere: a level's indent is a measure, not any number (#1593). + let tw = |v: Option<&str>| v.and_then(|v| v.trim().parse::().ok()).map(|v| v / 20.0).and_then(designcraft_doc::attrs::imported_measure); let ind = e.child("pPr").and_then(|p| p.child("ind")); Level { format: e.val("numFmt").map_or(Format::Number(NumberStyle::Arabic), |f| number_style(&f)), diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index 782071798..6254ca668 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -229,7 +229,7 @@ pub fn import(bytes: &[u8]) -> Result { "strike" => restyle(&|s| s.strike = on), // Half-points; sizes outside the engine's 0.1–1296 pt range fall back to the default. "fs" => restyle(&|s| { - s.size = param.map(|p| p as f64 / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)); + s.size = param.map(|p| p as f64 / 2.0).and_then(designcraft_doc::attrs::imported_type_size); }), "plain" => restyle(&|s| { s.bold = false; diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index 3a890734a..74806ae4c 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -201,7 +201,9 @@ pub fn import(bytes: &[u8]) -> Result { } "cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()), "cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| value.to_string()), - "cSize" => fmt.over.size = value.trim().parse().ok().filter(|s: &f64| s.is_finite()), + // A size the engine cannot mean (0, negative, 1e300) is dropped, so the text + // keeps its style's size (#1593). + "cSize" => fmt.over.size = value.trim().parse().ok().and_then(designcraft_doc::attrs::imported_type_size), "DefineParaStyle" | "DefineCharStyle" => { let nm = value.split('=').next().unwrap_or("").trim().to_string(); let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles }; @@ -276,6 +278,24 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + /// A size does not have to be non-finite to be nonsense: a file can hold `1e300`, `0` or a + /// negative, and tagged text took all three and set them on the text (#1593). They are now + /// dropped, as RTF and DOCX already dropped them, so the text keeps its style's size. + #[test] + fn a_char_size_the_engine_cannot_mean_is_dropped() { + for v in ["1e300", "-5", "0", "1297", "0.09"] { + let src = format!("\r\nbig"); + let i = import(src.as_bytes()).unwrap(); + let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size; + assert_eq!(size, None, "cSize {v} is not a size the engine can mean"); + } + // A size inside the range still arrives. + let src = "\r\nbig"; + let i = import(src.as_bytes()).unwrap(); + let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size; + assert_eq!(size, Some(18.0)); + } + /// `f64::from_str` accepts "NaN" and "inf"; a non-finite size must not reach the story. #[test] fn non_finite_char_size_is_ignored() { diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index 3f20f3dee..83bea3c87 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -11,7 +11,7 @@ use crate::{ImportError, Imported}; /// "B12" → (row 11, column 1). fn cell_ref(r: &str) -> Option<(usize, usize)> { - let letters: String = r.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); + let letters: String = r.chars().take_while(char::is_ascii_alphabetic).collect(); let row: usize = r[letters.len()..].parse().ok()?; let col = letters.chars().try_fold(0usize, |acc, c| { let digit = (c.to_ascii_uppercase() as u8).checked_sub(b'A')? as usize + 1; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f0fd68019..8ec32fe8c 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -34,6 +34,8 @@ use serde_json::Value; pub use catalog::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info}; pub use layout::{CanvasLayout, SpreadSlot}; pub use nav::ZoomTool; +/// The exact text an IME commit types (see [`Tool::ime_commit`]). +pub use text::ime_commit_text; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Mods { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index c437484c2..cba5fc317 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -570,8 +570,7 @@ fn finish_snapped_resize( proposed: Rect, mods: Mods, driver_x: bool, - x_edges: [bool; 3], - y_edges: [bool; 3], + (x_edges, y_edges): ([bool; 3], [bool; 3]), hit: &crate::Snap, ) -> Rect { if !mods.alt { @@ -1021,7 +1020,7 @@ impl Tool for SelectionTool { pointer, }, ); - to = finish_snapped_resize(from, handle, proposed, ev.mods, driver_x, x_edges, y_edges, &hit); + to = finish_snapped_resize(from, handle, proposed, ev.mods, driver_x, (x_edges, y_edges), &hit); crate::snap::lay_dimension_guides(&mut hit.guides, cx.layout.xf(spread), proposed, to, x_edges, y_edges, hit.length_delta); self.guides = hit.guides; } diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 11da46178..974081dd5 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -204,7 +204,7 @@ fn match_rotation(cx: &ToolContext, req: &SnapRequest<'_>, zone_length: f64) -> let mut seen = HashSet::new(); if let Some(spread) = cx.doc.spread(req.spread) { for item in &spread.items { - push_rotation(&mut best, cx.selection, req, item, Affine::IDENTITY, false, 0, &mut seen, proposed, tol); + push_rotation(&mut best, cx.selection, req, item, Walk::ROOT, &mut seen, (proposed, tol)); } } if shows_parent_items(cx, req) { @@ -223,7 +223,24 @@ fn push_parent_rotations( tol: f64, ) { for (item, _) in shown_parent_items(cx.doc, req.spread) { - push_rotation(best, cx.selection, req, item, Affine::IDENTITY, false, 0, seen_items, proposed, tol); + push_rotation(best, cx.selection, req, item, Walk::ROOT, seen_items, (proposed, tol)); + } +} + +/// Where a recursive walk over an item tree stands: the parent's absolute transform, whether an +/// ancestor was skipped, and how deep it is. +#[derive(Clone, Copy)] +struct Walk { + ancestor: Affine, + skipped: bool, + depth: usize, +} + +impl Walk { + const ROOT: Walk = Walk { ancestor: Affine::IDENTITY, skipped: false, depth: 0 }; + + fn child(self, ancestor: Affine, skipped: bool) -> Walk { + Walk { ancestor, skipped, depth: self.depth + 1 } } } @@ -232,13 +249,11 @@ fn push_rotation( selection: &Selection, req: &SnapRequest<'_>, item: &Item, - ancestor: Affine, - skipped: bool, - depth: usize, + walk: Walk, seen: &mut HashSet, - proposed: f64, - tol: f64, + (proposed, tol): (f64, f64), ) { + let Walk { ancestor, skipped, depth } = walk; if depth >= ALIGN_GROUP_DEPTH || !seen.insert(item.id) || item.hidden { return; } @@ -254,7 +269,7 @@ fn push_rotation( return; } for child in item.children() { - push_rotation(best, selection, req, child, abs, skip, depth + 1, seen, proposed, tol); + push_rotation(best, selection, req, child, walk.child(abs, skip), seen, (proposed, tol)); } } @@ -393,10 +408,11 @@ fn grid_origin(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis) -> Option= 0 while the line is inside the page -/// (the bottom edge is not a line, matching the canvas). An increment of 0 or less skips the -/// pass. `relative_to` and `view_threshold` are not read, and hiding the baseline grid does not -/// turn this off. Only a page the rect crosses on x contributes. A dragged ruler guide +/// Lines are `page.y0 + origin + n * increment` for n >= 0 while the line is inside the page +/// (the bottom edge is not a line, matching the canvas), `origin` from +/// [`BaselineGrid::origin`](designcraft_doc::BaselineGrid::origin). An increment of 0 or less +/// skips the pass. `view_threshold` is not read, and hiding the baseline grid does not turn this +/// off. Only a page the rect crosses on x contributes. A dragged ruler guide /// ([`Gesture::Guide`]) doesn't snap to it. fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option { if !matches!(axis, Axis::Y) || !cx.snap.snap_to_guides || matches!(req.gesture, Gesture::Guide) { @@ -417,7 +433,10 @@ fn pass_baseline(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) if !ranges_overlap(moving_lo, moving_hi, lo, hi) { continue; } - let origin = bounds.y0 + baseline.start; + let origin = bounds.y0 + baseline.origin(page); + if !origin.is_finite() { + continue; + } offer_enabled(&mut best, req.rect, flags, axis, tol, |v| nearest_baseline(v, origin, increment, bounds.y0, bounds.y1)); } best.map(|b| b.hit) @@ -907,7 +926,7 @@ fn dimension_lengths(cx: &ToolContext, req: &SnapRequest<'_>) -> Vec { let mut seen = HashSet::new(); if let Some(sp) = cx.doc.spread(req.spread) { for item in &sp.items { - push_own_lengths(&mut out, cx.selection, req, item, Affine::IDENTITY, false, 0, &mut seen); + push_own_lengths(&mut out, cx.selection, req, item, Walk::ROOT, &mut seen); } } if shows_parent_items(cx, req) { @@ -916,16 +935,8 @@ fn dimension_lengths(cx: &ToolContext, req: &SnapRequest<'_>) -> Vec { out } -fn push_own_lengths( - out: &mut Vec, - selection: &Selection, - req: &SnapRequest<'_>, - item: &Item, - ancestor: Affine, - skipped: bool, - depth: usize, - seen: &mut HashSet, -) { +fn push_own_lengths(out: &mut Vec, selection: &Selection, req: &SnapRequest<'_>, item: &Item, walk: Walk, seen: &mut HashSet) { + let Walk { ancestor, skipped, depth } = walk; if depth >= ALIGN_GROUP_DEPTH || !seen.insert(item.id) || item.hidden { return; } @@ -936,7 +947,7 @@ fn push_own_lengths( let skip = skipped || align_skips_item(selection, req, item.id); if item.is_group() && !item.children().is_empty() { for child in item.children() { - push_own_lengths(out, selection, req, child, abs, skip, depth + 1, seen); + push_own_lengths(out, selection, req, child, walk.child(abs, skip), seen); } return; } @@ -994,7 +1005,7 @@ fn stroke_outset(item: &Item) -> f64 { fn push_parent_lengths(out: &mut Vec, cx: &ToolContext, req: &SnapRequest<'_>, seen_items: &mut HashSet) { for (item, _) in shown_parent_items(cx.doc, req.spread) { - push_own_lengths(out, cx.selection, req, item, Affine::IDENTITY, false, 0, seen_items); + push_own_lengths(out, cx.selection, req, item, Walk::ROOT, seen_items); } } @@ -1084,11 +1095,11 @@ fn pass_align(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> let mut best: Option = None; if let Some(sp) = cx.doc.spread(req.spread) { for page in &sp.pages { - offer_box(&mut best, req.rect, flags, axis, page.bounds(), tol, cx.snap.align_edges, cx.snap.align_centers); + offer_box(&mut best, req.rect, flags, axis, page.bounds(), tol, (cx.snap.align_edges, cx.snap.align_centers)); } for item in &sp.items { let Some(rect) = item_target_rect(cx.selection, req, item) else { continue }; - offer_box(&mut best, req.rect, flags, axis, rect, tol, cx.snap.align_edges, cx.snap.align_centers); + offer_box(&mut best, req.rect, flags, axis, rect, tol, (cx.snap.align_edges, cx.snap.align_centers)); } } if shows_parent_items(cx, req) { @@ -1186,7 +1197,7 @@ fn group_box(item: &Item, seen: &mut HashSet, depth: usize, filter: Opti fn offer_parent_items(best: &mut Option, cx: &ToolContext, req: &SnapRequest<'_>, flags: [bool; 3], axis: Axis, tol: f64) { for (item, dx) in shown_parent_items(cx.doc, req.spread) { let Some(rect) = item_target_rect(cx.selection, req, item) else { continue }; - offer_box(best, req.rect, flags, axis, shift_x(rect, dx), tol, cx.snap.align_edges, cx.snap.align_centers); + offer_box(best, req.rect, flags, axis, shift_x(rect, dx), tol, (cx.snap.align_edges, cx.snap.align_centers)); } } @@ -1280,7 +1291,7 @@ fn offer_line(best: &mut Option, moving: Rect, flags: [bool; 3], axis: Axi } } -fn offer_box(best: &mut Option, moving: Rect, flags: [bool; 3], axis: Axis, target: Rect, tol: f64, edges: bool, centers: bool) { +fn offer_box(best: &mut Option, moving: Rect, flags: [bool; 3], axis: Axis, target: Rect, tol: f64, (edges, centers): (bool, bool)) { let (m0, m_mid, m1) = axis_triple(moving, axis); let (t0, t_mid, t1) = axis_triple(target, axis); let [use_left, use_mid, use_right] = flags; @@ -1790,6 +1801,22 @@ mod tests { assert_eq!(hit.delta.y, 0.0); } + #[test] + fn baseline_relative_to_the_top_margin_snaps_from_the_margin() { + let mut doc = Document::new(&NewDocument::default()); + std::sync::Arc::make_mut(&mut doc.spreads[0]).pages[0].margins.top = 37.0; + doc.settings.baseline_grid.start = 0.0; + doc.settings.baseline_grid.increment = 10.0; + doc.settings.baseline_grid.relative_to = designcraft_doc::GridRelative::TopMargin; + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let cx = ctx_on(&doc, &sel, &cache, &layout); + // Lines at 37 + 10n: a top at 58 goes up to 57, not down to the page-relative 60. + let hit = snap(&cx, y_req(Rect::new(100.0, 58.0, 140.0, 80.0))); + assert!((hit.delta.y - -1.0).abs() < 1e-6, "{}", hit.delta.y); + } + #[test] fn nearer_of_margin_and_baseline_wins() { // Bottom margin at 792 - 54 = 738, between baseline lines 732 and 744. diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 56658f41e..daa68949c 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -5,7 +5,8 @@ //! //! IME: the marked text of a composition is a preview of `text.insert` at the caret (so it lays //! out in place like typed text), underlined, until the IME commits it (one `text.insert`, one -//! undo step) or clears it (the preview goes, leaving no undo step). +//! undo step) or clears it (the preview goes, leaving no undo step). A delete-surrounding batch +//! is one `text.deleteSurrounding` instead; the engine runs it through the command pipeline. use std::ops::Range; @@ -136,6 +137,13 @@ impl TypeTool { } } +/// The text an IME commit types: `\r` becomes a paragraph break, and other control characters +/// are dropped (Tab and the line break stay). The engine and the UI share this so a commit's +/// exact accepted text is known without inspecting the document. +pub fn ime_commit_text(text: &str) -> String { + text.chars().map(|c| if c == '\r' { '\n' } else { c }).filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect() +} + /// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). /// `None` when `r` runs past the end of `s` or backwards. fn char_range_to_bytes(s: &str, r: Range) -> Option> { @@ -212,6 +220,123 @@ fn underlines(cx: &ToolContext, ts: TextSel, r: Range) -> Vec<(Point, Poi out } +impl TypeTool { + fn pointer_down(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + self.start = Some(ev.pos); + self.drawing = false; + self.selecting = None; + self.cell_drag = None; + self.unit = None; + self.border = None; + if ev.kind == PointerKind::Down + && let Some(b) = border_at(cx, ev.pos) + { + self.border = Some(b); + return vec![]; + } + // Text frames take the caret (locked ones too, as in InDesign); an empty frame + // becomes one, unless it's locked: then the press draws a new text frame over it. + if let Some((_, id)) = cx.hit(ev.pos) + && let Some(it) = cx.doc.item(id) + && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) + && (it.is_text_frame() || !it.locked) + { + let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + let clicks = ev.click_count(); + // Whole rows/columns selected: wait to see whether this is a drag. + if clicks == 1 && !ev.mods.shift && whole_rows_or_cols(cx) { + self.cell_drag = Some((id.0, sp)); + return vec![]; + } + self.selecting = Some(id.0); + // Two presses select a word (or a run of spaces), three a line, four a + // paragraph, five the story. + if clicks >= 2 { + let unit = match clicks { + 2 => "word", + 3 => "line", + 4 => "paragraph", + _ => "story", + }; + self.unit = Some(unit); + return vec![Action::Exec("text.selectAt".into(), json!({"frame": id.0, "point": [sp.x, sp.y], "unit": unit}))]; + } + let cmd = if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }; + return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]; + } + vec![] + } + + fn pointer_drag(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let Some(a) = self.start else { return vec![] }; + if let Some(b) = self.border.as_mut() { + // A click with a little wobble must not resize. + if !b.begun && (ev.pos - a).hypot() < cx.tol(3.0) { + return vec![]; + } + return b.drag(ev.pos, ev.mods.shift); + } + if self.cell_drag.is_some() { + return vec![]; + } + if let Some(fid) = self.selecting { + let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + let mut p = json!({"frame": fid, "point": [sp.x, sp.y]}); + // After several clicks the drag grows the selection by whole units. + if let Some(unit) = self.unit { + p["unit"] = json!(unit); + } + return vec![Action::Exec("text.extendTo".into(), p)]; + } + if (ev.pos - a).hypot() < cx.tol(3.0) && !self.drawing { + return vec![]; + } + let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; + // Drawn like the frame tools: the press point and the moving edges snap. + let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa }; + let (r, guides) = snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, ev.pos), ev.mods); + self.guides = guides; + let mut out = vec![]; + if !self.drawing { + self.drawing = true; + out.push(Action::Begin("Create Text Frame".into())); + } + out.push(Action::Preview( + "frame.create".into(), + json!({"spread": spread_json(sr), "shape": "rectangle", "content": "text", "rect": rect_json(r), "caret": true, "vertical": self.vertical}), + )); + out + } + + fn pointer_up(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + self.guides.clear(); + let start = self.start.take(); + if let Some(b) = self.border.take() { + return if b.begun { vec![Action::Commit] } else { vec![] }; + } + if let Some((fid, from)) = self.cell_drag.take() { + let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + let moved = start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)); + return if moved { + vec![Action::Exec("table.dropCells".into(), json!({"frame": fid, "from": [from.x, from.y], "to": [sp.x, sp.y]}))] + } else { + vec![Action::Exec("text.placeCaret".into(), json!({"frame": fid, "point": [from.x, from.y]}))] + }; + } + if std::mem::take(&mut self.drawing) { + self.selecting = None; + return vec![Action::Commit]; + } + // Ends a press in text: drops dragged text, or is ignored by the engine. + if let Some(fid) = self.selecting.take() { + let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); + let moved = start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)); + return vec![Action::Exec("text.release".into(), json!({"frame": fid, "point": [sp.x, sp.y], "moved": moved, "copy": ev.mods.alt}))]; + } + vec![] + } +} + impl Tool for TypeTool { fn id(&self) -> &'static str { if self.vertical { "verticalType" } else { "type" } @@ -219,121 +344,9 @@ impl Tool for TypeTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { - PointerKind::Down | PointerKind::DoubleClick => { - self.start = Some(ev.pos); - self.drawing = false; - self.selecting = None; - self.cell_drag = None; - self.unit = None; - self.border = None; - if ev.kind == PointerKind::Down - && let Some(b) = border_at(cx, ev.pos) - { - self.border = Some(b); - return vec![]; - } - // Text frames take the caret (locked ones too, as in InDesign); an empty frame - // becomes one, unless it's locked: then the press draws a new text frame over it. - if let Some((_, id)) = cx.hit(ev.pos) - && let Some(it) = cx.doc.item(id) - && !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. }) - && (it.is_text_frame() || !it.locked) - { - let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); - let clicks = ev.click_count(); - // Whole rows/columns selected: wait to see whether this is a drag. - if clicks == 1 && !ev.mods.shift && whole_rows_or_cols(cx) { - self.cell_drag = Some((id.0, sp)); - return vec![]; - } - self.selecting = Some(id.0); - // Two presses select a word (or a run of spaces), three a line, four a - // paragraph, five the story. - if clicks >= 2 { - let unit = match clicks { - 2 => "word", - 3 => "line", - 4 => "paragraph", - _ => "story", - }; - self.unit = Some(unit); - return vec![Action::Exec("text.selectAt".into(), json!({"frame": id.0, "point": [sp.x, sp.y], "unit": unit}))]; - } - let cmd = if ev.mods.shift { "text.extendTo" } else { "text.placeCaret" }; - return vec![Action::Exec(cmd.into(), json!({"frame": id.0, "point": [sp.x, sp.y]}))]; - } - vec![] - } - PointerKind::Drag => { - let Some(a) = self.start else { return vec![] }; - if let Some(b) = self.border.as_mut() { - // A click with a little wobble must not resize. - if !b.begun && (ev.pos - a).hypot() < cx.tol(3.0) { - return vec![]; - } - return b.drag(ev.pos, ev.mods.shift); - } - if self.cell_drag.is_some() { - return vec![]; - } - if let Some(fid) = self.selecting { - let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); - let mut p = json!({"frame": fid, "point": [sp.x, sp.y]}); - // After several clicks the drag grows the selection by whole units. - if let Some(unit) = self.unit { - p["unit"] = json!(unit); - } - return vec![Action::Exec("text.extendTo".into(), p)]; - } - if (ev.pos - a).hypot() < cx.tol(3.0) && !self.drawing { - return vec![]; - } - let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] }; - // Drawn like the frame tools: the press point and the moving edges snap. - let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa }; - let (r, guides) = snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, ev.pos), ev.mods); - self.guides = guides; - let mut out = vec![]; - if !self.drawing { - self.drawing = true; - out.push(Action::Begin("Create Text Frame".into())); - } - out.push(Action::Preview( - "frame.create".into(), - json!({"spread": spread_json(sr), "shape": "rectangle", "content": "text", "rect": rect_json(r), "caret": true, "vertical": self.vertical}), - )); - out - } - PointerKind::Up => { - self.guides.clear(); - let start = self.start.take(); - if let Some(b) = self.border.take() { - return if b.begun { vec![Action::Commit] } else { vec![] }; - } - if let Some((fid, from)) = self.cell_drag.take() { - let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); - let moved = start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)); - return if moved { - vec![Action::Exec("table.dropCells".into(), json!({"frame": fid, "from": [from.x, from.y], "to": [sp.x, sp.y]}))] - } else { - vec![Action::Exec("text.placeCaret".into(), json!({"frame": fid, "point": [from.x, from.y]}))] - }; - } - if std::mem::take(&mut self.drawing) { - self.selecting = None; - return vec![Action::Commit]; - } - // Ends a press in text: drops dragged text, or is ignored by the engine. - if let Some(fid) = self.selecting.take() { - let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos); - let moved = start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0)); - return vec![Action::Exec( - "text.release".into(), - json!({"frame": fid, "point": [sp.x, sp.y], "moved": moved, "copy": ev.mods.alt}), - )]; - } - vec![] - } + PointerKind::Down | PointerKind::DoubleClick => self.pointer_down(cx, ev), + PointerKind::Drag => self.pointer_drag(cx, ev), + PointerKind::Up => self.pointer_up(cx, ev), PointerKind::Move => vec![], } } @@ -437,7 +450,7 @@ impl Tool for TypeTool { } fn ime_commit(&mut self, cx: &ToolContext, text: &str) -> Vec { - let text: String = text.chars().map(|c| if c == '\r' { '\n' } else { c }).filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect(); + let text = ime_commit_text(text); // The marked text goes; the result is typed in its place like any typing: one command, // one undo step. let mut out = if self.preedit.take().is_some() { vec![Action::Cancel] } else { vec![] }; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c9d85253f..d39ad777b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -19,15 +19,20 @@ designcraft-compose = { workspace = true } designcraft-render = { workspace = true } designcraft-tools = { workspace = true } designcraft-engine = { workspace = true } +# The UI draws monochrome icon glyphs from egui's bundled emoji faces (☰, ⚠, 📋); 0.37 moved +# them behind this feature. They stay bundled copies of OSI/CC0 faces from `epaint_default_fonts`. +egui = { workspace = true, features = ["monochrome_emoji_fonts"] } +web-time = { workspace = true } +# Unicode bidi classes are used only to bypass the label cache for ordinary LTR text. unicode-bidi = { workspace = true } -unicode-bidi-mirroring = { workspace = true } -unicode-segmentation = { workspace = true } -egui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +# egui 0.37 no longer enables `default_fonts` for library consumers; egui_kittest disables +# egui's defaults, and package-scoped UI tests do not build the app binary. +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } tempfile = { workspace = true } designcraft-idml = { workspace = true } zip = { workspace = true } diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index 45acf8c38..73de469cc 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -93,7 +93,8 @@ fn main() { /// windowed app's raw-input hook does. fn step(harness: &mut egui_kittest::Harness<'_, DesignApp>) { let mut raw = std::mem::take(harness.input_mut()); - harness.state_mut().raw_input_hook(&mut raw); + let ctx = harness.ctx.clone(); + harness.state_mut().raw_input_hook(&ctx, &mut raw); *harness.input_mut() = raw; harness.step(); } diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs index 75442b93c..30e48274b 100644 --- a/crates/ui-egui/src/about.rs +++ b/crates/ui-egui/src/about.rs @@ -36,7 +36,7 @@ pub fn paint_mark(ui: &egui::Ui, rect: Rect, color: Color32) { let ctx = ui.ctx(); let ppp = ctx.pixels_per_point(); let px = ((rect.width().min(rect.height()) * ppp).round() as u32).clamp(8, 1024); - let key = egui::Id::new(("artcraft_mark", px, color.to_array())); + let key = egui::Id::unique(("artcraft_mark", px, color.to_array())); let tex: Option = ctx.data(|d| d.get_temp(key)); let tex = tex.unwrap_or_else(|| { let mut rc = vello_cpu::RenderContext::new_with(px as u16, px as u16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); @@ -114,7 +114,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } let t = Tokens::get(ctx); let mut open: Option<&'static str> = None; - let area = egui::Id::new("about"); + let area = egui::Id::unique("about"); let instance = area.with("placement"); let resp = crate::modal_drag::modal(ctx, area, instance).show(ctx, |ui| { ui.set_width(if app.ui.about_tab == 0 { 460.0 } else { 640.0 }); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2118c4929..8d3e4af8b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4,8 +4,11 @@ use designcraft_compose as compose; use designcraft_doc::{Content, Document, GuideSel, Item, SpreadRef}; use designcraft_engine::doc::Selection; +use designcraft_engine::{ImeMutation, ImeTextContext}; use designcraft_geom::{Affine, Point, Rect as DRect, Vec2 as DVec2}; use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, SpreadSlot, ToolKey}; +use egui::ime_context::{CommittedText, ImeMutationStamp, ImeObservation, ImeTarget}; +use egui::output::IMESurroundingText; use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value, json}; @@ -238,8 +241,14 @@ pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> { Some((pi, qi)) } -/// Scroll so absolute page `abs` is centred (keeps zoom). +/// Scroll so absolute page `abs` is centred (keeps zoom). A jump Go Back can return from. pub fn go_to_page(app: &mut DesignApp, abs: usize) { + note_jump(app, false); + centre_page(app, abs); + note_jump(app, true); +} + +fn centre_page(app: &mut DesignApp, abs: usize) { let Some(st) = app.session.active() else { return }; let layout = CanvasLayout::new(&st.doc, false); let Some(pr) = layout.page_rect(&st.doc, abs) else { return }; @@ -249,6 +258,65 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) { } } +/// The document's pages by id, in page order. +fn page_ids(doc: &Document) -> Vec { + doc.spreads.iter().flat_map(|s| s.pages.iter().map(|p| p.id)).collect() +} + +/// The page in view and the view centre's offset from that page's centre. Parent pages have none, +/// nor has a view still waiting to be fitted (leaving parent pages for a document page). +pub(crate) fn page_in_view(app: &DesignApp) -> Option<(designcraft_doc::ids::PageId, DVec2)> { + let st = app.session.active().filter(|st| !st.editing_parents)?; + let (v, r) = (app.view().filter(|v| v.fitted)?, app.canvas_rect?); + let abs = current_page(app)?; + let id = st.doc.page(abs)?.id; + let pr = CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)?; + let c = Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom); + Some((id, c - pr.center())) +} + +/// Layout ▸ Go Back / Go Forward record jumps (Go to Page, the page and spread commands, the Pages +/// panel), not scrolling: where the view is when a jump starts, and where the jump took it. +fn note_jump(app: &mut DesignApp, to: bool) { + let Some((id, offset)) = page_in_view(app) else { return }; + if let Some(h) = app.page_history_mut() { + if to { h.visit(id, offset) } else { h.leave(id, offset) } + } +} + +/// Whether Layout ▸ Go Back (`step` < 0) or Go Forward has a page to go to from the view as it is. +pub fn can_go_through_history(app: &DesignApp, step: isize) -> bool { + let Some(st) = app.session.active().filter(|st| !st.editing_parents) else { return false }; + // A view still waiting to be fitted is refitted next frame, which would replace the destination. + if !app.view().is_some_and(|v| v.fitted) { + return false; + } + let ids = page_ids(&st.doc); + let Some(mut h) = app.page_history().cloned() else { return false }; + if let Some((id, offset)) = page_in_view(app) { + h.leave(id, offset); + } + h.can_go(step, |id| ids.contains(&id)) +} + +/// Layout ▸ Go Back (`step` < 0) or Go Forward: show the page viewed before or after this one, where +/// the view was on it (zoom kept). +pub fn go_through_history(app: &mut DesignApp, step: isize) { + if !app.view().is_some_and(|v| v.fitted) { + return; + } + note_jump(app, false); + let Some(ids) = app.session.active().filter(|st| !st.editing_parents).map(|st| page_ids(&st.doc)) else { return }; + let Some((id, offset)) = app.page_history_mut().and_then(|h| h.go(step, |id| ids.contains(&id))) else { return }; + let Some(st) = app.session.active() else { return }; + let Some(pr) = ids.iter().position(|p| *p == id).and_then(|abs| CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)) else { return }; + let Some(rect) = app.canvas_rect else { return }; + let c = pr.center() + offset; + if let Some(v) = app.view_mut() { + v.origin = Point::new(c.x - rect.width() as f64 / 2.0 / v.zoom, c.y - rect.height() as f64 / 2.0 / v.zoom); + } +} + /// Presentation: the spread in view, centred and as large as `rect` allows. fn present_fit(app: &mut DesignApp, rect: Rect) { let Some(st) = app.session.active() else { return }; @@ -268,10 +336,29 @@ fn present_fit(app: &mut DesignApp, rect: Rect) { /// Centre the spread `delta` spreads after (or before) the one in view (keeps zoom); stops at the /// first and last spread. Parent spreads while they are being edited. pub fn step_spread(app: &mut DesignApp, delta: isize) { + // At the first or last spread only the view moves, within the spread: no jump for Go Back. + let jump = spread_step(app, delta).is_some_and(|(cur, to)| cur != to); + if jump { + note_jump(app, false); + } + centre_spread(app, delta); + if jump { + note_jump(app, true); + } +} + +/// The slot in view and the one `delta` spreads after (or before) it, stopping at the ends. +fn spread_step(app: &DesignApp, delta: isize) -> Option<(usize, usize)> { + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cur = current_slot(app, &layout)?; + Some((cur, cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1)))) +} + +fn centre_spread(app: &mut DesignApp, delta: isize) { + let Some((_, to)) = spread_step(app, delta) else { return }; let Some(st) = app.session.active() else { return }; let layout = CanvasLayout::new(&st.doc, st.editing_parents); - let Some(cur) = current_slot(app, &layout) else { return }; - let to = cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1)); let Some(b) = layout.slots.get(to).map(|s| s.bounds) else { return }; let Some(rect) = app.canvas_rect else { return }; if let Some(v) = app.view_mut() { @@ -296,25 +383,39 @@ pub fn set_zoom(app: &mut DesignApp, z: f64) { zoom_at(app, rect.center(), z / cur); } +/// How long egui (0.37 `WheelState`) keeps a scroll action's latched modifiers after the last +/// wheel event: 150 ms, or a second while fingers rest on a trackpad. The Alt latch below ends +/// with the action, exactly as egui ends Ctrl/Cmd, so the rest of an ended motion scrolls for +/// both keys and a later plain scroll is never taken for a zoom. +const SCROLL_ACTION_TIMEOUT: f64 = 0.150; +const RESTING_FINGER_TIMEOUT: f64 = 1.0; + /// Whether the wheel motion now arriving was made with Alt down. As egui does for Ctrl/Cmd, a /// motion keeps its key to its end: the wheel is smoothed over a few frames, and a trackpad swipe /// coasts on after the key is let go. fn wheel_alt(ui: &egui::Ui) -> bool { - let id = egui::Id::new("canvas_wheel_alt"); - // Alt, and whether a trackpad swipe is under way. - let (mut alt, mut swipe): (bool, bool) = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + let id = egui::Id::unique("canvas_wheel_alt"); + // Alt, whether a trackpad swipe is under way, and the time of the last wheel event. + let (mut alt, mut swipe, mut last): (bool, bool, f64) = ui.data(|d| d.get_temp(id)).unwrap_or((false, false, f64::NEG_INFINITY)); ui.input(|i| { for e in &i.events { - if let egui::Event::MouseWheel { phase, modifiers, .. } = e { - (alt, swipe) = match phase { - egui::TouchPhase::Start => (modifiers.alt, true), - egui::TouchPhase::Move => (modifiers.alt || (swipe && alt), swipe), - egui::TouchPhase::End | egui::TouchPhase::Cancel => (false, false), - }; + let egui::Event::MouseWheel { phase, modifiers, source, .. } = e else { continue }; + // egui ends the action (and drops its latched modifiers) when a new event arrives + // after this pause; the Alt latch ends there too. + let timeout = if swipe && *source == egui::MouseWheelSource::Trackpad { RESTING_FINGER_TIMEOUT } else { SCROLL_ACTION_TIMEOUT }; + if timeout < i.time - last { + alt = false; + swipe = false; } + last = i.time; + (alt, swipe) = match phase { + egui::TouchPhase::Start => (modifiers.alt, true), + egui::TouchPhase::Move => (modifiers.alt || (swipe && alt), swipe), + egui::TouchPhase::End | egui::TouchPhase::Cancel => (false, false), + }; } }); - ui.data_mut(|d| d.insert_temp(id, (alt, swipe))); + ui.data_mut(|d| d.insert_temp(id, (alt, swipe, last))); alt } @@ -376,7 +477,7 @@ fn navigation_space(app: &mut DesignApp, ctx: &egui::Context) -> bool { } fn spring_zoom_mode(app: &DesignApp, ctx: &egui::Context, space: bool) -> bool { - let down = ctx.data(|d| d.get_temp::(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false); + let down = ctx.data(|d| d.get_temp::(egui::Id::unique(("canvas_pointer_down", app.pane)))).unwrap_or(false); space && ctx.input(|i| i.modifiers.command) && !down && !app.session.tool_busy() } @@ -388,7 +489,7 @@ struct SpringZoom { /// Lend the Zoom tool the gesture; the selected tool and document selection stay intact. fn spring_zoom_input(app: &mut DesignApp, ui: &egui::Ui, resp: &egui::Response, xf: &Xf, active: bool) -> bool { - let id = egui::Id::new(("canvas_spring_zoom", app.pane)); + let id = egui::Id::unique(("canvas_spring_zoom", app.pane)); let mut zoom: SpringZoom = ui.data(|d| d.get_temp(id)).unwrap_or_default(); if !ui.input(|i| i.focused) { ui.data_mut(|d| d.remove::(id)); @@ -531,8 +632,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { painter.rect_stroke(xf.rect(slot.bounds), 0.0, Stroke::new(hair, Color32::from_rgb(0x4a, 0x4a, 0x4a)), StrokeKind::Outside); } if !preview { - let drag = ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))); - let press = ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_PRESS))); + let drag = ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))); + let press = ui.data(|d| d.get_temp::(egui::Id::unique(GUIDE_PRESS))); let moving = drag.as_ref().zip(press).and_then(|(g, press)| moving_guides(app, &xf, rect, g, press)); draw_guides(app, &painter, &xf, &doc, &layout, &t, moving.as_ref()); if let Some(g) = drag @@ -593,7 +694,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } draw_tool_overlays(app, &painter, &xf); - ime_output(app, ui.ctx(), &xf); + ime_canvas_pass(app, ui.ctx(), &xf); if rulers { draw_rulers(app, ui, full, rect, &xf, &doc, &layout, &t); ruler_units_menus(app, &resp, full, rect, &doc); @@ -611,8 +712,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let space = navigation_space(app, ui.ctx()); let zoom = spring_zoom_mode(app, ui.ctx(), space); let middle = ui.input(|i| i.pointer.middle_down()); - let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_alt_hand", app.pane)))).unwrap_or(false); - let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false); + let alt_hand: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_alt_hand", app.pane)))).unwrap_or(false); + let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_pointer_down", app.pane)))).unwrap_or(false); let alt_text = wants_text && !tool_drag && ui.input(|i| i.modifiers.alt); let c = if middle || alt_hand { Cursor::HandGrab @@ -624,7 +725,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) }; // A column guide that a press would drag, or one being dragged: a resize cursor across it. - let dragging = ui.data(|d| d.get_temp::(egui::Id::new((COLUMN_DRAG, app.pane)))).map(|g| g.spread); + let dragging = ui.data(|d| d.get_temp::(egui::Id::unique((COLUMN_DRAG, app.pane)))).map(|g| g.spread); let column = dragging.or_else(|| { let menu_open = context_menu_open(ui.ctx()); (!space && !zoom && !middle && !alt_hand && !tool_drag && !menu_open && xf.rot == 0) @@ -640,6 +741,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.ctx().set_cursor_icon(icon); draw_cursor_badge(&painter, p, c, &t); } + // A second viewport has no panels, dialogs or Quick Apply after its canvas: observe here, + // once, as the end of its pass. The root viewport observes at the end of `DesignApp::ui`. + if ui.ctx().viewport_id() != egui::ViewportId::ROOT { + ime_observe(app, ui.ctx()); + } } /// The loaded text cursor's badge beside the system cursor: lines of text, a closed chain @@ -962,7 +1068,8 @@ fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32, const MAX_BASELINES: usize = 216 * 72; /// Baseline grid lines inside a page, measured from its top: `start + n * increment` for n >= 0 (the bottom -/// edge is not a line). Lines above the page are skipped without walking them, however far up the start is. +/// edge is not a line), `start` from [`BaselineGrid::origin`](designcraft_doc::BaselineGrid::origin). Lines +/// above the page are skipped without walking them, however far up the start is. fn baseline_grid_lines(start: f64, increment: f64, height: f64) -> impl Iterator { let step = increment.max(1.0); let first = if start < 0.0 { start.rem_euclid(step) } else { start }; @@ -1022,7 +1129,7 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document // Baseline grid. if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold { let g = &s.baseline_grid; - for y in baseline_grid_lines(g.start, g.increment, p.height) { + for y in baseline_grid_lines(g.origin(p), g.increment, p.height) { painter.line_segment( [pt(Point::new(pr.x0, pr.y0 + y)), pt(Point::new(pr.x1, pr.y0 + y))], Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)), @@ -1578,7 +1685,7 @@ fn draw_text_selection( /// A text field had keyboard focus when this frame began, per window: egui keeps one focus per /// viewport, so a field focused in the main window must not mute the second window's canvas. fn field_focused_id(ctx: &egui::Context) -> egui::Id { - egui::Id::new("canvas_field_focused").with(ctx.viewport_id()) + egui::Id::unique("canvas_field_focused").with(ctx.viewport_id()) } /// Called before the panels draw. A panel field gives up focus on Enter or Tab while it draws, @@ -1600,7 +1707,7 @@ pub fn begin_frame(ctx: &egui::Context) { /// (held when this frame's events began, held after them). #[cfg(not(target_arch = "wasm32"))] fn modifiers_id() -> egui::Id { - egui::Id::new("canvas-frame-modifiers") + egui::Id::unique("canvas-frame-modifiers") } /// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin). @@ -1692,10 +1799,10 @@ fn draw_inverse_highlight( ); let (w, h) = (((bbox.width() * ppp).round() as u32).clamp(1, 8192), ((bbox.height() * ppp).round() as u32).clamp(1, 8192)); let Some(st) = app.session.active() else { return }; - let key = egui::Id::new(("inverse_sel", st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, story.0)) + let key = egui::Id::unique(("inverse_sel", st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, story.0)) .with((runs.iter().map(|r| r.2.len() + r.3.len() * 7919).sum::(), w, h)) .with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)))); - let cache_id = egui::Id::new("inverse_sel_tex"); + let cache_id = egui::Id::unique("inverse_sel_tex"); let cached: Option<(egui::Id, egui::TextureHandle)> = ctx.data(|d| d.get_temp(cache_id)); let tex = match cached { Some((k, t)) if k == key => t, @@ -1917,7 +2024,7 @@ fn draw_content_ghost(painter: &egui::Painter, xf: &Xf, doc: &Document, layout: /// A screen-resolution proxy of a placed graphic for the ghost, cached per asset. fn ghost_texture(ctx: &egui::Context, doc: &Document, g: &designcraft_doc::Graphic) -> Option { let asset = doc.assets.get(&g.asset)?; - let key = egui::Id::new(("content_ghost", std::sync::Arc::as_ptr(&asset.data) as usize, asset.data.len(), asset.page)); + let key = egui::Id::unique(("content_ghost", std::sync::Arc::as_ptr(&asset.data) as usize, asset.data.len(), asset.page)); if let Some(t) = ctx.data(|d| d.get_temp::(key)) { return Some(t); } @@ -1994,7 +2101,7 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, // While the zero point is dragged out of the corner, the rulers measure from where it would // land (kept on the pasteboard, as the engine keeps it) and crosshair lines show the spot. // Cmd-dragged, the lines are the two guides it makes; locked, a plain drag does nothing. - let dragged = ui.data(|d| d.get_temp::(egui::Id::new((ZERO_POINT_DRAG, app.pane)))).filter(|p| !corner.contains(*p)); + let dragged = ui.data(|d| d.get_temp::(egui::Id::unique((ZERO_POINT_DRAG, app.pane)))).filter(|p| !corner.contains(*p)); let guides = ui.input(|i| i.modifiers.command); let moving = dragged.filter(|_| !guides && !doc.settings.zero_point_locked).and_then(|p| zero_point_at(app, xf, p)); let cross = if guides { dragged } else { moving.map(|(_, c)| xf.to_screen(c)) }; @@ -2198,7 +2305,7 @@ struct MenuRequest { /// The popup id of the canvas context menu in split-view pane `pane`. fn context_menu_id(pane: u8) -> egui::Id { - egui::Id::new(("canvas_context_menu", pane)) + egui::Id::unique(("canvas_context_menu", pane)) } /// Was a canvas context menu open (in either pane of this window) when this frame began? @@ -2206,7 +2313,7 @@ fn context_menu_id(pane: u8) -> egui::Id { /// click or key still belongs to the menu. Popups are per window (viewport), and so is input. pub fn context_menu_open(ctx: &egui::Context) -> bool { let pass = ctx.cumulative_pass_nr(); - let key = egui::Id::new("canvas_context_menu_open").with(ctx.viewport_id()); + let key = egui::Id::unique("canvas_context_menu_open").with(ctx.viewport_id()); if let Some((p, open)) = ctx.data(|d| d.get_temp::<(u64, bool)>(key)) && p == pass { @@ -2342,7 +2449,7 @@ fn scrollbars(app: &mut DesignApp, ui: &mut egui::Ui, outer: Rect, screen: Rect, let mut hot = [false; 2]; for (n, vertical) in [true, false].into_iter().enumerate() { let (track, thumb, k) = scrollbar_rects(outer, screen, cb, vertical); - let id = egui::Id::new(("canvas_scrollbar", app.pane, vertical)); + let id = egui::Id::unique(("canvas_scrollbar", app.pane, vertical)); let resp = ui.interact(track, id, Sense::click_and_drag()); let grab_id = id.with("grab"); if resp.drag_started() { @@ -2426,10 +2533,10 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r // see the primary button. // A column guide drag keeps the pointer until the button is up, whatever Space or a menu does // meanwhile. - if ui.data(|d| d.get_temp::(egui::Id::new((COLUMN_DRAG, app.pane)))).is_some() && column_guide_drag(app, ui, resp, rect, &xf) { + if ui.data(|d| d.get_temp::(egui::Id::unique((COLUMN_DRAG, app.pane)))).is_some() && column_guide_drag(app, ui, resp, rect, &xf) { return; } - let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false); + let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::unique(("canvas_pointer_down", app.pane)))).unwrap_or(false); let hand = space && !ui.input(|i| i.modifiers.command); if !tool_drag && ((hand && resp.dragged()) || resp.dragged_by(egui::PointerButton::Middle)) { let d = xf.unrotate_delta(resp.drag_delta()); @@ -2440,7 +2547,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } // Editing text (Space types a space): Alt held at the press gives the Hand tool until the // button is released, and the caret stays. Alt pressed later in a text drag copies the text. - let alt_hand_id = egui::Id::new(("canvas_alt_hand", app.pane)); + let alt_hand_id = egui::Id::unique(("canvas_alt_hand", app.pane)); let mut alt_hand: bool = ui.data(|d| d.get_temp(alt_hand_id)).unwrap_or(false); let (press, alt, primary_down) = ui.input(|i| (i.pointer.primary_pressed(), i.modifiers.alt, i.pointer.primary_down())); if press && alt && wants_text && !tool_drag && !menu_open && resp.hovered() { @@ -2471,11 +2578,25 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let vi = app.view_info(); let pos = |p: Pos2| xf.to_canvas(p); let mut events: Vec = Vec::new(); - let down_id = egui::Id::new(("canvas_pointer_down", app.pane)); + let down_id = egui::Id::unique(("canvas_pointer_down", app.pane)); let mut down: bool = ui.data(|d| d.get_temp(down_id)).unwrap_or(false); - let clicks_id = egui::Id::new(("canvas_clicks", app.pane)); + let clicks_id = egui::Id::unique(("canvas_clicks", app.pane)); let (pressed, released, origin, latest, now) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin(), i.pointer.latest_pos(), i.time)); + // A complete click can arrive in one pass: release clears egui's held origin. + // Recover the actual primary press position, preserving the origin for held gestures. + let origin = origin.or_else(|| { + ui.input(|i| { + i.events + .iter() + .rev() + .find_map(|event| match event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } => Some(*pos), + _ => None, + }) + .filter(|pos| pos.is_finite()) + }) + }); let in_view = |o: Pos2| if xf.rot == 0 { rect.contains(o) } else { resp.rect.contains(o) }; if pressed && !space @@ -2498,7 +2619,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m, clicks: 1 }); } // Power Zoom: holding the Hand tool still for half a second turns the press into one. - let hold_id = egui::Id::new(("canvas_hold", app.pane)); + let hold_id = egui::Id::unique(("canvas_hold", app.pane)); if down && !released && app.session.tool_id() == "hand" && !m.alt { let (now, origin_pos) = ui.input(|i| (i.time, i.pointer.press_origin())); let (start, fired): (f64, bool) = ui.data(|d| d.get_temp(hold_id)).unwrap_or((now, false)); @@ -2581,7 +2702,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r }; let composing = composing_at_start || app.session.tool_composing(); match e { - egui::Event::Ime(ime) if wants_text => ime_event(app, ime, vi), + egui::Event::Ime(ime) if wants_text => ime_event(app, ui.ctx(), ime, vi), // Keys the IME passes through come as text only while nothing is marked. egui::Event::Text(t) if wants_text && !composing => { let _ = app.run("text.insert", json!({"text": t})); @@ -2648,6 +2769,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r _ => {} } } + finish_pending_ime_delete(app, ui.ctx()); let _ = DVec2::ZERO; } @@ -2663,28 +2785,77 @@ fn keyboard_pane(app: &DesignApp) -> bool { app.pane == app.focus_pane } +/// Has the composition these events belong to already ended outside the IME in this batch? +/// +/// A pointer press and a document switch end the composition and type its marked text where the +/// caret then is, but the system IME still sends the Commit (or a delete) for the copy it holds. +/// Applying those at the new caret would type the text twice, put it in another story or document, +/// or delete text there, so they are dropped and the IME is told to drop its copy. +fn ime_composition_ended(app: &DesignApp) -> bool { + app.ime_marked.is_some() && !app.session.tool_composing() +} + +/// Did the composition move to another owner (another story, cell or document state) since it +/// began? A late commit then belongs to the old one, not the caret that is there now. +fn ime_composition_moved(app: &DesignApp) -> bool { + let Some(recorded) = app.ime_target else { return false }; + app.session.ime_text_context().is_some_and(|context| ime_target(&context) != recorded) +} + +/// Drop events for a composition that already ended or moved, keeping what was typed. +fn drop_stale_ime_events(app: &mut DesignApp) { + app.ime_marked = None; + app.ime_target = None; + app.pending_ime_delete = None; + app.ime_discard = true; + // The host must interrupt the system IME's copy in this pass too, as for a click away. + app.ime_interrupt = true; +} + /// One input method event for the text caret: marked text previews in the story, a commit types. -fn ime_event(app: &mut DesignApp, e: egui::ImeEvent, vi: designcraft_engine::ViewInfo) { +fn ime_event(app: &mut DesignApp, ctx: &egui::Context, e: egui::ImeEvent, vi: designcraft_engine::ViewInfo) { let r = match e { egui::ImeEvent::Preedit { text, active_range_chars } => { - app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty()); + if text.is_empty() { + app.ime_marked = None; + app.ime_target = None; + } else { + // The composition's owner at its first marked text: a commit may only land there. + if app.ime_marked.is_none() { + app.ime_target = app.session.ime_text_context().map(|c| ime_target(&c)); + } + app.ime_marked = Some(text.clone()); + } app.session.tool_preedit(&text, active_range_chars, vi).map_err(|e| e.to_string()) } + // A composition that already ended or moved in this batch owns nothing any more. + egui::ImeEvent::Commit(_) | egui::ImeEvent::DeleteSurrounding { .. } | egui::ImeEvent::DeleteSurroundingBytes { .. } + if app.ime_interrupt || ime_composition_ended(app) || ime_composition_moved(app) => + { + // Dropping a stale delete clears the marked target. Keep the pass's interrupt latch + // authoritative so the commit following that delete cannot become a fresh insertion. + drop_stale_ime_events(app); + Ok(()) + } // A bare line break confirms the composition (Enter with some IMEs): the marked text, not // a new paragraph. egui::ImeEvent::Commit(t) if t == "\n" || t == "\r" => { + finish_pending_ime_delete(app, ctx); keep_marked_text(app); Ok(()) } - egui::ImeEvent::Commit(t) => { - app.ime_marked = None; - app.session.tool_ime_commit(&t, vi).map_err(|e| e.to_string()) - } + egui::ImeEvent::Commit(t) => ime_commit(app, ctx, &t, vi), // The web text agent's corrections and mobile Backspace: characters around the caret go. - egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } if !app.session.tool_composing() => { - delete_surrounding(app, before_chars, after_chars) + // These are held for the Commit in the same batch so both mutations are one undo step. + egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } => { + app.pending_ime_delete = Some((before_chars, after_chars, false)); + Ok(()) } - // `Enabled` and `Disabled` carry nothing: the IME is on while `ime_output` sets `ime`. + egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes } => { + app.pending_ime_delete = Some((before_bytes, after_bytes, true)); + Ok(()) + } + // `Enabled` and `Disabled` carry nothing: the IME is on while the observation sets `ime`. _ => Ok(()), }; if let Err(e) = r { @@ -2693,15 +2864,245 @@ fn ime_event(app: &mut DesignApp, e: egui::ImeEvent, vi: designcraft_engine::Vie app.after_engine(); } -/// Delete `before` characters before the caret and `after` after it (sizes come from the browser: -/// a correction never deletes more than a word's worth). -fn delete_surrounding(app: &mut DesignApp, before: usize, after: usize) -> Result<(), String> { - for (n, forward) in [(before, false), (after, true)] { - for _ in 0..n.min(64) { - app.run("text.delete", json!({"forward": forward}))?; - } +/// The bounded committed snapshot and stable target just before an IME mutation. +struct ImeBefore { + target: ImeTarget, + stamp: ImeMutationStamp, + snapshot: IMESurroundingText, + document_uid: u64, + document_epoch: u64, + story: designcraft_doc::StoryId, + cell: Option, + anchor: usize, + focus: usize, +} + +fn ime_target(context: &ImeTextContext<'_>) -> ImeTarget { + let owner = egui::Id::unique(("designcraft-ime", context.document_uid, context.story.0, context.cell.map(|c| (c.table, c.row, c.col)))); + ImeTarget::new(owner, context.document_epoch) +} + +fn capture_ime_before(app: &DesignApp) -> Option { + let context = app.session.ime_text_context()?; + let snapshot = IMESurroundingText::from_text(context.text, context.focus, context.anchor, None); + let stamp = ImeMutationStamp::from_surrounding_text(snapshot.clone()); + Some(ImeBefore { + target: ime_target(&context), + stamp, + snapshot, + document_uid: context.document_uid, + document_epoch: context.document_epoch, + story: context.story, + cell: context.cell, + anchor: context.anchor, + focus: context.focus, + }) +} + +fn ime_commit(app: &mut DesignApp, ctx: &egui::Context, text: &str, vi: designcraft_engine::ViewInfo) -> Result<(), String> { + let pending = app.pending_ime_delete.take(); + let before = capture_ime_before(app); + let r = if let Some((before_bytes, after_bytes, bytes)) = pending { + app.session.ime_commit_after_delete(text, vi, before_bytes, after_bytes, bytes) + } else { + app.session.ime_commit(text, vi) + }; + app.ime_marked = None; + app.ime_target = None; + let r = r.map_err(|e| e.to_string()); + if let Ok(mutation) = &r { + note_ime_change(app, ctx, before, mutation.as_ref(), pending); } - Ok(()) + r.map(|_| ()) +} + +fn finish_pending_ime_delete(app: &mut DesignApp, ctx: &egui::Context) { + let Some((before_bytes, after_bytes, bytes)) = app.pending_ime_delete.take() else { return }; + if ime_composition_ended(app) || ime_composition_moved(app) { + // The composition ended earlier in this batch: its delete belongs to the copy the system + // IME still holds, not to the caret that is there now. + drop_stale_ime_events(app); + return; + } + let before = capture_ime_before(app); + match app.session.ime_delete_surrounding(before_bytes, after_bytes, bytes) { + Ok(mutation) => { + // The delete drops the composition (its marked text is not typed): the IME's copy goes. + if !app.session.tool_composing() && app.ime_marked.take().is_some() { + app.ime_discard = true; + } + if !app.session.tool_composing() { + app.ime_target = None; + } + note_ime_change(app, ctx, before, mutation.as_ref(), Some((before_bytes, after_bytes, bytes))); + } + Err(e) => app.status(e.to_string()), + } + app.after_engine(); +} + +/// Record the origin of a successful IME mutation. +/// +/// A receipt is earned only when the engine reported the exact mutation it applied and the bounded +/// committed snapshot agrees with it. Smart quotes, autocorrect, tracked changes and any other +/// application transformation therefore report `Other`, as do failed, stale and no-op mutations. +fn note_ime_change( + app: &mut DesignApp, + ctx: &egui::Context, + before: Option, + mutation: Option<&ImeMutation>, + delete: Option<(usize, usize, bool)>, +) { + let Some(before) = before else { return }; + let Some(after) = app.session.ime_text_context() else { + let viewport = ctx.viewport_id(); + app.ime_tracker.note_application_change(viewport, ctx.cumulative_frame_nr_for(viewport), before.target); + return; + }; + if ime_target(&after) != before.target { + return; + } + let after_snapshot = IMESurroundingText::from_text(after.text, after.focus, after.anchor, None); + if &after_snapshot == before.stamp.surrounding_text() { + return; + } + let after_stamp = ImeMutationStamp::from_surrounding_text(after_snapshot.clone()); + let viewport = ctx.viewport_id(); + let frame_nr = ctx.cumulative_frame_nr_for(viewport); + if mutation.is_some_and(|m| ime_change_is_exact(&before, &after_snapshot, m, delete)) { + app.ime_tracker.note_input_method_apply(viewport, frame_nr, before.target, before.stamp, after_stamp); + } else { + app.ime_tracker.note_application_change(viewport, frame_nr, before.target); + } +} + +/// Does the bounded post-mutation snapshot agree with the mutation the engine applied? +/// +/// The expected result is built from the reported ranges and typed text — absolute byte offsets in +/// the whole story, not the truncated outgoing window — so a delete that reaches past the window +/// still matches. Every visible byte of the after window must agree with it, but a mutation the +/// after window does not overlap at all (a delete longer than the window) rests on the engine's +/// report alone, which is exact because the engine computed it from the whole story. A rewrite, +/// tracked changes or a replaced story is not a receipt. +fn ime_change_is_exact(before: &ImeBefore, after: &IMESurroundingText, m: &ImeMutation, delete: Option<(usize, usize, bool)>) -> bool { + // The same target, selection and text the receipt was captured around. + if m.document_uid != before.document_uid + || m.document_epoch != before.document_epoch + || m.story != before.story + || m.cell != before.cell + || m.anchor_before != before.anchor + || m.focus_before != before.focus + { + return false; + } + let (Some(before_payload), Some(after_payload)) = (before.snapshot.available(), after.available()) else { return false }; + let before_text = before_payload.text.as_str(); + let after_text = after_payload.text.as_str(); + let selection = m.anchor_before.min(m.focus_before)..m.anchor_before.max(m.focus_before); + // The reported deletion adjoins the selection, is inside the text, and exists only for a + // delete-surrounding event. Its exact extent is the engine's: it computes the ranges from the + // whole story with the requested counts. + if m.deleted_before.end != selection.start + || m.deleted_after.start != selection.end + || m.deleted_before.start > selection.start + || m.deleted_after.end > m.text_len_before + { + return false; + } + if delete.is_none() && (!m.deleted_before.is_empty() || !m.deleted_after.is_empty()) { + return false; + } + // The story changed by exactly the removed bytes, the replaced selection and the typed text: + // tracked changes and an inserted paragraph return (a table anchor) break this identity. + let replaced = if m.inserted.is_empty() { 0 } else { selection.len() }; + let expected_len = m + .text_len_before + .saturating_sub(m.deleted_before.len()) + .saturating_sub(m.deleted_after.len()) + .saturating_sub(replaced) + .saturating_add(m.inserted.len()); + if m.text_len_after != expected_len { + return false; + } + // A commit collapses the caret after the typed text; a delete keeps the selection, shifted. + let (want_anchor, want_focus) = if m.inserted.is_empty() { + (m.anchor_before.saturating_sub(m.deleted_before.len()), m.focus_before.saturating_sub(m.deleted_before.len())) + } else { + let at = m.deleted_before.start + m.inserted.len(); + (at, at) + }; + if m.anchor_after != want_anchor || m.focus_after != want_focus { + return false; + } + // The text around the caret, rebuilt from the reported mutation. A snapshot's window starts + // before its selection's start by the selection's own offset in that window. + let before_win = selection.start.saturating_sub(before_payload.cursor.min(before_payload.anchor)); + let after_selection = m.anchor_after.min(m.focus_after)..m.anchor_after.max(m.focus_after); + let after_win = after_selection.start.saturating_sub(after_payload.cursor.min(after_payload.anchor)); + if after_win.saturating_add(after_payload.cursor) != m.focus_after || after_win.saturating_add(after_payload.anchor) != m.anchor_after { + return false; + } + let before_end = before_win.saturating_add(before_text.len()); + let after_end = after_win.saturating_add(after_text.len()); + if before_end > m.text_len_before || after_end > m.text_len_after { + return false; + } + let kept = if m.inserted.is_empty() { selection.len() } else { 0 }; + let inserted_post = m.deleted_before.start + kept; + let tail_post = inserted_post + m.inserted.len(); + let compared = (|| -> Option { + let mut n = 0usize; + n += ime_compare_kept(before_text, before_win..before_end, 0..m.deleted_before.start, 0, after_text, after_win..after_end)?; + if kept > 0 { + n += ime_compare_kept(before_text, before_win..before_end, selection.clone(), m.deleted_before.start, after_text, after_win..after_end)?; + } + n += ime_compare_window(after_text, after_win..after_end, &(inserted_post..tail_post), &m.inserted)?; + n += ime_compare_kept( + before_text, + before_win..before_end, + m.deleted_after.end..m.text_len_before, + tail_post, + after_text, + after_win..after_end, + )?; + Some(n) + })(); + compared.is_some() +} + +/// Compare one unchanged piece of the story: the pre-mutation bytes `pre` (absolute) appear at +/// post-mutation offset `post_start`. Returns the number of bytes compared, or `None` on a +/// mismatch. Bytes outside the bounded before window cannot be checked and are skipped. +fn ime_compare_kept( + before_text: &str, + before_win: std::ops::Range, + pre: std::ops::Range, + post_start: usize, + after_text: &str, + after_win: std::ops::Range, +) -> Option { + let from = pre.start.max(before_win.start); + let to = pre.end.min(before_win.end); + if from >= to { + return Some(0); + } + let expected = before_text.get(from - before_win.start..to - before_win.start)?; + let post = post_start + (from - pre.start)..post_start + (to - pre.start); + ime_compare_window(after_text, after_win, &post, expected) +} + +/// Compare `expected`, which begins at absolute post-mutation offset `post.start`, with what the +/// after window shows of it. Returns the number of bytes compared; `None` when they differ. +fn ime_compare_window(after_text: &str, after_win: std::ops::Range, post: &std::ops::Range, expected: &str) -> Option { + let from = post.start.max(after_win.start); + let to = post.end.min(after_win.end); + if from >= to { + return Some(0); + } + let actual = after_text.get(from - after_win.start..to - after_win.start)?; + let skip = from - post.start; + let want = expected.get(skip..skip + (to - from))?; + (actual == want).then_some(to - from) } /// End a composition outside the IME (a click away, the canvas losing the keyboard): the marked @@ -2710,6 +3111,7 @@ pub(crate) fn keep_marked_text(app: &mut DesignApp) { if app.ime_marked.take().is_some() { app.ime_discard = true; } + app.ime_target = None; if app.session.tool_composing() { if let Err(e) = app.session.end_composition() { app.status(e.to_string()); @@ -2718,31 +3120,83 @@ pub(crate) fn keep_marked_text(app: &mut DesignApp) { } } +/// What the canvas found for this viewport pass, observed once at the end of the pass. +pub(crate) struct CanvasIme { + /// The viewport it was found in: the second window's canvas runs inside the root's pass and + /// must not consume the root's observation. + viewport: egui::ViewportId, + /// The caret's screen rectangle, when the canvas owns the text caret this pass. + caret: Option, +} + /// While the text caret is in the canvas, let the system IME compose (egui-winit allows it only in /// frames that set `ime`; the web build focuses its hidden text input then) and keep the candidate /// window at the caret, through zoom, scrolling, view rotation and vertical text. -fn ime_output(app: &mut DesignApp, ctx: &egui::Context, xf: &Xf) { +/// +/// The caret is only recorded here: the observation waits for the end of the viewport's pass, so +/// a command or a field that takes the keyboard in a later section of the UI is included. +fn ime_canvas_pass(app: &mut DesignApp, ctx: &egui::Context, xf: &Xf) { if !keyboard_pane(app) { + // Both panes see the same viewport and events. The focused pane owns this viewport's one + // observation; observing for the other would reset the context every split-window frame. return; } // A text field, dialog or Quick Apply has the keyboard: egui's own text editing serves the IME. let ours = app.session.wants_text() && !ctx.text_edit_focused() && !modal_open(app); let caret = if ours { app.session.tool_ime_caret(app.view_info()) } else { None }; - let Some((a, b)) = caret else { + let caret = caret.map(|(a, b)| Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand(1.0)); + app.canvas_ime = caret.is_some(); + app.ime_canvas = Some(CanvasIme { viewport: ctx.viewport_id(), caret }); +} + +/// Observe the IME once for this viewport pass, after every command and focus change it applied. +pub(crate) fn ime_observe(app: &mut DesignApp, ctx: &egui::Context) { + let viewport = ctx.viewport_id(); + if app.ime_canvas.as_ref().is_none_or(|canvas| canvas.viewport != viewport) { + return; + } + let Some(canvas) = app.ime_canvas.take() else { return }; + // A text field, dialog or Quick Apply that took the keyboard later in the pass owns the IME. + let ours = canvas.caret.is_some() && !ctx.text_edit_focused() && !modal_open(app); + let Some(r) = canvas.caret.filter(|_| ours) else { // The IME goes away with its marked text: keep that text as typed, like a click away. keep_marked_text(app); + app.ime_tracker.observe(viewport, None); return; }; - let r = Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand(1.0); // The composition ended outside the IME (a click, a command): the IME drops what it has marked. - let interrupt = app.ime_marked.is_some() && !app.session.tool_composing(); + let interrupt = app.ime_interrupt || (app.ime_marked.is_some() && !app.session.tool_composing()); + app.ime_interrupt = false; if interrupt { app.ime_marked = None; + app.ime_target = None; app.ime_discard = true; } app.canvas_ime = true; + // One observation per viewport pass, after this pass's events and commands. The committed + // text excludes any active pre-edit, and the tracker copies only the bounded IME window. + let update = match app.session.ime_text_context() { + Some(context) => { + let observation = ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), ime_target(&context)) + .purpose(egui::IMEPurpose::Normal) + .committed(Some(CommittedText::new(context.text, context.focus, context.anchor))); + app.ime_tracker.observe(viewport, Some(observation)) + } + None => app.ime_tracker.observe(viewport, None), + }; + let Some(update) = update else { return }; ctx.output_mut(|o| { - o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: interrupt }); + o.ime = Some(egui::output::IMEOutput { + owner: update.owner, + context_generation: update.context_generation, + purpose: update.purpose, + rect: r, + cursor_rect: r, + should_interrupt_composition: interrupt, + reset_request: None, + change_cause: update.change_cause, + surrounding_text: update.surrounding_text, + }); }); } @@ -2818,7 +3272,7 @@ fn draw_flattener_preview(painter: &egui::Painter, xf: &Xf, doc: &Document, layo /// story version). fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { for story in doc.stories.values() { - let key = egui::Id::new(("dyn_spell", std::sync::Arc::as_ptr(story) as usize, doc.user_words.len())); + let key = egui::Id::unique(("dyn_spell", std::sync::Arc::as_ptr(story) as usize, doc.user_words.len())); let bad: std::sync::Arc>> = match ctx.data(|d| d.get_temp(key)) { Some(v) => v, None => { @@ -2878,7 +3332,7 @@ const ZERO_POINT_DRAG: &str = "canvas_zero_point_drag"; /// Cmd-dragged (Ctrl elsewhere), it makes a horizontal and a vertical guide instead. /// Holds the pointer position while dragging; returns true while it owns the pointer. fn zero_point_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf) -> bool { - let id = egui::Id::new((ZERO_POINT_DRAG, app.pane)); + let id = egui::Id::unique((ZERO_POINT_DRAG, app.pane)); let corner = Rect::from_min_max(resp.rect.min, rect.min); let (pressed, released, down, origin, latest, dbl, command) = ui.input(|i| { ( @@ -3179,7 +3633,7 @@ fn guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(designcraft_doc::Sprea /// the guide is selected and the object is not. Returns true from the press to the release, so /// the tools never see that press. fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf) -> bool { - let id = egui::Id::new(GUIDE_DRAG); + let id = egui::Id::unique(GUIDE_DRAG); let (pressed, released, origin, latest) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin(), i.pointer.latest_pos())); let mut drag: Option = ui.data(|d| d.get_temp(id)); @@ -3208,7 +3662,7 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec if !shift && !selected { let _ = app.run("guide.select", json!({"spread": r, "page": pi, "index": gi})); } - ui.data_mut(|d| d.insert_temp(egui::Id::new(GUIDE_PRESS), GuidePress { origin: o, shift })); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(GUIDE_PRESS), GuidePress { origin: o, shift })); drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o), mods: egui::Modifiers::NONE }); } } @@ -3227,8 +3681,8 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec } ui.data_mut(|d| d.remove::(id)); let press: Option = ui.data_mut(|d| { - let press = d.get_temp(egui::Id::new(GUIDE_PRESS)); - d.remove::(egui::Id::new(GUIDE_PRESS)); + let press = d.get_temp(egui::Id::unique(GUIDE_PRESS)); + d.remove::(egui::Id::unique(GUIDE_PRESS)); press }); let Some(p) = g.at else { return true }; @@ -3490,7 +3944,7 @@ fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f6 /// move its gutter, both guides together; Escape puts it back. One undo step per drag. Returns /// true while it owns the pointer. fn column_guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf) -> bool { - let id = egui::Id::new((COLUMN_DRAG, app.pane)); + let id = egui::Id::unique((COLUMN_DRAG, app.pane)); let (pressed, released, origin, latest, shift, escape) = ui.input(|i| { ( i.pointer.primary_pressed(), @@ -3721,7 +4175,14 @@ mod tests { } fn harness(app: DesignApp) -> Harness<'static, DesignApp> { - let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + harness_with_step(app, 1.0 / 4.0) + } + + /// A window whose frames last `step_dt` seconds. Wheel motion is tested at a realistic frame + /// rate: egui ends a scroll action after 150 ms without wheel events, so quarter-second + /// frames would split every motion into separate actions. + fn harness_with_step(app: DesignApp, step_dt: f32) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(step_dt).build_ui_state( |ui, app: &mut DesignApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -3937,12 +4398,19 @@ mod tests { harness(app) } + /// [`new_document`] at 60 frames a second, the cadence a wheel motion really arrives at. + fn new_document_60fps() -> Harness<'static, DesignApp> { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + harness_with_step(app, 1.0 / 60.0) + } + fn held(modifiers: egui::Modifiers) -> egui::Event { egui::Event::ModifiersChanged(modifiers) } fn wheel(phase: egui::TouchPhase, up: f32, modifiers: egui::Modifiers) -> egui::Event { - egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, up), phase, modifiers } + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, up), phase, modifiers, source: egui::MouseWheelSource::Unknown } } // Plays `events` with the pointer at `at`, a frame each, and lets the motion settle. @@ -4000,7 +4468,7 @@ mod tests { |key| vec![vec![held(key | Modifiers::SHIFT), wheel(Move, 100.0, key | Modifiers::SHIFT)]], ]; for (i, motion) in motions.iter().enumerate() { - let (mut with_alt, mut with_command) = (new_document(), new_document()); + let (mut with_alt, mut with_command) = (new_document_60fps(), new_document_60fps()); let at = with_alt.state().canvas_rect.unwrap().center() + vec2(150.0, 90.0); let start = *with_alt.state().view().unwrap(); for (alt, command) in motion(Modifiers::ALT).iter().zip(motion(Modifiers::COMMAND)) { @@ -4030,8 +4498,8 @@ mod tests { egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }, |ui| { app.logic(ui.ctx()); - // Fonts installed by logic become available at the start of the next frame, - // just as in the full application UI. + // Fonts are installed by the first real UI pass, just as in the full application. + app.ui_setup(ui.ctx()); if app.fonts_ready { show(app, ui); } @@ -4829,7 +5297,7 @@ mod tests { primary(&mut h, ruler, true); h.event(egui::Event::PointerMoved(dest)); h.run_steps(2); - let g = h.ctx.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))).expect("dragging"); + let g = h.ctx.data(|d| d.get_temp::(egui::Id::unique(GUIDE_DRAG))).expect("dragging"); assert_eq!(g.orientation, designcraft_doc::Orientation::Horizontal); assert!(guide_readout(h.state(), &xf, &g).is_some_and(|t| t.starts_with("Y: "))); primary(&mut h, dest, false); @@ -5500,6 +5968,428 @@ mod tests { egui::Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(c..c) }) } + fn ime_payload(ime: &egui::output::IMEOutput) -> Option<(&str, usize, usize)> { + ime.surrounding_text.available().map(|p| (p.text.as_str(), p.cursor, p.anchor)) + } + + fn text_selection(app: &mut DesignApp, sid: &Value, anchor: usize, focus: usize) { + app.session.execute("text.select", &json!({"story": sid, "anchor": anchor, "focus": focus})).unwrap(); + } + + struct NativeClickFixture { + app: DesignApp, + ctx: egui::Context, + target: Value, + old: Value, + at: Pos2, + focus: usize, + } + + fn native_gui_click_fixture() -> NativeClickFixture { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let old = typing_app(&mut app, &ctx, "Old story"); + let frame = app.run("frame.create", json!({"rect": [36, 280, 300, 400], "content": "text", "text": "Target story"})).unwrap(); + let target = frame["story"].clone(); + // Measure an interior first-word caret through the engine, rather than guessing a + // font-dependent screen pixel or clicking the frame's resize border. + let (point, focus) = (50..180) + .find_map(|x| { + app.run("text.placeCaret", json!({"frame": frame["id"], "point": [x, 290]})).unwrap(); + let sel = app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!(sel.story.0, target.as_u64().unwrap()); + (1..5).contains(&sel.focus).then_some((Point::new(f64::from(x), 290.0), sel.focus)) + }) + .expect("an interior point in the target's first word"); + text_selection(&mut app, &old, "Old story".len(), "Old story".len()); + for _ in 0..4 { + ime_frame(&mut app, &ctx, vec![]); + } + let at = on_screen(&app, point); + assert!(app.canvas_rect.unwrap().contains(at)); + ime_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + assert_eq!(app.session.doc().unwrap().selection.text.unwrap().story.0, old.as_u64().unwrap()); + NativeClickFixture { app, ctx, target, old, at, focus } + } + + fn native_gui_button(pos: Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } + } + + /// The 37-byte Devanagari line the native run had in the previous story. + const NATIVE_GUI_OLD_DEVANAGARI: &str = "नमस्ते दुनिया"; + + /// The click fixture with the non-empty selection in the previous story that the native + /// Windows run had (37-byte Devanagari, 0..37). A non-empty selection shows the contextual + /// task bar; keep it present but clear of the target click. + fn native_gui_selected_old_click_fixture() -> NativeClickFixture { + let mut f = native_gui_click_fixture(); + text_selection(&mut f.app, &f.old, 0, "Old story".len()); + f.app.run("text.insert", json!({"text": NATIVE_GUI_OLD_DEVANAGARI})).unwrap(); + text_selection(&mut f.app, &f.old, 0, NATIVE_GUI_OLD_DEVANAGARI.len()); + for _ in 0..4 { + ime_frame(&mut f.app, &f.ctx, vec![]); + } + let sel = f.app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!((sel.story.0, sel.anchor, sel.focus), (f.old.as_u64().unwrap(), 0, NATIVE_GUI_OLD_DEVANAGARI.len())); + assert_eq!(NATIVE_GUI_OLD_DEVANAGARI.len(), 37, "the native fixture's Devanagari line was 37 UTF-8 bytes"); + assert!(!sel.is_caret(), "the previous story's selection is non-empty"); + assert!(!context_menu_open(&f.ctx), "no context menu owns this frame's keys"); + assert!(f.app.ui.dialog.is_none() && f.app.ui.palette.is_none(), "no modal owns the keyboard"); + assert!(!f.ctx.text_edit_focused(), "no text field owns the keyboard"); + assert_eq!(f.app.pane, f.app.focus_pane, "the canvas pane takes the keyboard"); + let task_bar = egui::Id::unique(("task_bar", f.ctx.viewport_id(), f.app.pane)); + let bar = f.ctx.memory(|m| m.area_rect(task_bar)).expect("the old selection shows the floating task bar"); + assert!(!bar.contains(f.at), "the floating task bar does not cover the target click at {bar:?}"); + f + } + + fn native_gui_input(f: &mut NativeClickFixture, events: Vec, discard: bool) -> Vec<(bool, bool, bool)> { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() }; + let mut edges = Vec::new(); + let mut out = f.ctx.run_ui(raw, |ui| { + edges.push(ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin().is_some()))); + f.app.logic(ui.ctx()); + f.app.ui(ui); + if discard && edges.len() == 1 { + ui.ctx().request_discard("native same-batch click exactly-once regression"); + } + }); + if discard { + assert_eq!(out.platform_output.num_completed_passes, 2, "the requested discarded pass actually ran"); + } + out.textures_delta.clear(); + edges + } + + fn native_gui_released(f: &NativeClickFixture) { + assert!(!f.ctx.input(|i| i.pointer.primary_down()), "the native primary button is up"); + let down = f.ctx.data(|d| d.get_temp::(egui::Id::unique(("canvas_pointer_down", f.app.pane)))).unwrap_or(false); + assert!(!down, "the canvas retained a released pointer"); + assert!(!f.app.session.tool_busy(), "a released click must not leave a tool gesture"); + } + + fn native_gui_second_click_target(f: &mut NativeClickFixture) -> (Value, Pos2, usize) { + let frame = f.app.run("frame.create", json!({"rect": [330, 280, 575, 400], "content": "text", "text": "Second story"})).unwrap(); + let story = frame["story"].clone(); + let (point, focus) = (345..465) + .find_map(|x| { + f.app.run("text.placeCaret", json!({"frame": frame["id"], "point": [x, 290]})).unwrap(); + let sel = f.app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!(sel.story.0, story.as_u64().unwrap()); + (1..5).contains(&sel.focus).then_some((Point::new(f64::from(x), 290.0), sel.focus)) + }) + .expect("an independently measured interior caret in the second story"); + text_selection(&mut f.app, &f.old, "Old story".len(), "Old story".len()); + for _ in 0..4 { + ime_frame(&mut f.app, &f.ctx, vec![]); + } + let at = on_screen(&f.app, point); + assert!(f.app.canvas_rect.unwrap().contains(f.at)); + assert!(f.app.canvas_rect.unwrap().contains(at)); + let max_click_dist = f.ctx.options(|o| o.input_options.max_click_dist); + assert!((at - f.at).length() > max_click_dist, "the two target presses must not form a double click"); + (story, at, focus) + } + + #[test] + fn native_gui_two_completed_clicks_use_last_primary_press_once() { + for reverse in [false, true] { + for discard in [false, true] { + let mut f = native_gui_click_fixture(); + let (second, second_at, second_focus) = native_gui_second_click_target(&mut f); + let (first_at, last_at, final_story, final_focus) = + if reverse { (second_at, f.at, f.target.clone(), f.focus) } else { (f.at, second_at, second.clone(), second_focus) }; + let initial = f.app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!((initial.story.0, initial.anchor, initial.focus), (f.old.as_u64().unwrap(), "Old story".len(), "Old story".len())); + let undo = f.app.session.doc().unwrap().history.undo.len(); + let journal = f.app.session.journal.len(); + let edges = native_gui_input( + &mut f, + vec![ + egui::Event::PointerMoved(first_at), + native_gui_button(first_at, true), + native_gui_button(first_at, false), + egui::Event::PointerMoved(last_at), + native_gui_button(last_at, true), + native_gui_button(last_at, false), + ], + discard, + ); + assert_eq!(edges[0], (true, true, false), "both completed clicks leave no held origin"); + assert_eq!(edges.iter().filter(|e| e.0).count(), 1, "discard must not replay either press"); + let sel = f.app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!( + (sel.story.0, sel.anchor, sel.focus), + (final_story.as_u64().unwrap(), final_focus, final_focus), + "reverse={reverse}, discard={discard}" + ); + let commands = &f.app.session.journal[journal..]; + assert_eq!( + commands.iter().filter(|(id, _)| id == "text.placeCaret").count(), + 1, + "one aggregate canvas selection, reverse={reverse}, discard={discard}" + ); + assert_eq!( + commands.iter().filter(|(id, _)| id == "text.release").count(), + 1, + "one corresponding release, reverse={reverse}, discard={discard}" + ); + let clicks = + f.ctx.data(|d| d.get_temp::(egui::Id::unique(("canvas_clicks", f.app.pane)))).expect("one recovered canvas press"); + assert_eq!(clicks.n, 1, "separated clicks and discarded passes must not select a word"); + assert_eq!(f.app.session.doc().unwrap().history.undo.len(), undo, "pointer selection adds no document history"); + native_gui_released(&f); + native_gui_input(&mut f, vec![egui::Event::Text("!".into())], false); + let mut target_text = "Target story".to_owned(); + let mut second_text = "Second story".to_owned(); + if reverse { + target_text.insert(final_focus, '!'); + } else { + second_text.insert(final_focus, '!'); + } + assert_eq!(story_text(&mut f.app, &f.target), target_text); + assert_eq!(story_text(&mut f.app, &second), second_text); + assert_eq!(story_text(&mut f.app, &f.old), "Old story"); + assert_eq!(f.app.session.doc().unwrap().history.undo.len(), undo + 1, "typing at the final caret is one edit"); + native_gui_released(&f); + } + } + } + + #[test] + fn native_gui_nonfinite_final_press_cannot_reuse_an_earlier_click() { + for discard in [false, true] { + let mut f = native_gui_click_fixture(); + let at = f.at; + let initial = f.app.session.doc().unwrap().selection.text; + let undo = f.app.session.doc().unwrap().history.undo.len(); + let journal = f.app.session.journal.len(); + let edges = native_gui_input( + &mut f, + vec![ + egui::Event::PointerMoved(at), + native_gui_button(at, true), + native_gui_button(at, false), + native_gui_button(pos2(f32::NAN, at.y), true), + egui::Event::PointerMoved(at), + native_gui_button(at, false), + ], + discard, + ); + assert_eq!(edges[0], (true, true, false)); + assert_eq!(edges.iter().filter(|e| e.0).count(), 1, "discard must not replay malformed input"); + assert_eq!( + f.app.session.doc().unwrap().selection.text, + initial, + "discard={discard}: reject the last press instead of reviving an earlier valid one" + ); + assert!(f.app.session.journal[journal..].iter().all(|(id, _)| id != "text.placeCaret" && id != "text.release")); + assert_eq!(f.app.session.doc().unwrap().history.undo.len(), undo); + assert_eq!(story_text(&mut f.app, &f.target), "Target story"); + assert_eq!(story_text(&mut f.app, &f.old), "Old story"); + native_gui_released(&f); + native_gui_input(&mut f, vec![egui::Event::PointerMoved(at + vec2(90.0, 0.0))], false); + assert_eq!(f.app.session.doc().unwrap().selection.text, initial, "motion after the invalid released press cannot drag"); + native_gui_released(&f); + } + } + + #[test] + fn native_gui_same_batch_click_switches_story_once_before_typing() { + for discard in [false, true] { + let mut f = native_gui_click_fixture(); + let undo = f.app.session.doc().unwrap().history.undo.len(); + let at = f.at; + let edges = + native_gui_input(&mut f, vec![egui::Event::PointerMoved(at), native_gui_button(at, true), native_gui_button(at, false)], discard); + assert_eq!(edges[0], (true, true, false), "the real input consumed both edges and cleared the origin"); + assert_eq!(edges.iter().filter(|e| e.0).count(), 1, "a discarded pass must not replay the press"); + let selection = f.app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!((selection.story.0, selection.anchor, selection.focus), (f.target.as_u64().unwrap(), f.focus, f.focus), "discard={discard}"); + let clicks = f.ctx.data(|d| d.get_temp::(egui::Id::unique(("canvas_clicks", f.app.pane)))).expect("one actual canvas click"); + assert_eq!(clicks.n, 1, "discard={discard}: replay must not turn a caret into word selection"); + native_gui_released(&f); + assert_eq!(f.app.session.doc().unwrap().history.undo.len(), undo, "a click is not a document edit"); + let end = |pressed| egui::Event::Key { key: egui::Key::End, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + native_gui_input(&mut f, vec![end(true), end(false)], false); + let caret = f.app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!((caret.story.0, caret.focus), (f.target.as_u64().unwrap(), "Target story".len())); + native_gui_input(&mut f, vec![egui::Event::Text("!".into())], false); + assert_eq!(story_text(&mut f.app, &f.target), "Target story!"); + assert_eq!(story_text(&mut f.app, &f.old), "Old story"); + assert_eq!(f.app.session.doc().unwrap().history.undo.len(), undo + 1, "one typed edit, including discarded-pass control"); + f.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(story_text(&mut f.app, &f.target), "Target story"); + assert_eq!(story_text(&mut f.app, &f.old), "Old story"); + f.app.run("edit.redo", json!({})).unwrap(); + assert_eq!(story_text(&mut f.app, &f.target), "Target story!"); + assert_eq!(story_text(&mut f.app, &f.old), "Old story"); + native_gui_released(&f); + } + } + + /// The native Windows failure: with a non-empty selection in another story, a click that + /// switches stories followed by End in the same job left the new story's caret at the click + /// point. End in a later job worked. Drive the actual app UI and actual pointer/key events in + /// one batch, in split batches, and with End while the press is still held; discard a pass to + /// cover replay. The End press must reach the type tool once and win over the click, wherever + /// the pointer release falls, and typing must go there once. + #[test] + fn native_gui_click_then_end_in_one_batch_keeps_the_target_caret_at_the_story_end() { + let names = ["same batch, click events first", "click batch then End batch", "End while the press is held"]; + for (layout, name) in names.into_iter().enumerate() { + for discard in [false, true] { + let mut f = native_gui_selected_old_click_fixture(); + let (at, target, old) = (f.at, f.target.clone(), f.old.clone()); + let undo = f.app.session.doc().unwrap().history.undo.len(); + let journal = f.app.session.journal.len(); + let moved = || egui::Event::PointerMoved(at); + let down = || native_gui_button(at, true); + let up = || native_gui_button(at, false); + let end = + |pressed| egui::Event::Key { key: egui::Key::End, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let batches: Vec> = match layout { + 0 => vec![vec![moved(), down(), up(), end(true), end(false)]], + 1 => vec![vec![moved(), down(), up()], vec![end(true), end(false)]], + _ => vec![vec![moved(), down()], vec![end(true)], vec![up(), end(false)]], + }; + for events in batches { + native_gui_input(&mut f, events, discard); + } + let selection = f.app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!( + (selection.story.0, selection.anchor, selection.focus), + (target.as_u64().unwrap(), "Target story".len(), "Target story".len()), + "{name}, discard={discard}: End must win after the click, wherever the release falls" + ); + let commands = &f.app.session.journal[journal..]; + assert_eq!( + commands.iter().filter(|(id, _)| id == "text.move").count(), + 1, + "{name}, discard={discard}: the End press reached the type tool once" + ); + let clicks = f.ctx.data(|d| d.get_temp::(egui::Id::unique(("canvas_clicks", f.app.pane)))).expect("one actual canvas click"); + assert_eq!(clicks.n, 1, "{name}, discard={discard}: a discarded pass must not replay the press as a word selection"); + native_gui_released(&f); + native_gui_input(&mut f, vec![egui::Event::Text("!".into())], discard); + assert_eq!(story_text(&mut f.app, &target), "Target story!", "{name}, discard={discard}: typing lands at the End caret once"); + assert_eq!(story_text(&mut f.app, &old), NATIVE_GUI_OLD_DEVANAGARI); + assert_eq!( + f.app.session.journal[journal..].iter().filter(|(id, _)| id == "text.insert").count(), + 1, + "{name}, discard={discard}: the text event is not replayed" + ); + assert_eq!(f.app.session.doc().unwrap().history.undo.len(), undo + 1, "{name}, discard={discard}: one edit"); + } + } + } + + #[test] + fn native_gui_split_click_and_drag_release_keep_pointer_ownership() { + for drag in [false, true] { + let mut f = native_gui_click_fixture(); + let at = f.at; + native_gui_input(&mut f, vec![native_gui_button(at, true)], false); + let sel = f.app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!((sel.story.0, sel.focus), (f.target.as_u64().unwrap(), f.focus)); + assert!(f.ctx.input(|i| i.pointer.primary_down())); + assert!(f.ctx.data(|d| d.get_temp::(egui::Id::unique(("canvas_pointer_down", f.app.pane)))).unwrap()); + if drag { + let moved = at + vec2(60.0, 0.0); + native_gui_input(&mut f, vec![egui::Event::PointerMoved(moved)], false); + let sel = f.app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!(sel.story.0, f.target.as_u64().unwrap()); + assert!(!sel.is_caret(), "a real held drag selects text"); + } + let release = if drag { pos2(-20.0, at.y) } else { at }; + native_gui_input(&mut f, vec![egui::Event::PointerMoved(release), native_gui_button(release, false)], false); + native_gui_released(&f); + let selection = f.app.session.doc().unwrap().selection.text; + native_gui_input(&mut f, vec![egui::Event::PointerMoved(at + vec2(90.0, 0.0))], false); + assert_eq!(f.app.session.doc().unwrap().selection.text, selection, "motion after release cannot continue the old drag"); + assert_eq!(story_text(&mut f.app, &f.target), "Target story"); + assert_eq!(story_text(&mut f.app, &f.old), "Old story"); + } + } + + #[test] + fn native_gui_outside_press_and_open_menu_do_not_click_through() { + for menu in [false, true] { + let mut f = native_gui_click_fixture(); + let at = f.at; + let selection = f.app.session.doc().unwrap().selection.text; + if menu { + let blank = f.app.canvas_rect.unwrap().max - vec2(30.0, 30.0); + for pressed in [true, false] { + let event = + egui::Event::PointerButton { pos: blank, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + native_gui_input(&mut f, vec![egui::Event::PointerMoved(blank), event], false); + } + assert!(context_menu_open(&f.ctx), "the actual secondary click opened the canvas menu"); + assert_eq!(f.app.session.doc().unwrap().selection.text, selection); + native_gui_input(&mut f, vec![egui::Event::PointerMoved(at), native_gui_button(at, true), native_gui_button(at, false)], false); + } else { + let outside = pos2(-20.0, -20.0); + assert!(!f.app.canvas_rect.unwrap().contains(outside)); + native_gui_input( + &mut f, + vec![ + egui::Event::PointerMoved(outside), + native_gui_button(outside, true), + egui::Event::PointerMoved(at), + native_gui_button(at, false), + ], + false, + ); + } + assert_eq!(f.app.session.doc().unwrap().selection.text, selection, "menu={menu}: an unowned press cannot move the old caret"); + assert_eq!(story_text(&mut f.app, &f.target), "Target story"); + assert_eq!(story_text(&mut f.app, &f.old), "Old story"); + native_gui_released(&f); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn native_gui_font_inventory_keeps_exact_regular_and_missing_style_diagnostics() { + if designcraft_fonts::CRAFT_FONTS.is_empty() { + assert!(std::env::var_os("CRAFT_FONTS_REQUIRED").is_none(), "required canonical fonts were not embedded"); + return; // The supported optional-input build has no central document fonts. + } + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for (family, text) in [("Noto Sans Hebrew", "שלום"), ("Noto Sans Devanagari", "नमस्ते")] { + assert!(designcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == family && f.style == "Regular"), "canonical {family} Regular absent"); + for style in ["Regular", "Unavailable Native GUI Style"] { + let frame = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap(); + app.run("text.select", json!({"story": frame["story"], "anchor": 0, "focus": text.len()})).unwrap(); + app.run("type.char", json!({"fontFamily": family, "fontStyle": style})).unwrap(); + } + } + let frame = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": "Missing"})).unwrap(); + app.run("text.select", json!({"story": frame["story"], "anchor": 0, "focus": 7})).unwrap(); + app.run("type.char", json!({"fontFamily": "Unavailable Native GUI Family", "fontStyle": "Regular"})).unwrap(); + let inventory = app.run("font.list", json!({})).unwrap(); + let rows = inventory.as_array().unwrap(); + assert_eq!(rows.len(), 5, "{inventory}"); + for family in ["Noto Sans Hebrew", "Noto Sans Devanagari"] { + for style in ["Regular", "Unavailable Native GUI Style"] { + let row = rows.iter().find(|r| r["family"] == family && r["style"] == style).unwrap(); + assert_eq!(row["missing"], false, "{row}"); + assert_eq!(row["styleMissing"], style != "Regular", "{row}"); + assert_eq!(row["matchStatus"], if style == "Regular" { "exact" } else { "styleSubstitute" }, "{row}"); + assert_eq!(row["resolvedFamily"], family, "{row}"); + assert_eq!(row["resolvedStyle"], "Regular", "{row}"); + assert_eq!(row["source"], "bundled", "an installed or injected face cannot satisfy this production contract: {row}"); + } + } + let missing = rows.iter().find(|r| r["family"] == "Unavailable Native GUI Family").unwrap(); + assert_eq!(missing["missing"], true, "{missing}"); + assert_eq!(missing["matchStatus"], "missing", "{missing}"); + assert!(missing["source"].is_null(), "{missing}"); + } + #[test] #[allow(deprecated)] // `ImeEvent::Enabled` / `Disabled`: egui no longer sends them, other integrations may. fn ime_events_compose_in_the_story_with_the_candidate_window_at_the_caret() { @@ -5582,6 +6472,312 @@ mod tests { assert_eq!(story_text(&mut app, &sid), "abde日"); } + #[test] + fn ime_reports_the_committed_story_and_excludes_the_pre_edit() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let sid = typing_app(&mut app, &ctx, "a😀b"); + let len = "a😀b".len(); + text_selection(&mut app, &sid, 0, len); + let ime = ime_frame(&mut app, &ctx, vec![]).expect("a text caret"); + assert_eq!(ime_payload(&ime), Some(("a😀b", len, 0)), "reversed selection keeps its direction"); + let owner = (ime.owner, ime.context_generation); + // Marked text previews in the document but is not committed surrounding text. + let ime = ime_frame(&mut app, &ctx, vec![preedit("に", 1)]).expect("a text caret"); + assert_eq!(ime_payload(&ime), Some(("a😀b", len, 0))); + assert_eq!(ime.owner, owner.0); + let ime = ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into()))]).expect("a text caret"); + assert_eq!(ime_payload(&ime), Some(("日", 3, 3))); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + assert_eq!(story_text(&mut app, &sid), "日"); + assert_eq!(ime.owner, owner.0); + assert_eq!(ime.context_generation, owner.1, "a commit is not a new context"); + let ime = ime_frame(&mut app, &ctx, vec![]).expect("a text caret"); + assert_eq!(ime_payload(&ime), Some(("日", 3, 3)), "a later pass reports the same committed text"); + assert_eq!((ime.owner, ime.context_generation), owner, "an unchanged pass keeps the context"); + } + + #[test] + fn ime_empty_story_is_available_and_wide_text_is_bounded() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + typing_app(&mut app, &ctx, ""); + let ime = ime_frame(&mut app, &ctx, vec![]).expect("a text caret"); + assert_eq!(ime_payload(&ime), Some(("", 0, 0)), "an empty document is available, not unavailable"); + assert!(!app.session.tool_composing()); + + let long = "a".repeat(5000); + let sid = typing_app(&mut app, &ctx, &long); + let ime = ime_frame(&mut app, &ctx, vec![]).expect("a text caret"); + let (text, _, _) = ime_payload(&ime).expect("a long document still has surrounding text"); + assert!(text.len() <= egui::output::MAX_IME_SURROUNDING_TEXT_BYTES, "{}", text.len()); + text_selection(&mut app, &sid, 0, 5000); + let ime = ime_frame(&mut app, &ctx, vec![]).expect("a text caret"); + assert!(ime.surrounding_text.available().is_none(), "a selection beyond the bound is explicitly unavailable"); + } + + #[test] + fn ime_owner_follows_the_story_and_the_document() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + typing_app(&mut app, &ctx, "ab"); + let first = ime_frame(&mut app, &ctx, vec![]).expect("a text caret"); + app.session.execute("text.move", &json!({"dir": "left"})).unwrap(); + let moved = ime_frame(&mut app, &ctx, vec![]).expect("a text caret"); + assert_eq!((moved.owner, moved.context_generation), (first.owner, first.context_generation), "a caret move keeps the context"); + + let other = app.run("frame.create", json!({"rect": [400, 36, 200, 100], "content": "text", "text": "xy"})).unwrap(); + let other_sid = other["story"].clone(); + text_selection(&mut app, &other_sid, 2, 2); + let second = ime_frame(&mut app, &ctx, vec![]).expect("a text caret"); + assert_ne!(second.owner, first.owner, "another story is another owner"); + assert_ne!(second.context_generation, first.context_generation); + + app.run("file.new", json!({})).unwrap(); + let doc = app.run("frame.create", json!({"rect": [36, 36, 200, 100], "content": "text", "text": "z"})).unwrap(); + text_selection(&mut app, &doc["story"], 1, 1); + let replaced = ime_frame(&mut app, &ctx, vec![]).expect("a text caret"); + assert_ne!(replaced.owner, second.owner, "a new document is another owner"); + assert_eq!(ime_payload(&replaced), Some(("z", 1, 1))); + } + + #[test] + fn ime_cause_distinguishes_commits_from_application_edits() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + typing_app(&mut app, &ctx, "a"); + let committed = ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into()))]).expect("a text caret"); + assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod); + let typed = ime_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]).expect("a text caret"); + assert_eq!(typed.change_cause, egui::output::ImeChangeCause::Other, "ordinary typing is an application edit"); + assert_eq!(ime_payload(&typed), Some(("a日!", 5, 5))); + + // An application edit before the commit in one pass breaks the receipt chain. + let mut before = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + typing_app(&mut before, &ctx, "a"); + let mixed = ime_frame(&mut before, &ctx, vec![egui::Event::Text("x".into()), egui::Event::Ime(egui::ImeEvent::Commit("日".into()))]) + .expect("a text caret"); + assert_eq!(mixed.change_cause, egui::output::ImeChangeCause::Other); + assert_eq!(ime_payload(&mixed), Some(("ax日", 5, 5))); + + // An application edit after the commit in one pass also dominates. + let mut after = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + typing_app(&mut after, &ctx, "a"); + let mixed = ime_frame(&mut after, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into())), egui::Event::Text("!".into())]) + .expect("a text caret"); + assert_eq!(mixed.change_cause, egui::output::ImeChangeCause::Other); + assert_eq!(ime_payload(&mixed), Some(("a日!", 5, 5))); + } + + #[test] + fn ime_cause_and_context_survive_a_discarded_pass() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + typing_app(&mut app, &ctx, "a"); + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), + events: vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into()))], + ..Default::default() + }; + let mut passes = 0; + let mut out = ctx.run_ui(raw, |ui| { + passes += 1; + if passes == 1 { + ui.ctx().request_discard("IME multipass regression"); + } + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(out.platform_output.num_completed_passes, 2, "the frame really was multipass"); + let ime = out.platform_output.ime.expect("a text caret"); + assert_eq!(ime_payload(&ime), Some(("a日", 4, 4)), "the second pass rebuilds the current text"); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod, "the discarded pass keeps the accepted commit's cause"); + } + + #[test] + fn ime_autocorrect_is_not_an_input_method_cause() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.session.prefs.autocorrect = true; + let sid = typing_app(&mut app, &ctx, "teh"); + let ime = ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit(" ".into()))]).expect("a text caret"); + assert_eq!(story_text(&mut app, &sid), "the "); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::Other); + } + + #[test] + fn ime_delete_surrounding_excludes_the_selection_and_rounds_bytes_inward() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let sid = typing_app(&mut app, &ctx, "aé😀b"); + // Select é, which keeps its bytes and direction; 'a' before and 😀 after go. + text_selection(&mut app, &sid, 1, 3); + ime_frame(&mut app, &ctx, vec![]); + let steps = app.session.doc().unwrap().history.undo.len(); + let ime = ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 2, after_bytes: 4 })]) + .expect("a text caret"); + assert_eq!(story_text(&mut app, &sid), "éb"); + let sel = app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!((sel.anchor, sel.focus), (0, 2), "the selected text stays selected and directed"); + assert_eq!(ime_payload(&ime), Some(("éb", 2, 0))); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1, "one deletion is one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(story_text(&mut app, &sid), "aé😀b"); + ime_frame(&mut app, &ctx, vec![]); + // The same deletion with a reversed selection keeps anchor > focus. + text_selection(&mut app, &sid, 3, 1); + ime_frame(&mut app, &ctx, vec![]); + let ime = ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 2, after_bytes: 4 })]) + .expect("a text caret"); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + let sel = app.session.doc().unwrap().selection.text.unwrap(); + assert_eq!(story_text(&mut app, &sid), "éb"); + assert_eq!((sel.anchor, sel.focus), (2, 0), "reversed selection stays reversed"); + + // One byte of a two-byte character leaves the whole character. + let partial = typing_app(&mut app, &ctx, "aé"); + text_selection(&mut app, &partial, 1, 1); + ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 0, after_bytes: 1 })]); + assert_eq!(story_text(&mut app, &partial), "aé"); + // A preceding count that lands inside the four-byte emoji leaves it whole too. + let partial = typing_app(&mut app, &ctx, "a😀"); + ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 2, after_bytes: 0 })]); + assert_eq!(story_text(&mut app, &partial), "a😀"); + } + + #[test] + fn ime_delete_surrounding_has_no_sixty_four_character_cap() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let sid = typing_app(&mut app, &ctx, &"x".repeat(100)); + ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 200, after_chars: 0 })]); + assert_eq!(story_text(&mut app, &sid), ""); + } + + #[test] + fn ime_delete_and_commit_are_one_undo_step() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let sid = typing_app(&mut app, &ctx, "ab"); + text_selection(&mut app, &sid, 1, 2); + ime_frame(&mut app, &ctx, vec![]); + let steps = app.session.doc().unwrap().history.undo.len(); + let ime = ime_frame( + &mut app, + &ctx, + vec![ + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 0 }), + egui::Event::Ime(egui::ImeEvent::Commit("X".into())), + ], + ) + .expect("a text caret"); + assert_eq!(story_text(&mut app, &sid), "X"); + assert_eq!(ime_payload(&ime), Some(("X", 1, 1))); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1, "the IME batch is one undo step"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(story_text(&mut app, &sid), "ab"); + } + + /// The input method's Commit for a composition a click ended earlier in the same event batch + /// must not type the marked text again at the clicked caret. + #[test] + fn a_commit_after_a_click_in_the_same_batch_is_not_typed_twice() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let sid = typing_app(&mut app, &ctx, "ab"); + ime_frame(&mut app, &ctx, vec![preedit("い", 1)]); + assert_eq!(story_text(&mut app, &sid), "abい"); + let p = on_screen(&app, Point::new(150.0, 120.0)); + let press = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + ime_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p), press(true), egui::Event::Ime(egui::ImeEvent::Commit("い".into()))]) + .expect("still at a text caret"); + let typed = story_text(&mut app, &sid); + assert_eq!(typed.matches('い').count(), 1, "the click typed the marked text once: {typed:?}"); + assert!(typed.starts_with("ab"), "{typed:?}"); + assert!(!app.session.tool_composing()); + assert!(app.take_ime_discard(), "the system IME is told to drop its copy"); + ime_frame(&mut app, &ctx, vec![press(false)]); + } + + /// A delete-surrounding and its commit from the same ended composition must not edit the + /// clicked caret's text either. + #[test] + fn a_delete_and_commit_after_a_click_in_the_same_batch_are_dropped() { + for delete in [ + egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 0 }, + egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }, + ] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let sid = typing_app(&mut app, &ctx, "ab"); + ime_frame(&mut app, &ctx, vec![preedit("い", 1)]); + let p = on_screen(&app, Point::new(150.0, 120.0)); + let press = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + ime_frame( + &mut app, + &ctx, + vec![egui::Event::PointerMoved(p), press(true), egui::Event::Ime(delete), egui::Event::Ime(egui::ImeEvent::Commit("い".into()))], + ) + .expect("still at a text caret"); + let typed = story_text(&mut app, &sid); + assert_eq!(typed.matches('い').count(), 1, "the late commit is dropped: {typed:?}"); + assert!(typed.contains("ab"), "the click's text was not deleted: {typed:?}"); + assert!(app.take_ime_discard()); + ime_frame(&mut app, &ctx, vec![press(false)]); + } + } + + /// A byte-count delete that reaches past the bounded IME window is still exactly the requested + /// input method mutation: the engine reports the ranges from the whole story. + #[test] + fn a_delete_beyond_the_surrounding_window_is_still_an_input_method_cause() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let sid = typing_app(&mut app, &ctx, &"x".repeat(5000)); + let ime = ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 4500, after_bytes: 0 })]) + .expect("a text caret"); + assert_eq!(story_text(&mut app, &sid).len(), 500); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + assert_eq!(ime_payload(&ime).map(|(text, cursor, anchor)| (text.len(), cursor, anchor)), Some((500, 500, 500))); + } + + /// The pass's IME observation comes after the later UI sections: a click into the Story + /// Editor's field takes the keyboard from the canvas in the same pass, so the composition + /// ends there and the canvas reports no IME. + #[test] + fn a_later_field_taking_the_keyboard_ends_the_composition_in_that_pass() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "ab"})).unwrap(); + let sid = f["story"].clone(); + app.select_tool("type"); + app.run("text.select", json!({"story": sid, "anchor": 2, "focus": 2})).unwrap(); + app.run("app.storyEditor", json!({"story": sid})).unwrap(); + let mut h = crate::test_window::open(app, egui::vec2(1440.0, 900.0)); + h.event(preedit("い", 1)); + h.run_steps(2); + assert!(h.state().app.session.tool_composing()); + let canvas_owner = h.output().platform_output.ime.as_ref().expect("the canvas owns the composition").owner; + let field = h.get_all_by_role(egui::accesskit::Role::MultilineTextInput).next().expect("the Story Editor's text field"); + field.click(); + h.run_steps(2); + assert!(!h.state().app.session.tool_composing(), "the composition ended where the field took the keyboard"); + assert!(h.state_mut().app.take_ime_discard()); + assert!(h.get_all_by_role(egui::accesskit::Role::MultilineTextInput).next().unwrap().is_focused(), "the field has the keyboard"); + let ime = h.output().platform_output.ime.as_ref().expect("the focused Story Editor publishes its IME context"); + assert_ne!(ime.owner, canvas_owner, "the field owns the IME, not the canvas"); + let typed = story_text(&mut h.state_mut().app, &sid); + assert!(typed.contains('い'), "the marked text stays as typed: {typed:?}"); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("X".into()))); + h.run_steps(2); + let edited = story_text(&mut h.state_mut().app, &sid); + assert_eq!(edited.matches('X').count(), 1, "the field's next commit is applied once: {edited:?}"); + assert_eq!(edited.matches('い').count(), 1, "the old canvas composition is not replayed: {edited:?}"); + } + #[test] fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -5781,7 +6977,7 @@ mod scrollbar_tests { let h = window(); let rect = h.state().app.canvas_rect.unwrap(); for vertical in [true, false] { - let r = h.ctx.read_response(egui::Id::new(("canvas_scrollbar", 0u8, vertical))).expect("scrollbar").rect; + let r = h.ctx.read_response(egui::Id::unique(("canvas_scrollbar", 0u8, vertical))).expect("scrollbar").rect; let (track, thumb, k) = bar(&h, vertical); assert_eq!(r, track); assert!(k > 0.0, "the zoomed-in view scrolls"); @@ -5833,7 +7029,7 @@ mod scrollbar_tests { let (_, v1, _) = bar(&h, true); let (_, h1, _) = bar(&h, false); assert!(v1.min.y > v0.min.y && h1.min.x > h0.min.x, "{v0:?} → {v1:?}, {h0:?} → {h1:?}"); - let r = h.ctx.read_response(egui::Id::new(("canvas_scrollbar", 0u8, true))).expect("scrollbar"); + let r = h.ctx.read_response(egui::Id::unique(("canvas_scrollbar", 0u8, true))).expect("scrollbar"); assert_eq!(r.rect, bar(&h, true).0); } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b0fed5799..34d6cfb5e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -7,7 +7,7 @@ use serde_json::json; use crate::panels; use crate::theme::{Tokens, semibold}; -use crate::widgets::{caption, measure, number}; +use crate::widgets::{NumField, caption, measure}; use crate::{DesignApp, icons}; /// Height of the application bar (InDesign 2026), then a 7 pt lower band and a 1 pt border. The @@ -31,7 +31,7 @@ const MAC_LIGHTS_LEFT: f32 = 10.0; pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_APP_BAR - MAC_LIGHT) / 2.0; fn search_id() -> egui::Id { - egui::Id::new("app_bar_search") + egui::Id::unique("app_bar_search") } /// Whether the application bar's search field had the keyboard last frame. Escape takes egui's @@ -94,7 +94,7 @@ fn search_field(app: &mut DesignApp, ui: &mut egui::Ui) { .text_color(t.text) .hint_text(hint) .desired_width(field.width()) - .vertical_align(egui::Align::Center) + .align(egui::Align2::LEFT_CENTER) // Return runs the highlighted match instead of leaving the field. .return_key(None), ) @@ -219,12 +219,12 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { egui::pos2(full.min.x - left as f32, full.min.y), egui::pos2(full.max.x - caption_w, full.min.y + bar + APP_BAR_BAND), ); - let response = ui.interact(drag, egui::Id::new("window_title_drag"), Sense::click_and_drag()); + let response = ui.interact(drag, egui::Id::unique("window_title_drag"), Sense::click_and_drag()); // Only from an empty part of the bar: the lower band, or the gap between the menus // and the tools (as they were last frame). A menu title pressed and dragged doesn't // move the window, as with native title bars. - let (gap0, gap1) = - ui.data(|d| (d.get_temp::(egui::Id::new("app_bar_menus_end")), d.get_temp::(egui::Id::new("app_bar_tools_start")))); + let (gap0, gap1) = ui + .data(|d| (d.get_temp::(egui::Id::unique("app_bar_menus_end")), d.get_temp::(egui::Id::unique("app_bar_tools_start")))); let empty = |p: egui::Pos2| p.y >= full.min.y + bar || matches!((gap0, gap1), (Some(a), Some(b)) if p.x >= a && p.x <= b); let from_empty = ui.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())).is_some_and(empty); if response.double_clicked() && from_empty { @@ -245,7 +245,7 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.scope_builder(egui::UiBuilder::new().max_rect(scrolled), |ui| { // The right-hand controls' width, measured last frame: in a window too narrow for them // and the menus they follow the menus, and the bar scrolls sideways. - let tools_w_id = ui.id().with("tools_width"); + let tools_w_id = ui.scope_id().with("tools_width"); let tools_w: f32 = ui.data(|d| d.get_temp(tools_w_id)).unwrap_or(0.0); crate::widgets::overflow_scrolling(ui); // (Drag-scrolling is touch-only by default, so a mouse drag on the bar moves the window.) @@ -264,7 +264,7 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { } menus_end = ui.min_rect().max.x; // Where the menus end on screen (moves as a narrow bar scrolls). - ui.data_mut(|d| d.insert_temp(egui::Id::new("app_bar_menus_end"), menus_end)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("app_bar_menus_end"), menus_end)); let size = vec2(tools_slot_width(ui.available_width(), tools_w), ui.available_height()); let tools = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(4.0); @@ -331,7 +331,7 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("help.discord", json!({})); } tools_start = ui.min_rect().min.x; - ui.data_mut(|d| d.insert_temp(egui::Id::new("app_bar_tools_start"), tools_start)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("app_bar_tools_start"), tools_start)); ui.min_rect().width() }); if (tools.inner - tools_w).abs() > 0.5 { @@ -373,8 +373,8 @@ fn caption_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { [("window.close", "Close"), ("window.maximize", if maximized { "Restore" } else { "Maximize" }), ("window.minimize", "Minimize")] { let (rect, _) = ui.allocate_exact_size(vec2(CAPTION_BUTTON_W, APP_BAR), Sense::hover()); - let response = ui.interact(rect, egui::Id::new(command), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + let response = ui.interact(rect, egui::Id::unique(command), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let hovered = response.hovered(); if hovered { ui.painter().rect_filled(rect, 0.0, if command == "window.close" { t.caption_close } else { t.hover }); @@ -430,7 +430,7 @@ pub(crate) fn resize_borders(app: &mut DesignApp, ui: &mut egui::Ui) { ("west", rect(r.left(), r.top() + corner, r.left() + edge, r.bottom() - corner), egui::CursorIcon::ResizeHorizontal), ("east", rect(r.right() - edge, r.top() + corner, r.right(), r.bottom() - corner), egui::CursorIcon::ResizeHorizontal), ] { - let response = ui.interact(region, egui::Id::new(("window_resize", name)), Sense::drag()).on_hover_cursor(cursor); + let response = ui.interact(region, egui::Id::unique(("window_resize", name)), Sense::drag()).on_hover_cursor(cursor); if response.drag_started_by(egui::PointerButton::Primary) { let _ = app.run("window.resize", json!({"edge": name})); app.flush_window_commands(ui.ctx()); @@ -477,6 +477,32 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// Page-size preset menu and the portrait / landscape toggles of the Control panel. +fn page_preset_and_orientation(app: &mut DesignApp, ui: &mut egui::Ui, lang: &str, size: (f64, f64), n: usize, anchor: u8) { + let landscape = size.0 > size.1; + ui.vertical(|ui| { + let cur = panels::properties::preset_name(size.0, size.1); + egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur))).width(130.0).show_ui( + ui, + |ui| { + for (name, w, h) in panels::properties::PAGE_PRESETS { + if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(lang, name))).clicked() { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor})); + } + } + }, + ); + ui.horizontal(|ui| { + for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] { + if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(lang, tip)).clicked() && landscape != want { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": size.1, "height": size.0, "anchor": anchor})); + } + } + }); + }); +} + /// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size /// preset, orientation, Liquid Page Rule and Show Parent Page Overlay, for /// the page the Page tool selected (else the page in view). W/H keep the reference point in place. @@ -518,28 +544,7 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { }); vsep(ui); // Page-size preset and orientation. - let landscape = pg.width > pg.height; - ui.vertical(|ui| { - let cur = panels::properties::preset_name(pg.width, pg.height); - egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, cur))).width(130.0).show_ui( - ui, - |ui| { - for (name, w, h) in panels::properties::PAGE_PRESETS { - if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(&lang, name))).clicked() { - let (w, h) = if landscape { (h, w) } else { (w, h) }; - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor})); - } - } - }, - ); - ui.horizontal(|ui| { - for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] { - if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(&lang, tip)).clicked() && landscape != want { - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": pg.height, "height": pg.width, "anchor": anchor})); - } - } - }); - }); + page_preset_and_orientation(app, ui, &lang, (pg.width, pg.height), n, anchor); vsep(ui); ui.vertical(|ui| { caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); @@ -564,20 +569,105 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The Control panel's font sizes (and leadings), as InDesign lists them. +const SIZES: &[f64] = &[6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 24.0, 30.0, 36.0, 48.0, 60.0, 72.0]; +/// The Control panel's tracking values (1/1000 em). +const TRACKING: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0]; + +/// Whether the text Control panel shows Paragraph Formatting Controls (¶) rather than Character +/// Formatting Controls (A). +const PARA_MODE: &str = "ctl_text_para_mode"; + +/// An icon and a field after it, as the Control panel lays its fields out. +pub(crate) fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Option { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), r, icon, t.icon); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); + let label = resp.id; + resp.on_hover_text(tip); + field.spinner().labelled_by(label).show(ui) +} + +/// A column of two rows (the Control panel's two-row groups). +fn rows(ui: &mut egui::Ui, mut row: impl FnMut(&mut egui::Ui, usize)) { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 3.0; + ui.horizontal(|ui| row(ui, 0)); + ui.horizontal(|ui| row(ui, 1)); + }); +} + +/// A small toggle button showing `text` (the case and position buttons). +fn text_toggle(ui: &mut egui::Ui, text: &str, strike: bool, on: bool, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 20.0), Sense::click()); + if on { + ui.painter().rect_filled(r, 2.0, t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + let col = if on { t.text_strong } else { t.icon }; + if text == "small-caps" { + crate::icons::paint(ui.painter(), r, "small-caps", col); + } else { + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(13.0), col); + } + if strike { + ui.painter().line_segment([r.center() + vec2(-6.0, 0.5), r.center() + vec2(6.0, 0.5)], Stroke::new(1.2, col)); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, tip)); + resp.on_hover_text(tip).clicked() +} + +/// The text Control panel, as InDesign's: A shows the Character Formatting Controls (font, size +/// and leading, the case and position buttons, kerning and tracking, the scales, baseline shift +/// and skew, the character style and language); ¶ the Paragraph Formatting Controls (alignment, +/// the indents, space before and after, the drop cap, the paragraph style, Hyphenate and Align to +/// Baseline Grid). fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let attrs = panels::text_attrs(app); let chars = attrs.as_ref().map(|a| a["chars"].clone()).unwrap_or_default(); let para = attrs.as_ref().map(|a| a["para"].clone()).unwrap_or_default(); + let mode_id = egui::Id::unique(PARA_MODE); + let para_mode = ui.data(|d| d.get_temp::(mode_id)).unwrap_or(false); ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new("A").font(semibold(15.0)).color(t.text_strong)); - }); - ui.horizontal(|ui| { - ui.label(egui::RichText::new("¶").font(semibold(15.0)).color(t.text_dim)); - }); + ui.spacing_mut().item_spacing.y = 3.0; + for (para, text, tip) in [(false, "A", "Character Formatting Controls"), (true, "¶", "Paragraph Formatting Controls")] { + let on = para_mode == para; + let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click()); + if on { + ui.painter().rect_filled(r, 2.0, t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, semibold(15.0), if on { t.text_strong } else { t.text_dim }); + let tip = tr(tip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, &tip)); + if resp.on_hover_text(&tip).clicked() { + ui.data_mut(|d| d.insert_temp(mode_id, para)); + } + } }); vsep(ui); + if para_mode { + control_paragraph(app, ui, &attrs, ¶, &tr); + } else { + control_character(app, ui, &attrs, &chars, ¶, &tr); + } +} + +fn control_character( + app: &mut DesignApp, + ui: &mut egui::Ui, + attrs: &Option, + chars: &serde_json::Value, + para: &serde_json::Value, + tr: &dyn Fn(&str) -> String, +) { ui.vertical(|ui| { let fam = chars["fontFamily"].as_str().unwrap_or("").to_string(); let sty = chars["fontStyle"].as_str().unwrap_or("").to_string(); @@ -585,84 +675,358 @@ fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) { panels::font_style_picker(app, ui, &fam, &sty, 170.0); }); vsep(ui); - egui::Grid::new("ctl_char").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| { - caption(ui, "𝐓"); - if let Some(v) = number(ui, "csize", chars["size"].as_f64(), " pt", 54.0, 2) { - let _ = app.run("type.char", json!({"attrs": {"size": v}})); + // Size and leading: a spinner on the left and a list of common values on the right; + // automatic leading in parentheses. + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field( + ui, + "font-size", + &tr("Font Size"), + NumField::number("csize", chars["size"].as_f64(), " pt", 2).width(84.0).presets(SIZES).range(0.1, 1296.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"size": v}})); + } + } else { + let auto = chars["leading"]["kind"].as_str() != Some("points"); + let lead = if auto { + chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)) + } else { + chars["leading"]["value"].as_f64() + }; + let lead = lead.map(|v| (v * 100.0).round() / 100.0); + let key = if auto { "cleadauto" } else { "clead" }; + if let Some(v) = icon_field( + ui, + "leading", + &tr("Leading"), + NumField::number(key, lead, " pt", 2).width(84.0).presets(SIZES).range(0.0, 5000.0).parens(auto), + ) { + let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); + } } - caption(ui, crate::i18n::tr(&app.ui.language, "VA")); - if let Some(v) = number(ui, "ctrack", chars["tracking"].as_f64(), "", 48.0, 0) { - let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); + }); + vsep(ui); + // All Caps, Superscript, Underline / Small Caps, Subscript, Strikethrough. + let caps = chars["capitalization"].as_str().unwrap_or("normal").to_string(); + let pos = chars["position"].as_str().unwrap_or("normal").to_string(); + let (ul, st) = (chars["underline"].as_bool().unwrap_or(false), chars["strikethrough"].as_bool().unwrap_or(false)); + let mut set = None; + rows(ui, |ui, row| { + if row == 0 { + if text_toggle(ui, "TT", false, caps == "allCaps", &tr("All Caps")) { + set = Some(json!({"capitalization": if caps == "allCaps" { "normal" } else { "allCaps" }})); + } + if text_toggle(ui, "T¹", false, pos == "superscript", &tr("Superscript")) { + set = Some(json!({"position": if pos == "superscript" { "normal" } else { "superscript" }})); + } + if text_toggle(ui, "T̲", false, ul, &tr("Underline")) { + set = Some(json!({"underline": !ul})); + } + } else { + if text_toggle(ui, "small-caps", false, caps == "smallCaps", &tr("Small Caps")) { + set = Some(json!({"capitalization": if caps == "smallCaps" { "normal" } else { "smallCaps" }})); + } + if text_toggle(ui, "T₁", false, pos == "subscript", &tr("Subscript")) { + set = Some(json!({"position": if pos == "subscript" { "normal" } else { "subscript" }})); + } + if text_toggle(ui, "T", true, st, &tr("Strikethrough")) { + set = Some(json!({"strikethrough": !st})); + } } - ui.end_row(); - caption(ui, "Ā"); - let lead = match chars["leading"]["kind"].as_str() { - Some("points") => chars["leading"]["value"].as_f64(), - _ => chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2)), - }; - if let Some(v) = number(ui, "clead", lead, " pt", 54.0, 2) { - let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); + }); + if let Some(a) = set { + let _ = app.run("type.char", json!({"attrs": a})); + } + vsep(ui); + rows(ui, |ui, row| { + if row == 0 { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), r, "kerning", t.icon); + resp.on_hover_text(tr("Kerning")); + crate::panels::properties::kerning_control(app, ui, "ckern", &chars["kerning"], 92.0); + } else { + if let Some(v) = icon_field( + ui, + "tracking", + &tr("Tracking"), + NumField::number("ctrack", chars["tracking"].as_f64(), "", 0).width(92.0).presets(TRACKING).step(5.0).range(-1000.0, 10000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); + } } - caption(ui, crate::i18n::tr(&app.ui.language, "IT")); - if let Some(v) = number(ui, "chs", chars["hScale"].as_f64().map(|v| v * 100.0), "%", 48.0, 0) { - let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}})); + }); + vsep(ui); + const SCALES: &[f64] = &[25.0, 50.0, 75.0, 80.0, 90.0, 100.0, 110.0, 120.0, 125.0, 150.0, 200.0]; + let pct = |v: &serde_json::Value| v.as_f64().map(|s| (s * 1000.0).round() / 10.0); + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field( + ui, + "vscale", + &tr("Vertical Scale"), + NumField::number("cvs", pct(&chars["vScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"vScale": v / 100.0}})); + } + } else { + if let Some(v) = icon_field( + ui, + "hscale", + &tr("Horizontal Scale"), + NumField::number("chs", pct(&chars["hScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}})); + } + } + }); + vsep(ui); + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field( + ui, + "baseline-shift", + &tr("Baseline Shift"), + NumField::number("cbs", chars["baselineShift"].as_f64(), " pt", 2).width(76.0).range(-5000.0, 5000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}})); + } + } else { + if let Some(v) = icon_field( + ui, + "skew", + &tr("Skew (False Italic)"), + NumField::number("csk", chars["skew"].as_f64(), "°", 1).width(76.0).range(-85.0, 85.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"skew": v}})); + } } - ui.end_row(); }); vsep(ui); ui.vertical(|ui| { - let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default(); - ui.horizontal(|ui| { - for (a, icon) in [(Align::Left, "align-left"), (Align::Center, "align-center"), (Align::Right, "align-right")] { - if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() { - let _ = app.run("type.para", json!({"attrs": {"align": a}})); + ui.spacing_mut().item_spacing.y = 3.0; + // The character style, then the language. + let cs = attrs.as_ref().and_then(|a| a["characterStyle"].as_str()).unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string(); + let names: Vec = app.session.active().map(|d| d.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let mut pick = None; + egui::ComboBox::from_id_salt("ctl_char_style") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &cs))) + .width(150.0) + .show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cs, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() { + pick = Some(n.clone()); + } } - } - }); - ui.horizontal(|ui| { - for (a, icon) in [(Align::LeftJustified, "align-justify"), (Align::FullyJustified, "align-justify-all")] { - if icons::button(ui, icon, 20.0, cur == a, a.label()).clicked() { - let _ = app.run("type.para", json!({"attrs": {"align": a}})); - } - } + }); + if let Some(n) = pick { + let _ = app.run("style.character.apply", json!({"name": n})); + } + let lang = chars["language"].as_str().unwrap_or("English: USA").to_string(); + egui::ComboBox::from_id_salt("ctl_language").selected_text(crate::rtl::widget(ui, tr(&lang))).width(150.0).show_ui(ui, |ui| { + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| crate::panels::properties::language_menu(app, ui, &lang)); }); }); +} + +fn control_paragraph( + app: &mut DesignApp, + ui: &mut egui::Ui, + attrs: &Option, + para: &serde_json::Value, + tr: &dyn Fn(&str) -> String, +) { + let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default(); + let mut align = None; + rows(ui, |ui, row| { + if row == 0 { + for (a, icon) in [ + (Align::Left, "palign-left"), + (Align::Center, "palign-center"), + (Align::Right, "palign-right"), + (Align::TowardsSpine, "palign-spine-towards"), + ] { + if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() { + align = Some(a); + } + } + } else { + for (a, icon) in [ + (Align::LeftJustified, "palign-justify-left"), + (Align::CenterJustified, "palign-justify-center"), + (Align::RightJustified, "palign-justify-right"), + (Align::FullyJustified, "palign-justify-all"), + (Align::AwayFromSpine, "palign-spine-away"), + ] { + if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() { + align = Some(a); + } + } + } + }); + if let Some(a) = align { + let _ = app.run("type.para", json!({"attrs": {"align": a}})); + } vsep(ui); let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas); - egui::Grid::new("ctl_para").num_columns(4).spacing(vec2(4.0, 3.0)).show(ui, |ui| { - caption(ui, "→|"); - if let Some(v) = measure(ui, "cli", para["leftIndent"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"leftIndent": v}})); + let mut set = None; + for ((ia, ta, ka), (ib, tb, kb)) in [ + (("indent-left", "Left Indent", "leftIndent"), ("indent-first", "First Line Left Indent", "firstLineIndent")), + (("indent-right", "Right Indent", "rightIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")), + (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")), + ] { + rows(ui, |ui, row| { + if row == 0 { + if let Some(v) = icon_field(ui, ia, &tr(ta), NumField::measure(ka, para[ka].as_f64(), units).width(84.0).range(0.0, 10_000.0)) { + set = Some((ka, v)); + } + } else { + let min = crate::panels::properties::para_field_min(kb); + if let Some(v) = icon_field(ui, ib, &tr(tb), NumField::measure(kb, para[kb].as_f64(), units).width(84.0).range(min, 10_000.0)) { + set = Some((kb, v)); + } + } + }); + vsep(ui); + } + if let Some((k, v)) = set { + let _ = app.run("type.para", json!({"attrs": {k: v}})); + } + let mut drop = None; + rows(ui, |ui, row| { + if row == 0 { + let max = designcraft_compose::MAX_DROP_CAP_LINES as f64; + if let Some(v) = icon_field( + ui, + "drop-lines", + &tr("Drop Cap Number of Lines"), + NumField::number("cdl", para["dropCapLines"].as_f64(), "", 0).width(64.0).range(0.0, max), + ) { + drop = Some(("dropCapLines", v)); + } + } else { + let max = designcraft_compose::MAX_DROP_CAP_CHARS as f64; + if let Some(v) = icon_field( + ui, + "drop-chars", + &tr("Drop Cap One or More Characters"), + NumField::number("cdc", para["dropCapChars"].as_f64(), "", 0).width(64.0).range(0.0, max), + ) { + drop = Some(("dropCapChars", v)); + } } - caption(ui, "↑¶"); - if let Some(v) = measure(ui, "csb", para["spaceBefore"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"spaceBefore": v}})); - } - ui.end_row(); - caption(ui, "→¶"); - if let Some(v) = measure(ui, "cfi", para["firstLineIndent"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"firstLineIndent": v}})); - } - caption(ui, "↓¶"); - if let Some(v) = measure(ui, "csa", para["spaceAfter"].as_f64(), units, 54.0) { - let _ = app.run("type.para", json!({"attrs": {"spaceAfter": v}})); - } - ui.end_row(); }); + if let Some((k, v)) = drop { + let _ = app.run("type.para", json!({"attrs": {k: v.max(0.0) as u64}})); + } + vsep(ui); + // Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or, + // pressed, none). Pressed when every selected paragraph has it, as Type › Apply Bullets shows. + use designcraft_doc::ListType; + let pressed = [ListType::Bullets, ListType::Numbers].map(|k| app.session.selection_has_list(k)); + let mut list = None; + rows(ui, |ui, row| { + let (icon, cmd, tip) = if row == 0 { ("bullets", "list.bullets", "Bulleted List") } else { ("numbering", "list.numbers", "Numbered List") }; + let on = pressed.get(row).copied().unwrap_or(false); + if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() { + list = Some((cmd, !on)); + } + }); + if let Some((cmd, on)) = list { + let _ = app.run(cmd, json!({"on": on})); + } + vsep(ui); + // Shading over Border: on or off, the Paragraph Borders and Shading dialog on its tab, and the + // colour, as InDesign's. + let doc = app.session.active().map(|d| d.doc.clone()); + let mut shade = None; + let mut dialog = None; + // Both checkboxes as wide as the wider label, so the icons and colours line up in columns. + let check_w = ["Shading", "Border"] + .iter() + .map(|l| ui.fonts_mut(|f| f.layout_no_wrap(tr(l), egui::FontId::proportional(11.5), Color32::WHITE).size().x)) + .fold(0.0, f32::max) + + 28.0; + rows(ui, |ui, row| { + let (on_key, color_key, label, icon, tab) = if row == 0 { + ("shadingOn", "shadingColor", "Shading", "para-shading", "shading") + } else { + ("borderOn", "borderColor", "Border", "para-border", "border") + }; + let mut on = para[on_key].as_bool().unwrap_or(false); + ui.allocate_ui_with_layout(vec2(check_w, crate::widgets::FIELD_H), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(check_w); + if crate::widgets::checkbox(ui, &mut on, &tr(label)).changed() { + shade = Some(json!({ on_key: on })); + } + }); + if crate::widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…")).clicked() { + dialog = Some(tab); + } + if let Some(doc) = &doc { + let cur = para[color_key].as_str().unwrap_or("[Black]").to_string(); + if let Some(w) = swatch_field(ui, doc, &cur, 96.0, &tr(if row == 0 { "Paragraph Shading" } else { "Paragraph Border" })) { + shade = Some(json!({ color_key: w })); + } + } + }); + if let Some(attrs) = shade { + let _ = app.run("type.para", json!({ "attrs": attrs })); + } + if let Some(tab) = dialog { + let _ = app.run("app.paragraphBordersDialog", json!({ "tab": tab })); + } vsep(ui); ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 3.0; let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string(); let ov = attrs.as_ref().and_then(|a| a["paraOverrides"].as_u64()).unwrap_or(0) + attrs.as_ref().and_then(|a| a["charOverrides"].as_u64()).unwrap_or(0); panels::para_style_picker(app, ui, &ps, ov > 0, 170.0); - let hy = para["hyphenate"].as_bool().unwrap_or(true); - let mut h = hy; - if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); - } + ui.horizontal(|ui| { + let mut h = para["hyphenate"].as_bool().unwrap_or(true); + if crate::widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() { + let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); + } + // First Line Only is aligned too: the button shows it pressed, and a click turns alignment off. + let grid = matches!(para["gridAlign"].as_str(), Some("allLines" | "firstLineOnly")); + if crate::widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() { + let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}})); + } + }); }); } +/// A swatch field of the Control panel (Shading and Border colours): the swatch's chip and name, +/// and the document's swatches in its menu. Returns the swatch picked. +pub(crate) fn swatch_field(ui: &mut egui::Ui, doc: &designcraft_doc::Document, cur: &str, width: f32, label: &str) -> Option { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(width, crate::widgets::FIELD_H), Sense::click()); + let resp = resp.on_hover_text(label); + resp.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, label); + info.current_text_value = Some(cur.to_string()); + info + }); + let p = ui.painter(); + p.rect_filled(r, 0.0, if resp.hovered() { t.hover } else { t.input }); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + let seg = egui::Rect::from_min_max(egui::pos2(r.max.x - crate::widgets::SEG_W, r.min.y), r.max); + p.line_segment([seg.left_top(), seg.left_bottom()], Stroke::new(1.0, t.field_border)); + icons::paint(p, egui::Rect::from_center_size(seg.center(), vec2(12.0, 12.0)), "chevron-down", t.icon); + let (c, g) = crate::widgets::swatch_colors(doc, cur, 1.0); + let chip = egui::Rect::from_min_size(egui::pos2(r.min.x + 4.0, r.center().y - 6.0), vec2(12.0, 12.0)); + crate::widgets::paint_chip(p, chip, c, g); + let clip = egui::Rect::from_min_max(r.min, egui::pos2(seg.min.x - 2.0, r.max.y)); + p.with_clip_rect(clip).text(egui::pos2(chip.max.x + 5.0, r.center().y), egui::Align2::LEFT_CENTER, cur, egui::FontId::proportional(11.5), t.text); + let mut picked = None; + egui::Popup::menu(&resp).show(|ui| { + picked = crate::panels::swatch_list(ui, doc, cur); + }); + picked +} + /// The widest a document tab grows: a longer name is shortened so its close button and the /// tabs after it stay in view. const DOC_TAB_MAX_W: f32 = 260.0; @@ -695,8 +1059,8 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { let elided = galley.elided; let w = (galley.size().x + zoom_label.size().x + DOC_TAB_PAD).max(if is_active { 210.0 } else { 150.0 }).min(max_w); let r = egui::Rect::from_min_size(egui::pos2(x, bar.min.y), vec2(w, 28.0)); - let resp = ui.interact(r, ui.id().with(("doctab", i)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + let resp = ui.interact(r, ui.scope_id().with(("doctab", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title)); let resp = if elided { resp.on_hover_ui(|ui| { let _ = crate::rtl::label(ui, title); @@ -706,8 +1070,8 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { }; ui.painter().rect_filled(r, 0.0, if is_active { t.panel } else { t.tab_strip }); let cr = egui::Rect::from_center_size(egui::pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)); - let cresp = ui.interact(cr, ui.id().with(("docclose", i)), Sense::click()); - cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{close_label} {title}"))); + let cresp = ui.interact(cr, ui.scope_id().with(("docclose", i)), Sense::click()); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{close_label} {title}"))); ui.painter().text( cr.center(), egui::Align2::CENTER_CENTER, @@ -1029,7 +1393,7 @@ pub(crate) fn recent_file_stat(ctx: &egui::Context, path: &str) -> RecentStat { } fn recent_file_stat_at(ctx: &egui::Context, path: &str, now: f64) -> RecentStat { - let key = egui::Id::new(("recent_stat", path)); + let key = egui::Id::unique(("recent_stat", path)); if let Some(st) = ctx.data(|d| d.get_temp::(key)) && (0.0..RECENT_STAT_MS).contains(&(now - st.checked)) { @@ -1044,7 +1408,7 @@ fn recent_file_stat_at(ctx: &egui::Context, path: &str, now: f64) -> RecentStat /// The texture of a recent file's saved first-page picture, decoded once per file version. fn recent_preview_texture(ctx: &egui::Context, path: &str, modified: Option) -> Option { type Cached = (Option, Option); - let key = egui::Id::new(("recent_preview", path)); + let key = egui::Id::unique(("recent_preview", path)); if let Some((m, tex)) = ctx.data(|d| d.get_temp::(key)) && m == modified { @@ -1114,6 +1478,28 @@ fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) { #[cfg(test)] mod tests { + + /// A Control panel field is labelled by its icon, so assistive technology reads "Tracking" + /// (or "Font Size", …) with the value. + #[test] + fn icon_fields_are_labelled_by_their_icon() { + use egui::accesskit::Role; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + ctx.enable_accesskit(); + let mut update = None; + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + icon_field(ui, "tracking", "Tracking", NumField::number("tracking_test", Some(0.0), "", 0)); + }); + out.textures_delta.clear(); + update = out.platform_output.accesskit_update; + } + let update = update.expect("an AccessKit tree"); + let named = |n: &egui::accesskit::Node| n.label() == Some("Tracking") || n.value() == Some("Tracking"); + let icon = update.nodes.iter().find(|(_, n)| named(n)).map(|(id, _)| *id).expect("the icon's node"); + assert!(update.nodes.iter().any(|(_, n)| n.role() == Role::TextInput && n.labelled_by().contains(&icon)), "the editor names its icon"); + } use egui::{pos2, vec2}; use egui_kittest::kittest::{NodeT, Queryable}; @@ -1157,7 +1543,7 @@ mod tests { /// The rect of an object Control panel group. fn group(h: &egui_kittest::Harness<'static, test_window::Window>, id: &str) -> egui::Rect { - h.ctx.read_response(egui::Id::new(("object_control", id))).unwrap().rect + h.ctx.read_response(egui::Id::unique(("object_control", id))).unwrap().rect } /// The rect of the node with this label, preferring the one in the Control bar's row: the @@ -1303,12 +1689,21 @@ mod tests { fn long_document_names_keep_tabs_short_and_close_buttons_visible() { let latin = "Quarterly report with a very long file name ".repeat(6); let hebrew = "דוח רבעוני עם שם קובץ ארוך מאוד ".repeat(6); - let mut app = app(); - for title in [latin.as_str(), hebrew.as_str(), "Short"] { - app.run("file.new", serde_json::json!({ "title": title })).unwrap(); + // Use the bundled or pinned test face; optional-input builds without either skip + // the Hebrew tab explicitly instead of rendering tofu under the strict glyph policy. + let hebrew_ok = test_window::install_test_hebrew_face(); + if !hebrew_ok { + eprintln!("skipped the Hebrew tab: set DESIGNCRAFT_TEST_HEBREW_FONT to the pinned NotoSansHebrew.ttf"); } + let mut app = app(); + app.run("file.new", serde_json::json!({ "title": latin })).unwrap(); + if hebrew_ok { + app.run("file.new", serde_json::json!({ "title": hebrew })).unwrap(); + } + app.run("file.new", serde_json::json!({ "title": "Short" })).unwrap(); let h = test_window::open(app, vec2(1200.0, 600.0)); - for title in [latin.as_str(), hebrew.as_str()] { + let titles = if hebrew_ok { vec![latin.as_str(), hebrew.as_str()] } else { vec![latin.as_str()] }; + for title in titles { let tab = rect(&h, title); assert!(tab.width() <= super::DOC_TAB_MAX_W, "{title:?} tab is {} wide", tab.width()); let close = rect(&h, &format!("Close {title}")); @@ -1318,6 +1713,233 @@ mod tests { assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); } + /// Zero2Graphic's page layout video (0:48): with the Type tool, the Control panel's font size + /// has a list of sizes; the instructor picks 72 pt from it. Leading has the same list. + #[test] + fn the_type_control_panel_lists_font_sizes_and_leadings() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Headline"})).unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 8})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(1800.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = |r: &egui::Rect| bar.y_range().contains(r.center().y); + let field = |h: &egui_kittest::Harness<'static, test_window::Window>, value: &str| { + h.query_all_by_value(value).map(|n| n.rect()).find(in_bar).unwrap_or_else(|| panic!("no {value:?} field in the Control panel")) + }; + let press = |h: &mut egui_kittest::Harness<'static, test_window::Window>, p: egui::Pos2| { + h.hover_at(p); + h.drag_at(p); + h.drop_at(p); + }; + let size_of = |h: &egui_kittest::Harness<'static, test_window::Window>| { + let st = h.state().app.session.doc().unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + st.doc.story(sid).unwrap().format_after(0).over.size + }; + // The ⌄ segment right of the size field opens the list; choosing 72 pt sets the size. + let size = field(&h, "12 pt"); + let chevron = pos2(size.max.x + 1.0 + crate::widgets::SEG_W / 2.0, size.center().y); + press(&mut h, chevron); + h.run_steps(3); + h.get_by_label("72 pt").click(); + h.run_steps(3); + assert_eq!(size_of(&h), Some(72.0)); + // Auto leading shows in parentheses, with the same list beside it. + let lead = field(&h, "(86.4 pt)"); + press(&mut h, pos2(lead.max.x + 1.0 + crate::widgets::SEG_W / 2.0, lead.center().y)); + h.run_steps(3); + h.get_by_label("60 pt").click(); + h.run_steps(3); + let st = h.state().app.session.doc().unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(60.0))); + // The spinner left of the size steps it by a point. + let size = field(&h, "72 pt"); + press(&mut h, pos2(size.min.x - 5.0 - crate::widgets::SEG_W / 2.0, size.min.y + 3.0)); + h.run_steps(3); + assert_eq!(size_of(&h), Some(73.0)); + // Typing the automatic value itself over "(87.6 pt)" fixes the leading at it. + h.state_mut().app.run("type.char", serde_json::json!({"attrs": {"leading": {"kind": "auto"}}})).unwrap(); + h.run_steps(3); + let lead = field(&h, "(87.6 pt)"); + press(&mut h, lead.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("87.6".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let st = h.state().app.session.doc().unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + assert_eq!(st.doc.story(sid).unwrap().format_after(0).over.leading, Some(designcraft_doc::Leading::Points(87.6))); + // Escape cancels: the same typing, then Escape, leaves the leading automatic. + h.state_mut().app.run("type.char", serde_json::json!({"attrs": {"leading": {"kind": "auto"}}})).unwrap(); + h.run_steps(3); + let auto = h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading; + let lead = field(&h, "(87.6 pt)"); + press(&mut h, lead.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("87.6".into())); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "Escape leaves it automatic"); + // Escape also reverts a different number, in the leading field and in a plain one. (It + // leaves the text too, so each try selects it again.) + for (shown, typed) in [("(87.6 pt)", "20"), ("73 pt", "50")] { + h.state_mut().app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 8})).unwrap(); + h.state_mut().app.session.set_tool("type"); + h.run_steps(3); + let r = field(&h, shown); + press(&mut h, r.center()); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(typed.into())); + h.key_press(egui::Key::Escape); + h.run_steps(3); + } + assert_eq!(h.state().app.session.doc().unwrap().doc.story(sid).unwrap().format_after(0).over.leading, auto, "still automatic"); + assert_eq!(size_of(&h), Some(73.0), "the size is unchanged"); + } + + /// The Control panel's ¶ mode on a First Line Only paragraph: Align to Baseline Grid shows + /// pressed, and a click turns alignment off instead of making it All Lines. + #[test] + fn the_control_panel_grid_button_keeps_first_line_only() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Bulbs"})).unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.run("type.para", serde_json::json!({"attrs": {"gridAlign": "firstLineOnly"}})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(2400.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y); + let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str| { + let r = h.query_all_by_label(label).map(|n| n.rect()).find(|r| in_bar(*r)).unwrap_or_else(|| panic!("no {label:?} in the Control panel")); + h.hover_at(r.center()); + h.drag_at(r.center()); + h.drop_at(r.center()); + h.run_steps(3); + }; + click(&mut h, "Paragraph Formatting Controls"); + let pressed = h.query_all_by_label("Align to Baseline Grid").find(|n| in_bar(n.rect())).map(|n| n.accesskit_node().toggled()); + assert_eq!(pressed, Some(Some(egui::accesskit::Toggled::True))); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["gridAlign"], "firstLineOnly"); + click(&mut h, "Align to Baseline Grid"); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["gridAlign"], "none"); + } + + /// The course's chapter 16: the text Control panel has InDesign's two modes. A shows the + /// Character Formatting Controls (the case buttons, kerning and tracking, the scales…), ¶ the + /// Paragraph Formatting Controls (alignment, indents, spacing, the drop cap…). + #[test] + fn the_type_control_panel_has_character_and_paragraph_modes() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Bulbs"})).unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 5})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(2400.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y); + let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str| { + let r = h.query_all_by_label(label).map(|n| n.rect()).find(|r| in_bar(*r)).unwrap_or_else(|| panic!("no {label:?} in the Control panel")); + h.hover_at(r.center()); + h.drag_at(r.center()); + h.drop_at(r.center()); + h.run_steps(3); + }; + let has = |h: &egui_kittest::Harness<'static, test_window::Window>, label: &str| h.query_all_by_label(label).any(|n| in_bar(n.rect())); + assert!(has(&h, "Small Caps") && has(&h, "Vertical Scale") && !has(&h, "Drop Cap Number of Lines")); + click(&mut h, "Small Caps"); + click(&mut h, "Paragraph Formatting Controls"); + assert!(has(&h, "Drop Cap Number of Lines") && has(&h, "Space Before") && !has(&h, "Small Caps")); + click(&mut h, "Align to Baseline Grid"); + click(&mut h, "Align center"); + let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap(); + assert_eq!(a["chars"]["capitalization"], "smallCaps"); + assert_eq!(a["para"]["gridAlign"], "allLines"); + assert_eq!(a["para"]["align"], "center"); + click(&mut h, "Character Formatting Controls"); + assert!(has(&h, "Small Caps")); + } + + /// The course's chapter 21: the ¶ Control panel has InDesign's Bulleted and Numbered List + /// buttons (with the default hanging indent), and Shading and Border with their colours and + /// the Paragraph Borders and Shading dialog. + #[test] + fn the_paragraph_control_panel_has_lists_shading_and_border() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app + .run( + "frame.create", + serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "4 eggs +1 cup milk"}), + ) + .unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(2400.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y); + let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str, nth: usize| { + let r = h + .query_all_by_label(label) + .map(|n| n.rect()) + .filter(|r| in_bar(*r)) + .nth(nth) + .unwrap_or_else(|| panic!("no {label:?} in the Control panel")); + h.hover_at(r.center()); + h.drag_at(r.center()); + h.drop_at(r.center()); + h.run_steps(3); + }; + click(&mut h, "Paragraph Formatting Controls", 0); + click(&mut h, "Bulleted List", 0); + let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap(); + assert_eq!( + (a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64(), a["para"]["firstLineIndent"].as_f64()), + (Some("bullets"), Some(18.0), Some(-18.0)) + ); + click(&mut h, "Numbered List", 0); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["listType"], "numbers"); + click(&mut h, "Shading", 0); + click(&mut h, "Border", 0); + let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap(); + assert_eq!((a["para"]["shadingOn"].as_bool(), a["para"]["borderOn"].as_bool()), (Some(true), Some(true))); + assert!(h.query_all_by_label("Paragraph Shading").any(|n| in_bar(n.rect())), "the shading colour"); + // The icon beside Border opens the dialog on its Border tab. + click(&mut h, "Paragraph Borders and Shading…", 1); + let d = h.state().app.ui.dialog.clone().expect("the dialog"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "border")); + // The Last Line Right Indent takes a negative value, as the First Line Left Indent does. + h.state_mut().app.ui.dialog = None; + h.state_mut().app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap(); + h.state_mut().app.session.set_tool("type"); + h.run_steps(3); + // The field is labelled by its icon. + let at = h + .query_all_by_label("Last Line Right Indent") + .filter(|n| n.accesskit_node().role() == egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .find(|r| in_bar(*r)) + .expect("the last line indent") + .center(); + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("-0p6".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["lastLineIndent"].as_f64(), Some(-6.0)); + } + #[test] fn the_page_tool_has_its_own_control_panel() { // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the @@ -1596,7 +2218,7 @@ mod tests { } fn caption_center(&self, id: &str) -> egui::Pos2 { - self.ctx.read_response(egui::Id::new(id)).unwrap().rect.center() + self.ctx.read_response(egui::Id::unique(id)).unwrap().rect.center() } } @@ -1620,7 +2242,7 @@ mod tests { #[test] fn title_area_drags_and_double_clicks_but_menus_do_not_drag() { let mut h = WindowHarness::new(); - let title = h.ctx.read_response(egui::Id::new("window_title_drag")).unwrap().rect; + let title = h.ctx.read_response(egui::Id::unique("window_title_drag")).unwrap().rect; assert!(title.width() > 0.0, "there is a drag region between menus and tools"); let p = title.center(); let mut commands = h.press(p); @@ -1670,8 +2292,8 @@ mod tests { h.size = vec2(640.0, 560.0); h.frame(vec![]); h.frame(vec![]); - let rect = |h: &WindowHarness, id: &str| h.ctx.read_response(egui::Id::new(id)).unwrap().rect; - let menus_end = |h: &WindowHarness| h.ctx.data(|d| d.get_temp::(egui::Id::new("app_bar_menus_end"))).unwrap(); + let rect = |h: &WindowHarness, id: &str| h.ctx.read_response(egui::Id::unique(id)).unwrap().rect; + let menus_end = |h: &WindowHarness| h.ctx.data(|d| d.get_temp::(egui::Id::unique("app_bar_menus_end"))).unwrap(); let (close, menus0) = (rect(&h, "window.close"), menus_end(&h)); assert!(close.left() >= 0.0 && close.right() <= h.size.x, "{close:?}"); let p = egui::pos2(200.0, 18.0); @@ -1682,6 +2304,7 @@ mod tests { delta: vec2(0.0, -3000.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }, ]); for _ in 0..10 { @@ -1700,8 +2323,8 @@ mod tests { h.size = vec2(900.0, 560.0); h.frame(vec![]); h.frame(vec![]); - let drag = h.ctx.read_response(egui::Id::new("window_title_drag")).unwrap().rect; - let close = h.ctx.read_response(egui::Id::new("window.close")).unwrap().rect; + let drag = h.ctx.read_response(egui::Id::unique("window_title_drag")).unwrap().rect; + let close = h.ctx.read_response(egui::Id::unique("window.close")).unwrap().rect; assert!(drag.right() <= close.left() + 0.5, "{drag:?} {close:?}"); let p = egui::pos2(450.0, APP_BAR + APP_BAR_BAND / 2.0); assert!(drag.contains(p), "{drag:?}"); @@ -1717,7 +2340,7 @@ mod tests { h.frame(vec![]); h.frame(vec![]); for id in ["window.minimize", "window.maximize", "window.close"] { - let rect = h.ctx.read_response(egui::Id::new(id)).unwrap().rect; + let rect = h.ctx.read_response(egui::Id::unique(id)).unwrap().rect; assert!(rect.left() >= 0.0 && rect.right() <= h.size.x, "{id}: {rect:?}"); let p = rect.center(); assert!(!h.click(p).is_empty(), "{id} remains clickable"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1cb7395c0..f935873f8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -600,7 +600,7 @@ mod automation_contract_tests { assert_eq!(app.control_synthetic, queued); // The ordinary input hook consumes one pointer event per frame, freeing // enough room for a complete click without dropping earlier input. - app.raw_input_hook(&mut egui::RawInput::default()); + app.raw_input_hook(&ctx, &mut egui::RawInput::default()); assert_eq!(app.control_synthetic.len(), 10000); assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], true); assert_eq!(app.control_synthetic.len(), 10003); @@ -786,7 +786,7 @@ mod deferred_clipboard_tests { assert!(!app.in_control); for step in 0..3 { let mut raw = egui::RawInput { time: Some(step as f64 * 0.05), ..Default::default() }; - app.raw_input_hook(&mut raw); + app.raw_input_hook(&ctx, &mut raw); ctx.run_ui(raw, |ui| menus::shortcuts(&mut app, ui.ctx())).textures_delta.clear(); } let texts: Vec = app.session.active().unwrap().doc.stories.values().map(|story| story.text.clone()).collect(); @@ -794,7 +794,7 @@ mod deferred_clipboard_tests { assert!(!texts.iter().any(|text| text.contains("SYNTHETIC_CLIPBOARD_SENTINEL"))); // Once control input is drained, an ordinary user paste still reads its clipboard. let mut raw = egui::RawInput::default(); - app.raw_input_hook(&mut raw); + app.raw_input_hook(&ctx, &mut raw); app.run("edit.paste", json!({})).unwrap(); assert_eq!(reads.load(Ordering::SeqCst), 1); } @@ -805,12 +805,12 @@ mod deferred_clipboard_tests { let (req, _) = ControlRequest::new("ui.move", json!({"x":10,"y":10})); let _ = handle(&mut app, &ctx, &req); let mut raw = egui::RawInput::default(); - app.raw_input_hook(&mut raw); + app.raw_input_hook(&ctx, &mut raw); let mut private = ctx.run_ui(raw, |ui| ui.ctx().copy_text("control field".into())); assert!(!private.platform_output.commands.iter().any(|cmd| matches!(cmd, egui::OutputCommand::CopyText(_)))); private.textures_delta.clear(); let mut raw = egui::RawInput::default(); - app.raw_input_hook(&mut raw); + app.raw_input_hook(&ctx, &mut raw); let mut native = ctx.run_ui(raw, |ui| ui.ctx().copy_text("native field".into())); assert!(native.platform_output.commands.iter().any(|cmd| matches!(cmd, egui::OutputCommand::CopyText(text) if text=="native field"))); native.textures_delta.clear(); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index b9efa035a..22937978d 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -212,10 +212,8 @@ impl Default for View { } } -/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. -pub fn contributors_ui(ui: &mut egui::Ui, lang: &str) { - let id = egui::Id::new("credits_view"); - let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); +/// The name toggle, sort and layout controls above the list. +fn controls(ui: &mut egui::Ui, v: &mut View, lang: &str) { ui.horizontal_wrapped(|ui| { ui.label(tr(lang, "Show")); for m in NameMode::ALL { @@ -244,6 +242,13 @@ pub fn contributors_ui(ui: &mut egui::Ui, lang: &str) { v.table = true; } }); +} + +/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. +pub fn contributors_ui(ui: &mut egui::Ui, lang: &str) { + let id = egui::Id::unique("credits_view"); + let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); + controls(ui, &mut v, lang); let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); let totals = tr(lang, "{contributors} contributors · {commits} commits") .replace("{contributors}", &list.len().to_string()) diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 28f98a5e0..96cf7a116 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -318,7 +318,7 @@ pub fn show(ctx: &egui::Context, images: &mut Images, c: Cursor, hover: Pos2) -> system(c) } Mode::Painted => { - let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor")); + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("tool-cursor")); // Divide by the zoom factor so the glyph keeps its size in physical pixels. let scale = 1.0 / ctx.zoom_factor().max(0.01); let painter = ctx.layer_painter(layer); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b412a06f0..599159da7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -132,7 +132,17 @@ impl Dialog { fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { let mut s = d.s(key); - if ui.add(egui::TextEdit::singleline(&mut s).desired_width(w).horizontal_align(egui::Align::Min)).changed() { + if ui.add(egui::TextEdit::singleline(&mut s).desired_width(w).align(egui::Align2::LEFT_TOP)).changed() { + d.fields.insert(key.into(), Value::String(s)); + } +} + +/// [`text_field`] with the visible caption's text as its accessible name (a text field with no +/// label, hint or `labelled_by` is an unnamed input). +fn text_field_named(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32, name: &str) { + let mut s = d.s(key); + let resp = ui.add(egui::TextEdit::singleline(&mut s).desired_width(w).align(egui::Align2::LEFT_TOP)).accessible_name(name.to_owned()); + if resp.changed() { d.fields.insert(key.into(), Value::String(s)); } } @@ -527,6 +537,15 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { _ => ((inner - o.gutter * (n - 1.0)) / n).max(0.0), }; let kind = |v: Value| v.as_str().unwrap_or_default().to_string(); + // A frame without a custom baseline grid shows the document grid's start and increment. + let grid = o.baseline_grid.unwrap_or_else(|| { + let g = app.session.active().map(|s| s.doc.settings.baseline_grid.clone()).unwrap_or_default(); + let relative_to = match g.relative_to { + designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage, + designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin, + }; + designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to } + }); let shown = json!({ "columns": o.columns, "gutter": fm(o.gutter), "columnsKind": kind(serde_json::to_value(o.columns_kind).unwrap_or_default()), "columnWidth": fm(width), @@ -542,6 +561,8 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { "useMinimumHeight": o.auto_size_use_min_height, "minimumHeight": fm(o.auto_size_min_height), "useMinimumWidth": o.auto_size_use_min_width, "minimumWidth": fm(o.auto_size_min_width), "noLineBreaks": o.auto_size_no_line_breaks, + "customGrid": o.baseline_grid.is_some(), "gridStart": fm(grid.start), "gridIncrement": fm(grid.increment), + "gridRelativeTo": grid.relative_to.name(), }); if let Value::Object(m) = &shown { for (k, v) in m { @@ -593,6 +614,28 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) text_field(ui, d, "firstBaselineMin", 70.0); ui.end_row(); }); + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(tr("Baseline Grid")).font(semibold(12.0))); + check(ui, d, "customGrid", tr("Use Custom Baseline Grid")); + ui.add_enabled_ui(d.b("customGrid"), |ui| { + egui::Grid::new("tfo_grid").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, tr("Start:")); + text_field(ui, d, "gridStart", 70.0); + ui.end_row(); + crate::rtl::label(ui, tr("Relative To:")); + translated_combo( + &lang, + ui, + d, + "gridRelativeTo", + &[("page", "Top of Page"), ("margin", "Top Margin"), ("frame", "Top of Frame"), ("inset", "Top Inset")], + ); + ui.end_row(); + crate::rtl::label(ui, tr("Increment Every:")); + text_field(ui, d, "gridIncrement", 70.0); + ui.end_row(); + }); + }); } "autosize" => auto_size(&lang, ui, d), "rules" => column_rules(app, ui, d), @@ -751,7 +794,7 @@ fn reference_point(ui: &mut egui::Ui, d: &mut Dialog, key: &str) { for (i, name) in designcraft_engine::autosize::REFERENCES.iter().enumerate() { let c = r.min + egui::vec2(5.0 + 18.0 * (i % 3) as f32, 5.0 + 18.0 * (i / 3) as f32); let dot = egui::Rect::from_center_size(c, egui::vec2(8.0, 8.0)); - let resp = ui.interact(dot.expand(2.0), ui.id().with((key, i)), egui::Sense::click()); + let resp = ui.interact(dot.expand(2.0), ui.scope_id().with((key, i)), egui::Sense::click()); if resp.clicked() { d.fields.insert(key.into(), json!(name)); } @@ -857,6 +900,30 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result= 0.0).ok_or_else(|| bad(what))?)); } } + // The custom baseline grid: off, or the fields the user changed (all of them when it is + // turned on, since it shows the document grid's). + let grid_keys = ["customGrid", "gridStart", "gridIncrement", "gridRelativeTo"]; + if grid_keys.iter().any(|k| edited(d, k)) { + if d.b("customGrid") { + let all = edited(d, "customGrid"); + let mut g = Map::new(); + if all || edited(d, "gridStart") { + g.insert("start".into(), json!(d.m("gridStart").ok_or_else(|| bad("the baseline grid start is not a measure"))?)); + } + if all || edited(d, "gridIncrement") { + g.insert( + "increment".into(), + json!(d.m("gridIncrement").filter(|v| *v > 0.0).ok_or_else(|| bad("the baseline grid increment is not a measure"))?), + ); + } + if all || edited(d, "gridRelativeTo") { + g.insert("relativeTo".into(), json!(d.s("gridRelativeTo"))); + } + p.insert("baselineGrid".into(), Value::Object(g)); + } else { + p.insert("baselineGrid".into(), Value::Null); + } + } if p.is_empty() { return Ok(Value::Null); } @@ -994,7 +1061,7 @@ fn margins_and_columns_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Di crate::icons::button(ui, if linked { "link" } else { "link-broken" }, 20.0, linked, &tip) }) .inner; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); if resp.clicked() { d.fields.insert("link".into(), json!(!linked)); } @@ -1103,7 +1170,7 @@ fn create_guides_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) /// InDesign shows it ("click in another field to see the value multiplied by two"). fn measure_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { let mut s = d.s(key); - let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(w).horizontal_align(egui::Align::Min)); + let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(w).align(egui::Align2::LEFT_TOP)); if r.changed() { d.fields.insert(key.into(), Value::String(s)); } @@ -1277,7 +1344,7 @@ fn setup_spin(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, min: f64 (1.0, egui::Rect::from_min_max(seg.min, egui::pos2(seg.max.x, seg.center().y))), (-1.0, egui::Rect::from_min_max(egui::pos2(seg.min.x, seg.center().y), seg.max)), ] { - let resp = ui.interact(half, ui.id().with((key, dir < 0.0)), egui::Sense::click()); + let resp = ui.interact(half, ui.scope_id().with((key, dir < 0.0)), egui::Sense::click()); if resp.hovered() { ui.painter().rect_filled(half.shrink(1.0), 1.0, t.hover); } @@ -1296,7 +1363,8 @@ fn setup_spin(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, min: f64 let text = egui::Rect::from_min_max(egui::pos2(seg.right() + 5.0, r.top() + 1.0), egui::pos2(r.right() - 3.0, r.bottom() - 1.0)); let mut s = d.s(key); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text).layout(egui::Layout::left_to_right(egui::Align::Center))); - let resp = child.add(egui::TextEdit::singleline(&mut s).id(ui.id().with((key, "text"))).frame(egui::Frame::NONE).desired_width(text.width())); + let resp = + child.add(egui::TextEdit::singleline(&mut s).id(ui.scope_id().with((key, "text"))).frame(egui::Frame::NONE).desired_width(text.width())); if resp.changed() { d.fields.insert(key.into(), Value::String(s)); changed = true; @@ -2064,10 +2132,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { match d.s("section").as_str() { "span" => "Span Columns", "bullets" => "Bullets and Numbering", + "keep" => "Keep Options", + "nested" => "Drop Caps and Nested Styles", + "grep" => "GREP Styles", _ => "Paragraph Borders and Shading", }, ), "justification" => crate::i18n::tr(&app.ui.language, "Justification"), + "hyphenation" => crate::i18n::tr(&app.ui.language, "Hyphenation Settings"), "insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"), "findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"), "polygonSettings" => crate::i18n::tr(&app.ui.language, "Polygon Settings"), @@ -2100,7 +2172,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }, }; // No dimmed backdrop: the page behind a dialog is what its settings and colours are judged against. - let host = egui::Id::new("dialog"); + let host = egui::Id::unique("dialog"); let instance = host.with(&d.id); crate::modal_drag::modal(ctx, host, instance).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| { let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum(); @@ -2109,6 +2181,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "alert" => 440.0, "closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0, "newDocument" => 960.0, + "hyphenation" => 560.0, "numberingSection" => 560.0, crate::effects_dialog::ID => 720.0, _ => 640.0, @@ -2361,6 +2434,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let current = d.fields.get("current").cloned().unwrap_or_default(); justification(&app.ui.language, ui, &mut d, "", ¤t); } + "hyphenation" => { + let current = d.fields.get("current").cloned().unwrap_or_default(); + let units = app.session.active().map_or(Unit::Picas, |st| st.doc.settings.horizontal_units); + let preview = crate::i18n::tr(&app.ui.language, "Preview").to_string(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| hyphenation(&app.ui.language, ui, &mut d, "", ¤t, units)); + ui.add_space(24.0); + check(ui, &mut d, "preview", &preview); + }); + hyphenation_preview(app, &mut d); + if let Some(st) = d.fields.get("status").and_then(Value::as_str) { + ui.add_space(6.0); + crate::rtl::label(ui, egui::RichText::new(st).color(ui.visuals().error_fg_color)); + } + } "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), "colorPicker" => color_picker(ui, &mut d), @@ -3173,6 +3261,24 @@ pub fn confirm(app: &mut DesignApp) -> Result { } app.run("type.para", json!({"attrs": attrs})) } + "hyphenation" => { + // The preview goes; the edits are made once, as one undo step. + if d.fields.contains_key(PREVIEWED) { + app.session.cancel_interaction(); + } + let attrs = hyphenation_edits(&d); + if attrs.is_empty() { + return Ok(Value::Null); + } + app.run("type.para", json!({"attrs": attrs})).inspect_err(|e| { + // The dialog stays open, with the reason, to be corrected. + app.status(e.clone()); + let mut d = d; + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e)); + app.ui.dialog = Some(d); + }) + } "justification" => { let current = d.fields.get("current").cloned().unwrap_or_default(); if let Err(e) = check_justification(&d, "", ¤t) { @@ -3679,18 +3785,13 @@ fn check_paragraph_style_justification(app: &DesignApp, d: &Dialog) -> Result<() check_justification(d, "p.", &base) } -fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Result { - let name = d.s("name"); - if let Err(e) = check_paragraph_style_justification(app, d) { - // The caller shows the reason in the dialog's status line. - d.fields.insert("section".into(), json!("justify")); - return Err(e); - } +/// The `p.*` and `c.*` dialog fields as the paragraph and character attribute maps a style +/// command takes. `p.ruleAbove.weight` is one field of a rule (the command keeps the others). +fn style_option_maps(d: &Dialog) -> (serde_json::Map, serde_json::Map) { let mut para = serde_json::Map::new(); let mut chars = serde_json::Map::new(); for (k, v) in &d.fields { if let Some(a) = k.strip_prefix("p.") { - // `p.ruleAbove.weight`: one field of a rule (the command keeps the others). match a.split_once('.') { Some((attr, field)) => { if let Value::Object(m) = para.entry(attr).or_insert_with(|| json!({})) { @@ -3705,6 +3806,17 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul chars.insert(a.into(), v.clone()); } } + (para, chars) +} + +fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Result { + let name = d.s("name"); + if let Err(e) = check_paragraph_style_justification(app, d) { + // The caller shows the reason in the dialog's status line. + d.fields.insert("section".into(), json!("justify")); + return Err(e); + } + let (para, chars) = style_option_maps(d); let based = d.s("basedOn"); let rename = d.s("rename"); let (r, style) = if d.b("new") { @@ -3739,6 +3851,26 @@ fn confirm_paragraph_style_options(app: &mut DesignApp, d: &mut Dialog) -> Resul Ok(r) } +/// Copies the dialog's `c.*` (and, for a paragraph style, `p.*`) fields into the preview params. +fn fill_style_preview_fields(params: &mut Value, d: &Dialog, paragraph: bool) { + for (key, value) in &d.fields { + if let Some(attr) = key.strip_prefix("c.") { + params["chars"][attr] = value.clone(); + } else if paragraph && let Some(attr) = key.strip_prefix("p.") { + if let Some((attr, field)) = attr.split_once('.') { + if params["para"][attr].is_null() { + params["para"][attr] = json!({}); + } + if let Some(fields) = params["para"][attr].as_object_mut() { + fields.insert(field.into(), value.clone()); + } + } else { + params["para"][attr] = value.clone(); + } + } + } +} + /// Preview the changed style attributes through the same engine commands as OK. Renaming and /// export tagging have no visual effect and wait for OK, so the dialog can keep looking up the /// original style while the preview is visible. The interaction restores its starting document @@ -3768,22 +3900,7 @@ fn style_options_preview(app: &mut DesignApp, d: &mut Dialog) { if paragraph { params["para"] = json!({}); } - for (key, value) in &d.fields { - if let Some(attr) = key.strip_prefix("c.") { - params["chars"][attr] = value.clone(); - } else if paragraph && let Some(attr) = key.strip_prefix("p.") { - if let Some((attr, field)) = attr.split_once('.') { - if params["para"][attr].is_null() { - params["para"][attr] = json!({}); - } - if let Some(fields) = params["para"][attr].as_object_mut() { - fields.insert(field.into(), value.clone()); - } - } else { - params["para"][attr] = value.clone(); - } - } - } + fill_style_preview_fields(&mut params, d, paragraph); if let Some(based) = d.fields.get("basedOn") { params["basedOn"] = match based.as_str() { Some(designcraft_doc::NO_PARA_STYLE | designcraft_doc::NO_CHAR_STYLE) => Value::Null, @@ -3900,274 +4017,12 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo text_field(ui, d, "x.class", 160.0); }); } - "nested" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Drop Caps")).font(semibold(12.0))); - egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (label, key, max) in [ - ("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), - ("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), - ] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); - let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64(); - if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) { - d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64))); - } - ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Character Style:")); - let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string(); - let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style }; - egui::ComboBox::from_id_salt("ps_dropcap_style") - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(160.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))).clicked() { - d.fields.insert("p.dropCapStyle".into(), json!(c)); - } - } - }); - ui.end_row(); - }); - for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] { - let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false); - if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { - d.fields.insert(format!("p.{key}"), json!(on)); - } - } - ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Styles")).font(semibold(12.0))); - let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); - let mut changed = false; - let mut remove = None; - for (i, ns) in list.iter_mut().enumerate() { - ui.horizontal(|ui| { - let style = ns["style"].as_str().unwrap_or("[None]").to_string(); - egui::ComboBox::from_id_salt(("ns_style", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(110.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui - .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))) - .clicked() - { - ns["style"] = json!(c); - changed = true; - } - } - }); - let through = ns["through"].as_bool().unwrap_or(true); - egui::ComboBox::from_id_salt(("ns_thr", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if through { "through" } else { "up to" }))) - .width(70.0) - .show_ui(ui, |ui| { - for (v, l) in [(true, "through"), (false, "up to")] { - if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() { - ns["through"] = json!(v); - changed = true; - } - } - }); - let mut n = ns["count"].as_u64().unwrap_or(1) as u32; - let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| { - if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { - ns["count"] = json!(n); - *changed = true; - } - }; - if !crate::i18n::category_first(&app.ui.language) { - count_value(ui, ns, &mut changed); - } - let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string(); - egui::ComboBox::from_id_salt(("ns_until", i)) - .selected_text(crate::rtl::widget( - ui, - if crate::i18n::category_first(&app.ui.language) { - format!("{}:", crate::i18n::tr(&app.ui.language, &kind)) - } else { - crate::i18n::tr(&app.ui.language, &kind).to_owned() - }, - )) - .width(100.0) - .show_ui(ui, |ui| { - for k in [ - "sentences", - "words", - "characters", - "letters", - "digits", - "tab", - "forcedLineBreak", - "emSpace", - "enSpace", - "dropcap", - "chars", - ] { - if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k))).clicked() { - ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) }; - changed = true; - } - } - }); - if crate::i18n::category_first(&app.ui.language) { - count_value(ui, ns, &mut changed); - } - if kind == "chars" { - let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string(); - if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() { - ns["until"]["chars"] = json!(c); - changed = true; - } - } - if ui - .small_button("×") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete")); - }) - .clicked() - { - remove = Some(i); - } - }); - } - if let Some(i) = remove { - list.remove(i); - changed = true; - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Nested Style"))).clicked() { - list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}})); - changed = true; - } - if changed { - d.fields.insert("p.nestedStyles".into(), Value::Array(list)); - } - ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Nested Line Styles")).font(semibold(12.0))); - let mut lines: Vec = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default(); - let mut changed = false; - let mut remove = None; - for (i, l) in lines.iter_mut().enumerate() { - ui.horizontal(|ui| { - let style = l["style"].as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(("nls_style", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(130.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui - .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))) - .clicked() - { - l["style"] = json!(c); - changed = true; - } - } - }); - crate::rtl::label( - ui, - if crate::i18n::category_first(&app.ui.language) { - format!("{}:", crate::i18n::tr(&app.ui.language, "lines")) - } else { - crate::i18n::tr(&app.ui.language, "for").to_owned() - }, - ); - let mut n = l["lines"].as_u64().unwrap_or(1) as u32; - if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { - l["lines"] = json!(n); - changed = true; - } - if !crate::i18n::category_first(&app.ui.language) { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, if n == 1 { "line" } else { "lines" })); - } - if ui - .small_button("×") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete")); - }) - .clicked() - { - remove = Some(i); - } - }); - } - if let Some(i) = remove { - lines.remove(i); - changed = true; - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Line Style"))).clicked() { - lines.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "lines": 1})); - changed = true; - } - if changed { - d.fields.insert("p.nestedLineStyles".into(), Value::Array(lines)); - } - } - "grep" => { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "GREP Styles")).font(semibold(12.0))); - let mut list: Vec = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default(); - let mut changed = false; - let mut remove = None; - for (i, g) in list.iter_mut().enumerate() { - ui.horizontal(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Apply Style:")); - let style = g["style"].as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(("gs_style", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &style))) - .width(110.0) - .show_ui(ui, |ui| { - for c in &cnames { - if ui - .selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, c))) - .clicked() - { - g["style"] = json!(c); - changed = true; - } - } - }); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "To Text:")); - let mut pat = g["pattern"].as_str().unwrap_or("").to_string(); - let bad = grep_error(&pat).is_some(); - let r = ui.add( - egui::TextEdit::singleline(&mut pat) - .desired_width(150.0) - .text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90))), - ); - if r.changed() { - g["pattern"] = json!(pat); - changed = true; - } - if ui - .small_button("×") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Delete")); - }) - .clicked() - { - remove = Some(i); - } - }); - if let Some(e) = grep_error(g["pattern"].as_str().unwrap_or("")) { - crate::rtl::label(ui, egui::RichText::new(e).size(11.0).color(egui::Color32::from_rgb(240, 90, 90))); - } - } - if let Some(i) = remove { - list.remove(i); - changed = true; - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New GREP Style"))).clicked() { - list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"})); - changed = true; - } - if changed { - d.fields.insert("p.grepStyles".into(), Value::Array(list)); - } - } + "nested" => drop_caps_and_nested_styles(&app.ui.language, ui, d, &pv, &cnames), + "grep" => grep_styles(&app.ui.language, ui, d, &pv, &cnames), "chars" => basic_character_formats(app, ui, d, &CharFields { base: &cv, sparse: false }), "indents" => { egui::Grid::new("psi").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Alignment:")); + let alignment_label = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Alignment:")); let a: designcraft_doc::Align = serde_json::from_value(cur(d, "p.align", &pv["align"])).unwrap_or_default(); egui::ComboBox::from_id_salt("psalign") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, a.label()))) @@ -4178,7 +4033,9 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo d.fields.insert("p.align".into(), serde_json::to_value(al).unwrap_or_default()); } } - }); + }) + .response + .labelled_by(alignment_label.id); ui.end_row(); for (label, key) in [ ("Left Indent:", "leftIndent"), @@ -4194,31 +4051,38 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } ui.end_row(); } + // The CJK Grid Settings section edits the same `p.gridAlign` field. + use designcraft_doc::GridAlign; + const GRID_ALIGN: [(GridAlign, &str); 3] = + [(GridAlign::None, "None"), (GridAlign::AllLines, "All Lines"), (GridAlign::FirstLineOnly, "First Line Only")]; + let grid_label = crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Align to Grid:")); + let g: GridAlign = serde_json::from_value(cur(d, "p.gridAlign", &pv["gridAlign"])).unwrap_or_default(); + let shown = GRID_ALIGN.iter().find(|o| o.0 == g).map_or("", |o| o.1); + egui::ComboBox::from_id_salt("psgridalign") + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, shown))) + .width(240.0) + .show_ui(ui, |ui| { + for (v, label) in GRID_ALIGN { + if ui.selectable_label(v == g, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() + && let Ok(v) = serde_json::to_value(v) + { + d.fields.insert("p.gridAlign".into(), v); + } + } + }) + .response + .labelled_by(grid_label.id); + ui.end_row(); }); + ui.add_space(6.0); + let mut ignore = cur(d, "p.ignoreOpticalMargin", &pv["ignoreOpticalMargin"]).as_bool().unwrap_or(false); + if ui.checkbox(&mut ignore, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ignore Optical Margin"))).changed() { + d.fields.insert("p.ignoreOpticalMargin".into(), json!(ignore)); + } } "tabs" => style_tabs(&app.ui.language, ui, d, &pv, &doc), "rules" => paragraph_rules(app, ui, d, "p.", &pv), - "hyph" => { - let mut h = cur(d, "p.hyphenate", &pv["hyphenate"]).as_bool().unwrap_or(true); - if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() { - d.fields.insert("p.hyphenate".into(), json!(h)); - } - egui::Grid::new("psh").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - for (label, key) in [ - ("Words with at Least:", "hyphMinWord"), - ("After First:", "hyphAfterFirst"), - ("Before Last:", "hyphBeforeLast"), - ("Hyphen Limit:", "hyphLimit"), - ] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); - let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64(); - if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) { - d.fields.insert(format!("p.{key}"), json!(n.max(0.0) as u64)); - } - ui.end_row(); - } - }); - } + "hyph" => hyphenation(&app.ui.language, ui, d, "p.", &pv, units), "justify" => justification(&app.ui.language, ui, d, "p.", &pv), "color" => character_color(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc), "advanced" => advanced_character_formats(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }), @@ -4243,7 +4107,10 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo d.fields.insert("rename".into(), json!(name)); } let mut rn = d.s("rename"); - if ui.add(egui::TextEdit::singleline(&mut rn).desired_width(220.0)).changed() { + let name_field = ui + .add(egui::TextEdit::singleline(&mut rn).desired_width(220.0)) + .accessible_name(crate::i18n::tr(&app.ui.language, "Style Name").to_owned()); + if name_field.changed() { d.fields.insert("rename".into(), json!(rn)); } ui.end_row(); @@ -4255,7 +4122,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo .map(str::to_string) .or(style.based_on.clone()) .unwrap_or_else(|| "[No Paragraph Style]".into()); - egui::ComboBox::from_id_salt("psbased") + let combo = egui::ComboBox::from_id_salt("psbased") .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &based))) .width(220.0) .show_ui(ui, |ui| { @@ -4265,6 +4132,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Based On")); ui.end_row(); }); ui.add_space(8.0); @@ -4408,12 +4276,16 @@ fn style_tabs(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc: &d }); } crate::rtl::label(ui, crate::i18n::tr(lang, "Leader:")); - if let (Some(l), Some(i)) = (text_commit(ui, "psleader", stop.as_ref().map_or("", |s| s.leader.as_str()), 36.0, stop.is_some()), sel) { + let leader_name = crate::i18n::tr(lang, "Leader:"); + let leader = text_commit(ui, "psleader", stop.as_ref().map_or("", |s| s.leader.as_str()), 36.0, stop.is_some(), leader_name); + if let (Some(l), Some(i)) = (leader, sel) { result = Some(tabs::leader_value(&l, "tabs").map_err(|e| e.to_string()).and_then(|l| tabs::change(&mut list, i, None, Some(l), None))); } crate::rtl::label(ui, crate::i18n::tr(lang, "Align On:")); let on_char = stop.as_ref().is_some_and(|s| s.align == designcraft_doc::TabAlign::Char); - if let (Some(c), Some(i)) = (text_commit(ui, "psalignon", stop.as_ref().map_or("", |s| s.align_on.as_str()), 18.0, on_char), sel) { + let align_name = crate::i18n::tr(lang, "Align On:"); + let align_on = text_commit(ui, "psalignon", stop.as_ref().map_or("", |s| s.align_on.as_str()), 18.0, on_char, align_name); + if let (Some(c), Some(i)) = (align_on, sel) { result = Some(tabs::align_on_value(&c, "tabs").map_err(|e| e.to_string()).and_then(|c| tabs::change(&mut list, i, None, None, Some(c)))); } ui.add_space(6.0); @@ -4714,6 +4586,254 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc }); } +/// Drop Caps and Nested Styles: the drop cap, nested styles and nested line styles (`p.` fields). +fn drop_caps_and_nested_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) { + let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Drop Caps")).font(semibold(12.0))); + egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (label, key, max) in [ + ("Lines:", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), + ("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), + ] { + crate::rtl::label(ui, crate::i18n::tr(lang, label)); + let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64(); + if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) { + d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64))); + } + ui.end_row(); + } + crate::rtl::label(ui, crate::i18n::tr(lang, "Character Style:")); + let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string(); + let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style }; + egui::ComboBox::from_id_salt("ps_dropcap_style") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(160.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + d.fields.insert("p.dropCapStyle".into(), json!(c)); + } + } + }); + ui.end_row(); + }); + for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] { + let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false); + if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).changed() { + d.fields.insert(format!("p.{key}"), json!(on)); + } + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Nested Styles")).font(semibold(12.0))); + let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, ns) in list.iter_mut().enumerate() { + ui.horizontal(|ui| { + let style = ns["style"].as_str().unwrap_or("[None]").to_string(); + egui::ComboBox::from_id_salt(("ns_style", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(110.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + ns["style"] = json!(c); + changed = true; + } + } + }); + let through = ns["through"].as_bool().unwrap_or(true); + egui::ComboBox::from_id_salt(("ns_thr", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if through { "through" } else { "up to" }))) + .width(70.0) + .show_ui(ui, |ui| { + for (v, l) in [(true, "through"), (false, "up to")] { + if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(lang, l))).clicked() { + ns["through"] = json!(v); + changed = true; + } + } + }); + let mut n = ns["count"].as_u64().unwrap_or(1) as u32; + let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| { + if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { + ns["count"] = json!(n); + *changed = true; + } + }; + if !crate::i18n::category_first(lang) { + count_value(ui, ns, &mut changed); + } + let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string(); + egui::ComboBox::from_id_salt(("ns_until", i)) + .selected_text(crate::rtl::widget( + ui, + if crate::i18n::category_first(lang) { + format!("{}:", crate::i18n::tr(lang, &kind)) + } else { + crate::i18n::tr(lang, &kind).to_owned() + }, + )) + .width(100.0) + .show_ui(ui, |ui| { + for k in + ["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "dropcap", "chars"] + { + if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(lang, k))).clicked() { + ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) }; + changed = true; + } + } + }); + if crate::i18n::category_first(lang) { + count_value(ui, ns, &mut changed); + } + if kind == "chars" { + let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() { + ns["until"]["chars"] = json!(c); + changed = true; + } + } + if ui + .small_button("×") + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); + }) + .clicked() + { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + list.remove(i); + changed = true; + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New Nested Style"))).clicked() { + list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "through": true, "count": 1, "until": {"kind": "words"}})); + changed = true; + } + if changed { + d.fields.insert("p.nestedStyles".into(), Value::Array(list)); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Nested Line Styles")).font(semibold(12.0))); + let mut lines: Vec = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, l) in lines.iter_mut().enumerate() { + ui.horizontal(|ui| { + let style = l["style"].as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt(("nls_style", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(130.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + l["style"] = json!(c); + changed = true; + } + } + }); + crate::rtl::label( + ui, + if crate::i18n::category_first(lang) { + format!("{}:", crate::i18n::tr(lang, "lines")) + } else { + crate::i18n::tr(lang, "for").to_owned() + }, + ); + let mut n = l["lines"].as_u64().unwrap_or(1) as u32; + if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { + l["lines"] = json!(n); + changed = true; + } + if !crate::i18n::category_first(lang) { + crate::rtl::label(ui, crate::i18n::tr(lang, if n == 1 { "line" } else { "lines" })); + } + if ui + .small_button("×") + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); + }) + .clicked() + { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + lines.remove(i); + changed = true; + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New Line Style"))).clicked() { + lines.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "lines": 1})); + changed = true; + } + if changed { + d.fields.insert("p.nestedLineStyles".into(), Value::Array(lines)); + } +} + +/// GREP Styles: a character style applied to text matching each pattern (`p.` fields). +fn grep_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) { + let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "GREP Styles")).font(semibold(12.0))); + let mut list: Vec = cur(d, "p.grepStyles", &pv["grepStyles"]).as_array().cloned().unwrap_or_default(); + let mut changed = false; + let mut remove = None; + for (i, g) in list.iter_mut().enumerate() { + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Apply Style:")); + let style = g["style"].as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt(("gs_style", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) + .width(110.0) + .show_ui(ui, |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + g["style"] = json!(c); + changed = true; + } + } + }); + crate::rtl::label(ui, crate::i18n::tr(lang, "To Text:")); + let mut pat = g["pattern"].as_str().unwrap_or("").to_string(); + let bad = grep_error(&pat).is_some(); + let r = + ui.add(egui::TextEdit::singleline(&mut pat).desired_width(150.0).text_color_opt(bad.then_some(egui::Color32::from_rgb(240, 90, 90)))); + if r.changed() { + g["pattern"] = json!(pat); + changed = true; + } + if ui + .small_button("×") + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); + }) + .clicked() + { + remove = Some(i); + } + }); + if let Some(e) = grep_error(g["pattern"].as_str().unwrap_or("")) { + crate::rtl::label(ui, egui::RichText::new(e).size(11.0).color(egui::Color32::from_rgb(240, 90, 90))); + } + } + if let Some(i) = remove { + list.remove(i); + changed = true; + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(lang, "New GREP Style"))).clicked() { + list.push(json!({"style": cnames.get(1).cloned().unwrap_or_default(), "pattern": "\\d+"})); + changed = true; + } + if changed { + d.fields.insert("p.grepStyles".into(), Value::Array(list)); + } +} + /// Paragraph Style Options › Keep Options: lines kept with the next paragraph, the paragraph's /// lines kept together (all of them, or its first and last lines), and where it starts. fn keep_options(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value) { @@ -5123,6 +5243,15 @@ fn paragraph_local(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.set_min_width(480.0); match d.s("section").as_str() { "span" => span_columns(&lang, ui, d, &pv, &doc), + "keep" => keep_options(&lang, ui, d, &pv), + "nested" | "grep" => { + let cnames: Vec = doc.styles.character.iter().map(|c| c.name.clone()).collect(); + if d.s("section") == "nested" { + drop_caps_and_nested_styles(&lang, ui, d, &pv, &cnames); + } else { + grep_styles(&lang, ui, d, &pv, &cnames); + } + } "bullets" => { let (char_styles, families, fonts, scope) = ListUi::new_parts(app); let (family, style) = (d.s("family"), d.s("style")); @@ -5186,6 +5315,58 @@ pub fn open_new_style(app: &mut DesignApp, para: bool) { app.ui.dialog = Some(Dialog::new(id, json!({"new": true, "rename": name}))); } +/// The General section of the Character Style Options dialog: name, Based On, and a summary of +/// the style's settings. +fn character_style_general( + lang: &str, + ui: &mut egui::Ui, + d: &mut Dialog, + styles: &designcraft_doc::Styles, + style: &designcraft_doc::CharacterStyle, + based: &str, +) { + let (name, new) = (d.s("name"), d.b("new")); + egui::Grid::new("csg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Style Name:")); + text_field_named(ui, d, "rename", 220.0, crate::i18n::tr(lang, "Style Name")); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Based On:")); + let combo = egui::ComboBox::from_id_salt("csbased") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, based))) + .width(220.0) + .show_ui(ui, |ui| { + // Not itself, nor a style based on it. + for s in styles.character.iter().filter(|s| new || !styles.char_based_on_cycles(&name, &s.name)) { + if ui.selectable_label(s.name == based, crate::rtl::widget(ui, crate::i18n::style_name(lang, &s.name))).clicked() { + d.fields.insert("basedOn".into(), json!(s.name)); + } + } + }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(lang, "Based On")); + ui.end_row(); + }); + let status = d.s("status"); + if !status.is_empty() { + crate::rtl::label(ui, egui::RichText::new(status).color(crate::theme::Tokens::get(ui.ctx()).text_strong)); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Style Settings:")).font(semibold(12.0))); + // What OK would leave the style setting, after its Based On. + let mut own = serde_json::to_value(&style.chars).unwrap_or_default(); + if let Some(o) = own.as_object_mut() { + for (k, v) in d.fields.iter().filter_map(|(k, v)| Some((k.strip_prefix("c.")?, v))) { + if v.is_null() { + o.remove(k); + } else { + o.insert(k.to_string(), v.clone()); + } + } + } + let mut parts = vec![crate::i18n::style_name(lang, based).to_string()]; + parts.extend(own.as_object().into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(lang, k), setting_text(v)))); + ui.add(egui::Label::new(egui::RichText::new(parts.join(" + ")).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); +} + /// Character Style Options: General, then the character sections it shares with Paragraph Style /// Options. A character style sets only some attributes: the fields show those it and its Based /// On set and leave the rest blank. Edits are `c.` fields (`null` = no longer set), applied @@ -5248,46 +5429,7 @@ fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo "underline" => line_options(&lang, ui, d, &cf, &doc, "underline"), "strikethrough" => line_options(&lang, ui, d, &cf, &doc, "strikethrough"), s if style_cjk::is_section(s) => style_cjk::section_ui(app, ui, d, s, &cf, None, &doc), - _ => { - egui::Grid::new("csg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&lang, "Style Name:")); - text_field(ui, d, "rename", 220.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&lang, "Based On:")); - egui::ComboBox::from_id_salt("csbased") - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&lang, &based))) - .width(220.0) - .show_ui(ui, |ui| { - // Not itself, nor a style based on it. - for s in styles.character.iter().filter(|s| new || !styles.char_based_on_cycles(&name, &s.name)) { - if ui.selectable_label(s.name == based, crate::rtl::widget(ui, crate::i18n::style_name(&lang, &s.name))).clicked() { - d.fields.insert("basedOn".into(), json!(s.name)); - } - } - }); - ui.end_row(); - }); - let status = d.s("status"); - if !status.is_empty() { - crate::rtl::label(ui, egui::RichText::new(status).color(crate::theme::Tokens::get(ui.ctx()).text_strong)); - } - ui.add_space(8.0); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Style Settings:")).font(semibold(12.0))); - // What OK would leave the style setting, after its Based On. - let mut own = serde_json::to_value(&style.chars).unwrap_or_default(); - if let Some(o) = own.as_object_mut() { - for (k, v) in d.fields.iter().filter_map(|(k, v)| Some((k.strip_prefix("c.")?, v))) { - if v.is_null() { - o.remove(k); - } else { - o.insert(k.to_string(), v.clone()); - } - } - } - let mut parts = vec![crate::i18n::style_name(&lang, &based).to_string()]; - parts.extend(own.as_object().into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(&lang, k), setting_text(v)))); - ui.add(egui::Label::new(egui::RichText::new(parts.join(" + ")).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); - } + _ => character_style_general(&lang, ui, d, styles, &style, &based), }); }); } @@ -5808,6 +5950,158 @@ fn check_justification(d: &Dialog, prefix: &str, current: &Value) -> Result<(), Ok(()) } +/// Paragraph attributes the Hyphenation Settings dialog sets. +pub const HYPH_ATTRS: [&str; 10] = [ + "hyphenate", + "hyphMinWord", + "hyphAfterFirst", + "hyphBeforeLast", + "hyphLimit", + "hyphZone", + "hyphWeight", + "hyphCapitalized", + "hyphLastWord", + "hyphAcrossColumn", +]; + +/// InDesign's Hyphenation Settings: Hyphenate; Words with at Least, After First and Before Last +/// (letters), Hyphen Limit (hyphens) and Hyphenation Zone; the Better Spacing ↔ Fewer Hyphens +/// slider (0–10, as InDesign's HyphenWeight); Hyphenate Capitalized Words, Last Word and Across +/// Column. Edited fields are stored as `{prefix}{key}`; the others show `current` (a mixed value +/// is null and shows blank). The Hyphenation dialog for the selected paragraphs and Paragraph +/// Style Options › Hyphenation both use it. +fn hyphenation(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value, units: Unit) { + let tr = |s: &str| crate::i18n::tr(lang, s).to_string(); + let cur = |d: &Dialog, k: &str| d.fields.get(&format!("{prefix}{k}")).cloned().unwrap_or_else(|| current[k].clone()); + // Labels right-aligned against their fields, as InDesign's. + let label_right = |ui: &mut egui::Ui, text: &str| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::Label::new(crate::rtl::widget(ui, text)).wrap_mode(egui::TextWrapMode::Extend)) + }); + }; + let checkbox = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { + let v = cur(d, key); + // Mixed shows indeterminate and starts off, so the first click turns it on for all. + let mut on = v.as_bool().unwrap_or(false); + if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, tr(label))).indeterminate(v.is_null())).changed() { + d.fields.insert(format!("{prefix}{key}"), json!(on)); + } + }; + checkbox(ui, d, "hyphenate", "Hyphenate"); + ui.add_space(4.0); + egui::Grid::new(("hyph", prefix)).num_columns(3).spacing([8.0, 6.0]).show(ui, |ui| { + // InDesign's ranges. + for (label, key, unit, max) in [ + ("Words with at Least:", "hyphMinWord", "letters", 25.0), + ("After First:", "hyphAfterFirst", "letters", 15.0), + ("Before Last:", "hyphBeforeLast", "letters", 15.0), + ("Hyphen Limit:", "hyphLimit", "hyphens", 25.0), + ] { + label_right(ui, &tr(label)); + let min = if key == "hyphLimit" { 0.0 } else { 1.0 }; + if let Some(n) = crate::widgets::spin_number(ui, &format!("hy{prefix}{key}"), cur(d, key).as_f64(), "", 60.0, 0) { + d.fields.insert(format!("{prefix}{key}"), json!(n.round().clamp(min, max) as u64)); + } + crate::rtl::label(ui, tr(unit)); + ui.end_row(); + } + label_right(ui, &tr("Hyphenation Zone:")); + if let Some(n) = crate::widgets::spin_measure(ui, &format!("hy{prefix}zone"), cur(d, "hyphZone").as_f64(), units, 60.0) { + d.fields.insert(format!("{prefix}hyphZone"), json!(if n.is_finite() { n.clamp(0.0, 8640.0) } else { 36.0 })); + } + ui.end_row(); + }); + ui.add_space(6.0); + // Better Spacing ↔ Fewer Hyphens, in InDesign's eleven steps. + ui.horizontal(|ui| { + crate::rtl::label(ui, tr("Better Spacing")); + let w = cur(d, "hyphWeight").as_f64(); + let mut step = w.map_or(5.0, |w| (w * 10.0).round()).clamp(0.0, 10.0); + // Mixed across the selection: the slider shows dimmed, and a click or drag on it, even + // one that leaves the knob in place, sets every paragraph to that step. + let mixed = w.is_none(); + // The ticks go under the knob: their place in the paint order is taken first. + let ticks = ui.painter().add(egui::Shape::Noop); + let r = ui + .scope(|ui| { + ui.spacing_mut().slider_width = 180.0; + if mixed { + ui.multiply_opacity(0.4); + } + ui.add(egui::Slider::new(&mut step, 0.0..=10.0).step_by(1.0).show_value(false).handle_shape(egui::style::HandleShape::Circle)) + }) + .inner; + // The tick marks under the track. + let t = crate::theme::Tokens::get(ui.ctx()); + let inner = r.rect.shrink2(egui::vec2(r.rect.height() / 2.0, 0.0)); + let y = r.rect.center().y; + let marks: Vec = (0..=10) + .map(|i| { + let x = inner.left() + inner.width() * i as f32 / 10.0; + egui::Shape::line_segment([egui::pos2(x, y - 8.0), egui::pos2(x, y + 8.0)], egui::Stroke::new(1.0, t.text_dim)) + }) + .collect(); + ui.painter().set(ticks, egui::Shape::Vec(marks)); + if r.changed() || (mixed && (r.clicked() || r.drag_stopped())) { + d.fields.insert(format!("{prefix}hyphWeight"), json!(step / 10.0)); + } + crate::rtl::label(ui, tr("Fewer Hyphens")); + }); + ui.add_space(6.0); + egui::Grid::new(("hyphc", prefix)).num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| { + checkbox(ui, d, "hyphCapitalized", "Hyphenate Capitalized Words"); + checkbox(ui, d, "hyphLastWord", "Hyphenate Last Word"); + ui.end_row(); + checkbox(ui, d, "hyphAcrossColumn", "Hyphenate Across Column"); + ui.end_row(); + }); +} + +/// The Hyphenation dialog's edits, as `type.para` attrs. +fn hyphenation_edits(d: &Dialog) -> Map { + HYPH_ATTRS.iter().filter_map(|k| d.fields.get(*k).map(|v| (k.to_string(), v.clone()))).collect() +} + +/// Preview on: the edits show on the page (redone when a field changes) without an undo step; +/// off, the paragraphs go back. +fn hyphenation_preview(app: &mut DesignApp, d: &mut Dialog) { + let shown = d.fields.get(PREVIEWED).cloned(); + if !d.b("preview") { + if shown.is_some() { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + } + return; + } + let params = json!({"attrs": hyphenation_edits(d)}); + if shown.as_ref() == Some(¶ms) || d.fields.get(PREVIEW_FAILED) == Some(¶ms) { + return; + } + if shown.is_none() + && let Err(e) = app.session.begin_interaction("Hyphenation") + { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + match app.session.preview("type.para", params.clone()) { + Ok(_) => { + d.fields.remove("status"); + d.fields.remove(PREVIEW_FAILED); + d.fields.insert(PREVIEWED.into(), params); + } + // Rejected settings: the paragraphs go back, and the reason shows until they change. + Err(e) => { + app.session.cancel_interaction(); + d.fields.remove(PREVIEWED); + d.fields.insert("status".into(), json!(e.to_string())); + d.fields.insert(PREVIEW_FAILED.into(), params); + } + } +} + +/// The hyphenation settings a preview was refused for (not tried again until they change). +const PREVIEW_FAILED: &str = "previewFailed"; + /// The Justification controls (the Justification dialog, and the Paragraph Style Options section). /// `current` holds the resolved paragraph attributes, `null` where the selected paragraphs differ /// (the field shows empty); each edit is stored as `{prefix}{attr}`, so OK changes only the fields @@ -6436,7 +6730,7 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style); ui.end_row(); crate::rtl::label(ui, tr("Between Entry and Number:")); - ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).horizontal_align(egui::Align::Min)); + ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).align(egui::Align2::LEFT_TOP)); crate::rtl::label(ui, tr("Style:")); toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style); ui.end_row(); @@ -6715,7 +7009,7 @@ fn color_picker(ui: &mut egui::Ui, d: &mut Dialog) { let mut cmyk = nums::<4>(d, "cmyk").unwrap_or_else(|| cmyk_of(rgb)); let mut model = if d.s("model") == "cmyk" || (!d.fields.contains_key("model") && d.fields.contains_key("cmyk")) { "cmyk" } else { "rgb" }; let mut edit = ColorEdit::None; - let hex_id = egui::Id::new("color_picker_hex"); + let hex_id = egui::Id::unique("color_picker_hex"); ui.horizontal_top(|ui| { let mut c = egui::Color32::from_rgb(to8(rgb[0]), to8(rgb[1]), to8(rgb[2])); let shown = c; @@ -6729,7 +7023,9 @@ fn color_picker(ui: &mut egui::Ui, d: &mut Dialog) { for (v, l) in cmyk.iter_mut().zip(["C", "M", "Y", "K"]) { if let Some((rv, rl)) = rgb_rows.next() { ui.label(rl); - if ui.add(egui::DragValue::new(rv).range(0.0..=255.0).max_decimals(1)).changed() { + // The channel letter is a label beside a bare spinner: name the spinner. + let field = ui.add(egui::DragValue::new(rv).range(0.0..=255.0).max_decimals(1)).accessible_name(rl.to_owned()); + if field.changed() { edit = ColorEdit::Rgb; } } else { @@ -6737,7 +7033,8 @@ fn color_picker(ui: &mut egui::Ui, d: &mut Dialog) { ui.label(""); } ui.label(l); - if ui.add(egui::DragValue::new(v).range(0.0..=100.0).max_decimals(1).suffix("%")).changed() { + let field = ui.add(egui::DragValue::new(v).range(0.0..=100.0).max_decimals(1).suffix("%")).accessible_name(l.to_owned()); + if field.changed() { edit = ColorEdit::Cmyk; } ui.end_row(); @@ -6748,7 +7045,8 @@ fn color_picker(ui: &mut egui::Ui, d: &mut Dialog) { ui.label("#"); let editing = ui.memory(|m| m.has_focus(hex_id)); let mut h = if editing { d.s("hexEdit") } else { format!("{:02x}{:02x}{:02x}", to8(rgb[0]), to8(rgb[1]), to8(rgb[2])) }; - if ui.add(egui::TextEdit::singleline(&mut h).id(hex_id).desired_width(70.0)).changed() + let hex_field = ui.add(egui::TextEdit::singleline(&mut h).id(hex_id).desired_width(70.0)).accessible_name("Hex".to_owned()); + if hex_field.changed() && let Some(v) = hex_rgb(&h) { rgb = v; @@ -7602,6 +7900,35 @@ mod tests { assert_eq!(props.number_label(3), "III)\t"); } + #[test] + fn paragraph_style_options_ignore_optical_margin_is_set_on_ok() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Flush"})).unwrap(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Flush", "section": "indents"}))); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::unique("ignore_optical_margin_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Ignore Optical Margin").click(); + h.run_steps(3); + h.get_by_label(" OK ").click(); + h.run_steps(3); + let app = h.state(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.styles.para("Flush").unwrap().para.ignore_optical_margin, Some(true)); + } + #[test] fn paragraph_style_options_keep_a_bad_grep_pattern_open_with_the_error() { let (mut app, _) = ruled_paragraph(); @@ -7633,6 +7960,146 @@ mod tests { app.run("type.selectionAttrs", json!({})).unwrap()["para"][key].clone() } + /// The hyphenation video (Graphd Tutorials, 0:39–1:13): the Paragraph panel menu's + /// Hyphenation… opens InDesign's Hyphenation Settings for the selected paragraphs. Preview + /// shows the slider moved to Fewer Hyphens on the page without an undo step, OK makes it one + /// step on the selected paragraph only, and Cancel puts it back. + #[test] + fn hyphenation_settings_preview_apply_and_cancel_on_the_selected_paragraph() { + let (mut app, story) = two_paragraphs(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + let sid = designcraft_doc::StoryId(story.as_u64().unwrap()); + let weight = |app: &DesignApp, para: usize| { + let d = &app.session.doc().unwrap().doc; + d.styles.resolve_para(&d.story(sid).unwrap().paras[para]).0.hyph_weight + }; + assert!(crate::menus::menu_enabled(&app, "app.hyphenationDialog")); + app.run("app.hyphenationDialog", json!({})).unwrap(); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!( + (d.id.as_str(), d.fields["current"]["hyphWeight"].as_f64(), d.fields["current"]["hyphenate"].as_bool()), + ("hyphenation", Some(0.5), Some(true)) + ); + for label in [ + "Hyphenation Settings", + "Hyphenate", + "Words with at Least:", + "letters", + "Hyphen Limit:", + "hyphens", + "Hyphenation Zone:", + "Better Spacing", + "Fewer Hyphens", + "Hyphenate Capitalized Words", + "Hyphenate Last Word", + "Hyphenate Across Column", + "Preview", + ] { + assert!(labels.iter().any(|(t, _, _)| t == label), "{label}: {labels:?}"); + } + // Fewer Hyphens (9 of 10) with Preview: on the page, no undo step. + let before = undo_steps(&app); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("hyphWeight".into(), json!(0.9)); + d.fields.insert("preview".into(), json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert_eq!(weight(&app, 0), 0.9, "previewed"); + assert_eq!(undo_steps(&app), before, "a preview is no step"); + // Cancel: back as it was. + cancel(&mut app); + assert_eq!(weight(&app, 0), 0.5); + // Again, and OK: one step, the selected paragraph only. + app.run("app.hyphenationDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("hyphWeight".into(), json!(0.9)); + d.fields.insert("preview".into(), json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + confirm(&mut app).unwrap(); + assert_eq!((weight(&app, 0), weight(&app, 1)), (0.9, 0.5)); + assert_eq!(undo_steps(&app), before + 1, "one undo step"); + // Paragraph Style Options › Hyphenation has the same form. + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": designcraft_doc::story::BASIC_PARAGRAPH, "section": "hyph"}))); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for label in ["Hyphenation Zone:", "Better Spacing", "Fewer Hyphens", "Hyphenate Last Word", "Hyphenate Across Column"] { + assert!(labels.iter().any(|(t, _, _)| t == label), "style options: {label}"); + } + app.ui.dialog = None; + app.run("edit.deselectAll", json!({})).unwrap(); + assert!(!crate::menus::menu_enabled(&app, "app.hyphenationDialog"), "no paragraph"); + } + + /// Hyphenate differs across the selection: it shows mixed, and the first click turns it on for + /// all. Settings the command refuses show why: a preview goes back and isn't retried until they + /// change, and OK keeps the dialog open. + /// A mixed Better Spacing ↔ Fewer Hyphens slider: a click on its middle step sets every + /// selected paragraph to it, though the knob already sat there. + #[test] + fn hyphenation_settings_mixed_slider_click_sets_the_step() { + let (mut app, story) = two_paragraphs(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + app.run("type.para", json!({"attrs": {"hyphWeight": 0.2}})).unwrap(); + app.run("text.select", json!({"story": story, "anchor": 0, "focus": 7})).unwrap(); + app.run("app.hyphenationDialog", json!({})).unwrap(); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let rect = |l: &str| labels.iter().find(|(t, _, _)| t == l).map(|(_, r, _)| *r).unwrap(); + let (better, fewer) = (rect("Better Spacing"), rect("Fewer Hyphens")); + click_dialog(&mut app, &ctx, screen, egui::pos2((better.right() + fewer.left()) / 2.0, better.center().y)); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("hyphWeight"), Some(&json!(0.5))); + } + + #[test] + fn hyphenation_settings_mixed_checkbox_and_refused_settings() { + let (mut app, story) = two_paragraphs(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)); + app.run("type.para", json!({"attrs": {"hyphenate": false}})).unwrap(); + app.run("text.select", json!({"story": story, "anchor": 0, "focus": 7})).unwrap(); + app.run("app.hyphenationDialog", json!({})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().fields["current"]["hyphenate"].is_null(), "mixed"); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let at = labels.iter().find(|(t, _, _)| t == "Hyphenate").map(|(_, r, _)| r.left_center() - egui::vec2(10.0, 0.0)).unwrap(); + click_dialog(&mut app, &ctx, screen, at); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("hyphenate"), Some(&json!(true)), "the first click turns it on"); + // A refused setting (as a control-channel caller could send it): no preview, the reason. + let before = undo_steps(&app); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("hyphWeight".into(), json!("heavy")); + d.fields.insert("preview".into(), json!(true)); + dialog_frame(&mut app, &ctx, screen, vec![]); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(!d.fields.contains_key(PREVIEWED) && d.fields.contains_key("status"), "{:?}", d.fields); + assert!(app.session.doc().unwrap().interaction.is_none(), "the preview went back"); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(app.session.doc().unwrap().interaction.is_none(), "not tried again"); + // OK with it: an error, and the dialog stays open with the reason. + assert!(confirm(&mut app).is_err()); + let d = app.ui.dialog.as_ref().expect("the dialog stays open"); + assert!(d.fields.contains_key("status")); + assert_eq!(undo_steps(&app), before); + // Corrected, it previews and applies. + app.ui.dialog.as_mut().unwrap().fields.insert("hyphWeight".into(), json!(0.9)); + dialog_frame(&mut app, &ctx, screen, vec![]); + assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("status")); + confirm(&mut app).unwrap(); + assert_eq!(para_attr(&mut app, &story, 0, "hyphenate"), true); + assert_eq!(para_attr(&mut app, &story, 4, "hyphWeight"), 0.9); + } + #[test] fn justification_dialog_applies_to_a_local_paragraph_and_cancel_changes_nothing() { let (mut app, story) = two_paragraphs(); @@ -7799,7 +8266,7 @@ mod tests { open_style_options(&mut app, true, "Tutorial"); let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("style_preview_remember_fonts"); + let key = egui::Id::unique("style_preview_remember_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); @@ -7834,7 +8301,7 @@ mod tests { open_style_options(&mut app, paragraph, "Tutorial"); let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("style_preview_test_fonts"); + let key = egui::Id::unique("style_preview_test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); @@ -7975,6 +8442,35 @@ mod tests { assert_eq!((opts(&app).columns_kind, opts(&app).column_width), (designcraft_doc::ColumnsKind::FixedNumber, 120.0)); } + #[test] + fn text_frame_options_set_a_custom_baseline_grid_relative_to() { + use designcraft_doc::{FrameBaselineGrid, FrameGridRelative}; + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let grid = |app: &DesignApp| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.baseline_grid; + let open = |app: &mut DesignApp, fields: &[(&str, Value)]| { + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(app, &mut d); + for (k, v) in fields { + d.fields.insert((*k).into(), v.clone()); + } + app.ui.dialog = Some(d); + confirm(app) + }; + app.run("selection.set", json!({"ids": [id.0]})).unwrap(); + // Turned on, the grid takes the shown start and increment (the document grid's: 3p0 and 1p0). + open(&mut app, &[("customGrid", json!(true)), ("gridRelativeTo", json!("frame"))]).unwrap(); + assert_eq!(grid(&app), Some(FrameBaselineGrid { start: 36.0, increment: 12.0, relative_to: FrameGridRelative::TopOfFrame })); + // The dialog shows the frame's grid; changing Relative To keeps its start and increment. + open(&mut app, &[("gridStart", json!("0p10"))]).unwrap(); + open(&mut app, &[("gridRelativeTo", json!("inset"))]).unwrap(); + assert_eq!(grid(&app), Some(FrameBaselineGrid { start: 10.0, increment: 12.0, relative_to: FrameGridRelative::TopInset })); + open(&mut app, &[("customGrid", json!(false))]).unwrap(); + assert_eq!(grid(&app), None); + } + #[test] fn text_frame_options_change_only_what_was_edited() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -8232,7 +8728,13 @@ mod tests { screen, vec![ egui::Event::PointerMoved(pos), - egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }, + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta, + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }, ], ); // Let smooth wheel scrolling and scrollbar visibility settle before clicking. @@ -9330,6 +9832,31 @@ mod tests { assert_eq!(st.doc.styles.para(&name).unwrap().para.align, Some(designcraft_doc::Align::Center)); } + #[test] + fn paragraph_style_options_align_to_grid_shows_and_sets_the_style_grid_alignment() { + use designcraft_doc::GridAlign; + use egui_kittest::kittest::Queryable; + let mut app = test_app(); + app.session.execute("style.paragraph.create", &json!({"name": "Grid Body", "para": {"gridAlign": "firstLineOnly"}})).unwrap(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Grid Body", "section": "indents"}))); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + let grid = |h: &egui_kittest::Harness<'static, crate::test_window::Window>| { + h.state().app.session.active().unwrap().doc.styles.para("Grid Body").unwrap().para.grid_align + }; + h.run_steps(4); + h.get_by_value("First Line Only"); + confirm(&mut h.state_mut().app).unwrap(); + assert_eq!(grid(&h), Some(GridAlign::FirstLineOnly), "OK without edits keeps the style's alignment"); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Grid Body", "section": "indents"}))); + h.run_steps(4); + h.get_by_value("First Line Only").click(); + h.run_steps(3); + h.get_by_label("All Lines").click(); + h.run_steps(3); + confirm(&mut h.state_mut().app).unwrap(); + assert_eq!(grid(&h), Some(GridAlign::AllLines)); + } + #[test] fn delete_paragraph_style_dialog_removes_the_style_and_reassigns_text() { let mut app = test_app(); @@ -9811,7 +10338,7 @@ mod tests { h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(3); } - let rect = |h: &Harness<'static, DesignApp>| h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog"))).unwrap(); + let rect = |h: &Harness<'static, DesignApp>| h.ctx.memory(|m| m.area_rect(egui::Id::unique("dialog"))).unwrap(); let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); app.ui.dialog = Some(Dialog::new("insertTable", Value::Null)); @@ -9864,7 +10391,7 @@ mod tests { fn the_new_document_window_drags_mostly_aside_and_keeps_its_preview() { use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::{Harness, kittest::Queryable}; - let rect = |h: &Harness<'static, DesignApp>| h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog"))).unwrap(); + let rect = |h: &Harness<'static, DesignApp>| h.ctx.memory(|m| m.area_rect(egui::Id::unique("dialog"))).unwrap(); let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"title": "A"})).unwrap(); app.ui.new_document_preview = true; diff --git a/crates/ui-egui/src/dialogs/options.rs b/crates/ui-egui/src/dialogs/options.rs index d8aba2bea..4f192cd7d 100644 --- a/crates/ui-egui/src/dialogs/options.rs +++ b/crates/ui-egui/src/dialogs/options.rs @@ -3,7 +3,7 @@ use designcraft_color::{Color, ColorType, SwatchValue}; use serde_json::{Map, Value, json}; -use super::{Dialog, check, text_field}; +use super::{Dialog, check, text_field_named}; use crate::DesignApp; use crate::i18n::tr; @@ -97,7 +97,11 @@ fn caption(ui: &mut egui::Ui, lang: &str, s: &str) { fn channel(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, max: f64, suffix: &str) { ui.label(egui::RichText::new(label).monospace()); let mut v = d.n(key).unwrap_or(0.0); - if ui.add(egui::DragValue::new(&mut v).range(0.0..=max).speed(0.5).max_decimals(1).suffix(suffix)).changed() { + let resp = ui.add(egui::DragValue::new(&mut v).range(0.0..=max).speed(0.5).max_decimals(1).suffix(suffix)); + let changed = resp.changed(); + // The channel name is a painted label beside a bare spinner: name the spinner too. + let _ = resp.accessible_name(label.to_owned()); + if changed { d.fields.insert(key.into(), json!(v)); } ui.end_row(); @@ -112,7 +116,7 @@ pub fn swatch_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } egui::Grid::new("swatch_options").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { caption(ui, lang, "Swatch Name:"); - ui.add_enabled_ui(!d.b("locked") && !auto, |ui| text_field(ui, d, "swatchName", 220.0)); + ui.add_enabled_ui(!d.b("locked") && !auto, |ui| text_field_named(ui, d, "swatchName", 220.0, tr(lang, "Swatch Name"))); ui.end_row(); }); if kind == "color" { @@ -125,7 +129,7 @@ pub fn swatch_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if kind == "color" { caption(ui, lang, "Color Type:"); let cur = d.s("colorType"); - egui::ComboBox::from_id_salt("swatch_color_type") + let combo = egui::ComboBox::from_id_salt("swatch_color_type") .selected_text(crate::rtl::widget(ui, tr(lang, if cur == "spot" { "Spot" } else { "Process" }))) .show_ui(ui, |ui| { for (v, l) in [("process", "Process"), ("spot", "Spot")] { @@ -134,6 +138,7 @@ pub fn swatch_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } }); + let _ = crate::widgets::name_combo(combo, tr(lang, "Color Type")); ui.end_row(); } caption(ui, lang, "Color Mode:"); @@ -143,16 +148,20 @@ pub fn swatch_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { "gray" => "Gray", _ => "CMYK", }; - egui::ComboBox::from_id_salt("swatch_color_mode").selected_text(crate::rtl::widget(ui, tr(lang, label(&cur)))).show_ui(ui, |ui| { - for m in ["cmyk", "rgb", "gray"] { - if ui.selectable_label(cur == m, crate::rtl::widget(ui, tr(lang, label(m)))).clicked() - && cur != m - && let Some(c) = dialog_color(&d.fields) - { - set_color(&mut d.fields, c, Some(m)); + let combo = egui::ComboBox::from_id_salt("swatch_color_mode").selected_text(crate::rtl::widget(ui, tr(lang, label(&cur)))).show_ui( + ui, + |ui| { + for m in ["cmyk", "rgb", "gray"] { + if ui.selectable_label(cur == m, crate::rtl::widget(ui, tr(lang, label(m)))).clicked() + && cur != m + && let Some(c) = dialog_color(&d.fields) + { + set_color(&mut d.fields, c, Some(m)); + } } - } - }); + }, + ); + let _ = crate::widgets::name_combo(combo, tr(lang, "Color Mode")); ui.end_row(); match d.s("mode").as_str() { "rgb" => { @@ -182,7 +191,7 @@ pub fn swatch_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.end_row(); caption(ui, lang, "Tint:"); let mut v = d.n("tint").unwrap_or(100.0); - if ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%").integer()).changed() { + if ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%").integer()).accessible_name(tr(lang, "Tint")).changed() { d.fields.insert("tint".into(), json!(v)); } ui.end_row(); @@ -239,7 +248,7 @@ pub fn open_color_group_options(app: &mut DesignApp, name: &str) -> Result<(), S pub fn color_group_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { caption(ui, &app.ui.language, "Name:"); - text_field(ui, d, "groupName", 220.0); + text_field_named(ui, d, "groupName", 220.0, tr(&app.ui.language, "Name")); }); status(ui, d); } @@ -269,11 +278,11 @@ pub fn layer_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let named = designcraft_doc::LAYER_COLORS.iter().find(|(_, c)| *c == rgb).map(|(n, _)| *n); egui::Grid::new("layer_options").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { caption(ui, lang, "Name:"); - text_field(ui, d, "name", 220.0); + text_field_named(ui, d, "name", 220.0, tr(lang, "Name")); ui.end_row(); caption(ui, lang, "Color:"); ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("layer_color") + let combo = egui::ComboBox::from_id_salt("layer_color") .selected_text(crate::rtl::widget(ui, tr(lang, named.unwrap_or("Custom")))) .width(140.0) .show_ui(ui, |ui| { @@ -287,6 +296,7 @@ pub fn layer_options(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } }); + let _ = crate::widgets::name_combo(combo, tr(lang, "Color")); super::color_field(ui, d, "rgb"); }); ui.end_row(); @@ -326,7 +336,7 @@ mod tests { let mut h = Harness::builder().with_size(vec2(360.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( move |ui, app: &mut DesignApp| { // The fonts are installed before the first frame that draws. - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d4551e813..aa8428469 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -23,6 +23,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("typography", "Typography", "panel-typography"), ("glyphs", "Glyphs", "panel-character"), ("paragraph", "Paragraph", "panel-paragraph"), + ("story", "Story", "panel-text-frame"), ("textWrap", "Text Wrap", "panel-wrap"), ("table", "Table", "panel-table"), ("align", "Align", "panel-align"), @@ -90,11 +91,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.painter().galley(egui::pos2(r.min.x + 13.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); - let response = craft_ui::tabs::Tab::new(ui.id().with(("docktab", *id)), label, active).sense(Sense::click_and_drag()).show_at( - ui, - r, - |_, _| {}, - ); + let response = craft_ui::tabs::Tab::new(ui.scope_id().with(("docktab", *id)), label, active) + .sense(Sense::click_and_drag()) + .show_at(ui, r, |_, _| {}); if response.clicked() { app.ui.dock_tab = id.to_string(); } @@ -102,7 +101,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { x += w; } let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - let menu = ui.interact(menu_r, ui.id().with("dock-menu"), Sense::click()); + let menu = ui.interact(menu_r, ui.scope_id().with("dock-menu"), Sense::click()); if menu.hovered() { ui.painter().rect_filled(menu_r, 3.0, t.hover); } @@ -189,7 +188,7 @@ fn panel_entry(id: &str) -> Option<(&'static str, &'static str, &'static str)> { fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egui::Response) { if response.clicked() { if app.ui.floating.iter().any(|(p, _)| p == id) { - ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", id)))); + ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::unique(("floating_panel", id)))); } else { let open = app.ui.open_panel.as_deref() == Some(id); app.ui.open_panel = if open { None } else { Some(id.to_string()) }; @@ -216,10 +215,10 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned(); let screen = ctx.content_rect(); - let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::new("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 }; + let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::unique("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 }; let pos = egui::pos2(screen.max.x - icon_column_width(app) - dock_w - 262.0, 120.0); let mut open = true; - egui::Area::new(egui::Id::new("panel_flyout")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("panel_flyout")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(0)).show(ui, |ui| { ui.set_width(256.0); // A previous shorter flyout must not constrain the next panel's scroll viewport. @@ -228,18 +227,18 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { ui.painter().rect_filled(strip, 0.0, t.panel_darker); crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 13.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong); let close = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - if ui.interact(close, ui.id().with("flyclose"), Sense::click()).clicked() { + if ui.interact(close, ui.scope_id().with("flyclose"), Sense::click()).clicked() { open = false; } ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim); // Float: the button beside the close box, or drag the header away from the dock. let float_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - let fr = ui.interact(float_r, ui.id().with("flyfloat"), Sense::click()).on_hover_ui(|ui| { + let fr = ui.interact(float_r, ui.scope_id().with("flyfloat"), Sense::click()).on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Float panel")); }); float_glyph(ui.painter(), float_r, t.text_dim); let head = egui::Rect::from_min_max(strip.min, egui::pos2(float_r.min.x - 2.0, strip.max.y)); - let hr = ui.interact(head, ui.id().with("flyhead"), Sense::drag()); + let hr = ui.interact(head, ui.scope_id().with("flyhead"), Sense::drag()); let torn = hr.drag_stopped() && hr.total_drag_delta().is_some_and(|d| d.length() > 24.0); if fr.clicked() || torn { let at = if torn { ui.ctx().pointer_latest_pos().unwrap_or(pos) - vec2(20.0, 10.0) } else { pos - vec2(30.0, -30.0) }; @@ -285,6 +284,7 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { "typography" => panels::typography::show(app, ui), "glyphs" => panels::glyphs::show(app, ui), "paragraph" => panels::properties::paragraph_panel(app, ui), + "story" => panels::story::show(app, ui), "textWrap" => panels::properties::wrap_panel(app, ui), "align" => panels::properties::align_panel(app, ui), "pathfinder" => panels::properties::pathfinder_panel(app, ui), @@ -359,7 +359,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { let mut open = true; let mut dock = false; let mut w = egui::Window::new(label.clone()) - .id(egui::Id::new(("floating_panel", &id))) + .id(egui::Id::unique(("floating_panel", &id))) .title_bar(false) .default_pos(egui::pos2(at[0], at[1])) .default_width(if id == "tabs" { 560.0 } else { 256.0 }) @@ -378,7 +378,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 12.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong); let close = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 3.0), vec2(18.0, 18.0)); if ui - .interact(close, ui.id().with("fclose"), Sense::click()) + .interact(close, ui.scope_id().with("fclose"), Sense::click()) .on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Close")); }) @@ -390,7 +390,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { let dock_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 3.0), vec2(18.0, 18.0)); if docks && ui - .interact(dock_r, ui.id().with("fdock"), Sense::click()) + .interact(dock_r, ui.scope_id().with("fdock"), Sense::click()) .on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Dock panel")); }) @@ -470,7 +470,7 @@ mod visual_regressions { harness.run_steps(4); let stem = format!("designcraft-dock-{}-{width}-{scale}x", brightness.id()); harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); - let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("dock")).unwrap().outer_rect; + let panel = egui::containers::panel::PanelState::load(&harness.ctx, harness.ui_id().with("dock")).unwrap().outer_rect; let region = serde_json::json!({ "screen": [width, 640.0], "scale": scale, "regions": {"dock-tabs": [panel.left(), panel.top(), panel.width(), 45.0]} @@ -540,7 +540,8 @@ mod chevron_tests { /// The » / « chevron painted at the right end of a panel's 11 pt header. fn chevron(harness: &Harness<'_, DesignApp>, panel: &str) -> egui::Pos2 { - let r = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(panel)).expect("the panel is laid out").outer_rect; + // A panel is keyed by the parent Ui the app draws in (see `test_window::panel_rect`). + let r = egui::containers::panel::PanelState::load(&harness.ctx, harness.ui_id().with(panel)).expect("the panel is laid out").outer_rect; egui::pos2(r.max.x - 10.0, r.min.y + 5.0) } diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index 456ca4a58..bcfe4b26a 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -116,10 +116,17 @@ fn is_on(fx: &Value, page: &str) -> bool { /// The `object.effects` params the dialog stands for. pub fn params(d: &Dialog) -> Value { - json!({"ids": d.fields.get("ids").cloned().unwrap_or(json!([])), "effects": d.fields.get("fx").cloned().unwrap_or(json!({})), - "opacity": d.fields.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), "blend": d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal")}) + let mut p = json!({"ids": d.fields.get("ids").cloned().unwrap_or(json!([])), "effects": d.fields.get("fx").cloned().unwrap_or(json!({})), + "opacity": d.fields.get("opacity").and_then(Value::as_f64).unwrap_or(1.0), "blend": d.fields.get("blend").and_then(Value::as_str).unwrap_or("Normal")}); + if let Some(a) = d.fields.get(GLOBAL_LIGHT).and_then(Value::as_f64) { + p["globalLight"] = json!(a); + } + p } +/// The global light angle as changed in the dialog (an Angle field with Use Global Light on). +const GLOBAL_LIGHT: &str = "globalLight"; + /// Apply: the preview goes and the settings are made once, as one undo step. pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { if d.fields.contains_key(PREVIEWED) { @@ -202,12 +209,19 @@ fn percent(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str]) { } } -fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], enabled: bool, global: f64) { - let mut v = if enabled { num(d, path) } else { global }; - let r = ui.add_enabled(enabled, egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)); +/// The effect's own angle at `path`, or with Use Global Light (`own` false) the document's +/// global light angle: editing it then changes that one, for every effect that uses it. +fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], own: bool, global: f64) -> egui::Response { + let mut v = if own { num(d, path) } else { global }; + let r = ui.add(egui::DragValue::new(&mut v).range(-180.0..=180.0).suffix("°").max_decimals(1)); if r.changed() { - set(d, path, json!(v)); + if own { + set(d, path, json!(v)); + } else { + d.fields.insert(GLOBAL_LIGHT.into(), json!(v)); + } } + r } fn flag(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], label: &str) { @@ -273,7 +287,9 @@ fn page_ui(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, page: &str) { let lang = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); let unit = app.session.active().map_or(Unit::Points, |st| st.doc.settings.horizontal_units); - let global = app.session.active().map_or(120.0, |st| st.doc.settings.global_light); + // The global light angle as the dialog will set it (an edit in it shows at once). + let global = + d.fields.get(GLOBAL_LIGHT).and_then(Value::as_f64).unwrap_or_else(|| app.session.active().map_or(120.0, |st| st.doc.settings.global_light)); let row = |ui: &mut egui::Ui, l: &str| crate::rtl::label(ui, tr(l)); crate::rtl::label(ui, egui::RichText::new(tr(PAGES.iter().find(|p| p.0 == page).map_or("Transparency", |p| p.1))).font(semibold(14.0))); let grid = |id: &str| egui::Grid::new(("fx_grid", id.to_string())).num_columns(4).spacing([8.0, 6.0]); @@ -657,6 +673,32 @@ mod tests { texts } + /// With Use Global Light on, the Angle is the document's global light and stays editable: + /// a change there is applied to the document with the effects (it was greyed out). + #[test] + fn the_angle_with_global_light_sets_the_global_light() { + let (mut app, _) = app_with_frame(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + crate::menus::activate(&mut app, "object.dropShadow", &Value::Null); + let d = app.ui.dialog.as_mut().unwrap(); + set(d, &["dropShadow", "globalLight"], json!(true)); + frame(&mut app, &ctx); + // The field shows the global angle and is enabled: a DragValue that isn't disabled. + let global = app.session.doc().unwrap().doc.settings.global_light; + let mut d = app.ui.dialog.clone().unwrap(); + let ui_ctx = egui::Context::default(); + let mut enabled = false; + let mut out = ui_ctx.run_ui(egui::RawInput::default(), |ui| { + enabled = angle(ui, &mut d, &["dropShadow", "angle"], false, global).enabled(); + }); + out.textures_delta.clear(); + assert!(enabled, "the Angle stays editable with Use Global Light"); + d.fields.insert(GLOBAL_LIGHT.into(), json!(15.0)); + confirm(&mut app, &d).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.settings.global_light, 15.0); + } + #[test] fn set_makes_the_path_and_ignores_an_empty_one() { let mut d = Dialog::new(ID, json!({"fx": {}})); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index be5f43c97..19e9d911d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -46,6 +46,71 @@ pub fn picker_languages() -> Vec<(&'static str, &'static str)> { /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ( + "Character Formatting Controls", + [ + "Steuerelemente für Zeichenformatierung", + "Commandes de mise en forme des caractères", + "Controles de formato de carácter", + "文字形式コントロール", + "字符格式控制", + ], + ), + ( + "Paragraph Formatting Controls", + [ + "Steuerelemente für Absatzformatierung", + "Commandes de mise en forme des paragraphes", + "Controles de formato de párrafo", + "段落形式コントロール", + "段落格式控制", + ], + ), + ("Font Size", ["Schriftgrad", "Corps", "Tamaño de fuente", "フォントサイズ", "字体大小"]), + ("Vertical Scale", ["Vertikal skalieren", "Échelle verticale", "Escala vertical", "垂直比率", "垂直缩放"]), + ("Horizontal Scale", ["Horizontal skalieren", "Échelle horizontale", "Escala horizontal", "水平比率", "水平缩放"]), + ("Baseline Shift", ["Grundlinienversatz", "Décalage vertical", "Desplazamiento vertical", "ベースラインシフト", "基线偏移"]), + ( + "Skew (False Italic)", + ["Verzerren (falscher Kursivstil)", "Inclinaison (faux italique)", "Sesgar (cursiva falsa)", "歪み(斜体)", "倾斜(伪斜体)"], + ), + ("Left Indent", ["Einzug links", "Retrait gauche", "Sangría izquierda", "左インデント", "左缩进"]), + ("Right Indent", ["Einzug rechts", "Retrait droit", "Sangría derecha", "右インデント", "右缩进"]), + ( + "First Line Left Indent", + ["Einzug links in erster Zeile", "Retrait gauche de première ligne", "Sangría izquierda en primera línea", "1行目左インデント", "首行左缩进"], + ), + ( + "Last Line Right Indent", + ["Einzug rechts in letzter Zeile", "Retrait droit de dernière ligne", "Sangría derecha en última línea", "最終行右インデント", "末行右缩进"], + ), + ( + "Drop Cap Number of Lines", + ["Initiale: Anzahl der Zeilen", "Lettrine (nombre de lignes)", "Capitular: número de líneas", "ドロップキャップの行数", "首字下沉行数"], + ), + ( + "Drop Cap One or More Characters", + [ + "Initiale: ein oder mehrere Zeichen", + "Lettrine (un ou plusieurs caractères)", + "Capitular: uno o más caracteres", + "ドロップキャップの文字数", + "首字下沉一个或多个字符", + ], + ), + ( + "Align to Baseline Grid", + [ + "An Grundlinienraster ausrichten", + "Aligner sur la grille de ligne de base", + "Alinear con cuadrícula base", + "ベースライングリッドに揃える", + "对齐基线网格", + ], + ), + // Properties › Paragraph (•••). + ("Space Before", ["Abstand davor", "Espace avant", "Espacio anterior", "段落前の間隔", "段前间距"]), + ("Space After", ["Abstand danach", "Espace après", "Espacio posterior", "段落後の間隔", "段后间距"]), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), @@ -135,6 +200,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Previous Page", ["Vorherige Seite", "Page précédente", "Página anterior", "前のページ", "上一页"]), ("Next Page", ["Nächste Seite", "Page suivante", "Página siguiente", "次のページ", "下一页"]), ("Last Page", ["Letzte Seite", "Dernière page", "Última página", "最後のページ", "最后一页"]), + ("Go Back", ["Zurück gehen", "Revenir", "Retroceder", "前に戻る", "后退"]), + ("Go Forward", ["Vorwärts gehen", "Avancer", "Avanzar", "次に進む", "前进"]), ( "Close Tab Group", ["Registerkartengruppe schließen", "Fermer le groupe d'onglets", "Cerrar grupo de fichas", "タブグループを閉じる", "关闭选项卡组"], @@ -1010,6 +1077,102 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]), + ("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]), + ("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]), + ("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]), + ( + "Space Between Paragraphs Using Same Style", + [ + "Abstand zwischen Absätzen mit gleichem Format", + "Espace entre les paragraphes de même style", + "Espacio entre párrafos con el mismo estilo", + "同じスタイルの段落間のスペース", + "使用相同样式的段落间距", + ], + ), + ("Ignore", ["Ignorieren", "Ignorer", "Ignorar", "無視", "忽略"]), + ( + "Do Not Align to Baseline Grid", + [ + "Nicht am Grundlinienraster ausrichten", + "Ne pas aligner sur la grille de ligne de base", + "No alinear con cuadrícula base", + "ベースライングリッドに揃えない", + "不对齐基线网格", + ], + ), + ( + "Only Align First Line to Grid", + [ + "Nur erste Zeile am Raster ausrichten", + "Aligner uniquement la première ligne sur la grille", + "Alinear solo primera línea con cuadrícula", + "最初の行のみグリッドに揃える", + "仅首行对齐网格", + ], + ), + ( + "Balance Ragged Lines", + ["Unausgeglichene Zeilen ausgleichen", "Équilibrer les lignes irrégulières", "Equilibrar líneas desiguales", "行末を調整", "平衡未对齐的行"], + ), + ( + "Drop Caps and Nested Styles…", + [ + "Initialen und verschachtelte Formate …", + "Lettrines et styles imbriqués…", + "Capitulares y estilos anidados…", + "ドロップキャップと先頭文字スタイル…", + "首字下沉和嵌套样式…", + ], + ), + ("GREP Styles…", ["GREP-Stile …", "Styles GREP…", "Estilos GREP…", "正規表現スタイル…", "GREP 样式…"]), + ( + "Restart/Continue Numbering", + [ + "Nummerierung neu beginnen/fortführen", + "Recommencer/Poursuivre la numérotation", + "Reiniciar/Continuar numeración", + "番号の振り直し/継続", + "重新开始/继续编号", + ], + ), + ( + "Convert Bullets and Numbering to Text", + [ + "Aufzählungszeichen und Nummerierung in Text konvertieren", + "Convertir les puces et la numérotation en texte", + "Convertir viñetas y numeración en texto", + "箇条書きと段落番号をテキストに変換", + "将项目符号和编号转换为文本", + ], + ), + ("Hyphenation…", ["Silbentrennung …", "Césure…", "Separación por sílabas…", "ハイフネーション…", "连字…"]), + ( + "Hyphenation Settings", + ["Silbentrennungseinstellungen", "Paramètres de césure", "Configuración de separación por sílabas", "ハイフネーション設定", "连字设置"], + ), + ("hyphens", ["Trennstriche", "tirets", "guiones", "ハイフン", "连字符"]), + ("Hyphenation Zone:", ["Silbentrennungsbereich:", "Zone de césure :", "Zona de separación:", "ハイフネーション領域 :", "连字区:"]), + ("Better Spacing", ["Bessere Abstände", "Meilleur espacement", "Mejor espaciado", "スペースを優先", "更好的间距"]), + ("Fewer Hyphens", ["Weniger Trennstriche", "Moins de tirets", "Menos guiones", "ハイフンを減らす", "更少的连字符"]), + ( + "Hyphenate Capitalized Words", + [ + "Großgeschriebene Wörter trennen", + "Césure des mots en capitales", + "Separar palabras en mayúscula", + "大文字で始まる単語をハイフネーション", + "连字大写的单词", + ], + ), + ( + "Hyphenate Last Word", + ["Letztes Wort trennen", "Césure du dernier mot", "Separar última palabra", "最後の単語をハイフネーション", "连字最后一个单词"], + ), + ( + "Hyphenate Across Column", + ["Über Spalten trennen", "Césure entre colonnes", "Separar entre columnas", "段をまたいでハイフネーション", "跨栏连字"], + ), ( "Differs from this document", ["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"], @@ -1613,6 +1776,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Convert to Form Field", ["Convert to Form Field", "Convertir en champ de formulaire", "Convert to Form Field", "Convert to Form Field", "转换为表单域"], ), + ("Convert to Note", ["Convert to Note", "Convertir en note", "Convert to Note", "Convert to Note", "转换为注释"]), ("Conveyor", ["Conveyor", "Convoyeur", "Conveyor", "Conveyor", "传送带"]), ("Copy Link(s) To…", ["Copy Link(s) To…", "Copier les liens vers…", "Copy Link(s) To…", "Copy Link(s) To…", "复制链接至…"]), ("Corner Options…", ["Corner Options…", "Options d'arrondi…", "Corner Options…", "Corner Options…", "角选项…"]), @@ -1648,6 +1812,8 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Define Lists…", ["Define Lists…", "Définir des listes…", "Define Lists…", "Define Lists…", "定义列表…"]), + ("Apply Bullets", ["Aufzählungszeichen anwenden", "Appliquer des puces", "Aplicar viñetas", "箇条書きを適用", "应用项目符号"]), + ("Apply Numbers", ["Nummerierung anwenden", "Appliquer la numérotation", "Aplicar números", "段落番号を適用", "应用编号"]), ( "Define Text Variable…", ["Define Text Variable…", "Définir une variable de texte…", "Define Text Variable…", "Define Text Variable…", "定义文本变量…"], @@ -1862,6 +2028,16 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Insert Pages…", ["Insert Pages…", "Insérer des pages…", "Insert Pages…", "Insert Pages…", "插入页面…"]), ("Insert Variable", ["Insert Variable", "Insérer une variable", "Insert Variable", "Insert Variable", "插入变量"]), + ( + "Convert Variable to Text", + [ + "Variable in Text konvertieren", + "Convertir la variable en texte", + "Convertir variable en texto", + "変数をテキストに変換", + "将变量转换为文本", + ], + ), ("Inspect Document", ["Inspect Document", "Inspecter le document", "Inspect Document", "Inspect Document", "检查文档"]), ( "Interface Color Theme", @@ -1921,6 +2097,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("New Table Style…", ["New Table Style…", "Nouveau style de tableau…", "New Table Style…", "New Table Style…", "新建表样式…"]), ("Next Cell", ["Next Cell", "Cellule suivante", "Next Cell", "Next Cell", "下一个单元格"]), ("Next Document", ["Next Document", "Document suivant", "Next Document", "Next Document", "下一个文档"]), + ("Next Note", ["Next Note", "Note suivante", "Next Note", "Next Note", "下一个注释"]), ("Next Object Above", ["Next Object Above", "Objet suivant au-dessus", "Next Object Above", "Next Object Above", "上方下一个对象"]), ("Next Object Below", ["Next Object Below", "Objet suivant en dessous", "Next Object Below", "Next Object Below", "下方下一个对象"]), ( @@ -1999,6 +2176,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Preflight Document", ["Preflight Document", "Contrôle en amont du document", "Preflight Document", "Preflight Document", "预检文档"]), ("Previous Cell", ["Previous Cell", "Cellule précédente", "Previous Cell", "Previous Cell", "上一个单元格"]), ("Previous Document", ["Previous Document", "Document précédent", "Previous Document", "Previous Document", "上一个文档"]), + ("Previous Note", ["Previous Note", "Note précédente", "Previous Note", "Previous Note", "上一个注释"]), ( "Previous Object in Group", ["Previous Object in Group", "Objet précédent du groupe", "Previous Object in Group", "Previous Object in Group", "组中的上一个对象"], @@ -4465,18 +4643,18 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str { } fn lookup<'a>(lang: &str, s: &'a str) -> &'a str { - static GERMAN: OnceLock> = OnceLock::new(); - static JAPANESE: OnceLock> = OnceLock::new(); - static TRANSLATIONS: OnceLock> = OnceLock::new(); - static ARABIC: OnceLock> = OnceLock::new(); - static UKRAINIAN: OnceLock> = OnceLock::new(); - static PORTUGUESE_BR: OnceLock> = OnceLock::new(); - static ITALIAN: OnceLock> = OnceLock::new(); - static POLISH: OnceLock> = OnceLock::new(); - static RUSSIAN: OnceLock> = OnceLock::new(); - static CZECH: OnceLock> = OnceLock::new(); - static FRENCH: OnceLock> = OnceLock::new(); - static GEORGIAN: OnceLock> = OnceLock::new(); + static GERMAN: OnceLock> = OnceLock::new(); + static JAPANESE: OnceLock> = OnceLock::new(); + static TRANSLATIONS: OnceLock> = OnceLock::new(); + static ARABIC: OnceLock> = OnceLock::new(); + static UKRAINIAN: OnceLock> = OnceLock::new(); + static PORTUGUESE_BR: OnceLock> = OnceLock::new(); + static ITALIAN: OnceLock> = OnceLock::new(); + static POLISH: OnceLock> = OnceLock::new(); + static RUSSIAN: OnceLock> = OnceLock::new(); + static CZECH: OnceLock> = OnceLock::new(); + static FRENCH: OnceLock> = OnceLock::new(); + static GEORGIAN: OnceLock> = OnceLock::new(); if lang == "ar" { return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s); } diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 31f7fb70d..beafe4b84 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Restart Numbering", "إعادة الترقيم"), + ("Continue Numbering", "متابعة الترقيم"), + ("Keep Options…", "خيارات الإبقاء…"), + ("Space Between Paragraphs Using Same Style", "المسافة بين الفقرات ذات النمط نفسه"), + ("Ignore", "تجاهل"), + ("Do Not Align to Baseline Grid", "عدم المحاذاة إلى شبكة خط الأساس"), + ("Only Align First Line to Grid", "محاذاة السطر الأول فقط إلى الشبكة"), + ("Balance Ragged Lines", "موازنة الأسطر غير المتساوية"), + ("Drop Caps and Nested Styles…", "الأحرف الاستهلالية والأنماط المتداخلة…"), + ("GREP Styles…", "أنماط GREP…"), + ("Restart/Continue Numbering", "إعادة/متابعة الترقيم"), + ("Convert Bullets and Numbering to Text", "تحويل التعداد النقطي والترقيم إلى نص"), + ("Hyphenation…", "الواصلة…"), + ("Hyphenation Settings", "إعدادات الواصلة"), + ("hyphens", "واصلات"), + ("Hyphenation Zone:", "منطقة الواصلة:"), + ("Better Spacing", "تباعد أفضل"), + ("Fewer Hyphens", "واصلات أقل"), + ("Hyphenate Capitalized Words", "وصل الكلمات ذات الأحرف الكبيرة"), + ("Hyphenate Last Word", "وصل الكلمة الأخيرة"), + ("Hyphenate Across Column", "الوصل عبر الأعمدة"), + ("Character Formatting Controls", "عناصر تحكم تنسيق الأحرف"), + ("Paragraph Formatting Controls", "عناصر تحكم تنسيق الفقرات"), + ("Font Size", "حجم الخط"), + ("Vertical Scale", "المقياس الرأسي"), + ("Horizontal Scale", "المقياس الأفقي"), + ("Baseline Shift", "إزاحة خط الأساس"), + ("Skew (False Italic)", "إمالة (مائل زائف)"), + ("Left Indent", "مسافة بادئة يسرى"), + ("Right Indent", "مسافة بادئة يمنى"), + ("First Line Left Indent", "مسافة بادئة يسرى للسطر الأول"), + ("Last Line Right Indent", "مسافة بادئة يمنى للسطر الأخير"), + ("Drop Cap Number of Lines", "عدد أسطر الحرف الاستهلالي"), + ("Drop Cap One or More Characters", "حرف استهلالي واحد أو أكثر"), + ("Align to Baseline Grid", "محاذاة إلى شبكة خط الأساس"), + ("Apply Bullets", "تطبيق التعداد النقطي"), + ("Apply Numbers", "تطبيق الترقيم"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), @@ -170,6 +207,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "الصفحة السابقة"), ("First Page", "الصفحة الأولى"), ("Last Page", "الصفحة الأخيرة"), + ("Go Back", "رجوع"), + ("Go Forward", "تقدم"), ("Font", "خط"), ("Size", "الحجم"), ("Character", "حرف"), @@ -1341,6 +1380,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "تحويل النص إلى جدول"), ("Convert to Button", "تحويل إلى زر"), ("Convert to Form Field", "تحويل إلى حقل نموذج"), + ("Convert to Note", "تحويل إلى ملاحظة"), ("Copy Link(s) To…", "نسخ الروابط إلى…"), ("Corner Options…", "خيارات الزوايا…"), ("Create Alternate Layout…", "إنشاء تخطيط بديل…"), @@ -1411,6 +1451,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "إدراج تعبير رياضي…"), ("Insert Pages…", "إدراج صفحات…"), ("Insert Variable", "إدراج متغير"), + ("Convert Variable to Text", "تحويل المتغير إلى نص"), ("Inspect Document", "فحص المستند"), ("Interface Color Theme", "نسق ألوان الواجهة"), ("Join the ArtCraft Discord…", "الانضمام إلى Discord الخاص بـ ArtCraft…"), @@ -1444,6 +1485,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "نمط جدول جديد…"), ("Next Cell", "الخلية التالية"), ("Next Document", "المستند التالي"), + ("Next Note", "الملاحظة التالية"), ("Next Object Above", "الكائن التالي أعلى"), ("Next Object Below", "الكائن التالي أسفل"), ("Next Object in Group", "الكائن التالي في المجموعة"), @@ -1472,6 +1514,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "فحص المستند قبل الطباعة"), ("Previous Cell", "الخلية السابقة"), ("Previous Document", "المستند السابق"), + ("Previous Note", "الملاحظة السابقة"), ("Previous Object in Group", "الكائن السابق في المجموعة"), ("Printers", "طابعات"), ("Proof Colors", "ألوان المعاينة الطباعية"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 4d41c30dc..2f12068a1 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Restart Numbering", "Začít číslování znovu"), + ("Continue Numbering", "Pokračovat v číslování"), + ("Keep Options…", "Volby zachování…"), + ("Space Between Paragraphs Using Same Style", "Mezera mezi odstavci se stejným stylem"), + ("Ignore", "Ignorovat"), + ("Do Not Align to Baseline Grid", "Nezarovnávat na mřížku účaří"), + ("Only Align First Line to Grid", "Zarovnat na mřížku jen první řádek"), + ("Balance Ragged Lines", "Vyrovnat nezarovnané řádky"), + ("Drop Caps and Nested Styles…", "Iniciály a vnořené styly…"), + ("GREP Styles…", "Styly GREP…"), + ("Restart/Continue Numbering", "Začít znovu/pokračovat v číslování"), + ("Convert Bullets and Numbering to Text", "Převést odrážky a číslování na text"), + ("Hyphenation…", "Dělení slov…"), + ("Hyphenation Settings", "Nastavení dělení slov"), + ("hyphens", "spojovníky"), + ("Hyphenation Zone:", "Zóna dělení:"), + ("Better Spacing", "Lepší mezery"), + ("Fewer Hyphens", "Méně spojovníků"), + ("Hyphenate Capitalized Words", "Dělit slova s velkým počátečním písmenem"), + ("Hyphenate Last Word", "Dělit poslední slovo"), + ("Hyphenate Across Column", "Dělit přes sloupce"), + ("Character Formatting Controls", "Ovládací prvky formátování znaků"), + ("Paragraph Formatting Controls", "Ovládací prvky formátování odstavců"), + ("Font Size", "Velikost písma"), + ("Vertical Scale", "Svislá změna velikosti"), + ("Horizontal Scale", "Vodorovná změna velikosti"), + ("Baseline Shift", "Posun účaří"), + ("Skew (False Italic)", "Zkosení (nepravá kurzíva)"), + ("Left Indent", "Odsazení zleva"), + ("Right Indent", "Odsazení zprava"), + ("First Line Left Indent", "Odsazení prvního řádku zleva"), + ("Last Line Right Indent", "Odsazení posledního řádku zprava"), + ("Drop Cap Number of Lines", "Počet řádků iniciály"), + ("Drop Cap One or More Characters", "Iniciála: jeden nebo více znaků"), + ("Align to Baseline Grid", "Zarovnat na mřížku účaří"), + ("Apply Bullets", "Použít odrážky"), + ("Apply Numbers", "Použít číslování"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), @@ -470,6 +507,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Převést text na tabulku"), ("Convert to Button", "Převést na tlačítko"), ("Convert to Form Field", "Převést na pole formuláře"), + ("Convert to Note", "Převést na poznámku"), ("Conveyor", "Dopravník"), ("Copy Link(s) To…", "Kopírovat propojení do…"), ("Corner Options…", "Možnosti rohů…"), @@ -560,6 +598,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Vložit matematický výraz…"), ("Insert Pages…", "Vložit stránky…"), ("Insert Variable", "Vložit proměnnou"), + ("Convert Variable to Text", "Převést proměnnou na text"), ("Inspect Document", "Prozkoumat dokument"), ("Interface Color Theme", "Barevný motiv rozhraní"), ("Join the ArtCraft Discord…", "Připojit se k Discordu ArtCraft…"), @@ -595,6 +634,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Nový styl tabulky…"), ("Next Cell", "Další buňka"), ("Next Document", "Další dokument"), + ("Next Note", "Další poznámka"), ("Next Object Above", "Další objekt nad"), ("Next Object Below", "Další objekt pod"), ("Next Object in Group", "Další objekt ve skupině"), @@ -628,6 +668,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Předtisková kontrola dokumentu"), ("Previous Cell", "Předchozí buňka"), ("Previous Document", "Předchozí dokument"), + ("Previous Note", "Předchozí poznámka"), ("Previous Object in Group", "Předchozí objekt ve skupině"), ("Primary Text Frame", "Primární textový rámeček"), ("Print Booklet…", "Tisk brožury…"), @@ -1443,6 +1484,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Předchozí stránka"), ("First Page", "První stránka"), ("Last Page", "Poslední stránka"), + ("Go Back", "Přejít zpět"), + ("Go Forward", "Přejít vpřed"), ("Object Styles", "Styly objektů"), ("Row", "Řádek"), ("Column", "Sloupec"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 367ca52f8..35a566ad6 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -31,6 +31,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Ausschneiden"), ("Copy", "Kopieren"), ("Paste", "Einfügen"), + ("Restart Numbering", "Nummerierung neu beginnen"), + ("Continue Numbering", "Nummerierung fortführen"), + ("Keep Options…", "Umbruchoptionen …"), + ("Space Between Paragraphs Using Same Style", "Abstand zwischen Absätzen mit gleichem Format"), + ("Ignore", "Ignorieren"), + ("Do Not Align to Baseline Grid", "Nicht am Grundlinienraster ausrichten"), + ("Only Align First Line to Grid", "Nur erste Zeile am Raster ausrichten"), + ("Balance Ragged Lines", "Unausgeglichene Zeilen ausgleichen"), + ("Drop Caps and Nested Styles…", "Initialen und verschachtelte Formate …"), + ("GREP Styles…", "GREP-Stile …"), + ("Restart/Continue Numbering", "Nummerierung neu beginnen/fortführen"), + ("Convert Bullets and Numbering to Text", "Aufzählungszeichen und Nummerierung in Text konvertieren"), + ("Hyphenation…", "Silbentrennung …"), + ("Hyphenation Settings", "Silbentrennungseinstellungen"), + ("hyphens", "Trennstriche"), + ("Hyphenation Zone:", "Silbentrennungsbereich:"), + ("Better Spacing", "Bessere Abstände"), + ("Fewer Hyphens", "Weniger Trennstriche"), + ("Hyphenate Capitalized Words", "Großgeschriebene Wörter trennen"), + ("Hyphenate Last Word", "Letztes Wort trennen"), + ("Hyphenate Across Column", "Über Spalten trennen"), + ("Apply Bullets", "Aufzählungszeichen anwenden"), + ("Apply Numbers", "Nummerierung anwenden"), ("Inches Decimal", "Zoll dezimal"), ("Back", "Zurück"), ("Warning", "Warnung"), @@ -521,6 +544,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Text in Tabelle umwandeln"), ("Convert to Button", "In Schaltfläche umwandeln"), ("Convert to Form Field", "In Formularfeld umwandeln"), + ("Convert to Note", "In Notiz umwandeln"), ("Conveyor", "Förderband"), ("Copy Link(s) To…", "Verknüpfung(en) kopieren nach …"), ("Corner Options…", "Eckenoptionen …"), @@ -611,6 +635,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Mathematischen Ausdruck einfügen …"), ("Insert Pages…", "Seiten einfügen …"), ("Insert Variable", "Variable einfügen"), + ("Convert Variable to Text", "Variable in Text konvertieren"), ("Inspect Document", "Dokument untersuchen"), ("Interface Color Theme", "Farbschema der Oberfläche"), ("Join the ArtCraft Discord…", "Dem ArtCraft-Discord beitreten …"), @@ -646,6 +671,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Neues Tabellenformat …"), ("Next Cell", "Nächste Zelle"), ("Next Document", "Nächstes Dokument"), + ("Next Note", "Nächste Notiz"), ("Next Object Above", "Nächstes Objekt darüber"), ("Next Object Below", "Nächstes Objekt darunter"), ("Next Object in Group", "Nächstes Objekt in der Gruppe"), @@ -679,6 +705,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Preflight für Dokument"), ("Previous Cell", "Vorherige Zelle"), ("Previous Document", "Vorheriges Dokument"), + ("Previous Note", "Vorherige Notiz"), ("Previous Object in Group", "Vorheriges Objekt in der Gruppe"), ("Primary Text Frame", "Primärer Textrahmen"), ("Print Booklet…", "Broschüre drucken …"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 681230d84..d19a5b56e 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -337,8 +337,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Sixth Space", "Sixième d'espace"), ("Smart Guide Options", "Options des repères commentés"), ("Smooth Tool", "Outil Lissage"), - ("Space After", "Espace après"), - ("Space Before", "Espace avant"), ("Start Page #", "N° de la première page"), ("Step and Repeat", "Exécuter et répéter"), ("Stop Preview", "Arrêter l'aperçu"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 755272fe5..d81a34df9 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Restart Numbering", "Riavvia numerazione"), + ("Continue Numbering", "Continua numerazione"), + ("Keep Options…", "Opzioni di separazione…"), + ("Space Between Paragraphs Using Same Style", "Spazio tra paragrafi con lo stesso stile"), + ("Ignore", "Ignora"), + ("Do Not Align to Baseline Grid", "Non allineare alla griglia linee di base"), + ("Only Align First Line to Grid", "Allinea solo la prima riga alla griglia"), + ("Balance Ragged Lines", "Bilancia righe irregolari"), + ("Drop Caps and Nested Styles…", "Capilettera e stili nidificati…"), + ("GREP Styles…", "Stili GREP…"), + ("Restart/Continue Numbering", "Riavvia/continua numerazione"), + ("Convert Bullets and Numbering to Text", "Converti elenchi puntati e numerati in testo"), + ("Hyphenation…", "Sillabazione…"), + ("Hyphenation Settings", "Impostazioni sillabazione"), + ("hyphens", "trattini"), + ("Hyphenation Zone:", "Area di sillabazione:"), + ("Better Spacing", "Spaziatura migliore"), + ("Fewer Hyphens", "Meno trattini"), + ("Hyphenate Capitalized Words", "Sillaba parole con iniziale maiuscola"), + ("Hyphenate Last Word", "Sillaba ultima parola"), + ("Hyphenate Across Column", "Sillaba tra colonne"), + ("Character Formatting Controls", "Controlli di formattazione carattere"), + ("Paragraph Formatting Controls", "Controlli di formattazione paragrafo"), + ("Font Size", "Dimensione font"), + ("Vertical Scale", "Scala verticale"), + ("Horizontal Scale", "Scala orizzontale"), + ("Baseline Shift", "Spostamento linea di base"), + ("Skew (False Italic)", "Inclinazione (falso corsivo)"), + ("Left Indent", "Rientro sinistro"), + ("Right Indent", "Rientro destro"), + ("First Line Left Indent", "Rientro sinistro prima riga"), + ("Last Line Right Indent", "Rientro destro ultima riga"), + ("Drop Cap Number of Lines", "Capolettera: numero di righe"), + ("Drop Cap One or More Characters", "Capolettera: uno o più caratteri"), + ("Align to Baseline Grid", "Allinea alla griglia di base"), + ("Apply Bullets", "Applica punti elenco"), + ("Apply Numbers", "Applica numeri"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), @@ -598,6 +635,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Converti testo in tabella"), ("Convert to Button", "Converti in pulsante"), ("Convert to Form Field", "Converti in campo modulo"), + ("Convert to Note", "Converti in nota"), ("Conveyor", "Nastro"), ("Copy Link(s) To…", "Copia collegamenti in…"), ("Corner Options…", "Opzioni angoli…"), @@ -688,6 +726,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Inserisci espressione matematica…"), ("Insert Pages…", "Inserisci pagine…"), ("Insert Variable", "Inserisci variabile"), + ("Convert Variable to Text", "Converti variabile in testo"), ("Inspect Document", "Ispeziona documento"), ("Interface Color Theme", "Tema colore dell'interfaccia"), ("Join the ArtCraft Discord…", "Unisciti al Discord di ArtCraft…"), @@ -723,6 +762,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Nuovo stile tabella…"), ("Next Cell", "Cella successiva"), ("Next Document", "Documento successivo"), + ("Next Note", "Nota successiva"), ("Next Object Above", "Oggetto successivo sopra"), ("Next Object Below", "Oggetto successivo sotto"), ("Next Object in Group", "Oggetto successivo nel gruppo"), @@ -756,6 +796,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Verifica preliminare documento"), ("Previous Cell", "Cella precedente"), ("Previous Document", "Documento precedente"), + ("Previous Note", "Nota precedente"), ("Previous Object in Group", "Oggetto precedente nel gruppo"), ("Primary Text Frame", "Cornice di testo principale"), ("Print Booklet…", "Stampa opuscolo…"), @@ -1617,6 +1658,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Pagina precedente"), ("First Page", "Prima pagina"), ("Last Page", "Ultima pagina"), + ("Go Back", "Torna indietro"), + ("Go Forward", "Vai avanti"), ("Object Styles", "Stili oggetto"), ("Row", "Riga"), ("Column", "Colonna"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 37b14ac5c..9c57319cb 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -30,6 +30,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "カット"), ("Copy", "コピー"), ("Paste", "ペースト"), + ("Restart Numbering", "番号の振り直し"), + ("Continue Numbering", "番号の継続"), + ("Keep Options…", "段落分離禁止オプション…"), + ("Space Between Paragraphs Using Same Style", "同じスタイルの段落間のスペース"), + ("Ignore", "無視"), + ("Do Not Align to Baseline Grid", "ベースライングリッドに揃えない"), + ("Only Align First Line to Grid", "最初の行のみグリッドに揃える"), + ("Balance Ragged Lines", "行末を調整"), + ("Drop Caps and Nested Styles…", "ドロップキャップと先頭文字スタイル…"), + ("GREP Styles…", "正規表現スタイル…"), + ("Restart/Continue Numbering", "番号の振り直し/継続"), + ("Convert Bullets and Numbering to Text", "箇条書きと段落番号をテキストに変換"), + ("Hyphenation…", "ハイフネーション…"), + ("Hyphenation Settings", "ハイフネーション設定"), + ("hyphens", "ハイフン"), + ("Hyphenation Zone:", "ハイフネーション領域 :"), + ("Better Spacing", "スペースを優先"), + ("Fewer Hyphens", "ハイフンを減らす"), + ("Hyphenate Capitalized Words", "大文字で始まる単語をハイフネーション"), + ("Hyphenate Last Word", "最後の単語をハイフネーション"), + ("Hyphenate Across Column", "段をまたいでハイフネーション"), + ("Apply Bullets", "箇条書きを適用"), + ("Apply Numbers", "段落番号を適用"), ("Paste Vector Art", "ベクトルアートをペースト"), ("Baseline Options", "ベースラインオプション"), ("Column Rules", "段落境界線"), @@ -713,6 +736,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "テキストを表に変換"), ("Convert to Button", "ボタンに変換"), ("Convert to Form Field", "フォームフィールドに変換"), + ("Convert to Note", "注釈に変換"), ("Conveyor", "コンベヤー"), ("Copy Link(s) To…", "リンクを指定先にコピー…"), ("Corner Options…", "角のオプション…"), @@ -803,6 +827,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "数式を挿入…"), ("Insert Pages…", "ページを挿入…"), ("Insert Variable", "変数を挿入"), + ("Convert Variable to Text", "変数をテキストに変換"), ("Inspect Document", "ドキュメントを調査"), ("Interface Color Theme", "インターフェイスのカラーテーマ"), ("Join the ArtCraft Discord…", "ArtCraft の Discord に参加…"), @@ -838,6 +863,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "新規表スタイル…"), ("Next Cell", "次のセル"), ("Next Document", "次のドキュメント"), + ("Next Note", "次の注釈"), ("Next Object Above", "1つ前面のオブジェクト"), ("Next Object Below", "1つ背面のオブジェクト"), ("Next Object in Group", "グループ内の次のオブジェクト"), @@ -871,6 +897,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "ドキュメントのプリフライト"), ("Previous Cell", "前のセル"), ("Previous Document", "前のドキュメント"), + ("Previous Note", "前の注釈"), ("Previous Object in Group", "グループ内の前のオブジェクト"), ("Primary Text Frame", "主テキストフレーム"), ("Print Booklet…", "小冊子をプリント…"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 0300b62d7..61196d196 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), + ("Continue Numbering", "ნუმერაციის გაგრძელება"), + ("Keep Options…", "შენარჩუნების პარამეტრები…"), + ("Space Between Paragraphs Using Same Style", "დაშორება ერთი სტილის აბზაცებს შორის"), + ("Ignore", "უგულებელყოფა"), + ("Do Not Align to Baseline Grid", "საბაზისო ბადეზე არ გასწორდეს"), + ("Only Align First Line to Grid", "ბადეზე მხოლოდ პირველი ხაზის გასწორება"), + ("Balance Ragged Lines", "უსწორმასწორო ხაზების დაბალანსება"), + ("Drop Caps and Nested Styles…", "საწყისი ასოები და ჩადგმული სტილები…"), + ("GREP Styles…", "GREP სტილები…"), + ("Restart/Continue Numbering", "ნუმერაციის თავიდან დაწყება/გაგრძელება"), + ("Convert Bullets and Numbering to Text", "მარკერებისა და ნუმერაციის ტექსტად გარდაქმნა"), + ("Hyphenation…", "დამარცვლა…"), + ("Hyphenation Settings", "დამარცვლის პარამეტრები"), + ("hyphens", "დეფისი"), + ("Hyphenation Zone:", "დამარცვლის ზონა:"), + ("Better Spacing", "უკეთესი დაშორება"), + ("Fewer Hyphens", "ნაკლები დეფისი"), + ("Hyphenate Capitalized Words", "მთავრული სიტყვების დამარცვლა"), + ("Hyphenate Last Word", "ბოლო სიტყვის დამარცვლა"), + ("Hyphenate Across Column", "სვეტებს შორის დამარცვლა"), + ("Character Formatting Controls", "სიმბოლოების ფორმატირების მართვა"), + ("Paragraph Formatting Controls", "აბზაცების ფორმატირების მართვა"), + ("Font Size", "შრიფტის ზომა"), + ("Vertical Scale", "ვერტიკალური მასშტაბი"), + ("Horizontal Scale", "ჰორიზონტალური მასშტაბი"), + ("Baseline Shift", "საბაზო ხაზის წანაცვლება"), + ("Skew (False Italic)", "დახრა (ცრუ კურსივი)"), + ("Left Indent", "მარცხენა შეწევა"), + ("Right Indent", "მარჯვენა შეწევა"), + ("First Line Left Indent", "პირველი სტრიქონის მარცხენა შეწევა"), + ("Last Line Right Indent", "ბოლო სტრიქონის მარჯვენა შეწევა"), + ("Drop Cap Number of Lines", "ინიციალის სტრიქონების რაოდენობა"), + ("Drop Cap One or More Characters", "ინიციალი: ერთი ან მეტი სიმბოლო"), + ("Align to Baseline Grid", "საბაზო ბადეზე სწორება"), + ("Apply Bullets", "მარკერების გამოყენება"), + ("Apply Numbers", "ნუმერაციის გამოყენება"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), @@ -525,6 +562,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "ტექსტის ცხრილად გარდაქმნა"), ("Convert to Button", "ღილაკად გარდაქმნა"), ("Convert to Form Field", "ფორმის ველად გარდაქმნა"), + ("Convert to Note", "შენიშვნად გარდაქმნა"), ("Conveyor", "კონვეიერი"), ("Copy Link(s) To…", "ბმული ფაილების კოპირება…"), ("Corner Options…", "კუთხეების პარამეტრები…"), @@ -616,6 +654,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "მათემატიკური გამოსახულების ჩასმა…"), ("Insert Pages…", "გვერდების ჩასმა…"), ("Insert Variable", "ცვლადის ჩასმა"), + ("Convert Variable to Text", "ცვლადის ტექსტად გარდაქმნა"), ("Inspect Document", "დოკუმენტის შემოწმება"), ("Interface Color Theme", "ინტერფეისის ფერთა თემა"), ("Join the ArtCraft Discord…", "შემოუერთდით ArtCraft-ის Discord-ს…"), @@ -651,6 +690,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "ცხრილის ახალი სტილი…"), ("Next Cell", "შემდეგი უჯრა"), ("Next Document", "შემდეგი დოკუმენტი"), + ("Next Note", "შემდეგი შენიშვნა"), ("Next Object Above", "შემდეგი ობიექტი ზემოთ"), ("Next Object Below", "შემდეგი ობიექტი ქვემოთ"), ("Next Object in Group", "ჯგუფის შემდეგი ობიექტი"), @@ -684,6 +724,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "დოკუმენტის წინასწარი შემოწმება"), ("Previous Cell", "წინა უჯრა"), ("Previous Document", "წინა დოკუმენტი"), + ("Previous Note", "წინა შენიშვნა"), ("Previous Object in Group", "ჯგუფის წინა ობიექტი"), ("Primary Text Frame", "ძირითადი ტექსტური ჩარჩო"), ("Print Booklet…", "ბროშურის დაბეჭდვა…"), @@ -1549,6 +1590,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "წინა გვერდი"), ("First Page", "პირველი გვერდი"), ("Last Page", "ბოლო გვერდი"), + ("Go Back", "უკან დაბრუნება"), + ("Go Forward", "წინ გადასვლა"), ("Object Styles", "ობიექტის სტილები"), ("Row", "სტრიქონი"), ("Rulers", "სახაზავები"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 7d1d12c67..4dffa9de3 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Restart Numbering", "Zacznij numerowanie od nowa"), + ("Continue Numbering", "Kontynuuj numerowanie"), + ("Keep Options…", "Opcje utrzymywania…"), + ("Space Between Paragraphs Using Same Style", "Odstęp między akapitami o tym samym stylu"), + ("Ignore", "Ignoruj"), + ("Do Not Align to Baseline Grid", "Nie wyrównuj do siatki linii bazowych"), + ("Only Align First Line to Grid", "Wyrównaj do siatki tylko pierwszy wiersz"), + ("Balance Ragged Lines", "Wyrównaj nierówne wiersze"), + ("Drop Caps and Nested Styles…", "Inicjały i style zagnieżdżone…"), + ("GREP Styles…", "Style GREP…"), + ("Restart/Continue Numbering", "Zacznij od nowa/kontynuuj numerowanie"), + ("Convert Bullets and Numbering to Text", "Konwertuj wypunktowanie i numerowanie na tekst"), + ("Hyphenation…", "Dzielenie wyrazów…"), + ("Hyphenation Settings", "Ustawienia dzielenia wyrazów"), + ("hyphens", "dywizy"), + ("Hyphenation Zone:", "Strefa dzielenia:"), + ("Better Spacing", "Lepsze odstępy"), + ("Fewer Hyphens", "Mniej dywizów"), + ("Hyphenate Capitalized Words", "Dziel wyrazy pisane wielką literą"), + ("Hyphenate Last Word", "Dziel ostatni wyraz"), + ("Hyphenate Across Column", "Dziel między łamami"), + ("Character Formatting Controls", "Elementy formatowania znaków"), + ("Paragraph Formatting Controls", "Elementy formatowania akapitów"), + ("Font Size", "Rozmiar czcionki"), + ("Vertical Scale", "Skala pionowa"), + ("Horizontal Scale", "Skala pozioma"), + ("Baseline Shift", "Przesunięcie linii bazowej"), + ("Skew (False Italic)", "Pochylenie (fałszywa kursywa)"), + ("Left Indent", "Wcięcie z lewej"), + ("Right Indent", "Wcięcie z prawej"), + ("First Line Left Indent", "Wcięcie pierwszego wiersza z lewej"), + ("Last Line Right Indent", "Wcięcie ostatniego wiersza z prawej"), + ("Drop Cap Number of Lines", "Inicjał: liczba wierszy"), + ("Drop Cap One or More Characters", "Inicjał: jeden lub więcej znaków"), + ("Align to Baseline Grid", "Wyrównaj do siatki linii bazowych"), + ("Apply Bullets", "Zastosuj wypunktowanie"), + ("Apply Numbers", "Zastosuj numerowanie"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), @@ -477,6 +514,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Zamień tekst na tabelę"), ("Convert to Button", "Zamień na przycisk"), ("Convert to Form Field", "Zamień na pole formularza"), + ("Convert to Note", "Zamień na notatkę"), ("Conveyor", "Przenośnik"), ("Copy Link(s) To…", "Kopiuj łącza do…"), ("Corner Options…", "Opcje narożników…"), @@ -567,6 +605,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Wstaw wyrażenie matematyczne…"), ("Insert Pages…", "Wstaw strony…"), ("Insert Variable", "Wstaw zmienną"), + ("Convert Variable to Text", "Zamień zmienną na tekst"), ("Inspect Document", "Sprawdź dokument"), ("Interface Color Theme", "Motyw kolorystyczny interfejsu"), ("Join the ArtCraft Discord…", "Dołącz do Discorda ArtCraft…"), @@ -602,6 +641,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Nowy styl tabeli…"), ("Next Cell", "Następna komórka"), ("Next Document", "Następny dokument"), + ("Next Note", "Następna notatka"), ("Next Object Above", "Następny obiekt powyżej"), ("Next Object Below", "Następny obiekt poniżej"), ("Next Object in Group", "Następny obiekt w grupie"), @@ -635,6 +675,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Inspekcja wstępna dokumentu"), ("Previous Cell", "Poprzednia komórka"), ("Previous Document", "Poprzedni dokument"), + ("Previous Note", "Poprzednia notatka"), ("Previous Object in Group", "Poprzedni obiekt w grupie"), ("Primary Text Frame", "Główna ramka tekstowa"), ("Print Booklet…", "Drukuj broszurę…"), @@ -1480,6 +1521,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Poprzednia strona"), ("First Page", "Pierwsza strona"), ("Last Page", "Ostatnia strona"), + ("Go Back", "Przejdź wstecz"), + ("Go Forward", "Przejdź dalej"), ("Object Styles", "Style obiektów"), ("Row", "Wiersz"), ("Rulers", "Linijki"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index f552e9a63..4628baffa 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Restart Numbering", "Reiniciar numeração"), + ("Continue Numbering", "Continuar numeração"), + ("Keep Options…", "Opções de manutenção…"), + ("Space Between Paragraphs Using Same Style", "Espaço entre parágrafos com o mesmo estilo"), + ("Ignore", "Ignorar"), + ("Do Not Align to Baseline Grid", "Não alinhar à grade de linha de base"), + ("Only Align First Line to Grid", "Alinhar somente a primeira linha à grade"), + ("Balance Ragged Lines", "Equilibrar linhas irregulares"), + ("Drop Caps and Nested Styles…", "Capitulares e estilos aninhados…"), + ("GREP Styles…", "Estilos GREP…"), + ("Restart/Continue Numbering", "Reiniciar/continuar numeração"), + ("Convert Bullets and Numbering to Text", "Converter marcadores e numeração em texto"), + ("Hyphenation…", "Hifenização…"), + ("Hyphenation Settings", "Configurações de hifenização"), + ("hyphens", "hifens"), + ("Hyphenation Zone:", "Zona de hifenização:"), + ("Better Spacing", "Melhor espaçamento"), + ("Fewer Hyphens", "Menos hifens"), + ("Hyphenate Capitalized Words", "Hifenizar palavras iniciadas por maiúscula"), + ("Hyphenate Last Word", "Hifenizar última palavra"), + ("Hyphenate Across Column", "Hifenizar entre colunas"), + ("Character Formatting Controls", "Controles de formatação de caracteres"), + ("Paragraph Formatting Controls", "Controles de formatação de parágrafos"), + ("Font Size", "Tamanho da fonte"), + ("Vertical Scale", "Escala vertical"), + ("Horizontal Scale", "Escala horizontal"), + ("Baseline Shift", "Deslocamento da linha de base"), + ("Skew (False Italic)", "Inclinação (falso itálico)"), + ("Left Indent", "Recuo à esquerda"), + ("Right Indent", "Recuo à direita"), + ("First Line Left Indent", "Recuo à esquerda da primeira linha"), + ("Last Line Right Indent", "Recuo à direita da última linha"), + ("Drop Cap Number of Lines", "Capitular: número de linhas"), + ("Drop Cap One or More Characters", "Capitular: um ou mais caracteres"), + ("Align to Baseline Grid", "Alinhar à grade de linha de base"), + ("Apply Bullets", "Aplicar marcadores"), + ("Apply Numbers", "Aplicar números"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), @@ -176,6 +213,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Página anterior"), ("First Page", "Primeira página"), ("Last Page", "Última página"), + ("Go Back", "Voltar"), + ("Go Forward", "Avançar"), ("Font", "Fonte"), ("Size", "Tamanho"), ("Character", "Caractere"), @@ -1478,6 +1517,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Converter texto em tabela"), ("Convert to Button", "Converter em botão"), ("Convert to Form Field", "Converter em campo de formulário"), + ("Convert to Note", "Converter em nota"), ("Copy Link(s) To…", "Copiar link(s) para…"), ("Corner Options…", "Opções de canto…"), ("Create Frame", "Criar quadro"), @@ -1544,6 +1584,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Cross-Reference…", "Inserir referência cruzada…"), ("Insert Math Expression…", "Inserir expressão matemática…"), ("Insert Variable", "Inserir variável"), + ("Convert Variable to Text", "Converter variável em texto"), ("Inspect Document", "Inspecionar documento"), ("Interface Color Theme", "Tema de cores da interface"), ("Join the ArtCraft Discord…", "Entrar no Discord do ArtCraft…"), @@ -1577,6 +1618,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Novo estilo de tabela…"), ("Next Cell", "Próxima célula"), ("Next Document", "Próximo documento"), + ("Next Note", "Próxima nota"), ("Next Object Above", "Próximo objeto acima"), ("Next Object Below", "Próximo objeto abaixo"), ("Next Object in Group", "Próximo objeto no grupo"), @@ -1604,6 +1646,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Verificar documento (preflight)"), ("Previous Cell", "Célula anterior"), ("Previous Document", "Documento anterior"), + ("Previous Note", "Nota anterior"), ("Previous Object in Group", "Objeto anterior no grupo"), ("Printers", "Impressoras"), ("Proof Colors", "Cores de prova"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3f6ef1be0..f13694bb9 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Restart Numbering", "Начать нумерацию заново"), + ("Continue Numbering", "Продолжить нумерацию"), + ("Keep Options…", "Параметры удержания…"), + ("Space Between Paragraphs Using Same Style", "Интервал между абзацами одного стиля"), + ("Ignore", "Игнорировать"), + ("Do Not Align to Baseline Grid", "Не выравнивать по базовой сетке"), + ("Only Align First Line to Grid", "Выравнивать по сетке только первую строку"), + ("Balance Ragged Lines", "Сбалансировать рваные строки"), + ("Drop Caps and Nested Styles…", "Буквицы и вложенные стили…"), + ("GREP Styles…", "Стили GREP…"), + ("Restart/Continue Numbering", "Начать заново/продолжить нумерацию"), + ("Convert Bullets and Numbering to Text", "Преобразовать маркеры и нумерацию в текст"), + ("Hyphenation…", "Расстановка переносов…"), + ("Hyphenation Settings", "Параметры расстановки переносов"), + ("hyphens", "дефисов"), + ("Hyphenation Zone:", "Зона переносов:"), + ("Better Spacing", "Лучшие интервалы"), + ("Fewer Hyphens", "Меньше переносов"), + ("Hyphenate Capitalized Words", "Переносить слова с прописной буквы"), + ("Hyphenate Last Word", "Переносить последнее слово"), + ("Hyphenate Across Column", "Переносить между колонками"), + ("Character Formatting Controls", "Элементы управления форматированием символов"), + ("Paragraph Formatting Controls", "Элементы управления форматированием абзацев"), + ("Font Size", "Кегль"), + ("Vertical Scale", "Масштаб по вертикали"), + ("Horizontal Scale", "Масштаб по горизонтали"), + ("Baseline Shift", "Смещение базовой линии"), + ("Skew (False Italic)", "Наклон (ложный курсив)"), + ("Left Indent", "Отступ слева"), + ("Right Indent", "Отступ справа"), + ("First Line Left Indent", "Отступ первой строки слева"), + ("Last Line Right Indent", "Отступ последней строки справа"), + ("Drop Cap Number of Lines", "Буквица: число строк"), + ("Drop Cap One or More Characters", "Буквица: один или несколько символов"), + ("Align to Baseline Grid", "Выровнять по сетке базовых линий"), + ("Apply Bullets", "Применить маркеры"), + ("Apply Numbers", "Применить нумерацию"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), @@ -457,6 +494,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Преобразовать текст в таблицу"), ("Convert to Button", "Преобразовать в кнопку"), ("Convert to Form Field", "Преобразовать в поле формы"), + ("Convert to Note", "Преобразовать в заметку"), ("Conveyor", "Конвейер"), ("Copy Link(s) To…", "Копировать связанные файлы в…"), ("Corner Options…", "Параметры углов…"), @@ -547,6 +585,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Вставить математическое выражение…"), ("Insert Pages…", "Вставить страницы…"), ("Insert Variable", "Вставить переменную"), + ("Convert Variable to Text", "Преобразовать переменную в текст"), ("Inspect Document", "Проверить документ"), ("Interface Color Theme", "Цветовая тема интерфейса"), ("Join the ArtCraft Discord…", "Присоединиться к Discord ArtCraft…"), @@ -582,6 +621,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Новый стиль таблицы…"), ("Next Cell", "Следующая ячейка"), ("Next Document", "Следующий документ"), + ("Next Note", "Следующая заметка"), ("Next Object Above", "Следующий объект выше"), ("Next Object Below", "Следующий объект ниже"), ("Next Object in Group", "Следующий объект в группе"), @@ -615,6 +655,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Предпечатная проверка документа"), ("Previous Cell", "Предыдущая ячейка"), ("Previous Document", "Предыдущий документ"), + ("Previous Note", "Предыдущая заметка"), ("Previous Object in Group", "Предыдущий объект в группе"), ("Primary Text Frame", "Основная текстовая рамка"), ("Print Booklet…", "Печать буклета…"), @@ -1431,6 +1472,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Предыдущая страница"), ("First Page", "Первая страница"), ("Last Page", "Последняя страница"), + ("Go Back", "Назад"), + ("Go Forward", "Вперёд"), ("Object Styles", "Стили объектов"), ("Row", "Строка"), ("Column", "Столбец"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 9c7cc855a..5dca5b717 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,43 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Restart Numbering", "Почати нумерацію знову"), + ("Continue Numbering", "Продовжити нумерацію"), + ("Keep Options…", "Параметри утримання…"), + ("Space Between Paragraphs Using Same Style", "Інтервал між абзацами одного стилю"), + ("Ignore", "Ігнорувати"), + ("Do Not Align to Baseline Grid", "Не вирівнювати за базовою сіткою"), + ("Only Align First Line to Grid", "Вирівнювати за сіткою лише перший рядок"), + ("Balance Ragged Lines", "Збалансувати нерівні рядки"), + ("Drop Caps and Nested Styles…", "Буквиці та вкладені стилі…"), + ("GREP Styles…", "Стилі GREP…"), + ("Restart/Continue Numbering", "Почати знову/продовжити нумерацію"), + ("Convert Bullets and Numbering to Text", "Перетворити маркери й нумерацію на текст"), + ("Hyphenation…", "Перенесення…"), + ("Hyphenation Settings", "Параметри перенесення"), + ("hyphens", "дефісів"), + ("Hyphenation Zone:", "Зона перенесення:"), + ("Better Spacing", "Кращі інтервали"), + ("Fewer Hyphens", "Менше перенесень"), + ("Hyphenate Capitalized Words", "Переносити слова з великої літери"), + ("Hyphenate Last Word", "Переносити останнє слово"), + ("Hyphenate Across Column", "Переносити між колонками"), + ("Character Formatting Controls", "Елементи керування форматуванням символів"), + ("Paragraph Formatting Controls", "Елементи керування форматуванням абзаців"), + ("Font Size", "Кегль"), + ("Vertical Scale", "Масштаб по вертикалі"), + ("Horizontal Scale", "Масштаб по горизонталі"), + ("Baseline Shift", "Зсув базової лінії"), + ("Skew (False Italic)", "Нахил (несправжній курсив)"), + ("Left Indent", "Відступ ліворуч"), + ("Right Indent", "Відступ праворуч"), + ("First Line Left Indent", "Відступ першого рядка ліворуч"), + ("Last Line Right Indent", "Відступ останнього рядка праворуч"), + ("Drop Cap Number of Lines", "Буквиця: кількість рядків"), + ("Drop Cap One or More Characters", "Буквиця: один або кілька символів"), + ("Align to Baseline Grid", "Вирівняти за сіткою базових ліній"), + ("Apply Bullets", "Застосувати маркери"), + ("Apply Numbers", "Застосувати нумерацію"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), @@ -601,6 +638,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Convert Text to Table", "Перетворити текст на таблицю"), ("Convert to Button", "Перетворити на кнопку"), ("Convert to Form Field", "Перетворити на поле форми"), + ("Convert to Note", "Перетворити на примітку"), ("Conveyor", "Конвеєр"), ("Copy Link(s) To…", "Копіювати пов’язані файли до…"), ("Corner Options…", "Параметри кутів…"), @@ -691,6 +729,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Insert Math Expression…", "Вставити математичний вираз…"), ("Insert Pages…", "Вставити сторінки…"), ("Insert Variable", "Вставити змінну"), + ("Convert Variable to Text", "Перетворити змінну на текст"), ("Inspect Document", "Перевірити документ"), ("Interface Color Theme", "Колірна тема інтерфейсу"), ("Join the ArtCraft Discord…", "Приєднатися до Discord ArtCraft…"), @@ -726,6 +765,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("New Table Style…", "Новий стиль таблиці…"), ("Next Cell", "Наступна клітинка"), ("Next Document", "Наступний документ"), + ("Next Note", "Наступна примітка"), ("Next Object Above", "Наступний об’єкт вище"), ("Next Object Below", "Наступний об’єкт нижче"), ("Next Object in Group", "Наступний об’єкт у групі"), @@ -759,6 +799,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preflight Document", "Перевірити документ перед друком"), ("Previous Cell", "Попередня клітинка"), ("Previous Document", "Попередній документ"), + ("Previous Note", "Попередня примітка"), ("Previous Object in Group", "Попередній об’єкт у групі"), ("Primary Text Frame", "Основний текстовий фрейм"), ("Print Booklet…", "Друк буклета…"), @@ -1614,6 +1655,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Previous Page", "Попередня сторінка"), ("First Page", "Перша сторінка"), ("Last Page", "Остання сторінка"), + ("Go Back", "Назад"), + ("Go Forward", "Вперед"), ("Object Styles", "Стилі об’єктів"), ("Row", "Рядок"), ("Column", "Стовпець"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a90fe989e..f0486b97c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -798,6 +798,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(3.0, v), (5.0, v)]); } } + // Do Not Align to Baseline Grid: ragged lines of text, free of any grid. + "grid-none" => { + for (y, x1) in [(5.0, 17.0), (8.5, 12.0), (12.0, 17.0), (15.5, 14.0)] { + pen.line(&[(3.0, y), (x1, y)]); + } + } "grid-baseline" => { pen.rect(3.0, 3.0, 17.0, 17.0); for y in [6.5, 10.0, 13.5] { @@ -916,6 +922,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(12.0, 3.5), (12.0, 16.5)]); pen.line(&[(9.0, 10.0), (11.0, 10.0)]); } + "small-caps" => { + // Two original T shapes share a baseline; no phonetic small-cap glyph is required. + pen.line(&[(3.0, 5.0), (10.0, 5.0)]); + pen.line(&[(6.5, 5.0), (6.5, 15.0)]); + pen.line(&[(12.0, 8.0), (17.0, 8.0)]); + pen.line(&[(14.5, 8.0), (14.5, 15.0)]); + } "font-size" => { pen.text(6.0, 12.5, "T", 10.0); pen.text(13.0, 10.5, "T", 17.0); @@ -938,6 +951,86 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(15.0, 13.5), (17.0, 15.5), (15.0, 17.5)]); } } + "vscale" => { + pen.text(13.0, 10.5, "T", 15.0); + pen.line(&[(4.0, 3.0), (4.0, 17.0)]); + pen.line(&[(2.5, 5.0), (4.0, 3.0), (5.5, 5.0)]); + pen.line(&[(2.5, 15.0), (4.0, 17.0), (5.5, 15.0)]); + } + "hscale" => { + pen.text(10.0, 8.0, "T", 13.0); + pen.line(&[(3.0, 16.5), (17.0, 16.5)]); + pen.line(&[(5.0, 14.5), (3.0, 16.5), (5.0, 18.5)]); + pen.line(&[(15.0, 14.5), (17.0, 16.5), (15.0, 18.5)]); + } + "baseline-shift" => { + pen.text(7.0, 11.0, "A", 13.0); + pen.text(15.5, 5.5, "a", 8.0); + pen.line(&[(15.5, 17.5), (15.5, 10.0)]); + pen.line(&[(13.5, 12.0), (15.5, 10.0), (17.5, 12.0)]); + } + "skew" => { + pen.w = 1.8; + pen.line(&[(7.0, 4.0), (17.0, 4.0)]); + pen.line(&[(12.0, 4.0), (8.0, 17.0)]); + } + "indent-left" | "indent-right" | "indent-first" | "indent-last" => { + // Text lines, and an arrow at the side (or line) indented. + let (x0, x1) = match name { + "indent-left" => (9.0, 18.0), + "indent-right" => (2.0, 11.0), + _ => (2.0, 18.0), + }; + for (k, y) in [4.0, 8.0, 12.0, 16.0].into_iter().enumerate() { + let (a, z) = match (name, k) { + ("indent-first", 0) => (9.0, x1), + ("indent-last", 3) => (x0, 11.0), + _ => (x0, x1), + }; + pen.line(&[(a, y), (z, y)]); + } + let (y, from, to) = match name { + "indent-left" => (10.0, 2.0, 7.0), + "indent-right" => (10.0, 18.0, 13.0), + "indent-first" => (4.0, 2.0, 7.0), + _ => (16.0, 18.0, 13.0), + }; + pen.line(&[(from, y), (to, y)]); + let d = if to > from { -2.0 } else { 2.0 }; + pen.line(&[(to + d, y - 2.0), (to, y), (to + d, y + 2.0)]); + } + "space-before" | "space-after" => { + let before = name == "space-before"; + let (bar, lines) = if before { (3.0, [11.0, 14.5, 18.0]) } else { (17.0, [2.0, 5.5, 9.0]) }; + pen.line(&[(2.0, bar), (18.0, bar)]); + for y in lines { + pen.line(&[(8.0, y), (18.0, y)]); + } + let (a, z) = if before { (5.0, 10.0) } else { (10.0, 15.0) }; + pen.line(&[(4.0, a), (4.0, z)]); + pen.line(&[(2.5, a + 1.5), (4.0, a), (5.5, a + 1.5)]); + pen.line(&[(2.5, z - 1.5), (4.0, z), (5.5, z - 1.5)]); + } + // Space Between Paragraphs Using Same Style: two paragraphs and the space between them. + "space-same" => { + for y in [2.0, 5.0, 15.0, 18.0] { + pen.line(&[(8.0, y), (18.0, y)]); + } + pen.line(&[(4.0, 6.5), (4.0, 13.5)]); + pen.line(&[(2.5, 8.0), (4.0, 6.5), (5.5, 8.0)]); + pen.line(&[(2.5, 12.0), (4.0, 13.5), (5.5, 12.0)]); + } + "drop-lines" | "drop-chars" => { + pen.text(5.5, 8.0, "A", 14.0); + if name == "drop-chars" { + pen.text(13.5, 4.5, "a", 8.0); + } + for (y, x) in [(if name == "drop-chars" { 8.5 } else { 3.5 }, 11.0), (8.5, 11.0), (13.5, 11.0), (17.5, 2.0)] { + if name == "drop-lines" || y > 5.0 { + pen.line(&[(x, y), (18.0, y)]); + } + } + } "corner" => { pen.w = 1.0; for (x, y) in [(3.0, 3.0), (14.0, 3.0), (3.0, 14.0), (14.0, 14.0)] { @@ -975,6 +1068,20 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(7.5, *y), (if k == 1 { 14.0 } else { 17.0 }, *y)]); } } + // Paragraph shading: a square with a checked fill (the Control panel's Shading). + "para-shading" => { + pen.w = 1.0; + pen.rect(4.0, 4.0, 16.0, 16.0); + pen.frect(4.0, 4.0, 10.0, 10.0); + pen.frect(10.0, 10.0, 16.0, 16.0); + } + // Paragraph border: a box with its corner drawn heavy (the Control panel's Border). + "para-border" => { + pen.w = 1.0; + pen.rect(4.0, 4.0, 16.0, 16.0); + pen.w = 2.5; + pen.line(&[(5.0, 12.0), (5.0, 5.0), (12.0, 5.0)]); + } "pilcrow-menu" => { pen.text(9.0, 10.0, "¶", 14.0); pen.fill(&[(14.0, 15.0), (17.0, 15.0), (15.5, 17.0)]); @@ -1058,7 +1165,7 @@ pub fn button(ui: &mut egui::Ui, name: &str, size: f32, selected: bool, tip: &st let pad = size * 0.16; paint(ui.painter(), r.shrink(pad), name, if selected { t.text_strong } else { t.icon }); let enabled = ui.is_enabled(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, tip)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, selected, tip)); if tip.is_empty() { resp } else { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b149cea64..0141e16a4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -21,6 +21,7 @@ pub mod menus; mod modal_drag; mod new_document; mod object_control; +mod page_history; mod panel_docking; pub mod panels; mod prefs; @@ -194,6 +195,12 @@ pub struct UiState { /// The view (by its `views` key) Presentation was entered from; leaving puts it back. #[serde(skip)] pub presentation_view: Option<(u64, View)>, + /// The page in that view and the place on it, for Go Back after leaving Presentation. + #[serde(skip)] + pub presentation_page: Option<(designcraft_doc::ids::PageId, designcraft_geom::Vec2)>, + /// That view's Go Back / Go Forward history; leaving puts it back with one jump added. + #[serde(skip)] + pub(crate) presentation_history: Option, pub frame_edges: bool, pub rulers: bool, pub guides: bool, @@ -371,6 +378,8 @@ impl Default for UiState { screen_mode: ScreenMode::Normal, presentation_return: ScreenMode::Normal, presentation_view: None, + presentation_page: None, + presentation_history: None, frame_edges: true, rulers: true, guides: true, @@ -566,12 +575,67 @@ pub struct Perf { pub fps: f64, } +/// The bounded admission policy for `ui.screenshot` captures. +pub(crate) const SCREENSHOT_MAX_REQUESTS: usize = 4; +pub(crate) const SCREENSHOT_MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024; +pub(crate) const SCREENSHOT_MAX_AGGREGATE_BYTES: usize = 256 * 1024 * 1024; + +/// The viewport geometry a capture was admitted for. +#[derive(Clone, Copy, PartialEq, Debug)] +struct ScreenshotGeometry { + width: usize, + height: usize, + /// The active document state epoch at admission; a switch or Revert invalidates it. + document: u64, + /// Logical size, DPI, zoom and fullscreen state. The window position is deliberately absent: + /// moving the window does not change the captured surface or its content. eframe 0.37 exposes + /// no portable surface-generation identity, so a same-size surface recreation is still a + /// documented qualification gap. + signature: [u32; 6], +} + +/// The document, dialog and keyboard focus an injected input batch was captured for. If any of +/// them changed before a settled UI pass took the batch, the batch no longer belongs to the new +/// state and is discarded instead of replaying stale clicks or text. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +struct InputGeneration { + document: u64, + dialog: Option, + focus: Option, +} + +/// One native file drop captured from the raw input. The generation travelled with it so a +/// document, dialog or focus change before the next settled UI pass discards the stale drop. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +struct PendingDrop { + path: String, + pointer: Option, + generation: InputGeneration, +} + +/// One admitted `ui.screenshot` request. The token, path, deadline and epoch are app-owned; the +/// shared capture slot only correlates one completion with this record. +struct ShotRequest { + token: u64, + path: Option, + reply: Sender, + deadline: f64, + viewport: egui::ViewportId, + epoch: u64, + geometry: ScreenshotGeometry, + reserved_bytes: usize, + slot: Option, +} + pub struct DesignApp { pending_pdf: Option, pub session: Session, pub ui: UiState, pub services: Services, pub views: HashMap, + /// Layout ▸ Go Back / Go Forward, by view (like `views`). + pub(crate) page_history: HashMap, /// The canvas cache and rect of the pane in `pane` (the other pane's are in `other_pane`). pub canvas: CanvasCache, pub canvas_rect: Option, @@ -592,14 +656,47 @@ pub struct DesignApp { pub synthetic: Vec, /// Control input stays separate from native-menu input until its frame is processed. pub(crate) control_synthetic: Vec, + /// Resolved native menu choices, kept stable across menu rebuilds until input settles. + native_actions: std::collections::VecDeque<(String, Value)>, + native_events_pending: std::sync::Arc, control_input_frame: std::sync::Arc, clipboard_guard_installed: bool, /// Story open in the Story Editor. pub story_editor: Option, control_rx: Option>, - pending_shots: Vec<(u64, Option, Sender, f64)>, + pending_shots: Vec, queued_shots: Vec<(u64, f64, u32)>, shot_token: u64, + /// The viewport geometry the live screenshot requests were admitted for. + screenshot_geometry: Option, + /// Advanced whenever the viewport size, DPI, zoom or surface changes (even back again). + screenshot_epoch: u64, + /// A synthetic input step was injected and has not yet reached a settled UI pass. + synthetic_injected: bool, + /// The exact events of that batch, so a generation change can remove them from the host's + /// pending raw input before they reach a UI pass. + injected_events: Vec, + /// The batch came from the control channel (private clipboard and provenance). + injected_private: bool, + /// What the document, dialog and focus were when that batch was injected. + injected_generation: InputGeneration, + /// The batch is in the host's pending raw input and not yet consumed by a settled pass. + injected_pending: std::sync::Arc, + /// A settled `run_ui` pass ended since the batch was injected; the next host iteration + /// acknowledges it. This also covers harnesses that consume the raw input without `app.ui`. + injected_settled: std::sync::Arc, + /// The end-of-pass settlement observer is installed on the egui context. + injected_settle_hook: bool, + /// Native dropped files with the input generation they were captured for, buffered from the + /// raw input before a pass so hidden logic cannot replay a previous drop. + #[cfg(not(target_arch = "wasm32"))] + pending_drops: Vec, + #[cfg(test)] + logic_tick_ticks: u32, + #[cfg(test)] + logic_frame_ticks: u32, + #[cfg(test)] + input_ticks: u32, styled: bool, /// The interface language the UI fonts were installed for (it orders the CJK fallbacks). fonts_lang: String, @@ -621,9 +718,9 @@ pub struct DesignApp { /// Shortcuts the native menu handles (skip them in the egui shortcut handler). pub native_shortcuts: std::collections::HashSet, last_time: f64, - /// When recovery data was last written (seconds, egui time). + /// When recovery data was last written (seconds on the monotonic clock; see [`now_ms`]). pub last_recovery: f64, - /// The egui context (set by the first frame): copied text goes to the system clipboard through it. + /// The egui context (set by the first UI pass): copied text goes to the system clipboard through it. egui_ctx: Option, /// A control-channel request is being handled: commands it runs never read or write the /// user's system clipboard (a control client must not see or replace it). @@ -640,8 +737,21 @@ pub struct DesignApp { /// The canvas asked for the system IME in the last frame (the web build's text input has the /// keyboard then). pub(crate) canvas_ime: bool, + /// Owner/generation/cause bookkeeping for the canvas story's custom IME producer. + pub(crate) ime_tracker: egui::ime_context::ImeContextTracker, + /// An IME delete-surrounding request waiting for the Commit in the same event batch. + pub(crate) pending_ime_delete: Option<(usize, usize, bool)>, + /// The owner the current composition began in: a late commit may only land there. + pub(crate) ime_target: Option, + /// This pass dropped a late commit or delete for a composition that had already ended: the + /// system IME must interrupt its copy (as for a click away). + pub(crate) ime_interrupt: bool, + /// What the canvas recorded for this pass's IME observation, applied at the end of the pass. + pub(crate) ime_canvas: Option, /// Quit was chosen: close the window on the next frame, as its close button does. quit_requested: bool, + /// An OS close event deferred while a synthetic gesture or native action is pending. + close_requested_pending: bool, /// Close the window without asking about unsaved changes (the control channel's `app.quit`). pub(crate) quit_without_asking: bool, /// No document is discarded until every unsaved document has an up-to-date approval. @@ -669,12 +779,16 @@ impl DesignApp { pub fn new(session: Session, services: Services) -> Self { // The font menus and the first file opened need the installed fonts: catalog them now. designcraft_fonts::FontDb::global().scan_in_background(); + // The app owns the monotonic origin for recovery timing and frame pacing; `now_ms` itself + // is process-global so screenshot deadlines stay comparable across hosts. + let now = now_ms() / 1000.0; DesignApp { pending_pdf: None, session, ui: UiState::default(), services, views: HashMap::new(), + page_history: HashMap::new(), canvas: CanvasCache::new(), canvas_rect: None, split: false, @@ -687,6 +801,8 @@ impl DesignApp { perf: Perf::default(), synthetic: vec![], control_synthetic: vec![], + native_actions: Default::default(), + native_events_pending: Default::default(), control_input_frame: Default::default(), clipboard_guard_installed: false, story_editor: None, @@ -694,6 +810,23 @@ impl DesignApp { pending_shots: vec![], queued_shots: vec![], shot_token: 0, + screenshot_geometry: None, + screenshot_epoch: 0, + synthetic_injected: false, + injected_events: Vec::new(), + injected_private: false, + injected_generation: InputGeneration::default(), + injected_pending: Default::default(), + injected_settled: Default::default(), + injected_settle_hook: false, + #[cfg(not(target_arch = "wasm32"))] + pending_drops: Vec::new(), + #[cfg(test)] + logic_tick_ticks: 0, + #[cfg(test)] + logic_frame_ticks: 0, + #[cfg(test)] + input_ticks: 0, styled: false, fonts_lang: String::new(), fonts_system: false, @@ -706,15 +839,21 @@ impl DesignApp { pending_window_commands: vec![], native_menu: false, native_shortcuts: Default::default(), - last_time: 0.0, - last_recovery: 0.0, + last_time: now, + last_recovery: now, egui_ctx: None, in_control: false, object_clip_text: None, ime_marked: None, ime_discard: false, canvas_ime: false, + ime_tracker: egui::ime_context::ImeContextTracker::new(), + pending_ime_delete: None, + ime_target: None, + ime_interrupt: false, + ime_canvas: None, quit_requested: false, + close_requested_pending: false, quit_without_asking: false, quitting: None, cursors: cursors::Images::default(), @@ -739,6 +878,13 @@ impl DesignApp { let k = self.view_key()?; Some(self.views.entry(k).or_default()) } + pub(crate) fn page_history(&self) -> Option<&page_history::PageHistory> { + self.page_history.get(&self.view_key()?) + } + pub(crate) fn page_history_mut(&mut self) -> Option<&mut page_history::PageHistory> { + let k = self.view_key()?; + Some(self.page_history.entry(k).or_default()) + } /// Make `pane` current: its canvas cache and rect move in. pub fn switch_pane(&mut self, pane: u8) { if pane == self.pane { @@ -805,6 +951,11 @@ impl DesignApp { self.canvas_ime } + /// The second window uses a stable viewport id; forget its IME context when it closes. + pub(crate) fn end_second_ime_viewport(&mut self) { + self.ime_tracker.end_viewport(egui::ViewportId::from_hash_of("designcraft_second_window")); + } + /// ": " in the status bar: the feature name is translated, the engine's own /// error text stays as the engine wrote it. pub fn status_error(&mut self, feature: &str, error: impl std::fmt::Display) { @@ -1071,7 +1222,7 @@ impl DesignApp { } } let Some(inbox) = self.services.inbox.clone() else { return }; - let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())); + let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); for file in files { if file.request.purpose == "shortcuts" { shortcut_import::import_delivered(self, &file.bytes); @@ -1137,8 +1288,51 @@ impl DesignApp { self.ui.flyout = None; } - /// Per-frame logic before layout. + /// Queue and deadline work, run once per app callback. + /// + /// `logic_tick` runs on every call (a first pass, a later discarded pass, or a hidden + /// `run_logic` tick) and consumes queues and deadlines once. Work that is genuinely once per + /// presented layout frame lives in [`Self::ui_setup`] / [`Self::ui_frame_end`], which only a + /// real `run_ui` pass calls. A hidden `run_logic` tick also reports pass index zero, so the + /// pass index alone cannot identify a UI pass. pub fn logic(&mut self, ctx: &egui::Context) { + #[cfg(test)] + { + self.logic_tick_ticks += 1; + } + self.logic_tick(ctx); + } + + /// Work whose identity is a consumed queue item, revision or deadline. + fn logic_tick(&mut self, ctx: &egui::Context) { + self.update_screenshot_epoch(ctx); + self.drain_control(ctx); + if self.fonts_ready && !self.host_order_pending() { + self.drain_inbox(); + self.open_os_documents(); + } + if self.host_order_pending() { + ctx.request_repaint(); + } + self.collect_screenshots(ctx); + // Ending a stale preview is state cleanup after a control/menu action, not a frame effect: + // it stays here so a hidden tick still closes a preview whose dialog has gone. + if !self.host_order_pending() { + new_document::end_stale_preview(self); + } + self.request_hidden_paint_for_pending_input(ctx); + self.handle_quit(ctx); + self.flush_window_commands(ctx); + } + + /// The first pass of a real `run_ui`: fonts, theme, scale and window state, before any widget + /// is laid out. It must run before the font gate in [`Self::ui`], or the app would wait for + /// fonts that this function is what installs. + pub(crate) fn ui_setup(&mut self, ctx: &egui::Context) { + #[cfg(test)] + { + self.logic_frame_ticks += 1; + } if self.egui_ctx.is_none() { self.egui_ctx = Some(ctx.clone()); } @@ -1190,7 +1384,12 @@ impl DesignApp { theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); self.restyle = false; } - let now = ctx.input(|i| i.time); + } + + /// The end of a settled `run_ui` pass: pacing, recovery and screenshot settlement. Recovery + /// uses the monotonic clock, so a wall-clock/NTP jump cannot postpone or advance it. + fn ui_frame_end(&mut self, ctx: &egui::Context) { + let now = now_ms() / 1000.0; // Crash recovery: unsaved documents are written to the recovery folder every 30 s. #[cfg(not(target_arch = "wasm32"))] if self.session.recovery_dir.is_some() && now - self.last_recovery > (self.session.prefs.recovery_minutes * 60.0).max(5.0) { @@ -1206,32 +1405,34 @@ impl DesignApp { self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; } self.last_time = now; - self.drain_control(ctx); - new_document::end_stale_preview(self); - if self.fonts_ready { - self.drain_inbox(); - self.open_os_documents(); - } - if !self.synthetic.is_empty() || !self.control_synthetic.is_empty() { - ctx.request_repaint(); - } - self.collect_screenshots(ctx); self.issue_screenshots(ctx); - if self.fonts_ready { - menus::shortcuts(self, ctx); + } + + /// Keep pending synthetic input and screenshot settlement repainting while the window may be + /// hidden. + /// + /// `visible() == None` means unknown and is treated like hidden. The command holds for one + /// frame and is sent once per tick until a settled UI pass has taken the injected step or + /// issued the queued capture. + fn request_hidden_paint_for_pending_input(&self, ctx: &egui::Context) { + let pending = self.host_order_pending() || !self.queued_shots.is_empty(); + if !pending || ctx.input(|i| i.viewport().visible()) == Some(true) { + return; } - self.flush_window_commands(ctx); + // The 0.37 web backend ignores `RequestPaintWhileHidden` until the fork carries it. Do not + // pretend a hidden-tab capture or gesture will run there; the request times out instead. #[cfg(not(target_arch = "wasm32"))] - { - let (dropped, pointer) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.hover_pos())); - for f in dropped { - let p = f.path().to_string_lossy().to_string(); - if !p.is_empty() { - let _ = self.open_dropped_at(&p, pointer); - } - } + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + + /// Advance the screenshot geometry epoch whenever size, DPI, zoom or surface change. + fn update_screenshot_epoch(&mut self, ctx: &egui::Context) { + let Some(mut geometry) = screenshot_geometry(ctx) else { return }; + geometry.document = self.active_document_epoch(); + if self.screenshot_geometry != Some(geometry) { + self.screenshot_geometry = Some(geometry); + self.screenshot_epoch = self.screenshot_epoch.wrapping_add(1); } - self.handle_quit(ctx); } /// Quit: close the window the way its close button does, so unsaved documents ask first. @@ -1242,10 +1443,20 @@ impl DesignApp { /// Ask about unsaved documents without closing any of them yet. A later Cancel must also /// keep documents whose earlier answer was Don't Save, including their history and recovery. fn handle_quit(&mut self, ctx: &egui::Context) { - if std::mem::take(&mut self.quit_requested) { + let close_requested = ctx.input(|i| i.viewport().close_requested()); + if self.host_order_pending() { + if close_requested { + self.close_requested_pending = true; + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + ctx.request_repaint(); + return; + } + let replay_close = std::mem::take(&mut self.close_requested_pending); + if std::mem::take(&mut self.quit_requested) || replay_close { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } - if ctx.input(|i| i.viewport().close_requested()) { + if close_requested || replay_close { if self.quit_without_asking { self.quitting = None; return; @@ -1505,31 +1716,207 @@ impl DesignApp { self.clipboard_guard_installed = true; } - /// Inject synthetic events (one pointer event per frame), retaining control provenance. - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { - let private = !self.control_synthetic.is_empty(); + /// Inject synthetic events (one pointer event per host iteration), retaining control + /// provenance until a settled UI pass has actually taken the batch. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + if !self.injected_settle_hook { + let pending = self.injected_pending.clone(); + let settled = self.injected_settled.clone(); + ctx.on_end_pass( + "injected input settlement", + std::sync::Arc::new(move |ui| { + if ui.ctx().viewport_id() == egui::ViewportId::ROOT + && pending.load(std::sync::atomic::Ordering::Relaxed) + && !ui.ctx().will_discard() + { + settled.store(true, std::sync::atomic::Ordering::Relaxed); + } + }), + ); + self.injected_settle_hook = true; + } + // A settled pass that did not go through `DesignApp::ui` (a harness consuming raw input + // directly) still consumed the batch; acknowledge it before considering the next step. + if self.injected_settled.swap(false, std::sync::atomic::Ordering::Relaxed) { + self.synthetic_injected = false; + self.injected_events.clear(); + self.injected_pending.store(false, std::sync::atomic::Ordering::Relaxed); + self.injected_private = false; + } + #[cfg(not(target_arch = "wasm32"))] + { + let pointer = raw.events.iter().rev().find_map(|event| match event { + egui::Event::PointerMoved(pos) | egui::Event::PointerButton { pos, .. } => Some(*pos), + _ => None, + }); + let generation = self.input_generation(); + for file in std::mem::take(&mut raw.dropped_files) { + let path = file.path().to_string_lossy().to_string(); + if !path.is_empty() { + self.pending_drops.push(PendingDrop { path, pointer, generation }); + } + } + } + self.discard_stale_injected_batch(raw); + // A batch acknowledged by the previous pass keeps its private provenance through that + // pass's end hook (the clipboard guard), and is cleared at the next host iteration. + if !self.synthetic_injected { + self.injected_private = false; + } + let private = self.injected_private || !self.control_synthetic.is_empty(); self.control_input_frame.store(private, std::sync::atomic::Ordering::Relaxed); + // While the previous batch is still unapplied (it lives in eframe's pending raw input), + // inject nothing more: one pointer step per UI pass, not per hidden logic tick. + if self.synthetic_injected { + return; + } + let private = !self.control_synthetic.is_empty(); let events = if private { &mut self.control_synthetic } else { &mut self.synthetic }; let Some(first) = events.first() else { return }; let n = match first { egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } => 1, _ => events.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(events.len(), |i| i + 1), }; - if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { - raw.events.push(egui::Event::PointerMoved(*p)); + let batch: Vec = events.drain(..n).collect(); + self.injected_generation = self.input_generation(); + self.injected_private = private; + self.injected_events = batch.clone(); + self.injected_pending.store(true, std::sync::atomic::Ordering::Relaxed); + raw.events.extend(batch); + // Held until a settled UI pass runs with the fonts installed; a hidden logic tick, a + // discarded multipass first pass or a font-gated early return must not acknowledge it. + self.synthetic_injected = true; + } + + /// Notify after enqueuing a native menu event, before waking the host. The returned callback + /// owns its state so the event handler never borrows the app. + pub fn native_action_notifier(&self, ctx: egui::Context) -> Box { + let pending = self.native_events_pending.clone(); + Box::new(move || { + pending.store(true, std::sync::atomic::Ordering::Release); + ctx.request_repaint(); + }) + } + + /// Poll from the first real UI pass, under the host's normal UI/watchdog guard. Resolve menu + /// choices before refreshing their IDs, then execute them once synthetic input has settled. + /// Event arrival already blocks logic deliveries, so a deferred action keeps its ordering + /// without executing native dialogs or menu commands from a hidden logic-only tick. + pub fn poll_native_actions(&mut self, ctx: &egui::Context, mut next: impl FnMut() -> Option<(String, Value)>) { + // Clear before draining: a notification arriving during the drain stays set for the + // next pass, even when its channel send races the final empty receive. + self.native_events_pending.swap(false, std::sync::atomic::Ordering::AcqRel); + while let Some((id, params)) = next() { + self.native_actions.push_back((id, params)); } - raw.events.extend(events.drain(..n)); + self.apply_native_actions(ctx); + } + + fn synthetic_input_pending(&self) -> bool { + self.synthetic_injected || !self.control_synthetic.is_empty() || !self.synthetic.is_empty() + } + + fn host_order_pending(&self) -> bool { + self.synthetic_input_pending() || !self.native_actions.is_empty() || self.native_events_pending.load(std::sync::atomic::Ordering::Acquire) + } + + fn apply_native_actions(&mut self, ctx: &egui::Context) { + while !self.synthetic_input_pending() { + let Some((id, params)) = self.native_actions.pop_front() else { break }; + menus::activate_native(self, ctx, &id, ¶ms); + } + if self.host_order_pending() { + ctx.request_repaint(); + } + } + + /// The current state an injected batch belongs to. + fn input_generation(&self) -> InputGeneration { + InputGeneration { + document: self.active_document_epoch(), + dialog: self.ui.dialog.as_ref().map(|dialog| egui::Id::unique(&dialog.id)), + focus: self.egui_ctx.as_ref().and_then(|ctx| ctx.memory(|memory| memory.focused())), + } + } + + /// Drop a batch whose document, dialog or focus changed before a UI pass could take it. + /// The batch is still in the host's pending raw input; remove exactly the events we appended + /// (contiguously, in order) so a subsequent forced paint cannot replay stale input. + fn discard_stale_injected_batch(&mut self, raw: &mut egui::RawInput) { + if !self.synthetic_injected || self.input_generation() == self.injected_generation { + return; + } + let events = std::mem::take(&mut self.injected_events); + if !events.is_empty() + && let Some(start) = raw.events.windows(events.len()).rposition(|window| window == events.as_slice()) + { + raw.events.drain(start..start + events.len()); + } + self.synthetic_injected = false; + self.injected_pending.store(false, std::sync::atomic::Ordering::Relaxed); + self.injected_private = false; + self.control_input_frame.store(false, std::sync::atomic::Ordering::Relaxed); + } + + /// A font-gated first pass saw the events before any widget did. Put the same batch back at + /// the front of its queue (with the original provenance) so a later UI pass consumes it. + fn requeue_injected_batch(&mut self) { + if !std::mem::take(&mut self.synthetic_injected) { + return; + } + let events = std::mem::take(&mut self.injected_events); + if !events.is_empty() { + if self.injected_private { + self.control_synthetic.splice(0..0, events); + } else { + self.synthetic.splice(0..0, events); + } + } + self.injected_pending.store(false, std::sync::atomic::Ordering::Relaxed); + self.injected_private = false; + self.control_input_frame.store(false, std::sync::atomic::Ordering::Relaxed); } /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { + #[cfg(test)] + { + self.input_ticks += 1; + } let ctx = ui.ctx().clone(); self.canvas_ime = false; + self.ime_canvas = None; + self.ime_interrupt = false; + // Fonts/theme must be installed from the real UI pass, before the font gate below: doing + // this only from a hidden logic tick would make the first shown pass wait forever. + if ctx.current_pass_index() == 0 { + self.ui_setup(&ctx); + } if !self.fonts_ready { + // The first pass installs fonts and returns before any widget sees this frame's input. + // Put an injected step back rather than acknowledge it as consumed. + self.requeue_injected_batch(); ctx.request_repaint(); return; } self.settle_live_preview(); + // Input consumers run once, in the live first UI pass, before layout. Hidden logic ticks + // must not replay shortcuts or dropped files from stale input. + if ctx.current_pass_index() == 0 { + #[cfg(not(target_arch = "wasm32"))] + { + // Sample the generation once for the whole batch: placing the first valid file + // must not suppress later files that belong to the same captured batch. + let current = self.input_generation(); + for drop in std::mem::take(&mut self.pending_drops) { + if drop.generation != current { + continue; + } + let _ = self.open_dropped_at(&drop.path, drop.pointer); + } + } + menus::shortcuts(self, &ctx); + } let t0 = now_ms(); let t = theme::Tokens::get(&ctx); let presenting = self.ui.screen_mode == ScreenMode::Presentation; @@ -1642,12 +2029,18 @@ impl DesignApp { canvas::show(self, ui); }); if ui.input(|i| i.viewport().close_requested()) { + self.end_second_ime_viewport(); self.second_window = false; self.focus_pane = 0; } }, ); self.switch_pane(self.focus_pane); + } else if self.second_window { + // The child viewport does not run this pass (Presentation, or no document for its + // title): its pass counter stops advancing, so treat it as gone and let the tracker + // start a fresh context when the window is shown again. + self.end_second_ime_viewport(); } // Tab, Shift+Tab and Presentation hide the floating panels with the docked ones. if !presenting && self.ui.hidden_panels == 0 { @@ -1658,84 +2051,320 @@ impl DesignApp { dialogs::show(self, &ctx); about::show(self, &ctx); menus::palette(self, &ctx); + // The IME is observed once, here: after the canvas, the floating panels, the story editor, + // dialogs and Quick Apply have all applied this pass's commands and focus changes. + canvas::ime_observe(self, &ctx); if self.custom_titlebar { chrome::resize_borders(self, ui); } for url in std::mem::take(&mut self.ui.pending_urls) { ctx.open_url(egui::OpenUrl::new_tab(url)); } + if !ctx.will_discard() { + // A settled UI pass has taken the injected step (a discarded pass has not). Keep + // `injected_private` until the next raw-input hook, so this pass's end-pass clipboard + // guard still sees the control provenance. + self.synthetic_injected = false; + self.injected_events.clear(); + self.injected_pending.store(false, std::sync::atomic::Ordering::Relaxed); + self.injected_settled.store(false, std::sync::atomic::Ordering::Relaxed); + self.ui_frame_end(&ctx); + } self.perf.frame_ms = now_ms() - t0; } fn drain_control(&mut self, ctx: &egui::Context) { + // An injected batch may still be waiting in the host's pending raw input. Running another + // control command now could switch documents or dialogs under it, so keep the channel + // queued until a settled UI pass has taken the batch. + if self.host_order_pending() { + return; + } let Some(rx) = self.control_rx.take() else { return }; - while let Ok(req) = rx.try_recv() { + while !self.host_order_pending() { + let Ok(req) = rx.try_recv() else { break }; let reply = req.reply.clone(); match control::handle(self, ctx, &req) { control::Outcome::Done(v) => { let _ = reply.send(v); } control::Outcome::Screenshot { path } => { - self.shot_token += 1; - let token = self.shot_token; - self.queued_shots.push((token, now_ms() + 120.0, 0)); - self.pending_shots.push((token, path, reply, now_ms() + 8000.0)); + if let Err(e) = self.admit_screenshot(path, reply.clone()) { + let _ = reply.send(e); + } } } } self.control_rx = Some(rx); } + /// Admit one `ui.screenshot` request against the app's count and byte budgets. + /// + /// Admission happens before any capture is issued: the checked, current viewport dimensions + /// reserve their RGBA bytes, and queued plus completed images are counted together. + fn admit_screenshot(&mut self, path: Option, reply: Sender) -> Result { + let Some(mut geometry) = self.screenshot_geometry else { + return Err(json!({"ok": false, "error": "the window geometry is not known yet"})); + }; + // The cached geometry may predate a document switch handled earlier in this same logic + // tick. Admit for the current document, not for the one the cache last saw. + geometry.document = self.active_document_epoch(); + if self.pending_shots.len() >= SCREENSHOT_MAX_REQUESTS { + return Err(json!({"ok": false, "error": format!("at most {SCREENSHOT_MAX_REQUESTS} screenshot requests may be outstanding")})); + } + let reserved_bytes = geometry + .width + .checked_mul(geometry.height) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| json!({"ok": false, "error": "the screenshot dimensions overflow the addressable size"}))?; + if reserved_bytes > SCREENSHOT_MAX_IMAGE_BYTES { + return Err(json!({ + "ok": false, + "error": format!( + "a screenshot of {}×{} exceeds the {SCREENSHOT_MAX_IMAGE_BYTES}-byte image limit", + geometry.width, geometry.height + ) + })); + } + let used = self.pending_shots.iter().try_fold(0usize, |total, shot| total.checked_add(shot.reserved_bytes)); + let over_budget = used.and_then(|used| used.checked_add(reserved_bytes)).is_none_or(|total| total > SCREENSHOT_MAX_AGGREGATE_BYTES); + if over_budget { + return Err(json!({"ok": false, "error": format!("the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte screenshot budget is exhausted")})); + } + self.shot_token += 1; + let token = self.shot_token; + let now = now_ms(); + self.queued_shots.push((token, now + 120.0, 0)); + self.pending_shots.push(ShotRequest { + token, + path, + reply, + deadline: now + 8000.0, + viewport: egui::ViewportId::ROOT, + epoch: self.screenshot_epoch, + geometry, + reserved_bytes, + slot: None, + }); + Ok(token) + } + + /// The current document state (`0` when none is open). Revert preserves the UID but + /// gives the replacement state a new epoch, invalidating old captures and input. + fn active_document_epoch(&self) -> u64 { + self.session.active().map_or(0, |document| document.identity_epoch) + } + + /// Reject one admitted request: remove its admission and queued token and cancel any live + /// capture slot before replying with the error. + fn reject_screenshot(&mut self, index: usize, message: &str) { + let shot = self.pending_shots.remove(index); + self.queued_shots.retain(|(token, ..)| *token != shot.token); + if let Some(slot) = &shot.slot { + slot.cancel(); + } + let _ = shot.reply.send(json!({"ok": false, "error": message})); + } + + /// Settle and issue queued captures. Called only at the end of a settled `run_ui` pass, so + /// `frames` counts actual layout passes; hidden logic ticks never satisfy the three-frame + /// settling policy on their own. fn issue_screenshots(&mut self, ctx: &egui::Context) { let now = now_ms(); + let mut due = Vec::new(); self.queued_shots.retain_mut(|(token, at, frames)| { *frames += 1; if now >= *at && *frames >= 3 { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); + due.push(*token); false } else { true } }); + for token in due { + let Some(index) = self.pending_shots.iter().position(|shot| shot.token == token) else { continue }; + if self.pending_shots[index].geometry.document != self.active_document_epoch() { + self.reject_screenshot(index, "the active document changed before the screenshot was issued"); + continue; + } + let Some(current) = self.screenshot_geometry else { + let shot = self.pending_shots.remove(index); + let _ = shot.reply.send(json!({"ok": false, "error": "the window geometry is not known yet"})); + continue; + }; + // Re-check the current dimensions before issuing: a request admitted before a resize, + // DPI or zoom change must renew its checked reservation (or fail, never overrun it). + if self.pending_shots[index].epoch != self.screenshot_epoch { + let bytes = current.width.checked_mul(current.height).and_then(|pixels| pixels.checked_mul(4)); + let others = self + .pending_shots + .iter() + .enumerate() + .filter(|(i, _)| *i != index) + .try_fold(0usize, |total, (_, shot)| total.checked_add(shot.reserved_bytes)); + let reserved = bytes.zip(others).and_then(|(bytes, others)| others.checked_add(bytes).map(|total| (bytes, total))); + let fits = reserved.is_some_and(|(bytes, total)| bytes <= SCREENSHOT_MAX_IMAGE_BYTES && total <= SCREENSHOT_MAX_AGGREGATE_BYTES); + let Some((bytes, _)) = reserved else { + let shot = self.pending_shots.remove(index); + let _ = shot.reply.send(json!({ + "ok": false, + "error": "the window changed and the screenshot no longer fits the admitted budget" + })); + continue; + }; + if !fits { + let shot = self.pending_shots.remove(index); + let _ = shot.reply.send(json!({ + "ok": false, + "error": "the window changed and the screenshot no longer fits the admitted budget" + })); + continue; + } + let shot = &mut self.pending_shots[index]; + shot.epoch = self.screenshot_epoch; + shot.geometry = current; + shot.reserved_bytes = bytes; + } + let shot = &mut self.pending_shots[index]; + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, shot.viewport, SCREENSHOT_MAX_IMAGE_BYTES); + shot.slot = Some(slot); + // The callback holds only a weak slot: it never captures the app, writes a file, runs a + // command or wakes the context itself (the slot's live request does that). + ctx.send_viewport_cmd_to(shot.viewport, egui::ViewportCommand::Screenshot(callback)); + } if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } + // The screenshot command itself forces one hidden paint; the settling frames before it do + // not, so ask for them while the window may be hidden. The 0.37 web backend does not yet + // honor this command (fork follow-up): hidden-tab captures time out there rather than + // pretending to settle. + if !self.queued_shots.is_empty() && ctx.input(|i| i.viewport().visible()) != Some(true) { + #[cfg(not(target_arch = "wasm32"))] + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } } + /// Take completed captures out of their slots and write files only on this (UI) thread. fn collect_screenshots(&mut self, ctx: &egui::Context) { if self.pending_shots.is_empty() { return; } - let events: Vec<_> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; - Some((token, image.clone())) - } - _ => None, - }) - .collect() - }); - for (token, image) in events { - if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) { - let (_, path, reply, _) = self.pending_shots.remove(i); - let _ = reply.send(control::save_screenshot(self, &image, path.as_deref())); - } - } let now = now_ms(); - self.pending_shots.retain(|(_, _, reply, deadline)| { - if now < *deadline { - return true; + let current_document = self.active_document_epoch(); + let mut i = 0; + while i < self.pending_shots.len() { + // Check before taking the callback: a request whose document changed must not write, + // retry, or wait for its slot, even when no frame has completed yet. + if self.pending_shots.get(i).is_some_and(|shot| shot.geometry.document != current_document) { + self.reject_screenshot(i, "the active document changed while the screenshot was pending"); + continue; } - let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use ui.render"})); - false - }); + let taken = self.pending_shots.get(i).and_then(|shot| shot.slot.as_ref()).and_then(|slot| slot.take()); + let Some(result) = taken else { + if now >= self.pending_shots[i].deadline { + let shot = self.pending_shots.remove(i); + self.queued_shots.retain(|(token, ..)| *token != shot.token); + if let Some(slot) = &shot.slot { + slot.cancel(); + } + let _ = shot.reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use ui.render"})); + } else { + i += 1; + } + continue; + }; + let (epoch_changed, within_reserved) = { + let shot = &self.pending_shots[i]; + let within = match &result { + Ok(image) => { + let [width, height] = image.size; + width <= shot.geometry.width && height <= shot.geometry.height + } + Err(_) => true, + }; + (shot.epoch != self.screenshot_epoch, within) + }; + if !epoch_changed && within_reserved { + let shot = self.pending_shots.remove(i); + self.queued_shots.retain(|(token, ..)| *token != shot.token); + let value = match result { + Ok(image) => control::save_screenshot(self, image.as_ref(), shot.path.as_deref()), + Err(e) => json!({"ok": false, "error": e.to_string()}), + }; + let _ = shot.reply.send(value); + continue; + } + let (retry, retry_token) = { + let shot = &self.pending_shots[i]; + (now < shot.deadline, shot.token) + }; + if let Some(slot) = self.pending_shots[i].slot.take() { + slot.cancel(); + } + if !retry { + let shot = self.pending_shots.remove(i); + self.queued_shots.retain(|(token, ..)| *token != shot.token); + let message = match result { + Err(e) => e.to_string(), + Ok(_) if epoch_changed => "the window changed while the screenshot was captured".to_string(), + Ok(_) => "the screenshot is larger than the admitted viewport".to_string(), + }; + let _ = shot.reply.send(json!({"ok": false, "error": message})); + continue; + } + // Reject the stale pixels and retry within the same deadline with a fresh epoch. + self.queued_shots.push((retry_token, now + 120.0, 0)); + i += 1; + } + if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } } } +/// The physical capture surface of the root viewport, from its logical rect and DPI. +fn screenshot_geometry(ctx: &egui::Context) -> Option { + let zoom = ctx.zoom_factor(); + let fallback = ctx.pixels_per_point(); + let (rect, native, fullscreen) = ctx.input(|i| { + let viewport = i.viewport(); + // Web (eframe's web backend) and egui_kittest set `screen_rect` but not `inner_rect`; + // both describe the same logical content rect for this viewport. + (viewport.inner_rect.or(i.raw.screen_rect), viewport.native_pixels_per_point, viewport.fullscreen) + }); + let rect = rect?; + let pixels_per_point = match native { + // `native_pixels_per_point` is before egui's zoom + // (`Context::pixels_per_point() == zoom_factor * native_pixels_per_point`), so apply the + // zoom exactly once. The fallback already includes it. + Some(native) => native * zoom, + None => fallback, + }; + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return None; + } + let width = (rect.width() * pixels_per_point).ceil(); + let height = (rect.height() * pixels_per_point).ceil(); + if !width.is_finite() || !height.is_finite() || width < 1.0 || height < 1.0 { + return None; + } + Some(ScreenshotGeometry { + width: width as usize, + height: height as usize, + document: 0, + signature: [ + rect.width().to_bits(), + rect.height().to_bits(), + pixels_per_point.to_bits(), + u32::from(fullscreen.unwrap_or(false)), + zoom.to_bits(), + // Reserved for a surface-generation counter once a backend exposes one. + 0, + ], + }) +} + /// Whether CJK-only interface is shown (Preferences › Type › Show CJK Features): the explicit /// preference, else on for a Japanese, Chinese or Korean interface language. /// @@ -1755,16 +2384,15 @@ pub fn opens_as_document(name: &str) -> bool { [".designcraft", ".idml", ".indd", ".indt"].iter().any(|ext| n.ends_with(ext)) } +/// Milliseconds from a monotonic process-local origin, on native and web alike. +/// +/// Screenshot settlement, deadlines and retries must not use wall time: an NTP/manual clock jump +/// would expire or postpone a capture, and `Context::input(i.time)` is stale inside `run_logic`. +/// `web_time::Instant` is `std::time::Instant` natively and `performance.now()` on wasm. pub fn now_ms() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) - } - #[cfg(target_arch = "wasm32")] - { - 0.0 - } + use std::sync::OnceLock; + static START: OnceLock = OnceLock::new(); + START.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() * 1000.0 } /// Named `KeyboardEvent.key` values (W3C UI Events) that a browser reports for a key that types @@ -1835,10 +2463,10 @@ mod tests { app, ); h.run_steps(4); - let bar = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("control_bar")).unwrap().outer_rect; + let bar = egui::containers::panel::PanelState::load(&h.ctx, h.ui_id().with("control_bar")).unwrap().outer_rect; assert!(bar.height() > 40.0, "{bar:?}"); // The X/Y/W/H group: its second row holds Y: and H:. - let group = h.ctx.read_response(egui::Id::new(("object_control", "dimensions"))).unwrap().rect; + let group = h.ctx.read_response(egui::Id::unique(("object_control", "dimensions"))).unwrap().rect; assert!(bar.contains(group.left_top()), "the X/Y/W/H group {group:?} is in the Control panel {bar:?}"); assert!(group.bottom() <= bar.bottom(), "X/Y/W/H group {group:?} hangs below the Control panel {bar:?}"); } @@ -2715,6 +3343,1119 @@ mod tests { app.run("text.select", json!({"story": story, "anchor": 2})).unwrap(); assert_eq!(app.run("edit.paste", json!({})).unwrap()["anchored"], 1); } + + // --------------------------------------------------------------------------------------- + // egui 0.37 lifecycle, hidden input and screenshot completion regressions. + // --------------------------------------------------------------------------------------- + + /// One frame of the real callback order the native and web hosts use. + fn shot_frame(app: &mut DesignApp, ctx: &egui::Context, size: egui::Vec2) -> egui::FullOutput { + shot_frame_at(app, ctx, egui::Rect::from_min_size(egui::Pos2::ZERO, size), true) + } + + /// One frame at a chosen window rect. `inner_rect = false` is the web/egui_kittest shape: + /// `screen_rect` is set but `ViewportInfo::inner_rect` is not. + fn shot_frame_at(app: &mut DesignApp, ctx: &egui::Context, rect: egui::Rect, inner_rect: bool) -> egui::FullOutput { + let mut raw = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { + inner_rect: inner_rect.then_some(rect), + native_pixels_per_point: Some(1.0), + minimized: Some(false), + occluded: Some(false), + ..Default::default() + }, + ); + let mut output = ctx.run_ui(raw, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }); + output.textures_delta.clear(); + output + } + + /// A hidden host iteration: the raw input is prepared and then only the logic callback runs. + fn hidden_tick(app: &mut DesignApp, ctx: &egui::Context, raw: &mut egui::RawInput) -> egui::LogicOutput { + app.raw_input_hook(ctx, raw); + ctx.run_logic(raw, |ctx| app.logic(ctx)) + } + + fn hidden_raw() -> egui::RawInput { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0)); + let mut raw = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { + inner_rect: Some(rect), + native_pixels_per_point: Some(1.0), + minimized: Some(true), + occluded: Some(true), + ..Default::default() + }, + ); + raw + } + + fn hidden_paints(output: &egui::LogicOutput) -> usize { + output + .viewport_commands + .get(&egui::ViewportId::ROOT) + .map_or(0, |commands| commands.iter().filter(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)).count()) + } + + fn screenshot_callback(output: &mut egui::FullOutput) -> Option { + let viewport = output.viewport_output.get_mut(&egui::ViewportId::ROOT)?; + for command in &mut viewport.commands { + if matches!(command, egui::ViewportCommand::Screenshot(_)) { + let egui::ViewportCommand::Screenshot(callback) = std::mem::replace(command, egui::ViewportCommand::Focus) else { + return None; + }; + return Some(callback); + } + } + None + } + + fn screenshot_app() -> (DesignApp, egui::Context, std::sync::Arc>>) { + let writes: std::sync::Arc>> = Default::default(); + let sink = writes.clone(); + let mut app = DesignApp::new( + Session::new(), + Services { + write: Some(Box::new(move |path, bytes| { + sink.lock().unwrap().push((path.to_string(), bytes.len())); + Ok(()) + })), + ..Default::default() + }, + ); + app.run("file.new", json!({"title": "shots"})).unwrap(); + let ctx = egui::Context::default(); + for _ in 0..4 { + let mut output = shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + output.textures_delta.clear(); + } + (app, ctx, writes) + } + + #[test] + fn screenshot_callback_stays_weak_and_writes_only_from_logic() { + let (mut app, ctx, writes) = screenshot_app(); + let (reply, replies) = std::sync::mpsc::channel(); + let path = std::env::temp_dir().join(format!("designcraft-shot-{}.png", std::process::id())); + app.admit_screenshot(Some(path.to_string_lossy().to_string()), reply).unwrap(); + app.queued_shots[0].1 = 0.0; + let mut callback = None; + for _ in 0..3 { + let mut output = shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + if let Some(cb) = screenshot_callback(&mut output) { + callback = Some(cb); + } + } + let callback = callback.expect("the settling frames issue one capture"); + assert!(writes.lock().unwrap().is_empty()); + // The callback only fills its slot; writing happens in the next logic pass. + callback.complete(std::sync::Arc::new(egui::ColorImage::filled([640, 480], egui::Color32::BLACK))); + assert!(writes.lock().unwrap().is_empty(), "the callback must not write files"); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + let written = writes.lock().unwrap().clone(); + assert_eq!(written.len(), 1, "one file after the logic drain"); + assert_eq!(written[0].0, path.to_string_lossy()); + let reply = replies.recv().unwrap(); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 640); + assert_eq!(reply["result"]["height"], 480); + assert!(app.pending_shots.is_empty(), "the reservation is released"); + } + + #[test] + fn screenshot_admission_bounds_count_aggregate_and_image_bytes() { + let (mut app, ctx, _writes) = screenshot_app(); + let admit = |app: &mut DesignApp, path: &str| { + let (reply, _replies) = std::sync::mpsc::channel(); + app.admit_screenshot(Some(path.into()), reply) + }; + for i in 0..SCREENSHOT_MAX_REQUESTS { + assert!(admit(&mut app, &format!("shot-{i}.png")).is_ok(), "request {i}"); + } + let error = admit(&mut app, "too-many.png").unwrap_err(); + assert_eq!(error["ok"], false, "{error}"); + // Drain the admitted requests without replying so the byte-budget check is exercised. + app.pending_shots.clear(); + app.queued_shots.clear(); + for _ in 0..2 { + shot_frame(&mut app, &ctx, egui::vec2(5120.0, 5120.0)); + } + assert!(admit(&mut app, "hundred-a.png").is_ok()); + assert!(admit(&mut app, "hundred-b.png").is_ok()); + let error = admit(&mut app, "over-aggregate.png").unwrap_err(); + assert!(error["error"].as_str().unwrap_or_default().contains("budget"), "{error}"); + app.pending_shots.clear(); + app.queued_shots.clear(); + for _ in 0..2 { + shot_frame(&mut app, &ctx, egui::vec2(6000.0, 6000.0)); + } + let error = admit(&mut app, "too-large.png").unwrap_err(); + assert!(error["error"].as_str().unwrap_or_default().contains("image limit"), "{error}"); + } + + #[test] + fn screenshot_epoch_change_and_timeout_drop_stale_pixels() { + let (mut app, ctx, writes) = screenshot_app(); + let (reply, replies) = std::sync::mpsc::channel(); + app.admit_screenshot(Some("stale.png".into()), reply).unwrap(); + app.queued_shots[0].1 = 0.0; + let mut callback = None; + for _ in 0..3 { + let mut output = shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + if let Some(cb) = screenshot_callback(&mut output) { + callback = Some(cb); + } + } + let callback = callback.expect("issued"); + // Resize away and back: the epoch advances even though the size ends up the same. + shot_frame(&mut app, &ctx, egui::vec2(800.0, 600.0)); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + callback.clone().complete(std::sync::Arc::new(egui::ColorImage::filled([640, 480], egui::Color32::BLACK))); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert!(replies.try_recv().is_err(), "stale pixels must not be replied"); + assert!(!app.queued_shots.is_empty(), "the request is retried inside its deadline"); + assert!(writes.lock().unwrap().is_empty()); + // Let the deadline pass: the retry reports an error and a later callback is inert. + app.pending_shots[0].deadline = 0.0; + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + let reply = replies.try_recv().unwrap(); + assert_eq!(reply["ok"], false, "{reply}"); + callback.complete(std::sync::Arc::new(egui::ColorImage::filled([640, 480], egui::Color32::BLACK))); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert!(replies.try_recv().is_err(), "no second reply from a late callback"); + assert!(writes.lock().unwrap().is_empty()); + assert!(app.pending_shots.is_empty()); + } + + #[test] + fn screenshot_geometry_uses_screen_rect_when_inner_rect_is_absent() { + let (mut app, ctx, writes) = screenshot_app(); + app.screenshot_geometry = None; + app.screenshot_epoch = 0; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0)); + // Web eframe and egui_kittest set screen_rect but not ViewportInfo::inner_rect. + for _ in 0..2 { + shot_frame_at(&mut app, &ctx, rect, false); + } + let geometry = app.screenshot_geometry.expect("the screen rect is the logical viewport"); + assert_eq!((geometry.width, geometry.height), (640, 480), "the fallback is the same logical geometry"); + let (reply, replies) = std::sync::mpsc::channel(); + app.admit_screenshot(Some("web-like.png".into()), reply).expect("an inner-rect-less host admits screenshots"); + app.queued_shots[0].1 = 0.0; + let mut callback = None; + for _ in 0..3 { + if let Some(cb) = screenshot_callback(&mut shot_frame_at(&mut app, &ctx, rect, false)) { + callback = Some(cb); + } + } + callback.expect("the capture is issued").complete(std::sync::Arc::new(egui::ColorImage::filled([640, 480], egui::Color32::BLACK))); + shot_frame_at(&mut app, &ctx, rect, false); + let reply = replies.recv().unwrap(); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(writes.lock().unwrap().len(), 1); + } + + #[test] + fn screenshot_geometry_applies_native_dpi_and_zoom_once() { + let (mut app, ctx, _writes) = screenshot_app(); + app.ui.ui_scale = 1.5; + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(100.0, 50.0)); + let raw = { + let mut raw = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { inner_rect: Some(rect), native_pixels_per_point: Some(2.0), minimized: Some(false), ..Default::default() }, + ); + raw + }; + // The first pass applies the zoom; the next logic tick measures the new geometry. + for _ in 0..2 { + let mut raw = raw.clone(); + raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().inner_rect = Some(rect); + ctx.run_ui(raw, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }) + .textures_delta + .clear(); + } + let geometry = app.screenshot_geometry.expect("geometry"); + // 100 x 50 points at 2 physical pixels per point and 1.5 UI zoom: apply each exactly once. + assert_eq!((geometry.width, geometry.height), (300, 150)); + } + + #[test] + fn screenshot_epoch_ignores_window_position_but_tracks_size_changes() { + let (mut app, ctx, _writes) = screenshot_app(); + let before = app.screenshot_epoch; + let moved = egui::Rect::from_min_size(egui::pos2(120.0, 80.0), egui::vec2(640.0, 480.0)); + shot_frame_at(&mut app, &ctx, moved, true); + assert_eq!(app.screenshot_epoch, before, "moving the window is not a capture-surface change"); + shot_frame_at(&mut app, &ctx, egui::Rect::from_min_size(egui::pos2(120.0, 80.0), egui::vec2(800.0, 600.0)), true); + let resized = app.screenshot_epoch; + assert_ne!(resized, before, "a size change advances the epoch"); + shot_frame_at(&mut app, &ctx, egui::Rect::from_min_size(egui::pos2(120.0, 80.0), egui::vec2(640.0, 480.0)), true); + assert_ne!(app.screenshot_epoch, resized, "and resizing back advances it again"); + } + + #[test] + fn control_screenshot_replies_with_the_saved_capture() { + let (mut app, ctx, writes) = screenshot_app(); + let (requests, receiver) = std::sync::mpsc::channel(); + app = app.with_control(receiver); + let (reply, replies) = std::sync::mpsc::channel(); + requests.send(ControlRequest { method: "ui.screenshot".into(), params: json!({"path": "control.png", "focus": false}), reply }).unwrap(); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert_eq!(app.pending_shots.len(), 1, "ui.screenshot is admitted, not issued from the queue"); + assert!(replies.try_recv().is_err(), "the reply waits for the capture"); + app.queued_shots[0].1 = 0.0; + let mut callback = None; + for _ in 0..3 { + if let Some(cb) = screenshot_callback(&mut shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0))) { + callback = Some(cb); + } + } + let callback = callback.expect("the settled frames issue one capture"); + callback.complete(std::sync::Arc::new(egui::ColorImage::filled([640, 480], egui::Color32::BLACK))); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + let reply = replies.recv().unwrap(); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 640); + assert_eq!(reply["result"]["height"], 480); + let written = writes.lock().unwrap().clone(); + assert_eq!(written.len(), 1); + assert_eq!(written[0].0, "control.png"); + assert!(written[0].1 > 0); + assert!(app.pending_shots.is_empty(), "the reservation is released"); + assert!(app.queued_shots.is_empty()); + } + + #[test] + fn control_screenshot_times_out_without_writing_a_file() { + let (mut app, ctx, writes) = screenshot_app(); + let (requests, receiver) = std::sync::mpsc::channel(); + app = app.with_control(receiver); + let (reply, replies) = std::sync::mpsc::channel(); + requests.send(ControlRequest { method: "ui.screenshot".into(), params: json!({"path": "control.png", "focus": false}), reply }).unwrap(); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert_eq!(app.pending_shots.len(), 1); + app.pending_shots[0].deadline = 0.0; + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + let reply = replies.recv().unwrap(); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("no frame"), "{reply}"); + assert!(writes.lock().unwrap().is_empty(), "a timed-out request writes no file"); + assert!(app.pending_shots.is_empty()); + } + + #[test] + fn discarded_pass_runs_the_frame_stage_once_but_ticks_every_pass() { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"title": "multipass"})).unwrap(); + let ctx = egui::Context::default(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 600.0)); + let mut raw = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + raw.viewports + .insert(egui::ViewportId::ROOT, egui::ViewportInfo { inner_rect: Some(rect), native_pixels_per_point: Some(1.0), ..Default::default() }); + let mut passes = 0; + let mut output = ctx.run_ui(raw, |ui| { + passes += 1; + if passes == 1 { + // Requested before `app.ui` so the first pass is a discarded one and must not + // acknowledge injected input. + ui.ctx().request_discard("forced multipass regression"); + } + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }); + assert_eq!(output.platform_output.num_completed_passes, 2, "the frame really was multipass"); + // 0.37's TexturesDelta panics if dropped unapplied; this test only inspects the passes. + output.textures_delta.clear(); + assert_eq!(app.logic_tick_ticks, 2, "every pass ticks queues and deadlines"); + assert_eq!(app.logic_frame_ticks, 1, "once-per-frame work runs once"); + assert_eq!(app.input_ticks, 2); + } + + #[test] + fn hidden_logic_requests_exactly_one_paint_per_tick_until_a_settled_ui_pass() { + let (mut app, ctx, _writes) = screenshot_app(); + let mut raw = hidden_raw(); + // Three pointer steps queued: one batch may be in the host's pending raw input at a time. + app.control_synthetic = vec![ + egui::Event::PointerMoved(egui::pos2(10.0, 10.0)), + egui::Event::PointerMoved(egui::pos2(20.0, 20.0)), + egui::Event::PointerMoved(egui::pos2(30.0, 30.0)), + ]; + app.raw_input_hook(&ctx, &mut raw); + let injected = raw.events.clone(); + assert_eq!(app.injected_events.len(), 1, "one pointer event in the batch"); + assert_eq!(app.control_synthetic.len(), 2, "the rest wait for later host iterations"); + for tick in 0..3 { + let output = hidden_tick(&mut app, &ctx, &mut raw); + assert_eq!(hidden_paints(&output), 1, "tick {tick}: exactly one hidden paint request"); + assert!(app.synthetic_injected, "the injected step is still unapplied"); + assert!(app.control_input_frame.load(std::sync::atomic::Ordering::Relaxed), "control provenance is retained while hidden"); + assert_eq!(raw.events, injected, "tick {tick}: no second batch accumulated into the pending raw input"); + assert_eq!(app.control_synthetic.len(), 2, "tick {tick}: the queue did not drain another step"); + } + // A real visible UI pass takes exactly the one pending event (no duplicated PointerMoved). + raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().minimized = Some(false); + raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().occluded = Some(false); + let mut seen = Vec::new(); + ctx.run_ui(raw, |ui| { + seen = ui.ctx().input(|i| i.events.clone()); + app.logic(&ui.ctx().clone()); + app.ui(ui); + }) + .textures_delta + .clear(); + assert_eq!(seen.iter().filter(|event| matches!(event, egui::Event::PointerMoved(_))).count(), 1); + assert!(!app.synthetic_injected, "a settled UI pass acknowledges delivery"); + assert!(app.control_input_frame.load(std::sync::atomic::Ordering::Relaxed), "the pass's end guard still sees the provenance"); + app.control_synthetic.clear(); + app.raw_input_hook(&ctx, &mut egui::RawInput::default()); + assert!(!app.control_input_frame.load(std::sync::atomic::Ordering::Relaxed), "the next host iteration clears it"); + // Unknown visibility is treated like hidden, and the next queued step is injected. + let mut unknown = egui::RawInput::default(); + unknown.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo::default()); + app.control_synthetic.push(egui::Event::Text("x".into())); + app.raw_input_hook(&ctx, &mut unknown); + let output = ctx.run_logic(&unknown, |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&output), 1, "visible() == None is treated like hidden"); + } + + #[test] + fn font_gated_first_pass_requeues_injected_input_instead_of_acknowledging_it() { + let mut app = DesignApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"title": "fonts"})).unwrap(); + let ctx = egui::Context::default(); + app.control_synthetic.push(egui::Event::Text("x".into())); + let mut raw = hidden_raw(); + app.raw_input_hook(&ctx, &mut raw); + assert!(app.synthetic_injected); + // The first real pass installs fonts and returns before any widget consumes input. + ctx.run_ui(raw, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }) + .textures_delta + .clear(); + assert!(!app.synthetic_injected, "a font-gated pass is not an acknowledgement"); + assert_eq!(app.control_synthetic, vec![egui::Event::Text("x".into())], "the batch is back in its queue"); + assert!(!app.control_input_frame.load(std::sync::atomic::Ordering::Relaxed)); + // Once fonts are ready the same step is injected and a settled pass consumes it. + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert!(app.fonts_ready); + let mut raw = hidden_raw(); + app.raw_input_hook(&ctx, &mut raw); + assert!(app.synthetic_injected); + raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().minimized = Some(false); + ctx.run_ui(raw, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }) + .textures_delta + .clear(); + assert!(!app.synthetic_injected); + assert!(app.control_synthetic.is_empty()); + } + + #[test] + fn discarded_pass_does_not_acknowledge_injected_input() { + let (mut app, ctx, _writes) = screenshot_app(); + app.control_synthetic.push(egui::Event::Text("d".into())); + let mut raw = hidden_raw(); + raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().minimized = Some(false); + app.raw_input_hook(&ctx, &mut raw); + assert!(app.synthetic_injected); + let mut passes = 0; + let output = ctx.run_ui(raw, |ui| { + passes += 1; + if passes == 1 { + ui.ctx().request_discard("injected input must survive a discarded pass"); + } + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + assert_eq!(output.platform_output.num_completed_passes, 2); + assert!(!app.synthetic_injected, "the settled second pass acknowledges it"); + assert!(app.control_synthetic.is_empty()); + } + + #[test] + fn a_document_switch_before_the_ui_pass_drops_the_pending_batch() { + let (mut app, ctx, _writes) = screenshot_app(); + let pos = egui::pos2(10.0, 10.0); + app.control_synthetic.push(egui::Event::PointerMoved(pos)); + let mut raw = hidden_raw(); + app.raw_input_hook(&ctx, &mut raw); + assert!(app.synthetic_injected); + let _ = hidden_tick(&mut app, &ctx, &mut raw); + // A menu/control action switched documents before the forced paint. + app.run("file.new", json!({"title": "handoff"})).unwrap(); + app.raw_input_hook(&ctx, &mut raw); + assert!(!app.synthetic_injected, "the stale batch was discarded"); + assert!(app.injected_events.is_empty()); + assert!( + !raw.events.iter().any(|event| matches!(event, egui::Event::PointerMoved(p) if *p == pos)), + "the event was removed from pending input" + ); + raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().minimized = Some(false); + let mut seen = Vec::new(); + ctx.run_ui(raw, |ui| { + seen = ui.ctx().input(|i| i.events.clone()); + app.logic(&ui.ctx().clone()); + app.ui(ui); + }) + .textures_delta + .clear(); + assert!(!seen.iter().any(|event| matches!(event, egui::Event::PointerMoved(p) if *p == pos)), "the new document did not receive it"); + } + + #[cfg(not(target_arch = "wasm32"))] + fn prepare_revert(app: &mut DesignApp) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + app.run("file.saveAs", json!({"path": dir.path().join("revert.designcraft")})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 144, 144], "content": "text", "text": "unsaved"})).unwrap(); + assert!(app.session.active().unwrap().is_dirty()); + dir + } + + #[cfg(not(target_arch = "wasm32"))] + fn revert_preserving_uid(app: &mut DesignApp) { + let before = app.session.active().unwrap(); + let (uid, epoch) = (before.uid, before.identity_epoch); + app.run("file.revert", json!({})).unwrap(); + let after = app.session.active().unwrap(); + assert_eq!(after.uid, uid, "Revert keeps the open document's stable identity"); + assert_ne!(after.identity_epoch, epoch, "Revert replaces the document state"); + assert!(!after.is_dirty()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_revert_discards_a_buffered_input_batch() { + let (mut app, ctx, _writes) = screenshot_app(); + let _dir = prepare_revert(&mut app); + let pos = egui::pos2(10.0, 10.0); + app.control_synthetic.push(egui::Event::PointerMoved(pos)); + let mut raw = hidden_raw(); + app.raw_input_hook(&ctx, &mut raw); + assert!(app.synthetic_injected); + revert_preserving_uid(&mut app); + app.raw_input_hook(&ctx, &mut raw); + assert!(!app.synthetic_injected, "input captured before Revert must not reach the restored document"); + assert!(app.injected_events.is_empty()); + assert!(!raw.events.iter().any(|event| matches!(event, egui::Event::PointerMoved(p) if *p == pos))); + } + + #[cfg(not(target_arch = "wasm32"))] + fn visible_raw() -> egui::RawInput { + let mut raw = hidden_raw(); + let viewport = raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap(); + viewport.minimized = Some(false); + viewport.occluded = Some(false); + raw + } + + #[cfg(not(target_arch = "wasm32"))] + fn command_key(key: egui::Key) -> Vec { + let modifiers = egui::Modifiers { command: true, mac_cmd: true, ..egui::Modifiers::NONE }; + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec() + } + + /// A different document with one selectable item, for an asynchronous open. + #[cfg(not(target_arch = "wasm32"))] + fn document_with_a_frame() -> Vec { + let mut session = Session::new(); + session.execute("file.new", &json!({"title": "Incoming"})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap(); + let serialized = session.execute("file.serialize", &json!({})).unwrap(); + designcraft_engine::cmd::base64_decode(serialized["base64"].as_str().unwrap()) + } + + /// One host iteration in eframe's order: `raw_input_hook`, then `run_ui` calling + /// `App::logic` and `App::ui` (eframe native `update`). + #[cfg(not(target_arch = "wasm32"))] + fn run_host_frame(app: &mut DesignApp, ctx: &egui::Context, mut raw: egui::RawInput) { + app.raw_input_hook(ctx, &mut raw); + ctx.run_ui(raw, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + if ctx.current_pass_index() == 0 { + app.poll_native_actions(&ctx, || None); + } + app.ui(ui); + }) + .textures_delta + .clear(); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_native_revert_waits_for_the_control_gesture() { + let (mut app, ctx, _writes) = screenshot_app(); + let _dir = prepare_revert(&mut app); + let mut actions = std::collections::VecDeque::from([("file.revert".to_owned(), json!({}))]); + for pressed in [true, false] { + app.control_synthetic.push(egui::Event::PointerButton { + pos: egui::pos2(320.0, 240.0), + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + } + for step in 0..3 { + let mut raw = visible_raw(); + app.raw_input_hook(&ctx, &mut raw); + ctx.run_ui(raw, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + if ctx.current_pass_index() == 0 { + app.poll_native_actions(&ctx, || actions.pop_front()); + } + app.ui(ui); + }) + .textures_delta + .clear(); + assert_eq!(app.session.active().unwrap().is_dirty(), step < 2, "Revert runs only after the release"); + assert_eq!(ctx.input(|input| input.pointer.primary_down()), step == 0, "pointer state is not reset by the boundary"); + } + assert!(actions.is_empty()); + assert!(app.session.active().unwrap().selection.is_empty()); + assert!(!app.control_input_frame.load(std::sync::atomic::Ordering::Relaxed), "native actions keep user clipboard provenance"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_close_waits_for_input_and_keeps_the_request() { + let (mut app, ctx, _writes) = screenshot_app(); + let _dir = prepare_revert(&mut app); + app.control_synthetic.push(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut raw = visible_raw(); + raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().events.push(egui::ViewportEvent::Close); + app.raw_input_hook(&ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(&ui.ctx().clone()); + assert!(app.ui.dialog.is_none(), "the close dialog must not receive the old document's input"); + app.ui(ui); + }); + assert!(out.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::CancelClose)); + out.textures_delta.clear(); + assert_eq!(app.session.documents().len(), 1); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(dialog_uid(&app), Some(app.session.active().unwrap().uid), "the close request survives the deferred frame"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_control_command_waits_for_the_preceding_input() { + let (mut app, ctx, _writes) = screenshot_app(); + let (requests, receiver) = std::sync::mpsc::channel(); + app = app.with_control(receiver); + let (movement, first_reply) = ControlRequest::new("ui.move", json!({"x": 10, "y": 10})); + let (command, second_reply) = ControlRequest::new("engine.execute", json!({"command": "frame.create", "params": {"rect": [72,72,144,144]}})); + requests.send(movement).unwrap(); + requests.send(command).unwrap(); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(first_reply.try_recv().unwrap()["ok"], true); + assert!(second_reply.try_recv().is_err(), "later commands must not overtake queued input"); + run_host_frame(&mut app, &ctx, visible_raw()); + assert!(second_reply.try_recv().is_err(), "the injected input must settle first"); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(second_reply.try_recv().unwrap()["ok"], true, "the later command is eventually handled"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_native_revert_precedes_the_deferred_os_open() { + let (mut app, ctx, _writes) = screenshot_app(); + let dir = prepare_revert(&mut app); + let original = app.session.active().unwrap().uid; + let path = dir.path().join("incoming.designcraft"); + std::fs::write(&path, document_with_a_frame()).unwrap(); + app.open_documents_later([path.to_string_lossy().to_string()]); + app.control_synthetic.push(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut actions = std::collections::VecDeque::from([("file.revert".to_owned(), json!({}))]); + let mut raw = visible_raw(); + app.raw_input_hook(&ctx, &mut raw); + ctx.run_ui(raw, |ui| { + app.logic(&ui.ctx().clone()); + if ui.ctx().current_pass_index() == 0 { + app.poll_native_actions(ui.ctx(), || actions.pop_front()); + } + app.ui(ui); + }) + .textures_delta + .clear(); + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.active().unwrap().is_dirty()); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(app.session.active().unwrap().uid, original); + assert!(!app.session.active().unwrap().is_dirty(), "Revert runs before delivery resumes"); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(app.session.documents().len(), 2); + assert_ne!(app.session.active().unwrap().uid, original); + assert!(!app.session.documents().iter().find(|doc| doc.uid == original).unwrap().is_dirty(), "Revert targeted the original document"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_stale_preview_waits_for_the_input_batch() { + let (mut app, ctx, _writes) = screenshot_app(); + let original = app.session.active().unwrap().uid; + app.session.preview_new_document(&json!({"title": "Preview"})).unwrap(); + let preview = app.session.new_document_preview(); + assert!(preview.is_some()); + app.control_synthetic.push(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(app.session.new_document_preview(), preview, "preview cleanup cannot replace the input's document"); + run_host_frame(&mut app, &ctx, visible_raw()); + assert!(app.session.new_document_preview().is_none()); + assert_eq!(app.session.active().unwrap().uid, original); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_native_choices_wait_for_forwarded_text_input() { + let (mut app, ctx, _writes) = screenshot_app(); + let _dir = prepare_revert(&mut app); + let mut text = String::from("native field"); + ctx.run_ui(visible_raw(), |ui| { + ui.text_edit_singleline(&mut text).request_focus(); + }) + .textures_delta + .clear(); + assert!(ctx.text_edit_focused(), "the native edit command must forward to an actual text field"); + let mut actions = std::collections::VecDeque::from([("edit.selectAll".to_owned(), Value::Null), ("file.revert".to_owned(), json!({}))]); + app.poll_native_actions(&ctx, || actions.pop_front()); + run_host_frame(&mut app, &ctx, visible_raw()); + assert!(app.session.active().unwrap().is_dirty(), "Revert waits for Select All's forwarded input"); + run_host_frame(&mut app, &ctx, visible_raw()); + assert!(!app.session.active().unwrap().is_dirty()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_arriving_native_event_blocks_an_os_open_without_synthetic_input() { + let (mut app, ctx, _writes) = screenshot_app(); + let dir = prepare_revert(&mut app); + let original = app.session.active().unwrap().uid; + let path = dir.path().join("incoming.designcraft"); + std::fs::write(&path, document_with_a_frame()).unwrap(); + app.open_documents_later([path.to_string_lossy().to_string()]); + let mut actions = std::collections::VecDeque::from([("file.revert".to_owned(), json!({}))]); + app.native_action_notifier(ctx.clone())(); + let mut raw = visible_raw(); + app.raw_input_hook(&ctx, &mut raw); + ctx.run_ui(raw, |ui| { + app.logic(&ui.ctx().clone()); + assert_eq!(app.session.documents().len(), 1, "a menu event already queued by the host must precede the OS open"); + if ui.ctx().current_pass_index() == 0 { + app.poll_native_actions(ui.ctx(), || actions.pop_front()); + } + app.ui(ui); + }) + .textures_delta + .clear(); + assert!(!app.session.active().unwrap().is_dirty(), "the menu action ran in the real UI callback"); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(app.session.documents().len(), 2); + assert_ne!(app.session.active().unwrap().uid, original); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_deferred_native_action_waits_for_a_real_ui_pass() { + let (mut app, ctx, _writes) = screenshot_app(); + let _dir = prepare_revert(&mut app); + app.control_synthetic.push(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut actions = std::collections::VecDeque::from([("file.revert".to_owned(), json!({}))]); + let mut raw = visible_raw(); + app.raw_input_hook(&ctx, &mut raw); + ctx.run_ui(raw, |ui| { + app.logic(&ui.ctx().clone()); + if ui.ctx().current_pass_index() == 0 { + app.poll_native_actions(ui.ctx(), || actions.pop_front()); + } + app.ui(ui); + }) + .textures_delta + .clear(); + assert!(app.session.active().unwrap().is_dirty()); + let mut raw = hidden_raw(); + app.raw_input_hook(&ctx, &mut raw); + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + assert!(app.session.active().unwrap().is_dirty(), "a hidden logic-only tick must not execute native menu actions"); + run_host_frame(&mut app, &ctx, visible_raw()); + assert!(!app.session.active().unwrap().is_dirty(), "the deferred action runs when a UI callback resumes"); + } + + /// An inbox delivery runs only after input sampled for the current document has settled. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_inbox_open_must_not_receive_a_pending_shortcut() { + let (mut app, ctx, _writes) = screenshot_app(); + app.services.inbox = Some(Inbox::default()); + let request = app.import_request("open"); + let file = ImportedFile { request, name: "incoming.designcraft".into(), bytes: document_with_a_frame() }; + app.services.inbox.as_ref().unwrap().lock().unwrap().push(file); + app.control_synthetic.extend(command_key(egui::Key::A)); + run_host_frame(&mut app, &ctx, visible_raw()); + // A deferred delivery is handled by the next settled host iteration. + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(app.session.documents().len(), 2, "the inbox file is still opened"); + assert!( + app.session.active().unwrap().selection.is_empty(), + "Select All sampled before the asynchronous open must not reach the opened document" + ); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_os_document_must_not_receive_a_pending_shortcut() { + let (mut app, ctx, _writes) = screenshot_app(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("incoming.designcraft"); + std::fs::write(&path, document_with_a_frame()).unwrap(); + app.open_documents_later([path.to_string_lossy().to_string()]); + app.control_synthetic.extend(command_key(egui::Key::A)); + run_host_frame(&mut app, &ctx, visible_raw()); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(app.session.documents().len(), 2, "the OS document is still opened"); + assert!(app.session.active().unwrap().selection.is_empty(), "Select All sampled before the OS open must not reach the opened document"); + } + + /// The intended boundary: an imported file waits while the batch that was sampled for the + /// current document is pending, and is handled once that batch has settled. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_inbox_delivery_waits_for_a_settled_batch_then_runs() { + let (mut app, ctx, _writes) = screenshot_app(); + app.services.inbox = Some(Inbox::default()); + let request = app.import_request("open"); + let file = ImportedFile { request, name: "incoming.designcraft".into(), bytes: document_with_a_frame() }; + app.services.inbox.as_ref().unwrap().lock().unwrap().push(file); + app.control_synthetic.push(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(app.session.documents().len(), 1, "the delivery waited for the injected step"); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(app.session.documents().len(), 2, "the deferred delivery is not lost"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_host_order_os_document_waits_for_a_settled_batch_then_runs() { + let (mut app, ctx, _writes) = screenshot_app(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("incoming.designcraft"); + std::fs::write(&path, document_with_a_frame()).unwrap(); + app.open_documents_later([path.to_string_lossy().to_string()]); + app.control_synthetic.push(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(app.session.documents().len(), 1, "the OS open waited for the injected step"); + run_host_frame(&mut app, &ctx, visible_raw()); + assert_eq!(app.session.documents().len(), 2, "the deferred OS open is not lost"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_revert_discards_a_buffered_native_drop() { + let (mut app, ctx) = drop_test_app(); + let dir = prepare_revert(&mut app); + let path = dir.path().join("drop.png"); + std::fs::write(&path, designcraft_render::Rendered { width: 10, height: 10, pixels: vec![200; 400] }.to_png()).unwrap(); + let mut raw = visible_raw_with_drop(&[&path]); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(app.pending_drops.len(), 1); + revert_preserving_uid(&mut app); + let assets_before = app.session.active().unwrap().doc.assets.len(); + run_ui_pass(&mut app, &ctx, raw); + assert_eq!(app.session.active().unwrap().doc.assets.len(), assets_before, "a stale native drop must not modify the restored document"); + assert!(app.pending_drops.is_empty()); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_revert_rejects_screenshots_before_issue_and_in_flight() { + for in_flight in [false, true] { + let (mut app, ctx, writes) = screenshot_app(); + let _dir = prepare_revert(&mut app); + let (reply, replies) = std::sync::mpsc::channel(); + app.admit_screenshot(Some("revert-stale.png".into()), reply).unwrap(); + app.queued_shots[0].1 = 0.0; + let mut callback = None; + if in_flight { + for _ in 0..3 { + let mut output = shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + if let Some(cb) = screenshot_callback(&mut output) { + callback = Some(cb); + } + } + assert!(callback.is_some(), "the in-flight case must actually issue a capture"); + } + revert_preserving_uid(&mut app); + if let Some(callback) = callback { + callback.complete(std::sync::Arc::new(egui::ColorImage::filled([640, 480], egui::Color32::BLACK))); + } + for _ in 0..4 { + let mut output = shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert!(screenshot_callback(&mut output).is_none(), "Revert must not issue or retry an old capture (in_flight={in_flight})"); + } + let reply = replies.try_recv().expect("the replaced document's screenshot receives an error"); + assert_eq!(reply["ok"], false, "in_flight={in_flight}: {reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("document")); + assert!(writes.lock().unwrap().is_empty(), "stale pixels must not be saved"); + assert!(app.pending_shots.is_empty()); + assert!(app.queued_shots.is_empty()); + } + } + + // --------------------------------------------------------------------------------------- + // Document-generation regressions: reused screenshot slots and buffered native drops must + // not cross a document/dialog/focus change. + // --------------------------------------------------------------------------------------- + + #[test] + fn design_generation_screenshot_switch_before_issue_does_not_capture_the_new_document() { + let (mut app, ctx, writes) = screenshot_app(); + let document_a = app.session.active().unwrap().uid; + let (requests, receiver) = std::sync::mpsc::channel(); + app = app.with_control(receiver); + let (reply, replies) = std::sync::mpsc::channel(); + requests + .send(ControlRequest { method: "ui.screenshot".into(), params: json!({"path": "switch-before-issue.png", "focus": false}), reply }) + .unwrap(); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert_eq!(app.pending_shots.len(), 1, "ui.screenshot is admitted"); + assert_eq!(app.pending_shots[0].geometry.document, document_a); + // A menu/control action switches documents before the settling rule issues the capture. + app.run("file.new", json!({"title": "handoff-before-issue"})).unwrap(); + let document_b = app.session.active().unwrap().uid; + assert_ne!(document_b, document_a); + if let Some(at) = app.queued_shots.first_mut() { + at.1 = 0.0; + } + let mut callback = None; + for _ in 0..3 { + let mut output = shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + if let Some(cb) = screenshot_callback(&mut output) { + callback = Some(cb); + } + } + if let Some(callback) = callback { + callback.complete(std::sync::Arc::new(egui::ColorImage::filled([640, 480], egui::Color32::BLACK))); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + } + assert!(writes.lock().unwrap().is_empty(), "the switched request must not write a file"); + let reply = replies.try_recv().expect("the switched request must receive an error receipt"); + assert_eq!(reply["ok"], false, "the old document's request must not succeed on the new one: {reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("document"), "the error must name the document change: {reply}"); + assert!(app.pending_shots.is_empty(), "the stale request released its admission"); + assert!(app.queued_shots.is_empty(), "the stale request left no repaint token"); + } + + #[test] + fn design_generation_screenshot_in_flight_after_switch_does_not_complete_on_the_new_document() { + let (mut app, ctx, writes) = screenshot_app(); + let document_a = app.session.active().unwrap().uid; + let (reply, replies) = std::sync::mpsc::channel(); + app.admit_screenshot(Some("switch-in-flight.png".into()), reply).unwrap(); + app.queued_shots[0].1 = 0.0; + let mut callback = None; + for _ in 0..3 { + let mut output = shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + if let Some(cb) = screenshot_callback(&mut output) { + callback = Some(cb); + } + } + let callback = callback.expect("the request is issued on the first document"); + assert_eq!(app.pending_shots[0].geometry.document, document_a); + // The capture is in flight on A when the app switches to B. + app.run("file.new", json!({"title": "handoff-in-flight"})).unwrap(); + assert_ne!(app.session.active().unwrap().uid, document_a); + callback.complete(std::sync::Arc::new(egui::ColorImage::filled([640, 480], egui::Color32::BLACK))); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + // Old code retries the stale request on B; follow any retry through a completion so the + // regression proves that B never receives the old capture. + if let Some(at) = app.queued_shots.first_mut() { + at.1 = 0.0; + } + let mut retried = None; + for _ in 0..3 { + let mut output = shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + if let Some(cb) = screenshot_callback(&mut output) { + retried = Some(cb); + } + } + if let Some(retried) = retried { + retried.complete(std::sync::Arc::new(egui::ColorImage::filled([640, 480], egui::Color32::BLACK))); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + } + assert!(writes.lock().unwrap().is_empty(), "the in-flight request must not write a file after the switch"); + let reply = replies.try_recv().expect("the in-flight request must receive an error receipt"); + assert_eq!(reply["ok"], false, "the in-flight request must not succeed on the new document: {reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("document"), "the error must name the document change: {reply}"); + assert!(app.pending_shots.is_empty(), "the stale request released its admission"); + assert!(app.queued_shots.is_empty(), "the stale request left no repaint token"); + } + + #[test] + fn design_generation_screenshot_admission_uses_the_current_document_not_the_cached_geometry() { + let (mut app, _ctx, _writes) = screenshot_app(); + let document_a = app.session.active().unwrap().uid; + // `screenshot_app` populated the geometry cache for A. Switch documents without a frame + // between the switch and admission, as two control requests in one logic tick would. + app.run("file.new", json!({"title": "admission-current-document"})).unwrap(); + let document_b = app.session.active().unwrap().uid; + assert_ne!(document_b, document_a); + let (reply, replies) = std::sync::mpsc::channel(); + app.admit_screenshot(None, reply).unwrap(); + assert_eq!( + app.pending_shots[0].geometry.document, document_b, + "admission must use the current session document, not the last cached geometry" + ); + assert!(replies.try_recv().is_err(), "admission alone replies only when it fails"); + app.pending_shots.clear(); + app.queued_shots.clear(); + } + + #[cfg(not(target_arch = "wasm32"))] + #[derive(Debug)] + struct TestDroppedFile(std::path::PathBuf); + + #[cfg(not(target_arch = "wasm32"))] + impl egui::DroppedFile for TestDroppedFile { + fn path(&self) -> &std::path::Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|error| error.to_string()) + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn dropped_file(path: &std::path::Path) -> egui::DroppedFileHandle { + std::sync::Arc::new(TestDroppedFile(path.to_path_buf())) + } + + #[cfg(not(target_arch = "wasm32"))] + fn drop_test_app() -> (DesignApp, egui::Context) { + let (mut app, ctx, _writes) = screenshot_app(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + let _ = app.view_mut(); + (app, ctx) + } + + #[cfg(not(target_arch = "wasm32"))] + fn temporary_png(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("designcraft-ui-{}-{name}.png", std::process::id())); + std::fs::write(&path, designcraft_render::Rendered { width: 10, height: 10, pixels: vec![200; 400] }.to_png()).unwrap(); + path + } + + #[cfg(not(target_arch = "wasm32"))] + fn visible_raw_with_drop(paths: &[&std::path::Path]) -> egui::RawInput { + let mut raw = hidden_raw(); + let viewport = raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap(); + viewport.minimized = Some(false); + viewport.occluded = Some(false); + raw.events.push(egui::Event::PointerMoved(egui::pos2(100.0, 100.0))); + raw.dropped_files.extend(paths.iter().map(|path| dropped_file(path))); + raw + } + + #[cfg(not(target_arch = "wasm32"))] + fn run_ui_pass(app: &mut DesignApp, ctx: &egui::Context, raw: egui::RawInput) { + ctx.run_ui(raw, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }) + .textures_delta + .clear(); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_generation_native_drop_is_discarded_when_the_document_changes_before_ui() { + let (mut app, ctx) = drop_test_app(); + let path = temporary_png("drop-document-change"); + let mut raw = visible_raw_with_drop(&[&path]); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.dropped_files.is_empty(), "raw_input_hook captures the native drop"); + assert_eq!(app.pending_drops.len(), 1, "the raw drop is buffered for the UI pass"); + // A menu/control action switches documents before the settled UI pass consumes it. + app.run("file.new", json!({"title": "drop-target"})).unwrap(); + let assets_before = app.session.active().unwrap().doc.assets.len(); + run_ui_pass(&mut app, &ctx, raw); + assert_eq!(app.session.active().unwrap().doc.assets.len(), assets_before, "the stale drop must not land in the switched document"); + assert!(app.pending_drops.is_empty(), "the discarded drop released its buffer"); + let _ = std::fs::remove_file(path); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_generation_native_drop_is_discarded_when_a_dialog_opens_before_ui() { + let (mut app, ctx) = drop_test_app(); + let path = temporary_png("drop-dialog-change"); + let mut raw = visible_raw_with_drop(&[&path]); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(app.pending_drops.len(), 1); + app.run("app.preferences", json!({})).unwrap(); + assert!(app.ui.dialog.is_some(), "a real dialog changed the generation"); + let assets_before = app.session.active().unwrap().doc.assets.len(); + run_ui_pass(&mut app, &ctx, raw); + assert_eq!(app.session.active().unwrap().doc.assets.len(), assets_before, "the stale drop must not land behind the dialog"); + assert!(app.pending_drops.is_empty(), "the discarded drop released its buffer"); + let _ = std::fs::remove_file(path); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_generation_native_drop_is_discarded_when_focus_changes_before_ui() { + let (mut app, ctx) = drop_test_app(); + let path = temporary_png("drop-focus-change"); + let mut raw = visible_raw_with_drop(&[&path]); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(app.pending_drops.len(), 1); + let focus = egui::Id::unique("design-generation-drop-focus"); + assert_ne!(app.input_generation().focus, Some(focus)); + ctx.memory_mut(|memory| memory.request_focus(focus)); + assert_eq!(app.input_generation().focus, Some(focus), "keyboard focus changed after the drop was buffered"); + let assets_before = app.session.active().unwrap().doc.assets.len(); + run_ui_pass(&mut app, &ctx, raw); + assert_eq!(app.session.active().unwrap().doc.assets.len(), assets_before, "the stale drop must not land after a focus change"); + assert!(app.pending_drops.is_empty(), "the discarded drop released its buffer"); + let _ = std::fs::remove_file(path); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn design_generation_native_drop_batch_without_a_change_still_places_every_file() { + let (mut app, ctx) = drop_test_app(); + let first = temporary_png("drop-batch-a"); + let second = temporary_png("drop-batch-b"); + let mut raw = visible_raw_with_drop(&[&first, &second]); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(app.pending_drops.len(), 2, "both files were captured from the raw input"); + let assets_before = app.session.active().unwrap().doc.assets.len(); + run_ui_pass(&mut app, &ctx, raw); + assert_eq!(app.session.active().unwrap().doc.assets.len(), assets_before + 2, "both files in the valid batch are placed"); + assert!(app.pending_drops.is_empty()); + let _ = std::fs::remove_file(first); + let _ = std::fs::remove_file(second); + } } #[cfg(test)] @@ -2881,13 +4622,32 @@ pub(crate) mod test_window { delta, phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(10); } + /// Ensure a Hebrew face is available for the interface's glyph assertions. + /// + /// Canonical craft-fonts builds bundle the face. Optional-input builds can pass + /// `DESIGNCRAFT_TEST_HEBREW_FONT=/abs/path/NotoSansHebrew.ttf` (the pinned OFL fixture) + /// and otherwise skip their Hebrew-only assertions explicitly. + pub fn install_test_hebrew_face() -> bool { + let db = designcraft_fonts::FontDb::global(); + if db.has_family("Noto Sans Hebrew") { + return true; + } + let Ok(path) = std::env::var("DESIGNCRAFT_TEST_HEBREW_FONT") else { return false }; + designcraft_engine::io::read(path).is_ok_and(|bytes| db.add_font(bytes) > 0) + } + /// The outer rect of a side or top panel, from egui's memory. + /// + /// 0.37 keys a panel by its *parent Ui's* scope: `Ui::scope_id().with_salt(salt)`. In this + /// harness the app draws into a Ui the harness owns (a child of the viewport root Ui), so + /// `Harness::ui_id()` — not the viewport's root id — is the parent to salt. pub fn panel_rect(h: &Harness<'static, Window>, id: &str) -> egui::Rect { - egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new(id)).map(|s| s.outer_rect).unwrap() + egui::containers::panel::PanelState::load(&h.ctx, h.ui_id().with(id)).map(|s| s.outer_rect).unwrap() } /// A primary click at `pos`, then a few frames. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d97dcdeba..9d4c694e5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -114,7 +114,21 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{tab?: border|shading} — border and shading of the selected paragraphs (type.para)", ), ("app.spanColumnsDialog", "Span Columns…", None, "{} — span or split columns of the selected paragraphs (type.para)"), + ("app.keepOptionsDialog", "Keep Options…", None, "{} — Keep with Next and Keep Lines Together of the selected paragraphs (type.para)"), + ( + "app.nestedStylesDialog", + "Drop Caps and Nested Styles…", + None, + "{} — drop cap, nested styles and nested line styles of the selected paragraphs (type.para)", + ), + ("app.grepStylesDialog", "GREP Styles…", None, "{} — GREP styles of the selected paragraphs (type.para)"), ("app.bulletsNumberingDialog", "Bullets and Numbering…", None, "{} — bullets or numbers of the selected paragraphs (type.para)"), + ( + "app.hyphenationDialog", + "Hyphenation…", + None, + "{} — Hyphenation Settings of the selected paragraphs (type.para): limits, zone, Better Spacing ↔ Fewer Hyphens, Preview", + ), ( "app.justificationDialog", "Justification…", @@ -237,6 +251,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("view.nextSpread", "Next Spread", Some("Alt+PageDown"), "{} — centre the spread after the one in view"), ("view.previousSpread", "Previous Spread", Some("Alt+PageUp"), "{} — centre the spread before the one in view"), ("view.goToPage", "Go to Page…", Some("Cmd+J"), "{page: number (position) | \"name\" (section page name, or \"+n\" for a position)}"), + ("view.goBack", "Go Back", Some("Cmd+PageUp"), "{} — show the page viewed before this one in the window"), + ("view.goForward", "Go Forward", Some("Cmd+PageDown"), "{} — show the page viewed after this one (after Go Back)"), ( "window.panel", "Show Panel", @@ -427,6 +443,8 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.nextSpread", "ui:view.previousSpread", "ui:view.goToPage", + "ui:view.goBack", + "ui:view.goForward", "-", "cmd:layout.section", "cmd:toc.generate", @@ -484,7 +502,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", ">Notes", "cmd:note.new", + "cmd:note.convertFromText", "ui:window.panel|Notes|{\"panel\": \"notes\"}", + "-", + "cmd:note.previous", + "cmd:note.next", "<", ">Track Changes", "cmd:changes.track", @@ -507,9 +529,13 @@ pub const MENUS: &[(&str, &[&str])] = &[ ">Text Variables", "cmd:variables.define", "cmd:variables.insert", + "cmd:variables.convertToText", "<", "-", ">Bulleted & Numbered Lists", + "cmd:list.bullets", + "cmd:list.numbers", + "-", "cmd:list.define", "<", "-", @@ -896,6 +922,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:window.panel|Conditional Text|{\"panel\": \"conditions\"}", "ui:window.panel|Glyphs|{\"panel\": \"glyphs\"}", "ui:window.panel|Paragraph|{\"panel\": \"paragraph\"}", + "ui:window.panel|Story|{\"panel\": \"story\"}", "ui:window.panel|Table|{\"panel\": \"table\"}", "ui:app.tabsPanel", "ui:window.panel|Typography|{\"panel\": \"typography\"}", @@ -949,7 +976,8 @@ pub const CLASSIC_GROUPS: &[&[&str]] = &[ ]; /// The Typography preset's available panels in the order observed in InDesign's workspace. -/// CC Libraries and the separate Story panel have no corresponding DesignCraft panel. +/// CC Libraries has no corresponding DesignCraft panel, and where the Story panel sits in it is +/// not recorded, so these groups leave it out. pub const TYPOGRAPHY_GROUPS: &[&[&str]] = &[ &["pages", "layers", "swatches"], &["glyphs", "textWrap"], @@ -1280,11 +1308,19 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + "app.paragraphBordersDialog" + | "app.spanColumnsDialog" + | "app.bulletsNumberingDialog" + | "app.keepOptionsDialog" + | "app.nestedStylesDialog" + | "app.grepStylesDialog" => { let Some(a) = crate::panels::text_attrs(app) else { return Some(Err("select text or a text frame".into())) }; let section = match id { "app.spanColumnsDialog" => "span", "app.bulletsNumberingDialog" => "bullets", + "app.keepOptionsDialog" => "keep", + "app.nestedStylesDialog" => "nested", + "app.grepStylesDialog" => "grep", _ if p.get("tab").and_then(Value::as_str) == Some("shading") => "shading", _ => "border", }; @@ -1297,6 +1333,18 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else { + return Some(Err("select text or a text frame".into())); + }; + let mixed: Vec<&str> = a["paraMixed"].as_array().map(|m| m.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + let current: serde_json::Map = crate::dialogs::HYPH_ATTRS + .iter() + .map(|k| (k.to_string(), if mixed.contains(k) { Value::Null } else { a["para"][*k].clone() })) + .collect(); + app.ui.dialog = Some(crate::dialogs::Dialog::new("hyphenation", json!({"current": current}))); + Ok(Value::Null) + } "app.justificationDialog" => { let Some(a) = app.session.execute("type.selectionAttrs", &json!({"mixed": true})).ok().filter(|a| !a.is_null()) else { return Some(Err("select text or a text frame".into())); @@ -1627,6 +1675,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + crate::canvas::go_through_history(app, if id == "view.goBack" { -1 } else { 1 }); + Ok(Value::Null) + } "view.nextSpread" | "view.previousSpread" => { crate::canvas::step_spread(app, if id == "view.nextSpread" { 1 } else { -1 }); Ok(Value::Null) @@ -1781,6 +1833,9 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Option { // The second view moves into its own window (on the web: a floating window). - app.second_window = p.get("on").and_then(Value::as_bool).unwrap_or(true); + let on = p.get("on").and_then(Value::as_bool).unwrap_or(true); + if app.second_window && !on { + app.end_second_ime_viewport(); + } + app.second_window = on; app.split = false; if !app.second_window { app.switch_pane(0); @@ -1933,7 +1992,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Some(TYPOGRAPHY_GROUPS), _ => None, }; - app.ui.icon_groups = groups.map(|groups| groups.iter().map(|g| g.iter().map(|p| p.to_string()).collect()).collect()); + app.ui.icon_groups = groups.map(|groups| groups.iter().map(|g| g.iter().map(ToString::to_string).collect()).collect()); app.ui.dock_expanded = groups.is_none(); let dock_tab = match name { "Interactive for PDF" | "Digital Publishing" => Some("pages"), @@ -1970,13 +2029,28 @@ fn set_screen_mode(app: &mut DesignApp, mode: ScreenMode) { app.ui.presentation_return = app.ui.screen_mode; // Presentation fits the spread to the screen; leaving it puts the user's view back. app.ui.presentation_view = app.view_key().zip(app.view().copied()); + app.ui.presentation_page = crate::canvas::page_in_view(app); + app.ui.presentation_history = app.page_history().cloned(); } if mode != ScreenMode::Presentation && presenting && let Some((k, v)) = app.ui.presentation_view.take() - && let Some(view) = app.views.get_mut(&k) + && app.views.contains_key(&k) { - *view = v; + // Go Back sees the presentation as one jump to the last spread presented, and putting the + // view back as a jump from there: the spreads in between would otherwise have replaced the + // place the view was at with the presentation's. + let shown = app.ui.presentation_page.take().zip(crate::canvas::page_in_view(app)).filter(|_| app.view_key() == Some(k)); + let h = app.page_history.entry(k).or_default(); + *h = app.ui.presentation_history.take().unwrap_or_default(); + if let Some(((from, at), (to, offset))) = shown { + h.leave(from, at); + h.visit(to, offset); + h.visit(from, at); + } + if let Some(view) = app.views.get_mut(&k) { + *view = v; + } } app.ui.screen_mode = mode; } @@ -2399,6 +2473,11 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.hiddenCharacters" => app.ui.hidden_characters, "view.taggedFrames" => app.ui.tagged_frames, "changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes), + // Pressed, as the Bulleted and Numbered List buttons: every paragraph the command applies + // to (selected text, cells or text frames) has it. + "list.bullets" | "list.numbers" => { + app.session.selection_has_list(if id == "list.bullets" { designcraft_doc::ListType::Bullets } else { designcraft_doc::ListType::Numbers }) + } "window.controlBar" => app.ui.control_bar, "window.split" => app.split, "window.newWindow" => app.second_window, @@ -2455,7 +2534,7 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if is_window_control(id) { return window_control_available(app, id); } - if id == "app.justificationDialog" { + if matches!(id, "app.justificationDialog" | "app.hyphenationDialog") { return enabled(app, "type.para"); } if id.starts_with("fit.") { @@ -2464,6 +2543,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) { return false; } + if id == "view.goBack" || id == "view.goForward" { + return crate::canvas::can_go_through_history(app, if id == "view.goBack" { -1 } else { 1 }); + } if ui_label(id).is_some() { return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help."); } @@ -3179,7 +3261,7 @@ pub fn palette(app: &mut DesignApp, ctx: &egui::Context) { let Some(mut q) = app.ui.palette.clone() else { return }; let mut close = false; let mut run: Option<(String, Value)> = None; - egui::Modal::new(egui::Id::new("palette")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("palette")).show(ctx, |ui| { ui.set_width(480.0); let r = ui.add( egui::TextEdit::singleline(&mut q) @@ -3453,6 +3535,7 @@ mod tests { delta: egui::vec2(0.0, -3000.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }, ], ); @@ -3710,6 +3793,7 @@ mod tests { delta: egui::vec2(0.0, -40.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }], ); let scrolled = cameras(&mut app); @@ -3760,8 +3844,9 @@ mod tests { output.textures_delta.clear(); } assert!(warnings.is_empty(), "both bars reused the same interactive widget IDs: {warnings:?}"); - let bars: Vec<_> = - (0_u8..2).map(|pane| ctx.memory(|memory| memory.area_rect(egui::Id::new(("task_bar", egui::ViewportId::ROOT, pane)))).unwrap()).collect(); + let bars: Vec<_> = (0_u8..2) + .map(|pane| ctx.memory(|memory| memory.area_rect(egui::Id::unique(("task_bar", egui::ViewportId::ROOT, pane)))).unwrap()) + .collect(); assert!(!bars[0].intersects(bars[1]), "each pane keeps its own bar: {bars:?}"); } @@ -3955,8 +4040,8 @@ mod tests { } }; // The icon column's flyout (the classic dock) and a floating panel group (the docking layout). - let flyout = egui::LayerId::new(egui::Order::Foreground, egui::Id::new("panel_flyout")); - let group = egui::LayerId::new(egui::Order::Middle, egui::Id::new("designcraft-panel-docking").with(("floating", "preflight"))); + let flyout = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("panel_flyout")); + let group = egui::LayerId::new(egui::Order::Middle, egui::Id::unique("designcraft-panel-docking").with(("floating", "preflight"))); let shown = |layer: egui::LayerId| ctx.memory(|m| m.areas().is_visible(&layer)); let toggles = [ ("window.hidePanels", json!({}), json!({})), @@ -4195,6 +4280,272 @@ mod tests { assert_eq!(v.zoom, zoom); } + #[test] + fn go_back_and_go_forward_retrace_the_pages_viewed() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap(); + let can = |app: &crate::DesignApp| (menu_enabled(app, "view.goBack"), menu_enabled(app, "view.goForward")); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // In the Layout menu after Go to Page. + let layout = MENUS.iter().find(|m| m.0 == "Layout").unwrap().1; + let at = |id: &str| layout.iter().position(|e| *e == format!("ui:{id}")).unwrap(); + assert_eq!(at("view.goBack"), at("view.goToPage") + 1); + assert_eq!(at("view.goForward"), at("view.goBack") + 1); + assert_eq!(can(&app), (false, false), "one page viewed: nowhere to go"); + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + let cmd_shift = egui::Modifiers { shift: true, ..cmd }; + let shift = egui::Modifiers::SHIFT; + // Pages viewed: 1, 5, 7. + app.run("view.goToPage", json!({"page": 5})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, key(egui::Key::PageDown, cmd_shift)); + assert_eq!(page(&app), 6); + frame(&mut app, vec![]); + assert_eq!(can(&app), (true, false)); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 4, "Go Back"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 0, "Go Back again"); + frame(&mut app, vec![]); + assert_eq!(can(&app), (false, true), "back at the first page viewed"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(page(&app), 4, "Go Forward"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(page(&app), 6, "Go Forward again"); + frame(&mut app, vec![]); + assert_eq!(can(&app), (true, false)); + // After going back, viewing another page drops the pages ahead. + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 4); + frame(&mut app, key(egui::Key::PageDown, shift)); + assert_eq!(page(&app), 5, "Next Page"); + frame(&mut app, vec![]); + assert_eq!(can(&app), (true, false), "nothing ahead after a new page"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(page(&app), 5, "Go Forward does nothing"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 4, "Go Back returns to the page before Next Page"); + } + + #[test] + fn go_back_waits_for_the_view_to_be_fitted() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp| { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + frame(&mut app); + frame(&mut app); + app.run("view.goToPage", json!({"page": 5})).unwrap(); + frame(&mut app); + assert!(menu_enabled(&app, "view.goBack")); + // Leaving parent pages through the Pages panel: the canvas refits on the next frame. + app.view_mut().unwrap().fitted = false; + assert!(!menu_enabled(&app, "view.goBack"), "the refit would replace where Go Back went"); + crate::canvas::go_through_history(&mut app, -1); + frame(&mut app); + assert!(menu_enabled(&app, "view.goBack"), "the unfitted Go Back used no history entry"); + } + + #[test] + fn next_spread_at_the_last_spread_is_no_jump() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap(); + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + let alt = egui::Modifiers::ALT; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // Pages 1 | 2-3 | 4-5 | 6-7: page 6 is the left page of the last spread. + app.run("view.goToPage", json!({"page": 6})).unwrap(); + frame(&mut app, vec![]); + assert_eq!(page(&app), 5); + // Next Spread has no spread to go to; centring the last one moves the view to its fold. + frame(&mut app, key(egui::Key::PageDown, alt)); + frame(&mut app, key(egui::Key::PageDown, alt)); + assert_ne!(page(&app), 5, "the view moved to the right page"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(page(&app), 0, "Go Back returns to the page before Go to Page"); + assert!(!menu_enabled(&app, "view.goBack")); + } + + #[test] + fn go_back_returns_to_the_view_a_jump_left_and_scrolling_adds_no_entry() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let spread = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap() + }; + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + let alt = egui::Modifiers::ALT; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // Pages 1 | 2-3 | 4-5 | 6-7. Spread 2-3 fitted: the view centre is on the fold. + frame(&mut app, key(egui::Key::PageDown, alt)); + app.run("view.fitSpread", json!({})).unwrap(); + frame(&mut app, vec![]); + let before = *app.view().unwrap(); + frame(&mut app, key(egui::Key::PageDown, alt)); + assert_eq!(spread(&app), 2, "Next Spread"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + let v = *app.view().unwrap(); + assert!((v.origin - before.origin).hypot() < 1e-6, "Go Back shows the spread as it was: {:?} vs {:?}", v.origin, before.origin); + assert_eq!(v.zoom, before.zoom); + // Zoomed in after the jump: Go Back keeps the magnification in use, as InDesign's does, and + // centres the same place. + let centre = |app: &crate::DesignApp| { + let (v, r) = (app.view().unwrap(), app.canvas_rect.unwrap()); + designcraft_geom::Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom) + }; + let place = centre(&app); + frame(&mut app, key(egui::Key::PageDown, alt)); + app.run("view.zoomIn", json!({})).unwrap(); + let zoomed = app.view().unwrap().zoom; + assert!(zoomed > before.zoom); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(app.view().unwrap().zoom, zoomed, "the zoom in use is kept"); + assert!((centre(&app) - place).hypot() < 1e-6, "the same place: {:?} vs {place:?}", centre(&app)); + // Scrolling down two spreads is not a jump: Go Forward keeps its page, and Go Back skips + // from where the scroll ended straight to the page before the first jump. + let slots = designcraft_tools::layout::CanvasLayout::new(&app.session.active().unwrap().doc, false).slots; + app.view_mut().unwrap().origin.y += slots[3].bounds.center().y - slots[1].bounds.center().y; + frame(&mut app, vec![]); + assert_eq!(spread(&app), 3); + assert!(menu_enabled(&app, "view.goForward"), "scrolling kept the page ahead"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(spread(&app), 0, "Go Back"); + assert!(!menu_enabled(&app, "view.goBack"), "the scroll added no entry"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(spread(&app), 3, "Go Forward returns to where the scroll ended"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(spread(&app), 2); + } + + #[test] + fn go_back_after_presentation_returns_to_the_last_spread_presented() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + let key = |k: egui::Key, modifiers: egui::Modifiers| { + vec![ + egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ] + }; + let spread = |app: &crate::DesignApp| { + let st = app.session.active().unwrap(); + crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap() + }; + let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + // Scrolled off the page's centre, so the place Go Back returns to is not just the page. + let view = app.view_mut().unwrap(); + view.origin.x += 40.0; + view.origin.y += 60.0; + frame(&mut app, vec![]); + assert_eq!(spread(&app), 0); + let before = *app.view().unwrap(); + // Presentation goes on to spreads 1 and 2; leaving it puts spread 0 back as it was. + app.run("view.screenMode", json!({"mode": "presentation"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(spread(&app), 2); + app.run("view.screenMode", json!({"mode": "normal"})).unwrap(); + frame(&mut app, vec![]); + assert_eq!(spread(&app), 0); + assert!(!menu_enabled(&app, "view.goForward")); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(spread(&app), 2, "Go Back shows the last spread presented"); + frame(&mut app, key(egui::Key::PageUp, cmd)); + assert_eq!(spread(&app), 0, "Go Back again, the view Presentation was entered from"); + let v = *app.view().unwrap(); + assert!((v.origin - before.origin).hypot() < 1e-6, "where it was: {:?} vs {:?}", v.origin, before.origin); + assert!(!menu_enabled(&app, "view.goBack"), "the presentation is one jump: spread 1 isn't kept"); + frame(&mut app, key(egui::Key::PageDown, cmd)); + assert_eq!(spread(&app), 2, "Go Forward"); + } + #[test] fn presentation_shows_one_spread_and_goes_through_them_with_keys_and_clicks() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -4715,7 +5066,7 @@ mod tests { events, ..Default::default() }; - app.raw_input_hook(&mut input); + app.raw_input_hook(ctx, &mut input); let mut menu = menu; let mut out = ctx.run_ui(input, |ui| { let ctx = ui.ctx().clone(); @@ -5349,6 +5700,7 @@ mod tests { delta: egui::vec2(0.0, -5000.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }, ]); for _ in 0..60 { @@ -5675,6 +6027,7 @@ mod tests { delta: egui::vec2(0.0, -400.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }; let mut after = tile_y(vec![egui::Event::PointerMoved(at), wheel]); for _ in 0..30 { @@ -5725,7 +6078,7 @@ mod tests { let mut row = egui::Rect::NOTHING; let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| { - egui::Area::new(egui::Id::new("menu_rows")).fixed_pos(egui::pos2(50.0, 50.0)).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("menu_rows")).fixed_pos(egui::pos2(50.0, 50.0)).show(ui.ctx(), |ui| { let before = ui.cursor().min; menu_column(app, ui, &[("Test", entries.clone())]); row = egui::Rect::from_min_max(before, ui.min_rect().max); diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index ee59de800..65e60329c 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -278,11 +278,13 @@ pub(crate) fn end_stale_preview(app: &mut DesignApp) { } } -/// Drop the views (one per pane) of a preview document that has gone. +/// Drop the views and page histories (one per pane) of a preview document that has gone. fn forget_view(app: &mut DesignApp, uid: Option) { if let Some(uid) = uid.filter(|uid| app.session.documents().iter().all(|d| d.uid != *uid)) { app.views.remove(&uid); app.views.remove(&(uid ^ (1 << 63))); + app.page_history.remove(&uid); + app.page_history.remove(&(uid ^ (1 << 63))); } } @@ -409,7 +411,7 @@ fn card(ui: &mut Ui, c: &Card, units: Unit, selected: bool) -> egui::Response { FontId::proportional(11.0), t.text_dim, ); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &c.label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &c.label)); resp } @@ -455,7 +457,7 @@ fn tabs(app: &DesignApp, ui: &mut Ui, d: &mut Dialog) { select(d, &c); } } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, text)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, text)); } ui.min_rect() }); @@ -567,7 +569,7 @@ fn spin(ui: &mut Ui, d: &mut Dialog, key: &str, width: f32, step: Step) -> bool for (dir, half) in [(1.0, Rect::from_min_max(seg.min, pos2(seg.max.x, seg.center().y))), (-1.0, Rect::from_min_max(pos2(seg.min.x, seg.center().y), seg.max))] { - let resp = ui.interact(half, ui.id().with((key, dir < 0.0)), Sense::click()); + let resp = ui.interact(half, ui.scope_id().with((key, dir < 0.0)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(half.shrink(1.0), 1.0, t.hover); } @@ -587,11 +589,10 @@ fn spin(ui: &mut Ui, d: &mut Dialog, key: &str, width: f32, step: Step) -> bool let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_rect).layout(Layout::left_to_right(Align::Center))); let resp = child.add( egui::TextEdit::singleline(&mut s) - .id(ui.id().with((key, "text"))) + .id(ui.scope_id().with((key, "text"))) .frame(egui::Frame::NONE) .desired_width(text_rect.width()) - .horizontal_align(Align::Min) - .vertical_align(Align::Center), + .align(egui::Align2::LEFT_CENTER), ); if resp.changed() { d.fields.insert(key.into(), Value::String(s)); @@ -734,19 +735,19 @@ fn details(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) { let mut name = d.s("name"); let hint = app.session.next_untitled_title(); let resp = ui.add(egui::TextEdit::singleline(&mut name).hint_text(hint).desired_width(2.0 * COLUMN_W - 10.0).font(semibold(13.0))); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tr("Document Name"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tr("Document Name"))); if resp.changed() { d.fields.insert("name".into(), json!(name)); } let save = crate::widgets::icon_toggle_sized(ui, "preset-save", false, tr("Save Document Preset"), vec2(30.0, 26.0)); - save.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tr("Save Document Preset"))); + save.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tr("Save Document Preset"))); // It holds a text field: clicks inside it don't close it. egui::Popup::menu(&save).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { ui.set_min_width(220.0); crate::rtl::label(ui, tr("Save Document Preset As:")); let mut preset = d.s("presetName"); let field = ui.add(egui::TextEdit::singleline(&mut preset).hint_text(tr("Custom")).desired_width(200.0)); - field.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tr("Preset Name"))); + field.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tr("Preset Name"))); // Type the name as soon as the popup opens. if save.clicked() { field.request_focus(); @@ -957,6 +958,7 @@ mod tests { delta: vec2(0.0, -400.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }; frame(&mut app, &ctx, vec![wheel]); let texts = settle(&mut app, &ctx); diff --git a/crates/ui-egui/src/object_control.rs b/crates/ui-egui/src/object_control.rs index 0a845a38d..ffa5ec2e6 100644 --- a/crates/ui-egui/src/object_control.rs +++ b/crates/ui-egui/src/object_control.rs @@ -13,7 +13,7 @@ pub(crate) const HEIGHT: f32 = ROW_PITCH + FIELD_H; fn group(ui: &mut Ui, id: &str, width: f32, draw: impl FnOnce(&mut Ui, Rect)) { let (rect, _) = ui.allocate_exact_size(vec2(width, HEIGHT), Sense::hover()); - ui.interact(rect, egui::Id::new(("object_control", id)), Sense::hover()); + ui.interact(rect, egui::Id::unique(("object_control", id)), Sense::hover()); let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center))); draw(&mut child, rect); } @@ -32,8 +32,8 @@ fn cell(ui: &mut Ui, group: Rect, x: f32, row: usize, width: f32, enabled: bo fn icon(ui: &mut Ui, group: Rect, x: f32, row: usize, name: &str, tip: &str) { let rect = Rect::from_min_size(group.min + vec2(x, row as f32 * ROW_PITCH + 2.5), vec2(16.0, 16.0)); icons::paint(ui.painter(), rect, name, Tokens::get(ui.ctx()).icon); - let resp = ui.interact(rect, ui.id().with(("caption", name, row)), Sense::hover()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, tip)); + let resp = ui.interact(rect, ui.scope_id().with(("caption", name, row)), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); resp.on_hover_ui(|ui| { crate::rtl::label(ui, tip); }); @@ -45,7 +45,7 @@ fn separator(ui: &mut Ui) { } fn toggle(ui: &mut Ui, group: Rect, x: f32, key: &str, tip: &str) -> bool { - let id = egui::Id::new(key); + let id = egui::Id::unique(key); let on = ui.data(|d| d.get_temp::(id)).unwrap_or(false); let rect = Rect::from_min_size(group.min + vec2(x, 12.5), vec2(20.0, FIELD_H)); if properties::place(ui, rect, |ui| widgets::icon_toggle(ui, if on { "link" } else { "link-broken" }, on, tip)).clicked() { @@ -184,7 +184,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut Ui) { } if let Some(resp) = chip_resp { let label = tr(if stroke { "Stroke" } else { "Fill" }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); resp.on_hover_text(label); } }); @@ -362,7 +362,7 @@ mod tests { fn bottom_row_spinner_edits_height_inside_the_control_panel() { let (ctx, mut app) = fixture(true); let before = panels::sel_info(&app).unwrap().page_rect.height(); - let group = ctx.read_response(egui::Id::new(("object_control", "dimensions"))).unwrap().rect; + let group = ctx.read_response(egui::Id::unique(("object_control", "dimensions"))).unwrap().rect; assert!(group.bottom() <= 59.0, "second row is inside the panel: {group:?}"); let height_up = group.min + vec2(123.0 + widgets::SEG_W / 2.0, ROW_PITCH + FIELD_H / 4.0); click(&ctx, &mut app, height_up); @@ -374,7 +374,7 @@ mod tests { fn empty_selection_keeps_numeric_controls_inactive() { let (ctx, mut app) = fixture(false); let status = app.ui.status.clone(); - let group = ctx.read_response(egui::Id::new(("object_control", "dimensions"))).unwrap().rect; + let group = ctx.read_response(egui::Id::unique(("object_control", "dimensions"))).unwrap().rect; click(&ctx, &mut app, group.min + vec2(24.0, FIELD_H / 4.0)); assert_eq!(app.ui.status, status, "disabled spinners do not attempt transforms"); assert!(panels::sel_info(&app).is_none()); @@ -385,7 +385,7 @@ mod tests { let (ctx, mut app) = fixture(true); frame_with_width(&ctx, &mut app, vec![], 900.0); frame_with_width(&ctx, &mut app, vec![], 900.0); - let id = egui::Id::new(("object_control", "align")); + let id = egui::Id::unique(("object_control", "align")); let before = ctx.read_response(id).unwrap().rect; assert!(before.right() > 900.0, "the toolbar overflows at the minimum width"); frame_with_width( @@ -398,6 +398,7 @@ mod tests { delta: vec2(-500.0, 0.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }, ], 900.0, diff --git a/crates/ui-egui/src/page_history.rs b/crates/ui-egui/src/page_history.rs new file mode 100644 index 000000000..177bfcf9c --- /dev/null +++ b/crates/ui-egui/src/page_history.rs @@ -0,0 +1,148 @@ +//! Layout ▸ Go Back / Go Forward: the pages a window has jumped between, in the order it showed them. +//! InDesign keeps this order for the session, like a web browser's Back and Forward. + +use designcraft_doc::ids::PageId; +use designcraft_geom::Vec2; + +/// Entries kept: the oldest go first. +const LIMIT: usize = 256; + +#[derive(Clone, Debug, Default)] +pub struct PageHistory { + /// Each page with where the view centre was on it, as an offset from the page's centre. + pages: Vec<(PageId, Vec2)>, + /// The entry in view. + at: usize, +} + +impl PageHistory { + /// A jump or Go Back starts from `page`, the view centre `offset` from the page's centre: the + /// entry in view takes that place, wherever scrolling has moved the view since it was recorded, + /// as a browser keeps a page's scroll position. + pub fn leave(&mut self, page: PageId, offset: Vec2) { + match self.pages.get_mut(self.at) { + Some(e) => *e = (page, offset), + None => self.visit(page, offset), + } + } + + /// A jump has brought the view to `page`, its centre `offset` from the page's centre. Another page + /// than the entry in view drops the entries ahead of it, as a browser does when a link is followed + /// after going back. + pub fn visit(&mut self, page: PageId, offset: Vec2) { + if let Some(e) = self.pages.get_mut(self.at) + && e.0 == page + { + e.1 = offset; + return; + } + self.pages.truncate(self.at.saturating_add(1)); + self.pages.push((page, offset)); + if self.pages.len() > LIMIT { + self.pages.remove(0); + } + self.at = self.pages.len().saturating_sub(1); + } + + /// The entry Go Back (`step` < 0) or Go Forward goes to: the nearest one that way whose page is + /// still in the document and isn't the page in view. + fn target(&self, step: isize, exists: impl Fn(PageId) -> bool) -> Option { + let current = self.pages.get(self.at).map(|e| e.0); + let usable = |i: &usize| self.pages.get(*i).is_some_and(|&(p, _)| Some(p) != current && exists(p)); + if step < 0 { (0..self.at).rev().find(usable) } else { (self.at.saturating_add(1)..self.pages.len()).find(usable) } + } + + /// Whether Go Back (`step` < 0) or Go Forward has a page to go to. + pub fn can_go(&self, step: isize, exists: impl Fn(PageId) -> bool) -> bool { + self.target(step, exists).is_some() + } + + /// Go Back (`step` < 0) or Go Forward: the page to show and where the view was on it. It becomes + /// the entry in view. + pub fn go(&mut self, step: isize, exists: impl Fn(PageId) -> bool) -> Option<(PageId, Vec2)> { + let i = self.target(step, exists)?; + self.at = i; + self.pages.get(i).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL: fn(PageId) -> bool = |_| true; + + fn visited(pages: &[u64]) -> PageHistory { + let mut h = PageHistory::default(); + for &p in pages { + h.visit(PageId(p), Vec2::ZERO); + } + h + } + + fn page(e: Option<(PageId, Vec2)>) -> Option { + e.map(|(p, _)| p.0) + } + + #[test] + fn go_back_and_forward_retrace_the_pages_in_the_order_they_were_viewed() { + let mut h = visited(&[1, 5, 3]); + assert!(!h.can_go(1, ALL), "nothing ahead of the last page viewed"); + assert_eq!(page(h.go(-1, ALL)), Some(5)); + assert_eq!(page(h.go(-1, ALL)), Some(1)); + assert_eq!(h.go(-1, ALL), None, "the first page viewed has nothing before it"); + assert_eq!(page(h.go(1, ALL)), Some(5)); + assert_eq!(page(h.go(1, ALL)), Some(3)); + assert_eq!(h.go(1, ALL), None); + } + + #[test] + fn going_back_does_not_record_the_page_it_shows() { + let mut h = visited(&[1, 5, 3]); + let (p, offset) = h.go(-1, ALL).unwrap(); + // The next jump starts from it. + h.leave(p, offset); + assert_eq!(page(h.go(1, ALL)), Some(3), "Go Forward still has the page left"); + } + + #[test] + fn an_entry_keeps_the_place_a_jump_left_it_at() { + let mut h = visited(&[1, 5]); + // Scrolled from page 5 to page 7, then jumped. + h.leave(PageId(7), Vec2::new(-200.0, 30.0)); + h.visit(PageId(9), Vec2::ZERO); + assert_eq!(h.go(-1, ALL), Some((PageId(7), Vec2::new(-200.0, 30.0))), "back to where the view was"); + assert_eq!(page(h.go(-1, ALL)), Some(1), "scrolling added no entry"); + assert_eq!(page(h.go(1, ALL)), Some(7)); + assert_eq!(page(h.go(1, ALL)), Some(9), "nor dropped the ones ahead"); + } + + #[test] + fn viewing_another_page_after_going_back_clears_go_forward() { + let mut h = visited(&[1, 5, 3]); + h.go(-1, ALL); + h.visit(PageId(7), Vec2::ZERO); + assert_eq!(page(h.go(-1, ALL)), Some(5), "the page gone back to stays behind the new one"); + assert_eq!(page(h.go(1, ALL)), Some(7)); + assert!(!h.can_go(1, ALL), "the page that was ahead is gone"); + } + + #[test] + fn deleted_pages_are_skipped() { + let mut h = visited(&[1, 5, 3]); + let exists = |p: PageId| p != PageId(5); + assert_eq!(page(h.go(-1, exists)), Some(1)); + assert_eq!(page(h.go(1, exists)), Some(3)); + assert!(!visited(&[5, 3]).can_go(-1, exists), "no page left to go back to"); + } + + #[test] + fn the_history_is_bounded() { + let mut h = visited(&(0..LIMIT as u64 + 10).collect::>()); + let mut back = 0; + while h.go(-1, ALL).is_some() { + back += 1; + } + assert_eq!(back, LIMIT - 1); + } +} diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index bec168ea1..b022f2647 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -15,14 +15,14 @@ struct LegacyDrag { pub(crate) fn cancel_gesture(app: &mut DesignApp, ctx: &egui::Context) { cancel_legacy_drag(app, ctx); - craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); + craft_ui::docking::cancel_drag::(ctx, egui::Id::unique("designcraft-panel-docking")); } fn cancel_legacy_drag(app: &mut DesignApp, ctx: &egui::Context) -> bool { - let key = egui::Id::new("designcraft-panel-docking").with("legacy-origin"); + let key = egui::Id::unique("designcraft-panel-docking").with("legacy-origin"); let previous = ctx.data_mut(|data| data.remove_temp::(key)); if let Some(previous) = previous { - craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); + craft_ui::docking::cancel_drag::(ctx, egui::Id::unique("designcraft-panel-docking")); if previous.generation != app.ui.docking_generation { return false; } @@ -431,7 +431,7 @@ fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result bool { - let area = egui::Id::new("designcraft-panel-docking"); + let area = egui::Id::unique("designcraft-panel-docking"); let changed_workspace = ui.ctx().data_mut(|data| { let previous = data.get_temp::(area.with("generation")); data.insert_temp(area.with("generation"), app.ui.docking_generation); @@ -463,7 +463,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { blocked.push(ui.ctx().content_rect()); } else if crate::story_editor::visible(app) { // The editor is rendered after the dock; block its unmeasured first frame conservatively. - let rect = ui.ctx().memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap_or_else(|| ui.ctx().content_rect()); + let rect = ui.ctx().memory(|m| m.area_rect(egui::Id::unique("story_editor"))).unwrap_or_else(|| ui.ctx().content_rect()); blocked.push(rect); } let t = crate::theme::Tokens::get(ui.ctx()); @@ -484,7 +484,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { style.panels_label = crate::i18n::tr(&language, "Panels").to_owned(); style.resize_label = crate::i18n::tr(&language, "Resize panels").to_owned(); style.resize_window_label = crate::i18n::tr(&language, "Resize panel window").to_owned(); - let mut area = craft_ui::docking::DockArea::new(egui::Id::new("designcraft-panel-docking")).blocked_rects(&blocked); + let mut area = craft_ui::docking::DockArea::new(egui::Id::unique("designcraft-panel-docking")).blocked_rects(&blocked); if let Some(panel) = &raise { area = area.raise_panel(panel.clone()); } @@ -526,7 +526,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) { return app.ui.docking.is_some(); } - ui.ctx().data_mut(|data| data.remove::(egui::Id::new("designcraft-panel-docking").with("legacy-origin"))); + ui.ctx().data_mut(|data| data.remove::(egui::Id::unique("designcraft-panel-docking").with("legacy-origin"))); } for action in output.actions { if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { @@ -580,11 +580,11 @@ pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, re egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)), ); if ensure(&mut layout, id).is_ok() - && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("designcraft-panel-docking"), id.to_string(), source) + && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::unique("designcraft-panel-docking"), id.to_string(), source) { ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new("designcraft-panel-docking").with("legacy-origin"), + egui::Id::unique("designcraft-panel-docking").with("legacy-origin"), LegacyDrag { layout: app.ui.docking.clone(), open_panel: app.ui.open_panel.clone(), @@ -614,7 +614,7 @@ fn three_line_button(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { let t = crate::theme::Tokens::get(ui.ctx()); let area = ui.max_rect(); let r = egui::Rect::from_center_size(area.center(), egui::vec2(18.0, 18.0)); - let menu = ui.interact(r, ui.id().with("panel-menu"), egui::Sense::click()); + let menu = ui.interact(r, ui.scope_id().with("panel-menu"), egui::Sense::click()); if menu.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index f436cfb5e..f34af3c0c 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -88,8 +88,9 @@ fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab drag(&mut h, source, egui::pos2(160.0, 140.0)); let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking"); assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); - let source = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); - let destination = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + let source = h.ctx.read_response(egui::Id::unique("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + let destination = + h.ctx.read_response(egui::Id::unique("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); drag(&mut h, source, destination); let layout = h.state().ui.docking.as_ref().unwrap(); assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into()))); @@ -159,7 +160,7 @@ fn capture_custom_panel_docking_visual_fixtures() { .save(directory.join(format!("designcraft-shared-docking-{}-{language}-{width}-{scale}x.png", theme.id()))) .unwrap(); if language == "ar" { - let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"swatches".to_string())); + let tab = egui::Id::unique("designcraft-panel-docking").with(("tab", &"swatches".to_string())); let at = harness.ctx.read_response(tab).unwrap().rect.center(); harness.input_mut().events.extend([ Event::PointerMoved(at), @@ -291,8 +292,8 @@ fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() { std::fs::create_dir_all(directory).unwrap(); h.render().unwrap().save(directory.join("designcraft-populated-stroke-floating.png")).unwrap(); } - let from = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center(); - let to = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + let from = h.ctx.read_response(egui::Id::unique("designcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center(); + let to = h.ctx.read_response(egui::Id::unique("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); drag(&mut h, from, to); assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty()); assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel); @@ -448,7 +449,7 @@ fn modal_overlay_blocks_a_real_tab_drag_release() { let mut app = app(); app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); let mut h = harness(app, vec2(1280.0, 800.0)); - let area = egui::Id::new("designcraft-panel-docking"); + let area = egui::Id::unique("designcraft-panel-docking"); let source = h.ctx.read_response(area.with(("tab", &"properties".to_string()))).unwrap().rect.center(); h.event(Event::PointerMoved(source)); h.run_steps(1); @@ -498,7 +499,7 @@ fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); - let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string())); + let tab = egui::Id::unique("designcraft-panel-docking").with(("tab", &"properties".to_string())); let from = h.ctx.read_response(tab).unwrap().rect.center(); h.event(Event::PointerMoved(from)); h.run_steps(1); @@ -507,7 +508,7 @@ fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { h.event(Event::PointerMoved(from - vec2(90.0, 0.0))); h.run_steps(2); assert_eq!(h.ctx.dragged_id(), Some(tab), "must own a real tab drag before opening editor"); - assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).is_none()); + assert!(h.ctx.memory(|m| m.area_rect(egui::Id::unique("story_editor"))).is_none()); let layout = h.state().app.ui.docking.clone(); let frame_before = h.ctx.cumulative_frame_nr(); h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap(); @@ -519,19 +520,19 @@ fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { ]); h.run_steps(1); assert_eq!(h.ctx.cumulative_frame_nr() - frame_before, 1, "release must occur in the first editor frame"); - let measured = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap(); + let measured = h.ctx.memory(|m| m.area_rect(egui::Id::unique("story_editor"))).unwrap(); assert!(!measured.contains(release), "fixture exercises the conservative first-frame guard outside eventual editor bounds"); assert_eq!(h.state().app.ui.docking, layout, "first editor frame must block release before it has a measured rectangle"); assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc); h.run_steps(3); - let editor = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap(); + let editor = h.ctx.memory(|m| m.area_rect(egui::Id::unique("story_editor"))).unwrap(); let screen = h.ctx.content_rect(); let outside = [screen.left_bottom() + vec2(80.0, -80.0), screen.right_bottom() + vec2(-450.0, -80.0)] .into_iter() .find(|p| !editor.contains(*p)) .expect("fixture has space outside editor"); let before_outside = h.state().app.ui.docking.clone(); - let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string())); + let tab = egui::Id::unique("designcraft-panel-docking").with(("tab", &"layers".to_string())); let from = h.ctx.read_response(tab).unwrap().rect.center(); h.event(Event::PointerMoved(from)); h.run_steps(1); @@ -608,7 +609,13 @@ fn capture_legacy_panel_menu_visual_fixture() { assert!(before.is_none_or(|rect| !submenu.contains_rect(rect)), "actual submenu must require scrolling to reach its last target"); h.input_mut().events.extend([ Event::PointerMoved(scroll_at), - Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -1600.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }, + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -1600.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }, ]); h.run_steps(12); let target = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).unwrap().rect(); @@ -636,7 +643,7 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { let mut h = harness(app, vec2(1280.0, 800.0)); assert!(h.state().ui.docking.is_none()); let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); - let dock = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("dock")).unwrap().outer_rect; + let dock = egui::containers::panel::PanelState::load(&h.ctx, h.ui_id().with("dock")).unwrap().outer_rect; let at = egui::pos2(dock.right() - 15.0, tab.center().y); h.event(Event::PointerMoved(at)); h.run_steps(1); @@ -768,7 +775,7 @@ fn pages_keeps_its_panel_menu_in_the_docking_layout() { assert!(app.ui.docking.is_some()); let mut h = harness(app, vec2(1280.0, 800.0)); let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); - let dock = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-dock")).unwrap().outer_rect; + let dock = egui::containers::panel::PanelState::load(&h.ctx, h.ui_id().with("shared-panel-dock")).unwrap().outer_rect; click(&mut h, egui::pos2(dock.right() - 12.0, tab.center().y), PointerButton::Primary); assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); for item in ["Numbering & Section Options…", "Close Tab Group", "Float Panel"] { diff --git a/crates/ui-egui/src/panels/cell_stroke.rs b/crates/ui-egui/src/panels/cell_stroke.rs index 750e180a8..b7f4eaa2e 100644 --- a/crates/ui-egui/src/panels/cell_stroke.rs +++ b/crates/ui-egui/src/panels/cell_stroke.rs @@ -111,7 +111,7 @@ pub fn edge_proxy(ui: &mut Ui, id: &str, edges: &mut Edges, lang: &str) -> bool let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(PROXY_SIZE, PROXY_SIZE), Sense::click()); let label = tr(lang, "Cell edges").to_string(); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, &label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, &label)); let resp = resp.on_hover_text(tr( lang, "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", @@ -122,8 +122,8 @@ pub fn edge_proxy(ui: &mut Ui, id: &str, edges: &mut Edges, lang: &str) -> bool let (double, triple) = (resp.double_clicked(), resp.triple_clicked()); // A double-click counts only on the line of the click before it; a triple-click // decides from the choice before the sequence's first click. - let last_key = ui.id().with((id, "proxy_last")); - let start_key = ui.id().with((id, "proxy_start")); + let last_key = ui.scope_id().with((id, "proxy_last")); + let start_key = ui.scope_id().with((id, "proxy_start")); let last: Option = ui.data(|d| d.get_temp(last_key)).flatten(); if !double && !triple { ui.data_mut(|d| d.insert_temp(start_key, *edges)); @@ -192,7 +192,7 @@ pub fn stroke_fields(app: &mut DesignApp, ui: &mut Ui, id: &str, v: &Value) -> O ui.end_row(); caption(ui, tr(&lang, "Type:")); let shown = type_label(&lang, &v["type"]); - egui::ComboBox::from_id_salt((id, "type")).selected_text(crate::rtl::widget(ui, &shown)).width(140.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt((id, "type")).selected_text(crate::rtl::widget(ui, &shown)).width(140.0).show_ui(ui, |ui| { for kind in TYPES { let text = tr(&lang, builtin(kind).label()); if ui.selectable_label(shown == text, crate::rtl::widget(ui, text)).clicked() { @@ -208,6 +208,7 @@ pub fn stroke_fields(app: &mut DesignApp, ui: &mut Ui, id: &str, v: &Value) -> O } } }); + let _ = crate::widgets::name_combo(combo, tr(&lang, "Type")); ui.end_row(); for (label, key, tint_label, tint_key) in [("Color:", "color", "Tint:", "tint"), ("Gap Color:", "gapColor", "Gap Tint:", "gapTint")] { caption(ui, tr(&lang, label)); @@ -238,7 +239,7 @@ pub fn current(app: &mut DesignApp, edges: &Edges) -> Value { /// The Stroke panel for table cells: the proxy and the stroke fields, applied at once. pub fn panel(app: &mut DesignApp, ui: &mut Ui) { - let key = egui::Id::new("cell_stroke_edges"); + let key = egui::Id::unique("cell_stroke_edges"); let mut edges: Edges = ui.data(|d| d.get_temp(key)).unwrap_or([true; 6]); let lang = app.ui.language.clone(); if edge_proxy(ui, "stroke_panel", &mut edges, &lang) { @@ -282,7 +283,7 @@ mod tests { app.run("table.select", json!({"what": "table"})).unwrap(); let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); diff --git a/crates/ui-egui/src/panels/conditions.rs b/crates/ui-egui/src/panels/conditions.rs index 45eddec20..f5f2c81c5 100644 --- a/crates/ui-egui/src/panels/conditions.rs +++ b/crates/ui-egui/src/panels/conditions.rs @@ -7,6 +7,18 @@ use serde_json::json; use crate::DesignApp; use crate::theme::Tokens; +/// Click on a condition name: toggle it on the selected text, or tell the user to select some. +fn apply_condition(app: &mut DesignApp, name: &str, has_text: bool, applied: &[String]) { + if has_text { + let on = !applied.iter().any(|a| a == name); + if let Err(e) = app.run("condition.apply", json!({"name": name, "on": on})) { + app.status_error("Conditional Text", e); + } + } else { + app.status(crate::i18n::tr(&app.ui.language, "Select text to apply a condition")); + } +} + pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -28,7 +40,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); // Visibility (eye). let eye = egui::Rect::from_min_size(row.min + vec2(2.0, 3.0), vec2(16.0, 16.0)); - let er = ui.interact(eye, ui.id().with(("cond_eye", &c.name)), Sense::click()).on_hover_ui(|ui| { + let er = ui.interact(eye, ui.scope_id().with(("cond_eye", &c.name)), Sense::click()).on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Show / hide text with this condition")); }); if c.visible { @@ -47,7 +59,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } // Name (click: toggle on the selected text) and indicator colour. let name_r = egui::Rect::from_min_max(row.min + vec2(42.0, 0.0), row.max - vec2(24.0, 0.0)); - let nr = ui.interact(name_r, ui.id().with(("cond_name", &c.name)), Sense::click()); + let nr = ui.interact(name_r, ui.scope_id().with(("cond_name", &c.name)), Sense::click()); if nr.hovered() { ui.painter().rect_filled(name_r, 0.0, t.hover); } @@ -55,14 +67,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let chip = egui::Rect::from_min_size(egui::pos2(row.max.x - 20.0, row.min.y + 5.0), vec2(14.0, 12.0)); ui.painter().rect_filled(chip, 2.0, egui::Color32::from_rgb(c.color[0], c.color[1], c.color[2])); if nr.clicked() { - if has_text { - let on = !applied.contains(&c.name); - if let Err(e) = app.run("condition.apply", json!({"name": c.name, "on": on})) { - app.status_error("Conditional Text", e); - } - } else { - app.status(crate::i18n::tr(&app.ui.language, "Select text to apply a condition")); - } + apply_condition(app, &c.name, has_text, &applied); } nr.context_menu(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Condition"))).clicked() { @@ -72,7 +77,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); } ui.add_space(6.0); - let id = egui::Id::new("new_condition_name"); + let id = egui::Id::unique("new_condition_name"); let mut name: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); ui.horizontal(|ui| { ui.add( diff --git a/crates/ui-egui/src/panels/datamerge.rs b/crates/ui-egui/src/panels/datamerge.rs index f0ae77279..edf7b3261 100644 --- a/crates/ui-egui/src/panels/datamerge.rs +++ b/crates/ui-egui/src/panels/datamerge.rs @@ -97,7 +97,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.separator(); crate::rtl::label(ui, egui::RichText::new(tr(&lang, "Merge options")).strong().color(t.text_strong)); - if let Some(records) = choice(ui, "dm_records", &options.records, &[("all", "All records"), ("one", "One record"), ("range", "Range")], &lang) { + let records_name = tr(&lang, "Merge options"); + let record_options = [("all", "All records"), ("one", "One record"), ("range", "Range")]; + if let Some(records) = choice(ui, "dm_records", &options.records, &record_options, &lang, records_name) { let _ = app.run("data.options", json!({"records": records})); } if options.records == "one" { @@ -113,11 +115,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } crate::rtl::label(ui, egui::RichText::new(tr(&lang, "Records per page")).color(t.text)); - if let Some(per_page) = choice(ui, "dm_per_page", &options.per_page, &[("single", "Single record"), ("multiple", "Multiple records")], &lang) { + let per_page_name = tr(&lang, "Records per page"); + let per_page_options = [("single", "Single record"), ("multiple", "Multiple records")]; + if let Some(per_page) = choice(ui, "dm_per_page", &options.per_page, &per_page_options, &lang, per_page_name) { let _ = app.run("data.options", json!({"perPage": per_page})); } if options.per_page == "multiple" { - if let Some(arrange) = choice(ui, "dm_arrange", &options.arrange, &[("rows", "Rows first"), ("columns", "Columns first")], &lang) { + let arrange_options = [("rows", "Rows first"), ("columns", "Columns first")]; + if let Some(arrange) = choice(ui, "dm_arrange", &options.arrange, &arrange_options, &lang, records_name) { let _ = app.run("data.options", json!({"arrange": arrange})); } let labels = ["Top", "Right", "Bottom", "Left"]; @@ -154,6 +159,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ("none", "None"), ], &lang, + "Image Fitting", ) { let _ = app.run("data.options", json!({"fitting": fitting})); } @@ -184,16 +190,17 @@ fn select_source(app: &mut DesignApp) { let _ = app.run("data.source.select", json!({"path": path})); } -fn choice(ui: &mut egui::Ui, id: &str, current: &str, options: &[(&str, &str)], lang: &str) -> Option { +fn choice(ui: &mut egui::Ui, id: &str, current: &str, options: &[(&str, &str)], lang: &str, name: &str) -> Option { let label = options.iter().find(|(value, _)| *value == current).map(|(_, text)| tr(lang, text)).unwrap_or(current); let mut picked = None; - egui::ComboBox::from_id_salt(id).selected_text(label).width(ui.available_width().min(200.0)).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt(id).selected_text(label).width(ui.available_width().min(200.0)).show_ui(ui, |ui| { for (value, text) in options { if ui.selectable_label(*value == current, tr(lang, text)).clicked() { picked = Some((*value).to_string()); } } }); + let _ = crate::widgets::name_combo(combo, name.to_owned()); picked.filter(|value| value != current) } diff --git a/crates/ui-egui/src/panels/font_menu.rs b/crates/ui-egui/src/panels/font_menu.rs index 84572a335..f07e8a810 100644 --- a/crates/ui-egui/src/panels/font_menu.rs +++ b/crates/ui-egui/src/panels/font_menu.rs @@ -64,7 +64,7 @@ fn regular(styles: &[String]) -> String { } fn meta(ui: &Ui, fonts: &designcraft_fonts::ScopedFonts<'static>, scope: u32, family: &str) -> FamilyMeta { - let key = egui::Id::new(("font_meta", family, scope)); + let key = egui::Id::unique(("font_meta", family, scope)); if let Some(m) = ui.data(|d| d.get_temp::(key)) { return m; } @@ -101,7 +101,7 @@ fn family_kind(fonts: &designcraft_fonts::ScopedFonts<'static>, family: &str) -> /// The families' kinds, starting to read them the first time they're needed for `scope`. fn kinds(ctx: &egui::Context, fonts: &designcraft_fonts::ScopedFonts<'static>, scope: u32) -> SharedKinds { - let shared: SharedKinds = ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::new("font_kinds")).clone()); + let shared: SharedKinds = ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("font_kinds")).clone()); let start = { let mut k = lock(&shared); let start = k.scope != Some(scope); @@ -225,8 +225,8 @@ fn preview( ) -> egui::TextureHandle { let ppp = ui.ctx().pixels_per_point(); let px = (size * ppp).round() as u32; - let key = egui::Id::new(("font_preview", family, style, scope, text, px, color.to_array())); - let cache_id = egui::Id::new("font_previews"); + let key = egui::Id::unique(("font_preview", family, style, scope, text, px, color.to_array())); + let cache_id = egui::Id::unique("font_previews"); if let Some(t) = ui.data_mut(|d| d.get_temp_mut_or_default::(cache_id).get(key)) { return t; } @@ -281,7 +281,7 @@ fn toggle(ui: &mut Ui, on: bool, tip: &str, paint: impl FnOnce(&egui::Painter, R ui.painter().rect_filled(r, 3.0, t.hover); } paint(ui.painter(), r, if on { t.text_strong } else { t.icon }); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, tip)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, on, tip)); resp.on_hover_text(tip).clicked() } @@ -303,7 +303,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur let (fonts, scope) = (super::fonts(app), super::font_scope(app)); let english = app.session.prefs.show_font_names_in_english; let mut favs = app.session.prefs.favorite_fonts.clone(); - let state_id = egui::Id::new("font_menu_state"); + let state_id = egui::Id::unique("font_menu_state"); let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); let frame = ui.ctx().cumulative_frame_nr(); if st.frame.is_none_or(|f| f.saturating_add(1) < frame) { @@ -552,7 +552,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur None => family.label(english).to_string(), }; let is_current = style.is_none() && f == current; - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, is_current, &label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, is_current, &label)); if style.is_none() && label != *f { resp = resp.on_hover_text(f); } @@ -571,7 +571,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur // Favourite star. let star = Rect::from_center_size(pos2(r.min.x + 10.0, cy), vec2(16.0, 16.0)); let fav = favs.contains(f); - let sr = ui.interact(star, ui.id().with(("fav", f, recent)), Sense::click()); + let sr = ui.interact(star, ui.scope_id().with(("fav", f, recent)), Sense::click()); paint_star(&p, star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim }); if sr.clicked() { toggle_fav = Some(f.clone()); @@ -580,13 +580,9 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur // The arrow opens a family of several styles. if m.styles.len() > 1 && !recent { let arrow = Rect::from_center_size(pos2(r.min.x + 28.0, cy), vec2(16.0, 16.0)); - let ar = ui.interact(arrow, ui.id().with(("open", f)), Sense::click()); + let ar = ui.interact(arrow, ui.scope_id().with(("open", f)), Sense::click()); ar.widget_info(|| { - egui::WidgetInfo::labeled( - egui::WidgetType::Button, - true, - tr(if st.expanded.contains(f) { "Hide Styles" } else { "Show Styles" }), - ) + egui::WidgetInfo::labeled(egui::Role::Button, true, tr(if st.expanded.contains(f) { "Hide Styles" } else { "Show Styles" })) }); let open = st.expanded.contains(f); crate::icons::paint(&p, arrow.shrink(2.0), if open { "chevron-down" } else { "chevron-right" }, t.icon); @@ -611,8 +607,8 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur // Show Similar Fonts (on hover), and the format. if style.is_none() && (hovered || st.similar.as_deref() == Some(f.as_str())) { let sim = Rect::from_center_size(pos2(icons_x + 12.0, cy), vec2(18.0, 18.0)); - let sr = ui.interact(sim, ui.id().with(("similar", f, recent)), Sense::click()); - sr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tr("Show Similar Fonts"))); + let sr = ui.interact(sim, ui.scope_id().with(("similar", f, recent)), Sense::click()); + sr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tr("Show Similar Fonts"))); p.text(sim.center(), Align2::CENTER_CENTER, "≈", FontId::proportional(15.0), if sr.hovered() { t.text_strong } else { t.icon }); if sr.on_hover_text(tr("Show Similar Fonts")).clicked() { similar = Some(f.clone()); @@ -622,7 +618,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut Ui, menu: &[FamilyInfo], cur if style.is_none() { let badge = Rect::from_center_size(pos2(r.max.x - 16.0, cy), vec2(20.0, 16.0)); paint_format(&p, badge, m.kind.format, t.text_dim, t.accent); - ui.interact(badge, ui.id().with(("format", f, recent)), Sense::hover()).on_hover_text(tr(format_name(m.kind.format))); + ui.interact(badge, ui.scope_id().with(("format", f, recent)), Sense::hover()).on_hover_text(tr(format_name(m.kind.format))); } if resp.clicked() && !handled { pick = Some(FontPick { family: f.clone(), style: style.map(str::to_string) }); @@ -673,7 +669,7 @@ mod preview_cache_tests { let ctx = egui::Context::default(); let tex = |i: usize| ctx.load_texture(format!("t{i}"), egui::ColorImage::new([1, 1], vec![egui::Color32::WHITE]), Default::default()); let mut c = Previews::default(); - let key = |i: usize| egui::Id::new(("p", i)); + let key = |i: usize| egui::Id::unique(("p", i)); for i in 0..Previews::CAP + 40 { c.insert(key(i), tex(i)); // The first one stays in use, so it is kept. diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index 5d91c194f..7d1da4ea3 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -21,7 +21,7 @@ struct State { pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let id = egui::Id::new("glyphs_panel"); + let id = egui::Id::unique("glyphs_panel"); let mut st: State = ui.data(|d| d.get_temp(id)).unwrap_or_default(); // Default to the font at the text cursor. if st.family.is_empty() { @@ -38,13 +38,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { st.style = f.style.clone().or_else(|| db.styles(&f.family).into_iter().next()).unwrap_or_else(|| "Regular".into()); st.family = f.family; } - egui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| { for s in db.styles(&st.family) { if ui.selectable_label(s == st.style, &s).clicked() { st.style = s; } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Font Style")); }); ui.add( egui::TextEdit::singleline(&mut st.query) @@ -120,7 +121,7 @@ pub fn grid( let max_rows = (ui.ctx().input(|i| i.max_texture_side) as u32 / cell_px).max(1); chars.truncate((max_rows * cols) as usize); let ink = t.text_strong; - let key = egui::Id::new(("glyph_grid", family, style, scope, q, cols, cell_px, ink.to_array())); + let key = egui::Id::unique(("glyph_grid", family, style, scope, q, cols, cell_px, ink.to_array())); let tex: egui::TextureHandle = match ui.data(|d| d.get_temp::(key)) { Some(t) => t, None => { diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 5c3280142..7ca6cf595 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -20,7 +20,7 @@ fn dest_text(lang: &str, d: &Value) -> String { pub fn hyperlinks(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let id = egui::Id::new("new_hyperlink_url"); + let id = egui::Id::unique("new_hyperlink_url"); let mut url: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| "https://".into()); let has_target = app.session.active().is_some_and(|d| d.selection.text.is_some_and(|t| !t.is_caret()) || !d.selection.items.is_empty()); ui.horizontal(|ui| { @@ -168,7 +168,7 @@ pub fn tags(app: &mut DesignApp, ui: &mut egui::Ui) { let has_sel = app.session.active().is_some_and(|d| !d.selection.items.is_empty()); // Selected text is tagged inline instead of its frame. let text_sel = app.session.active().and_then(|d| d.selection.text).is_some_and(|t| !t.range().is_empty()); - let id = egui::Id::new("new_tag_name"); + let id = egui::Id::unique("new_tag_name"); let mut name: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); ui.horizontal(|ui| { ui.add( @@ -227,7 +227,7 @@ pub fn tags(app: &mut DesignApp, ui: &mut egui::Ui) { } } // DTD: load, validate (problems listed until the next validation), delete. - let vid = egui::Id::new("dtd_problems"); + let vid = egui::Id::unique("dtd_problems"); let mut problems: Option> = ui.data(|d| d.get_temp(vid)); ui.horizontal(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load DTD…"))).clicked() @@ -365,7 +365,7 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { let mut pick = None; ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "On Release")); - egui::ComboBox::from_id_salt("button_action") + let combo = egui::ComboBox::from_id_salt("button_action") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, labels.iter().find(|l| l.0 == kind).map_or("", |l| l.1)))) .show_ui(ui, |ui| { for (k, l) in labels { @@ -374,6 +374,7 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "On Release Action")); }); let mut params = None; if let Some(k) = pick { @@ -390,7 +391,7 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { }); } if kind == "url" { - let key = egui::Id::new(("button_url", id.0)); + let key = egui::Id::unique(("button_url", id.0)); let mut u: String = ui.data(|d| d.get_temp(key)).unwrap_or(url.clone()); let r = ui.add(egui::TextEdit::singleline(&mut u).desired_width(f32::INFINITY)); if r.lost_focus() && u != url { @@ -425,7 +426,7 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { let cur = ff.as_ref().map(|f| f.kind); ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Form Field")); - egui::ComboBox::from_id_salt("form_kind") + let combo = egui::ComboBox::from_id_salt("form_kind") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, kinds.iter().find(|k| k.0 == cur).map_or("", |k| k.1)))) .show_ui(ui, |ui| { for (k, l) in kinds { @@ -437,9 +438,10 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Form Field Type")); }); if let Some(f) = ff { - let key = egui::Id::new(("form_edit", id.0)); + let key = egui::Id::unique(("form_edit", id.0)); let (mut name, mut value, mut opts): (String, String, String) = ui.data(|d| d.get_temp(key)).unwrap_or((f.name.clone(), f.value.clone(), f.options.join(", "))); let mut commit = false; @@ -537,7 +539,7 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { crate::widgets::caption(ui, &format!("{} {}", crate::i18n::tr(&lang, "Page:"), d.page_name(page))); }); }); - egui::ComboBox::from_id_salt("liquid_rule") + let combo = egui::ComboBox::from_id_salt("liquid_rule") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, liquid_rule_label(pg.liquid)))) .width(190.0) .show_ui(ui, |ui| { @@ -547,6 +549,7 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&lang, "Liquid Page Rule")); ui.separator(); // The selected object (one on this page). let item = app @@ -666,7 +669,7 @@ pub fn transitions(app: &mut DesignApp, ui: &mut egui::Ui) { use designcraft_doc::TransitionKind as K; crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Spread"), si + 1)).strong()); let mut kind = cur.map(|c| c.kind); - egui::ComboBox::from_id_salt("transition_kind") + let combo = egui::ComboBox::from_id_salt("transition_kind") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, kind.map_or("None", K::label)))) .show_ui(ui, |ui| { ui.selectable_value(&mut kind, None, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "None"))); @@ -674,6 +677,7 @@ pub fn transitions(app: &mut DesignApp, ui: &mut egui::Ui) { ui.selectable_value(&mut kind, Some(k), crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, k.label()))); } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Transition")); let mut duration = cur.map_or(1.0, |c| c.duration); let mut horizontal = cur.is_some_and(|c| c.horizontal); let mut changed = kind != cur.map(|c| c.kind); @@ -789,7 +793,7 @@ mod liquid_panel_tests { let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); let mut h = Harness::builder().with_size(egui::vec2(260.0, 420.0)).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("test_fonts"); + let key = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 657e9b611..1f5382ac2 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -80,10 +80,10 @@ fn eye_lock( if locked { icons::paint(ui.painter(), Rect::from_center_size(lock.center(), vec2(13.0, 13.0)), "lock", col); } - let e = ui.interact(eye, ui.id().with(("eye", key)), Sense::click()).on_hover_ui(|ui| { + let e = ui.interact(eye, ui.scope_id().with(("eye", key)), Sense::click()).on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(language, if visible { "Hide" } else { "Show" })); }); - let l = ui.interact(lock, ui.id().with(("lock", key)), Sense::click()).on_hover_ui(|ui| { + let l = ui.interact(lock, ui.scope_id().with(("lock", key)), Sense::click()).on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(language, if locked { "Unlock" } else { "Lock" })); }); (e.clicked(), l.clicked()) @@ -103,7 +103,7 @@ fn selection_square( let s = if small { 7.0 } else { 8.0 }; let r = Rect::from_center_size(pos2(row.max.x - 12.0, row.center().y), vec2(s, s)); let sense = if selected && !small { Sense::click_and_drag() } else { Sense::click() }; - let resp = ui.interact(r.expand(4.0), ui.id().with(("selsq", key)), sense); + let resp = ui.interact(r.expand(4.0), ui.scope_id().with(("selsq", key)), sense); if selected { ui.painter().rect_filled(r, 0.0, color); } else if resp.hovered() { @@ -267,16 +267,16 @@ fn object_rows( let (eye, lock) = eye_lock(ui, ls.language, row, !it.hidden, it.locked, !ls.layer_visible, ("item", it.id.0)); let indent = 14.0 * depth as f32; let expandable = !it.children().is_empty() && it.states.is_empty() && depth < MAX_DEPTH; - let open_id = egui::Id::new(("group_open", it.id.0)); + let open_id = egui::Id::unique(("group_open", it.id.0)); let open = expandable && ui.data(|d| d.get_temp(open_id)).unwrap_or(false); let mut tri_clicked = false; if expandable { let tri = Rect::from_min_size(row.min + vec2(2.0 * COL_W + 14.0 + indent, 0.0), vec2(14.0, ROW_H)); disclosure(ui, tri, open, t.icon); - tri_clicked = ui.interact(tri, ui.id().with(("gtri", it.id.0)), Sense::click()).clicked(); + tri_clicked = ui.interact(tri, ui.scope_id().with(("gtri", it.id.0)), Sense::click()).clicked(); } let name = item_label(ls.doc, it); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, is_sel, &name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, is_sel, &name)); let field = Rect::from_min_max(row.min + vec2(2.0 * COL_W + 28.0 + indent, 3.0), pos2(row.max.x - 28.0, row.max.y - 3.0)); let renaming = match rename_field(ui, field, it.id.0) { // An empty name goes back to the automatic label. @@ -287,7 +287,7 @@ fn object_rows( false } Some(None) => false, - None => ui.data(|d| d.get_temp::(egui::Id::new(RENAME))).is_some_and(|(k, ..)| k == it.id.0), + None => ui.data(|d| d.get_temp::(egui::Id::unique(RENAME))).is_some_and(|(k, ..)| k == it.id.0), }; if !renaming { let clip = Rect::from_min_max(row.min, pos2(row.max.x - 26.0, row.max.y)); @@ -323,7 +323,7 @@ fn object_rows( // after the double-click delay) renames it; a quick double-click only selects. let (now, add) = ui.input(|i| (i.time, i.modifiers.shift || i.modifiers.command)); let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); - let last_id = egui::Id::new(("layers_last_click", it.id.0)); + let last_id = egui::Id::unique(("layers_last_click", it.id.0)); let last: Option = ui.data(|d| d.get_temp(last_id)); ui.data_mut(|d| d.insert_temp(last_id, now)); let slow = !sq && !add && !resp.double_clicked() && last.is_some_and(|t| now - t > delay && now - t < SLOW_RENAME_WINDOW); @@ -377,7 +377,7 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { let shown: Vec = selected.iter().map(|i| doc.top_level_of(*i).unwrap_or(*i)).collect(); // A new selection (made on the canvas or anywhere else) is revealed: its layer opens and its row // scrolls into view, once, so the panel can still be scrolled and closed freely afterwards. - let last_id = egui::Id::new("layers_revealed_selection"); + let last_id = egui::Id::unique("layers_revealed_selection"); let now: Vec = shown.iter().map(|i| i.0).collect(); let reveal = !now.is_empty() && ui.data(|d| d.get_temp::>(last_id)).as_ref() != Some(&now); ui.data_mut(|d| d.insert_temp(last_id, now)); @@ -391,7 +391,7 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.y = 0.0; // `doc.layers` runs top (frontmost) to bottom, as the panel lists them. for (li, l) in doc.layers.iter().enumerate() { - let open_id = egui::Id::new(("layer_open", l.id.0)); + let open_id = egui::Id::unique(("layer_open", l.id.0)); let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(l.id == active); if reveal && sel_layers.contains(&l.id) && !open { open = true; @@ -399,7 +399,7 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { } let lc = c32(l.color); let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &l.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &l.name)); if l.id == active { ui.painter().rect_filled(row, 0.0, t.row_selected); } else if resp.hovered() { @@ -408,7 +408,7 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { let (eye, lock) = eye_lock(ui, &language, row, l.visible, l.locked, false, ("layer", l.id.0)); let tri = Rect::from_min_size(row.min + vec2(2.0 * COL_W + 2.0, 0.0), vec2(14.0, ROW_H)); disclosure(ui, tri, open, t.icon); - let tri_r = ui.interact(tri, ui.id().with(("tri", l.id.0)), Sense::click()); + let tri_r = ui.interact(tri, ui.scope_id().with(("tri", l.id.0)), Sense::click()); // Layer colour chip and name. let chip = Rect::from_min_size(row.min + vec2(2.0 * COL_W + 18.0, 7.0), vec2(4.0, 10.0)); ui.painter().rect_filled(chip, 0.0, lc); @@ -570,7 +570,7 @@ const RENAME: &str = "layers_rename"; /// or a click elsewhere confirms it with a changed text, `Some(None)` when Esc cancels or the text /// is the one it started with, `None` while typing or when this row isn't being renamed. fn rename_field(ui: &mut Ui, r: Rect, key: RenameKey) -> Option> { - let id = egui::Id::new(RENAME); + let id = egui::Id::unique(RENAME); let (k, mut text, start): RenameState = ui.data(|d| d.get_temp(id))?; if k != key { return None; @@ -601,7 +601,7 @@ fn rename_field(ui: &mut Ui, r: Rect, key: RenameKey) -> Option> /// Start renaming row `key`, its field showing `name`. fn start_rename(ui: &Ui, key: RenameKey, name: &str) { - ui.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, name.to_string(), name.to_string()))); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(RENAME), (key, name.to_string(), name.to_string()))); } #[cfg(test)] @@ -739,13 +739,13 @@ mod tests { app.run("selection.set", json!({"ids": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(260.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); h.run_steps(2); - let open = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::(egui::Id::new(("layer_open", first.0)))).unwrap_or(false); + let open = |h: &Harness<'_, DesignApp>| h.ctx.data(|d| d.get_temp::(egui::Id::unique(("layer_open", first.0)))).unwrap_or(false); assert!(!open(&h), "closed while nothing on it is selected"); h.state_mut().run("selection.set", json!({"ids": [id]})).unwrap(); h.run_steps(2); assert!(open(&h), "selecting the object opens its layer"); // Closed again by hand, it stays closed while the selection stays the same. - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("layer_open", first.0)), false)); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("layer_open", first.0)), false)); h.run_steps(2); assert!(!open(&h)); } @@ -760,7 +760,7 @@ mod tests { app.run("selection.set", json!({"ids": []})).unwrap(); let mut h = Harness::builder().with_size(egui::vec2(260.0, 400.0)).with_step_dt(0.05).build_ui_state(|ui, app: &mut DesignApp| show(app, ui), app); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("layer_open", h.state().session.active().unwrap().active_layer.0)), true)); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("layer_open", h.state().session.active().unwrap().active_layer.0)), true)); h.run_steps(2); let row = h.get_by_label("").rect().center(); let click = |h: &mut Harness<'_, DesignApp>| { @@ -799,7 +799,7 @@ mod tests { h.run_steps(2); let rename = |h: &mut Harness<'_, DesignApp>, key: RenameKey, typed: &str| { // As a slow double-click starts it (with an emptied field here, so the typing is the name). - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (key, String::new(), String::new()))); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(RENAME), (key, String::new(), String::new()))); h.run_steps(2); h.get_by_role(egui::accesskit::Role::TextInput).type_text(typed); h.run_steps(1); @@ -809,7 +809,7 @@ mod tests { rename(&mut h, id, "Hero bottle"); assert_eq!(h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().name, "Hero bottle"); // Esc leaves the name as it was. - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (id, String::new(), String::new()))); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(RENAME), (id, String::new(), String::new()))); h.run_steps(2); h.get_by_role(egui::accesskit::Role::TextInput).type_text("Oops"); h.key_press(egui::Key::Escape); @@ -828,7 +828,7 @@ mod tests { let name = |h: &Harness<'_, DesignApp>| h.state().session.active().unwrap().doc.item(ItemId(id)).unwrap().name.clone(); // As a slow double-click on the unnamed row starts it: the field shows the automatic label. let start = |h: &mut Harness<'_, DesignApp>| { - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new(RENAME), (id, "Rectangle".to_string(), "Rectangle".to_string()))); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(RENAME), (id, "Rectangle".to_string(), "Rectangle".to_string()))); h.run_steps(2); }; start(&mut h); diff --git a/crates/ui-egui/src/panels/library.rs b/crates/ui-egui/src/panels/library.rs index 65fb16862..689b43ffa 100644 --- a/crates/ui-egui/src/panels/library.rs +++ b/crates/ui-egui/src/panels/library.rs @@ -84,10 +84,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// Book panel: the open book's documents with their page ranges; numbering, synchronising and -/// exporting the whole book. -pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); +fn book_open_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Book…"))).clicked() && let Some(path) = app.pick_save("Book.dcbook") @@ -102,6 +99,36 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { app.status_error("Book", e); } }); +} + +fn book_action_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add Document…"))).clicked() + && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("open")) + && let Err(e) = app.run("book.add", json!({"path": path})) + { + app.status_error("Book", e); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Update Numbering"))).clicked() { + let _ = app.run("book.paginate", json!({})); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Synchronize"))).clicked() { + let _ = app.run("book.syncStyles", json!({})); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export PDF…"))).clicked() + && let Some(path) = app.pick_save("Book.pdf") + && let Err(e) = app.run("book.exportPdf", json!({"path": path})) + { + app.status_error("Book", e); + } + }); +} + +/// Book panel: the open book's documents with their page ranges; numbering, synchronising and +/// exporting the whole book. +pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + book_open_buttons(app, ui); let Ok(info) = app.session.execute("book.list", &json!({})) else { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No book open.")).size(11.0).color(t.text_dim)); return; @@ -140,32 +167,13 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { }); } ui.separator(); - ui.horizontal_wrapped(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add Document…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("open")) - && let Err(e) = app.run("book.add", json!({"path": path})) - { - app.status_error("Book", e); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Update Numbering"))).clicked() { - let _ = app.run("book.paginate", json!({})); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Synchronize"))).clicked() { - let _ = app.run("book.syncStyles", json!({})); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export PDF…"))).clicked() - && let Some(path) = app.pick_save("Book.pdf") - && let Err(e) = app.run("book.exportPdf", json!({"path": path})) - { - app.status_error("Book", e); - } - }); + book_action_buttons(app, ui); } /// Scripts panel: saved command scripts — run (one undo step), edit, add, delete, load. pub fn scripts(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let sel_id = egui::Id::new("scripts_sel"); + let sel_id = egui::Id::unique("scripts_sel"); let mut sel: usize = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0); if app.ui.scripts.is_empty() { crate::rtl::label( diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f2229b604..b7085b050 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -12,6 +12,7 @@ pub mod library; pub mod notes; pub mod pages; pub mod properties; +pub mod story; pub mod styles; pub mod swatches; pub mod table; @@ -264,7 +265,7 @@ pub fn preflight_errors(app: &DesignApp) -> usize { static MEMO: std::sync::Mutex> = std::sync::Mutex::new(None); let Some(st) = app.session.active() else { return 0 }; let key = std::sync::Arc::as_ptr(&st.doc) as usize; - let mut g = MEMO.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = MEMO.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some((k, n)) = *g && k == key { @@ -292,7 +293,7 @@ fn swatch_menu_font() -> egui::FontId { pub fn swatch_menu_row(ui: &mut egui::Ui, doc: &designcraft_doc::Document, name: &str, current: &str, width: f32) -> bool { let t = Tokens::get(ui.ctx()); let (row, resp) = ui.allocate_exact_size(vec2(width, SWATCH_MENU_ROW_H), egui::Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, name == current, name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, name == current, name)); let hovered = resp.hovered(); if hovered { ui.painter().rect_filled(row, 2.0, t.accent_strong); @@ -403,9 +404,10 @@ pub fn font_combo( pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option { let shown = if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) }; field.widget_info(|| { - let mut info = egui::WidgetInfo::new(egui::WidgetType::ComboBox); + let mut info = egui::WidgetInfo::new(egui::Role::ComboBox); info.enabled = true; info.current_text_value = Some(shown.clone()); + info.label = Some(crate::i18n::tr(&app.ui.language, "Font Family").to_owned()); info }); egui::Popup::menu(field) @@ -451,13 +453,14 @@ pub fn style_shown(current: &str) -> String { pub fn font_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, current: &str, width: f32) { let styles = fonts(app).styles(family); let mut pick = None; - egui::ComboBox::from_id_salt("font_style").selected_text(style_shown(current)).width(width).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("font_style").selected_text(style_shown(current)).width(width).show_ui(ui, |ui| { for s in &styles { if ui.selectable_label(s == current, s).clicked() { pick = Some(s.clone()); } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Font Style")); if let Some(s) = pick { let _ = app.run("type.char", json!({"attrs": {"fontStyle": s}})); } @@ -468,13 +471,14 @@ pub fn para_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, let mut pick = None; let shown = crate::i18n::style_name(&app.ui.language, current); let label = if overridden { format!("{shown}+") } else { shown.to_string() }; - egui::ComboBox::from_id_salt("para_style").selected_text(crate::rtl::widget(ui, label)).width(width).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("para_style").selected_text(crate::rtl::widget(ui, label)).width(width).show_ui(ui, |ui| { for n in names.iter().filter(|n| *n != designcraft_doc::NO_PARA_STYLE) { if ui.selectable_label(n == current, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() { pick = Some(n.clone()); } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Paragraph Style")); if let Some(n) = pick { let _ = app.run("style.paragraph.apply", json!({"name": n, "clearOverrides": false})); } @@ -499,17 +503,26 @@ mod font_menu_tests { const FAMILY: &str = "DC UI Test Mincho"; let font = font_with(FAMILY, &['a']).unwrap(); let font = with_names(&font, &[(3, 1, 0x409, 1, FAMILY), (3, 1, 0x409, 2, "Regular"), (3, 1, 0x0411, 1, "UIテスト明朝")]).unwrap(); - designcraft_fonts::FontDb::global().add_font(font); + // A database of this test's own: a font added to the process-wide one stays there, and its + // Japanese native name would then need CJK glyphs in every later test that draws the menu. + let db = designcraft_fonts::FontDb::with_font_dirs(Vec::new()); + db.add_font(font); + let menu = |app: &DesignApp| { + let mut v: Vec = db.scoped(0).family_infos(); + v.retain(|f| !f.family.starts_with('.')); + designcraft_fonts::sort_for_menu(&mut v, app.session.prefs.show_font_names_in_english); + v + }; let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - let menu = font_menu(&app); - let at = menu.iter().position(|f| f.family == FAMILY).unwrap(); - assert_eq!(menu[at].group, FontGroup::Japanese); - assert!(menu[..at].iter().any(|f| f.family == designcraft_fonts::DEFAULT_FAMILY), "Western fonts first"); - assert!(menu.windows(2).all(|w| w[0].group <= w[1].group), "grouped"); - assert_eq!(font_label(&app, &menu, FAMILY), "UIテスト明朝"); - assert_eq!(font_label(&app, &menu, designcraft_fonts::DEFAULT_FAMILY), designcraft_fonts::DEFAULT_FAMILY); + let entries = menu(&app); + let at = entries.iter().position(|f| f.family == FAMILY).unwrap(); + assert_eq!(entries[at].group, FontGroup::Japanese); + assert!(entries[..at].iter().any(|f| f.family == designcraft_fonts::DEFAULT_FAMILY), "Western fonts first"); + assert!(entries.windows(2).all(|w| w[0].group <= w[1].group), "grouped"); + assert_eq!(font_label(&app, &entries, FAMILY), "UIテスト明朝"); + assert_eq!(font_label(&app, &entries, designcraft_fonts::DEFAULT_FAMILY), designcraft_fonts::DEFAULT_FAMILY); app.run("prefs.set", json!({"showFontNamesInEnglish": true})).unwrap(); - assert_eq!(font_label(&app, &font_menu(&app), FAMILY), FAMILY); + assert_eq!(font_label(&app, &menu(&app), FAMILY), FAMILY); } #[test] diff --git a/crates/ui-egui/src/panels/notes.rs b/crates/ui-egui/src/panels/notes.rs index 6eebb7411..da250bff5 100644 --- a/crates/ui-egui/src/panels/notes.rs +++ b/crates/ui-egui/src/panels/notes.rs @@ -11,7 +11,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let Ok(list) = app.session.execute("note.list", &json!({})) else { return }; let notes = list.as_array().cloned().unwrap_or_default(); let has_text = app.session.active().is_some_and(|d| d.selection.text.is_some()); - let id = egui::Id::new("new_note_text"); + let id = egui::Id::unique("new_note_text"); let mut draft: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); ui.add( egui::TextEdit::multiline(&mut draft) diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 4b2360f8f..acb05f9f4 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -28,7 +28,7 @@ const MAX_THUMB_W: f64 = 1024.0; fn thumb(app: &mut DesignApp, ctx: &egui::Context, of: ThumbOf, h: f32) -> Option { let st = app.session.active()?; let key = (st.uid, st.revision, of, h.round() as u32); - let mut g = THUMBS.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = THUMBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let map = g.get_or_insert_with(HashMap::new); if let Some(t) = map.get(&key) { return Some(t.clone()); @@ -366,7 +366,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { t.text_strong, ); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label)); let resp = if opts.parents_vertical { resp } else { resp.on_hover_text(label) }; resp.dnd_set_drag_payload(PageDrag::Parent(row.parent.map(|(_, id)| id))); if resp.double_clicked() @@ -448,11 +448,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let abs = first + pi; let x0 = row.min.x + center + (p.x as f32 - spine) * scale; let pr = egui::Rect::from_min_size(egui::pos2(x0, top), vec2(p.width as f32 * scale, p.height as f32 * scale)); - let resp = ui.interact(pr, ui.id().with(("page", abs)), Sense::click_and_drag()); + let resp = ui.interact(pr, ui.scope_id().with(("page", abs)), Sense::click_and_drag()); let selected = abs == cur && !editing_parents; // Screen readers hear "Page 3". resp.widget_info(|| { - egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, format!("Page {}", app.session.page_label(abs))) + egui::WidgetInfo::selected(egui::Role::Button, true, selected, format!("Page {}", app.session.page_label(abs))) }); let tint = if selected { SELECTED_TINT } else { Color32::WHITE }; match thumbnails.then(|| thumb(app, ui.ctx(), ThumbOf::Page(abs), th)).flatten() { @@ -547,7 +547,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let dy = dresp.drag_delta().y * if opts.pages_on_top { -1.0 } else { 1.0 }; app.ui.pages_parents_height = Some([(parents_h + dy).clamp(parents_min, parents_max), space]); } - dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "Parents and pages divider")); + dresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Splitter, true, "Parents and pages divider")); }; if opts.pages_on_top { pages_part(app, ui); @@ -771,7 +771,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu /// A small label following the pointer while dragging. pub(crate) fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { let Some(p) = ui.ctx().pointer_interact_pos() else { return }; - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("page_drag"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("page_drag"))); let g = painter.layout_no_wrap(label.to_string(), egui::FontId::proportional(11.0), t.text_strong); let r = egui::Rect::from_min_size(p + vec2(10.0, 8.0), g.size() + vec2(10.0, 6.0)); painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside); @@ -857,7 +857,7 @@ mod tests { fn sized_harness(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(size).build_ui_state( |ui, app: &mut DesignApp| { - let key = egui::Id::new("test_fonts"); + let key = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); @@ -886,7 +886,7 @@ mod tests { fn menu_harness(app: DesignApp, abs: usize, si: usize) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( move |ui, app: &mut DesignApp| { - let key = egui::Id::new("test_fonts"); + let key = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(key)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(key, true)); @@ -988,7 +988,9 @@ mod tests { assert!(window.contains_rect(h.get_by_label(label).rect()), "{label} is on screen"); } // Seven parent rows: four and a half show at first; the rest scroll. - let divider = h.get_by_label("Parents and pages divider").rect(); + let divider_node = h.get_by_label("Parents and pages divider"); + assert_eq!(divider_node.accesskit_node().role(), egui::accesskit::Role::Splitter, "the divider is a splitter, not an unknown node"); + let divider = divider_node.rect(); let before = divider.center().y; assert!(h.query_by_label("G-Parent").is_none_or(|n| n.rect().top() > divider.top()), "the last parent is scrolled away"); let (a, b) = (divider.center(), divider.center() + vec2(0.0, 80.0)); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 0422936e3..f3dc60e3f 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -46,11 +46,11 @@ pub(crate) fn place(ui: &mut Ui, r: Rect, f: impl FnOnce(&mut Ui) -> R) -> R } fn temp_bool(ui: &Ui, key: &str, default: bool) -> bool { - ui.data(|d| d.get_temp::(egui::Id::new(key))).unwrap_or(default) + ui.data(|d| d.get_temp::(egui::Id::unique(key))).unwrap_or(default) } fn set_temp_bool(ui: &Ui, key: &str, v: bool) { - ui.data_mut(|d| d.insert_temp(egui::Id::new(key), v)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(key), v)); } /// Label of the current selection for the selection-type band. @@ -402,7 +402,7 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() { let ir = sub(b, x, y + 2.0, 17.0, 17.0); icons::paint(ui.painter(), ir, icon, t.icon); - ui.interact(ir, ui.id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| { + ui.interact(ir, ui.scope_id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); }); let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| { @@ -545,7 +545,7 @@ pub(crate) fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { for i in 0..9u8 { let c = pos2(r.min.x + pitch * (i % 3) as f32 + pitch / 2.0, r.min.y + pitch * (i / 3) as f32 + pitch / 2.0); let sq = Rect::from_center_size(c, vec2(6.0, 6.0)); - let resp = ui.interact(sq.expand(1.5), ui.id().with(("refpt", i)), Sense::click()); + let resp = ui.interact(sq.expand(1.5), ui.scope_id().with(("refpt", i)), Sense::click()); if i == cur { ui.painter().rect_filled(sq.expand(0.5), 0.0, egui::Color32::WHITE); } else { @@ -664,7 +664,7 @@ fn transform_angles(app: &mut DesignApp, ui: &mut Ui, rp: u8) { let x = if col == 0 { 40.0 } else { 120.0 }; let ir = sub(b, x, yy + 2.0, 17.0, 17.0); icons::paint(ui.painter(), ir, icon, t.icon); - ui.interact(ir, ui.id().with(id), Sense::hover()).on_hover_text(crate::i18n::tr(&lang, tip)); + ui.interact(ir, ui.scope_id().with(id), Sense::hover()).on_hover_text(crate::i18n::tr(&lang, tip)); if let Some(v) = place(ui, sub(b, x + 19.0, yy, 54.0, FIELD_H), |ui| widgets::number(ui, id, tv_get(key), suffix, 54.0, 1)) { set.insert(key.into(), json!(v)); } @@ -686,7 +686,7 @@ fn transform_angles(app: &mut DesignApp, ui: &mut Ui, rp: u8) { ] { let label = crate::i18n::tr(&lang, tip); let resp = icon_toggle(ui, icon, false, label); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); if resp.clicked() { let _ = app.run(cmd, params); } @@ -814,12 +814,12 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let chip = sub(b, 4.0, 2.0, 17.0, 17.0); widgets::paint_chip(ui.painter(), chip, fc, fg); ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); - let resp = ui.interact(chip, ui.id().with("fillchip"), Sense::click()).on_hover_text(format!( + let resp = ui.interact(chip, ui.scope_id().with("fillchip"), Sense::click()).on_hover_text(format!( "{}: {}", crate::i18n::tr(&app.ui.language, "Fill"), i.fill )); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fill swatch")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Fill swatch")); swatch_menu(app, &resp, &doc, &i.fill, false, i.is_text && i.count == 1, false); crate::rtl::paint( ui.painter(), @@ -836,12 +836,12 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let chip = sub(b, 4.0, 2.0, 17.0, 17.0); widgets::paint_stroke_chip(ui.painter(), chip, sc, egui::Color32::from_gray(20)); ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); - let resp = ui.interact(chip, ui.id().with("strokechip"), Sense::click()).on_hover_text(format!( + let resp = ui.interact(chip, ui.scope_id().with("strokechip"), Sense::click()).on_hover_text(format!( "{}: {}", crate::i18n::tr(&app.ui.language, "Stroke"), i.stroke )); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Stroke swatch")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Stroke swatch")); swatch_menu(app, &resp, &doc, &i.stroke, true, i.is_text && i.count == 1, false); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Stroke"), 56.0)).clicked() { show_panel(app, "stroke"); @@ -884,7 +884,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); } let more = sub(b, 147.0, 0.0, 18.0, FIELD_H); - let mresp = ui.interact(more, ui.id().with("opmore"), Sense::click()); + let mresp = ui.interact(more, ui.scope_id().with("opmore"), Sense::click()); ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); if mresp.clicked() { @@ -916,7 +916,7 @@ fn swatch_menu( ) { let lang = app.ui.language.clone(); let tr = |s: &'static str| crate::i18n::tr(&lang, s); - let to_text_id = egui::Id::new(("swatch_apply_to_text", stroke)); + let to_text_id = egui::Id::unique(("swatch_apply_to_text", stroke)); let to_text = text_only || text_frame && resp.ctx.data(|d| d.get_temp::(to_text_id)).unwrap_or(false); // What the popup shows and changes: the frame's fill or stroke, or its text's. let (key, tint_key) = if stroke { ("stroke", "strokeTint") } else { ("fill", "fillTint") }; @@ -1167,6 +1167,9 @@ fn text_style_section(app: &mut DesignApp, ui: &mut Ui) { let shown = crate::i18n::style_name(&app.ui.language, &cur); let label = if ov > 0 { format!("{shown}+") } else { shown.to_string() }; let resp = widgets::dropdown_h(ui, "", fw, 34.0); + // The row paints its own swatch and name, so the dropdown itself has no text to name it. + let style_kind = crate::i18n::tr(&app.ui.language, if tab == 0 { "Paragraph Style" } else { "Character Style" }).to_owned(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, &style_kind)); // "Ag" swatch + style name. let ag = Rect::from_min_size(resp.rect.min + vec2(6.0, 6.0), vec2(22.0, 22.0)); ui.painter().rect_filled(ag, 1.0, egui::Color32::WHITE); @@ -1254,7 +1257,7 @@ fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { if k == 0 { ui.painter().text(chip.center(), egui::Align2::CENTER_CENTER, "T", crate::theme::semibold(12.0), tc); } - let resp = ui.interact(chip, ui.id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); + let resp = ui.interact(chip, ui.scope_id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); swatch_menu(app, &resp, &doc, &sw, k != 0, false, true); if k == 0 { crate::rtl::paint( @@ -1296,6 +1299,9 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { let fonts = super::fonts(app); let menu = super::font_menu(app); let resp = widgets::dropdown(ui, "", fw); + // A painted search icon and family name, as in the font menus: name the field itself. + let family_label = crate::i18n::tr(&app.ui.language, "Font Family:").to_owned(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, &family_label)); let r = resp.rect; icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(3.0, 3.0), vec2(14.0, 14.0)), "search", t.icon); widgets::chevron_down(ui.painter(), r.min + vec2(22.0, 10.5), t.icon); @@ -1348,21 +1354,145 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { }) { let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); } - // Language: hyphenation, spelling and typographer's quotes follow it. - ui.add_space(1.0); + // ••• shows the rest in place, as InDesign's Properties panel does. + let more = temp_bool(ui, "props_char_more", false); + if more { + character_more(app, ui, &c, fw); + } + if more_options(ui, &app.ui.language).clicked() { + set_temp_bool(ui, "props_char_more", !more); + } +} + +/// A percentage or other number field in one half of a two-field row, with its icon. +#[allow(clippy::too_many_arguments)] +fn half_field(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option { + half_field_or_clear(ui, b, right, y, half, icon, tip, field).flatten() +} + +/// [`half_field`] for a value that may be unset: emptying the field commits `Some(None)`. +#[allow(clippy::too_many_arguments)] +fn half_field_or_clear(ui: &mut Ui, b: Rect, right: bool, y: f32, half: f32, icon: &str, tip: &str, field: NumField) -> Option> { + let t = Tokens::get(ui.ctx()); + let x = if right { half + 10.0 } else { 0.0 }; + let ir = sub(b, x, y + 1.0, 19.0, 19.0); + icons::paint(ui.painter(), ir, icon, t.icon); + let resp = ui.interact(ir, ui.scope_id().with(("half_icon", icon)), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip)); + let label = resp.id; + resp.on_hover_text(tip); + place(ui, sub(b, x + 24.0, y, half - 24.0, FIELD_H), |ui| field.width(half - 24.0).labelled_by(label).show_or_clear(ui)) +} + +/// Properties › Character after •••: vertical and horizontal scale, baseline shift and skew, +/// language and OpenType, then All Caps, Small Caps, Superscript, Subscript, Underline and +/// Strikethrough, as InDesign lays them out. +fn character_more(app: &mut DesignApp, ui: &mut Ui, c: &Value, fw: f32) { + let lang_ui = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string(); + let half = (fw - 10.0) / 2.0; + let b = block(ui, 2.0 * FIELD_H + 5.0); + const SCALES: &[f64] = &[25.0, 50.0, 75.0, 80.0, 90.0, 100.0, 110.0, 120.0, 125.0, 150.0, 200.0]; + let pct = |v: &Value| v.as_f64().map(|s| (s * 1000.0).round() / 10.0); + let field = |id, v, suffix, decimals| NumField::number(id, v, suffix, decimals).spinner(); + if let Some(v) = half_field( + ui, + b, + false, + 0.0, + half, + "vscale", + &tr("Vertical Scale"), + field("cvscale", pct(&c["vScale"]), "%", 1).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"vScale": v / 100.0}})); + } + if let Some(v) = half_field( + ui, + b, + true, + 0.0, + half, + "hscale", + &tr("Horizontal Scale"), + field("chscale", pct(&c["hScale"]), "%", 1).presets(SCALES).range(1.0, 1000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}})); + } + if let Some(v) = half_field( + ui, + b, + false, + 26.0, + half, + "baseline-shift", + &tr("Baseline Shift"), + field("cbshift", c["baselineShift"].as_f64(), " pt", 2).range(-5000.0, 5000.0), + ) { + let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}})); + } + if let Some(v) = + half_field(ui, b, true, 26.0, half, "skew", &tr("Skew (False Italic)"), field("cskew", c["skew"].as_f64(), "°", 1).range(-85.0, 85.0)) + { + let _ = app.run("type.char", json!({"attrs": {"skew": v}})); + } + // Language (hyphenation, spelling and typographer's quotes follow it) and OpenType. + ui.add_space(4.0); let lang = c["language"].as_str().unwrap_or("English: USA").to_string(); - let label = crate::i18n::tr(&app.ui.language, "Language"); - let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &lang), fw).on_hover_ui(|ui| { - crate::rtl::label(ui, label); + let label = tr("Language"); + let row = block(ui, FIELD_H); + let resp = place(ui, sub(row, 0.0, 0.0, half, FIELD_H), |ui| widgets::dropdown(ui, crate::i18n::tr(&lang_ui, &lang), half)).on_hover_ui(|ui| { + crate::rtl::label(ui, label.clone()); }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, &label)); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(fw); + ui.set_min_width(half); egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| language_menu(app, ui, &lang)); }); - if more_options(ui, &app.ui.language).clicked() { - show_panel(app, "character"); + if place(ui, sub(row, half + 10.0, 0.0, half, FIELD_H), |ui| widgets::outline_button(ui, &tr("OpenType"), half)).clicked() { + show_typography_panel(app); } + // The case and position buttons. + ui.add_space(8.0); + let caps = c["capitalization"].as_str().unwrap_or("normal").to_string(); + let pos = c["position"].as_str().unwrap_or("normal").to_string(); + let (ul, st) = (c["underline"].as_bool().unwrap_or(false), c["strikethrough"].as_bool().unwrap_or(false)); + let buttons: [(&str, &str, bool, Value); 6] = [ + ("TT", "All Caps", caps == "allCaps", json!({"capitalization": if caps == "allCaps" { "normal" } else { "allCaps" }})), + ("small-caps", "Small Caps", caps == "smallCaps", json!({"capitalization": if caps == "smallCaps" { "normal" } else { "smallCaps" }})), + ("T¹", "Superscript", pos == "superscript", json!({"position": if pos == "superscript" { "normal" } else { "superscript" }})), + ("T₁", "Subscript", pos == "subscript", json!({"position": if pos == "subscript" { "normal" } else { "subscript" }})), + ("T̲", "Underline", ul, json!({"underline": !ul})), + ("T", "Strikethrough", st, json!({"strikethrough": !st})), + ]; + let pitch = fw / 6.0; + let row = block(ui, 22.0); + for (i, (text, tip, on, attrs)) in buttons.into_iter().enumerate() { + let r = Rect::from_center_size(row.min + vec2(pitch * (i as f32 + 0.5), 11.0), vec2(26.0, 22.0)); + let resp = ui.interact(r, ui.scope_id().with(("case", i)), Sense::click()); + let t = Tokens::get(ui.ctx()); + if on { + ui.painter().rect_filled(r, 2.0, t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + let col = if on { t.text_strong } else { t.icon }; + if text == "small-caps" { + crate::icons::paint(ui.painter(), r, "small-caps", col); + } else { + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(13.0), col); + } + // Strikethrough: the T struck through (a combining stroke doesn't draw in every font). + if i == 5 { + ui.painter().line_segment([r.center() + vec2(-6.0, 0.5), r.center() + vec2(6.0, 0.5)], Stroke::new(1.2, col)); + } + let tip = tr(tip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, &tip)); + if resp.on_hover_text(&tip).clicked() { + let _ = app.run("type.char", json!({"attrs": attrs})); + } + } + ui.add_space(4.0); } /// Paragraph section: the 9 alignment buttons and •••. @@ -1392,20 +1522,81 @@ fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) { } } }); + let more = temp_bool(ui, "props_para_more", false); + if more { + paragraph_more(app, ui, &a["para"], fw); + } if more_options(ui, &app.ui.language).clicked() { - show_panel(app, "paragraph"); + set_temp_bool(ui, "props_para_more", !more); } } +/// Properties › Paragraph after •••: left and right indent, first line and last line indent, +/// space before and after, drop cap lines and characters, then Hyphenate and Align to Baseline +/// Grid, as InDesign lays them out. +fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) { + let lang_ui = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string(); + let u = units(app); + let half = (fw - 10.0) / 2.0; + ui.add_space(4.0); + let b = block(ui, 4.0 * FIELD_H + 15.0); + let rows = [ + (("indent-left", "Left Indent", "leftIndent"), ("indent-right", "Right Indent", "rightIndent")), + (("indent-first", "First Line Left Indent", "firstLineIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")), + (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")), + ]; + for (i, (l, r)) in rows.into_iter().enumerate() { + let y = i as f32 * (FIELD_H + 5.0); + for (right, (icon, tip, key)) in [(false, l), (true, r)] { + let min = para_field_min(key); + let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0); + if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: v}})); + } + } + } + let y = 3.0 * (FIELD_H + 5.0); + for (right, icon, tip, key, max) in [ + (false, "drop-lines", "Drop Cap Number of Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), + (true, "drop-chars", "Drop Cap One or More Characters", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), + ] { + let field = NumField::number(key, p[key].as_f64(), "", 0).spinner().range(0.0, max as f64); + if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}})); + } + } + ui.add_space(4.0); + ui.horizontal(|ui| { + // First Line Only is aligned too: the button shows it pressed, and a click turns alignment off. + let grid = matches!(p["gridAlign"].as_str(), Some("allLines" | "firstLineOnly")); + if widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() { + let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}})); + } + ui.add_space(12.0); + let mut h = p["hyphenate"].as_bool().unwrap_or(true); + if widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() { + let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); + } + }); + ui.add_space(4.0); +} + fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { - let Some(a) = text_attrs(app) else { return }; - let lt = a["para"]["listType"].as_str().unwrap_or("none").to_string(); + if text_attrs(app).is_none() { + return; + } ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; - for (icon, kind, tip) in [("bullets", "bullets", "Bulleted List"), ("numbering", "numbers", "Numbered List")] { - let on = lt == kind; + // As InDesign's: a list with the default hanging indent, or (pressed: every selected + // paragraph has it, as Type › Apply Bullets shows) no list. + use designcraft_doc::ListType; + for (icon, kind, cmd, tip) in + [("bullets", ListType::Bullets, "list.bullets", "Bulleted List"), ("numbering", ListType::Numbers, "list.numbers", "Numbered List")] + { + let on = app.session.selection_has_list(kind); if icon_toggle(ui, icon, on, crate::i18n::tr(&app.ui.language, tip)).clicked() { - let _ = app.run("type.para", json!({"attrs": {"listType": if on { "none" } else { kind }}})); + let _ = app.run(cmd, json!({"on": !on})); } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -1505,7 +1696,7 @@ pub(crate) const LANGUAGES: &[&str] = &[ /// The Language list (Character panel, Properties ▸ Character): picking one sets the language of /// the selected text. -fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { +pub(crate) fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { for l in LANGUAGES { if ui.selectable_label(*l == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() { let _ = app.run("type.char", json!({"attrs": {"language": l}})); @@ -1517,7 +1708,7 @@ fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { /// Kerning for the text selection, or at a caret for the pair at the caret: the label shows /// Metrics, Optical, 0 or the manual value; its menu sets one of the first three or a manual value /// in 1/1000 em (typed or stepped). -fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) { +pub(crate) fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) { let kind = k["kind"].as_str().unwrap_or("metrics"); let manual = k["value"].as_f64().filter(|v| kind == "manual" && v.is_finite()); let label = match (kind, manual) { @@ -1528,7 +1719,7 @@ fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, }; let resp = widgets::dropdown(ui, crate::i18n::tr(&app.ui.language, &label), width); let name = crate::i18n::tr(&app.ui.language, "Kerning"); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, name)); egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { for (text, v) in [("Metrics", json!({"kind": "metrics"})), ("Optical", json!({"kind": "optical"})), ("0", json!({"kind": "none"}))] { if ui.selectable_label(label == text, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, text))).clicked() { @@ -1539,7 +1730,13 @@ fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, ui.separator(); // A manual value in 1/1000 em: after each selected character, or between the two at a caret. let field = format!("{id}_manual"); - if let Some(v) = NumField::number(&field, Some(manual.unwrap_or(0.0)), "", 0).width(90.0).spinner().step(5.0).range(-1000.0, 10000.0).show(ui) + if let Some(v) = NumField::number(&field, Some(manual.unwrap_or(0.0)), "", 0) + .name("Manual Kerning") + .width(90.0) + .spinner() + .step(5.0) + .range(-1000.0, 10000.0) + .show(ui) { let _ = app.run("type.kernPair", json!({"kerning": {"kind": "manual", "value": v}})); } @@ -1561,10 +1758,11 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let lang = c["language"].as_str().unwrap_or("English: USA").to_string(); ui.horizontal(|ui| { caption(ui, crate::i18n::tr(&app.ui.language, "Language")); - egui::ComboBox::from_id_salt("char_language") + let combo = egui::ComboBox::from_id_salt("char_language") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &lang))) .width(170.0) .show_ui(ui, |ui| language_menu(app, ui, &lang)); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Language")); }); // Digits (World-Ready): how the figures 0-9 are drawn (Arabic 0123, Hindi ٠١٢٣, Farsi ۰۱۲۳). let digits = c["digits"].as_str().unwrap_or("default").to_string(); @@ -1574,13 +1772,14 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { .iter() .find(|o| o.0 == digits) .map_or_else(|| digit_label(&app.ui.language, DIGIT_OPTIONS[0]), |o| digit_label(&app.ui.language, *o)); - egui::ComboBox::from_id_salt("char_digits").selected_text(crate::rtl::widget(ui, cur)).width(150.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("char_digits").selected_text(crate::rtl::widget(ui, cur)).width(150.0).show_ui(ui, |ui| { for o in DIGIT_OPTIONS { if ui.selectable_label(o.0 == digits, crate::rtl::widget(ui, digit_label(&app.ui.language, o))).clicked() { let _ = app.run("type.char", json!({"attrs": {"digits": o.0}})); } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Digits")); }); // Two columns: Size, Kerning and Baseline on the left, Leading and Tracking on the right. A // column's captions share the width of its widest one and each field takes the rest of the @@ -1610,7 +1809,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } }); ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Color")); + caption(ui, crate::i18n::tr(&app.ui.language, "Color:")); super::swatch_picker(app, ui, "tfill", c["fill"].as_str().map(str::to_string), |app, n| { let _ = app.run("type.char", json!({"attrs": {"fill": n}})); }); @@ -1717,7 +1916,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.end_row(); }); ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Color")); + caption(ui, crate::i18n::tr(&app.ui.language, "Color:")); let cur = c[format!("{key}Color")].as_str().filter(|s| !s.is_empty()).map(str::to_string); super::swatch_picker(app, ui, &format!("{key}c"), cur.clone(), |app, n| { let _ = app.run("type.char", json!({"attrs": {format!("{key}Color"): n}})); @@ -1737,161 +1936,236 @@ pub fn show_typography_panel(app: &mut DesignApp) { let _ = app.run("window.panel", json!({"panel": "typography"})); } +/// The paragraph alignments in InDesign's order, with their icons. +const PARA_ALIGNS: [(Align, &str); 9] = [ + (Align::Left, "palign-left"), + (Align::Center, "palign-center"), + (Align::Right, "palign-right"), + (Align::LeftJustified, "palign-justify-left"), + (Align::CenterJustified, "palign-justify-center"), + (Align::RightJustified, "palign-justify-right"), + (Align::FullyJustified, "palign-justify-all"), + (Align::TowardsSpine, "palign-spine-towards"), + (Align::AwayFromSpine, "palign-spine-away"), +]; + +/// Space Between Paragraphs Using Same Style's presets (pt), after Ignore. +const SAME_STYLE_SPACING: &[f64] = &[0.0, 3.0, 6.0, 9.0, 12.0, 18.0, 24.0]; + +/// The Paragraph panel, laid out as InDesign's: the alignment buttons in groups of 3, 4 and 2; +/// indents, spacing and drop cap fields by their icons in two columns; Shading and Border with +/// their colours; Hyphenate and the baseline grid buttons. The rest (composer, grid and ragged +/// lines options, the paragraph dialogs) is in the panel menu. pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let Some(a) = text_attrs(app) else { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select text or a text frame.")); return; }; + let lang_ui = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang_ui, s).to_string(); let u = units(app); let p = a["para"].clone(); - ui.horizontal(|ui| { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paragraph")).strong()); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - crate::menus::menu_button(ui, "☰", |ui| { - for (label, id) in [ - ("Paragraph Rules…", "app.paragraphRulesDialog"), - ("Justification…", "app.justificationDialog"), - ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"), - ("Span Columns…", "app.spanColumnsDialog"), - ("Bullets and Numbering…", "app.bulletsNumberingDialog"), - ] { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - let _ = app.run(id, json!({})); - ui.close(); - } - } - }); - }); - }); + let fw = full_width(ui); + let half = (fw - 10.0) / 2.0; + // Alignment, and the panel menu at the right. let cur: Align = serde_json::from_value(p["align"].clone()).unwrap_or_default(); - ui.horizontal(|ui| { - for (al, icon) in [ - (Align::Left, "align-left"), - (Align::Center, "align-center"), - (Align::Right, "align-right"), - (Align::LeftJustified, "align-justify"), - (Align::FullyJustified, "align-justify-all"), - ] { - if icons::button(ui, icon, 24.0, cur == al, al.label()).clicked() { - let _ = app.run("type.para", json!({"attrs": {"align": al}})); - } + const GAP: f32 = 8.0; + let pitch = ((fw - 24.0 - 2.0 * GAP) / 9.0).clamp(16.0, 24.0); + let b = block(ui, 21.0); + let mut x = 0.0; + for (i, (al, icon)) in PARA_ALIGNS.into_iter().enumerate() { + if i == 3 || i == 7 { + x += GAP; } - }); - // Paragraph Direction (World-Ready): switching also mirrors left/right alignment. - let rtl = p["direction"] == "rightToLeft"; - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Direction")); - for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] { - if ui.selectable_label(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() && !on { - let align = match cur { - Align::Left => Align::Right, - Align::Right => Align::Left, - a => a, - }; - let _ = app.run("type.para", json!({"attrs": {"direction": dir, "align": align}})); - } - } - }); - egui::Grid::new("paragrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| { - for (row, ((la, ka), (lb, kb))) in [ - (("Left", "leftIndent"), ("Right", "rightIndent")), - (("First", "firstLineIndent"), ("Last", "lastLineIndent")), - (("Before", "spaceBefore"), ("After", "spaceAfter")), - ] - .into_iter() - .enumerate() + if place(ui, sub(b, x, 0.0, pitch, 21.0), |ui| widgets::icon_toggle_sized(ui, icon, cur == al, &tr(al.label()), vec2(pitch, 21.0))).clicked() { - caption(ui, crate::i18n::tr(&app.ui.language, la)); - if let Some(v) = measure(ui, &format!("pp{row}a"), p[ka].as_f64(), u, 64.0) { - let _ = app.run("type.para", json!({"attrs": {ka: v}})); - } - caption(ui, crate::i18n::tr(&app.ui.language, lb)); - if let Some(v) = measure(ui, &format!("pp{row}b"), p[kb].as_f64(), u, 64.0) { - let _ = app.run("type.para", json!({"attrs": {kb: v}})); - } - ui.end_row(); + let _ = app.run("type.para", json!({"attrs": {"align": al}})); } - // Drop cap: number of lines and characters. - for (label, key, max, id) in [ - ("Drop Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES, "ppdl"), - ("Drop Chars", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS, "ppdc"), - ] { - caption(ui, crate::i18n::tr(&app.ui.language, label)); - if let Some(v) = number(ui, id, p[key].as_f64(), "", 64.0, 0) { - let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}})); - } - } - ui.end_row(); - }); - ui.horizontal(|ui| { - let mut h = p["hyphenate"].as_bool().unwrap_or(true); - if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); - } - let mut grid = p["gridAlign"].as_str() == Some("allLines"); - if ui.checkbox(&mut grid, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Align to baseline grid"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "allLines" } else { "none" }}})); - } - }); - let mut optical = p["opticalMargin"].as_bool().unwrap_or(false); - if ui.checkbox(&mut optical, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Optical Margin Alignment"))).changed() { - let _ = app.run("type.para", json!({"attrs": {"opticalMargin": optical}})); + x += pitch; } - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Composer")); - let (single, [paragraph, single_line]) = composer_choice(p["composer"].as_str()); - if ui.selectable_label(!single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph"))).clicked() { - let _ = app.run("type.para", json!({"attrs": {"composer": paragraph}})); + place(ui, sub(b, fw - 20.0, 0.0, 20.0, 21.0), |ui| crate::menus::menu_button(ui, "☰", |ui| paragraph_panel_menu(app, ui, &p))); + ui.add_space(6.0); + // Indents, spacing and drop cap, two to a row as in InDesign. + let row = FIELD_H + 5.0; + let b = block(ui, 5.0 * row - 5.0); + let rows = [ + (("indent-left", "Left Indent", "leftIndent"), ("indent-right", "Right Indent", "rightIndent")), + (("indent-first", "First Line Left Indent", "firstLineIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")), + (("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")), + ]; + for (i, (l, r)) in rows.into_iter().enumerate() { + for (right, (icon, tip, key)) in [(false, l), (true, r)] { + // The first and last line indents may be negative (a hanging line), as InDesign's. + let min = if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 }; + let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0); + if let Some(v) = half_field(ui, b, right, i as f32 * row, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: v}})); + } } - if ui.selectable_label(single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Single-line"))).clicked() { - let _ = app.run("type.para", json!({"attrs": {"composer": single_line}})); + } + // Space Between Paragraphs Using Same Style: Ignore (unset) or a space that replaces the + // space after and before between paragraphs of the same style. + let ignore = tr("Ignore"); + let field = NumField::measure("sameParaStyleSpacing", p["sameParaStyleSpacing"].as_f64(), u) + .spinner() + .presets(SAME_STYLE_SPACING) + .empty_text(&ignore) + .range(0.0, 10_000.0); + if let Some(v) = half_field_or_clear(ui, b, false, 3.0 * row, half, "space-same", &tr("Space Between Paragraphs Using Same Style"), field) { + let _ = app.run("type.para", json!({"attrs": {"sameParaStyleSpacing": v}})); + } + for (right, icon, tip, key, max) in [ + (false, "drop-lines", "Drop Cap Number of Lines", "dropCapLines", designcraft_compose::MAX_DROP_CAP_LINES), + (true, "drop-chars", "Drop Cap One or More Characters", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), + ] { + let field = NumField::number(key, p[key].as_f64(), "", 0).spinner().range(0.0, max as f64); + if let Some(v) = half_field(ui, b, right, 4.0 * row, half, icon, &tr(tip), field) { + let _ = app.run("type.para", json!({"attrs": {key: (v.max(0.0) as u64).min(max as u64)}})); } - }); - // Paragraph Border and Shading. - let swatches: Vec = - app.session.active().map(|d| d.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect()).unwrap_or_default(); - for (on_key, color_key, label) in [("shadingOn", "shadingColor", "Shading"), ("borderOn", "borderColor", "Border")] { - ui.horizontal(|ui| { - let mut on = p[on_key].as_bool().unwrap_or(false); - if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { - let _ = app.run("type.para", json!({ on_key: on })); - } - if on { - let cur = p[color_key].as_str().unwrap_or("[Black]").to_string(); - egui::ComboBox::from_id_salt(("pbs", label)).selected_text(&cur).width(110.0).show_ui(ui, |ui| { - for w in &swatches { - if ui.selectable_label(*w == cur, w).clicked() { - let _ = app.run("type.para", json!({ color_key: w })); - } - } - }); - if label == "Shading" { - if let Some(v) = number(ui, "pshtint", p["shadingTint"].as_f64().map(|t| t * 100.0), "%", 46.0, 0) { - let _ = app.run("type.para", json!({"shadingTint": (v / 100.0).clamp(0.0, 1.0)})); - } - } else if let Some(v) = number(ui, "pbw", p["borderWeights"][0].as_f64(), " pt", 50.0, 2) { - let w = [v.max(0.0); 4]; - let _ = app.run("type.para", json!({ "borderWeights": w })); - } - } - }); } - if p["shadingOn"].as_bool().unwrap_or(false) || p["borderOn"].as_bool().unwrap_or(false) { - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Offsets")); - let o = p["borderOffsets"][0].as_f64().or(p["shadingOffsets"][0].as_f64()); - if let Some(v) = number(ui, "pbo", o, " pt", 50.0, 2) { - let o = [v; 4]; - let _ = app.run("type.para", json!({ "borderOffsets": o, "shadingOffsets": o })); + // Shading and Border: on or off at the left; at the right the Paragraph Borders and Shading + // dialog on its tab and the colour. + ui.add_space(10.0); + let doc = app.session.active().map(|d| d.doc.clone()); + let b = block(ui, 2.0 * row - 5.0); + for (i, (on_key, color_key, label, icon, tab, name)) in [ + ("shadingOn", "shadingColor", "Shading", "para-shading", "shading", "Paragraph Shading"), + ("borderOn", "borderColor", "Border", "para-border", "border", "Paragraph Border"), + ] + .into_iter() + .enumerate() + { + let y = i as f32 * row; + let mut on = p[on_key].as_bool().unwrap_or(false); + if place(ui, sub(b, 0.0, y, half, FIELD_H), |ui| widgets::checkbox(ui, &mut on, &tr(label))).changed() { + let _ = app.run("type.para", json!({"attrs": {on_key: on}})); + } + if place(ui, sub(b, half + 10.0, y, 23.0, FIELD_H), |ui| widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…"))) + .clicked() + { + let _ = app.run("app.paragraphBordersDialog", json!({"tab": tab})); + } + if let Some(doc) = &doc { + let color = p[color_key].as_str().unwrap_or("[Black]").to_string(); + let w = half - 24.0; + if let Some(sw) = place(ui, sub(b, half + 34.0, y, w, FIELD_H), |ui| crate::chrome::swatch_field(ui, doc, &color, w, &tr(name))) { + let _ = app.run("type.para", json!({"attrs": {color_key: sw}})); } - }); + } + } + // Hyphenate, and Do Not Align / Align to Baseline Grid. + ui.add_space(10.0); + let b = block(ui, FIELD_H); + let mut h = p["hyphenate"].as_bool().unwrap_or(true); + if place(ui, sub(b, 0.0, 0.0, half, FIELD_H), |ui| widgets::checkbox(ui, &mut h, &tr("Hyphenate"))).changed() { + let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); + } + let grid = matches!(p["gridAlign"].as_str(), Some("allLines" | "firstLineOnly")); + for (i, (icon, tip, on, v)) in + [("grid-none", "Do Not Align to Baseline Grid", !grid, "none"), ("grid-baseline", "Align to Baseline Grid", grid, "allLines")] + .into_iter() + .enumerate() + { + if place(ui, sub(b, fw - 48.0 + i as f32 * 25.0, 0.0, 23.0, FIELD_H), |ui| widgets::icon_toggle(ui, icon, on, &tr(tip))).clicked() && !on { + let _ = app.run("type.para", json!({"attrs": {"gridAlign": v}})); + } + } + ui.add_space(4.0); +} + +/// The Paragraph panel menu, in the order of InDesign's. +fn paragraph_panel_menu(app: &mut DesignApp, ui: &mut Ui, p: &Value) { + let lang = app.ui.language.clone(); + let item = |ui: &mut Ui, label: &str, check: Option, enabled: bool| -> bool { + let label = crate::i18n::tr(&lang, label); + let text = match check { + Some(true) => format!("✓ {label}"), + Some(false) => format!(" {label}"), + None => label.to_string(), + }; + let clicked = ui.add_enabled(enabled, egui::Button::new(crate::rtl::widget(ui, text))).clicked(); + if clicked { + ui.close(); + } + clicked + }; + let mut attrs = None; + let mut command = None; + let (single, [paragraph, single_line]) = composer_choice(p["composer"].as_str()); + if item(ui, "Paragraph Composer", Some(!single), true) { + attrs = Some(json!({"composer": paragraph})); + } + if item(ui, "Single-line Composer", Some(single), true) { + attrs = Some(json!({"composer": single_line})); + } + ui.separator(); + let grid = p["gridAlign"].as_str().unwrap_or("none"); + if item(ui, "Only Align First Line to Grid", Some(grid == "firstLineOnly"), grid != "none") { + attrs = Some(json!({"gridAlign": if grid == "firstLineOnly" { "allLines" } else { "firstLineOnly" }})); + } + let balance = p["balanceRagged"].as_bool().unwrap_or(false); + if item(ui, "Balance Ragged Lines", Some(balance), true) { + attrs = Some(json!({"balanceRagged": !balance})); + } + // Story-wide Optical Margin Alignment is in the Story panel. + let ignore = p["ignoreOpticalMargin"].as_bool().unwrap_or(false); + if item(ui, "Ignore Optical Margin", Some(ignore), true) { + attrs = Some(json!({"ignoreOpticalMargin": !ignore})); + } + ui.separator(); + for (label, id) in [ + ("Justification…", "app.justificationDialog"), + ("Keep Options…", "app.keepOptionsDialog"), + ("Span Columns…", "app.spanColumnsDialog"), + ("Hyphenation…", "app.hyphenationDialog"), + ("Drop Caps and Nested Styles…", "app.nestedStylesDialog"), + ("GREP Styles…", "app.grepStylesDialog"), + ("Paragraph Rules…", "app.paragraphRulesDialog"), + ("Paragraph Borders and Shading…", "app.paragraphBordersDialog"), + ] { + if item(ui, label, None, true) { + command = Some(id); + } + } + ui.separator(); + if item(ui, "Bullets and Numbering…", None, true) { + command = Some("app.bulletsNumberingDialog"); + } + // Restart Numbering at 1, or Continue Numbering from the paragraph before, for a numbered list. + let numbered = p["listType"].as_str() == Some("numbers"); + let restarts = p["startAt"].is_u64(); + let label = match (numbered, restarts) { + (false, _) => "Restart/Continue Numbering", + (true, false) => "Restart Numbering", + (true, true) => "Continue Numbering", + }; + if item(ui, label, None, numbered) { + attrs = Some(json!({"startAt": if restarts { Value::Null } else { json!(1) }})); + } + item(ui, "Convert Bullets and Numbering to Text", None, false); + if item(ui, "Define Lists…", None, true) { + command = Some("list.define"); + } + // Paragraph Direction (World-Ready): switching also mirrors left and right alignment. + ui.separator(); + let rtl = p["direction"] == "rightToLeft"; + for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] { + if item(ui, label, Some(on), true) && !on { + let align = match serde_json::from_value(p["align"].clone()).unwrap_or_default() { + Align::Left => Align::Right, + Align::Right => Align::Left, + a => a, + }; + attrs = Some(json!({"direction": dir, "align": align})); + } + } + if let Some(attrs) = attrs { + let _ = app.run("type.para", json!({"attrs": attrs})); + } + // As a menu choice: Define Lists… and the other commands open their dialogs. + if let Some(id) = command { + crate::menus::activate(app, id, &Value::Null); } - let ps = a["paragraphStyle"].as_str().unwrap_or("").to_string(); - let ov = a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0); - ui.horizontal(|ui| { - caption(ui, crate::i18n::tr(&app.ui.language, "Style")); - super::para_style_picker(app, ui, &ps, ov > 0, 190.0); - }); } /// Stroke panel: weight, cap, miter limit, join, alignment, type, arrowheads, gap colour. @@ -1960,7 +2234,7 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) { StrokeType::Style { name } => name.clone(), k => k.label().to_string(), }; - egui::ComboBox::from_id_salt("stype") + let combo = egui::ComboBox::from_id_salt("stype") .selected_text(crate::rtl::widget( ui, if matches!(st.kind, StrokeType::Style { .. }) { &shown } else { crate::i18n::tr(&app.ui.language, &shown) }, @@ -1996,10 +2270,11 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Type")); ui.end_row(); for (key, label, cur, flip) in [("start", "Start/End:", st.start, true), ("end", "", st.end, false)] { caption(ui, crate::i18n::tr(&app.ui.language, label)); - egui::ComboBox::from_id_salt(key) + let combo = egui::ComboBox::from_id_salt(key) .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, cur.label()))) .width(140.0) .show_ui(ui, |ui| { @@ -2012,6 +2287,9 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } }); + // The second arrowhead reuses the shared caption, so it needs its own semantic name. + let name = crate::i18n::tr(&app.ui.language, if flip { "Start/End" } else { "End" }).to_owned(); + let _ = crate::widgets::name_combo(combo, name); ui.end_row(); } caption(ui, crate::i18n::tr(&app.ui.language, "Gap Color:")); @@ -2116,7 +2394,7 @@ pub fn wrap_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let u = units(app); let offsets = info.as_ref().map(|i| i.wrap_offsets); // Linked while the offsets are all the same, until the link is clicked. - let linked_id = egui::Id::new("wrap_offsets_linked"); + let linked_id = egui::Id::unique("wrap_offsets_linked"); let linked = ui.data(|d| d.get_temp::(linked_id)).unwrap_or_else(|| offsets.is_none_or(|o| o.iter().all(|v| (v - o[0]).abs() < 1e-9))); let mut set: Option<[f64; 4]> = None; ui.add_enabled_ui(mode != "none" && info.is_some(), |ui| { @@ -2195,16 +2473,17 @@ fn wrap_combo( ui.horizontal(|ui| { caption(ui, crate::i18n::tr(&app.ui.language, caption_text)); let shown = opts.iter().chain(shown_only).find(|o| o.0 == cur).map_or(cur, |o| o.1).to_string(); - egui::ComboBox::from_id_salt(salt).selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &shown))).width(170.0).show_ui( - ui, - |ui| { + let combo = egui::ComboBox::from_id_salt(salt) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, &shown))) + .width(170.0) + .show_ui(ui, |ui| { for (k, l) in opts { if ui.selectable_label(*k == cur, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, l))).clicked() { let _ = app.run("object.textWrap", json!({ key: k })); } } - }, - ); + }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, caption_text)); }); } @@ -2366,7 +2645,7 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let Some(st) = app.session.active() else { return }; let t = Tokens::get(ui.ctx()); // The list checks files on disk: refresh it at most twice a second. - let key = egui::Id::new("links_panel"); + let key = egui::Id::unique("links_panel"); let now = ui.input(|i| i.time); let rev = (st.uid, st.revision); let cached: Option<(f64, (u64, u64), Vec)> = ui.data(|d| d.get_temp(key)); @@ -2382,7 +2661,7 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No placed graphics.")).color(t.text_dim)); return; } - let sel_key = egui::Id::new("links_panel_sel"); + let sel_key = egui::Id::unique("links_panel_sel"); let mut chosen: Option = ui.data(|d| d.get_temp(sel_key)); // Leave room for Status and Page/PPI instead of widening the grid for long source paths. let name_width = (ui.available_width() - 160.0).max(50.0); @@ -2608,7 +2887,7 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { B::Luminosity, ]; ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("blend") + let combo = egui::ComboBox::from_id_salt("blend") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, it.blend.label()))) .width(130.0) .show_ui(ui, |ui| { @@ -2618,6 +2897,7 @@ pub fn effects_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Blending")); caption(ui, crate::i18n::tr(&app.ui.language, "Opacity")); if let Some(v) = number(ui, "eop", Some(it.opacity as f64 * 100.0), "%", 46.0, 0) { let _ = app.run("object.opacity", json!({"opacity": (v / 100.0).clamp(0.0, 1.0)})); @@ -2798,7 +3078,8 @@ fn variable_font_axes(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, styl ui.spacing_mut().slider_width = (ui.available_width() - PANEL_FIELD_W - ui.spacing().item_spacing.x * 2.0).max(40.0); let (step, decimals) = axis_step(*min, *max); // The field beside the slider takes typed values, so the slider's own is hidden. - let slider = ui.add(egui::Slider::new(&mut value.1, *min..=*max).step_by(f64::from(step)).show_value(false)); + let slider = + ui.add(egui::Slider::new(&mut value.1, *min..=*max).step_by(f64::from(step)).show_value(false)).accessible_name((*name).to_owned()); // Only the user's own change counts: the slider also reports one when it snaps a value // that is off its steps, which it would then do on every frame. let pressed = slider.is_pointer_button_down_on() || slider.dragged(); @@ -2905,9 +3186,18 @@ pub fn digit_label(lang: &str, o: (&str, &str, &str)) -> String { if o.2.is_empty() { name.to_string() } else { format!("{name} {}", o.2) } } +/// The least value a paragraph field takes: the first and last line indents may be negative (a +/// hanging line, a last line past the right indent), as in InDesign; the others not. +pub(crate) fn para_field_min(key: &str) -> f64 { + if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 } +} + #[cfg(test)] mod tests { - use egui_kittest::{Harness, kittest::Queryable}; + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; use super::*; @@ -2979,6 +3269,18 @@ mod tests { } /// With the caret in text, the Stroke panel offers the text's stroke options, not an empty panel. + /// The first and last line indents take negative values, as InDesign's (and the Paragraph + /// panel's) do; the left and right indents and the spacing don't. + #[test] + fn last_line_indent_may_be_negative() { + for key in ["firstLineIndent", "lastLineIndent"] { + assert!(super::para_field_min(key) < 0.0, "{key}"); + } + for key in ["leftIndent", "rightIndent", "spaceBefore", "spaceAfter"] { + assert_eq!(super::para_field_min(key), 0.0, "{key}"); + } + } + #[test] fn stroke_panel_shows_the_text_stroke_options_while_text_is_selected() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -3083,7 +3385,7 @@ mod tests { let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( |ui, app: &mut DesignApp| { // The panel's fonts are installed before its first frame. - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); @@ -3094,6 +3396,9 @@ mod tests { app, ); h.run_steps(3); + assert!(h.query_by_label("Language").is_none(), "under •••, as in InDesign"); + h.get_all_by_label("More Options").next().unwrap().click(); + h.run_steps(3); h.get_by_label("Language").click(); h.run_steps(2); h.get_by_label("French").click(); @@ -3101,6 +3406,182 @@ mod tests { assert_eq!(language(h.state_mut()), "French"); } + /// Properties › Character and Paragraph •••, as in InDesign: the sections expand in place, + /// with the scales, baseline shift, skew, language, OpenType and the case buttons, and the + /// indents, spacing and drop caps. + /// Properties › Paragraph ••• on a First Line Only paragraph: Align to Baseline Grid shows + /// pressed, and a click turns alignment off instead of making it All Lines. + #[test] + fn more_options_grid_button_keeps_first_line_only() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "If fine plants", "caret": true})).unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + app.run("type.para", json!({"attrs": {"gridAlign": "firstLineOnly"}})).unwrap(); + let mut h = panel_harness(app, show); + h.run_steps(3); + h.get_all_by_label("More Options").nth(1).unwrap().click(); + h.run_steps(3); + assert_eq!(h.get_by_label("Align to Baseline Grid").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["gridAlign"], "firstLineOnly"); + h.get_by_label("Align to Baseline Grid").click(); + h.run_steps(3); + assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["gridAlign"], "none"); + } + + #[test] + fn more_options_expand_character_and_paragraph_in_place() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "If fine plants", "caret": true})).unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + let mut h = panel_harness(app, show); + h.run_steps(3); + for l in ["Vertical Scale", "Small Caps", "Drop Cap Number of Lines", "Space Before"] { + assert!(h.query_by_label(l).is_none(), "{l} hidden until •••"); + } + h.get_all_by_label("More Options").next().unwrap().click(); + h.run_steps(3); + h.get_by_label("Small Caps").click(); + h.run_steps(3); + let _ = h.get_by_label("OpenType"); + let _ = h.get_by_label("Language"); + h.get_all_by_label("More Options").nth(1).unwrap().click(); + h.run_steps(3); + let _ = h.get_by_label("Hyphenate"); + h.get_by_label("Align to Baseline Grid").click(); + h.run_steps(3); + let a = text_attrs(h.state_mut()).unwrap(); + assert_eq!(a["chars"]["capitalization"], "smallCaps"); + assert_eq!(a["para"]["gridAlign"], "allLines"); + // ••• again folds the section. + h.get_all_by_label("More Options").next().unwrap().click(); + h.run_steps(3); + assert!(h.query_by_label("Small Caps").is_none()); + } + + /// The Paragraph panel on a First Line Only paragraph: Align to Baseline Grid shows pressed + /// and keeps First Line Only, and the panel menu checks Only Align First Line to Grid. + #[test] + fn paragraph_panel_keeps_first_line_only() { + let mut app = text_app(); + app.run("type.para", json!({"attrs": {"gridAlign": "firstLineOnly"}})).unwrap(); + let mut h = panel_harness(app, paragraph_panel); + h.run_steps(3); + assert_eq!(h.get_by_label("Align to Baseline Grid").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + h.get_by_label("Align to Baseline Grid").click(); + h.run_steps(3); + assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["gridAlign"], "firstLineOnly"); + h.get_by_label("☰").click(); + h.run_steps(3); + let _ = h.get_by_label("✓ Only Align First Line to Grid"); + } + + #[test] + fn paragraph_panel_menu_toggles_ignore_optical_margin() { + let mut h = panel_harness(text_app(), paragraph_panel); + h.run_steps(3); + h.get_by_label("☰").click(); + h.run_steps(3); + h.get_by_label(" Ignore Optical Margin").click(); + h.run_steps(3); + assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["ignoreOpticalMargin"], true); + h.get_by_label("☰").click(); + h.run_steps(3); + h.get_by_label("✓ Ignore Optical Margin").click(); + h.run_steps(3); + assert_eq!(text_attrs(h.state_mut()).unwrap()["para"]["ignoreOpticalMargin"], false); + } + + #[test] + fn paragraph_panel_menu_define_lists_opens_its_dialog() { + let mut h = panel_harness(text_app(), paragraph_panel); + h.run_steps(3); + h.get_by_label("☰").click(); + h.run_steps(3); + h.get_by_label("Define Lists…").click(); + h.run_steps(3); + assert!(h.state_mut().ui.dialog.is_some(), "Define Lists"); + } + + #[test] + fn paragraph_panel_is_laid_out_as_indesigns_with_its_panel_menu() { + let mut h = panel_harness(text_app(), paragraph_panel); + h.run_steps(3); + for l in [ + "Left Indent", + "Right Indent", + "First Line Left Indent", + "Last Line Right Indent", + "Space Before", + "Space After", + "Space Between Paragraphs Using Same Style", + "Drop Cap Number of Lines", + "Drop Cap One or More Characters", + "Paragraph Shading", + "Paragraph Border", + "Hyphenate", + "Do Not Align to Baseline Grid", + ] { + let _ = h.get_by_label(l); + } + let para = |h: &mut Harness<'static, DesignApp>| text_attrs(h.state_mut()).unwrap()["para"].clone(); + // Space Between Paragraphs Using Same Style starts at Ignore; a value is set, and emptying + // the field ignores it again. + assert!(para(&mut h)["sameParaStyleSpacing"].is_null()); + let field = h.get_all_by_role(egui::accesskit::Role::TextInput).nth(6).unwrap(); + field.focus(); + field.type_text("6 pt"); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(para(&mut h)["sameParaStyleSpacing"], 6.0); + h.get_all_by_role(egui::accesskit::Role::TextInput).nth(6).unwrap().focus(); + h.run_steps(1); + for _ in 0..8 { + h.key_press(egui::Key::Backspace); + } + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(para(&mut h)["sameParaStyleSpacing"].is_null(), "Ignore"); + // Shading on, then the grid buttons and Only Align First Line to Grid from the menu. + h.get_by_label("Shading").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["shadingOn"], true); + h.get_by_label("Align to Baseline Grid").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["gridAlign"], "allLines"); + h.get_by_label("☰").click(); + h.run_steps(3); + for l in [ + " Balance Ragged Lines", + "Keep Options…", + "Drop Caps and Nested Styles…", + "GREP Styles…", + "Define Lists…", + "Restart/Continue Numbering", + ] { + let _ = h.get_by_label(l); + } + h.get_by_label(" Only Align First Line to Grid").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["gridAlign"], "firstLineOnly"); + h.get_by_label("Do Not Align to Baseline Grid").click(); + h.run_steps(3); + assert_eq!(para(&mut h)["gridAlign"], "none"); + // Keep Options… opens the selected paragraphs' Keep Options, and OK applies them. + h.get_by_label("☰").click(); + h.run_steps(3); + h.get_by_label("Keep Options…").click(); + h.run_steps(3); + let app = h.state_mut(); + let d = app.ui.dialog.as_mut().expect("Keep Options"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "keep")); + d.fields.insert("p.keepWithNext".into(), json!(2)); + crate::dialogs::confirm(app).unwrap(); + assert_eq!(para(&mut h)["keepWithNext"], 2); + } + fn kerning(app: &mut DesignApp) -> Value { text_attrs(app).unwrap()["chars"]["kerning"].clone() } @@ -3109,7 +3590,7 @@ mod tests { fn panel_harness(app: DesignApp, panel: fn(&mut DesignApp, &mut egui::Ui)) -> Harness<'static, DesignApp> { Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( move |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); @@ -3236,7 +3717,7 @@ mod tests { // The app commits a preview no panel held at the start of each frame (DesignApp::ui). let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); @@ -3278,8 +3759,8 @@ mod tests { // The caption and the control share the name: click the control. h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Kerning").click(); h.run_steps(2); - // The menu's field is drawn after the panel's own fields. - let field = h.get_all_by_role(egui::accesskit::Role::TextInput).last().unwrap(); + // Address the manual-value editor by semantics, not tree traversal order. + let field = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Manual Kerning"); field.focus(); field.type_text("-40"); h.run_steps(1); @@ -3297,7 +3778,7 @@ mod tests { h.run_steps(3); h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Kerning").click(); h.run_steps(2); - let field = h.get_all_by_role(egui::accesskit::Role::TextInput).last().unwrap(); + let field = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Manual Kerning"); field.focus(); field.type_text("-80"); h.run_steps(1); @@ -3319,7 +3800,7 @@ mod tests { assert_ne!(before, "[Paper]"); let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); @@ -3350,7 +3831,7 @@ mod tests { fn panel(app: DesignApp) -> Harness<'static, DesignApp> { let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); @@ -3364,6 +3845,35 @@ mod tests { h } + /// The course's chapter 21: Properties' Bulleted List button gives the selected paragraphs + /// bullets with InDesign's hanging indent; pressed again, both go. + #[test] + fn the_bulleted_list_button_applies_a_hanging_indent() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let f = app + .run( + "frame.create", + json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs +1 cup milk"}), + ) + .unwrap(); + app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap(); + let mut h = panel(app); + h.get_by_label("Bulleted List").click(); + h.run_steps(3); + let a = h.state_mut().run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!( + (a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64(), a["para"]["firstLineIndent"].as_f64()), + (Some("bullets"), Some(18.0), Some(-18.0)) + ); + assert_eq!(crate::menus::checked(h.state(), "list.bullets", &json!({})), Some(true), "Type › Apply Bullets shows it"); + h.get_by_label("Bulleted List").click(); + h.run_steps(3); + let a = h.state_mut().run("type.selectionAttrs", json!({})).unwrap(); + assert_eq!((a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64()), (Some("none"), Some(0.0))); + } + #[test] fn several_shapes_show_pathfinder_and_intersect_combines_them() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -3659,7 +4169,7 @@ fn selected_container(app: &DesignApp) -> Option<(designcraft_doc::Item, Vec) -> Option { use designcraft_doc::{FlexAlign, FlexJustify}; let t = Tokens::get(ui.ctx()); - let resp = ui.interact(r, ui.id().with("flex_grid"), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with("flex_grid"), Sense::click()); ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); let cell = r.width() / 3.0; let pos = |a: FlexAlign| match a { @@ -3847,7 +4357,7 @@ fn flex_section(app: &mut DesignApp, ui: &mut Ui) { { let ir = sub(b, x, y + 2.0, 17.0, 17.0); icons::paint(ui.painter(), ir, icon, t.icon); - ui.interact(ir, ui.id().with(("flex_pad", k)), Sense::hover()).on_hover_ui(|ui| { + ui.interact(ir, ui.scope_id().with(("flex_pad", k)), Sense::hover()).on_hover_ui(|ui| { crate::rtl::label(ui, tr(tip)); }); let value = f.as_ref().map(|_| pad[k]); @@ -3888,7 +4398,7 @@ fn flex_section(app: &mut DesignApp, ui: &mut Ui) { let y = 2.0 * FIELD_H + 10.0; let ir = sub(b, x, y + 2.0, 17.0, 17.0); icons::paint(ui.painter(), ir, icon, if live { t.icon } else { t.text_dim }); - ui.interact(ir, ui.id().with(("flex_gap", k)), Sense::hover()).on_hover_ui(|ui| { + ui.interact(ir, ui.scope_id().with(("flex_gap", k)), Sense::hover()).on_hover_ui(|ui| { crate::rtl::label(ui, tr(tip)); }); let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { diff --git a/crates/ui-egui/src/panels/story.rs b/crates/ui-egui/src/panels/story.rs new file mode 100644 index 000000000..b2d53bff1 --- /dev/null +++ b/crates/ui-egui/src/panels/story.rs @@ -0,0 +1,106 @@ +//! Window › Type & Tables › Story: the story-wide options. + +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::widgets::{self, NumField}; + +/// The Story panel: Optical Margin Alignment for the stories of the text selection or the +/// selected text frames, and the type size its hang amounts are set for. Without text or a text +/// frame selected, the controls are disabled. +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + let options = super::text_attrs(app).map(|a| a["storyOptions"].clone()); + let enabled = options.is_some(); + let options = options.unwrap_or(Value::Null); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal_wrapped(|ui| { + // Stories that differ show unchecked; a click turns it on for all of them. + let mut on = options["opticalMargin"].as_bool().unwrap_or(false); + if widgets::checkbox(ui, &mut on, &tr("Optical Margin Alignment")).changed() { + let _ = app.run("story.options", json!({"opticalMargin": on})); + } + let field = NumField::number("storyOpticalMarginSize", options["opticalMarginSize"].as_f64(), " pt", 2) + .width(72.0) + .range(designcraft_doc::OpticalMargin::MIN_SIZE, designcraft_doc::OpticalMargin::MAX_SIZE); + if let Some(size) = crate::chrome::icon_field(ui, "font-size", &tr("Optical Margin Size"), field) { + let _ = app.run("story.options", json!({"opticalMarginSize": size})); + } + }); + }); +} + +#[cfg(test)] +mod tests { + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; + + use super::*; + + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut h = Harness::builder().with_size(egui::vec2(260.0, 200.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + h + } + + fn optical_margin(h: &Harness<'static, DesignApp>, story: u64) -> designcraft_doc::OpticalMargin { + h.state().session.active().unwrap().doc.story(designcraft_doc::StoryId(story)).unwrap().optical_margin + } + + fn size_field_text(h: &Harness<'static, DesignApp>) -> Option { + h.get_all_by_role(egui::accesskit::Role::TextInput).next().unwrap().value() + } + + #[test] + fn story_panel_sets_and_shows_the_storys_optical_margin() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let story = + app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "“Hung”", "caret": true})).unwrap()["story"] + .as_u64() + .unwrap(); + app.run("edit.selectAll", json!({})).unwrap(); + let mut h = harness(app); + assert_eq!(size_field_text(&h).as_deref(), Some("12 pt")); + h.get_by_label("Optical Margin Alignment").click(); + h.run_steps(3); + assert!(optical_margin(&h, story).on); + assert_eq!(h.get_by_label("Optical Margin Alignment").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + h.get_all_by_role(egui::accesskit::Role::TextInput).next().unwrap().click(); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("18".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(optical_margin(&h, story), designcraft_doc::OpticalMargin { on: true, size: 18.0 }); + // A value set elsewhere shows in the panel. + h.state_mut().run("story.options", json!({"opticalMarginSize": 30})).unwrap(); + h.run_steps(3); + assert_eq!(size_field_text(&h).as_deref(), Some("30 pt")); + } + + #[test] + fn story_panel_is_disabled_without_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let mut h = harness(app); + assert!(h.get_by_label("Optical Margin Alignment").accesskit_node().is_disabled()); + h.get_by_label("Optical Margin Alignment").click(); + h.run_steps(3); + assert_eq!(h.get_by_label("Optical Margin Alignment").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); + } +} diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index f7e6fa803..ea13bdb40 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -117,7 +117,7 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren let shown = n.rsplit('/').next().unwrap_or(n); let label = if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) }; let row_label = format!("{label}{}", if current == Some(n) && overridden { "+" } else { "" }); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, current == Some(n), &row_label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, current == Some(n), &row_label)); crate::rtl::paint(ui.painter(), row.min + vec2(8.0, 11.0), egui::Align2::LEFT_CENTER, &row_label, egui::FontId::proportional(12.5), t.text); let editable = n != if para { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE }; if resp.double_clicked() && editable { @@ -169,7 +169,7 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren } } ui.separator(); - let id = egui::Id::new(("new_style_group", para)); + let id = egui::Id::unique(("new_style_group", para)); let mut name: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); let r = ui.add( egui::TextEdit::singleline(&mut name) @@ -298,7 +298,7 @@ mod tests { let undo_len = app.session.doc().unwrap().history.undo.len(); let mut h = Harness::builder().with_size(vec2(360.0, 240.0)).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 878904a5f..560fd0966 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -25,10 +25,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { swatch_row(app, ui, &doc, sw, 0.0, fill_cur.as_deref()); } for g in &doc.color_groups { - let open_id = egui::Id::new(("color_group_open", &g.name)); + let open_id = egui::Id::unique(("color_group_open", &g.name)); let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &g.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &g.name)); if resp.hovered() { ui.painter().rect_filled(row, 0.0, t.hover); } @@ -78,7 +78,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.add_space(6.0); // InDesign's bottom row: New Color Group, New Swatch, Delete Swatch (the one last clicked). - let picked: Option = ui.data(|d| d.get_temp(egui::Id::new(PICKED))).filter(|n: &String| doc.swatch(n).is_some_and(|w| !w.locked)); + let picked: Option = ui.data(|d| d.get_temp(egui::Id::unique(PICKED))).filter(|n: &String| doc.swatch(n).is_some_and(|w| !w.locked)); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let del = ui @@ -88,7 +88,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { && let Some(name) = &picked && app.run("swatch.delete", json!({"name": name})).is_ok() { - ui.data_mut(|d| d.remove::(egui::Id::new(PICKED))); + ui.data_mut(|d| d.remove::(egui::Id::unique(PICKED))); } // The selection's fill or stroke (whichever is in front) becomes a swatch; with nothing // selected, a new colour. @@ -167,7 +167,7 @@ fn header(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Documen ui.horizontal(|ui| { let (r, resp) = ui.allocate_exact_size(vec2(27.0, 27.0), Sense::click()); let tip = crate::i18n::tr(&app.ui.language, if stroke_front { "Stroke" } else { "Fill" }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); let fill_r = egui::Rect::from_min_size(r.min + vec2(1.0, 2.0), vec2(16.0, 16.0)); let stroke_r = egui::Rect::from_min_size(r.min + vec2(10.0, 10.0), vec2(16.0, 16.0)); let paint = |stroke: bool| { @@ -230,7 +230,7 @@ fn set_tint(app: &mut DesignApp, percent: f64) -> Result egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } @@ -256,14 +256,14 @@ fn swatch_row( let t = Tokens::get(ui.ctx()); // Rows can be dragged onto the Gradient panel's ramp. let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &sw.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &sw.name)); resp.dnd_set_drag_payload(SwatchDrag(sw.name.clone())); if resp.dragged() && let Some(p) = ui.ctx().pointer_interact_pos() { // The swatch's chip follows the pointer. let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0); - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("swatch_drag"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("swatch_drag"))); let chip = egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(16.0, 16.0)); crate::widgets::paint_chip(&painter, chip, c, g); painter.rect_stroke(chip, 0.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside); @@ -296,7 +296,7 @@ fn swatch_row( } ui.painter().text(row.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, kind, egui::FontId::proportional(10.5), t.text_dim); if resp.clicked() && !resp.double_clicked() { - ui.data_mut(|d| d.insert_temp(egui::Id::new(PICKED), sw.name.clone())); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(PICKED), sw.name.clone())); let _ = super::apply_swatch(app, app.ui.stroke_front, &sw.name, None); } // Double-click: Swatch Options (the first click has applied the swatch). @@ -359,7 +359,7 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }; let text = super::colors_affect_text(app); let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; - let mode_id = egui::Id::new("color_panel_mode"); + let mode_id = egui::Id::unique("color_panel_mode"); let mut stroke = app.ui.stroke_front; let default_mode = if doc.settings.intent == designcraft_doc::Intent::Print { ColorMode::Cmyk } else { ColorMode::Rgb }; let mut mode: ColorMode = ui.data(|d| d.get_temp(mode_id)).unwrap_or(default_mode); @@ -391,7 +391,7 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.label(egui::RichText::new(&name).size(11.0).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - egui::ComboBox::from_id_salt("color_mode") + let combo = egui::ComboBox::from_id_salt("color_mode") .width(64.0) .selected_text(match mode { ColorMode::Cmyk => "CMYK", @@ -403,6 +403,7 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.selectable_value(&mut mode, ColorMode::Cmyk, "CMYK"); ui.selectable_value(&mut mode, ColorMode::Rgb, "RGB"); }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Color Mode")); }); }); app.ui.stroke_front = stroke; @@ -427,7 +428,11 @@ pub fn color_panel(app: &mut DesignApp, ui: &mut egui::Ui) { // tint is read back from the document every frame) and applies it, clamped, on Enter // or when it loses focus. ui.spacing_mut().slider_width = (ui.available_width() - 60.0).max(60.0); - if ui.add(egui::Slider::new(&mut tint, 0.0..=100.0).integer().show_value(false)).drag_stopped() { + // The painted "T" caption is not a text node next to the slider, so name it here. + let slider = ui + .add(egui::Slider::new(&mut tint, 0.0..=100.0).integer().show_value(false)) + .accessible_name(crate::i18n::tr(&app.ui.language, "Tint")); + if slider.drag_stopped() { commit = Some(tint); } if let Some(v) = @@ -530,9 +535,11 @@ fn channel(ui: &mut egui::Ui, label: &str, v: &mut f32, scale: f32, suffix: &str changed |= resp.drag_stopped() || resp.clicked(); // The value is applied when typing ends or a drag stops; until then the field's number // lives here, because `v` is read back from the document every frame. - let pending_id = ui.id().with(("channel", label)); + let pending_id = ui.scope_id().with(("channel", label)); let mut num = ui.data(|d| d.get_temp::(pending_id)).unwrap_or((*v * scale).round()); - let field = ui.add(egui::DragValue::new(&mut num).range(0.0..=scale).suffix(suffix).update_while_editing(false)); + // The channel name is a painted label beside a bare spinner: name the spinner too. + let field = + ui.add(egui::DragValue::new(&mut num).range(0.0..=scale).suffix(suffix).update_while_editing(false)).accessible_name(label.to_owned()); if field.dragged() { ui.data_mut(|d| d.insert_temp(pending_id, num)); } else { @@ -681,8 +688,8 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { let is_gradient = cur.is_some(); let g = cur.unwrap_or_default(); let radial = g.kind == designcraft_color::GradientKind::Radial; - let sel_id = egui::Id::new("grad_sel"); - let drag_id = egui::Id::new("grad_drag"); + let sel_id = egui::Id::unique("grad_sel"); + let drag_id = egui::Id::unique("grad_drag"); let mut sel: RampSel = ui.data(|d| d.get_temp(sel_id)).unwrap_or(RampSel::Stop(0)); let last = g.stops.len().saturating_sub(1); sel = match sel { @@ -703,7 +710,7 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { if resp.dragged() && let Some(p) = ui.ctx().pointer_interact_pos() { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gradient_drag"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("gradient_drag"))); thumbnail(&painter, egui::Rect::from_min_size(p + vec2(8.0, 8.0), vec2(18.0, 18.0)), &g, t.field_border); } } @@ -717,13 +724,18 @@ pub fn gradient_panel(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { crate::widgets::caption_w(ui, crate::i18n::tr(&lang, "Type:"), 52.0); let shown = if is_gradient { crate::i18n::tr(&lang, g.kind.label()) } else { "" }; - egui::ComboBox::from_id_salt("grad_type").selected_text(crate::rtl::widget(ui, shown)).width(104.0).show_ui(ui, |ui| { - for (k, v) in [("Linear", "linear"), ("Radial", "radial")] { - if ui.selectable_label(is_gradient && g.kind.label() == k, crate::rtl::widget(ui, crate::i18n::tr(&lang, k))).clicked() { - send = Some(json!({"kind": v})); + let combo = + egui::ComboBox::from_id_salt("grad_type").selected_text(crate::rtl::widget(ui, shown)).width(104.0).show_ui(ui, |ui| { + for (k, v) in [("Linear", "linear"), ("Radial", "radial")] { + if ui + .selectable_label(is_gradient && g.kind.label() == k, crate::rtl::widget(ui, crate::i18n::tr(&lang, k))) + .clicked() + { + send = Some(json!({"kind": v})); + } } - } - }); + }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&lang, "Type")); }); ui.horizontal(|ui| { crate::widgets::caption_w(ui, crate::i18n::tr(&lang, "Location:"), 52.0); @@ -1025,7 +1037,7 @@ mod gradient_panel_tests { let app = app_with_frame(); let mut h = Harness::builder().with_size(vec2(280.0, 200.0)).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); @@ -1166,7 +1178,7 @@ mod swatches_header_tests { fn harness(app: DesignApp) -> Harness<'static, DesignApp> { Harness::builder().with_size(vec2(300.0, 520.0)).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); diff --git a/crates/ui-egui/src/panels/table.rs b/crates/ui-egui/src/panels/table.rs index ba653364e..5bda05989 100644 --- a/crates/ui-egui/src/panels/table.rs +++ b/crates/ui-egui/src/panels/table.rs @@ -69,13 +69,14 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { for (label, kind, names, cur) in [("Table Style", "table", tnames, tcur), ("Cell Style", "cell", cnames, ccur)] { ui.horizontal(|ui| { caption(ui, crate::i18n::tr(&app.ui.language, label)); - egui::ComboBox::from_id_salt(("tp_style", kind)).selected_text(&cur).width(130.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt(("tp_style", kind)).selected_text(&cur).width(130.0).show_ui(ui, |ui| { for n in &names { if ui.selectable_label(*n == cur, n).clicked() { run(app, &format!("style.{kind}.apply"), json!({"name": n, "table": tid})); } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, label)); if crate::icons::button( ui, "plus", @@ -125,7 +126,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let row = rows.get(r0).cloned().unwrap_or(Value::Null); let exactly = row["mode"] == "exactly"; caption(ui, crate::i18n::tr(&app.ui.language, "Row Height")); - egui::ComboBox::from_id_salt("tp_rowmode") + let combo = egui::ComboBox::from_id_salt("tp_rowmode") .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, if exactly { "Exactly" } else { "At Least" }))) .width(70.0) .show_ui(ui, |ui| { @@ -136,6 +137,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } }); + let _ = crate::widgets::name_combo(combo, crate::i18n::tr(&app.ui.language, "Row Height")); if let Some(h) = measure(ui, "tp_rowh", row["height"].as_f64(), unit, 64.0) { run(app, "table.setRowHeight", json!({"height": h})); } diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index db5353afe..49910ca31 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -130,7 +130,7 @@ pub(crate) fn ruler(ui: &mut Ui, salt: &str, r: &Ruler) -> Option { Some(nearest.map_or(Grab::New, |(i, _)| Grab::Tab(i))) }; - let key = ui.id().with(("tab_ruler", salt)); + let key = ui.scope_id().with(("tab_ruler", salt)); let mut drag: Option = ui.data(|d| d.get_temp(key)); let mut edit = None; if resp.drag_started() @@ -266,11 +266,14 @@ pub(crate) fn align_buttons(ui: &mut Ui, lang: &str, current: TabAlign) -> Optio } /// A one-line text field that commits on Enter or when it loses focus. -pub(crate) fn text_commit(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool) -> Option { - let tid = ui.id().with(("tabs_text", id)); +/// +/// `name` is the visible caption's text (`Leader:`, `Align On:`): the field has no label widget of +/// its own, so screen readers and tests need it named here. +pub(crate) fn text_commit(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, name: &str) -> Option { + let tid = ui.scope_id().with(("tabs_text", id)); let focused = ui.memory(|m| m.has_focus(tid)); let mut buf: String = if focused { ui.data(|d| d.get_temp(tid)).unwrap_or_else(|| current.to_string()) } else { current.to_string() }; - let r = ui.add_enabled(enabled, egui::TextEdit::singleline(&mut buf).id(tid).desired_width(width)); + let r = ui.add_enabled(enabled, egui::TextEdit::singleline(&mut buf).id(tid).desired_width(width)).accessible_name(name.to_owned()); ui.data_mut(|d| d.insert_temp(tid, buf.clone())); (r.lost_focus() && buf != current).then_some(buf) } @@ -321,7 +324,7 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { let zoom = app.view().map(|v| v.zoom as f32); // Panel position above the frame: on request (the magnet, or opening the panel). - let win = ui.ctx().memory(|m| m.area_rect(egui::Id::new(("floating_panel", "tabs")))); + let win = ui.ctx().memory(|m| m.area_rect(egui::Id::unique(("floating_panel", "tabs")))); let state = &mut app.ui.tabs_panel; if state.selected.is_some_and(|i| i >= tabs.len()) { state.selected = None; @@ -364,12 +367,14 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) { } } crate::rtl::label(ui, tr("Leader:")); - if let (Some(l), Some(i)) = (text_commit(ui, "leader", sel.map_or("", |s| s.leader.as_str()), 36.0, sel.is_some()), selected) { + let leader = text_commit(ui, "leader", sel.map_or("", |s| s.leader.as_str()), 36.0, sel.is_some(), tr("Leader:")); + if let (Some(l), Some(i)) = (leader, selected) { cmds.push(("type.tabs.change", json!({"index": i, "leader": l}))); } crate::rtl::label(ui, tr("Align On:")); let char_tab = sel.is_some_and(|s| s.align == TabAlign::Char); - if let (Some(c), Some(i)) = (text_commit(ui, "alignOn", sel.map_or("", |s| s.align_on.as_str()), 18.0, char_tab), selected) { + let align_on = text_commit(ui, "alignOn", sel.map_or("", |s| s.align_on.as_str()), 18.0, char_tab, tr("Align On:")); + if let (Some(c), Some(i)) = (align_on, selected) { cmds.push(("type.tabs.change", json!({"index": i, "alignOn": c}))); } ui.add_space(6.0); diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs index 4206459d0..71358c41a 100644 --- a/crates/ui-egui/src/panels/typography.rs +++ b/crates/ui-egui/src/panels/typography.rs @@ -322,7 +322,7 @@ mod tests { app.run("edit.selectAll", json!({})).unwrap(); let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state( |ui, app: &mut DesignApp| { - let id = egui::Id::new("test_fonts"); + let id = egui::Id::unique("test_fonts"); if ui.data(|d| d.get_temp::(id)).is_none() { crate::theme::install_fonts(ui.ctx(), ""); ui.data_mut(|d| d.insert_temp(id, true)); diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index cb88619ec..55473469d 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -1,12 +1,15 @@ -//! Read-only interface text: Unicode bidi run ordering followed by Arabic shaping. -//! Source strings stay logical. Never use these galleys for editable text/caret mapping. +//! Read-only interface text in any script. Source strings stay logical: epaint resolves each +//! paragraph's direction (`LayoutJob::direction == Auto`), runs the Unicode bidi algorithm once +//! and orders a row's glyphs for painting while the glyph array and `Galley::job.text` stay +//! logical. Never use these galleys for editable text/caret mapping (the canvas has its own +//! composer). +use egui::epaint::text::TextDirection; use egui::text::{LayoutJob, TextWrapping}; -use egui::{Align, Align2, Color32, FontFamily, FontId, Galley, Pos2, Rect, WidgetText}; -use std::{collections::HashMap, ops::Range, sync::Arc}; -use unicode_bidi::{BidiClass, BidiInfo, bidi_class}; -use unicode_segmentation::UnicodeSegmentation; +use egui::{Align, Align2, Color32, FontId, Galley, Pos2, Rect, WidgetText}; +use std::{collections::HashMap, sync::Arc}; +use unicode_bidi::{BidiClass, bidi_class}; -/// Allocation-free preflight: ordinary Latin, CJK and emoji labels need no bidi analysis. +/// Allocation-free preflight: ordinary Latin, CJK and emoji labels need no cache lookup. /// Include directional controls so explicit embeddings/overrides keep their existing behavior. fn may_have_rtl(text: &str) -> bool { !text.is_ascii() @@ -29,26 +32,28 @@ fn may_have_rtl(text: &str) -> bool { }) } -fn has_rtl(text: &str) -> bool { - may_have_rtl(text) && BidiInfo::new(text, None).has_rtl() -} - -/// Resolve translated labels through bidi, without changing IDs, commands or stored text. +/// Resolve translated labels through the shaper, without changing IDs, commands or stored text. pub fn widget(ui: &egui::Ui, text: impl Into) -> WidgetText { let text = text.into(); - if !has_rtl(text.text()) { + if !may_have_rtl(text.text()) { return text; } let mut job = (*text.into_layout_job(ui.style(), egui::FontSelection::Default, Align::Center)).clone(); + // The shaper owns bidi: it keeps the glyphs in logical order and gives each row a visual + // order (and the paragraph's start edge), so nothing here may reorder or mirror text. + job.direction = TextDirection::Auto; + if job.language.is_none() { + job.language = ui.language(); + } job.wrap = TextWrapping::from_wrap_mode_and_width(ui.wrap_mode(), ui.available_width()); WidgetText::Galley(layout(ui.ctx(), job)) } -/// Labels are read-only: egui's LTR selection indices cannot map bidi glyph order. -/// Accessibility still receives the original logical string through Galley::job. +/// Labels are read-only: no editable text/caret mapping is derived from a shaped galley. +/// Accessibility still receives the original logical string through `Galley::job`. pub fn label(ui: &mut egui::Ui, text: impl Into) -> egui::Response { let text = text.into(); - let rtl = has_rtl(text.text()); + let rtl = may_have_rtl(text.text()); if !rtl { return ui.label(text); } @@ -90,62 +95,6 @@ pub fn paint(p: &egui::Painter, pos: Pos2, anchor: Align2, text: &str, font: Fon p.galley(rect.min, galley, color); } -/// Reorder directional runs, not characters: the shaper needs logical Arabic to join letters. -fn visual_line(job: &LayoutJob, bidi: &BidiInfo<'_>, line: Range) -> LayoutJob { - let mut visual = LayoutJob { break_on_newline: false, ..Default::default() }; - if line.is_empty() { - if let Some(section) = - job.sections.iter().find(|s| usize::from(s.byte_range.start) <= line.start && line.start <= usize::from(s.byte_range.end)) - { - visual.append("", 0.0, section.format.clone()); - } - return visual; - } - let Some(para) = bidi.paragraphs.iter().find(|p| p.range.start <= line.start && line.start < p.range.end) else { - // Preserve the font/height for an empty line. - if let Some(section) = job.sections.first() { - visual.append("", 0.0, section.format.clone()); - } - return visual; - }; - let (levels, runs) = bidi.visual_runs(para, line); - for run in runs { - let rtl = levels[run.start].is_rtl(); - let mut sections: Vec<_> = job - .sections - .iter() - .filter_map(|section| { - let start = run.start.max(section.byte_range.start.into()); - let end = run.end.min(section.byte_range.end.into()); - (start < end).then_some((section, start..end)) - }) - .collect(); - if rtl { - sections.reverse(); - } - for (section, range) in sections { - let mut format = section.format.clone(); - if rtl { - let bold = format.font_id.family == FontFamily::Name("semibold".into()); - format.font_id.family = FontFamily::Name(if bold { "arabic-semibold" } else { "arabic" }.into()); - if bold { - format.coords = egui::epaint::text::VariationCoords::new([(*b"wght", 600.0)]); - } - } - let text = &job.text[range]; - if rtl && !text.chars().any(designcraft_fonts::is_rtl) { - // Neutral-only RTL runs have no script for the shaper to infer a direction. - // Apply UBA L4 mirroring here; Arabic runs are mirrored by the shaper. - let neutral: String = text.chars().rev().map(|c| unicode_bidi_mirroring::get_mirrored(c).unwrap_or(c)).collect(); - visual.append(&neutral, section.leading_space, format); - } else { - visual.append(text, section.leading_space, format); - } - } - } - visual -} - const MAX_CACHED_LABELS: usize = 256; const MAX_CACHED_TEXT_BYTES: usize = 4096; @@ -200,7 +149,7 @@ fn layout(ctx: &egui::Context, job: LayoutJob) -> Arc { return layout_uncached(ctx, job); } let token = ctx.fonts_mut(|fonts| fonts.layout_no_wrap(String::new(), FontId::default(), Color32::TRANSPARENT)); - let id = egui::Id::new("designcraft.rtl.layout-cache"); + let id = egui::Id::unique("designcraft.rtl.layout-cache"); let key = egui::util::hash(&job); let cached = ctx.data_mut(|data| data.get_temp_mut_or_default::(id).lookup(token, key, &job)); if let Some(galley) = cached { @@ -212,99 +161,16 @@ fn layout(ctx: &egui::Context, job: LayoutJob) -> Arc { } fn layout_uncached(ctx: &egui::Context, mut job: LayoutJob) -> Arc { - if !has_rtl(&job.text) { - return ctx.fonts_mut(|fonts| fonts.layout_job(job)); - } - let original = Arc::new(job.clone()); - if !job.break_on_newline { - // Single-line widgets show line separators as spaces; byte offsets stay unchanged. - job.text = job.text.replace(['\n', '\r'], " "); - } - let bidi = BidiInfo::new(&job.text, None); - let shape = |range| ctx.fonts_mut(|fonts| fonts.layout_job(visual_line(&job, &bidi, range))); - // Wrap in logical reading order BEFORE applying bidi separately to each line. - let mut ranges = Vec::new(); - let mut start = 0; - for paragraph in job.text.split_inclusive('\n') { - let end = start + paragraph.len(); - let content_end = if paragraph.ends_with('\n') { end - 1 } else { end }; - if !job.wrap.max_width.is_finite() || shape(start..content_end).size().x <= job.wrap.max_width { - ranges.push(start..content_end); - start = end; - continue; - } - let mut line_start = start; - let mut word_break = start; - for (offset, cluster) in job.text[start..content_end].grapheme_indices(true) { - let index = start + offset; - let boundary = index + cluster.len(); - if job.wrap.max_width.is_finite() && index > line_start && shape(line_start..boundary).size().x > job.wrap.max_width { - let cut = if !job.wrap.break_anywhere && word_break > line_start { word_break } else { index }; - ranges.push(line_start..cut); - line_start = cut; - } - if cluster.chars().all(char::is_whitespace) { - word_break = boundary; - } - } - ranges.push(line_start..content_end); - start = end; - } - if job.text.ends_with('\n') { - ranges.push(start..start); - } - let elided = ranges.len() > job.wrap.max_rows; - ranges.truncate(job.wrap.max_rows); - let mut lines = Vec::new(); - for (i, range) in ranges.iter().enumerate() { - if elided && i + 1 == ranges.len() { - // Append the ellipsis in logical order, then reorder and shape the final line. - let mut last = LayoutJob::default(); - for section in &job.sections { - let start = range.start.max(section.byte_range.start.into()); - let end = range.end.min(section.byte_range.end.into()); - if start < end { - last.append(&job.text[start..end], 0.0, section.format.clone()); - } - } - let format = last.sections.last().or(job.sections.first()).map(|s| s.format.clone()).unwrap_or_default(); - let tail = job.wrap.overflow_character.map_or(String::new(), |c| c.to_string()); - let base = bidi.paragraphs.iter().find(|p| p.range.contains(&range.start)).map(|p| p.level); - loop { - let mut candidate = last.clone(); - candidate.append(&tail, 0.0, format.clone()); - let info = BidiInfo::new(&candidate.text, base); - let shaped = ctx.fonts_mut(|fonts| fonts.layout_job(visual_line(&candidate, &info, 0..candidate.text.len()))); - if shaped.size().x <= job.wrap.max_width || last.text.is_empty() { - lines.push(shaped); - break; - } - let end = last.text.grapheme_indices(true).next_back().map_or(0, |(i, _)| i); - last.text.truncate(end); - for section in &mut last.sections { - section.byte_range.end = usize::from(section.byte_range.end).min(end).into(); - } - last.sections.retain(|s| s.byte_range.start < s.byte_range.end); - } - } else { - lines.push(shape(range.clone())); - } - } - let mut galley = Galley::concat(original, &lines, ctx.pixels_per_point()); - galley.elided = elided; - // Each Arabic line shares the right edge; Latin runs and numbers remain LTR. - let width = galley.rect.width(); - galley.mesh_bounds = Rect::NOTHING; - for row in &mut galley.rows { - row.pos.x += width - row.size.x; - galley.mesh_bounds |= row.visuals.mesh_bounds.translate(row.pos.to_vec2()); - } - Arc::new(galley) + job.direction = TextDirection::Auto; + // The 0.37 shaper owns single-line handling as well as bidi. Preserve the logical source + // for accessibility and cache equality, including line separators in a single-line label. + ctx.fonts_mut(|fonts| fonts.layout_job(job)) } #[cfg(test)] mod tests { use super::*; + use egui::FontFamily; fn with_fonts(test: impl FnOnce(&egui::Context)) { let ctx = egui::Context::default(); @@ -317,6 +183,20 @@ mod tests { output.textures_delta.clear(); } + /// The glyphs in the order they are painted, left to right per row. + fn drawn(galley: &Galley) -> String { + let mut out = String::new(); + for row in &galley.rows { + for i in row.row.visual_order().filter(|&i| row.row.glyphs.get(i).is_some_and(|g| g.advance_width > 0.0)) { + if let Some(glyph) = row.row.glyphs.get(i) { + out.push(glyph.chr); + } + } + } + out + } + + /// The glyphs in logical order (the stored order, independent of the paragraph direction). fn displayed(galley: &Galley) -> String { galley.rows.iter().flat_map(|row| row.glyphs.iter().filter(|g| g.advance_width > 0.0).map(|g| g.chr)).collect() } @@ -439,21 +319,19 @@ mod tests { } #[test] - fn ordinary_multilingual_labels_skip_bidi_analysis() { + fn ordinary_multilingual_labels_skip_the_rtl_layout_cache() { for text in ["", "File (123)", "Édition", "中文排版", "スウォッチ", "e\u{301}", "🎨 🙂"] { assert!(!may_have_rtl(text), "{text:?}"); - assert!(!BidiInfo::new(text, None).has_rtl(), "{text:?}"); } for text in ["ملف", "עברית", "PDF ملف 123", "\u{1e900}", "\u{200f}", "\u{202e}abc\u{202c}", "\u{2067}abc\u{2069}", "١٢٣"] { assert!(may_have_rtl(text), "{text:?}"); - assert_eq!(has_rtl(text), BidiInfo::new(text, None).has_rtl(), "{text:?}"); } } #[test] fn non_rtl_widgets_keep_native_text_and_formatting() { with_fonts(|ctx| { - egui::Area::new(egui::Id::new("rtl-fast-path-test")).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("rtl-fast-path-test")).show(ctx, |ui| { for text in ["中文", "日本語", "Édition", "🎨"] { let rendered = widget(ui, egui::RichText::new(text).strong()); assert!(matches!(rendered, WidgetText::RichText(_))); @@ -469,20 +347,21 @@ mod tests { with_fonts(|ctx| { let text = "ملف (PDF 123) جديد"; let galley = plain(ctx, text, FontId::proportional(15.0), Color32::WHITE); + // The source stays logical in both the job and the glyph array. assert_eq!(galley.job.text, text); - let visual = displayed(&galley); + let row = &galley.rows[0].row; + assert_eq!(row.text(), text); + // Painting is reordered once by the shaper: the last logical word is drawn leftmost, + // and the Latin number inside the Arabic sentence still reads left to right. + let visual = drawn(&galley); assert!(visual.contains("PDF 123"), "{visual}"); - let row = &galley.rows[0]; - let left_bracket = row.glyphs.iter().find(|g| g.chr == ')' && !g.uv_rect.is_nothing()).unwrap(); - // A neutral-only final bracket must be mirrored even without an Arabic script run. - let job = LayoutJob::simple_singleline("ملف (PDF 123)".to_owned(), FontId::proportional(15.0), Color32::WHITE); - let bidi = BidiInfo::new(&job.text, None); - let visual_job = visual_line(&job, &bidi, 0..job.text.len()); - assert!(visual_job.text.starts_with("(PDF 123"), "{}", visual_job.text); - let p = row.glyphs.iter().find(|g| g.chr == 'P').unwrap(); - let three = row.glyphs.iter().find(|g| g.chr == '3').unwrap(); - assert!(left_bracket.pos.x < p.pos.x && p.pos.x < three.pos.x); assert!(visual.starts_with('د'), "Arabic final word belongs at the left: {visual}"); + let x = |c: char| row.glyphs.iter().find(|g| g.chr == c && g.advance_width > 0.0).unwrap().pos.x; + // The shaper mirrors the brackets: the logical ')' is drawn as '(' left of the number, + // the logical '(' as ')' right of it. + assert!(x(')') < x('P') && x('3') < x('('), "brackets around the number: {visual}"); + // Letters inside the right-to-left words are placed right to left. + assert!(x('ج') < x('ف'), "Arabic word order: {visual}"); assert!(galley.rect.is_finite()); assert!(galley.mesh_bounds.is_finite()); }); @@ -503,18 +382,26 @@ mod tests { } #[test] - fn joins_arabic_letters_using_one_font_including_spaces() { + fn arabic_fallback_matches_the_native_shaper_including_spaces() { with_fonts(|ctx| { let text = "بِدء مُستند جَديد"; let galley = plain(ctx, text, FontId::proportional(15.0), Color32::WHITE); let expected = ctx.fonts_mut(|fonts| { - fonts.layout_job(LayoutJob::simple_singleline(text.to_owned(), FontId::new(15.0, FontFamily::Name("arabic".into())), Color32::WHITE)) + // The native shaper resolves Arabic clusters from the requested UI family. A + // different Arabic-primary family changes spaces and line metrics too; it is + // not an oracle for fallback shaping in a proportional UI label. + let mut job = LayoutJob::simple_singleline(text.to_owned(), FontId::proportional(15.0), Color32::WHITE); + job.direction = TextDirection::Auto; + fonts.layout_job(job) }); + assert_eq!(galley.job.text, text); assert_eq!(displayed(&galley), displayed(&expected)); assert_eq!(galley.size(), expected.size()); - // Contextual glyphs (atlas positions) must match the unbroken Arabic shaping run. + assert_eq!(galley.rows[0].glyphs.len(), expected.rows[0].glyphs.len()); + // Contextual glyphs (atlas positions) must match the native logical shaping run. for (actual, expected) in galley.rows[0].glyphs.iter().zip(&expected.rows[0].glyphs) { assert_eq!(actual.uv_rect, expected.uv_rect); + assert_eq!(actual.advance_width, expected.advance_width); } }); } @@ -530,8 +417,10 @@ mod tests { assert!(galley.rows.len() >= 3); let first: String = galley.rows[0].glyphs.iter().map(|g| g.chr).collect(); assert!(first.contains('م') && first.contains('س') && !first.contains('ج'), "{first}"); + // Every right-to-left row is flush with the right edge. Rows are pixel-snapped, so the + // shared edge can differ from the galley width by less than one pixel. for row in &galley.rows { - assert!((row.pos.x + row.size.x - galley.size().x).abs() < 0.01); + assert!((row.pos.x + row.size.x - galley.size().x).abs() < 1.0, "{:?}", row.pos); } assert_eq!(galley.job.text, text); }); diff --git a/crates/ui-egui/src/story_editor.rs b/crates/ui-egui/src/story_editor.rs index 2bd481d82..e19efe8e4 100644 --- a/crates/ui-egui/src/story_editor.rs +++ b/crates/ui-egui/src/story_editor.rs @@ -45,7 +45,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { let mut edit: Option<(usize, usize, String)> = None; let words = story.text.split_whitespace().count().to_string(); egui::Window::new(crate::i18n::tr(&app.ui.language, "Story Editor — {count} words").replace("{count}", &words)) - .id(egui::Id::new("story_editor")) + .id(egui::Id::unique("story_editor")) .open(&mut open) .default_size(vec2(560.0, 520.0)) .show(ctx, |ui| { diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index 5b7d34b92..88c152f5b 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -16,7 +16,7 @@ const GRIP: &str = "task_bar_grip"; /// Show the bar under `sel` (screen rect of the selection), or where it is pinned, kept inside /// `canvas`. pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { - let grip_id = egui::Id::new((GRIP, ctx.viewport_id(), app.pane)); + let grip_id = egui::Id::unique((GRIP, ctx.viewport_id(), app.pane)); // (Two separate context calls: `is_pointer_over_egui` inside an `input` closure deadlocks.) let dragging = ctx.dragged_id() == Some(grip_id); if !app.ui.task_bar || (ctx.input(|i| i.pointer.any_down()) && !ctx.is_pointer_over_egui() && !dragging) { @@ -29,7 +29,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { } let t = Tokens::get(ctx); // The same selection can be shown in two canvases; each bar needs its own area and widgets. - let id = egui::Id::new(("task_bar", ctx.viewport_id(), app.pane)); + let id = egui::Id::unique(("task_bar", ctx.viewport_id(), app.pane)); // Width from the previous frame (content-sized). let w = ctx.memory(|m| m.area_rect(id).map(|r| r.width())).unwrap_or(420.0); let at = match app.ui.task_bar_pin { @@ -100,8 +100,8 @@ fn split_button(ui: &mut Ui, icon: &str, label: &str) -> (egui::Response, egui:: let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover()); let main = Rect::from_min_max(r.min, pos2(r.max.x - widgets::SEG_W, r.max.y)); let seg = Rect::from_min_max(pos2(main.max.x, r.min.y), r.max); - let mr = ui.interact(main, ui.id().with(("split", label)), Sense::click()); - let sr = ui.interact(seg, ui.id().with(("splitseg", label)), Sense::click()); + let mr = ui.interact(main, ui.scope_id().with(("split", label)), Sense::click()); + let sr = ui.interact(seg, ui.scope_id().with(("splitseg", label)), Sense::click()); for (rr, resp) in [(main, &mr), (seg, &sr)] { if resp.hovered() { ui.painter().rect_filled(rr, 0.0, t.hover); @@ -165,7 +165,7 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) { let sw = c["fill"].as_str().unwrap_or("[Black]").to_string(); let (col, g) = widgets::swatch_colors(&doc, &sw, 1.0); let resp = widgets::swatch_chip(ui, 17.0, col, g).on_hover_text(format!("{}: {sw}", crate::i18n::tr(&app.ui.language, "Fill"))); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Task Bar fill")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Task Bar fill")); let mut picked = None; egui::Popup::menu(&resp).show(|ui| { picked = panels::swatch_list(ui, &doc, &sw); @@ -227,9 +227,7 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) { widgets::paint_stroke_chip(ui.painter(), r, col, egui::Color32::from_gray(20)); } let resp = resp.on_hover_text(format!("{}: {sw}", if k == 0 { "Fill" } else { "Stroke" })); - resp.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Task Bar {}", if k == 0 { "fill" } else { "stroke" })) - }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Task Bar {}", if k == 0 { "fill" } else { "stroke" }))); let mut picked = None; egui::Popup::menu(&resp).show(|ui| { picked = panels::swatch_list(ui, &doc, &sw); @@ -311,7 +309,7 @@ mod tests { } fn bar(h: &Harness<'static, Window>) -> egui::Rect { - h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), h.state().app.pane)))).unwrap() + h.ctx.memory(|m| m.area_rect(egui::Id::unique(("task_bar", h.ctx.viewport_id(), h.state().app.pane)))).unwrap() } fn grip(h: &Harness<'static, Window>) -> egui::Pos2 { @@ -371,7 +369,7 @@ mod tests { h.state_mut().app.run("window.split", json!({"on":true})).unwrap(); h.run_steps(6); let area = - |h: &Harness<'static, Window>, pane| h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), pane)))).unwrap(); + |h: &Harness<'static, Window>, pane| h.ctx.memory(|m| m.area_rect(egui::Id::unique(("task_bar", h.ctx.viewport_id(), pane)))).unwrap(); for pane in [0_u8, 1] { h.state_mut().app.run("window.taskBarPin", json!({"at":[100,120]})).unwrap(); h.run_steps(4); @@ -397,7 +395,7 @@ mod tests { h.state_mut().app.switch_pane(0); h.run_steps(4); let expected = h.state().app.ui.task_bar_at.unwrap(); - let left = h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), 0_u8)))).unwrap(); + let left = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("task_bar", h.ctx.viewport_id(), 0_u8)))).unwrap(); click_at(&mut h, pos2(left.max.x - 19.0, left.center().y)); let pin = h.get_by_label_contains("Pin Bar Position").rect(); let at = pos2(pin.max.x - 2.0, pin.center().y); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 666394e09..846cf2e89 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -269,11 +269,12 @@ pub fn install_fonts(ctx: &egui::Context, lang: &str) -> bool { let craft = designcraft_fonts::CRAFT_FONTS; let mut defs = font_definitions(craft, lang); let system = system_ui_fonts(craft); - if let Some(system) = system { + let found = system.is_some(); + if let Some(system) = &system { add_system_fallbacks(&mut defs, system, lang); } ctx.set_fonts(defs); - system.is_some() + found } /// Whether [`install_fonts`] would find the installed fonts cataloged. @@ -282,6 +283,7 @@ pub fn system_fonts_ready() -> bool { } /// An installed font the UI falls back to for a script the built-in faces lack. +#[derive(Clone)] pub(crate) struct SystemFont { /// The ISO 15924 code of the script it is for ("Jpan", "Kore", "Taml"…). script: &'static str, @@ -350,17 +352,25 @@ const SYSTEM_UI_FONTS: &[(&str, char, &[&str])] = &[ /// Traditional Chinese (font names in the font menus), Indic, Southeast Asian and other scripts /// in any build. egui can't find system fonts itself; the document font scan (not on the web) /// does. -fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option<&'static [SystemFont]> { +fn system_ui_fonts(craft: &[designcraft_fonts::CraftFont]) -> Option> { static FACES: std::sync::OnceLock> = std::sync::OnceLock::new(); let db = designcraft_fonts::FontDb::global(); // Never wait for the font scan here (the first frame): it runs in the background, and the // faces are read once, when it is done. - // (Tests wait instead, so that the fonts don't change in the middle of one.) if !db.system_fonts_cataloged() && !cfg!(test) { db.scan_in_background(); return None; } - Some(FACES.get_or_init(|| installed_ui_fonts(db, craft))) + if cfg!(test) { + // Tests install controlled faces in the process-wide database (`testing::font_with`), + // so the set cannot be cached across them; a cached empty first call would otherwise + // hide every face a later test adds. Cataloging here is a no-op once it is done (the + // web has no system fonts). + #[cfg(not(target_arch = "wasm32"))] + let _ = db.load_system_fonts(); + return Some(installed_ui_fonts(db, craft)); + } + Some(FACES.get_or_init(|| installed_ui_fonts(db, craft)).to_vec()) } /// The faces of [`SYSTEM_UI_FONTS`] that `db` has, for the scripts `craft` doesn't cover. @@ -375,8 +385,12 @@ fn installed_ui_fonts(db: &designcraft_fonts::FontDb, craft: &[designcraft_fonts return None; } let face = db.face(family, "Regular"); - // `face` falls back to another family when this one can't be read: keep only the real one. - (face.family.eq_ignore_ascii_case(family) && !face.data().is_empty() && face.covers(*sample)).then_some((family, face)) + // Keep the requested family, and leave bundled document faces to the craft UI stack. + (face.family.eq_ignore_ascii_case(family) + && !matches!(&face.source, designcraft_fonts::FontSource::Bundled) + && !face.data().is_empty() + && face.covers(*sample)) + .then_some((family, face)) }); let Some((family, face)) = found else { continue }; if !faces.iter().any(|f| f.family.eq_ignore_ascii_case(family)) { @@ -454,6 +468,11 @@ pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], l let add = |fonts: &mut FontDefinitions, name: &str, data: &'static [u8]| { fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(data))); }; + // egui's last-resort faces (Ubuntu-Light, egui-icons and, with `monochrome_emoji_fonts`, + // NotoEmoji-Regular and emoji-icon-font): every family ends with them so a glyph outside + // the requested face and the script faces (☰, ⚠, 📋) is not tofu. Captured before the + // interface face is added. + let last_resort: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); add(&mut fonts, "ui", include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf")); add(&mut fonts, "ui-semibold", include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf")); add(&mut fonts, "mono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")); @@ -467,12 +486,15 @@ pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], l let chinese = pick("Hans"); let arabic = pick("Arab"); let georgian = pick("Geor"); + // The manifest can add scripts without a special UI preference order (Hebrew, Devanagari, + // ...). They still need installing: system fallback discovery skips scripts supplied here. + let other: Vec<_> = craft.iter().filter(|f| !["Jpan", "Hans", "Arab", "Geor"].iter().any(|s| f.scripts.contains(s))).collect(); let name = |f: &designcraft_fonts::CraftFont| format!("craft-{}-{}", f.family, f.style); for f in japanese.iter().chain(&chinese) { // CJK faces sit on the Latin baseline (see `baseline_tweak`). fonts.font_data.insert(name(f), Arc::new(FontData { tweak: baseline_tweak(f.bytes, 0), ..FontData::from_static(f.bytes) })); } - for f in arabic.iter().chain(&georgian) { + for f in arabic.iter().chain(&georgian).chain(&other) { add(&mut fonts, &name(f), f.bytes); } // Han characters take the forms of the interface language: Chinese faces first for Chinese. @@ -484,16 +506,25 @@ pub(crate) fn font_definitions(craft: &'static [designcraft_fonts::CraftFont], l ja.sort_by_key(|f| (f.family != "BIZ UDPGothic", f.style != "Bold")); } let (first, second) = if zh { (&chinese, &ja) } else { (&ja, &chinese) }; - stack.extend(first.iter().chain(second).chain(&arabic).chain(&georgian).map(|f| name(f))); + stack.extend(first.iter().chain(second).chain(&arabic).chain(&georgian).chain(&other).map(|f| name(f))); } - // Arabic runs (rtl.rs): the Arabic face first, so letters and spaces shape as one run. + // A bold label keeps every icon/emoji glyph the regular one has: the semibold family ends + // with the same last-resort faces, after the script faces. + if let Some(stack) = fonts.families.get_mut(&FontFamily::Name("semibold".into())) { + stack.extend(last_resort.iter().cloned()); + } + // Arabic runs: the Arabic face first, so letters and spaces shape as one run. These families + // keep every other family's fallbacks (the interface faces, the CJK, Arabic and Georgian + // script faces, then egui's last-resort icon/emoji faces) so Latin, symbols and emoji inside + // Arabic text are not tofu. for family in ["arabic", "arabic-semibold"] { let ui = if family == "arabic" { "ui" } else { "ui-semibold" }; let stack = arabic .iter() .map(|f| name(f)) .chain([ui.to_string(), "ui".to_string()]) - .chain(japanese.iter().chain(&chinese).chain(&georgian).map(|f| name(f))); + .chain(japanese.iter().chain(&chinese).chain(&georgian).chain(&other).map(|f| name(f))) + .chain(last_resort.iter().cloned()); let mut seen = std::collections::HashSet::new(); fonts.families.insert(FontFamily::Name(family.into()), stack.filter(|n| seen.insert(n.clone())).collect()); } @@ -572,6 +603,26 @@ mod japanese_font_tests { ctx } + /// A newly supplied script must remain usable without any matching installed system font. + /// Previously craft's script coverage suppressed system discovery while these faces were + /// omitted from the actual UI font definitions, so a Hebrew tab panicked on its first glyph. + #[test] + fn additional_craft_scripts_render_in_every_ui_family() { + let bytes = Box::leak(designcraft_fonts::testing::font_with("DC extra scripts", &['ד', 'क']).unwrap().into_boxed_slice()); + let craft = Box::leak( + vec![designcraft_fonts::CraftFont { family: "DC extra scripts", style: "Regular", scripts: &["Hebr", "Deva"], bytes }].into_boxed_slice(), + ); + let ctx = ctx_with(craft); + ctx.fonts_mut(|fonts| { + for family in families().into_iter().chain([egui::FontFamily::Name("arabic".into()), egui::FontFamily::Name("arabic-semibold".into())]) { + let font = egui::FontId::new(13.0, family); + assert!(fonts.has_glyph(&font, 'ד') && fonts.has_glyph(&font, 'क'), "missing supplied script in {font:?}"); + let galley = fonts.layout_no_wrap("דक".into(), font, egui::Color32::WHITE); + assert!(galley.size().x > 0.0 && galley.rect.is_finite()); + } + }); + } + #[test] fn japanese_ui_glyphs_are_available_in_every_family() { if designcraft_fonts::CRAFT_FONTS.is_empty() { @@ -766,9 +817,9 @@ mod ukrainian_font_tests { for (name, family, style) in [("ui", "Source Sans 3", "Regular"), ("ui-semibold", "Source Sans 3", "Semibold"), ("mono", "JetBrains Mono", "Regular")] { - let data = &definitions.font_data[name].font; + let data = definitions.font_data[name].bytes(); let face = db.face(family, style); - assert_eq!(face.data(), data.as_ref(), "configured UI face {name}"); + assert_eq!(face.data(), data, "configured UI face {name}"); for character in &characters { assert_ne!(face.glyph_for(*character), 0, "missing {character} in {name}"); } @@ -793,9 +844,9 @@ mod russian_font_tests { for (name, family, style) in [("ui", "Source Sans 3", "Regular"), ("ui-semibold", "Source Sans 3", "Semibold"), ("mono", "JetBrains Mono", "Regular")] { - let data = &definitions.font_data[name].font; + let data = definitions.font_data[name].bytes(); let face = db.face(family, style); - assert_eq!(face.data(), data.as_ref(), "configured UI face {name}"); + assert_eq!(face.data(), data, "configured UI face {name}"); for character in &characters { assert_ne!(face.glyph_for(*character), 0, "missing {character} in {name}"); } diff --git a/crates/ui-egui/src/title_bar.rs b/crates/ui-egui/src/title_bar.rs index a369fa9fb..dc4d00d10 100644 --- a/crates/ui-egui/src/title_bar.rs +++ b/crates/ui-egui/src/title_bar.rs @@ -71,7 +71,7 @@ pub fn double_click_command(action: DoubleClick, maximized: bool) -> Option egui::Id { - egui::Id::new("title_bar_window_movable") + egui::Id::unique("title_bar_window_movable") } /// Whether AppKit may move the window when a drag starts in the title-bar band: false while the diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6b7d2c1c2..a6b03f668 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -15,7 +15,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let frame = egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.divider)); // A single column that doesn't fit the window's height becomes two, without changing the // preference; whatever two columns still can't fit scrolls. - let single_h_id = egui::Id::new("tools_single_column_height"); + let single_h_id = egui::Id::unique("tools_single_column_height"); let single_h: Option = ui.data(|d| d.get_temp(single_h_id)); let viewport_h = ui.available_height() - frame.total_margin().sum().y; let crowded = !app.ui.tools_double_column && single_h.is_some_and(|h| h > viewport_h); @@ -101,7 +101,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } // The release that ends a hold which opened the flyout isn't a click // on the tool (egui reports releases within 0.8 s as clicks). - let hold_id = egui::Id::new(("flyout_hold", gi)); + let hold_id = egui::Id::unique(("flyout_hold", gi)); let ended_hold = resp.clicked() && ui.data_mut(|d| d.remove_temp::(hold_id)).unwrap_or(false); if resp.clicked() && !ended_hold { chosen = Some(shown.id); @@ -131,7 +131,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } if app.ui.flyout == Some(gi) { let pos = resp.rect.right_top() + vec2(4.0, 0.0); - egui::Area::new(egui::Id::new(("flyout", gi))).order(egui::Order::Foreground).fixed_pos(pos).show( + egui::Area::new(egui::Id::unique(("flyout", gi))).order(egui::Order::Foreground).fixed_pos(pos).show( ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { @@ -166,7 +166,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { t.text_dim, ); } - row.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl.label)); + row.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl.label)); if row.clicked() { chosen = Some(tl.id); } @@ -195,7 +195,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { && chosen.is_none() && !opened_now { - let area = ui.ctx().memory(|m| m.area_rect(egui::Id::new(("flyout", gi)))); + let area = ui.ctx().memory(|m| m.area_rect(egui::Id::unique(("flyout", gi)))); let outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| area.is_none_or(|a| !a.contains(p)))); let esc = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); if outside || esc { @@ -241,7 +241,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: u let def = egui::Rect::from_min_size(egui::pos2(x0, row.min.y), vec2(10.0, 10.0)); icons::paint(ui.painter(), def, "default-colors", t.icon); if ui - .interact(def, ui.id().with("proxy_default"), Sense::click()) + .interact(def, ui.scope_id().with("proxy_default"), Sense::click()) .on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Default Fill and Stroke (D)")); }) @@ -252,7 +252,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: u let swap = egui::Rect::from_min_size(egui::pos2(x0 + 19.0, row.min.y), vec2(11.0, 11.0)); icons::paint(ui.painter(), swap, "swap", t.icon); if ui - .interact(swap, ui.id().with("proxy_swap_btn"), Sense::click()) + .interact(swap, ui.scope_id().with("proxy_swap_btn"), Sense::click()) .on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Swap Fill and Stroke (Shift+X)")); }) @@ -297,15 +297,15 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: u paint_fill(p); } let fill_tip = crate::i18n::tr(&app.ui.language, "Fill (X)"); - let fresp = ui.interact(fill_r, ui.id().with("proxy_fill"), Sense::click()).on_hover_ui(|ui| { + let fresp = ui.interact(fill_r, ui.scope_id().with("proxy_fill"), Sense::click()).on_hover_ui(|ui| { crate::rtl::label(ui, fill_tip); }); - fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fill_tip)); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, fill_tip)); let stroke_tip = crate::i18n::tr(&app.ui.language, "Stroke (X)"); - let sresp = ui.interact(stroke_r.translate(vec2(0.0, 0.0)), ui.id().with("proxy_stroke"), Sense::click()).on_hover_ui(|ui| { + let sresp = ui.interact(stroke_r.translate(vec2(0.0, 0.0)), ui.scope_id().with("proxy_stroke"), Sense::click()).on_hover_ui(|ui| { crate::rtl::label(ui, stroke_tip); }); - sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, stroke_tip)); + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, stroke_tip)); if fresp.clicked() || sresp.clicked() { let front = if stroke_front { !fresp.clicked() } else { sresp.clicked() && !fill_r.contains(sresp.interact_pointer_pos().unwrap_or_default()) }; @@ -334,7 +334,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: u } icons::paint(ui.painter(), r.shrink(1.5), icon, if on { t.text_strong } else { t.icon }); if ui - .interact(r, ui.id().with(("proxy_fmt", k)), Sense::click()) + .interact(r, ui.scope_id().with(("proxy_fmt", k)), Sense::click()) .on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); }) @@ -373,8 +373,8 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: u for (k, (tip, id)) in applies.into_iter().enumerate() { let c = egui::pos2(row.min.x + width / 2.0 + (k as f32 - 1.0) * pitch, row.center().y); let r = egui::Rect::from_center_size(c, vec2(18.0, 18.0)); - let resp = ui.interact(r, ui.id().with(("proxy_apply", k)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, tip))); + let resp = ui.interact(r, ui.scope_id().with(("proxy_apply", k)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(&app.ui.language, tip))); if k == front_kind { ui.painter().rect(r, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); } @@ -391,8 +391,8 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: u } else { let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); let (tip, id) = applies[front_kind]; - let resp = ui.interact(well, ui.id().with("proxy_apply"), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, tip))); + let resp = ui.interact(well, ui.scope_id().with("proxy_apply"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(&app.ui.language, tip))); let resp = resp.on_hover_ui(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); }); @@ -460,13 +460,13 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: u flyout_triangle(ui.painter(), other, t.icon); let tr = |s: &str| crate::i18n::tr(&app.ui.language, s).to_string(); let (normal_tip, other_tip) = (tr("Normal"), format!("{} (W)", tr("Preview"))); - let resp = ui.interact(normal, ui.id().with("screen_normal"), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &normal_tip)); + let resp = ui.interact(normal, ui.scope_id().with("screen_normal"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &normal_tip)); if resp.on_hover_text(&normal_tip).clicked() { let _ = app.run("view.screenMode", json!({"mode": "normal"})); } - let resp = ui.interact(other, ui.id().with("screen_mode"), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &other_tip)); + let resp = ui.interact(other, ui.scope_id().with("screen_mode"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &other_tip)); let resp = resp.on_hover_text(&other_tip); if resp.clicked() { let _ = app.run("view.screenMode", json!({"mode": "preview"})); @@ -482,7 +482,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: u return; } let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); - let resp = ui.interact(well, ui.id().with("screen_mode"), Sense::click()); + let resp = ui.interact(well, ui.scope_id().with("screen_mode"), Sense::click()); ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); let preview = mode == crate::ScreenMode::Preview; icons::paint( @@ -550,7 +550,7 @@ fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui:: } icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(17.0, 17.0)), icon, if active { t.text_strong } else { t.icon }); let enabled = ui.is_enabled(); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip)); resp.on_hover_ui(|ui| { crate::rtl::label(ui, tip); }) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fb9876aa2..01877304c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -71,6 +71,7 @@ impl NumKind<'_> { /// A numeric field. Build with [`NumField::measure`] / [`NumField::number`], then [`NumField::show`]. pub struct NumField<'a> { id: &'a str, + name: Option<&'a str>, value: Option, kind: NumKind<'a>, width: f32, @@ -80,6 +81,10 @@ pub struct NumField<'a> { range: (f64, f64), enabled: bool, parens: bool, + /// The widget naming the field (an icon or caption beside it), for accessibility. + label: Option, + /// What an unset value shows (InDesign's "Ignore"), and the first of the presets. + empty: Option<&'a str>, } impl<'a> NumField<'a> { @@ -90,7 +95,38 @@ impl<'a> NumField<'a> { Self::new(id, value, NumKind::Number { suffix, decimals }) } fn new(id: &'a str, value: Option, kind: NumKind<'a>) -> Self { - NumField { id, value, kind, width: 72.0, spinner: false, presets: &[], step: None, range: (f64::MIN, f64::MAX), enabled: true, parens: false } + NumField { + id, + name: None, + value, + kind, + width: 72.0, + spinner: false, + presets: &[], + step: None, + range: (f64::MIN, f64::MAX), + enabled: true, + parens: false, + label: None, + empty: None, + } + } + /// The field's user-facing name, independent of its stable internal ID. + pub fn name(mut self, name: &'a str) -> Self { + self.name = Some(name); + self + } + /// The editor is labelled by widget `id` (an icon or caption that names the field), so + /// assistive technology reads the field's name with its value. + pub fn labelled_by(mut self, id: egui::Id) -> Self { + self.label = Some(id); + self + } + /// An unset value shows `text` (dimmed), and the preset menu offers it first: picking it, or + /// emptying the field, commits `Some(None)` from [`NumField::show_or_clear`]. + pub fn empty_text(mut self, text: &'a str) -> Self { + self.empty = Some(text); + self } /// Total width (spinner and preset segments included). pub fn width(mut self, w: f32) -> Self { @@ -141,7 +177,8 @@ impl<'a> NumField<'a> { /// values that may be unset, like a character style's attributes). pub fn show_or_clear(self, ui: &mut Ui) -> Option> { let t = Tokens::get(ui.ctx()); - let key = ui.id().with(self.id); + let name = self.name.unwrap_or(self.id); + let key = ui.scope_id().with(self.id); let (rect, _) = ui.allocate_exact_size(vec2(self.width, FIELD_H), Sense::hover()); let step = self.step.unwrap_or_else(|| self.kind.step()); let clamp = |v: f64| v.clamp(self.range.0, self.range.1); @@ -175,6 +212,15 @@ impl<'a> NumField<'a> { [pos2(c.x, c.y + b), pos2(c.x + 4.0, c.y + a * 0.6)], Stroke::new(1.0, if self.enabled { t.icon } else { t.text_disabled }), ); + // The two spinner halves are real inputs: name them for screen readers and strict + // accessibility checks, which would otherwise see unnamed clickable nodes. + resp.widget_info(|| { + egui::WidgetInfo::labeled( + egui::Role::Button, + self.enabled, + format!("{} {}", if dir > 0.0 { "Increase" } else { "Decrease" }, name), + ) + }); if resp.clicked() { let mul = if ui.input(|i| i.modifiers.shift) { 10.0 } else { 1.0 }; out = Some(clamp(self.value.unwrap_or(0.0) + dir * step * mul)); @@ -186,21 +232,25 @@ impl<'a> NumField<'a> { if !self.presets.is_empty() { let seg = Rect::from_min_max(pos2(rect.max.x - SEG_W, rect.min.y), rect.max); text_rect.max.x = seg.min.x; - let resp = ui.interact(seg, key.with("presets"), Sense::click()); - ui.painter().rect_filled(seg, 0.0, if resp.hovered() { t.hover } else { t.input }); - chevron_down(ui.painter(), seg.center(), t.icon); + let resp = ui.interact(seg, key.with("presets"), if self.enabled { Sense::click() } else { Sense::hover() }); + ui.painter().rect_filled(seg, 0.0, if resp.hovered() && self.enabled { t.hover } else { t.input }); + chevron_down(ui.painter(), seg.center(), if self.enabled { t.icon } else { t.text_disabled }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, self.enabled, format!("{name} presets"))); preset_resp = Some(resp); } // Value. ui.painter().rect_filled(text_rect, 0.0, bg); let mut buf: String = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| self.value.map(|v| self.fmt(v)).unwrap_or_default()); - let te = egui::TextEdit::singleline(&mut buf) + let mut te = egui::TextEdit::singleline(&mut buf) .id(focused_id) .frame(egui::Frame::NONE) .font(label_font()) .text_color(fg) .margin(egui::Margin::ZERO) - .vertical_align(egui::Align::Center); + .align(egui::Align2::LEFT_CENTER); + if let Some(empty) = self.empty { + te = te.hint_text(egui::RichText::new(empty).font(label_font()).color(t.text_dim)); + } // The field already allocated its slot. A scoped allocation here would consume another // grid cell and enlarge toolbar rows; the value editor must stay inside that same slot. let value_rect = Rect::from_min_max(text_rect.min + vec2(5.0, 1.0), text_rect.max - vec2(1.0, 1.0)); @@ -209,7 +259,11 @@ impl<'a> NumField<'a> { if !self.enabled { editor_ui.disable(); } - let r = editor_ui.put(value_rect, te); + let r = editor_ui.put(value_rect, te).accessible_name(name); + let r = match self.label { + Some(id) => r.labelled_by(id), + None => r, + }; if r.has_focus() { // ↑/↓ step the value while typing. let (up, down, shift) = ui.input_mut(|i| { @@ -227,8 +281,14 @@ impl<'a> NumField<'a> { ui.data_mut(|d| d.insert_temp(key, buf.clone())); } if r.lost_focus() { - if let Some(v) = self.parse(&buf).map(clamp) - && self.value.is_none_or(|old| (old - v).abs() > 1e-9) + // An automatic value (in parentheses) typed over commits even when it's the same + // number: typing "14.4 pt" over "(14.4 pt)" makes it a fixed value. Escape reverts + // whatever was typed, as in InDesign. + let escaped = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let typed_over_auto = self.parens && !buf.trim_start().starts_with('('); + if escaped { + } else if let Some(v) = self.parse(&buf).map(clamp) + && (typed_over_auto || self.value.is_none_or(|old| (old - v).abs() > 1e-9)) { out = Some(v); } else if buf.trim().is_empty() && self.value.is_some() { @@ -250,9 +310,17 @@ impl<'a> NumField<'a> { if !self.presets.is_empty() { p.line_segment([pos2(rect.max.x - SEG_W, rect.min.y), pos2(rect.max.x - SEG_W, rect.max.y)], Stroke::new(1.0, t.field_border)); } - if let Some(resp) = preset_resp { + if let Some(resp) = preset_resp + && self.enabled + { egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(self.width); + if let Some(empty) = self.empty + && ui.selectable_label(self.value.is_none(), empty).clicked() + { + cleared = self.value.is_some(); + ui.close(); + } for v in self.presets { if ui.selectable_label(self.value.is_some_and(|c| (c - v).abs() < 1e-9), self.kind.format(*v)).clicked() { out = Some(*v); @@ -290,6 +358,16 @@ pub fn chevron_down(p: &egui::Painter, c: egui::Pos2, col: Color32) { p.line_segment([pos2(c.x, c.y + 2.0), pos2(c.x + 4.0, c.y - 2.0)], Stroke::new(1.0, col)); } +/// Name a combo box for screen readers and tests. +/// +/// `egui::ComboBox` announces its current value, not the field it belongs to, so every combo box +/// is otherwise an unnamed input. Pass the visible caption (or a semantic name when a painted +/// caption has no text node of its own). +pub fn name_combo(combo: egui::InnerResponse>, name: impl Into) -> egui::InnerResponse> { + let egui::InnerResponse { inner, response } = combo; + egui::InnerResponse { inner, response: response.accessible_name(name) } +} + /// A dropdown box (field style, ⌄ segment on the right). Returns the response; open a popup on it /// with [`egui::Popup::menu`]. pub fn dropdown(ui: &mut Ui, text: &str, width: f32) -> Response { @@ -307,6 +385,8 @@ pub fn dropdown_h(ui: &mut Ui, text: &str, width: f32, h: f32) -> Response { chevron_down(p, seg.center(), t.icon); let clip = Rect::from_min_max(r.min, pos2(seg.min.x - 2.0, r.max.y)); p.with_clip_rect(clip).text(pos2(r.min.x + 7.0, r.center().y), egui::Align2::LEFT_CENTER, text, label_font(), t.text); + // A painted, clickable box is otherwise an unidentified input; it opens a choice list. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), text)); resp } @@ -341,7 +421,7 @@ pub fn outline_button(ui: &mut Ui, label: &str, width: f32) -> Response { p.rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); crate::rtl::paint(p, r.center(), egui::Align2::CENTER_CENTER, label, label_font(), t.text); // Screen readers (and tests) find it by its label. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); resp } @@ -372,7 +452,7 @@ pub fn link_label(ui: &mut Ui, text: &str, width: f32) -> Response { ui.painter().galley(pos, g, t.text); let y = r.center().y + 6.5; ui.painter().line_segment([pos2(r.min.x, y), pos2(r.min.x + w, y)], Stroke::new(1.0, if resp.hovered() { t.text_strong } else { t.text_dim })); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, text)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Link, true, text)); resp.on_hover_cursor(egui::CursorIcon::PointingHand) } @@ -381,7 +461,9 @@ pub fn more_options(ui: &mut Ui, language: &str) -> Response { let t = Tokens::get(ui.ctx()); let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover()); let r = Rect::from_min_max(pos2(row.max.x - 26.0, row.min.y), pos2(row.max.x - 4.0, row.max.y)); - let resp = ui.interact(r, ui.id().with(("more", row.min.y as i32)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("more", row.min.y as i32)), Sense::click()); + let label = crate::i18n::tr(language, "More Options"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); for i in 0..3 { ui.painter().circle_filled(pos2(r.min.x + 4.0 + i as f32 * 7.0, r.center().y), 1.9, if resp.hovered() { t.text_strong } else { t.icon }); } @@ -401,7 +483,7 @@ pub fn caption_w(ui: &mut Ui, s: &str, w: f32) { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover()); // Painted, so tell screen readers (and UI tests) what it says. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, s)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, s)); crate::rtl::paint(ui.painter(), pos2(r.max.x - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, s, egui::FontId::proportional(11.5), t.text); } @@ -422,7 +504,7 @@ pub fn section(ui: &mut Ui, title: &str, _default_open: bool) -> bool { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 16.0), Sense::hover()); crate::rtl::paint(ui.painter(), r.left_center(), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(11.5), t.text_strong); // Screen readers hear the section's title. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, title)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, title)); ui.add_space(3.0); true } @@ -539,7 +621,7 @@ pub fn icon_toggle_sized(ui: &mut Ui, icon: &str, on: bool, tip: &str, size: egu let s = (size.y - 4.0).min(17.0); crate::icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, if on { t.text_strong } else { t.icon }); // Named by its tooltip, for screen readers. - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, tip)); if tip.is_empty() { resp } else { @@ -567,6 +649,8 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { *on = !*on; resp.mark_changed(); } + // A painted check box must still report its role, label and toggled state. + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), *on, label)); resp } @@ -578,7 +662,7 @@ pub fn segmented(ui: &mut Ui, labels: &[&str], active: usize, width: f32) -> Opt let mut out = None; for (i, l) in labels.iter().enumerate() { let sr = Rect::from_min_size(pos2(r.min.x + w * i as f32, r.min.y), vec2(w, BUTTON_H)); - let resp = ui.interact(sr, ui.id().with(("seg", l)), Sense::click()); + let resp = ui.interact(sr, ui.scope_id().with(("seg", l)), Sense::click()); if i == active { ui.painter().rect_filled(sr, 0.0, t.well); } else if resp.hovered() { @@ -754,6 +838,36 @@ mod tests { assert_eq!(m.parse("1p0+0p6"), Some(18.0)); assert_eq!(m.step(), 1.0); } + + #[test] + fn disabled_numeric_presets_are_disabled_and_do_not_open() { + use egui_kittest::kittest::{NodeT, Queryable}; + + const PRESETS: &[f64] = &[0.0, 50.0, 100.0]; + let mut h = egui_kittest::Harness::builder().with_size(vec2(420.0, 240.0)).build_ui(|ui| { + assert!(NumField::number("Opacity", Some(50.0), "%", 0).presets(PRESETS).enabled(false).show(ui).is_none()); + }); + h.run_steps(2); + assert!(h.get_by_label("Opacity presets").accesskit_node().is_disabled()); + h.get_by_label("Opacity presets").click(); + h.run_steps(3); + assert!(!egui::Popup::is_any_open(&h.ctx), "a disabled preset button must not open its menu"); + h.check_accessibility(); + } + + /// Strict accessibility: every input in these fields is named and reports the right role. + #[test] + fn numeric_dropdown_and_checkbox_inputs_have_accessible_names_and_roles() { + const PRESETS: &[f64] = &[0.0, 50.0, 100.0]; + let mut h = egui_kittest::Harness::builder().with_size(vec2(420.0, 240.0)).build_ui(|ui| { + let _ = NumField::number("Opacity", Some(50.0), "%", 0).spinner().presets(PRESETS).show(ui); + let _ = dropdown(ui, "Paragraph Styles", 160.0); + let mut on = true; + let _ = checkbox(ui, &mut on, "Preview"); + }); + h.run_steps(2); + h.check_accessibility(); + } } /// `img` made to fit the GPU's largest texture (`max_side` pixels a side), sampled down diff --git a/crates/ui-egui/tests/it/color_profiles_restart.rs b/crates/ui-egui/tests/it/color_profiles_restart.rs index ad7ebcc75..e84022b6f 100644 --- a/crates/ui-egui/tests/it/color_profiles_restart.rs +++ b/crates/ui-egui/tests/it/color_profiles_restart.rs @@ -6,9 +6,13 @@ use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, Services, UiState}; use serde_json::json; -/// The app's first frame, where saved settings are applied. +/// The app's first frame, where saved settings are applied: the host's `logic` tick and the +/// first real `run_ui` pass (where fonts, theme and colour settings are installed). fn start(app: &mut DesignApp) { - let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| app.logic(&ui.ctx().clone())); + let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); out.textures_delta.clear(); } diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 000000000..dc76b9265 --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1422 @@ +# egui 0.37 migration plan for DesignCraft + +Status: planning only. No production change, build, test, GUI run, browser run, benchmark, +push, pull request or publication was performed while writing this document. Every future test +or build runs through `maintenance/task.py run/status/finish` with its own task, source and +evidence; never run a build directly. Every future push or new pull request needs fresh explicit +human approval after the exact contents and validation are shown. The contributor credit +preference remains username-only (`@skylartaylor`); do not add display or real names or any +agent/model/tool attribution. + +This plan covers only `storytold/designcraft`. It records the work needed to move the +DesignCraft desktop, web and UI crates from the current coherent 0.36.2 dependency set to +egui 0.37.0, the final reviewed suite fork and the matching final reviewed winit revision. +Shared-library work remains in `storytold/craft-libs`; this plan consumes it but does not +redefine it. Candidate `aiongg` revisions below are comparison inputs only. A production +manifest must name the final reviewed `storytold` revisions, which do not exist until the +fork/winit/craft-libs publication is reviewed and approved. + +Capture acceptance follows the suite contract: a queued-not-issued hidden request and +each settling frame must request `RequestPaintWhileHidden` from logic; only an issued +screenshot command forces paint itself. Callback arrival has no implicit wake, so the +live request owns the viewport waker. Record known issuance UI bounds and a geometry +epoch, reject/retry any intervening resize/DPI/zoom/surface change even if it changes +back, and derive pixel scale from returned dimensions only under an unchanged epoch. +Default limits are 4 requests, 128 MiB/image and 256 MiB aggregate. Count reserved +in-flight and completed images together. + + +## 1. Baseline and provenance + +### 1.1 Exact source baseline + +| Item | Value | +| --- | --- | +| DesignCraft checkout | `/home/codex/work/CraftApps/egui037-migration-20261022/designcraft` | +| Baseline commit | `e74208dd7e0c801d15dfdf926d64cfe5e3b346d3` (`Merge pull request #1642 from aiongg/ci-speed`) | +| Workspace version / edition / MSRV | `0.12.0` / edition 2024 / Rust 1.95 | +| Lockfile | `Cargo.lock`, format version 4 | +| Current egui family | `egui`, `epaint`, `ecolor`, `emath`, `eframe`, `egui-wgpu`, `egui-winit`, `egui_kittest`, `epaint_default_fonts` all `0.36.2` from crates.io | +| Current winit | `git+https://github.com/storytold/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7` (0.30.13, W1–W14) | +| Current `craft-ui` | `git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51...` | +| Upstream egui 0.37.0 | `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` (release commit, not the annotated-tag object) | +| Candidate egui fork (comparison only) | `aiongg/egui` `craft/0.37` = `a4f9a17f64a6bae61350fb85804c9df0d9865916` (E1–E21; E21b/P7b still to be added) | +| Candidate winit (comparison only) | `aiongg/winit` `craft/0.30.13-lang` = `8b35388bd719e8cc86a95971ba0bc026c9a8f058` (W1–W27; W15P still to be added) | +| Final storytold egui pin | **Unknown until approved publication**: a reviewed immutable `storytold/egui` revision containing the complete P8 handoff: E1–E21, E21b/P7b, E4/P2b, P7 key pairing and P7c fixes. Never ship the candidate hash. | +| Final storytold winit pin | **Unknown until approved publication**: a reviewed immutable `storytold/winit` revision containing W1–W27 plus native W15P PRIMARY. Never ship the candidate hash. | +| Final `craft-ui` pin | **Unknown until approved publication**: ``, the reviewed CL-2F revision (including CL-1, and CL-3 if it has landed) derived from current craft-libs, not a PR head. D1 uses the reviewed CL-1 + CL-2 provisional revision as a pilot; CL-2F follows the DesignCraft/GridCraft adapter reviews. | +| Published predecessor | `storytold/egui` `craft/0.36.2` = `767b1cad294120689a514bb4bd68c077e46679b9`; published `storytold/winit` = `ffae5198770d7730e718043f035a772b7dbc3b1b` | +| craft-libs PR 199 | head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78`; audit tree `ec8840af8c5fda695cf195cdd05e5ae078415c07`. Both are **stale trees, not bases**: they omit `crates/platform` and revert current security/settings/docking/printer fixes. Apply only the API-port hunks from `8614e4a04fae516fba16c22b31ff1008cb6be10b` (and relevant README changes from `1764bca`) onto current craft-libs `49190884278f16c9a9f5823d7698a8ffb5deb062`. | +| Related language work | `storytold/craft-libs#198`, delivered through fork E5/E9/E12–E17/E19–E21, E21b and winit W17–W27 plus W15P | + +Authoritative lifecycle evidence for this plan is +`/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lifecycle-source-audit.md`. +It was checked against upstream `841a328b` and candidate `a4f9a17f`: both native +`EpiIntegration::update` and web `AppRunner::logic` call `app.logic` then `app.ui` inside +`Context::run_ui`, so `App::logic` can run once per layout pass in both hosts. After a normally +completed `run_ui`, `current_pass_index()` is zero even after multiple passes. `run_logic` does +not restore an old index. `ViewportInfo::visible()` is a method derived from `minimized`/ +`occluded`; it is not a callback-phase signal, and `None` means unknown. + +The IME gap is recorded in +`/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/ime-surrounding-gap.md`. The +candidate never calls W27 `set_ime_surrounding_text`, and `IMEOutput` has no logical text. E21b/P7b +is a required new change, not existing capability. + +The historical compiler diagnostics at +`/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/designcraft.errors` +are clues only. They were produced against an earlier prepare state, their counts are not +acceptance, and the current source may differ. They do show the first API breaks: missing +`Event::source`, removed `egui::UserData`/`Event::Screenshot`, renamed roles in +`new_document.rs`, `font_menu.rs`, `panels/*`, and `FontData` blob typing in `theme.rs`. + +### 1.2 Packages, binaries, examples and feature targets + +| Target | Kind | Relevant manifest | Notes | +| --- | --- | --- | --- | +| `designcraft-ui-egui` | library | `crates/ui-egui/Cargo.toml` | The swappable L6 UI. Production depends on `craft-ui`, `egui`; dev depends on `egui_kittest` (`wgpu`, `snapshot`) and `designcraft-fonts` (`testing`). D1 adds an explicit `egui/default_fonts` dev feature (see §3.7). Build script only bakes contributor credits. | +| `designcraft` | binary | `apps/designcraft/Cargo.toml` | eframe features `default_fonts`, `wgpu`, `accesskit`, `wayland`, `x11`, `links`. D1 adds `clipboard` to preserve Copy/Cut and Paste on 0.37 (0.36.2 forced it through eframe; see §3.5). Native-only `rfd`, `arboard`, `winit`; macOS `muda`, `objc2*`, `fmv-macos-events`; Windows `winsafe`. | +| `designcraft-web` | wasm binary | `apps/designcraft-web/Cargo.toml` | `eframe` features `default_fonts`, `wgpu`, `accesskit`; `rfd`, `wasm-bindgen`, `web-sys`; `trunk` build. Browser clipboard/drop paths are web-specific. | +| `designcraft-cli` | binary | `apps/designcraft-cli/Cargo.toml` | No egui/eframe/winit/rfd; headless and MCP. | +| `ui_shot` | example | `crates/ui-egui/examples/ui_shot.rs` | `egui_kittest::Harness` with wgpu at 1440×900, 2×; drives control requests and saves whole-window PNGs. It steps one UI pass per `harness.step()` and is not native presentation evidence. | +| `xtask` | workspace tool | `xtask/Cargo.toml` | `cargo xtask ci`, `wasm`, `layers`, `assets`, `pdf-deps`, `test`, `stats`, `parity`. | + +There are 22 workspace manifests (`Cargo.toml` plus `apps/*` and `crates/*`), no nested +workspace, no `vendor/` directory, no examples or benchmarks that use egui other than +`ui_shot`, and no `[[bench]]`, `[[test]]` or `[[example]]` entries in any manifest. The only +workspace patch section is the root `[patch.crates-io]` in `Cargo.toml`. + +Relevant platform cfgs: + +| Cfg | Files | What must remain true | +| --- | --- | --- | +| `target_arch = "wasm32"` | `apps/designcraft-web/src/{main,web}.rs`, `crates/ui-egui/src/lib.rs` (`now_ms`, recovery, render worker) | Browser entry point and async file/drop/download paths build and run; native-only recovery, dropped-file paths and render worker stay out. | +| `not(target_arch = "wasm32")` | `crates/ui-egui/src/lib.rs`, `apps/designcraft/Cargo.toml` | Native recovery, `arboard`, `rfd`, control server, `winit` and the enabled clipboard path keep compiling. | +| `unix` and `not(target_os = "macos")` | `apps/designcraft/src/main.rs` X11 event-loop builder | XWayland selection and file drops still work; `winit` pin changes do not remove `EventLoopBuilderExtX11`. | +| `windows` | `apps/designcraft/{Cargo.toml,src/gpu.rs}`, `build.rs` | `winsafe` display-GPU query and Windows resource build remain; DX12 backend selection and `WgpuError` fallback are tested. | +| `target_os = "macos"` | `apps/designcraft/src/{main,native_menu,mac_window,apple_events}.rs`, `crates/fonts` CoreText | Native menu, Apple events, `movable_by_window_background`, IME marked-text discard and cancelable termination keep their winit W12/W14/W22/W26 behavior. | +| X11/Wayland | `egui-winit` clipboard path, `apps/designcraft/src/main.rs` | D1 enables the explicit clipboard feature; D3 adds E4/W15P native PRIMARY on the winit data device. One regular data device only; password exclusion and unchanged-selection policy preserved. | +| `cfg(test)` / `cfg(all(test, unix))` | `crates/ui-egui/src/*`, `apps/designcraft/src/main.rs` | Test-only counters and atomic-writer tests do not leak into release; kittest strict names and missing-glyph policy are explicit. | + +### 1.3 Current CI and validation commands + +All future commands below run through `maintenance/task.py run` with a unique `--job-id`, the +owning `--task`, exact `--source` and durable `--evidence`; inspect with `status` and complete +with `finish` after reviews. + +- `cargo xtask ci`: `fmt --all -- --check`, `pdf-deps`, `clippy --workspace --all-targets -- + -D warnings`, `nextest run --workspace --lib --bins --tests` (or `cargo test`), doctests, + `assets`, `layers`, `wasm`. +- `cargo xtask wasm`: one `cargo check --target wasm32-unknown-unknown --keep-going` over + L0–L5 plus `designcraft-ui-egui`. +- `cargo test -p designcraft-ui-egui --lib` is the package-scoped UI row. It must run with the + explicit `egui/default_fonts` dev feature and with `theme::install_fonts` before layout. +- Clean-checkout dependency gate: after publication, a fresh clone runs + `cargo fetch --locked` and `cargo metadata --locked --format-version 1` against the final + storytold revisions. A missing/unreachable revision or a machine-local path fails the gate. +- CI workflow `.github/workflows/ci.yml` runs `cargo xtask ci` on Ubuntu 24.04 with pinned + craft-fonts `abb83316d96aa59c1cf64784289e378fe9fa5695`, nextest, and the apt/winit build + dependencies. +- `windows-arm64.yml` packages and installs ARM64; `freebsd.yml` runs the workspace in a + FreeBSD VM; `release.yml` uses the shared release workflow. +- Manual UI evidence: `cargo run --release -p designcraft -- --sample --control 7979`, + control requests, `ui.screenshot`, and `cargo run -p designcraft-ui-egui --example ui_shot + -- script.jsonl` for offscreen whole-window screenshots. +- Native/browser workflows are listed in §6.4; `ui_shot` alone is not native focus, IME, + picker, clipboard or browser-presentation evidence. +- There is no performance benchmark target today. `ui.inspect` exposes + `perf.frameMs`, `perf.renderMs`, `perf.fps`, `perf.patches`; `perf.frameMs` uses + `now_ms()` (`SystemTime` on native, `0.0` on wasm) and is not a monotonic acceptance + timer. §7 defines the test-only raw-sample harness. + +## 2. What 0.37 changes that actually affects DesignCraft + +DesignCraft already uses `eframe::App::logic`/`App::ui` and a root `Ui` on 0.36.2. The +0.37 migration must preserve that structure; it is not a reason to rewrite panels into +`Context::root_ui` calls or to move every action out of `logic`. + +| 0.37 change | Source anchor | DesignCraft impact | +| --- | --- | --- | +| `Event::MouseWheel` gains `source: MouseWheelSource` | `egui/src/data/input/event.rs`, `MouseWheelSource` | Every synthetic wheel event in tests must set `source` (`Unknown` for most; `Wheel`/`Trackpad`/`Momentum` where the behavior under test depends on it). Production matching already uses `..`, but the canvas's Alt-wheel state machine must be rechecked. | +| `Event::Screenshot` and `egui::UserData` are removed; `ViewportCommand::Screenshot(ScreenshotCallback)` | `egui/src/data/screenshot_callback.rs`, `viewport.rs`; upstream #8489 | `ScreenshotCallback` is one-shot and does not repaint. Replace token/`UserData` correlation with a per-request completion slot owned by the request record and a weak callback; see §3.4. | +| `WidgetType` is replaced by `accesskit::Role` (`egui::Role`); alias deprecated | `egui/src/lib.rs` compatibility table; #8555 | Map `SelectableLabel` → `Button`, `TextEdit` → `TextInput`, `CheckBox` → `CheckBox`, `Other` → `Unknown`. The full file list is in §3.7: `new_document.rs`, `panels/font_menu.rs`, `panels/pages.rs`, `panels/mod.rs`, `panels/styles.rs`, `panels/cell_stroke.rs`, `panels/properties.rs`, `panels/swatches.rs`, `panels/layers.rs`, `object_control.rs`, `widgets.rs`, `toolbar.rs`, `chrome.rs`, `taskbar.rs`, `icons.rs`, `dialogs.rs`. | +| `Id::new` deprecated for `Id::unique`; `Ui::id` renamed `Ui::scope_id`; `UiBuilder::id` renamed `scope_id` | `egui/src/id.rs`, `egui/src/ui.rs`; #8534/#8542/#8586 | Mechanical but warning-sensitive because `clippy -D warnings` treats deprecations as failures. Use `Id::unique` for root IDs and `scope_id` for child scopes. Do not change the hashed values: `Id::new` now delegates to `Id::unique`, so state keys remain stable. | +| `Panel` constructors take `impl AsIdSalt`; panel ID is `parent_ui.scope_id().with_salt(salt)`; `CentralPanel` remains last; root `Ui` is the normal app surface | `egui/src/containers/panel.rs`; #8592 | Existing `Panel::left("tools")`, `Panel::right("dock")`, etc. still type-check. Panel IDs are now parent-salted. DesignCraft does not persist egui memory (eframe persistence is off); update in-memory test lookup keys and retain the actual app-owned UiState/docking preferences. No PanelState migration is needed. Panel order must not change. `CentralPanel::default().show(ui, …)` is correct; `Context::root_ui` must not be called inside `App::ui`. | +| `default_fonts` is no longer a default `egui`/`epaint` feature | `egui/Cargo.toml`; #8583 | `eframe` still enables it in the app, but `cargo test -p designcraft-ui-egui` does not build the app and `egui_kittest` disables egui defaults. Add `egui/default_fonts` explicitly to the ui crate's dev feature set and install DesignCraft fonts before layout; do not rely on workspace unification. | +| `FontData.font` is now a shared `Blob` (`Arc + Send + Sync>`), not `Cow<[u8]>` | `epaint/src/text/font_data.rs` | Production struct-update code in `theme.rs` still works; tests that compare `data.as_ref()` must use `data.bytes()` or `data.font.as_ref().as_ref()`. This caused the historical `theme.rs:766/793` errors. | +| `egui_kittest` fails unnamed input widgets and panics on missing glyphs by default | `egui_kittest/src/accessibility.rs`, `builder.rs`; #8589/#8601 | The first green stage must satisfy strict names/glyph policy, not disable it globally. D1 adds names/policy for the existing test surface; D4 completes coverage. Deliberate tofu fixtures use `HarnessBuilder::allow_missing_glyphs()` with a named reason. | +| Native eframe can discover system fonts through `egui_system_font_provider`, and `NativeOptions::system_font_fallback` defaults true | `eframe/src/native/epi_integration.rs`; #8512 | DesignCraft already catalogs system fonts in `designcraft-fonts` with script-specific families and baseline tweaks. Set the option false and keep the feature off, or font order and line metrics change. Add the #8512 cold-start workload in §7. | +| Clipboard is no longer forced on by eframe; `egui-winit` clipboard is an explicit feature | `eframe-0.36.2/Cargo.toml` target dep forces `egui-winit/clipboard`; `eframe-0.37.0/Cargo.toml` exposes `clipboard = ["egui-winit/clipboard"]` and depends on egui-winit with defaults off; `egui-winit-0.37.0/src/clipboard.rs` stores only in-process without arboard/smithay | 0.36.2 wrote Copy/Cut to the OS clipboard. On 0.37 without the feature, `ctx.copy_text` is in-process only and `RequestPaste` cannot read the OS clipboard. D1 enables `eframe/clipboard` to preserve native Copy/Cut/Paste, with the control-channel guard. D3 adds E4/W15P PRIMARY on the same winit data-device path. | +| X11/Wayland PRIMARY selection is new in `egui-winit`, and the candidate's E4 path replaces arboard with winit's data device | `egui-winit/src/clipboard.rs`, `lib.rs`; #8428; E4 | Enabling clipboard makes PRIMARY reachable. The final fork must include E4 plus native W15P; test pointer- and keyboard-triggered ownership (including keyboard-only focus), serial freshness, owner/seat changes, empty/absent offers, self-paste, canceled/slow transfers on wlroots/KWin/Mutter, password exclusion and no second regular data device. | +| `TextEdit` selection behavior changed on click-away | upstream #8763, unmerged PR #8764 | The fork owns the selected fix. Source audit finds no DesignCraft control that reads an egui `TextEdit` selection in the same pass: formatting acts on the engine `TextSel`; dialog/search buttons read buffers. The regression test documents stock 0.37 click-away behavior and asserts the app's engine selection/target is unchanged; if a future control reads `TextEdit` state, carry #8764 or defer the command with a tested app change. | +| Text tessellation is reported ~60% slower for plain text | upstream #8731 | UI frame cost may rise even though layout is unchanged. The fix must keep color emoji correct; branch once per row, not clamp-only. Measure the fixed 120-label workload. | +| DX12 pipeline creation is reported ~4× slower from `wrap_texel_coord` | upstream #8736 | Windows startup can get ~40 ms slower. Measure renderer/shader construction separately from first frame; do not call shader compile time UI performance. | +| `#8748` hidden-parent/deferred-child is closed and unmerged | upstream #8748 | Do not assume hidden-window child viewport updates are fixed by 0.37. The fork owns a tested solution; DesignCraft must open/update/close its immediate second viewport while the parent is hidden/occluded/overdue, with unknown visibility (`visible() == None`) as well as `Some(false)`. | +| `Context::root_ui`, `run_ui`, `run_pass`, `run_logic`; root `Ui` cannot be recursively entered; both native and web `App::logic` run per `run_ui` pass | `egui/src/context.rs`; `eframe/src/native/epi_integration.rs`; `eframe/src/web/app_runner.rs`; #8535; lifecycle audit | Use the passed `&mut Ui` in `DesignApp::ui`; use `run_ui` for UI tests and `run_logic` only for side-effect tests. Recursive `root_ui` panics. Do not use `visible()` as a logic/UI phase signal; a forced hidden paint is still a UI pass. | +| Style defaults changed (roomier buttons, larger corner radius) | #8617/#8445 | Custom `Tokens` still drive most colors, but egui widget spacing/rounding inherited from `Visuals` can shift geometry. Visual baselines need an explained intended change, not a blind update. | +| E21b/P7b outgoing IME surrounding text is missing | `IMEOutput`/`egui-winit` candidate source; `ime-surrounding-gap.md` | Add the bounded transient payload, built-in `TextEdit` producer and W27 Wayland adapter; DesignCraft's custom canvas editor supplies a real model/preedit/UTF-8 adapter, not `None`. | + +## 3. Source audit and concrete migration design + +### 3.1 Existing frame flow + +The desktop host implements `eframe::App` in `apps/designcraft/src/main.rs`: +`App::logic` calls `startup.enter`, `take_notice`, the `frames == 2` presented check, +`frames += 1`, startup error-log installation, macOS native menu/Apple-event polling, then +`DesignApp::logic`; `App::ui` calls `DesignApp::ui`, then macOS IME/movable handling. +`App::raw_input_hook` delegates to `DesignApp::raw_input_hook`. The web shell does the same +in `apps/designcraft-web/src/web.rs`, with asynchronous drop-file reading and a graphics +health check. + +Source facts that constrain the design (verified against the pinned trees): + +1. Native `EpiIntegration::update` (`eframe-0.37.0/src/native/epi_integration.rs:349-361`) + calls `Context::run_ui(raw_input, |ui| { app.logic(ui.ctx(), …); app.ui(ui, …) })`. + Web `AppRunner::logic` (`eframe-0.37.0/src/web/app_runner.rs:290-293`) does the same. + A multipass frame therefore calls host and app `logic` once per pass, not once per frame. +2. `Context::run_logic` (`egui-0.37.0/src/context.rs:1174-1202`) only refreshes viewport/ + window/focus input; events and time stay from the previous UI pass. +3. `run_dyn` passes `new_input.take()` to each `begin_pass`; the second pass has no raw + events. `PointerState::button_clicked` scans `pointer_events`, which are cleared and + repopulated from that empty event list, so a button does not re-fire merely because a + pass was discarded. Command-producing UI still needs a one-pass/undo test because a + discarded first pass may act on pre-discard geometry; it does not need a global + `!will_discard()` ban. +4. After a completed `run_ui`, `ContextImpl::end_pass` takes `viewport.output`; the returned + output owns the completed-pass count and `current_pass_index()` reads the now-zero + viewport output. `run_logic` does not restore a stale index. The earlier comment alleging + a leftover nonzero count was wrong. +5. `ViewportInfo` has `minimized`/`occluded` fields and a `visible() -> Option` method; + there is no public `visible` field. `visible() == None` is unknown, and eframe forces a + real UI pass for `RequestPaintWhileHidden`/pending `Screenshot` even when hidden. +6. `update_logic_only` stores the prepared raw input in `pending_raw_input`; the next + `update` prepends it to new input. Normal pointer/key/text/IME events can therefore be + replayed after hidden logic. + +`DesignApp::logic` (`crates/ui-egui/src/lib.rs:1102`) currently does all of: +`egui_ctx` capture, `window_maximized`, one-time CMS activation through `color_applied`, +`ctx.set_zoom_factor` from `ui.ui_scale`, `zoom_with_keyboard=false`, font installation and +the 250 ms system-font poll, `theme::apply`/`restyle`, FPS smoothing from `i.time`, recovery +autosave from `i.time`, control drain, stale new-document preview cleanup, inbox/open-OS- +documents, synthetic repaint requests, screenshot collect/issue, shortcut handling, window- +command flush, native dropped-file handling and quit handling. `DesignApp::ui` (`lib.rs:1472`) +does layout: app bar, control bar, status bar, Tools, dock, central canvas, second viewport, +floating panels/story editor/dialogs/about/palette, resize borders, URLs and frame timing. + +This is a 0.36.2-shaped flow, not a 0.37 regression. However, 0.37 makes the latent problems +more important: + +1. Every per-frame side effect in `logic` runs again on a discarded second pass. That + includes font/theme setup, recovery checks, FPS sampling, screenshot issue/collect and + side effects that are not input-driven. +2. While minimized/occluded, eframe normally runs `Context::run_logic` and no UI pass. All + `ctx.input` except viewport/window/focus information is stale. A forced hidden paint is + different: `run_ui` does run, with fresh prepared input, even though `visible()` may be + `false` or `None`. +3. Host `App::logic` counts passes in `frames`; on a multipass frame `frames` advances twice, + so `startup.presented()` and startup error logging happen at the wrong pass. +4. Input buffered by `update_logic_only`, and synthetic control events injected in + `raw_input_hook` during a hidden tick, can replay into a later UI pass with a different + focus or document state. + +The correct migration is to make the frame stages explicit, not to merge `logic` into +`ui`, not to add a generic `Context` bridge, and not to use `visible()` as a phase signal. + +### 3.2 Frame-stage contract + +Introduce an explicit, testable split inside `DesignApp` while keeping the public +`logic(&Context)` and `ui(&mut Ui)` entry points that hosts and tests use. + +```rust +// crates/ui-egui/src/lib.rs +pub fn logic(&mut self, ctx: &egui::Context) { + // Every call (first pass, later discarded pass, or hidden run_logic) may poll consumed + // queues and deadlines. It must never replay an already-consumed message. + self.logic_tick(ctx); + // current_pass_index is 0 on the first pass and on hidden run_logic, and >0 on later + // passes of the same run_ui. It is not a freshness test and must not gate input reads. + if ctx.current_pass_index() == 0 { + self.logic_frame(ctx); + } +} + +pub fn ui(&mut self, ui: &mut egui::Ui) { + let first_pass = ui.ctx().current_pass_index() == 0; + self.layout(ui, first_pass); +} +``` + +The guard is deliberately narrow: it removes duplicate per-run_ui side effects, but a hidden +`run_logic` tick still has index 0, so autosave, control commands, inbox results and deadlines +continue. A command that arrives between passes is retained in its queue (or its reply is +pending); the control server requests a repaint, and the next frame's first pass consumes it. +Never put an unconditional `return` for `current_pass_index() != 0` around queue draining, +screenshot completion or background deadlines. + +`logic_tick` (every call) owns work whose identity is a consumed queue item, revision or +monotonic deadline: + +- `drain_control`; each request is taken once. Screenshot requests add a request record and + do not issue from the queue. +- `drain_inbox` and `open_os_documents` after fonts are ready; inbox items are consumed once. +- `handle_quit` (fresh `close_requested` in `i.viewport()`), `flush_window_commands`. +- Screenshot result draining, late-result rejection and deadline replies; take results out + of the slot under a short lock before encoding/writing. +- While a control gesture still has queued synthetic events, request a paint while hidden + (§3.3). Never inject a synthetic event during a logic-only tick. +- Filter buffered OS input after a hidden tick using the focus/document generation recorded + at the tick (§3.2 input policy). + +`logic_frame` (first pass only) owns genuine once-per-run_ui work: + +- `egui_ctx` capture; `window_maximized` update (`lib.rs:1106`). +- One-time CMS activation through `color_applied` (`lib.rs:1107-1112`). +- `ctx.set_zoom_factor` from `ui.ui_scale` and `zoom_with_keyboard=false` (`lib.rs:1114-1119`). +- Font installation/reinstallation from `theme::install_fonts`, `fonts_system` readiness and + the 250 ms `request_repaint_after` while the font scan runs (`lib.rs:1121-1131`). +- `theme::apply`/`restyle` (`lib.rs:1132-1135`). +- Recovery autosave (`lib.rs:1136-1147`), changed from `ctx.input(i.time)` to a monotonic + `Instant` clock so it continues during hidden logic and never uses a stale frame time. + Recovery is native-only (`#[cfg(not(target_arch = "wasm32"))]`); wasm uses its browser + time source rather than `Instant`/`now_ms()`. +- FPS smoothing only on a real UI pass, using a monotonic `Instant` on native and the + browser frame time on wasm; do not inject zero/duplicate samples from hidden logic. +- `new_document::end_stale_preview(self)` (`lib.rs:1157`) and the synthetic/control + `ctx.request_repaint()` while events remain (`lib.rs:1160-1162`). +- Screenshot issue after the 120 ms/3-frame settling rule (`lib.rs:1636-1648`). +- `menus::shortcuts` moves out of `logic` into the first-pass `input_stage` below. +- Native dropped files move out of `logic` into `pending_drops`, captured in + `raw_input_hook`; `logic` never reads stale `ctx.input` drops. +- `handle_quit` stays in `logic_tick` because `close_requested` is fresh viewport data and a + close request must not wait for a first pass. + +`layout` calls `input_stage(ui)` only on the first pass, before the app bar: + +- `menus::shortcuts(self, ui.ctx())` runs against live input; +- native dropped files are taken from `pending_drops` (captured in `raw_input_hook`), not + read from stale `ctx.input` in logic; +- no other input event is read twice. `canvas::show` and text fields continue to use egui's + normal per-widget input. Shortcut consumption happens before layout, preserving the + existing order. + +`DesignApp::ui` must never call `ctx.root_ui(...)`: the root `Ui` is already borrowed by +the host callback, and `Context::root_ui` panics in that situation (#8535). The passed +`&mut Ui` is the one to use for `Panel`, `CentralPanel`, `Area`, and `show_viewport_immediate`. + +Host guard: `apps/designcraft/src/main.rs` must not increment `frames` or run macOS menu/ +Apple-event polling on every pass. Wrap the host body in the same first-pass condition (or +dedupe with `ctx.cumulative_frame_nr()`), while always delegating to `self.app.logic(ctx)`, +which has its own narrow guard. `frames` then counts presented `run_ui` frames, preserving +the `frames == 2` startup.presented semantics. Add a host-level forced-multipass test through +the actual `App` wrapper, not only `DesignApp`. + +`raw_input_hook` remains the one place that sees the exact `RawInput` before a pass. It +continues the current batching: one pointer event per host iteration, a key press/release +pair together, with trailing Text retained for a later iteration (`lib.rs:1456-1469`). Add: + +- native `raw.dropped_files` capture into `pending_drops` and removal from the raw input, so + a hidden `run_logic` cannot re-open the last drop; +- a focus/document generation snapshot on each hidden logic tick; +- a filter that drops buffered `Text`/`Ime`/`Paste` events when the focus target or active + document changed since the hidden tick, and drops an incomplete pointer chord (press + without release or release without press) across a hidden->visible transition. Complete + pointer/key events are replayed in order; pointer hover is refreshed or cleared with + `PointerGone`. Record the policy and counts in the test-only instrumentation. + +On web, `WebShell::raw_input_hook` is called in both the visible and hidden `AppRunner` +paths. It must capture dropped files and start the asynchronous `bytes_async()` read there, +pushing the result into the existing inbox. `WebShell::logic` must not take +`ctx.input_mut` for drops. This is a source correction: the current +`apps/designcraft-web/src/web.rs:351-370` reads drops in `logic`, while `raw_input_hook` +(`web.rs:374-379`) does not. + +Test-only instrumentation may record stage counts: +`#[cfg(test)] logic_tick_ticks`, `logic_frame_ticks`, `input_ticks`, +`screenshot_callbacks`, so forced-multipass and hidden tests can prove one side effect and +one input stage per frame. The counters must not exist in release builds. + +`Context::run_ui` is the test entry point for layout and `Context::run_logic` for +side-effect-only tests. Do not initialize fonts inside `run_logic` in a UI test: no pass +runs, so font installation/layout does not behave as in a visible frame. `ui_shot` and +kittest harnesses call `app.logic(ui.ctx())` and `app.ui(ui)` inside the UI closure as they +do now, with the same pass-index guard. Tests must also drive the real host/web callback +order, not a fictional once-per-frame native host. + +### 3.3 Control input while hidden + +The control channel is used for automation even when the window is minimized. Under +0.37, a hidden window normally runs only `logic`; synthetic pointer/key/text/IME events +injected through `raw_input_hook` are not applied unless a UI pass runs. A +`ViewportCommand::RequestPaintWhileHidden` holds for **one frame** and must be sent again for +another (`egui/src/viewport.rs:1239-1247`); a plain `request_repaint` does not force a hidden +paint (`egui-winit/src/lib.rs:1922-1924`). + +Policy: + +- Do not use `visible()` as a phase signal. Use it only as a hint for whether to request a + hidden paint. `None` (unknown visibility) is treated like hidden for this purpose. +- raw_input_hook runs before both host paths, so deliberately allow its drain-based + injection into pending_raw_input during a logic-only iteration. Eframe retains those + events for the next full pass. Send RequestPaintWhileHidden while an admitted gesture + has queued OR injected-but-unapplied events; acknowledge delivery only from known UI + phase. Preserve current pointer/key batching, generation filtering and event order. + Test hidden injection followed by forced paint: one action/reply, no dropped or replayed + events. An empty synthetic queue alone does not mean pending input was already applied. +- Pure engine commands (`engine.execute`) never request a hidden paint. +- `ui.screenshot` already forces a paint through the `Screenshot` viewport command; do not + add a second `RequestPaintWhileHidden` for it. Still send `Focus` as today. +- `ui.dialog.confirm` and other methods whose result depends on layout follow the same + pending-gesture rule. +- The control reply may be `ok` before all events are applied (current behavior). Tests and + documentation must assert the final command/undo/document result, not the number of + events sent or a single paint request. + +### 3.4 Screenshot callback migration + +Current code (`lib.rs:587`, `drain_control`, `issue_screenshots` at 1636, +`collect_screenshots` at 1652) stores `(token, path, reply, deadline)` in `pending_shots`, +queues `(token, at, frames)` in `queued_shots`, sends +`ViewportCommand::Screenshot(UserData::new(token))`, then scans `Event::Screenshot` for +`user_data.downcast_ref::()`. All of that changes. + +Shared contract: the suite selects one bounded, cancelable completion slot per admitted +request. Eleven existing craft-ui creative consumers adopt its helper; DeckCraft, FigCraft and ArtCraft Native keep a +small local contract-compatible adapter. DesignCraft uses craft-ui. The helper is staged as +CL-2 (provisional) and frozen as CL-2F after DesignCraft and GridCraft adapters are reviewed. + +DesignCraft request record: + +- Keep the existing token counter, path, reply channel, 8 s deadline and 120 ms/3-frame + settling rule in `DesignCraft`. These are app-owned. +- Each `pending_shots` record owns one `craft_ui::capture::CaptureSlot` (provisional API; + final name/API after CL-2F). The slot holds the request id, target `ViewportId`, deadline, + a weak wake handle and at most one `Arc`. +- `issue_screenshots` sends + `ctx.send_viewport_cmd(ViewportCommand::Screenshot(ScreenshotCallback::new(move |image| { + if let Some(slot) = slot.upgrade() { + slot.complete(image); // weak slot; no DesignApp/Context strong capture + } + })))`. + The callback captures only a `Weak` reference to the slot. It must not capture + `egui::Context` strongly (no context self-retention cycle), must not encode PNGs, write + files, run commands, hold a long lock or borrow mutable app state. +- `ScreenshotCallback` is one-shot; clones share one callback and complete at most once. +- Bound admission by both request count and aggregate pending image bytes before issuing a + capture. Compute the allowed surface dimensions, reject an oversized/stale image on + completion, release its reservation and image, and account for renderer/readback transient + memory separately in peak RSS. A post-callback size check alone is not a bound. +- Capture the target `ViewportId`; use `request_repaint_of(viewport)` from the live slot, not + an implicit current/default viewport. Release the slot lock before waking. +- `drain_screenshot_results` runs in `logic_tick`, matches the slot record, takes the image + out under a short lock, then calls `control::save_screenshot(self, image.as_ref(), path)` + on the UI thread. Late callbacks for timed-out/canceled/closed records release the image + and send no reply/file. +- Correlate only `ViewportId::ROOT` for `ui.screenshot` until the protocol grows an explicit + viewport field; do not guess from the pointer. +- Account for E2 surface fitting: the returned image may be smaller than the window. Derive + x/y scale from the returned image dimensions and the recorded logical viewport rect/DPI; + a resize/DPI generation mismatch retries within the deadline or returns an error. Never + combine old pixels with current geometry. + +Tests: two outstanding tokens/viewports resolve to their own paths; completion is one-shot; +out-of-order completion; timeout plus late callback; cancel/close/shutdown; delayed readback; +hidden-window wakeup; aggregate memory returns to zero after drain; a reused viewport/document +generation does not bind an old capture to a new document; no file/reply from a canceled +capture; `ui_shot` still renders whole-window PNGs. + +### 3.5 Input, IME, clipboard and keyboard layouts + +`Event::MouseWheel` constructors in `canvas.rs`, `menus.rs`, `dialogs.rs`, `chrome.rs`, +`new_document.rs`, `object_control.rs`, `panel_docking_tests.rs` and `lib.rs` must add +`source`. Use `egui::MouseWheelSource::Unknown` for synthetic wheel input unless a test +is specifically about `Trackpad`/`Momentum`; the canvas's `wheel_alt` only depends on +`phase` and `modifiers`, so behavior is preserved. + +IME: + +- The canvas (`canvas::ime_event`, `canvas::delete_surrounding`) currently handles + `Preedit`, `Commit`, `DeleteSurrounding { before_chars, after_chars }`. + `delete_surrounding` loops `text.delete` up to 64 times (`canvas.rs:2695-2701`), and each + `text.delete` is an ordinary undoable command (`crates/engine/src/lib.rs:565-585`), so the + current path can create up to 64 undo entries. +- E21 adds `ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }`. Implement a + single mandatory engine command, `text.deleteSurroundingBytes { beforeBytes, afterBytes }` + (or an equivalent single `edit`), that deletes the exact UTF-8 ranges around the caret in + one `s.edit` call, producing one undo entry. Do not make this optional and do not + approximate bytes as characters/`text.delete` loops. +- Validate both counts are bounded (default cap 4096 bytes each and no larger than the story/ + selection), both ranges are valid UTF-8 boundaries via `str::get`, no range extends past + the story, and no split grapheme is produced. Preserve track-changes behavior by marking + deleted text and stepping the caret, as `text.delete` does. Test replacement, deletion- + only batches, combining mark/flag/ZWJ, preedit/commit sequencing, `tool_composing()`, + focus/serial changes and canceled compositions. +- The web text agent keeps its existing `DeleteSurrounding` character behavior. +- E21b/P7b: add the bounded outgoing surrounding-text payload to `IMEOutput`, the built-in + `TextEdit` producer and the egui-winit W27 Wayland adapter. DesignCraft's custom canvas + editor (`canvas::ime_output`, `canvas.rs:2720-2753`) supplies a real model/preedit/UTF-8 + adapter: logical committed document text around the caret, preedit excluded, UTF-8 byte + cursor/anchor, at most 4000 bytes, explicit Unavailable context (distinct from available-empty) when the selection + cannot fit, and clearing on focus loss, no IME, reset and password/no-context. It must not + pass display-bidi text or set the payload to `None` as a placeholder. Audit every + `IMEOutput` constructor/destructure. Tests cover outgoing text after delete+commit and + deletion-only batches, supplementary/combining/RTL text, preedit exclusion, long lines, + field/window switching and the full E21b contract: owner/context generation even + when unavailable, available-empty distinct, W27 committed clear boundary, latest-pass + None replacing earlier payload, old-focus-epoch invalidation, and current-epoch done + serial mismatch applying incoming edits once while deferring outgoing until a match. + Include initially empty→non-empty and normal→password→normal protocol cases. + +Clipboard: + +- Source contract: `eframe-0.36.2/Cargo.toml:234-237` forces + `egui-winit` with `features = ["clipboard"]` even with eframe defaults off. 0.37's eframe + exposes `clipboard = ["egui-winit/clipboard"]` and depends on egui-winit with + `default-features = false` (`eframe-0.37.0/Cargo.toml:60,239-241`). Without the feature, + `egui-winit-0.37.0/src/clipboard.rs:113-141` stores `set_text` only in-process. DesignCraft's + Copy/Cut calls `ctx.copy_text` (`crates/ui-egui/src/lib.rs:806-824`), and its focused-field + Paste path sends `ViewportCommand::RequestPaste` (`menus.rs:2717`). +- Enable `eframe/clipboard` in the D1 source-compatibility PR to preserve native OS + Copy/Cut/Paste. This activates arboard/smithay in egui-winit alongside the app's arboard + read path; keep the app-owned `install_clipboard_guard` so control actions never write or + read the user's clipboard, and keep `Services::clipboard_text` for the menu Paste path that + must not go through egui. +- Native evidence in D1: select text, Copy/Cut, read from an independent arboard + process/handle on X11 and Wayland; test object copy and SVG/text paste; verify focused + `TextEdit` paste and canvas paste are not doubled; verify control-channel privacy. +- D1 includes E4/W15P in its complete fork pin; D3 expands native policy coverage. E4 routes regular clipboard through winit's Wayland data device with an + arboard fallback; W15P adds a native PRIMARY path on the same connection/event queue and + per-seat lifecycle using SCTK 0.19.2 `PrimarySelectionManagerState`/devices/sources/offers. + Do not bind a second regular `wl_data_device`. Test PRIMARY pointer- and keyboard-triggered ownership (including keyboard-only focus), serial + freshness, owner/seat changes, empty/absent offers, self-paste, canceled/slow transfers, + password exclusion and no unchanged-selection reclaim on wlroots/KWin/Mutter. Native + capability evidence is still required; compile-only is not acceptance. +- `Event::PasteImage` is new but DesignCraft has no image-paste contract. Leave it + unhandled rather than inventing a placement path. Add a test that it does not panic and + does not leak into text paste. + +Keyboard layouts: after the fork pin, `RawInput::keyboard_layout_is_latin` and +`RawInput::latin_layout_chars` (E19) must preserve tool and command shortcuts on Russian, +Arabic and Hebrew layouts. W25 covers Linux Latin-layout lookup, W26 macOS deletion and W27 Wayland surrounding +text; Windows keeps its own native input path. E19 also needs a real Windows test +with a non-Latin layout; a synthetic `KeyboardEvent` test is not enough +for the OS layout table. + +### 3.6 Panels, docking and viewports + +`DesignApp::ui` keeps the existing order: + +1. `chrome::app_bar` (`Panel::top("app_bar")`) +2. optional `chrome::control_bar` +3. `chrome::status_bar` (`Panel::bottom("status_bar")`) +4. `toolbar::show` (`Panel::left("tools")`) +5. `dock::show` / `panel_docking::show` (`Panel::right("dock")`, + `Panel::right("shared-panel-dock")`, icon columns) +6. `CentralPanel::default()` last among panels +7. second-window `show_viewport_immediate` +8. floating panels/flyouts, story editor, dialogs/about/palette (areas/windows/modals) +9. custom-titlebar resize borders + +0.37's `Panel` constructors accept `AsIdSalt`; the string salts remain stable, but the +panel `Id` is now `parent_ui.scope_id().with_salt(salt)` (`egui/src/containers/panel.rs:974- +975`), whereas the 0.36 panel id was the salt alone. D1 updates test helpers such as `dock.rs:473` and `panel_docking_tests.rs:639` to +load the parent-salted key using the actual parent Ui scope. Eframe persistence is disabled: +there is no saved egui PanelState migration. Preserve app-owned UiState/docking layout through +`save_prefs` and test its save/reopen behavior and panel rectangles. Keep the docking identity +`designcraft-panel-docking` unchanged for saved layout and live drag state. + +`craft-ui` CL-1: + +- Do not use PR199's `b68de8c`/`ec8840a` trees as a base. They are stale relative to current + `4919088`, omit `crates/platform`, and revert current security/settings/docking/printer + fixes (including floating-group move/resize guards `d66e345`/`fd9cf54`). +- Apply only the API-port hunks from `8614e4a` (and relevant README changes from + `1764bca`) onto current `4919088`, preserving intervening fixes, `crates/platform` and + unrelated crates. Review a diff against both `5c874d51` (the current DesignCraft pin) and + `4919088`; pin the exact resulting immutable revision. +- `WidgetType` → `Role` in `buttons/mod.rs`, `docking/surface.rs`, `layout/resize.rs`, + `tabs/mod.rs`; `Id::new` → `Id::unique`; `Ui::id`/`UiBuilder::id` → `scope_id`. +- The corrected revision keeps the same docking transaction model and `DockArea` API + DesignCraft uses. A model/permission change is not incidental: if intentionally changed, + state it as a product decision with tests. +- CL-3 adds the explicit opt-in `DockArea::direction(DockDirection::{LeftToRight, + RightToLeft})` visual policy. DesignCraft calls `LeftToRight` explicitly for now (no E9 + global mirroring) and relies on CL-3's geometry/hit/drag/keyboard tests. Saved physical + splits, rectangles and logical tab order must be unchanged by direction; never reverse + saved panel order or pass display-bidi text into shaping/clipboard. + +The second window uses +`ViewportId::from_hash_of("designcraft_second_window")` and +`ViewportBuilder::with_title(...).with_inner_size([960, 720])`. Keep the ID and the +immediate viewport. Add tests for: opening/closing the second window through +`window.newWindow`; the main window's pane focus returning on click; close-request +cancellation of the main viewport; screenshot correlation to the root viewport only; +`CentralPanel` not consuming the second window's available rect; and #8748 hidden-parent/ +deferred-child behavior while the root is occluded/minimized/overdue and while +`visible() == None` (open, update, close, and acknowledge without restoring the parent). + +### 3.7 Fonts, glyphs, RTL and accessibility + +`theme::font_definitions` builds `FontDefinitions` from bundled Source Sans 3/JetBrains +Mono plus optional craft-fonts (`CRAFT_FONTS_DIR`) and installed system faces selected by +`SYSTEM_UI_FONTS`. `install_fonts` returns false while the background font scan is +running and the app reinstalls when `system_fonts_ready()` becomes true. This must stay the +source of truth. + +Concrete changes: + +- `theme::SystemFont::new` and the `FontData { index, tweak, ..from_owned }` constructions + are source-compatible with 0.37's `FontData`; only test assertions that read + `definitions.font_data[name].font` or `data.as_ref()` need `data.bytes()`/`font.as_ref()`. +- Set `NativeOptions::system_font_fallback = false` explicitly and keep eframe's + `system_fonts` feature off. This prevents `egui_system_font_provider` from adding fonts + ahead of DesignCraft's script-specific order and makes the policy visible in code. + Document that DesignCraft's own `FontDb` provides system fallbacks. +- `FontDefinitions::default()` is empty when `default_fonts` is off + (`epaint-0.37.0/src/text/font_definitions.rs:111-117`). Add `egui/default_fonts` explicitly + to the `designcraft-ui-egui` dev feature set and install DesignCraft's fonts before layout + in package-scoped tests, together with explicit `monochrome_emoji_fonts` where used. + This restores egui's built-in icon/emoji fallback without relying + on the app binary's feature unification. +- Production keeps `MissingGlyphPolicy::Tofu` (the default). Tests that deliberately draw + unsupported scripts use `HarnessBuilder::allow_missing_glyphs()` with a named reason; tests + proving coverage keep the panic policy. Do not set tofu globally in production tests + merely to silence a regression. +- Test fixtures use the licensed fonts pinned in the egui plan §10.6 (craft-fonts + `2481a8a`, Noto Sans Arabic/Thai/CJK, BIZ UDPGothic; Noto Sans Hebrew and Noto Color Emoji + from their upstream OFL sources with the recorded SHA256/licences). DesignCraft keeps its + own policy: Latin UI/document fonts stay in `assets/fonts/`; central craft-fonts remains an + optional `CRAFT_FONTS_DIR` input; do not copy new font binaries into this repository. + Fetch fixtures into the task's explicit fixture directory and retain licences. +- Add a glyph-coverage test over every `i18n` catalog string and every UI font family, + using `FontDb` faces, not egui's `has_glyph` alone (it can report false for the face + that supplies the replacement glyph). Gate craft-fonts-specific CJK expectations on + `CRAFT_FONTS`; when absent, assert the built-in coverage and skip CJK glyph assertions + explicitly. Existing Ukrainian/Russian tests in `theme.rs` are the model. + +RTL/bidi single owner: + +- At D1 the fork's E5/E12 becomes the only bidi owner for egui-rendered UI/label text. + `rtl::widget`/`rtl::label` pass logical `LayoutJob`s (with explicit language/direction + where needed) to `fonts.layout_job`; remove the manual `BidiInfo::visual_runs` reordering, + section reversal, neutral-character mirroring, Arabic family swap and per-row + `row.pos.x += width - row.size.x` right-alignment in `rtl.rs`. Keeping them would process + Arabic/Hebrew a second time in epaint's `BidiLevels::new` (the fork's + `text_layout.rs:120` always runs bidi; `LayoutJob::direction` defaults to `Auto`). + The document composer's own `crates/compose/src/bidi.rs`/`shape.rs` remains the bidi + owner for document text and canvas glyph placement; this change is about the egui label + path, not a rewrite of document composition. +- Keep `Galley::job.text` as the logical source and use logical strings for clipboard, + save, accessibility, search and commands. Never pass visual-order/display-bidi text to + shaping or the clipboard. Keep the bounded layout cache (256 labels/4096 bytes) keyed by + the logical job, font token and language/direction. +- Editable `TextEdit` paths use the fork's logical text and grapheme/caret mapping; the + custom canvas editor must not feed visual-order strings. +- D3 differential tests: Arabic/Hebrew/mixed fixtures assert visual glyph order, logical + `Galley::job.text`, row widths, caret/hit positions, selection across boundaries, marks, + end caret and exactly one alignment (no double right-shift). ASCII fixtures must remain + identical to stock 0.37 where the API is shared. Run the fixtures at D3, not "only if + replaced". + +`Context::set_language` (E17, fork only) is applied once when `ui.language` changes, +after fonts are installed and before layout. It must not change stored document text, +ids, commands or persisted workspace data. `Context::language` is the fallback on the +web if eframe does not set it. + +Do not enable `Style::right_to_left` (E9). DesignCraft keeps an LTR panel/docking model with +RTL text inside labels, matching the persisted `craft_ui::docking::Layout` and existing +workspaces. Enabling E9 would mirror menus, scrollbars and docking chrome without a product +decision and would double-mirror shaped text. The explicit `DockArea::direction(LeftToRight)` +above is the only docking direction policy in this migration. + +Accessibility labels: kittest's `check_accessibility` now runs by default, and the first +green stage must satisfy strict names/glyph policy. Add explicit names where a control is +an input (`Role::Button`, `TextInput`, `CheckBox`, `Slider`, `SpinButton`, `ComboBox`, `Tab`, +`Splitter`): + +- `icons::button`, `widgets::icon_toggle_sized`, `widgets::checkbox`, + `widgets::dropdown`, toolbar/fill/stroke chips: use the existing localized tip/`label` + via `Response::accessible_name` or `WidgetInfo`. +- `widgets::NumField`: add a label field or `.accessible_name`; do not leave the + internal `id` as the only name. +- `dialogs::text_field`: pass the visible label id and use `ui.label_inputs_by(label.id, + …)` or `ui.name_inputs(tr(key), …)`; section labels added as plain `ui.label` do not + automatically name the field. +- `panels/font_menu.rs`, `panels/pages.rs`, `panels/layers.rs`, `panels/library.rs`, + `story_editor.rs`, `new_document.rs`: bind each field to its visible label; icon-only + star/expand buttons use their localized tooltip. +- Role migration files: the plan's earlier list omitted `panels/properties.rs`, + `panels/swatches.rs`, `panels/layers.rs` and `object_control.rs`. Map: swatch/color well → + `Role::Button` with the swatch name/value; layer row → `Role::ListItem`/`Role::Button` + matching its selectable row behavior; object-control label → `Role::Label`; combo/dropdown → + `Role::ComboBox`; numeric field → `Role::SpinButton`; text field → `Role::TextInput`; + checkbox → `Role::CheckBox`; tab → `Role::Tab`; split/resize → `Role::Splitter`. +- `craft_ui::docking::DockArea` already names tab/overflow/resize controls after CL-1/CL-3; + keep `Role::Tab`, `Role::Button`, `Role::Splitter` and labels stable. +- `Response::labelled_by` is preferred when a visible label exists; + `Response::accessible_name` for icon-only controls; `Ui::name_inputs` only for + third-party/grouped widgets that cannot be named at their construction site. Do not + paper over a missing label with a generic "field" name. + +### 3.8 Theme and visual defaults + +`theme::apply` sets `Visuals`, text styles and widget palette from `Tokens`; most colors +are explicit. 0.37's roomier button defaults and larger corner radius can change layout +even when tokens are unchanged. Audit: +- `v.button_padding`, `v.menu_margin`, `v.window_margin`, `v.item_spacing`, + `v.interact_size` and `CornerRadius` in `theme::apply`; +- `widgets::FIELD_H`, `BUTTON_H`, `SEG_W`; +- panel default sizes in `dock.rs`, `panel_docking.rs`, `toolbar.rs`; +- menu popup width/height and `menus::menu_button`; +- `chrome::app_bar` / `control_bar` / `status_bar` heights. + +Do not replace explicit geometry with 0.37 defaults. A visual diff is an intended change +only after review and is recorded in evidence. + +## 4. Dependency and publication topology + +### 4.1 Intended suite graph + +For suite validation, DesignCraft resolves exactly one copy of each egui-family crate and +one winit. The revisions below are placeholders until the reviewed `storytold` publications +exist; candidate `aiongg` hashes are comparison inputs only. + +``` +designcraft / designcraft-web / designcraft-ui-egui + -> egui 0.37.0 git (contains E1-E21, E21b, E4/P2b) + -> eframe 0.37.0 git + -> egui-winit 0.37.0 git + -> egui-wgpu 0.37.0 git + -> egui_kittest 0.37.0 git (dev) + -> epaint/ecolor/emath/epaint_default_fonts git + -> winit 0.30.13 git (W1-W27 plus W15P PRIMARY) + -> craft-ui git (CL-2F after adapter review; includes CL-1/CL-3) + -> wgpu 30.0.1 registry + -> accesskit 0.25.1 registry +``` + +`egui_extras` is not used by DesignCraft; do not add or patch it. `egui_plot` and +`egui_dock` are not used; do not assume version numbers for them. `egui_system_font_provider` +is not used because the `system_fonts` feature stays off. + +### 4.2 Root `[patch.crates-io]` additions + +A dependency's own `[patch]` section does not propagate to this consumer. Add all of the +following to the root `Cargo.toml`, each with the same full immutable git revision and no +moving branch or machine-local path: + +```toml +[patch.crates-io] +egui = { git = "https://github.com/storytold/egui", rev = "" } +eframe = { git = "https://github.com/storytold/egui", rev = "" } +egui-winit = { git = "https://github.com/storytold/egui", rev = "" } +egui-wgpu = { git = "https://github.com/storytold/egui", rev = "" } +egui_kittest = { git = "https://github.com/storytold/egui", rev = "" } +epaint = { git = "https://github.com/storytold/egui", rev = "" } +epaint_default_fonts = { git = "https://github.com/storytold/egui", rev = "" } +ecolor = { git = "https://github.com/storytold/egui", rev = "" } +emath = { git = "https://github.com/storytold/egui", rev = "" } +winit = { git = "https://github.com/storytold/winit", rev = "" } +``` + +The egui remote must be the published, reachable `storytold/egui` mirror containing the +reviewed fork history. Pinning `aiongg/egui` directly is not the production plan; it would be +an explicit policy decision, not a default. The winit remote must likewise be the final +reviewed `storytold/winit`, not the candidate URL/revision. A branch name, a machine-local +path, or a `path` override must never appear in a shipped manifest. + +Workspace dependency versions change from `0.36` to `0.37` for `egui`, `eframe` and +`egui_kittest`. `egui_extras` is present in the workspace dependencies but is not used by +DesignCraft; bump it only if a future consumer adds a real dependency on it. +`apps/designcraft` keeps its eframe feature list and adds `clipboard` in D1 (see §3.5). Do +not add `system_fonts`. + +### 4.3 Lockfile, clean checkout and duplicate checks + +After generating a lockfile through the managed build workflow, verify: + +- `Cargo.lock` has exactly one `egui`, `epaint`, `ecolor`, `emath`, `eframe`, + `egui-winit`, `egui-wgpu`, `egui_kittest` and `winit` entry. +- Every egui-family source is `git+https://github.com/storytold/egui?rev=#`; + no `registry+.../egui-0.36.2` or `-0.37.0` remains and no candidate `aiongg` source is + shipped. +- `winit` source is the exact final storytold revision; no registry 0.30.13 and no old + `3d51077f...` copy remains. +- `wgpu` stays `30.0.1`; `accesskit` moves to the version egui 0.37 requires + (`0.25.1` in the reference graph). +- `cargo metadata --locked --format-version 1` resolves the same graph as the lock. +- `cargo tree -d` shows no duplicate egui-family crate and no duplicate winit. A + duplicate `accesskit_consumer` from unrelated crates is a warning to inspect, not an + automatic failure. +- `cargo xtask layers` still reports `designcraft-ui-egui` as the only egui consumer + below L7, and `cargo xtask wasm` includes `designcraft-ui-egui`. +- `cargo xtask ci` passes with the exact lock and with and without `CRAFT_FONTS_DIR` for + the font tests that support both. +- Clean checkout: `cargo fetch --locked` and `cargo metadata --locked --format-version 1` + succeed without local overrides. Negative fixture: replacing the final egui/winit revision + with a candidate hash or the old DesignCraft winit override must fail the gate. + +The root `[patch.crates-io]` also contains the unrelated PDF entries. Keep +`krilla`, `hayro`, `hayro-interpret`, `hayro-syntax` and `hayro-jbig2` at +`storytold/craft-libs` `89dcbb3060377a26c99a6d80caa16084366704c4` unchanged. They are not +egui vendors and have their own provenance, tests and removal conditions. + +### 4.4 craft-libs CL-1/CL-2 and language work + +CL-1 is a rebased API port, not a merge of PR 199's tree. Start from current +`4919088`, apply only the `8614e4a` hunks (plus relevant `1764bca` README changes), and +preserve `d66e345`/`fd9cf54`, `crates/platform`, security/settings/printing and unrelated +crates. Review the diff against both `5c874d51` (DesignCraft's current pin) and `4919088`. +The corrected revision must: +- keep `craft-ui` production source compatible with **stock** egui 0.37.0; +- expose the same docking transaction model and `DockArea` API DesignCraft uses, plus the + CL-3 opt-in direction API; +- provide the CL-2 capture slot and CL-2F frozen API used by DesignCraft's screenshot adapter; +- pass its registry-egui 0.37 check, all-feature clippy and tests; +- publish an immutable revision that DesignCraft can pin. + +DesignCraft does not take fork-only API from `craft-ui`: the fork lives in the dependency +patch table, not in `craft-ui`'s manifest. This keeps `craft-ui` publishable and lets +DesignCraft choose the fork independently. + +Language work (#198) enters DesignCraft only through the final fork pin and the selected app +changes in §3.5/§3.7. It must not introduce ICU locale conversion, change document language +semantics, mirror the whole UI, add fonts, or replace the docking model. + +## 5. Existing patches and vendored fixes: keep/remove audit + +DesignCraft has no `vendor/` directory. Its relevant non-registry inputs are: + +| Patch / input | Current revision | Provenance | Decision for this migration | Removal condition | +| --- | --- | --- | --- | --- | +| `winit` | `storytold/winit` `3d51077f...` (W1–W14) | storytold/winit `craft/0.30.13`; X11 `with_x11`, macOS `with_close_requested_on_terminate`, `mac_window::sync_movable` | Replace with `` (W1–W27 plus W15P). W12/W14 remain; W15/W15P/W25/W26/W27 are needed by E4/E19/E21. | Upstream winit 0.30/0.31 contains the equivalent patches and the suite no longer needs the fork. | +| `egui`/`eframe`/`egui-winit`/`egui-wgpu`/`egui_kittest`/`epaint`/`ecolor`/`emath` | registry 0.36.2 | crates.io | Replace by `` containing E1–E21, E21b and E4/P2b in D1 after the isolated stock-0.37 comparison, including P7c/P8. Candidate `a4f9a17f` is comparison input, not the pin. | Upstream egui release contains the equivalent behavior (or the suite drops each patch when its `PATCHES.md` condition is met). | +| `egui_extras` | registry 0.36.2, only in `craft-ui` dev-dependencies | crates.io; not used by DesignCraft | Leave alone. Do not add a patch entry unless a DesignCraft crate directly uses it. | No removal needed. | +| `krilla` | craft-libs `89dcbb30...`, version `=0.8.2` | PDF backend vendor patch in craft-libs; unrelated to egui | Keep exactly. | Its own PDF backend gate. | +| `hayro`, `hayro-interpret`, `hayro-syntax`, `hayro-jbig2` | craft-libs `89dcbb30...`, pinned versions | PDF parser vendor patches in craft-libs | Keep exactly. | Their own PDF parser gates. | +| `craft-ui` | `5c874d51...` | craft-libs published revision | Move to the reviewed CL-1/`` in the D1 capture pilot, then `` (CL-2F/CL-3). | Replaced by the final reviewed revisions. | +| `craft-pdf`, `craft-atomic`, `craft-cms`, `craft-docpath`, `craft-print` | individual craft-libs revisions | Shared libraries, not egui | Keep. Do not conflate their revisions with the UI migration. | Their own release cadence. | + +Do not delete an unrelated vendor or patch merely because the lockfile changes. Do not copy +`aiongg/egui`'s workspace `[patch]` section into DesignCraft's manifest; only the root +`[patch.crates-io]` entries above apply. + +## 6. Test and regression strategy + +### 6.1 Fixtures and determinism + +- Use `file.newSample` / `designcraft_engine::sample::magazine()` for a deterministic + multi-page document. Avoid user files and environment-dependent font caches. +- Run native tests with `DESIGNCRAFT_NO_PREFS=1` and a dedicated + `DESIGNCRAFT_CONFIG_DIR`, or an explicit empty override, so preferences and recovery do + not leak between runs. +- Build with the pinned craft-fonts ref `abb83316d96aa59c1cf64784289e378fe9fa5695` for + release/font evidence, and also without `CRAFT_FONTS_DIR` to keep the bundled-font path + tested. Use the §10.6 font fixtures for Arabic/Thai/CJK/Japanese/Hebrew/color-emoji + coverage with their recorded SHA256/licences; keep them out of app source. +- Use fixed window sizes 1440×900 and 1280×800, at 1× and 2× `pixels_per_point`, and + fixed themes `mediumDark`, `light`, `highContrast`. Record the exact fixture, font + directory, DPI and OS/renderer with every screenshot. +- Use `CRAFT_UI_DOCKING_FIXTURES` for the existing explicit docking visual fixtures; + do not turn those optional captures into unconditional CI jobs. +- Every command runs through `maintenance/task.py run/status/finish` with an allocated + target/VM and durable evidence; do not run tests directly. + +### 6.2 Unit / kittest regressions + +| Area | Test (new or adapted) | User-observable assertion | +| --- | --- | --- | +| Frame staging | `logic_frame_runs_once_across_a_discarded_pass` | Force a `request_discard` by making the Tools panel single-column too tall; assert one font/theme/recovery/FPS tick and one input stage, not two. | +| Host staging | `host_multipass_counts_one_presented_frame` | Drive the real host `App` wrapper through a forced discard; `frames`/startup.presented/error logging advance once, and macOS menu/Apple-event polling is not duplicated when available. | +| Hidden logic | `hidden_logic_ticks_after_completed_multipass_keep_working` | A completed multipass frame followed by repeated `run_logic` ticks runs font readiness/recovery/queue checks each tick with fresh viewport data and no stale index assumption. | +| Hidden forced paint | `forced_hidden_paint_is_a_ui_pass` | `RequestPaintWhileHidden` with `visible() == Some(false)` and with `visible() == None` runs `logic` + `ui`; visibility is not used as a phase guard. | +| Control while hidden | `control_gesture_requests_paint_until_all_events_apply` | A hidden multi-frame `ui.click`/`ui.drag`/`ui.key` sends `RequestPaintWhileHidden` on each hidden frame while events remain; the final command/undo result is correct. Pure `engine.execute` requests no paint. | +| Screenshots | `two_screenshot_viewports_resolve_to_their_own_paths` | Internal capture-helper test (the `ui.screenshot` control method stays ROOT-only): two outstanding requests with different tokens/viewports receive the matching image; no cross-write. | +| Screenshot lifecycle | `screenshot_cancel_timeout_and_late_callback_release` | Timeout/cancel/close writes no file and sends no second reply; a late callback releases its image; aggregate pending bytes return to zero. | +| Screenshot memory | `screenshot_admission_bounds_aggregate_bytes` | Oversized/concurrent captures are rejected or bounded; peak retained bytes stay within the admitted budget; E2 surface-fit scaling is correct. | +| Mouse wheel | `wheel_events_keep_their_source_and_phase` | Alt-wheel zoom, plain-wheel scroll, trackpad phase end and momentum source do not latch modifiers. | +| Roles/IDs | `role_and_id_migration_keeps_existing_state_keys` | A saved `UiState`/workspace round-trips; panel tabs, layer rows and styles resolve by the same stable IDs. | +| Panel state | `saved_panel_state_migrates_to_parent_salted_ids` | Old-format panel rects/widths load into the new parent-salted keys; the same outer rects and panel order result. | +| Panels | `panel_order_and_central_rect_are_unchanged` | App bar/control/status/tools/dock occupy the same outer rects; central canvas rect is unchanged at 1440×900 and after narrow-resize. | +| Docking | `saved_workspace_restores_tree_float_and_hidden_panel` (existing plus 0.37) | Docking tree, floating geometry and hidden placement survive serialization; invalid actions are atomic; direction changes do not reorder saved splits/tabs. | +| Second window | `second_window_opens_closes_and_keeps_main_focus` | Popout exists, close request removes it, main pane focus returns; main close can still be cancelled. | +| Hidden child (#8748) | `hidden_parent_child_viewport_updates_and_closes` | With the parent occluded/minimized/overdue and with `visible() == None`, the child opens/updates/closes and acknowledges without restoring the parent. | +| Accessibility | `every_visible_input_has_an_accessible_name` | `Harness::unnamed_widgets()` is empty; specific roles/labels for tabs, icon buttons, numeric fields, combo boxes, layer rows, swatches and text fields. | +| Glyphs | `every_catalog_character_has_a_face_or_a_declared_gap` | Bundled fonts cover Latin/Cyrillic catalogs; craft-fonts CJK assertions run when present; tests use panic policy for covered text and explicit tofu only for deliberate gaps. | +| IME exact bytes | `ime_replacement_deletes_exact_bytes_in_one_undo_step` | `DeleteSurroundingBytes` replaces a combining mark/letter and Undo restores the exact prior text in one step; `DeleteSurrounding` character path still works; caps/UTF-8/track changes are enforced. | +| IME surrounding text | `outgoing_surrounding_text_is_logical_and_bounded` | DesignCraft canvas emits logical model text after delete+commit/deletion-only; preedit excluded; <=4000 bytes at UTF-8 boundaries; focus/password/no-IME clears; no `None` placeholder; latest completed pass including None replaces the descriptor/payload atomically; owner remains present for Unavailable and W27 clear/epoch/serial behavior follows P7b. | +| IME composition | existing `canvas`/`story_editor` preedit-commit-cancel tests | Marked text does not duplicate, click-away discards on macOS, no undo step for a cancelled composition. | +| Text selection (#8763/#8764) | `click_away_then_button_acts_on_engine_selection` | A button reading the app's engine `TextSel` after clicking away from an egui `TextEdit` still acts on the intended range; stock 0.37's TextEdit cursor/selection loss is recorded and does not change the engine target; if a future control reads TextEdit state, the command is deferred with an explanation. | +| Commands/undo | `multipass_edit_is_one_undo_step` | A text edit, live-preview commit, dialog apply or toolbar/app-bar button produces exactly one history entry across discarded passes (including the second viewport). | +| Save/reopen | `save_reopen_preserves_document_and_workspace` | Save to a temp `.designcraft`, reopen through `file.openBytes`, compare document model/selection/style/swatches; saved workspace and panel layout restore. | +| Clipboard native | `copy_cut_write_the_system_clipboard` | On X11 and Wayland, select text/objects, Copy/Cut, read from an independent arboard reader; paste back; control actions never read or replace the clipboard. | +| CLI control privacy | existing `control_paste_never_reads_the_system_clipboard` | Control actions still never read or replace the user's clipboard. | +| Web drop | `web_drop_is_captured_in_raw_input_hook` | A browser `drop` with a synthetic File starts the async read in `raw_input_hook`, reaches the inbox once, and opens the document; no drop read remains in `WebShell::logic`. | +| Buffered input | `hidden_restore_drops_stale_text_ime_and_incomplete_pointer` | After a hidden tick, changed focus/document discards buffered text/IME/paste; a partial pointer chord is dropped; complete pointer/key events replay in order; two windows stay separate. | +| Menu/palette | existing `menus` tests plus 0.37 role checks | Menus open/close, keyboard activation, overflow within viewport, Quick Apply runs one command. | +| Canvas | existing guide/column/frame-hit tests plus new 0.37 geometry | Hit tests and smart guides do not regress from panel rect changes. | + +### 6.3 Semantic accessibility assertions + +Add `Harness::check_accessibility()` (the default) to UI tests and explicit queries: + +- Menus: root menu items have `Role::MenuItem` or `Role::MenuBar`; disabled items remain + reachable by name. +- Panel tabs: `Role::Tab`, selected state and localized label; tab drag still starts from + the tab rect. +- Icon-only controls: tooltip text is the accessible name; star/expand, eye/lock, fill/ + stroke chips, screen-mode toggles, toolbar overflow. +- Text fields: each has a visible `labelled_by` relationship or a `placeholder`; a blank + label is not an acceptable name. Numeric fields expose `Role::SpinButton` and a value. +- Swatches/color wells, layer rows, object-control labels, combo boxes: the mappings in + §3.7, with name/value/selected state. +- Sliders and split handles: `Role::Slider`/`Role::Splitter`, value/range and accessible + name; keyboard arrow changes still work. +- Dialog modals: `Role::Dialog` or a named `Area`; the title is the name; Escape cancels. +- Focus: Tab order reaches the control bar, panels, canvas and dialog buttons; focus ring + uses `Tokens::accent`/`focus_stroke`; no invisible widget is exposed. + +These are semantic assertions, not only screenshot checks. A screen reader sees the same +names and roles across 0.36.2 -> 0.37 except where a deliberate mapping (e.g. +`SelectableLabel` -> `Button`) requires a new role. + +### 6.4 Visual, native and browser workflows + +- Whole-window PNGs at 1440×900/1× and 2×, medium-dark and light, with the same sample + document and fonts. Compare against 0.36.2 baseline produced in the same environment. +- Justify tolerances in advance: geometry assertions use exact `Rect` comparisons for + panel/canvas/tab rects (within 0.01 pt), and pixel comparisons allow antialiasing noise + only where the change is explained (for example 0.37 corner radius). Do not accept a + golden because it changed; inspect `.new` vs `.old`, list the intended deltas, and keep + the old baseline for rollback. +- Add high-contrast, Arabic, Hebrew, CJK and color-emoji fixtures. For RTL, compare glyph + order, logical `Galley::job.text`, row width and caret/hit positions, not only pixels. +- Windows: use a uniquely owned clone and `C:\CraftApps\Launch-CraftDesktop.ps1` through + the scheduled-task helper. Drive control-channel edit/save/reopen, dock/float/popout and + `ui.screenshot`, inspect fresh screenshots, and record the software renderer + (Microsoft Basic Render Driver) as a separate row. +- Linux: native X11 and Wayland runs with compositor identity; exercise panels, second + window, copy/paste/PRIMARY, file drop, IME (Wayland) and visible edited/saved/reopened + workflows. No inherited display/compositor exists at planning time; provision an + isolated display/window manager and compositor before claiming this row. +- Browser: run the Trunk build in a pinned browser with WebGPU and `?webgl`; load the sample, + edit, save/download, drag-drop a synthetic file, take screenshots and record the browser/ + adapter/backend. Wasm compile and `ui_shot` are not browser presentation evidence. +- Keep software-renderer and real-GPU results separate. Capture native screenshots with + `ui.screenshot` or a native window capture; `ui_shot` is offscreen only. + +### 6.5 Deliberate broken-behavior probes + +Before accepting the suite, temporarily break each behavior and confirm the test fails: + +1. Remove an `accessible_name` from a toolbar button -> accessibility test names it. +2. Change a panel salt (`"dock"` -> `"dock2"`) or load the wrong parent-salted test key + -> panel identity/geometry test fails; app-owned saved layout remains separately asserted. +3. Call `ctx.root_ui` inside `DesignApp::ui` -> test panics with the documented recursive + borrow message (run in a `catch_unwind` probe, not production). +4. Remove the first-pass `logic_frame` guard -> forced-multipass test sees two logic-frame + ticks. Do not use `visible()` to "fix" it. +5. Correlate screenshots by arrival order instead of the per-request slot -> two-token/ + two-viewport test writes the wrong file; cancel a slot and let a late callback arrive -> + no file/reply. +6. Drop `DeleteSurroundingBytes` handling or fall back to the 64-step `text.delete` loop -> + exact-byte/one-undo test leaves replacement text or extra undo steps. +7. Remove the E21b surrounding-text adapter or emit display-bidi text -> logical/bounded + outgoing-text test fails. +8. Restore `rtl.rs`'s manual reorder alongside fork E5/E12 -> RTL differential test shows + double processing/double alignment. +9. Restore `Id::new`/`Ui::id` -> `clippy -D warnings` fails, proving the warning gate. +10. Reintroduce `Event::Screenshot` scanning or a strong `Context` capture in the callback -> + compile/test or cancel/shutdown test fails. +11. Move web drop reading back into `WebShell::logic` -> browser drop test fails. +12. Remove the host first-pass guard -> host multipass test sees two frame counts. + +## 7. Performance measurement protocol + +### 7.1 Baseline timing and environment + +Record a baseline **before** changing production code, from the exact current SHA +`e74208dd...`, lockfile, feature set and craft-ui revision. Run the candidate from the +exact migration revision. Use separate fresh processes for each sample; do not compare a +warm process with a cold one. + +The benchmark harness is test-only work and must exist in both baseline and candidate +trees. Stage it in a separate test-only commit, cherry-pick it into a read-only baseline +worktree at `e74208dd...`, and run the baseline first through `task.py`. It records +`Instant`-based raw samples around `run_ui`/operation boundaries; it must not use +`perf.frame_ms`/`now_ms()` (`SystemTime` on native, `0.0` on wasm) as the acceptance timer. +`ui.inspect` reports only the last frame and `ui_shot` prints control replies, not per-frame +timings; neither is a sample source. + +Record with every result: +- source SHA (baseline/candidate/stock-0.37 control), `Cargo.lock` SHA-256, feature list, + `CRAFT_FONTS_DIR` and craft-fonts ref, fixture name/hash; +- OS and version, CPU/GPU, driver, backend (`Vulkan`/`DX12`/`Metal`/`GL`/`WebGPU`), + renderer type (hardware vs WARP/llvmpipe), display scale, window size, pixels-per-point; +- profile (`release` unless a debug-only diagnostic is named), warmups, repetitions, + raw samples, median/p95/p99, peak RSS, allocations, texture upload bytes and idle + CPU/repaint count. + +Host capacity/qualification facts: read-only inspection finds an i915 PCI `8086:3E96` +render device and a readable/writable `/dev/dri/renderD128` to the parent; the worker +sandbox may hide it. No display session or compositor is inherited. Availability is not a +qualified Vulkan/OpenGL/native compositor result; the managed renderer probe must identify +the actual backend. The Windows baseline is software-rendered (Microsoft Basic Render +Driver). A 128 GiB Windows full clone may not fit current capacity; use an allocated +baseline-backed overlay with an adequate reservation or retain the platform row as blocked. +macOS and physical Windows/audio remain separate rows. + +### 7.2 Operations + +1. **Startup**: at least 20 fresh processes; measure wall/`Instant` from process start to + first presented frame and to first interactive sample document. Report RSS and the + #8512 system-font discovery/first-missing-glyph component separately. The #8736 DX12 + renderer/shader/pipeline construction is a separate Windows row, never a UI frame. +2. **Idle UI**: open the sample, leave it untouched for 60 s, count frames/repaints and CPU. + No continuous repaint storm; the font scan's 250 ms poll must stop once cataloged. +3. **Frame distribution**: at least 1,000 individual frames per configuration, with at + least 10 warmups and raw per-frame samples; report p50/p95/p99. Run the sample, the Tools + panel two-column state and a floating dock. Record shapes, vertices/indices, texture + set/free count/bytes, galley cache size and requested-repaint count. +4. **Steady operations**: at least 30 timed iterations per operation after 10 warmups: + layout, panel open/float/dock/activate latency, canvas zoom/pan/damage patches, + selection and caret movement, text layout/tessellation. +5. **Text tessellation #8731**: fixed 120-label ASCII scene plus a mixed/color-glyph + control; time `Context::tessellate` separately for 0.36, stock 0.37 and the final fork. + If the ~60% plain-text regression reproduces, require a color-safe row-level fix or a + documented exception; clamp-only is not a fix. +6. **#8736 DX12**: Windows clone; time renderer/pipeline/shader construction separately from + first frame, with predictable filtering disabled/enabled as applicable. Record adapter + and driver. +7. **#8512 fonts**: cold process with bundled fonts, with `CRAFT_FONTS_DIR` and with a + test-only system provider fixture; compare discovery/startup and glyph coverage. Do not + enable the system provider in production to win the measurement. +8. **#8763/#8764 selection**: select text then click a toolbar/context action; record + command latency and assert the intended range/undo. This is correctness evidence; the + tail timing is a separate sample. +9. **#8748 hidden child**: open/update/close the second viewport while the parent is + hidden/occluded/overdue; record first visible child frame and close acknowledgement. +10. **Screenshots**: visible and hidden `ui.screenshot` requests; record reply latency, + file size, retained bytes, cancellation/timeout and late-callback cleanup. +11. **Memory**: RSS after sample load, after 10 pan cycles, after 30 screenshots and after + a drain/shutdown; retained image bytes must return to zero. +12. **Stock-0.37 control**: where the operation exists unchanged on stock 0.37, run the same + minimal adapter/harness against 0.36, stock 0.37 and the final fork; explain semantic + differences. Unavailable equivalence is an attribution limitation, not proof an + upstream regression is unavoidable. + +Use matched commands and the same fonts/fixture for baseline and candidate. Do not report +`cargo build` time as UI performance. + +### 7.3 Thresholds and noise policy + +Predefine before looking at candidate numbers. The shared suite floors are minimums; an app +may tighten, never loosen: + +- UI frame median: candidate <= baseline + 5%; p95 <= baseline + 10%; p99 (>=1,000 frames) + <= baseline + 15%. +- Canvas render median: <= baseline + 5%; p95 <= +10%; patch count no increase beyond + fixture determinism. +- Idle CPU: <= baseline + 20% and no more than one extra repaint per second after fonts + are ready; zero paints in 2 s after settle. +- Startup: <= baseline +5% median / +10% p95, with the #8736 DX12 shader-compilation component reported + separately. A #8736-sized regression needs the upstream/fork fix or explicit approval. +- Screenshot reply p95: <= baseline + 10%; retained bytes bounded and zero after drain. +- Peak RSS: investigate a repeatable increase over +5% or +16 MiB, whichever is reached + first; report absolute and percentage. (Use the app's existing stricter budget if one is + found.) +- Text tessellation: if the isolated probe reproduces #8731's ~60% regression, require a + color-safe row-level fix or a documented exception; never accept a clamp-only shader + change. +- Sub-millisecond operations: the relative increase must also exceed 0.05 ms or be + corroborated by stable instruction/allocation counts. + +Sample counts: at least 10 warmups and 30 timed iterations per steady operation; at least 20 +fresh processes for startup; at least 1,000 individual frames for frame distributions. Use +five independent sessions of at least 200 frame samples for tail percentiles, and pool the +raw samples for p99; do not report p99 from 30 samples. Alternate baseline/candidate rounds; +discard a session if a build/test/profiling process competes for CPU or the coefficient of +variation of medians exceeds 3% (5% for microbenchmarks), then rerun. If the difference is +within 2×MAD of paired samples, call it equal and do not chase it. Re-run and profile before +filing or accepting a regression. Never auto-update a snapshot or lower a threshold to hide +a timing or geometry change. + +## 8. Staged implementation PRs and rollback + +The dependency DAG is: + +```text +plans land -> baseline harness + measurements +completed winit -> egui P8 (P2b/P7/P7b/P7c included) + -> craft-libs CL-1 -> CL-2 reviewed capture prototype + -> DesignCraft D1 / GridCraft G1 pilot adapters -> CL-2F API freeze +DesignCraft D1 -> D2 lifecycle -> D3 extended native/language qualification +craft-libs CL-3 + D1 -> D4 docking/accessibility +D2/D3/D4 -> D5 visual/workflow acceptance -> D6 final platform/performance evidence +``` + +- **D1 — atomic final-fork compatibility pilot.** Pin the complete final storytold + egui/winit graph and reviewed craft-ui CL-1/CL-2 prototype; regenerate the lock. + Apply roles/IDs/FontData/wheel changes, explicit native clipboard and test fonts, + strict minimum names/glyphs, the shared capture-slot adapter and its cancellation/ + bounds/wake tests, parent-salted test lookup keys, and the required E21/E21b custom + editor adapter. Remove `rtl.rs` duplicate visual reorder/alignment as the final fork + becomes the bidi owner. Preserve app-owned UiState/docking preferences; no invented + egui-memory migration. Gate: cargo xtask ci/wasm, UI/native tests, one-family graph, + clean locked fetch, capture and IME contract tests, clipboard and save/reopen. This + reviewed pilot plus GridCraft's adapter permits CL-2F; stock 0.37 is an isolated + benchmark/control workspace, never a production handoff. Rollback restores complete + e74208dd source/manifests/lock/executable and prior preferences. +- **D2 — frame stages and hidden input.** Add the narrow once-per-layout-frame guard, + host pacing/queue separation, monotonic deadlines, buffered synthetic-input policy + and per-step hidden paint. The shared capture API is already adopted in D1; extend + hidden/multipass/restore stress tests here. Do not replace it with a local interim + slot. Revert D2 as a behavior/test unit only if D1's compatibility checks still pass. +- **D3 — extended native/language qualification.** After D2 and CL-2F, use the same + complete final fork and frozen helper; qualify E4/W15P, P7 key release, E21/E21b and + P7c selection/hidden-child behavior on native platforms and add language policy as + required. Required IME/bidi API adaptations already land in D1. Rollback removes + only these later policy/test changes; it never strips fork patches while retaining + fork-only source. A dependency rollback restores the full D1 predecessor set. +- **D4 — accessibility and glyph contracts.** Add the remaining names/roles, full role + file list, explicit `DockArea::direction(LeftToRight)`, and coverage tests. Gate: + `unnamed_widgets()` empty, explicit role/label queries, glyph coverage with and without + craft-fonts, direction geometry/hit/drag and saved-layout tests. Rollback: revert the + label/direction changes; no document or command behavior changes. +- **D5 — visual/geometry baselines and workflows.** Review every intended 0.37 visual + delta, update fixtures only with explanation, add the save/reopen/dock/popout, native + X11/Wayland, Windows VM and browser workflow tests. Gate: geometry/hit tests, pixel + comparisons, deliberate broken-behavior probes. Rollback: restore the previous reviewed + baselines; production remains on D3/D4. +- **D6 — qualification and performance.** Windows VM, Linux native, browser, macOS gate, + paired performance protocol with the stock-0.37 control. No production behavior change + unless a measured regression requires a separately reviewed fix. Rollback: no code + rollback expected; keep the evidence and the old dependency set until acceptance. + +Each PR must be one coherent outcome, use one task id per commit, and carry the validation +results at the reviewed revision. No PR is published without fresh direct human approval. + +## 9. Risks, unresolved questions and blocking acceptance criteria + +### Blocking unknowns + +1. **Final dependency publications.** The final reviewed `storytold/egui` and + `storytold/winit` revisions do not exist yet. Candidate `aiongg` hashes are comparison + inputs only. D1 cannot ship until the complete P8 handoff and winit revisions are published with E1–E21, + E21b, E4/P2b, W1–W27 and W15P, and a clean checkout fetches them `--locked`. +2. **craft-libs CL-1/CL-2F revisions.** The 0.37-compatible craft-ui revision must be the + `8614e4a` port onto current `4919088`, preserving `d66e345`/`fd9cf54`, `crates/platform` + and unrelated fixes. PR199's `b68de8c`/`ec8840a` trees are not bases. The capture API + freezes only after the DesignCraft/GridCraft adapter review. +3. **Clipboard/PRIMARY.** D1 enables `eframe/clipboard` to preserve OS Copy/Cut/Paste. D3 + needs E4/W15P native evidence on X11 and Wayland (wlroots/KWin/Mutter): pointer- + and keyboard-triggered ownership including keyboard-only focus, serial freshness, owner/seat changes, empty/absent offers, self-paste, canceled/ + slow transfers, password exclusion, no unchanged-selection reclaim and no second regular + data device. A compile-only or X11-only result is not acceptance. +4. **Windows GPU fallback.** E2/E3 turns some egui-wgpu configure/renderer failures into + `WgpuError` values rather than panics. DesignCraft's `gpu::finish` already handles + `eframe::Error::Wgpu(_)`, but the exact startup fallback behavior must be tested on the + Windows clone with a deliberately failing adapter/backend. Do not claim it from unit + tests only. +5. **macOS native evidence.** macOS menu shortcuts, press-and-hold IME, `deleteSurrounding` + bytes, `movable_by_window_background`, Dock Quit and viewport screenshots need real + hardware or remain explicitly unresolved. A software/VM screenshot is not native + evidence. +6. **`#8731` text tessellation.** The upstream report is a measured regression, not a + DesignCraft result. If the candidate reproduces it, a color-safe fix (row-level branch) + or an explicit exception is required before accepting the performance row. +7. **`#8736` DX12 startup.** The shader-compile cost is a Windows startup risk. Measure it + separately and do not hide it inside a UI frame average. +8. **`#8748` hidden-parent/deferred-child.** Closed and unmerged. The final fork must own a + tested solution; DesignCraft's immediate second viewport must open/update/close while the + parent is hidden/occluded/overdue and while visibility is unknown. +9. **Accessibility scope.** The exact number of unnamed inputs will only be known after + D1's first strict `Harness::check_accessibility()` run. D1 must fix the existing test + surface or record an explicit per-test policy; D4 completes the remaining controls. If + the D4 remainder is large, split "core chrome/panels" and "dialogs/rare panels"; do not + disable the check globally. +10. **Text selection #8763/#8764.** The source audit finds no control that reads an egui + `TextEdit` selection in the same pass, so no app workaround is planned. The fork owns + the upstream fix. If implementation finds such a control, carry #8764 or defer the + command with a tested app change; do not hide it in the test. +11. **E21b/P7b.** The incoming E21/W27 path is not full surrounding-text integration. + DesignCraft's canvas needs a real logical model/preedit/UTF-8 adapter, bounded at 4000 + bytes and cleared on focus/password/no-IME; `None` placeholders are not acceptance. + Native Wayland IME trace remains required. +12. **DockArea direction.** CL-3's opt-in direction is a visual policy, not a schema change. + DesignCraft passes `LeftToRight` explicitly for now; saved physical splits/rectangles + and logical tab order must remain unchanged. No display-bidi text reaches shaping or + clipboard. +13. **Platform capacity/qualification.** The parent can see i915 PCI `8086:3E96` and + `/dev/dri/renderD128`; the worker sandbox may hide it, and no display session/compositor + is inherited. Windows needs a uniquely owned clone/overlay within allocator capacity + (a 128 GiB full clone may not fit). Browser WebGPU/WebGL and real native X11/Wayland + workflows need provisioning. These rows remain blockers until executed; availability + alone is not a pass. +14. **Capture API freeze.** CL-2F must be reviewed after both adapters. A strong `Context` + capture, unbounded pending images, a late file write/reply or a lock held across wakeup + is a blocking failure, not a follow-up. +15. **`Cargo.lock` coherence.** Regenerate, never hand-edit. A lock with both 0.36.2 and + 0.37, a candidate `aiongg` source, or a machine-local path is an immediate failure. + +### Non-blocking but important + +- Style default changes may move toolbar/panel geometry even with explicit tokens. +- `egui_kittest` 0.37's missing-glyph panic can make an unrelated test fail first; fix the + fixture/name or use a named per-test allow with a reason, not a global tofu/disable switch. +- The web build's new glyph rasterizer can change emoji/fallback rendering. Verify with + WebGPU and `?webgl`, and keep texture filter/wrap expectations. +- `Cargo.lock` must be regenerated through the managed workflow, not hand-edited. +- The `designcraft-testkit` workspace dependency points at a missing `crates/testkit` in + the baseline; it is unused, but the dependency audit must not mistake it for an egui + consumer. Do not add or remove it as part of this migration. +- Saved docking direction must never be implemented by reversing saved panel order or + serialized tab order. + +## 10. Acceptance checklist + +- [ ] Exact baseline SHA and dependency revisions recorded; old 0.36.2 set retained for + rollback. +- [ ] All 22 manifests, root lockfile, root patch section, build scripts, `ui_shot`, + platform cfgs and CI workflows audited. +- [ ] Exactly one egui family and one winit resolve from the final reviewed storytold + revisions; no registry 0.36.2, candidate `aiongg` source or machine-local path remains. +- [ ] Clean checkout passes `cargo fetch --locked` and `cargo metadata --locked`. +- [ ] `cargo xtask ci`, `cargo xtask wasm`, `cargo test -p designcraft-ui-egui --lib`, + `trunk build --release`, native Windows/Linux and browser workflows pass at the + reviewed revision. +- [ ] `App::logic`/`App::ui` root-`Ui` flow preserved; native and web per-pass logic tested; + no recursive `root_ui`; multipass, repeated hidden ticks, forced hidden paint and + host counters tested; visibility is not used as a phase. +- [ ] No egui PanelState persistence is invented; parent-salted test keys, panel order/rects and app-owned workspace + restore. +- [ ] Screenshots use the craft-ui completion slot: weak callback, viewport-aware wakeup, + one-shot, timeout/cancel/close, out-of-order, bounded aggregate bytes and no strong + `Context` cycle. +- [ ] Commands, undo transactions, save/reopen, workspace persistence, clipboard privacy + and native Copy/Cut/Paste are unchanged. +- [ ] E4/W15P PRIMARY is evidenced natively on X11/Wayland, or explicitly unresolved; + password/unchanged-selection policy holds. +- [ ] E21b outgoing surrounding text is logical, bounded at 4000 bytes, preedit-excluded and + cleared on focus/password/no-IME; no `None` placeholder. +- [ ] Fork E5/E12 is the only bidi owner for egui UI/label text (the document composer + remains the document-text owner); no display-bidi text reaches UI shaping or clipboard. +- [ ] Accessibility role/name assertions pass under strict kittest; glyph coverage is + explicit with and without craft-fonts. +- [ ] Visual baselines have explained intended deltas; geometry/hit/direction/dock tests + pass; #8763/#8764 and #8748 tests have the documented expected outcome. +- [ ] Paired performance results meet the predefined thresholds with >=10 warmups/30 + steady iterations/20 fresh startups/1,000-frame distributions and a stock-0.37 + control, or every regression has a reviewed fix/exception. +- [ ] macOS, real-GPU, Wayland/GNOME and non-Latin-layout gates are either evidenced or + listed as unresolved; no result is implied from a compile or software renderer. + +## 11. References + +Primary source inspected locally (read-only; no checkout switch): + +- egui upstream `0.37.0` = `841a328ba2dd8f6996beb7189ebb7fc00e668a2a`: + `crates/egui/src/data/input/event.rs`, `data/input/mouse_wheel_source.rs`, + `data/input/viewport_info.rs`, `data/screenshot_callback.rs`, `viewport.rs`, `context.rs`, + `containers/panel.rs`, `id.rs`, `ui.rs`, `data/output.rs`, `accessibility.rs`, + `input_state/mod.rs`; `crates/epaint/src/text/font_definitions.rs`, + `crates/epaint/src/text/font_data.rs`; + `crates/eframe/src/{epi.rs,native/epi_integration.rs,native/wgpu_integration.rs,web/app_runner.rs}`; + `crates/egui-winit/src/{clipboard.rs,lib.rs}`; `crates/egui-wgpu/src/{setup.rs,winit.rs}`; + `crates/egui_kittest/src/{builder.rs,accessibility.rs}`. +- Candidate fork for comparison: `aiongg/egui` `craft/0.37` = + `a4f9a17f64a6bae61350fb85804c9df0d9865916`, including `PATCHES.md` (E1–E21; E21b/P7b + still to be added). This is not the production pin. +- Candidate winit for comparison: `aiongg/winit` `craft/0.30.13-lang` = + `8b35388bd719e8cc86a95971ba0bc026c9a8f058`, including `PATCHES.md` (W1–W27; W15P still + to be added). This is not the production pin. +- `storytold/craft-libs` PR 199 head = + `b68de8c5f4f1c060904d211a7b1320aa0f7bce78`; audit merge + `ec8840af8c5fda695cf195cdd05e5ae078415c07`; API-port source + `8614e4a04fae516fba16c22b31ff1008cb6be10b`; current craft-libs baseline + `49190884278f16c9a9f5823d7698a8ffb5deb062`. +- `storytold/egui` published 0.36.2 = + `767b1cad294120689a514bb4bd68c077e46679b9`; `storytold/winit` published 0.30.13 = + `ffae5198770d7730e718043f035a772b7dbc3b1b`. +- DesignCraft baseline = + `e74208dd7e0c801d15dfdf926d64cfe5e3b346d3`. +- Lifecycle evidence: + `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lifecycle-source-audit.md` + (native/web per-pass logic, zero pass index after completed `run_ui`, `visible()` is not a + phase). +- IME evidence: + `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/ime-surrounding-gap.md` + (E21b/P7b gap and custom-editor inventory). +- Shared suite contract: + `/home/codex/work/CraftApps/egui037-migration-20261022/craft-libs/docs/egui-037-suite-rollout.md` + (completion slot, DockArea direction, performance sample counts, stock-0.37 control, + platform evidence) and `craft-libs/docs/egui-037-migration-plan.md` (§7.7, §7.8, §10). +- Font fixtures: `egui/docs/egui-037-migration-plan.md` §10.6 (craft-fonts `2481a8a`, + Noto Sans Arabic/Thai/CJK, BIZ UDPGothic, Noto Sans Hebrew, Noto Color Emoji; exact + SHA256/licences). No font binaries are added by this plan. +- Historical diagnostics: + `/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/designcraft.{errors,log,prepare.diff}`. +- Upstream reports and notes reviewed through the local snapshot + `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/upstream-discussions.json`: + [#8731](https://github.com/emilk/egui/issues/8731), + [#8736](https://github.com/emilk/egui/issues/8736), + [#8763](https://github.com/emilk/egui/issues/8763) / PR + [#8764](https://github.com/emilk/egui/pull/8764) (unmerged), + [#8748](https://github.com/emilk/egui/pull/8748) (closed, not merged), + [#8631](https://github.com/emilk/egui/pull/8631), + [#8535](https://github.com/emilk/egui/pull/8535), + [#8489](https://github.com/emilk/egui/pull/8489), + [#8555](https://github.com/emilk/egui/pull/8555), + [#8583](https://github.com/emilk/egui/pull/8583), + [#8512](https://github.com/emilk/egui/pull/8512), + [#8428](https://github.com/emilk/egui/pull/8428), + [#8657](https://github.com/emilk/egui/pull/8657), + [#8577](https://github.com/emilk/egui/pull/8577), + [#8641](https://github.com/emilk/egui/pull/8641). + +These links identify the behavior and reports inspected. They are not fresh DesignCraft +validation, and an upstream status may have changed since the snapshot. + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +Canonical `storytold/designcraft` `main` now resolves to `2e36dcf909627c8f0e5662fe6b4825b1c1c24420`. The owned checkout was fast-forwarded to this revision. Earlier baseline SHAs and line anchors above are retained as historical review evidence; use this new revision and its exact lockfile for current-main integration and paired measurements. The original refs remain reachable. + +Preserve browser page zoom and canvas touch-action:none alongside packaging fixes. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + +### Local pinned font inputs (2026-10-23) + +Parent fetched and verified all eight manifest fonts at `storytold/craft-fonts` +`2481a8a324b986a45954e90045ed1f8f2efaaf3c` into +`/home/codex/work/CraftApps/egui037-migration-20261022/fixtures/craft-fonts`. +Use this exact directory as the optional `CRAFT_FONTS_DIR` build input. All +manifest SHA256 hashes match; licenses retained. This does not waive the +required no-corpus test configuration. The separately pinned Hebrew fixture +is at `/home/codex/work/CraftApps/egui037-migration-20261022/fixtures/noto-sans-hebrew/NotoSansHebrew.ttf` +with adjacent OFL.txt/provenance.json. Keep these fonts out of app source; +required test fixtures may use an explicit external input with verified hash +and clear missing-input behavior, never silently disable glyph checks. + +## Final local landing record — 2026-10-25 + +This section records the current local landing state. Everything above remains historical planning and +implementation evidence: the older candidate hashes, baseline anchors and "planning only" wording +describe earlier states and are not the current pins. No commit, index write, ref update, fetch, +push, pull request, comment, merge, build, fmt, test, Cargo, VM, resource or cleanup action was +performed while writing this record; the qualification results below were produced separately by +the parent through the managed `task.py` workflow and are cited as evidence, not rerun here. + +### Current dependency pins + +| Item | Revision | +| --- | --- | +| egui family (`egui`, `epaint`, `ecolor`, `emath`, `eframe`, `egui-wgpu`, `egui-winit`, `egui_kittest`, `egui_extras`, `epaint_default_fonts`) | `storytold/egui` `6d518fc799681571963842f95b55a6b68431b15a` | +| winit | `storytold/winit` `a517b3846ee8457d84ee4975110bfda6797c44dc` | +| craft-libs (`craft-ui`, `craft-print`) | `storytold/craft-libs` `25da1314d443171b4adeaad2cd9454b273f35c6f` | +| CI craft-fonts | `storytold/craft-fonts` `b076417b5880bd4154bd22f00695b1d28c6bea83` | + +`Cargo.toml` and `Cargo.lock` resolve one egui family and one winit from these storytold revisions; +the lock has no registry 0.36.2 egui family and no candidate `aiongg` source. `crates/fonts/build.rs` +continues to treat craft-fonts as an optional `CRAFT_FONTS_DIR` input, not a manifest dependency. + +### Exact dirty union + +| Item | Value | +| --- | --- | +| Checkout | `/home/codex/work/CraftApps/egui037-migration-20261022/designcraft-implementation` | +| Git worktree HEAD (unchanged) | `80d13911b2d20fbb5cd9398696627bb3a715417b` | +| Earlier integrated main base in the dirty state | `d8d78851729a2c1a10e5bfad7c591f3de78651a0` | +| Imported new main | `45ed6cfce0f86e59ff66ab18a05c3ec03ccef531` (`refs/migration-observations/20261024-1610-main`) | +| New-main distance from the earlier base | 141 commits, 127 changed paths | +| Files in the union | 619 = 613 tracked + 6 untracked | +| Worktree status | 186 entries = 180 modified + 6 untracked | +| Merge state | no `MERGE_HEAD`; index empty; no conflict markers; `git diff --check` clean | + +The six untracked paths are exactly: + +- `crates/compose/src/placed.rs` +- `crates/idml/src/tests/facing_ltr.rs` +- `crates/idml/src/tests/frame_grid.rs` +- `crates/ui-egui/src/page_history.rs` +- `crates/ui-egui/src/panels/story.rs` +- `docs/egui-037-migration-plan.md` + +The 16:28 reconciliation packet recorded 185 status entries: the 92 pre-existing dirty paths plus the +127 imported paths with 34 overlaps. The current tree adds one later modified tracked path, +`apps/designcraft/src/native_menu.rs` (the native-menu/action host-order repair), for 186. Of the 127 imported paths, 103 were adopted whole +and 24 were reviewed 3-way content merges; later reviewed deviations are the Windows-only test fixtures +in `apps/designcraft-cli/tests/it/cli.rs` and `crates/engine/src/file_access.rs`, the documented +`Id::new` → `Id::unique` port in `crates/ui-egui/src/panels/story.rs`, and the host-order changes above. + +`80d13911` is an ancestor of `45ed6cf`, so an ordinary merge would fast-forward. The eventual manual +union commit therefore needs both parents, `80d13911b2d20fbb5cd9398696627bb3a715417b` and +`45ed6cfce0f86e59ff66ab18a05c3ec03ccef531`, to preserve both the migration branch and the imported +main history. No such commit exists yet. + +### Review-1643 findings on the current source + +Both actionable defects from the 16:43 review are fixed in the current source: + +- **Document generation identity.** `InputGeneration` and `ScreenshotGeometry` now compare + `DocState::identity_epoch` through `active_document_epoch()` (`crates/ui-egui/src/lib.rs`), so a + `file.revert` that preserves the UID still invalidates buffered input, native drops and admitted or + in-flight screenshots. Regression tests cover a buffered input batch, a buffered native drop and + screenshots before issue and in flight; all pass in the Linux full-CI job below. +- **NumField disabled presets.** The preset segment now uses `Sense::hover()` and reports + `self.enabled == false` when the field is disabled, does not open its menu, and dims its chevron; + `disabled_numeric_presets_are_disabled_and_do_not_open` passes. +- The earlier `panels/properties.rs` evidence mismatch is also closed: the current file is + `9515f944ddbbf61bb2099324ac6825fd7603262adc605686c2a932948a81f768`, matching the later + Windows-qualified manifest and the current worktree. + +No other real source defect was found in this bounded audit. Residual items below are qualification +limits, not claimed defects. + +### Current qualification evidence + +- **Linux full CI** `egui037-designcraft-final-characterization-ci-20261025-0050`: all 9 steps + passed (fmt, pdf-deps, clippy, test, i18n-report, doctest, assets, layers, wasm); 77 result + summaries total **3230 passed, 0 failed, 6 ignored**. The job's captured unstaged patch is + byte-identical to the current tracked worktree diff, so it qualifies the current tracked source. + The run includes the corrected canvas characterization test over three batch shapes and the + discard/no-discard matrix. +- **Windows qualification** job 102: all 9 steps passed, **3198 passed across 77 result summaries** + on the 619-path source manifest `5208ddb75ad8d06a48c334b48e5e80dc193ae2b9739b119dd50942aa3ebd5a40`. + Exactly two test-only Windows fixture paths differ from the applied prior manifest + (`apps/designcraft-cli/tests/it/cli.rs`, `crates/engine/src/file_access.rs`); the engine production + prefix is unchanged. Qualified desktop executable + `a34478f09ccae2571320b3ada489665adae450031ca24918ec4e8b165edfc47a`; qualified CLI + `318ec6cb7521f941aaab01a63b2c0f970bd70bfb50e5545aa645f63d1589cbee`. +- **Native Windows GUI** `native-gui-2238`: edit, undo, redo, selection, save and reopen were captured + with document, UI and window evidence; the native input diagnostic ordered three production + click/End replays on the qualified executable, each reaching click caret 6 and End caret 12, with + all three text strings unchanged and the document clean. Six screenshots were reviewed, but the state JSON is the exact caret + oracle because a blinking caret can make two screenshots share pixels. +- **Native input diagnostic** `native-order-review0048.txt`: no application event loss is shown. The + trace records the End press/release pair arriving before the click pair; the app acted correctly + for the order it received. The temporary diagnostic build was fully restored to the exact 619-file + qualified source and binaries in the VM before the ordered production replays. +- **Exact-source boundary.** The Windows 619-path manifest predates the host-only canvas + characterization test. In the current tree, `crates/ui-egui/src/canvas.rs` differs from that + manifest only inside `mod tests` (the first difference starts at line 6016; the test module starts + at line 4046), so production bytes are unchanged. The Windows run did not execute that new test; + Linux full CI did. This document is amended after the Windows manifest, so the documentation file + is also not byte-identical to that earlier snapshot. + +### Remaining limits and regression risk + +The union includes 141 upstream main commits and 127 changed paths, only part of which is egui 0.37 +API work. It also imports page history/Go Back, the Story panel, notes and table work, optical-margin +options, linked-file read caps, SVG preflight, IDML facing/frame-grid tests, and other current-main +features. Review the 180-file tracked diff and the 24 manual overlap resolutions as product changes, +not as a mechanical dependency bump; the imported tests reduce but do not remove that risk. + +Still unqualified for this exact union: macOS native menus/IME/Apple events, real-GPU rendering and +the DirectX 12 startup row, native Linux X11/Wayland presentation, native Windows IME and clipboard/ +PRIMARY selection, physical-device input ordering, browser presentation beyond the wasm check, +paired performance against the thresholds in §7, and packaging/release artifacts. The Windows VM +uses a software renderer. Cross-device QMP command order is not a guest-delivery guarantee; the +ordered replay gates input on an observed click state rather than zero-delay host issuance. diff --git a/docs/gaps.md b/docs/gaps.md index 75befc060..ad56bfc37 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 16: Convert Variable to Text; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -177,9 +177,12 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 30–50 h. ### 16. Menu items still missing (U) -- **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes +- **Missing:** 149 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes +- **Missing:** 150 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear + Effects / Clear All Transparency / Rotate 180° / Reverse Path, + update commands, Convert Bullets/Numbering to Text, Object ▸ Clear Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward, Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User @@ -202,6 +205,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · - **Estimate:** 20–35 h. ### 19. Placed graphics depth (FF) +- **Safety:** PDF visibility variants preserve catalog/configuration entries and nonzero-generation + references, redefining the existing catalog rather than choosing an arbitrary object number. + Encrypted, hybrid-reference and over-budget rewrites are rejected; no print-fidelity claim. - **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer comps beyond visibility; the Clipping Path Options dialog (tracing into a clip that keeps the frame, Threshold/Tolerance/Inset Frame preview, picking an alpha channel or a path saved in a diff --git a/docs/mcp.md b/docs/mcp.md index 741174e13..581736489 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -88,6 +88,8 @@ claude mcp add designcraft -- designcraft-cli mcp ```sh designcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +designcraft-cli mcp --automation-read-root /work/project --automation-read-root /work/shared-assets \ + --automation-write-root /work/project/out --automation-write-root /tmp/scratch ``` ```json @@ -98,16 +100,16 @@ designcraft-cli mcp --automation-read-root /work/project --automation-write-root The two flags (VectorCraft, PhotoCraft and the other Craft apps take the same ones, so one client config works across the suite) keep the agent to the folders you give it: -- Every file a command reads must lie inside the **read root**: `open_document`, `file.open`, +- Every file a command reads must lie inside a **read root**: `open_document`, `file.open`, `file.openIdml`, `place_image` and `file.place` / `place.load` / `media.options` with a `path`, `links.relink`, `links.relinkFolder`, `color.loadProfile`, `swatch.load`, `library.open`, `book.open`, `data.source.select`, `data.merge` and `file.importXml` / `xml.loadDtd` with a `path`, and `file.recovery.list` / `file.recovery.open` with a `dir`. A DesignCraft document names more files than the agent does: linked images, data sources, book chapters, the `Document Fonts` folder beside a document, an IDML package's linked images and the files an image cell of a merge names. - Those are read through the same checks, so a path in a document that leads outside the read root + Those are read through the same checks, so a path in a document that leads outside the read roots reads as a file that isn't there. -- Every file a command writes must lie inside the **write root**: `save_document`, `export_png`, +- Every file a command writes must lie inside a **write root**: `save_document`, `export_png`, `render_page` with a `path`, `file.save`, `file.saveAs`, `file.saveACopy`, `file.exportPdf`, `file.exportEpub`, `file.exportFixedEpub`, `file.exportHtml`, `file.exportText`, `file.exportXml`, `file.exportIdml`, `file.printBooklet`, `snippet.export`, `swatch.save`, `library.new` and the @@ -115,21 +117,26 @@ config works across the suite) keep the agent to the folders you give it: `file.package`, `links.copyTo` and `file.recovery.save` / `file.recovery.open` with a `dir` (reopening removes the entries it read). `file.package` and `links.copyTo` check their folder before they create anything. -- Read and write access are separate. Give the same folder twice for both. A root left out grants - none of its access: with only `--automation-read-root` nothing can be written to disk. Without - either flag nothing changes. +- Each flag may be given more than once (#1552): a path inside any of the read roots may be read, + one inside any of the write roots written, so one session can read a project folder and a shared + assets folder, or write to an output folder and a scratch folder, without widening a root to + their common parent (which the roots don't grant). A folder given twice counts once. +- Read and write access are separate. Give the same folder to both flags for both. A kind left out + grants none of its access: with only `--automation-read-root` nothing can be written to disk. + Without either flag nothing changes. - A path is made absolute against the server's working directory, then its links are followed: a - file (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, + file (or folder) link that leads out of the roots is outside them. For a file that doesn't exist yet, its nearest existing folder's links are followed and the rest must be plain names, so `..` below a folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would - create its target). The result is compared with the root by whole folder names: `/work2/x` is not + create its target). The result is compared with each root by whole folder names: `/work2/x` is not inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused. - A refused path is an ordinary tool error the agent can read and correct, for example - `automation path rejected: outside the write root /work/project/out: /etc/x.designcraft` or + `automation path rejected: outside the write root /work/project/out: /etc/x.designcraft` (with + several, `outside the write roots /work/project/out, /tmp/scratch: ...`) or `automation filesystem access is not granted: write authority is absent: /work/project/out/x.designcraft`. -- The roots must be existing folders; a missing one stops the server with an error. Each flag is - given once (`--flag ` or `--flag=`). +- The roots must be existing folders; a missing one stops the server with an error. Each flag takes + one folder per use (`--flag ` or `--flag=`) and may be used again for more. The flags confine the in-process server, so `--connect` together with them is an error: the server would otherwise talk to an app that isn't confined. DesignCraft's desktop app has no equivalent flags diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md index 4cbc985d3..26d73aa77 100644 --- a/docs/pdf-backend.md +++ b/docs/pdf-backend.md @@ -1,7 +1,7 @@ # Shared PDF backend -DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the four Hayro reader crates to -craft-libs revision `89dcbb3060377a26c99a6d80caa16084366704c4`. Existing `craft-ui` and `craft-print` revisions +DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the five Hayro reader crates (with +`hayro-cmap`) to craft-libs revision `9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef`. Existing `craft-ui` and `craft-print` revisions remain unchanged. The reader migration is reviewed on top of the separate writer change. ## Writing @@ -49,6 +49,12 @@ are recorded separately in ASSETS.md. DesignCraft retains placement, layer-name policy, document state and the application render cache. Render output is premultiplied RGBA8. Page boxes use the same crop, rotation and UserUnit transform as raster output, including fractional placed-PDF dimensions. +Layer choices are validated for every selected PDF before any document/undo state changes; +a selection containing an unsupported or layerless PDF is refused rather than partially edited. +Stale persisted/relinked choices that cannot be honored suppress the graphic on canvas rather +than reveal hidden source content; resetting to file defaults remains available. All-content +cropping keeps the whole page when real optional-content state cannot be rewritten, while +layerless files and empty/null optional-content properties retain measured bounds. Layered-PDF export stops on failed layer rewriting, missing pages or reported incomplete content rather than saving an unchanged or incomplete placed page. Shared diagnostics only report known backend warnings; they do not certify all PDF fidelity. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ffb2ac993..511548246 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Convert Variable to Text; list buttons with the default hanging indent; custom anchored objects honour Relative to Spine; PDF/X-4 visible unembedded font detection, #909; autoflow fills the following pages first; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -204,7 +204,7 @@ table is the one summed. | Area | Breadth (checklist) | Ready | Hours | Main gaps | |---|---|---|---|---| | Application shell & workspace | 99% | 55% | 40–60 | context menus not opening on some systems; window controls (#333); System theme (#237); New Window/Arrange; Help menu | -| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, Go Back/Forward, layout grid (CJK) | +| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, layout grid (CJK) | | Layers | 100% | 75% | 5–10 | IDML layer order fixed 2026-10-09; Layer panel options depth | | Frames, shapes & paths | 100% | 60% | 25–40 | object copy/paste failures, reference point relocation (#337), Join/Reverse Path/Convert Point menu | | Transform | 100% | 70% | 10–20 | Rotate 180°; transform panel depth | @@ -324,7 +324,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Object ▸ Generate QR Code (`object.qrCode`: web link, text, SMS, email, business card; vector modules as a compound path, into a frame or re-encoded in place). - Conditional text (`condition.new/options/delete/apply/list`, Conditional Text panel): hidden conditions drop their text from layout and exports, indicators underline on screen, IDML Condition/AppliedConditions. - Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. -- Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported. +- Editorial notes (`note.new/edit/delete/convertToText/convertFromText/list`, Notes panel, Type › Notes › Convert to Note): anchored in text, flagged on screen, never printed or exported. - File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. - Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, CMYK spreads with separable blend modes use two ink-channel passes through the working colour settings. Parent-page items, nested groups, effects and pasteboard share that path; 100% K in Color Dodge whitens coloured areas while paper stays outside the blend backdrop. Effect blend layers sit outside their clips and offscreen filters, preserving their backdrop in RGB too. The pass count is independent of blended-object count, and unchanged viewports reuse one cached raster (64 MiB cap). Spreads containing Hue, Saturation, Color or Luminosity still use the existing RGB blend followed by a CMYK proof approximation; full nonseparable CMYK blending remains open. - High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). @@ -343,7 +343,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). - Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. Essentials Classic shows the Control panel and InDesign's iconic panel groups with names. The Window menu follows InDesign's: Arrange, Workspace, then the panels alphabetically with their Color, Editorial, Interactive, Object & Layout, Output, Styles, Type & Tables and Utilities groups. - Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting. -- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position. Their text wrap pushes aside the text of other stories they overlap and the lines of their own story after the anchor's line. +- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column, Relative to Spine (mirrored on right-hand pages); IDML Anchored position. Their text wrap pushes aside the text of other stories they overlap and the lines of their own story after the anchor's line. - Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF. - Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|trim|bleed|art|media}`, Place PDF dialog): the frame shows that page box. - Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). @@ -382,7 +382,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web). - Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole). - Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `