From 358614e1ff5cea5fe979a17be7fb5ed415d58e83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:53:02 +0000 Subject: [PATCH 01/50] ci(deps): bump Swatinem/rust-cache Bumps the actions group with 1 update in the / directory: [Swatinem/rust-cache](https://github.com/swatinem/rust-cache). Updates `Swatinem/rust-cache` from 2.9.2 to 2.9.3 - [Release notes](https://github.com/swatinem/rust-cache/releases) - [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md) - [Commits](https://github.com/swatinem/rust-cache/compare/6323deb102c322ba6fcbdcafc7e3dddab59af2b6...d1527c8f67ef9af357290475d4af4b0fab8f12e0) --- updated-dependencies: - dependency-name: Swatinem/rust-cache dependency-version: 2.9.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/printing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/printing.yml b/.github/workflows/printing.yml index 99aef710d9..e22a4b3688 100644 --- a/.github/workflows/printing.yml +++ b/.github/workflows/printing.yml @@ -55,7 +55,7 @@ jobs: components: rustfmt, clippy targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: save-if: ${{ github.ref == 'refs/heads/main' }} From fd071202e7b8fe387c5cfe1d9687036281aeaaba Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sat, 24 Oct 2026 08:30:31 +0200 Subject: [PATCH 02/50] E37: migrate WordCraft to egui 0.37 with guarded IME and capture lifecycle --- .github/workflows/ci.yml | 2 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- Cargo.lock | 1689 +++++++++++++++-- Cargo.toml | 29 +- apps/wordcraft-web/src/web.rs | 39 +- apps/wordcraft/Cargo.toml | 4 +- apps/wordcraft/src/main.rs | 27 +- apps/wordcraft/src/print.rs | 5 +- craft-i18n.rev | 2 +- crates/doc/src/ime_text.rs | 694 +++++++ crates/doc/src/lib.rs | 1 + crates/engine/Cargo.toml | 6 + crates/engine/src/cmd/equation.rs | 467 ++++- crates/engine/src/cmd/file.rs | 4 + crates/engine/src/cmd/mod.rs | 109 +- crates/engine/src/cmd/moves.rs | 2 +- crates/engine/src/cmd/text.rs | 77 +- crates/engine/src/cmd/tools.rs | 8 + crates/engine/src/lib.rs | 32 +- crates/engine/src/session.rs | 410 +++- crates/engine/src/tests.rs | 96 + crates/fonts/src/fontdb.rs | 11 +- crates/layout/src/para.rs | 85 +- crates/layout/src/tests.rs | 370 +++- crates/layout/src/tests_ligature.rs | 92 + crates/layout/src/tests_textbox.rs | 109 +- crates/ui-egui/Cargo.toml | 16 +- crates/ui-egui/examples/perf_probe.rs | 4 +- crates/ui-egui/examples/ui_shot.rs | 3 +- crates/ui-egui/src/a11y.rs | 3 +- crates/ui-egui/src/a11y_pane.rs | 4 +- crates/ui-egui/src/backstage.rs | 38 +- crates/ui-egui/src/canvas.rs | 1113 +++++++++-- crates/ui-egui/src/caret_glide.rs | 2 +- crates/ui-egui/src/chrome.rs | 34 +- crates/ui-egui/src/colors.rs | 12 +- crates/ui-egui/src/content_controls.rs | 2 +- crates/ui-egui/src/control.rs | 712 ++++++- crates/ui-egui/src/credits.rs | 2 +- crates/ui-egui/src/crop.rs | 2 +- crates/ui-egui/src/dialogs.rs | 184 +- crates/ui-egui/src/dialogs_chart.rs | 32 +- crates/ui-egui/src/dialogs_keyboard.rs | 5 +- crates/ui-egui/src/dialogs_lists.rs | 2 +- crates/ui-egui/src/dialogs_para.rs | 59 +- crates/ui-egui/src/dialogs_refs.rs | 107 +- crates/ui-egui/src/dialogs_review.rs | 12 +- crates/ui-egui/src/dialogs_smart_art.rs | 48 +- crates/ui-egui/src/dialogs_table_style.rs | 35 +- crates/ui-egui/src/equation_tab.rs | 48 +- crates/ui-egui/src/file_browser.rs | 19 +- crates/ui-egui/src/format_shape.rs | 18 +- crates/ui-egui/src/i18n/audit.rs | 4 + crates/ui-egui/src/ibeam.rs | 2 +- crates/ui-egui/src/ink.rs | 2 +- crates/ui-egui/src/keys.rs | 1531 ++++++++++++++- crates/ui-egui/src/keytips.rs | 10 +- crates/ui-egui/src/lib.rs | 870 +++++++-- crates/ui-egui/src/mini_toolbar.rs | 22 +- crates/ui-egui/src/options.rs | 77 +- crates/ui-egui/src/panes.rs | 10 +- crates/ui-egui/src/previews.rs | 26 +- crates/ui-egui/src/print_page.rs | 60 +- crates/ui-egui/src/printing.rs | 2 +- crates/ui-egui/src/read_aloud.rs | 7 +- crates/ui-egui/src/ribbon.rs | 55 +- crates/ui-egui/src/ribbon_fit.rs | 10 +- crates/ui-egui/src/ruler_tabs.rs | 19 +- crates/ui-egui/src/scroll.rs | 14 +- crates/ui-egui/src/table_ruler.rs | 4 +- crates/ui-egui/src/tests_perf.rs | 1 + crates/ui-egui/src/theme.rs | 14 + crates/ui-egui/src/widgets.rs | 48 +- crates/ui-egui/src/window_close.rs | 8 +- crates/ui-egui/src/window_geometry.rs | 179 +- crates/ui-egui/src/zotero.rs | 2 +- crates/ui-egui/tests/it/native_print.rs | 16 +- docs/egui-037-migration-plan.md | 1413 ++++++++++++++ docs/layout-parity.md | 13 +- docs/localization-parity.md | 3 +- docs/shared-pdf.md | 15 +- docs/typing-parity.md | 76 +- .../flatpak/ai.storyteller.wordcraft.yml | 2 +- packaging/linux/package.sh | 1 - packaging/windows/package.ps1 | 14 + packaging/windows/wordcraft.wxs | 11 + 87 files changed, 10201 insertions(+), 1230 deletions(-) create mode 100644 crates/doc/src/ime_text.rs create mode 100644 docs/egui-037-migration-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5cbc378e1..e6ba1400eb 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: 8dcdacd5153e64560d109541a47d806f26f048c0 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 91876c2917..05d6bbb7b1 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -28,4 +28,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: 8dcdacd5153e64560d109541a47d806f26f048c0 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a73f61245..74fdd7b65a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: wordcraft app-title: WordCraft - craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/Cargo.lock b/Cargo.lock index 3165d15b9e..c1a84cdf2b 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.3", + "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.3", "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", @@ -129,7 +124,7 @@ checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -452,12 +447,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" @@ -500,6 +531,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 = "block-buffer" version = "0.12.1" @@ -693,6 +733,17 @@ 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 = "chardetng" version = "1.0.0" @@ -710,7 +761,12 @@ 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]] @@ -719,7 +775,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common", + "crypto-common 0.2.2", "inout", ] @@ -732,12 +788,45 @@ 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 = "cmov" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[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 0.9.6", + "der", + "spki", + "x509-cert", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -793,6 +882,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 = "const-oid" version = "0.10.2" @@ -809,6 +904,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" @@ -822,7 +927,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", @@ -835,7 +940,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", ] @@ -845,21 +950,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - [[package]] name = "cpubits" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" +[[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" @@ -883,7 +988,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "krilla", "thiserror 2.0.21", @@ -892,19 +997,32 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" dependencies = [ + "craft-winprint-gdi", + "miniz_oxide 0.8.9", + "roxmltree", "tempfile", + "winprint", + "winsafe", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" dependencies = [ "egui", ] +[[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" @@ -914,6 +1032,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" @@ -954,6 +1084,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 = "crypto-common" version = "0.2.2" @@ -978,6 +1118,30 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "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" @@ -1002,15 +1166,25 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", "ctutils", ] @@ -1074,10 +1248,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", "emath", @@ -1085,9 +1264,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1109,6 +1287,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1120,9 +1299,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "accesskit", "ahash", @@ -1140,9 +1318,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1160,31 +1337,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", "egui", @@ -1196,9 +1376,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "dify", "eframe", @@ -1223,9 +1402,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", ] @@ -1273,9 +1451,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1291,16 +1468,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", ] [[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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" [[package]] name = "equivalent" @@ -1386,6 +1564,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611" +[[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" @@ -1398,6 +1586,12 @@ 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" @@ -1418,6 +1612,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" @@ -1501,6 +1717,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" @@ -1537,6 +1769,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" @@ -1550,12 +1788,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" @@ -1575,6 +1826,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" @@ -1596,8 +1860,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]] @@ -1621,22 +1888,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glifo" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" -dependencies = [ - "bytemuck", - "foldhash 0.2.0", - "hashbrown 0.17.1", - "log", - "peniko", - "skrifa 0.44.0", - "smallvec", - "vello_common 0.1.0", -] - [[package]] name = "glifo" version = "0.3.0" @@ -1651,7 +1902,7 @@ dependencies = [ "png", "skrifa 0.44.0", "smallvec", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -1755,6 +2006,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" @@ -1854,7 +2124,7 @@ dependencies = [ "hayro-syntax", "kurbo", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash 2.1.3", "siphasher", "skrifa 0.42.1", @@ -1903,6 +2173,12 @@ dependencies = [ "zune-jpeg", ] +[[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" @@ -1921,9 +2197,48 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", ] +[[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 = "hybrid-array" version = "0.4.15" @@ -1933,6 +2248,92 @@ dependencies = [ "typenum", ] +[[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 = "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" @@ -2100,6 +2501,12 @@ dependencies = [ "unic-langid", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-docker" version = "0.2.0" @@ -2232,20 +2639,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=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ - "base64", + "base64 0.22.1", "bumpalo", "flate2", "float-cmp", @@ -2256,7 +2663,6 @@ dependencies = [ "pdf-writer", "png", "rustc-hash 2.1.3", - "rustybuzz", "siphasher", "skrifa 0.42.1", "smallvec", @@ -2365,6 +2771,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" @@ -2389,6 +2801,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2409,6 +2827,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" @@ -2867,6 +3296,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" @@ -2956,6 +3391,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" @@ -2992,8 +3446,18 @@ 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", ] @@ -3004,7 +3468,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]] @@ -3013,8 +3487,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", @@ -3029,6 +3516,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 = "pin-project" version = "1.1.13" @@ -3174,6 +3670,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" @@ -3199,7 +3719,7 @@ dependencies = [ "bit-vec 0.8.0", "bitflags 2.13.2", "num-traits", - "rand", + "rand 0.9.5", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -3235,6 +3755,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" @@ -3263,7 +3840,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]] @@ -3273,9 +3861,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" @@ -3285,13 +3879,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]] @@ -3443,6 +4052,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 = "rfd" version = "0.17.2" @@ -3470,6 +4122,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3517,6 +4189,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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +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" @@ -3535,30 +4282,18 @@ dependencies = [ "wait-timeout", ] -[[package]] -name = "rustybuzz" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser", - "unicode-bidi-mirroring", - "unicode-ccc", - "unicode-properties", - "unicode-script", -] - [[package]] name = "ryu" 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" @@ -3568,6 +4303,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3580,6 +4324,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" @@ -3592,6 +4359,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" @@ -3635,6 +4408,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" @@ -3655,6 +4439,29 @@ 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 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -3662,8 +4469,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[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 0.10.7", ] [[package]] @@ -3673,8 +4491,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -3693,6 +4511,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 0.9.6", + "der", + "digest 0.10.7", + "pem", + "rand_core 0.10.1", + "sha2 0.10.9", + "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 0.9.6", + "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 0.9.6", + "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" @@ -3847,6 +4834,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" @@ -3856,6 +4853,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" @@ -3874,6 +4881,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[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", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -3886,6 +4914,22 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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" @@ -3908,6 +4952,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" @@ -3931,6 +4984,38 @@ dependencies = [ "web-sys", ] +[[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" @@ -4038,13 +5123,69 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +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.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" 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]] @@ -4098,6 +5239,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" @@ -4142,13 +5328,10 @@ dependencies = [ ] [[package]] -name = "ttf-parser" -version = "0.25.1" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -dependencies = [ - "core_maths", -] +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "type-map" @@ -4206,18 +5389,6 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" -[[package]] -name = "unicode-bidi-mirroring" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" - -[[package]] -name = "unicode-ccc" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" - [[package]] name = "unicode-general-category" version = "1.1.0" @@ -4260,6 +5431,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" @@ -4289,21 +5472,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vello_common" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" -dependencies = [ - "bytemuck", - "fearless_simd 0.4.1", - "guillotiere", - "log", - "peniko", - "smallvec", - "thiserror 2.0.21", -] - [[package]] name = "vello_common" version = "0.2.0" @@ -4320,18 +5488,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[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" @@ -4340,13 +5496,13 @@ checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "crossbeam-channel", - "glifo 0.3.0", + "glifo", "hashbrown 0.17.1", "ordered-channel", "png", "rayon", "thread_local", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -4374,6 +5530,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" @@ -4610,6 +5781,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4891,6 +6071,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" @@ -5086,8 +6277,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181" dependencies = [ "ahash", "android-activity", @@ -5098,7 +6288,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -5143,6 +6333,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" @@ -5153,6 +6364,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -5232,7 +6449,7 @@ version = "0.10.0" name = "wordcraft-doc" version = "0.10.0" dependencies = [ - "base64", + "base64 0.22.1", "proptest", "serde", "serde_json", @@ -5252,7 +6469,7 @@ dependencies = [ "miniz_oxide 0.8.9", "proptest", "serde_json", - "sha1", + "sha1 0.11.0", "thiserror 2.0.21", "wordcraft-doc", "wordcraft-geom", @@ -5263,7 +6480,7 @@ name = "wordcraft-docx" version = "0.10.0" dependencies = [ "aes", - "base64", + "base64 0.22.1", "cfb", "chrono", "getrandom 0.3.4", @@ -5272,8 +6489,8 @@ dependencies = [ "log", "proptest", "quick-xml", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.0", "thiserror 2.0.21", "unicode-bidi", "wordcraft-doc", @@ -5426,7 +6643,7 @@ version = "0.10.0" dependencies = [ "image", "log", - "vello_cpu 0.2.0", + "vello_cpu", "wordcraft-doc", "wordcraft-fonts", "wordcraft-geom", @@ -5444,13 +6661,12 @@ dependencies = [ "egui", "egui_kittest", "image", - "js-sys", "log", "serde", "serde_json", "sys-locale", "toml", - "unicode-bidi", + "web-time", "wordcraft-doc", "wordcraft-engine", "wordcraft-fonts", @@ -5557,6 +6773,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 0.9.6", + "der", + "sha1 0.10.7", + "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" @@ -5582,6 +6833,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" @@ -5776,6 +7033,20 @@ 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" @@ -5904,3 +7175,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" diff --git a/Cargo.toml b/Cargo.toml index 3e08edf802..94108552f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ harfrust = "0.13" wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -75,10 +75,12 @@ getrandom = "0.3" zeroize = "1" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +egui_extras = { version = "0.37", default-features = false } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" +# A monotonic clock that works on native and in the browser (`performance.now()`). +web-time = "1.1" rfd = "0.17" arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } @@ -110,4 +112,19 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" } +ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" } diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index b2e698caba..acf142200e 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -116,21 +116,6 @@ fn stored_prefs() -> Option { impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - for f in dropped { - let inbox = self.inbox.clone(); - let ctx = ctx.clone(); - wasm_bindgen_futures::spawn_local(async move { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - match f.bytes_async().await { - Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); - ctx.request_repaint(); - } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), - } - }); - } let msgs = std::mem::take(&mut *self.messages.borrow_mut()); for msg in msgs { self.app.status(msg); @@ -149,11 +134,31 @@ impl eframe::App for WebShell { } } - 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) { + // Browser drops are input: take them only in the first real UI pass, never in a hidden + // `run_logic` tick that still sees the previous shown frame's RawInput. + if ui.ctx().current_pass_index() == 0 { + let ctx = ui.ctx().clone(); + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + for f in dropped { + let inbox = self.inbox.clone(); + let ctx = ctx.clone(); + wasm_bindgen_futures::spawn_local(async move { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); + match f.bytes_async().await { + Ok(bytes) => { + inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), + } + }); + } + } self.app.ui(ui); } } diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index dbad7e93f3..0fbbc4e4ed 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -19,7 +19,7 @@ wordcraft-fonts = { workspace = true } wordcraft-ui-egui = { workspace = true } # The control key file's location (shared with the MCP bridge). wordcraft-control-key = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 85410340da..7f93f2d123 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -27,39 +27,44 @@ mod traffic_lights; use wordcraft_engine::Session; use wordcraft_ui_egui::{ Services, UiState, WordApp, - window_geometry::{WindowGeometry, take_rescue}, + window_geometry::{RawWindowState, WindowGeometry, take_rescue}, }; /// The app, the restored window geometry (or, with none, the default: no position) until the /// first frame that knows the monitor has checked it, the control server's key file (removed on -/// exit), the file dialogs the app asked for and, on macOS, the documents opened from Finder. +/// exit), the file dialogs the app asked for, this frame's raw host window state and, on macOS, +/// the documents opened from Finder. struct App( WordApp, Option, Option, file_dialogs::Launcher, + // The host's answer for this frame. `InputState` still describes the last painted frame on + // a hidden logic tick. + RawWindowState, #[cfg(target_os = "macos")] fmv_macos_events::Inbox, ); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] - apple_events::poll(&self.4, &mut self.0, ctx); + apple_events::poll(&self.5, &mut self.0, ctx); self.0.logic(ctx); self.3.show(frame); + let window = self.4; // A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit // there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first // knows the monitor: the off-screen check below takes the pending geometry then. - if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) { + if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos)); } let prev = self.0.ui.window; - self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size())); + self.0.ui.window = WindowGeometry::track(prev, &window); // The first frame, once the monitor is known (as `take_rescue` waits for): maximize a // window that opened at its normal size so it never flashed on screen half-opened. - let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some())); - if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) { + let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some()); + if let Some(pos) = take_rescue(&mut self.1, &window) { ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos)); } if let Some(command) = maximize { @@ -85,8 +90,11 @@ impl eframe::App for App { } } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // Keep this frame's native window state. `logic` must not read the last painted + // `InputState` when a hidden Wayland tick reports no fresh inner rect. + self.4 = RawWindowState::from_raw(raw); + self.0.raw_input_hook(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.0.ui(ui); @@ -480,6 +488,7 @@ fn main() -> eframe::Result { Some(restored.unwrap_or_default()), key_file, dialogs, + RawWindowState::default(), #[cfg(target_os = "macos")] apple_events.connect(&cc.egui_ctx), ))) diff --git a/apps/wordcraft/src/print.rs b/apps/wordcraft/src/print.rs index de52a6360f..9cfde15862 100644 --- a/apps/wordcraft/src/print.rs +++ b/apps/wordcraft/src/print.rs @@ -5,7 +5,10 @@ pub fn discover() -> Discover { Box::new(|| { let (sender, receiver) = std::sync::mpsc::channel(); let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || { - let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect(); + let printers = craft_print::printers() + .into_iter() + .map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default }) + .collect(); let _ = sender.send(printers); }); receiver diff --git a/craft-i18n.rev b/craft-i18n.rev index 1fbe354a03..9a8975664f 100644 --- a/craft-i18n.rev +++ b/craft-i18n.rev @@ -1 +1 @@ -04600cbb1508f1b15927ec574a5e32e5f376fd8b +e5bfe19b95d30f50f250985bdc3aeec47c05af09 diff --git a/crates/doc/src/ime_text.rs b/crates/doc/src/ime_text.rs new file mode 100644 index 0000000000..6392ed313e --- /dev/null +++ b/crates/doc/src/ime_text.rs @@ -0,0 +1,694 @@ +//! Visible logical text for IME surrounding-text queries. +//! +//! The platform input method works on what the reader sees, not on the stored run text: tracked +//! deletions are hidden, and an inline object stands for its plain-text result. The helpers here +//! build a bounded window around a selection without cloning the paragraph or the document, and +//! map both ends in both directions so a query and a delete use exactly the same text. + +use std::ops::Range; + +use crate::para::{OBJ, Paragraph}; +use crate::{Document, Pos}; + +/// A bounded visible-logical window around a selection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImeWindow { + /// At most [`MAX_IME_TEXT_BYTES`] bytes of visible committed text. + pub text: String, + /// UTF-8 byte offset of the primary end in [`Self::text`]. + pub cursor: usize, + /// UTF-8 byte offset of the other end in [`Self::text`]. + pub anchor: usize, +} + +/// The largest visible window an IME query copies. +pub const MAX_IME_TEXT_BYTES: usize = 4000; + +/// Which unit an input method's delete-surrounding counts use. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeleteUnit { + /// Unicode scalar values of the visible text. + Chars, + /// UTF-8 bytes of the visible text. + Bytes, +} + +/// A deletion request in the paragraph's stored byte offsets. +/// +/// The selection itself is never part of `before` or `after`; both ranges are outside it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImeDelete { + /// Stored byte range to erase before the selection. + pub before: Option>, + /// Stored byte range to erase after the selection. + pub after: Option>, + /// Stored byte offset of the selection's anchor **before** the deletions. + /// + /// The ranges are returned in the paragraph's stored text as it is now; the caller applies + /// them (and shifts these offsets past `before`) itself. + pub anchor: usize, + /// Stored byte offset of the selection's focus **before** the deletions. + pub focus: usize, +} + +/// One paragraph's visible logical text. +/// +/// Deleted runs are skipped and inline objects contribute their plain-text result. Iteration is +/// lazy: no full visible copy is made. +pub struct VisibleParagraph<'a> { + para: &'a Paragraph, + deleted: Vec>, +} + +impl<'a> VisibleParagraph<'a> { + /// A view of `para` with tracked deletions hidden. + pub fn new(para: &'a Paragraph) -> Self { + let mut deleted = para.deleted_ranges(); + deleted.sort_unstable_by_key(|r| r.start); + Self { para, deleted } + } + + /// Whether `off` falls inside a tracked deletion, advancing `next` past the runs that end + /// before it. + /// + /// The ranges are sorted, so one monotone cursor makes a whole scan O(text + ranges) instead + /// of O(text × ranges): alternating tracked revisions used to make every character scan every + /// range. + fn is_deleted_at(&self, next: &mut usize, off: usize) -> bool { + while let Some(r) = self.deleted.get(*next) { + if r.end > off { + break; + } + *next = next.saturating_add(1); + } + self.deleted.get(*next).is_some_and(|r| r.start <= off && off < r.end) + } + + /// Call `f` for every visible piece: `(stored byte start, piece, stored byte len)`. + fn for_each_piece(&self, mut f: impl FnMut(usize, &str, usize)) { + self.for_each_piece_while(|start, piece, stored| { + f(start, piece, stored); + true + }); + } + + /// Call `f` for visible pieces until it returns `false`. + fn for_each_piece_while(&self, mut f: impl FnMut(usize, &str, usize) -> bool) { + let mut object = 0usize; + let mut deleted = 0usize; + for (i, c) in self.para.text.char_indices() { + let is_object = c == OBJ; + if is_object { + object += 1; + } + if self.is_deleted_at(&mut deleted, i) { + continue; + } + let keep_going = if is_object { + let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text()); + f(i, piece, OBJ.len_utf8()) + } else { + let mut buf = [0u8; 4]; + f(i, c.encode_utf8(&mut buf), c.len_utf8()) + }; + if !keep_going { + break; + } + } + } + + /// Total visible UTF-8 bytes. + pub fn visible_len(&self) -> usize { + let mut len = 0usize; + self.for_each_piece(|_, piece, _| len = len.saturating_add(piece.len())); + len + } + + /// Visible offset of a stored paragraph byte offset. + /// + /// `None` when the stored offset is not a character boundary, points inside a tracked + /// deletion, or would split an inline object's plain-text result. + pub fn visible_offset(&self, stored: usize) -> Option { + if stored > self.para.text.len() || !self.para.text.is_char_boundary(stored) { + return None; + } + let mut visible = 0usize; + let mut found = false; + let mut object = 0usize; + let mut deleted = 0usize; + for (i, c) in self.para.text.char_indices() { + if i == stored { + found = true; + break; + } + if i > stored { + break; + } + let is_object = c == OBJ; + if is_object { + object += 1; + } + if self.is_deleted_at(&mut deleted, i) { + // A position at the start of a deleted run is still a visible position; one + // inside it is not. + continue; + } + if is_object { + let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text()); + visible = visible.saturating_add(piece.len()); + } else { + visible = visible.saturating_add(c.len_utf8()); + } + } + if !found { + // The end of the paragraph, or a deleted run at its end. + let mut next = 0usize; + return (!self.is_deleted_at(&mut next, stored)).then_some(visible); + } + if stored < self.para.text.len() && self.is_deleted_at(&mut deleted, stored) { + // The start of a deleted run is still a visible caret position; only a position + // strictly inside one is unrepresentable. + let at_start = self.deleted.iter().any(|r| r.start == stored); + return at_start.then_some(visible); + } + Some(visible) + } + + /// Stored paragraph byte offset for a visible offset. + /// + /// `None` when the visible offset falls inside an inline object's plain-text result or is + /// not a boundary of the visible representation. + pub fn stored_offset(&self, visible: usize) -> Option { + let mut acc = 0usize; + let mut answer = None; + let mut object = 0usize; + let mut deleted = 0usize; + for (i, c) in self.para.text.char_indices() { + let is_object = c == OBJ; + if is_object { + object += 1; + } + if self.is_deleted_at(&mut deleted, i) { + continue; + } + let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() }; + if visible == acc { + answer = Some(i); + break; + } + if visible < acc.saturating_add(piece_len) { + // Inside a character (or an object's expansion): no stored boundary there. + return None; + } + acc = acc.saturating_add(piece_len); + } + answer.or_else(|| (visible == acc).then_some(self.para.text.len())) + } + + /// Stored offset for the **start** of a visible range, skipping zero-length objects at + /// the same boundary. + /// + /// A content control's markers are inline objects with no plain text, so they share a + /// visible offset with the content just inside (or after) them. A range start must map + /// after a control-start marker and after a control-end marker, never onto the marker + /// itself: an IME deletion must not break the control. Non-empty object expansions still + /// map to the object's own stored offset, so a range that reaches one deletes it whole. + pub fn stored_offset_after(&self, visible: usize) -> Option { + let mut acc = 0usize; + let mut object = 0usize; + let mut deleted = 0usize; + for (i, c) in self.para.text.char_indices() { + let is_object = c == OBJ; + if is_object { + object += 1; + } + if self.is_deleted_at(&mut deleted, i) { + continue; + } + let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() }; + if visible == acc && piece_len > 0 { + return Some(i); + } + if visible < acc.saturating_add(piece_len) { + return None; + } + acc = acc.saturating_add(piece_len); + } + (visible == acc).then_some(self.para.text.len()) + } + + /// Stored offset for a visible offset, preferring the position **before** a tracked + /// deletion that starts there. Use this for the end of a range so a deletion run just + /// after the caret is never drawn into it. + pub fn stored_offset_before(&self, visible: usize) -> Option { + let mut acc = 0usize; + let mut object = 0usize; + let mut deleted = 0usize; + for (i, c) in self.para.text.char_indices() { + if visible == acc { + return Some(i); + } + let is_object = c == OBJ; + if is_object { + object += 1; + } + if self.is_deleted_at(&mut deleted, i) { + continue; + } + let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() }; + if visible < acc.saturating_add(piece_len) { + return None; + } + acc = acc.saturating_add(piece_len); + } + (visible == acc).then_some(self.para.text.len()) + } + + /// Round a visible byte offset forward to the next visible boundary (inward for a start). + pub fn ceil_boundary(&self, visible: usize) -> usize { + let mut acc = 0usize; + let mut out = None; + self.for_each_piece(|_, piece, _| { + if out.is_some() { + return; + } + if acc >= visible { + out = Some(acc); + return; + } + acc = acc.saturating_add(piece.len()); + if acc >= visible { + out = Some(acc); + } + }); + out.unwrap_or(acc) + } + + /// Round a visible byte offset backward to the previous visible boundary (inward for an end). + pub fn floor_boundary(&self, visible: usize) -> usize { + let mut acc = 0usize; + let mut out = 0usize; + self.for_each_piece(|_, piece, _| { + let next = acc.saturating_add(piece.len()); + if next > visible { + return; + } + acc = next; + out = acc; + }); + out + } + + /// A bounded window with `cursor` and `anchor` as visible byte offsets. + /// + /// The selection is never split and the window never exceeds [`MAX_IME_TEXT_BYTES`]. A + /// selection wider than that, an invalid offset, or an offset that cannot be represented as + /// visible text returns `None` (unavailable, not fabricated). + pub fn window(&self, cursor: usize, anchor: usize, max_bytes: usize) -> Option { + // The public API caps its own budget: a caller cannot ask for an unbounded copy. + let max_bytes = max_bytes.min(MAX_IME_TEXT_BYTES); + let (sel_start, sel_end) = (cursor.min(anchor), cursor.max(anchor)); + let selection_len = sel_end - sel_start; + if selection_len > max_bytes { + return None; + } + // Spend the budget around the selection and stop walking at its end: the paragraph is + // never copied and the work is bounded by the caret's distance from the paragraph start. + let before = (max_bytes - selection_len) / 2; + let window_start = sel_start.saturating_sub(before); + let window_end = window_start.saturating_add(max_bytes); + let mut text = String::with_capacity(window_end.saturating_sub(window_start).min(max_bytes)); + let mut first_kept = None; + let mut acc = 0usize; + self.for_each_piece_while(|_, piece, _| { + for (k, c) in piece.char_indices() { + let start = acc.saturating_add(k); + let end = start.saturating_add(c.len_utf8()); + if end > window_end { + return false; + } + if start >= window_start { + if first_kept.is_none() { + first_kept = Some(start); + } + text.push(c); + } + } + acc = acc.saturating_add(piece.len()); + acc < window_end + }); + let Some(first) = first_kept else { + // An empty document is an available empty context, never unavailable. + return (cursor == 0 && anchor == 0).then_some(ImeWindow { text: String::new(), cursor: 0, anchor: 0 }); + }; + Some(ImeWindow { text, cursor: cursor.saturating_sub(first), anchor: anchor.saturating_sub(first) }) + } +} + +impl Document { + /// Bounded visible logical text around `cursor`/`anchor` in one paragraph. + /// + /// A selection across paragraphs or blocks is unrepresentable and returns `None`. + pub fn ime_surrounding(&self, cursor: &Pos, anchor: &Pos, max_bytes: usize) -> Option { + if cursor.story != anchor.story || cursor.path != anchor.path { + return None; + } + let para = self.para_at(cursor)?; + let view = VisibleParagraph::new(para); + let c = view.visible_offset(cursor.off)?; + let a = view.visible_offset(anchor.off)?; + view.window(c, a, max_bytes.min(MAX_IME_TEXT_BYTES)) + } + + /// Delete-surrounding ranges in the stored paragraph text. + /// + /// Counts exclude the selection, tracked deletions and any pre-edit (which never lives in + /// the model here). Byte counts that end inside a scalar leave it whole: a start rounds + /// forward and an end rounds backward; a byte count beyond the visible text is refused. + /// Character counts clamp at the paragraph's ends, as the web text agent's do. A range that + /// cannot be represented in stored text returns `None` instead of guessing; so does a + /// request with nothing to erase (`0` on both sides). + pub fn ime_delete_surrounding(&self, cursor: &Pos, anchor: &Pos, before: usize, after: usize, unit: DeleteUnit) -> Option { + if cursor.story != anchor.story || cursor.path != anchor.path { + return None; + } + if before == 0 && after == 0 { + // Nothing to erase: don't build the boundary table (or walk a long paragraph) to + // discover it. + return None; + } + let para = self.para_at(cursor)?; + let view = VisibleParagraph::new(para); + let c = view.visible_offset(cursor.off)?; + let a = view.visible_offset(anchor.off)?; + let (sel_start, sel_end) = (c.min(a), c.max(a)); + + let (before_start, after_end) = match unit { + DeleteUnit::Bytes => { + let total = view.visible_len(); + if before > sel_start || after > total - sel_end { + return None; + } + (view.ceil_boundary(sel_start.saturating_sub(before)), view.floor_boundary(sel_end.saturating_add(after))) + } + DeleteUnit::Chars => char_boundaries(&view, sel_start, sel_end, before, after)?, + }; + + // Start a range after a deletion run and end it before one, so tracked deletions are + // never part of the erased stored range. + let before_start = view.stored_offset_after(before_start)?; + let before_end = view.stored_offset_before(sel_start)?; + let after_start = view.stored_offset_after(sel_end)?; + let after_end = view.stored_offset_before(after_end)?; + let before_range = (before_start < before_end).then_some(before_start..before_end); + let after_range = (after_start < after_end).then_some(after_start..after_end); + Some(ImeDelete { before: before_range, after: after_range, anchor: anchor.off, focus: cursor.off }) + } +} + +/// The visible byte boundaries `before` characters back from `sel_start` and `after` +/// characters on from `sel_end`. +/// +/// Two walks and constant memory replace the old per-character `Vec` that held every boundary +/// (a hostile count could make it allocate the whole paragraph). Character counts clamp at the +/// paragraph's ends; `None` when either selection end is not a boundary of the visible text. +fn char_boundaries(view: &VisibleParagraph<'_>, sel_start: usize, sel_end: usize, before: usize, after: usize) -> Option<(usize, usize)> { + let mut acc = 0usize; + let mut chars = 0usize; + let mut start_index = None; + let mut end_index = None; + view.for_each_piece(|_, piece, _| { + for (k, _) in piece.char_indices() { + let at = acc.saturating_add(k); + if at == sel_start { + start_index = Some(chars); + } + if at == sel_end { + end_index = Some(chars); + } + chars = chars.saturating_add(1); + } + acc = acc.saturating_add(piece.len()); + }); + if sel_start == acc { + start_index = Some(chars); + } + if sel_end == acc { + end_index = Some(chars); + } + let start_index = start_index?; + let end_index = end_index?; + let target_before = start_index.saturating_sub(before); + let target_after = end_index.saturating_add(after).min(chars); + + let mut acc = 0usize; + let mut index = 0usize; + let mut before_start = None; + let mut after_end = (target_after == 0).then_some(0); + view.for_each_piece(|_, piece, _| { + for (k, _) in piece.char_indices() { + let at = acc.saturating_add(k); + if index == target_before && before_start.is_none() { + before_start = Some(at); + } + if index == target_after && after_end.is_none() { + after_end = Some(at); + } + index = index.saturating_add(1); + } + acc = acc.saturating_add(piece.len()); + }); + if index == target_before && before_start.is_none() { + before_start = Some(acc); + } + if index == target_after && after_end.is_none() { + after_end = Some(acc); + } + Some((before_start?, after_end?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::para::Run; + use crate::props::CharProps; + use crate::{InlineObject, StoryRef}; + + fn para_with_runs(parts: &[(&str, bool)]) -> Paragraph { + let mut p = Paragraph::new(); + for (text, deleted) in parts { + let mut props = CharProps::default(); + if *deleted { + props.del = Some(0); + } + p.text.push_str(text); + p.runs.push(Run { len: text.len(), props }); + } + p + } + + #[test] + fn tracked_deletions_are_hidden_and_offsets_still_map() { + let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]); + let view = VisibleParagraph::new(&p); + assert_eq!(view.visible_len(), 4); + assert_eq!(view.visible_offset(0), Some(0)); + assert_eq!(view.visible_offset(2), Some(2), "the start of a deleted run is a visible caret position"); + assert_eq!(view.visible_offset(3), None, "inside a tracked deletion"); + assert_eq!(view.visible_offset(5), Some(2)); + assert_eq!(view.visible_offset(7), Some(4)); + assert_eq!(view.stored_offset(2), Some(5)); + assert_eq!(view.stored_offset(4), Some(7)); + assert_eq!(view.window(2, 2, 4000).map(|w| w.text), Some("abcd".into())); + } + + #[test] + fn objects_contribute_their_plain_text_and_split_mapping_stops() { + let mut p = Paragraph::new(); + p.text = format!("a{OBJ}b"); + p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }]; + p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() }); + let view = VisibleParagraph::new(&p); + assert_eq!(view.visible_len(), 5); + assert_eq!(view.visible_offset(1), Some(1)); + assert_eq!(view.visible_offset(4), Some(4)); + assert_eq!(view.stored_offset(1), Some(1)); + assert_eq!(view.stored_offset(4), Some(4)); + assert_eq!(view.stored_offset(2), None, "inside the object's text"); + } + + #[test] + fn an_empty_document_is_available_and_empty() { + let p = Paragraph::new(); + let view = VisibleParagraph::new(&p); + let w = view.window(0, 0, MAX_IME_TEXT_BYTES).expect("an available empty context"); + assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("", 0, 0)); + let doc = Document::new(); + let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), MAX_IME_TEXT_BYTES).expect("available"); + assert_eq!(w.text, ""); + } + + #[test] + fn bounded_window_keeps_the_selection_and_rejects_too_wide_ones() { + let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default()); + let view = VisibleParagraph::new(&p); + let w = view.window(5000, 5000, MAX_IME_TEXT_BYTES).expect("a bounded window"); + assert!(w.text.len() <= MAX_IME_TEXT_BYTES); + assert_eq!(w.cursor, w.anchor); + assert!(w.cursor <= w.text.len()); + assert!(view.window(0, 4001, MAX_IME_TEXT_BYTES).is_none()); + } + + #[test] + fn byte_deletion_rounds_inward_and_leaves_partial_scalars_whole() { + let doc = Document::from_text("aé👍b"); + let caret = Pos::body(0, 8); + let one = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("b"); + assert_eq!(one.before, Some(7..8)); + // Two bytes start inside the emoji: it is left whole, only `b` goes. + let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b"); + assert_eq!(two.before, Some(7..8)); + // Six bytes start inside `é`: it stays whole, the emoji and `b` go. + let six = doc.ime_delete_surrounding(&caret, &caret, 6, 0, DeleteUnit::Bytes).expect("emoji and b"); + assert_eq!(six.before, Some(3..8)); + // A count beyond the paragraph is refused, not clipped to delete a whole side. + assert!(doc.ime_delete_surrounding(&caret, &caret, 0, 1, DeleteUnit::Bytes).is_none()); + assert!(doc.ime_delete_surrounding(&caret, &caret, 99, 0, DeleteUnit::Bytes).is_none()); + } + + #[test] + fn byte_deletion_excludes_a_reversed_selection() { + let doc = Document::from_text("abcd"); + let cursor = Pos::body(0, 1); + let anchor = Pos::body(0, 3); + let del = doc.ime_delete_surrounding(&cursor, &anchor, 1, 1, DeleteUnit::Bytes).expect("a deletion"); + assert_eq!(del.before, Some(0..1), "only the character before the selection"); + assert_eq!(del.after, Some(3..4), "only the character after the selection"); + assert_eq!((del.anchor, del.focus), (3, 1), "the reversed selection is preserved"); + } + + #[test] + fn tracked_deletions_are_not_counted_for_deletion_or_query() { + let mut doc = Document::from_text("abDELcd"); + let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]); + doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))]; + let view = VisibleParagraph::new(doc.para_at(&Pos::body(0, 0)).expect("paragraph")); + assert_eq!(view.window(2, 2, 100).map(|w| w.text), Some("abcd".into())); + let caret = Pos::body(0, 7); + let del = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("c"); + assert_eq!(del.before, Some(6..7), "the stored deletion run is skipped"); + } + + #[test] + fn cross_paragraph_selection_is_unavailable() { + let doc = Document::from_text("one\ntwo"); + let cursor = Pos::body(0, 1); + let anchor = Pos::body(1, 1); + assert!(doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).is_none()); + assert!(doc.ime_delete_surrounding(&cursor, &anchor, 1, 0, DeleteUnit::Chars).is_none()); + } + + /// An object's plain-text result stands for the object in the visible text; a deletion + /// that starts inside that expansion leaves the object whole (byte) or is refused (chars). + #[test] + fn object_expansion_is_never_partly_deleted() { + let mut doc = Document::from_text(""); + let mut p = Paragraph::new(); + p.text = format!("a{OBJ}b"); + p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }]; + p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() }); + doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))]; + let caret = Pos::body(0, 5); + // Two bytes before the caret end inside `x+y` (`y` is the last byte): the start rounds + // forward past the object, so only `b` goes. + let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b only"); + assert_eq!(two.before, Some(4..5), "stored UTF-8 offsets keep the object whole"); + let mut remaining = format!("a{OBJ}b"); + remaining.replace_range(two.before.clone().unwrap(), ""); + assert_eq!(remaining, format!("a{OBJ}")); + // A character that lands inside the expansion has no stored boundary: refused. + assert!(doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Chars).is_none()); + // A count reaching before the object erases the object as a whole. + let five = doc.ime_delete_surrounding(&caret, &caret, 5, 0, DeleteUnit::Bytes).expect("object and b"); + assert_eq!(five.before, Some(0..5)); + } + + /// Character counts on the right side clamp at the paragraph's end and never cross into the + /// next paragraph. + #[test] + fn trailing_character_deletion_clamps_at_the_paragraph_end() { + let doc = Document::from_text("ab\ncd"); + let caret = Pos::body(0, 1); + let del = doc.ime_delete_surrounding(&caret, &caret, 0, 99, DeleteUnit::Chars).expect("b only"); + assert_eq!(del.after, Some(1..2)); + let empty = doc.ime_delete_surrounding(&caret, &caret, 0, 0, DeleteUnit::Chars); + assert!(empty.is_none(), "nothing to erase"); + } + + /// Sorted tracked deletions are scanned with one cursor: alternating revisions stay correct + /// (and linear) instead of each character scanning every range. + #[test] + fn alternating_tracked_revisions_map_correctly() { + // Equal-length visible/deleted pieces, so the expected visible byte count is exact. + let parts: Vec<(String, bool)> = + (0..2000).map(|i| (if i % 2 == 0 { format!("v{i:04};") } else { format!("D{i:04};") }, i % 2 == 1)).collect(); + let parts: Vec<(&str, bool)> = parts.iter().map(|(t, d)| (t.as_str(), *d)).collect(); + let p = para_with_runs(&parts); + let view = VisibleParagraph::new(&p); + assert_eq!(view.visible_len(), p.text.len() - 6 * 1000); + assert_eq!(view.visible_offset(p.text.len()), Some(view.visible_len())); + let w = view.window(0, 0, 100).expect("a window"); + assert!(w.text.len() <= 100); + assert!(!w.text.contains('D'), "tracked deletions never show"); + } + + /// The public window never copies more than the IME cap, whatever a caller asks for. + #[test] + fn window_budget_is_capped() { + let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default()); + let view = VisibleParagraph::new(&p); + let w = view.window(0, 0, usize::MAX).expect("a bounded window"); + assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES); + let doc = Document::from_text(&"a".repeat(10_000)); + let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), usize::MAX).expect("bounded"); + assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES); + } + + #[test] + fn table_cell_paragraph_maps_inside_its_cell() { + let mut doc = Document::from_text("outside"); + let mut table = crate::Table::new(1, 1, 100.0); + table.rows[0].cells[0].blocks = vec![crate::para_block(Paragraph::with_text("cell", CharProps::default()))]; + doc.body.push(std::sync::Arc::new(crate::Block::Table(table))); + let path = crate::Path(vec![1, 0, 0, 0]); + let cursor = Pos { story: StoryRef::Body, path: path.clone(), off: 4 }; + let anchor = Pos { story: StoryRef::Body, path, off: 2 }; + let w = doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).expect("the cell text"); + assert_eq!(w.text, "cell"); + assert_eq!(w.cursor, 4); + assert_eq!(w.anchor, 2); + } + + /// Content-control markers are zero-length visible objects. A deletion range starts after + /// the marker and ends before the next one, so an ordinary IME edit never removes a marker + /// or leaves a half-paired control. + #[test] + fn control_markers_are_not_included_in_delete_ranges() { + use crate::control::{ContentControl, ControlKind}; + let mut p = Paragraph::new(); + p.text = format!("pre{OBJ}Ada{OBJ}X"); + p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }]; + let mut ctl = ContentControl::new(ControlKind::RichText); + ctl.placeholder_text = "Enter name".into(); + p.objects.push(InlineObject::ControlStart { control: Box::new(ctl) }); + p.objects.push(InlineObject::ControlEnd); + let mut doc = Document::new(); + doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))]; + // Stored positions are UTF-8 byte offsets; each OBJ marker occupies three bytes. + let cursor = Pos::body(0, 9); + let del = doc.ime_delete_surrounding(&cursor, &cursor, 3, 1, DeleteUnit::Bytes).expect("a deletion"); + assert_eq!(del.before, Some(6..9), "the control-start marker and `pre` stay outside"); + assert_eq!(del.after, Some(12..13), "the control-end marker stays outside; only `X` goes"); + assert_eq!((del.anchor, del.focus), (9, 9)); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d4c548a2cf..bc9bd6be12 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -26,6 +26,7 @@ pub mod fields; pub mod fill; pub mod freeform; pub mod graphic; +pub mod ime_text; pub mod legacy_form; pub mod line_dash; pub mod link; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index e1ad64eca8..9844c57269 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -8,6 +8,12 @@ description = "WordCraft engine: session, selection, undo, the command registry [lints] workspace = true +[features] +# Front-end producer tests need to simulate a globally exhausted IME revision allocator. It only +# ever makes receipt classification fail closed. Never enable this for a production build; the +# UI's dev-dependencies enable it for its tests. +test-support = [] + [dependencies] wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } diff --git a/crates/engine/src/cmd/equation.rs b/crates/engine/src/cmd/equation.rs index a9aea62044..febf4358a5 100644 --- a/crates/engine/src/cmd/equation.rs +++ b/crates/engine/src/cmd/equation.rs @@ -10,7 +10,7 @@ use wordcraft_doc::para::OBJ; use wordcraft_doc::{InlineObject, Pos}; use super::{delete_selection, sel_result, split_para}; -use crate::{CmdError, CmdResult, CommandSpec, MathEdit, Selection, Session, p}; +use crate::{CmdError, CmdResult, CommandSpec, ImeEditOutcome, MathEdit, Selection, Session, p}; fn editing(s: &Session) -> Option<&'static str> { if s.math.is_none() { Some("not editing an equation") } else { None } @@ -40,6 +40,9 @@ pub fn specs() -> Vec { .params(r#"{"dir": "left|right|up|down|home|end|next|prev"}"#), CommandSpec::new("equation.backspace", "Delete Back in Equation", "Equation", |s, _| delete(s, true)).when(editing), CommandSpec::new("equation.delete", "Delete in Equation", "Equation", |s, _| delete(s, false)).when(editing), + CommandSpec::new("equation.deleteSurrounding", "Delete Around in Equation", "Equation", delete_surrounding) + .when(editing) + .params(r#"{"before": n, "after": n}"#), CommandSpec::new("equation.enter", "Enter in Equation", "Equation", enter).when(editing), CommandSpec::new("equation.convert", "Convert", "Equation › Conversions", convert) .when(near_equation_or_all) @@ -100,21 +103,68 @@ fn equation_at_caret(s: &Session) -> Option { None } +/// What one equation edit changed. `content_changed` gates an IME receipt; `state_changed` +/// covers style, representation and lazy-normalization changes that must poison a mixed frame. +#[derive(Clone, Copy, Debug, Default)] +struct EquationEffect { + content_changed: bool, + state_changed: bool, +} + +impl EquationEffect { + fn merge(&mut self, other: Self) { + self.content_changed |= other.content_changed; + self.state_changed |= other.state_changed; + } +} + +fn apply_effect(s: &mut Session, effect: EquationEffect, transformed: bool) { + s.set_ime_edit_outcome(ImeEditOutcome { + classified: true, + content_changed: effect.content_changed, + state_changed: effect.state_changed || effect.content_changed, + transformed, + selection_changed: false, + }); +} + +fn classified_noop(s: &mut Session) { + s.set_ime_edit_outcome(ImeEditOutcome { classified: true, ..ImeEditOutcome::default() }); +} + /// Run `f` on the equation at `at`; keeps its linear text in step and drops the source OMML /// (it no longer matches). -fn with_eq(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result { +/// +/// The logical before-state is captured *after* lazy `nodes` materialization: an imported +/// equation that only gets normalized is a state change, never an IME content change. +fn with_eq(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<(R, EquationEffect), CmdError> { let para = s.doc.para_mut(at.story, &at.path)?; let Some(InlineObject::Equation { linear, display, math }) = para.object_at_mut(at.off) else { return Err(CmdError::Failed("no equation there".into())); }; - if math.nodes.is_empty() && !linear.is_empty() && !math.linear { + let normalized = math.nodes.is_empty() && !linear.is_empty() && !math.linear; + if normalized { math.nodes = parse_linear(linear); } + let nodes_before = math.nodes.clone(); + let linear_mode_before = math.linear; + let jc_before = math.jc; + let display_before = *display; + let cached_linear_before = linear.clone(); + let omml_before_nonempty = !math.omml.is_empty(); let r = f(math, display); - *linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) }; + let new_linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) }; + let content_changed = math.nodes != nodes_before || math.linear != linear_mode_before; + let state_changed = normalized + || content_changed + || math.jc != jc_before + || *display != display_before + || omml_before_nonempty + || new_linear != cached_linear_before; + *linear = new_linear; math.omml.clear(); para.touch(); - Ok(r) + Ok((r, EquationEffect { content_changed, state_changed })) } /// All the text of an equation's runs (its linear form while shown linear). @@ -178,11 +228,22 @@ fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult let obj = InlineObject::Equation { linear, display, math: Math { nodes, ..Default::default() } }; let end = s.doc.insert_object(&at, obj, &props)?; if p::bool(v, "edit").unwrap_or(true) { + let Some(session) = crate::next_math_session() else { + return Err(CmdError::Failed("no equation editing session available".into())); + }; s.sel = Selection::caret(at.clone()); - s.math = Some(MathEdit { at, pos: caret }); + s.math = Some(MathEdit { at, pos: caret, session }); } else { s.sel = Selection::caret(end); } + // Creating an equation inserts visible content, even when its tree is initially empty. + s.set_ime_edit_outcome(ImeEditOutcome { + classified: true, + content_changed: true, + state_changed: true, + transformed: false, + selection_changed: false, + }); result(s) } @@ -200,8 +261,11 @@ fn edit(s: &mut Session, v: &Value) -> CmdResult { }; let pos = v.get("pos").and_then(|p| serde_json::from_value::(p.clone()).ok()).unwrap_or_else(|| MathPos::new(Vec::new(), me::units(&nodes))); + let Some(session) = crate::next_math_session() else { + return Err(CmdError::Failed("no equation editing session available".into())); + }; s.sel = Selection::caret(at.clone()); - s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at }); + s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at, session }); result(s) } @@ -221,17 +285,24 @@ fn current(s: &Session) -> Result { fn type_text(s: &mut Session, v: &Value) -> CmdResult { let text = p::req_str(v, "text")?.to_string(); let m = current(s)?; + if text.is_empty() { + // No normalization, no linear-cache rewrite, no revision: an empty commit is a no-op. + classified_noop(s); + return result(s); + } let latex = s.math_latex; let style = TypeStyle { nor: s.math_normal_text }; let mut pos = m.pos.clone(); - with_eq(s, &m.at, |math, _| { + let mut transformed = false; + let (_, effect) = with_eq(s, &m.at, |math, _| { if math.linear { // Linear form is plain text: no build-up. + let text: String = text.chars().take(crate::MAX_EQUATION_TYPE_CHARS).collect(); pos = me::insert_text(&mut math.nodes, &pos, &text, TypeStyle::default()); set_literal(&mut math.nodes); return; } - for c in text.chars().take(10_000) { + for c in text.chars().take(crate::MAX_EQUATION_TYPE_CHARS) { let rel = matches!(wordcraft_doc::math::math_class(c), wordcraft_doc::math::MClass::Rel); if !style.nor && (c == ' ' || c == '\t' || rel) { let mut changed = false; @@ -243,6 +314,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult { pos = p; changed = true; } + if changed { + transformed = true; + } if c == ' ' && changed { continue; } @@ -253,9 +327,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult { if let Some(m) = s.math.as_mut() { m.pos = pos; } + apply_effect(s, effect, transformed); result(s) } - /// Runs of a linear-form equation are literal text. fn set_literal(nodes: &mut Arg) { for n in nodes.iter_mut() { @@ -272,7 +346,7 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult { return insert_nodes_as_equation(s, nodes, &json!({})); }; let mut pos = m.pos.clone(); - with_eq(s, &m.at, |math, _| { + let (_, effect) = with_eq(s, &m.at, |math, _| { if math.linear { pos = me::insert_text(&mut math.nodes, &pos, &to_linear(&nodes), TypeStyle::default()); set_literal(&mut math.nodes); @@ -283,6 +357,7 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult { if let Some(m) = s.math.as_mut() { m.pos = pos; } + apply_effect(s, effect, false); result(s) } @@ -296,7 +371,7 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult { }; let mut pos = m.pos.clone(); let style = TypeStyle { nor: s.math_normal_text }; - with_eq(s, &m.at, |math, _| { + let (_, effect) = with_eq(s, &m.at, |math, _| { pos = me::insert_text(&mut math.nodes, &pos, &c, style); if math.linear { set_literal(&mut math.nodes); @@ -305,6 +380,7 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult { if let Some(m) = s.math.as_mut() { m.pos = pos; } + apply_effect(s, effect, false); result(s) } @@ -324,48 +400,49 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult { return Err(CmdError::Params(format!("unknown direction {dir}"))); } // Leaving a slot finishes what was typed there (AutoCorrect names, build-up). That is worked - // out on a copy: plain navigation must leave the equation (and its source OMML) untouched, - // and a real change is a document edit — undoable and marking the document dirty (#446, #447). + // out on a local copy: plain navigation must leave the equation (and its source OMML) + // untouched, and a real change is a document edit — undoable and marking the document dirty + // (#446, #447). Any failure happens before the single commit, so no rollback is needed. let shown_linear = matches!( s.doc.para_at(&m.at).and_then(|p| p.object_at(m.at.off)), Some(InlineObject::Equation { math, .. }) if math.linear ); - if !s.math_normal_text - && !shown_linear - && let Some(before) = nodes_of(s, &m.at) - { + let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?; + let mut pos = m.pos.clone(); + if !s.math_normal_text && !shown_linear { let latex = s.math_latex; - let mut nodes = before.clone(); - let mut pos = m.pos.clone(); + let before = nodes.clone(); + let mut candidate = nodes.clone(); + let mut candidate_pos = pos.clone(); let mut changed = false; - if let Some(p) = me::autocorrect(&mut nodes, &pos) { - pos = p; + if let Some(p) = me::autocorrect(&mut candidate, &candidate_pos) { + candidate_pos = p; changed = true; } - if let Some(p) = me::build_up(&mut nodes, &pos, latex) { - pos = p; + if let Some(p) = me::build_up(&mut candidate, &candidate_pos, latex) { + candidate_pos = p; changed = true; } - if changed && nodes != before { + pos = candidate_pos; + if changed && candidate != before { // `equation.move` is a pure command, so record the edit here. s.checkpoint("Build Up"); - with_eq(s, &m.at, |math, _| math.nodes = nodes)?; + let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = candidate.clone())?; + apply_effect(s, effect, true); + // `equation.move` is pure for ordinary navigation, so `Session::run` does not touch + // the document for us. A build-up is a real edit: mark it dirty and refresh layout. s.touch(); - if let Some(mm) = s.math.as_mut() { - mm.pos = pos; - } + nodes = candidate; } } - let m = current(s)?; - let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?; let next = match dir { - "left" => me::move_left(&nodes, &m.pos), - "right" => me::move_right(&nodes, &m.pos), - "up" => Some(me::move_vertical(&nodes, &m.pos, true).unwrap_or(m.pos.clone())), - "down" => Some(me::move_vertical(&nodes, &m.pos, false).unwrap_or(m.pos.clone())), - "home" => Some(me::home_end(&nodes, &m.pos, false)), - "end" => Some(me::home_end(&nodes, &m.pos, true)), - "next" | "prev" => Some(me::next_placeholder(&nodes, &m.pos, dir == "prev").unwrap_or(m.pos.clone())), + "left" => me::move_left(&nodes, &pos), + "right" => me::move_right(&nodes, &pos), + "up" => Some(me::move_vertical(&nodes, &pos, true).unwrap_or(pos.clone())), + "down" => Some(me::move_vertical(&nodes, &pos, false).unwrap_or(pos.clone())), + "home" => Some(me::home_end(&nodes, &pos, false)), + "end" => Some(me::home_end(&nodes, &pos, true)), + "next" | "prev" => Some(me::next_placeholder(&nodes, &pos, dir == "prev").unwrap_or(pos.clone())), other => return Err(CmdError::Params(format!("unknown direction {other}"))), }; match next { @@ -379,34 +456,92 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult { None => exit(s, &json!({"before": dir == "left"})), } } - fn delete(s: &mut Session, back: bool) -> CmdResult { let m = current(s)?; let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?; - // An empty equation goes away entirely. + // An empty equation goes away entirely: removing the object is a content change. if nodes.is_empty() { s.math = None; s.sel = Selection { anchor: m.at.clone(), focus: Pos { off: m.at.off + OBJ.len_utf8(), ..m.at.clone() } }; let at = delete_selection(s)?; s.sel = Selection::caret(at); + s.set_ime_edit_outcome(ImeEditOutcome { + classified: true, + content_changed: true, + state_changed: true, + transformed: false, + selection_changed: false, + }); return sel_result(s); } let mut pos = m.pos.clone(); - with_eq(s, &m.at, |math, _| { + let (_, effect) = with_eq(s, &m.at, |math, _| { pos = if back { me::delete_back(&mut math.nodes, &pos) } else { me::delete_forward(&mut math.nodes, &pos) }; })?; if let Some(m) = s.math.as_mut() { m.pos = pos; } + apply_effect(s, effect, false); result(s) } +/// Delete a bounded number of equation units on each side of the caret in one transaction. +/// +/// The requested steps are simulated on a local tree and caret. Any step that cannot make +/// progress refuses the whole request before the document is touched; the commit (if any) is a +/// single command and a single undo step. +fn delete_surrounding(s: &mut Session, v: &Value) -> CmdResult { + let m = current(s)?; + let before = p::u64(v, "before").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX); + let after = p::u64(v, "after").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX); + if before.saturating_add(after) == 0 { + return Err(CmdError::Params("nothing to delete".into())); + } + if before > crate::MAX_EQUATION_DELETE_UNITS || after > crate::MAX_EQUATION_DELETE_UNITS { + return Err(CmdError::Params(format!("at most {} units on each side", crate::MAX_EQUATION_DELETE_UNITS))); + } + let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?; + let mut pos = m.pos.clone(); + for _ in 0..before { + let prev_nodes = nodes.clone(); + let prev_pos = pos.clone(); + pos = me::delete_back(&mut nodes, &pos); + if nodes == prev_nodes { + // Walking into the next argument is not a deletion. A requested unit that only + // moves the caret must refuse the whole request, or a later step can apply against + // a tree the platform's surrounding snapshot did not describe. + return Err(CmdError::Failed(if pos == prev_pos { + "the equation cannot delete that many units".into() + } else { + "the equation delete made no progress".into() + })); + } + } + for _ in 0..after { + let prev_nodes = nodes.clone(); + let prev_pos = pos.clone(); + pos = me::delete_forward(&mut nodes, &pos); + if nodes == prev_nodes { + return Err(CmdError::Failed(if pos == prev_pos { + "the equation cannot delete that many units".into() + } else { + "the equation delete made no progress".into() + })); + } + } + let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = nodes)?; + if let Some(m) = s.math.as_mut() { + m.pos = pos; + } + apply_effect(s, effect, false); + Ok(json!({"before": before, "after": after, "changed": true})) +} fn enter(s: &mut Session, _: &Value) -> CmdResult { let m = current(s)?; let latex = s.math_latex; let mut pos = m.pos.clone(); let mut changed = false; - with_eq(s, &m.at, |math, _| { + let (_, effect) = with_eq(s, &m.at, |math, _| { if math.linear { return; } @@ -427,6 +562,7 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult { if let Some(m) = s.math.as_mut() { m.pos = pos; } + apply_effect(s, effect, true); return result(s); } // Nothing to build: Enter ends the equation and starts a new paragraph. @@ -434,6 +570,13 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult { let after = Pos { off: m.at.off + OBJ.len_utf8(), ..m.at }; let new = split_para(s, &after)?; s.sel = Selection::caret(new); + s.set_ime_edit_outcome(ImeEditOutcome { + classified: true, + content_changed: true, + state_changed: true, + transformed: false, + selection_changed: false, + }); sel_result(s) } @@ -457,6 +600,7 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult { let latex = s.math_latex; if p::bool(v, "all").unwrap_or(false) { let mut n = 0; + let mut effect = EquationEffect::default(); let stories: Vec = std::iter::once(wordcraft_doc::StoryRef::Body).chain(s.doc.parts.keys().map(|k| wordcraft_doc::StoryRef::Part(*k))).collect(); for story in stories { @@ -468,7 +612,8 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult { .unwrap_or_default(); for off in offs { let at = Pos { story, path: path.clone(), off }; - with_eq(s, &at, |math, _| convert_math(math, professional, latex))?; + let (_, one) = with_eq(s, &at, |math, _| convert_math(math, professional, latex))?; + effect.merge(one); n += 1; } } @@ -477,23 +622,26 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult { m.pos = MathPos::default(); } s.clamp_selection(); + apply_effect(s, effect, false); return Ok(json!({"converted": n})); } let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; let mut end = 0; - with_eq(s, &at, |math, _| { + let (_, effect) = with_eq(s, &at, |math, _| { convert_math(math, professional, latex); end = me::units(&math.nodes); })?; if let Some(m) = s.math.as_mut() { m.pos = MathPos::new(Vec::new(), end); } + apply_effect(s, effect, false); result(s) } fn set_display(s: &mut Session, v: &Value) -> CmdResult { let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; - with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?; + let (_, effect) = with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?; + apply_effect(s, effect, false); result(s) } @@ -505,10 +653,11 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult { "center" => MathJc::Center, _ => MathJc::CenterGroup, }; - with_eq(s, &at, |math, display| { + let (_, effect) = with_eq(s, &at, |math, display| { math.jc = jc; *display = true; })?; + apply_effect(s, effect, false); result(s) } @@ -516,7 +665,7 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult { fn number(s: &mut Session, v: &Value) -> CmdResult { let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; let mut pos_reset = false; - with_eq(s, &at, |math, display| { + let (_, effect) = with_eq(s, &at, |math, display| { let numbered = matches!(math.nodes.as_slice(), [MNode::EqArr { rows }] if rows.iter().any(|r| wordcraft_doc::math::has_number_mark(r))); let want = p::bool(v, "value").unwrap_or(!numbered); if want && !numbered { @@ -539,6 +688,7 @@ fn number(s: &mut Session, v: &Value) -> CmdResult { m.pos = MathPos::default(); } s.clamp_selection(); + apply_effect(s, effect, false); result(s) } @@ -566,13 +716,14 @@ fn set(s: &mut Session, v: &Value) -> CmdResult { let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give linear or latex".into()))?; let end = me::units(&nodes); - with_eq(s, &at, |math, _| { + let (_, effect) = with_eq(s, &at, |math, _| { math.nodes = nodes; math.linear = false; })?; if let Some(m) = s.math.as_mut() { m.pos = MathPos::new(Vec::new(), end); } + apply_effect(s, effect, false); result(s) } @@ -614,11 +765,12 @@ fn structure(s: &mut Session, v: &Value) -> CmdResult { .ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?, }; let mut pos = None; - with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?; + let (_, effect) = with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?; let pos = pos.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?; if let Some(m) = s.math.as_mut() { m.pos = pos; } + apply_effect(s, effect, false); result(s) } @@ -863,9 +1015,16 @@ mod tests { // #447: building `a/b` up on a Left move left the document clean and Undo did nothing. let mut s = imported("a/b"); assert!(!s.dirty); + let layout_rev = s.rev(); + let ime_rev = revision(&mut s); s.run("equation.move", &json!({"dir": "left"})).unwrap(); + // Every command replaces the last outcome; sample the edit before the read-only query. + let outcome = s.ime_edit_outcome(); assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "\\frac{a}{b}"); assert!(s.dirty, "build-up marks the document dirty"); + assert!(s.rev() > layout_rev, "build-up refreshes the layout revision"); + assert!(outcome.classified && outcome.content_changed && outcome.state_changed && outcome.transformed, "{outcome:?}"); + assert!(revision(&mut s) > ime_rev, "build-up advances the unavailable IME revision"); s.run("edit.undo", &json!({})).unwrap(); s.run("equation.edit", &json!({})).unwrap(); assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "a/b", "Undo restores the plain run"); @@ -941,4 +1100,214 @@ mod tests { assert_eq!(subs, ["i", "j"], "{nodes:#?}"); assert!(nodes.iter().all(|n| matches!(n, MNode::Script { .. }) || matches!(n, MNode::Run(r) if r.text.trim().is_empty())), "{nodes:#?}"); } + + fn revision(s: &mut Session) -> u64 { + s.ime_state_revision().expect("an IME state revision") + } + + fn clear_nodes(s: &mut Session) { + let at = s.math.as_ref().expect("editing").at.clone(); + if let Some(InlineObject::Equation { math, .. }) = s.doc.para_mut(at.story, &at.path).expect("paragraph").object_at_mut(at.off) { + math.nodes.clear(); + } + } + + #[test] + fn equation_backspace_at_start_is_a_classified_noop() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "x+y"})).unwrap(); + s.run("equation.move", &json!({"dir": "home"})).unwrap(); + let before = revision(&mut s); + s.run("equation.backspace", &json!({})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified); + assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); + assert_eq!(revision(&mut s), before, "a no-op allocates no revision"); + assert_eq!(linear(&s), "x+y"); + } + + #[test] + fn lazy_normalization_is_state_not_content() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "x+1"})).unwrap(); + clear_nodes(&mut s); + // Editing parses the local tree but leaves the stored equation lazy. + s.run("equation.edit", &json!({})).unwrap(); + s.run("equation.move", &json!({"dir": "home"})).unwrap(); + let before = revision(&mut s); + s.run("equation.backspace", &json!({})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified); + assert!(!outcome.content_changed, "the normalized tree was already logical content"); + assert!(outcome.state_changed, "materializing the cached nodes is a state change"); + assert!(revision(&mut s) > before, "state changes advance the revision"); + } + + #[test] + fn empty_equation_type_is_a_classified_noop_in_both_modes() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "x"})).unwrap(); + let before = revision(&mut s); + s.run("equation.type", &json!({"text": ""})).unwrap(); + assert!(s.ime_edit_outcome().classified); + assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed); + assert_eq!(revision(&mut s), before, "built-up empty type is a no-op"); + s.run("equation.convert", &json!({"to": "linear"})).unwrap(); + let before = revision(&mut s); + s.run("equation.type", &json!({"text": ""})).unwrap(); + assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed); + assert_eq!(revision(&mut s), before, "linear empty type is a no-op"); + } + + #[test] + fn automatic_equation_rewrites_are_transformed() { + for text in ["a/b ", "\\alpha "] { + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + s.run("equation.type", &json!({"text": text})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.content_changed, "{text}: {outcome:?}"); + assert!(outcome.transformed, "{text}: {outcome:?}"); + } + } + + #[test] + fn equation_style_changes_are_state_changes_without_content() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "x"})).unwrap(); + let before = revision(&mut s); + s.run("equation.justify", &json!({"jc": "left"})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified && outcome.state_changed && !outcome.content_changed, "{outcome:?}"); + assert!(revision(&mut s) > before); + let before = revision(&mut s); + s.run("equation.display", &json!({"value": false})).unwrap(); + assert!(s.ime_edit_outcome().state_changed && !s.ime_edit_outcome().content_changed); + assert!(revision(&mut s) > before); + } + + #[test] + fn convert_all_merges_every_equations_effect() { + let mut s = session(); + s.run("document.setText", &json!({"text": "\r"})).unwrap(); + s.run("insert.equation", &json!({"linear": "a/b"})).unwrap(); + s.run("equation.convert", &json!({"to": "linear"})).unwrap(); + s.run("equation.exit", &json!({})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("insert.equation", &json!({"linear": "c/d"})).unwrap(); + s.run("equation.exit", &json!({})).unwrap(); + s.run("equation.convert", &json!({"to": "professional", "all": true})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}"); + } + + #[test] + fn equation_delete_surrounding_is_one_transaction_and_one_undo_step() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "abcd"})).unwrap(); + let depth = s.undo_depth(); + s.run("equation.deleteSurrounding", &json!({"before": 2, "after": 0})).unwrap(); + assert_eq!(linear(&s), "ab"); + assert_eq!(s.undo_depth(), depth + 1, "one undo step, not one per unit"); + assert!(s.ime_edit_outcome().classified && s.ime_edit_outcome().content_changed); + } + + #[test] + fn equation_delete_surrounding_refuses_partial_progress() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "ab"})).unwrap(); + let session_token = s.math.as_ref().unwrap().session; + let at = s.math.as_ref().unwrap().at.clone(); + let depth = s.undo_depth(); + let err = s.run("equation.deleteSurrounding", &json!({"before": 3, "after": 0})).unwrap_err(); + assert!(err.to_string().contains("cannot delete"), "{err}"); + assert_eq!(linear(&s), "ab", "the whole request is refused, no prefix applied"); + assert_eq!(s.undo_depth(), depth, "no undo step for a refused transaction"); + let m = s.math.as_ref().expect("still editing"); + assert_eq!(m.session, session_token); + assert_eq!(m.at, at); + assert!(!s.ime_edit_outcome().classified, "a refusal has no outcome"); + assert_eq!( + s.last_command.as_ref().map(|(id, _)| id.as_str()), + Some("equation.deleteSurrounding"), + "only the pre-dispatch Repeat record remains" + ); + } + + #[test] + fn equation_delete_surrounding_bounds_and_empty_equations() { + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 0})).is_err(), "zero counts are refused"); + assert!(s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).is_err(), "an empty equation refuses"); + assert!(s.math.is_some(), "the empty equation object stays"); + s.run("equation.type", &json!({"text": "a"})).unwrap(); + let depth = s.undo_depth(); + assert!(s.run("equation.deleteSurrounding", &json!({"before": 65, "after": 0})).is_err()); + assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 65})).is_err()); + assert_eq!(linear(&s), "a"); + assert_eq!(s.undo_depth(), depth); + s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).unwrap(); + assert_eq!(linear(&s), "", "exactly deleting the last unit leaves the equation object"); + assert!(s.math.is_some()); + } + + #[test] + fn equation_delete_surrounding_refuses_a_caret_only_step() { + let mut s = session(); + s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap(); + // At the end of the numerator a forward delete only steps into the denominator: the + // tree does not change, so a request that would report progress is refused whole. + s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 }; + let before = linear(&s); + let depth = s.undo_depth(); + let err = s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 1})).unwrap_err(); + assert!(err.to_string().contains("no progress"), "{err}"); + assert_eq!(linear(&s), before); + assert_eq!(s.undo_depth(), depth); + } + + /// A deletion that really erases on one side cannot buy a caret-only step on the other: + /// the mixed request is refused whole, so a following IME commit cannot land in the + /// argument the platform never asked about. + #[test] + fn equation_delete_surrounding_refuses_a_mixed_caret_only_step() { + let mut s = session(); + s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap(); + // At the end of the numerator. `before: 1` erases `a`, but `after: 1` would only step + // into the denominator (the report's mixed-direction reproducer). + s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 }; + let before = linear(&s); + let pos = s.math.as_ref().expect("editing").pos.clone(); + let depth = s.undo_depth(); + let err = s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 1})).unwrap_err(); + assert!(err.to_string().contains("no progress"), "{err}"); + assert_eq!(linear(&s), before, "the successful-looking prefix is not applied"); + assert_eq!(s.undo_depth(), depth, "no undo step for a refused mixed request"); + assert_eq!(s.math.as_ref().expect("editing").pos, pos, "the caret stays where the request found it"); + } + + #[test] + fn equation_type_caps_linear_and_built_up_input() { + for linear_mode in [false, true] { + let mut s = session(); + s.run("insert.equation", &json!({})).unwrap(); + if linear_mode { + s.run("equation.convert", &json!({"to": "linear"})).unwrap(); + } + let text = "a".repeat(crate::MAX_EQUATION_TYPE_CHARS + 5); + s.run("equation.type", &json!({"text": text})).unwrap(); + assert_eq!(linear(&s).chars().count(), crate::MAX_EQUATION_TYPE_CHARS, "linear={linear_mode}"); + } + } + + #[test] + fn equation_delete_surrounding_moves_the_caret_for_a_one_step_delete() { + let mut s = session(); + s.run("insert.equation", &json!({"linear": "abc"})).unwrap(); + s.run("equation.move", &json!({"dir": "home"})).unwrap(); + s.run("equation.deleteSurrounding", &json!({"after": 1, "before": 0})).unwrap(); + assert_eq!(linear(&s), "bc"); + assert_eq!(s.math.as_ref().unwrap().pos, MathPos::new(Vec::new(), 0)); + } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index cf59c833e8..5c31662cea 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -188,6 +188,10 @@ pub fn specs() -> Vec { let text = p::req_str(v, "text")?; let d = Document::from_text(text); s.doc.body = d.body; + // The body is new even though the document id stays: a live IME composition (or an + // equation editing session at the same offset) belongs to the old content. + s.note_body_replaced(); + s.math = None; s.sel = crate::Selection::caret(s.doc.start_of(StoryRef::Body)); sel_result(s) }) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2e71472a7d..2f7541afe8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -150,16 +150,43 @@ pub fn selected_paths(s: &Session) -> Vec { s.doc.para_paths_in(&a, &b) } -/// Delete the selection (respecting track changes); returns the collapsed position. -pub fn delete_selection(s: &mut Session) -> Result { +/// What one selection deletion actually did, for IME outcome classification. +#[derive(Clone, Debug, Default)] +pub(crate) struct SelectionDelete { + pub at: Pos, + pub content_changed: bool, + pub state_changed: bool, +} + +/// Delete the selection (respecting track changes), reporting whether committed content changed. +/// +/// Under Track Changes an all-already-deleted range is a real no-op: no revision record is +/// created and the effect says so. +pub(crate) fn delete_selection_effect(s: &mut Session) -> Result { let (a, b) = s.sel.ordered(); if a == b { - return Ok(a); + return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false }); } controls::check_delete(s, &a, &b)?; - let at = if s.doc.settings.track_changes { track_delete(s, &a, &b)? } else { s.doc.delete_range(&a, &b)? }; - s.sel = crate::Selection::caret(at.clone()); - Ok(at) + let effect = if s.doc.settings.track_changes { + if !track_delete_would_change(s, &a, &b) { + SelectionDelete { at: a.clone(), content_changed: false, state_changed: false } + } else { + let rid = new_revision(s, RevisionKind::Delete); + let deleted = track_delete_as(s, &a, &b, rid)?; + SelectionDelete { at: deleted.at, content_changed: deleted.changed, state_changed: deleted.changed } + } + } else { + let at = s.doc.delete_range(&a, &b)?; + SelectionDelete { at, content_changed: true, state_changed: true } + }; + s.sel = crate::Selection::caret(effect.at.clone()); + Ok(effect) +} + +/// Delete the selection (respecting track changes); returns the collapsed position. +pub fn delete_selection(s: &mut Session) -> Result { + delete_selection_effect(s).map(|e| e.at) } /// A revision record for the current author. @@ -217,15 +244,47 @@ pub fn remove_empty_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc: Ok(true) } +/// Whether a tracked deletion of `a..b` would change anything: any non-deleted run intersects +/// the range, or a removable/markable paragraph mark lies inside it. +pub(crate) fn track_delete_would_change(s: &Session, a: &Pos, b: &Pos) -> bool { + let paths = s.doc.paths_between(a, b); + for path in &paths { + let Some(p) = s.doc.para(a.story, path) else { continue }; + let from = if *path == a.path { a.off } else { 0 }; + let to = if *path == b.path { b.off } else { p.len() }; + if p.run_ranges().any(|(r, c)| r.end > from && r.start < to && c.del.is_none()) { + return true; + } + if *path != b.path && has_next_para(s, a.story, path) && p.mark.del.is_none() { + return true; + } + } + false +} + +/// What one tracked deletion did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct TrackDeleteEffect { + pub at: Pos, + /// At least one run was removed/marked deleted, or a paragraph mark was removed/marked. + pub changed: bool, +} + /// Tracked deletion: own insertions are removed, other text is marked deleted. +/// +/// Preflights first: an all-already-deleted range does nothing and creates no revision record. pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result { + if !track_delete_would_change(s, a, b) { + return Ok(a.clone()); + } let rid = new_revision(s, RevisionKind::Delete); - track_delete_as(s, a, b, rid) + track_delete_as(s, a, b, rid).map(|e| e.at) } /// [`track_delete`] marking the deleted text with revision `rid`. -pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result { +pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result { let author = s.author.clone(); + let mut changed = false; // Remove text this author inserted (it never existed for the reader); mark the rest. let paths = s.doc.paths_between(a, b); for path in paths.iter().rev() { @@ -248,6 +307,7 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re } else { para.format(x, y, &|c| c.del = Some(rid))?; } + changed = true; } // The paragraph mark is inside the range unless this is its last paragraph. Like text, // a mark this author inserted is removed (the paragraphs join again), any other is @@ -264,14 +324,27 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re p.mark.del = Some(rid); p.touch(); } + changed = true; } } } - Ok(a.clone()) + Ok(TrackDeleteEffect { at: a.clone(), changed }) +} + +/// What one text insertion/deletion commit changed, for IME outcome classification. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct InsertEffect { + pub content_changed: bool, + pub state_changed: bool, } /// Insert typed text at the caret (replacing the selection), with pending formatting and track changes. pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> { + insert_text(s, text, true).map(|_| ()) +} + +/// [`type_text`] with the effect the insertion had. +pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result { insert_text(s, text, true) } @@ -279,30 +352,32 @@ pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> { /// being transferred, not Enter key presses: an empty list item between them stays (Enter on /// one would end the list). pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> { - insert_text(s, text, false) + insert_text(s, text, false).map(|_| ()) } -fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> { +fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result { let mut props = s.typing_props(); if s.sel.is_collapsed() { s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus)); } // Content controls: a placeholder is replaced, locked ones refuse. - if let Some(p) = controls::prepare_edit(s)? { + let prepared = controls::prepare_edit(s)?; + let placeholder_changed = prepared.is_some(); + if let Some(p) = prepared { props = p; } // Pasted text is text: pending properties left by AutoCorrect don't give it a symbol's font (#2036). if !enter { props = props.without_symbol(); } - delete_selection(s)?; + let deletion = delete_selection_effect(s)?; // Typed text is new: never deleted, and not a formatting change of the text around it. props.fmt_change = None; // Its direction comes from its own letters, as on screen, not from the run it's typed into: // Latin typed into reopened Hebrew must not be saved as right-to-left (#1543). The DOCX // writer marks the right-to-left pieces itself. props.rtl = None; - if s.doc.settings.track_changes { + if s.doc.settings.track_changes && !text.is_empty() { props.ins = Some(new_revision(s, RevisionKind::Insert)); props.del = None; } else { @@ -311,13 +386,16 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> } let mut at = s.sel.focus.clone(); let mut first = true; + let mut inserted = false; for part in text.split(['\r', '\u{2029}']) { if !first { at = split_para_as(s, &at, enter)?; + inserted = true; } first = false; let part = part.replace('\u{000B}', "\n"); if !part.is_empty() { + inserted = true; let starts_para = direction_unset(s, &at); at = s.doc.insert_text(&at, &part, &props)?; // Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing @@ -334,7 +412,8 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> } s.sel = crate::Selection::caret(at); s.goal_x = None; - Ok(()) + let content_changed = deletion.content_changed || inserted; + Ok(InsertEffect { content_changed, state_changed: deletion.state_changed || content_changed || placeholder_changed }) } /// Is the paragraph at `at` still without letters (empty, or only spaces, digits and diff --git a/crates/engine/src/cmd/moves.rs b/crates/engine/src/cmd/moves.rs index 4e23cf8ef2..e0d1da2a39 100644 --- a/crates/engine/src/cmd/moves.rs +++ b/crates/engine/src/cmd/moves.rs @@ -58,7 +58,7 @@ pub fn cut_tracked(s: &mut Session) -> Result { } s.doc.revisions.push(Revision::new(RevisionKind::Delete, s.author.clone(), now_iso())); let rid = (s.doc.revisions.len() - 1) as u32; - let at = track_delete_as(s, &a, &b, rid)?; + let at = track_delete_as(s, &a, &b, rid)?.at; s.sel = Selection::caret(at); // Only the author's own insertions were cut: they are gone, not moved. if in_use(s, rid) { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index d83d16425c..16c9134b57 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -5,7 +5,7 @@ use wordcraft_doc::para::{COLUMN_BREAK, NB_HYPHEN, NBSP, OBJ, PAGE_BREAK, SOFT_H use wordcraft_doc::props::{Border, BorderStyle, NumRef}; use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table}; -use super::{delete_selection, non_mac, sel_result, split_para, type_text}; +use super::{delete_selection, delete_selection_effect, non_mac, sel_result, split_para, type_text, type_text_effect}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; pub fn specs() -> Vec { @@ -100,10 +100,18 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { let text = p::req_str(v, "text")?; let raw = p::bool(v, "raw").unwrap_or(false); typing_options(s, text); - let t = if raw { None } else { autoformat(s, text) }; - type_text(s, t.as_deref().unwrap_or(text))?; + let smart_quote = if raw { None } else { autoformat(s, text) }; + let effect = type_text_effect(s, smart_quote.as_deref().unwrap_or(text))?; + let mut outcome = crate::ImeEditOutcome { + classified: true, + content_changed: effect.content_changed, + state_changed: effect.state_changed, + transformed: smart_quote.is_some(), + selection_changed: false, + }; let trigger = text.chars().count() == 1 && text.chars().all(|c| c == ' ' || ",.;:!?".contains(c)); if raw || !trigger { + s.set_ime_edit_outcome(outcome); return sel_result(s); } // The paragraph as typed, so an automatic change can be undone on its own. @@ -114,8 +122,19 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { if !listed { super::tools::autocorrect(s).inspect_err(|_| s.autocap = None)?; } - if text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes { - dash_autoformat(s).inspect_err(|_| s.autocap = None)?; + let dashed = text == " " + && !listed + && s.prefs.autocorrect.enabled + && s.prefs.autocorrect.dashes + && dash_autoformat(s).inspect_err(|_| s.autocap = None)?; + // Compare the paragraph with the one exactly as typed. This is the actual effect of + // AutoCorrect (including formatting-only superscripts and links), not an undo label that + // may have been left by an earlier step. + let rewritten = typed.as_ref().is_some_and(|(para, _)| s.doc.para_at(&f) != Some(para)); + if listed || dashed || rewritten { + outcome.content_changed = true; + outcome.state_changed = true; + outcome.transformed = true; } if let Some((para, sel)) = typed && s.doc.para_at(&f) != Some(¶) @@ -133,6 +152,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { } } s.autocap.take_if(|c| c.depth == u64::MAX); + s.set_ime_edit_outcome(outcome); sel_result(s) } @@ -188,10 +208,10 @@ fn list_autoformat(s: &mut Session) -> Result { } /// "word -- word" → en dash, "word--word" → em dash (on the space after). -fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { +fn dash_autoformat(s: &mut Session) -> Result { let f = s.sel.focus.clone(); - let Some(para) = s.doc.para_at(&f) else { return Ok(()) }; - let Some(before) = para.text.get(..f.off) else { return Ok(()) }; + let Some(para) = s.doc.para_at(&f) else { return Ok(false) }; + let Some(before) = para.text.get(..f.off) else { return Ok(false) }; if before.ends_with(' ') && let Some(i) = before.trim_end().rfind(" -- ") && before.trim_end().get(i + 4..).is_some_and(|w| !w.is_empty() && !w.contains(' ')) @@ -202,6 +222,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { p.delete(i + 1, i + 3)?; p.insert_text(i + 1, "\u{2013}", &props)?; s.sel = Selection::caret(Pos { off: f.off + 1, ..f }); + return Ok(true); } else if before.ends_with(' ') && let Some(i) = before.trim_end().rfind(" - ") && !before.trim_end()[i + 3..].contains(' ') @@ -211,6 +232,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { p.delete(i + 1, i + 2)?; p.insert_text(i + 1, "\u{2013}", &props)?; s.sel = Selection::caret(Pos { off: f.off + 2, ..f }); + return Ok(true); } else if before.ends_with(' ') && let Some(i) = before.trim_end().rfind("--") && i > 0 @@ -221,8 +243,9 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { p.delete(i, i + 2)?; p.insert_text(i, "\u{2014}", &props)?; s.sel = Selection::caret(Pos { off: f.off + 1, ..f }); + return Ok(true); } - Ok(()) + Ok(false) } /// An AutoFormat that Enter applies to the paragraph just finished. @@ -689,7 +712,14 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { s.goal_x = None; s.pending = None; if !s.sel.is_collapsed() { - delete_selection(s)?; + let effect = delete_selection_effect(s)?; + s.set_ime_edit_outcome(crate::ImeEditOutcome { + classified: true, + content_changed: effect.content_changed, + state_changed: effect.state_changed, + transformed: false, + selection_changed: false, + }); return sel_result(s); } let f = s.sel.focus.clone(); @@ -700,7 +730,14 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { c.note_backspace(&a, &f); } s.sel = Selection { anchor: a, focus: f }; - delete_selection(s)?; + let effect = delete_selection_effect(s)?; + s.set_ime_edit_outcome(crate::ImeEditOutcome { + classified: true, + content_changed: effect.content_changed, + state_changed: effect.state_changed, + transformed: false, + selection_changed: false, + }); return sel_result(s); } // At the start of a paragraph. @@ -779,7 +816,14 @@ fn delete_inner(s: &mut Session, _: &Value) -> CmdResult { s.goal_x = None; s.pending = None; if !s.sel.is_collapsed() { - delete_selection(s)?; + let effect = delete_selection_effect(s)?; + s.set_ime_edit_outcome(crate::ImeEditOutcome { + classified: true, + content_changed: effect.content_changed, + state_changed: effect.state_changed, + transformed: false, + selection_changed: false, + }); return sel_result(s); } let f = s.sel.focus.clone(); @@ -787,7 +831,14 @@ fn delete_inner(s: &mut Session, _: &Value) -> CmdResult { if f.off < para.len() { let b = Pos { off: para.next_boundary(f.off), ..f.clone() }; s.sel = Selection { anchor: f, focus: b }; - delete_selection(s)?; + let effect = delete_selection_effect(s)?; + s.set_ime_edit_outcome(crate::ImeEditOutcome { + classified: true, + content_changed: effect.content_changed, + state_changed: effect.state_changed, + transformed: false, + selection_changed: false, + }); return sel_result(s); } // At the end: join with the next paragraph in the same container. diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 3403cae1fd..1ecc22cdf4 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -490,6 +490,10 @@ pub fn specs() -> Vec { s.doc.settings.protection = protection; s.doc.settings.protect_hash = protect_hash; s.doc.settings.protect_formatting = protect_formatting; + // The restored document reuses the open document's id; the body and any equation + // editing session are replaced. + s.note_body_replaced(); + s.math = None; s.touch(); s.clamp_selection(); } @@ -1205,6 +1209,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult { let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; s.doc = doc; s.doc.ensure_nonempty(); + s.note_body_replaced(); + s.math = None; } let level = p::str(v, "level").unwrap_or("word"); let opts = CompareOptions { @@ -1285,6 +1291,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult { } s.doc.body = out; s.doc.ensure_nonempty(); + s.note_body_replaced(); + s.math = None; if show_in != "original" || p::str(v, "original").is_some() { // The result is a new, untitled document: saving it never overwrites either file. s.path = None; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0158a6054f..4a3535b395 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,8 +28,38 @@ pub use io::Password; pub use keymap::KeyMap; pub use ribbon::RibbonLayout; pub use session::{ - CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale, + CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, + next_math_session, set_system_locale, }; +/// The outcome a command reports for IME receipt classification. +/// +/// A command that can be called by an input method must classify its committed effects. Only a +/// classified content change without an automatic rewrite may mint an IME receipt; any other +/// committed state change still advances the IME state revision and therefore makes a mixed +/// frame `Other` rather than certifying it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ImeEditOutcome { + /// The handler explicitly classified the committed effects. + pub classified: bool, + /// IME-visible content changed. Only this gates an IME receipt. + pub content_changed: bool, + /// Any committed document state changed, including style or representation changes. + pub state_changed: bool, + /// An automatic engine rewrite was applied (AutoCorrect, build-up, AutoFormat). + pub transformed: bool, + /// The caret or selection changed. Filled in by [`Session::run`]. + pub selection_changed: bool, +} + +/// The largest text one `equation.type` call inserts, in characters. The engine truncates to this +/// in both linear and built-up modes; the UI refuses an IME commit above it instead of inserting a +/// prefix. +pub const MAX_EQUATION_TYPE_CHARS: usize = 10_000; + +/// The largest number of unit steps one `equation.deleteSurrounding` request may take on either +/// side of the caret. A hostile IME count must not become an unbounded command. +pub const MAX_EQUATION_DELETE_UNITS: usize = 64; + pub use wordcraft_doc as doc; pub use wordcraft_geom::Region; pub use wordcraft_layout as layout; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fb9999c74e..54e89ac112 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -23,7 +23,7 @@ use wordcraft_doc::props::CharProps; use wordcraft_doc::{Document, PartKind, Path, Pos, StoryRef, Table}; use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewMode}; -use crate::{CmdError, Registry}; +use crate::{CmdError, ImeEditOutcome, Registry}; /// A selection: anchor (where it started) and focus (where the caret is). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -323,8 +323,9 @@ struct Undo { /// What [`Session::restore`] needs to take commands back exactly: the document, the selection, /// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the -/// open typing group, a pending [`Session::join_next_undo`] and the dirty flag. See -/// [`Session::edit_snapshot`]. +/// open typing group, the pending +/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises +/// and the dirty flag. See [`Session::edit_snapshot`]. pub struct EditSnapshot { doc: Document, sel: Selection, @@ -337,7 +338,13 @@ pub struct EditSnapshot { redo: Vec>, typing_open: bool, join_next: bool, + equation_join_next: bool, + /// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects. + ime_text_join: Option, dirty: bool, + /// The body's replacement epoch then: a previewed replacement that is rolled back is not a + /// replacement after all. + ime_body_epoch: u64, } /// Undo steps that the commands run between [`Session::edit_snapshot`] and [`Session::restore`] @@ -416,6 +423,14 @@ pub struct Session { typing_open: bool, /// The next mutating command joins the previous undo step (later frames of a drag). join_next: bool, + /// The next equation typing command joins a successful equation `deleteSurrounding`'s undo + /// step. Unlike [`Self::join_next`], any other command consumes and discards it, so an + /// unrelated Bold or document replacement can never be merged into the deletion. + equation_join_next: bool, + /// The next `text.insert` at this exact selection joins a successful body + /// `DeleteSurrounding`'s undo step. Any other command consumes and discards it, so an + /// intervening edit owns its own checkpoint instead of being merged into the deletion. + ime_text_join: Option, /// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857). pub(crate) autocap: Option, rev: u64, @@ -426,6 +441,31 @@ pub struct Session { /// Which document this is: changes when [`Session::set_document`] replaces it (open, new, /// mail merge, recover), never on an edit. document_id: u64, + /// How many times the document's body was replaced under the same [`Self::document_id`] + /// (`document.setText`, version restore, compare, Undo/Redo). It is the body's IME + /// replacement epoch: a composition must not survive the swap, even when the new body has + /// the same text or the same bounded surrounding window. + ime_body_epoch: u64, + /// Engine-owned, checked, process-monotonic revision for IME receipts whose target cannot + /// provide surrounding text. `None` means the allocator is exhausted (fail closed). + ime_state_revision: Option, + /// The caret/equation-session token the revision was last allocated for. It covers selection + /// and caret changes without walking the document. + ime_state_seen: Option, + /// The current command's explicit IME effect classification. + ime_edit_outcome: ImeEditOutcome, + /// Simulation of a globally exhausted IME revision allocator for front-end producer tests. + /// Only [`Self::exhaust_ime_state_revision`] sets it; production builds do not compile it, + /// and it only makes receipt classification fail closed. + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: bool, + /// A successful non-IME command changed document state since the last IME observation. It is + /// drained before publishing a receipt and at observation time; it is never cleared by a later + /// command. It covers mixed formatting/IME frames when available stamps ignore the revision. + pending_ime_application_change: bool, + /// Whether the current command was entered through [`Session::run_ime`]. Nested commands + /// inherit the origin. + ime_origin: bool, cache: LayoutCache, /// (revision, width, read height, view, read line spacing, layout, proofing, outline, /// (deletions left out, deletions marked Hidden, body deletions in balloons), numeral display, the word being typed). @@ -515,13 +555,43 @@ pub struct Session { /// The number of edit locations Go Back cycles through (Word: four). const EDIT_MARKS: usize = 4; +/// The caret identity used for the process-monotonic IME state revision. +#[derive(Clone, Debug, PartialEq, Eq)] +enum ImeCaretState { + Body(Selection), + Equation { session: u64, pos: wordcraft_doc::math_edit::MathPos }, +} + +/// One checked allocation from a process-monotonic IME revision counter. +fn allocate_ime_state_revision(counter: &std::sync::atomic::AtomicU64) -> Option { + counter.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() +} + +static IME_STATE_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +fn next_ime_state_revision() -> Option { + allocate_ime_state_revision(&IME_STATE_REVISION) +} + /// The equation being edited. #[derive(Clone, Debug, PartialEq)] pub struct MathEdit { - /// The equation object (its U+FFFC). + /// The equation object (its U+FFFC). This moves as the document is edited, so it is not an + /// IME owner; [`Self::session`] is. pub at: Pos, /// The caret inside it. pub pos: wordcraft_doc::math_edit::MathPos, + /// A stable token for this editing session, unique across documents and re-entry. + pub session: u64, +} + +/// Next stable token for an equation editing session, or `None` when the counter is exhausted. +/// +/// Exhaustion fails closed: a repeated token could let a cached IME context outlive the +/// equation it described, so the caller refuses to start editing instead. +pub fn next_math_session() -> Option { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() } /// Editing preferences that persist between runs (the front end saves and restores them). @@ -704,10 +774,20 @@ impl Session { redo: Vec::new(), typing_open: false, join_next: false, + equation_join_next: false, + ime_text_join: None, autocap: None, rev: 1, protect_facts: std::sync::Mutex::new(None), document_id: 1, + ime_body_epoch: 0, + ime_state_revision: None, + ime_state_seen: None, + ime_edit_outcome: ImeEditOutcome::default(), + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: false, + pending_ime_application_change: false, + ime_origin: false, cache: LayoutCache::new(), layout: None, typing: None, @@ -814,6 +894,29 @@ impl Session { pub fn document_id(&self) -> u64 { self.document_id } + + /// How many times the body was replaced under this [`Self::document_id`]. + /// + /// Front ends use it as the body's IME replacement epoch and to tell a body swap from an + /// ordinary edit; it advances for `document.setText`, a version restore, a compare and + /// Undo/Redo, never for typing. + pub fn ime_body_epoch(&self) -> u64 { + self.ime_body_epoch + } + + /// Note that a command replaced the document or its whole body under this document id. + /// + /// The counter fails closed: if it were ever exhausted (unreachable in practice), the + /// document owner id advances too, so a cached IME context still cannot be reused. + pub fn note_body_replaced(&mut self) { + self.ime_body_epoch = match self.ime_body_epoch.checked_add(1) { + Some(epoch) => epoch, + None => { + self.document_id = self.document_id.saturating_add(1); + u64::MAX + } + }; + } pub fn touch(&mut self) { self.rev = self.rev.wrapping_add(1); self.dirty = true; @@ -1051,6 +1154,36 @@ impl Session { pub fn join_next_undo(&mut self) { self.join_next = true; } + /// Make the next equation typing command (`equation.type` or `equation.backspace`) join the + /// previous equation deletion's undo step. Unlike [`Self::join_next_undo`], the promise is + /// not generic: any other command consumes and discards it, so unrelated edits get their own + /// checkpoint. A deletion-only IME request therefore cannot leak a join into the next edit. + pub fn join_next_equation_edit(&mut self) { + self.equation_join_next = true; + } + /// Discard an equation replacement join that received no replacement (for example an empty + /// commit), so it cannot join a later composition's typing. + pub fn clear_equation_join(&mut self) { + self.equation_join_next = false; + } + /// Make the next `text.insert` at the current selection join a successful body + /// `DeleteSurrounding`'s undo step. Unlike [`Self::join_next_undo`], the promise is scoped to + /// that exact replacement: any other command consumes and discards it, so an intervening + /// ordinary edit gets its own checkpoint. + pub fn join_next_ime_text_replacement(&mut self) { + self.ime_text_join = Some(self.sel.clone()); + } + /// Discard a body replacement join that received no replacement (an empty or refused + /// commit, a new request or a refusal), so it cannot join a later composition. + pub fn clear_ime_text_join(&mut self) { + self.ime_text_join = None; + } + /// Discard every pending IME replacement join. A refused surrounding request supersedes the + /// composition it interrupts, including any promise from an earlier accepted request. + pub fn clear_ime_joins(&mut self) { + self.equation_join_next = false; + self.ime_text_join = None; + } /// Stamp the document's save metadata (modified time, last modified by, revision; created /// when it has none) the way Word does on every save. `file.save` calls it, and so does any /// front end that writes the bytes itself (the web build's download, #262). Like Word, the @@ -1145,6 +1278,9 @@ impl Session { column: std::mem::replace(&mut self.column, u.column), }; self.redo.push(Arc::new(cur)); + // Undo swaps the whole body back: a composition belongs to the text it showed. + self.note_body_replaced(); + self.refresh_math_session(); self.touch(); true } @@ -1164,9 +1300,28 @@ impl Session { column: std::mem::replace(&mut self.column, u.column), }; self.history.push(Arc::new(cur)); + self.note_body_replaced(); + self.refresh_math_session(); self.touch(); true } + /// An equation's editing session cannot ride across Undo/Redo: the equation tree, or the + /// object at that offset, may be a different one now. A new session token starts a new IME + /// context; if tokens are exhausted, the equation is left (fail closed). + fn refresh_math_session(&mut self) { + if self.math.is_none() { + return; + } + match next_math_session() { + Some(session) => { + if let Some(m) = self.math.as_mut() { + m.session = session; + } + } + None => self.math = None, + } + } + /// Drop history (after open / new). pub fn reset_history(&mut self) { self.autocap = None; @@ -1220,12 +1375,16 @@ impl Session { /// Replace the document (open/new). pub fn set_document(&mut self, doc: Document) { self.autocap = None; - self.document_id = self.document_id.wrapping_add(1); + // Never reuse an id after wrapping: the IME context is keyed by it. + self.document_id = self.document_id.saturating_add(1); + self.note_body_replaced(); self.bib_style = doc_bib_style(&doc); self.doc = doc; self.doc.ensure_nonempty(); self.password = None; self.file_name = None; + // The old document's equation is gone: its editing session cannot carry over. + self.math = None; self.sel = Selection::caret(self.doc.start_of(StoryRef::Body)); self.pending = None; self.typing = None; @@ -1249,7 +1408,8 @@ impl Session { /// may want to take it back without a trace (cheap: the document's blocks are shared). /// /// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near - /// the limit), `redo`, the open typing group, a pending `join_next_undo` and `dirty`. `rev`, + /// the limit), `redo`, the open typing group, the pending `join_next_undo` and + /// `join_next_equation_edit` promises and `dirty`. `rev`, /// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not /// covered: a caller that lets the command change them (the view, the find state, the /// clipboard, macros, the file path…) saves and puts them back itself. @@ -1294,6 +1454,8 @@ impl Session { redo, typing_open, join_next, + equation_join_next, + ime_text_join, autocap: _, rev: _, protect_facts: _, @@ -1313,6 +1475,16 @@ impl Session { merge: _, ui_requests: _, document_id: _, + ime_body_epoch, + // The IME revision and its caret token are process-global evidence, not document + // state: a rollback must not reuse a revision. + ime_state_revision: _, + ime_state_seen: _, + ime_edit_outcome: _, + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: _, + pending_ime_application_change: _, + ime_origin: _, read_aloud: _, prefs: _, system_region: _, @@ -1348,7 +1520,10 @@ impl Session { redo: redo.clone(), typing_open: *typing_open, join_next: *join_next, + equation_join_next: *equation_join_next, + ime_text_join: ime_text_join.clone(), dirty: *dirty, + ime_body_epoch: *ime_body_epoch, } } @@ -1356,7 +1531,20 @@ impl Session { /// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout /// is recomputed. pub fn restore(&mut self, snap: EditSnapshot) { - let EditSnapshot { doc, sel, history_len, evicted, head, redo, typing_open, join_next, dirty } = snap; + let EditSnapshot { + doc, + sel, + history_len, + evicted, + head, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + dirty, + ime_body_epoch, + } = snap; let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX); if gone > 0 { let back = gone.min(head.len()); @@ -1371,6 +1559,9 @@ impl Session { self.redo = redo; self.typing_open = typing_open; self.join_next = join_next; + self.equation_join_next = equation_join_next; + self.ime_text_join = ime_text_join; + self.ime_body_epoch = ime_body_epoch; self.touch(); self.dirty = dirty; } @@ -1417,13 +1608,105 @@ impl Session { f } + /// The current command's explicit IME effect classification. + pub fn ime_edit_outcome(&self) -> ImeEditOutcome { + self.ime_edit_outcome + } + + /// Set the effect classification from a command handler. [`Session::run`] fills + /// `selection_changed` and enforces the content/state invariant after the command. + pub(crate) fn set_ime_edit_outcome(&mut self, outcome: ImeEditOutcome) { + self.ime_edit_outcome = outcome; + } + + /// Take the pending application-origin change signal. The UI owns one session, so a simple + /// bool is enough; it deliberately survives later commands until drained. + pub fn take_pending_ime_application_change(&mut self) -> bool { + std::mem::take(&mut self.pending_ime_application_change) + } + + /// Simulate a globally exhausted IME revision allocator in this session (test support). + /// + /// This only ever makes receipt classification fail closed. It exists so front-end producer + /// tests can exercise the real `ime_stamp` path instead of hand-building an `Unavailable` + /// stamp; production builds do not compile it. + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn exhaust_ime_state_revision(&mut self) { + self.ime_state_exhausted = true; + self.ime_state_revision = None; + } + + /// The checked, process-monotonic revision for IME receipts with unavailable surrounding + /// text. Selection/caret motion allocates on the next call; `None` means exhausted. + pub fn ime_state_revision(&mut self) -> Option { + self.sync_ime_caret_state(); + self.ime_state_revision + } + + fn current_ime_caret_state(&self) -> ImeCaretState { + match &self.math { + Some(m) => ImeCaretState::Equation { session: m.session, pos: m.pos.clone() }, + None => ImeCaretState::Body(self.sel.clone()), + } + } + + /// Allocate the next IME state revision, or store `None` when the allocator is exhausted. + /// Test support can pin exhaustion for this session so a front-end producer test sees the + /// real `ime_stamp`/receipt path fail closed. + fn reset_ime_state_revision(&mut self) { + #[cfg(any(test, feature = "test-support"))] + if self.ime_state_exhausted { + self.ime_state_revision = None; + return; + } + // v5: allocation failure must store `None`, never retain a stale revision. + self.ime_state_revision = next_ime_state_revision(); + } + + fn sync_ime_caret_state(&mut self) { + let state = self.current_ime_caret_state(); + if self.ime_state_seen.as_ref() != Some(&state) { + self.ime_state_seen = Some(state); + self.reset_ime_state_revision(); + } + } + + /// Run one command as the input method's own transaction. + /// + /// Commands run inside this scope are IME-origin: a successful command run *outside* this + /// scope sets the pending application-change signal, but nothing inside it does. Nested commands inherit + /// the origin. The previous origin is restored on every return, including unknown, disabled or + /// refused commands. + pub fn run_ime(&mut self, id: &str, params: &Value) -> Result { + let previous = std::mem::replace(&mut self.ime_origin, true); + let result = self.run(id, params); + self.ime_origin = previous; + result + } + /// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and /// control channel). Mutating commands are undoable; a failed command leaves the document as /// it was; a panic inside a command becomes an error. pub fn run(&mut self, id: &str, params: &Value) -> Result { + // Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled, + // protected) leave it at the default. + self.ime_edit_outcome = ImeEditOutcome::default(); + // A rolled-back transaction must not leave a successful application-change signal from a + // nested command that ran before the failure. + let pending_application_change_before = self.pending_ime_application_change; let join = std::mem::take(&mut self.join_next); + // The equation replacement promise is not a generic undo join: only the command that + // actually types into the equation may consume it. Any other command discards it. + let equation_join = std::mem::take(&mut self.equation_join_next); + // The body replacement promise is likewise scoped: only the `text.insert` at the + // selection the deletion left may consume it. + let ime_text_join = self.ime_text_join.take(); let reg = self.registry.clone(); let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) }; + let join = join + || (equation_join && matches!(id, "equation.type" | "equation.backspace")) + || (id == "text.insert" && ime_text_join.as_ref().is_some_and(|sel| sel == &self.sel)); if let Some(why) = (spec.enabled)(self) { return Err(CmdError::Disabled(format!("{id}: {why}"))); } @@ -1461,6 +1744,10 @@ impl Session { } // The undo stacks are snapshotted whole, not as a length: commands run nested commands, // whose checkpoints, undos and redos (and evictions at the limit) must be put back too. + let ime_epoch_before = spec.mutates.then_some(self.ime_body_epoch); + // Commands may leave an equation (a whole-body replacement) even after they fail; put + // the editing session back with the document. + let math_before = spec.mutates.then(|| self.math.clone()); let before_doc = if spec.mutates { Some(( self.doc.clone(), @@ -1515,6 +1802,7 @@ impl Session { let default_language_before = self.prefs.default_language.clone(); let run = spec.run; let mutates = spec.mutates; + let ime_caret_before = self.current_ime_caret_state(); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params))); let mut result = match result { Ok(r) => r, @@ -1547,6 +1835,25 @@ impl Session { } } self.clamp_selection(); + let mut ime_outcome = self.ime_edit_outcome; + if ime_outcome.content_changed { + ime_outcome.state_changed = true; + } + // An unclassified mutating command is conservatively a state change. It can make + // a frame Other, but it can never mint a receipt because `classified` is false. + if !ime_outcome.classified && mutates { + ime_outcome.state_changed = true; + } + ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before; + self.ime_edit_outcome = ime_outcome; + if ime_outcome.state_changed { + let caret = self.current_ime_caret_state(); + self.ime_state_seen = Some(caret); + self.reset_ime_state_revision(); + } + if !self.ime_origin && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) { + self.pending_ime_application_change = true; + } if sel_before.focus.story == StoryRef::Body && self.in_header_footer() { self.hf_return = Some(sel_before.clone()); } @@ -1569,8 +1876,16 @@ impl Session { self.note_typing(id, before_doc.as_ref().map(|b| &b.0), &sel_before); } Err(e) => { + self.ime_edit_outcome = ImeEditOutcome::default(); + self.pending_ime_application_change = pending_application_change_before; self.autocap = None; self.prefs.default_language = default_language_before; + if let Some(epoch) = ime_epoch_before { + self.ime_body_epoch = epoch; + } + if let Some(math) = math_before { + self.math = math; + } if let Some(versions) = versions_before { self.versions = versions; } @@ -1682,3 +1997,84 @@ pub fn blank_document(region: &wordcraft_geom::Region) -> Document { d.last_section.page_h = h; d } + +#[cfg(test)] +mod ime_revision_tests { + use super::*; + use serde_json::json; + + #[test] + fn checked_revision_allocation_refuses_to_wrap() { + let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1); + assert_eq!(allocate_ime_state_revision(&counter), Some(u64::MAX - 1)); + assert_eq!(allocate_ime_state_revision(&counter), None, "exhaustion fails closed"); + assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), u64::MAX); + } + + #[test] + fn selection_and_caret_motion_advance_the_unavailable_revision() { + let mut s = Session::new(Document::from_text("hello")); + let first = s.ime_state_revision().expect("a revision"); + assert_eq!(s.ime_state_revision(), Some(first), "an unchanged caret reuses its revision"); + s.sel = Selection::caret(Pos::body(0, 2)); + let moved = s.ime_state_revision().expect("a revision"); + assert!(moved > first, "a direct selection assignment is caught"); + } + + #[test] + fn successful_state_changes_allocate_but_selection_only_commands_do_not() { + let mut s = Session::new(Document::from_text("hello")); + let first = s.ime_state_revision().expect("a revision"); + s.run("select.all", &json!({})).unwrap(); + // `run` does not allocate for selection-only work; the next stamp sees the new caret. + assert!(s.ime_state_revision() > Some(first)); + let selected = s.ime_state_revision().expect("a revision"); + s.run("para.alignCenter", &json!({})).unwrap(); + assert!(s.ime_state_revision() > Some(selected), "a committed state change advances"); + } + + #[test] + fn exhausted_allocator_keeps_a_real_equation_commit_uncertifiable() { + let mut s = Session::new(Document::new()); + s.run("insert.equation", &json!({})).unwrap(); + s.exhaust_ime_state_revision(); + assert_eq!(s.ime_state_revision(), None, "exhaustion fails closed"); + s.run_ime("equation.type", &json!({"text": "x"})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}"); + assert_eq!(s.ime_state_revision(), None, "a real producer command cannot mint a revision"); + } + + #[test] + fn application_origin_signal_is_taken_once_and_ime_commands_do_not_set_it() { + let mut s = Session::new(Document::from_text("hello")); + s.run("para.alignCenter", &json!({})).unwrap(); + assert!(s.take_pending_ime_application_change(), "a normal command signals"); + assert!(!s.take_pending_ime_application_change(), "taking clears it"); + s.run_ime("text.insert", &json!({"text": "x"})).unwrap(); + assert!(!s.take_pending_ime_application_change(), "an IME transaction is not an application change"); + s.run("text.insert", &json!({"text": "y"})).unwrap(); + assert!(s.take_pending_ime_application_change()); + } + + #[test] + fn ime_origin_is_restored_and_nested_commands_inherit_it() { + let mut s = Session::new(Document::from_text("hello")); + s.run("tools.macros", &json!({"define": {"name": "centre", "steps": [{"command": "para.alignCenter", "params": {}}]}})).unwrap(); + s.take_pending_ime_application_change(); + + // Unknown and refused commands restore the normal application origin; the failed run + // itself leaves no application-change signal. + assert!(s.run_ime("no.such.command", &json!({})).is_err()); + assert!(!s.take_pending_ime_application_change(), "an unknown IME command is not an application change"); + assert!(s.run_ime("equation.backspace", &json!({})).is_err()); + assert!(!s.take_pending_ime_application_change(), "a refused IME command is not an application change"); + + // A nested `run` inherits the IME origin from `run_ime`; the same macro run normally is + // an application change. + s.run_ime("tools.macros", &json!({"run": "centre"})).unwrap(); + assert!(!s.take_pending_ime_application_change(), "the nested command inherits the IME origin"); + s.run("tools.macros", &json!({"run": "centre"})).unwrap(); + assert!(s.take_pending_ime_application_change(), "a normal macro run is application-origin"); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 44747c0ab9..3aac3b104b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9536,3 +9536,99 @@ fn export_png_refuses_a_path_that_is_not_png() { } let _ = std::fs::remove_dir_all(&dir); } + +/// The body's IME replacement epoch advances when the whole body is swapped (setText, Undo/Redo) +/// and stays put for ordinary edits; an equation editing session cannot ride across a replacement. +#[test] +fn ime_body_epoch_marks_body_replacements_not_edits() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "one"})); + let first = s.ime_body_epoch(); + run(&mut s, "text.insert", json!({"text": " two"})); + assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement"); + + run(&mut s, "document.setText", json!({"text": "same text"})); + let replaced = s.ime_body_epoch(); + assert!(replaced > first, "setText replaces the body under the same document id"); + assert_eq!(s.document_id(), 1, "the document id itself is unchanged"); + + // The same text again still replaces the body: a composition must not survive on content + // equality. + run(&mut s, "document.setText", json!({"text": "same text"})); + assert!(s.ime_body_epoch() > replaced); + + // Undo swaps the body back. + let before_undo = s.ime_body_epoch(); + assert!(s.undo()); + assert!(s.ime_body_epoch() > before_undo, "Undo replaces the body"); +} + +#[test] +fn a_body_replacement_ends_equation_editing() { + let mut s = s(); + run(&mut s, "insert.equation", json!({"linear": "x+y"})); + assert!(s.math.is_some()); + run(&mut s, "document.setText", json!({"text": "x+y"})); + assert!(s.math.is_none(), "the old equation's editing session is gone"); +} + +/// A failed command that cleared the equation session puts it back with the document. +#[test] +fn a_failed_replacement_keeps_equation_editing() { + let mut s = s(); + run(&mut s, "insert.equation", json!({"linear": "x+y"})); + let session = s.math.as_ref().map(|m| m.session); + assert!(session.is_some()); + // Missing `text` is rejected before any mutation. + assert!(s.run("document.setText", &json!({})).is_err()); + assert_eq!(s.math.as_ref().map(|m| m.session), session); +} + +/// Track Changes: deleting a range whose text is already marked deleted is a real no-op. It +/// creates no revision record, no undo-visible change and no IME revision. +#[test] +fn deleting_an_already_deleted_tracked_range_is_a_classified_noop() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "abc"})); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; + run(&mut s, "text.backspace", json!({})); + let revisions = s.doc.revisions.len(); + let revision = s.ime_state_revision().expect("an IME revision"); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; + run(&mut s, "text.backspace", json!({})); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified, "{outcome:?}"); + assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); + assert_eq!(s.doc.revisions.len(), revisions, "no unused Delete revision is appended"); + assert_eq!(s.ime_state_revision(), Some(revision), "the no-op did not move the settled caret"); + s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) }; + assert!(s.ime_state_revision() > Some(revision), "re-selecting moves the caret, not the document"); +} + +/// `text.insert` classifies the automatic rewrites its own handler performs: smart quotes, +/// list autoformat and AutoCorrect are `transformed`, not the IME's literal insert. +#[test] +fn text_insert_marks_automatic_rewrites_as_transformed() { + let mut session = s(); + run(&mut session, "text.insert", json!({"text": "\""})); + assert!(session.ime_edit_outcome().transformed, "smart quote: {:?}", session.ime_edit_outcome()); + assert_eq!(text(&session), "\u{201C}"); + + let mut session = s(); + for c in ["W", "o", "r", "d", " ", "(", "c", ")", " "] { + run(&mut session, "text.insert", json!({"text": c})); + } + assert!(session.ime_edit_outcome().transformed, "AutoCorrect: {:?}", session.ime_edit_outcome()); + + let mut session = s(); + run(&mut session, "text.insert", json!({"text": "*"})); + run(&mut session, "text.insert", json!({"text": " "})); + assert!(session.ime_edit_outcome().transformed, "list AutoFormat: {:?}", session.ime_edit_outcome()); + + let mut session = s(); + run(&mut session, "text.insert", json!({"text": "A--b"})); + run(&mut session, "text.insert", json!({"text": " "})); + assert!(session.ime_edit_outcome().transformed, "dash AutoFormat: {:?}", session.ime_edit_outcome()); + assert_eq!(text(&session), "A\u{2014}b "); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 03ebb9a0f4..069c23964e 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1998,10 +1998,19 @@ mod tests { } // A Thai font with OpenType tables, as the per-character fallback prefers. assert!(f.has_layout_tables(), "{} shapes Thai", f.family); - // Without system fonts no bundled face covers Thai: the caller keeps the per-character path. + } + + #[test] + fn a_run_without_a_covering_face_keeps_the_per_character_path() { + // Use only this repository's Latin faces. Optional craft-fonts inputs can contain a + // complete Thai face even when system font loading is disabled. let bundled = FontDb::with_font_dirs(Vec::new()); + bundled.faces.write().unwrap().retain(|face| BUNDLED.iter().any(|bytes| std::ptr::eq(face.data(), *bytes))); bundled.set_system_fallback(false); let latin = bundled.face(FALLBACK_FAMILY, "Regular"); + let chars: Vec = THAI_RUN.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(!bundled.faces.read().unwrap().is_empty(), "the negative fixture retains real Latin faces"); + assert!(bundled.faces.read().unwrap().iter().all(|face| !face.covers('น'))); assert!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_none()); } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 7bf59bd5fc..d03f42145b 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -129,7 +129,8 @@ pub enum ClKind { Marker, } -/// The smallest unit of layout: a grapheme cluster (or object, tab, break). +/// One drawing segment of a grapheme cluster (or object, tab, break). +/// Font/style changes may split its drawing; paragraph edit/wrap boundaries keep it atomic. #[derive(Clone, Debug)] pub struct Cluster { pub start: usize, @@ -357,6 +358,9 @@ pub struct ParaLayout { /// Sum of line heights (without space before/after). pub height: f32, pub text_len: usize, + /// Drawing segments that start inside a whole-paragraph grapheme/complex-script cluster. + /// Font or run formatting can split the drawing, but cannot introduce an edit/wrap stop. + joined_cluster_starts: Vec, /// Paragraph contains fields whose text depends on the page. pub has_page_fields: bool, /// Note references (part id) in this paragraph, by cluster index. @@ -1893,6 +1897,19 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { Label { text: shown(text), ascent, style, glyphs, runs, x: 0.0, width: x } }); + // Shaping happens separately per font/style segment. Segment-local grapheme starts can + // therefore land inside a paragraph grapheme (e.g. an emoji whose components use different + // fallback faces). Retain the drawing segments and record only those illegal edit stops. + let joined_cluster_starts = if p.text.is_ascii() { + Vec::new() + } else { + let boundaries = wordcraft_doc::cluster::cluster_starts(&p.text); + let mut joined: Vec = + b.clusters.iter().filter_map(|c| (c.start < p.text.len() && boundaries.binary_search(&c.start).is_err()).then_some(c.start)).collect(); + joined.sort_unstable(); + joined.dedup(); + joined + }; let mut pl = ParaLayout { rp, styles: b.styles, @@ -1903,6 +1920,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { label, height: 0.0, text_len: p.text.len(), + joined_cluster_starts, has_page_fields, notes, issues: env.proofing.map(|o| proof_issues(p, &runs, &o, env.typing_word, is_list_item(doc, p))).unwrap_or_default(), @@ -1927,6 +1945,11 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let mark_left_out = env.hide_deleted.hides(doc, mark_rc.del) || (mark_rc.hidden && !env.show_hidden); let gone = mark_left_out && (p.text.is_empty() || left.first() == Some(&(0..p.text.len()))); pl.left_out = left; + // Neither ordinary nor hyphenated wrapping may split a drawing group inside a grapheme. + for c in &mut pl.clusters { + c.break_after &= pl.joined_cluster_starts.binary_search(&c.end).is_err(); + } + pl.hyph_after.retain(|&k| pl.clusters.get(k as usize).is_some_and(|c| pl.joined_cluster_starts.binary_search(&c.end).is_err())); for k in pl.hyph_after.clone() { if let Some(c) = pl.clusters.get_mut(k as usize) { c.break_after = true; @@ -2253,24 +2276,27 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio end = LineEnd::Wrap; break; } + // A grapheme can have several drawing segments; fit it as one unit while keeping + // each segment's font, glyphs and position for painting. + let advance = pl.atomic_advance(j); // Text after a right/centre tab grows leftwards into the tab's space first. let absorbs = pending_tab.is_some_and(|(tj, stop, _)| { let room = pl.clusters.get(tj).map(|t| t.adv).unwrap_or(0.0); match stop.align { - TabAlign::Right | TabAlign::Decimal => room >= c.adv, - TabAlign::Center => room >= c.adv / 2.0 && x + c.adv / 2.0 <= right_edge + 0.01, + TabAlign::Right | TabAlign::Decimal => room >= advance, + TabAlign::Center => room >= advance / 2.0 && x + advance / 2.0 <= right_edge + 0.01, _ => false, } }); - let shrink = if shrink_spaces && !has_tab { space_room(space_w, x + c.adv - pl.auto_space_after(j) - word_from) } else { 0.0 }; + let shrink = if shrink_spaces && !has_tab { space_room(space_w, x + advance - pl.auto_space_after(j) - word_from) } else { 0.0 }; let fits = - absorbs || x + c.adv - pl.auto_space_after(j) <= right_edge + shrink + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker; + absorbs || x + advance - pl.auto_space_after(j) <= right_edge + shrink + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker; // A line's first item stays on it even when it doesn't fit; markers before it // (a content control's start, a bookmark) and floating objects' anchors take no room // and don't count (Word: a picture a little wider than the line, after an anchor, // stays on the first line rather than leaving the anchor a line of its own). let takes_room = |c: &Cluster| c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0); - if !fits && (c0..j).any(|k| pl.clusters.get(k).is_some_and(takes_room)) { + if !fits && pl.is_edit_boundary(c.start) && (c0..j).any(|k| pl.clusters.get(k).is_some_and(takes_room)) { // Wrap: back up to the last break opportunity on this line. end = LineEnd::Wrap; // Hyphenate only when the plain break leaves more than the hyphenation zone empty. @@ -2305,11 +2331,14 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio // hyphenation point. let full = base_right - row_lo; skip = narrowed && skipped < 50 && widths.get(c0).is_some_and(|w| *w <= full + 0.01); - // Never split a ligature's letters across lines: the glyph sits on the first - // letter, so back up to it (unless it starts the line). + // Never split a ligature's letters or a grapheme's drawing segments across + // lines: back up to their first segment (unless it starts the line). let glyphless = |k: usize| pl.clusters.get(k).is_some_and(|c| c.kind == ClKind::Text && c.g1 <= c.g0); let mut b = j; - while b > c0 + 1 && glyphless(b) { + while b > c0 + 1 && (glyphless(b) || pl.clusters.get(b).is_some_and(|c| !pl.is_edit_boundary(c.start))) { + b -= 1; + } + while b > c0 && pl.clusters.get(b).is_some_and(|c| !pl.is_edit_boundary(c.start)) { b -= 1; } if b < j @@ -3001,6 +3030,27 @@ fn resolve_tab(pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>, } impl ParaLayout { + fn is_edit_boundary(&self, off: usize) -> bool { + self.joined_cluster_starts.binary_search(&off).is_err() + } + + /// At a legal group start, include every following drawing segment of that grapheme. + /// Interior segments keep their own advance; they are never independent wrap candidates. + fn atomic_advance(&self, k: usize) -> f32 { + let Some(c) = self.clusters.get(k) else { return 0.0 }; + let mut advance = c.adv; + if self.joined_cluster_starts.is_empty() || !self.is_edit_boundary(c.start) { + return advance; + } + for next in self.clusters.get(k + 1..).into_iter().flatten() { + if self.is_edit_boundary(next.start) { + break; + } + advance += next.adv; + } + advance + } + /// The automatic space in cluster `k`'s advance ([`auto_space`]), 0 without one. fn auto_space_after(&self, k: usize) -> f32 { self.auto_space.binary_search_by_key(&k, |a| a.0).ok().and_then(|i| self.auto_space.get(i)).map_or(0.0, |a| a.1) @@ -3018,6 +3068,13 @@ impl ParaLayout { /// x of byte offset `off` within line `li` (clamped to the line). pub fn x_of(&self, li: usize, off: usize) -> Option { let l = self.lines.get(li)?; + let off = if self.is_edit_boundary(off) { + off + } else { + // Explicit offsets at a font boundary inside a grapheme use its leading edge too. + let k = self.clusters.partition_point(|c| c.start < off); + self.clusters.get(..k).into_iter().flatten().rev().find(|c| self.is_edit_boundary(c.start)).map_or(0, |c| c.start) + }; if !l.vis.is_empty() { return self.x_of_bidi(l, off); } @@ -3095,11 +3152,12 @@ impl ParaLayout { } } - /// Caret offsets a click or arrow key can reach on line `li`: each cluster start and the line - /// end, except after a line-ending break or past a wrapped line's trailing space. + /// Caret offsets a click or arrow key can reach on line `li`: whole-grapheme starts and the + /// line end, except after a line-ending break or past a wrapped line's trailing space. pub fn line_offsets(&self, li: usize) -> Vec { let Some(l) = self.lines.get(li) else { return Vec::new() }; - let mut v: Vec = (l.c0..l.c1).filter_map(|k| self.clusters.get(k).map(|c| c.start)).collect(); + let mut v: Vec = + (l.c0..l.c1).filter_map(|k| self.clusters.get(k).filter(|c| self.is_edit_boundary(c.start)).map(|c| c.start)).collect(); let last_break = l.c1 > l.c0 && self.clusters.get(l.c1 - 1).is_some_and(|c| matches!(c.kind, ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak)); let wrapped = l.c1 > l.c0 && l.end == LineEnd::Wrap && self.lines.get(li + 1).is_some(); @@ -3127,6 +3185,9 @@ impl ParaLayout { for k in l.c0..=l.c1 { let Some(cx) = l.xs.get(k - l.c0) else { break }; let off = if k < l.c1 { self.clusters.get(k).map(|c| c.start).unwrap_or(l.stop) } else { l.stop }; + if !self.is_edit_boundary(off) { + continue; + } // Don't place the caret after a line-ending break or wrap-trailing space on a wrapped line. if k == l.c1 && k > l.c0 { let lastc = self.clusters.get(k - 1); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 5d8878aa4a..7a285d0290 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -11,6 +11,168 @@ fn lay(doc: &Document) -> DocLayout { layout(doc, &mut c, &LayoutOptions::default()) } +/// These pagination fixtures test physical boundaries, not the host's Aptos substitution. +/// Keep their explicit spacing local; ordinary document/layout defaults remain unchanged. +pub(super) fn pagination_fixture_document(text: &str) -> Document { + let mut doc = Document::from_text(text); + doc.styles.default_chr.font = Some(WRAP_TEST_FONT.into()); + doc.styles.default_chr.size = Some(12.0); + for block in &mut doc.body { + if let Block::Para(para) = Arc::make_mut(block) { + para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0)); + para.props.space_before = Some(0.0); + para.props.space_after = Some(8.0); + } + } + doc +} + +pub(super) fn assert_pagination_fixture_font(layout: &DocLayout) { + let mut paragraphs = 0; + for page in &layout.pages { + for item in &page.items { + if let Placed::Lines { para, .. } = item { + paragraphs += 1; + for style in ¶.styles { + assert_eq!(style.rc.font, WRAP_TEST_FONT, "the fixture requests its bundled face"); + assert_eq!(style.face.family, WRAP_TEST_FONT, "the fixture must not silently substitute another family"); + assert_eq!(style.face.style, "Regular", "the fixture must resolve the intended regular face"); + assert_eq!(style.size, 12.0, "the fixture keeps its explicit point size"); + assert!( + style.face.data() == include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").as_slice(), + "the selected face must be the exact already-bundled regular font bytes" + ); + } + } + } + } + assert!(paragraphs > 0, "font assertions must inspect actual laid-out paragraphs"); +} + +/// Preserve the 22 short filler paragraphs while giving the following row a measured boundary. +fn pagination_filler_leaving(room: f32) -> Document { + let lines: Vec = (1..=22).map(|i| format!("Filler {i}")).collect(); + let mut doc = pagination_fixture_document(&lines.join("\n")); + let after = doc.last_section.text_height() - room - 22.0 * 14.0 - 21.0 * 8.0; + assert!(after >= 8.0, "the last gap may add room-taking space, not remove filler content"); + doc.para_mut(StoryRef::Body, &Path::top(21)).unwrap().props.space_after = Some(after); + let probe = lay(&doc); + assert_pagination_fixture_font(&probe); + assert_eq!(probe.pages.len(), 1, "all filler must precede the row on page one"); + let lines: Vec<_> = probe.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Body, path, para, y, l0, l1, .. } if path.0.len() == 1 => Some((para, *y, l1 - l0)), + _ => None, + }) + .collect(); + assert_eq!(lines.len(), 22, "every filler paragraph remains"); + assert!(lines.iter().all(|(_, _, count)| *count == 1), "filler text must remain one line per paragraph"); + let (last, y, _) = lines.last().unwrap(); + let remaining = probe.pages[0].body.bottom() - y - last.height - last.rp.space_after; + assert!((remaining - room).abs() < 0.02, "requested {room}pt of actual row room, got {remaining}pt"); + assert!(remaining > 0.0 && (remaining < 63.6) == (room < 63.6), "the measured room must be on the intended side of the row boundary"); + doc +} + +/// Failure-only evidence for the selected pagination fixtures below. Inspect the already produced +/// styles and placements, not a second run. +pub(super) fn pagination_fixture_diagnostics(label: &str, doc: Option<&Document>, layout: &DocLayout) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + let _ = writeln!(out, "\npagination fixture {label}: {} pages", layout.pages.len()); + for (pi, page) in layout.pages.iter().enumerate().take(8) { + let _ = writeln!( + out, + "page={pi} body={:?} first_block={} floats={:?} items={}", + page.body, + page.first_block, + page.float_tables, + page.items.len() + ); + let mut body_bottom = None::<(f32, f32)>; + for item in &page.items { + if let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, y, .. } = item + && path.0.len() == 1 + && let (Some(first), Some(last)) = (para.lines.get(*l0), para.lines.get(l1.saturating_sub(1))) + { + let bottom = y + last.top + last.height - first.top; + if body_bottom.is_none_or(|(prior, _)| bottom > prior) { + body_bottom = Some((bottom, para.rp.space_after)); + } + } + } + if let Some((bottom, after)) = body_bottom { + let _ = writeln!( + out, + " top-level body text bottom={bottom:.3} trailing_space_after={after:.3} remaining_to_body_bottom={:.3} remaining_after_unadjusted_space={:.3}", + page.body.bottom() - bottom, + page.body.bottom() - bottom - after + ); + } + for (ii, item) in page.items.iter().enumerate().filter(|(i, _)| *i < 32 || i.saturating_add(16) >= page.items.len()) { + match item { + Placed::Lines { story, path, para, l0, l1, x, y, turn } => { + let text = doc.and_then(|d| d.para(*story, path)).map(|p| p.text.chars().take(96).collect::()); + let _ = writeln!( + out, + " item={ii} Lines story={story:?} path={path:?} text={text:?} xy=({x:.3},{y:.3}) turn={turn:?} lines={l0}..{l1}/{} para_height={:.3} before={:.3} after={:.3} spacing={:?}", + para.lines.len(), + para.height, + para.rp.space_before, + para.rp.space_after, + para.rp.line_spacing + ); + for (li, line) in + para.lines.iter().enumerate().filter(|(i, _)| *i >= *l0 && *i < *l1 && (*i == *l0 || i.saturating_add(1) == *l1)) + { + let _ = writeln!( + out, + " line={li} bytes={}..{} top={:.3} height={:.3} baseline={:.3} text_asc={:.3} text_desc={:.3} end={:?}", + line.start, line.stop, line.top, line.height, line.baseline, line.text_asc, line.text_desc, line.end + ); + } + for (si, st) in + para.styles.iter().enumerate().filter(|(i, _)| *i == 0 || para.clusters.iter().any(|c| c.style as usize == *i)).take(4) + { + let (asc, desc) = wordcraft_fonts::word::line_metrics(&st.face); + let raw_height = (asc + desc) * f64::from(st.size) / st.face.upem; + let _ = writeln!( + out, + " style={si} requested={:?} actual={:?}/{:?} face_id={} upem={} size={} word_asc={} word_desc={} raw_height_pt={raw_height:.3} resolved_asc={:.3} resolved_desc={:.3}", + st.rc.font, + st.face.family, + st.face.style, + st.face.id(), + st.face.upem, + st.size, + asc, + desc, + st.ascent, + st.descent + ); + } + } + Placed::Cell { rect, table, row, cell, story, clip, .. } => { + let _ = writeln!(out, " item={ii} Cell table={table:?} story={story:?} row={row} cell={cell} rect={rect:?} clip={clip}"); + } + Placed::Object { rect, story, path, off, text_box, .. } => { + let _ = writeln!(out, " item={ii} Object story={story:?} path={path:?} off={off} textbox={text_box:?} rect={rect:?}"); + } + _ => {} + } + } + if page.items.len() > 48 { + let _ = writeln!(out, " omitted {} middle items (first32/last16 retained)", page.items.len() - 48); + } + } + if layout.pages.len() > 8 { + let _ = writeln!(out, "omitted {} later pages", layout.pages.len() - 8); + } + out +} + #[derive(Clone, Copy)] enum SpacingLocation { Cell, @@ -4697,8 +4859,6 @@ fn exact_rows_add_only_the_bottom_margin() { #[test] fn exact_row_moves_whole() { use wordcraft_doc::props::HeightRule; - let lines: Vec = (1..=200).map(|i| format!("Filler {i}")).collect(); - let one_page = lay(&Document::from_text(&lines.join("\n"))).pages[0].items.iter().filter(|i| matches!(i, Placed::Lines { .. })).count(); let mut t = Table::new(1, 1, 400.0); t.rows[0].props.height = Some(63.6); t.rows[0].props.height_rule = HeightRule::Exact; @@ -4707,12 +4867,27 @@ fn exact_row_moves_whole() { p.insert_object(0, picture(30.0, height, Default::default()), &Default::default()).unwrap(); t.rows[0].cells[0].blocks.push(wordcraft_doc::para_block(p)); } - let mut d = Document::from_text(&lines[..one_page - 2].join("\n")); - let end = d.body.len(); - d.insert_block(StoryRef::Body, &Path::top(end), wordcraft_doc::Block::Table(t)).unwrap(); - let l = lay(&d); - let row_page = l.pages.iter().position(|p| p.items.iter().any(|i| matches!(i, Placed::Cell { row: 0, .. }))); - assert_eq!(row_page, Some(1), "Word keeps an exact row together when it fits on the next page"); + // The same intact row moves with only 50pt left, and stays when 90pt is available. + for (room, expected_page) in [(50.0, 1), (90.0, 0)] { + assert_eq!(room < 63.6, expected_page == 1); + let mut d = pagination_filler_leaving(room); + let end = d.body.len(); + d.insert_block(StoryRef::Body, &Path::top(end), wordcraft_doc::Block::Table(t.clone())).unwrap(); + let l = lay(&d); + assert_pagination_fixture_font(&l); + let row_pages: Vec<_> = l + .pages + .iter() + .enumerate() + .flat_map(|(page, p)| p.items.iter().filter_map(move |i| matches!(i, Placed::Cell { row: 0, .. }).then_some(page))) + .collect(); + assert_eq!( + row_pages, + vec![expected_page], + "Word keeps an exact row whole; available={room} height=63.6{}", + pagination_fixture_diagnostics("exact_row_moves_whole", Some(&d), &l) + ); + } } /// `cell_margins` is what the layout uses: a cell's own margins, else the table's, else Word's; @@ -5317,9 +5492,35 @@ fn a_floating_table_ignores_a_paragraphs_empty_lines() { use wordcraft_doc::para::Anchor; use wordcraft_doc::props::TableFloat; - let mut doc = Document::from_text(&[""; 9].join("\n")); + let mut doc = pagination_fixture_document(&[""; 9].join("\n")); + // Visible's line breaks belong inside its paragraph, not separate body paragraphs. doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Visible\n\n\n", Default::default()))); doc.body.extend((0..14).map(|_| wordcraft_doc::para_block(wordcraft_doc::Paragraph::new()))); + for block in &mut doc.body { + if let Block::Para(para) = Arc::make_mut(block) { + para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0)); + para.props.space_before = Some(0.0); + para.props.space_after = Some(8.0); + } + } + let before_table = lay(&doc); + assert_pagination_fixture_font(&before_table); + assert_eq!(doc.body.len(), 24, "all original body paragraphs remain"); + assert_eq!(before_table.pages.len(), 1, "the table's preceding body/anchor must be on page zero"); + let preceding: Vec<_> = before_table.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Body, path, para, y, .. } if path.0.len() == 1 => Some((path.0[0], *y + para.height)), + _ => None, + }) + .collect(); + assert_eq!(preceding.iter().map(|(block, _)| *block).collect::>(), (0..24).collect::>()); + assert!(preceding.last().unwrap().1 < before_table.pages[0].body.bottom(), "the final preceding paragraph actually fits the anchor page"); + assert!( + before_table.pages[0].items.iter().any(|it| matches!(it, Placed::Lines { path, para, .. } if path.0 == [9] && para.lines.len() == 4)), + "the visible paragraph retains all three trailing empty lines" + ); let mut table = Table::new(12, 1, 300.0); for (i, row) in table.rows.iter_mut().enumerate() { row.cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Approval {i}"), Default::default()))]; @@ -5329,6 +5530,18 @@ fn a_floating_table_ignores_a_paragraphs_empty_lines() { doc.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(table)).unwrap(); let layout = lay(&doc); + assert_pagination_fixture_font(&layout); + let cells: Vec<_> = layout + .pages + .iter() + .flat_map(|page| &page.items) + .filter_map(|it| match it { + Placed::Cell { row, rect, .. } => Some((*row, *rect)), + _ => None, + }) + .collect(); + assert_eq!(cells.iter().map(|(row, _)| *row).collect::>(), (0..12).collect::>(), "every floating row remains once"); + assert!((cells[0].1.y - (before_table.pages[0].body.y + 50.0)).abs() < 0.01, "the table keeps its margin-relative 50pt anchor"); let table_pages: Vec = layout .pages .iter() @@ -5340,7 +5553,11 @@ fn a_floating_table_ignores_a_paragraphs_empty_lines() { }) }) .collect(); - assert!(!table_pages.is_empty() && table_pages.iter().all(|page| *page == 0), "table rows on pages {table_pages:?}"); + assert!( + !table_pages.is_empty() && table_pages.iter().all(|page| *page == 0), + "table rows on pages {table_pages:?}; table_block={at} margin_anchor_y=50{}", + pagination_fixture_diagnostics("a_floating_table_ignores_a_paragraphs_empty_lines", Some(&doc), &layout) + ); } #[test] @@ -6143,25 +6360,42 @@ fn an_inherited_footer_sizes_its_percentage_table_by_its_own_section() { #[test] fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() { use wordcraft_doc::props::HeightRule; - // Measured in Word: with 49 pt left on the page, a row of eight lines splits, but the same row - // with an at-least height of 63.6 pt goes to the next page whole. + // Exactly 50pt of measured room remains. The same eight paragraphs split without a minimum, + // but an at-least height of 63.6pt moves the row to the next page whole. let first_row_line_page = |min: Option| { - let lines: Vec = (1..=200).map(|i| format!("Filler {i}")).collect(); - let one_page = lay(&Document::from_text(&lines.join("\n"))).pages[0].items.iter().filter(|i| matches!(i, Placed::Lines { .. })).count(); + let mut d = pagination_filler_leaving(50.0); let mut t = Table::new(1, 1, 400.0); t.rows[0].props.height = min; t.rows[0].props.height_rule = HeightRule::AtLeast; t.rows[0].cells[0].blocks = (1..=8).map(|k| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Row line {k}"), Default::default()))).collect(); - // Fill the page, leaving two filler paragraphs' room (less than 63.6 pt) for the row. - let mut d = Document::from_text(&lines[..one_page - 2].join("\n")); let end = d.body.len(); d.insert_block(StoryRef::Body, &Path::top(end), wordcraft_doc::Block::Table(t)).unwrap(); let l = lay(&d); - l.pages.iter().position(|p| p.items.iter().any(|i| matches!(i, Placed::Lines { path, .. } if path.0.first() == Some(&(end as u32))))) + assert_pagination_fixture_font(&l); + let row_lines: Vec<_> = l + .pages + .iter() + .enumerate() + .flat_map(|(page, p)| { + p.items.iter().filter_map(move |i| match i { + Placed::Lines { path, l0, l1, .. } if path.0.first() == Some(&(end as u32)) => Some((page, l1 - l0)), + _ => None, + }) + }) + .collect(); + assert_eq!(row_lines.iter().map(|(_, count)| count).sum::(), 8, "all eight row paragraphs remain"); + (row_lines.first().map(|(page, _)| *page), d, l) }; - assert_eq!(first_row_line_page(None), Some(0), "a row without a minimum height splits"); - assert_eq!(first_row_line_page(Some(63.6)), Some(1), "a row with a minimum height that doesn't fit moves on"); + let (page, d, l) = first_row_line_page(None); + assert_eq!(page, Some(0), "a row without a minimum height splits{}", pagination_fixture_diagnostics("at_least_row/no_minimum", Some(&d), &l)); + let (page, d, l) = first_row_line_page(Some(63.6)); + assert_eq!( + page, + Some(1), + "a row with a minimum height that doesn't fit moves on; available=50 height=63.6{}", + pagination_fixture_diagnostics("at_least_row/minimum", Some(&d), &l) + ); } /// #1742: a paragraph that leaves `room` points of the letter page's 648 pt body, then the table @@ -6169,13 +6403,66 @@ fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() { /// table comes first, at the top of the page. Returns the layout and, per page, the table rows /// that have a cell there. fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayout, Vec>) { - let mut d = Document::from_text("Text above the table"); - d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(648.0 - room - 14.0); + let mut d = pagination_fixture_document("Text above the table"); + let at = if room >= 648.0 { 0 } else { 1 }; + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(if at == 0 { 0.0 } else { 648.0 - room - 14.0 }); + let before_table = lay(&d); + assert_pagination_fixture_font(&before_table); + assert_eq!(before_table.pages.len(), 1); + if at == 1 { + let (para, y) = before_table.pages[0] + .items + .iter() + .find_map(|it| match it { + Placed::Lines { story: StoryRef::Body, path, para, y, .. } if path.0 == [0] => Some((para, *y)), + _ => None, + }) + .unwrap(); + let remaining = before_table.pages[0].body.bottom() - y - para.height - para.rp.space_after; + assert!((remaining - room).abs() < 0.02, "the measured filler leaves {remaining}pt, expected {room}pt"); + } let mut t = Table::new(rows, 1, 468.0); build(&mut t); - let at = if room >= 648.0 { 0 } else { 1 }; + for row in &mut t.rows { + for cell in &mut row.cells { + for block in &mut cell.blocks { + if let Block::Para(para) = Arc::make_mut(block) { + para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0)); + para.props.space_before = Some(0.0); + para.props.space_after = Some(0.0); + } + } + } + } d.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(t)).unwrap(); let l = lay(&d); + assert_pagination_fixture_font(&l); + let trailing: Vec<_> = l + .pages + .iter() + .enumerate() + .flat_map(|(page, p)| { + p.items.iter().filter_map(move |it| match it { + Placed::Lines { story: StoryRef::Body, path, .. } if path.0 == [if at == 0 { 1 } else { 0 }] => Some(page), + _ => None, + }) + }) + .collect(); + assert_eq!(trailing.len(), 1, "the real paragraph before/after the table is retained exactly once"); + assert_eq!(d.para(StoryRef::Body, &Path::top(if at == 0 { 1 } else { 0 })).unwrap().text, "Text above the table"); + if at == 0 { + let first_cell = l.pages[0] + .items + .iter() + .find_map(|it| match it { + Placed::Cell { row: 0, rect, .. } => Some(rect), + _ => None, + }) + .unwrap(); + assert!((first_cell.y - l.pages[0].body.y).abs() < 0.01, "the tested table really begins at the page top"); + let last_table_page = l.pages.iter().rposition(|p| p.items.iter().any(|it| matches!(it, Placed::Cell { .. }))).unwrap(); + assert!(trailing[0] >= last_table_page, "the retained paragraph follows the table"); + } let pages = l .pages .iter() @@ -6255,12 +6542,34 @@ fn an_at_least_row_at_the_top_of_a_page_splits_instead_of_moving_on() { use wordcraft_doc::props::HeightRule; // At the top of a page (or under the header rows repeated there) moving on gains nothing: a // row taller than the room splits, and pagination ends. - let (_, pages) = at_least_rows(648.0, 1, |t| { + let (l, pages) = at_least_rows(648.0, 1, |t| { t.rows[0].props.height = Some(700.0); t.rows[0].props.height_rule = HeightRule::AtLeast; t.rows[0].cells[0].blocks = lines_in_cell(80, "Line"); }); - assert!(pages.len() >= 2 && pages.iter().all(|p| p == &vec![0]), "{pages:?}"); + assert!( + pages.len() >= 2 && pages.iter().all(|p| p == &vec![0]), + "{pages:?}; helper retains body block1 \"Text above the table\" after table block0{}", + pagination_fixture_diagnostics("at_least_row/top_of_page", None, &l) + ); + let data_lines = |layout: &DocLayout, row: u32| { + let mut paragraphs = std::collections::BTreeSet::new(); + let count = layout + .pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Body, path, l0, l1, .. } if path.0.len() == 4 && path.0[..3] == [0, row, 0] => { + paragraphs.insert(path.0[3]); + Some(l1 - l0) + } + _ => None, + }) + .sum::(); + assert_eq!(paragraphs.into_iter().collect::>(), (0..80).collect::>(), "all eighty original cell paragraphs remain"); + assert_eq!(count, 80, "every short cell paragraph is drawn exactly once"); + }; + data_lines(&l, 0); // A 100 pt header row, then a row at least 600 pt with 80 lines: under the header only 548 pt // are left on each page, so the row splits there instead of running past the bottom margin. let (l, pages) = at_least_rows(648.0, 2, |t| { @@ -6271,10 +6580,19 @@ fn an_at_least_row_at_the_top_of_a_page_splits_instead_of_moving_on() { t.rows[1].props.height_rule = HeightRule::AtLeast; t.rows[1].cells[0].blocks = lines_in_cell(80, "Line"); }); - assert!(pages.len() >= 3 && pages.iter().all(|p| p == &vec![0, 1]), "{pages:?}"); + assert!( + pages.len() >= 3 && pages.iter().all(|p| p == &vec![0, 1]), + "{pages:?}; repeated100pt header plus minimum600pt row{}", + pagination_fixture_diagnostics("at_least_row/repeated_header", None, &l) + ); + data_lines(&l, 1); for p in &l.pages { let bottom = p.items.iter().filter_map(|i| if let Placed::Cell { rect, .. } = i { Some(rect.y + rect.h) } else { None }).fold(0.0, f32::max); - assert!(bottom <= 720.0 + 0.5, "a row part runs past the bottom margin to {bottom}"); + assert!( + bottom <= 720.0 + 0.5, + "a row part runs past the bottom margin to {bottom}{}", + pagination_fixture_diagnostics("at_least_row/repeated_header_bottom", None, &l) + ); } } diff --git a/crates/layout/src/tests_ligature.rs b/crates/layout/src/tests_ligature.rs index 18fb611d33..a5b80778bc 100644 --- a/crates/layout/src/tests_ligature.rs +++ b/crates/layout/src/tests_ligature.rs @@ -171,6 +171,98 @@ fn an_emoji_zwj_sequence_stays_one_cluster() { assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}"); } +/// Font/style fragments still draw independently, but every user-facing stop and emergency +/// wrap must be at a whole paragraph grapheme boundary, in LTR and bidirectional lines alike. +fn assert_atomic_drawing_group(d: &Document, legal: &[usize], start: usize, inside: usize, end: usize) { + let l = lay(d); + let pl = para_layout(&l); + assert!(pl.clusters.iter().any(|c| c.start == inside), "fixture must actually split the drawing inside the grapheme"); + let li = pl.line_of(start); + assert_eq!(pl.x_of(li, inside), pl.x_of(li, start), "an explicit internal offset uses the grapheme's leading edge"); + let mut reachable: Vec<_> = (0..pl.lines.len()).flat_map(|i| pl.line_offsets(i)).collect(); + reachable.sort_unstable(); + reachable.dedup(); + assert_eq!(reachable, legal, "every expected grapheme boundary remains reachable"); + if d.last_section.text_width() <= 18.01 { + assert!(pl.lines.len() > 1, "the narrow fixture must actually exercise wrapping"); + } + for (li, line) in pl.lines.iter().enumerate() { + assert!(legal.contains(&line.start) && legal.contains(&line.stop), "wrap cannot split a grapheme: {}..{}", line.start, line.stop); + assert!(pl.line_offsets(li).iter().all(|o| legal.contains(o)), "only legal keyboard stops"); + let left = (line.c0..line.c1).filter_map(|k| line.cl_left(k)).fold(line.left, f32::min); + let right = (line.c0..line.c1).filter_map(|k| line.cl_right(k)).fold(line.right, f32::max); + for sample in 0..=128 { + let x = left + (right - left) * sample as f32 / 128.0; + let off = pl.off_at_x(li, x); + assert!(legal.contains(&off), "click at {x} reached internal offset {off}"); + } + } + // The whole group's selection covers every differently drawn component without gaps. + let rects = l.selection_rects(d, &Pos::body(0, start), &Pos::body(0, end), 0); + let width: f32 = rects.iter().map(|(_, r)| r.w).sum(); + let expected = (pl.x_of(li, end).unwrap() - pl.x_of(li, start).unwrap()).abs(); + assert!(width > 0.0 && (width - expected).abs() < 0.05, "whole-grapheme selection: {width}, expected {expected}"); +} + +#[test] +fn a_mixed_style_emoji_grapheme_has_atomic_caret_hit_and_wrap_boundaries() { + for bidi in [false, true] { + let mut d = Document::from_text("x\u{1F469}\u{200D}\u{1F4BB}y"); + d.format_range(&Pos::body(0, 0), &Pos::body(0, 13), &|c| { + c.font = Some("Source Sans 3".into()); + c.size = Some(24.0); + }) + .unwrap(); + d.format_range(&Pos::body(0, 8), &Pos::body(0, 12), &|c| c.size = Some(48.0)).unwrap(); + d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi = Some(bidi); + let wide = lay(&d); + let pl = para_layout(&wide); + if bidi { + assert!(pl.lines.iter().any(|line| !line.vis.is_empty()), "exercise the bidi hit/caret path"); + } + let styles: Vec<_> = pl.clusters.iter().filter(|c| c.start >= 1 && c.end <= 12).map(|c| pl.styles[c.style as usize].size).collect(); + assert!(styles.contains(&24.0) && styles.contains(&48.0), "both drawing sizes must survive: {styles:?}"); + assert_atomic_drawing_group(&d, &[0, 1, 12, 13], 1, 8, 12); + for width in [18.0, 32.0, 48.0, 96.0] { + d.last_section.page_w = 144.0 + width; + assert_atomic_drawing_group(&d, &[0, 1, 12, 13], 1, 8, 12); + } + } +} + +#[test] +fn a_mixed_bundled_font_combining_grapheme_preserves_drawing_and_atomic_boundaries() { + for bidi in [false, true] { + let mut d = Document::from_text("xa\u{301}b"); + d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| { + c.font = Some("Source Sans 3".into()); + c.size = Some(24.0); + }) + .unwrap(); + d.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| { + c.font = Some("Source Serif 4".into()); + c.size = Some(48.0); + }) + .unwrap(); + d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi = Some(bidi); + let wide = lay(&d); + let pl = para_layout(&wide); + if bidi { + assert!(pl.lines.iter().any(|line| !line.vis.is_empty()), "exercise the bidi hit/caret path"); + } + let base = pl.clusters.iter().find(|c| c.start == 1).unwrap(); + let mark = pl.clusters.iter().find(|c| c.start == 2).unwrap(); + assert_eq!(pl.styles[base.style as usize].face.family, "Source Sans 3"); + assert_eq!(pl.styles[mark.style as usize].face.family, "Source Serif 4"); + assert!(base.g1 > base.g0 && mark.g1 > mark.g0, "both independently shaped pieces keep their glyphs"); + assert_atomic_drawing_group(&d, &[0, 1, 4, 5], 1, 2, 4); + for width in [18.0, 32.0, 48.0, 96.0] { + d.last_section.page_w = 144.0 + width; + assert_atomic_drawing_group(&d, &[0, 1, 4, 5], 1, 2, 4); + } + } +} + #[test] fn an_indic_conjunct_stays_one_cluster() { // क्ष: ka, virama, ssa — one conjunct; the caret never stops inside it. diff --git a/crates/layout/src/tests_textbox.rs b/crates/layout/src/tests_textbox.rs index aef5932edf..0398dca494 100644 --- a/crates/layout/src/tests_textbox.rs +++ b/crates/layout/src/tests_textbox.rs @@ -186,25 +186,106 @@ fn inline_box_that_resizes_to_fit_takes_its_texts_height() { let brk = wordcraft_doc::para::PAGE_BREAK; let doc = |auto_fit: bool| { let filler: Vec = (1..=FILLER).map(|i| format!("Filler line {i}")).collect(); - let mut d = Document::from_text(&format!("{}\n\n\n{brk}\nSecond page text.", filler.join("\n"))); + let mut d = super::tests::pagination_fixture_document(&format!("{}\n\n\n{brk}\nSecond page text.", filler.join("\n"))); + // Exact spacing is only for the filler/short text. The object paragraph must retain + // automatic spacing so its inline box really contributes its physical height. + d.para_mut(StoryRef::Body, &Path::top(FILLER)).unwrap().props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.15)); + let probe = lay(&d); + super::tests::assert_pagination_fixture_font(&probe); + let body_para = |layout: &DocLayout, index: usize| { + layout + .pages + .iter() + .enumerate() + .find_map(|(page, p)| { + p.items.iter().find_map(|it| match it { + Placed::Lines { story: StoryRef::Body, path, para, y, .. } if path.0 == [index as u32] => { + Some((page, *y, para.height, para.rp.space_after)) + } + _ => None, + }) + }) + .unwrap() + }; + let (anchor_page, anchor_y, _, _) = body_para(&probe, FILLER); + assert_eq!(anchor_page, 0, "all twenty-two original filler paragraphs must precede the box on page one"); + assert_eq!(d.body.len(), FILLER + 4, "all original blank, break and following paragraphs remain"); + assert_eq!(d.para(StoryRef::Body, &Path::top(FILLER + 1)).unwrap().text, ""); + assert_eq!(d.para(StoryRef::Body, &Path::top(FILLER + 2)).unwrap().text, brk.to_string()); + let available = probe.pages[0].body.bottom() - anchor_y; + let (_, _, blank_h, blank_after) = body_para(&probe, FILLER + 1); + let (_, _, break_h, break_after) = body_para(&probe, FILLER + 2); let id = text_box(&mut d, FILLER, "", 453.5, 283.46, TextBody { auto_fit, anchor: VAlign::Center, ..Default::default() }); - d.parts.get_mut(&id).unwrap().blocks = - (1..=3).map(|i| para_block(Paragraph::with_text(&format!("Text box line {i}"), Default::default()))).collect(); + d.parts.get_mut(&id).unwrap().blocks = (1..=3) + .map(|i| { + let mut p = Paragraph::with_text(&format!("Text box line {i}"), Default::default()); + p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0)); + p.props.space_before = Some(0.0); + p.props.space_after = Some(8.0); + para_block(p) + }) + .collect(); let l = lay(&d); - let r = l.pages.iter().find_map(|p| { - p.items.iter().find_map(|it| match it { - Placed::Object { rect, text_box: Some(s), .. } if *s == id => Some(*rect), + super::tests::assert_pagination_fixture_font(&l); + let (object_page, r) = l + .pages + .iter() + .enumerate() + .find_map(|(page, p)| { + p.items.iter().find_map(|it| match it { + Placed::Object { rect, text_box: Some(s), .. } if *s == id => Some((page, *rect)), + _ => None, + }) + }) + .unwrap(); + let (_, _, object_line_h, object_after) = body_para(&l, FILLER); + let needed = object_line_h + object_after + blank_h + blank_after + break_h + break_after; + assert!(available > 0.0, "the box anchor must have measured space available"); + if auto_fit { + assert!(needed < available - 1.0, "auto-fit box plus blank and break need {needed}pt, only {available}pt available"); + } else { + assert!(r.h > available + 1.0, "the fixed 10cm box itself must exceed the {available}pt remaining"); + } + let shown: Vec<_> = l + .pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Part(s), path, l0, l1, .. } if *s == id => Some((path.0.clone(), l1 - l0)), _ => None, }) - }); - (l.pages.len(), r.unwrap()) + .collect(); + assert_eq!(shown.iter().map(|(_, count)| count).sum::(), 3, "all three original text-box paragraphs remain visible"); + assert_eq!(shown.iter().map(|(path, _)| path.clone()).collect::>(), vec![vec![0], vec![1], vec![2]]); + assert_eq!(object_page, usize::from(!auto_fit), "the real inline box moves only when its saved height cannot fit"); + assert_eq!(body_para(&l, FILLER + 2).0, usize::from(!auto_fit), "the explicit page break follows the box on its page"); + assert_eq!(body_para(&l, FILLER + 3).0, if auto_fit { 1 } else { 2 }, "the following text stays after the explicit break"); + (l.pages.len(), r, d, l) }; - let (pages, r) = doc(true); - assert!(r.h > 3.0 * 12.0 && r.h < 80.0, "the box fits its three lines: {r:?}"); - assert_eq!(pages, 2, "the page break stays on the first page"); - let (pages, saved) = doc(false); - assert_eq!(pages, 3, "a box keeping its 10 cm pushes the page break over"); - assert!((saved.h - 283.46).abs() < 1.0, "without auto-fit the box keeps its saved height: {saved:?}"); + let (pages, r, d, l) = doc(true); + assert!( + r.h > 3.0 * 12.0 && r.h < 80.0, + "the box fits its three lines: {r:?}{}", + super::tests::pagination_fixture_diagnostics("inline_autofit/height", Some(&d), &l) + ); + assert_eq!( + pages, + 2, + "the page break stays on the first page; filler={FILLER} box={r:?}{}", + super::tests::pagination_fixture_diagnostics("inline_autofit/pages", Some(&d), &l) + ); + let (pages, saved, d, l) = doc(false); + assert_eq!( + pages, + 3, + "a box keeping its 10 cm pushes the page break over; filler={FILLER} box={saved:?}{}", + super::tests::pagination_fixture_diagnostics("inline_fixed/pages", Some(&d), &l) + ); + assert!( + (saved.h - 283.46).abs() < 1.0, + "without auto-fit the box keeps its saved height: {saved:?}{}", + super::tests::pagination_fixture_diagnostics("inline_fixed/height", Some(&d), &l) + ); } /// Filler paragraphs before the box in [`inline_box_that_resizes_to_fit_takes_its_texts_height`]: diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 9b1f49dc84..c74cab2877 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,23 +9,23 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in # `craft-i18n.rev`). Its own rev: lazy per-locale parsing (storytold/craft-libs#247). craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "f7cc8a7df15e824b02cf73859567a6242d3a7de4" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } -unicode-bidi = { workspace = true } wordcraft-fonts = { workspace = true } wordcraft-layout = { workspace = true } wordcraft-render = { workspace = true } wordcraft-engine = { workspace = true } wordcraft-zotero = { workspace = true } -egui = { workspace = true } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +web-time = { workspace = true } # Pictures pasted from the system clipboard arrive as pixels and are inserted as PNG (#45). image = { workspace = true } # The system's preferred interface languages (#8) and region (#838). Native: OS APIs; wasm: @@ -38,12 +38,14 @@ sys-locale = { version = "0.3", features = ["js"] } [target.'cfg(target_os = "linux")'.dependencies] zbus = "=5.19.0" -[target.'cfg(target_arch = "wasm32")'.dependencies] -# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32). -js-sys = "0.3" - [dev-dependencies] cfb = { workspace = true } +# Producer tests simulate a globally exhausted IME revision allocator through the engine's +# explicit test-support feature; production builds of this crate never enable it. +wordcraft-engine = { workspace = true, features = ["test-support"] } +# kittest's own dev-dependencies do not propagate their font features. Declare the fonts the +# interface tests lay out explicitly: default faces plus monochrome emoji fallbacks. +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } zip = { workspace = true } diff --git a/crates/ui-egui/examples/perf_probe.rs b/crates/ui-egui/examples/perf_probe.rs index b2ef7fc42d..6254b7a931 100644 --- a/crates/ui-egui/examples/perf_probe.rs +++ b/crates/ui-egui/examples/perf_probe.rs @@ -18,7 +18,8 @@ static READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new fn step(h: &mut egui_kittest::Harness<'_, WordApp>) -> f64 { let mut raw = std::mem::take(h.input_mut()); - h.state_mut().raw_input_hook(&mut raw); + let ctx = h.ctx.clone(); + h.state_mut().raw_input_hook(&ctx, &mut raw); *h.input_mut() = raw; let t = Instant::now(); h.step(); @@ -108,6 +109,7 @@ fn main() { delta: egui::vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); f.push(step(h)); } diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index d818ed94da..ecad12fd40 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -86,7 +86,8 @@ fn main() { fn step(harness: &mut egui_kittest::Harness<'_, WordApp>) { 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/a11y.rs b/crates/ui-egui/src/a11y.rs index 1962409490..8b8462dbd1 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -435,6 +435,7 @@ impl Builder<'_> { Some(url) => { let lid = lb.pid.with(("link", lb.li, lb.seg)); let mut l = Node::new(Role::Link); + l.set_label(n.value().filter(|text| !text.is_empty()).unwrap_or(url).to_owned()); l.set_url(url.clone()); l.set_bounds(bounds(r)); self.add(lid, Some(lb.pid), l); @@ -702,7 +703,7 @@ mod tests { let n = layout.pages.len(); let rects = vec![Rect::from_min_size(egui::pos2(f32::NAN, 0.0), egui::vec2(f32::INFINITY, 1.0)); n]; let p = Pos::body(0, 1_000_000); - let t = build(&doc, &layout, Id::new("doc"), &rects, (0, n + 5), (&p, &p)); + let t = build(&doc, &layout, Id::unique("doc"), &rects, (0, n + 5), (&p, &p)); assert!(!t.nodes.is_empty() && t.nodes.len() <= MAX_NODES); assert!(t.selection.is_some(), "an offset past the end lands at the end"); assert!(page_window(&rects, Rect::NOTHING).is_none()); diff --git a/crates/ui-egui/src/a11y_pane.rs b/crates/ui-egui/src/a11y_pane.rs index 25d2c82c62..cfc70fc258 100644 --- a/crates/ui-egui/src/a11y_pane.rs +++ b/crates/ui-egui/src/a11y_pane.rs @@ -65,7 +65,7 @@ pub fn accessibility(app: &mut WordApp, ui: &mut Ui) { /// The checker's findings, kept until the document changes. fn issues(app: &mut WordApp, ui: &Ui) -> Vec { - let key = egui::Id::new("a11y_issues"); + let key = egui::Id::unique("a11y_issues"); let stamp = (app.session.document_id(), app.session.rev()); if let Some((s, v)) = ui.data(|d| d.get_temp::<((u64, u64), Vec)>(key)) && s == stamp @@ -140,7 +140,7 @@ fn still_selected(app: &WordApp, pos: &Pos) -> bool { /// A text box editing `current`; the draft lives in egui memory while it has focus (frame-local /// buffers lose keystrokes). Returns the new text when editing ends with a change. fn draft_box(ui: &mut Ui, key: impl std::hash::Hash + std::fmt::Debug, current: &str, enabled: bool, multiline: bool) -> Option { - let key = egui::Id::new(key); + let key = egui::Id::unique(key); let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| current.to_string()); let edit = if multiline { egui::TextEdit::multiline(&mut text).desired_rows(5) } else { egui::TextEdit::singleline(&mut text) }; let r = ui.add_enabled(enabled, edit.desired_width(f32::INFINITY)); diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 2e8a0ba6f7..89f92a9bbb 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -53,7 +53,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } // The chevron's stroke starts at the content edge. let c = back_chevron_c(band.min.x, band.center().y, app.session.view.full_screen, app.mac_titlebar.content_left); - let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, band.height())), ui.id().with("backstage_back"), Sense::click()); + let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, band.height())), ui.scope_id().with("backstage_back"), Sense::click()); icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE); resp } else { @@ -62,7 +62,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // not also close the File page, same as the macOS chevron fix (#2972) below. let (r, _) = ui.allocate_exact_size(vec2(200.0, 40.0), Sense::hover()); let c = pos2(r.min.x + 26.0, r.center().y); - let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, r.height())), ui.id().with("backstage_back"), Sense::click()); + let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, r.height())), ui.scope_id().with("backstage_back"), Sense::click()); icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE); resp }; @@ -76,7 +76,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // No document open (#1129): Info, Save, Save As, Print and Export are greyed out. let on = has_document || works_without_document(id); let (r, resp) = ui.add_enabled_ui(on, |ui| ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click())).inner; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tl!(label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!(label))); let active = app.ui.backstage_page == id; if active { ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(46)); @@ -124,7 +124,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { for (label, id) in [("About", "about"), ("Discord community", "discord")] { let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(label))); if resp.hovered() { ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26)); } @@ -231,7 +231,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { let caption = crate::i18n::tc("template", label); let galley = ui.painter().layout_no_wrap(caption.to_string(), regular(12.5), ui.visuals().text_color()); let (tile, resp) = ui.allocate_exact_size(vec2(galley.size().x.max(150.0), 194.0 + galley.size().y), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), caption)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), caption)); let r = Rect::from_min_size(tile.min, vec2(150.0, 194.0)); ui.painter().rect( r, @@ -243,7 +243,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { let ppp = ui.ctx().pixels_per_point(); let key = format!("tpl:{template}:{ppp}"); let region = app.session.region(); - let tex = ui.ctx().data(|d| d.get_temp::(egui::Id::new(&key))).or_else(|| { + let tex = ui.ctx().data(|d| d.get_temp::(egui::Id::unique(&key))).or_else(|| { let mut s = wordcraft_engine::Session::new(wordcraft_doc::Document::new()); // The blank tile shows the region's paper (#838). s.system_region = region; @@ -257,7 +257,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px), egui::TextureOptions::LINEAR, ); - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone())); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(&key), h.clone())); Some(h) }); if let Some(h) = tex { @@ -325,7 +325,7 @@ fn home_page(app: &mut WordApp, ui: &mut Ui) { fn new_page(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); heading(ui, "New"); - let id = egui::Id::new("backstage_template_search"); + let id = egui::Id::unique("backstage_template_search"); let mut query = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or_default(); let search = ui.add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search for templates")).desired_width(360.0)); if search.changed() { @@ -405,7 +405,7 @@ struct PersonalListing { } fn personal_listing_id() -> egui::Id { - egui::Id::new("backstage_personal_templates") + egui::Id::unique("backstage_personal_templates") } #[cfg(test)] @@ -499,7 +499,7 @@ fn open_personal(app: &mut WordApp, path: &str) -> Result<(), String> { } fn personal_error_id() -> egui::Id { - egui::Id::new("backstage_personal_error") + egui::Id::unique("backstage_personal_error") } fn open_list(app: &mut WordApp, ui: &mut Ui) { @@ -521,15 +521,13 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0), 40.0), Sense::click()); // Two documents with the same file name are told apart by their path, as on screen. let spoken = if names.iter().filter(|n| *n == name).count() > 1 { format!("{name}, {p}") } else { name.clone() }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &spoken)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &spoken)); if resp.hovered() { ui.painter().rect_filled(r, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(22.0, 22.0)), "printLayout", t.icon, t.accent); - let display_name = crate::canvas::bidi_display(name); - let display_p = crate::canvas::bidi_display(p); - ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, display_name.as_ref(), medium(13.0), t.text); - ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, display_p.as_ref(), regular(11.0), t.text_dim); + ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, name, medium(13.0), t.text); + ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, p, regular(11.0), t.text_dim); if resp.clicked() { let failed = open_recent(app, p).err(); ui.ctx().data_mut(|d| d.insert_temp(open_error_id(), failed.unwrap_or_default())); @@ -538,7 +536,7 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { } fn open_error_id() -> egui::Id { - egui::Id::new("backstage_open_error") + egui::Id::unique("backstage_open_error") } /// Drop the Recent list's open error once File is closed, so it doesn't come back next time. @@ -553,7 +551,7 @@ pub(crate) fn forget_open_error(ctx: &egui::Context) { /// render loop skips the status bar, so a failed write (no permission, disk full, a bad path) /// needs its own place to say why, or the page just sits there with a Browse… button and no clue. pub(crate) fn save_as_error_id() -> egui::Id { - egui::Id::new("backstage_save_as_error") + egui::Id::unique("backstage_save_as_error") } /// Drop the Save As page's error once File is closed or a later save succeeds, so it doesn't @@ -1106,14 +1104,14 @@ mod tests { assert!(h.query_by_label("Report").is_some(), "the built-in templates stay"); // The search covers both. - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "memo".to_string())); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), "memo".to_string())); h.run_steps(3); assert!(h.query_by_label("Team memo").is_some(), "search finds a personal template"); assert!(h.query_by_label("Contract").is_none() && h.query_by_label("Report").is_none()); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "zzz".to_string())); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), "zzz".to_string())); h.run_steps(3); assert!(h.query_by_label("No templates match your search.").is_some()); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), String::new())); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), String::new())); h.run_steps(3); h.get_by_label("Team memo").click(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2ac93d185e..e82edeb388 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -182,6 +182,20 @@ pub struct CanvasState { /// An automatic change (AutoCorrect, AutoFormat) altered the text around the caret: the /// input method is asked to start afresh (#1480). pub(crate) ime_reset: bool, + /// A replacement or an unsupported IME mutation means the next observation must interrupt + /// the live composition, whatever the surrounding text looks like. Unlike [`Self::ime_reset`] + /// this is not limited to the web text agent. + pub(crate) ime_interrupt_now: bool, + /// A nonempty preedit lost its body/equation target. The next canvas event batch must + /// reject that old composition's queued replacement before dispatching any mutation. + pub(crate) ime_interrupted_preedit: bool, + /// The last bounded surrounding window and the `(revision, cursor, anchor)` it was built + /// from. An idle pass reuses it instead of walking a long paragraph again. + pub(crate) ime_window_cache: Option<(u64, wordcraft_doc::Pos, wordcraft_doc::Pos, Option)>, + /// Per-viewport IME owner, generation and change-cause state. + pub(crate) ime_tracker: egui::ImeContextTracker, + /// The viewport whose IME state [`Self::ime_tracker`] holds, so closing one releases it. + pub(crate) ime_viewport: Option, /// Alt+numpad entry in progress: the digits typed while Alt is held (#961). pub(crate) alt_code: String, /// The character an Alt code just typed: if the platform also sends its own composed @@ -287,6 +301,11 @@ impl Default for CanvasState { swallow_text: false, ime_join_text: None, ime_reset: false, + ime_interrupt_now: false, + ime_interrupted_preedit: false, + ime_window_cache: None, + ime_tracker: egui::ImeContextTracker::new(), + ime_viewport: None, alt_code: String::new(), alt_code_echo: None, wheel: Default::default(), @@ -1215,6 +1234,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if app.canvas.wheel.is_animating() { ui.ctx().request_repaint(); } + let mut ime_geometry: Option<(Rect, Rect)> = None; + let mut ime_interrupt = false; let out = scroll_area.show_viewport(&mut ui_area, |ui, viewport| { origin = ui.min_rect().min - viewport.min.to_vec2(); let content = Rect::from_min_size(ui.min_rect().min, geo.size); @@ -1223,7 +1244,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // and Escape did nothing in Read Mode until the reader clicked a page (#1772). // It also keeps the focus when Focus hides the rulers (#1678). ui.advance_cursor_after_rect(content); - let resp = ui.interact(content, egui::Id::new("wordcraft_canvas"), Sense::click_and_drag()); + let resp = ui.interact(content, egui::Id::unique("wordcraft_canvas"), Sense::click_and_drag()); if let Some(tr) = scroll_target { ui.scroll_to_rect(Rect::from_min_size(ui.min_rect().min + tr.min.to_vec2(), tr.size()), None); } @@ -1440,7 +1461,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if floating_comments(app) { // Its own layer, so presses on the card never reach the canvas under it. let ctx = ui.ctx().clone(); - egui::Area::new(egui::Id::new("floating_comment")).order(egui::Order::Foreground).fixed_pos(Pos2::ZERO).sense(Sense::hover()).show( + egui::Area::new(egui::Id::unique("floating_comment")).order(egui::Order::Foreground).fixed_pos(Pos2::ZERO).sense(Sense::hover()).show( &ctx, |aui| { aui.set_clip_rect(view_rect); @@ -1509,9 +1530,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } // Caret. let focused = resp.has_focus() || app.canvas.focused; - // After an automatic change, the web's text agent starts afresh (`keys::note_automatic_change`). - // Only there: on X11 an interrupt recreates the input context and fcitx drops later keys (#1169). + // After an automatic change the web's text agent starts afresh + // (`keys::note_automatic_change`) — only there, because on X11 an interrupt recreates the + // input context and fcitx drops later keys (#1169). A body replacement interrupts on every + // platform: the composition belonged to the text that was swapped out. let ime_reset = std::mem::take(&mut app.canvas.ime_reset) && cfg!(target_arch = "wasm32") && app.canvas.ime_preedit.is_empty(); + let replaced = std::mem::take(&mut app.canvas.ime_interrupt_now); + ime_interrupt = ime_reset || replaced; // Editing an equation: frame it as Word does (a box as tall as its line, a grip at its // top left, an options strip at its right) and draw the caret inside it. let mut math_caret = false; @@ -1567,14 +1592,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if focused { ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530))); let cr = Rect::from_min_max(pos2(x, y0), pos2(x + 1.0, y1)); - ui.ctx().output_mut(|o| { - o.ime = Some(egui::output::IMEOutput { - rect: cr, - cursor_rect: cr, - purpose: Default::default(), - should_interrupt_composition: ime_reset, - }); - }); + ime_geometry = Some((cr, cr)); } } } @@ -1595,7 +1613,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // window move to the input method's cursor inside it. let mut preedit_color = None; let mut ime_dx = 0.0; - if !app.canvas.ime_preedit.is_empty() { + if focused && !app.canvas.ime_preedit.is_empty() { // The composition is interface text, but it may be in a script the interface fonts lack // (Bangla, Thai ...): borrow the document fonts' fallback faces, or it draws as boxes (#565). let new = crate::theme::faces_for_preedit( @@ -1644,14 +1662,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // IME candidate window placement. let cr = Rect::from_min_max(pos2(x, y0), pos2(x + 1.0, y1)); if focused { - ui.ctx().output_mut(|o| { - o.ime = Some(egui::output::IMEOutput { - rect: cr, - cursor_rect: cr, - purpose: Default::default(), - should_interrupt_composition: ime_reset, - }); - }); + ime_geometry = Some((cr, cr)); } } crate::objects::paint(app, &painter, &t, &layout, &rects, geo.scale); @@ -1662,7 +1673,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::table_mouse::paint(app, &painter, &t, &layout, &rects, geo.scale); (resp, rects) }); - app.canvas.scroll_offset = out.state.offset; + app.canvas.scroll_offset = out.state.clamped_offset(); scrollbar_page_tip(app, ui, out.id, &geo.rects, &geo.bands, layout.pages.len()); // The page the status bar names (#3880): the caret's, or a whole-story selection's page, while // it is on screen; else the page at the top of the window. @@ -1697,8 +1708,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { deselect_balloon(app); } // Space and Enter also set `Response::clicked` on a focused click-sensing widget - // (egui's fake primary click). `request_focus` always interrupts IME; on X11 that - // destroys the XIM context and fcitx then drops every later key (#1169). A pointer + // (egui's fake primary click). Re-requesting an already focused widget no longer + // interrupts IME in 0.37. A pointer // click, an accessibility click, or a drag still takes focus, including a click on // a canvas that already has it, so a composition commits. `want_focus` focuses only // while the canvas does not, and does not interrupt an editor that already has it. @@ -1706,6 +1717,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let pointer_click = resp.clicked_by(egui::PointerButton::Primary); let accesskit_click = resp.clicked() && !pointer_click && !keyboard_click; let focus_click = pointer_click || accesskit_click || resp.drag_started(); + if focus_click { + ui.memory_mut(|memory| memory.interrupt_ime()); + } if focus_click || (app.canvas.want_focus && !resp.has_focus()) { resp.request_focus(); } @@ -1719,6 +1733,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); } app.canvas.focused = resp.has_focus(); + // One observation per viewport pass, after every event and command for the pass has been + // applied: the owner, generation, bounded surrounding text and change cause all come from + // this same settled state. + crate::keys::observe_ime(app, ui.ctx(), ime_geometry, ime_interrupt); // Screen readers: the document under the canvas, with the caret (#488). crate::a11y::expose(app, ui.ctx(), resp.id, &layout, &rects, area); // The buttons that start or stop middle-click scrolling don't also click the page. @@ -1828,7 +1846,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.add_space(4.0); // The command panel keeps its own width, not the toolbar's: the popup's justified // layout would stretch it, so it takes the width egui measures in its sizing pass. - let width_id = ui.id().with("context_menu_width"); + let width_id = ui.scope_id().with("context_menu_width"); let width = ui.data(|d| d.get_temp::(width_id)).filter(|_| !ui.is_sizing_pass()); let size = egui::vec2(width.unwrap_or(ui.available_width()), ui.available_height()); let rect = egui::Rect::from_min_size(ui.cursor().min, size); @@ -2102,6 +2120,7 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re active_balloon(app, ui, painter, *id, &thread, card, fs, color) } else { card_shapes(painter, &t, card, color, selected, None); + let text_painter = painter.with_clip_rect(card.intersect(clip)); let mut y = card.min.y + 6.0; for (i, (g, gap)) in lines.into_iter().enumerate() { y += gap; @@ -2110,10 +2129,10 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re painter.hline(card.min.x + 10.0..=card.max.x - 8.0, y - gap / 2.0, Stroke::new(1.0, t.border)); } let h = g.size().y; - painter.galley(pos2(card.min.x + 10.0, y), g, t.text); + text_painter.galley(pos2(card.min.x + 10.0, y), g, t.text); y += h; } - let r = ui.interact(card, ui.id().with(("balloon", *id)), Sense::click()); + let r = ui.interact(card, ui.scope_id().with(("balloon", *id)), Sense::click()); if r.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } @@ -2161,120 +2180,17 @@ fn card_shapes(painter: &egui::Painter, t: &Tokens, card: Rect, color: Color32, } } -/// Does `text` hold right-to-left letters (Arabic, Hebrew, Syriac, Thaana, N'Ko …)? -pub(crate) fn has_rtl(text: &str) -> bool { - text.chars().any(|c| matches!(unicode_bidi::bidi_class(c), unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL)) -} - -/// Put `text` into visual display order for egui when it contains right-to-left characters. -/// Uses an LTR base paragraph direction so left-aligned UI elements (recent files, paths) preserve -/// trailing extensions and numbers in their proper visual order. -pub(crate) fn bidi_display(text: &str) -> std::borrow::Cow<'_, str> { - if !has_rtl(text) { - return std::borrow::Cow::Borrowed(text); - } - let info = unicode_bidi::BidiInfo::new(text, Some(unicode_bidi::Level::ltr())); - match info.paragraphs.first() { - Some(p) => std::borrow::Cow::Owned(visual_row(&info, p, text, 0..text.len())), - None => std::borrow::Cow::Borrowed(text), - } -} - -/// The row `line` of paragraph `p` of `para` in display order, for egui: the bidi runs left to -/// right as they are shown, and inside a right-to-left run its words reversed. The letters of each -/// word stay in logical order, because egui's shaper already runs an Arabic word right to left. -fn visual_row(info: &unicode_bidi::BidiInfo, p: &unicode_bidi::ParagraphInfo, para: &str, line: std::ops::Range) -> String { - // `visual_runs` indexes the line's first byte: an empty line (an empty last row) would panic. - if line.start >= line.end || line.end > p.range.end { - return String::new(); - } - let (levels, runs) = info.visual_runs(p, line); - let mut out = String::new(); - for run in runs { - let rtl = levels.get(run.start).is_some_and(|l| l.is_rtl()); - // Runs lie on character boundaries; a missing slice is skipped, never a panic. - let Some(piece) = para.get(run) else { continue }; - if rtl { - let mut words: Vec<&str> = piece.split_inclusive(char::is_whitespace).collect(); - words.reverse(); - for w in words { - // The space that followed a word now goes before it. - let word = w.trim_end_matches(char::is_whitespace); - out.push_str(w.get(word.len()..).unwrap_or_default()); - out.extend(word.chars().map(mirrored)); - } - } else { - out.push_str(piece); - } - } - out -} - -/// `c` as drawn in a right-to-left run: brackets face the other way (Unicode's Bidi_Mirrored -/// pairs that interface text uses), since egui draws the character it is given. -fn mirrored(c: char) -> char { - match c { - '(' => ')', - ')' => '(', - '[' => ']', - ']' => '[', - '{' => '}', - '}' => '{', - '<' => '>', - '>' => '<', - '«' => '»', - '»' => '«', - '‹' => '›', - '›' => '‹', - _ => c, - } -} - -/// A read-only balloon's text, wrapped to `width`. egui joins Arabic letters but places them left -/// to right, so a right-to-left comment read its words backwards. Each wrapped row is put into -/// display order (Unicode bidi, per paragraph) and a right-to-left paragraph's rows are set against -/// the right edge. Text without right-to-left letters is laid out exactly as before. +/// A read-only balloon's text, wrapped to `width`. The fork's layout orders right-to-left and +/// mixed-direction paragraphs itself; `TextDirection::Auto` takes each paragraph's first strong +/// character. Place each already-shaped row against the card's start edge, including neutral +/// paragraphs inheriting their predecessor's direction. Logical text and glyph order stay intact. fn balloon_text(painter: &egui::Painter, text: String, font: egui::FontId, color: Color32, width: f32) -> std::sync::Arc { - if !has_rtl(&text) { - return painter.layout(text, font, color, width); - } - let format = egui::TextFormat::simple(font.clone(), color); - let mut job = egui::text::LayoutJob::default(); - let mut rows: Vec<(String, bool)> = Vec::new(); - for para in text.split('\n') { - let info = unicode_bidi::BidiInfo::new(para, None); - if info.paragraphs.is_empty() { - rows.push((String::new(), false)); - } - for p in &info.paragraphs { - let rtl = p.level.is_rtl(); - // Wrap in logical order at spaces, by the words' widths (the order doesn't change a word's - // width), then put each row in display order. egui's own rows can't be used: it draws a - // lam-alef pair as one glyph, so its glyph count isn't the character count. - let mut start = p.range.start; - let mut end = start; - let mut used = 0.0; - for word in para.get(p.range.clone()).unwrap_or_default().split_inclusive(char::is_whitespace) { - let w = painter.layout_no_wrap(word.trim_end().to_string(), font.clone(), color).size().x; - let gap = painter.layout_no_wrap(word.get(word.trim_end().len()..).unwrap_or_default().to_string(), font.clone(), color).size().x; - if end > start && used + w > width { - rows.push((visual_row(&info, p, para, start..end).trim().to_string(), rtl)); - start = end; - used = 0.0; - } - used += w + gap; - end += word.len(); - } - rows.push((visual_row(&info, p, para, start..end).trim().to_string(), rtl)); - } - } - let last = rows.len().saturating_sub(1); - for (i, (row, rtl)) in rows.into_iter().enumerate() { - let lead = if rtl { (width - painter.layout_no_wrap(row.clone(), font.clone(), color).size().x).max(0.0) } else { 0.0 }; - let row = if i < last { row + "\n" } else { row }; - job.append(&row, lead, format.clone()); - } - painter.layout_job(job) + let mut job = egui::text::LayoutJob::simple(text, font, color, width); + job.direction = egui::epaint::text::TextDirection::Auto; + job.halign = egui::Align::Min; + // Row placement below supplies the reading edge, including empty RTL rows. + job.halign_min_is_start = false; + craft_ui::text::align_galley_rows(painter.layout_job(job), width, |row| if row.rtl_paragraph { egui::Align::Max } else { egui::Align::Min }) } /// The selected balloon: the comment and its replies as editable fields (each edit is one undo @@ -2285,7 +2201,7 @@ fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u let t = Tokens::get(ui.ctx()); let slot = painter.add(egui::Shape::Noop); // The card's own click area sits under its fields, so a click on its padding stays here. - let _ = ui.interact(card, ui.id().with(("balloon", id)), Sense::click()); + let _ = ui.interact(card, ui.scope_id().with(("balloon", id)), Sense::click()); let inner = Rect::from_min_max(pos2(card.min.x + 10.0, card.min.y + 6.0), pos2(card.max.x - 6.0, card.min.y + 20_000.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min))); child.spacing_mut().item_spacing.y = 3.0; @@ -2407,7 +2323,7 @@ fn scrollbar_page_tip(app: &mut WordApp, ui: &Ui, scroll: egui::Id, rects: &[Rec (dragged && !rects.is_empty() && pages > 0).then(|| top_page(rects, bands, app.canvas.scroll_offset.y).min(pages - 1)); let (Some(page), Some(at)) = (app.canvas.scrollbar_page, ui.ctx().pointer_latest_pos()) else { return }; let text = crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &(page + 1).to_string()), ("pages", &pages.to_string())]); - egui::Area::new(egui::Id::new("scrollbar_page_tip")) + egui::Area::new(egui::Id::unique("scrollbar_page_tip")) .order(egui::Order::Tooltip) .interactable(false) .pivot(egui::Align2::RIGHT_CENTER) @@ -2806,7 +2722,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo let key = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" }; let target = if is_generated_bookmark_link(&link) { tl!("Current Document").to_string() } else { link }; let tip = format!("{target}\n{}", crate::i18n::fmt(tl!("{key}+Click to follow link"), &[("key", key)])); - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { ui.label(tip); }); } @@ -2912,7 +2828,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo // which keep the pointer where they overlap. crate::table_ruler::columns(app, ui, layout, pi, bar, x0, scale); // Dragging the left-indent marker. - let id = ui.id().with("ruler_left"); + let id = ui.scope_id().with("ruler_left"); let mr = Rect::from_center_size(pos2(left, bar.max.y - 3.0), vec2(12.0, 12.0)); let r = ui.interact(mr, id, Sense::click_and_drag()); if r.dragged() @@ -2926,7 +2842,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo let _ = app.run("para.indents", json!({"left": snapped})); } let fr = Rect::from_center_size(pos2(first, bar.min.y + 3.0), vec2(12.0, 10.0)); - let r = ui.interact(fr, ui.id().with("ruler_first"), Sense::click_and_drag()); + let r = ui.interact(fr, ui.scope_id().with("ruler_first"), Sense::click_and_drag()); if r.dragged() && let Some(pp) = r.interact_pointer_pos() { @@ -2937,7 +2853,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo let _ = app.run("para.indents", json!({"firstLine": (pt / 4.5).round() * 4.5})); } let rr = Rect::from_center_size(pos2(right, bar.max.y - 3.0), vec2(12.0, 12.0)); - let r = ui.interact(rr, ui.id().with("ruler_right"), Sense::click_and_drag()); + let r = ui.interact(rr, ui.scope_id().with("ruler_right"), Sense::click_and_drag()); if r.dragged() && let Some(pp) = r.interact_pointer_pos() { @@ -3186,7 +3102,7 @@ fn margin_handle( ) { let cursor = if matches!(edge, MarginEdge::Left | MarginEdge::Right) { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }; - let r = ui.interact(zone, ui.id().with(("ruler_margin", edge)), Sense::drag()).on_hover_cursor(cursor); + let r = ui.interact(zone, ui.scope_id().with(("ruler_margin", edge)), Sense::drag()).on_hover_cursor(cursor); let r = if r.dragged() { r } else { r.on_hover_text(tl!(edge.label())) }; if r.dragged() && let Some(pp) = r.interact_pointer_pos() @@ -3863,7 +3779,13 @@ mod tests { #[test] fn reading_wheel_snaps_one_column_per_tick_and_respects_touchpads() { - let ev = |unit, y| egui::Event::MouseWheel { unit, delta: vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }; + let ev = |unit, y| egui::Event::MouseWheel { + unit, + delta: vec2(0.0, y), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }; let mut partial = 0.0; for _ in 0..6 { assert_eq!(read_wheel_turns(&[ev(egui::MouseWheelUnit::Point, -65.0)], true, &mut partial), 1); @@ -4646,8 +4568,13 @@ mod tests { /// Lays `text` out as a read-only balloon does, with a document face that covers Arabic, and /// returns each glyph's character and left edge. fn balloon_glyphs(text: &str, width: f32) -> Option> { + balloon_glyphs_at_scale(text, width, 1.0) + } + + fn balloon_glyphs_at_scale(text: &str, width: f32, ppp: f32) -> Option> { let face = wordcraft_fonts::FontDb::global().fallback_for('ش', u32::MAX)?; let ctx = egui::Context::default(); + ctx.set_pixels_per_point(ppp); let mut fonts = crate::theme::font_definitions(false, false); crate::theme::add_fallback_faces(&mut fonts, &[face]); ctx.set_fonts(fonts); @@ -4655,8 +4582,41 @@ mod tests { let mut out = Vec::new(); ctx.run_ui(egui::RawInput::default(), |ui| { let g = balloon_text(ui.painter(), text.to_string(), egui::FontId::proportional(14.0), Color32::BLACK, width); + let atlas = ui.fonts(|fonts| fonts.image()); for r in &g.rows { out.extend(r.row.glyphs.iter().map(|gl| (gl.chr, r.pos.x + gl.pos.x))); + for glyph in &r.row.glyphs { + // epaint rounds glyph origins individually while row advance stays fractional. + // The two edge roundings can differ by one physical pixel. + assert!( + r.pos.x + glyph.max_x() <= width + 1.0 / ppp, + "wrapped glyph {:?}: origin {}, max {}, row width {}, limit {} (text {:?})", + glyph.chr, + r.pos.x, + glyph.max_x(), + r.size.x, + width, + text + ); + if !matches!(glyph.chr, '(' | ')') { + continue; + } + // Glyph.chr remains logical in 0.37. Check the actual mirrored bitmap, + // not a manually reversed display string, to verify bracket orientation. + let [x0, y0] = glyph.uv_rect.min.map(usize::from); + let [x1, y1] = glyph.uv_rect.max.map(usize::from); + let y = usize::midpoint(y0, y1); + let (mut ink, mut moment) = (0.0, 0.0); + for x in x0..x1 { + let alpha = f32::from(atlas[(x, y)].a()); + ink += alpha; + moment += (x - x0) as f32 * alpha; + } + assert!(ink > 0.0 && x1 > x0, "bracket must have a visible bitmap"); + let drawn = if moment / ink < (x1 - x0 - 1) as f32 / 2.0 { '(' } else { ')' }; + let expected = if glyph.is_rtl() { if glyph.chr == '(' { ')' } else { '(' } } else { glyph.chr }; + assert_eq!(drawn, expected, "logical bracket {} at bidi level {}", glyph.chr, glyph.bidi_level); + } } }) .drop_without_applying_deltas(); @@ -4674,45 +4634,59 @@ mod tests { let g = balloon_glyphs("الربط (APIs) هنا", 400.0).unwrap(); assert!(x(&g, 'A') < x(&g, 's')); assert!(x(&g, 'ط') > x(&g, 'A') && x(&g, 'A') > x(&g, 'ه'), "{g:?}"); - // The opening bracket, on the right in Arabic, is drawn mirrored: `(APIs)` on screen. - assert!(x(&g, '(') < x(&g, 'A') && x(&g, ')') > x(&g, 's'), "brackets face the word: {g:?}"); + // The logical opening bracket is on the right and is drawn as a closing bracket. + // balloon_glyphs also checks the atlas bitmap, so this verifies actual `(APIs)` output. + assert!(x(&g, ')') < x(&g, 'A') && x(&g, '(') > x(&g, 's'), "brackets surround the word: {g:?}"); // A paragraph that starts in Latin runs left to right. let g = balloon_glyphs("@Ahmed برجاء مراجعة", 400.0).unwrap(); assert!(x(&g, '@') < x(&g, 'ب') && x(&g, '@') < 50.0, "{g:?}"); assert!(x(&g, 'ب') > x(&g, 'م'), "the Arabic words still read from the right: {g:?}"); // Wrapped rows stay inside the width. let long = "بيان الأنظمة المطلوب ربطها ومتطلبات التكامل وواجهات الربط البرمجي وآلية تبادل البيانات"; - let g = balloon_glyphs(long, 200.0).unwrap(); - assert!(g.iter().all(|(_, x)| *x <= 200.0), "{g:?}"); + for ppp in [1.0, 1.25, 1.5, 2.0] { + let g = balloon_glyphs_at_scale(long, 200.0, ppp).unwrap(); + assert!(!g.is_empty()); // Check real glyph right edges at each physical scale. + } + // Alignment follows each paragraph, including a neutral paragraph's inherited direction. + let g = balloon_glyphs("شركة\n123\nLatin", 400.0).unwrap(); + assert!(x(&g, 'ش') > 300.0 && x(&g, '1') > 300.0 && x(&g, 'L') < 1.0, "{g:?}"); } #[test] - fn balloon_rows_put_right_to_left_words_in_display_order() { - let row = |text: &str| { - let info = unicode_bidi::BidiInfo::new(text, None); - let p = info.paragraphs.first().unwrap(); - visual_row(&info, p, text, p.range.clone()) - }; - // Words swap, each word's letters stay as they are (egui shapes the word). - assert_eq!(row("شركة اي للكهرباء"), "للكهرباء اي شركة"); - // A Latin name and a number inside Arabic keep their own order. - assert_eq!(row("يجب ربط Odoo 19 هنا"), "هنا Odoo 19 ربط يجب"); - assert_eq!(row("Should include Meters system API"), "Should include Meters system API"); - assert!(!has_rtl("Should include Meters system API")); - assert!(has_rtl("נא לבדוק")); - // An empty row at the end of the text (egui's last row after a wrap) is empty, not a panic. - let text = "شركة اي"; - let info = unicode_bidi::BidiInfo::new(text, None); - let p = info.paragraphs.first().unwrap(); - assert_eq!(visual_row(&info, p, text, text.len()..text.len()), ""); - assert_eq!(visual_row(&info, p, text, 0..text.len() + 5), ""); - } - - #[test] - fn bidi_display_preserves_extensions_and_numbers() { - assert_eq!(bidi_display("report.docx").as_ref(), "report.docx"); - assert_eq!(bidi_display("نواقص 2026.docx").as_ref(), "2026 نواقص.docx"); - assert_eq!(bidi_display("مذكرة داخلية.docx").as_ref(), "داخلية مذكرة.docx"); + fn read_only_balloon_text_is_clipped_to_its_card() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("text.insert", json!({"text": "Some text here"})).unwrap(); + app.run("select.text", json!({"text": "text"})).unwrap(); + let body = "Balloon ink stays within its own card"; + let id = app.run("review.newComment", json!({"text": body})).unwrap()["id"].as_u64().unwrap() as u32; + app.run("view.commentsPane", json!({"value": false})).unwrap(); + app.canvas.balloon = None; + let ctx = egui::Context::default(); + let mut checked = false; + for frame in 0..6 { + let input = egui::RawInput { + time: Some(f64::from(frame) / 60.0), + screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1440.0, 900.0))), + ..Default::default() + }; + let out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + if let Some((_, card)) = app.canvas.balloon_rects.iter().find(|(i, _)| *i == id) { + for clipped in &out.shapes { + if let egui::Shape::Text(text) = &clipped.shape + && text.galley.text() == body + { + assert!(card.contains_rect(clipped.clip_rect), "comment ink must not cross onto the page"); + assert!(clipped.clip_rect.is_positive()); + checked = true; + } + } + } + out.drop_without_applying_deltas(); + } + assert!(checked, "a read-only comment body must actually be painted"); } #[test] @@ -5525,7 +5499,8 @@ mod tests { // The events winit delivers for a Control-click, through the app's input hook as eframe runs it. let send = |h: &mut egui_kittest::Harness<'_, WordApp>, events: Vec| { let mut raw = egui::RawInput { events, ..Default::default() }; - h.state_mut().raw_input_hook(&mut raw); + let ctx = h.ctx.clone(); + h.state_mut().raw_input_hook(&ctx, &mut raw); for e in raw.events { h.event(e); } @@ -6909,11 +6884,13 @@ mod tests { let at = h.state().canvas.page_rects.first().map(|r| r.center()).expect("a page"); h.event(egui::Event::PointerMoved(at)); h.step(); + let mut pointer_interrupted = false; for pressed in [true, false] { h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); h.step(); + pointer_interrupted |= ime_interrupted(&h) == Some(true); } - assert_eq!(ime_interrupted(&h), Some(true), "a click still interrupts composition"); + assert!(pointer_interrupted, "a pointer click still interrupts composition"); } /// While an input method composes, the caret and the candidate window sit at its cursor inside @@ -6956,6 +6933,782 @@ mod tests { assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("にほん")); } + /// The outgoing IME descriptor is built from settled, visible logical text. A pre-edit does + /// not change the model; an accepted commit alone earns `InputMethod`; ordinary typing earns + /// `Other`. The commit mixes Korean, Japanese, ASCII and an emoji. + #[test] + fn ime_descriptor_reports_visible_logical_text_and_exact_cause() { + use egui::ImeChangeCause; + let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("ab")), Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.state_mut().session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 2)); + h.step(); + let text = |h: &egui_kittest::Harness<'_, WordApp>| { + h.output().platform_output.ime.as_ref().and_then(|i| i.surrounding_text.available()).map(|p| p.text.clone()) + }; + let cause = |h: &egui_kittest::Harness<'_, WordApp>| h.output().platform_output.ime.as_ref().map(|i| i.change_cause); + assert_eq!(text(&h), Some("ab".into()), "the committed text is available"); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })); + h.step(); + assert_eq!(text(&h), Some("ab".into()), "the pre-edit never enters the committed model"); + assert_eq!(cause(&h), Some(ImeChangeCause::Other), "a pre-edit is no mutation"); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("한日本A😀".into()))); + h.step(); + assert!(text(&h).unwrap_or_default().contains("한日本A😀"), "the commit is in the visible text"); + assert_eq!(cause(&h), Some(ImeChangeCause::InputMethod), "an accepted commit is the IME's change"); + h.event(egui::Event::Text("x".into())); + h.step(); + assert_eq!(cause(&h), Some(ImeChangeCause::Other), "ordinary typing is an application change"); + } + + /// Tracked deletions never go to the input method. The caret may straddle one and the + /// selection direction survives the visible mapping. + #[test] + fn ime_descriptor_hides_tracked_deletions_and_preserves_reversed_selection() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{Paragraph, Run, para_block}; + let mut p = Paragraph::new(); + p.text = "abDELcd".into(); + p.runs = vec![ + Run { len: 2, props: CharProps::default() }, + Run { len: 3, props: CharProps { del: Some(0), ..Default::default() } }, + Run { len: 2, props: CharProps::default() }, + ]; + let mut doc = wordcraft_doc::Document::from_text("x"); + doc.body = vec![para_block(p)]; + let app = WordApp::new(wordcraft_engine::Session::new(doc), Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.state_mut().session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos::body(0, 2), focus: wordcraft_doc::Pos::body(0, 7) }; + h.step(); + let payload = + |h: &egui_kittest::Harness<'_, WordApp>| h.output().platform_output.ime.as_ref().and_then(|i| i.surrounding_text.available()).cloned(); + let p = payload(&h).expect("the visible text is available"); + assert_eq!(p.text, "abcd"); + assert_eq!(p.cursor, 4); + assert_eq!(p.anchor, 2, "the reversed selection stays reversed"); + } + + /// Byte deletion keeps the selection out of both erased sides, then the commit replaces it; + /// both accepted mutations are `InputMethod` receipts over bounded snapshots. + #[test] + fn ime_byte_deletion_preserves_the_selection_and_earns_input_method() { + use egui::ImeChangeCause; + let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("abcd")), Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.state_mut().session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos::body(0, 3), focus: wordcraft_doc::Pos::body(0, 1) }; + h.step(); + let cause = |h: &egui_kittest::Harness<'_, WordApp>| h.output().platform_output.ime.as_ref().map(|i| i.change_cause); + h.event(egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 1 })); + h.step(); + let body = |h: &egui_kittest::Harness<'_, WordApp>| h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end().to_string(); + assert_eq!(body(&h), "bc", "the selected text is preserved"); + assert_eq!(h.state().session.sel.anchor.off, 2, "the selection anchor moved with the erased prefix"); + assert_eq!(h.state().session.sel.focus.off, 0); + assert_eq!(cause(&h), Some(ImeChangeCause::InputMethod), "an accepted deletion is the IME's change"); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("X".into()))); + h.step(); + assert_eq!(body(&h), "X", "the commit replaces the preserved selection"); + assert_eq!(cause(&h), Some(ImeChangeCause::InputMethod), "delete plus commit chain into one IME change"); + } + + /// A selection wider than the protocol limit is honestly unavailable, not truncated. + #[test] + fn ime_descriptor_is_unavailable_for_a_too_wide_selection() { + let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text(&"a".repeat(5000))), Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.state_mut().session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos::body(0, 0), focus: wordcraft_doc::Pos::body(0, 5000) }; + h.step(); + let ime = h.output().platform_output.ime.clone().expect("the canvas owns the IME context"); + assert!(ime.surrounding_text.available().is_none(), "a 5000-byte selection is unavailable"); + } + + /// Replacing the document starts a new IME context; caret motion inside it does not. + #[test] + fn ime_generation_changes_on_document_replacement_but_not_on_caret_motion() { + let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("hello")), Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + let generation = |h: &egui_kittest::Harness<'_, WordApp>| h.output().platform_output.ime.as_ref().map(|i| i.context_generation); + let first = generation(&h).expect("an IME context"); + h.state_mut().run("caret.right", json!({})).unwrap(); + h.step(); + assert_eq!(generation(&h), Some(first), "caret motion keeps the context"); + h.state_mut().session.set_document(wordcraft_doc::Document::from_text("new")); + h.step(); + assert!(generation(&h) > Some(first), "a replaced document starts a new context"); + } + + /// An equation's structured tree has no reversible surrounding text: report `Unavailable` + /// while keeping the stable editing-session context and candidate geometry through caret + /// motion. A real content commit is proved by the engine's checked revision and earns + /// `InputMethod`. + #[test] + fn ime_equation_is_unavailable_with_a_stable_session_context() { + use egui::ImeChangeCause; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("insert.equation", json!({"linear": "x+y"})).unwrap(); + assert!(app.session.math.is_some(), "the equation is being edited"); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + let ime = h.output().platform_output.ime.clone().expect("the equation places its candidate window"); + assert!(ime.surrounding_text.available().is_none(), "no fabricated equation surroundings"); + let generation = ime.context_generation; + h.state_mut().run("equation.move", json!({"dir": "left"})).unwrap(); + h.step(); + let moved = h.output().platform_output.ime.clone().expect("still editing"); + assert_eq!(moved.context_generation, generation, "caret motion keeps the equation's IME context"); + assert!(moved.surrounding_text.available().is_none()); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("z".into()))); + h.step(); + let committed = h.output().platform_output.ime.clone().expect("still editing"); + assert_eq!(committed.change_cause, ImeChangeCause::InputMethod, "the engine revision proves the content commit"); + assert!(committed.surrounding_text.available().is_none()); + } + + /// A transformed equation commit (build-up or Math AutoCorrect) is `Other`: the engine's + /// per-command outcome marks it transformed even though the revision moved and content + /// changed. The equation really built up; no InputMethod receipt is fabricated. + #[test] + fn ime_transformed_equation_commits_are_other() { + use egui::ImeChangeCause; + for (typed, latex) in [("a/b ", "\\frac{a}{b}"), ("\\alpha ", "\\alpha")] { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("insert.equation", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.event(egui::Event::Ime(egui::ImeEvent::Commit(typed.into()))); + h.step(); + let info = h.state_mut().run("equation.get", json!({})).unwrap(); + assert_eq!(info["latex"].as_str(), Some(latex), "the commit was transformed: {typed:?}"); + let ime = h.output().platform_output.ime.clone().expect("still editing the equation"); + assert_eq!(ime.change_cause, ImeChangeCause::Other, "an automatic equation rewrite is not the IME's own edit: {typed:?}"); + } + } + + /// A literal IME commit after an earlier AutoCorrect is still the IME's own change. The + /// stale `"AutoFormat"` undo label left by that earlier step must not classify this command. + #[test] + fn ime_literal_commit_after_autoformat_is_input_method() { + use egui::ImeChangeCause; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + for text in ["Word", " ", "(c)", " "] { + app.run("text.insert", json!({"text": text})).unwrap(); + } + assert_eq!(app.session.undo_label(), Some("AutoFormat"), "the earlier AutoCorrect left its undo label"); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.event(egui::Event::Ime(egui::ImeEvent::Commit("x".into()))); + h.step(); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "Word \u{a9} x"); + let ime = h.output().platform_output.ime.clone().expect("an IME context"); + assert_eq!(ime.change_cause, ImeChangeCause::InputMethod, "the literal commit is not the earlier AutoFormat"); + } + + /// A real allocator exhaustion (not a hand-built stamp) keeps the engine revision absent; + /// an equation commit and a wide body deletion still happen but can never certify a receipt. + #[test] + fn exhausted_ime_revisions_never_certify_equation_or_wide_body_edits() { + use egui::ImeChangeCause; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("insert.equation", json!({"linear": "x"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.state_mut().session.exhaust_ime_state_revision(); + h.step(); + assert_eq!(h.state_mut().session.ime_state_revision(), None, "the allocator is exhausted"); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("y".into()))); + h.step(); + let linear = h.state_mut().run("equation.get", json!({})).unwrap()["linear"].as_str().unwrap_or_default().to_string(); + assert_eq!(linear, "xy", "the real commit still happened"); + let ime = h.output().platform_output.ime.clone().expect("still editing the equation"); + assert_eq!(ime.change_cause, ImeChangeCause::Other, "without a revision the change cannot be certified"); + + let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text(&"a".repeat(5000))), Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.state_mut().session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos::body(0, 1), focus: wordcraft_doc::Pos::body(0, 5000) }; + h.step(); + h.state_mut().session.exhaust_ime_state_revision(); + h.step(); + h.event(egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 })); + h.step(); + let body = h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body); + assert_eq!(body.matches('a').count(), 4999, "the wide body delete still happened"); + let ime = h.output().platform_output.ime.clone().expect("an IME context"); + assert_eq!(ime.change_cause, ImeChangeCause::Other, "the exhausted revision cannot prove the delete"); + } + + /// A body replaced under the same document id starts a new IME context and interrupts the + /// live composition, even when the replacement text (and so the bounded window) is identical. + #[test] + fn ime_generation_changes_on_body_replacement_with_the_same_text() { + let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("hello")), Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })); + h.step(); + assert_eq!(h.state().canvas.ime_preedit.as_str(), "に"); + let first = h.output().platform_output.ime.as_ref().map(|i| i.context_generation).expect("an IME context"); + h.state_mut().run("document.setText", json!({"text": "hello"})).unwrap(); + h.step(); + let ime = h.output().platform_output.ime.clone().expect("the canvas still owns the IME context"); + assert!(ime.context_generation > first, "a replaced body starts a new context"); + assert!(ime.should_interrupt_composition, "the old composition is interrupted"); + assert!(h.state().canvas.ime_preedit.is_empty(), "the old pre-edit is not kept"); + assert!(ime.surrounding_text.available().is_some(), "the new body still has surroundings"); + } + + /// Undo (and a version restore) puts a whole body back: the IME generation follows it too. + #[test] + fn ime_generation_changes_on_undo_and_version_restore() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("one")), Default::default()); + app.run("file.versions", json!({"save": "v1"})).unwrap(); + app.run("document.setText", json!({"text": "two"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + let generation = |h: &egui_kittest::Harness<'_, WordApp>| h.output().platform_output.ime.as_ref().map(|i| i.context_generation); + let first = generation(&h).expect("an IME context"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.step(); + assert!(generation(&h) > Some(first), "Undo swaps the body back"); + let after_undo = generation(&h); + h.state_mut().run("file.versions", json!({"restore": 0})).unwrap(); + h.step(); + assert!(generation(&h) > after_undo, "a version restore replaces the body"); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "one"); + } + + /// With smart quotes off the engine inserts the quote as typed: that is the input method's + /// own edit and earns an `InputMethod` receipt (it used to be forced to `Other`). + #[test] + fn ime_apostrophe_with_smart_quotes_off_is_an_input_method_change() { + use egui::ImeChangeCause; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("tools.autocorrect", json!({"smartQuotes": false})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.event(egui::Event::Ime(egui::ImeEvent::Commit("'".into()))); + h.step(); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "'", "the quote is not curled"); + let ime = h.output().platform_output.ime.clone().expect("an IME context"); + assert_eq!(ime.change_cause, ImeChangeCause::InputMethod, "the engine inserted it unchanged"); + } + + /// With smart quotes on, the same commit is AutoFormat work and stays `Other` (the existing + /// contract), so the fix distinguishes the two instead of always assuming a transformation. + #[test] + fn ime_apostrophe_with_smart_quotes_on_stays_other() { + use egui::ImeChangeCause; + let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + // Smart quotes are on by default. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.event(egui::Event::Ime(egui::ImeEvent::Commit("'".into()))); + h.step(); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "\u{2018}", "curled"); + let ime = h.output().platform_output.ime.clone().expect("an IME context"); + assert_eq!(ime.change_cause, ImeChangeCause::Other, "AutoFormat changed the text"); + } + + /// A real IME-only literal commit is the input method's own edit. + #[test] + fn ime_literal_commit_is_input_method() { + use egui::ImeChangeCause; + let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.event(egui::Event::Ime(egui::ImeEvent::Commit("literal".into()))); + h.step(); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "literal"); + let ime = h.output().platform_output.ime.clone().expect("an IME context"); + assert_eq!(ime.change_cause, ImeChangeCause::InputMethod); + } + + /// A formatting change before an IME commit in the same frame must force `Other`, even + /// though an available body stamp cannot see the style change. + #[test] + fn para_alignment_before_ime_commit_is_other() { + use egui::ImeChangeCause; + let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("hello")), Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.state_mut().session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 5)); + h.step(); + h.state_mut().run("para.alignCenter", json!({})).unwrap(); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("x".into()))); + h.step(); + let ime = h.output().platform_output.ime.clone().expect("an IME context"); + assert_eq!(ime.change_cause, ImeChangeCause::Other, "an application change before the receipt poisons the frame"); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "hellox"); + assert_eq!( + h.state().session.doc.para_at(&wordcraft_doc::Pos::body(0, 0)).and_then(|p| p.props.align), + Some(wordcraft_doc::props::Align::Center), + "the application command really ran" + ); + } + + /// A formatting change after an accepted IME commit in the same frame must also force + /// `Other`: the pending application signal survives the receipt and is drained at observation. + #[test] + fn para_alignment_after_ime_commit_is_other() { + use egui::ImeChangeCause; + let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("hello")), Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.state_mut().session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 5)); + h.step(); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("x".into()))); + h.event(egui::Event::Key { + key: egui::Key::E, + physical_key: Some(egui::Key::E), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::COMMAND, + }); + h.step(); + let ime = h.output().platform_output.ime.clone().expect("an IME context"); + assert_eq!(ime.change_cause, ImeChangeCause::Other, "an application change after the receipt poisons the frame"); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "hellox"); + assert_eq!( + h.state().session.doc.para_at(&wordcraft_doc::Pos::body(0, 0)).and_then(|p| p.props.align), + Some(wordcraft_doc::props::Align::Center), + "the application command really ran" + ); + } + + /// A selected non-empty SmartArt graphic consumes an IME commit as an application edit: its + /// item changes, the body does not, and the frame is `Other`. + #[test] + fn smart_art_item_ime_commit_is_other_and_does_not_reach_the_body() { + use egui::ImeChangeCause; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("insert.smartArt", json!({"items": ["A"]})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.event(egui::Event::Ime(egui::ImeEvent::Commit("x".into()))); + h.step(); + let body = h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end().to_string(); + assert_eq!(body, "", "the commit must not fall through into the body"); + let item = wordcraft_engine::cmd::smart_art::selected_smart_art(&h.state().session).and_then(|g| g.items.first().map(|i| i.text.clone())); + assert_eq!(item.as_deref(), Some("Ax"), "the SmartArt item really changed"); + let ime = h.output().platform_output.ime.clone().expect("an IME context"); + assert_eq!(ime.change_cause, ImeChangeCause::Other); + } + + /// A selected graphic with no items consumes the commit without editing anything. The body + /// stays empty and no InputMethod receipt is fabricated. + #[test] + fn empty_smart_art_ime_commit_is_consumed_without_body_insertion() { + use egui::ImeChangeCause; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("insert.smartArt", json!({"items": ["A"]})).unwrap(); + // `insert.smartArt` refuses an empty item list, but an imported/edited model may hold + // none: make the selected graphic empty while keeping its identity. + let at = app.session.sel.ordered().0; + if let Some(wordcraft_doc::InlineObject::Graphic { graphic, .. }) = + app.session.doc.para_mut(at.story, &at.path).expect("paragraph").object_at_mut(at.off) + && let Some(spec) = std::sync::Arc::make_mut(graphic).smart_art.as_mut() + { + std::sync::Arc::make_mut(spec).items.clear(); + } + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + let depth = h.state().session.undo_depth(); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("x".into()))); + h.step(); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "", "no body insertion"); + assert_eq!(h.state().session.undo_depth(), depth, "consuming an empty graphic changes no document"); + let ime = h.output().platform_output.ime.clone().expect("an IME context"); + assert_eq!(ime.change_cause, ImeChangeCause::Other, "no receipt and no application edit"); + } + + /// A wide selection's surrounding text is unavailable; deleting inside it still earns + /// `InputMethod` when both engine revisions are present and strictly increase. + #[test] + fn ime_wide_unavailable_deletion_uses_engine_revisions() { + use egui::ImeChangeCause; + let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text(&"a".repeat(5000))), Default::default()); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + // A wide selection with one character before it: the counted deletion is outside the + // selection, so the selection stays wide (unavailable) after it. + h.state_mut().session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos::body(0, 1), focus: wordcraft_doc::Pos::body(0, 5000) }; + h.step(); + let before = h.output().platform_output.ime.clone().expect("an IME context"); + assert!(before.surrounding_text.available().is_none(), "the wide selection is unavailable"); + h.event(egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 })); + h.step(); + let body = h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body); + assert_eq!(body.matches('a').count(), 4999); + let after = h.output().platform_output.ime.clone().expect("an IME context"); + assert!(after.surrounding_text.available().is_none(), "the selection is still wide after the deletion"); + assert_eq!(after.change_cause, ImeChangeCause::InputMethod, "the engine revisions prove the change"); + } + + /// A transactional equation delete that cannot make progress is refused as a whole and + /// interrupts the composition without a receipt. + #[test] + fn ime_equation_delete_at_start_is_refused_without_a_receipt() { + use egui::ImeChangeCause; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("insert.equation", json!({"linear": "x+y"})).unwrap(); + app.run("equation.move", json!({"dir": "home"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.event(egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 0 })); + h.step(); + let linear = h.state_mut().run("equation.get", json!({})).unwrap()["linear"].as_str().unwrap_or_default().to_string(); + assert_eq!(linear, "x+y", "nothing is deleted"); + let ime = h.output().platform_output.ime.clone().expect("an IME context"); + assert_ne!(ime.change_cause, ImeChangeCause::InputMethod); + assert!(ime.should_interrupt_composition, "the refused equation delete interrupts the composition"); + } + + /// A byte-counted replacement inside an equation is refused as a whole: nothing is deleted, + /// only the immediately paired stale `Text`/`Commit` is dropped and the composition is + /// interrupted. A later direct commit in the same raw batch or a later frame is fresh input + /// and applies, so the adjacent-pair bound never swallows it. + #[test] + fn ime_equation_byte_delete_drops_only_its_immediate_replacement() { + use egui::ImeChangeCause; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("insert.equation", json!({"linear": "x+y"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + let linear = |h: &mut egui_kittest::Harness<'_, WordApp>| { + h.state_mut().run("equation.get", json!({})).unwrap()["linear"].as_str().unwrap_or_default().to_string() + }; + // Harness::event steps each queued event in a separate frame. Insert the complete + // protocol replacement into RawInput to exercise a single native input batch. + h.input_mut().events.extend([ + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), + egui::Event::Ime(egui::ImeEvent::Commit("Z".into())), + egui::Event::Ime(egui::ImeEvent::Commit("W".into())), + ]); + h.step(); + assert_eq!(linear(&mut h), "x+yW", "the adjacent stale commit is dropped and the next direct commit applies"); + let ime = h.output().platform_output.ime.clone().expect("still editing the equation"); + assert_eq!(ime.change_cause, ImeChangeCause::InputMethod, "the fresh direct commit is the input method's change"); + assert!(ime.should_interrupt_composition, "the refused byte deletion still interrupts the stale composition"); + + // An adjacent `Text` + `Commit` pair is one replacement; a third commit is fresh. + let base = linear(&mut h); + h.input_mut().events.extend([ + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), + egui::Event::Text("Q".into()), + egui::Event::Ime(egui::ImeEvent::Commit("Q2".into())), + egui::Event::Ime(egui::ImeEvent::Commit("R".into())), + ]); + h.step(); + assert_eq!(linear(&mut h), format!("{base}R"), "the adjacent pair is dropped, not every later commit"); + let ime = h.output().platform_output.ime.clone().expect("still editing the equation"); + assert_eq!(ime.change_cause, ImeChangeCause::InputMethod); + assert!(ime.should_interrupt_composition); + + // A refused deletion with no replacement in its batch must not swallow a later direct + // commit, even with no pre-edit before it. + h.event(egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 })); + h.step(); + let base = linear(&mut h); + assert_eq!(linear(&mut h), "x+yWR", "the refused deletion alone changed nothing"); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("Y".into()))); + h.step(); + assert_eq!(linear(&mut h), format!("{base}Y"), "a later direct commit applies without a pre-edit"); + let ime = h.output().platform_output.ime.clone().expect("still editing the equation"); + assert_eq!(ime.change_cause, ImeChangeCause::InputMethod, "the fresh commit is the input method's change"); + assert!(!ime.should_interrupt_composition, "the fresh commit is not treated as stale"); + } + + /// A satisfied transactional equation delete commits content and earns one `InputMethod` + /// receipt over the checked unavailable revisions. + #[test] + fn ime_equation_delete_is_one_input_method_change() { + use egui::ImeChangeCause; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("insert.equation", json!({"linear": "x+y"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.event(egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 0 })); + h.step(); + let linear = h.state_mut().run("equation.get", json!({})).unwrap()["linear"].as_str().unwrap_or_default().to_string(); + assert_eq!(linear, "x+"); + let ime = h.output().platform_output.ime.clone().expect("still editing the equation"); + assert_eq!(ime.change_cause, ImeChangeCause::InputMethod, "the engine revision proves the delete"); + } + + /// An application change in an equation context and an IME commit in the same frame are + /// `Other` in both orders, just as for body text. + #[test] + fn ime_equation_context_mixes_with_application_changes() { + use egui::ImeChangeCause; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("insert.equation", json!({"linear": "x"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + h.state_mut().run("equation.justify", json!({"jc": "left"})).unwrap(); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("y".into()))); + h.step(); + let before = h.output().platform_output.ime.clone().expect("still editing"); + assert_eq!(before.change_cause, ImeChangeCause::Other, "an application change before the receipt poisons the frame"); + let linear = h.state_mut().run("equation.get", json!({})).unwrap()["linear"].as_str().unwrap_or_default().to_string(); + assert_eq!(linear, "xy"); + + h.event(egui::Event::Ime(egui::ImeEvent::Commit("z".into()))); + h.step(); + let accepted = h.output().platform_output.ime.clone().expect("still editing"); + assert_eq!(accepted.change_cause, ImeChangeCause::InputMethod); + h.state_mut().run("equation.justify", json!({"jc": "right"})).unwrap(); + h.step(); + let after = h.output().platform_output.ime.clone().expect("still editing"); + assert_eq!(after.change_cause, ImeChangeCause::Other, "an application change after the receipt poisons the frame"); + } + + /// Replacing the edited equation under the same document id gives it a fresh session token: + /// the IME context changes and the old composition is interrupted. + #[test] + fn ime_equation_replacement_starts_a_new_context() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("insert.equation", json!({"linear": "x+y"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..8 { + h.step(); + } + let first = h.output().platform_output.ime.as_ref().map(|i| i.context_generation).expect("an IME context"); + h.state_mut().run("insert.equation", json!({"linear": "z"})).unwrap(); + h.step(); + let ime = h.output().platform_output.ime.clone().expect("the new equation owns the context"); + assert!(ime.context_generation > first, "a new equation editing session is a new context"); + assert!(ime.should_interrupt_composition, "the old equation composition is interrupted"); + assert!(h.state().session.math.is_some(), "the new equation is being edited"); + } + fn page_at_index(rects: &[Rect], p: Pos2) -> Option { nearest_page(rects, p) } diff --git a/crates/ui-egui/src/caret_glide.rs b/crates/ui-egui/src/caret_glide.rs index 0feb2b9713..12b886f3ef 100644 --- a/crates/ui-egui/src/caret_glide.rs +++ b/crates/ui-egui/src/caret_glide.rs @@ -322,7 +322,7 @@ fn parse_ms(value: Option<&str>) -> f64 { } fn state_id() -> egui::Id { - egui::Id::new("caret_glide") + egui::Id::unique("caret_glide") } /// What puts the caret where it is: the document's revision and the caret's position in it. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 60053a871d..896db0d44d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -26,7 +26,7 @@ pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::Pat /// The Quick Access Toolbar's button for command `id`. pub(crate) fn qat_button_id(id: &str) -> egui::Id { - egui::Id::new(("qat_button", id)) + egui::Id::unique(("qat_button", id)) } fn qat_button(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, enabled: bool) -> egui::Response { @@ -101,12 +101,12 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) { /// The Undo button's drop-down arrow (#701), which opens [`undo_list`]. pub(crate) fn undo_list_button_id() -> egui::Id { - egui::Id::new("qat_undo_list") + egui::Id::unique("qat_undo_list") } /// The status bar's message, the one that says what went wrong (#2402). pub(crate) fn status_message_id() -> egui::Id { - egui::Id::new("status_message") + egui::Id::unique("status_message") } /// Row `i` of the Undo list (0 = the newest step). @@ -203,7 +203,7 @@ fn undo_list(ui: &mut Ui, app: &mut WordApp) { /// The Quick Access Toolbar's menu button (…), which opens [`qat_menu`]. pub(crate) fn qat_menu_button_id() -> egui::Id { - egui::Id::new("qat_menu_button") + egui::Id::unique("qat_menu_button") } /// The Quick Access Toolbar's menu: common commands to show or hide, and More Commands… @@ -289,7 +289,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { .show(ui, |ui| { let full = ui.max_rect(); // Drag the window by the title bar (integrated title bar on macOS). - let drag = ui.interact(full, ui.id().with("title_drag"), Sense::click_and_drag()); + let drag = ui.interact(full, ui.scope_id().with("title_drag"), Sense::click_and_drag()); if drag.drag_started() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } @@ -347,7 +347,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { // Account / community. let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click()); ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Account"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Account"))); let initials: String = app.session.user_initials().chars().take(2).collect(); ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent); if resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)).clicked() { @@ -405,8 +405,8 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { let w = text_width(ui.painter(), &shown, &font, track, t.text); if available > 24.0 { let rect = Rect::from_center_size(pos2(cx, full.center().y), vec2(w + 8.0, control_h)); - let response = ui.interact(rect, ui.id().with("document_name"), Sense::click()).on_hover_text(tl!("Rename Document")); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &title)); + let response = ui.interact(rect, ui.scope_id().with("document_name"), Sense::click()).on_hover_text(tl!("Rename Document")); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &title)); if response.clicked() { let _ = app.run("file.rename", json!({})); } @@ -430,7 +430,7 @@ fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(260.0, control_h), Sense::click()); let ph = tl!("Search commands and help"); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, ph)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, ph)); ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(16.0, 16.0)), "search", t.text_dim, t.accent); let pfont = TypeRung::Control.regular(); @@ -506,7 +506,7 @@ pub fn screen_bar(app: &mut WordApp, ui: &mut Ui, screen: Screen) { let shade = if resp.hovered() || app.focus_ribbon { egui::Color32::from_gray(70) } else { egui::Color32::from_gray(40) }; ui.painter().rect_filled(r, 4.0, shade); icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), "more", egui::Color32::from_gray(220), t.accent); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Show the ribbon"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Show the ribbon"))); if resp.on_hover_text(tl!("Show the ribbon")).clicked() { let _ = app.run("ui.focusRibbon", json!({})); } @@ -522,7 +522,7 @@ pub fn screen_bar(app: &mut WordApp, ui: &mut Ui, screen: Screen) { /// click on the page puts it away; Focus stays on. pub fn focus_ribbon(app: &mut WordApp, ctx: &egui::Context) { let width = ctx.content_rect().width(); - egui::Area::new(egui::Id::new("focus_ribbon")).fixed_pos(pos2(0.0, SCREEN_BAR_HEIGHT)).order(egui::Order::Middle).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("focus_ribbon")).fixed_pos(pos2(0.0, SCREEN_BAR_HEIGHT)).order(egui::Order::Middle).show(ctx, |ui| { ui.set_width(width); ui.set_max_width(width); crate::ribbon::show(app, ui); @@ -568,7 +568,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { let w = text_width(ui.painter(), s, &font, track, t.text_dim); let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click()); paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, s)); resp }; if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())])) @@ -652,7 +652,11 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { let mut v = if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT }; let slider = egui::Slider::new(&mut v, if reading { 0.75..=1.5 } else { 0.1..=5.0 }).logarithmic(!reading).show_value(false); ui.spacing_mut().slider_width = 110.0; - if ui.add(slider).changed() { + let response = ui.add(slider); + response.widget_info(|| { + egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), if reading { tl!("Text Size") } else { tl!("Zoom") }) + }); + if response.changed() { let command = if reading { "view.readTextSize" } else { "view.zoom" }; let _ = app.run(command, json!({"value": (v * 100.0).round()})); } @@ -678,7 +682,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { ui.painter().rect_filled(r, 3.0, t.hover); } icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, name)); if resp.on_hover_text(name).clicked() { let _ = app.run(id, json!({})); } @@ -705,7 +709,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { ftrack, t.text_dim, ); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, focus)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, focus)); if resp.clicked() { let _ = app.run("view.focus", json!({})); } diff --git a/crates/ui-egui/src/colors.rs b/crates/ui-egui/src/colors.rs index e5c0d22e3d..0f2258ca79 100644 --- a/crates/ui-egui/src/colors.rs +++ b/crates/ui-egui/src/colors.rs @@ -248,15 +248,15 @@ pub enum Pick { } fn recent_id() -> Id { - Id::new("wordcraft.recentColors") + Id::unique("wordcraft.recentColors") } fn request_id() -> Id { - Id::new("wordcraft.moreColorsRequest") + Id::unique("wordcraft.moreColorsRequest") } fn open_flag_id() -> Id { - Id::new("wordcraft.colorsDialogOpen") + Id::unique("wordcraft.colorsDialogOpen") } /// Removes and returns a temporary value. @@ -449,7 +449,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { ctx.data_mut(|m| m.insert_temp(open_flag_id(), false)); return; }; - let resp = egui::Modal::new(Id::new("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d)); + let resp = egui::Modal::new(Id::unique("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d)); let mut done = resp.inner; if done.is_none() && resp.should_close() { done = Some(false); @@ -799,7 +799,7 @@ mod tests { assert_eq!(Target::cmd("format.color").command("1A2B3C"), Some(("format.color".into(), json!({"color": "1A2B3C"})))); let glow = Target::at("shape.effects", json!({"glow": {"color": null, "size": 8.0}}), "/glow/color"); assert_eq!(glow.command("00FF00"), Some(("shape.effects".into(), json!({"glow": {"color": "00FF00", "size": 8.0}})))); - assert_eq!(Target::Field(Id::new("x")).command("00FF00"), None); + assert_eq!(Target::Field(Id::unique("x")).command("00FF00"), None); } #[test] @@ -837,7 +837,7 @@ mod tests { assert_eq!(app.canvas.last_font_color, "7A3EC8"); assert_eq!(app.ui.recent_colors.first().map(String::as_str), Some("7A3EC8")); // A dialog field gets the colour back instead. - let id = Id::new("field"); + let id = Id::unique("field"); d.target = Target::Field(id); finish(&mut app, &ctx, &d); let mut value = String::new(); diff --git a/crates/ui-egui/src/content_controls.rs b/crates/ui-egui/src/content_controls.rs index 2febf59634..923be1d215 100644 --- a/crates/ui-egui/src/content_controls.rs +++ b/crates/ui-egui/src/content_controls.rs @@ -163,7 +163,7 @@ pub fn popup(app: &mut WordApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let at_json = serde_json::to_value(&at).unwrap_or_default(); let mut close = false; - let area = egui::Area::new(egui::Id::new("content_control_popup")) + let area = egui::Area::new(egui::Id::unique("content_control_popup")) .order(egui::Order::Foreground) .fixed_pos(pos2(anchor.max.x - 180.0, anchor.max.y + 2.0)); let shown = area.show(ctx, |ui| { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7dddc372a1..5a3d55e06e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -15,6 +15,8 @@ use std::sync::mpsc::Sender; +use craft_ui::capture::CaptureSlot; + use serde_json::{Value, json}; use crate::WordApp; @@ -244,7 +246,7 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O }; let delta = egui::vec2(f("dx").unwrap_or(0.0), f("dy").unwrap_or(0.0)); app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y))); - app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase, modifiers: mods(p) }); + app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase, modifiers: mods(p), source: egui::MouseWheelSource::Unknown }); ok(json!({"queued": true})) } "ui.text" => { @@ -312,23 +314,715 @@ fn render_png(app: &WordApp, page: u64, scale: f32) -> Result { Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()})) } +/// The bounded admission policy for `ui.screenshot` captures. This is the screenshot queue; +/// the engine's 64-entry UI-intent queue is unrelated. The per-image and aggregate limits are +/// working-set limits, not just raw RGBA: encoding a PNG and base64-encoding it for a reply can +/// briefly need several times the raw pixel buffer. +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; +pub(crate) const SCREENSHOT_MAX_RETRIES: u32 = 2; +/// An incompressible PNG of `raw` RGBA bytes. Deflate's stored-block worst case adds five bytes +/// per 64 KiB block; zlib/PNG framing and the encoder's small internal buffers get 64 KiB more. +/// Checked, because every byte count here comes from a hostile window size. +fn png_upper_bound(raw: usize) -> Option { + let blocks = raw.div_ceil(65_535); + raw.checked_add(blocks.checked_mul(5)?)?.checked_add(64 * 1024) +} + +/// The length of the base64 text for `bytes`, rounded up, as `base64_encode` produces it. +fn base64_upper_bound(bytes: usize) -> Option { + bytes.div_ceil(3).checked_mul(4) +} + +/// Peak memory of one screenshot, in bytes: the completed `ColorImage`, the one straight-alpha +/// RGBA copy handed to the PNG encoder, the PNG buffer, and (for a reply without a path) the +/// base64 string. All of these can be live at once, so admission reserves their sum rather than +/// only the raw pixels or a PNG-only estimate. Checked throughout: a hostile surface size must +/// result in an error, never a wrapped budget. +fn working_set(raw: usize, inline_reply: bool) -> Option { + let png = png_upper_bound(raw)?; + let mut peak = raw.checked_add(raw)?.checked_add(png)?; + if inline_reply { + peak = peak.checked_add(base64_upper_bound(png)?)?; + } + Some(peak) +} + +/// The surface reported by one `RawInput`, before it is tied to a document. Captured in the host +/// input hook because `InputState::viewport_rect` and the last painted screen rect are stale on a +/// hidden logic tick; RawInput is the host's answer for this frame. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SurfaceGeometry { + viewport: egui::Vec2, + /// Native pixels per point times egui's zoom, so a zoom change is its own epoch. + pixels_per_point: f32, + max_texture_side: usize, + minimized: bool, + occluded: bool, +} + +impl SurfaceGeometry { + pub(crate) fn from_raw(raw: &egui::RawInput, ctx: &egui::Context) -> Option { + let viewport = raw.viewports.get(&raw.viewport_id)?; + let minimized = viewport.minimized == Some(true); + let occluded = viewport.occluded == Some(true); + // A minimized/occluded surface may report no rectangle at all. Its stale size is harmless + // because such a surface is never capturable; a visible surface without a rectangle is not + // fresh enough to use. + let rect = match viewport.inner_rect.or(raw.screen_rect) { + Some(rect) => rect, + None if minimized || occluded => ctx.content_rect(), + None => return None, + }; + let zoom = ctx.zoom_factor(); + let pixels_per_point = viewport.native_pixels_per_point.map_or_else(|| ctx.pixels_per_point(), |native| native * zoom); + let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side)); + if !rect.width().is_finite() || !rect.height().is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 { + return None; + } + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 || !zoom.is_finite() || zoom <= 0.0 { + return None; + } + Some(Self { viewport: rect.size(), pixels_per_point, max_texture_side, minimized, occluded }) + } + + pub(crate) fn with_document(self, document: u64) -> CaptureGeometry { + CaptureGeometry { surface: self, document } + } +} + +/// The viewport geometry and document identity a capture is admitted, issued and completed +/// against. `WordApp` keeps a monotonic epoch: any change, including a change back, makes an +/// older image stale. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CaptureGeometry { + surface: SurfaceGeometry, + document: u64, +} + +impl CaptureGeometry { + pub(crate) fn document(self) -> u64 { + self.document + } + + pub(crate) fn unavailable_reason(self) -> Option<&'static str> { + if self.surface.minimized { + Some("the window is minimized") + } else if self.surface.occluded { + Some("the window is fully occluded") + } else { + None + } + } + + /// The screenshot's physical size, rounded to the nearest pixel. + /// + /// The renderer's maximum texture side is deliberately not applied here. It is a texture + /// limit, not the size of the surface the callback reports; capping one axis would distort + /// the expected aspect and reject the real image. [`Self::oversize_reason`] rejects a window + /// that cannot have a surface texture at all, before admission. + pub(crate) fn expected_size(self) -> Option<[usize; 2]> { + if self.unavailable_reason().is_some() { + return None; + } + let axis = |points: f32| -> Option { + let px = (f64::from(points) * f64::from(self.surface.pixels_per_point)).round(); + if !px.is_finite() || px <= 0.0 || px > usize::MAX as f64 { + return None; + } + Some(px as usize) + }; + Some([axis(self.surface.viewport.x)?, axis(self.surface.viewport.y)?]) + } + + /// Why this surface is too large to capture at all: a window larger than the renderer's + /// maximum texture side has no surface texture to copy. Reject it explicitly rather than + /// silently capping the expected size and failing exact comparisons later. + pub(crate) fn oversize_reason(self) -> Option { + let [width, height] = self.expected_size()?; + let max = self.surface.max_texture_side; + (max > 0 && (width > max || height > max)) + .then(|| format!("screenshot failed: the window is {width}×{height} pixels, over the renderer's {max}-pixel texture limit")) + } + + /// Checked `width * height * 4` bytes for the expected image. + pub(crate) fn expected_bytes(self) -> Option { + let [width, height] = self.expected_size()?; + width.checked_mul(height)?.checked_mul(4) + } + + /// The admission reservation for the complete capture working set (image plus encoding). + /// `inline_reply` includes the base64 string a reply without a path produces. + pub(crate) fn reserved_bytes(self, inline_reply: bool) -> Option { + working_set(self.expected_bytes()?, inline_reply) + } +} + +/// One admitted capture. The request owns its token, path, deadline, immutable document identity, +/// geometry and reservation; the shared slot owns the image and its weak callback. +pub(crate) struct Shot { + pub(crate) token: u64, + pub(crate) path: Option, + pub(crate) reply: Sender, + /// One monotonic deadline for the whole request, including retries. + pub(crate) deadline: f64, + pub(crate) ready_at: f64, + pub(crate) frames: u32, + pub(crate) issued: bool, + pub(crate) retries: u32, + /// The document the request was admitted for. It never changes; a document switch cancels. + pub(crate) document: u64, + pub(crate) geometry: CaptureGeometry, + pub(crate) epoch: u64, + pub(crate) expected: [usize; 2], + /// Admission reservation, including the encoding working set. + pub(crate) bytes: usize, + pub(crate) slot: Option, + pub(crate) callback: Option, +} + +/// All outstanding `ui.screenshot` requests. +#[derive(Default)] +pub(crate) struct Shots { + pub(crate) token: u64, + pub(crate) requests: Vec, +} + +impl Shots { + pub(crate) fn in_flight(&self) -> bool { + !self.requests.is_empty() + } + + fn reserved(&self) -> usize { + self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.bytes)) + } +} + +fn error_reply(token: u64, message: impl std::fmt::Display) -> Value { + json!({"ok": false, "error": message.to_string(), "request": token}) +} + +fn reject_shot(reply: Sender, token: u64, message: impl std::fmt::Display) { + let _ = reply.send(error_reply(token, message)); +} + +fn next_token(app: &mut WordApp) -> u64 { + let mut token = app.shots.token.wrapping_add(1); + if token == 0 { + token = 1; + } + app.shots.token = token; + token +} + +/// Admit one screenshot request against the count and byte budgets **before** a capture is +/// issued. A rejected request gets one error reply, with its request token, and never evicts an +/// earlier request. +pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Option, reply: Sender) { + let token = next_token(app); + let Some(geometry) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else { + reject_shot(reply, token, "screenshot failed: the window geometry is not known yet"); + return; + }; + if let Some(reason) = geometry.unavailable_reason() { + reject_shot(reply, token, format!("screenshot failed: {reason}")); + return; + } + let Some(expected) = geometry.expected_size() else { + reject_shot(reply, token, "screenshot failed: the window has no capturable size"); + return; + }; + if let Some(reason) = geometry.oversize_reason() { + reject_shot(reply, token, reason); + return; + } + let inline_reply = path.is_none(); + let Some(bytes) = geometry.reserved_bytes(inline_reply) else { + reject_shot(reply, token, "screenshot failed: the window size overflows the screenshot budget"); + return; + }; + if bytes > SCREENSHOT_MAX_IMAGE_BYTES { + reject_shot( + reply, + token, + format!( + "screenshot budget exceeded: {bytes} bytes reserved for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + ), + ); + return; + } + if app.shots.requests.len() >= SCREENSHOT_MAX_REQUESTS { + reject_shot( + reply, + token, + format!("screenshot budget exceeded: {} requests are already outstanding (limit {SCREENSHOT_MAX_REQUESTS})", app.shots.requests.len()), + ); + return; + } + let reserved = app.shots.reserved().saturating_add(bytes); + if reserved > SCREENSHOT_MAX_AGGREGATE_BYTES { + reject_shot( + reply, + token, + format!("screenshot budget exceeded: {reserved} bytes reserved, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit"), + ); + return; + } + let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), SCREENSHOT_MAX_IMAGE_BYTES); + let now = crate::now_ms(); + app.shots.requests.push(Shot { + token, + path, + reply, + deadline: now + 8000.0, + ready_at: now + 120.0, + frames: 0, + issued: false, + retries: 0, + document: geometry.document(), + geometry, + epoch: app.capture_epoch, + expected, + bytes, + slot: Some(slot), + callback: Some(callback), + }); + // The capture command is issued from `ui`; while hidden, logic keeps asking for a painted + // frame until it gets there. + ctx.request_repaint(); +} + +/// Issued only from `ui`, after the layout has settled. Counts one frame per host frame on the +/// first pass, revalidates geometry and the byte reservation, and sends one callback command. +/// `current_pass_index() == 0` is meaningful here because this function is already in a UI pass. +pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { + let now = crate::now_ms(); + let first_pass = ctx.current_pass_index() == 0; + let mut failures: Vec<(usize, String)> = Vec::new(); + for i in 0..app.shots.requests.len() { + let (issued, ready_at, frames) = { + let shot = &app.shots.requests[i]; + (shot.issued, shot.ready_at, shot.frames) + }; + if issued { + continue; + } + let frames = if first_pass { frames.saturating_add(1) } else { frames }; + app.shots.requests[i].frames = frames; + if now < ready_at || frames < 3 { + continue; + } + let Some(current) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else { + failures.push((i, "screenshot failed: the window geometry is not known yet".into())); + continue; + }; + if let Some(reason) = current.unavailable_reason() { + failures.push((i, format!("screenshot failed: {reason} while the capture was queued"))); + continue; + } + if current.document() != app.shots.requests[i].document { + failures.push((i, "screenshot failed: the document changed while the capture was queued".into())); + continue; + } + let Some(expected) = current.expected_size() else { + failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into())); + continue; + }; + if let Some(reason) = current.oversize_reason() { + failures.push((i, reason)); + continue; + } + let inline_reply = app.shots.requests[i].path.is_none(); + let Some(bytes) = current.reserved_bytes(inline_reply) else { + failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into())); + continue; + }; + if bytes > SCREENSHOT_MAX_IMAGE_BYTES { + failures.push(( + i, + format!( + "screenshot budget exceeded: {bytes} bytes reserved for one image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit" + ), + )); + continue; + } + let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.bytes)); + if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { + failures.push((i, format!("screenshot budget exceeded: outstanding requests would reserve over {SCREENSHOT_MAX_AGGREGATE_BYTES} bytes"))); + continue; + } + let epoch = app.capture_epoch; + let shot = &mut app.shots.requests[i]; + shot.geometry = current; + shot.expected = expected; + shot.bytes = bytes; + shot.epoch = epoch; + if let Some(callback) = shot.callback.take() { + ctx.send_viewport_cmd_to(shot.viewport(), egui::ViewportCommand::Screenshot(callback)); + shot.issued = true; + } + } + for (i, message) in failures.into_iter().rev() { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject_shot(shot.reply, shot.token, message); + } + if app.shots.in_flight() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } +} + +impl Shot { + fn viewport(&self) -> egui::ViewportId { + egui::ViewportId::ROOT + } +} + +/// A completed image is either stale because the surface changed (safe to retry once within the +/// deadline) or invalid for this request (report it at once; retrying cannot help). +enum ImageCheck { + Stale(String), + Invalid(String), +} + +/// The callback's physical size is the host surface texture's extent, rounded independently of +/// the expected `round(logical points × pixels_per_point)`. One physical pixel on each axis is +/// therefore the same surface seen through a different rounding boundary, not a resize. +fn dimensions_are_a_rounded_variant(expected: [usize; 2], actual: [usize; 2]) -> bool { + // Both axes are derived from the same logical viewport and the same pixels-per-point; each + // may round at a boundary one pixel away, but a larger change on either axis is a resize. + expected[0].abs_diff(actual[0]) <= 1 && expected[1].abs_diff(actual[1]) <= 1 +} + +fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), ImageCheck> { + let [width, height] = image.size; + if width == 0 || height == 0 { + return Err(ImageCheck::Invalid("the screenshot has no pixels".to_string())); + } + // A screenshot is issued for one exact logical viewport. Exact equality would reject a valid + // fractional-DPI capture whose callback rounds by one pixel; a larger difference, or a + // different aspect, is a different surface. + if !dimensions_are_a_rounded_variant(expected, [width, height]) { + return Err(ImageCheck::Stale(format!( + "screenshot is {width}×{height} pixels, expected {}×{}: the window changed size while it was captured", + expected[0], expected[1] + ))); + } + let pixels = width.checked_mul(height).ok_or_else(|| ImageCheck::Invalid("screenshot dimensions overflow".to_string()))?; + if image.pixels.len() != pixels { + return Err(ImageCheck::Invalid(format!("screenshot has {} pixels for its {width}×{height} dimensions", image.pixels.len()))); + } + // The largest image allowed by the one-pixel rounding allowance. The reservation was made for + // the expected size; a sibling one pixel wider or taller is still the same surface. + let largest = expected[0] + .checked_add(1) + .and_then(|w| expected[1].checked_add(1).and_then(|h| w.checked_mul(h))) + .ok_or_else(|| ImageCheck::Invalid("screenshot dimensions overflow".to_string()))?; + if pixels > largest { + return Err(ImageCheck::Invalid(format!("screenshot has {pixels} pixels, over the {largest} allowed by its reservation"))); + } + Ok(()) +} + +/// Called from `logic`: take each completed slot once, check the deadline **before** writing, +/// validate request/document/geometry identity, and only then encode and reply. A late, stale or +/// oversized image is dropped without a file; a document switch cancels instead of rebinding. +pub(crate) fn drain_screenshots(app: &mut WordApp, ctx: &egui::Context) { + let now = crate::now_ms(); + let current = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }); + let epoch = app.capture_epoch; + let mut i = 0; + while i < app.shots.requests.len() { + enum Action { + Keep, + Remove(Value), + Restart, + } + let action = { + let shot = &mut app.shots.requests[i]; + if now >= shot.deadline { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + Action::Remove(error_reply(shot.token, "no frame was presented before the screenshot timeout")) + } else if !shot.issued { + Action::Keep + } else if let Some(reason) = current.and_then(CaptureGeometry::unavailable_reason) { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + Action::Remove(error_reply(shot.token, format!("screenshot failed: {reason} while the capture was pending"))) + } else if current.is_some_and(|geometry| geometry.document() != shot.document) { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + Action::Remove(error_reply(shot.token, "screenshot failed: the document changed while the capture was queued")) + } else { + match shot.slot.as_ref().and_then(|slot| slot.take()) { + None => Action::Keep, + Some(Err(error)) => Action::Remove(error_reply(shot.token, error)), + Some(Ok(image)) => { + let stale = current != Some(shot.geometry) || epoch != shot.epoch; + if stale { + if shot.retries < SCREENSHOT_MAX_RETRIES { + Action::Restart + } else { + Action::Remove(error_reply(shot.token, "screenshot geometry changed while it was captured; no image was written")) + } + } else { + match validate_image(&image, shot.expected) { + Err(ImageCheck::Stale(_)) if shot.retries < SCREENSHOT_MAX_RETRIES => Action::Restart, + Err(ImageCheck::Stale(message) | ImageCheck::Invalid(message)) => Action::Remove(error_reply(shot.token, message)), + Ok(()) => Action::Remove(save_screenshot(image.as_ref(), shot.path.as_deref(), shot.token)), + } + } + } + } + } + }; + match action { + Action::Keep => i += 1, + Action::Remove(value) => { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + let _ = shot.reply.send(value); + } + Action::Restart => { + let Some(current) = current else { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject_shot(shot.reply, shot.token, "screenshot failed: the window geometry is not known"); + continue; + }; + let Some(expected) = current.expected_size() else { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject_shot(shot.reply, shot.token, "screenshot failed: the window size overflows the screenshot budget"); + continue; + }; + if let Some(reason) = current.oversize_reason() { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject_shot(shot.reply, shot.token, reason); + continue; + } + let inline_reply = app.shots.requests[i].path.is_none(); + let Some(bytes) = current.reserved_bytes(inline_reply) else { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject_shot(shot.reply, shot.token, "screenshot failed: the window size overflows the screenshot budget"); + continue; + }; + let others = + app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.bytes)); + if bytes > SCREENSHOT_MAX_IMAGE_BYTES || others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject_shot(shot.reply, shot.token, "screenshot budget exceeded while retrying after a resize"); + continue; + } + let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), SCREENSHOT_MAX_IMAGE_BYTES); + let shot = &mut app.shots.requests[i]; + shot.slot = Some(slot); + shot.callback = Some(callback); + shot.issued = false; + shot.ready_at = now + 120.0; + shot.frames = 0; + shot.retries = shot.retries.saturating_add(1); + shot.geometry = current; + shot.expected = expected; + shot.bytes = bytes; + shot.epoch = epoch; + i += 1; + } + } + } + if app.shots.in_flight() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } +} + +/// The screenshot as a straight-alpha PNG, using one RGBA copy rather than the extra full-image +/// copy [`wordcraft_render::Rendered::to_png`] makes for its premultiplied input. The callback's +/// `ColorImage` is premultiplied; PNG stores straight alpha. +fn screenshot_png(image: &egui::ColorImage) -> Option> { + let [width, height] = image.size; + if width == 0 || height == 0 { + return None; + } + let pixels = width.checked_mul(height)?; + if image.pixels.len() != pixels { + return None; + } + let mut rgba = Vec::with_capacity(pixels.checked_mul(4)?); + for color in &image.pixels { + let [r, g, b, a] = color.to_array(); + if a == 0 || a == 255 { + rgba.extend_from_slice(&[r, g, b, a]); + } else { + let straight = |c: u8| ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8; + rgba.extend_from_slice(&[straight(r), straight(g), straight(b), a]); + } + } + let img = image::RgbaImage::from_raw(u32::try_from(width).ok()?, u32::try_from(height).ok()?, rgba)?; + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) +} + /// The window screenshot: written to `path` when one was given, else base64 PNG in the reply -/// (never a fixed name in the shared temporary folder). -pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { +/// (never a fixed name in the shared temporary folder). Every reply carries the request token so +/// a client can correlate it with the admission, timeout and error replies. +pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64) -> Value { let [w, h] = image.size; - let bytes: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); - let rendered = wordcraft_render::Rendered { width: w as u32, height: h as u32, pixels: bytes }; - let png = rendered.to_png(); + let Some(raw) = w.checked_mul(h).and_then(|pixels| pixels.checked_mul(4)) else { + return error_reply(token, "screenshot dimensions overflow the byte count"); + }; + let inline_reply = path.is_none(); + let Some(peak) = working_set(raw, inline_reply) else { + return error_reply(token, "screenshot working set overflows the byte count"); + }; + if peak > SCREENSHOT_MAX_IMAGE_BYTES { + return error_reply( + token, + format!("screenshot needs {peak} bytes of working set, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit"), + ); + } + let Some(png) = screenshot_png(image) else { + return error_reply(token, "screenshot PNG encoding failed"); + }; + if png.len() > SCREENSHOT_MAX_IMAGE_BYTES { + return error_reply(token, format!("screenshot PNG uses {} bytes, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", png.len())); + } let Some(path) = path else { - return json!({"ok": true, "result": {"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": w, "height": h}}); + let encoded = wordcraft_engine::cmd::insert::base64_encode(&png); + if encoded.len() > SCREENSHOT_MAX_IMAGE_BYTES { + return error_reply( + token, + format!("screenshot base64 reply uses {} bytes, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", encoded.len()), + ); + } + return json!({"ok": true, "request": token, "result": {"png": encoded, "width": w, "height": h}}); }; #[cfg(target_arch = "wasm32")] let _ = (&png, path); #[cfg(not(target_arch = "wasm32"))] { if let Err(e) = std::fs::write(path, &png) { - return json!({"ok": false, "error": format!("{path}: {e}")}); + return error_reply(token, format!("{path}: {e}")); } } - json!({"ok": true, "result": {"path": path, "width": w, "height": h}}) + json!({"ok": true, "request": token, "result": {"path": path, "width": w, "height": h}}) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn surface(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> SurfaceGeometry { + let ctx = egui::Context::default(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, viewport); + let mut raw = egui::RawInput { screen_rect: Some(rect), max_texture_side: Some(max_texture_side), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { + inner_rect: Some(rect), + native_pixels_per_point: Some(pixels_per_point), + minimized: Some(false), + occluded: Some(false), + ..Default::default() + }, + ); + SurfaceGeometry::from_raw(&raw, &ctx).expect("a visible surface") + } + + fn geometry(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> CaptureGeometry { + CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), document: 7 } + } + + fn image(width: usize, height: usize) -> egui::ColorImage { + egui::ColorImage::filled([width, height], egui::Color32::BLACK) + } + + /// The reservation covers every live copy: the image, the RGBA copy the encoder consumes, the + /// PNG buffer and the base64 reply string. A path reply needs no base64. + #[test] + fn screenshot_working_set_counts_image_copy_png_and_base64() { + let raw = 1_000_000usize; + let png = png_upper_bound(raw).expect("the PNG bound"); + let base64 = base64_upper_bound(png).expect("the base64 bound"); + assert_eq!(working_set(raw, false), Some(raw + raw + png)); + assert_eq!(working_set(raw, true), Some(raw + raw + png + base64)); + assert!(working_set(raw, true).expect("bounded") > raw * 4, "raw pixels alone are not the peak"); + assert_eq!(base64_upper_bound(0), Some(0)); + assert_eq!(base64_upper_bound(1), Some(4)); + assert_eq!(base64_upper_bound(4), Some(8), "rounded up, as the encoder pads"); + assert_eq!(base64_upper_bound(usize::MAX), None, "overflow is refused, not wrapped"); + assert_eq!(working_set(usize::MAX, true), None); + } + + /// The 128 MiB per-image budget is a working-set budget, not a raw-pixel budget: a + /// 3000×3000 inline reply no longer fits even though its raw pixels are only 36 MB, while a + /// path reply for the same image (no base64) does. + #[test] + fn the_per_image_budget_reflects_the_encoding_peak() { + let fits = geometry(egui::vec2(2000.0, 2000.0), 1.0, 16_384); + assert!(fits.reserved_bytes(true).expect("bounded") <= SCREENSHOT_MAX_IMAGE_BYTES); + + let too_big_inline = geometry(egui::vec2(3000.0, 3000.0), 1.0, 16_384); + assert!(too_big_inline.reserved_bytes(true).expect("bounded") > SCREENSHOT_MAX_IMAGE_BYTES); + assert!(too_big_inline.reserved_bytes(false).expect("bounded") <= SCREENSHOT_MAX_IMAGE_BYTES); + } + + /// A texture limit must not cap the expected physical size: capping one axis changes the + /// expected aspect and makes the real callback look stale. Out-of-limit surfaces are rejected + /// explicitly instead. + #[test] + fn expected_size_is_not_capped_by_the_texture_limit() { + let wide = geometry(egui::vec2(10_000.0, 2_000.0), 1.0, 8_192); + assert_eq!(wide.expected_size(), Some([10_000, 2_000])); + let reason = wide.oversize_reason().expect("over the texture limit"); + assert!(reason.contains("texture limit"), "{reason}"); + + let fits = geometry(egui::vec2(8_000.0, 2_000.0), 1.0, 8_192); + assert_eq!(fits.expected_size(), Some([8_000, 2_000])); + assert_eq!(fits.oversize_reason(), None); + } + + /// Fractional DPI rounds the host surface and the expected size at different boundaries: a + /// one-pixel difference on each axis is the same capture, a two-pixel difference is stale. + #[test] + fn a_callback_one_pixel_off_the_rounded_size_is_accepted() { + let g = geometry(egui::vec2(100.4, 100.4), 1.5, 16_384); + assert_eq!(g.expected_size(), Some([151, 151])); + assert!(validate_image(&image(152, 150), [151, 151]).is_ok()); + assert!(matches!(validate_image(&image(153, 149), [151, 151]), Err(ImageCheck::Stale(_)))); + assert!(matches!(validate_image(&image(800, 600), [151, 151]), Err(ImageCheck::Stale(_)))); + } + + /// A real resize is stale even when one axis happens to be close: both axes must match within + /// the one-pixel rounding allowance. + #[test] + fn a_two_pixel_change_is_a_resize_not_rounding() { + assert!(dimensions_are_a_rounded_variant([1000, 1000], [1001, 999])); + assert!(!dimensions_are_a_rounded_variant([1000, 1000], [1200, 800])); + assert!(!dimensions_are_a_rounded_variant([1000, 1000], [1002, 999])); + assert!(!dimensions_are_a_rounded_variant([1000, 1000], [1000, 998])); + } } diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index f571b33b68..be72aa3339 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -212,7 +212,7 @@ 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) { - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(tl!("Show")); diff --git a/crates/ui-egui/src/crop.rs b/crates/ui-egui/src/crop.rs index 05dec4d298..0fc1b06241 100644 --- a/crates/ui-egui/src/crop.rs +++ b/crates/ui-egui/src/crop.rs @@ -285,7 +285,7 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: /// it can't be decoded (then the cropped-away area is just dimmed). fn source(app: &WordApp, ctx: &egui::Context, media: &str) -> Option { let bytes = app.session.doc.media.get(media)?; - let id = egui::Id::new(("crop_source", media, bytes.len())); + let id = egui::Id::unique(("crop_source", media, bytes.len())); if let Some(t) = ctx.data(|d| d.get_temp::>(id)) { return t; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0709aca182..8592f243fd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -936,7 +936,7 @@ pub(crate) fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut /// colour grid, Recent Colors and More Colors… (#606). `value` is hex or `none_value`. pub(crate) fn color_field(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_label: &str, none_value: &str) { ui.horizontal(|ui| { - let id = ui.id().with("colorField"); + let id = ui.scope_id().with("colorField"); crate::colors::take_field(ui.ctx(), id, value); let c = wordcraft_doc::Rgb::parse(value); let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); @@ -1356,7 +1356,7 @@ impl Dialog { /// Grid picker for Insert › Table (hover to size, click to insert). pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) { let t = Tokens::get(ui.ctx()); - let id = egui::Id::new("table_picker_hover"); + let id = egui::Id::unique("table_picker_hover"); let hover: (usize, usize) = ui.data(|d| d.get_temp(id)).unwrap_or((0, 0)); ui.label(if hover.0 > 0 { crate::i18n::fmt(tl!("{cols}×{rows} Table"), &[("cols", &hover.1.to_string()), ("rows", &hover.0.to_string())]) @@ -1396,7 +1396,7 @@ pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) { /// The open dialog, as (window id, frames it has been centred for). fn dialog_top_key() -> egui::Id { - egui::Id::new("dialog_top") + egui::Id::unique("dialog_top") } /// Frames a dialog stays centred: the first one measures it, the second centres that size. @@ -1487,7 +1487,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::Organizer { .. } => "Organizer", Dialog::Layout(d) => d.title(), }; - let id = egui::Id::new(("dialog", title)); + let id = egui::Id::unique(("dialog", title)); let window = crate::window_close::window(ctx, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false)); let shown = place_dialog(ctx, id, window).show(ctx, |ui| { ui.set_min_width(340.0); @@ -1628,10 +1628,12 @@ fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &mut bool, c egui::Grid::new("ps_paper").num_columns(4).show(ui, |ui| { use wordcraft_engine::cmd::page::{MAX_PAGE_SIDE, MIN_PAGE_SIDE}; let range = MIN_PAGE_SIDE / ppu..=MAX_PAGE_SIDE / ppu; - ui.label(tl!(l.width)); - let w = ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2)); - ui.label(tl!(l.height)); - let h = ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)); + let width_label = ui.label(tl!(l.width)); + let w = ui + .add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2)) + .labelled_by(width_label.id); + let height_label = ui.label(tl!(l.height)); + let h = ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)).labelled_by(height_label.id); if (w.changed() || h.changed()) && paper.0 != paper.1 { *landscape = paper.0 > paper.1; } @@ -1763,8 +1765,8 @@ fn apply_font(app: &mut WordApp, d: &Dialog) { fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { match d { Dialog::RenameDocument { name, message, focus } => { - ui.label(tl!("Document name")); - let r = ui.add(egui::TextEdit::singleline(name).desired_width(320.0).char_limit(200)); + let label = ui.label(tl!("Document name")); + let r = ui.add(egui::TextEdit::singleline(name).desired_width(320.0).char_limit(200)).labelled_by(label.id); if *focus { r.request_focus(); *focus = false; @@ -1858,15 +1860,15 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } ui.end_row(); - ui.label(tl!("Size:")); - ui.text_edit_singleline(size); + let size_label = ui.label(tl!("Size:")); + ui.text_edit_singleline(size).labelled_by(size_label.id); ui.end_row(); ui.label(tl!("Font color:")); let theme = app.session.doc.settings.theme_colors.clone(); color_field(ui, &theme, color, "Automatic", "auto"); ui.end_row(); - ui.label(tl!("Spacing (pt):")); - ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0)); + let spacing_label = ui.label(tl!("Spacing (pt):")); + ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0)).labelled_by(spacing_label.id); ui.end_row(); let label = tl!("Kerning for fonts:"); match kern { @@ -1880,8 +1882,9 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } ui.horizontal(|ui| { - ui.add_enabled(*kern == Some(true), egui::DragValue::new(kern_at).speed(0.5).range(1.0..=1638.0).max_decimals(1)); - ui.label(tl!("Points and above")); + let points = ui.add_enabled(*kern == Some(true), egui::DragValue::new(kern_at).speed(0.5).range(1.0..=1638.0).max_decimals(1)); + let points_label = ui.label(tl!("Points and above")); + points.labelled_by(points_label.id); }); ui.end_row(); }); @@ -1959,43 +1962,44 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); const ALIGNS: [(&str, &str); 4] = [("left", "Left"), ("center", "Center"), ("right", "Right"), ("justify", "Justify")]; ui.horizontal(|ui| { - ui.label(tl!("Alignment:")); + let align_label = ui.label(tl!("Alignment:")); let shown = if mixed.contains("align") { "" } else { ALIGNS.iter().find(|(v, _)| v == align).map_or(align.as_str(), |(_, l)| tl!(l)) }; - egui::ComboBox::from_id_salt("para_align").selected_text(shown).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("para_align").selected_text(shown).show_ui(ui, |ui| { for (v, l) in ALIGNS { if ui.selectable_value(align, v.to_string(), tl!(l)).clicked() { mixed.remove("align"); } } }); + combo.response.labelled_by(align_label.id); }); ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5))); // Mirrored indents are the inside and outside ones (they swap on even pages). let (l_label, r_label) = if opts[0] { ("Inside:", "Outside:") } else { ("Left:", "Right:") }; egui::Grid::new("ind").num_columns(4).show(ui, |ui| { - ui.label(tl!(l_label)); - para_number(ui, left, ParaNumber::Length, "left", mixed); - ui.label(tl!(r_label)); - para_number(ui, right, ParaNumber::Length, "right", mixed); + let left_label = ui.label(tl!(l_label)); + para_number(ui, left, ParaNumber::Length, "left", mixed, &left_label); + let right_label = ui.label(tl!(r_label)); + para_number(ui, right, ParaNumber::Length, "right", mixed, &right_label); ui.end_row(); - ui.label(tl!("First line:")); - para_number(ui, first, ParaNumber::Length, "indentFirst", mixed); + let first_label = ui.label(tl!("First line:")); + para_number(ui, first, ParaNumber::Length, "indentFirst", mixed, &first_label); ui.end_row(); }); para_check(ui, &mut opts[0], tl!("Mirror indents"), "mirrorIndents", mixed); ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5))); egui::Grid::new("sp").num_columns(4).show(ui, |ui| { - ui.label(tl!("Before:")); - para_number(ui, before, ParaNumber::Points, "spaceBefore", mixed); - ui.label(tl!("Line spacing:")); - para_number(ui, line, ParaNumber::Lines, "lineSpacing", mixed); + let before_label = ui.label(tl!("Before:")); + para_number(ui, before, ParaNumber::Points, "spaceBefore", mixed, &before_label); + let line_label = ui.label(tl!("Line spacing:")); + para_number(ui, line, ParaNumber::Lines, "lineSpacing", mixed, &line_label); ui.end_row(); - ui.label(tl!("After:")); - para_number(ui, after, ParaNumber::Points, "spaceAfter", mixed); + let after_label = ui.label(tl!("After:")); + para_number(ui, after, ParaNumber::Points, "spaceAfter", mixed, &after_label); ui.end_row(); }); para_check(ui, &mut opts[1], tl!("Don't add space between paragraphs of the same style"), "contextualSpacing", mixed); @@ -2054,8 +2058,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } let replace_mode = *tab == FindTab::Replace; ui.horizontal(|ui| { - ui.label(tl!("Find what:")); - let r = ui.add(egui::TextEdit::singleline(query).id(egui::Id::new("find_what")).desired_width(240.0)); + let find_label = ui.label(tl!("Find what:")); + let r = ui.add(egui::TextEdit::singleline(query).id(egui::Id::unique("find_what")).desired_width(240.0)).labelled_by(find_label.id); if std::mem::take(focus) { r.request_focus(); } @@ -2065,8 +2069,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); if replace_mode { ui.horizontal(|ui| { - ui.label(tl!("Replace with:")); - if ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)).gained_focus() { + let replace_label = ui.label(tl!("Replace with:")); + if ui.add(egui::TextEdit::singleline(replace).desired_width(228.0)).labelled_by(replace_label.id).gained_focus() { extra.to_replace = true; } }); @@ -2242,17 +2246,21 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } => { ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5))); egui::Grid::new("ps").num_columns(4).show(ui, |ui| { - ui.label(tl!("Top:")); - ui.add(egui::DragValue::new(top).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)); - ui.label(tl!("Bottom:")); + let top_label = ui.label(tl!("Top:")); + ui.add(egui::DragValue::new(top).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)) + .labelled_by(top_label.id); + let bottom_label = ui.label(tl!("Bottom:")); ui.add( egui::DragValue::new(bottom).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2), - ); + ) + .labelled_by(bottom_label.id); ui.end_row(); - ui.label(if *mirror { tl!("Inside:") } else { tl!("Left:") }); - ui.add(egui::DragValue::new(left).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)); - ui.label(if *mirror { tl!("Outside:") } else { tl!("Right:") }); - ui.add(egui::DragValue::new(right).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)); + let left_label = ui.label(if *mirror { tl!("Inside:") } else { tl!("Left:") }); + ui.add(egui::DragValue::new(left).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)) + .labelled_by(left_label.id); + let right_label = ui.label(if *mirror { tl!("Outside:") } else { tl!("Right:") }); + ui.add(egui::DragValue::new(right).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)) + .labelled_by(right_label.id); ui.end_row(); }); ui.label(egui::RichText::new(tl!("Orientation")).font(semibold(12.5))); @@ -2271,10 +2279,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.radio_value(&mut grid.0, 3, tl!("Text snaps to characters")); }); egui::Grid::new("ps_grid").num_columns(4).show(ui, |ui| { - ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:"))); - ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)); - ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:"))); - ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)); + let chars_label = ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:"))); + ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id); + let lines_label = ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:"))); + ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id); ui.end_row(); }); ui.horizontal(|ui| { @@ -2348,7 +2356,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.end_row(); if !*place { ui.label(tl!("Address:")); - let mut out = egui::TextEdit::singleline(url).id(egui::Id::new("link_address")).show(ui); + let mut out = egui::TextEdit::singleline(url).id(egui::Id::unique("link_address")).show(ui); if std::mem::take(focus) { out.response.request_focus(); let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(url.chars().count())); @@ -2560,8 +2568,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } Dialog::ModifyStyle { back, resume, id, name, font, size, bold, italic, color, before, after } => { egui::Grid::new("ms").num_columns(2).show(ui, |ui| { - ui.label(tl!("Name:")); - ui.text_edit_singleline(name); + let name_label = ui.label(tl!("Name:")); + ui.text_edit_singleline(name).labelled_by(name_label.id); ui.end_row(); ui.label(tl!("Font:")); let shown = app.previews.font_shown(font); @@ -2575,16 +2583,16 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } ui.end_row(); - ui.label(tl!("Size:")); - ui.add(egui::DragValue::new(size).range(1.0..=1638.0).speed(0.5)); + let size_label = ui.label(tl!("Size:")); + ui.add(egui::DragValue::new(size).range(1.0..=1638.0).speed(0.5)).labelled_by(size_label.id); ui.end_row(); - ui.label(tl!("Color:")); - ui.text_edit_singleline(color); + let color_label = ui.label(tl!("Color:")); + ui.text_edit_singleline(color).labelled_by(color_label.id); ui.end_row(); ui.label(tl!("Space before/after:")); ui.horizontal(|ui| { - ui.add(egui::DragValue::new(before).range(0.0..=1584.0)); - ui.add(egui::DragValue::new(after).range(0.0..=1584.0)); + ui.add(egui::DragValue::new(before).range(0.0..=1584.0)).accessible_name(tl!("Space before")); + ui.add(egui::DragValue::new(after).range(0.0..=1584.0)).accessible_name(tl!("Space after")); }); ui.end_row(); }); @@ -3001,13 +3009,13 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.radio_value(choice, 0, tl!("All")); ui.radio_value(choice, 1, tl!("Current record")); ui.horizontal(|ui| { - ui.radio_value(choice, 2, tl!("From:")); + let from_label = ui.radio_value(choice, 2, tl!("From:")); // Typing a number picks the range, as in Word. - if ui.add(egui::TextEdit::singleline(from).desired_width(60.0)).changed() { + if ui.add(egui::TextEdit::singleline(from).desired_width(60.0)).labelled_by(from_label.id).changed() { *choice = 2; } - ui.label(tl!("To:")); - if ui.add(egui::TextEdit::singleline(to).desired_width(60.0)).changed() { + let to_label = ui.label(tl!("To:")); + if ui.add(egui::TextEdit::singleline(to).desired_width(60.0)).labelled_by(to_label.id).changed() { *choice = 2; } }); @@ -3182,7 +3190,8 @@ pub(crate) fn recipient_list( ui.horizontal(|ui| { ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover()); for (i, f) in fields.iter_mut().enumerate() { - ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(f).font(egui::TextStyle::Button).hint_text(tl!("Field name"))); + ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(f).font(egui::TextStyle::Button).hint_text(tl!("Field name"))) + .accessible_name(format!("{} {}", tl!("Field name"), i + 1)); let x = ui.add_enabled(can_remove, egui::Button::new("×").small().min_size(vec2(X_W, X_W))).on_hover_text(tl!("Remove field")); x_w = x.rect.width(); if x.clicked() { @@ -3210,7 +3219,10 @@ pub(crate) fn recipient_list( for (j, c) in row.iter_mut().enumerate() { // Keyed on the record, not its place in the grid: when the view hides // or moves rows, the keyboard can't land in another record (#1576). - let cell = ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(c).id_salt(("recipient_cell", r, j))); + let field_name = fields.get(j).map_or("", String::as_str); + let cell = ui + .add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(c).id_salt(("recipient_cell", r, j))) + .accessible_name(crate::i18n::fmt(tl!("{field}, record {n}"), &[("field", field_name), ("n", &(r + 1).to_string())])); if cell.has_focus() { focused = Some(cell.id); } @@ -3518,7 +3530,7 @@ enum ParaNumber { /// A Paragraph dialog number box for the field `key`. While the selected paragraphs differ in /// it, it shows nothing, not even the unit, until a value is typed or dragged in. -fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: &str, mixed: &mut BTreeSet) { +fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: &str, mixed: &mut BTreeSet, label: &egui::Response) { let unit = crate::options::unit(); let blank = mixed.contains(key); // A value from the document outside the range (line spacing 6) stays until edited. @@ -3534,7 +3546,7 @@ fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: &str, mixed: ParaNumber::Points => field.suffix(format!(" {}", tl!("pt"))), ParaNumber::Lines => field, }; - if ui.add(field).changed() { + if ui.add(field).labelled_by(label.id).changed() { mixed.remove(key); } } @@ -3607,8 +3619,8 @@ fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_location: &mut let (mut add, mut delete, mut go_to) = (false, false, false); ui.horizontal_top(|ui| { ui.vertical(|ui| { - ui.label(tl!("Bookmark name:")); - ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); + let name_label = ui.label(tl!("Bookmark name:")); + ui.add(egui::TextEdit::singleline(name).desired_width(240.0)).labelled_by(name_label.id); egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| { ui.set_width(232.0); ui.set_height(150.0); @@ -3684,7 +3696,7 @@ fn manage_styles(app: &mut WordApp, ui: &mut Ui, alphabetical: &mut bool, select }); }); let t = Tokens::get(ui.ctx()); - let shown = egui::Id::new("manage_styles_shown"); + let shown = egui::Id::unique("manage_styles_shown"); egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| { egui::ScrollArea::vertical().id_salt("manage_styles_list").max_height(200.0).auto_shrink([false, false]).show(ui, |ui| { ui.set_width(380.0); @@ -3828,7 +3840,7 @@ const SEARCH_SUGGESTIONS: [&str; 3] = ["review.spelling", "review.wordCount", "f /// Search's "Find … in the document": the Navigation pane opens on its results for `text`. fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, text: &str) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new("nav_query"), text.to_string())); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("nav_query"), text.to_string())); app.ui.nav_tab = "results".into(); let _ = app.run("view.navigationPane", json!({"value": true})); let _ = app.run("edit.find", json!({"text": text})); @@ -3890,20 +3902,25 @@ fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, target: &mut S ui.add_space(16.0); ui.vertical(|ui| match what { GoToWhat::Page => { - ui.label(tl!("Enter page number:")); - let r = ui.add(egui::TextEdit::singleline(target).desired_width(220.0)); + let label = ui.label(tl!("Enter page number:")); + let r = ui.add(egui::TextEdit::singleline(target).desired_width(220.0)).labelled_by(label.id); if !r.has_focus() { r.request_focus(); } ui.label(egui::RichText::new(tl!("Type +2 or -1 to move from the page you are on.")).weak()); } GoToWhat::Bookmark => { - ui.label(tl!("Enter bookmark name:")); - egui::ComboBox::from_id_salt("goto_bookmark").width(220.0).selected_text(target.as_str()).show_ui(ui, |ui| { - for b in &bookmarks { - ui.selectable_value(target, b.clone(), b.as_str()); - } - }); + let label = ui.label(tl!("Enter bookmark name:")); + egui::ComboBox::from_id_salt("goto_bookmark") + .width(220.0) + .selected_text(target.as_str()) + .show_ui(ui, |ui| { + for b in &bookmarks { + ui.selectable_value(target, b.clone(), b.as_str()); + } + }) + .response + .labelled_by(label.id); if bookmarks.is_empty() { ui.label(egui::RichText::new(tl!("This document has no bookmarks.")).weak()); } @@ -4079,13 +4096,14 @@ fn page_valign_combo(ui: &mut egui::Ui, valign: &mut wordcraft_doc::section::Pag const CHOICES: [(PageVAlign, &str); 4] = [(PageVAlign::Top, "Top"), (PageVAlign::Center, "Center"), (PageVAlign::Justify, "Justified"), (PageVAlign::Bottom, "Bottom")]; ui.horizontal(|ui| { - ui.label(tl!("Vertical alignment:")); + let label = ui.label(tl!("Vertical alignment:")); let shown = CHOICES.iter().find(|(v, _)| v == valign).map_or("", |(_, l)| tl!(l)); - egui::ComboBox::from_id_salt("ps_valign").selected_text(shown).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("ps_valign").selected_text(shown).show_ui(ui, |ui| { for (v, l) in CHOICES { ui.selectable_value(valign, v, tl!(l)); } }); + combo.response.labelled_by(label.id); }); } @@ -4128,7 +4146,7 @@ mod tests { fn the_bookmark_dialog_goes_to_and_deletes_bookmarks() { fn press(h: &mut egui_kittest::Harness<'_, WordApp>, label: &str) { use egui_kittest::kittest::Queryable; - let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "Bookmark")))).expect("the dialog is open"); + let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Bookmark")))).expect("the dialog is open"); h.query_all_by_label(label).find(|n| dialog.contains(n.rect().center())).expect(label).click(); for _ in 0..3 { h.step(); @@ -4474,6 +4492,8 @@ mod tests { for _ in 0..4 { h.step(); } + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "From:"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "To:"); h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click(); for _ in 0..2 { h.step(); @@ -4843,7 +4863,7 @@ mod tests { for _ in 0..4 { h.step(); } - let id = egui::Id::new(("dialog", "Paragraph")); + let id = egui::Id::unique(("dialog", "Paragraph")); let before = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is open"); let screen = h.ctx.content_rect(); assert!((before.center().x - screen.center().x).abs() <= 1.0, "opens centred: {before:?} in {screen:?}"); @@ -4894,7 +4914,7 @@ mod tests { for _ in 0..4 { h.step(); } - let id = egui::Id::new(("dialog", "Word Count")); + let id = egui::Id::unique(("dialog", "Word Count")); let before = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is open"); let bx = crate::window_close::close_rect(before, 1.0, crate::window_close::geometry(&h.ctx)); let title = h.ctx.read_response(id.with("__title_click")).expect("egui's title bar").rect; @@ -5199,7 +5219,9 @@ mod tests { for _ in 0..4 { h.step(); } - assert!(h.query_by_label("Vertical alignment:").is_some(), "the dialog has the control"); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Vertical alignment:"); + h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Characters per line:"); + h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Lines per page:"); h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click(); for _ in 0..4 { h.step(); diff --git a/crates/ui-egui/src/dialogs_chart.rs b/crates/ui-egui/src/dialogs_chart.rs index 72663acb7a..feb78c2251 100644 --- a/crates/ui-egui/src/dialogs_chart.rs +++ b/crates/ui-egui/src/dialogs_chart.rs @@ -287,7 +287,7 @@ pub fn data_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartDataForm) -> bool { } if let Some(row) = f.cells.get_mut(i) { for cell in row.iter_mut() { - ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(cell).horizontal_align(egui::Align::Max)); + ui.add_sized([CELL_W, ROW_H - 4.0], value_cell(cell)); ui.allocate_exact_size(vec2(X_W, ROW_H - 4.0), Sense::hover()); } } @@ -336,6 +336,13 @@ pub fn data_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartDataForm) -> bool { } } +/// A numeric chart-data cell. `RIGHT_TOP` preserves the old deprecated +/// `horizontal_align(Align::Max)` behaviour: numbers are right-aligned but stay on the cell's +/// first line, rather than becoming vertically centred. +fn value_cell(text: &mut String) -> egui::TextEdit<'_> { + egui::TextEdit::singleline(text).align(egui::Align2::RIGHT_TOP) +} + #[cfg(test)] mod tests { use super::*; @@ -356,4 +363,27 @@ mod tests { f.cells[1][0] = "abc".into(); assert!(f.params().is_err()); } + + /// The chart-data grid kept Word's top line when the deprecated `horizontal_align(Align::Max)` + /// became `align(Align2)`: `RIGHT_CENTER` silently changed vertical alignment too. + #[test] + fn chart_value_cells_are_right_and_top_aligned() { + fn text_pos(shape: &egui::Shape) -> Option { + match shape { + egui::Shape::Text(text) => Some(text.pos), + egui::Shape::Vec(shapes) => shapes.iter().find_map(text_pos), + _ => None, + } + } + let ctx = egui::Context::default(); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(120.0, 120.0))), ..Default::default() }; + let mut text = "7".to_owned(); + let output = ctx.run_ui(raw, |ui| { + ui.add_sized([100.0, 100.0], value_cell(&mut text)); + }); + let pos = output.shapes.iter().find_map(|clipped| text_pos(&clipped.shape)).expect("the cell's text is painted"); + output.drop_without_applying_deltas(); + assert!(pos.y < 20.0, "RIGHT_TOP leaves the number on the first line, not centred (pos={pos:?})"); + assert!(pos.x > 40.0, "the number is right-aligned (pos={pos:?})"); + } } diff --git a/crates/ui-egui/src/dialogs_keyboard.rs b/crates/ui-egui/src/dialogs_keyboard.rs index 156e648558..41bf6162fa 100644 --- a/crates/ui-egui/src/dialogs_keyboard.rs +++ b/crates/ui-egui/src/dialogs_keyboard.rs @@ -111,7 +111,9 @@ pub(crate) fn list(ui: &mut Ui, id: &str, size: egui::Vec2, rows: &[(String, Str ui.centered_and_justified(|ui| ui.label(egui::RichText::new(tl!("(none)")).weak())); return; } - let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0; + // Virtual rows must include the button's padding and minimum interaction height; + // otherwise the last row can extend beyond the scroll area's declared content height. + let row_h = (ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y).max(ui.spacing().interact_size.y); // Bring the selected row into view when the selection (or the list) changes, so the row // an action targets is never off-screen; scrolling by hand doesn't change the selection. let seen = ui.make_persistent_id(("scrolled_to", id)); @@ -432,6 +434,7 @@ mod tests { delta: egui::vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }); h.step(); } diff --git a/crates/ui-egui/src/dialogs_lists.rs b/crates/ui-egui/src/dialogs_lists.rs index ac6b8d0f3d..ab3db4657c 100644 --- a/crates/ui-egui/src/dialogs_lists.rs +++ b/crates/ui-egui/src/dialogs_lists.rs @@ -434,7 +434,7 @@ fn choice(ui: &mut Ui, id: &str, value: &mut String, options: &[(&str, &str)]) { /// hex. fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_label: &str, auto: Option) { ui.horizontal(|ui| { - let id = ui.id().with("colorField"); + let id = ui.scope_id().with("colorField"); crate::colors::take_field(ui.ctx(), id, value); let c = wordcraft_doc::Rgb::parse(value); let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); diff --git a/crates/ui-egui/src/dialogs_para.rs b/crates/ui-egui/src/dialogs_para.rs index 03575bcf68..34654ea8fc 100644 --- a/crates/ui-egui/src/dialogs_para.rs +++ b/crates/ui-egui/src/dialogs_para.rs @@ -231,10 +231,12 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool { // Position, edited in place: the interface unit by default, any // unit spelled out, either decimal separator (`.measure`). let mut pv = t.pos / k; - let r = ui.add_sized( - vec2(58.0, 18.0), - egui::DragValue::new(&mut pv).speed(0.05).range(-1584.0 / k..=1584.0 / k).measure(u).max_decimals(2), - ); + let r = ui + .add_sized( + vec2(58.0, 18.0), + egui::DragValue::new(&mut pv).speed(0.05).range(-1584.0 / k..=1584.0 / k).measure(u).max_decimals(2), + ) + .accessible_name(format!("{} {}", tl!("Tab stop position:"), i + 1)); if r.changed() { f.set_pos(i, pv * k); } @@ -253,7 +255,9 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool { for v in crate::ruler_tabs::KINDS { ui.selectable_value(&mut a, v, tl!(crate::ruler_tabs::kind_label(v))); } - }); + }) + .response + .accessible_name(format!("{} {}", tl!("Alignment:"), i + 1)); if a != t.align { f.set_align(i, a); pick = Some(i); @@ -265,11 +269,16 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool { .find(|(v, _, _)| *v == l) .map(|(_, _, fill)| if fill.is_empty() { tl!("None").to_string() } else { (*fill).to_string() }) .unwrap_or_default(); - egui::ComboBox::from_id_salt("tab_leader").width(64.0).selected_text(shown).show_ui(ui, |ui| { - for (v, label, _) in LEADERS { - ui.selectable_value(&mut l, v, tl!(label)); - } - }); + egui::ComboBox::from_id_salt("tab_leader") + .width(64.0) + .selected_text(shown) + .show_ui(ui, |ui| { + for (v, label, _) in LEADERS { + ui.selectable_value(&mut l, v, tl!(label)); + } + }) + .response + .accessible_name(format!("{} {}", tl!("Leader"), i + 1)); if l != t.leader { f.set_leader(i, l); pick = Some(i); @@ -313,8 +322,9 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool { ui.add_space(12.0); ui.vertical(|ui| { ui.set_min_width(130.0); - ui.label(tl!("Default tab stops:")); - ui.add(egui::DragValue::new(&mut f.default_tab).speed(0.05).range(1.0 / k..=1584.0 / k).measure(u).max_decimals(2)); + let default_label = ui.label(tl!("Default tab stops:")); + ui.add(egui::DragValue::new(&mut f.default_tab).speed(0.05).range(1.0 / k..=1584.0 / k).measure(u).max_decimals(2)) + .labelled_by(default_label.id); ui.add_space(8.0); ui.label(tl!("Tab stops to be cleared:")); let list = if f.clear_all { @@ -914,6 +924,31 @@ mod tests { assert_eq!(f.stops[0].pos, 36.0); } + /// The inline tab-stop rows are named by their column, and the default interval by its label. + #[test] + fn tab_stop_fields_have_semantic_names() { + use egui_kittest::kittest::Queryable; + let mut app = app_with("a\tb"); + app.run("para.tabs", json!({"set": [{"pos": 72.0}]})).unwrap(); + app.dialog = crate::dialogs::Dialog::open("tabs", &mut app); + assert!(matches!(app.dialog, Some(crate::dialogs::Dialog::Tabs { .. }))); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Tab stop position: 1"); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Alignment: 1"); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Leader 1"); + h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Default tab stops:"); + } + /// #3031: a paragraph with no stops — the default — says so in the list box instead of /// showing a blank frame. #[test] diff --git a/crates/ui-egui/src/dialogs_refs.rs b/crates/ui-egui/src/dialogs_refs.rs index a6f49b5bfb..be80bd4168 100644 --- a/crates/ui-egui/src/dialogs_refs.rs +++ b/crates/ui-egui/src/dialogs_refs.rs @@ -149,14 +149,15 @@ fn heading(ui: &mut Ui, s: &str) { ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5))); } -/// A choice from `options` ((value, label)) in a drop-down. -fn choice(ui: &mut Ui, id: &str, value: &mut T, options: &[(T, &str)]) { - let label = options.iter().find(|(v, _)| v == value).map(|(_, l)| tl!(l).to_string()).unwrap_or_default(); - egui::ComboBox::from_id_salt(id).selected_text(label).width(150.0).show_ui(ui, |ui| { +/// A choice from `options` ((value, label)) in a drop-down, named by its visible label. +fn choice(ui: &mut Ui, id: &str, value: &mut T, options: &[(T, &str)], label: &egui::Response) { + let shown = options.iter().find(|(v, _)| v == value).map(|(_, l)| tl!(l).to_string()).unwrap_or_default(); + let combo = egui::ComboBox::from_id_salt(id).selected_text(shown).width(150.0).show_ui(ui, |ui| { for (v, l) in options { ui.selectable_value(value, v.clone(), tl!(l)); } }); + combo.response.labelled_by(label.id); } const LEADERS: [(&str, &str); 4] = [("none", "(none)"), ("dot", "........"), ("hyphen", "--------"), ("underscore", "________")]; @@ -312,24 +313,25 @@ impl CaptionForm { } fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> Next { - ui.label(tl!("Caption:")); + let caption_label = ui.label(tl!("Caption:")); ui.horizontal(|ui| { ui.label(f.preview()); - ui.add(egui::TextEdit::singleline(&mut f.text).desired_width(220.0).hint_text(tl!("Caption text"))); + ui.add(egui::TextEdit::singleline(&mut f.text).desired_width(220.0).hint_text(tl!("Caption text"))).labelled_by(caption_label.id); }); ui.add_space(6.0); let mut then = None; egui::Grid::new("cap_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Label:")); + let label_label = ui.label(tl!("Label:")); let labels: Vec<(String, &str)> = f.labels.iter().map(|l| (l.clone(), l.as_str())).collect(); - egui::ComboBox::from_id_salt("cap_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("cap_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| { for (v, l) in &labels { ui.selectable_value(&mut f.label, v.clone(), tl!(l)); } }); + combo.response.labelled_by(label_label.id); ui.end_row(); - ui.label(tl!("Position:")); - choice(ui, "cap_pos", &mut f.position, &[("below".to_string(), "Below the item"), ("above".to_string(), "Above the item")]); + let position_label = ui.label(tl!("Position:")); + choice(ui, "cap_pos", &mut f.position, &[("below".to_string(), "Below the item"), ("above".to_string(), "Above the item")], &position_label); ui.end_row(); }); ui.checkbox(&mut f.exclude_label, tl!("Leave the label out of the caption")); @@ -421,14 +423,14 @@ impl CrossRefForm { fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next { let was = f.to.clone(); egui::Grid::new("xref_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Reference type:")); + let type_label = ui.label(tl!("Reference type:")); let types = ref_types(app); let types: Vec<(String, &str)> = types.iter().map(|(v, l)| (v.clone(), l.as_str())).collect(); - choice(ui, "xref_type", &mut f.to, &types); + choice(ui, "xref_type", &mut f.to, &types, &type_label); ui.end_row(); - ui.label(tl!("Insert reference to:")); + let show_label = ui.label(tl!("Insert reference to:")); let shows: Vec<(String, &str)> = f.shows().iter().map(|(v, l)| (v.to_string(), *l)).collect(); - choice(ui, "xref_show", &mut f.show, &shows); + choice(ui, "xref_show", &mut f.show, &shows, &show_label); ui.end_row(); }); if f.to != was { @@ -461,26 +463,27 @@ fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next { fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next { egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Format:")); + let format_label = ui.label(tl!("Format:")); let opts: Vec<(String, &str)> = NUMBER_FORMATS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - choice(ui, "cap_fmt", &mut f.format, &opts); + choice(ui, "cap_fmt", &mut f.format, &opts, &format_label); ui.end_row(); }); ui.checkbox(&mut f.chapter, tl!("Add the chapter number")); ui.add_enabled_ui(f.chapter, |ui| { egui::Grid::new("cap_chap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Chapters begin at:")); + let chapter_label = ui.label(tl!("Chapters begin at:")); let opts: Vec<(u8, String)> = (1..=9u8).map(|l| (l, crate::i18n::fmt(tl!("Heading {n}"), &[("n", &l.to_string())]))).collect(); let shown = opts.iter().find(|(l, _)| *l == f.chapter_level).map(|(_, s)| s.clone()).unwrap_or_default(); - egui::ComboBox::from_id_salt("cap_level").selected_text(shown).width(150.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("cap_level").selected_text(shown).width(150.0).show_ui(ui, |ui| { for (l, s) in &opts { ui.selectable_value(&mut f.chapter_level, *l, s); } }); + combo.response.labelled_by(chapter_label.id); ui.end_row(); - ui.label(tl!("Separator:")); + let separator_label = ui.label(tl!("Separator:")); let opts: Vec<(String, &str)> = SEPARATORS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - choice(ui, "cap_sep", &mut f.separator, &opts); + choice(ui, "cap_sep", &mut f.separator, &opts, &separator_label); ui.end_row(); }); }); @@ -497,8 +500,8 @@ fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next { } fn new_label_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> Next { - ui.label(tl!("Label:")); - let r = ui.add(egui::TextEdit::singleline(&mut f.new_label).desired_width(260.0)); + let label = ui.label(tl!("Label:")); + let r = ui.add(egui::TextEdit::singleline(&mut f.new_label).desired_width(260.0)).labelled_by(label.id); if !r.has_focus() { r.request_focus(); } @@ -587,16 +590,16 @@ fn index_ui(app: &mut WordApp, ui: &mut Ui, f: &mut IndexForm) -> Next { ui.radio_value(&mut f.kind, "runIn".to_string(), tl!("Run-in")); }); ui.end_row(); - ui.label(tl!("Columns:")); - ui.add(egui::DragValue::new(&mut f.columns).range(1..=4)); + let columns_label = ui.label(tl!("Columns:")); + ui.add(egui::DragValue::new(&mut f.columns).range(1..=4)).labelled_by(columns_label.id); ui.end_row(); ui.label(""); ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin")); ui.end_row(); - ui.label(tl!("Tab leader:")); + let leader_label = ui.label(tl!("Tab leader:")); ui.add_enabled_ui(f.right_align, |ui| { let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - choice(ui, "idx_leader", &mut f.leader, &opts); + choice(ui, "idx_leader", &mut f.leader, &opts, &leader_label); }); ui.end_row(); ui.label(""); @@ -664,30 +667,30 @@ impl MarkForm { fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next { heading(ui, "Index"); egui::Grid::new("xe").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Main entry:")); - ui.add(egui::TextEdit::singleline(&mut f.entry).desired_width(220.0)); + let entry_label = ui.label(tl!("Main entry:")); + ui.add(egui::TextEdit::singleline(&mut f.entry).desired_width(220.0)).labelled_by(entry_label.id); ui.end_row(); - ui.label(tl!("Subentry:")); - ui.add(egui::TextEdit::singleline(&mut f.subentry).desired_width(220.0)); + let subentry_label = ui.label(tl!("Subentry:")); + ui.add(egui::TextEdit::singleline(&mut f.subentry).desired_width(220.0)).labelled_by(subentry_label.id); ui.end_row(); }); heading(ui, "Options"); ui.horizontal(|ui| { - ui.radio_value(&mut f.option, "see".to_string(), tl!("Cross-reference:")); - ui.add_enabled(f.option == "see", egui::TextEdit::singleline(&mut f.see).desired_width(160.0)); + let see_label = ui.radio_value(&mut f.option, "see".to_string(), tl!("Cross-reference:")); + ui.add_enabled(f.option == "see", egui::TextEdit::singleline(&mut f.see).desired_width(160.0)).labelled_by(see_label.id); }); ui.radio_value(&mut f.option, "page".to_string(), tl!("This page")); ui.horizontal(|ui| { - ui.add_enabled_ui(!f.bookmarks.is_empty(), |ui| { - ui.radio_value(&mut f.option, "range".to_string(), tl!("Pages of bookmark:")); - }); + let bookmark_label = + ui.add_enabled_ui(!f.bookmarks.is_empty(), |ui| ui.radio_value(&mut f.option, "range".to_string(), tl!("Pages of bookmark:"))).inner; ui.add_enabled_ui(f.option == "range", |ui| { let opts: Vec<(String, &str)> = f.bookmarks.iter().map(|b| (b.clone(), b.as_str())).collect(); - egui::ComboBox::from_id_salt("xe_bm").selected_text(f.bookmark.clone()).width(140.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("xe_bm").selected_text(f.bookmark.clone()).width(140.0).show_ui(ui, |ui| { for (v, l) in &opts { ui.selectable_value(&mut f.bookmark, v.clone(), *l); } }); + combo.response.labelled_by(bookmark_label.id); }); }); heading(ui, "Page number style"); @@ -824,17 +827,17 @@ fn toc_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm) -> Next { ui.add_enabled_ui(f.page_numbers, |ui| { ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin")); ui.horizontal(|ui| { - ui.label(tl!("Tab leader:")); + let leader_label = ui.label(tl!("Tab leader:")); ui.add_enabled_ui(f.right_align, |ui| { let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - choice(ui, "toc_leader", &mut f.leader, &opts); + choice(ui, "toc_leader", &mut f.leader, &opts, &leader_label); }); }); }); ui.checkbox(&mut f.hyperlinks, tl!("Link entries to their headings")); ui.horizontal(|ui| { - ui.label(tl!("Levels:")); - ui.add(egui::DragValue::new(&mut f.levels).range(1..=9)); + let levels_label = ui.label(tl!("Levels:")); + ui.add(egui::DragValue::new(&mut f.levels).range(1..=9)).labelled_by(levels_label.id); }); ui.add_space(4.0); let mut then = None; @@ -867,8 +870,9 @@ fn toc_options_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm, was: &TocForm for (name, level) in f.style_levels.iter_mut() { // Built-in styles read in the interface language; the document keeps the English name. let shown = app.session.doc.styles.styles.iter().find(|st| st.name == *name).map_or_else(|| name.clone(), crate::i18n::style_name); - ui.label(shown); - ui.add(egui::DragValue::new(level).range(0..=9).custom_formatter(|v, _| if v < 0.5 { String::new() } else { format!("{v:.0}") })); + let style_label = ui.label(shown); + ui.add(egui::DragValue::new(level).range(0..=9).custom_formatter(|v, _| if v < 0.5 { String::new() } else { format!("{v:.0}") })) + .labelled_by(style_label.id); ui.end_row(); } }); @@ -969,23 +973,24 @@ fn figures_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FiguresForm) -> Next { sample(ui, &lines, f.right_align, leader_char(&f.leader)); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Label:")); + let label_label = ui.label(tl!("Label:")); let labels: Vec<(String, &str)> = f.labels.iter().map(|l| (l.clone(), l.as_str())).collect(); - egui::ComboBox::from_id_salt("tof_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("tof_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| { for (v, l) in &labels { ui.selectable_value(&mut f.label, v.clone(), tl!(l)); } }); + combo.response.labelled_by(label_label.id); }); ui.checkbox(&mut f.include_label, tl!("Include label and number")); ui.checkbox(&mut f.page_numbers, tl!("Include page numbers")); ui.add_enabled_ui(f.page_numbers, |ui| { ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin")); ui.horizontal(|ui| { - ui.label(tl!("Tab leader:")); + let leader_label = ui.label(tl!("Tab leader:")); ui.add_enabled_ui(f.right_align, |ui| { let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - choice(ui, "tof_leader", &mut f.leader, &opts); + choice(ui, "tof_leader", &mut f.leader, &opts, &leader_label); }); }); }); @@ -1142,24 +1147,26 @@ impl SourceForm { fn source_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SourceForm) -> Next { egui::Grid::new("src").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| { - ui.label(tl!("Source type:")); + let kind_label = ui.label(tl!("Source type:")); let shown = SOURCE_TYPES.iter().find(|(k, ..)| *k == f.source.kind).map(|(_, l, _)| tl!(l).to_string()).unwrap_or_default(); - egui::ComboBox::from_id_salt("src_kind").selected_text(shown).width(200.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("src_kind").selected_text(shown).width(200.0).show_ui(ui, |ui| { for (k, l, _) in SOURCE_TYPES { ui.selectable_value(&mut f.source.kind, k.to_string(), tl!(l)); } }); + combo.response.labelled_by(kind_label.id); ui.end_row(); let edit = f.edit; for (key, label) in f.fields() { - ui.label(format!("{}:", tl!(label))); + let field_label = ui.label(format!("{}:", tl!(label))); if let Some(v) = field_mut(&mut f.source, key) { let hint = match key { "author" => tl!("Last, First; Last, First"), "tag" => tl!("Made from the author and year when empty"), _ => "", }; - ui.add_enabled(!(edit && key == "tag"), egui::TextEdit::singleline(v).desired_width(260.0).hint_text(hint)); + ui.add_enabled(!(edit && key == "tag"), egui::TextEdit::singleline(v).desired_width(260.0).hint_text(hint)) + .labelled_by(field_label.id); } ui.end_row(); } diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 69861a3415..ee20c9d3e5 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -435,11 +435,11 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) { ui.checkbox(&mut o.caps_lock, tl!("Correct accidental usage of cAPS LOCK key")); ui.checkbox(&mut o.replace_text, tl!("Replace text as you type")); egui::Grid::new("ac_boxes").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| { - ui.label(tl!("Replace:")); - ui.label(tl!("With:")); + let replace_label = ui.label(tl!("Replace:")); + let with_label = ui.label(tl!("With:")); ui.end_row(); - ui.add(egui::TextEdit::singleline(&mut f.from).desired_width(110.0)); - ui.add(egui::TextEdit::singleline(&mut f.to).desired_width(260.0)); + ui.add(egui::TextEdit::singleline(&mut f.from).desired_width(110.0)).labelled_by(replace_label.id); + ui.add(egui::TextEdit::singleline(&mut f.to).desired_width(260.0)).labelled_by(with_label.id); ui.end_row(); }); // The list, filtered to what's typed in Replace (as Word scrolls to it). @@ -526,8 +526,8 @@ fn exceptions(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) { fn math_tab(ui: &mut Ui, f: &mut AutoCorrectForm) { ui.label(egui::RichText::new(tl!("In equations, typing a name after a backslash replaces it with its symbol.")).small().weak()); ui.horizontal(|ui| { - ui.label(tl!("Replace:")); - ui.add(egui::TextEdit::singleline(&mut f.math_filter).desired_width(140.0)); + let label = ui.label(tl!("Replace:")); + ui.add(egui::TextEdit::singleline(&mut f.math_filter).desired_width(140.0)).labelled_by(label.id); }); let filter = f.math_filter.trim().trim_start_matches('\\').to_string(); egui::ScrollArea::vertical().id_salt("ac_math").max_height(240.0).auto_shrink([false, true]).show(ui, |ui| { diff --git a/crates/ui-egui/src/dialogs_smart_art.rs b/crates/ui-egui/src/dialogs_smart_art.rs index 977c184be6..4546c162c0 100644 --- a/crates/ui-egui/src/dialogs_smart_art.rs +++ b/crates/ui-egui/src/dialogs_smart_art.rs @@ -295,7 +295,7 @@ pub fn frame_button(app: &mut WordApp, ctx: &egui::Context, layout: &wordcraft_l return; } let t = Tokens::get(ctx); - egui::Area::new(egui::Id::new("smart_art_frame_button")).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("smart_art_frame_button")).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| { egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border)).corner_radius(4).inner_margin(2).show(ui, |ui| { if crate::widgets::icon_button(ui, "textPane", tl!("Text Pane")).clicked() { let _ = app.run("smartArt.textPane", json!({"value": true})); @@ -306,20 +306,43 @@ pub fn frame_button(app: &mut WordApp, ctx: &egui::Context, layout: &wordcraft_l /// Text typed with a SmartArt graphic selected goes at the end of its current item (the first, /// before any is picked), and the Text Pane opens, as Word does; it never replaces the graphic. -/// False when no graphic is selected. -pub fn type_into(app: &mut WordApp, typed: &str) -> bool { - let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else { return false }; - let Some(i) = app.smart_art_item.filter(|i| *i < g.items.len()).or((!g.items.is_empty()).then_some(0)) else { return true }; +/// +/// What typing into a selected SmartArt graphic did. +/// +/// A selected graphic always consumes the input, even when it has no item to edit: the typing +/// must not fall through into the body. Only the absence of a SmartArt target lets the body type. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SmartArtTypeResult { + /// No SmartArt graphic is selected: the caller may type into the body. + NotHandled, + /// A selected graphic consumed the input without editing the document. + ConsumedNoChange, + /// A SmartArt item really changed. + Changed, +} + +pub(crate) fn type_into(app: &mut WordApp, typed: &str) -> SmartArtTypeResult { + let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else { + return SmartArtTypeResult::NotHandled; + }; + let Some(i) = app.smart_art_item.filter(|i| *i < g.items.len()).or((!g.items.is_empty()).then_some(0)) else { + // Selected but empty: consume, never insert into the body, never claim an edit. + return SmartArtTypeResult::ConsumedNoChange; + }; let text = g.items.get(i).map_or(String::new(), |it| it.text.clone()) + typed; if app.smart_art_typing == Some(i) { app.session.join_next_undo(); } - if app.run("smartArt.items", items_with(&g, i, &text)).is_ok() { - app.smart_art_item = Some(i); - app.smart_art_typing = Some(i); + if app.run("smartArt.items", items_with(&g, i, &text)).is_err() { + // A selected graphic that refuses the edit still consumes the event; an IME composition + // must start afresh rather than be applied to the body. + app.canvas.ime_interrupt_now = true; + return SmartArtTypeResult::ConsumedNoChange; } + app.smart_art_item = Some(i); + app.smart_art_typing = Some(i); let _ = app.run("smartArt.textPane", json!({"value": true})); - true + SmartArtTypeResult::Changed } /// The Text Pane: a row per item, indented by level. Typing changes the graphic as you go (one @@ -330,7 +353,7 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tl!("One line per item. Enter adds an item.")).color(t.text_dim)); ui.add_space(4.0); - let focus_id = egui::Id::new("smart_art_focus"); + let focus_id = egui::Id::unique("smart_art_focus"); let want_focus: Option = ui.data_mut(|d| d.remove_temp(focus_id)); let mut typed = false; app.smart_art_row_focused = false; @@ -340,7 +363,10 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) { ui.add_space(f32::from(it.level) * 16.0); ui.label(egui::RichText::new("•").color(t.text_dim)); let mut text = it.text.clone(); - let r = ui.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(f32::INFINITY)); + let item_name = format!("{} {}", tl!("Text Pane"), i + 1); + let r = ui + .add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(f32::INFINITY)) + .accessible_name(item_name); if want_focus == Some(i) { r.request_focus(); } diff --git a/crates/ui-egui/src/dialogs_table_style.rs b/crates/ui-egui/src/dialogs_table_style.rs index bcf359f7ed..b7bbc1f8f6 100644 --- a/crates/ui-egui/src/dialogs_table_style.rs +++ b/crates/ui-egui/src/dialogs_table_style.rs @@ -470,11 +470,11 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool { let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone()); let mut rebased = false; egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Name:")); - ui.text_edit_singleline(name); + let name_label = ui.label(tl!("Name:")); + ui.text_edit_singleline(name).labelled_by(name_label.id); ui.end_row(); - ui.label(tl!("Style based on:")); - egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| { + let base_label = ui.label(tl!("Style based on:")); + let base = egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| { for (sid, sname) in &styles { if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on { *based_on = sid.clone(); @@ -482,16 +482,18 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool { } } }); + base.response.labelled_by(base_label.id); ui.end_row(); - ui.label(tl!("Apply formatting to:")); - egui::ComboBox::from_id_salt("tstyle_region").width(200.0).selected_text(tl!(PARTS.get(*region).map_or("Whole Table", |(_, l)| *l))).show_ui( - ui, - |ui| { + let region_label = ui.label(tl!("Apply formatting to:")); + let part = egui::ComboBox::from_id_salt("tstyle_region") + .width(200.0) + .selected_text(tl!(PARTS.get(*region).map_or("Whole Table", |(_, l)| *l))) + .show_ui(ui, |ui| { for (i, (_, n)) in PARTS.iter().enumerate() { ui.selectable_value(region, i, tl!(*n)); } - }, - ); + }); + part.response.labelled_by(region_label.id); ui.end_row(); }); // A new style shows its new base's formatting; a modified one keeps its own. @@ -512,7 +514,8 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool { { r.font = f; } - ui.add(egui::TextEdit::singleline(&mut r.size).desired_width(36.0)).on_hover_text(tl!("Size:")); + let size = ui.add(egui::TextEdit::singleline(&mut r.size).desired_width(36.0)); + size.on_hover_text(tl!("Size:")).accessible_name(tl!("Size:")); ui.toggle_value(&mut r.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")); ui.toggle_value(&mut r.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")); ui.toggle_value(&mut r.underline, egui::RichText::new("U").underline()).on_hover_text(tl!("Underline")); @@ -524,14 +527,15 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool { // Borders: the pen (style, width, colour) and which edges; then the cell shading. ui.horizontal(|ui| { let current = LINE_STYLES.iter().find(|(s, _)| *s == pen.style).map_or("Single", |(_, n)| *n); - ui.label(tl!("Style:")); - egui::ComboBox::from_id_salt("tstyle_pen_style").selected_text(tl!(current)).width(90.0).show_ui(ui, |ui| { + let style_label = ui.label(tl!("Style:")); + let style = egui::ComboBox::from_id_salt("tstyle_pen_style").selected_text(tl!(current)).width(90.0).show_ui(ui, |ui| { for (s, n) in LINE_STYLES { ui.selectable_value(&mut pen.style, s, tl!(n)); } }); - ui.label(tl!("Width:")); - egui::ComboBox::from_id_salt("tstyle_pen_width") + style.response.labelled_by(style_label.id); + let width_label = ui.label(tl!("Width:")); + let width = egui::ComboBox::from_id_salt("tstyle_pen_width") .selected_text(crate::i18n::fmt(tl!("{n} pt"), &[("n", &pen.width.to_string())])) .width(70.0) .show_ui(ui, |ui| { @@ -539,6 +543,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool { ui.selectable_value(&mut pen.width, w, crate::i18n::fmt(tl!("{n} pt"), &[("n", &w.to_string())])); } }); + width.response.labelled_by(width_label.id); ui.push_id("tstyle_pen_color", |ui| { ui.label(tl!("Color:")); color_menu(ui, &theme, &mut pen.color); diff --git a/crates/ui-egui/src/equation_tab.rs b/crates/ui-egui/src/equation_tab.rs index 43ca8ab8a5..41ea057013 100644 --- a/crates/ui-egui/src/equation_tab.rs +++ b/crates/ui-egui/src/equation_tab.rs @@ -40,11 +40,31 @@ fn paint_math(app: &mut WordApp, ui: &Ui, r: Rect, key: &str, linear: &str, node } None => { let t = Tokens::get(ui.ctx()); - ui.painter().text(r.center(), Align2::CENTER_CENTER, linear, regular(11.0), t.text_dim); + // Math previews are asynchronous. Interface fonts need not cover the math font's + // repertoire, so use readable linear commands until the actual equation is ready. + let placeholder = math_placeholder(ui, linear); + ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, placeholder, regular(11.0), t.text_dim); } } } +fn math_placeholder(ui: &Ui, linear: &str) -> String { + let font = regular(11.0); + let mut result = String::new(); + for c in linear.chars() { + if c.is_whitespace() || ui.fonts_mut(|fonts| fonts.has_glyph(&font, c)) { + result.push(c); + } else if let Some(name) = wordcraft_doc::math_symbols::name_of(c) { + result.push('\\'); + result.push_str(name); + } else { + use std::fmt::Write as _; + let _ = write!(result, "U+{:04X}", c as u32); + } + } + result +} + /// A big ribbon button whose picture is an equation; opens `menu`. fn math_menu_button(ui: &mut Ui, app: &mut WordApp, picture: &str, label: &str, menu: impl FnOnce(&mut Ui, &mut WordApp)) { let label = tl!(label); @@ -154,8 +174,8 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) { let (r, _) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::hover()); let top = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + BIG_LABEL_Y - 2.0)); let bottom = Rect::from_min_max(pos2(r.min.x, r.min.y + BIG_LABEL_Y - 2.0), r.max); - let rt = ui.interact(top, ui.id().with("eq_insert_top"), Sense::click()); - let rb = ui.interact(bottom, ui.id().with("eq_insert_bottom"), Sense::click()); + let rt = ui.interact(top, ui.scope_id().with("eq_insert_top"), Sense::click()); + let rb = ui.interact(bottom, ui.scope_id().with("eq_insert_bottom"), Sense::click()); for (rr, resp) in [(top, &rt), (bottom, &rb)] { if resp.hovered() { ui.painter().rect_filled(rr, 4.0, t.hover); @@ -178,10 +198,10 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) { /// The Symbols group: a strip of the current set's symbols, and a menu with every set. fn symbols(ui: &mut Ui, app: &mut WordApp) { - let id = egui::Id::new("eq_symbol_set"); + let id = egui::Id::unique("eq_symbol_set"); let set = ui.data(|d| d.get_temp::(id)).unwrap_or(0).min(SYMBOL_SETS.len().saturating_sub(1)); let Some((_, chars)) = SYMBOL_SETS.get(set) else { return }; - let start_id = egui::Id::new("eq_symbol_start"); + let start_id = egui::Id::unique("eq_symbol_start"); let per_row = 10usize; let rows = 3usize; let all: Vec = chars.chars().collect(); @@ -256,7 +276,9 @@ fn symbol_button(ui: &mut Ui, app: &mut WordApp, c: char, t: &Tokens) -> bool { let s = c.to_string(); let nodes = vec![wordcraft_doc::math::MNode::Run(wordcraft_doc::math::MRun { text: s.clone(), lit: true, ..Default::default() })]; paint_math(app, ui, r, &format!("sym:{s}"), &s, Some(nodes), false, 11.0); - let name = wordcraft_doc::math_symbols::name_of(c).map(|n| format!("{s} \\{n}")).unwrap_or_else(|| format!("{s} U+{:04X}", c as u32)); + let display = math_placeholder(ui, &s); + let name = + wordcraft_doc::math_symbols::name_of(c).map(|n| format!("{display} \\{n}")).unwrap_or_else(|| format!("{display} U+{:04X}", c as u32)); resp.on_hover_text(name).clicked() } @@ -378,3 +400,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); }); } + +#[cfg(test)] +mod placeholder_tests { + #[test] + fn a_missing_math_glyph_has_a_readable_linear_placeholder() { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(!ui.fonts_mut(|fonts| fonts.has_glyph(&super::regular(11.0), '∁'))); + assert_eq!(super::math_placeholder(ui, "x+\u{2201}"), "x+\\complement"); + assert_eq!(super::math_placeholder(ui, "x+y"), "x+y"); + }); + output.textures_delta.clear(); + } +} diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs index 502d6a3aa5..79d91fb28f 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -309,7 +309,7 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let mut done: Option> = None; let window = egui::Window::new(tl!(b.title())) - .id(egui::Id::new("builtin_file_browser")) + .id(egui::Id::unique("builtin_file_browser")) .collapsible(false) .resizable(false) .anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0)); @@ -347,7 +347,7 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option> { b.navigate(h); } - let r = ui.add(egui::TextEdit::singleline(&mut b.location).desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(&mut b.location).desired_width(f32::INFINITY)).accessible_name(tl!("Folder path")); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { location_done = true; let typed = b.location.clone(); @@ -439,11 +439,16 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option> } let before = b.filter; let current = b.filters.get(b.filter).map(|(l, _)| tl!(l.as_str()).to_string()).unwrap_or_default(); - egui::ComboBox::from_id_salt("builtin_file_browser_type").selected_text(current).width(150.0).show_ui(ui, |ui| { - for (i, (l, _)) in b.filters.iter().enumerate() { - ui.selectable_value(&mut b.filter, i, tl!(l.as_str())); - } - }); + egui::ComboBox::from_id_salt("builtin_file_browser_type") + .selected_text(current) + .width(150.0) + .show_ui(ui, |ui| { + for (i, (l, _)) in b.filters.iter().enumerate() { + ui.selectable_value(&mut b.filter, i, tl!(l.as_str())); + } + }) + .response + .accessible_name(tl!("File type")); refilter = b.filter != before; }); if refilter { diff --git a/crates/ui-egui/src/format_shape.rs b/crates/ui-egui/src/format_shape.rs index 685008d577..2e90f8be8b 100644 --- a/crates/ui-egui/src/format_shape.rs +++ b/crates/ui-egui/src/format_shape.rs @@ -17,11 +17,11 @@ use crate::WordApp; use crate::theme::Tokens; fn mark_id() -> Id { - Id::new("format_shape.drag_mark") + Id::unique("format_shape.drag_mark") } fn stop_id() -> Id { - Id::new("format_shape.stop") + Id::unique("format_shape.stop") } /// Forgets a drag's undo mark: called while the pane is closed, so a drag cut short by closing @@ -36,7 +36,7 @@ pub fn forget_drag(ctx: &egui::Context) { struct Variation(wordcraft_doc::Pos, Fill, Rgb); fn variation_id() -> Id { - Id::new("format_shape.variation") + Id::unique("format_shape.variation") } /// The colour Shape Fill › Gradient builds from for the object at `pos` filled with `fill`: @@ -128,7 +128,7 @@ pub fn pane(app: &mut WordApp, ui: &mut Ui) { fn solid(app: &mut WordApp, ui: &mut Ui, theme: &[Rgb], color: Rgb, transparency: f32) { let mut c = color; - if color_field(ui, theme, Id::new("format_shape.solid_color"), &mut c) { + if color_field(ui, theme, Id::unique("format_shape.solid_color"), &mut c) { apply(app, ui, Some(&Fill::Solid { color: c, transparency }), None); } let mut t = transparency; @@ -140,8 +140,8 @@ fn solid(app: &mut WordApp, ui: &mut Ui, theme: &[Rgb], color: Rgb, transparency /// A labelled 0–100% slider with its value box; the response when it changed. fn percent(ui: &mut Ui, label: &str, v: &mut f32) -> Option { ui.horizontal(|ui| { - ui.label(label); - let s = ui.add(egui::Slider::new(v, 0.0..=100.0).suffix("%").fixed_decimals(0)); + let label = ui.label(label); + let s = ui.add(egui::Slider::new(v, 0.0..=100.0).suffix("%").fixed_decimals(0)).labelled_by(label.id); (s.changed()).then_some(s) }) .inner @@ -289,7 +289,7 @@ fn gradient(app: &mut WordApp, ui: &mut Ui, theme: &[Rgb], g: &Gradient) { let Some(stop) = g.stops.get(sel).copied() else { return }; let set = |s: GradStop| with(g, |g| g.stops[sel] = s); let mut c = stop.color; - if color_field(ui, theme, Id::new("format_shape.stop_color"), &mut c) { + if color_field(ui, theme, Id::unique("format_shape.stop_color"), &mut c) { apply(app, ui, Some(&set(GradStop { color: c, ..stop })), None); } ui.horizontal(|ui| { @@ -360,7 +360,7 @@ fn stops_bar(app: &mut WordApp, ui: &mut Ui, g: &Gradient, sel: usize) { }; let Some(p) = resp.interact_pointer_pos() else { return }; // Whether the drag in progress began on a handle: only then does it move a stop. - let grab_id = Id::new("format_shape.grabbed"); + let grab_id = Id::unique("format_shape.grabbed"); if resp.drag_started() || resp.clicked() { resp.request_focus(); let hit = near(p); @@ -436,7 +436,7 @@ pub fn swatch(ui: &mut Ui, fill: &Fill, tip: &str) -> bool { let ring = if resp.hovered() { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) }; ui.painter().rect_stroke(r.shrink(2.0), 0.0, ring, egui::StrokeKind::Outside); let name = tl!(tip); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); resp.on_hover_text(name).clicked() } diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 95239eff72..6c93bc0241 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -167,8 +167,12 @@ impl Exempt { let theme_fonts = plain.strip_prefix(": ").is_some_and(|f| f.split(" / ").count() == 2); let icon_lettering = matches!(plain, "Aa" | "ab" | "abc" | "ac" | "cd" | "ab-" | "fx" | "ABC"); let initials = plain.chars().count() == 2 && plain.chars().all(|c| c.is_ascii_uppercase()); + // Math tiles fall back to UnicodeMath syntax while their preview loads. These are + // language-independent commands, and only names in the real symbol table qualify. + let math_symbol = plain.strip_prefix('\\').is_some_and(|name| wordcraft_doc::math_symbols::symbol(name).is_some()); icon_lettering // drawn inside icons || initials // the account button + || math_symbol || status || theme_fonts || is_shortcut() diff --git a/crates/ui-egui/src/ibeam.rs b/crates/ui-egui/src/ibeam.rs index 2678962bcf..ccb811c807 100644 --- a/crates/ui-egui/src/ibeam.rs +++ b/crates/ui-egui/src/ibeam.rs @@ -18,7 +18,7 @@ pub(crate) fn replace(ctx: &egui::Context, p: Pos2, bg: Rgb) -> bool { } ctx.set_cursor_icon(CursorIcon::None); // Above the page and whatever floats over it, as the system pointer would be. - let painter = ctx.layer_painter(LayerId::new(Order::Tooltip, Id::new("wordcraft_ibeam"))); + let painter = ctx.layer_painter(LayerId::new(Order::Tooltip, Id::unique("wordcraft_ibeam"))); paint(&painter, p, crate::canvas::caret_contrast(bg)); true } diff --git a/crates/ui-egui/src/ink.rs b/crates/ui-egui/src/ink.rs index 2eebb6bf74..dc25e145fd 100644 --- a/crates/ui-egui/src/ink.rs +++ b/crates/ui-egui/src/ink.rs @@ -388,7 +388,7 @@ pub fn replay_bar(app: &mut WordApp, ui: &Ui, area: Rect) { let Some(r) = app.canvas.ink_ui.replay.as_ref() else { return }; let (playing, n, total) = (r.playing, r.started(), r.strokes.len()); let mut action = None; - egui::Area::new(egui::Id::new("ink_replay_bar")) + egui::Area::new(egui::Id::unique("ink_replay_bar")) .order(egui::Order::Foreground) .pivot(Align2::CENTER_BOTTOM) .fixed_pos(pos2(area.center().x, area.max.y - 18.0)) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index abf9513909..585f91a802 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -5,6 +5,15 @@ use egui::{Key, Modifiers}; use serde_json::{Value, json}; use crate::WordApp; +use wordcraft_engine::ImeEditOutcome; + +/// The most pre-edit text a UI pass will hold and paint. A composition is a word or a phrase; +/// anything larger is a malformed or hostile event, and laying it out every frame would cost. +const MAX_IME_PREEDIT_BYTES: usize = wordcraft_doc::ime_text::MAX_IME_TEXT_BYTES; + +/// The largest IME commit inserted in one event. Real commits are short; a multi-megabyte one is +/// refused rather than turned into document text without a bound. +const MAX_IME_COMMIT_BYTES: usize = 64 * 1024; /// The registry's name for a key. pub fn key_name(k: Key) -> Option<&'static str> { @@ -231,6 +240,18 @@ fn is_paste_special_release(key: Key, modifiers: egui::Modifiers) -> bool { key == Key::V && modifiers.command && modifiers.alt && !modifiers.shift } +/// The adjacent replacement a refused byte-counted equation deletion must discard. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EquationReplacementDrop { + /// No deletion is waiting for a replacement. + None, + /// The next event may be the replacement: one `Commit`, or a `Text` starting a pair. + Awaiting, + /// The replacement's `Text` was dropped; its immediately following `Commit` also belongs + /// to that same platform replacement. + TextDropped, +} + /// Events for the focused canvas: text, editing keys, clipboard, IME. While keytips are showing /// every key belongs to them (letters pick badges, Escape cancels), so nothing here runs. pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { @@ -268,14 +289,70 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { return; } let alt_codes = alt_codes_supported(ctx); - // `DeleteSurrounding` and the text replacing what it erased arrive in the same frame. + // The erased text's formatting is carried only for a replacement in this raw batch. The + // undo join belongs to the deletion transaction instead: the engine's scoped + // `ime_text_join` survives frames until its matching `text.insert` or any other command, + // key or new request consumes or clears it, so a platform that delivers the `Commit` in a + // later batch still shares the deletion's Undo checkpoint. app.canvas.ime_join_text = None; + // A refused byte-counted equation deletion suppresses only the replacement immediately + // paired with it. egui-winit pushes `DeleteSurroundingBytes` immediately before the + // `Commit` from the same winit IME event, so one adjacent event is the replacement. Some + // platforms route the same replacement through a `Text` followed by its `Commit`; that + // adjacent pair is the only exception. Any other event is fresh input: clearing the state + // lets a later key, pre-edit or direct commit in this same raw batch apply. This is local, + // not canvas state: an untagged event in a later frame cannot be told apart from fresh + // input, so it must never be dropped. + let mut drop_equation_replacement = EquationReplacementDrop::None; + // A body/equation replacement may have invalidated a live preedit before this batch (or + // an earlier event can do it below). Its already queued IME replacement belongs to the old + // target. This is separate from the outgoing interrupt, which is observed after mutation. + // Keep the quarantine local to this batch: a later untagged commit can be fresh input. + let mut interrupted_preedit = std::mem::take(&mut app.canvas.ime_interrupted_preedit); + let mut interrupted_replacement = EquationReplacementDrop::None; // Mod held since an earlier frame (its ModifiersChanged went by then). let held = ctx.input(|i| i.modifiers); if held.command { app.canvas.paste_mods = Some(held); } for e in events { + interrupted_preedit |= std::mem::take(&mut app.canvas.ime_interrupted_preedit); + let stale_composition_event = if interrupted_preedit { + match &e { + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { .. } | egui::ImeEvent::DeleteSurroundingBytes { .. }) => { + interrupted_replacement = EquationReplacementDrop::Awaiting; + true + } + egui::Event::Ime(egui::ImeEvent::Commit(_)) => { + interrupted_preedit = false; + interrupted_replacement = EquationReplacementDrop::None; + true + } + egui::Event::Text(_) if matches!(interrupted_replacement, EquationReplacementDrop::Awaiting) => { + interrupted_replacement = EquationReplacementDrop::TextDropped; + true + } + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) if text.is_empty() => true, + // A new marked composition or ordinary input is not the old target's queued + // replacement. Commit-only input without an interrupted preedit is never gated. + egui::Event::Ime(egui::ImeEvent::Preedit { .. }) + | egui::Event::Text(_) + | egui::Event::Paste(_) + | egui::Event::Key { pressed: true, .. } => { + interrupted_preedit = false; + interrupted_replacement = EquationReplacementDrop::None; + false + } + _ => false, + } + } else { + false + }; + if stale_composition_event { + app.session.clear_ime_joins(); + app.canvas.ime_join_text = None; + continue; + } // Which presses reached the canvas, and the modifiers held with Mod, in event order: Mod // can go down and up within one frame, leaving the frame's modifiers without it. match &e { @@ -290,12 +367,47 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } _ => {} } + // The replacement is the event immediately after the refused deletion: one `Commit`, + // or the adjacent `Text` + `Commit` pair some platforms use. Any intervening event is + // unrelated and clears the state before it is handled. + let drop_event = match (drop_equation_replacement, &e) { + (EquationReplacementDrop::None, _) => false, + (EquationReplacementDrop::Awaiting, egui::Event::Text(_)) => { + drop_equation_replacement = EquationReplacementDrop::TextDropped; + true + } + (EquationReplacementDrop::Awaiting, egui::Event::Ime(egui::ImeEvent::Commit(_))) => { + drop_equation_replacement = EquationReplacementDrop::None; + true + } + (EquationReplacementDrop::Awaiting, _) => { + drop_equation_replacement = EquationReplacementDrop::None; + false + } + (EquationReplacementDrop::TextDropped, egui::Event::Ime(egui::ImeEvent::Commit(_))) => { + drop_equation_replacement = EquationReplacementDrop::None; + true + } + (EquationReplacementDrop::TextDropped, _) => { + drop_equation_replacement = EquationReplacementDrop::None; + false + } + }; + if drop_event { + app.canvas.ime_preedit.clear(); + app.canvas.ime_preedit_range = None; + app.canvas.ime_interrupt_now = true; + continue; + } // An Alt shortcut (Alt+Shift+D, Alt+F7…) may also send its key's text; it isn't typed. match &e { egui::Event::Key { pressed: true, .. } => { app.canvas.swallow_text = false; app.canvas.alt_code_echo = None; app.canvas.ime_join_text = None; + // A key between a deletion and its text means that text is fresh input, not the + // replacement the deletion promised (even when the key runs no command). + app.session.clear_ime_text_join(); } egui::Event::Text(_) if std::mem::take(&mut app.canvas.swallow_text) => continue, egui::Event::Text(t) => { @@ -346,6 +458,9 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { let is_cmd = if cfg!(target_os = "macos") { m.command } else { (m.command || m.ctrl) && !m.alt }; if !is_cmd && !t.is_empty() && t.chars().all(|c| !c.is_control()) { let _ = app.run("equation.type", json!({"text": t})); + } else { + // Ignored text is not the replacement a deletion promised. + app.session.clear_ime_joins(); } continue; } @@ -353,16 +468,72 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { app.canvas.ime_preedit.clear(); app.canvas.ime_preedit_range = None; if !text.is_empty() { - let _ = app.run("equation.type", json!({"text": text})); + if text.len() > MAX_IME_COMMIT_BYTES || text.chars().count() > wordcraft_engine::MAX_EQUATION_TYPE_CHARS { + // Refuse the whole commit (no prefix in either equation mode) and ask + // the platform to start the composition afresh. It is not the + // replacement for a preceding deletion. + log::warn!("ignoring an oversized IME equation commit of {} bytes", text.len()); + app.session.clear_ime_joins(); + app.canvas.ime_interrupt_now = true; + continue; + } + let before_stamp = ime_stamp(app); + match app.run_ime("equation.type", json!({"text": text})) { + Ok(_) => { + let outcome = app.session.ime_edit_outcome(); + let after_stamp = ime_stamp(app); + publish_ime_receipt(app, before_stamp, after_stamp, outcome); + } + Err(_) => { + // The equation refused the commit (it may be gone): start the + // composition afresh instead of leaving one it believes landed. + app.canvas.ime_interrupt_now = true; + } + } + } else { + // An empty commit is not the replacement for a preceding deletion. + app.session.clear_ime_joins(); + } + continue; + } + // A phone keyboard's or macOS replacement inside an equation: erase the counted + // units on each side with one transactional equation command. The tree cannot + // provide a surrounding snapshot; an engine revision proves a real commit and the + // platform is never handed fabricated surroundings. + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { + delete_in_equation(app, *before_chars, *after_chars); + continue; + } + // Byte counts have no reversible mapping into a structured equation, so an + // exact replacement cannot be performed without guessing at tree units. Refuse + // the whole request, clear the composition and drop the replacement text that + // the platform sends for the deletion it believes it made. Inserting it beside + // the untouched equation would duplicate the old text. + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { + if *before_bytes != 0 || *after_bytes != 0 { + log::debug!("refusing byte delete-surrounding inside an equation"); + app.canvas.ime_preedit.clear(); + app.canvas.ime_preedit_range = None; + // This refusal is a new request, not the replacement promised by an + // earlier equation deletion. + app.session.clear_ime_joins(); + drop_equation_replacement = EquationReplacementDrop::Awaiting; + app.canvas.ime_interrupt_now = true; } continue; } egui::Event::Paste(t) => { - let _ = app.run("equation.type", json!({"text": t})); + if app.run("equation.type", json!({"text": t})).is_err() { + app.canvas.ime_interrupt_now = true; + } continue; } egui::Event::Key { key, pressed: true, modifiers, .. } if equation_key(app, *key, *modifiers) => continue, - _ => {} + // Anything else between the deletion and its replacement (a pre-edit, a key + // release, focus, a pointer event) means the next equation typing command is a + // new edit, not the immediate replacement. Drop the promise; a command consumes + // it in `Session::run` even when it is not a typing command. + _ => app.session.clear_ime_joins(), } } match e { @@ -373,24 +544,62 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { continue; } if t.chars().all(|c| !c.is_control()) && !t.is_empty() { - if crate::dialogs_smart_art::type_into(app, &t) { - continue; + match crate::dialogs_smart_art::type_into(app, &t) { + crate::dialogs_smart_art::SmartArtTypeResult::Changed => { + note_ime_application_change(app); + continue; + } + crate::dialogs_smart_art::SmartArtTypeResult::ConsumedNoChange => continue, + crate::dialogs_smart_art::SmartArtTypeResult::NotHandled => {} } // Typing with a text box selected types into it. crate::objects::enter_text_box(app); - type_text(app, &t); + type_text(app, &t, false); } } egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => delete_surrounding(app, before_chars, after_chars), + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { + delete_surrounding_bytes(app, before_bytes, after_bytes) + } egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { - app.canvas.ime_preedit = text; - app.canvas.ime_preedit_range = active_range_chars; + if text.len() <= MAX_IME_PREEDIT_BYTES { + app.canvas.ime_preedit = text; + app.canvas.ime_preedit_range = active_range_chars; + } else { + log::warn!("ignoring an IME pre-edit of {} bytes", text.len()); + app.canvas.ime_preedit.clear(); + app.canvas.ime_preedit_range = None; + } } egui::Event::Ime(egui::ImeEvent::Commit(text)) => { app.canvas.ime_preedit.clear(); app.canvas.ime_preedit_range = None; - if !text.is_empty() && !crate::dialogs_smart_art::type_into(app, &text) { - type_text(app, &text); + if !text.is_empty() { + if text.len() <= MAX_IME_COMMIT_BYTES { + match crate::dialogs_smart_art::type_into(app, &text) { + crate::dialogs_smart_art::SmartArtTypeResult::Changed => { + app.session.clear_ime_text_join(); + app.canvas.ime_join_text = None; + note_ime_application_change(app); + } + crate::dialogs_smart_art::SmartArtTypeResult::ConsumedNoChange => { + app.session.clear_ime_text_join(); + app.canvas.ime_join_text = None; + } + crate::dialogs_smart_art::SmartArtTypeResult::NotHandled => { + type_text(app, &text, true); + } + } + } else { + log::warn!("ignoring an IME commit of {} bytes", text.len()); + app.session.clear_ime_text_join(); + app.canvas.ime_join_text = None; + app.canvas.ime_interrupt_now = true; + } + } else { + // An empty commit is not the replacement a preceding deletion promised. + app.session.clear_ime_text_join(); + app.canvas.ime_join_text = None; } } egui::Event::Paste(t) => { @@ -605,27 +814,281 @@ fn finish_alt_code(app: &mut WordApp) { /// Typed text (a key's text or an input method's commit). Text replacing what an input method's /// `DeleteSurrounding` just erased takes the erased text's formatting, never overtypes what /// follows, and stays in the typing undo group (#1480). -fn type_text(app: &mut WordApp, text: &str) { - if let Some(props) = app.canvas.ime_join_text.take() { - app.session.join_next_undo(); +/// +/// `ime` says this text came from an accepted IME commit. Only then is a receipt taken, and only +/// around the insertion itself: a transformation the engine performs (AutoCorrect, AutoFormat) +/// makes the frame [`egui::ImeChangeCause::Other`] instead. +fn type_text(app: &mut WordApp, text: &str, ime: bool) { + let before = ime.then(|| ime_stamp(app)); + // Where the text lands, so a quote can be checked against what the engine actually inserted. + let start = app.session.sel.ordered().0; + let pending_before = app.session.pending.clone(); + let typed = if let Some(props) = app.canvas.ime_join_text.take() { + // The replacement takes the erased text's formatting. `Session::run` does not restore + // `pending` on failure (it is not part of the document snapshot), so keep it here: a + // refused replacement must not leave its formatting waiting for the next ordinary key. + // The undo join itself is the engine's scoped body-replacement promise, consumed by + // `Session::run` only for this `text.insert` at the promised selection. app.session.pending = Some(props); let overtype = std::mem::replace(&mut app.session.prefs.overtype, false); - let typed = app.run("text.insert", json!({"text": text})).is_ok(); + let typed = + if ime { app.run_ime("text.insert", json!({"text": text})).is_ok() } else { app.run("text.insert", json!({"text": text})).is_ok() }; app.session.prefs.overtype = overtype; if typed { app.session.continue_typing(); + } else { + app.session.pending = pending_before; + // The platform already applied its own deletion to the text it believed was there. + // Its replacement was refused, so the surrounding snapshot is stale and the + // deletion's join must not reach a later composition. + app.session.clear_ime_text_join(); + app.canvas.ime_interrupt_now = true; } + typed + } else if ime { + app.run_ime("text.insert", json!({"text": text})).is_ok() } else { - let _ = app.run("text.insert", json!({"text": text})); + app.run("text.insert", json!({"text": text})).is_ok() + }; + if !typed { + return; + } + let mut outcome = app.session.ime_edit_outcome(); + // The engine's own outcome is the per-command provenance: `text.insert` compares the paragraph + // with the one it typed and marks AutoCorrect/list/dash rewrites `transformed`. A smart quote + // is also checked against the actual inserted text, so smart quotes off stays the IME's own + // edit. Never infer this from an undo label, which may belong to an earlier step. A refused + // insert has no provenance at all: it must not fabricate an AutoFormat change or receipt. + let quote = text == "\"" || text == "'"; + outcome.transformed |= quote && !inserted_literal_at(app, text, &start); + note_automatic_change(app, outcome); + if let Some(before) = before { + let after = ime_stamp(app); + publish_ime_receipt(app, before, after, outcome); } - note_automatic_change(app); } -/// AutoCorrect or AutoFormat just changed the text around the caret (its own undo step, which -/// closes the typing group). The web's text agent still holds the text as typed and counts its -/// `DeleteSurrounding` from that, so it is asked to start afresh (`canvas::ime_reset`). -fn note_automatic_change(app: &mut WordApp) { - if !app.session.typing_open() && app.session.undo_label() == Some("AutoFormat") { +/// Whether the text at `start` is exactly `text` (the engine inserted it unchanged). +/// +/// A smart quote, an AutoCorrect replacement or a relocated insert makes this false: the edit is +/// AutoFormat work, not the input method's own change. +fn inserted_literal_at(app: &WordApp, text: &str, start: &wordcraft_doc::Pos) -> bool { + let Some(para) = app.session.doc.para_at(start) else { return false }; + let Some(end) = start.off.checked_add(text.len()) else { return false }; + para.text.get(start.off..end) == Some(text) +} + +/// The bounded visible-logical window around the body selection, cached by `(revision, cursor, +/// anchor)` so an idle pass does not walk a long paragraph again. Every edit bumps `rev`. +fn ime_window(app: &mut WordApp) -> Option { + let rev = app.session.rev(); + let cursor = app.session.sel.focus.clone(); + let anchor = app.session.sel.anchor.clone(); + if let Some((r, c, a, w)) = &app.canvas.ime_window_cache + && *r == rev + && *c == cursor + && *a == anchor + { + return w.clone(); + } + let window = app.session.doc.ime_surrounding(&cursor, &anchor, wordcraft_doc::ime_text::MAX_IME_TEXT_BYTES); + app.canvas.ime_window_cache = Some((rev, cursor, anchor, window.clone())); + window +} + +/// Observe the settled editor at the end of a UI pass and publish its IME descriptor. +/// +/// The tracker is scoped by native viewport. A closed viewport releases its state; a pass without +/// a visible focused editor reports an unfocused observation so a later focus starts afresh. +pub(crate) fn observe_ime(app: &mut WordApp, ctx: &egui::Context, geometry: Option<(egui::Rect, egui::Rect)>, interrupt: bool) { + let viewport = ctx.viewport_id(); + // Drain an application-origin change before the final observation. The signal survives later + // commands until here (or until a receipt publishes), so an edit after the receipt still + // makes the frame `Other`. + if app.session.take_pending_ime_application_change() { + note_ime_application_change(app); + } + if app.canvas.ime_viewport != Some(viewport) { + if let Some(old) = app.canvas.ime_viewport { + app.canvas.ime_tracker.end_viewport(old); + } + app.canvas.ime_viewport = Some(viewport); + } + let frame = ctx.cumulative_frame_nr_for(viewport); + let pass = ctx.cumulative_pass_nr_for(viewport); + let focused = app.canvas.focused && geometry.is_some(); + let update = if focused { + let target = ime_target(app); + if app.session.math.is_some() { + // An equation's structured tree has no reversible text mapping yet: report an + // explicit Unavailable, never fabricated surroundings. Its stable session token is + // the replacement epoch, so caret motion keeps one context. The engine revision lets + // an unavailable receipt prove a committed edit. + let mut observation = egui::ImeObservation::new(frame, pass, target); + if let Some(rev) = app.session.ime_state_revision() { + observation = observation.unavailable_revision(rev); + } + app.canvas.ime_tracker.observe(viewport, Some(observation)) + } else { + let window = ime_window(app); + if let Some(w) = &window { + let committed = Some(egui::CommittedText::new(&w.text, w.cursor, w.anchor)); + app.canvas.ime_tracker.observe(viewport, Some(egui::ImeObservation::new(frame, pass, target).committed(committed))) + } else { + // A wide selection or cross-story caret has no single surrounding window. Its + // engine revision orders unavailable observations; no text is fabricated. + let mut observation = egui::ImeObservation::new(frame, pass, target); + if let Some(rev) = app.session.ime_state_revision() { + observation = observation.unavailable_revision(rev); + } + app.canvas.ime_tracker.observe(viewport, Some(observation)) + } + } + } else { + // No editor has the keyboard: a pre-edit painted from the old focus must not linger. + app.canvas.ime_preedit.clear(); + app.canvas.ime_preedit_range = None; + app.canvas.ime_tracker.observe(viewport, None) + }; + if let (Some(update), Some((rect, cursor_rect))) = (update, geometry) { + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { + owner: update.owner, + context_generation: update.context_generation, + purpose: update.purpose, + rect, + cursor_rect, + should_interrupt_composition: interrupt, + change_cause: update.change_cause, + surrounding_text: update.surrounding_text, + }); + }); + } +} + +/// The immutable IME owner for the editor that currently has the keyboard. +/// +/// The document identity and story are stable across edits; an equation adds its editing-session +/// token as the replacement epoch because its document position moves with the text. +fn ime_target(app: &WordApp) -> egui::ImeTarget { + let document = app.session.document_id(); + if let Some(math) = &app.session.math { + egui::ImeTarget::new(egui::Id::unique(("wordcraft-ime-equation", document)), math.session) + } else { + // A body replaced under the same document id (setText, version restore, compare, + // Undo/Redo) advances the epoch, so the platform clears the old composition. + egui::ImeTarget::new(egui::Id::unique(("wordcraft-ime-body", document, app.session.sel.focus.story)), app.session.ime_body_epoch()) + } +} + +/// The bounded committed snapshot used by IME receipts. +/// +/// An equation has no reversible tree-to-text mapping, and a selection across blocks has no +/// single visible paragraph, so both are honestly unavailable rather than fabricated. +fn ime_stamp(app: &mut WordApp) -> egui::ImeMutationStamp { + let unavailable = |app: &mut WordApp| match app.session.ime_state_revision() { + Some(rev) => egui::ImeMutationStamp::unavailable_at_revision(rev), + None => egui::ImeMutationStamp::unavailable(), + }; + if app.session.math.is_some() { + return unavailable(app); + } + match ime_window(app) { + Some(w) => egui::ImeMutationStamp::from_committed(&w.text, w.cursor, w.anchor, None), + None => unavailable(app), + } +} + +/// Record a successful accepted IME commit/delete for the current frame and viewport. +fn note_ime_receipt(app: &mut WordApp, before: egui::ImeMutationStamp, after: egui::ImeMutationStamp) { + let Some(ctx) = app.ctx.clone() else { return }; + let viewport = ctx.viewport_id(); + let frame = ctx.cumulative_frame_nr_for(viewport); + let target = ime_target(app); + app.canvas.ime_tracker.note_input_method_apply(viewport, frame, target, before, after); +} + +/// Record an application-origin edit in the current frame; `Other` dominates the frame. +fn note_ime_application_change(app: &mut WordApp) { + let Some(ctx) = app.ctx.clone() else { return }; + let viewport = ctx.viewport_id(); + let frame = ctx.cumulative_frame_nr_for(viewport); + let target = ime_target(app); + app.canvas.ime_tracker.note_application_change(viewport, frame, target); +} + +/// Whether the stamp pair proves the transition an IME receipt needs. +/// The receipt decision, kept pure so tests can exercise every revision/availability case. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ImeProof { + /// Classified no-op with an unchanged stamp: leave an earlier receipt as the frame's cause. + NoMutation, + /// A committed change the stamps cannot prove (or an automatic rewrite): `Other`. + UnprovenMutation, + /// A classified content change whose exact stamp transition is proven. + Certified, +} + +fn unavailable_revision(stamp: &egui::ImeMutationStamp) -> Option { + stamp.unavailable_revision() +} + +fn stamp_transition_proven(before: &egui::ImeMutationStamp, after: &egui::ImeMutationStamp) -> bool { + use egui::output::IMESurroundingText; + match (before.surrounding_text(), after.surrounding_text()) { + (IMESurroundingText::Available(a), IMESurroundingText::Available(b)) => a != b, + (IMESurroundingText::Unavailable, IMESurroundingText::Unavailable) => { + matches!((unavailable_revision(before), unavailable_revision(after)), (Some(a), Some(b)) if a < b) + } + // A mixed transition has one unavailable endpoint; only that endpoint carries a revision. + (IMESurroundingText::Unavailable, IMESurroundingText::Available(_)) | (IMESurroundingText::Available(_), IMESurroundingText::Unavailable) => { + let unavailable = if matches!(before.surrounding_text(), IMESurroundingText::Unavailable) { before } else { after }; + unavailable_revision(unavailable).is_some() + } + } +} + +fn classify_ime_proof(outcome: ImeEditOutcome, before: &egui::ImeMutationStamp, after: &egui::ImeMutationStamp) -> ImeProof { + if !outcome.classified { + return if outcome.state_changed { ImeProof::UnprovenMutation } else { ImeProof::NoMutation }; + } + if outcome.transformed { + return ImeProof::UnprovenMutation; + } + if outcome.state_changed && !outcome.content_changed { + return ImeProof::UnprovenMutation; + } + if outcome.content_changed { + return if stamp_transition_proven(before, after) { ImeProof::Certified } else { ImeProof::UnprovenMutation }; + } + if before == after { ImeProof::NoMutation } else { ImeProof::UnprovenMutation } +} + +/// Publish at most one receipt for a successful IME command, or force `Other` when the engine +/// outcome and the bounded stamps cannot prove a pure content change. +fn publish_ime_receipt(app: &mut WordApp, before: egui::ImeMutationStamp, after: egui::ImeMutationStamp, outcome: ImeEditOutcome) { + if app.ctx.is_none() { + return; + } + // A successful application-origin command since the last observation poisons the frame, even + // when the available text stamp is unchanged (formatting-only changes). + if app.session.take_pending_ime_application_change() { + note_ime_application_change(app); + return; + } + match classify_ime_proof(outcome, &before, &after) { + ImeProof::NoMutation => {} + ImeProof::UnprovenMutation => note_ime_application_change(app), + ImeProof::Certified => note_ime_receipt(app, before, after), + } +} + +/// AutoCorrect or AutoFormat just changed the text around the caret. The web's text agent still +/// holds the text as typed and counts its `DeleteSurrounding` from that, so it is asked to start +/// afresh (`canvas::ime_reset`). The caller passes the engine's per-command outcome; an undo label +/// from an earlier step cannot make an unrelated literal commit look transformed. +fn note_automatic_change(app: &mut WordApp, outcome: ImeEditOutcome) { + if outcome.transformed { app.canvas.ime_reset = true; } } @@ -635,44 +1098,335 @@ fn note_automatic_change(app: &mut WordApp) { /// selection. The web build's text agent (eframe's hidden ``) sends it, then the new /// text, whenever its value changes other than by an addition at its end (#1480): Backspace /// and corrections from phone keyboards, and Chrome's caret jump. The desktop builds never get -/// it (egui-winit 0.36 doesn't send it). The counts come from outside: they stop at the -/// paragraph's ends, so a paragraph mark or list number is never erased. The erasure joins an -/// open typing group and the text that follows joins it too ([`type_text`]), so one Undo takes -/// back the typing with its correction. -/// With a selection (the agent starts afresh on any click or editing key, so it doesn't send -/// one then) nothing is erased: the text that follows replaces the selection as typing does. -/// Nor inside an equation, whose text the agent's characters went to. +/// it (egui-winit 0.36 doesn't send it). +/// +/// The counts are over the visible logical text: tracked deletions are hidden and inline objects +/// contribute their plain-text result. They exclude the selection, which is preserved, and any +/// pre-edit (which never lives in the model here). Erasure joins an open typing group and the +/// text that follows joins it too ([`type_text`]), so one Undo takes back the typing with its +/// correction. Neither path runs inside an equation, whose text is not the document's. fn delete_surrounding(app: &mut WordApp, before: usize, after: usize) { - let s = &app.session; - // Inside an equation the document's text isn't what the input method typed into. - if !s.sel.is_collapsed() || s.math.is_some() { + delete_surrounding_common(app, before, after, wordcraft_doc::ime_text::DeleteUnit::Chars); +} + +/// An input method's byte-counted `DeleteSurroundingBytes` (macOS and Wayland): erase the UTF-8 +/// range it names, then the following `Commit` replaces it. Both counts round inward at a +/// character boundary (a start forward, an end backward), so a count that ends inside a +/// character leaves that character whole. The selection and paragraph marks stay outside the +/// range. +fn delete_surrounding_bytes(app: &mut WordApp, before: usize, after: usize) { + delete_surrounding_common(app, before, after, wordcraft_doc::ime_text::DeleteUnit::Bytes); +} + +/// Refuse a body surrounding request and discard the composition it invalidates. +/// +/// The platform has already changed the text it believed was around the caret, so its snapshot is +/// stale: interrupt it, unpaint the local pre-edit, and drop every replacement promise (body and +/// equation) that could otherwise join a later edit into an earlier deletion. +fn refuse_ime_request(app: &mut WordApp) { + app.canvas.ime_preedit.clear(); + app.canvas.ime_preedit_range = None; + app.canvas.ime_join_text = None; + app.session.clear_ime_joins(); + app.canvas.ime_interrupt_now = true; +} + +/// A signed stored-length change: negative when the paragraph shrank, positive only when a +/// restored content-control placeholder grew in place of the erased text. +fn signed_len_delta(before: usize, after: usize) -> Option { + let after = i64::try_from(after).ok()?; + let before = i64::try_from(before).ok()?; + after.checked_sub(before) +} + +/// Move a stored UTF-8 offset by a signed paragraph-length delta. +fn shift_offset(off: usize, shift: i64) -> Option { + let off = i64::try_from(off).ok()?; + usize::try_from(off.checked_add(shift)?).ok() +} + +/// The end of a placeholder `restore_placeholder` just inserted at or before `deleted_start`. +/// +/// `restore_placeholder` sets the caret to the control's content start before inserting. Finding +/// that exact state proves the growth came from a placeholder insertion at a position before the +/// erased range, so the stored suffix really moved by the measured signed delta. +fn restored_placeholder_end(s: &wordcraft_engine::Session, deleted_start: usize) -> Option { + let at = &s.sel.focus; + let r = s.doc.control_at(at)?; + if !r.control.showing_placeholder || r.content_start() != *at || r.start.path != r.end.path { + return None; + } + let start = r.content_start().off; + if start > deleted_start { + return None; + } + let placeholder = r.control.placeholder_shown(); + let end = start.checked_add(placeholder.len())?; + // The caret alone is not proof: a control that was already showing its placeholder and had + // that text deleted also leaves the caret at the content start. Require the restored + // placeholder text itself to occupy the content from the start. + let para = s.doc.para_at(at)?; + (!placeholder.is_empty() && para.text.get(start..end) == Some(placeholder)).then_some(end) +} + +/// Both delete-surrounding paths: one visible-logical mapping for the query and the deletion. +fn delete_surrounding_common(app: &mut WordApp, before: usize, after: usize, unit: wordcraft_doc::ime_text::DeleteUnit) { + if before == 0 && after == 0 { return; } - let f = s.sel.focus.clone(); - let Some(text) = s.doc.para_at(&f).map(|p| p.text.as_str()) else { return }; - let (Some(head), Some(tail)) = (text.get(..f.off), text.get(f.off..)) else { return }; - let start = f.off.saturating_sub(head.chars().rev().take(before).map(char::len_utf8).sum::()); - let end = f.off.saturating_add(tail.chars().take(after).map(char::len_utf8).sum::()); - if start == end { + // A new surrounding request supersedes any replacement promised by an earlier one. + app.session.clear_ime_joins(); + if app.session.math.is_some() { + refuse_ime_request(app); return; } - let join = s.typing_open(); - // The formatting typing at the caret would use: that of the erased text (or set for it). - let props = s.typing_props(); - app.session.sel = - wordcraft_engine::Selection { anchor: wordcraft_doc::Pos { off: start, ..f.clone() }, focus: wordcraft_doc::Pos { off: end, ..f.clone() } }; - let caret = f; - if join { - app.session.join_next_undo(); + let cursor = app.session.sel.focus.clone(); + let anchor = app.session.sel.anchor.clone(); + if cursor.story != anchor.story || cursor.path != anchor.path { + refuse_ime_request(app); + return; } - if app.run("text.backspace", json!({})).is_ok() { - app.canvas.ime_join_text = Some(props); + let Some(del) = app.session.doc.ime_delete_surrounding(&cursor, &anchor, before, after, unit) else { + refuse_ime_request(app); + return; + }; + if del.before.is_none() && del.after.is_none() { + refuse_ime_request(app); + return; + } + let join = app.session.typing_open(); + let props = app.session.typing_props(); + let original = app.session.sel.clone(); + let pending_before = app.session.pending.clone(); + let goal_x_before = app.session.goal_x; + let before_stamp = ime_stamp(app); + // Both sides are one IME request: if the second half is refused (Restrict Editing, a + // content control, an already-deleted tracked range, a classified no-op…), the first half + // must go back too. The document, selection, undo stack, typing group, dirty flag, pending + // formatting, `goal_x` and every pending join are restored or cleared. Deliberately not + // restored: `last_command`, macro recording, Go Back marks, `also_selected`, `typing`/ + // `autocap` and canvas preedit (a refusal clears it). + let snapshot = app.session.edit_snapshot(); + let rollback = |app: &mut WordApp, + snapshot: wordcraft_engine::EditSnapshot, + original: wordcraft_engine::Selection, + pending: Option, + goal_x: Option| { + app.session.restore(snapshot); + app.session.sel = original; + app.session.pending = pending; + app.session.goal_x = goal_x; + refuse_ime_request(app); + }; + let satisfied = |app: &mut WordApp| { + let outcome = app.session.ime_edit_outcome(); + outcome.classified && outcome.content_changed && outcome.state_changed && !outcome.transformed + }; + // Stored offsets of the suffix (and of the original selection) move by the paragraph's + // signed length change. A placeholder can make that positive; subtracting a removed-byte + // count cannot describe that, and would leave the second range and the caret inside the + // placeholder. + let mut shift = 0i64; + let mut deleted = false; + let mut first_placeholder_end = None; + if let Some(range) = del.before.clone() { + if range.start >= range.end { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + } + let Some(len_before) = app.session.doc.para_at(&cursor).map(|p| p.len()) else { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + }; + // A control already showing its placeholder cannot have received a new one from this + // deletion; its unchanged text must not be mistaken for a restored placeholder. + let showing_before = + app.session.doc.control_at(&wordcraft_doc::Pos { off: range.end, ..cursor.clone() }).is_some_and(|r| r.control.showing_placeholder); + app.session.sel = wordcraft_engine::Selection { + anchor: wordcraft_doc::Pos { off: range.start, ..cursor.clone() }, + focus: wordcraft_doc::Pos { off: range.end, ..cursor.clone() }, + }; + if join { + app.session.join_next_undo(); + } + if app.run_ime("text.backspace", json!({})).is_err() || !satisfied(app) { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + } + deleted = true; + // A tracked delete of text this author inserted removes it from the stored paragraph; + // other tracked deletions only mark it. A content control emptied by the deletion gains + // its placeholder again, which can make the paragraph longer. Measure the signed change + // and require proof for growth: only a placeholder restored at or before the erased + // range can move the suffix right. + let Some(len_after) = app.session.doc.para_at(&cursor).map(|p| p.len()) else { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + }; + let Some(delta) = signed_len_delta(len_before, len_after) else { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + }; + let restored = restored_placeholder_end(&app.session, range.start).filter(|_| !showing_before); + if delta > 0 && restored.is_none() { + // Growth that cannot be attributed to a placeholder insertion at/before the erased + // range has no provable suffix mapping: refuse the whole transaction. + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + } + // Keep the restored placeholder's end: for a caret it is the provable insertion point, + // also when a tracked deletion keeps the erased bytes in the stored paragraph. + first_placeholder_end = restored; + shift = delta; + } + let mut second_placeholder_end = None; + if let Some(range) = del.after { + let Some(len_before) = app.session.doc.para_at(&cursor).map(|p| p.len()) else { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + }; + let Some(start) = shift_offset(range.start, shift) else { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + }; + let Some(end) = shift_offset(range.end, shift) else { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + }; + let in_bounds = app + .session + .doc + .para_at(&cursor) + .is_some_and(|p| start < end && end <= p.len() && p.text.is_char_boundary(start) && p.text.is_char_boundary(end)); + if !in_bounds { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + } + // The mapped end is in the paragraph as the first side left it; a control already + // showing its placeholder there is not one this side restores. + let showing_before = + app.session.doc.control_at(&wordcraft_doc::Pos { off: end, ..cursor.clone() }).is_some_and(|r| r.control.showing_placeholder); + app.session.sel = wordcraft_engine::Selection { + anchor: wordcraft_doc::Pos { off: start, ..cursor.clone() }, + focus: wordcraft_doc::Pos { off: end, ..cursor.clone() }, + }; + // A second erased side belongs to the same undo step as the first (and to the + // typing group when one is open), and so does the commit that follows. + if deleted || join { + app.session.join_next_undo(); + } + if app.run_ime("text.backspace", json!({})).is_err() || !satisfied(app) { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + } + deleted = true; + // The after side can empty a different control and restore its placeholder too. That + // insertion changes the caret's position by its own length, not by the stored delta of + // the text beyond it, so map the caret to the restored placeholder's end explicitly. + let Some(len_after) = app.session.doc.para_at(&cursor).map(|p| p.len()) else { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + }; + let Some(delta) = signed_len_delta(len_before, len_after) else { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + }; + let restored = restored_placeholder_end(&app.session, start).filter(|_| !showing_before); + if delta > 0 && restored.is_none() { + // The after side grew the paragraph without a provable placeholder insertion: + // the final caret cannot be mapped safely. + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + } + second_placeholder_end = restored; + } + let (anchor_off, focus_off) = if let Some(end) = second_placeholder_end { + // A selection spanning the emptied control cannot be remapped through a placeholder + // insertion without guessing; only a caret has a provable position. + if del.anchor != del.focus { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + } + (end, end) + } else if let (Some(end), true) = (first_placeholder_end, del.anchor == del.focus) { + // The first side restored the placeholder; its end is the caret's provable position even + // while an after-side deletion is applied beyond the control. + (end, end) } else { - // Refused (a protected document): leave the caret where it was. - app.session.sel = wordcraft_engine::Selection::caret(caret); + let (Some(anchor_off), Some(focus_off)) = (shift_offset(del.anchor, shift), shift_offset(del.focus, shift)) else { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + }; + (anchor_off, focus_off) + }; + let valid = + app.session.doc.para_at(&cursor).is_some_and(|p| { + anchor_off <= p.len() && focus_off <= p.len() && p.text.is_char_boundary(anchor_off) && p.text.is_char_boundary(focus_off) + }); + if !valid { + rollback(app, snapshot, original, pending_before, goal_x_before); + return; + } + app.session.sel = wordcraft_engine::Selection { + anchor: wordcraft_doc::Pos { off: anchor_off, ..cursor.clone() }, + focus: wordcraft_doc::Pos { off: focus_off, ..cursor }, + }; + if deleted { + // Only the next `text.insert` at this exact selection may join the deletion; any + // intervening ordinary command consumes and discards the promise and owns its own Undo + // checkpoint. + app.session.join_next_ime_text_replacement(); + app.canvas.ime_join_text = Some(props); + let after_stamp = ime_stamp(app); + let outcome = ImeEditOutcome { classified: true, content_changed: true, state_changed: true, transformed: false, selection_changed: true }; + publish_ime_receipt(app, before_stamp, after_stamp, outcome); } } +/// An input method's `DeleteSurrounding` inside an equation: one bounded transactional command. +/// +/// The structured tree has no surrounding snapshot; the engine revision on the unavailable stamp +/// proves the commit. Any refusal leaves the document untouched and interrupts the composition. +/// `Session::run` has already consumed the pending undo joins before dispatch and may have +/// recorded the attempted no-op command in the macro/Repeat; those generic side effects are not +/// rolled back. +fn delete_in_equation(app: &mut WordApp, before: usize, after: usize) { + if app.session.math.is_none() { + return; + } + // A new surrounding request supersedes any earlier equation deletion's promised join, + // whether or not this one can run. Clear the painted composition before every refusal, + // too: it belongs to a snapshot the interrupt is about to discard. + app.session.clear_ime_joins(); + app.canvas.ime_preedit.clear(); + app.canvas.ime_preedit_range = None; + if before > wordcraft_engine::MAX_EQUATION_DELETE_UNITS || after > wordcraft_engine::MAX_EQUATION_DELETE_UNITS { + app.canvas.ime_interrupt_now = true; + return; + } + if before == 0 && after == 0 { + return; + } + if app.session.typing_open() { + app.session.join_next_undo(); + } + let before_stamp = ime_stamp(app); + match app.run_ime("equation.deleteSurrounding", json!({"before": before, "after": after})) { + Ok(_) => { + // The replacement the platform commits next belongs to the same edit as the + // deletion, exactly like the body path's `ime_join_text`. This is an equation-only + // promise: an unrelated command consumes it without joining, so it cannot merge + // into this deletion's undo step. + app.session.join_next_equation_edit(); + let outcome = app.session.ime_edit_outcome(); + let after_stamp = ime_stamp(app); + publish_ime_receipt(app, before_stamp, after_stamp, outcome); + } + Err(_) => { + app.canvas.ime_interrupt_now = true; + } + } +} /// Text arriving right after an Alt code typed `echo` (no key pressed between): whether it was /// the platform's composed character and is handled. The same character isn't typed twice. On /// Windows the text is always the composed character: for a code up to 255 it replaces ours (the @@ -728,6 +1482,59 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { mod tests { use super::*; + fn outcome(classified: bool, content: bool, state: bool, transformed: bool) -> ImeEditOutcome { + ImeEditOutcome { classified, content_changed: content, state_changed: state, transformed, selection_changed: false } + } + + /// The central gate: a classified no-op leaves an earlier receipt alone, while any + /// unprovable mutation forces `Other`. + #[test] + fn ime_receipt_gate_distinguishes_noop_from_unproven_mutation() { + let same = egui::ImeMutationStamp::from_committed("ab", 2, 2, None); + assert_eq!(classify_ime_proof(outcome(true, false, false, false), &same, &same), ImeProof::NoMutation); + let changed = egui::ImeMutationStamp::from_committed("abx", 3, 3, None); + assert_eq!(classify_ime_proof(outcome(true, true, true, false), &same, &changed), ImeProof::Certified); + assert_eq!(classify_ime_proof(outcome(true, true, true, true), &same, &changed), ImeProof::UnprovenMutation); + assert_eq!(classify_ime_proof(outcome(false, false, true, false), &same, &changed), ImeProof::UnprovenMutation); + assert_eq!(classify_ime_proof(outcome(true, true, true, false), &same, &same), ImeProof::UnprovenMutation); + } + + /// Unavailable receipts need both checked revisions and a strict increase; mixed transitions + /// need the unavailable endpoint's revision. + #[test] + fn ime_receipt_gate_checks_unavailable_revisions() { + let ten = egui::ImeMutationStamp::unavailable_at_revision(10); + let eleven = egui::ImeMutationStamp::unavailable_at_revision(11); + let missing = egui::ImeMutationStamp::unavailable(); + let content = outcome(true, true, true, false); + assert_eq!(classify_ime_proof(content, &ten, &eleven), ImeProof::Certified); + assert_eq!(classify_ime_proof(content, &eleven, &ten), ImeProof::UnprovenMutation); + assert_eq!(classify_ime_proof(content, &ten, &ten), ImeProof::UnprovenMutation); + assert_eq!(classify_ime_proof(content, &missing, &eleven), ImeProof::UnprovenMutation); + let available = egui::ImeMutationStamp::from_committed("abx", 3, 3, None); + assert_eq!(classify_ime_proof(content, &ten, &available), ImeProof::Certified); + assert_eq!(classify_ime_proof(content, &missing, &available), ImeProof::UnprovenMutation); + assert_eq!(classify_ime_proof(content, &available, &ten), ImeProof::Certified); + assert_eq!(classify_ime_proof(content, &available, &missing), ImeProof::UnprovenMutation); + } + + /// A revision-only unavailable change is a changed observation, not a no-op. + #[test] + fn ime_receipt_gate_treats_revision_only_changes_as_unproven() { + let ten = egui::ImeMutationStamp::unavailable_at_revision(10); + let eleven = egui::ImeMutationStamp::unavailable_at_revision(11); + assert_eq!(classify_ime_proof(outcome(true, false, false, false), &ten, &eleven), ImeProof::UnprovenMutation); + } + + /// An exhausted engine allocator emits `Unavailable` without a revision. It can never prove + /// a content change; a classified no-op still leaves an earlier receipt alone. + #[test] + fn ime_receipt_gate_rejects_exhausted_unavailable_stamps() { + let exhausted = egui::ImeMutationStamp::unavailable(); + assert_eq!(classify_ime_proof(outcome(true, true, true, false), &exhausted, &exhausted), ImeProof::UnprovenMutation); + assert_eq!(classify_ime_proof(outcome(true, false, false, false), &exhausted, &exhausted), ImeProof::NoMutation); + } + /// #1678: Ctrl+F1 collapses or expands the ribbon (not on macOS); plain F1 stays Help's. #[test] fn ctrl_f1_collapses_the_ribbon() { @@ -1118,6 +1925,87 @@ mod tests { egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) } + /// A real body replacement invalidates the old preedit even if its text is unchanged. + /// Its queued replacement must not edit the new body or add an Undo checkpoint. + #[test] + fn ime_interrupted_body_preedit_drops_queued_replacement_before_mutation() { + for replacement in [ + vec![egui::Event::Ime(egui::ImeEvent::Commit("stale".into()))], + vec![surround(1, 1), egui::Event::Ime(egui::ImeEvent::Commit("stale".into()))], + vec![surround_bytes(1, 1), egui::Event::Ime(egui::ImeEvent::Commit("stale".into()))], + vec![surround_bytes(1, 1), egui::Event::Text("stale".into()), egui::Event::Ime(egui::ImeEvent::Commit("stale".into()))], + ] { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "ABCDE"})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 2)); + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })]); + assert_eq!(a.canvas.ime_preedit, "に"); + a.run("document.setText", json!({"text": "ABCDE"})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 2)); + let depth = a.session.undo_depth(); + alt_frame(&ctx, &mut a, replacement); + assert_eq!(body(&a), "ABCDE", "queued events cannot replace text in the new body"); + assert_eq!(a.session.sel.focus.off, 2, "not even the new caret moves"); + assert_eq!(a.session.undo_depth(), depth, "rejection happens before any transaction"); + assert!(a.canvas.ime_preedit.is_empty()); + assert!(a.canvas.ime_interrupt_now); + } + } + + /// Exiting the equation through a real key in the same raw batch invalidates its preedit + /// before the following body deletion/commit, as does an external target change. + #[test] + fn ime_interrupted_equation_preedit_drops_queued_body_replacement() { + for in_batch in [false, true] { + let (ctx, mut a) = alt_frame_app(); + a.run("insert.equation", json!({"linear": "abc"})).unwrap(); + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None })]); + assert_eq!(a.canvas.ime_preedit, "に"); + let mut events = Vec::new(); + if in_batch { + events.push(egui::Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + } else { + a.run("equation.exit", json!({})).unwrap(); + } + let before = body(&a); + let depth = a.session.undo_depth(); + events.extend([surround(1, 0), egui::Event::Ime(egui::ImeEvent::Commit("stale".into()))]); + alt_frame(&ctx, &mut a, events); + assert!(a.session.math.is_none(), "the equation exit itself still takes effect"); + assert_eq!(body(&a), before, "the equation's queued replacement cannot edit the body"); + assert_eq!(a.session.undo_depth(), depth, "neither rejected event creates an Undo step"); + } + } + + /// Untagged commits are legitimate without an interrupted composition, after a fresh + /// preedit, after the one stale replacement, and after an empty intervening batch. + #[test] + fn ime_interrupted_preedit_does_not_suppress_fresh_input() { + for fresh_case in 0..4 { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "ABCDE"})).unwrap(); + if fresh_case != 0 { + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "old".into(), active_range_chars: None })]); + } + a.run("document.setText", json!({"text": "ABCDE"})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 2)); + let depth = a.session.undo_depth(); + let mut events = Vec::new(); + match fresh_case { + 1 => events.push(egui::Event::Ime(egui::ImeEvent::Preedit { text: "fresh".into(), active_range_chars: None })), + 2 => events.push(egui::Event::Ime(egui::ImeEvent::Commit("stale".into()))), + 3 => alt_frame(&ctx, &mut a, vec![]), + _ => {} + } + events.push(egui::Event::Ime(egui::ImeEvent::Commit("X".into()))); + alt_frame(&ctx, &mut a, events); + assert_eq!(body(&a), "ABXCDE", "fresh case {fresh_case} must remain eligible"); + assert_eq!(a.session.undo_depth(), depth + 1); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "ABCDE"); + } + } + /// The web's text agent keeps what it typed and, when its value changes other than by an /// addition at the end, sends `DeleteSurrounding` for the changed tail and then the new text /// (#1480). The document follows the agent's value instead of repeating it. @@ -1143,6 +2031,96 @@ mod tests { assert_eq!(body(&a), "риabм "); } + fn surround_bytes(before_bytes: usize, after_bytes: usize) -> egui::Event { + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) + } + + /// macOS and Wayland IME replacement counts UTF-8 bytes and must delete exactly that range. + /// A byte count into the middle of a character is refused rather than slicing the string. + #[test] + fn delete_surrounding_bytes_replaces_exact_utf8_and_joins_one_undo_step() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "aé👍b"})).unwrap(); + // The caret is before `b`, after the four-byte emoji. + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 7)); + alt_frame(&ctx, &mut a, vec![surround_bytes(4, 0), egui::Event::Text("x".into())]); + assert_eq!(body(&a), "aéxb"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "aé👍b", "delete plus commit is one undo step"); + + // A range that starts inside the emoji is not a UTF-8 boundary: nothing changes. + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 7)); + alt_frame(&ctx, &mut a, vec![surround_bytes(3, 0)]); + assert_eq!(body(&a), "aé👍b"); + // Beyond the paragraph is refused too, and never joins the next paragraph. + alt_frame(&ctx, &mut a, vec![surround_bytes(0, 99)]); + assert_eq!(body(&a), "aé👍b"); + let depth = a.session.undo_depth(); + for (before, after) in [(99, 0), (usize::MAX, 0), (0, usize::MAX)] { + alt_frame(&ctx, &mut a, vec![surround_bytes(before, after)]); + assert_eq!(body(&a), "aé👍b", "out-of-range counts must not erase a prefix"); + assert_eq!(a.session.undo_depth(), depth); + } + } + + /// A combining accent is its own UTF-8 range; replacing it does not erase the base letter. + /// `document.setText` leaves the caret at the start, so the test moves it past `e` + accent. + #[test] + fn delete_surrounding_bytes_replaces_a_combining_mark_and_accepts_empty_commit() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "e\u{301}"})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, "e\u{301}".len())); + alt_frame(&ctx, &mut a, vec![surround_bytes(2, 0), egui::Event::Text(String::new())]); + assert_eq!(body(&a), "e"); + // A deletion-only batch and a 0/0 batch stay harmless. + alt_frame(&ctx, &mut a, vec![surround_bytes(1, 0)]); + assert_eq!(body(&a), ""); + alt_frame(&ctx, &mut a, vec![surround_bytes(0, 0)]); + assert_eq!(body(&a), ""); + } + + /// The real macOS/Wayland path is `ImeEvent::Commit`, not `Event::Text`: the byte deletion + /// and the committed replacement are one edit, and Undo restores the original text. + #[test] + fn ime_commit_replaces_delete_surrounding_bytes_in_one_undo_step() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "e\u{301}"})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, "e\u{301}".len())); + alt_frame(&ctx, &mut a, vec![surround_bytes("e\u{301}".len(), 0), egui::Event::Ime(egui::ImeEvent::Commit("é".into()))]); + assert_eq!(body(&a), "é", "the combining sequence is replaced by the IME's committed text"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "e\u{301}", "delete plus Commit is one undo step"); + } + + /// A platform may deliver the `Commit` that replaces a `DeleteSurrounding` in a later raw + /// batch than the deletion. The undo join belongs to the deletion transaction, not to the + /// frame: the commit still shares its checkpoint, so one Undo restores the original input. + #[test] + fn ime_commit_in_a_later_frame_joins_the_delete_surrounding_undo_step() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "teh"})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 3)); + let depth = a.session.undo_depth(); + + alt_frame(&ctx, &mut a, vec![surround_bytes(3, 0)]); + assert_eq!(body(&a), ""); + assert_eq!(a.session.undo_depth(), depth + 1, "the deletion is one step"); + + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit("the".into()))]); + assert_eq!(body(&a), "the", "the later commit replaces the erased text"); + assert_eq!(a.session.undo_depth(), depth + 1, "the later commit joins the deletion's checkpoint"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "teh", "one Undo restores the original input"); + } + + /// An accepted IME commit can also be empty: it finishes composition without adding text. + #[test] + fn ime_empty_commit_finishes_without_inserting() { + let (ctx, mut a) = alt_frame_app(); + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit(String::new()))]); + assert_eq!(body(&a), ""); + } + /// `after_chars` erases after the caret; both counts are characters, not bytes. #[test] fn delete_surrounding_erases_characters_on_both_sides_of_the_caret() { @@ -1250,6 +2228,459 @@ mod tests { assert!(a.canvas.ime_reset, "AutoCorrect changed the text"); } + /// Both sides of one `DeleteSurrounding` are one request: when the second `text.backspace` + /// is refused (here by a content control locked against editing), the first half is rolled + /// back and no rewrite or receipt is promised. + #[test] + fn a_refused_second_delete_surrounding_rolls_the_request_back() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "ab"})).unwrap(); + // Wrap `b` in a rich-text control that refuses edits, leaving `a` editable. + a.session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos::body(0, 1), focus: wordcraft_doc::Pos::body(0, 2) }; + a.run("developer.richText", json!({"tag": "locked"})).unwrap(); + a.run("developer.properties", json!({"control": "locked", "lockEdit": true})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 1)); + let depth = a.session.undo_depth(); + let pending = wordcraft_doc::props::CharProps { bold: Some(true), ..Default::default() }; + a.session.pending = Some(pending.clone()); + a.session.goal_x = Some(123.0); + alt_frame(&ctx, &mut a, vec![surround(1, 1)]); + assert_eq!(body(&a), "ab", "the applied left side is rolled back with the refused right one"); + assert_eq!(a.session.undo_depth(), depth, "no half-erasure is left in Undo"); + assert!(a.canvas.ime_join_text.is_none(), "no text rewrite is promised"); + assert!(a.canvas.ime_interrupt_now, "the platform's stale surrounding snapshot is discarded"); + assert_eq!(a.session.sel.focus.off, 1, "the caret is back where the request found it"); + assert_eq!(a.session.pending, Some(pending), "pending formatting is restored"); + assert_eq!(a.session.goal_x, Some(123.0), "the Up/Down goal column is restored"); + } + + /// A successful body `DeleteSurrounding` followed in the same raw batch by a commit refused + /// by the locked content at the caret must leave no trace but the deletion: the replacement's + /// pending formatting is rolled back, the composition is interrupted, no undo step or + /// fabricated AutoFormat change is recorded, and the caret stays at the deletion. + /// + /// Forms mode makes the `za` control fillable and everything outside it protected. Deleting + /// its final `a` is allowed; the following commit starts at the locked `X` control's content + /// start, which typing moves beside the control (as Word does at a content-control edge) and + /// into protected text, where Restrict Editing refuses it. The deletion therefore stands and + /// the replacement does not. + #[test] + fn a_refused_ime_commit_after_a_successful_delete_restores_pending_and_interrupts() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "zaXY"})).unwrap(); + a.session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos::body(0, 0), focus: wordcraft_doc::Pos::body(0, 2) }; + a.run("developer.richText", json!({"tag": "fill"})).unwrap(); + a.run("select.text", json!({"text": "X"})).unwrap(); + a.run("developer.richText", json!({"tag": "locked"})).unwrap(); + a.run("developer.properties", json!({"control": "locked", "lockEdit": true})).unwrap(); + a.run("review.restrict", json!({"mode": "forms"})).unwrap(); + let locked = + a.session.doc.control_ranges(wordcraft_doc::StoryRef::Body).into_iter().find(|r| r.control.tag == "locked").expect("the locked control"); + a.session.sel = wordcraft_engine::Selection::caret(locked.content_start()); + a.session.pending = None; + a.canvas.ime_reset = false; + let _ = a.session.take_pending_ime_application_change(); + let depth = a.session.undo_depth(); + alt_frame(&ctx, &mut a, vec![surround(1, 0), egui::Event::Ime(egui::ImeEvent::Commit("\"".into()))]); + assert_eq!(body(&a), "zXY", "the locked replacement is refused, the earlier deletion stands"); + assert_eq!(a.session.undo_depth(), depth + 1, "only the successful deletion added an undo step"); + let locked = a + .session + .doc + .control_ranges(wordcraft_doc::StoryRef::Body) + .into_iter() + .find(|r| r.control.tag == "locked") + .expect("the locked control remains"); + assert_eq!(a.session.sel.focus, locked.content_start(), "the caret stays where the deletion left it"); + assert_eq!(a.session.sel.anchor, locked.content_start()); + assert_eq!(a.session.pending, None, "the refused replacement leaves no pending formatting from the erased text"); + assert!(a.canvas.ime_join_text.is_none(), "the refused replacement promises no rewrite"); + assert!(a.canvas.ime_interrupt_now, "the platform's stale surrounding snapshot is interrupted"); + assert!(!a.canvas.ime_reset, "a refused commit is not an AutoFormat change"); + assert!(!a.session.take_pending_ime_application_change(), "no application-origin receipt is fabricated"); + } + + /// Track Changes can remove the author's own insertion from the stored text. The right-hand + /// range and the final caret then move left by what was actually removed: the second side + /// must erase the character the platform named, not the one that took its stored offset. + #[test] + fn tracked_delete_surrounding_shifts_past_its_own_insertion() { + let (ctx, mut a) = alt_frame_app(); + a.run("review.trackChanges", json!({"value": true})).unwrap(); + a.run("text.insert", json!({"text": "abXY"})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 2)); + alt_frame(&ctx, &mut a, vec![surround(1, 1)]); + assert_eq!(body(&a), "aY", "the own insertion `b` and the following `X` are erased"); + assert_eq!(a.session.sel.focus.off, 1, "the caret points after the stored deletion"); + assert_eq!(a.session.sel.anchor.off, 1); + } + + /// Track Changes plus a refusal: the first side really removes this author's own insertion + /// from the stored paragraph, then a locked control refuses the second side. The whole + /// request is rolled back, including the shifted caret and undo stack. + #[test] + fn tracked_own_insertion_then_a_refused_side_rolls_the_whole_request_back() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "aXY"})).unwrap(); + // Lock the following `X`; inserting `b` before it under Track Changes is this author's own + // insertion, which a tracked delete removes from the stored text instead of marking. + a.session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos::body(0, 1), focus: wordcraft_doc::Pos::body(0, 2) }; + a.run("developer.richText", json!({"tag": "locked"})).unwrap(); + a.run("developer.properties", json!({"control": "locked", "lockEdit": true})).unwrap(); + a.run("review.trackChanges", json!({"value": true})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 1)); + a.run("text.insert", json!({"text": "b"})).unwrap(); + assert_eq!(body(&a), "abXY"); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 2)); + let depth = a.session.undo_depth(); + let revisions = a.session.doc.revisions.len(); + let pending = wordcraft_doc::props::CharProps { bold: Some(true), ..Default::default() }; + a.session.pending = Some(pending.clone()); + a.session.goal_x = Some(123.0); + alt_frame(&ctx, &mut a, vec![surround(1, 1)]); + assert_eq!(body(&a), "abXY", "the first side's own-insertion deletion is rolled back with the refused second side"); + assert_eq!(a.session.undo_depth(), depth, "the half-erasure leaves no undo step"); + assert_eq!(a.session.doc.revisions.len(), revisions, "revision metadata is restored too"); + assert!(a.canvas.ime_join_text.is_none(), "no rewrite is promised"); + assert!(a.canvas.ime_interrupt_now, "the platform's stale surrounding snapshot is discarded"); + assert_eq!(a.session.sel.focus.off, 2, "the caret is back where the request found it"); + assert_eq!(a.session.pending, Some(pending), "pending formatting is restored"); + assert_eq!(a.session.goal_x, Some(123.0), "the Up/Down goal column is restored"); + } + + fn wrap_in_control(a: &mut WordApp, start: usize, end: usize, tag: &str, placeholder: &str) { + a.session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos::body(0, start), focus: wordcraft_doc::Pos::body(0, end) }; + a.run("developer.richText", json!({"tag": tag})).unwrap(); + a.run("developer.properties", json!({"control": tag, "placeholder": placeholder})).unwrap(); + } + + fn control(a: &WordApp, tag: &str) -> wordcraft_doc::ControlRange { + a.session + .doc + .control_ranges(wordcraft_doc::StoryRef::Body) + .into_iter() + .find(|r| r.control.tag == tag) + .unwrap_or_else(|| panic!("the {tag} control remains")) + } + + /// Emptying a control restores a placeholder longer than the erased text. The right-hand + /// range and the caret must move by the *signed* length change: the `X` after the control is + /// erased (not a placeholder character), the caret lands at the placeholder's end, and the + /// commit replaces the placeholder in the same undo step as the deletion. + #[test] + fn delete_surrounding_maps_after_text_and_caret_past_a_growing_placeholder() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "Name: AdaX"})).unwrap(); + wrap_in_control(&mut a, 6, 9, "name", "Enter the full name"); + let r = control(&a, "name"); + let placeholder = r.control.placeholder_shown().to_string(); + assert!(placeholder.len() > "Ada".len(), "the fixture grows the paragraph: {placeholder:?}"); + a.session.sel = wordcraft_engine::Selection::caret(r.end.clone()); + let depth = a.session.undo_depth(); + + alt_frame(&ctx, &mut a, vec![surround(3, 1)]); + assert_eq!(body(&a), format!("Name: {placeholder}"), "the following `X` is erased, not a placeholder character"); + let end = control(&a, "name").content_start().off + placeholder.len(); + assert_eq!((a.session.sel.anchor.off, a.session.sel.focus.off), (end, end), "the caret is at the placeholder's end, never inside it"); + assert_eq!(a.session.undo_depth(), depth + 1, "both erased sides are one undo step"); + + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit("N".into()))]); + assert_eq!(body(&a), "Name: N", "the commit replaces the restored placeholder"); + assert_eq!(a.session.undo_depth(), depth + 1, "the replacement joins the deletion"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "Name: AdaX", "one Undo restores both erased sides and the replacement"); + } + + /// A placeholder shorter than the erased text also has to map the suffix and caret by the + /// signed delta; subtracting a removed-byte count alone describes only this direction. + #[test] + fn delete_surrounding_maps_a_shrinking_placeholder_and_after_text() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "Name: AdaX"})).unwrap(); + wrap_in_control(&mut a, 6, 9, "name", "N"); + let r = control(&a, "name"); + a.session.sel = wordcraft_engine::Selection::caret(r.end.clone()); + let depth = a.session.undo_depth(); + + alt_frame(&ctx, &mut a, vec![surround(3, 1)]); + assert_eq!(body(&a), "Name: N", "the shorter placeholder replaces `Ada`, and `X` still goes"); + let end = control(&a, "name").content_start().off + 1; + assert_eq!((a.session.sel.anchor.off, a.session.sel.focus.off), (end, end)); + assert_eq!(a.session.undo_depth(), depth + 1); + + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit("Z".into()))]); + assert_eq!(body(&a), "Name: Z"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "Name: AdaX", "one Undo restores the original value and the following text"); + } + + /// The after side alone can empty a control and restore a longer placeholder. Its caret + /// must map to the restored placeholder's end, not to the old pre-deletion offset, and the + /// text beyond the after-side range stays put. + #[test] + fn delete_surrounding_maps_a_placeholder_restored_by_the_after_side() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "aXY"})).unwrap(); + wrap_in_control(&mut a, 1, 2, "name", "Enter the value"); + let r = control(&a, "name"); + let placeholder = r.control.placeholder_shown().to_string(); + assert!(placeholder.len() > 1); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 1)); + let depth = a.session.undo_depth(); + + alt_frame(&ctx, &mut a, vec![surround(0, 1)]); + assert_eq!(body(&a), format!("a{placeholder}Y"), "only the one after-side `X` is erased; `Y` is beyond the range"); + let end = control(&a, "name").content_start().off + placeholder.len(); + assert_eq!((a.session.sel.anchor.off, a.session.sel.focus.off), (end, end)); + assert_eq!(a.session.undo_depth(), depth + 1); + + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit("Q".into()))]); + assert_eq!(body(&a), "aQY", "the commit replaces the placeholder and leaves the following `Y`"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "aXY", "one Undo restores the emptied control, the following text and the commit"); + } + + /// A refused body request clears the locally painted pre-edit and every replacement join. + /// The earlier successful deletion stays, but the following commit gets its own Undo step. + #[test] + fn a_refused_body_delete_clears_preedit_and_joins() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "ab"})).unwrap(); + wrap_in_control(&mut a, 1, 2, "locked", "b"); + a.run("developer.properties", json!({"control": "locked", "lockEdit": true})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 1)); + let depth = a.session.undo_depth(); + + alt_frame( + &ctx, + &mut a, + vec![surround(1, 0), egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: None }), surround(0, 1)], + ); + assert_eq!(body(&a), "b", "the editable `a` is erased; the locked control's `b` is not"); + assert!(a.canvas.ime_preedit.is_empty(), "a refused request must not leave its pre-edit painted"); + assert_eq!(a.canvas.ime_preedit_range, None); + assert!(a.canvas.ime_join_text.is_none(), "no replacement formatting is promised"); + assert!(a.canvas.ime_interrupt_now, "the platform's stale surroundings are interrupted"); + assert_eq!(a.session.undo_depth(), depth + 1, "only the accepted first deletion has a step"); + + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit("X".into()))]); + assert_eq!(body(&a), "Xb"); + assert_eq!(a.session.undo_depth(), depth + 2, "the cleared join gives the commit its own checkpoint"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "b", "Undo takes back only the commit, not the earlier deletion"); + } + + /// A body deletion promises its join only to the immediate matching replacement. An ordinary + /// mutating command in between consumes the promise and keeps its own Undo checkpoint. + #[test] + fn body_delete_join_does_not_leak_to_an_intervening_command() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "abcd"})).unwrap(); + a.session.sel = wordcraft_engine::Selection::caret(wordcraft_doc::Pos::body(0, 4)); + let base = a.session.undo_depth(); + + alt_frame(&ctx, &mut a, vec![surround(1, 0)]); + assert_eq!(body(&a), "abc"); + assert_eq!(a.session.undo_depth(), base + 1, "the deletion is its own step"); + + a.run("format.bold", json!({})).unwrap(); + assert_eq!(body(&a), "abc", "the intervening command changes no text"); + assert_eq!(a.session.undo_depth(), base + 2, "an intervening ordinary command owns its checkpoint"); + + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit("X".into()))]); + assert_eq!(body(&a), "abcX", "the replacement still applies"); + assert_eq!(a.session.undo_depth(), base + 3, "the replacement no longer joins across the ordinary command"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "abc", "Undo takes back only the commit, not the deletion or the Bold step"); + } + + /// A cross-block selection has no single surrounding snapshot. Refusing the request must + /// also interrupt the composition instead of leaving the platform's hidden text stale. + #[test] + fn cross_block_delete_surrounding_is_refused_and_interrupts() { + let (ctx, mut a) = alt_frame_app(); + a.run("document.setText", json!({"text": "one\ntwo"})).unwrap(); + a.session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos::body(0, 1), focus: wordcraft_doc::Pos::body(1, 1) }; + alt_frame(&ctx, &mut a, vec![surround(1, 0)]); + assert_eq!(body(&a), "one\ntwo", "nothing is deleted across blocks"); + assert!(a.canvas.ime_interrupt_now, "the refused request interrupts the composition"); + } + + /// An IME replacement inside an equation erases the counted characters with the equation's + /// commands; a byte count (which has no mapping into the tree) deletes nothing. A refusal + /// drops only the replacement immediately paired with it: the next `Commit`, or an adjacent + /// `Text` + `Commit`. A later key, pre-edit or direct commit in the same raw batch is fresh + /// input and applies; so does one in a later frame. + #[test] + fn ime_equation_byte_delete_drops_only_its_immediate_replacement() { + let (ctx, mut a) = alt_frame_app(); + a.run("insert.equation", json!({"linear": "x+y"})).unwrap(); + let linear = |a: &mut WordApp| a.run("equation.get", json!({})).unwrap().get("linear").and_then(|v| v.as_str()).unwrap_or("").to_string(); + assert_eq!(linear(&mut a), "x+y"); + alt_frame(&ctx, &mut a, vec![surround(1, 0)]); + assert_eq!(linear(&mut a), "x+", "the character before the caret in the equation is erased"); + let base = linear(&mut a); + + // A direct commit in a later frame is fresh input, not the stale replacement. + a.canvas.ime_interrupt_now = false; + alt_frame(&ctx, &mut a, vec![surround_bytes(1, 0)]); + assert_eq!(linear(&mut a), base, "a byte count is not guessed into equation characters"); + assert!(a.canvas.ime_interrupt_now, "the refused byte deletion interrupts the composition"); + a.canvas.ime_interrupt_now = false; + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit("D".into()))]); + assert_eq!(linear(&mut a), format!("{base}D"), "a later direct commit applies without a pre-edit"); + + // The immediately following commit belongs to the refused replacement and is dropped; + // the next commit in the same raw batch is fresh input and applies. + let after_pair = linear(&mut a); + a.canvas.ime_interrupt_now = false; + alt_frame( + &ctx, + &mut a, + vec![surround_bytes(1, 0), egui::Event::Ime(egui::ImeEvent::Commit("Z".into())), egui::Event::Ime(egui::ImeEvent::Commit("Y".into()))], + ); + assert_eq!(linear(&mut a), format!("{after_pair}Y"), "only the adjacent stale commit is dropped"); + let after_commit = linear(&mut a); + + // A platform that routes the replacement through `Text` before its `Commit` sends the + // adjacent pair; both belong to the refused deletion, and a later commit still applies. + a.canvas.ime_interrupt_now = false; + alt_frame( + &ctx, + &mut a, + vec![ + surround_bytes(1, 0), + egui::Event::Text("Q".into()), + egui::Event::Ime(egui::ImeEvent::Commit("Q2".into())), + egui::Event::Ime(egui::ImeEvent::Commit("R".into())), + ], + ); + assert_eq!(linear(&mut a), format!("{after_commit}R"), "the adjacent Text+Commit pair is stale, the next commit is not"); + + // Any intervening event ends the pair: a key then its Text is ordinary fresh input. + let after_commit = linear(&mut a); + a.canvas.ime_interrupt_now = false; + let key = egui::Event::Key { key: Key::F20, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + alt_frame(&ctx, &mut a, vec![surround_bytes(1, 0), key, egui::Event::Text("K".into())]); + assert_eq!(linear(&mut a), format!("{after_commit}K"), "a key between the deletion and text clears the suppression"); + + // A pre-edit likewise ends the stale pair; its commit is fresh. + let after_commit = linear(&mut a); + a.canvas.ime_interrupt_now = false; + alt_frame( + &ctx, + &mut a, + vec![ + surround_bytes(1, 0), + egui::Event::Ime(egui::ImeEvent::Preedit { text: "p".into(), active_range_chars: None }), + egui::Event::Ime(egui::ImeEvent::Commit("P".into())), + ], + ); + assert_eq!(linear(&mut a), format!("{after_commit}P"), "a fresh composition in the same batch is accepted"); + } + + /// An over-cap equation deletion is still a new surrounding request: it discards the + /// composition that was painted for the snapshot it refuses, and interrupts it. + #[test] + fn over_cap_equation_delete_clears_the_painted_preedit() { + let (ctx, mut a) = alt_frame_app(); + a.run("insert.equation", json!({"linear": "abc"})).unwrap(); + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "p".into(), active_range_chars: None })]); + assert_eq!(a.canvas.ime_preedit, "p"); + a.canvas.ime_interrupt_now = false; + alt_frame(&ctx, &mut a, vec![surround(wordcraft_engine::MAX_EQUATION_DELETE_UNITS + 1, 0)]); + assert_eq!(body(&a), "abc", "the over-cap request deletes nothing"); + assert!(a.canvas.ime_preedit.is_empty(), "the refused request's pre-edit is not left painted"); + assert_eq!(a.canvas.ime_preedit_range, None); + assert!(a.canvas.ime_interrupt_now, "the platform's stale snapshot is interrupted"); + } + + /// An over-cap equation deletion also clears a previous equation deletion's promised join, + /// so the next commit cannot silently merge into that earlier undo step. + #[test] + fn over_cap_equation_delete_clears_the_previous_replacement_join() { + let (ctx, mut a) = alt_frame_app(); + a.run("insert.equation", json!({"linear": "abcd"})).unwrap(); + alt_frame(&ctx, &mut a, vec![surround(1, 0), surround(wordcraft_engine::MAX_EQUATION_DELETE_UNITS + 1, 0)]); + assert_eq!(body(&a), "abc", "the first, valid deletion stands"); + assert!(a.canvas.ime_interrupt_now, "the over-cap request interrupts the composition"); + let depth = a.session.undo_depth(); + a.canvas.ime_interrupt_now = false; + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit("X".into()))]); + assert_eq!(body(&a), "abcX"); + assert_eq!(a.session.undo_depth(), depth + 1, "the cleared join gives the commit its own undo step"); + } + + /// The transactional equation delete and the following commit are one undo step, like the + /// body's `DeleteSurrounding` plus its replacement text. + #[test] + fn ime_equation_delete_and_commit_are_one_undo_step() { + let (ctx, mut a) = alt_frame_app(); + a.run("insert.equation", json!({"linear": "abcd"})).unwrap(); + alt_frame(&ctx, &mut a, vec![surround(1, 0), egui::Event::Ime(egui::ImeEvent::Commit("X".into()))]); + assert_eq!(body(&a), "abcX"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "abcd", "one Undo restores the deleted unit and the commit together"); + } + + /// An equation deletion promises a join only to its immediate replacement. An unrelated + /// command in between consumes the promise without joining, so it gets its own undo step + /// instead of being merged into the deletion. + #[test] + fn ime_equation_delete_join_does_not_leak_to_an_unrelated_command() { + let (ctx, mut a) = alt_frame_app(); + a.run("insert.equation", json!({"linear": "abcd"})).unwrap(); + let base = a.session.undo_depth(); + alt_frame(&ctx, &mut a, vec![surround(1, 0)]); + assert_eq!(body(&a), "abc"); + assert_eq!(a.session.undo_depth(), base + 1, "the deletion is its own undo step"); + + a.run("equation.justify", json!({"jc": "right"})).unwrap(); + assert_eq!(a.session.undo_depth(), base + 2, "an unrelated command keeps its own checkpoint"); + + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit("X".into()))]); + assert_eq!(body(&a), "abcX"); + assert_eq!(a.session.undo_depth(), base + 3, "the replacement no longer joins across the unrelated command"); + + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(body(&a), "abc", "Undo takes back only the commit, not the deletion and the unrelated command"); + } + + /// Hostile pre-edit and commit sizes are refused before they reach the canvas or the model. + #[test] + fn oversized_ime_events_are_ignored() { + let (ctx, mut a) = alt_frame_app(); + let huge = "a".repeat(MAX_IME_PREEDIT_BYTES + 1); + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: huge, active_range_chars: Some(0..1) })]); + assert!(a.canvas.ime_preedit.is_empty(), "an oversized pre-edit is not painted"); + assert_eq!(a.canvas.ime_preedit_range, None); + let huge = "b".repeat(MAX_IME_COMMIT_BYTES + 1); + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit(huge))]); + assert_eq!(body(&a), "", "an oversized commit is not inserted"); + // The equation cap is lower than the byte cap: an over-cap commit is refused before the + // command, with no prefix committed and the composition interrupted. + a.run("insert.equation", json!({})).unwrap(); + a.canvas.ime_interrupt_now = false; + let long = "c".repeat(wordcraft_engine::MAX_EQUATION_TYPE_CHARS + 1); + alt_frame(&ctx, &mut a, vec![egui::Event::Ime(egui::ImeEvent::Commit(long))]); + assert_eq!(body(&a), "", "no equation prefix is committed"); + assert!(a.canvas.ime_interrupt_now, "the refused commit starts the composition afresh"); + } + + /// A pre-edit belongs to the editor that had focus: losing it drops the painted composition. + #[test] + fn observe_ime_drops_a_preedit_without_focus() { + let ctx = egui::Context::default(); + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + a.canvas.ime_preedit = "に".into(); + a.canvas.ime_preedit_range = Some(0..1); + a.canvas.focused = false; + let r = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(10.0, 10.0)); + observe_ime(&mut a, &ctx, Some((r, r)), false); + assert!(a.canvas.ime_preedit.is_empty(), "no composition is kept for an unfocused editor"); + assert_eq!(a.canvas.ime_preedit_range, None); + } + #[test] fn typing_with_smart_art_selected_types_into_an_item() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index ed61700bb2..b1e18ac6a1 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -9,16 +9,16 @@ use crate::theme::{Tokens, semibold}; /// egui ids the ribbon publishes its keytip state under (accessors, since `Id::new` isn't const). pub fn phase_id() -> egui::Id { - egui::Id::new("wc_keytips_phase") + egui::Id::unique("wc_keytips_phase") } pub fn tab_id() -> egui::Id { - egui::Id::new("wc_keytips_tab") + egui::Id::unique("wc_keytips_tab") } pub fn letters_id() -> egui::Id { - egui::Id::new("wc_keytips_letters") + egui::Id::unique("wc_keytips_letters") } pub fn rects_id() -> egui::Id { - egui::Id::new("wc_keytips_rects") + egui::Id::unique("wc_keytips_rects") } /// A bare Alt press (either Alt key) arms or cancels keytips. @@ -863,7 +863,7 @@ fn legacy_hint(app: &WordApp, ui: &Ui, tabs: Option) { let font = egui::FontId::proportional(12.0); let galley = ui.ctx().fonts_mut(|f| f.layout_no_wrap(text, font, t.text)); let r = Rect::from_min_size(tabs.left_bottom() + Vec2::new(4.0, 2.0), galley.size() + Vec2::new(16.0, 10.0)); - let p = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("wc_legacy_keys"))); + let p = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("wc_legacy_keys"))); p.rect_filled(r, CornerRadius::same(4), t.menu); p.rect_stroke(r, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); p.galley(r.min + Vec2::new(8.0, 5.0), galley, t.text); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59fb5b892d..ce00a5325a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -555,10 +555,21 @@ pub struct WordApp { /// Focus (#1678): the ribbon is dropped down from the Focus bar, over the page. pub(crate) focus_ribbon: bool, control_rx: Option>, - shot_token: u64, - queued_shots: Vec<(u64, f64, u32)>, - pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, + /// Admitted `ui.screenshot` requests: count/byte budgets, retries and completion slots. + shots: control::Shots, + /// Monotonic geometry epoch; any size/DPI/zoom/surface/document change increments it, + /// including a change back to an earlier value. + capture_epoch: u64, + /// The surface reported by the last `RawInput` hook, without a document identity. + surface_geometry: Option, + /// The geometry `capture_epoch` describes; `None` before the first logic pass. + capture_geometry: Option, + /// Synthetic input not consumed by a real UI pass yet. pub(crate) synthetic: Vec, + /// The batch offered to eframe is already in its pending RawInput; do not append it again. + synthetic_pending: bool, + /// How many events of `synthetic` the pending batch contains. + synthetic_injected: usize, /// macOS: the primary button went down with Control, so it is being read as the secondary. control_click: bool, /// Badge rects recorded during layout for the current keytip phase (cleared by `logic`, painted by `show`). @@ -633,6 +644,9 @@ pub struct WordApp { pub zotero: zotero::ZoteroLink, /// The egui context, once the first frame has run (background work wakes the UI with it). pub(crate) ctx: Option, + /// Whether the current command was entered by an input method's own transaction. The engine + /// uses it to keep IME commits from setting the application-change signal. + pub(crate) ime_origin: bool, /// Read Aloud: the start of the sentence the caret was last moved to, and the last error shown. pub(crate) read_aloud_at: Option, pub(crate) read_aloud_error: Option, @@ -705,10 +719,13 @@ impl WordApp { mac_titlebar: chrome::MacTitleBar::default(), focus_ribbon: false, control_rx: None, - shot_token: 0, - queued_shots: Vec::new(), - pending_shots: Vec::new(), + shots: control::Shots::default(), + capture_epoch: 0, + surface_geometry: None, + capture_geometry: None, synthetic: Vec::new(), + synthetic_pending: false, + synthetic_injected: 0, control_click: false, keytip_rects: Vec::new(), nav_search_focus: false, @@ -747,6 +764,7 @@ impl WordApp { outline_prev_tab: None, zotero: zotero::ZoteroLink::default(), ctx: None, + ime_origin: false, read_aloud_at: None, read_aloud_error: None, autosave_path: None, @@ -887,6 +905,17 @@ impl WordApp { r } + /// Run a command as the input method's own transaction while preserving every normal + /// [`Self::run`] side effect (save prompts, clipboard, body-epoch tracking, status, …). + /// + /// The origin is restored on every return, including errors and nested commands. + pub(crate) fn run_ime(&mut self, id: &str, params: Value) -> Result { + let previous = std::mem::replace(&mut self.ime_origin, true); + let result = self.run(id, params); + self.ime_origin = previous; + result + } + /// Run a command the user asked for (ribbon, shortcut, Backstage, file drop). New, Open, /// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask /// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`). @@ -1056,8 +1085,35 @@ impl WordApp { return Ok(json!({"downloaded": name, "bytes": bytes.len()})); } let document = self.session.document_id(); + let body_epoch = self.session.ime_body_epoch(); + let math_session = self.session.math.as_ref().map(|m| m.session); + let composing_before = !self.canvas.ime_preedit.is_empty(); let old_path = self.session.path.clone(); - let r = self.session.run(id, ¶ms).map_err(|e| e.to_string()); + let r = if self.ime_origin { + self.session.run_ime(id, ¶ms).map_err(|e| e.to_string()) + } else { + self.session.run(id, ¶ms).map_err(|e| e.to_string()) + }; + if self.session.ime_body_epoch() != body_epoch { + self.canvas.ime_interrupted_preedit |= composing_before; + // The body (or the whole document) was replaced under the same id — setText, a + // version restore, compare, Undo/Redo. A composition belongs to the old content: + // drop its pre-edit and ask the platform to interrupt it, even when the new bounded + // window happens to be identical. + self.canvas.ime_preedit.clear(); + self.canvas.ime_preedit_range = None; + self.canvas.ime_window_cache = None; + self.canvas.ime_interrupt_now = true; + } + if self.session.math.as_ref().map(|m| m.session) != math_session { + self.canvas.ime_interrupted_preedit |= composing_before; + // Entering, leaving or replacing the edited equation changes the IME target's + // replacement epoch under the same document id. The old composition belongs to a + // tree this session no longer edits. + self.canvas.ime_preedit.clear(); + self.canvas.ime_preedit_range = None; + self.canvas.ime_interrupt_now = true; + } // Copy and Cut put what they took on the system clipboard, whichever control ran them // (the ribbon, a context menu, the native menu, or the Cmd+C/X event). Only the key event // used to write it, so a Cut button deleted the words without copying them. @@ -2156,17 +2212,6 @@ impl WordApp { } options::sync(self); self.settle_async_saves(); - // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as Word's - // pane does. Consuming the key stops Tab from moving the caret to the next row, and so does - // resetting the focus direction: the item keeps focus after its level changes. - if self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { - let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); - let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); - if shift_tab || tab { - ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); - let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); - } - } // Chinese text wants the Chinese face before the Japanese one (one glyph style per line). // An installed CJK font is read only once CJK text is shown (#241): a CJK interface, the // language names in Options, or a file name the caption is drawing. A file opened later @@ -2223,6 +2268,15 @@ impl WordApp { if self.ctx.is_none() { self.ctx = Some(ctx.clone()); } + // Capture geometry/document identity is current before control admission and completion. + // The epoch is monotonic: resizing back to an earlier size is still a new epoch. + let capture_geometry = self.surface_geometry.map(|surface| surface.with_document(self.session.document_id())); + if let Some(geometry) = capture_geometry + && self.capture_geometry != Some(geometry) + { + self.capture_geometry = Some(geometry); + self.capture_epoch = self.capture_epoch.wrapping_add(1); + } self.poll_printing(ctx); self.drain_control(ctx); zotero::poll(self, ctx); @@ -2254,45 +2308,60 @@ impl WordApp { if self.autosaves() && self.session.dirty { ctx.request_repaint_after(std::time::Duration::from_millis(1000)); } - self.collect_screenshots(ctx); - self.issue_screenshots(ctx); - if !self.synthetic.is_empty() { + // Screenshot deadlines, geometry and completions belong to `logic`; issuing the one + // callback command belongs to `ui` (below). A queued/settling capture needs a real UI + // pass, and so does an injected synthetic step, even while the window is hidden. + control::drain_screenshots(self, ctx); + if self.shots.in_flight() || self.synthetic_pending || !self.synthetic.is_empty() { + if ctx.input(|i| i.viewport().visible()) != Some(true) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } ctx.request_repaint(); } - #[cfg(not(target_arch = "wasm32"))] - let mut placed_picture = false; - #[cfg(not(target_arch = "wasm32"))] - for f in ctx.input(|i| i.raw.dropped_files.clone()) { - { - let path = f.path().to_string_lossy().to_string(); - let lp = path.to_ascii_lowercase(); - let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lp.ends_with(e)); - let sheet = wordcraft_engine::cmd::mailings::is_spreadsheet_name(&lp); - let _ = if img { - self.caret_after_placed_picture(placed_picture); - let r = self.run("insert.picture", json!({"path": path})); - placed_picture = Self::placed_in_text(&r); - r - } else if sheet { - // A spreadsheet isn't a document: it's a recipient list (#334). - self.load_recipients(json!({"path": path})) - } else if wordcraft_fonts::is_font_file_name(&lp) { - self.run("font.add", json!({"path": path})) - } else { - self.run("file.open", json!({"path": path})) - }; - } - } - // The open above may have changed the file name the caption draws. - self.ensure_caption_fonts(); - self.ensure_comment_fonts(); - self.ensure_recover_fonts(); - self.ensure_recent_fonts(); - self.ensure_find_fonts(); - self.install_ui_fonts(ctx, lang); self.report_dirty(); } + /// Files dropped on the native window. Raw input is taken only in the first UI pass: a + /// hidden `run_logic` tick leaves the previous shown frame's events stale, and replaying a + /// drop would open or insert it again. + #[cfg(not(target_arch = "wasm32"))] + fn open_dropped_files(&mut self, files: Vec) { + let before = self.session.document_id(); + let mut placed_picture = false; + for f in &files { + let path = f.path().to_string_lossy().to_string(); + let lp = path.to_ascii_lowercase(); + let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lp.ends_with(e)); + let sheet = wordcraft_engine::cmd::mailings::is_spreadsheet_name(&lp); + let _ = if img { + self.caret_after_placed_picture(placed_picture); + let r = self.run("insert.picture", json!({"path": path})); + placed_picture = Self::placed_in_text(&r); + r + } else if sheet { + // A spreadsheet isn't a document: it's a recipient list (#334). + self.load_recipients(json!({"path": path})) + } else if wordcraft_fonts::is_font_file_name(&lp) { + self.run("font.add", json!({"path": path})) + } else { + self.run("file.open", json!({"path": path})) + }; + } + if self.session.document_id() != before { + self.autosave_path = None; + self.dialog = None; + self.ui.backstage = false; + self.canvas.want_focus = true; + } + if !files.is_empty() { + self.ensure_caption_fonts(); + self.ensure_comment_fonts(); + self.ensure_recover_fonts(); + self.ensure_recent_fonts(); + self.ensure_find_fonts(); + } + } + /// Install the interface fonts when the language, the CJK fallback, or a caption fallback /// face has changed since the last install. fn install_ui_fonts(&mut self, ctx: &egui::Context, lang: i18n::Lang) { @@ -2376,13 +2445,26 @@ impl WordApp { } } - /// Read a Control-click as the right-click on macOS, then inject synthetic input events from - /// the control channel (one pointer event per frame). - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + /// Read a Control-click as the right-click on macOS, then offer at most one synthetic gesture + /// step to the host. eframe carries an unconsumed RawInput into the next pass and into hidden + /// logic ticks, so offering it again would duplicate input. The step stays queued until a + /// final (not discarded) UI pass acknowledges it, and a press/release pair stays intact. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // Capture the surface from the raw input itself. `InputState::viewport_rect` and the + // last painted screen rect are stale in a hidden `run_logic` tick; RawInput is the host's + // current answer, including minimized/occluded and a Wayland `inner_rect: None`. + if let Some(geometry) = control::SurfaceGeometry::from_raw(raw, ctx) { + // A hidden resize and change back still invalidate an older capture, even if + // neither intermediate geometry reaches a drawing or logic pass. + if self.surface_geometry != Some(geometry) { + self.capture_epoch = self.capture_epoch.wrapping_add(1); + } + self.surface_geometry = Some(geometry); + } if cfg!(target_os = "macos") { control_click_as_secondary(&mut raw.events, &mut self.control_click); } - if self.synthetic.is_empty() { + if self.synthetic_pending || self.synthetic.is_empty() || self.fonts_frames < 2 { return; } let n = match self.synthetic.first() { @@ -2402,7 +2484,9 @@ impl WordApp { self.canvas.focused = ctx.memory(|m| m.has_focus(id)); } } - raw.events.extend(self.synthetic.drain(..n)); + raw.events.extend(self.synthetic.iter().take(n).cloned()); + self.synthetic_pending = true; + self.synthetic_injected = n; } /// Lay out the window. @@ -2415,6 +2499,30 @@ impl WordApp { let opts = ctx.options(|o| o.input_options); ctx.input_mut(|i| i.smooth_scroll_delta = scroll::unsmoothed_scroll(i, &opts)); } + // Raw input is read in the known UI phase, first pass only. `logic` also runs hidden, + // where its input events are from the previous shown frame. Handle it before the font + // warm-up return: eframe does not preserve dropped files for a later frame, so a drop in + // the first two passes would otherwise disappear. + #[cfg(not(target_arch = "wasm32"))] + if ctx.current_pass_index() == 0 { + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + if !dropped.is_empty() { + self.open_dropped_files(dropped); + let lang = i18n::Lang::from_pref(&self.ui.language); + self.install_ui_fonts(&ctx, lang); + } + } + // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as + // Word's pane does. This is an input action, so it runs in the first UI pass only; hidden + // `run_logic` ticks still see the previous shown frame's keys. + if ctx.current_pass_index() == 0 && self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { + let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); + let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); + if shift_tab || tab { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); + } + } // Fonts installed by `logic` take effect on the next frame. if self.fonts_frames < 2 { self.fonts_frames += 1; @@ -2485,6 +2593,23 @@ impl WordApp { self.frame_ms = now_ms() - t0; // Typing and formatting land here, after `logic` has reported. self.report_dirty(); + // One callback command per admitted request. This is in `ui` so the captured frame is + // the layout just drawn; a discarded pass is not a layout anyone will see. The shot's + // `issued` flag makes repeated final passes harmless. + if !ctx.will_discard() { + control::issue_screenshots(self, &ctx); + } + // The raw input was consumed by this host frame's passes; only a final UI pass proves + // the step is done. A discarded pass keeps the queue for the next UI frame. + if self.synthetic_pending && !ctx.will_discard() { + let n = self.synthetic_injected.min(self.synthetic.len()); + self.synthetic.drain(..n); + self.synthetic_pending = false; + self.synthetic_injected = 0; + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } + } } /// Carry View › Full Screen (`view.fullScreen`, F11), Focus and Full Screen View (#1678) to @@ -2534,7 +2659,7 @@ impl WordApp { } let Some(pos) = ctx.input(|i| if i.pointer.any_click() { i.pointer.interact_pos() } else { None }) else { return }; // The ribbon dropped over the page in Focus (#1678) is chrome, though it lies over the page. - let on_focus_ribbon = self.focus_ribbon && ctx.layer_id_at(pos).is_some_and(|l| l.id == egui::Id::new("focus_ribbon")); + let on_focus_ribbon = self.focus_ribbon && ctx.layer_id_at(pos).is_some_and(|l| l.id == egui::Id::unique("focus_ribbon")); // The page handles its own clicks (comment balloons there take the keyboard themselves). if !on_focus_ribbon && (self.canvas.balloon.is_some() || self.canvas.viewport.is_some_and(|v| v.contains(pos))) { return; @@ -2619,65 +2744,11 @@ impl WordApp { 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)); - } + control::Outcome::Screenshot { path } => control::admit_screenshot(self, ctx, path, reply), } } self.control_rx = Some(rx); } - - fn issue_screenshots(&mut self, ctx: &egui::Context) { - let now = now_ms(); - 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))); - false - } else { - true - } - }); - if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } - } - - 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(&image, path.as_deref())); - } - } - let now = now_ms(); - self.pending_shots.retain(|(_, _, reply, deadline)| { - if now < *deadline { - return true; - } - let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden?); use ui.render"})); - false - }); - } } /// Why AutoSave can't cover the document ([`WordApp::autosave_block`]); the switch is greyed out @@ -2906,19 +2977,13 @@ pub(crate) fn discards_document(id: &str, params: &Value) -> bool { } } -/// Wall-clock milliseconds since the Unix epoch: the system clock, or the browser's on the web. +/// Monotonic milliseconds for deadlines, settling and animation. `web_time::Instant` is a real +/// monotonic clock on native (`Instant::now`) and in the browser (`performance.now()`), so a wall +/// clock or NTP adjustment cannot make a screenshot deadline or autosave fire early or late. 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) - } - // `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock. - #[cfg(target_arch = "wasm32")] - { - let ms = js_sys::Date::now(); - if ms.is_finite() && ms > 0.0 { ms } else { 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 } /// macOS reads a Control-click as the right-click (winit reports the primary button with Control @@ -3387,9 +3452,12 @@ mod tests { app(), ); h.run_steps(6); + assert!(h.unnamed_widgets().is_empty(), "chrome inputs have accessible names: {:?}", h.unnamed_widgets()); + h.check_accessibility(); h.get_by_label("Document1 — Not saved").click(); h.run_steps(2); assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("renameDocument")); + assert!(h.unnamed_widgets().is_empty(), "the document-name field is labelled: {:?}", h.unnamed_widgets()); h.get_by_role_and_label(egui::accesskit::Role::Button, "Rename").click(); h.run_steps(2); assert!(h.state().dialog.is_none()); @@ -3801,7 +3869,7 @@ mod tests { for _ in 0..2 { frame(&mut a, Vec::new()); } - assert!(ctx.memory(|m| m.has_focus(egui::Id::new("find_what"))), "Find what has the focus"); + assert!(ctx.memory(|m| m.has_focus(egui::Id::unique("find_what"))), "Find what has the focus"); frame(&mut a, vec![egui::Event::Text("abc".into())]); let Some(dialogs::Dialog::Find { query, .. }) = &a.dialog else { panic!("no Find dialog") }; assert!(query.ends_with("abc"), "typing went into Find what: {query:?}"); @@ -3834,7 +3902,7 @@ mod tests { for _ in 0..2 { frame(&mut a, Vec::new()); } - assert!(ctx.memory(|m| m.has_focus(egui::Id::new("link_address"))), "Address has focus"); + assert!(ctx.memory(|m| m.has_focus(egui::Id::unique("link_address"))), "Address has focus"); frame(&mut a, vec![event]); let Some(dialogs::Dialog::Link { url, text, .. }) = &a.dialog else { panic!("no Link dialog") }; assert_eq!(url, expected, "input replaces the initial address"); @@ -3875,6 +3943,7 @@ mod tests { delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, }; frame(&mut a, vec![wheel(1.0)]); for _ in 0..30 { @@ -3920,6 +3989,7 @@ mod tests { delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers, + source: egui::MouseWheelSource::Unknown, }; // Away from the caret, several pages down. for _ in 0..60 { @@ -4022,7 +4092,13 @@ mod tests { } let over_page = a.canvas.canvas_rect.unwrap().center(); frame(&mut a, vec![egui::Event::PointerMoved(over_page)]); - let wheel = |unit, y: f32, phase| egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase, modifiers: egui::Modifiers::NONE }; + let wheel = |unit, y: f32, phase| egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, y), + phase, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }; let start = a.canvas.scroll_offset.y; frame(&mut a, vec![wheel(egui::MouseWheelUnit::Point, -40.0, egui::TouchPhase::Start)]); assert_eq!(a.canvas.scroll_offset.y, start + 40.0, "the first touchpad delta lands in the same frame"); @@ -4234,6 +4310,7 @@ mod tests { delta: egui::vec2(0.0, -65.0), phase: egui::TouchPhase::Start, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }], screen, ); @@ -4380,6 +4457,7 @@ mod tests { delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }; let notch = crate::scroll::notch_px(a.canvas.scale / crate::canvas::PX_PER_PT); let at = a.canvas.scroll_offset.y; @@ -4435,6 +4513,7 @@ mod tests { delta: egui::vec2(0.0, notches), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, }; let opts = egui::InputOptions::default(); let notch_zoom = (opts.scroll_zoom_speed * opts.line_scroll_speed).exp(); @@ -4500,6 +4579,7 @@ mod tests { delta: egui::vec2(0.0, delta), phase: egui::TouchPhase::Move, modifiers, + source: egui::MouseWheelSource::Unknown, }; // Scroll down a little, so the pointer is well inside the document. frame(&mut a, vec![wheel(-3.0, egui::Modifiers::NONE)]); @@ -4625,6 +4705,7 @@ mod tests { delta: egui::vec2(0.0, -3.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }; frame(&mut a, vec![egui::Event::PointerMoved(mid), wheel]); for _ in 0..30 { @@ -4924,6 +5005,7 @@ mod tests { delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }; frame(&mut a, vec![egui::Event::PointerMoved(vp.center()), wheel]); for _ in 0..30 { @@ -4966,7 +5048,7 @@ mod tests { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), ..Default::default() }; - a.raw_input_hook(&mut raw); + a.raw_input_hook(ctx, &mut raw); ctx.run_ui(raw, |ui| { a.logic(ui.ctx()); a.ui(ui); @@ -5026,7 +5108,7 @@ mod tests { for _ in 0..2 { control_frame(&mut a, &ctx, true); } - assert!(ctx.memory(|m| m.has_focus(egui::Id::new("find_what")))); + assert!(ctx.memory(|m| m.has_focus(egui::Id::unique("find_what")))); for _ in 0..2 { control_frame(&mut a, &ctx, false); } @@ -5074,7 +5156,7 @@ mod tests { assert_eq!(a.session.doc, doc, "control input respects the same protection as real keys"); assert!(a.synthetic.is_empty()); let mut native = egui::RawInput { focused: false, ..Default::default() }; - a.raw_input_hook(&mut native); + a.raw_input_hook(&ctx, &mut native); assert!(!native.focused, "a frame without control keys keeps the native focus state"); assert!(!a.canvas.focused); } @@ -5121,6 +5203,7 @@ mod tests { delta: egui::vec2(0.0, vp.center().y - at.y), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }; frame(&mut a, vec![egui::Event::PointerMoved(vp.center()), wheel]); for _ in 0..30 { @@ -5216,7 +5299,7 @@ mod tests { let mut frame = |a: &mut WordApp| { t += 1.0 / 60.0; let mut input = egui::RawInput { time: Some(t), screen_rect: Some(screen), ..Default::default() }; - a.raw_input_hook(&mut input); + a.raw_input_hook(&ctx, &mut input); ctx.run_ui(input, |ui| { a.logic(ui.ctx()); a.ui(ui); @@ -6180,7 +6263,7 @@ mod tests { use printing::Printer; for ok in [true, false] { let mut a = app(); - a.printing.printers = vec![Printer { name: "Test Printer".into(), default: true }]; + a.printing.printers = vec![Printer { name: "Test Printer".into(), label: String::new(), default: true }]; a.printing.settings.printer = "Test Printer".into(); a.services.print_native = Some(Box::new(move |_, _, _, _| { let (tx, rx) = std::sync::mpsc::channel(); @@ -6241,6 +6324,7 @@ mod tests { delta: egui::vec2(-120.0, -120.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(20); let moved = page(&h); @@ -8320,7 +8404,7 @@ mod tests { a.services.pasted = Some(q.clone()); a.run("ui.dialog", json!({"name": "find"})).unwrap(); let mut h = paste_harness(a); - h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("find_what"))); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("find_what"))); h.step(); h.step(); assert!(!h.state().canvas.focused); @@ -8941,8 +9025,9 @@ mod tests { assert!(v["result"]["width"].as_u64().unwrap() > 0 && v["result"]["pages"].as_u64().unwrap() >= 1, "{v}"); let image = egui::ColorImage::from_rgba_unmultiplied([2, 1], &[255, 0, 0, 255, 0, 0, 255, 255]); - let v = control::save_screenshot(&image, None); + let v = control::save_screenshot(&image, None, 41); assert_eq!(v["ok"], true, "{v}"); + assert_eq!(v["request"], 41); assert!(v["result"].get("path").is_none(), "no file: {v}"); let png = wordcraft_engine::cmd::insert::base64_decode(v["result"]["png"].as_str().expect("png")).expect("base64"); assert_eq!(&png[..4], b"\x89PNG"); @@ -9012,7 +9097,7 @@ mod tests { let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; h.event(egui::Event::Text("Kilns fire clay. Kilns cool.".into())); h.step(); - let nav_query = |h: &egui_kittest::Harness| h.ctx.data(|d| d.get_temp::(egui::Id::new("nav_query"))).unwrap_or_default(); + let nav_query = |h: &egui_kittest::Harness| h.ctx.data(|d| d.get_temp::(egui::Id::unique("nav_query"))).unwrap_or_default(); // Ctrl+F: the pane, and typing goes into its search box. h.event(key(egui::Key::F, egui::Modifiers::COMMAND)); h.step(); @@ -9064,6 +9149,519 @@ mod tests { assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("find")); } + fn shot_raw(size: egui::Vec2, hidden: bool) -> egui::RawInput { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let mut raw = egui::RawInput { screen_rect: Some(rect), max_texture_side: Some(16_384), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { + inner_rect: Some(rect), + native_pixels_per_point: Some(1.0), + minimized: Some(hidden), + occluded: Some(hidden), + ..Default::default() + }, + ); + raw + } + + fn shot_frame(app: &mut WordApp, ctx: &egui::Context, size: egui::Vec2) -> egui::FullOutput { + let mut raw = shot_raw(size, false); + app.raw_input_hook(ctx, &mut raw); + let mut output = ctx.run_ui(raw, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }); + output.textures_delta.clear(); + output + } + + fn take_shot_callbacks(output: &mut egui::FullOutput) -> Vec { + let mut callbacks = Vec::new(); + if let Some(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 { + continue; + }; + callbacks.push(callback); + } + } + } + callbacks + } + + fn screenshot_app(size: egui::Vec2) -> (WordApp, egui::Context) { + let mut app = app(); + app.run("document.setText", json!({"text": "Screenshot"})).unwrap(); + let ctx = egui::Context::default(); + for _ in 0..4 { + shot_frame(&mut app, &ctx, size); + } + (app, ctx) + } + + fn admit(app: &mut WordApp, ctx: &egui::Context, path: Option) -> std::sync::mpsc::Receiver { + let (reply, replies) = std::sync::mpsc::channel(); + control::admit_screenshot(app, ctx, path, reply); + replies + } + + fn issue_ready(app: &mut WordApp, ctx: &egui::Context, size: egui::Vec2) -> Vec { + for shot in &mut app.shots.requests { + shot.ready_at = 0.0; + shot.frames = 3; + } + let mut output = shot_frame(app, ctx, size); + take_shot_callbacks(&mut output) + } + + /// The deadline/animation clock is monotonic on native and in the browser. + #[test] + fn now_ms_never_moves_backwards() { + let mut last = now_ms(); + for _ in 0..1000 { + let now = now_ms(); + assert!(now >= last, "the clock went backwards: {last} -> {now}"); + last = now; + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[derive(Debug)] + struct DroppedText(std::path::PathBuf); + + #[cfg(not(target_arch = "wasm32"))] + impl egui::DroppedFile for DroppedText { + fn path(&self) -> &std::path::Path { + &self.0 + } + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } + } + + /// A file dropped in the first two font-warm-up passes is taken before the early return; + /// eframe does not preserve dropped files for a later frame. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_drop_before_font_warm_up_is_not_lost() { + let (mut app, ctx) = (app(), egui::Context::default()); + let path = std::env::temp_dir().join(format!("wordcraft-drop-warmup-{}.txt", std::process::id())); + std::fs::write(&path, b"dropped text").expect("write the dropped document"); + let mut raw = shot_raw(egui::vec2(640.0, 480.0), false); + raw.dropped_files.push(std::sync::Arc::new(DroppedText(path.clone()))); + app.raw_input_hook(&ctx, &mut raw); + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + assert_eq!(app.fonts_frames, 1, "the UI returned for font warm-up"); + assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end_matches(['\r', '\n']), "dropped text"); + let _ = std::fs::remove_file(&path); + } + + /// Wayland may leave `ViewportInfo::inner_rect` unknown: capture geometry then uses the same + /// RawInput frame's screen rect and rounds logical points to physical pixels. + #[test] + fn capture_geometry_falls_back_to_screen_rect_and_rounds_physical_pixels() { + let ctx = egui::Context::default(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 200.0)); + let mut raw = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(1.5); + let surface = control::SurfaceGeometry::from_raw(&raw, &ctx).expect("screen rect fallback"); + let geometry = surface.with_document(7); + assert_eq!(geometry.expected_size(), Some([480, 300])); + assert_eq!(geometry.expected_bytes(), Some(480 * 300 * 4)); + } + + /// A completed capture reaches its own slot, the callback never writes a file, and the + /// request is replied to exactly once from `logic`. + #[test] + fn screenshot_callback_completes_one_request_without_writing_from_the_callback() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let replies = admit(&mut app, &ctx, None); + let callbacks = issue_ready(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert_eq!(callbacks.len(), 1); + callbacks.into_iter().next().unwrap().complete(std::sync::Arc::new(egui::ColorImage::filled([640, 480], egui::Color32::BLACK))); + assert!(replies.try_recv().is_err(), "the callback itself sends no reply"); + 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); + assert!(app.shots.requests.is_empty(), "the reservation is released"); + } + + /// A discarded first layout pass does not issue two callback commands: the shot's issued + /// flag and first-pass settle counter keep the capture one-shot across the final pass. + #[test] + fn screenshot_issue_is_one_shot_across_a_multipass_frame() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let replies = admit(&mut app, &ctx, None); + for shot in &mut app.shots.requests { + shot.ready_at = 0.0; + shot.frames = 3; + } + let mut passes = 0; + let mut output = ctx.run_ui(shot_raw(egui::vec2(640.0, 480.0), false), |ui| { + if passes == 0 { + ui.request_discard("test capture multipass"); + } + app.logic(ui.ctx()); + app.ui(ui); + passes += 1; + }); + assert_eq!(passes, 2); + let callbacks = take_shot_callbacks(&mut output); + assert_eq!(callbacks.len(), 1, "one callback for the whole host frame"); + callbacks.into_iter().next().unwrap().complete(std::sync::Arc::new(egui::ColorImage::filled([640, 480], egui::Color32::BLACK))); + output.drop_without_applying_deltas(); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert_eq!(replies.recv().unwrap()["ok"], true); + } + + /// Two requests keep their own callbacks; a later completion does not consume the earlier + /// request's image or reply. + #[test] + fn screenshot_callbacks_complete_out_of_order() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let first = admit(&mut app, &ctx, None); + let second = admit(&mut app, &ctx, None); + let mut callbacks = issue_ready(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert_eq!(callbacks.len(), 2); + let first_callback = callbacks.remove(0); + let second_callback = callbacks.remove(0); + second_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 second_reply = second.recv().unwrap(); + assert_eq!(second_reply["ok"], true, "{second_reply}"); + assert!(first.try_recv().is_err(), "the first request is still pending"); + assert_eq!(app.shots.requests.len(), 1); + first_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 first_reply = first.recv().unwrap(); + assert_eq!(first_reply["ok"], true, "{first_reply}"); + assert!(app.shots.requests.is_empty()); + } + + /// A deadline is checked before a completed image is written. A callback that arrives after + /// the timeout finds a cancelled slot and gets no second reply. + #[test] + fn a_late_screenshot_callback_never_writes_or_replies() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let path = std::env::temp_dir().join(format!("wordcraft-late-shot-{}.png", std::process::id())); + let _ = std::fs::remove_file(&path); + let replies = admit(&mut app, &ctx, Some(path.to_string_lossy().to_string())); + let callbacks = issue_ready(&mut app, &ctx, egui::vec2(640.0, 480.0)); + let callback = callbacks.into_iter().next().unwrap(); + app.shots.requests[0].deadline = 0.0; + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert_eq!(replies.recv().unwrap()["ok"], false); + 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(), "one reply only"); + assert!(!path.exists(), "no late file was written"); + assert!(app.shots.requests.is_empty()); + } + + /// Resizing away and back is still a new geometry epoch, so old pixels are rejected and the + /// request retries inside its original deadline. + #[test] + fn screenshot_epoch_changes_even_when_geometry_returns() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let replies = admit(&mut app, &ctx, None); + let callbacks = issue_ready(&mut app, &ctx, egui::vec2(640.0, 480.0)); + let callback = callbacks.into_iter().next().unwrap(); + shot_frame(&mut app, &ctx, egui::vec2(800.0, 600.0)); + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + 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(), "stale pixels are dropped"); + assert_eq!(app.shots.requests.len(), 1, "the request retries"); + assert!(!app.shots.requests[0].issued); + app.shots.requests[0].deadline = 0.0; + shot_frame(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert_eq!(replies.recv().unwrap()["ok"], false); + } + + #[test] + fn screenshot_epoch_observes_a_raw_change_and_change_back() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let replies = admit(&mut app, &ctx, None); + let callbacks = issue_ready(&mut app, &ctx, egui::vec2(640.0, 480.0)); + let callback = callbacks.into_iter().next().unwrap(); + let issued_epoch = app.shots.requests[0].epoch; + let mut away = shot_raw(egui::vec2(800.0, 600.0), false); + app.raw_input_hook(&ctx, &mut away); + let mut back = shot_raw(egui::vec2(640.0, 480.0), false); + app.raw_input_hook(&ctx, &mut back); + assert!(app.capture_epoch > issued_epoch); + 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(), "pixels from before the hidden change are dropped"); + assert_eq!(app.shots.requests.len(), 1, "the request retries"); + assert!(!app.shots.requests[0].issued); + } + + /// Wayland can report `inner_rect: None`; a resize is still seen through the raw screen rect + /// and starts a new epoch instead of reusing the last painted size. + #[test] + fn screenshot_geometry_uses_the_current_raw_screen_rect_on_wayland() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let before = app.capture_epoch; + let mut raw = shot_raw(egui::vec2(320.0, 200.0), false); + raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().inner_rect = None; + app.raw_input_hook(&ctx, &mut raw); + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + assert!(app.capture_epoch > before, "a resize starts a new geometry epoch"); + assert_eq!(app.capture_geometry.and_then(control::CaptureGeometry::expected_size), Some([320, 200])); + } + + /// A successful capture with a path writes exactly that file, and its reply carries the + /// request token used to correlate admission, timeout and completion. + #[test] + fn screenshot_success_writes_the_requested_path_and_correlates_the_token() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let path = std::env::temp_dir().join(format!("wordcraft-shot-ok-{}.png", std::process::id())); + let _ = std::fs::remove_file(&path); + let replies = admit(&mut app, &ctx, Some(path.to_string_lossy().to_string())); + let token = app.shots.requests[0].token; + let callbacks = issue_ready(&mut app, &ctx, egui::vec2(640.0, 480.0)); + callbacks.into_iter().next().unwrap().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["request"], token, "{reply}"); + let png = std::fs::read(&path).expect("the requested screenshot file"); + assert_eq!(&png[..4], b"\x89PNG", "a real PNG was written"); + let _ = std::fs::remove_file(&path); + } + + /// A request admitted for document A is never rebound to document B. The slot is cancelled, + /// the requested path is not written and the caller gets one document-changed error. + #[test] + fn screenshot_document_switch_cancels_instead_of_rebinding() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let path = std::env::temp_dir().join(format!("wordcraft-shot-doc-switch-{}.png", std::process::id())); + let _ = std::fs::remove_file(&path); + let replies = admit(&mut app, &ctx, Some(path.to_string_lossy().to_string())); + let document = app.session.document_id(); + app.session.dirty = false; // A saved document can be replaced without the save prompt. + app.run("file.new", json!({})).unwrap(); + assert_ne!(document, app.session.document_id(), "the test must actually replace the document"); + let callbacks = issue_ready(&mut app, &ctx, egui::vec2(640.0, 480.0)); + assert!(callbacks.is_empty(), "no callback is issued for the new document"); + let reply = replies.recv().unwrap(); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("document changed"), "{reply}"); + assert!(!path.exists(), "document B's pixels never reach document A's path"); + assert!(app.shots.requests.is_empty()); + assert!(replies.try_recv().is_err(), "one reply only"); + } + + /// A minimized (or fully occluded) window cannot be captured: admission/issuance cancels the + /// request rather than accepting the last visible surface or retrying it. + #[test] + fn screenshot_minimized_window_is_cancelled_not_retried() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let replies = admit(&mut app, &ctx, None); + for shot in &mut app.shots.requests { + shot.ready_at = 0.0; + shot.frames = 3; + } + let mut raw = shot_raw(egui::vec2(640.0, 480.0), true); + app.raw_input_hook(&ctx, &mut raw); + let mut output = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + let callbacks = take_shot_callbacks(&mut output); + output.drop_without_applying_deltas(); + assert!(callbacks.is_empty(), "no capture is issued while minimized"); + let reply = replies.recv().unwrap(); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("minimized"), "{reply}"); + assert!(app.shots.requests.is_empty()); + } + + /// Admission also refuses a minimized surface: the last visible size must not be captured. + #[test] + fn screenshot_admission_rejects_a_minimized_window() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let mut raw = shot_raw(egui::vec2(640.0, 480.0), true); + app.raw_input_hook(&ctx, &mut raw); + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + let replies = admit(&mut app, &ctx, None); + let reply = replies.recv().unwrap(); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("minimized"), "{reply}"); + assert!(app.shots.requests.is_empty()); + } + + /// A malformed completion is reported at once; it is not treated as a resize and retried. + #[test] + fn screenshot_malformed_completion_fails_without_retrying() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let replies = admit(&mut app, &ctx, None); + let callbacks = issue_ready(&mut app, &ctx, egui::vec2(640.0, 480.0)); + callbacks.into_iter().next().unwrap().complete(std::sync::Arc::new(egui::ColorImage { + size: [640, 480], + source_size: egui::vec2(640.0, 480.0), + pixels: Vec::new(), + })); + 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("pixels"), "{reply}"); + assert!(reply.get("request").is_some(), "completion errors carry the request token: {reply}"); + assert!(app.shots.requests.is_empty(), "a malformed image is not retried"); + assert!(replies.try_recv().is_err()); + } + + /// Admission enforces four requests, 128 MiB per image and 256 MiB aggregate, without + /// evicting an earlier request. + #[test] + fn screenshot_admission_bounds_count_image_and_aggregate() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + for _ in 0..control::SCREENSHOT_MAX_REQUESTS { + let _ = admit(&mut app, &ctx, None); + } + let (reply, replies) = std::sync::mpsc::channel(); + control::admit_screenshot(&mut app, &ctx, None, reply); + assert_eq!(replies.recv().unwrap()["ok"], false); + assert_eq!(app.shots.requests.len(), control::SCREENSHOT_MAX_REQUESTS); + + // 2300×2250 reserves about 90 MB including image, RGBA, PNG and base64 buffers: + // two fit the 256 MB aggregate, a third does not. + let mut app = self::app(); + let ctx = egui::Context::default(); + let mut raw = shot_raw(egui::vec2(2300.0, 2250.0), false); + app.raw_input_hook(&ctx, &mut raw); + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + let _first = admit(&mut app, &ctx, None); + let _second = admit(&mut app, &ctx, None); + let (reply, replies) = std::sync::mpsc::channel(); + control::admit_screenshot(&mut app, &ctx, None, reply); + let over = replies.recv().unwrap(); + assert!(over["error"].as_str().unwrap_or_default().contains("aggregate"), "{over}"); + + // 3400×3000 exceeds the per-image working set on its own. + let mut app = self::app(); + let ctx = egui::Context::default(); + let mut raw = shot_raw(egui::vec2(3400.0, 3000.0), false); + app.raw_input_hook(&ctx, &mut raw); + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + let (reply, replies) = std::sync::mpsc::channel(); + control::admit_screenshot(&mut app, &ctx, None, reply); + let too_large = replies.recv().unwrap(); + assert!(too_large["error"].as_str().unwrap_or_default().contains("image limit"), "{too_large}"); + } + + /// A queued capture on a hidden window asks for a real paint, because issuing the screenshot + /// is UI work even though `logic` is the only callback a hidden tick gets. The forced paint + /// pass must actually reach issuance: exactly one callback, and the request is marked issued. + #[test] + fn a_hidden_queued_capture_requests_a_paint_and_the_forced_pass_issues_it() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + let _replies = admit(&mut app, &ctx, None); + for shot in &mut app.shots.requests { + shot.ready_at = 0.0; + shot.frames = 3; + } + let mut raw = shot_raw(egui::vec2(640.0, 480.0), true); + app.raw_input_hook(&ctx, &mut raw); + let output = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + let commands = output.viewport_commands.get(&egui::ViewportId::ROOT).cloned().unwrap_or_default(); + assert!(commands.contains(&egui::ViewportCommand::RequestPaintWhileHidden), "{commands:?}"); + assert!( + output.viewport_commands.values().flatten().all(|command| !matches!(command, egui::ViewportCommand::Screenshot(_))), + "logic alone does not issue the capture" + ); + assert!(app.shots.requests.first().is_some_and(|shot| !shot.issued), "the request is still queued"); + + // The paint requested above is a full UI pass. Issuing happens there, not in `logic`. + let mut paint = shot_raw(egui::vec2(640.0, 480.0), false); + app.raw_input_hook(&ctx, &mut paint); + let mut output = ctx.run_ui(paint, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }); + let callbacks = take_shot_callbacks(&mut output); + output.drop_without_applying_deltas(); + assert_eq!(callbacks.len(), 1, "the forced paint pass issues exactly one capture"); + assert!(app.shots.requests.first().is_some_and(|shot| shot.issued), "the queued request reached issuance"); + } + + /// Real RawInput is offered once, survives hidden ticks, and a multipass frame consumes it + /// once. The step is not drained by a pass that will be discarded. + #[test] + fn synthetic_input_is_offered_once_through_hidden_and_multipass_frames() { + let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); + app.run("document.setText", json!({"text": ""})).unwrap(); + app.synthetic = vec![egui::Event::Text("x".into())]; + let mut first = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut first); + assert_eq!(first.events.len(), 1); + assert!(app.synthetic_pending); + assert_eq!(app.synthetic.len(), 1, "the step is not consumed by the hook"); + + // eframe keeps the unconsumed RawInput for later; a hidden logic tick must not append it + // again, nor offer the next step. + let mut hidden = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut hidden); + assert!(hidden.events.is_empty(), "the pending step is not appended twice"); + app.synthetic.push(egui::Event::Text("y".into())); + let mut hidden_again = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut hidden_again); + assert!(hidden_again.events.is_empty(), "a later synthetic step waits too"); + assert_eq!(app.synthetic.len(), 2); + + // One host frame, two passes. The first is discarded, so the UI ack waits for the final + // pass; the input itself exists only in the first pass and must type exactly once. + let mut passes = 0; + ctx.run_ui(first, |ui| { + ui.request_discard("test multipass"); + app.logic(ui.ctx()); + app.ui(ui); + passes += 1; + }) + .drop_without_applying_deltas(); + assert_eq!(passes, 2, "the discard forced a second pass"); + assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "x"); + assert!(!app.synthetic_pending, "the final UI pass acknowledged the step"); + assert_eq!(app.synthetic.len(), 1, "only the offered step was removed"); + + // The next host frame offers y, which was waiting behind the acknowledged step. + let mut next = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut next); + assert_eq!(next.events, vec![egui::Event::Text("y".into())]); + ctx.run_ui(next, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + assert!(!app.synthetic_pending); + assert!(app.synthetic.is_empty()); + assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "xy", "each step typed once"); + } + + /// A synthetic step waits for the font warm-up frames rather than being injected into a pass + /// whose layout has no usable font atlas. + #[test] + fn synthetic_input_waits_for_font_warm_up() { + let mut app = app(); + app.synthetic = vec![egui::Event::Text("x".into())]; + let ctx = egui::Context::default(); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "fonts are not ready yet"); + assert_eq!(app.synthetic.len(), 1, "the step is retained"); + } + /// #991: the status bar's Focus button leaves only the page (no ribbon, status bar or panes); /// Escape brings them back. #[test] diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index e2b944ef57..9eafba3d67 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -99,7 +99,7 @@ pub fn anchor_rect(selection_top: f32, selection_left: f32, viewport: Rect) -> O /// Where the bar and the controls in it were drawn last frame (`[bar, controls]`), kept in the /// context's memory under this id so tests can check that no control spills out of the bar. pub fn shown_id() -> egui::Id { - egui::Id::new("wc_mini_toolbar").with("shown") + egui::Id::unique("wc_mini_toolbar").with("shown") } /// Whether the bar may be shown for the current app state. @@ -181,7 +181,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: R // the topmost layer, so a click on the bar never reaches the document underneath. Clipped to // the document area (`constrain_to` without `constrain`, which would push it inside), the part // past its edge takes no clicks either, so the ribbon and the rulers under it keep them. - let area = egui::Area::new(egui::Id::new("wc_mini_toolbar")).order(Order::Middle).interactable(true); + let area = egui::Area::new(egui::Id::unique("wc_mini_toolbar")).order(Order::Middle).interactable(true); let area = area.constrain_to(viewport).constrain(false).fixed_pos(bar.min); area.show(ctx, |ui| { ui.set_clip_rect(shown); @@ -260,7 +260,7 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) { st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default(); let sizes: Vec = wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect(); - if let Some(v) = widgets::combo(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None) + if let Some(v) = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size")) && let Ok(x) = v.trim().parse::() { let _ = app.run("format.size", json!({"size": x})); @@ -524,7 +524,7 @@ mod tests { /// The bar's layer, when it was drawn last frame. fn bar_layer(h: &egui_kittest::Harness<'static, WordApp>) -> Option { - h.ctx.memory(|m| m.areas().visible_layer_ids().into_iter().find(|l| l.id == egui::Id::new("wc_mini_toolbar"))) + h.ctx.memory(|m| m.areas().visible_layer_ids().into_iter().find(|l| l.id == egui::Id::unique("wc_mini_toolbar"))) } fn click_at(h: &mut egui_kittest::Harness<'static, WordApp>, at: egui::Pos2) { @@ -776,7 +776,13 @@ mod tests { h.event(egui::Event::PointerMoved(page.center())); h.step(); let delta = egui::vec2(0.0, target - line.min.y); - h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase: egui::TouchPhase::Start, modifiers: Default::default() }); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta, + phase: egui::TouchPhase::Start, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }); h.step(); h.state_mut().canvas.wheel = Default::default(); steps(&mut h); @@ -864,7 +870,7 @@ mod tests { a.run("select.all", json!({})).unwrap(); let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(400.0, 400.0)).build_ui_state( |ui, app: &mut WordApp| { - egui::Area::new(egui::Id::new("font_colour_probe")).fixed_pos(pos2(50.0, 50.0)).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("font_colour_probe")).fixed_pos(pos2(50.0, 50.0)).show(ui.ctx(), |ui| { font_color_menu(ui, app); }); }, @@ -883,10 +889,10 @@ mod tests { fn picking_a_highlight_swatch_returns_focus_to_the_document() { let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), Default::default()); a.run("select.all", json!({})).unwrap(); - let origin = egui::Id::new("hl_swatch_origin"); + let origin = egui::Id::unique("hl_swatch_origin"); let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(400.0, 300.0)).build_ui_state( move |ui, app: &mut WordApp| { - egui::Area::new(egui::Id::new("hl_probe")).fixed_pos(pos2(100.0, 100.0)).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("hl_probe")).fixed_pos(pos2(100.0, 100.0)).show(ui.ctx(), |ui| { ui.ctx().data_mut(|d| d.insert_temp(origin, ui.next_widget_position())); highlight_menu(ui, app); }); diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index db6a3dca3c..1ae1c99f76 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -147,7 +147,7 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) { let label = tl!(pane_label(id)); let active = current == id; let (r, resp) = ui.allocate_exact_size(vec2(170.0, 30.0), egui::Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label)); if active { ui.painter().rect_filled(r, 4.0, t.checked); } else if resp.hovered() { @@ -236,20 +236,21 @@ fn general(app: &mut WordApp, ui: &mut Ui) { ui.add_space(8.0); section(ui, "Personalize"); egui::Grid::new("options_user").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - ui.label(tl!("User name:")); + let user_label = ui.label(tl!("User name:")); let mut n = app.session.author.clone(); let protected = wordcraft_engine::cmd::protect::enforced(&app.session.doc); - if ui.add_enabled(!protected, egui::TextEdit::singleline(&mut n).desired_width(260.0)).changed() && !protected { + if ui.add_enabled(!protected, egui::TextEdit::singleline(&mut n).desired_width(260.0)).labelled_by(user_label.id).changed() && !protected { app.session.author = n; } ui.end_row(); - ui.label(tl!("Initials:")); + let initials_label = ui.label(tl!("Initials:")); let mut i = app.session.initials.clone(); let hint = app.session.user_initials(); if ui .add( egui::TextEdit::singleline(&mut i).desired_width(80.0).char_limit(wordcraft_engine::cmd::options::MAX_INITIALS_CHARS).hint_text(hint), ) + .labelled_by(initials_label.id) .changed() { set(app, json!({"initials": i})); @@ -317,11 +318,13 @@ fn proofing(app: &mut WordApp, ui: &mut Ui) { /// dictionary kept in a folder of the user's own. fn dictionaries(app: &mut WordApp, ui: &mut Ui) { let saved = app.session.prefs.dictionaries_folder.clone().unwrap_or_default(); - let id = egui::Id::new("options_dictionaries_folder"); + let id = egui::Id::unique("options_dictionaries_folder"); let mut folder = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| saved.clone()); - ui.label(tl!("Dictionaries folder:")); + let folder_label = ui.label(tl!("Dictionaries folder:")).id; ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(tl!("Not set: the system's usual places"))); + let r = ui + .add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(tl!("Not set: the system's usual places"))) + .labelled_by(folder_label); let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let changed = folder.trim() != saved; if ui.add_enabled(changed, egui::Button::new(tl!("Apply"))).clicked() || (enter && changed) { @@ -341,7 +344,7 @@ fn dictionaries(app: &mut WordApp, ui: &mut Ui) { ui.data_mut(|d| d.insert_temp(id, folder)); note(ui, "Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's."); // The dictionary each language found, looked up again after the folder changes. - let found_id = egui::Id::new("options_dictionaries_found"); + let found_id = egui::Id::unique("options_dictionaries_found"); let generation = wordcraft_engine::proof::dictionary_generation(); let cached = ui.data(|d| d.get_temp::<(u64, Vec<(String, Option, Option)>)>(found_id)).filter(|(g, _)| *g == generation); let found = match cached { @@ -407,10 +410,12 @@ fn custom_dictionary(app: &mut WordApp, ui: &mut Ui) { }); } }); - let id = egui::Id::new("options_dictionary_new"); + let id = egui::Id::unique("options_dictionary_new"); let mut word = ui.data(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut word).desired_width(220.0).char_limit(MAX_WORD_CHARS).hint_text(tl!("New word"))); + let r = ui + .add(egui::TextEdit::singleline(&mut word).desired_width(220.0).char_limit(MAX_WORD_CHARS).hint_text(tl!("New word"))) + .accessible_name(tl!("New word")); let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let w = word.trim().to_string(); let ok = !w.is_empty() && !w.chars().any(char::is_whitespace); @@ -481,8 +486,9 @@ fn accessibility(app: &mut WordApp, ui: &mut Ui) { section(ui, "Read Aloud"); let mut rate = app.session.read_aloud.rate(); ui.horizontal(|ui| { - ui.label(tl!("Speed")); - let r = ui.add(egui::Slider::new(&mut rate, MIN_RATE..=MAX_RATE).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×"))); + let label = ui.label(tl!("Speed")); + let r = + ui.add(egui::Slider::new(&mut rate, MIN_RATE..=MAX_RATE).step_by(0.05).custom_formatter(|v, _| format!("{v:.2}×"))).labelled_by(label.id); if r.changed() { let _ = app.run("readAloud.speed", json!({"value": rate})); } @@ -510,19 +516,19 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) { ui.add_space(8.0); section(ui, "Display"); egui::Grid::new("options_display").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Recent documents to show:")); + let recent_label = ui.label(tl!("Recent documents to show:")); let mut n = app.ui.recent_shown(); - if ui.add(egui::DragValue::new(&mut n).range(0..=crate::MAX_RECENT).speed(0.2)).changed() { + if ui.add(egui::DragValue::new(&mut n).range(0..=crate::MAX_RECENT).speed(0.2)).labelled_by(recent_label.id).changed() { app.ui.recent_count = n; } ui.end_row(); - ui.label(tl!("Measurement units:")); + let units_label = ui.label(tl!("Measurement units:")); // The user's choice, else the region's (#838): Automatic follows the region. let units = app.session.units(); let auto = app.session.prefs.units.is_none(); let name = UNITS.iter().find(|(u, _)| *u == units).map_or("Inches", |(_, n)| n); let shown = if auto { format!("{} ({})", tl!("Automatic"), tl!(name)) } else { tl!(name).to_string() }; - egui::ComboBox::from_id_salt("options_units").selected_text(shown).width(200.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("options_units").selected_text(shown).width(200.0).show_ui(ui, |ui| { if ui.selectable_label(auto, tl!("Automatic")).clicked() { set(app, json!({"units": "auto"})); } @@ -532,16 +538,17 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) { } } }); + combo.response.labelled_by(units_label.id); ui.end_row(); // The region new documents, units and the decimal separator follow; Automatic is the // system's (#838). - ui.label(tl!("Region:")); + let region_label = ui.label(tl!("Region:")); let chosen = app.session.prefs.region.clone(); let system = &app.session.system_region; let system_tag = if system.territory.is_empty() { system.language.clone() } else { format!("{}-{}", system.language, system.territory) }; let automatic = crate::i18n::fmt(tl!("Automatic ({region})"), &[("region", system_tag.as_str())]); let shown = chosen.clone().unwrap_or_else(|| automatic.clone()); - egui::ComboBox::from_id_salt("options_region").selected_text(shown).width(200.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("options_region").selected_text(shown).width(200.0).show_ui(ui, |ui| { if ui.selectable_label(chosen.is_none(), automatic.as_str()).clicked() { set(app, json!({"region": "auto"})); } @@ -551,6 +558,7 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) { } } }); + combo.response.labelled_by(region_label.id); ui.end_row(); }); let mut keytips = app.ui.keytips_enabled; @@ -595,13 +603,13 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) { /// Template starts. Empty is the app's own folder, shown as the hint. fn templates_folder(app: &mut WordApp, ui: &mut Ui) { let saved = app.session.prefs.user_templates_folder.clone().unwrap_or_default(); - let id = egui::Id::new("options_user_templates_folder"); + let id = egui::Id::unique("options_user_templates_folder"); let mut folder = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| saved.clone()); let default = app.services.templates_dir.as_ref().map(|d| d.to_string_lossy().into_owned()); - ui.label(tl!("User templates:")); + let templates_label = ui.label(tl!("User templates:")).id; ui.horizontal(|ui| { let hint = default.clone().unwrap_or_else(|| tl!("Not set").to_string()); - let r = ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(hint)); + let r = ui.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(hint)).labelled_by(templates_label); let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let changed = folder.trim() != saved; if ui.add_enabled(changed, egui::Button::new(tl!("Apply"))).clicked() || (enter && changed) { @@ -630,14 +638,15 @@ fn agents(ui: &mut Ui) { fn theme_picker(app: &mut WordApp, ui: &mut Ui) { use crate::theme::Appearance; ui.horizontal(|ui| { - ui.label(tl!("Interface theme:")); - egui::ComboBox::from_id_salt("interface_theme").selected_text(tl!(app.ui.theme.label())).width(220.0).show_ui(ui, |ui| { + let label = ui.label(tl!("Interface theme:")); + let combo = egui::ComboBox::from_id_salt("interface_theme").selected_text(tl!(app.ui.theme.label())).width(220.0).show_ui(ui, |ui| { for a in Appearance::ALL { if ui.selectable_label(app.ui.theme == a, tl!(a.label())).clicked() { let _ = app.run("ui.theme", json!({"value": a.code()})); } } }); + combo.response.labelled_by(label.id); }); } @@ -650,8 +659,8 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) { let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &InterfaceSize::AUTO.drawn_percent(native).to_string())]); let selected = size.percent().map_or_else(|| auto.clone(), |pct| format!("{pct}%")); ui.horizontal(|ui| { - ui.label(tl!("Interface size:")); - egui::ComboBox::from_id_salt("interface_size").selected_text(selected).width(220.0).show_ui(ui, |ui| { + let label = ui.label(tl!("Interface size:")); + let combo = egui::ComboBox::from_id_salt("interface_size").selected_text(selected).width(220.0).show_ui(ui, |ui| { if ui.selectable_label(size.is_auto(), auto).clicked() { let _ = app.run("ui.interfaceSize", json!({"value": "auto"})); } @@ -661,6 +670,7 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) { } } }); + combo.response.labelled_by(label.id); }); ui.label( RichText::new(tl!( @@ -676,14 +686,15 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) { fn animations_picker(app: &mut WordApp, ui: &mut Ui) { use crate::theme::Animations; ui.horizontal(|ui| { - ui.label(tl!("Animation effects:")); - egui::ComboBox::from_id_salt("animation_effects").selected_text(tl!(app.ui.animations.label())).width(220.0).show_ui(ui, |ui| { + let label = ui.label(tl!("Animation effects:")); + let combo = egui::ComboBox::from_id_salt("animation_effects").selected_text(tl!(app.ui.animations.label())).width(220.0).show_ui(ui, |ui| { for a in Animations::ALL { if ui.selectable_label(app.ui.animations == a, tl!(a.label())).clicked() { let _ = app.run("ui.animations", json!({"value": a.code()})); } } }); + combo.response.labelled_by(label.id); }); note(ui, "Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once."); if app.ui.animations == Animations::System { @@ -705,8 +716,8 @@ fn language_picker(app: &mut WordApp, ui: &mut Ui) { let auto_label = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", system.name())]); let current = if app.ui.language == AUTO { auto_label.clone() } else { Lang::from_pref(&app.ui.language).name().to_string() }; ui.horizontal(|ui| { - ui.label(tl!("Interface language:")); - egui::ComboBox::from_id_salt("interface_language").selected_text(current).width(220.0).show_ui(ui, |ui| { + let label = ui.label(tl!("Interface language:")); + let combo = egui::ComboBox::from_id_salt("interface_language").selected_text(current).width(220.0).show_ui(ui, |ui| { if ui.selectable_label(app.ui.language == AUTO, auto_label.as_str()).clicked() { let _ = app.run("ui.language", json!({"value": AUTO})); } @@ -716,6 +727,7 @@ fn language_picker(app: &mut WordApp, ui: &mut Ui) { } } }); + combo.response.labelled_by(label.id); }); ui.label(RichText::new(tl!("Automatic follows your system's language. Menus and commands change; your documents don't.")).small().weak()); } @@ -814,13 +826,14 @@ fn default_app_button(app: &mut WordApp, ui: &mut Ui) { fn numeral_picker(app: &mut WordApp, ui: &mut Ui) { use wordcraft_layout::NumeralMode; ui.horizontal(|ui| { - ui.label(tl!("Numerals:")); + let label = ui.label(tl!("Numerals:")); let mut sel = app.session.view.numeral; - egui::ComboBox::from_id_salt("numerals").selected_text(tl!(sel.label())).width(220.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("numerals").selected_text(tl!(sel.label())).width(220.0).show_ui(ui, |ui| { for m in [NumeralMode::Arabic, NumeralMode::Hindi, NumeralMode::Context, NumeralMode::System] { ui.selectable_value(&mut sel, m, tl!(m.label())); } }); + combo.response.labelled_by(label.id); if sel != app.session.view.numeral { let _ = app.run("view.numeral", json!({ "value": sel })); } @@ -1146,7 +1159,7 @@ mod tests { assert!(h.query_by_label("Animation effects:").is_some()); assert!(h.query_by_label("Your system has animation effects turned off.").is_some()); assert!(!h.state().animations_on()); - h.get_by_role(egui::accesskit::Role::ComboBox).click(); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Animation effects:").click(); h.run_steps(4); h.get_by_label("On").click(); h.run_steps(4); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fa37d7a940..db7e378596 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -122,7 +122,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("view.navigationPane", json!({"value": false})); return; } - let qid = egui::Id::new("nav_query"); + let qid = egui::Id::unique("nav_query"); let mut q = ui.data(|d| d.get_temp::(qid)).unwrap_or_default(); // Ctrl+F with text selected searches for it, as Word does (a selection within one line). let focus = std::mem::take(&mut app.nav_search_focus); @@ -460,7 +460,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { }); // Editable comment text (one undo step per edit, like the balloons). comment_editor(app, ui, id as u32, None, false); - let open_key = egui::Id::new(("comment_reply_open", id)); + let open_key = egui::Id::unique(("comment_reply_open", id)); let mut replying = ui.data(|d| d.get_temp::(open_key)).unwrap_or(false); ui.horizontal(|ui| { if ui.small_button(tl!("Go to")).clicked() @@ -471,7 +471,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { if ui.small_button(tl!("Reply")).clicked() { replying = true; ui.data_mut(|d| d.insert_temp(open_key, true)); - ui.data_mut(|d| d.insert_temp(egui::Id::new(("comment_reply_focus", id)), true)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(("comment_reply_focus", id)), true)); } let resolved = c.get("resolved").and_then(Value::as_bool).unwrap_or(false); if ui.small_button(if resolved { tl!("Reopen") } else { tl!("Resolve") }).clicked() { @@ -482,7 +482,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { } }); if replying { - let focus = ui.data_mut(|d| d.remove_temp::(egui::Id::new(("comment_reply_focus", id)))).unwrap_or(false); + let focus = ui.data_mut(|d| d.remove_temp::(egui::Id::unique(("comment_reply_focus", id)))).unwrap_or(false); if reply_editor(app, ui, id as u32, None, focus) { ui.data_mut(|d| d.remove::(open_key)); } @@ -574,7 +574,7 @@ pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Opti /// A comment field's widget id, per document: egui's own state for the field (its cursor and /// undo history) never carries over to another document's comment with the same id. fn field_id(app: &WordApp, ui: &Ui, kind: &str, id: u32) -> egui::Id { - ui.id().with((kind, app.session.document_id(), id)) + ui.scope_id().with((kind, app.session.document_id(), id)) } /// Write a comment's draft text back, when it has one that differs from the comment. The draft diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index ba9d8a5294..75e60121ae 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -217,7 +217,7 @@ impl Previews { self.east_asian = None; ctx.data_mut(|d| { for f in families { - d.remove::(egui::Id::new(format!("font:{f}"))); + d.remove::(egui::Id::unique(format!("font:{f}"))); } }); } @@ -226,9 +226,9 @@ impl Previews { pub fn font_preview_fn(&mut self) -> Box Response> { Box::new(|ui: &mut Ui, name: &str| { let key = format!("font:{name}"); - let tex: Option = ui.ctx().data(|d| d.get_temp::(egui::Id::new(&key))); + let tex: Option = ui.ctx().data(|d| d.get_temp::(egui::Id::unique(&key))); let (r, resp) = ui.allocate_exact_size(vec2(260.0, crate::widgets::COMBO_PREVIEW_ROW_H), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name)); let t = Tokens::get(ui.ctx()); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); @@ -237,7 +237,7 @@ impl Previews { // Context accessors share a lock: read input before taking the data write lock. let now = ui.input(|i| i.time); let n = ui.ctx().data_mut(|d| { - let c = d.get_temp_mut_or_default::<(f64, u32)>(egui::Id::new("font_preview_budget")); + let c = d.get_temp_mut_or_default::<(f64, u32)>(egui::Id::unique("font_preview_budget")); if c.0 != now { *c = (now, 0); } @@ -252,7 +252,7 @@ impl Previews { let mut img = font_entry_image(name, ppp); ink_mask(&mut img); let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR); - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone())); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(&key), h.clone())); Some(h) }); match tex { @@ -517,7 +517,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let rev = app.session.rev(); let skey = app.previews.styles_key(&app.session.doc, rev); let ppp = ui.ctx().pixels_per_point(); - let start = ui.data(|d| d.get_temp::(egui::Id::new("gallery_start"))).unwrap_or(0).min(styles.len().saturating_sub(1)); + let start = ui.data(|d| d.get_temp::(egui::Id::unique("gallery_start"))).unwrap_or(0).min(styles.len().saturating_sub(1)); // A compact ribbon group (#662, #1285) trims the gallery to fewer tiles before it folds. let visible = if crate::ribbon_fit::compact(ui.ctx()) { STYLE_TILES_COMPACT } else { STYLE_TILES }; crate::ribbon_fit::add_saving(ui.ctx(), (STYLE_TILES - STYLE_TILES_COMPACT) as f32 * 76.0); @@ -525,9 +525,9 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { ui.painter().rect(area.shrink(1.0), 4.0, t.input, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); for (k, (id, name)) in styles.iter().skip(start).take(visible).enumerate() { let r = Rect::from_min_size(pos2(area.min.x + 3.0 + k as f32 * 76.0, area.min.y + 3.0), vec2(74.0, area.height() - 6.0)); - let resp = ui.interact(r, ui.id().with(("style_tile", id)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("style_tile", id)), Sense::click()); let active = *id == current; - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, name)); if active { ui.painter().rect(r, 3.0, t.checked, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); } else if resp.hovered() { @@ -580,7 +580,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let thirds = ar.height() / 3.0; for (i, (icon, delta)) in [("chevronUp", -1i32), ("chevronDown", 1), ("more", 0)].iter().enumerate() { let br = Rect::from_min_size(pos2(ar.min.x, ar.min.y + thirds * i as f32), vec2(ar.width(), thirds)); - let resp = ui.interact(br, ui.id().with(("gal_arrow", i)), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with(("gal_arrow", i)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(br, 2.0, t.hover); } @@ -590,7 +590,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let _ = app.run("view.stylesPane", json!({"value": true})); } else { let n = if *delta < 0 { start.saturating_sub(visible) } else { (start + visible).min(styles.len().saturating_sub(1)) }; - ui.data_mut(|d| d.insert_temp(egui::Id::new("gallery_start"), n)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("gallery_start"), n)); } } } @@ -791,9 +791,9 @@ mod tests { let worker = std::thread::spawn(move || { let ctx = egui::Context::default(); let preview = Previews::default().font_preview_fn(); - let budget_id = egui::Id::new("font_preview_budget"); - let sans_id = egui::Id::new("font:Source Sans 3"); - let inter_id = egui::Id::new("font:Inter"); + let budget_id = egui::Id::unique("font_preview_budget"); + let sans_id = egui::Id::unique("font:Source Sans 3"); + let inter_id = egui::Id::unique("font:Inter"); ctx.data_mut(|d| d.insert_temp(budget_id, (1.0_f64, 11_u32))); for time in [1.0, 2.0, 3.0] { diff --git a/crates/ui-egui/src/print_page.rs b/crates/ui-egui/src/print_page.rs index a963bc57e2..fa8b7c2a78 100644 --- a/crates/ui-egui/src/print_page.rs +++ b/crates/ui-egui/src/print_page.rs @@ -82,23 +82,40 @@ const NO_SCALING: &str = "none"; const ORIENTATIONS: [(&str, &str); 3] = [("auto", "Auto orientation"), ("portrait", "Portrait"), ("landscape", "Landscape")]; /// A choice list over `(id, label)` pairs; true when the choice changed. -fn choice(ui: &mut Ui, salt: &str, current: &mut String, items: &[(&str, &str)]) -> bool { +fn choice(ui: &mut Ui, salt: &str, current: &mut String, items: &[(&str, &str)], label: &egui::Response) -> bool { let shown = items.iter().find(|(id, _)| *id == current.as_str()).map_or("", |(_, l)| *l); let mut changed = false; - egui::ComboBox::from_id_salt(salt).width(260.0).selected_text(tl!(shown)).show_ui(ui, |ui| { - for (id, label) in items { - if ui.selectable_label(current == id, tl!(label)).clicked() && current != id { + let combo = egui::ComboBox::from_id_salt(salt).width(260.0).selected_text(tl!(shown)).show_ui(ui, |ui| { + for (id, item) in items { + if ui.selectable_label(current == id, tl!(item)).clicked() && current != id { *current = (*id).to_string(); changed = true; } } }); + combo.response.labelled_by(label.id); + changed +} + +/// A choice list named without a visible label beside it (its heading names a group of choices). +fn choice_named(ui: &mut Ui, salt: &str, current: &mut String, items: &[(&str, &str)], name: &str) -> bool { + let shown = items.iter().find(|(id, _)| *id == current.as_str()).map_or("", |(_, l)| *l); + let mut changed = false; + let combo = egui::ComboBox::from_id_salt(salt).width(260.0).selected_text(tl!(shown)).show_ui(ui, |ui| { + for (id, item) in items { + if ui.selectable_label(current == id, tl!(item)).clicked() && current != id { + *current = (*id).to_string(); + changed = true; + } + } + }); + combo.response.accessible_name(name); changed } /// The output layout, kept while the document is unchanged (laying out every frame would be slow). fn layout_of(app: &WordApp, ctx: &egui::Context) -> Arc { - let id = egui::Id::new("print_page_layout"); + let id = egui::Id::unique("print_page_layout"); let rev = app.session.rev(); if let Some((r, l)) = ctx.data(|d| d.get_temp::<(u64, Arc)>(id)) && r == rev @@ -112,7 +129,7 @@ fn layout_of(app: &WordApp, ctx: &egui::Context) -> Arc { /// The texture of one page, re-rendered only when the document, page or size changes. fn page_texture(app: &WordApp, ctx: &egui::Context, lay: &DocLayout, page: usize, width: f32) -> Option { - let id = egui::Id::new("print_page_texture"); + let id = egui::Id::unique("print_page_texture"); let key = (app.session.rev(), page, width as u32, (ctx.pixels_per_point() * 100.0) as u32); if let Some((k, t)) = ctx.data(|d| d.get_temp::<((u64, usize, u32, u32), TextureHandle)>(id)) && k == key @@ -184,10 +201,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.add_enabled(!app.printing.busy(), egui::Checkbox::new(&mut app.ui.print.collate, tl!("Collate copies"))); } } else { - ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0))); + let copies_label = ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0))); ui.horizontal(|ui| { let mut n = app.ui.print.copies; - ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2)); + ui.add(egui::DragValue::new(&mut n).range(1..=wordcraft_engine::print::MAX_COPIES).speed(0.2)).labelled_by(copies_label.id); app.ui.print.copies = n.clamp(1, wordcraft_engine::print::MAX_COPIES); if app.ui.print.copies > 1 { ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies")); @@ -195,8 +212,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); } - ui.label(egui::RichText::new(tl!("Pages")).font(medium(13.0))); - if choice(ui, "print_range", &mut app.ui.print.range, &RANGES) { + let pages_label = ui.label(egui::RichText::new(tl!("Pages")).font(medium(13.0))); + if choice(ui, "print_range", &mut app.ui.print.range, &RANGES, &pages_label) { // Show the first page that will print. app.ui.print.preview = if app.ui.print.range == "all" { 0 } else { current.0.min(total.saturating_sub(1)) }; } @@ -206,16 +223,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { .hint_text(tl!("Pages like 1-3, 5")) .desired_width(260.0) .char_limit(wordcraft_engine::print::MAX_SPEC_CHARS), - ); + ) + .labelled_by(pages_label.id); } - choice(ui, "print_parity", &mut app.ui.print.parity, &PARITIES); - ui.label(egui::RichText::new(tl!("Orientation")).font(medium(13.0))); - choice(ui, "print_orientation", &mut app.ui.print.orientation, &ORIENTATIONS); + choice_named(ui, "print_parity", &mut app.ui.print.parity, &PARITIES, tl!("Pages in the range")); + let orientation_label = ui.label(egui::RichText::new(tl!("Orientation")).font(medium(13.0))); + choice(ui, "print_orientation", &mut app.ui.print.orientation, &ORIENTATIONS, &orientation_label); // Word: Pages per sheet › Scale to paper size. Only the printout changes. - ui.label(egui::RichText::new(tl!("Scale to paper size")).font(medium(13.0))); + let paper_label = ui.label(egui::RichText::new(tl!("Scale to paper size")).font(medium(13.0))); let papers: Vec<(&str, &str)> = std::iter::once((NO_SCALING, "No Scaling")).chain(wordcraft_geom::PAPER_SIZES.iter().map(|(n, _, _)| (*n, *n))).collect(); - choice(ui, "print_paper", &mut app.ui.print.paper, &papers); + choice(ui, "print_paper", &mut app.ui.print.paper, &papers, &paper_label); match wordcraft_engine::print::plan(&app.ui.print.params(), total, current) { Ok(plan) => { @@ -274,8 +292,8 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) { let room = vec2((size.x + 2.0 * PREVIEW_MARGIN).max(ui.available_width()), (size.y + 2.0 * PREVIEW_MARGIN).max(ui.available_height())); let (area, _) = ui.allocate_exact_size(room, Sense::hover()); let r = Rect::from_center_size(area.center(), size); - let resp = ui.interact(r, ui.id().with("print_preview_page"), Sense::hover()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, &label)); + let resp = ui.interact(r, ui.scope_id().with("print_preview_page"), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, &label)); ui.painter().rect_filled(r.translate(vec2(2.0, 3.0)), 0.0, t.page_shadow); ui.painter().rect(r, 0.0, egui::Color32::WHITE, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); let on_sheet = sheet.map_or(r, |f| Rect::from_min_size(r.min + vec2(f.dx, f.dy) * scale, vec2(page_w, page_h) * f.k * scale)); @@ -287,7 +305,7 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) { ui.set_height(PREVIEW_BAR); let nav = |ui: &mut Ui, icon: &str, tip: &str, enabled: bool| { let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip)); let col = if enabled { t.icon } else { t.text_disabled }; if enabled && resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); @@ -316,7 +334,9 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) { if room >= 40.0 { let mut v = z; ui.spacing_mut().slider_width = room.min(110.0); - if ui.add(egui::Slider::new(&mut v, 0.1..=5.0).logarithmic(true).show_value(false)).on_hover_text(tl!("Zoom")).changed() { + let response = ui.add(egui::Slider::new(&mut v, 0.1..=5.0).logarithmic(true).show_value(false)).on_hover_text(tl!("Zoom")); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), tl!("Zoom"))); + if response.changed() { app.ui.print.zoom = Some(v.clamp(0.1, 5.0)); } } diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs index 22f774cfb0..f467100f97 100644 --- a/crates/ui-egui/src/printing.rs +++ b/crates/ui-egui/src/printing.rs @@ -32,7 +32,7 @@ pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate short_edge: &translate("Two-sided: short edge"), grayscale: &translate("Grayscale"), }; - state.show(ui, egui::Id::new("wordcraft_print_settings"), discover, &labels); + state.show(ui, egui::Id::unique("wordcraft_print_settings"), discover, &labels); if let Some(message) = &state.message { ui.label(feedback(message, translate)); } diff --git a/crates/ui-egui/src/read_aloud.rs b/crates/ui-egui/src/read_aloud.rs index 0d3d0fb251..8a8fafa95b 100644 --- a/crates/ui-egui/src/read_aloud.rs +++ b/crates/ui-egui/src/read_aloud.rs @@ -60,7 +60,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { let mut run: Option<(&str, serde_json::Value)> = None; let right_top = app.canvas.canvas_rect.map_or(ctx.content_rect().right_top() + vec2(-24.0, 160.0), |r| r.right_top() + vec2(-24.0, 16.0)); let window = egui::Window::new(tl!("Read Aloud")) - .id(egui::Id::new("read-aloud-player")) + .id(egui::Id::unique("read-aloud-player")) .collapsible(false) .resizable(false) .pivot(egui::Align2::RIGHT_TOP) @@ -94,10 +94,10 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { ui.add_space(6.0); // Speed: applied when the slider is let go, so dragging doesn't restart the sentence // on every frame. - let id = egui::Id::new("read-aloud-rate"); + let id = egui::Id::unique("read-aloud-rate"); let mut rate = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| app.session.read_aloud.rate()); ui.horizontal(|ui| { - ui.label(tl!("Speed")); + let label = ui.label(tl!("Speed")); ui.spacing_mut().slider_width = 110.0; let r = ui.add( egui::Slider::new(&mut rate, MIN_RATE..=MAX_RATE) @@ -106,6 +106,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { .custom_formatter(|v, _| format!("{v:.2}×")) .custom_parser(|s| s.trim().trim_end_matches(['×', 'x']).trim().parse().ok()), ); + let r = r.labelled_by(label.id); if r.dragged() { ui.data_mut(|d| d.insert_temp(id, rate)); } else { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 2fe11d765b..eb76e270be 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -352,7 +352,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if share_text { ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent); } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, share)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, share)); if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() { let _ = app.run("ui.backstage", json!({"value": true, "page": "export"})); } @@ -401,7 +401,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::keytips::record_tab(r, tab, "", &mut app.keytip_rects); } let active = app.ui.tab == tab && !app.ui.backstage; - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, shown)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown)); if resp.hovered() && !active { ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); } @@ -464,7 +464,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ); // "»" means nothing read aloud: the button says what it holds. let more = tl!("More tabs"); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, more)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, more)); let resp = resp.on_hover_text(more); egui::Popup::menu(&resp).show(|ui| { for (tab, shown) in left_out { @@ -494,11 +494,19 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if !app.has_document() && app.ui.tab != "Help" { ui.disable(); } - let bg = ui.interact(ui.max_rect(), ui.id().with("ribbon_background"), Sense::click()); + let bg = ui.interact(ui.max_rect(), ui.scope_id().with("ribbon_background"), Sense::click()); bg.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); let entry = user_tabs.iter().find(|e| e.name == app.ui.tab); crate::ribbon_fit::begin(ui.ctx(), &app.ui.tab, ui.available_width() - 4.0); - let scroll = egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| { + // 0.37 keeps the scroll offset private: an edge-chevron click leaves a one-frame + // request here and the next scroll area is built at that offset. + let pending_id = ui.scope_id().with("ribbon_scroll_pending"); + let pending = ui.data(|d| d.get_temp::(pending_id)); + let mut area = egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden); + if let Some(x) = pending { + area = area.horizontal_scroll_offset(x); + } + let scroll = area.show(ui, |ui| { ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0); match entry { @@ -535,12 +543,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } }); }); + if pending.is_some() { + let _ = ui.data_mut(|d| d.remove_temp::(pending_id)); + } crate::ribbon_fit::end(ui.ctx()); // The bar is hidden, so a row wider than the window would be clipped with no sign. Edge chevrons show // where more is and scroll it on click. Every command also stays reachable through Search Commands. let r = scroll.inner_rect; let overflow = scroll.content_size.x - r.width(); - let offset = scroll.state.offset.x; + let offset = scroll.state.clamped_offset().x; let step = 160.0; let mut next = None; if offset > 1.0 { @@ -556,9 +567,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } if let Some(x) = next { - let mut state = scroll.state; - state.offset.x = x.clamp(0.0, overflow.max(0.0)); - state.store(ui.ctx(), scroll.id); + ui.data_mut(|d| d.insert_temp(pending_id, x.clamp(0.0, overflow.max(0.0)))); + ui.ctx().request_repaint(); } }); } @@ -603,7 +613,7 @@ fn two_lines(label: &str) -> String { /// A chevron over a ribbon edge that is also a button; `true` on the frame it is clicked. fn edge_cue(ui: &mut Ui, rect: Rect, glyph: &str, hint: &str, t: &Tokens) -> bool { - let resp = ui.interact(rect, ui.id().with(glyph), Sense::click()).on_hover_text(hint); + let resp = ui.interact(rect, ui.scope_id().with(glyph), Sense::click()).on_hover_text(hint); let ink = if resp.hovered() { t.text } else { t.text_dim }; ui.painter().rect_filled(rect, 0.0, t.ribbon); ui.painter().text(rect.center(), Align2::CENTER_CENTER, glyph, egui::FontId::proportional(18.0), ink); @@ -693,7 +703,7 @@ fn underline_item(ui: &mut Ui, app: &mut WordApp, style: &str, name: &str) { let enabled = crate::widgets::enabled(app, "format.underline"); let resp = ui.add_enabled(enabled, egui::Button::new("").min_size(vec2(200.0, 22.0))); let label = tl!(name); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); let t = Tokens::get(ui.ctx()); let color = if enabled { t.text } else { t.text_disabled }; paint_underline(ui.painter(), resp.rect.shrink2(vec2(10.0, 0.0)), style, color); @@ -999,9 +1009,14 @@ fn language_checks(current: &str, listed: impl Fn(&str) -> bool) -> LanguageChec /// A menu item with a check mark when `checked` (the current choice of several). fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str, params: Value) { - // An invisible mark keeps the unchecked labels aligned with the checked one. - let mark = egui::RichText::new("✓"); - let mark = if checked { mark } else { mark.color(egui::Color32::TRANSPARENT) }; + // A vector mark needs no font glyph; the empty atom keeps labels aligned. + let mark = egui::Atom::paint(vec2(14.0, 14.0), move |ui, args| { + if checked { + let r = args.rect; + let points = vec![r.min + r.size() * vec2(0.15, 0.5), r.min + r.size() * vec2(0.4, 0.75), r.min + r.size() * vec2(0.85, 0.2)]; + ui.painter().add(egui::Shape::line(points, egui::Stroke::new(1.5, args.fallback_text_color))); + } + }); let enabled = crate::widgets::enabled(app, id); let resp = ui.add_enabled(enabled, egui::Button::new((mark, tl!(label))).selected(checked).min_size(vec2(200.0, 0.0))); if resp.clicked() { @@ -2093,9 +2108,9 @@ fn record_box(ui: &mut Ui, app: &mut WordApp) { let on = !app.session.merge.rows.is_empty(); let shown = if on { (app.session.merge.record + 1).to_string() } else { String::new() }; // The draft lives in temp memory while typing (frame-local text would lose keystrokes). - let key = egui::Id::new("merge_record_box"); + let key = egui::Id::unique("merge_record_box"); let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| shown.clone()); - let r = ui.add_enabled(on, egui::TextEdit::singleline(&mut text).desired_width(36.0).horizontal_align(egui::Align::Center)); + let r = ui.add_enabled(on, egui::TextEdit::singleline(&mut text).desired_width(36.0).align(egui::Align2::CENTER_TOP)); let r = r.on_hover_text(tl!("Go to Record")); if r.changed() { ui.data_mut(|d| d.insert_temp(key, text.clone())); @@ -2316,7 +2331,7 @@ fn view(app: &mut WordApp, ui: &mut Ui) { // browser has no full screen to ask for, and says so (#662). let on = crate::widgets::enabled(app, "view.fullScreen"); let resp = big_toggle(ui, app, "fullScreen", "Full\nScreen", "view.fullScreen", v.full_screen); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, on, v.full_screen, "Full Screen")); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, v.full_screen, "Full Screen")); }); group(ui, "Macros", None, app, |ui, app| { big(ui, app, "macros", "Macros", "tools.macros", json!({}), false); @@ -2752,7 +2767,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { Some((pos, wordcraft_doc::para::InlineObject::Image { media, .. })) => (media, pos.off), _ => (String::new(), 0), }; - let key = egui::Id::new(("picture_transparency", media, off)); + let key = egui::Id::unique(("picture_transparency", media, off)); let mut v = ui.data(|d| d.get_temp::(key)).unwrap_or(0.0); let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(0.0..=100.0).suffix("%")); if r.changed() { @@ -2827,7 +2842,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { )); // While typing, the field reports each keystroke as a change; only a new // value (Enter, Tab, a click elsewhere, a drag) resizes, and Esc cancels. - let cancelled = esc_cancelled(ui, &r, egui::Id::new(("picture_size_field", key))); + let cancelled = esc_cancelled(ui, &r, egui::Id::unique(("picture_size_field", key))); if r.changed() && v != was && !cancelled { if r.dragged() && !r.drag_started() { app.session.join_next_undo(); @@ -2866,7 +2881,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { ui.label(egui::RichText::new(tl!("Alt:")).small()); // Frame-local buffers lose keystrokes; keep the draft in egui temp memory keyed // by picture (same pattern as the comment editor in panes.rs). - let key = egui::Id::new(("picture_alt", media.clone(), off)); + let key = egui::Id::unique(("picture_alt", media.clone(), off)); let mut alt = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| alt0.clone()); let r = ui.add(egui::TextEdit::singleline(&mut alt).desired_width(120.0).hint_text(tl!("Alt text"))); if r.changed() { diff --git a/crates/ui-egui/src/ribbon_fit.rs b/crates/ui-egui/src/ribbon_fit.rs index 5633935841..c904b0c8fc 100644 --- a/crates/ui-egui/src/ribbon_fit.rs +++ b/crates/ui-egui/src/ribbon_fit.rs @@ -93,19 +93,19 @@ struct Measured { } fn frame_id() -> Id { - Id::new("ribbon_fit_frame") + Id::unique("ribbon_fit_frame") } fn measured_id() -> Id { - Id::new("ribbon_fit_measured") + Id::unique("ribbon_fit_measured") } fn compact_id() -> Id { - Id::new("ribbon_fit_compact") + Id::unique("ribbon_fit_compact") } fn saving_id() -> Id { - Id::new("ribbon_fit_saving") + Id::unique("ribbon_fit_saving") } fn active_id() -> Id { - Id::new("ribbon_fit_active") + Id::unique("ribbon_fit_active") } /// What the measured widths depend on besides the groups themselves. diff --git a/crates/ui-egui/src/ruler_tabs.rs b/crates/ui-egui/src/ruler_tabs.rs index 9b68e90311..9cd8c51715 100644 --- a/crates/ui-egui/src/ruler_tabs.rs +++ b/crates/ui-egui/src/ruler_tabs.rs @@ -118,7 +118,7 @@ pub(crate) fn selector(app: &mut WordApp, ui: &Ui, corner: Rect) { return; } let t = Tokens::get(ui.ctx()); - let r = ui.interact(corner, ui.id().with("ruler_tab_kind"), Sense::click()); + let r = ui.interact(corner, ui.scope_id().with("ruler_tab_kind"), Sense::click()); if r.clicked() { app.canvas.tab_kind = next_kind(app.canvas.tab_kind, false); } else if r.secondary_clicked() { @@ -186,7 +186,7 @@ pub(crate) fn stops(app: &mut WordApp, ui: &Ui, hp: &Painter, g: &Ruler<'_>, tab // A click on an empty stretch over the text sets a stop of the selector's kind there; pressing // and dragging places it where the button is released. The edges stay with the margin handles // and indent markers, and only the primary button places stops. - let add = ui.interact(g.text.shrink2(vec2(4.0, 0.0)), ui.id().with("ruler_tab_add"), Sense::click_and_drag()); + let add = ui.interact(g.text.shrink2(vec2(4.0, 0.0)), ui.scope_id().with("ruler_tab_add"), Sense::click_and_drag()); let kind = app.canvas.tab_kind; let placing = |x: f32| TabDrag { from: None, para: para.clone(), align: kind, leader: TabLeader::None, at: snap(x), off: false }; if add.clicked() @@ -204,8 +204,11 @@ pub(crate) fn stops(app: &mut WordApp, ui: &Ui, hp: &Painter, g: &Ruler<'_>, tab let mut open_dialog = false; for (i, tab) in tabs.iter().enumerate() { let x = (g.x_of)(tab.pos); - let r = - ui.interact(Rect::from_center_size(pos2(x, g.bar.max.y - 4.0), vec2(9.0, 10.0)), ui.id().with(("ruler_tab", i)), Sense::click_and_drag()); + let r = ui.interact( + Rect::from_center_size(pos2(x, g.bar.max.y - 4.0), vec2(9.0, 10.0)), + ui.scope_id().with(("ruler_tab", i)), + Sense::click_and_drag(), + ); let starts = r.drag_started_by(egui::PointerButton::Primary); if starts { app.canvas.tab_drag = @@ -240,13 +243,15 @@ pub(crate) fn stops(app: &mut WordApp, ui: &Ui, hp: &Painter, g: &Ruler<'_>, tab draw_tab_glyph(hp, x, base, d.align, g.rtl, stroke); // A guide down the page shows where text will line up. if let Some(area) = app.canvas.canvas_rect { - let guide = - ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, ui.id().with("ruler_tab_guide"))).with_clip_rect(area); + let guide = ui + .ctx() + .layer_painter(egui::LayerId::new(egui::Order::Foreground, ui.scope_id().with("ruler_tab_guide"))) + .with_clip_rect(area); guide.extend(Shape::dashed_line(&[pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, t.text_dim), 3.0, 3.0)); } format!("{} {}", tl!(kind_label(d.align)), units.format_with(d.at, decimal)) }; - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), ui.id().with("ruler_tab_tip"), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), ui.scope_id().with("ruler_tab_tip"), egui::PopupAnchor::Pointer).show(|ui| { ui.label(label); }); } else { diff --git a/crates/ui-egui/src/scroll.rs b/crates/ui-egui/src/scroll.rs index c8d6ccb864..3e877e8583 100644 --- a/crates/ui-egui/src/scroll.rs +++ b/crates/ui-egui/src/scroll.rs @@ -14,6 +14,8 @@ //! stays: it carries on the fingers' own movement, as the momentum macOS and Windows send does //! whatever their animation setting. +#[cfg(test)] +use egui::MouseWheelSource; use egui::{Event, InputOptions, Modifiers, MouseWheelUnit, TouchPhase, Vec2}; /// Screen points one mouse-wheel notch scrolls at 100% zoom: about three lines of body text, @@ -177,7 +179,7 @@ impl CanvasScroll { let mut zooming = false; for event in &input.events { match event { - Event::MouseWheel { unit, delta, phase, modifiers } => { + Event::MouseWheel { unit, delta, phase, modifiers, .. } => { // Ctrl/⌘+wheel zooms (egui turns it into a zoom factor). if !hovered { continue; @@ -275,7 +277,7 @@ impl CanvasScroll { for event in &input.events { match event { Event::Zoom(factor) => zoom *= factor, - Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } if modifiers.matches_any(opts.zoom_modifier) => { + Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers, .. } if modifiers.matches_any(opts.zoom_modifier) => { let d = egui_points(*unit, *delta, input, opts); turned += opts.scroll_zoom_speed * (d.x + d.y); } @@ -326,7 +328,7 @@ fn egui_points(unit: MouseWheelUnit, delta: Vec2, input: &egui::InputState, opts pub fn unsmoothed_scroll(input: &egui::InputState, opts: &InputOptions) -> Vec2 { let mut total = Vec2::ZERO; for event in &input.events { - if let Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } = event + if let Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers, .. } = event && !modifiers.matches_any(opts.zoom_modifier) { total += axis_lock(egui_points(*unit, *delta, input, opts), *modifiers, opts); @@ -415,7 +417,7 @@ mod tests { } fn wheel(unit: MouseWheelUnit, delta: Vec2, modifiers: Modifiers) -> Event { - Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } + Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers, source: MouseWheelSource::Unknown } } /// One 60 fps frame over a tall egui scroll area with the pointer on it; `animate` false puts @@ -432,12 +434,12 @@ mod tests { if !animate { ui.ctx().input_mut(|i| i.smooth_scroll_delta = unsmoothed_scroll(i, &opts)); } - let id = egui::Id::new("canvas scroll under test"); + let id = egui::Id::unique("canvas scroll under test"); let mut scroll = ui.data(|d| d.get_temp::(id)).unwrap_or_default(); let zoom = ui.input(|i| scroll.zoom(i, &opts, animate)); ui.data_mut(|d| d.insert_temp(id, scroll)); let out = egui::ScrollArea::vertical().show(ui, |ui| ui.allocate_space(egui::vec2(100.0, 5000.0))); - seen = (out.state.offset.y, zoom); + seen = (out.state.clamped_offset().y, zoom); }) .drop_without_applying_deltas(); seen diff --git a/crates/ui-egui/src/table_ruler.rs b/crates/ui-egui/src/table_ruler.rs index 63bad91d3f..39f48876c4 100644 --- a/crates/ui-egui/src/table_ruler.rs +++ b/crates/ui-egui/src/table_ruler.rs @@ -56,8 +56,8 @@ fn grip(app: &mut WordApp, ui: &Ui, layout: &DocLayout, zone: Rect, column: bool } else { ("ruler_row", tl!("Table Row Height"), egui::CursorIcon::ResizeRow) }; - let r = ui.interact(zone, ui.id().with((name, i)), Sense::drag()).on_hover_cursor(cursor); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, label)); + let r = ui.interact(zone, ui.scope_id().with((name, i)), Sense::drag()).on_hover_cursor(cursor); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Splitter, true, label)); let r = if r.dragged() { r } else { r.on_hover_text(label) }; if r.drag_started() { crate::table_resize::grab(app, layout, at.0, at.1, at.2); diff --git a/crates/ui-egui/src/tests_perf.rs b/crates/ui-egui/src/tests_perf.rs index 45126f649a..ff857474de 100644 --- a/crates/ui-egui/src/tests_perf.rs +++ b/crates/ui-egui/src/tests_perf.rs @@ -41,6 +41,7 @@ fn wheel(h: &mut egui_kittest::Harness<'static, WordApp>, dy: f32) { delta: egui::vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 4afd0bb955..29d18cbda5 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -916,6 +916,20 @@ mod tests { } } + /// The test root declares egui's `default_fonts` and `monochrome_emoji_fonts` features + /// explicitly (kittest's own dev-dependencies do not propagate). WordCraft inserts Inter at + /// the front and SourceSans after egui's complete default fallback block, including the + /// monochrome emoji faces. Production keeps only `default_fonts` through eframe. + #[test] + fn interface_font_order_keeps_test_default_fonts_explicit() { + let defs = super::font_definitions(false, false); + let prop = &defs.families[&egui::FontFamily::Proportional]; + assert_eq!( + prop.iter().take(6).map(String::as_str).collect::>(), + ["Inter", "Ubuntu-Light", "egui-icons", "NotoEmoji-Regular", "emoji-icon-font", "SourceSans"] + ); + } + /// #565: the composition of an input method (Bangla through ibus) drew as boxes because only the /// interface fonts were used. Faces found for the uncovered characters are added as fallbacks, /// once, and not for characters the interface fonts draw. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 26996c2561..0422b7557d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -85,7 +85,7 @@ fn show_hint(resp: &Response, title: &str, notes: &[String]) { let ctx = &resp.ctx; let since_key = id_since_focus(resp); // The pass on which the pointer was last over any hinted control (see the focus hint below). - let hover_key = egui::Id::new("hint_pointer_over_a_control"); + let hover_key = egui::Id::unique("hint_pointer_over_a_control"); let pass = ctx.cumulative_pass_nr(); // A disabled response never reports hover, so test the pointer against its rectangle, as // `Tooltip::for_disabled` does. @@ -291,7 +291,7 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti if resp.clicked() && on { run_from_ribbon(app, id, params); } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tip)); resp } @@ -330,10 +330,10 @@ pub fn split( } let (arrow_title, _) = tooltip_lines(&crate::i18n::fmt(tl!("{name} options"), &[("name", tip)]), "", "", None); show_hint(&aresp, &arrow_title, &[]); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tip)); // The arrow opens the choices beside the button: it says whose, so it is not just "button" // (#3172). - aresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, &arrow_title)); + aresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, &arrow_title)); if !on { return; } @@ -399,7 +399,7 @@ pub fn menu_button( let reason = if on { None } else { Some(app.session.why_disabled(id).unwrap_or("")) }; let (title, notes) = tooltip_lines(tip, &shortcut_text(app, id), "", reason); show_hint(&resp, &title, ¬es); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tip)); if !on { return; } @@ -474,7 +474,7 @@ fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option ui.painter().text(pos2(r.center().x, title_y), Align2::CENTER_CENTER, title, regular(11.0), t.group_label); if let Some(cmd) = launcher { let lr = Rect::from_center_size(pos2(r.max.x - 6.0, title_y), vec2(11.0, 11.0)); - let resp = ui.interact(lr, ui.id().with(("launch", id_title)), Sense::click()); + let resp = ui.interact(lr, ui.scope_id().with(("launch", id_title)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(lr.expand(2.0), 2.0, t.hover); } @@ -487,7 +487,7 @@ fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option /// Where a collapsed group remembers whether its drop-down is open. fn collapsed_open_id(ui: &Ui, id_title: &str, position: usize) -> egui::Id { - ui.id().with(("collapsed_group", id_title, position)) + ui.scope_id().with(("collapsed_group", id_title, position)) } /// A group folded into one drop-down button (the window is too narrow, #662): the button, as tall @@ -507,7 +507,7 @@ fn collapsed_group( let t = Tokens::get(ui.ctx()); let text_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.5), t.text).size().x); let (r, resp) = ui.allocate_exact_size(vec2((text_w + DROPDOWN + 14.0).max(48.0), CONTENT_H + LABEL_H), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title)); let open_id = collapsed_open_id(ui, id_title, position); let was_open = ui.data(|d| d.get_temp::(open_id)).unwrap_or(false); bg(ui, r, &resp, was_open, &t); @@ -520,7 +520,7 @@ fn collapsed_group( ); label_line(ui, r.center().x, r.min.y + BIG_LABEL_Y, title, regular(11.5), t.text, true); let resp = resp.on_hover_text(title); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title)); let mut open = was_open; if resp.clicked() { open = !open; @@ -746,7 +746,7 @@ pub fn font_combo( items: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>, ) -> Option { - combo_impl(ui, id, width, current, items, &[], preview, true, "") + combo_impl(ui, id, width, current, items, &[], preview, true, tl!("Font")) } /// [`font_combo`] with the `recent` families (most recent first) in a Recently Used Fonts @@ -763,7 +763,7 @@ pub fn font_combo_with_recent( recent: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>, ) -> Option { - combo_impl(ui, id, width, current, items, recent, preview, true, "") + combo_impl(ui, id, width, current, items, recent, preview, true, tl!("Font")) } /// [`font_combo_with_recent`] with a hover hint `tip` (already translated). @@ -839,9 +839,9 @@ fn combo_impl( tip: &str, ) -> Option { let mut out = None; - let edit_id = ui.id().with(("combo_text", id)); - let te_id = ui.id().with(("combo_edit", id)); - let search_id = ui.id().with(("combo_search", id)); + let edit_id = ui.scope_id().with(("combo_text", id)); + let te_id = ui.scope_id().with(("combo_edit", id)); + let search_id = ui.scope_id().with(("combo_search", id)); let mut text = ui.data_mut(|d| d.get_temp::(edit_id)).unwrap_or_else(|| current.to_string()); let mut s: ComboSearch = if search { ui.data_mut(|d| d.get_temp(search_id)).unwrap_or_default() } else { ComboSearch::default() }; let mut focused = false; @@ -875,7 +875,7 @@ fn combo_impl( // The field says what it is: without a name a screen reader has only "edit" to say // (#3172). The arrow says whose options it opens, as the split buttons' arrow does. if !tip.is_empty() { - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, ui.is_enabled(), tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), tip)); } keytip_badge(ui, resp.rect, "combo", id); focused = resp.has_focus(); @@ -931,7 +931,7 @@ fn combo_impl( let (ar, aresp) = ui.allocate_exact_size(vec2(16.0, resp.rect.height()), Sense::click()); if !tip.is_empty() { let arrow_name = crate::i18n::fmt(tl!("{name} options"), &[("name", tip)]); - aresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &arrow_name)); + aresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &arrow_name)); } // It reaches one border width under the field, so the field's right border is the only line // between them: two borders side by side read as a double-width divider. @@ -1155,7 +1155,7 @@ fn list_rows<'a>( } } }); - (out, shown.state.offset.y, shown.inner_rect.height()) + (out, shown.state.clamped_offset().y, shown.inner_rect.height()) } /// How a drawn button looks this frame. Hover fades in over egui's animation time and the @@ -1253,7 +1253,7 @@ fn icon_text_button_with(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId let text_w = if show_text { galley.size().x } else { 0.0 }; let size = vec2(icon_text_button_size(ui, text_w, marker, show_text), galley.size().y.max(icon_w) + 2.0 * pad.y); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), text)); let st = drawn_state(ui, &resp, true); let r = r.expand(st.expansion); ui.painter().rect(r, ui.visuals().widgets.inactive.corner_radius, st.fill, st.stroke, egui::StrokeKind::Inside); @@ -1286,7 +1286,7 @@ pub fn icon_menu_item(ui: &mut Ui, icon: &str, text: &str, shortcut: &str, min_w let sc_w = sc.as_ref().map_or(0.0, |g| g.size().x + 3.0 * pad.x); let w = (pad.x + icon_w + 6.0 + galley.size().x + sc_w + pad.x).max(min_w).max(ui.available_width()); let (r, resp) = ui.allocate_exact_size(vec2(w, galley.size().y.max(icon_w) + 2.0 * pad.y), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), text)); let st = drawn_state(ui, &resp, false); ui.painter().rect_filled(r, ui.visuals().widgets.inactive.corner_radius, st.fill); let (line, accent) = if ui.is_enabled() { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; @@ -1587,7 +1587,7 @@ mod tests { for (n, events) in frames.into_iter().enumerate() { ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { if n == 0 { - ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f")))); + ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); } let v = if search { font_combo_with_recent(ui, "f", 150.0, "", items, recent, None) } else { combo(ui, "f", 150.0, "", items, None) }; out = out.take().or(v); @@ -1648,9 +1648,9 @@ mod tests { for (n, events) in frames.into_iter().enumerate() { ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { if n == 0 { - ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f")))); + ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); } - area = Some(ui.id().with(("combo_search", "f")).with("list")); + area = Some(ui.scope_id().with(("combo_search", "f")).with("list")); font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None); }) .drop_without_applying_deltas(); @@ -1689,7 +1689,7 @@ mod tests { (vec![egui::Event::Text("ar".into())], "Arial Black"), ]; ctx.run_ui(egui::RawInput::default(), |ui| { - ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f")))); + ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None); }) .drop_without_applying_deltas(); @@ -1768,7 +1768,7 @@ mod tests { crate::theme::apply(&ctx, appearance); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { if focused { - ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f")))); + ui.memory_mut(|m| m.request_focus(ui.scope_id().with(("combo_edit", "f")))); } combo(ui, "f", 150.0, "12", &sizes, None); }); diff --git a/crates/ui-egui/src/window_close.rs b/crates/ui-egui/src/window_close.rs index 8b2bc60506..a60ece7639 100644 --- a/crates/ui-egui/src/window_close.rs +++ b/crates/ui-egui/src/window_close.rs @@ -57,7 +57,7 @@ pub(crate) fn show(ctx: &egui::Context, window: &Response, t: &Tokens) -> bool { // Pointer-only, like a caption button: Tab never lands on it. let r = ui.interact(rect, id.with("hit"), Sense::CLICK | Sense::DRAG); let label = tl!("Close"); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); let r = r.on_hover_text(label); let down = r.is_pointer_button_down_on() && r.contains_pointer(); let hot = down || r.hovered(); @@ -144,14 +144,14 @@ mod tests { for _ in 0..4 { h.step(); } - click_close_box(&mut h, egui::Id::new("read-aloud-player")); + click_close_box(&mut h, egui::Id::unique("read-aloud-player")); assert!(!h.state().session.read_aloud.open, "the player stops and closes"); let _ = h.state_mut().run("ui.fileBrowser", json!({"purpose": "document"})); for _ in 0..4 { h.step(); } assert!(h.state().file_browser.open.is_some(), "the browser opened"); - click_close_box(&mut h, egui::Id::new("builtin_file_browser")); + click_close_box(&mut h, egui::Id::unique("builtin_file_browser")); assert!(h.state().file_browser.open.is_none(), "the browser is cancelled"); } @@ -165,7 +165,7 @@ mod tests { for _ in 0..4 { h.step(); } - let hit = egui::Id::new(("dialog", "Word Count")).with("close_box").with("hit"); + let hit = egui::Id::unique(("dialog", "Word Count")).with("close_box").with("hit"); let r = h.ctx.read_response(hit).expect("the close box is a widget"); assert!(r.sense.senses_click() && !r.sense.is_focusable(), "pointer-only: {:?}", r.sense); for _ in 0..12 { diff --git a/crates/ui-egui/src/window_geometry.rs b/crates/ui-egui/src/window_geometry.rs index e17769d643..5601eac941 100644 --- a/crates/ui-egui/src/window_geometry.rs +++ b/crates/ui-egui/src/window_geometry.rs @@ -6,6 +6,48 @@ use serde::{Deserialize, Serialize}; +/// The host's window state for one raw frame. +/// +/// [`egui::InputState::viewport`] still describes the last painted frame on a hidden logic tick, +/// and its `viewport_rect()` is that frame's content size. The host's [`egui::RawInput`] is the +/// fresh answer for this frame, so the app copies the fields it needs here in `raw_input_hook`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RawWindowState { + /// The content rectangle, when the platform reports it (not Wayland). + pub inner_rect: Option, + /// The window rectangle including decorations, when the platform reports it. + pub outer_rect: Option, + /// This frame's `RawInput::screen_rect`: the content size even where `inner_rect` is unknown. + pub screen_rect: Option, + pub monitor_size: Option, + pub maximized: Option, + pub minimized: Option, + pub fullscreen: Option, +} + +impl RawWindowState { + /// Copy the window state out of this frame's raw input. + pub fn from_raw(raw: &egui::RawInput) -> Self { + let viewport = raw.viewports.get(&raw.viewport_id); + Self { + inner_rect: viewport.and_then(|v| v.inner_rect).filter(|r| r.is_finite()), + outer_rect: viewport.and_then(|v| v.outer_rect).filter(|r| r.is_finite()), + screen_rect: raw.screen_rect.filter(|r| r.is_finite()), + monitor_size: viewport.and_then(|v| v.monitor_size), + maximized: viewport.and_then(|v| v.maximized), + minimized: viewport.and_then(|v| v.minimized), + fullscreen: viewport.and_then(|v| v.fullscreen), + } + } + + /// The content size reported by this frame, if any. Never the last painted size: a hidden + /// Wayland tick with neither an `inner_rect` nor a screen rect has no usable geometry. + pub fn content_size(self) -> Option { + let size = self.inner_rect.or(self.screen_rect)?.size(); + (size.is_finite() && size.x > 0.0 && size.y > 0.0).then_some(size) + } +} + /// The smallest window restored: the desktop app's minimum inner size. pub const MIN_SIZE: egui::Vec2 = egui::vec2(760.0, 480.0); /// Larger saved sizes are corrupt. eframe also shrinks the start size to the largest monitor. @@ -35,21 +77,26 @@ impl WindowGeometry { /// The geometry to save, given the last saved one and the window as it is now. While the /// window is minimized, full screen or maximized, the last un-maximized size and position are /// kept, so un-maximizing after the next launch returns to them. - pub fn track(prev: Option, info: &egui::ViewportInfo, content_size: egui::Vec2) -> Option { - if info.minimized == Some(true) || info.fullscreen == Some(true) { + pub fn track(prev: Option, window: &RawWindowState) -> Option { + if window.minimized == Some(true) || window.fullscreen == Some(true) { return prev; } // egui reads the maximized state on macOS only when the window opens (egui#3494), so a // window that opened zoomed would stay "maximized" for good. There the zoomed frame is // saved like any other size instead. - let maximized = info.maximized == Some(true) && !cfg!(target_os = "macos"); + let maximized = window.maximized == Some(true) && !cfg!(target_os = "macos"); if maximized && let Some(prev) = prev { return Some(Self { maximized, ..prev }); } - let rect = if cfg!(target_os = "macos") { info.inner_rect } else { info.outer_rect }; - let size = info.inner_rect.map_or(content_size, |r| r.size()); + let rect = if cfg!(target_os = "macos") { window.inner_rect } else { window.outer_rect }; + // No rect on this raw frame: keep the last saved geometry. Falling back to + // `InputState::viewport_rect()` here would save the previous painted size, which is the + // stale value a hidden Wayland tick must not write. + let Some(size) = window.content_size() else { + return prev; + }; let position = rect.map(|r| [r.min.x, r.min.y]); - let monitor = info.monitor_size.map(|s| [s.x, s.y]); + let monitor = window.monitor_size.map(|s| [s.x, s.y]); Self { position, width: size.x, height: size.y, maximized, monitor }.sanitized().or(prev) } @@ -94,12 +141,12 @@ impl WindowGeometry { /// position no monitor covers any more doesn't open off screen. Centred on the primary /// monitor, whose top-left corner is the origin. A maximized window fills whichever monitor /// the system picks, so it stays put. - pub fn rescue_position(&self, info: &egui::ViewportInfo) -> Option { + pub fn rescue_position(&self, window: &RawWindowState) -> Option { if self.maximized || self.position.is_none() { return None; } let saved = egui::Vec2::from(self.monitor?); - let now = info.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?; + let now = window.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?; if (now - saved).length() <= 1.0 { return None; } @@ -115,13 +162,13 @@ impl WindowGeometry { /// (no position here) is on the primary monitor, whose top-left corner is the origin, so its /// position counts too; a restored one may be on another monitor, so only its size does. A /// maximized window fills its monitor already. - pub fn fit_on_monitor(&self, info: &egui::ViewportInfo) -> Option<(egui::Pos2, egui::Vec2)> { - if self.maximized || info.maximized == Some(true) || info.fullscreen == Some(true) || info.minimized == Some(true) { + pub fn fit_on_monitor(&self, window: &RawWindowState) -> Option<(egui::Pos2, egui::Vec2)> { + if self.maximized || window.maximized == Some(true) || window.fullscreen == Some(true) || window.minimized == Some(true) { return None; } - let monitor = info.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?; - let inner = info.inner_rect.filter(|r| r.is_finite())?; - let outer = info.outer_rect.filter(|r| r.is_finite()).unwrap_or(inner); + let monitor = window.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?; + let inner = window.inner_rect.filter(|r| r.is_finite())?; + let outer = window.outer_rect.filter(|r| r.is_finite()).unwrap_or(inner); let frame = (outer.size() - inner.size()).max(egui::Vec2::ZERO); let room = monitor - 2.0 * SYSTEM_BAR; let too_big = outer.width() > room.x || outer.height() > room.y; @@ -137,11 +184,11 @@ impl WindowGeometry { } /// Checks the restored geometry in `pending` once, and only once the monitor size is known: while -/// `info` doesn't report a usable one, `pending` is left in place for a later frame. Then it is taken +/// `window` doesn't report a usable one, `pending` is left in place for a later frame. Then it is taken /// and [`WindowGeometry::rescue_position`] decides where to move the window, if anywhere. -pub fn take_rescue(pending: &mut Option, info: &egui::ViewportInfo) -> Option { - info.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?; - pending.take()?.rescue_position(info) +pub fn take_rescue(pending: &mut Option, window: &RawWindowState) -> Option { + window.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0)?; + pending.take()?.rescue_position(window) } /// The window size to ask for when a caller (the control channel's `ui.resize`) wants `size`: @@ -164,8 +211,8 @@ pub fn requested_size(size: egui::Vec2, max_side: f32) -> Option { mod tests { use super::*; - fn info(rect: egui::Rect, monitor: egui::Vec2) -> egui::ViewportInfo { - egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), monitor_size: Some(monitor), ..Default::default() } + fn raw(rect: egui::Rect, monitor: egui::Vec2) -> RawWindowState { + RawWindowState { inner_rect: Some(rect), outer_rect: Some(rect), screen_rect: Some(rect), monitor_size: Some(monitor), ..Default::default() } } fn saved() -> WindowGeometry { @@ -175,7 +222,7 @@ mod tests { #[test] fn records_the_window_and_restores_it() { let rect = egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(1200.0, 800.0)); - let g = WindowGeometry::track(None, &info(rect, egui::vec2(1920.0, 1080.0)), egui::Vec2::ZERO); + let g = WindowGeometry::track(None, &raw(rect, egui::vec2(1920.0, 1080.0))); assert_eq!(g, Some(saved())); let b = saved().apply(egui::ViewportBuilder::default()); assert_eq!(b.inner_size, Some(egui::vec2(1200.0, 800.0))); @@ -203,9 +250,9 @@ mod tests { #[test] fn maximized_keeps_the_normal_size_and_position() { let full = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1920.0, 1040.0)); - let mut i = info(full, egui::vec2(1920.0, 1080.0)); + let mut i = raw(full, egui::vec2(1920.0, 1080.0)); i.maximized = Some(true); - let g = WindowGeometry::track(Some(saved()), &i, egui::Vec2::ZERO); + let g = WindowGeometry::track(Some(saved()), &i); if cfg!(target_os = "macos") { // The zoomed frame is saved as the size; the window never reopens "maximized". assert_eq!(g.map(|g| (g.position, g.width, g.height, g.maximized)), Some((Some([0.0, 0.0]), 1920.0, 1040.0, false))); @@ -215,21 +262,69 @@ mod tests { // Minimized or full screen: nothing changes. i.maximized = Some(false); i.minimized = Some(true); - assert_eq!(WindowGeometry::track(Some(saved()), &i, egui::Vec2::ZERO), Some(saved())); + assert_eq!(WindowGeometry::track(Some(saved()), &i), Some(saved())); i.minimized = None; i.fullscreen = Some(true); - assert_eq!(WindowGeometry::track(Some(saved()), &i, egui::Vec2::ZERO), Some(saved())); + assert_eq!(WindowGeometry::track(Some(saved()), &i), Some(saved())); } #[test] - fn without_a_position_the_content_size_is_kept() { - // Wayland reports neither rect. - let i = egui::ViewportInfo::default(); - let g = WindowGeometry::track(None, &i, egui::vec2(1000.0, 700.0)); + fn without_a_position_the_raw_screen_size_is_kept() { + // Wayland reports no inner/outer rect, but RawInput still carries the content screen rect. + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); + let i = RawWindowState { screen_rect: Some(rect), ..Default::default() }; + let g = WindowGeometry::track(None, &i); assert_eq!(g, Some(WindowGeometry { position: None, width: 1000.0, height: 700.0, maximized: false, monitor: None })); assert_eq!(g.map(|g| g.apply(egui::ViewportBuilder::default()).position), Some(None)); } + /// A hidden Wayland logic tick has no fresh rect. It must keep the saved geometry rather than + /// write the previous painted `InputState` size, and a fresh `screen_rect` on the same raw + /// frame is used when the host reports one. + #[test] + fn a_hidden_raw_tick_never_reuses_the_last_painted_size() { + assert_eq!(WindowGeometry::track(Some(saved()), &RawWindowState::default()), Some(saved())); + + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)); + let fresh = RawWindowState { + inner_rect: None, + outer_rect: None, + screen_rect: Some(rect), + monitor_size: Some(egui::vec2(1920.0, 1080.0)), + ..Default::default() + }; + let g = WindowGeometry::track(Some(saved()), &fresh).expect("the fresh raw size"); + assert_eq!((g.position, g.width, g.height, g.monitor), (None, 1000.0, 700.0, Some([1920.0, 1080.0]))); + } + + #[test] + fn raw_window_state_reads_only_this_frames_viewport() { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0)); + let mut input = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + input.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { + inner_rect: Some(rect), + outer_rect: Some(rect.expand(10.0)), + monitor_size: Some(egui::vec2(1920.0, 1080.0)), + maximized: Some(false), + minimized: Some(false), + fullscreen: Some(false), + ..Default::default() + }, + ); + let state = RawWindowState::from_raw(&input); + assert_eq!(state.inner_rect, Some(rect)); + assert_eq!(state.outer_rect, Some(rect.expand(10.0))); + assert_eq!(state.content_size(), Some(egui::vec2(640.0, 480.0))); + assert_eq!(state.monitor_size, Some(egui::vec2(1920.0, 1080.0))); + + // A frame without its viewport entry (or with non-finite rects) contributes nothing. + let empty = RawWindowState::from_raw(&egui::RawInput::default()); + assert_eq!(empty, RawWindowState::default()); + assert_eq!(empty.content_size(), None); + } + #[test] fn insane_values_are_clamped_or_dropped() { let zero = WindowGeometry { width: 0.0, height: 800.0, ..saved() }; @@ -248,16 +343,16 @@ mod tests { fn a_window_whose_monitor_is_gone_is_moved_on_screen() { let on = egui::Rect::from_min_size(egui::pos2(2000.0, 50.0), egui::vec2(1200.0, 800.0)); // Same monitor: stays where it was. - assert_eq!(saved().rescue_position(&info(on, egui::vec2(1920.0, 1080.0))), None); + assert_eq!(saved().rescue_position(&raw(on, egui::vec2(1920.0, 1080.0))), None); // A different (smaller) monitor now: centred on it. - assert_eq!(saved().rescue_position(&info(on, egui::vec2(1600.0, 900.0))), Some(egui::pos2(200.0, 50.0))); + assert_eq!(saved().rescue_position(&raw(on, egui::vec2(1600.0, 900.0))), Some(egui::pos2(200.0, 50.0))); // Larger than that monitor: its top-left corner. - assert_eq!(saved().rescue_position(&info(on, egui::vec2(1024.0, 768.0))), Some(egui::Pos2::ZERO)); + assert_eq!(saved().rescue_position(&raw(on, egui::vec2(1024.0, 768.0))), Some(egui::Pos2::ZERO)); // Nothing to compare, or no saved position: no move. - assert_eq!(WindowGeometry { monitor: None, ..saved() }.rescue_position(&info(on, egui::vec2(1600.0, 900.0))), None); - assert_eq!(WindowGeometry { position: None, ..saved() }.rescue_position(&info(on, egui::vec2(1600.0, 900.0))), None); - assert_eq!(WindowGeometry { maximized: true, ..saved() }.rescue_position(&info(on, egui::vec2(1600.0, 900.0))), None); - assert_eq!(saved().rescue_position(&egui::ViewportInfo::default()), None); + assert_eq!(WindowGeometry { monitor: None, ..saved() }.rescue_position(&raw(on, egui::vec2(1600.0, 900.0))), None); + assert_eq!(WindowGeometry { position: None, ..saved() }.rescue_position(&raw(on, egui::vec2(1600.0, 900.0))), None); + assert_eq!(WindowGeometry { maximized: true, ..saved() }.rescue_position(&raw(on, egui::vec2(1600.0, 900.0))), None); + assert_eq!(saved().rescue_position(&RawWindowState::default()), None); } #[test] @@ -265,15 +360,15 @@ mod tests { let on = egui::Rect::from_min_size(egui::pos2(2000.0, 50.0), egui::vec2(1200.0, 800.0)); // The first frame doesn't know the monitor yet: nothing happens and the geometry is kept. let mut pending = Some(saved()); - assert_eq!(take_rescue(&mut pending, &egui::ViewportInfo::default()), None); + assert_eq!(take_rescue(&mut pending, &RawWindowState::default()), None); assert_eq!(pending, Some(saved())); // A later frame reports a different monitor: centred on it, and the check is done. - assert_eq!(take_rescue(&mut pending, &info(on, egui::vec2(1600.0, 900.0))), Some(egui::pos2(200.0, 50.0))); + assert_eq!(take_rescue(&mut pending, &raw(on, egui::vec2(1600.0, 900.0))), Some(egui::pos2(200.0, 50.0))); assert_eq!(pending, None); - assert_eq!(take_rescue(&mut pending, &info(on, egui::vec2(1024.0, 768.0))), None); + assert_eq!(take_rescue(&mut pending, &raw(on, egui::vec2(1024.0, 768.0))), None); // The same monitor: no move, and the check is done as well. let mut pending = Some(saved()); - assert_eq!(take_rescue(&mut pending, &info(on, egui::vec2(1920.0, 1080.0))), None); + assert_eq!(take_rescue(&mut pending, &raw(on, egui::vec2(1920.0, 1080.0))), None); assert_eq!(pending, None); } @@ -290,8 +385,8 @@ mod tests { } /// A window opened with its frame's top-left corner at `at`: 8 pt borders, a 31 pt title bar. - fn opened(at: egui::Pos2, content: egui::Vec2, monitor: egui::Vec2) -> egui::ViewportInfo { - egui::ViewportInfo { + fn opened(at: egui::Pos2, content: egui::Vec2, monitor: egui::Vec2) -> RawWindowState { + RawWindowState { inner_rect: Some(egui::Rect::from_min_size(at + egui::vec2(8.0, 31.0), content)), outer_rect: Some(egui::Rect::from_min_size(at, content + egui::vec2(16.0, 39.0))), monitor_size: Some(monitor), @@ -363,6 +458,6 @@ mod tests { i.monitor_size = None; assert_eq!(system.fit_on_monitor(&i), None); // Wayland reports no rects. - assert_eq!(system.fit_on_monitor(&egui::ViewportInfo { monitor_size: Some(laptop), ..Default::default() }), None); + assert_eq!(system.fit_on_monitor(&RawWindowState { monitor_size: Some(laptop), ..Default::default() }), None); } } diff --git a/crates/ui-egui/src/zotero.rs b/crates/ui-egui/src/zotero.rs index e5c4ef32b2..88e69b1e8a 100644 --- a/crates/ui-egui/src/zotero.rs +++ b/crates/ui-egui/src/zotero.rs @@ -230,7 +230,7 @@ pub fn show_alert(app: &mut WordApp, ctx: &egui::Context) { }; let mut picked = None; egui::Window::new("Zotero") - .id(egui::Id::new("zotero-alert")) + .id(egui::Id::unique("zotero-alert")) .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, -40.0)) diff --git a/crates/ui-egui/tests/it/native_print.rs b/crates/ui-egui/tests/it/native_print.rs index 585131e519..c74e6e2352 100644 --- a/crates/ui-egui/tests/it/native_print.rs +++ b/crates/ui-egui/tests/it/native_print.rs @@ -42,7 +42,7 @@ fn control_print_discovery_reports_metadata_without_host_side_effects() { assert!(command["params"].as_str().unwrap().contains(field), "{id}: {field}"); } } - app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true }); app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap(); let Outcome::Done(reply) = handle(&mut app, &ctx, &request) else { panic!("metadata replies immediately") }; for id in ["ui.printSubmit", "ui.print"] { @@ -69,7 +69,7 @@ fn native_print_uses_selected_settings_and_reports_spool_errors() { })); assert!(app.run("ui.print", json!({})).is_err()); assert!(jobs.borrow().is_empty(), "opening settings/exporting does not spool"); - app.printing.printers.push(Printer { name: "Chosen queue".into(), default: false }); + app.printing.printers.push(Printer { name: "Chosen queue".into(), label: String::new(), default: false }); let configured = app.run("ui.printSettings", json!({"printer":"Chosen queue", "copies":3,"duplex":"shortEdge","grayscale":true})).unwrap(); assert_eq!(configured["ready"], true); assert_eq!(configured["printers"][0]["name"], "Chosen queue"); @@ -98,7 +98,7 @@ fn print_settings_are_visible_in_a_narrow_window() { app.run("text.insert", json!({"text": "A document to print"})).unwrap(); app.services.printers = Some(Box::new(|| { let (sender, receiver) = std::sync::mpsc::channel(); - sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap(); + sender.send(vec![Printer { name: "Test queue".into(), label: String::new(), default: true }]).unwrap(); receiver })); app.services.print_native = Some(Box::new(|_, _, _, _| { @@ -127,7 +127,7 @@ fn print_settings_are_visible_in_a_narrow_window() { #[test] fn invalid_print_settings_are_rejected_without_partial_changes() { let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); - app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true }); let before = app.run("ui.printSettings", json!({})).unwrap(); for params in [ json!({"printer":"Missing"}), @@ -150,7 +150,7 @@ fn invalid_print_settings_are_rejected_without_partial_changes() { #[test] fn repeated_fast_jobs_each_surface_an_identical_receipt() { let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); - app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true }); app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap(); app.services.print_native = Some(Box::new(|_, _, _, _| { let (sender, receiver) = std::sync::mpsc::channel(); @@ -184,7 +184,7 @@ fn native_ranges_and_parity_do_not_duplicate_spooler_copies() { } } assert_eq!(app.session.export_layout().pages.len(), 3); - app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true }); app.run("ui.printSettings", json!({"printer":"Queue","copies":3})).unwrap(); let jobs = Rc::new(RefCell::new(Vec::new())); let observed = jobs.clone(); @@ -214,7 +214,7 @@ fn native_ranges_and_parity_do_not_duplicate_spooler_copies() { fn native_print_passes_orientation_options_to_spooler() { let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); app.run("text.insert", json!({"text": "Test orientation"})).unwrap(); - app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true }); app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap(); let jobs = Rc::new(RefCell::new(Vec::new())); let observed = jobs.clone(); @@ -251,7 +251,7 @@ fn native_print_passes_orientation_options_to_spooler() { fn native_print_scales_to_the_chosen_paper() { let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); app.run("text.insert", json!({"text": "Scale me"})).unwrap(); - app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true }); app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap(); let jobs = Rc::new(RefCell::new(Vec::new())); let observed = jobs.clone(); diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 0000000000..5c7a5b3fbf --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1413 @@ +# WordCraft: egui 0.37 migration plan + +**Status:** local candidate qualified, 2026-10-24. The complete reviewed migration and +current-main PDF dependency overlay passed parent-managed `cargo xtask ci` job +`egui037-wordcraft-candidate-20261024-current-main-full-ci-07` at 06:24:33 UTC: all eight +stages passed. Runtime/configuration bytes and the canonical lock match the reviewed 616-file +source snapshot. Only this status document changed after that run. Native GUI/IME/capture and +save/reopen traces, bundled egui font notice completion, paired performance, platform/installer execution and publication approval +remain separate gates. Historical implementation/run notes below are retained as history; +this status and the latest qualification section supersede their pending-check statements. +This document records local qualification, not publication or complete platform acceptance. +**Scope:** the WordCraft checkout at +`/home/codex/work/CraftApps/egui037-migration-20261022/wordcraft-implementation`. +**Baseline source:** `3a4770ef8ce037d2650c93e2d5764536549357c7` ("Merge pull request #3286 from +matstrange/fix/protection-inspect-tracking"), workspace version 0.10.0, edition 2024, +`rust-version = "1.95"`, resolver 3. Baseline `Cargo.lock` SHA-256 +`be3d0675270364a44b7011d1d80a6a255d5823c1a52088347faa18fa9cebfd88`; baseline `Cargo.toml` +SHA-256 `7838ccd3744441a54b9e8024121e9007e64b54294a6e0b442fe758edc45c184a`. + +Baseline dependency identities in that lockfile: + +| Source | Baseline identity | +|---|---| +| egui family | registry `egui`/`eframe`/`egui-winit`/`egui-wgpu`/`egui_glow`/`egui_kittest`/`epaint`/`emath`/`ecolor` = 0.36.2; `kittest` = 0.4.0 | +| winit | registry 0.30.13 | +| accesskit | registry `accesskit` 0.24.1, `accesskit_winit` 0.32.2, `accesskit_unix` 0.21.1 | +| craft-libs | `craft-ui` + `craft-print` at `d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d`; `craft-pdf` at `c3487320b2d1de657b860b3c8abb44e1f2b32bd8` | +| fonts | `CRAFT_FONTS_REF` `8dcdacd5153e64560d109541a47d806f26f048c0` (CI), plus the bundled Inter/SourceSans/JetBrains Mono assets | +| toolchain | workspace MSRV 1.95; the planning host used rustc/cargo 1.99.0, which is not an acceptance toolchain by itself | + +**Target and current local pins:** egui family 0.37.0 at +`173cc7375d35dc5fbc5b5d0994fdf787f547c5d1`, winit at +`ae48c756a3b81125eb796c0cc6dccf60990d1181`, and craft-ui/craft-print at +`c488caa9fbc2fb1a86e4674887e9b80b598ebbfa`. The current-main PDF security update uses +craft-pdf/krilla at `9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef`. These are immutable canonical +Git URL pins qualified through local mirrors; clean remote reachability and publication still +require explicit approval. Stock 0.37 is a measurement control, not the production target. +Earlier baseline and research revisions below remain historical references. + +This plan follows the suite contract in +`craft-libs/docs/egui-037-suite-rollout.md` and the planning contract. It records only +WordCraft-specific source, behavior and evidence. The shared rollout plan remains the authority +for suite-wide publication order and the shared performance protocol. + +## 1. What is and is not changing + +WordCraft already uses the 0.36 `App::logic` / `App::ui` split and an existing root `Ui`. +`App::logic` is already called before every UI pass, including repeated passes; `App::ui` +already receives the root `Ui`. The 0.37 migration must preserve that architecture rather than +rewriting it. The real work is: + +1. making the dependency graph coherent and immutable; +2. repairing the concrete 0.37 API breaks; +3. removing application-side text processing that the fork now performs correctly; +4. preserving one-shot lifecycle work across repeated passes and logic-only frames; +5. validating that the page, chrome, accessibility, input, clipboard and save/reopen behavior + remain what users expect. + +No engine, command id, document format, undo semantic or persistence format is intentionally +changed by this migration. The app remains `Session`-driven; egui remains a thin L6 front end. + +## 2. Repository inventory + +### 2.1 Workspace and package targets + +`Cargo.toml` declares one workspace, `members = ["crates/*", "apps/*", "xtask"]`, with no nested +workspace and no vendor directory. `cargo metadata --no-deps` lists these packages: + +- application: `wordcraft` (`apps/wordcraft`, native eframe app), `wordcraft-web` + (`apps/wordcraft-web`, wasm eframe app), `wordcraft-cli` (no egui dependency). +- UI: `wordcraft-ui-egui` (`crates/ui-egui`, the only direct egui/eframe consumer in the + workspace). It also depends on `craft-ui` for print-page controls and on `egui_kittest` as a + dev-dependency. +- lower layers and stand-alone crates: `wordcraft-{geom,doc,fonts,layout,docx,docbin,formats, + render,pdf,proof,engine,mcp,zotero,control-key,metafile}`. None may acquire an egui dependency; + `cargo xtask layers` enforces this (`xtask/src/layers.rs`, `UI_CRATES`). +- `xtask` itself has no egui dependency. + +Direct egui-family declarations are only: + +| Manifest | Declaration | Notes | +|---|---|---| +| root `Cargo.toml` | `egui = "0.36"`, `egui_extras = { version = "0.36", default-features = false }`, `eframe = { version = "0.36", default-features = false }`, `egui_kittest = "0.36"` | `egui_extras` is currently unused by source; remove it or pin it deliberately. | +| `crates/ui-egui/Cargo.toml` | `egui = { workspace = true }`; dev: `egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }` | `egui` is not given `default_fonts` here; production gets it from the app roots' eframe feature, while the UI test root must declare egui's font features itself because kittest's own dev-dependencies do not propagate. | +| `apps/wordcraft/Cargo.toml` | `eframe` with `default_fonts`, `wgpu`, `accesskit`, `wayland`, `x11`, `links`; `egui`; Linux/BSD `winit = "0.30"` with `x11` | `eframe` is the native renderer; S1 adds `clipboard` to preserve 0.36’s integration-forced OS text clipboard. | +| `apps/wordcraft-web/Cargo.toml` | `egui`; wasm `eframe` with `default_fonts`, `wgpu`, `accesskit` | web runner and browser download/inbox shell. | + +`craft-libs` is pinned in two places: `craft-ui` and `craft-print` at +`d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d`, and `craft-pdf` at +`c3487320b2d1de657b860b3c8abb44e1f2b32bd8`. `craft-ui` and `craft-print` share public print +types; `craft-pdf` does not share those types. Do not churn `craft-pdf` just to make revision +strings look uniform. + +### 2.2 Entry points and examples + +- Native: `apps/wordcraft/src/main.rs`. `impl eframe::App for App` at `main.rs:44` implements + `logic`, `raw_input_hook`, `ui` and `on_exit` (`main.rs:45-120`). `NativeOptions` and the + winit/platform setup are at `main.rs:376-420`; Windows wgpu backend/probe code is + `apps/wordcraft/src/graphics.rs:26-180`. +- Web: `apps/wordcraft-web/src/web.rs`. `WebShell` implements `eframe::App` at `web.rs:117`; + `logic` drains browser drops/messages and calls `WordApp::logic`, `ui` calls `WordApp::ui`; + `WebOptions`/WebGPU-WebGL2 selection is at `web.rs:35-65`. +- UI library: `WordApp::logic` at `crates/ui-egui/src/lib.rs:2087`, `WordApp::ui` at + `lib.rs:2353`, `raw_input_hook` at `lib.rs:2325`. +- Headless screenshot example: `crates/ui-egui/examples/ui_shot.rs` (1440x900 at 2x by default, + `wgpu` harness, `--sample`, control JSON lines). +- Performance probe: `crates/ui-egui/examples/perf_probe.rs` (open, typing, wheel/trackpad + scrolling, zoom, ribbon tabs, resize, accept-all; returns medians, p95 and max, not p99). +- Large-table benchmark: `crates/ui-egui/src/tests_perf.rs` (`big_table_scroll_bench`, ignored, + 2000x8 synthetic table by default, `WORDCRAFT_BENCH_DOCX` override). +- Control protocol: `crates/ui-egui/src/control.rs`, documented in `docs/control-protocol.md`. + `ui.screenshot`/`ui.render` reply shapes, request ids, timeouts and paths are public contracts. + +### 2.3 Platform configuration + +Relevant cfgs are all in the UI/app boundary, not in lower layers: + +- `cfg(target_os = "linux")`: `zbus = "=5.19.0"` in `crates/ui-egui/Cargo.toml`; + `desktop_theme.rs` reads the desktop portal. `system_clipboard.rs` and + `apps/wordcraft/src/clipboard.rs` use `wl-clipboard-rs` for the **regular** Wayland + clipboard (formats/text/pictures); they do not read or write PRIMARY + (`ClipboardType::Regular` only, `clipboard.rs:135-160`). Middle-click on the canvas is + WordCraft's autoscroll, not a PRIMARY paste (`autoscroll.rs:1-10`). +- `cfg(all(unix, not(target_os = "macos")))`: app `winit` X11; XWayland selection is in + `main.rs:404-413`. +- `cfg(target_os = "macos")`: `fmv-macos-events`, `objc2-app-kit`, `dispatch2`; termination + workaround `schedule_macos_terminate` at `main.rs:121-160`; AppKit pasteboard additions in + `apps/wordcraft/src/clipboard.rs`. +- `cfg(target_os = "windows")`: `open`, `windows` 0.62, `clipboard-win`; wgpu DX12/FXC and + backend probe in `apps/wordcraft/src/graphics.rs`. +- `cfg(target_arch = "wasm32")`: `js-sys`, `web-sys`, browser inbox, `font_store.rs`, browser + downloads; `sys-locale` with `js`. + +No code uses child viewports (`show_viewport_deferred`/`show_viewport_immediate`), `craft-ui` +docking, or `craft-window`. The app's "windows" are `egui::Window` areas inside the single root +viewport. A viewport-focused migration is therefore not triggered by this app; the one root +viewport still matters for screenshots, DPI and wgpu surface limits. + +## 3. Dependency target and publication constraints + +### 3.1 Exact coherent target + +All egui-family crates must resolve from one immutable fork revision, not merely from +`version = "0.37.0"`. The target family is: + +| Crate | Target | Source | +|---|---|---| +| `egui`, `eframe`, `egui-winit`, `egui-wgpu`, `egui_glow`, `egui_kittest`, `egui_extras`, `epaint`, `emath`, `ecolor`, `epaint_default_fonts`, `egui_system_font_provider` | 0.37.0 | the approved, published, reachable `storytold/egui` P8 release containing the complete series: P2b async W15P routing, P7 with held-key release pairing, P7b/E21b, P7c's selected #8763/#8748 fixes and justified performance fixes; the exact SHA is assigned only after review/publication. Research input: aiongg `a4f9a17f64a6bae61350fb85804c9df0d9865916` (`ai-0.37` in an `emilk/egui` clone). | +| `winit` | 0.30.13 | the completed, approved, published, reachable `storytold/winit` revision containing W1-W15 minus W5, plus W17-W27 including W20 as W5’s replacement including the W27 lifecycle revision, and W15P PRIMARY; exact SHA assigned after review/publication. Research input: aiongg `8b35388bd719e8cc86a95971ba0bc026c9a8f058` (`ai-lang`). | +| `accesskit` / `accesskit_winit` | 0.25.x / 0.34.x | registry, resolved by the lockfile | +| `kittest` | 0.5.0 | registry, via `egui_kittest` 0.37 | +| `craft-ui`, `craft-print` | the published craft-libs revision containing CL-1 and the CL-2F frozen helper containing the reviewed PR 199 port on current main | exact git `rev`, not a branch; it must move in the same atomic dependency stage as the egui/winit pins | +| `craft-pdf` | existing `c3487320...` unless a separate reviewed reason appears | exact git `rev` | + +Required prerequisites, all in the approved revisions above: + +- **E4/P2b + W15P:** the complete shared contract in `egui` plan §6 E4/P2b and winit plan + §5.3.1-5.3.2. Claim selection synchronously on a fresh **pointer or keyboard** triggering + serial for the focused seat, with origin and epoch; only serving/reading is async, using the + total-deadline (2 s), byte (16 MiB), worker (4 readers, 2 writers, queue 8) bounds. A live + per-owner completion waker routes only to the still-current request; canceled/stale results + are discarded. WordCraft's application clipboard stays regular-only; built-in `TextEdit` + PRIMARY is tested only where the feature graph enables it. +- **E21b/P7b + winit W27 lifecycle:** the complete shared contract in `egui` plan §6 E21b and + winit plan §5.2.3/§5.2.2, not a partial restatement. It includes the owner/context generation + on both Available and Unavailable, distinct explicitly-sent empty Available payloads, cache + clearing plus a committed disable/enable transition on owner/unavailable changes, the latest + completed pass (including `None`) as authoritative, and current-epoch serial-mismatch + handling that applies incoming edits once while deferring outgoing state. WordCraft's two + canvas producers need real model adapters. +- **P7c selected UI-regression fixes:** the fork owns the selected #8763 click-away selection + fix and #8748 hidden-parent/child fix, with pointer and keyboard regressions. #8764 remains + only a proposal input. WordCraft has no child viewports, so #8748 is a dependency-series + gate rather than app behavior, but no app workaround substitutes for P7c. + +The root `[patch.crates-io]` must contain every crate whose crates.io identity would otherwise +pull a second egui/winit. A dependency's own `[patch]` table is ignored when it is a dependency, +so the wordcraft root must repeat the patches. At minimum: `egui`, `eframe`, `egui-winit`, +`egui-wgpu`, `egui_glow`, `egui_kittest`, `egui_extras`, `epaint`, `emath`, `ecolor`, +`epaint_default_fonts`, `egui_system_font_provider`, and `winit`. Keep the existing +`krilla` patch only because `craft-pdf` requires it; it is unrelated to this migration. +The published manifest must not contain any absolute path patch, branch, `path = "/home/..."`, +or machine-local checkout. + +Use exact requirements in the workspace (`=0.37.0`, `=0.30.13`) so that removing a patch cannot +silently float to a different registry release. The resulting `Cargo.lock` must contain exactly +one `egui`, `epaint`, `emath`, `ecolor`, `eframe`, `egui-winit`, `egui-wgpu` and `winit` package, +all with the intended `git+...#` source and no `path` source. + +### 3.2 Existing shared work + +- **Current shared-library integration base:** `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`, + the reconciled merge base that carries the reviewed 0.37 API port of `craft-ui`. It is the + base for this migration; the exact reachable immutable publication pin remains a + central-publication prerequisite and is not guessed here. The historical PR 199 head + `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` and audit merge + `ec8840af8c5fda695cf195cdd05e5ae078415c07` are evidence only and must not be merged as + trees: they omit current `craft-platform` and revert security, settings, docking and printer + fixes. +- **craft-libs PR 199** (historical). Its API port — `WidgetType` to `accesskit::Role`, + `UiBuilder::id`/`Ui::id` to `scope_id`, `Id::new` to `Id::unique`, + `egui_extras`/`egui_kittest` 0.37, and the missing-glyph docking-fixture change — is already + contained in the `3832c47…` base. The historical API-port hunks from + `8614e4a04fae516fba16c22b31ff1008cb6be10b` and the relevant README changes from `1764bca` + on the earlier `49190884278f16c9a9f5823d7698a8ffb5deb062` main describe how that base was + assembled; they are no longer the base to apply. Preserve intervening fixes and provenance, + and re-run the actual gates; neither PR 199 nor historical checks qualify the resulting + tree. WordCraft pins the published CL-1 plus CL-2F revision in the same atomic dependency + stage as the egui/winit pins (section 9), never after a 0.37-only first step. +- **craft-libs #192 / print types** already landed on the path to PR 199: `craft-ui::printing::Printer` + gains `label`; `craft-print::Printer` gains `description`. WordCraft must adapt when it moves + its `craft-ui`/`craft-print` pin: + - `apps/wordcraft/src/print.rs:8` builds `printing::Printer` from + `craft_print::printers()`; add `label: printer.description`. + - `crates/ui-egui/src/lib.rs:6038` (test) constructs `Printer { name, default }`; add + `label: String::new()` or `..Default::default()`. + - `crates/ui-egui/src/printing.rs` continues to re-export the shared types and labels. +- **craft-libs #198** is a language-support proposal/issue, not a merged dependency. Its + requirements that matter to WordCraft are already represented in fork patches E5/E12/E13/E16/E17/ + E19/E21: logical text at OS/accessibility boundaries, no double bidi, IME replacement by bytes, + and Latin shortcut keys on non-Latin layouts. Do not import the proposal's locale/formatting + roadmap or any prohibited font asset into this migration. + +### 3.3 Fork patch provenance and decisions + +The fork is a superset of 0.37.0 plus the suite patches documented in its `PATCHES.md`. The +completed P8 revision retains the whole series; do not delete unrelated patches to reduce the +diff. WordCraft consumes only that published P8 revision, never the aiongg candidate or an +intermediate stage. The WordCraft-relevant patches are: + +| Patch | Relevance to WordCraft | Decision | +|---|---|---| +| E1 eframe viewport hooks | No child viewports today | Retain; do not adopt. | +| E2+E3 egui-wgpu surface fit/error scopes | Protects large windows and device setup | Retain; test large/high-DPI window and device-error paths. | +| E4 egui-winit Wayland clipboard | WordCraft reads the regular clipboard through arboard/wl-clipboard-rs; E4 changes egui-winit's own path | Required fork prerequisite with the complete P2b/W15P contract (synchronous pointer-or-keyboard claim, bounded async transfer); test the app's regular clipboard separately and do not assume E4 replaces WordCraft's HTML/RTF/MathML path. Do not add app-level PRIMARY paste. | +| E5/E12/E16 bidi shaping, direction, caret affinity | Replaces WordCraft's manual `bidi_display`/`visual_row` workarounds | Required. Remove double processing; use logical text and fork layout. | +| E6 IME focus request | Text fields and canvas IME | Retain; test preedit/focus. | +| E7/E19 Latin keys on non-Latin layouts | App shortcuts use `Event::Key.key`; fork supplies Latin layout characters | Required for shortcut parity; test Russian/Arabic/Hebrew layouts. | +| E8 TextEdit glyph overshoot | Dialogs and custom combo fields | Retain; visual test combining marks/Thai/Vietnamese. | +| E9 RTL UI mirroring | WordCraft has no RTL interface catalog yet | Retain as capability; do not enable a half-mirrored UI without a locale. | +| E10 web named keys/language | Browser shortcuts and hidden input | Required for web; test browser workflow. | +| E11 vello single-threaded glyph rasterization | WordCraft's page renderer is separate; epaint UI glyphs still use it | Retain; no app change unless `vello_cpu` feature use is introduced. | +| E13 grapheme editing | Fork TextEdit/caret behavior; app's document editing is engine-owned | Retain; ensure no duplicate grapheme handling; test emoji/combining. | +| E14 fallback baseline | CJK/Indic UI fallbacks | Retain; test CJK/Indic interface text. | +| E15 line breaker | egui UI text (ribbon, dialogs); document layout is app-owned | Retain; do not move document layout to epaint. | +| E17 language on Context/Ui | Accessibility language and widget text | Adopt `Context::set_language(Some(lang.code()))`; optionally tag document/canvas content with the editing language. | +| E18 eframe UI language | Could replace app-side `Frame` language plumbing | Evaluate; not required. | +| E20 widget text language | Dialog/ribbon text layout | Retain; test CJK/Thai line breaking. | +| E21 `ImeEvent::DeleteSurroundingBytes` | Desktop macOS/Wayland IME replacement | Required. Add a byte-range path alongside the existing char-count web path. | +| E21b/P7b outgoing IME surrounding text | Canvas/Wayland surrounding-text support; the candidate has incoming deletion only | Required fork prerequisite under the complete E21b/W27 contract (owner generation, empty-Available, serial/epoch rules). TextEdit producer and W27 adapter; both WordCraft canvas producers need real model/preedit/UTF-8 adapters. | + +Winit provenance: the final pin must be the completed, approved, published `storytold/winit` +revision containing W1-W15 minus W5, plus W17-W27 including W20 as W5’s replacement including the W27 lifecycle revision, and the +W15P PRIMARY path. The inspected `8b35388` `ai-lang` commit is an aiongg research input, not a production +pin. WordCraft's existing issue references (#787, #721 and related IME/DPI fixes) depend on +W1-W15; do not compare only against upstream 0.30.13 and conclude the patches are new. The patch +table records the removal condition for each. No wordcraft vendor directory exists, so no vendor +deletion is part of this plan. + +### 3.4 Pin and lock checks + +Proposed checks, to run through the managed `task.py` workflow after the plans land: + +1. `cargo metadata --locked --format-version 1` over the root and with + `--filter-platform wasm32-unknown-unknown` (`cargo metadata` has no `--target`); parse + package `source` and `version` and fail on any `path` source, + branch source, unknown git URL, duplicate egui/winit, or a `craft-ui`/`craft-print` pair from + different craft-libs revisions. +2. `cargo tree -d --workspace` and `cargo tree -i egui`, `cargo tree -i winit`; no duplicate + family member. +3. `cargo fetch --locked` from a clean clone, then a second `cargo metadata --locked`; no + network-only branch resolution. +4. A small `xtask` dependency gate (proposed name `cargo xtask deps`) with tests for: missing + root patch, two egui versions, old `craft-ui`, moving branch, absolute path, and a git SHA + that is not reachable from the approved fork ref. This turns the manual checks into a + regression. +5. Feature-identity checks, not just version metadata: `cargo tree -e features --target + wasm32-unknown-unknown -p wordcraft-web` and the native `-p wordcraft` tree must show the + expected `egui/default_fonts`, `egui-winit/clipboard` (required on native, with arboard resolved), + `accesskit`, `wgpu` and no `system_fonts` resolution for the app root. Record the exact + feature sets in the lock/evidence manifest. +6. Publication reachability: a clean clone must fetch the approved `storytold` revisions and + pass `--locked` without local overrides. S1 cannot start while the only available trees are + the aiongg research inputs; do not substitute a candidate SHA for a published pin. +7. Release/packaging check after the source migration: signed native builds and the web package + must use the same lock identity; no local overrides. + +## 4. Concrete 0.37 API and behavior refactors + +The historical compiler audit at +`/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/wordcraft.errors` +lists 42 diagnostics, identical for stock and fork. They are clues, not current acceptance and +not an exhaustive list. The concrete fixes are: + +### 4.1 `Event::MouseWheel` gains `source` + +`egui::Event::MouseWheel` now has `source: MouseWheelSource` (0.37 `data/input/event.rs`). +Affected constructors and patterns: + +- `crates/ui-egui/src/control.rs:247` (`ui.wheel` synthetic event): add + `source: egui::MouseWheelSource::Unknown`. +- `crates/ui-egui/src/canvas.rs:642` (pattern), `canvas.rs:3821` (test constructor). +- `crates/ui-egui/src/scroll.rs:180`, `:278`, `:329`, `:418`; `tests_perf.rs:39`; + `examples/perf_probe.rs:106`; `mini_toolbar.rs:779`; `dialogs_keyboard.rs:430`; and the + test constructors in `lib.rs` + (`:3772`, `:3817`, `:3924`, `:4131`, `:4277`, `:4332`, `:4397`, `:4522`, `:4821`, `:5018`, + `:6094`). +- Add `..` to patterns where the source is not used. For tests, use `Wheel` or `Trackpad` to + match the behavior under test; for the control channel use `Unknown`. +- Preserve `CanvasScroll` behavior: WordCraft disables egui's canvas wheel handling + (`ScrollArea::scroll_source { mouse_wheel: false }`) and computes its own delta. Do not start + feeding the same momentum twice. The app's `SYNTH_MOMENTUM` policy remains Linux/BSD-only; + macOS/Windows/Web already send their own momentum. Use `source` only for diagnostics/future + differentiation, not to change current deltas without a measured test. + +### 4.2 `ScrollArea::State::offset` is private + +Use the 0.37 accessors and builder: + +- `crates/ui-egui/src/canvas.rs:1650`: `app.canvas.scroll_offset = out.state.clamped_offset();` + (the old public offset was the clamped value; `unclamped_offset()` is for painting/rubber-band + only). +- `crates/ui-egui/src/widgets.rs:1158`: `shown.state.clamped_offset().y`. +- `crates/ui-egui/src/scroll.rs:440` (test helper): `seen = (out.state.clamped_offset().y, zoom);`. +- `crates/ui-egui/src/ribbon.rs:543` and `:560`: read `scroll.state.clamped_offset().x`. + Setting `state.offset.x` is no longer possible. Store a one-frame pending horizontal offset + (in `ui.data_mut` keyed by the scroll area id, or a small `RibbonScroll` field) and build the + next frame with `egui::ScrollArea::horizontal().horizontal_scroll_offset(x)`. Clear the pending + value after it is consumed. This preserves the edge-chevron behavior without touching private + state. +- `crates/ui-egui/src/canvas.rs:1173-1205` already uses `.scroll_offset(after)`; keep that + builder path. The scroll maximum remains the app's own `content_size - inner_rect.size()`. + +Verify the sweep is exhaustive: the only `state.offset` reads/writes in `crates/ui-egui/src` +are `canvas.rs:1650`, `widgets.rs:1158`, `scroll.rs:440`, `ribbon.rs:543/560`, plus the builder +path at `canvas.rs:1173-1205`; no other direct field access remains. Regression: wheel notch and +trackpad tests assert the same offsets and page indices after the change; ribbon left/right +chevrons move exactly one step and clamp at both ends. + +### 4.3 `WidgetType` becomes `accesskit::Role` + +The app uses `egui::WidgetInfo::labeled/selected(WidgetType::...)` in 34 places. Replace with +`egui::Role` using the mapping egui itself documents (and craft-libs PR 199 uses): + +| Old | New | Files | +|---|---|---| +| `Button` | `Role::Button` | `backstage.rs:79,127,234,524`; `widgets.rs:294,333,336,402,510,523,934,1256,1289`; `print_page.rs:290`; `format_shape.rs:439`; `previews.rs:231`; `chrome.rs:333,392,416,492,551,698`; `window_close.rs:60`; `ribbon.rs:355,467,696,2271` (Full Screen) | +| `SelectableLabel` (selected) | `Role::Button` with `selected` | `options.rs:150`; `previews.rs:530`; `chrome.rs:671`; `ribbon.rs:404` | +| `TextEdit` | `Role::TextInput` | `widgets.rs:878` | +| `Image` | `Role::Image` | `print_page.rs:278` | +| `Other` | `Role::Splitter` for the ruler drag grip, not `Unknown` | `table_ruler.rs:60` | + +The `table_ruler` grip is a draggable column/row boundary; `Role::Splitter` is an input role and +matches its behavior. If a test insists on the old generic role, review whether that test was +asserting implementation rather than semantics; do not hide a real control under `Unknown`. +`Role::Unknown` is not an input in `egui::accessibility::is_input`, so `unnamed_widgets()` cannot +detect a missing/Unknown node. Add an explicit `get_by_role(Role::Splitter, ...)` assertion for +the grip; a mutation that removes its name must fail `unnamed_widgets()`, and a mutation that +changes the role to `Unknown` must fail the explicit role query. + +`egui::accesskit::Role` remains available for tests; `egui::Role` is the re-export. Existing +`get_by_role_and_label(Role::Button, ...)` tests remain valid. + +### 4.4 Screenshot command becomes a callback + +0.37 removes `Event::Screenshot` and `UserData`; `ViewportCommand::Screenshot` carries a +`ScreenshotCallback`, and `Context::request_screenshot` is the convenience API. Current code: + +- `crates/ui-egui/src/lib.rs:2577` `issue_screenshots` sends + `ViewportCommand::Screenshot(UserData::new(token))`. +- `crates/ui-egui/src/lib.rs:2593` `collect_screenshots` scans `egui::Event::Screenshot` and + returns immediately when `pending_shots` is empty. +- `crates/ui-egui/src/control.rs:312` `save_screenshot(&ColorImage, path)` encodes/writes. + +Required design. WordCraft is one of the eleven `craft-ui` capture consumers and adopts the +shared helper only after CL-2F freezes it; it does not take the provisional CL-2 revision used +by the DesignCraft/GridCraft pilots. The callback contract is: + +- Keep request admission, paths, reply schema, settling delay and the 8 s deadline in + `WordApp`. Each request record owns a strong completion slot and waker; the + `ScreenshotCallback` holds only a `Weak` reference to the slot and receives pixels only. It + must not capture `WordApp`, a `Context`, a file path, or an unbounded `mpsc::Sender`. The + callback stores the image, validates it, releases the lock, and only then invokes the live + slot's app-owned waker. It never calls `Context::request_repaint` itself and never wakes + while holding the lock; never strongly capture a context from a callback queued in that same + context. +- `Context::request_screenshot` itself schedules a repaint: it calls `send_viewport_cmd` -> + `send_viewport_cmd_to` -> `request_repaint_of`. Only callback *arrival* is documented not to + request a repaint. Do not claim the request is repaint-free; keep the existing + `request_repaint_after(16 ms)` while a request is pending and wake explicitly after + completion. +- Admit by count **and** bytes. Before issuing, estimate `width * height * 4` from the current + viewport logical content and pixels-per-point with checked arithmetic, then reserve its + working set (raw RGBA + worst-case PNG + base64, a 10/3 factor) in an in-flight budget that + also counts completed-but-undrained slots. Use the shared defaults: at most 4 outstanding + requests, 128 MiB reserved per image and 256 MiB aggregate; reject excess admission without + evicting earlier requests. A 4K viewport at 2x is about 7680x4320x4 ~= 127 MiB raw and is + therefore rejected by the working-set limit rather than silently exceeding the encode peak. + Revalidate the checked dimensions and geometry epoch immediately before issuance. +- Bind the image to the issuance-time UI geometry: logical content/screen bounds, size, + pixels-per-point, zoom and surface epoch. On completion, validate dimensions/byte length + against the reservation and that epoch. Any geometry/DPI/zoom change before save, **even a + change back**, cancels and retries within the deadline or returns an explicit stale-capture + error; never combine old pixels with current geometry. With an unchanged epoch, derive the + pixel scale from the returned image dimensions and verify aspect/rounding. +- `collect_screenshots` drains the slots unconditionally in `logic`, matches the request + record, calls `control::save_screenshot(&image, path.as_deref())`, sends exactly one reply, + and releases the slot and reservation. A timeout/cancel/drop removes the record and releases + the slot; a late callback then finds a dead weak reference and retains nothing, writes no + file and sends no second reply. Retained image bytes must return to zero after drain, + timeout, cancel and shutdown. +- Hidden windows: the screenshot command itself forces a full hidden `App::logic` + `App::ui` + pass (`ActionRequested::wants_paint()`), while callback arrival does not repaint. A request + queued in `logic` but issued only in `ui`, including every remaining settling frame, must + therefore send `ViewportCommand::RequestPaintWhileHidden` from the logic side to reach + issuance; a wake/repaint alone may run hidden logic only. Keep the timeout contract and test + both the forced pass and the queued-not-issued settling path. +- The app has one root viewport. If child viewports are added later, route requests by + `ViewportId` and generation; do not reuse an old token for a new document or viewport. +- `ui_shot` uses `Harness::render`, not the viewport callback; keep it as a separate headless + path. Do not make `ui.render` depend on the native callback. +- The suite has fourteen capture consumers. The eleven existing `craft-ui` consumers require + CL-1 and CL-2F; only the DesignCraft/GridCraft pilots use the reviewed CL-2 provisional + revision, and their adapters are reviewed before CL-2F freezes the API. DeckCraft, FigCraft + and ArtCraft Native keep contract-compatible local adapters; `ai-egui` has no capture path. + Do not add `craft-ui` solely for capture; if DeckCraft/FigCraft later adopt it, WordCraft's + adapter stays unchanged. + +Tests: four-request/128 MiB/256 MiB admission, two outstanding requests with distinct +tokens, out-of-order completion, callback once and no self-wake, late callback after +timeout/cancel (no file/second reply), forced hidden pass, queued-not-issued +`RequestPaintWhileHidden`, resize/DPI/zoom change and change-back, and retained-bytes-zero +after drain/shutdown. + +### 4.5 `Id::new` and `Ui::id` deprecations + +0.37 deprecates `Id::new` in favor of `Id::unique` and renames `Ui::id` to `Ui::scope_id` +(`Ui::id` remains deprecated). Exact symbol-filtered counts in `crates/ui-egui/src` at this +baseline are 108 egui `Id::new` calls (89 `egui::Id::new` and 19 bare `Id::new`) and 37 +`ui.id()` calls; `FontId::new` (3) and `LayerId::new` (3) are unrelated and must not be swept. +The mechanical count is a review aid, not a replacement trigger. Replace with same-hash +equivalents: + +- `egui::Id::new(x)` -> `egui::Id::unique(x)`; hashes are intentionally unchanged for existing + ids, so widget state, focus and temp data keys stay stable. +- `ui.id()` -> `ui.scope_id()`; the value is unchanged. +- `UiBuilder::new().id(x)` -> `.scope_id(x)` if any is introduced (none currently in app code). +- Do not replace `Id::unique` with `IdSalt` for globally stored state; `IdSalt` is for child + scopes only. Preserve the distinction between stable scope ids and `ui.unique_id()`. + +The mechanical sweep is large but must be reviewed by symbol, not regex alone: `ui.id()` on a +`Ui` is not the same as `TextureHandle::id()`, `CommandSpec::id()`, `Document::id()` or other +domain ids. The replacement applies only to egui `Ui`. + +### 4.6 Panels take `AsIdSalt` + +`Panel::left/right/top/bottom` in 0.37 take `impl AsIdSalt`, not `impl Into`; the panel id +is now `parent_scope.with(IdSalt)`. Existing string literals in `chrome.rs`, `panes.rs`, +`backstage.rs` and `ribbon.rs` compile, but their effective ids change. WordCraft does not persist +panel sizes in `ui.json` or enable egui `persistence`, so the only consequence is fresh in-process +panel state after upgrade. Verify: + +- panel order remains title bar -> ribbon -> status -> side panes -> central canvas in + `WordApp::ui` (`lib.rs:2353-2410`); +- `Panel::top("title_bar")`, `Panel::top("ribbon")`, `Panel::bottom("status")`, the side panes in + `panes.rs:20-105`, and the `CentralPanel` reserve and clip correctly at 760x480, 1440x900 and + 2x DPI; +- no panel is shown on a non-root `Ui` where 0.37 would change order. `backstage.rs:40-160` + shows a left panel and then a central panel on the passed root `Ui`; keep that contract. +- Do not use `Context::root_ui` inside `WordApp::ui`: 0.37 explicitly panics when the root `Ui` + is already borrowed (`Context::root_ui` docs, upstream #8535). Pass the existing `Ui`. + +### 4.7 `default_fonts` is no longer a default feature + +0.37 removes `default_fonts` from `egui`/`epaint` default features; `eframe` still enables it. +`egui_kittest`'s normal `[dependencies] egui` uses `default-features = false`; only kittest's +own `[dev-dependencies]` enable `default_fonts`/`monochrome_emoji_fonts`, and a dependency's +dev-dependencies do not propagate. Neither kittest's manifest nor its dev-dependency graph +supplies fonts to WordCraft's test root. WordCraft's `theme::font_definitions_language` starts from +`FontDefinitions::default()`, inserts `Inter` at index 0, pushes `SourceSans` at the end, then +appends CJK/system faces (`theme.rs:536-620`). With `default_fonts` the default proportional +family is `["Ubuntu-Light", "egui-icons"]`; with `monochrome_emoji_fonts` it is +`["Ubuntu-Light", "egui-icons", "NotoEmoji-Regular", "emoji-icon-font"]`. + +The actual production order is therefore: +`Inter, Ubuntu-Light, egui-icons, SourceSans, `. +Explicitly enable `default_fonts` and `monochrome_emoji_fonts` on the UI test root's egui +dev dependency for the controlled emoji fixture; its expected order is: +`Inter, Ubuntu-Light, egui-icons, NotoEmoji-Regular, emoji-icon-font, SourceSans, +`. +Do not assert the earlier proposed `Inter, SourceSans, Ubuntu-Light, egui-icons` order; it is +wrong and would force an unintended fallback reorder. + +Concrete fix: + +- Keep eframe's `default_fonts` feature in both app manifests. +- Make `wordcraft-ui-egui` test features explicit with `egui` dev-dependency features + `default_fonts` and `monochrome_emoji_fonts`; these are egui features, not kittest features. + Do not rely on kittest's private dev-dependencies: without this explicit declaration the + test root is font-empty apart from WordCraft's own assets. Do not enable + `monochrome_emoji_fonts` in production just to make the test order match. +- Add a font coverage test that asserts the exact prefix/position for the feature set under + test: `Inter` at 0, `Ubuntu-Light` and `egui-icons` in the default fallback block, + `SourceSans` immediately after that block, then CJK/system faces. Assert `has_glyphs` for the + interface catalog's punctuation/symbol strings, `icons.rs` labels, Arabic/Hebrew, CJK, + Thai/Vietnamese combining marks and only emoji the chosen UI font actually covers. +- Choose the emoji source explicitly: interface emoji coverage is the bundled monochrome + `egui-icons` set (plus the test-only `NotoEmoji-Regular`/`emoji-icon-font` fallbacks from + `monochrome_emoji_fonts`). The production UI family has no color emoji font; do not assert + color/ZWJ emoji in the interface test. For document/canvas emoji, use the pinned test-only + Noto Color Emoji v2.047 fixture from egui plan 10.6 (`39ee3c58...`, OFL-1.1) or record a + native platform color-font result separately. Do not bundle a proprietary emoji font. +- Keep `system_fonts` off; it would add a system provider and startup discovery cost (upstream + #8512), change coverage and complicate reproducibility. + +The strict accessible-name sweep and this font/glyph work are part of S1, not deferred to the later screenshot stage: +`egui_kittest` 0.37's `check_accessibility` defaults to true and panics on unnamed visible +inputs after `Harness::run`, so S1 cannot leave `cargo xtask ci` green without them. + +### 4.8 kittest 0.37 stricter defaults + +`egui_kittest` 0.37 fails tests when an input widget has no accessible name and panics on a glyph +no installed font can draw. WordCraft has many custom-drawn controls and many `TextEdit`s whose +adjacent labels are not attached. The migration must not disable these checks globally. + +- First run the UI suite with defaults; enumerate `Harness::unnamed_widgets()`. +- Attach names with `Response::labelled_by(label.id)` where a visible label exists, or + `Response::accessible_name(...)` for icon-only controls. Prefer visible-label association over + duplicating translated strings. `Ui::label_inputs_by`/`Ui::name_inputs` are available in 0.37 + for groups. +- Audit the dialog modules: `dialogs_controls.rs`, `dialogs_review.rs`, `dialogs_layout.rs`, + `dialogs_insert.rs`, `dialogs_para.rs`, `dialogs_lists.rs`, `dialogs_table_style.rs`, + `options.rs`, `backstage.rs`, `colors.rs`, `print_page.rs`, and the combo field in + `widgets.rs:842-878`. A `TextEdit` with a `hint_text` passes the check, but a visible label is + better semantics; do not add hints merely to satisfy the test. +- Keep `Role::Button`/`Role::TextInput` accurate; a named widget with the wrong role is still a + screen-reader regression. The a11y tree in `a11y.rs` (Document/paragraph/table/text-run) must + keep its current logical text; the fork's bidi changes must not leak visual-order strings into + AccessKit. +- This is API compatibility, not behavior, so the name sweep and font coverage are part of S1. + The suite has 15 `h.run()` call sites and 153 harness constructions; most tests use + `step`/`run_steps`, where the default check does not fire. Add explicit + `h.check_accessibility()`/`unnamed_widgets()` assertions where the UI settles, and add explicit + role queries for controls such as the ruler grip. `Role::Unknown` is not an input and cannot + detect a missing node. + +### 4.9 `craft-ui`/`craft-print` print type change + +Already described in 3.2. Add a test that discovery maps CUPS `description` to `label`, and that +`shown_names` distinguishes two printers with the same label. Keep the app's command ids and +print settings schema unchanged. + +### 4.10 Text: remove double bidi and handle byte IME replacement + +This is the highest-risk app-specific change. WordCraft manually reorders right-to-left text for +egui 0.36: + +- `crates/ui-egui/src/canvas.rs:2132-2185` `has_rtl`/`bidi_display`/`visual_row`, used by + `backstage.rs:529-530` for recent file names. +- `crates/ui-egui/src/canvas.rs:2190-2245` `balloon_text` wraps and visually reorders comment + text itself, then right-aligns RTL rows. +- `crates/ui-egui/src/keys.rs:352-390` handles `ImeEvent::Preedit/Commit/DeleteSurrounding` + (char counts) and `keys.rs:626-680` implements `delete_surrounding` and `type_text`. +- `apps/wordcraft/src/clipboard.rs` and `crates/ui-egui/src/a11y.rs` use logical text at the + OS/accessibility boundary; they must stay logical. + +With the fork's E5/E12/E16, egui shapes RTL runs and places rows in visual order. Keeping +`bidi_display` would reverse the text a second time. Refactor: + +- Replace `bidi_display(name/path)` calls in `backstage.rs:529-530` with the original logical + strings. Set `LayoutJob`/widget direction only where a specific element needs an explicit base + direction; for ordinary labels, let `TextDirection::Auto` choose from the first strong + character. +- Replace the manual wrapping/reordering in `balloon_text` with an egui `LayoutJob` over logical + text, `TextDirection::Auto`, the existing font/color and `TextWrapping::max_width = width`. + Do not use `.trim()` on each visual row; let epaint handle trailing whitespace and line breaks. + If a right-aligned RTL paragraph needs an explicit `halign`, use the fork's + `halign_min_is_start`/`TextDirection` controls, not pre-reversed strings. +- Keep `has_rtl` only if a non-layout decision needs it; otherwise remove the dead helper and its + tests. Replace the old `visual_row` tests with tests that assert screen x positions of the first + and last Arabic/Hebrew words (fork behavior), not the old reordered strings. +- Keep document layout (`crates/layout`) untouched: it has its own bidi/line-break logic and is + not egui text. This migration only changes egui UI text and comment balloons. +- Add `Context::set_language(Some(lang.code().to_owned()))` in `WordApp::logic` when the effective + interface language changes, so AccessKit exposes the UI language. Optionally tag the canvas + document `Ui`/AccessKit node with the document editing language via `UiBuilder::language`; do + not tag every text run with a guessed language. + +IME byte replacement: + +- The fork adds `ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }` on macOS and + Wayland, sent immediately before the `Commit`. The existing char-count + `DeleteSurrounding` remains for web/legacy paths. +- Add a match arm next to `keys.rs:384` that: + 1. returns early if a selection exists or an equation is open, as the char path does; + 2. computes `start = off.saturating_sub(before_bytes)` and + `end = off.saturating_add(after_bytes)` and verifies both are UTF-8 boundaries in the + paragraph text; if not, refuse the event rather than slice at a non-boundary; + 3. returns immediately when `start == end` (the existing char path does, `keys.rs:656`); + otherwise selects `start..end`, joins the current typing group, runs `text.backspace` (or + the same delete command used by the engine), records `ime_join_text` for the following + commit, and restores the caret if the command is refused; + 4. does not erase paragraph marks/list labels beyond the paragraph. +- Keep one undo step for delete+commit. Extend the existing `#1480` tests and add: + emoji modifier/ZWJ replacement, combining Vietnamese accent replacement, a letter after an + emoji sequence, a deletion-only batch, a `0/0` and clamped-empty byte range, and a commit + with empty text. Test that outgoing + surrounding text (document text after the operation) is updated, not merely the event mapping. + +**E21b/P7b outgoing surrounding text (shared fork prerequisite).** The candidate egui-winit +never calls `WindowExtWayland::set_ime_surrounding_text`, and `egui::IMEOutput` has no logical +text (`data/output.rs:100-114`). E21's incoming delete/commit is not full surrounding-text +support. The complete contract is owned by `egui` plan §6 E21b and winit plan §5.2.3/§5.2.2; +WordCraft depends on it and does not restate a weaker subset. It provides: +an owner/context generation present for both Available and Unavailable; a valid empty document +as Available, explicitly sent as `set_surrounding_text("", 0, 0)` plus commit, distinct from +Unavailable; a bounded transient payload (logical committed text, UTF-8 byte cursor/anchor, +preedit excluded, at most 4,000 bytes, explicit unavailable when the selection cannot fit) with +no full-document allocation for the new payload; W27 cache clearing plus a committed +disable/enable boundary on owner/support transition; the latest completed pass's descriptor, +including authoritative `None`, as the only published value; and current-epoch `done` serial +mismatch applying incoming edits once while deferring outgoing state until a matching serial, +with obsolete epochs invalidated. + +Both WordCraft canvas producers (`crates/ui-egui/src/canvas.rs:1556-1561` math/equation caret +and `:1633-1638` text caret) need real model adapters, not `None`: derive the bounded logical +paragraph/equation excerpt and byte cursor/anchor from `session.sel`/the equation editor, +convert scalar/grapheme positions deliberately, exclude `ime_preedit`/`ime_preedit_range`, +and never send display-reordered bidi text. The adapter updates on commit/delete/selection/ +focus changes and clears on focus loss, password/no-context and widget switch. Existing +TextEdit buffer cloning is baseline behavior, not part of the new payload allocation. + +Tests: outgoing text after delete+commit and deletion-only batches; preedit exclusion and caret +movement; supplementary emoji/combining/RTL logical order; long selection/window limits; +empty→non-empty and unavailable/password→available; late `Done` and current-epoch serial +mismatch vs stale epoch; same-window field switch and two windows; a final pass with `None`; +focus loss/reset/password/no-context clearing; and a native Wayland text-input trace on a real +compositor. A kittest event test is not native IME proof. + +Do not make the document engine depend on egui's grapheme implementation. The engine's own +grapheme/backspace rules remain the source of truth for document editing. + +### 4.11 Accessibility roles, names and selection + +- Keep the custom AccessKit document tree in `a11y.rs` (Document, Paragraph, Heading, List, + ListItem, ListMarker, Table/Row/Cell, TextRun, Image, Link). The fork's bidi must not change + the logical `TextRun` strings; `set_text_direction` remains based on the layout's `line.rtl`. +- Ensure every new `Role` assignment has a name. The kittest default check will catch unnamed + input roles that `is_input` recognizes (`Button`, `TextInput`, `Splitter`, `ComboBox`, ...). +- `refocus_after_chrome_click` (`lib.rs:2471-2520`) relies on `ctx.memory().focused()` and + `ctx.cumulative_pass_nr()`. 0.37's `TextEdit` can lose its selection before a clicked widget + runs. P7c owns the selected #8763 and #8748 fixes; #8764 is an open, unmerged proposal input, + not the dependency. This is a blocking fork prerequisite, not accepted behavior. The + approved P8 revision must contain the central fixes with pointer and keyboard tests proving + the intended range reaches the adjacent action in the same pass. +- `Role::Splitter` on the ruler grip must not make a passive ruler tick an input; only the grip + gets the role. + +### 4.12 GPU, surface and texture behavior + +WordCraft uses wgpu 30 on native and WebGPU/WebGL2 on web; `graphics.rs` configures Windows +DX12/FXC and the Intel-Mac indirect-validation skip. wgpu and winit versions do not change, so +those APIs should compile; revalidate anyway under the new eframe/egui-wgpu: + +- `apps/wordcraft/src/graphics.rs:26-120` still reaches `WgpuSetup::CreateNew` and + `InstanceDescriptor`; confirm with `cargo check` on Windows. +- E2/E3 surface fit fixes should remove a class of large-window configure errors. Add a window + size/DPI test near the surface limit if hardware permits; otherwise record the unresolved + hardware gate. +- Page textures use `TextureOptions::LINEAR` (`canvas.rs:1261-1332`, `print_page.rs:128`, + `previews.rs`). Do not change filtering/wrap; the upstream #8736 shader cost is about + predictable filtering, not the app's sampling choice. If a renderer option is changed, test + the actual page pixels and color emoji, not just compile. +- Screenshot callback memory: `Arc` may be large at 2x/4K. The count/byte + admission, in-flight reservation and completed-slot accounting are part of the design. + +### 4.13 Clipboard and PRIMARY + +The app's clipboard is host-owned (`apps/wordcraft/src/clipboard.rs`, `system_clipboard.rs`) and +supports text, HTML, RTF, MathML, pictures and format names. It uses `wl-clipboard-rs` +`ClipboardType::Regular` only; there is no application PRIMARY read/write, and middle-click on +the canvas is autoscroll, not paste. S1 enables native `eframe/clipboard` to preserve the +0.36 integration-forced OS text clipboard and the `Event::Paste` paths used by canvas and +dialog TextEdits. This does not add PRIMARY to the app’s canvas or change rich-format +ownership. Do not route `Event::PasteImage` into a new app-level paste path. Verify: + +- copy/cut/paste text and formatting through the app's commands, not only egui `TextEdit`; +- regular Wayland clipboard behavior with `wl-clipboard-rs` and `arboard` after E4/W15, while + W15P/P2b PRIMARY remains a fork-level prerequisite and is not added to the app's own paste + path; +- native `egui-winit/clipboard` is enabled and built-in TextEdit PRIMARY follows W15P; +- paste text from an independent process into the canvas and a dialog/search TextEdit, + then copy back to the other process; an in-process fallback must fail this regression; +- no double clipboard owner or last-writer race on X11/Wayland when both egui and the app write; +- macOS/Windows native pasteboard formats (HTML, MathML, RTF) unchanged; +- browser paste picture path in `apps/wordcraft-web/src/web.rs:160-220` still receives events; +- a regression that middle-click on the canvas starts autoscroll and never pastes PRIMARY. + +## 5. Lifecycle, root `Ui`, hidden windows and repeated passes + +### 5.1 What eframe does + +In 0.37, `eframe` calls `App::logic` and `App::ui` inside `Context::run_ui` for every pass on +native and web (`crates/eframe/src/native/epi_integration.rs:349-365` and +`crates/eframe/src/web/app_runner.rs` in the target source). `Context::run_ui` may run the +closure more than once when a pass calls `request_discard` (default `max_passes = 2`). +`new_input.take()` gives events/dropped files to the first pass only. A hidden window normally +goes through `update_logic_only` -> `Context::run_logic`, which updates only viewport/focus +state and leaves events/time/layout from the previous UI pass. However a pending +`ViewportCommand::Screenshot` or `RequestPaintWhileHidden` makes eframe run a full hidden pass +(`is_visible = viewport.info.visible().unwrap_or(true) || +actions_requested.any(ActionRequested::wants_paint)`), so `ViewportInfo::visible()` is not a +phase indicator. After a normally completed `run_ui`, `current_pass_index()` is zero +(`end_pass` takes the output); do not use a stale nonzero index as evidence of hidden work. +This is the exact contract the WordCraft code must be audited against. + +### 5.2 `logic` is currently mostly idempotent; fix the exceptions + +`WordApp::logic` (`lib.rs:2087-2238`) drains channels (`drain_control`, `zotero::poll`, +`read_aloud::poll`, `poll_file_dialog`, `drain_inbox`, `drain_pasted`), ticks recovery/autosave, +issues/collects screenshots, and handles dropped files. Most drains are inherently once-only +because they `take`/`recv`. The audit must prove, not assume: + +- **Dropped files are stale in hidden/forced logic passes.** `lib.rs:2208-2230` reads + `ctx.input(|i| i.raw.dropped_files.clone())` without taking. A hidden `run_logic` frame or a + forced hidden full pass can see the last shown frame's drops and open/insert them again. + The browser shell already takes the vector in `apps/wordcraft-web/src/web.rs:119`; native + `WordApp::logic` still clones it. Change `WordApp::logic` to + `ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files))` and handle the drained vector. + Apply the same rule to any future input-derived queue read in `logic`; never poll a + prior pass's event twice. +- **SmartArt Tab** uses `ctx.input_mut(|i| i.consume_key(...))` (`lib.rs:2106-2118`); consuming + makes a repeated logic pass harmless. Keep that pattern. +- **Synthetic input** (`raw_input_hook`, `lib.rs:2325`) runs before each frame, not once per UI + pass, and can run on a hidden `update_logic_only` tick. Keep at most one unapplied synthetic + gesture step pending until a full `WordApp::ui` pass consumes it; repeated hidden logic ticks + must not inject the next step, and a press/release pair stays two ordered events, never + collapsed. When queued synthetic input needs a UI pass while hidden, request paint through + the same `RequestPaintWhileHidden` path as 4.4. +- **Screenshots** replace event collection with the request-owned completion slots in 4.4; a + repeated pass must not issue the same request twice. `queued_shots` and the request records + (with their slots/deadlines) remain the one-shot state. +- **Autosave** sets `last_autosave` before running `file.save` (`lib.rs:2312-2325`); a second + logic pass in the same frame sees the new timestamp. Keep that ordering; add a test with a + forced double pass that counts save calls. +- **Recovery/read-aloud/Zotero** mutate state flags (`recovery_seen`, `read_aloud_at`, + `zotero` channel state). Add a forced double-pass test that asserts one recovery tick, one + status transition and one Zotero answer per event. +- **`report_dirty`** is idempotent; do not move it out of `logic`/`ui`. +- **Undo transactions** are engine-owned. Do not open/close a transaction in `logic` on every + pass; only commands do. A repeated pass must not add an undo step. The IME delete+commit path + explicitly joins the typing group once. + +### 5.3 Repeated `ui` passes + +The app has one existing `request_discard` in `widgets.rs:985` (font list sizing). The +`WordApp::ui` path performs command side effects through widget clicks and `keys.rs`; events +exist only in the first pass, so the normal multi-pass layout rerun should not duplicate a click +or keystroke. This must be demonstrated: + +- add a test-only wrapper that requests a discard once in a `WordApp::ui` pass (or drive the font + list to a new row count) and assert one command execution, one undo step, one autosave and one + screenshot request; +- keep `logic` in the same test to prove it does not replay stale input; +- if a duplicate is found, fix it with a narrowly scoped one-shot action queue or by deferring + only the affected layout action. Do not introduce a generic "compatibility layer" or move all + commands into `logic` wholesale. + +### 5.4 Root `Ui` and panel ordering + +`WordApp::ui` receives `&mut egui::Ui` and must continue to use it directly: + +- `chrome::title_bar(self, ui)`, `ribbon::show(self, ui)`, `chrome::status_bar(self, ui)`, + `panes::show(self, ui)`, then `CentralPanel::default().show(ui, |ui| canvas::show(self, ui))` + (`lib.rs:2360-2410`). +- `backstage::show` uses `Panel::left` + `CentralPanel` on the same root; no recursive + `root_ui`. +- Dialogs/windows use `egui::Window`/`Area` on the context; they are not viewports. Keep their + `Id`/layer/order semantics. +- Test `Context::run_ui(RawInput, |ui| { app.logic(ui.ctx()); app.ui(ui) })` at 760x480 and + 1440x900, 1x/2x, both themes; assert the central canvas rect equals the root area after + panel reservations, page rects are unchanged, and clicks at page coordinates still hit the + same document positions. + +### 5.5 Logic-only and forced hidden-pass checks + +Use `Context::run_logic(&RawInput { viewports, focused, ..Default::default() }, |ctx| app.logic(ctx))` +(the 0.37 API) only for the background/timer contract; it is not a model of a screenshot-driven +hidden pass. Check: + +- a pending autosave still fires on its timer without a UI pass; +- a pending print/Zotero/read-aloud result still drains; +- stale events from the previous shown frame are not consumed twice; +- a pending screenshot may force a full hidden `App::logic` + `App::ui` pass; assert exactly one + request/reply and one command/undo/autosave, and test that no duplicate side effects occur. + Do not call that path logic-only, and do not infer the phase from `visible()`; +- a queued capture issued only in `ui` (including settling frames) or pending synthetic input + on a hidden window sends `RequestPaintWhileHidden`; test that it reaches issuance, that a + plain wake may run logic only, and measure its repaint cost; +- after a completed multi-pass `run_ui`, assert `current_pass_index() == 0`; do not claim a + stale nonzero index; +- fonts are not initialized in a logic-only test and then assumed laid out; `Context::run_logic` + performs no pass or layout, so call `run_ui` for layout assertions. (#8535 documents only the + recursive `root_ui` panic, not font initialization.) +- repeated hidden logic ticks do not advance the synthetic gesture queue; a full UI pass + consumes exactly one pending step, and a queued capture/synthetic step on a hidden window + uses `RequestPaintWhileHidden` to reach issuance. + +## 6. Behavior contracts and regression assertions + +### 6.1 Commands, undo and persistence + +The engine and command registry are not migrated. Tests must still prove the UI dispatch path: + +- `engine.execute` and keyboard shortcuts run the same command id for: text insert, bold, + undo/redo, save/saveAs/open, find, print settings. +- One typing run or IME replacement is one undo step; undo restores text and formatting; + redo reapplies. +- Save/reopen: create a synthetic document, type text, apply bold, insert a comment/bookmark, + save as a temp `.docx`, open it in a fresh `Session`, assert text, formatting, comments and + selection/page hints that the product expects. Do not assert egui widget ids survive reopen. +- Autosave writes the same file and does not clear undo; failure shows the existing message and + disables autosave for that file. +- `file.close` with unsaved changes still refuses unless `force`/Save, through control and UI. + +### 6.2 UI geometry and screenshots + +Fixtures: `wordcraft_engine::sample::sample_document()`, a large synthetic table +(`tests_perf::big_table_doc`), and a durable evidence `.docx` with Latin/Arabic/Hebrew/CJK/ +Vietnamese/emoji/comments/headers. Fixtures and fonts are licensed and pinned: use +`CRAFT_FONTS_DIR` at `8dcdacd5153e64560d109541a47d806f26f048c0` for the app's CI text faces, +the central craft-fonts fixtures at `2481a8a324b986a45954e90045ed1f8f2efaaf3c` for +Arabic/Thai/CJK/Japanese coverage, the pinned Noto Sans Hebrew variable fixture +(`7ef36a2c...`, OFL-1.1) for Hebrew, and the test-only Noto Color Emoji v2.047 +(`39ee3c58...`, OFL-1.1) for document emoji. Record every fixture file SHA256, source and +licence in the evidence manifest; do not redistribute proprietary system fonts. Run at 1x/2x, +light/dark, 1440x900 and 760x480. + +Assertions: + +- `ui.inspect`/`document.inspect` JSON: page count, page rects, canvas rect, caret screen point. + Page/canvas geometry for the same fixture and DPI must be unchanged; chrome heights may change + only after manual review of the 0.37 style deltas. +- `ui_shot` images: the page area (app-rendered textures) must match the 0.36 baseline exactly at + the same DPI; chrome comparisons use a documented per-region threshold. Proposed starting + policy: a candidate passes only if no page-area pixel differs and chrome changed-pixel fraction + is <=0.5% with max channel delta <=2 after review; any larger change needs a named cause and a + reviewed replacement image, never a bulk refresh. The exact threshold is frozen in the baseline + report before candidate measurement, as the shared protocol requires. +- Ribbon fit: at widths 760, 920, 1200, 1440 and with English/German/Chinese/Japanese, the same + groups compact/expand at the same widths, or a reviewed delta is recorded. `ribbon_fit` uses + measured text widths (`ribbon_fit.rs`), so font changes can alter layout even when the code is + unchanged. +- Keyboard focus after a chrome click still returns to the canvas (`refocus_after_chrome_click`) + and a dialog/field keeps focus while open. +- Accessibility snapshots: `h.unnamed_widgets()` empty; button/input names; document role tree; + Arabic text logical order in AccessKit; selected/disabled states for ribbon toggles. + +### 6.3 Accessibility and keyboard + +- Kittest `AccessKit` tree: Document node named after the file, paragraph/heading/list/table/ + image/link roles, selected text ranges, caret position. Assert text content is logical, not + visually reversed. +- Every input widget has a name; `Role::TextInput` fields keep their current values and labels. +- Tab/Shift+Tab, arrows, Enter/Space, Escape and F10 context menu still trigger the same + commands as before. A synthetic RawInput test can prove the E7/E19 translation contract for a + non-Latin layout, but kittest carries no OS keyboard layout: real Russian/Arabic/Hebrew + shortcut behavior and typing must be observed on native X11/Wayland/macOS. A green kittest + run is not native layout evidence. +- RTL/CJK/Thai/Vietnamese/combining-mark text is not clipped or tofu; `MissingGlyphPolicy::Panic` + is the default in kittest tests; deliberate tofu tests opt out explicitly. + +### 6.4 IME, bidi and graphemes + +- Preedit/commit/cancel: one edit and one undo step; candidate window rect follows the caret. +- `DeleteSurroundingBytes`: exact byte replacement on macOS/Wayland; char-count path remains for + web; both refuse non-boundary ranges without panicking; deletion-only and empty-commit batches + work. Inside an equation a byte range has no reliable tree mapping: the request is refused, + the composition interrupted and only the replacement immediately paired with the rejected + deletion dropped — the next `Commit`, or an adjacent `Text` + `Commit` pair. Any later + text/commit in the same raw-input batch is fresh input and must apply, as is a later direct + commit without a pre-edit (no persistent tombstone). An over-cap equation deletion likewise + clears the painted pre-edit and any earlier deletion's replacement join before it interrupts. + The backend's composition interrupt fences protocol-late stale input; an untagged synthetic + event in a later frame cannot be distinguished from fresh input. These are synthetic contract + tests; native macOS/Wayland IME traces are separate. +- E21b outgoing surrounding text: after delete+commit and deletion-only batches, after + focus/password transitions and with two windows, the payload carries the logical committed + text and byte cursor/anchor with preedit excluded. A kittest event test is not native IME + evidence. +- Bidi: Arabic/Hebrew comment balloon reads from the right, a Latin word inside an Arabic + paragraph keeps its left-to-right order, brackets mirror correctly, and no double reversal + occurs in Backstage file names. Test screen x positions of first/last words, not just string + equality. +- Grapheme editing: emoji ZWJ/modifiers, regional indicators, combining marks and Indic sequences + use the engine's cluster rules; caret/hit-test/selection do not split UTF-8. + +### 6.5 Clipboard and native behavior + +- Native Linux X11 and Wayland: copy formatted text, paste into another app, read + HTML/RTF/picture formats; no double data-device failure. +- Middle-click on the canvas starts autoscroll and never pastes PRIMARY. PRIMARY is tested only + in built-in `TextEdit` where the app's feature graph actually enables egui clipboard; do not + add app-level PRIMARY paste. +- Windows: paste HTML/RTF/MathML and pictures; save/open a document through a real file picker; + close/quit behavior unchanged. +- macOS (if evidence available): pasteboard formats, IME, native termination; otherwise mark + unresolved rather than claiming pass. +- Browser: WebGPU and WebGL2 fallback, paste pictures, download/save, localStorage prefs, + keyboard shortcuts in an iframe, resize; actual browser run, not just wasm compile. + +### 6.6 Deliberate mutation checks + +Before accepting the suite, introduce and then remove each defect in a scratch branch; each must +fail the named assertion: + +| Deliberate defect | Must fail | +|---|---| +| Reuse a screenshot token/path for two requests | screenshot correlation test, one reply per request | +| Deliver a late callback after timeout/cancel | no file, no second reply, retained bytes return to zero | +| Drop the ruler grip's accessible name | `unnamed_widgets()` fails | +| Change the ruler grip to `Role::Unknown` | explicit `get_by_role(Role::Splitter, ...)` fails | +| Remove `default_fonts` from the test build | glyph coverage test or kittest missing-glyph panic | +| Reintroduce `bidi_display` on fork text | RTL balloon/file-name screen-position test (double reversal) | +| Ignore `DeleteSurroundingBytes` | IME replacement/undo test | +| Make `ScrollArea` offset unclamped for page tracking | wheel/ribbon offset test | +| Force a duplicate logic pass | one-shot autosave/insert/screenshot counter test | +| Replay a dropped file from a prior pass in a forced hidden pass | stale-drop test counts one open/insert | +| Make middle-click paste PRIMARY | canvas autoscroll regression fails | +| Change page texture filtering | page-area pixel comparison | + +## 7. Validation matrix and managed execution + +No command below was run for this planning document. Every new host build/test and owned-VM +command must use `/home/codex/work/maintenance/task.py run`, then `status`, and `finish` after +all checks and evidence are complete, with a unique `--task`, a fresh `--job-id`, exact +`--source`, durable `--evidence`, and the allocator's target. Do not run Cargo directly outside +the allocated target; do not reuse another worker's target/job. + +### 7.1 Host gates (Linux) + +Proposed `task.py run` command bodies (the parent supplies task/job/evidence): + +- `cargo metadata --locked --format-version 1` +- `cargo check --workspace --all-targets --locked` +- `cargo clippy --workspace --all-targets --locked -- -D warnings` +- `cargo test --workspace --locked` +- `cargo test -p wordcraft-ui-egui --locked` +- `cargo test -p wordcraft-engine tests_typing --locked` +- `cargo test -p wordcraft-ui-egui --release big_table_scroll_bench -- --ignored --nocapture` +- `cargo run --release -p wordcraft-ui-egui --example perf_probe -- ` +- `cargo run --release -p wordcraft-ui-egui --example ui_shot -- ` +- `cargo check -p wordcraft-web --target wasm32-unknown-unknown --locked` +- `cargo build --release -p wordcraft --locked` +- `cargo xtask ci` (fmt, clippy, tests, assets, layers, wasm) +- `cargo xtask parity`, `cargo xtask assets`, `cargo xtask layers` +- `cargo xtask deps` (proposed new gate) and its negative tests + +`cargo xtask wasm` does not include `apps/wordcraft-web`; the separate +`printing.yml` browser check (`cargo check -p wordcraft-web --target wasm32-unknown-unknown +--locked`) is mandatory. A real browser run uses `packaging/web/package.sh` (trunk) or a local +`dist/web` server, with WebGPU and `?webgl` fallback, and the control/inbox workflows. + +### 7.2 Native Linux + +Run the release app with `--sample --control 7981` and a temporary settings dir +(`WORDCRAFT_NO_PREFS` for agent runs where appropriate). Use `ui.screenshot`, `ui.inspect`, +`ui.click`, `ui.key`, `ui.text`, `ui.wheel`, save/open. Observe X11 and a real Wayland +compositor (record compositor identity); use `WORDCRAFT_WAYLAND=1` where needed. Check regular +clipboard, middle-click autoscroll (no PRIMARY paste), file drop, native IME and +DeleteSurroundingBytes/outgoing surrounding text, real Russian/Arabic/Hebrew keyboard layouts, +mixed DPI, window resize, a forced hidden screenshot pass, screenshot timeout/cancel and page +geometry. `ui.key`/kittest injection is not a substitute for OS layout or IME evidence. + +### 7.3 Windows + +Use a uniquely named `craft-windows` clone, unused SSH/VNC ports, its own source checkout and +target. Build with `cargo build --release -p wordcraft --locked` (or the repo's documented +build/test commands), launch through `Launch-CraftDesktop.ps1`/`guest-exec.py`, and perform a +visible typed/edited/saved/reopened workflow plus print settings. Observe DX12/FXC startup and +the screenshot path. Do not claim macOS/Metal or physical audio from Windows software rendering. + +### 7.4 Web + +Build the wasm app, run it in a browser over `http://localhost` or HTTPS, exercise WebGPU and the +`?webgl` fallback, type/save/download/reopen a document, paste a picture, use the control +protocol if available, resize the iframe, and inspect console/panic output. A `cargo check` +alone is not browser acceptance. + +### 7.5 macOS, FreeBSD, other platforms + +- macOS: required for macOS IME/pasteboard/Metal/native termination claims. If no owned macOS + evidence is available, mark those rows **unresolved/blocking**, not passed. The server's + Linux host cannot substitute. +- FreeBSD: keep the existing nightly/FreeBSD workflow; run after migration and record the + result. Do not infer from Linux. +- Physical GPU: the host has an i915 render device but no inherited display session; that is + availability, not a qualified Vulkan/OpenGL/native compositor result. Record software-rendered + and real-GPU results separately when both exist. + +## 8. Performance protocol + +Use the shared protocol in `craft-libs/docs/egui-037-suite-rollout.md` and make WordCraft-specific +measurements. Floors: at least 10 warmups and 30 timed steady-operation iterations, at least 20 +fresh processes for startup, and at least 1,000 frames for any frame distribution with p99. +Licensed pinned fixtures apply; an app may tighten floors, never loosen them. + +- **S0/P0 instrumentation, before any production edit:** extend `perf_probe`/the bench harness to + emit raw per-frame samples with `n`, p50/p95/p99 and the full sample list. Raise typing, wheel + and trackpad workloads to at least 1,000 frames each; do not use `big_table_scroll_bench`'s + 10-idle/40-scroll-frame median/max as tail evidence. Add a measurement-only native process + harness (environment/flag gated, no behavior change) that records process start, first UI + pass, each pass begin/end, winit redraw/AboutToWait timestamps where exposed, and screenshot + readback; samples `/proc//stat` and `VmHWM` for 30 s idle; counts repaints/wakeups via + `Context::set_repaint_observer`; and writes a CSV/JSON trace. Run at least 20 fresh processes + for cold start. Report harness `WordApp::logic + ui` CPU time separately from presented-frame + latency/interval; never substitute one for the other. If a presented-frame timestamp cannot + be observed without changing the renderer, record that gap and use the screenshot-readback + timestamp only as a clearly labelled proxy. Preserve the 0.36.2 release + executable and SHA256 with a saved fixture for rollback rehearsal. +- Every baseline report and every investigated regression includes an equivalent stock-0.37 + control alongside 0.36 and the fork. Fork-only app code need not compile unchanged on stock: + use the same operation in a pinned minimal adapter/harness and record all three source/lock + identities. Unavailable equivalence is an attribution limitation, not a reason to omit the + control. +- Baseline and candidate: same host, compiler (stable/1.95+), `--release`, `--locked`, features, + `CRAFT_FONTS_DIR` ref, fixtures, OS, CPU/GPU model, wgpu backend/adapter/driver and host load, 1440x900, 1x and 2x DPI, + light/dark. Record full SHA and `Cargo.lock` hash for each. Use separate allocated targets and + alternate run order; no concurrent builds or noisy workloads. +- Workloads: cold start to first frame (>=20 fresh processes), open sample/large fixture, idle + 30 s (CPU, repaints, wakeups), typing in a long paragraph and a table (>=1,000 frame samples + per condition, repeating the input as needed), wheel and trackpad scroll (>=1,000 frames), + zoom in/out, ribbon tab switches, resize sweep, screenshot latency (>=30 requests, report n), + save/reopen latency, large-table scroll (`big_table_scroll_bench` with an extended sample + count), peak RSS, texture upload/free counts and bytes, allocation counters where available, + and font-atlas churn. +- Use the licensed pinned fixture/font set from section 6.2 for both baseline and candidate; + record file hashes, licences, DPI, theme and renderer identity. +- Report raw samples plus median/p95/p99 for frame distributions, median/p95 for discrete + operations with n, paired baseline/candidate ratios, and peak memory. Label harness CPU time + separately from presented-frame latency. Do not label a p99 from 20-50 samples as a tail + estimate; use at least 1,000 frames for frame distributions. +- Preliminary gates, frozen before candidate measurement: investigate a repeatable >5% median, + >10% p95 or >5% peak-RSS regression, any new idle busy-loop/repaint, or a lost absolute + budget. For sub-millisecond changes require the relative increase also to exceed the 0.05 ms + noise floor or corroborating allocation/instruction counts. Reject noisy comparisons as + inconclusive and rerun; never widen a threshold or tolerance after seeing a failure; any + exception needs a documented, pre-measurement tradeoff and review. +- Specifically reproduce/measure: + - upstream #8731: plain-text tessellation cost (UI chrome and dialogs); compare 0.36.2, + stock 0.37 and fork. If confirmed, review a row-level fast path, not a semantic change. + - upstream #8736: DX12 FXC pipeline creation and startup on Windows; separate renderer + construction from first frame. + - upstream #8512: system-font discovery startup if `system_fonts` is ever considered; this + plan keeps it off. +- Performance evidence must never be compile duration, a changed golden image, or an + unpaired run. Preserve baseline/candidate executables or hashes outside disposable targets. + +## 9. Reviewable PR stages, dependency DAG and rollback + +All stages are local until the user gives fresh direct approval for each publication. Each stage +must be independently reviewable and leave `main` buildable. No stage pushes/publishes by itself. + +``` +S0/P0 baseline harness + instrumentation (no production behavior) + └─ S1 atomic dependency + required text/capture adapters + craft-libs CL-1/CL-2F (one PR) + ├─ S2 adversarial screenshot/capture tests + visual/role review + ├─ S3 extended native IME/bidi/grapheme tests and optional language policy + ├─ S4 lifecycle/multi-pass/forced-hidden-pass/synthetic-input fixes + tests + └─ S5 native/web/Windows validation + performance evidence (after S2-S4) + └─ S6 documentation/progress updates + final adversarial review +``` + +- **S0/P0:** add the baseline capture scripts/fixtures, instrumentation and evidence layout; + record 0.36.2 baseline hashes, executable SHA256, pre-existing failures and the saved rollback + fixture. No dependency or production behavior change. Rehearse old-behavior replay with the + saved 0.36.2 executable and fixture before S1. +- **S1:** one atomic dependency PR, after the approved published P8 and completed winit + revisions exist: pin `=0.37.0`/`=0.30.13`, root patches, bump `egui_kittest`, and move + `craft-ui`/`craft-print` to the published revision containing CL-1 and CL-2F **in this same PR** (the + `d27646a` `craft-ui` in `crates/ui-egui/Cargo.toml:12` still requires egui 0.36 and cannot + compile against the 0.37 `Ui` passed at `crates/ui-egui/src/printing.rs:35`). Include + `Id::unique`, `scope_id`, + `Role` (all 34 sites), `MouseWheel.source` (including `examples/perf_probe.rs:106`), + `ScrollArea` accessors (including `scroll.rs:440`), `ScreenshotCallback`, + native `eframe/clipboard`, `default_fonts`/glyph policy, accessible-name/role sweep + and the print `label` adaptation. + Include CL-2F capture admission/drain/timeout/basic replies, remove duplicate + `bidi_display`/balloon visual reordering, handle E21 byte deletion and supply both + canvas E21b producers. These are required at the first fork pin; do not defer them. + The approved revisions contain P2b async W15P, P7 held-key pairing, P7b/E21b and P7c's + selected #8763/#8748 fixes; stock 0.37 or an intermediate stage is never pinned. Include the + minimal compile fixes, feature-identity checks and `cargo metadata` gate. This PR is not + split into "bump then fix"; the tree builds and `cargo xtask ci` passes at the end. Never pin + the aiongg research SHAs. +- **S2:** screenshot callback behavior: request-owned strong slots with weak callbacks, the + shared 4-request/128 MiB/256 MiB admission, geometry/DPI/zoom epoch cancel/retry, + `RequestPaintWhileHidden` for queued hidden requests, timeout/cancel/late-result tests, forced + hidden pass, visual review and explicit role assertions. Adopt `craft_ui::capture` only after + CL-2F freezes the API in S1; S2 expands stress tests. WordCraft needs CL-1 and CL-2F, while only the DesignCraft/GridCraft + pilots use the reviewed CL-2 provisional revision. If CL-2F is not available, wait; do not + publish a provisional adapter. +- **S3:** extend native grapheme/IME/bidi coverage and optional language policy on Context. + Required removal of double bidi, E21 deletion and both E21b adapters already land in S1. + Rollback removes only these later policy/test additions, never restores duplicate reorder. +- **S4:** lifecycle: stale dropped-file fix, forced multi-pass and forced hidden-pass tests, + logic-only tests, synthetic one-step gesture retention, autosave/screenshot/undo one-shot + tests. Can proceed in parallel with S3 after S1. +- **S5:** evidence: Linux X11/Wayland, Windows clone, browser, optional FreeBSD; performance + paired report including the stock-0.37 control; screenshots/geometry/accessibility/mutation + checks. No production edits except fixes found by the evidence, which go back into the owning + stage. +- **S6:** update `ROADMAP.md` progress log, `docs/gaps.md`/`docs/roadmap.md`/parity docs only for + behavior actually changed, and record the migration's status/revision history. No agent/ + model/tool signatures in public text; contributor credit remains username-only. + +Rollback: restore the previous coherent set, not just one version string: root `Cargo.toml` +egui family declarations, root `[patch.crates-io]`, `Cargo.lock`, `craft-ui`/`craft-print` rev, +and the source adaptations in one revert. Keep the old lockfile and baseline branch reachable. +Preserve the actual 0.36.2 release executable and SHA256 outside the disposable build target, +plus a saved `.docx` fixture; before S1 and after each stage, restore the prior coherent +manifests/lockfile/source set, run that executable on the saved fixture, and verify the old +open/edit/save/reopen behavior. Restoring only an old lockfile is not a rollback rehearsal. +No app persistence format changes are planned; panel ids are in-memory, preferences are unchanged. +If required text/IME compatibility cannot be proven, block S1 or restore its full coherent +predecessor set. Never restore 0.36 manual visual reordering while retaining the fork. +S3 alone may be reverted only because it contains later policy/tests, not required adapters. + +## 10. Open questions and blocking unknowns + +1. **craft-libs PR 199 is open/conflicting at inspection.** Its head `b68de8c` and historical + `ec8840a` tree must not be merged; port only the `8614e4a` API hunks and `1764bca` README + changes onto current `4919088`, preserving intervening fixes. The reviewed and published + CL-1 `craft-ui`/`craft-print` revision is a prerequisite for the WordCraft pin and must land + in the same atomic dependency stage (section 9). +2. **Fork publication/reachability.** Final pins must be the completed P8 `storytold/egui` + and completed `storytold/winit` revisions: approved, reachable and immutable. Their SHAs do + not exist at planning time and must not be invented; the aiongg candidate commits and stock + 0.37 are research/measurement inputs only. No branch pins and no intermediate production + stage in the release manifest. +3. **P7c selection/UI-regression fixes.** P7c owns the selected #8763 and #8748 fixes; + #8764 is an open, unmerged proposal input. The approved P8 revision must contain the central + fixes and their pointer/keyboard regressions; a WordCraft workaround is not an accepted + substitute. +4. **E21b/P7b and E4/P2b+W15P prerequisites.** The approved revisions must satisfy the complete + shared contracts in `egui` plan §6 E21b/E4 and winit plan §5.2/§5.3, including the W27 + lifecycle/serial rules, the W15P pointer-or-keyboard synchronous claim and bounded async + transfer. WordCraft's two canvas producers need real model/preedit/UTF-8 adapters. +5. **Capacity and platform evidence.** Host build reservations, a Windows clone, a Wayland + compositor, a browser with WebGPU/WebGL2, and macOS hardware are separate gates. Missing + evidence remains an explicit blocker, not a pass. +6. **Upstream #8731/#8736 measurements** are not yet reproduced on WordCraft. They may require a + reviewed fork patch or a documented tradeoff; do not accept a clamp-only texture change. +7. **Strict names/glyph sweep.** The exact list of unnamed inputs is unknown until the suite + runs. Fixing names and glyph coverage is part of S1, required rather than disabling the + check; most tests use `step`/`run_steps`, so explicit accessibility assertions are required. +8. **Bidi visual deltas.** Removing `bidi_display` should fix double reversal, but it changes + exact text placement in Backstage and comment balloons. Review screenshots at 1x/2x and both + themes; do not bulk-refresh goldens. +9. **IME byte ranges and native behavior.** macOS and Wayland text-input behavior cannot be + qualified on this Linux host. The byte path must refuse non-boundaries and be tested in a + native environment; a passing harness test is not native IME proof. The same applies to + native keyboard layouts and the screenshot-driven hidden pass. +10. **S0 instrumentation gap.** Raw frame samples, >=1,000-frame tails, cold start, idle CPU/ + wakeups and RSS are not measurable with the current tools and must be added before + production edits. Harness CPU time is not presented-frame latency. +11. **Font/emoji policy.** The actual family order is `Inter, Ubuntu-Light, egui-icons, + SourceSans, ...` (with `monochrome_emoji_fonts` explicitly enabled on WordCraft's test + root, not inherited from kittest), not the earlier proposed order. The UI has no color + emoji; document emoji use a pinned test fixture or a recorded native platform result. +12. **Craft-libs #198** remains a proposal, not a dependency. Do not silently include its + locale/formatting scope or font assets. +13. **`craft-print`/`craft-ui` #192** changes public print fields; confirm no other WordCraft + caller constructs these structs without the new field. +14. **Panel ids and any persisted egui state.** The app does not enable egui persistence now; + if that changes, old panel ids will not load. Record this as an explicit non-goal or add a + migration key deliberately. + +## 11. References + +Immutable or primary sources used for the API and behavior statements above: + +- egui 0.37.0 release commit: + +- `ScreenshotCallback` and `Context::request_screenshot`: + + and + +- `Event::MouseWheel` source and 0.37 input events: + +- `Ui::scope_id` / `Ui::id`, `Id::unique` / `Id::new`: + + and + +- `Context::root_ui` panic semantics (upstream #8535): + +- 0.37 release notes, including `default_fonts`, `Role`, screenshot and panel-id changes: + +- `egui_kittest` 0.37 accessibility/missing-glyph changes: + +- Candidate aiongg egui research input (not a production pin): + `a4f9a17f64a6bae61350fb85804c9df0d9865916`, `ai-0.37`; the inspected checkout's origin is + `emilk/egui`, and no `storytold/egui` `craft/0.37` ref contained it at inspection. +- Candidate aiongg winit research input (not a production pin): + `8b35388bd719e8cc86a95971ba0bc026c9a8f058`, `ai-lang` in `storytold/winit`. +- Final patch inventories will be linked from the approved published `storytold` revisions + after publication; do not use the candidate SHAs as immutable production links. +- craft-libs PR 199: +- craft-libs language-support issue 198: +- craft-libs current main used for the port: + `49190884278f16c9a9f5823d7698a8ffb5deb062`. +- Shared suite rollout/performance contract: + `craft-libs/docs/egui-037-suite-rollout.md` in the assigned suite checkout. +- Complete shared E21b/P2b and winit W15P/W27 contracts: `egui/docs/egui-037-migration-plan.md` + §6 (E4/P2b, E21b) and `winit/docs/egui-037-migration-plan.md` §5.2-5.3 in the planning + workspace. +- Pinned font fixtures (craft-fonts `2481a8a...`, Noto Color Emoji v2.047): + + and . +- Upstream reports used as risk hypotheses, not reproduced WordCraft results: + , + , + , + , + , + , + , + . +- Historical compiler diagnostics used only as clues: + `/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/fork/wordcraft.errors`. + +## 12. Revision history + +- 2026-10-23: initial plan. Inventory and source anchors from the assigned checkout; dependency + target, API refactors, lifecycle/root-`Ui` audit, behavior/evidence matrix, performance + protocol, staged PR DAG, rollback and blocking unknowns. No tests/builds run; no publication. +- 2026-10-23: revision 2 after independent review `plan-reviews/wordcraft-v1.md`. Corrected the + aiongg/reachability identity and PR 199 stale-tree rule; added E21b/P7b and W15P/E4/P2b as + fork prerequisites; replaced the screenshot channel with request-owned weak slots, count/byte + admission, in-flight budget, resize revalidation and post-unlock wakeup; documented the + screenshot-forced hidden pass and that requesting a screenshot schedules a repaint while + callback arrival does not; moved strict names/glyph work into S1; corrected font order and + emoji source; added `scroll.rs:440`; added exact Id counts, feature checks, P0 instrumentation, + native-vs-kittest boundaries, explicit role assertions, middle-click autoscroll preservation, + #8763/#8764 selection fix and executable rollback rehearsal. No tests/builds run. +- 2026-10-23: revision 3 after independent review `plan-reviews/wordcraft-v2.md`. Made S1 one + atomic dependency stage including craft-libs CL-1, replacing the S1/S2 cycle; made P8 and the + completed winit the only production pins; corrected the E21b/P2b/W15P contracts to the full + shared plans; adopted the 4/128 MiB/256 MiB capture limits and geometry-epoch rule; added + `RequestPaintWhileHidden` for queued hidden captures and the synthetic one-step gesture rule; + fixed `cargo metadata --filter-platform`, the 34th role site and the `perf_probe` wheel site; + added stock-0.37 controls, warmup/iteration floors and the RSS/noise thresholds; corrected + P7c/#8764 provenance, font prose and the #8535 citation. No tests/builds run. +- 2026-10-24: revision 4 after the IME repair review. Bounded the refused byte-counted equation + replacement to its raw-input batch (a later direct commit applies; no persistent tombstone), + and moved the simulated IME revision exhaustion behind the engine `test-support` feature that + only the UI's dev-dependencies enable. No tests/builds run. + + +## 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/wordcraft` `main` now resolves to `00ccee52354d9dfc48cb21a943b55f258fc76df0`. 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 the separate 64-entry engine UI-intent queue, newest Paste Special deduplication, save/export/print view preservation, linear-time unknown settings, finite font preview sizes and Format Painter cursor. Screenshot admission budgets are a separate queue. + +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 implementation note — 2026-10-23 + +Implemented in the owned worktree at canonical `00ccee52` without changing the workspace lockfile, +committing, running Cargo, or publishing: + +- Coherent 0.37 API port: `egui::Role` (including `Role::TextInput`, `Role::Image` and + `Role::Splitter` for the table-ruler grip), `Id::unique`, `Ui::scope_id`, + `MouseWheelSource`, `ScrollArea::clamped_offset`/`horizontal_scroll_offset`, and + `TextEdit::align`. +- Screenshot capture now uses `craft_ui::capture::CaptureSlot` with a weak callback. `WordApp` + owns request identity, an 8 s monotonic deadline (`web_time::Instant` on native and in the + browser), count (4), per-image (128 MiB) and aggregate (256 MiB) admission, retry accounting, + an immutable admitted document identity and a monotonic geometry epoch. Geometry is captured + from the host `RawInput` in the raw-input hook, including `minimized`/`occluded` and a Wayland + `inner_rect: None` fallback to the same frame's `screen_rect`; it is never read from stale + `InputState` geometry in a hidden logic tick. A minimized/occluded window and a document + switch cancel the request with an explicit error instead of retrying or rebinding it. The + deadline is checked before any successful image is encoded or written; stale geometry retries + only within the budget, while malformed, oversized and out-of-order completions do not. +- The engine's 64-entry UI-intent queue remains separate from the four screenshot requests. + Save/export/print view preservation, Paste Special deduplication, settings loading, font + preview bounds and the Format Painter cursor are unchanged. +- `logic` owns screenshot deadlines/results and asks for `RequestPaintWhileHidden` while a + capture or synthetic step is pending. `ui` issues the one callback command and counts only + first-pass layout settling. Native and web dropped-file handling moved to the first real UI + pass so hidden `run_logic` cannot replay stale RawInput. +- Synthetic input is offered once per host frame, survives hidden ticks, and is removed only + after a final non-discarded UI pass. Multi-pass and real-RawInput accumulation regressions are + in `crates/ui-egui/src/lib.rs`. +- Removed manual visual bidi reordering from Backstage and comment balloons. Comment balloons + now use `LayoutJob` with `TextDirection::Auto` and start alignment; the screen-position RTL + regression remains the oracle. +- Added `ImeEvent::DeleteSurroundingBytes` handling with UTF-8 boundary checks, selection + replacement, one typing undo group and deletion/empty-commit/emoji/combining-mark tests. +- Declared `default_fonts` + `monochrome_emoji_fonts` on the UI test root and `clipboard` on + the native eframe feature list. The exact test font order is pinned in `theme.rs`. + +### Fresh-review repairs (2026-10-23) + +The first 0.37 port was reviewed before compilation. These repairs are in the worktree: + +- Deadlines now use `web_time::Instant` on both native and browser, not `Date.now()` with a + backward clamp. Screenshot deadline, settling, autosave and animation share the same + monotonic clock. +- Capture geometry is taken from the host `RawInput` in `raw_input_hook`, never from stale + `InputState` geometry. The snapshot includes `minimized`, `occluded` and the Wayland + `inner_rect: None` fallback to that frame's `screen_rect`. A minimized/occluded surface is + rejected at admission and cancelled at issue/completion. +- A screenshot request stores an immutable admitted document id. Issuing or draining after a + document switch cancels the request with one explicit error; it is never rebound and retries + only for a same-document geometry change. Malformed/oversized completions are not retried. +- Admission reserves the encode working set (raw RGBA + worst-case PNG + base64), revalidates + it at issuance and checks the actual PNG/base64 size before replying. Completion requires the + exact expected physical dimensions; every reply carries the internal request token. +- Dropped files and the SmartArt Tab action run before the font warm-up return, so a drop in the + first two UI passes is not lost. Synthetic input waits for those frames and for a final + non-discarded UI pass. +- Chart-data cells use `Align2::RIGHT_TOP` (the deprecated `horizontal_align(Align::Max)` + changed x only); the regression checks the painted text's top/right position. The rename + field is tied to its label and the chrome/rename screens are checked for unnamed inputs. +- `unicode-bidi` is no longer a `ui-egui` dependency. Workspace manifests now name the 0.37 + egui family and `web-time`; `Cargo.lock` and the `craft-ui`/`craft-print` revision containing + `capture::CaptureSlot` and `Printer::label` remain parent integration gates. + +### E21b outgoing surrounding text (wired in this worktree) + +The reviewed E21b outgoing API is wired through the canvas observation path: + +1. `keys::observe_ime` emits one `IMEOutput` per focused viewport pass with an immutable + `ImeTarget` owner, context generation, purpose, canvas rectangle, cursor rectangle, + `should_interrupt_composition`, `change_cause` and `surrounding_text`. +2. The body producer uses `Document::ime_surrounding` and `VisibleParagraph`: tracked deletions + are hidden, inline objects contribute their plain text, offsets are logical UTF-8 bytes (never + display-reordered bidi), and the window is bounded by `MAX_IME_TEXT_BYTES`. It is reported as + `CommittedText`, with an unavailable revision when the selection spans blocks or cannot map. +3. The body owner is `(document id, story, body replacement epoch)`; an equation uses its stable + editing-session token. Undo/Redo and whole-body replacements advance the epoch/session so a + stale composition cannot be reused. +4. An equation or an unmappable/over-wide body selection is an explicit `Unavailable`, carrying + the engine revision that proves a committed edit; no truncated text is fabricated. +5. `publish_ime_receipt` accepts a successful IME edit only when the classified outcome and the + committed/unavailable stamps prove the exact transition; mixed availability requires the + unavailable side's checked revision, and exhausted revisions fail closed. + +Still outstanding for publication: native Wayland/focus-loss traces, an independent review of +this repair, final immutable fork/winit pins, and the parent's post-repair full gate rerun. +Kittest events are not native IME evidence. + +PR 787 is a historical patch reference only. This repository has no vendored-winit tree and +none was added; only the fetched PR metadata was inspected, and no vendored winit files or +security fixes were copied or overridden. + +All build, test, lockfile, formatting, native GUI and performance gates remain for the parent’s +managed `task.py` workflow. + +### Local behavioral qualification, 2026-10-23 + +The pinned craft-fonts corpus enables the strict UI suite without disabling missing-glyph checks. An intermediate run had 816 passed, 36 failed and 2 ignored; the later parent-managed run recorded 941 engine tests passing and 898 UI tests passing, with one doc IME fixture failure and three content-control placeholder undo failures whose source repairs are in the working tree. Those runs are pre-repair evidence only: the post-repair rerun remains required, and this document is not acceptance evidence. + +Read-only comment balloons now use `craft_ui::text::align_galley_rows` to place native-shaped rows against the card's paragraph start edge. This preserves the fork's logical text and mirrored glyphs. The existing Arabic test was updated to inspect atlas bitmap orientation because `Glyph::chr` now remains logical rather than describing a manually reordered display string. It also verifies an inherited-neutral paragraph and an LTR paragraph in the same balloon. The targeted RTL test passes. + +Pointer-driven caret changes explicitly interrupt IME after 0.37 stopped treating an already-focused `request_focus` as an interruption. The canvas regression passes while Space/Enter remain non-interrupting. The Ctrl-wheel anchor regression exposed a ScrollArea stale-bounds/previous-momentum defect; its fork repair has independent pre-fix failing controls and is under qualification. Native GUI traces for the wired E21b outgoing adapters, immutable publication pins and paired application performance remain outstanding. + + +## Latest local qualification — 2026-10-24 + +The candidate starts at `14001aee651090932fe6a6e23898f38b819f8c61` and includes the three-file +content overlay from observed main `c524027f34be2d1322b23064f80662d3638cf880` (root manifest, +Cargo.lock and shared-PDF documentation). The canonical lock SHA-256 is +`7f8878d742ad7ceab8d38dfcf878808bfd82c449088c7563c740e20372840865`. Independent reviews cover +the migration, stale-composition ownership, grapheme-safe edit/wrap boundaries, deterministic +pagination fixtures, normal production font features and current-main PDF overlay. + +The final full CI run passed fmt, strict clippy, tests, i18n-report, doctests, assets, layers and +WASM (16 crates). Its main test stage reports 4,106 passed, zero failed, four ignored and one +filtered test across 27 result summaries. Engine: 941 passed; layout: 503 passed; UI: 904 passed, +two ignored and one filtered. The separate i18n-report stage runs that filtered report test: +one passed, 4,110 filtered. Those filtered counts are selections over the same test binaries, +not additional unique tests. The doctest stage selected zero doctests. Earlier narrower runs +are not added to these totals. Full logs, source hashes and review bindings are retained in the +local task evidence under `final-landing/wordcraft-local-commit`. + +A separate normal-dependency production font probe passed for the visible table-style `⊞` +marker without external/system fonts or dev-dependency font unification. That probe preceded +the PDF-only overlay; font/runtime inputs remain unchanged. It establishes the bounded glyph +claim, not a full typography or platform result. Native input/GUI/capture/save/reopen traces, +paired app performance and memory/noise qualification, platform and installer execution and +explicit approval before each push/tag/new PR remain outstanding. No publication is claimed. + +Revision 5: replace stale pending local checks with the actual reviewed-source full-CI pass, +current immutable pins and bounded remaining acceptance gates; preserve earlier evidence/history. + + +### Remaining bundled-font notice gate + +Canonical egui0.37 default_fonts embeds `EGUI_ICONS` (the small emoji-icon-font subset), and +WordCraft starts from `FontDefinitions::default()` in `theme.rs`. The exact upstream MIT text +credits Copyright (c) 2014 John Slegers. WordCraft's existing NOTICE/ATTRIBUTION mention Rust +dependencies but do not currently carry that font copyright/permission text or an explicit +font attribution. Before distribution, complete and verify the font notice/attribution and +actual package notice paths. This is an explicit release gate separate from the six new +craft-fonts faces; the passing static asset check does not establish complete font-notice +coverage. Exact canonical license bytes are preserved in the local commit evidence. No claim +of a legal violation or complete bundled-font compliance follows from this scoped inspection. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index a096e516db..b6f6e57bcd 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** trivial (group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -235,10 +235,21 @@ The legacy text-shadow control compares visible grey pixels with a translated Ar Installed static fonts also resolve by their OpenType PostScript name (name ID 6), retaining the named face rather than substituting an unrelated family. Missing faces still use the existing fallback rules. A local Best for printing control from Word for Mac 16.113.2 (build 26092012) retained the directly requested Cambria-Bold face. +## Graphemes across drawing segments + +Font fallback and mixed run formatting can split a grapheme into several independently shaped +and painted pieces. Editing, hit testing and emergency line wrapping now use the full +paragraph's grapheme/complex-script cluster boundaries while preserving those pieces' fonts, +glyph ranges, source spans and sizes. The existing emoji ZWJ regression remains unchanged; +new mixed-style emoji and mixed bundled-font combining-mark controls cover LTR/bidi caret and +hit stops, full selection, and narrow/overwide line wrapping. Independent review and the full +layout suite are pending; this is not a new Word/native-IME acceptance claim. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; original emoji regression retained, new mixed-style/bundled-font LTR/bidi controls pending managed validation | | 2026-10-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged | | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index fbb2f5228f..ff207c7a9c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the egui 0.37 migration preserves the equation “Delete Around in Equation” translation in all 26 catalogs through the shared catalog pin; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (adding a language, the embedded catalogs): `crates/ui-egui/src/i18n/mod.rs`. The catalogs, their format and @@ -219,6 +219,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Preserved “Delete Around in Equation” in all 26 shared WordCraft catalogs; the native/release pin and Flatpak source advance together. | | 2026-10-23 | minor | Interface catalogs moved to storytold/craft-i18n (`locales//wordcraft.tsv`), looked up through craft-libs' `craft-i18n` crate and embedded from the pinned commit (`craft-i18n.rev`); strings other Crafting Apps already translate in the shared catalog now show translated; the "Open" paragraph-spacing preset no longer shows the file verb | | 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed | | 2026-10-23 | trivial | Interface-language tests: a missing translation falls back to English and never fails a test; `cargo xtask i18n-report` (part of `cargo xtask ci`) prints the strings each language lacks; single-language coverage tests removed | diff --git a/docs/shared-pdf.md b/docs/shared-pdf.md index b843deaecb..e8b6acb46c 100644 --- a/docs/shared-pdf.md +++ b/docs/shared-pdf.md @@ -2,7 +2,7 @@ WordCraft uses `craft-pdf` with default features disabled and `write` enabled. Both that dependency and the required root Krilla patch use the immutable `storytold/craft-libs` revision -`c3487320b2d1de657b860b3c8abb44e1f2b32bd8`. Rust 1.95 or newer is required. The revision must be +`9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef`. Rust 1.95 or newer is required. The revision must be available from the remote before a fresh checkout can resolve it. The shared writer checks a backend marker at compile time so an unpatched Krilla cannot replace the required backend. @@ -20,11 +20,14 @@ never chooses a clock or changes this policy. ## Backend sources and features -Krilla remains 0.8.2 with its default raster-image and simple-text support. WordCraft does not -embed existing PDF pages, so it does not enable Krilla's optional `pdf` feature or the shared -`write-pdf` feature. The shared writer has no parser dependency. Hayro syntax and interpretation -remain explicit test dependencies at their existing registry versions; no parser or codec fork -is introduced. The existing `craft-print` and `craft-ui` dependency pins are retained separately. +Krilla remains 0.8.2 with raster-image support only: the shared workspace turns Krilla's default +features off, so its naive `simple-text` shaping and the unmaintained `rustybuzz` and `ttf-parser` +crates behind it are not built. WordCraft shapes text itself and hands Krilla positioned glyphs, so +it does not enable the shared `write-simple-text` feature. WordCraft does not embed existing PDF +pages, so it does not enable Krilla's optional `pdf` feature or the shared `write-pdf` feature. +The shared writer has no parser dependency. Hayro syntax and interpretation remain explicit test +dependencies at their existing registry versions; no parser or codec fork is introduced. The +existing `craft-print` and `craft-ui` dependency pins are retained separately. Krilla's existing bundled profiles retain their upstream licences, recorded in the pinned shared repository's `licenses/pdf-writer/` and `ATTRIBUTION.md`. This migration diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 5f8d61472b..4433414d78 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** trivial (queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; the transactional equation delete proves its commit with the engine revision; a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; a tracked own-insertion shifts the rest of a two-sided IME deletion; AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -288,8 +288,8 @@ again. - An input method can ask to erase characters before and after the caret (`DeleteSurrounding`) and send replacement text. The web build's hidden text field does this whenever its contents change other than by adding to the end: Backspace and corrections from phone keyboards, and - the Chrome caret jump below. The desktop builds never receive it (egui-winit 0.36 doesn't - send it). WordCraft erases those characters as Backspace erases a selection, stopping at the + the Chrome caret jump below. The 0.37 desktop bridge can also deliver character- or + byte-counted surrounding deletions from the input method. WordCraft erases those characters as Backspace erases a selection, stopping at the paragraph's ends, then types the new text. The new text keeps the erased text's formatting and never overtypes what follows. Erasing and retyping stay in the open typing undo step, and typing after them joins it too (#1480, @@ -312,6 +312,74 @@ again. and the caret and the candidate window sit at the input method's cursor inside it: the end of the range it reports, or the end of the marked text when it reports none. The range is clamped to the text (`canvas::tests::the_caret_follows_the_input_method_cursor_in_the_composition`). +- A composition belongs to the text it was started on. When a command replaces the body under + the same document id — Replace Document Text, a version restore, Compare, Undo/Redo — or opens + a new document, the body's replacement epoch advances, the platform clears the composition, and + any marked text is dropped. Replacing the text with an identical one still starts a new + context (`canvas::tests::ime_generation_changes_on_body_replacement_with_the_same_text`, + `ime_generation_changes_on_undo_and_version_restore`, + `keys::tests::observe_ime_drops_a_preedit_without_focus`). Undo/Redo also gives the equation + being edited a fresh session token, so its context follows the restored tree. The marked text + is never painted without focus. +- If replacing the body or changing the edited equation invalidates nonempty marked text, + the next canvas input batch rejects that composition's queued surrounding deletions and one + replacement commit before any engine mutation. An associated `Text` fallback is rejected too. + A fresh nonempty preedit or ordinary typing ends that batch's suppression; without an old + marked composition, direct commits remain eligible. A later commit after the rejected one, + or in a later canvas batch, is also fresh input. New regression checks cover identical-body + replacement, both deletion units, equation exit before or within the batch, unchanged text + and Undo history, and fresh input (`keys::tests::ime_interrupted_body_preedit_drops_queued_replacement_before_mutation`, + `ime_interrupted_equation_preedit_drops_queued_body_replacement`, + `ime_interrupted_preedit_does_not_suppress_fresh_input`). Their managed validation is pending; + this does not claim native input-method acceptance or identify untagged events across frames. +- An accepted IME commit earns an `InputMethod` receipt only when the engine inserted exactly the + committed text. A single quote the engine leaves as typed (smart quotes off) is the input + method's own change; a smart quote or AutoCorrect replacement is `Other` + (`ime_apostrophe_with_smart_quotes_off_is_an_input_method_change`, + `ime_apostrophe_with_smart_quotes_on_stays_other`, + `ime_literal_commit_after_autoformat_is_input_method`). A `DeleteSurrounding` whose two sides + cannot both be applied is rolled back whole and publishes no receipt + (`a_refused_second_delete_surrounding_rolls_the_request_back`); every such refusal also + interrupts the composition and clears the painted pre-edit and both replacement joins because + the platform's hidden text already changed + (`a_refused_body_delete_clears_preedit_and_joins`). Under Track Changes, deleting text this + author inserted shortens the stored paragraph, so the following range and caret are shifted by + the bytes actually removed (`tracked_delete_surrounding_shifts_past_its_own_insertion`, + `tracked_own_insertion_then_a_refused_side_rolls_the_whole_request_back`). Emptying a content + control restores its placeholder, which may make the paragraph longer; a body deletion maps the + following range and caret by the signed stored-length change, and refuses the whole transaction + when growth cannot be proven to come from a placeholder restored at or before the erased range + (`delete_surrounding_maps_after_text_and_caret_past_a_growing_placeholder`, + `delete_surrounding_maps_a_shrinking_placeholder_and_after_text`, + `delete_surrounding_maps_a_placeholder_restored_by_the_after_side`). Control markers are + zero-length visible objects, so a range start maps after the start marker and after the end + marker: an ordinary IME deletion never breaks a control + (`ime_text::tests::control_markers_are_not_included_in_delete_ranges`). The replacement's undo + join is scoped to the next `text.insert` at the selection the deletion left; an intervening + command owns its own step (`body_delete_join_does_not_leak_to_an_intervening_command`). +- Inside an equation, `DeleteSurrounding` erases the counted characters with the equation's own + transactional command (at most 64 units a side), refusing the whole request if any step cannot + delete — including a step that would only move the caret into another argument + (`equation_delete_surrounding_refuses_a_mixed_caret_only_step`); the delete and the following + commit are one undo group. Byte counts have no reversible mapping into the + structured tree, so the whole request is refused, the composition is interrupted and the + replacement the platform sends for that rejected deletion — the immediately adjacent `Commit`, + or an adjacent `Text` + `Commit` pair — is dropped instead of being inserted beside the + untouched equation (the pre-edit never lives in the model). Any later `Text`/`Commit` in the + same raw-input batch is fresh input and applies, as is a later direct commit without a pre-edit; + no persistent tombstone can swallow it. An over-cap request clears the painted pre-edit and any + earlier deletion's replacement join before refusing. The equation's surroundings stay explicitly + unavailable (`ime_equation_delete_is_one_input_method_change`, + `ime_equation_delete_and_commit_are_one_undo_step`, + `ime_equation_byte_delete_drops_only_its_immediate_replacement`, + `over_cap_equation_delete_clears_the_painted_preedit`, + `over_cap_equation_delete_clears_the_previous_replacement_join`). The engine's checked revision + on the unavailable stamp proves a successful content change; a refusal, an automatic rewrite or + an exhausted revision publishes no receipt (`ime_transformed_equation_commits_are_other`, + `exhausted_ime_revisions_never_certify_equation_or_wide_body_edits`). +- Pre-edit and commit sizes are bounded (the visible window at 4000 bytes, a pre-edit at the same + cap, a commit at 64 KiB): larger hostile events are ignored rather than painted or inserted + (`oversized_ime_events_are_ignored`). ## Known gaps @@ -342,6 +410,8 @@ again. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | Input methods: reject queued old-composition replacements before mutation after body/equation target replacement (new regression validation pending); equation `DeleteSurrounding` is one transactional command whose checked revision proves a receipt; a caret-only step in a mixed request refuses the whole deletion, and an over-cap request clears the painted pre-edit and the previous replacement join; a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, never a later direct commit; a tracked own-insertion shifts the following range; a body deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its replacement undo join to the matching `text.insert`; refusals clear the painted pre-edit and joins; AutoCorrect/AutoFormat classification compares the paragraph as typed; equation replacement interrupts the composition | +| 2026-10-23 | trivial | Input methods: a replaced body (Replace Document Text, version restore, Compare, Undo/Redo) starts a new context and interrupts the composition; smart quotes off keeps an IME quote an `InputMethod` change; equation `DeleteSurrounding` erases counted characters; bounded pre-edit/commit sizes; a partly refused two-sided deletion is rolled back | | 2026-10-22 | trivial | Input methods: while composing, the caret and the candidate window follow the input method's cursor inside the marked text | | 2026-10-21 | trivial | Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459) | | 2026-10-21 | trivial | List AutoFormat (`- `, `1. `, …) triggers at the start of an already-written paragraph, not only a fresh empty one; checked in Word for Windows (#3352) | diff --git a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml index c1f9a0be17..8237997b0e 100644 --- a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml @@ -70,6 +70,6 @@ modules: # The interface translations at the commit in craft-i18n.rev (a test keeps the two equal). - type: git url: https://github.com/storytold/craft-i18n - commit: 04600cbb1508f1b15927ec574a5e32e5f376fd8b + commit: e5bfe19b95d30f50f250985bdc3aeec47c05af09 dest: craft-i18n - cargo-sources.json diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 25c1b7e57c..9b136372bd 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -129,7 +129,6 @@ if has appimage; then } APPDIR="$WORK/WordCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" ln -s usr/bin/wordcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index c791ffbd19..bd2593cf84 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -93,6 +93,19 @@ Copy-Item (Join-Path $Bin 'wordcraft.exe'), (Join-Path $Bin 'wordcraft-cli.exe') & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'wordcraft.exe') (Join-Path $Stage 'wordcraft-cli.exe') +# ---- font notices for the MSI ------------------------------------------------------------------ +# The portable ZIP already carries these notices; stage the complete files before MSI creation too. +$Docs = Join-Path $Stage 'font-licences' +New-Item -ItemType Directory -Force -Path $Docs | Out-Null +$HasCraftFontLicences = 0 +if ($env:CRAFT_FONTS_DIR) { + foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- # The installer's welcome page shows the license: build its RTF from LICENSE-MIT and LICENSE-APACHE # so it never drifts from them. Hard-wrapped lines are rejoined into paragraphs so the text wraps @@ -130,6 +143,7 @@ Invoke-Native 'wix build' { -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" ` -d "LicenseRtf=$LicenseRtf" ` + -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. diff --git a/packaging/windows/wordcraft.wxs b/packaging/windows/wordcraft.wxs index c0fa1f0aa8..1980772db0 100644 --- a/packaging/windows/wordcraft.wxs +++ b/packaging/windows/wordcraft.wxs @@ -16,6 +16,9 @@ already installed") or silently uninstall the other. packaging-lint.yml checks this. --> + + + + + + + + + + + From 65a0dc8dc67f31b5fb9644b4c88b60ce765213d3 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Sun, 25 Oct 2026 01:19:28 -0200 Subject: [PATCH 03/50] Method paths for 18 closures that only call a method (SonarCloud S1612) SonarCloud's analysis of main at b4da59f0c lists 23 closures such as `|v| v.as_slice()` that hand their argument straight to one method. This passes the method itself: `Vec::as_slice`, `str::to_ascii_lowercase`, `f64::total_cmp` and the like, all forms the repo already uses elsewhere. Behaviour is unchanged. Five stay. The four in crates/proof/src/es/mod.rs sit in code that #4191 replaces, and its replacement already uses `PoisonError::into_inner`. In crates/engine/src/io/safe_save.rs the path would be `::is::`, which reads worse than `|inner| inner.is::()`. --- apps/wordcraft/src/file_dialogs.rs | 2 +- crates/doc/src/cjk_numbers.rs | 2 +- crates/docbin/src/piece.rs | 2 +- crates/docx/src/read/story.rs | 2 +- crates/docx/src/write/embed.rs | 2 +- crates/fonts/src/fontdb.rs | 2 +- crates/fonts/src/ui_fallback.rs | 2 +- crates/formats/src/odt.rs | 2 +- crates/layout/src/lib.rs | 8 ++++---- crates/layout/src/math.rs | 4 ++-- crates/metafile/src/geom.rs | 2 +- crates/proof/src/affix.rs | 2 +- crates/ui-egui/examples/perf_probe.rs | 2 +- xtask/src/layers.rs | 2 +- 14 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 27c5349712..667298a72b 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -208,7 +208,7 @@ fn save_exts(name: &str) -> Vec { let suggested = std::path::Path::new(name) .extension() .and_then(std::ffi::OsStr::to_str) - .map(|e| e.to_ascii_lowercase()) + .map(str::to_ascii_lowercase) .filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str())) .unwrap_or_else(|| "docx".to_string()); let mut exts: Vec = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len()); diff --git a/crates/doc/src/cjk_numbers.rs b/crates/doc/src/cjk_numbers.rs index f3b6585c40..bdb1fbe658 100644 --- a/crates/doc/src/cjk_numbers.rs +++ b/crates/doc/src/cjk_numbers.rs @@ -238,7 +238,7 @@ pub(crate) fn ideograph_zodiac_traditional(n: u32) -> String { /// The `n`th (1-based) item of a sequence, or decimal past its end. fn nth(set: &[char], n: u32) -> String { - n.checked_sub(1).and_then(|i| set.get(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string()) + n.checked_sub(1).and_then(|i| set.get(i as usize)).map(ToString::to_string).unwrap_or_else(|| n.to_string()) } /// Code points `first..` enclosing 1 to `count`, decimal beyond. diff --git a/crates/docbin/src/piece.rs b/crates/docbin/src/piece.rs index 00a34c323c..a7274c9d9d 100644 --- a/crates/docbin/src/piece.rs +++ b/crates/docbin/src/piece.rs @@ -113,7 +113,7 @@ impl PieceTable { } PrmRef::Sprm0 { isprm, val: (p.prm >> 8) as u8 } } else { - PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(|v| v.as_slice())) + PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(Vec::as_slice)) } } diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 2048ea30d5..f948239d39 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -2291,7 +2291,7 @@ fn css_string(v: &str) -> String { out.push(d); } // One whitespace after the escape is its terminator, not part of the value. - if chars.clone().next().is_some_and(|w| w.is_whitespace()) { + if chars.clone().next().is_some_and(char::is_whitespace) { chars.next(); } } diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 56f3a78988..6e04956143 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -68,7 +68,7 @@ impl<'d> EmbedWriter<'d> { "[content_types].xml", ] .iter() - .map(|s| s.to_string()) + .map(ToString::to_string) .collect(); taken.insert(VBA_PROJECT_PART.to_ascii_lowercase()); // The bibliography writer uses these names when sources are present (#532). diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index abfc5b2f61..6d3dc1a6c7 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1243,7 +1243,7 @@ impl FontDb { stack.push(p); continue; } - let ext = p.extension().and_then(std::ffi::OsStr::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; } diff --git a/crates/fonts/src/ui_fallback.rs b/crates/fonts/src/ui_fallback.rs index 12a4a1a1d2..3ef38e55be 100644 --- a/crates/fonts/src/ui_fallback.rs +++ b/crates/fonts/src/ui_fallback.rs @@ -182,7 +182,7 @@ pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: if !tried.insert(path.clone()) { continue; } - let Some(size) = std::fs::metadata(&path).ok().filter(|m| m.is_file()).map(|m| m.len()) else { continue }; + let Some(size) = std::fs::metadata(&path).ok().filter(std::fs::Metadata::is_file).map(|m| m.len()) else { continue }; if size > MAX_FILE { continue; } diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index e1f0fd8860..8abf06cb82 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -440,7 +440,7 @@ impl Writer { /// Paragraph `p`, or with `chain` the paragraphs joined at deleted paragraph marks (`p` /// is the first). fn para_in(&mut self, p: &Para, chain: Option<&Vec>, out: &mut String) { - let paras: &[Para] = chain.map(|c| c.as_slice()).unwrap_or(std::slice::from_ref(p)); + let paras: &[Para] = chain.map(Vec::as_slice).unwrap_or(std::slice::from_ref(p)); let start = self.master.take(); self.chain = chain.map(|c| c.iter().map(|q| self.para_style(q)).collect()).unwrap_or_default(); self.part = 0; diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index f7d4b9b341..3068f6d207 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -1304,7 +1304,7 @@ fn layout_box( let float_y = if float.v_rel == Anchor::Paragraph { para_top } else { y }; let r = float_rect(frame, (0.0, width), float_y, w, h, float); let outline = ctx.wrap_outline(o); - excl.extend(wrap_area(r, float, outline.as_deref().map(|v| v.as_slice()))); + excl.extend(wrap_area(r, float, outline.as_deref().map(Vec::as_slice))); floats.insert(oi, r); } let rel = rel_exclusions(&excl, 0.0, y); @@ -3045,8 +3045,8 @@ impl PageFrame<'_> { /// Alignment `a` across the page: inside and outside become right and left on an even page. fn h_align(frame: Option<&Self>, a: Option) -> Option { match a { - Some(FloatAlign::Inside) if frame.is_some_and(|f| f.even()) => Some(FloatAlign::End), - Some(FloatAlign::Outside) if frame.is_some_and(|f| f.even()) => Some(FloatAlign::Start), + Some(FloatAlign::Inside) if frame.is_some_and(Self::even) => Some(FloatAlign::End), + Some(FloatAlign::Outside) if frame.is_some_and(Self::even) => Some(FloatAlign::Start), a => a, } } @@ -3365,7 +3365,7 @@ fn anchor_floats( // Positions relative to the paragraph start above its space before (Word for Windows). let mut r = float_rect(Some(frame), (col_x, width), y0 - before, w, h, float); let outline = ctx.wrap_outline(o); - let outline = outline.as_deref().map(|v| v.as_slice()); + let outline = outline.as_deref().map(Vec::as_slice); let mut area = wrap_area(r, float, outline); if pb.settle && text_before { let seed = early.and_then(|v| v.iter().find(|(b, k, _)| (*b, *k) == (block, oi))).map(|(_, _, a)| a); diff --git a/crates/layout/src/math.rs b/crates/layout/src/math.rs index 6180fad39b..7291e89dd6 100644 --- a/crates/layout/src/math.rs +++ b/crates/layout/src/math.rs @@ -1518,7 +1518,7 @@ impl Ctx { } segs_rows.push(segs.into_iter().map(hcat).collect()); } - let ncols = segs_rows.iter().map(|r| r.len()).max().unwrap_or(1); + let ncols = segs_rows.iter().map(Vec::len).max().unwrap_or(1); // Rows without `&` are centred below, so they don't widen the aligned columns. let mut col_w = vec![0.0f32; ncols]; let mut lone_w = 0.0f32; @@ -1829,7 +1829,7 @@ impl Ctx { } cells.push(row); } - let ncols = cells.iter().map(|r| r.len()).max().unwrap_or(0); + let ncols = cells.iter().map(Vec::len).max().unwrap_or(0); let mut col_w = vec![0.0f32; ncols]; for r in &cells { for (i, b) in r.iter().enumerate() { diff --git a/crates/metafile/src/geom.rs b/crates/metafile/src/geom.rs index 8063c730ab..0547de18ba 100644 --- a/crates/metafile/src/geom.rs +++ b/crates/metafile/src/geom.rs @@ -103,7 +103,7 @@ pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec { let mut at = 0usize; for &c in counts { let end = at.saturating_add(c).min(pts.len()); - let Some((first, rest)) = pts.get(at..end).and_then(|s| s.split_first()) else { + let Some((first, rest)) = pts.get(at..end).and_then(<[_]>::split_first) else { at = end; continue; }; diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index 1839a10346..c2580bca77 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -419,7 +419,7 @@ impl AffixDict { } stems.push((word, flags)); } - AffixDict::build(rules, stems, comments.iter().map(|s| s.to_string()).collect()) + AffixDict::build(rules, stems, comments.iter().map(ToString::to_string).collect()) } fn build(rules: Rules, mut stems: Vec<(String, String)>, comments: Vec) -> Result { diff --git a/crates/ui-egui/examples/perf_probe.rs b/crates/ui-egui/examples/perf_probe.rs index b2ef7fc42d..1ea778f3d7 100644 --- a/crates/ui-egui/examples/perf_probe.rs +++ b/crates/ui-egui/examples/perf_probe.rs @@ -27,7 +27,7 @@ fn step(h: &mut egui_kittest::Harness<'_, WordApp>) -> f64 { fn stats(v: &[f64]) -> serde_json::Value { let mut s = v.to_vec(); - s.sort_by(|a, b| a.total_cmp(b)); + s.sort_by(f64::total_cmp); let at = |q: f64| s.get(((s.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0); json!({"n": s.len(), "med": (at(0.5) * 10.0).round() / 10.0, "p95": (at(0.95) * 10.0).round() / 10.0, "max": (at(1.0) * 10.0).round() / 10.0}) } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index a1f43d86f1..ee0e69e47a 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -192,7 +192,7 @@ pub fn check(crates: &[Crate]) -> Vec { } } } - out.sort_by_key(|v| v.to_string()); + out.sort_by_key(ToString::to_string); out.dedup(); out } From 500f9cdfea3db39f3d44abf41aabf780a814a70e Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Sun, 25 Oct 2026 01:01:41 -0200 Subject: [PATCH 04/50] proof: remove the unused Patterns::from_parts It came in with the first proofing commit (d1a0ed39c) and has never had a caller; `#[allow(dead_code)]` kept the warning quiet. Because it is `pub(crate)`, nothing outside wordcraft-proof can reach it. --- crates/proof/src/patterns.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/proof/src/patterns.rs b/crates/proof/src/patterns.rs index a91235e48f..cb5855d493 100644 --- a/crates/proof/src/patterns.rs +++ b/crates/proof/src/patterns.rs @@ -217,11 +217,6 @@ impl Patterns { self.exceptions.len() } - #[allow(dead_code)] - pub(crate) fn from_parts(alpha: Alphabet, map: FxMap>, max_len: usize) -> Patterns { - Patterns { alpha, map, max_len, exceptions: HashMap::new() } - } - /// Make `letters` part of the alphabet even if no pattern mentions them. pub fn add_letters(&mut self, letters: impl IntoIterator) { for c in letters { From 944c67ba893e0ddc01fb787ad70659bf31fab033 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Sun, 25 Oct 2026 01:19:46 -0200 Subject: [PATCH 05/50] Mail merge: MERGEFIELD in any case is a merge field, MERGEFIELDX is not `mergefield_name` matched with `strip_prefix("MERGEFIELD")`. A field typed as `mergefield City` was therefore no merge field at all: Check for Errors skipped it, and Preview Results and Finish & Merge left it alone. A code such as `MERGEFIELDX a` went the other way and was read as a merge field named `X`. Field-type names ignore case (ECMA-376 Part 1, 17.16.1: "The field-type names DATE, Date, dAtE, and date are equivalent"). The other keywords in this file already go through the upper-cased `keyword()`. Now the type ends at the first space or quote and is compared ignoring case, which is the rule the shared HYPERLINK parser in #4222 follows. The two IF checks for a nested MERGEFIELD ignore case as well, so `IF { mergefield X } = ...` merges like its upper-case form. Old and new compared on 5,940 generated codes: 2,760 give the same name, 960 are case variants that are now found, and 2,220 start with a longer word such as `MERGEFIELDX` that no longer counts as MERGEFIELD. Test: merge_field_types_ignore_case. --- crates/engine/src/cmd/mailings.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 0b1ce53110..d45bc08d27 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -785,7 +785,13 @@ fn merge_fields(doc: &Document) -> Vec { } fn mergefield_name(instr: &str) -> Option { - let rest = instr.trim().strip_prefix("MERGEFIELD")?.trim(); + // The field type ignores case and ends at a space or a quote, so `MERGEFIELDX` is another field. + let t = instr.trim(); + let (kind, rest) = t.split_at_checked(t.find(|c: char| c.is_whitespace() || c == '"').unwrap_or(t.len()))?; + if !kind.eq_ignore_ascii_case("MERGEFIELD") { + return None; + } + let rest = rest.trim(); let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") }; Some(name.to_string()) } @@ -887,7 +893,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option { "SKIPIF" if condition(s, t, row).0 => Act::Skip, "NEXTIF" | "SKIPIF" => Act::Text(String::new()), // IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch). - "IF" if t.contains("MERGEFIELD") => { + "IF" if t.to_ascii_uppercase().contains("MERGEFIELD") => { let (holds, texts) = condition(s, t, row); Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string()) } @@ -904,7 +910,7 @@ fn placeholder(instr: &str) -> Option { "ADDRESSBLOCK" => Some("«AddressBlock»".into()), "GREETINGLINE" => Some("«GreetingLine»".into()), "NEXT" | "NEXTIF" | "SKIPIF" | "MERGEREC" => Some(String::new()), - "IF" if instr.contains("MERGEFIELD") => Some(String::new()), + "IF" if instr.to_ascii_uppercase().contains("MERGEFIELD") => Some(String::new()), _ => None, } } @@ -1546,6 +1552,24 @@ mod tests { assert_eq!(r, vec![vec!["Name, first, last", "City"], vec!["Ann", "Paris"]]); } + /// Field types ignore case (ECMA-376 Part 1, 17.16.1), and `MERGEFIELDX` is another field. + #[test] + fn merge_field_types_ignore_case() { + assert_eq!(mergefield_name(" mergefield City ").as_deref(), Some("City")); + assert_eq!(mergefield_name(r#"MergeField "First Name""#).as_deref(), Some("First Name")); + assert_eq!(mergefield_name(r#"MERGEFIELD"City""#).as_deref(), Some("City")); + assert_eq!(mergefield_name("MERGEFIELDX City"), None); + assert_eq!(placeholder("mergefield City").as_deref(), Some("«City»")); + assert_eq!(placeholder(r#"if { mergefield City } = "x" "y" "z""#).as_deref(), Some("")); + let mut s = Session::new(Document::new()); + s.run("mailings.recipients", &json!({"rows": [{"Name": "Alpha"}, {"Name": "Beta"}]})).unwrap(); + s.run("mailings.start", &json!({"kind": "directory"})).unwrap(); + s.run("insert.field", &json!({"instr": "mergefield Name", "result": ""})).unwrap(); + s.run("insert.field", &json!({"instr": r#"if { mergefield Name } = "Beta" " (b)" """#, "result": ""})).unwrap(); + s.run("mailings.finish", &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha\nBeta (b)"); + } + #[test] fn merge_end_to_end() { let mut s = Session::new(Document::new()); From f3961d74d9369b1e171cd9eb8f827376a396dbeb Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Sun, 25 Oct 2026 02:37:43 -0200 Subject: [PATCH 06/50] Mail merge: an IF is a merge rule only when it tests a MERGEFIELD Preview Results and Finish & Merge took any IF whose code held the word MERGEFIELD anywhere, and with the type now matched in any case, a quoted value counted too: IF { PAGE } = "mergefield" "yes" "no" was evaluated as a rule on an empty field and showed "no". Both guards now ask what condition() already reads: the first nested field, through mergefield_name. An IF on other fields keeps its result. --- crates/engine/src/cmd/mailings.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index d45bc08d27..488b9bc4a1 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -846,11 +846,15 @@ enum Act { Skip, } +/// The merge field a rule tests: the name in its first nested field, if that is a MERGEFIELD. +fn rule_field(t: &str) -> Option { + mergefield_name(t.split_once('{').and_then(|(_, r)| r.split('}').next())?) +} + /// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for /// `row`: whether it holds, and the quoted texts after the value (IF's true / false text). fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) { - let inner = t.split_once('{').and_then(|(_, r)| r.split('}').next()).unwrap_or(""); - let field = mergefield_name(inner).unwrap_or_default(); + let field = rule_field(t).unwrap_or_default(); let rest = t.split_once('}').map(|(_, r)| r).unwrap_or(""); let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect(); let mut quoted = rest.split('"').skip(1).step_by(2); @@ -893,7 +897,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option { "SKIPIF" if condition(s, t, row).0 => Act::Skip, "NEXTIF" | "SKIPIF" => Act::Text(String::new()), // IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch). - "IF" if t.to_ascii_uppercase().contains("MERGEFIELD") => { + "IF" if rule_field(t).is_some() => { let (holds, texts) = condition(s, t, row); Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string()) } @@ -910,7 +914,7 @@ fn placeholder(instr: &str) -> Option { "ADDRESSBLOCK" => Some("«AddressBlock»".into()), "GREETINGLINE" => Some("«GreetingLine»".into()), "NEXT" | "NEXTIF" | "SKIPIF" | "MERGEREC" => Some(String::new()), - "IF" if instr.to_ascii_uppercase().contains("MERGEFIELD") => Some(String::new()), + "IF" if rule_field(instr).is_some() => Some(String::new()), _ => None, } } @@ -1570,6 +1574,23 @@ mod tests { assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha\nBeta (b)"); } + /// An IF is a merge rule only when the field it tests is a MERGEFIELD: the word in a quoted + /// value doesn't make `IF { PAGE } = "mergefield" …` one. + #[test] + fn an_if_on_another_field_is_not_a_merge_rule() { + let other = r#"IF { PAGE } = "mergefield" "yes" "no""#; + assert_eq!(placeholder(other), None); + assert_eq!(placeholder(r#"IF { PAGE } = { MERGEFIELD City } "yes" "no""#), None); + assert_eq!(placeholder(r#"IF { MERGEFIELD City } = "mergefield" "yes" "no""#).as_deref(), Some("")); + let mut s = Session::new(Document::new()); + s.run("mailings.recipients", &json!({"rows": [{"Name": "Alpha"}]})).unwrap(); + s.run("mailings.start", &json!({"kind": "directory"})).unwrap(); + s.run("insert.field", &json!({"instr": "mergefield Name", "result": ""})).unwrap(); + s.run("insert.field", &json!({"instr": other, "result": " kept"})).unwrap(); + s.run("mailings.finish", &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha kept"); + } + #[test] fn merge_end_to_end() { let mut s = Session::new(Document::new()); From e31f2f06a5f8004ceaa88f68c00b47908e321aa4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 25 Oct 2026 09:04:19 -0400 Subject: [PATCH 07/50] Release 0.11.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 40 ++++++++++++++++++++-------------------- Cargo.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e762610f08..b46326df88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "wordcraft" -version = "0.10.0" +version = "0.11.0" dependencies = [ "arboard", "chrono", @@ -6556,7 +6556,7 @@ dependencies = [ [[package]] name = "wordcraft-cli" -version = "0.10.0" +version = "0.11.0" dependencies = [ "serde_json", "sys-locale", @@ -6569,11 +6569,11 @@ dependencies = [ [[package]] name = "wordcraft-control-key" -version = "0.10.0" +version = "0.11.0" [[package]] name = "wordcraft-doc" -version = "0.10.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "proptest", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "wordcraft-docbin" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cfb", "chrono", @@ -6603,7 +6603,7 @@ dependencies = [ [[package]] name = "wordcraft-docx" -version = "0.10.0" +version = "0.11.0" dependencies = [ "aes", "base64 0.22.1", @@ -6627,7 +6627,7 @@ dependencies = [ [[package]] name = "wordcraft-engine" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cfb", "chrono", @@ -6657,7 +6657,7 @@ dependencies = [ [[package]] name = "wordcraft-fonts" -version = "0.10.0" +version = "0.11.0" dependencies = [ "harfrust 0.13.3", "kurbo", @@ -6670,7 +6670,7 @@ dependencies = [ [[package]] name = "wordcraft-formats" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cfb", "chardetng", @@ -6688,7 +6688,7 @@ dependencies = [ [[package]] name = "wordcraft-geom" -version = "0.10.0" +version = "0.11.0" dependencies = [ "image", "serde", @@ -6696,7 +6696,7 @@ dependencies = [ [[package]] name = "wordcraft-layout" -version = "0.10.0" +version = "0.11.0" dependencies = [ "image", "js-sys", @@ -6716,7 +6716,7 @@ dependencies = [ [[package]] name = "wordcraft-mcp" -version = "0.10.0" +version = "0.11.0" dependencies = [ "log", "serde_json", @@ -6728,7 +6728,7 @@ dependencies = [ [[package]] name = "wordcraft-metafile" -version = "0.10.0" +version = "0.11.0" dependencies = [ "kurbo", "thiserror 2.0.21", @@ -6736,7 +6736,7 @@ dependencies = [ [[package]] name = "wordcraft-pdf" -version = "0.10.0" +version = "0.11.0" dependencies = [ "chrono", "craft-pdf", @@ -6757,7 +6757,7 @@ dependencies = [ [[package]] name = "wordcraft-proof" -version = "0.10.0" +version = "0.11.0" dependencies = [ "log", "miniz_oxide 0.8.9", @@ -6767,7 +6767,7 @@ dependencies = [ [[package]] name = "wordcraft-render" -version = "0.10.0" +version = "0.11.0" dependencies = [ "image", "log", @@ -6781,7 +6781,7 @@ dependencies = [ [[package]] name = "wordcraft-ui-egui" -version = "0.10.0" +version = "0.11.0" dependencies = [ "cfb", "chrono", @@ -6810,7 +6810,7 @@ dependencies = [ [[package]] name = "wordcraft-web" -version = "0.10.0" +version = "0.11.0" dependencies = [ "eframe", "egui", @@ -6829,7 +6829,7 @@ dependencies = [ [[package]] name = "wordcraft-zotero" -version = "0.10.0" +version = "0.11.0" dependencies = [ "log", "proptest", @@ -6992,7 +6992,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 7dc0744b0f..a79a64b2b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"] [workspace.package] -version = "0.10.0" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 52d81d02d3fcf5de69e4ec77335bbe2fa574cc9d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:47:48 +0100 Subject: [PATCH 08/50] Parse \genfrac in LaTeX import so binomials survive export (#4242) Found while testing crates/doc/src/math_latex.rs:861-865 (to_latex, FracKind::NoBar) Co-authored-by: Claude Haiku 5.5 --- crates/doc/src/math_latex.rs | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/doc/src/math_latex.rs b/crates/doc/src/math_latex.rs index 932aab1826..526b7966e1 100644 --- a/crates/doc/src/math_latex.rs +++ b/crates/doc/src/math_latex.rs @@ -324,6 +324,21 @@ impl Lp { let den = self.arg(); one(MNode::Frac { kind: FracKind::Bar, num, den }) } + "genfrac" => { + let beg = delim_arg(&self.arg()); + let end = delim_arg(&self.arg()); + let thickness = self.arg(); + let _style = self.arg(); + let num = self.arg(); + let den = self.arg(); + let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar }; + let frac = MNode::Frac { kind, num, den }; + if beg.is_none() && end.is_none() { + one(frac) + } else { + one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] }) + } + } "binom" | "dbinom" | "tbinom" => { let n = self.arg(); let k = self.arg(); @@ -801,6 +816,31 @@ fn text_chars(text: &str, s: &mut String) { } } +/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else. +fn delim_arg(a: &[MNode]) -> Option { + let [MNode::Run(r)] = a else { return None }; + let mut cs = r.text.chars(); + match (cs.next(), cs.next()) { + (Some(c), None) if c != '.' => Some(c), + _ => None, + } +} + +/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty +/// thickness means the default rule and anything that is not a plain number and unit is not zero. +fn is_zero_length(a: &[MNode]) -> bool { + let mut text = String::new(); + for n in a { + let MNode::Run(r) = n else { return false }; + text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}'))); + } + let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic()); + let unit = &text[number.len()..]; + !number.is_empty() + && matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu") + && number.parse::().is_ok_and(|v| v == 0.0) +} + fn delim_tex(c: Option) -> String { match c { None => ".".into(), @@ -1076,6 +1116,34 @@ mod tests { let _ = (LimLoc::UndOvr, MathJc::Center); } + #[test] + fn latex_binom_round_trip() { + let a = parse_latex(r"\binom{n}{k}"); + let l = to_latex(&a); + assert_eq!(parse_latex(&l), a, "exported as {l}"); + let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) }; + let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}"); + assert_eq!(b, vec![nobar("n", "k")]); + assert_eq!(parse_latex(&to_latex(&b)), b); + // Other spellings of a zero thickness. + for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] { + let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}")); + assert_eq!(c, vec![nobar("n", "k")], "thickness {z}"); + } + // A non-zero or default thickness keeps the bar. + for t in ["1pt", "0.4pt", ""] { + let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}")); + let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") }; + assert_eq!(c, vec![bar], "thickness {t:?}"); + } + // Delimiters are kept. + let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}"); + assert_eq!(d, a); + let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}"); + assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}"); + assert_eq!(parse_latex(&to_latex(&e)), e); + } + #[test] fn latex_round_trip() { for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sin⁡x+cos⁡y", "e^(-x^2)"] { From f490f9da9a4d36d7894fdc82b8e00ed27afc3f6d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:54:32 +0100 Subject: [PATCH 09/50] Linux desktop entry: stop declaring text/plain (#4206) * Linux desktop entry: stop declaring text/plain (fixes #4137) * Docs: plain text is no longer in the Linux desktop entry (#4137) --- apps/wordcraft/src/default_apps.rs | 13 ++++++++++++- docs/file-format-parity.md | 2 +- packaging/linux/ai.storyteller.wordcraft.desktop | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/wordcraft/src/default_apps.rs b/apps/wordcraft/src/default_apps.rs index 263fc73ae1..f1c1c06056 100644 --- a/apps/wordcraft/src/default_apps.rs +++ b/apps/wordcraft/src/default_apps.rs @@ -36,7 +36,8 @@ const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop"; /// The types the button makes WordCraft the default for on Linux and BSD: Word documents and /// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer -/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With. +/// lists under WordCraft's Capabilities; Markdown is only offered under Open With, and plain +/// text only on Windows and macOS (the `.desktop` file leaves it out, #4137). /// Every one is in the `.desktop` file's `MimeType=` (a test checks). #[cfg(any(all(unix, not(target_os = "macos")), test))] const WORD_MIME_TYPES: &[&str] = &[ @@ -164,6 +165,16 @@ mod tests { assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line"); } + /// Declaring `text/plain` makes WordCraft a handler for every config, script and log on systems with no default set. + #[test] + fn the_desktop_entry_does_not_declare_plain_text() { + let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop"); + let declared: Vec<&str> = + entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default(); + assert!(!declared.is_empty(), "MimeType= line not found"); + assert!(!declared.contains(&"text/plain"), "text/plain must not be declared"); + } + #[cfg(unix)] #[test] fn application_dirs_follow_the_xdg_spec() { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a81ed2ce06..85ee07c508 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -143,7 +143,7 @@ register WordCraft for these types but never take over a default; File › Optio | Platform | Registered types | Default app | Notes | |---|---|---|---| | Windows (MSI) | `.docx` `.docm` `.dotx` `.dotm` `.doc` `.dot` `.rtf` `.odt`; `.md` `.txt` under Open With only | Options button opens Settings › Default apps on WordCraft's page | One ProgId per type, `OpenWithProgids`, Capabilities + `RegisteredApplications` (`packaging/windows/wordcraft.wxs`). Not yet tried on a real install | -| Linux, BSD (deb, rpm, Flatpak, AppImage) | Word, ODT, RTF, Markdown, LaTeX, HTML, text (`MimeType=` in the desktop entry, AppStream `mediatype`) | Options button runs `xdg-mime default` for the Word, RTF and ODT types | Not from inside Flatpak (the sandbox can't change host defaults); a bare AppImage needs desktop integration first | +| Linux, BSD (deb, rpm, Flatpak, AppImage) | Word, ODT, RTF, Markdown, LaTeX, HTML (`MimeType=` in the desktop entry, AppStream `mediatype`) | Options button runs `xdg-mime default` for the Word, RTF and ODT types | Not from inside Flatpak (the sandbox can't change host defaults); a bare AppImage needs desktop integration first | | macOS | `CFBundleDocumentTypes` as Alternate handler (Word, ODT, RTF, Markdown, HTML, text) | Finder › Get Info › Open with › Change All (the Options page says so) | Apple events open the files (#27) | ## Paragraph-default preservation diff --git a/packaging/linux/ai.storyteller.wordcraft.desktop b/packaging/linux/ai.storyteller.wordcraft.desktop index e725883644..d5453a8d1d 100644 --- a/packaging/linux/ai.storyteller.wordcraft.desktop +++ b/packaging/linux/ai.storyteller.wordcraft.desktop @@ -11,4 +11,4 @@ StartupNotify=true StartupWMClass=ai.storyteller.wordcraft Categories=Office;WordProcessor; Keywords=word;processor;document;docx;writer;text;letter;report; -MimeType=application/vnd.openxmlformats-officedocument.wordprocessingml.document;application/vnd.ms-word.document.macroEnabled.12;application/vnd.openxmlformats-officedocument.wordprocessingml.template;application/vnd.ms-word.template.macroEnabled.12;application/msword;application/msword-template;application/vnd.oasis.opendocument.text;application/rtf;text/rtf;text/markdown;text/x-tex;text/html;text/plain; +MimeType=application/vnd.openxmlformats-officedocument.wordprocessingml.document;application/vnd.ms-word.document.macroEnabled.12;application/vnd.openxmlformats-officedocument.wordprocessingml.template;application/vnd.ms-word.template.macroEnabled.12;application/msword;application/msword-template;application/vnd.oasis.opendocument.text;application/rtf;text/rtf;text/markdown;text/x-tex;text/html; From 66e5c06b25ae2c934d534750b46a62fdd39f136a Mon Sep 17 00:00:00 2001 From: 7mz410 <80298365+7mz410@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:55:53 +0300 Subject: [PATCH 10/50] Lists: draw Arabic letter numbering (arabicAlpha, arabicAbjad) (#4218) * Lists: draw Arabic letter numbering (arabicAlpha, arabicAbjad) instead of decimal Both orders of the 28 letters; past the last letter they double, as Latin and Russian letters do. * Docs: layout parity lists Arabic list labels as drawn; status lines and revision history --- crates/doc/src/section.rs | 40 +++++++++++++++++++++++++++++++++++--- docs/file-format-parity.md | 11 +++++++---- docs/layout-parity.md | 7 +++++-- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index 4fa0849682..5090a6352e 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -255,7 +255,7 @@ impl NumFormat { pub fn from_ooxml(s: &str) -> NumFormat { NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal) } - /// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai, + /// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai, /// Vietnamese, text amounts) show decimal, as do values past a sequence's end /// (the 21st item of ①…⑳). pub fn format(self, n: u32) -> String { @@ -268,6 +268,8 @@ impl NumFormat { NumFormat::LowerLetter => letters(n), NumFormat::RussianUpper => russian_letters(n).to_uppercase(), NumFormat::RussianLower => russian_letters(n), + NumFormat::ArabicAlpha => repeated_letter(&ARABIC_ALPHA, n), + NumFormat::ArabicAbjad => repeated_letter(&ARABIC_ABJAD, n), NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)), NumFormat::CardinalText => cardinal_text(n), NumFormat::OrdinalText => ordinal_text(n), @@ -308,8 +310,6 @@ impl NumFormat { | NumFormat::VietnameseCounting | NumFormat::Hebrew1 | NumFormat::Hebrew2 - | NumFormat::ArabicAlpha - | NumFormat::ArabicAbjad | NumFormat::HindiVowels | NumFormat::HindiConsonants | NumFormat::HindiNumbers @@ -399,6 +399,27 @@ pub fn russian_letters(n: u32) -> String { std::iter::repeat_n(c, k as usize).collect() } +/// `arabicAlpha`: the 28 letters in alphabetical (hijā'ī) order. +const ARABIC_ALPHA: [char; 28] = + ['أ', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي']; + +/// `arabicAbjad`: the 28 letters in abjad order (أبجد هوز حطي كلمن سعفص قرشت ثخذ ضظغ). +const ARABIC_ABJAD: [char; 28] = + ['أ', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ']; + +/// One letter of `alphabet`, repeated once per pass through it (as [`letters`]: a..z, aa..zz). +fn repeated_letter(alphabet: &[char], n: u32) -> String { + let Some(&first) = alphabet.first() else { return n.to_string() }; + if n == 0 { + return String::new(); + } + let len = alphabet.len() as u32; + let n = n.min(len * 30); + let k = (n - 1) / len + 1; + let c = alphabet.get(((n - 1) % len) as usize).copied().unwrap_or(first); + std::iter::repeat_n(c, k as usize).collect() +} + const ONES: [&str; 20] = [ "zero", "one", @@ -920,6 +941,19 @@ mod tests { assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ"); } + #[test] + fn arabic_letters_count_in_both_orders() { + let alpha: Vec = (1..=5).map(|n| NumFormat::ArabicAlpha.format(n)).collect(); + assert_eq!(alpha.join(" "), "أ ب ت ث ج"); + let abjad: Vec = (1..=5).map(|n| NumFormat::ArabicAbjad.format(n)).collect(); + assert_eq!(abjad.join(" "), "أ ب ج د ه"); + assert_eq!(NumFormat::ArabicAlpha.format(28), "ي"); + assert_eq!(NumFormat::ArabicAbjad.format(28), "غ"); + // Past the 28th letter they double, as Latin and Russian letters do. + assert_eq!(NumFormat::ArabicAlpha.format(29), "أأ"); + assert_eq!(NumFormat::ArabicAbjad.format(30), "بب"); + } + #[test] fn landscape_swaps() { let mut s = SectionProps { margin_left: 50.0, ..Default::default() }; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 85ee07c508..69f7ea577b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -76,12 +76,12 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Feature | Read | Write | Notes | |---|---|---|---| -| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…); runs that mix right-to-left and left-to-right text are split on save where the direction changes, and the right-to-left pieces get `w:rtl` and a bidi language (`w:lang w:bidi`, e.g. he-IL), so Word lays Hebrew around Latin names, numbers and e-mail addresses out as WordCraft does and doesn't call it Arabic (#1543, checked in Word for Mac 16.113.4), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | +| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…); runs that mix right-to-left and left-to-right text are split on save where the direction changes, and the right-to-left pieces get `w:rtl` and a bidi language (`w:lang w:bidi`, e.g. he-IL), so Word lays Hebrew around Latin names, numbers and e-mail addresses out as WordCraft does and doesn't call it Arabic (#1543, checked in Word for Mac 16.113.4), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats and Arabic letters (`arabicAlpha`, `arabicAbjad`) are drawn, the rest (Hebrew, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | -| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | +| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats and Arabic letters (`arabicAlpha`, `arabicAbjad`) are drawn, the rest (Hebrew, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | | Positional tabs (`w:ptab`: alignment, margin or indent, leader) | ✅ | ✅ | Laid out at their place whatever the tab stops; one with an alignment or base the spec doesn't define reads as a plain tab (#2367). `crates/docx/tests/it/positional_tabs.rs` | | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256), every `w:tblStylePr` part (wholeTable, firstRow, lastRow, firstCol, lastCol, band1/2Vert, band1/2Horz, ne/nw/se/swCell) with run formatting, `w:jc`, cell borders, shading and `w:vAlign` (#714); a cell's own `w:vAlign w:val="top"` is kept and beats the style's (#1288); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362); table style band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`) are read and written, and a style without them has no bands, as Word reads it (ECMA-376 says one), while our banded styles write theirs (#3415) | -| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | +| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including a Styles gallery for files that mark no style `w:qFormat` (LibreOffice: Word's usual quick styles are added, #1238), complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats and Arabic letters (`arabicAlpha`, `arabicAbjad`) are drawn, the rest (Hebrew, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) | | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Row table border exceptions (`w:tblPrEx/w:tblBorders`) round-trip (`row_table_borders_round_trip`); other row table exceptions are not preserved. Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | | | Hyphenation settings (`w:autoHyphenation`, `w:hyphenationZone`, `w:doNotHyphenateCaps`, `w:consecutiveHyphenLimit`) | ✅ | ✅ | Zone, capitals and limit kept and honoured by layout (#407) | @@ -251,10 +251,13 @@ Imported text fields use the first nonempty cached result run's formatting on th ## Revision history +> 2026-10-25 · Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`). + > 2026-10-23 · cu20we: DrawingML flat, square and round line caps are imported, saved, displayed and exported to PDF; absent or unknown caps default to flat. VML end caps (including shapetype inheritance) are preserved through DOCX save. Ink and metafile stroke handling remain separate. Synthetic DOCX, raster and PDF regressions cover the change; no new native Word comparison. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`) | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | | 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 3e54caf942..ce04ba3602 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -55,7 +55,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852). An otherwise empty section at page start retains its line (`empty_page_section_keeps_line`); a closing mark after text stays quiet. | 1–2 | | Labels on page-break-only list paragraphs | ✅ | `layout::tests::break_only_list_has_no_label`: no bullet or number on the break, but the next number is still 3 after item 1 and a break. Plain empty items keep their labels. Synthetic DOCX observed through Adobe PDF conversion | — | | List labels after a leading page break | ✅ | `layout::tests_bidi::list_label_after_leading_page_break_uses_its_text_lines_alignment`: the number/bullet and text retain the no-break horizontal positions for start, centre, end and justified alignment, LTR/RTL, including text with a tab. The issue's synthetic DOCX exports with the number before the heading, matching the reporter's Word/LibreOffice reference (#2964); reference applications were not rerun locally | — | -| List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Arabic, Hindi, Thai and Vietnamese formats show decimal The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | +| List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Hindi, Thai and Vietnamese formats show decimal; `arabicAlpha` and `arabicAbjad` draw Arabic letters, then double (أأ, بب) (`doc::section::tests::arabic_letters_count_in_both_orders`). The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | @@ -245,6 +245,8 @@ Installed static fonts also resolve by their OpenType PostScript name (name ID 6 ## Revision history +> 2026-10-25 · Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`). + > 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged. > 2026-10-23 · cu20we: DrawingML flat, square and round line caps are imported, saved, displayed and exported to PDF; absent or unknown caps default to flat. VML end caps (including shapetype inheritance) are preserved through DOCX save. Ink and metafile stroke handling remain separate. Synthetic DOCX, raster and PDF regressions cover the change; no new native Word comparison. @@ -253,6 +255,7 @@ Installed static fonts also resolve by their OpenType PostScript name (name ID 6 | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`) | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | | 2026-10-23 | trivial | cu20we: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged | | 2026-10-24 | trivial | Exact-spacing table contact uses the specified line height in row selection and word skipping; large/small mark regressions in modes 11/12/14/15 (#3977); shape estimates unchanged | From 84c2d37a4986c2e842a1b549779cd3ced5feffae Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:57:06 +0100 Subject: [PATCH 11/50] Cap list label length when expanding level template text (#4003) Fixes #3638 Co-authored-by: Claude Haiku 5.5 --- crates/doc/src/numbering.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index d4323cdddc..30e7acfccb 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -357,7 +357,8 @@ impl Counters { return Some((def.text.clone(), def)); } let mut label = String::new(); - let mut chars = def.text.chars().peekable(); + // Word's level text is short; .docx and the lists command already cap it at 64. + let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable(); while let Some(c) = chars.next() { if c == '%' && let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) @@ -375,6 +376,9 @@ impl Counters { f => f, }; label.push_str(&fmt.format(val)); + if label.len() > MAX_LABEL_BYTES { + break; + } } else { label.push(c); } @@ -383,10 +387,29 @@ impl Counters { } } +/// Longest level text (in characters) expanded into a list label. +const MAX_LEVEL_TEXT: usize = 64; +/// Labels stop growing once they pass this many bytes. +const MAX_LABEL_BYTES: usize = 256; + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_huge_level_text_gives_a_bounded_label() { + let mut n = Numbering::default(); + let id = n.add_list(ListKind::Numbered); + let huge = Level { format: NumFormat::UpperRoman, text: "%1".repeat(30_000), start: u32::MAX, ..Level::default() }; + if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) { + num.level_overrides = vec![(0, huge)]; + } + let mut c = Counters::default(); + let label = c.next_label(&n, id, 0).unwrap().0; + assert!(label.len() <= 512, "label is {} bytes", label.len()); + assert!(label.starts_with("MMMM")); + } + #[test] fn a_list_s_own_level_definitions_win() { // A specification's article numbers: level 1 counts parts with no label of its own, level 2 From 9003e2b0af2e6f4a75853cb2358d255f6ba32390 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:59:48 -0400 Subject: [PATCH 12/50] Limit DOCX XML attributes (#3857) Caps attributes per XML element at 1024 with a Limit error. Co-authored-by: Claude Opus 5.5 --- crates/docx/src/xml.rs | 38 +++++++++++++++++++++++++++++++ crates/docx/tests/it/malformed.rs | 15 ++++++++++++ 2 files changed, 53 insertions(+) diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index 6fd1db7e96..0436265b0f 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -15,6 +15,8 @@ use crate::DocxError; pub const MAX_DEPTH: usize = 256; /// Maximum number of elements in one part. pub const MAX_ELEMENTS: usize = 8_000_000; +/// Maximum number of attributes on one element (#3702). +pub const MAX_ATTRS: usize = 1024; /// Known namespaces and the canonical prefix we use for them (read and write). pub const NAMESPACES: &[(&str, &str)] = &[ @@ -360,6 +362,9 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res }; let mut el = El { name, attrs: Vec::new(), kids: Vec::new() }; for a in e.attributes().with_checks(false) { + if el.attrs.len() >= MAX_ATTRS { + return Err(DocxError::Limit("too many attributes on one element".into())); + } let Ok(a) = a else { continue }; let key = a.key; let raw_key = key.as_ref(); @@ -669,6 +674,39 @@ mod tests { assert!(parse(s.as_bytes()).is_err()); } + /// One start tag carrying 200,000 attributes (distinct or duplicate names) is refused or + /// truncated instead of allocated: a 59 KB `.docx` used to cost 611 MB (#3702, WC-06). + #[test] + fn too_many_attributes_on_one_element_is_capped() { + // The cap the issue suggests; a fix may pick a lower one, never a higher one. + const CAP: usize = 1024; + let distinct: String = (0..200_000).map(|i| format!(" a{i}=\"\"")).collect(); + let duplicate = " a=\"\"".repeat(200_000); + for (label, attrs) in [("distinct", distinct), ("duplicate", duplicate)] { + let x = format!("", NAMESPACES[0].1); + match parse(x.as_bytes()) { + Err(DocxError::Limit(_)) => {} + Ok(p) => assert!(p.attrs.len() <= CAP, "{label}: kept {} attributes", p.attrs.len()), + Err(e) => panic!("{label}: expected a Limit error or a capped element, got {e}"), + } + } + // Real-world attribute counts are untouched. + let x = format!( + r#""#, + NAMESPACES[0].1 + ); + let p = parse(x.as_bytes()).unwrap(); + assert_eq!(p.attrs.len(), 4); + let fonts = p.child("w:rPr").and_then(|r| r.child("w:rFonts")).unwrap(); + assert_eq!(fonts.attrs.len(), 9); + assert_eq!(fonts.attr("w:cstheme"), Some("minorBidi")); + // Far more than any real producer writes, still well under any sane cap. + let many: String = (0..256).map(|i| format!(" a{i}=\"{i}\"")).collect(); + let p = parse(format!("").as_bytes()).unwrap(); + assert_eq!(p.attrs.len(), 256); + assert_eq!(p.attr("a255"), Some("255")); + } + #[test] fn garbage_is_error_not_panic() { assert!(parse(b"").is_err()); diff --git a/crates/docx/tests/it/malformed.rs b/crates/docx/tests/it/malformed.rs index df69bbd16d..0dcaeb8a9f 100644 --- a/crates/docx/tests/it/malformed.rs +++ b/crates/docx/tests/it/malformed.rs @@ -150,6 +150,21 @@ fn many_fields_and_huge_attribute_values() { assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("later")); } +/// A paragraph with 2,000,000 attributes deflates to ~15 KB. Opening it is refused with a +/// limit error, or the extras are dropped and the text survives (#3702, WC-06). +#[test] +fn millions_of_attributes_on_one_paragraph_are_capped() { + let attrs: String = (0..2_000_000).map(|i| format!(" a{i}=\"\"")).collect(); + let x = format!("kept"); + let start = std::time::Instant::now(); + match read(&doc_zip(x.as_bytes())) { + Err(DocxError::Limit(_)) => {} + Ok(d) => assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("kept")), + Err(e) => panic!("expected a Limit error or a document, got {e}"), + } + assert!(start.elapsed() < std::time::Duration::from_secs(10), "took {:?}", start.elapsed()); +} + /// A small valid document.xml to mutate. fn base_xml() -> String { format!( From 77200f9c130c46441e453cabe8789b586bfe42de Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:04:21 +0100 Subject: [PATCH 13/50] Indent a whole selected paragraph on Tab instead of replacing it (#4291) Fixes #4285 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/text.rs | 8 ++++++-- crates/engine/src/tests_typing.rs | 17 +++++++++++++++++ docs/typing-parity.md | 1 + 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 6db399d8d0..5761a3e94c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -773,8 +773,12 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult { if s.sel.is_collapsed() && tab_indent_on(s) && tab_indents(s, &f) { return indent_by_tab(s, &f); } - if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path { - // Multiple paragraphs selected: increase indent. + let whole_para = !s.sel.is_collapsed() && s.sel.anchor.path == s.sel.focus.path && { + let (a, b) = s.sel.ordered(); + a.off == 0 && s.doc.para_at(&a).is_some_and(|p| b.off == p.len()) + }; + if !s.sel.is_collapsed() && (s.sel.anchor.path != s.sel.focus.path || whole_para) { + // Several paragraphs or a whole paragraph selected: increase indent. return super::para::indent(s, &Value::Null); } ins_char(s, '\t') diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index c9a2c434d9..37ff3ccb38 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -3303,3 +3303,20 @@ fn highlight_partial_column_ignores_unselected_marks() { assert_eq!(p.mark.highlight, Some(Highlight::Yellow)); } } + +#[test] +fn tab_with_a_whole_paragraph_selected_indents_it() { + let mut s = typed("Hello world"); + run(&mut s, "select.paragraph", json!({})); + run(&mut s, "text.tab", json!({})); + assert_eq!(paras(&s), vec![para("Hello world", None)]); + assert_eq!(indents(&s).0, Some(36.0)); +} + +#[test] +fn tab_with_a_partial_selection_still_inserts_a_tab() { + let mut s = typed("Hello world"); + run(&mut s, "select.text", json!({"text": "ello"})); + run(&mut s, "text.tab", json!({})); + assert_eq!(paras(&s), vec![para("H\t world", None)]); +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 5120306827..5ab504bfb5 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -56,6 +56,7 @@ Bullets and Numbering decide whether to toggle off from the selected paragraphs' | Enter on an empty **nested** item | moves it up one level; only a top-level empty item leaves the list. The same when the list comes from the paragraph style (List Number 2 in a Word file, #2143) | `enter_on_an_empty_nested_item_moves_it_up_a_level_first`, `enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level` | | `* ` Enter (nothing typed) | list cancelled, Normal paragraph | `enter_right_after_starting_a_list_cancels_it` | | Tab / Shift+Tab at the start of an item | demote / promote | `shift_tab_and_tab_change_list_levels` | +| Tab with a whole paragraph (or several paragraphs) selected | indents the paragraph(s) by one default tab stop; the text stays. A partial selection inside one paragraph is replaced by a tab character | `tab_with_a_whole_paragraph_selected_indents_it`, `tab_with_a_partial_selection_still_inserts_a_tab` | | Backspace at the start of an item | 1st: number removed, indent kept; 2nd: Normal paragraph — **not** joined to the one above | `backspace_at_the_start_of_an_item_removes_the_number_then_the_indent` | | Enter mid-item / at item start | splits the item / inserts an empty item above | `enter_in_the_middle_or_at_the_start_of_an_item` | | Shift+Enter in an item | line break inside the same item | `shift_enter_stays_inside_the_list_item` | From 8c366eb176732635ce1d538c2eb386f1b1442814 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:19:38 +0100 Subject: [PATCH 14/50] Layout: a bookmark is no line-break opportunity (#4211) * Layout: a bookmark is no line-break opportunity (fixes #4193) * Layout parity: a bookmark is no line-break opportunity (#4193) --- crates/layout/src/para.rs | 21 +++++++++++++++------ crates/layout/src/tests.rs | 29 +++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +++-- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index f5c4a16083..16e3d34be1 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1517,6 +1517,8 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let math_props = doc.settings.math.clone().unwrap_or_default(); // The byte ranges of runs left out of the layout, in order, adjacent runs merged. let mut left: Vec> = Vec::new(); + // The byte ranges of objects with nothing to show (bookmarks, comment anchors), in order. + let mut bare: Vec> = Vec::new(); // Each run's proofing language and whether it is checked (spelling, grammar, hyphenation). let mut runs: Vec = Vec::new(); for (range, props) in p.run_ranges() { @@ -1752,7 +1754,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { push(&mut b, ClKind::Tab, 0.0, 0.0); ptabs.push((b.clusters.len() - 1, *align, *relative_to, *leader)); } - _ => push(&mut b, ClKind::Marker, 0.0, 0.0), + _ => { + bare.push(start..end); + push(&mut b, ClKind::Marker, 0.0, 0.0) + } } } } @@ -1769,15 +1774,19 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { if rp.snap_to_grid && env.grid.chars.is_some() { Vec::new() } else { auto_space(&mut b, &p.text, rp.auto_space_de, rp.auto_space_dn) }; // Break opportunities and hyphenation points come from the text as laid out: hidden text that is // not shown and, without markup, tracked deletions are left out, so a space or soft hyphen there - // is no place to break, and words are hyphenated as they read. + // is no place to break, and words are hyphenated as they read. An object with nothing to show + // is left out too: its U+FFFC has the line-break class CB, but a bookmark never moves a break. + let mut skip = left.clone(); + skip.extend(bare); + skip.sort_unstable_by_key(|r| r.start); // The text and, for each char of it, its end there and in `p.text`. - let (text, ends): (std::borrow::Cow, Vec<(usize, usize)>) = if left.is_empty() { + let (text, ends): (std::borrow::Cow, Vec<(usize, usize)>) = if skip.is_empty() { (p.text.as_str().into(), Vec::new()) } else { let (mut text, mut ends) = (String::new(), Vec::new()); - let mut next = left.iter().peekable(); + let mut next = skip.iter().peekable(); for (i, c) in p.text.char_indices() { - // `left` is in order: skip the ranges that end here, then is `i` in the next one? + // `skip` is in order: skip the ranges that end here, then is `i` in the next one? while next.next_if(|r| r.end <= i).is_some() {} if next.peek().is_some_and(|r| r.start <= i) { continue; @@ -1789,7 +1798,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { }; // The end of a char in `text` → the same place in `p.text`. let to_para = |i: usize| -> Option { - if left.is_empty() { Some(i) } else { ends.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| ends.get(k)).map(|e| e.1) } + if skip.is_empty() { Some(i) } else { ends.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| ends.get(k)).map(|e| e.1) } }; let mut found = Vec::new(); for (i, o) in unicode_linebreak::linebreaks(&text) { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index fccabf1154..b697eaf946 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1443,6 +1443,35 @@ fn a_bookmark_before_a_left_tab_past_the_right_indent_is_not_a_line() { assert_eq!(lines(true), lines(false)); } +/// A bookmark is not text: where a line breaks is the same with it as without it. U+FFFC has the +/// line-break class CB, which on its own would allow a break before and after it. +#[test] +fn a_bookmark_is_no_line_break_opportunity() { + // (the bookmark's offset in "Oremus", or none) -> the lines' clusters, not counting the bookmarks' + let lines = |marks: &[usize]| { + let mut d = Document::from_text("Oremus"); + // A column too narrow for the word, which then breaks inside it. + d.last_section.page_w = d.last_section.margin_left + d.last_section.margin_right + 30.0; + // From the end, so the offsets stay those of "Oremus". + for (n, &at) in marks.iter().enumerate().rev() { + let mark = InlineObject::BookmarkStart { name: format!("b{n}") }; + d.insert_object(&Pos::body(0, at), mark, &Default::default()).unwrap(); + } + let l = lay(&d); + let Some(para) = l.pages[0].items.iter().find_map(|i| if let Placed::Lines { para, .. } = i { Some(para.clone()) } else { None }) else { + panic!("no lines") + }; + para.lines + .iter() + .map(|l| para.clusters.get(l.c0..l.c1).map_or(0, |cs| cs.iter().filter(|c| c.kind != para::ClKind::Marker).count())) + .collect::>() + }; + let plain = lines(&[]); + assert!(plain.len() > 1, "the word is meant to break inside: {plain:?}"); + assert_eq!(lines(&[0, 0]), plain, "two bookmarks before the word leave no line of their own"); + assert_eq!(lines(&[3]), plain, "a bookmark inside the word is no place to wrap"); +} + #[test] fn lists_get_labels_and_indent() { let mut d = Document::from_text("one\ntwo\nthree"); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index ce04ba3602..493ec6a677 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -25,7 +25,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | 2026-10-16 | trivial | Split-cell content retains its resolved top margin and border band on continuation pages, matching original Word Mac16.113.2 controls. Synthetic regressions cover margins, borders, line spacing and image frames. | | Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. | — | | Subpoint table grid columns | 🟡 | Preserve positive grid widths rather than imposing a text-box minimum; ECMA-376 §17.4.16. Native Word for Mac 16.113.2 build26092012 confirms narrow trailing grid units. `layout::tests::subpoint_grid_keeps_widths`, `tiny_grid_scales_to_width`. Tiny totals normalize without overflowing the scale factor. Zero and invalid widths retain their fallback. Border painting remains separate. | n/a | -| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word` | — | +| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — | | A break before a slash that starts a word (" /word" wraps whole; not after a no-break space) | 🟡 | `layout::tests::a_word_after_a_slash_wraps_whole`, `layout::tests::a_no_break_space_before_a_slash_still_holds`; behaviour from the #2963 report, not compared with Word yet | — | | Narrow table cells | ✅ | `layout::tests::narrow_cells_keep_their_width`: usable widths below 12 pt wrap without expansion; oversized glyphs still make progress | 0 | | Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109 | — | @@ -263,6 +263,7 @@ Installed static fonts also resolve by their OpenType PostScript name (name ID 6 | 2026-10-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged | | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | +| 2026-10-25 | trivial | A bookmark, comment anchor or other zero-width marker is no line-break opportunity: a word that doesn't fit after one no longer leaves a blank line, and one inside a word no longer splits it (#4193) | | 2026-10-22 | trivial | A table row that another row follows fits on a page only with room for the border below it, as checked in Word for Mac: a 60-row table with 3 pt borders kept one row more than Word on page 1 for bottom margins of 78.2–81.0 pt (`layout::tests::a_row_leaves_room_for_the_border_below_it`) | | 2026-10-16 | trivial | Exact PostScript names resolve installed static faces without changing family lookup | | 2026-10-21 | trivial | Pin the font input of four geometric layout controls; assertions and renderer font policy unchanged. | From 7563ebb314e32e39d155a37da067602ebe380623 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:23:25 +0100 Subject: [PATCH 15/50] Layout: a no-break space is as wide as a space, not as the font's own U+00A0 glyph (#4210) * Layout: a no-break space is as wide as a space, not as the font's own U+00A0 glyph (fixes #4192) * Layout parity: a no-break space is as wide as a space (#4192) --- crates/layout/src/lib.rs | 2 + crates/layout/src/para.rs | 4 ++ crates/layout/src/tests_nbsp.rs | 65 +++++++++++++++++++++++++++++++++ docs/layout-parity.md | 3 +- 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 crates/layout/src/tests_nbsp.rs diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index f7d4b9b341..0d30535386 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -4939,6 +4939,8 @@ mod tests_grid; #[cfg(test)] mod tests_ligature; #[cfg(test)] +mod tests_nbsp; +#[cfg(test)] mod tests_numeral; #[cfg(test)] mod tests_pages; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 16e3d34be1..d2c62fc953 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1066,6 +1066,10 @@ impl<'a> Builder<'a> { let d = show(c); if d != c { d + } else if c == '\u{a0}' { + // Word sets a no-break space as wide as a space; some fonts draw their own + // U+00A0 glyph wider. Only the shaping changes: line breaking reads the text. + ' ' } else if upper { upper_char(c) } else { diff --git a/crates/layout/src/tests_nbsp.rs b/crates/layout/src/tests_nbsp.rs new file mode 100644 index 0000000000..b8dbc9aad7 --- /dev/null +++ b/crates/layout/src/tests_nbsp.rs @@ -0,0 +1,65 @@ +//! A no-break space is laid out as wide as an ordinary space, whatever the font's own U+00A0 +//! glyph measures (Lucida Bright draws it twice as wide). + +use super::*; + +/// Inter with its U+00A0 glyph twice as wide as its space, under the family name "Wnbsp". +/// Built from the bundled font so the test needs no installed font. +fn wide_nbsp_family() -> &'static str { + const FAMILY: &str = "Wnbsp"; + let inter = wordcraft_fonts::word::resolve("Inter", false, false).face; + let mut data = inter.data().to_vec(); + let gid = inter.glyph_for('\u{a0}') as usize; + assert_ne!(gid, inter.glyph_for(' ') as usize, "Inter keeps a glyph of its own for U+00A0"); + let u16_at = |d: &[u8], o: usize| u16::from_be_bytes([d[o], d[o + 1]]) as usize; + let u32_at = |d: &[u8], o: usize| u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]) as usize; + let table = |d: &[u8], tag: &[u8; 4]| { + (0..u16_at(d, 4)).map(|i| 12 + 16 * i).find(|&r| &d[r..r + 4] == tag).map(|r| (u32_at(d, r + 8), u32_at(d, r + 12))).unwrap() + }; + let (hmtx, _) = table(&data, b"hmtx"); + assert!(gid < u16_at(&data, table(&data, b"hhea").0 + 34), "the glyph has a full metrics record"); + let advance = u16_at(&data, hmtx + 4 * gid); + data[hmtx + 4 * gid..hmtx + 4 * gid + 2].copy_from_slice(&((advance * 2) as u16).to_be_bytes()); + // The family name, in the name table's UTF-16 and single-byte records alike. + let (name, len) = table(&data, b"name"); + let wide = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(wide("Inter"), wide(FAMILY)), (b"Inter".to_vec(), FAMILY.as_bytes().to_vec())] { + for i in name..name + len - from.len() { + if data[i..i + from.len()] == from[..] { + data[i..i + from.len()].copy_from_slice(&to); + } + } + } + wordcraft_fonts::FontDb::global().add_font(data); + FAMILY +} + +/// The width of the first line of `text` set in the wide-nbsp font. +fn line_width(text: &str) -> f32 { + let chr = wordcraft_doc::CharProps { font: Some(wide_nbsp_family().into()), size: Some(12.0), ..Default::default() }; + let d = Document { body: vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))], ..Document::new() }; + let mut c = LayoutCache::new(); + let l = layout(&d, &mut c, &LayoutOptions::default()); + for p in &l.pages { + for it in &p.items { + if let Placed::Lines { para, .. } = it { + let xs = ¶.lines[0].xs; + return xs[xs.len() - 1] - xs[0]; + } + } + } + panic!("paragraph not laid out"); +} + +#[test] +fn a_no_break_space_is_as_wide_as_a_space() { + let face = wordcraft_fonts::word::resolve(wide_nbsp_family(), false, false).face; + assert_eq!(face.family, wide_nbsp_family(), "the test font is installed under its own name"); + assert!( + face.advance(face.glyph_for('\u{a0}')) > 1.9 * face.advance(face.glyph_for(' ')), + "the font's own U+00A0 glyph is twice as wide as its space" + ); + let nbsp = line_width(&format!("|{}|", "\u{a0}".repeat(10))); + let space = line_width(&format!("|{}|", " ".repeat(10))); + assert!((nbsp - space).abs() < 0.01, "ten no-break spaces measure {nbsp} pt, ten spaces {space} pt"); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 493ec6a677..c443e7ca7d 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -264,6 +264,7 @@ Installed static fonts also resolve by their OpenType PostScript name (name ID 6 | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | | 2026-10-25 | trivial | A bookmark, comment anchor or other zero-width marker is no line-break opportunity: a word that doesn't fit after one no longer leaves a blank line, and one inside a word no longer splits it (#4193) | +| 2026-10-25 | trivial | A no-break space is shaped as a space, so it is as wide as one even in fonts whose own U+00A0 glyph is wider; it still holds the line together (#4192). `layout::tests_nbsp::a_no_break_space_is_as_wide_as_a_space` | | 2026-10-22 | trivial | A table row that another row follows fits on a page only with room for the border below it, as checked in Word for Mac: a 60-row table with 3 pt borders kept one row more than Word on page 1 for bottom margins of 78.2–81.0 pt (`layout::tests::a_row_leaves_room_for_the_border_below_it`) | | 2026-10-16 | trivial | Exact PostScript names resolve installed static faces without changing family lookup | | 2026-10-21 | trivial | Pin the font input of four geometric layout controls; assertions and renderer font policy unchanged. | From 5b46f9ba4d2669aca0009e60ba2ff9efe40800be Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 15:24:23 +0100 Subject: [PATCH 16/50] M-egui037: fix fontless tests and document migration behavior --- apps/wordcraft-web/src/web.rs | 6 + apps/wordcraft/src/main.rs | 8 + apps/wordcraft/src/print.rs | 2 + crates/doc/src/ime_text.rs | 18 + crates/engine/src/cmd/equation.rs | 63 +++ crates/engine/src/cmd/file.rs | 4 + crates/engine/src/cmd/mod.rs | 6 + crates/engine/src/cmd/text.rs | 9 + crates/engine/src/cmd/tools.rs | 5 + crates/engine/src/session.rs | 22 + crates/engine/src/tests.rs | 2 + crates/engine/src/tests_ime_noop.rs | 2 + crates/fonts/src/fontdb.rs | 3 + crates/layout/src/para.rs | 2 + crates/layout/src/tests.rs | 270 +++++++---- crates/layout/src/tests_ligature.rs | 4 + crates/layout/src/tests_textbox.rs | 3 + crates/ui-egui/examples/perf_probe.rs | 4 + crates/ui-egui/examples/ui_shot.rs | 1 + crates/ui-egui/src/a11y.rs | 2 + crates/ui-egui/src/backstage.rs | 9 + crates/ui-egui/src/canvas.rs | 605 +++++++++++++++--------- crates/ui-egui/src/caret_glide.rs | 1 + crates/ui-egui/src/chrome.rs | 4 + crates/ui-egui/src/colors.rs | 5 + crates/ui-egui/src/control.rs | 264 ++++++----- crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/dialogs_chart.rs | 1 + crates/ui-egui/src/dialogs_refs.rs | 21 + crates/ui-egui/src/dialogs_review.rs | 3 + crates/ui-egui/src/dialogs_smart_art.rs | 2 + crates/ui-egui/src/equation_tab.rs | 44 +- crates/ui-egui/src/format_shape.rs | 8 + crates/ui-egui/src/i18n/audit.rs | 52 +- crates/ui-egui/src/icons.rs | 8 +- crates/ui-egui/src/keys.rs | 246 +++++----- crates/ui-egui/src/keytips.rs | 3 + crates/ui-egui/src/lib.rs | 127 +++-- crates/ui-egui/src/options.rs | 5 + crates/ui-egui/src/panes.rs | 3 + crates/ui-egui/src/previews.rs | 2 + crates/ui-egui/src/printing.rs | 2 + crates/ui-egui/src/ribbon.rs | 13 +- crates/ui-egui/src/ribbon_fit.rs | 5 + crates/ui-egui/src/scroll.rs | 2 + crates/ui-egui/src/tests_perf.rs | 1 + crates/ui-egui/src/widgets.rs | 5 + crates/ui-egui/src/window_geometry.rs | 18 + crates/ui-egui/tests/it/native_print.rs | 13 + 49 files changed, 1307 insertions(+), 605 deletions(-) diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index ed4a2c124c..7fa947f02b 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -146,6 +146,9 @@ fn stored_prefs() -> Option { } impl eframe::App for WebShell { + /// Runs one browser frame: forwards queued host messages to the status bar, advances the app, + /// then writes changed preferences to browser storage immediately, because a reload or closed + /// tab never runs the exit path. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let msgs = std::mem::take(&mut *self.messages.borrow_mut()); for msg in msgs { @@ -165,10 +168,13 @@ impl eframe::App for WebShell { } } + /// Forwards this frame's raw browser input to the app before widgets run. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { self.app.raw_input_hook(ctx, raw); } + /// Draws the app. On the first pass of a visible frame it takes the browser's dropped files and + /// spawns asynchronous byte reads into the inbox; later passes only draw. fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // Browser drops are input: take them only in the first real UI pass, never in a hidden // `run_logic` tick that still sees the previous shown frame's RawInput. diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 7f93f2d123..2073b50699 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -46,6 +46,9 @@ struct App( ); impl eframe::App for App { + /// Advances one desktop frame: polls Finder events on macOS, runs the app and its file dialogs, + /// fits or rescues the window and maximizes it after opening, then gates a close or quit request + /// on unsaved changes and active printing. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] apple_events::poll(&self.5, &mut self.0, ctx); @@ -90,6 +93,8 @@ impl eframe::App for App { } } } + /// Records this frame's native window state before forwarding the hook, so logic never reads a + /// stale inner rectangle from a hidden tick. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { // Keep this frame's native window state. `logic` must not read the last painted // `InputState` when a hidden Wayland tick reports no fresh inner rect. @@ -328,6 +333,9 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) { } } +/// Parses the command line, installs logging and the panic hook, restores preferences and window +/// geometry, opens requested documents or the Start screen, starts the control server when +/// asked, and runs the eframe event loop. fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); diff --git a/apps/wordcraft/src/print.rs b/apps/wordcraft/src/print.rs index b8947a61e7..5e8489b71a 100644 --- a/apps/wordcraft/src/print.rs +++ b/apps/wordcraft/src/print.rs @@ -1,6 +1,8 @@ //! Desktop spooler adapter. Discovery and submission never run on the UI thread. use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit}; +/// Returns a discovery callback that enumerates spooler printers on a named worker thread and +/// sends the list through a channel, keeping spooler calls off the UI thread. pub fn discover() -> Discover { Box::new(|| { let (sender, receiver) = std::sync::mpsc::channel(); diff --git a/crates/doc/src/ime_text.rs b/crates/doc/src/ime_text.rs index 6392ed313e..2c4ed70a16 100644 --- a/crates/doc/src/ime_text.rs +++ b/crates/doc/src/ime_text.rs @@ -474,6 +474,7 @@ mod tests { use crate::props::CharProps; use crate::{InlineObject, StoryRef}; + /// Builds a paragraph with one run per `(text, deleted)` part; deleted parts carry revision 0. fn para_with_runs(parts: &[(&str, bool)]) -> Paragraph { let mut p = Paragraph::new(); for (text, deleted) in parts { @@ -487,6 +488,8 @@ mod tests { p } + /// A tracked deletion is hidden from the IME view but still maps stored offsets: its start is a + /// visible caret position, its interior is not, and a window concatenates only visible text. #[test] fn tracked_deletions_are_hidden_and_offsets_still_map() { let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]); @@ -502,6 +505,8 @@ mod tests { assert_eq!(view.window(2, 2, 4000).map(|w| w.text), Some("abcd".into())); } + /// An inline object contributes its plain text to visible length and boundary mapping; offsets + /// strictly inside that expansion have no stored boundary. #[test] fn objects_contribute_their_plain_text_and_split_mapping_stops() { let mut p = Paragraph::new(); @@ -517,6 +522,8 @@ mod tests { assert_eq!(view.stored_offset(2), None, "inside the object's text"); } + /// An empty paragraph yields an available empty IME context at offset 0; an empty document + /// yields an available empty context too, though this test asserts only its text, not its offsets. #[test] fn an_empty_document_is_available_and_empty() { let p = Paragraph::new(); @@ -528,6 +535,8 @@ mod tests { assert_eq!(w.text, ""); } + /// A window is capped at the IME byte budget while keeping cursor and anchor inside its text; a + /// selection wider than the budget is unavailable. #[test] fn bounded_window_keeps_the_selection_and_rejects_too_wide_ones() { let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default()); @@ -539,6 +548,8 @@ mod tests { assert!(view.window(0, 4001, MAX_IME_TEXT_BYTES).is_none()); } + /// Byte deletion rounds its start to a visible UTF-8 boundary, leaves partially covered scalar + /// values whole, and refuses counts that run past the paragraph. #[test] fn byte_deletion_rounds_inward_and_leaves_partial_scalars_whole() { let doc = Document::from_text("aé👍b"); @@ -556,6 +567,8 @@ mod tests { assert!(doc.ime_delete_surrounding(&caret, &caret, 99, 0, DeleteUnit::Bytes).is_none()); } + /// Surrounding deletion skips the selected range on both sides and returns the selection's + /// original reversed anchor and focus. #[test] fn byte_deletion_excludes_a_reversed_selection() { let doc = Document::from_text("abcd"); @@ -567,6 +580,8 @@ mod tests { assert_eq!((del.anchor, del.focus), (3, 1), "the reversed selection is preserved"); } + /// Tracked deletions are absent from windows and skipped when deleting around a caret, so an IME + /// edit cannot remove them. #[test] fn tracked_deletions_are_not_counted_for_deletion_or_query() { let mut doc = Document::from_text("abDELcd"); @@ -579,6 +594,8 @@ mod tests { assert_eq!(del.before, Some(6..7), "the stored deletion run is skipped"); } + /// A selection spanning paragraphs has no single surrounding context: both the query and + /// surrounding deletion return `None`. #[test] fn cross_paragraph_selection_is_unavailable() { let doc = Document::from_text("one\ntwo"); @@ -654,6 +671,7 @@ mod tests { assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES); } + /// A caret in a table-cell paragraph maps against that cell's text, not the body around the table. #[test] fn table_cell_paragraph_maps_inside_its_cell() { let mut doc = Document::from_text("outside"); diff --git a/crates/engine/src/cmd/equation.rs b/crates/engine/src/cmd/equation.rs index 961ceb9ade..f7be739b85 100644 --- a/crates/engine/src/cmd/equation.rs +++ b/crates/engine/src/cmd/equation.rs @@ -20,6 +20,9 @@ fn near_equation(s: &Session) -> Option<&'static str> { if s.math.is_some() || equation_at_caret(s).is_some() { None } else { Some("no equation at the caret") } } +/// Builds the equation command table: insert, edit and exit, typing, structures, symbols, caret +/// movement, deletion (including bounded surrounding deletion), enter, conversion, input format, +/// normal text, display, justification, numbering, set and get, gallery and structure actions. pub fn specs() -> Vec { vec![ CommandSpec::new("insert.equation", "Equation", "Insert › Symbols", insert) @@ -112,12 +115,15 @@ struct EquationEffect { } impl EquationEffect { + /// Merges another edit's effect flags, so a change on either side marks the merged effect. fn merge(&mut self, other: Self) { self.content_changed |= other.content_changed; self.state_changed |= other.state_changed; } } +/// Records the edit as a classified IME outcome; any content change also counts as a state +/// change, and `transformed` marks automatic rewrites. fn apply_effect(s: &mut Session, effect: EquationEffect, transformed: bool) { s.set_ime_edit_outcome(ImeEditOutcome { classified: true, @@ -128,6 +134,8 @@ fn apply_effect(s: &mut Session, effect: EquationEffect, transformed: bool) { }); } +/// Records a classified IME outcome with no content or state change, distinguishing a real +/// no-op from a command that never classified its edit. fn classified_noop(s: &mut Session) { s.set_ime_edit_outcome(ImeEditOutcome { classified: true, ..ImeEditOutcome::default() }); } @@ -213,6 +221,9 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { insert_nodes_as_equation(s, nodes, v) } +/// Clears any editing session, deletes the selection, creates an equation object (display on an +/// empty line unless the request overrides it), optionally opens editing at the first +/// placeholder, and reports a content change. fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult { s.math = None; let props = s.typing_props(); @@ -247,6 +258,8 @@ fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult result(s) } +/// Starts editing the equation at the caret, materializing lazy nodes and clamping the requested +/// position, and returns the equation and caret. fn edit(s: &mut Session, v: &Value) -> CmdResult { let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; let nodes = match s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) { @@ -282,6 +295,9 @@ fn current(s: &Session) -> Result { s.math.clone().ok_or_else(|| CmdError::Disabled("not editing an equation".into())) } +/// Caps typing at `MAX_EQUATION_TYPE_CHARS`; empty text is a classified no-op. Linear form +/// inserts literal runs, while built-up form applies AutoCorrect and build-up before spaces and +/// relational characters. fn type_text(s: &mut Session, v: &Value) -> CmdResult { let text = p::req_str(v, "text")?.to_string(); let m = current(s)?; @@ -339,6 +355,8 @@ fn set_literal(nodes: &mut Arg) { } } +/// Inserts a gallery, linear or LaTeX structure: a new equation when none is being edited, +/// literal text in linear form, or nodes at the caret in built-up form. fn insert_structure(s: &mut Session, v: &Value) -> CmdResult { let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give an id, linear or latex".into()))?; let Some(m) = s.math.clone() else { @@ -361,6 +379,8 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult { result(s) } +/// Rejects an empty character; outside editing creates an equation, otherwise inserts the +/// character at the math caret, marked literal in linear form. fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult { let c = p::req_str(v, "char")?.to_string(); if c.is_empty() { @@ -393,6 +413,8 @@ fn nodes_of(s: &Session, at: &Pos) -> Option { } } +/// Validates the direction, commits an AutoCorrect or build-up triggered by leaving a slot as one +/// undoable edit, moves or clamps the math caret, and exits past either end. fn move_caret(s: &mut Session, v: &Value) -> CmdResult { let m = current(s)?; let dir = p::str(v, "dir").unwrap_or("right"); @@ -456,6 +478,8 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult { None => exit(s, &json!({"before": dir == "left"})), } } +/// With the caret in an empty equation, removes the whole equation as a content change; +/// otherwise deletes backward or forward, updates the caret and records the effect. fn delete(s: &mut Session, back: bool) -> CmdResult { let m = current(s)?; let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?; @@ -536,6 +560,8 @@ fn delete_surrounding(s: &mut Session, v: &Value) -> CmdResult { apply_effect(s, effect, false); Ok(json!({"before": before, "after": after, "changed": true})) } +/// In built-up form runs AutoCorrect, build-up and equation numbering; when nothing changes, +/// leaves the equation and splits a new paragraph. fn enter(s: &mut Session, _: &Value) -> CmdResult { let m = current(s)?; let latex = s.math_latex; @@ -595,6 +621,8 @@ fn convert_math(math: &mut Math, professional: bool, latex: bool) { } } +/// Converts one equation or all equations between built-up and linear form, honoring LaTeX mode, +/// merging every effect and resetting the math caret. fn convert(s: &mut Session, v: &Value) -> CmdResult { let professional = p::str(v, "to").map(|t| !t.eq_ignore_ascii_case("linear")).unwrap_or(true); let latex = s.math_latex; @@ -638,6 +666,7 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult { result(s) } +/// Toggles or sets the equation's inline or display mode. fn set_display(s: &mut Session, v: &Value) -> CmdResult { let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; let (_, effect) = with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?; @@ -645,6 +674,8 @@ fn set_display(s: &mut Session, v: &Value) -> CmdResult { result(s) } +/// Maps `jc` to the math justification, with unknown values falling back to center-group, and +/// forces display mode. fn justify(s: &mut Session, v: &Value) -> CmdResult { let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; let jc = match p::str(v, "jc").unwrap_or("centerGroup") { @@ -712,6 +743,8 @@ fn without_number(row: Arg) -> Arg { out } +/// Replaces the equation's nodes from linear or LaTeX input, forces built-up form, and puts the +/// caret at the new end. fn set(s: &mut Session, v: &Value) -> CmdResult { let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?; let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give linear or latex".into()))?; @@ -752,6 +785,8 @@ fn structure_actions(s: &mut Session, _: &Value) -> CmdResult { Ok(json!({"actions": acts})) } +/// Applies a named structure action at an explicit or inferred level, refusing an action that +/// does not apply at the caret. fn structure(s: &mut Session, v: &Value) -> CmdResult { let m = current(s)?; let action = p::req_str(v, "action")?.to_string(); @@ -1010,6 +1045,9 @@ mod tests { assert!(document_xml(&s).contains(">bx<")); } + /// A Left move that builds up `a/b` makes the document dirty, advances layout and IME revisions, + /// reports a classified transformed change, and Undo restores the literal run; plain navigation + /// leaves no undo step. #[test] fn navigation_build_up_is_an_undoable_edit() { // #447: building `a/b` up on a Left move left the document clean and Undo did nothing. @@ -1101,10 +1139,13 @@ mod tests { assert!(nodes.iter().all(|n| matches!(n, MNode::Script { .. }) || matches!(n, MNode::Run(r) if r.text.trim().is_empty())), "{nodes:#?}"); } + /// Returns the session's current IME state revision, allocating a new one when the caret state changed. fn revision(s: &mut Session) -> u64 { s.ime_state_revision().expect("an IME state revision") } + /// Clears the stored node tree of the equation currently being edited, leaving its linear text + /// and lazy state in place. fn clear_nodes(s: &mut Session) { let at = s.math.as_ref().expect("editing").at.clone(); if let Some(InlineObject::Equation { math, .. }) = s.doc.para_mut(at.story, &at.path).expect("paragraph").object_at_mut(at.off) { @@ -1112,6 +1153,8 @@ mod tests { } } + /// Backspace at the start of an equation is classified with no content or state change, + /// allocates no revision and leaves the linear text intact. #[test] fn equation_backspace_at_start_is_a_classified_noop() { let mut s = session(); @@ -1126,6 +1169,8 @@ mod tests { assert_eq!(linear(&s), "x+y"); } + /// Materializing an imported equation's lazy nodes is a state change only: the outcome is + /// classified, content is unchanged, and the revision advances. #[test] fn lazy_normalization_is_state_not_content() { let mut s = session(); @@ -1143,6 +1188,7 @@ mod tests { assert!(revision(&mut s) > before, "state changes advance the revision"); } + /// Empty typing is a classified no-op in built-up and linear form, without a new revision. #[test] fn empty_equation_type_is_a_classified_noop_in_both_modes() { let mut s = session(); @@ -1159,6 +1205,7 @@ mod tests { assert_eq!(revision(&mut s), before, "linear empty type is a no-op"); } + /// Typing `a/b ` or `\alpha ` reports both a content change and a transformed automatic rewrite. #[test] fn automatic_equation_rewrites_are_transformed() { for text in ["a/b ", "\\alpha "] { @@ -1171,6 +1218,8 @@ mod tests { } } + /// Justification and display changes advance the revision and report a state, not content, + /// change. #[test] fn equation_style_changes_are_state_changes_without_content() { let mut s = session(); @@ -1186,6 +1235,8 @@ mod tests { assert!(revision(&mut s) > before); } + /// Converting every equation reports one classified effect that merges their content and state + /// changes. #[test] fn convert_all_merges_every_equations_effect() { let mut s = session(); @@ -1201,6 +1252,8 @@ mod tests { assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}"); } + /// A bounded deletion around the caret removes the requested units in one undo step and reports + /// a content change. #[test] fn equation_delete_surrounding_is_one_transaction_and_one_undo_step() { let mut s = session(); @@ -1212,6 +1265,8 @@ mod tests { assert!(s.ime_edit_outcome().classified && s.ime_edit_outcome().content_changed); } + /// An impossible bounded deletion applies no prefix, adds no undo step, keeps the session and + /// caret, and leaves no classified outcome. #[test] fn equation_delete_surrounding_refuses_partial_progress() { let mut s = session(); @@ -1234,6 +1289,8 @@ mod tests { ); } + /// Zero or oversized counts and deletions from an empty equation are refused without mutation; + /// exactly deleting all text leaves the empty equation object. #[test] fn equation_delete_surrounding_bounds_and_empty_equations() { let mut s = session(); @@ -1252,6 +1309,8 @@ mod tests { assert!(s.math.is_some()); } + /// A forward deletion that would only step into another argument is refused whole, leaving the + /// text and undo depth unchanged (the caret is not asserted here). #[test] fn equation_delete_surrounding_refuses_a_caret_only_step() { let mut s = session(); @@ -1287,6 +1346,8 @@ mod tests { assert_eq!(s.math.as_ref().expect("editing").pos, pos, "the caret stays where the request found it"); } + /// Typing more than the cap inserts exactly `MAX_EQUATION_TYPE_CHARS` characters in both linear + /// and built-up form. #[test] fn equation_type_caps_linear_and_built_up_input() { for linear_mode in [false, true] { @@ -1301,6 +1362,8 @@ mod tests { } } + /// Deleting one character forward removes it and moves the math caret to the start of the + /// remaining text. #[test] fn equation_delete_surrounding_moves_the_caret_for_a_one_step_delete() { let mut s = session(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7244ac29b7..6604734015 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -79,6 +79,10 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"name": name, "path": s.path})) } +/// Builds the file and document command table: rename, new, open, save, save as and encryption, +/// PDF/PNG/print output and print settings, close, properties and info, author and privacy +/// settings, and document inspection and text access. Recovery, compatibility, sharing, autosave, +/// `file.newFromTemplate` and `file.saveTemplate` are registered in [`super::tools::specs`]. pub fn specs() -> Vec { vec![ CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name; its extension is kept, so the name may be given with or without it)}"#).pure(), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1ee971af7a..d8d08f0833 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -355,6 +355,12 @@ pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> { insert_text(s, text, false).map(|_| ()) } +/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders, +/// deletes the selection, applies tracked-change insertions, and splits paragraphs at every typed +/// line break or pasted paragraph separator. A pasted separator keeps the list semantics of pasted +/// text; a one-line control suppresses typed breaks only. It gives an otherwise undirected paragraph +/// right-to-left direction when its first strong letter is RTL, and returns whether content or state +/// changed. fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result { let mut props = s.typing_props(); if s.sel.is_collapsed() { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index c8910372fb..119371d272 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -96,6 +96,9 @@ fn typing_options(s: &mut Session, text: &str) { } } +/// Inserts text with the typing options and smart quotes, records the IME outcome, and for a +/// single trigger character runs list, AutoCorrect and dash AutoFormat as a separate undo step +/// when they rewrite the paragraph. fn insert(s: &mut Session, v: &Value) -> CmdResult { let text = p::req_str(v, "text")?; let raw = p::bool(v, "raw").unwrap_or(false); @@ -880,6 +883,10 @@ fn backspace(s: &mut Session, v: &Value) -> CmdResult { Ok(r) } +/// Deletes the selection or the character before the caret. At a paragraph start it removes list +/// numbering while keeping the text position, takes a List Paragraph out of the list, outdents, +/// joins the previous paragraph or moves into a preceding table; the command wrapper restores a +/// content-control placeholder. fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { s.goal_x = None; s.pending = None; @@ -989,6 +996,8 @@ fn delete(s: &mut Session, v: &Value) -> CmdResult { Ok(r) } +/// Deletes the selection or the character after the caret, joins the next paragraph, handles a +/// following table and removes a trailing section break; tracked changes keep their marks. fn delete_inner(s: &mut Session, _: &Value) -> CmdResult { s.goal_x = None; s.pending = None; diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 1a00aa70e4..3f6fdcc4f0 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -429,6 +429,11 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { })) } +/// Builds the tools, review, file and insertion command table: Repeat and macros; AutoCorrect, +/// keyboard and ribbon customization and search; Compare, accessibility, inspection, protection, +/// versions, recovery, templates and autosave; compatibility, sharing, Quick Parts, fields, +/// signatures, objects and spreadsheets; header/footer, table and numbering settings; language, +/// markup, editor and selection commands; and design and view options. pub fn specs() -> Vec { vec![ CommandSpec::new("edit.repeat", "Repeat", "Quick Access Toolbar", |s, _| { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 051e684abd..94e93b7b68 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -581,6 +581,8 @@ fn allocate_ime_state_revision(counter: &std::sync::atomic::AtomicU64) -> Option static IME_STATE_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +/// Allocates the next process-monotonic IME state revision, returning `None` when the counter is +/// exhausted. fn next_ime_state_revision() -> Option { allocate_ime_state_revision(&IME_STATE_REVISION) } @@ -1328,6 +1330,9 @@ impl Session { } done } + /// Undoes the newest step: closes typing, records an AutoCorrect exception when the undone step + /// was its automatic capital, swaps the document, selection, pending formatting and column with + /// the redo snapshot, replaces the body identity, refreshes equation editing and touches layout. pub fn undo(&mut self) -> bool { self.typing_open = false; // Undoing the automatic capitalisation's own step makes its word an exception; any @@ -1356,6 +1361,8 @@ impl Session { let next = if redo { self.redo.last() } else { self.history.last() }; next.is_some_and(|u| crate::cmd::protect::protection_lifted(&self.doc, &u.doc)) } + /// Redoes the newest undone step by swapping the snapshots back, replacing the body identity, + /// refreshing equation editing and touching layout. pub fn redo(&mut self) -> bool { self.typing_open = false; let Some(u) = self.redo.pop().map(Arc::unwrap_or_clone) else { return false }; @@ -1750,6 +1757,8 @@ impl Session { self.ime_state_revision } + /// Returns the caret identity used for IME state revisions: the equation session and position + /// while editing, otherwise the body selection. fn current_ime_caret_state(&self) -> ImeCaretState { match &self.math { Some(m) => ImeCaretState::Equation { session: m.session, pos: m.pos.clone() }, @@ -1770,6 +1779,8 @@ impl Session { self.ime_state_revision = next_ime_state_revision(); } + /// Allocates a new revision only when the body selection or the equation session and position + /// differs from the last observed state. fn sync_ime_caret_state(&mut self) { let state = self.current_ime_caret_state(); if self.ime_state_seen.as_ref() != Some(&state) { @@ -2198,6 +2209,7 @@ mod ime_revision_tests { use super::*; use serde_json::json; + /// Checked allocations stop at `u64::MAX` and report exhaustion instead of wrapping. #[test] fn checked_revision_allocation_refuses_to_wrap() { let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1); @@ -2206,6 +2218,8 @@ mod ime_revision_tests { assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), u64::MAX); } + /// An unchanged caret reuses its revision, while a direct selection assignment is observed and + /// advances the revision. #[test] fn selection_and_caret_motion_advance_the_unavailable_revision() { let mut s = Session::new(Document::from_text("hello")); @@ -2216,6 +2230,8 @@ mod ime_revision_tests { assert!(moved > first, "a direct selection assignment is caught"); } + /// A selection-only command advances the revision on the next read, while a committed state + /// change advances it immediately. #[test] fn successful_state_changes_allocate_but_selection_only_commands_do_not() { let mut s = Session::new(Document::from_text("hello")); @@ -2228,6 +2244,8 @@ mod ime_revision_tests { assert!(s.ime_state_revision() > Some(selected), "a committed state change advances"); } + /// After the revision allocator is exhausted, a real equation edit still reports its classified + /// change but cannot mint a revision. #[test] fn exhausted_allocator_keeps_a_real_equation_commit_uncertifiable() { let mut s = Session::new(Document::new()); @@ -2240,6 +2258,8 @@ mod ime_revision_tests { assert_eq!(s.ime_state_revision(), None, "a real producer command cannot mint a revision"); } + /// The application-change signal is consumed once; commands run through `run_ime` do not set it, + /// while a normal command does. #[test] fn application_origin_signal_is_taken_once_and_ime_commands_do_not_set_it() { let mut s = Session::new(Document::from_text("hello")); @@ -2252,6 +2272,8 @@ mod ime_revision_tests { assert!(s.take_pending_ime_application_change()); } + /// Unknown and refused IME commands restore the normal origin without signalling an application + /// change, and a nested command inside a macro inherits the IME origin. #[test] fn ime_origin_is_restored_and_nested_commands_inherit_it() { let mut s = Session::new(Document::from_text("hello")); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bd095a6f33..fd3c8f5a0c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10538,6 +10538,8 @@ fn ime_body_epoch_marks_body_replacements_not_edits() { assert!(s.ime_body_epoch() > before_undo, "Undo replaces the body"); } +/// Replacing the document body while an equation is being edited ends that equation editing +/// session. #[test] fn a_body_replacement_ends_equation_editing() { let mut s = s(); diff --git a/crates/engine/src/tests_ime_noop.rs b/crates/engine/src/tests_ime_noop.rs index d867816bd8..51ad64c4bb 100644 --- a/crates/engine/src/tests_ime_noop.rs +++ b/crates/engine/src/tests_ime_noop.rs @@ -7,10 +7,12 @@ use serde_json::json; use crate::Session; +/// Creates an empty-document session for the IME no-op regressions. fn s() -> Session { Session::new(wordcraft_doc::Document::new()) } +/// Runs a command in the test session, panicking with the command id on error. fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value { s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 156b1be5e2..17f25b0767 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -2010,6 +2010,9 @@ mod tests { assert_eq!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_some(), thai_bundled); } + /// With only bundled Latin faces and no system fallback, a Thai run has no single covering face, + /// so run-level fallback declines. This test asserts that `fallback_for_run` returns `None`; the + /// caller's per-character path is not exercised here. #[test] fn a_run_without_a_covering_face_keeps_the_per_character_path() { // Use only this repository's Latin faces. Optional craft-fonts inputs can contain a diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 547647594a..c11d86b4c0 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -3043,6 +3043,8 @@ fn resolve_tab(pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>, } impl ParaLayout { + /// Returns whether `off` is a legal caret and edit boundary, that is, not the interior start of a + /// joined grapheme cluster. fn is_edit_boundary(&self, off: usize) -> bool { self.joined_cluster_starts.binary_search(&off).is_err() } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 5070f78254..656691741e 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -27,6 +27,8 @@ pub(super) fn pagination_fixture_document(text: &str) -> Document { doc } +/// Asserts that every laid-out fixture paragraph uses the bundled Source Sans 3 Regular bytes at +/// 12 pt and that at least one paragraph was actually inspected. pub(super) fn assert_pagination_fixture_font(layout: &DocLayout) { let mut paragraphs = 0; for page in &layout.pages { @@ -76,6 +78,106 @@ fn pagination_filler_leaving(room: f32) -> Document { doc } +/// The top-level body text bottom and its trailing space-after, if the page has one. +fn pagination_body_bottom(page: &Page) -> Option<(f32, f32)> { + let mut body_bottom = None::<(f32, f32)>; + for item in &page.items { + if let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, y, .. } = item + && path.0.len() == 1 + && let (Some(first), Some(last)) = (para.lines.get(*l0), para.lines.get(l1.saturating_sub(1))) + { + let bottom = y + last.top + last.height - first.top; + if body_bottom.is_none_or(|(prior, _)| bottom > prior) { + body_bottom = Some((bottom, para.rp.space_after)); + } + } + } + body_bottom +} + +/// Report the page's top-level body text bottom, when there is one. +fn pagination_write_body_bottom(out: &mut String, page: &Page) { + use std::fmt::Write as _; + if let Some((bottom, after)) = pagination_body_bottom(page) { + let _ = writeln!( + out, + " top-level body text bottom={bottom:.3} trailing_space_after={after:.3} remaining_to_body_bottom={:.3} remaining_after_unadjusted_space={:.3}", + page.body.bottom() - bottom, + page.body.bottom() - bottom - after + ); + } +} + +/// The first and last line of the placed range, with their physical extents. +fn pagination_write_line_rows(out: &mut String, para: &ParaLayout, l0: usize, l1: usize) { + use std::fmt::Write as _; + for (li, line) in para.lines.iter().enumerate().filter(|(i, _)| *i >= l0 && *i < l1 && (*i == l0 || i.saturating_add(1) == l1)) { + let _ = writeln!( + out, + " line={li} bytes={}..{} top={:.3} height={:.3} baseline={:.3} text_asc={:.3} text_desc={:.3} end={:?}", + line.start, line.stop, line.top, line.height, line.baseline, line.text_asc, line.text_desc, line.end + ); + } +} + +/// The styles this paragraph actually uses (plus the first), with their resolved metrics. +fn pagination_write_styles(out: &mut String, para: &ParaLayout) { + use std::fmt::Write as _; + for (si, st) in para.styles.iter().enumerate().filter(|(i, _)| *i == 0 || para.clusters.iter().any(|c| c.style as usize == *i)).take(4) { + let (asc, desc) = wordcraft_fonts::word::line_metrics(&st.face); + let raw_height = (asc + desc) * f64::from(st.size) / st.face.upem; + let _ = writeln!( + out, + " style={si} requested={:?} actual={:?}/{:?} face_id={} upem={} size={} word_asc={} word_desc={} raw_height_pt={raw_height:.3} resolved_asc={:.3} resolved_desc={:.3}", + st.rc.font, + st.face.family, + st.face.style, + st.face.id(), + st.face.upem, + st.size, + asc, + desc, + st.ascent, + st.descent + ); + } +} + +/// One placed paragraph line range and its styles. +fn pagination_write_lines(out: &mut String, ii: usize, item: &Placed, doc: Option<&Document>) { + use std::fmt::Write as _; + let Placed::Lines { story, path, para, l0, l1, x, y, turn } = item else { + return; + }; + let text = doc.and_then(|d| d.para(*story, path)).map(|p| p.text.chars().take(96).collect::()); + let _ = writeln!( + out, + " item={ii} Lines story={story:?} path={path:?} text={text:?} xy=({x:.3},{y:.3}) turn={turn:?} lines={l0}..{l1}/{} para_height={:.3} before={:.3} after={:.3} spacing={:?}", + para.lines.len(), + para.height, + para.rp.space_before, + para.rp.space_after, + para.rp.line_spacing + ); + pagination_write_line_rows(out, para, *l0, *l1); + pagination_write_styles(out, para); +} + +/// One retained placed item (the middle items are omitted by the caller). +fn pagination_write_item(out: &mut String, ii: usize, item: &Placed, doc: Option<&Document>) { + use std::fmt::Write as _; + match item { + Placed::Lines { .. } => pagination_write_lines(out, ii, item, doc), + Placed::Cell { rect, table, row, cell, story, clip, .. } => { + let _ = writeln!(out, " item={ii} Cell table={table:?} story={story:?} row={row} cell={cell} rect={rect:?} clip={clip}"); + } + Placed::Object { rect, story, path, off, text_box, .. } => { + let _ = writeln!(out, " item={ii} Object story={story:?} path={path:?} off={off} textbox={text_box:?} rect={rect:?}"); + } + _ => {} + } +} + /// Failure-only evidence for the selected pagination fixtures below. Inspect the already produced /// styles and placements, not a second run. pub(super) fn pagination_fixture_diagnostics(label: &str, doc: Option<&Document>, layout: &DocLayout) -> String { @@ -91,77 +193,9 @@ pub(super) fn pagination_fixture_diagnostics(label: &str, doc: Option<&Document> page.float_tables, page.items.len() ); - let mut body_bottom = None::<(f32, f32)>; - for item in &page.items { - if let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, y, .. } = item - && path.0.len() == 1 - && let (Some(first), Some(last)) = (para.lines.get(*l0), para.lines.get(l1.saturating_sub(1))) - { - let bottom = y + last.top + last.height - first.top; - if body_bottom.is_none_or(|(prior, _)| bottom > prior) { - body_bottom = Some((bottom, para.rp.space_after)); - } - } - } - if let Some((bottom, after)) = body_bottom { - let _ = writeln!( - out, - " top-level body text bottom={bottom:.3} trailing_space_after={after:.3} remaining_to_body_bottom={:.3} remaining_after_unadjusted_space={:.3}", - page.body.bottom() - bottom, - page.body.bottom() - bottom - after - ); - } + pagination_write_body_bottom(&mut out, page); for (ii, item) in page.items.iter().enumerate().filter(|(i, _)| *i < 32 || i.saturating_add(16) >= page.items.len()) { - match item { - Placed::Lines { story, path, para, l0, l1, x, y, turn } => { - let text = doc.and_then(|d| d.para(*story, path)).map(|p| p.text.chars().take(96).collect::()); - let _ = writeln!( - out, - " item={ii} Lines story={story:?} path={path:?} text={text:?} xy=({x:.3},{y:.3}) turn={turn:?} lines={l0}..{l1}/{} para_height={:.3} before={:.3} after={:.3} spacing={:?}", - para.lines.len(), - para.height, - para.rp.space_before, - para.rp.space_after, - para.rp.line_spacing - ); - for (li, line) in - para.lines.iter().enumerate().filter(|(i, _)| *i >= *l0 && *i < *l1 && (*i == *l0 || i.saturating_add(1) == *l1)) - { - let _ = writeln!( - out, - " line={li} bytes={}..{} top={:.3} height={:.3} baseline={:.3} text_asc={:.3} text_desc={:.3} end={:?}", - line.start, line.stop, line.top, line.height, line.baseline, line.text_asc, line.text_desc, line.end - ); - } - for (si, st) in - para.styles.iter().enumerate().filter(|(i, _)| *i == 0 || para.clusters.iter().any(|c| c.style as usize == *i)).take(4) - { - let (asc, desc) = wordcraft_fonts::word::line_metrics(&st.face); - let raw_height = (asc + desc) * f64::from(st.size) / st.face.upem; - let _ = writeln!( - out, - " style={si} requested={:?} actual={:?}/{:?} face_id={} upem={} size={} word_asc={} word_desc={} raw_height_pt={raw_height:.3} resolved_asc={:.3} resolved_desc={:.3}", - st.rc.font, - st.face.family, - st.face.style, - st.face.id(), - st.face.upem, - st.size, - asc, - desc, - st.ascent, - st.descent - ); - } - } - Placed::Cell { rect, table, row, cell, story, clip, .. } => { - let _ = writeln!(out, " item={ii} Cell table={table:?} story={story:?} row={row} cell={cell} rect={rect:?} clip={clip}"); - } - Placed::Object { rect, story, path, off, text_box, .. } => { - let _ = writeln!(out, " item={ii} Object story={story:?} path={path:?} off={off} textbox={text_box:?} rect={rect:?}"); - } - _ => {} - } + pagination_write_item(&mut out, ii, item, doc); } if page.items.len() > 48 { let _ = writeln!(out, " omitted {} middle items (first32/last16 retained)", page.items.len() - 48); @@ -4876,6 +4910,8 @@ fn exact_rows_add_only_the_bottom_margin() { assert!((tops[0] - (72.0 + 0.5 + 4.0)).abs() < 0.01, "first cell text at {}", tops[0]); } +/// A 63.6 pt exact row moves whole to page two with only 50 pt left and stays on page one with +/// 90 pt. #[test] fn exact_row_moves_whole() { use wordcraft_doc::props::HeightRule; @@ -5580,6 +5616,9 @@ fn floating_tables_take_no_room_and_text_wraps_beside_them() { assert!(bx + bleft > nx + 200.0, "body text at {}, right of the narrow table", bx + bleft); } +/// A margin-anchored floating table is placed from the anchor page's margin even when a body +/// paragraph keeps trailing empty lines: all 24 preceding paragraphs stay on page one and every +/// table row is drawn exactly once there. #[test] fn a_floating_table_ignores_a_paragraphs_empty_lines() { use wordcraft_doc::para::Anchor; @@ -6450,6 +6489,9 @@ fn an_inherited_footer_sizes_its_percentage_table_by_its_own_section() { assert!((own - landscape_w).abs() < 1.0, "own footer: {own} wide, landscape text {landscape_w}"); } +/// With exactly 50 pt left, an eight-paragraph row without a minimum splits on page one, while +/// the same row with an at-least height of 63.6 pt moves to page two whole; all eight paragraphs +/// remain. #[test] fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() { use wordcraft_doc::props::HeightRule; @@ -6491,11 +6533,8 @@ fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() { ); } -/// #1742: a paragraph that leaves `room` points of the letter page's 648 pt body, then the table -/// `build` shapes (one 468 pt column, `rows` rows of one short line each); with `room` 648 the -/// table comes first, at the top of the page. Returns the layout and, per page, the table rows -/// that have a cell there. -fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayout, Vec>) { +/// Build the filler paragraph and insert-room setting for [`at_least_rows`]. +fn at_least_rows_filler(room: f32) -> (Document, usize) { let mut d = pagination_fixture_document("Text above the table"); let at = if room >= 648.0 { 0 } else { 1 }; d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(if at == 0 { 0.0 } else { 648.0 - room - 14.0 }); @@ -6514,8 +6553,11 @@ fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayo let remaining = before_table.pages[0].body.bottom() - y - para.height - para.rp.space_after; assert!((remaining - room).abs() < 0.02, "the measured filler leaves {remaining}pt, expected {room}pt"); } - let mut t = Table::new(rows, 1, 468.0); - build(&mut t); + (d, at) +} + +/// Give every cell paragraph the fixture's exact one-line spacing. +fn set_at_least_row_spacing(t: &mut Table) { for row in &mut t.rows { for cell in &mut row.cells { for block in &mut cell.blocks { @@ -6527,11 +6569,11 @@ fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayo } } } - d.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(t)).unwrap(); - let l = lay(&d); - assert_pagination_fixture_font(&l); - let trailing: Vec<_> = l - .pages +} + +/// The pages holding the real paragraph that remains before/after the table. +fn at_least_rows_trailing(l: &DocLayout, at: usize) -> Vec { + l.pages .iter() .enumerate() .flat_map(|(page, p)| { @@ -6540,24 +6582,27 @@ fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayo _ => None, }) }) - .collect(); - assert_eq!(trailing.len(), 1, "the real paragraph before/after the table is retained exactly once"); - assert_eq!(d.para(StoryRef::Body, &Path::top(if at == 0 { 1 } else { 0 })).unwrap().text, "Text above the table"); - if at == 0 { - let first_cell = l.pages[0] - .items - .iter() - .find_map(|it| match it { - Placed::Cell { row: 0, rect, .. } => Some(rect), - _ => None, - }) - .unwrap(); - assert!((first_cell.y - l.pages[0].body.y).abs() < 0.01, "the tested table really begins at the page top"); - let last_table_page = l.pages.iter().rposition(|p| p.items.iter().any(|it| matches!(it, Placed::Cell { .. }))).unwrap(); - assert!(trailing[0] >= last_table_page, "the retained paragraph follows the table"); - } - let pages = l - .pages + .collect() +} + +/// Prove that the table begins at the page top and the retained paragraph follows it. +fn assert_at_least_table_at_page_top(l: &DocLayout, trailing: &[usize]) { + let first_cell = l.pages[0] + .items + .iter() + .find_map(|it| match it { + Placed::Cell { row: 0, rect, .. } => Some(rect), + _ => None, + }) + .unwrap(); + assert!((first_cell.y - l.pages[0].body.y).abs() < 0.01, "the tested table really begins at the page top"); + let last_table_page = l.pages.iter().rposition(|p| p.items.iter().any(|it| matches!(it, Placed::Cell { .. }))).unwrap(); + assert!(trailing[0] >= last_table_page, "the retained paragraph follows the table"); +} + +/// The table rows that have a cell on each page. +fn at_least_rows_pages(l: &DocLayout) -> Vec> { + l.pages .iter() .map(|p| { let mut rows: Vec = @@ -6565,7 +6610,28 @@ fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayo rows.dedup(); rows }) - .collect(); + .collect() +} + +/// #1742: a paragraph that leaves `room` points of the letter page's 648 pt body, then the table +/// `build` shapes (one 468 pt column, `rows` rows of one short line each); with `room` 648 the +/// table comes first, at the top of the page. Returns the layout and, per page, the table rows +/// that have a cell there. +fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayout, Vec>) { + let (mut d, at) = at_least_rows_filler(room); + let mut t = Table::new(rows, 1, 468.0); + build(&mut t); + set_at_least_row_spacing(&mut t); + d.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + assert_pagination_fixture_font(&l); + let trailing = at_least_rows_trailing(&l, at); + assert_eq!(trailing.len(), 1, "the real paragraph before/after the table is retained exactly once"); + assert_eq!(d.para(StoryRef::Body, &Path::top(if at == 0 { 1 } else { 0 })).unwrap().text, "Text above the table"); + if at == 0 { + assert_at_least_table_at_page_top(&l, &trailing); + } + let pages = at_least_rows_pages(&l); (l, pages) } @@ -6630,6 +6696,8 @@ fn an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page() { assert!(pages.len() >= 3 && pages[1..].iter().all(|p| p == &vec![0]), "it splits from page 2 on: {pages:?}"); } +/// An at-least row taller than the page splits from the top of a fresh page instead of moving +/// on, keeps all 80 cell paragraphs, and pagination terminates. #[test] fn an_at_least_row_at_the_top_of_a_page_splits_instead_of_moving_on() { use wordcraft_doc::props::HeightRule; diff --git a/crates/layout/src/tests_ligature.rs b/crates/layout/src/tests_ligature.rs index a5b80778bc..8a084a68ac 100644 --- a/crates/layout/src/tests_ligature.rs +++ b/crates/layout/src/tests_ligature.rs @@ -204,6 +204,8 @@ fn assert_atomic_drawing_group(d: &Document, legal: &[usize], start: usize, insi assert!(width > 0.0 && (width - expected).abs() < 0.05, "whole-grapheme selection: {width}, expected {expected}"); } +/// A mixed-size emoji ZWJ grapheme keeps both drawing sizes and gives caret hit testing, +/// selection and wrapping atomic boundaries in LTR and bidi at several widths. #[test] fn a_mixed_style_emoji_grapheme_has_atomic_caret_hit_and_wrap_boundaries() { for bidi in [false, true] { @@ -230,6 +232,8 @@ fn a_mixed_style_emoji_grapheme_has_atomic_caret_hit_and_wrap_boundaries() { } } +/// A base letter and combining mark using different bundled fonts keep their own faces and +/// glyphs, with atomic hit, selection and wrap boundaries across widths and bidi. #[test] fn a_mixed_bundled_font_combining_grapheme_preserves_drawing_and_atomic_boundaries() { for bidi in [false, true] { diff --git a/crates/layout/src/tests_textbox.rs b/crates/layout/src/tests_textbox.rs index 0398dca494..5148e770c8 100644 --- a/crates/layout/src/tests_textbox.rs +++ b/crates/layout/src/tests_textbox.rs @@ -179,6 +179,9 @@ fn own_margins_and_resize_to_fit_keep_the_text_in_view() { assert!(shown >= 1); } +/// An auto-fit inline box shrinks to its three lines and keeps the explicit page break on page +/// one; a fixed 10 cm box keeps its saved height and pushes the break and following text to +/// later pages. #[test] fn inline_box_that_resizes_to_fit_takes_its_texts_height() { // #2791: an inline box with Resize shape to fit text, saved 10 cm tall around three short diff --git a/crates/ui-egui/examples/perf_probe.rs b/crates/ui-egui/examples/perf_probe.rs index 6254b7a931..791e9d7065 100644 --- a/crates/ui-egui/examples/perf_probe.rs +++ b/crates/ui-egui/examples/perf_probe.rs @@ -16,6 +16,8 @@ use wordcraft_ui_egui::{Services, WordApp}; static READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +/// Applies the harness's pending raw input, runs one full app frame, and returns its wall-clock +/// duration in milliseconds. fn step(h: &mut egui_kittest::Harness<'_, WordApp>) -> f64 { let mut raw = std::mem::take(h.input_mut()); let ctx = h.ctx.clone(); @@ -33,6 +35,8 @@ fn stats(v: &[f64]) -> serde_json::Value { json!({"n": s.len(), "med": (at(0.5) * 10.0).round() / 10.0, "p95": (at(0.95) * 10.0).round() / 10.0, "max": (at(1.0) * 10.0).round() / 10.0}) } +/// Opens the document named on the command line and prints JSON timings for start-up, typing, +/// scrolling, zooming, ribbon tabs and window resizes. fn main() { let path = std::env::args().nth(1).expect("usage: perf_probe doc.docx [block]"); let block: Option = std::env::args().nth(2).and_then(|b| b.parse().ok()); diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index ecad12fd40..4ccfc1e534 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -84,6 +84,7 @@ fn main() { } } +/// Applies the harness's pending raw input and advances one UI frame. fn step(harness: &mut egui_kittest::Harness<'_, WordApp>) { let mut raw = std::mem::take(harness.input_mut()); let ctx = harness.ctx.clone(); diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index b99c5d9417..6963c5e375 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -54,6 +54,8 @@ struct Key { } impl Key { + /// Compares two AccessKit cache keys: layout identity, document id, table-preview flag, page range, + /// page rectangles and selection. fn same(&self, o: &Key) -> bool { Arc::ptr_eq(&self.layout, &o.layout) && self.doc == o.doc diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 89f92a9bbb..92b87b85f9 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -29,6 +29,8 @@ fn back_chevron_c(band_min_x: f32, center_y: f32, fullscreen: bool, content_left pos2(band_min_x + x, center_y) } +/// Draws the Backstage side menu and the active page. Without a document only the Home, New, Open +/// and Options pages work, and it handles Back, Escape, Save, Save As and Close routing. pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // No document open (#1129): only the pages that open or create one, and Options. @@ -222,6 +224,8 @@ fn heading(ui: &mut Ui, s: &str) { ui.add_space(16.0); } +/// Draws a template thumbnail and caption as one accessible button, lazily renders and caches a +/// region-aware page preview, and creates a document from the template on click. fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { let t = Tokens::get(ui.ctx()); ui.vertical(|ui| { @@ -404,6 +408,7 @@ struct PersonalListing { templates: Vec, } +/// Stable storage id for the personal-template listing cache. fn personal_listing_id() -> egui::Id { egui::Id::unique("backstage_personal_templates") } @@ -498,10 +503,13 @@ fn open_personal(app: &mut WordApp, path: &str) -> Result<(), String> { Ok(()) } +/// Stable storage id for the personal-template listing error. fn personal_error_id() -> egui::Id { egui::Id::unique("backstage_personal_error") } +/// Draws the Recent documents list, distinguishes duplicate names by path for screen readers, and +/// stores an open failure in this page's temporary state. fn open_list(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tl!("Recent")).font(semibold(16.0))); @@ -535,6 +543,7 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { } } +/// Stable storage id for the Recent list's open error. fn open_error_id() -> egui::Id { egui::Id::unique("backstage_open_error") } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index dcb74262a1..f24ca105c0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -254,6 +254,8 @@ impl CanvasState { } impl Default for CanvasState { + /// Creates empty canvas state: texture and tile caches, page keys, settle and scroll flags, caret + /// visibility, and inactive drag and column-selection state. fn default() -> Self { CanvasState { textures: HashMap::new(), @@ -1009,6 +1011,9 @@ pub(crate) fn preview_sel(app: &WordApp) -> Option<&wordcraft_engine::Selection> app.canvas.table_preview_layout.as_ref().map(|(_, _, sel, _)| sel) } +/// Lays out and paints the document pages for print, Read and Web views, settles viewports, draw +/// rulers and overlays, anchors zoom and scrolling, and routes pointer, selection, IME and caret +/// input. pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let full = ui.available_rect_before_wrap(); @@ -2477,6 +2482,9 @@ fn clamped_scroll(before: egui::Vec2, delta: egui::Vec2, max: egui::Vec2) -> egu (before - delta).clamp(egui::Vec2::ZERO, max) } +/// Routes pointer input: pen or eraser ink, object and crop drags, table border, selection and move +/// handles, then page hit testing, outlines, headers and footers, equations, selection and caret +/// interaction. fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) { // A pen or the eraser (Draw tab) owns the pointer. if crate::ink::pointer(app, ui, resp, rects, layout, scale) { @@ -3957,6 +3965,8 @@ mod tests { assert!(reading_animation_step(500.0, goal, 0.1).is_finite()); } + /// A large point-unit wheel tick turns exactly one reading column with no spill; line ticks turn + /// one column either way, small touchpad deltas accumulate, and columns align against the pane. #[test] fn reading_wheel_snaps_one_column_per_tick_and_respects_touchpads() { let ev = |unit, y| egui::Event::MouseWheel { @@ -4791,6 +4801,8 @@ mod tests { balloon_glyphs_at_scale(text, width, 1.0) } + /// Lays `text` out for a balloon at `ppp` with the document fallback face and returns each glyph's + /// character and physical left edge, asserting that wrapping stays within `width`. fn balloon_glyphs_at_scale(text: &str, width: f32, ppp: f32) -> Option> { let face = wordcraft_fonts::FontDb::global().fallback_for('ش', u32::MAX)?; let ctx = egui::Context::default(); @@ -4913,6 +4925,160 @@ mod tests { (fonts, fixtures) } + /// Independent pre-normalization reading-space oracle: position the original shaped rows + /// using their paragraph direction, then recompute complete bounds. Returns the reference + /// galley and whether this fixture actually exercised RTL shaping. + fn balloon_reading_reference( + fixture: &BalloonFontFixture, + original: &egui::Galley, + width: f32, + ppp: f32, + ) -> (std::sync::Arc, bool) { + let mut reference = (*original).clone(); + reference.rect = Rect::from_min_size(Pos2::ZERO, vec2(width, original.rect.height())); + reference.mesh_bounds = Rect::NOTHING; + for row in &mut reference.rows { + let factor = if row.rtl_paragraph { 1.0 } else { 0.0 }; + row.pos.x = ((f64::from(width) - f64::from(row.size.x)) * factor * f64::from(ppp)).floor() as f32 / ppp; + reference.rect |= row.rect(); + reference.mesh_bounds |= row.visuals.mesh_bounds.translate(row.pos.to_vec2()); + } + let mut rtl = false; + if fixture.family != "Balloon Latin" { + assert!(original.rows.iter().any(|row| row.rtl_paragraph), "actual RTL shaping is required"); + if width == 0.0 { + assert!( + reference.rows.iter().any(|row| row.rtl_paragraph && row.pos.x < 0.0 && row.size.x > width), + "actual negative reading-space overflow is required" + ); + } + if width == 200.0 { + let row = &reference.rows[0]; + let (first, second) = if fixture.family == "Noto Sans Arabic" { ('ش', 'ي') } else { ('ש', 'ע') }; + let x = |c| row.glyphs.iter().find(|glyph| glyph.chr == c).unwrap().pos.x; + assert!(x(first) > x(second), "the first RTL word is physically right of the next"); + assert!(reference.rows.iter().find(|row| row.text() == "123").unwrap().rtl_paragraph, "neutral paragraph inherits RTL"); + assert!(!reference.rows.iter().find(|row| row.text() == "Latin").unwrap().rtl_paragraph); + } + rtl = true; + } + (std::sync::Arc::new(reference), rtl) + } + + /// Every aligned row keeps its logical position, shared glyph meshes and finite ink bounds. + fn assert_balloon_aligned_rows(galley: &egui::Galley, paint_offset: egui::Vec2, original: &egui::Galley, reference: &egui::Galley, case: &str) { + let ppp = original.pixels_per_point; + let (mut empty_mesh_rows, mut drawn_mesh_rows) = (0, 0); + for (row_index, ((row, expected), old)) in galley.rows.iter().zip(&reference.rows).zip(&original.rows).enumerate() { + assert!(std::sync::Arc::ptr_eq(&row.row, &old.row), "glyph order/meshes remain shared: {case}, row={row_index}"); + assert_eq!(row.pos.y, old.pos.y, "row Y: {case}, row={row_index}"); + assert!((row.pos + paint_offset - expected.pos).length() * ppp < 1e-4, "absolute row position: {case}, row={row_index}"); + assert!( + galley.rect.contains_rect(row.rect()), + "logical row containment: {case}, row={row_index} text={:?} logical={:?} galley={:?}", + row.text(), + row.rect(), + galley.rect + ); + if row.visuals.mesh.vertices.is_empty() { + // Empty/whitespace rows still have logical height, but no + // finite ink rectangle: translating NOTHING yields NaNs. + empty_mesh_rows += 1; + assert!(row.visuals.mesh.indices.is_empty(), "empty mesh indices: {case}, row={row_index} text={:?}", row.text()); + assert_eq!(row.visuals.mesh_bounds, Rect::NOTHING, "empty ink sentinel: {case}, row={row_index} text={:?}", row.text()); + } else { + drawn_mesh_rows += 1; + let ink = row.visuals.mesh_bounds.translate(row.pos.to_vec2()); + assert!(ink.is_finite(), "finite row ink: {case}, row={row_index} text={:?} ink={ink:?}", row.text()); + assert!(galley.mesh_bounds.is_finite(), "finite galley ink: {case}, row={row_index} bounds={:?}", galley.mesh_bounds); + assert!( + galley.mesh_bounds.contains_rect(ink), + "finite ink containment: {case}, row={row_index} text={:?} ink={ink:?} galley={:?}", + row.text(), + galley.mesh_bounds + ); + for (vertex_index, vertex) in row.visuals.mesh.vertices.iter().enumerate() { + let pos = row.pos + vertex.pos.to_vec2(); + assert!( + galley.mesh_bounds.contains(pos), + "ink vertex containment: {case}, row={row_index} vertex={vertex_index} text={:?} pos={pos:?} galley={:?}", + row.text(), + galley.mesh_bounds + ); + } + } + } + assert!( + empty_mesh_rows > 0 && drawn_mesh_rows > 0, + "fixture must observe empty and nonempty meshes: {case}, empty={empty_mesh_rows} drawn={drawn_mesh_rows}" + ); + } + + /// Run `balloon_text` for one width/origin and prove its galley and painted ink match the + /// independent reference, with the caller's clip retained. + fn assert_balloon_paint_case( + ctx: &egui::Context, + fixture: &BalloonFontFixture, + font: &egui::FontId, + original: &egui::Galley, + reference: &std::sync::Arc, + width: f32, + origin: Pos2, + ) { + let ppp = original.pixels_per_point; + let rounding = ctx.tessellation_options(|options| options.round_text_to_pixels); + let case = format!("family={} ppp={ppp} width={width} rounding={rounding} origin={origin:?}", fixture.family); + let mut clip = Rect::NOTHING; + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + let aligned = balloon_text(ui.painter(), fixture.text.into(), (*font).clone(), Color32::BLACK, width); + let craft_ui::text::AlignedGalley::Aligned { galley, paint_offset, reading_rect } = &aligned else { panic!("valid width must align") }; + assert_eq!(aligned.height(), original.rect.height()); + assert_eq!(*reading_rect, Rect::from_min_size(Pos2::ZERO, vec2(width, original.rect.height()))); + assert_eq!(galley.job.halign, egui::Align::Min); + assert_eq!(galley.rect.left_top(), Pos2::ZERO); + assert_eq!(galley.text(), fixture.text); + assert_eq!(galley.rows.len(), original.rows.len()); + assert_eq!(galley.num_vertices, original.num_vertices); + assert_eq!(galley.num_indices, original.num_indices); + assert_balloon_aligned_rows(galley, *paint_offset, original, reference, &case); + clip = ui.painter().clip_rect(); + aligned.paint(ui.painter(), origin, Color32::BLACK); + }); + let shapes: Vec<_> = output + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(shape) => Some((clipped.clip_rect, shape)), + _ => None, + }) + .collect(); + assert_eq!(shapes.len(), 1, "the actual helper paint emits one shape"); + assert_eq!(shapes[0].0, clip, "painting retains the caller's clip"); + let options = ctx.tessellation_options(|options| *options); + let atlas_size = ctx.fonts(|fonts| fonts.image().size); + let tessellate = |shape: &egui::epaint::TextShape| { + let mut tessellator = egui::epaint::Tessellator::new(ppp, options, atlas_size, Vec::new()); + let mut mesh = egui::epaint::Mesh::default(); + tessellator.tessellate_text(shape, &mut mesh); + mesh + }; + let actual = tessellate(shapes[0].1); + let expected = tessellate(&egui::epaint::TextShape::new(origin, std::sync::Arc::clone(reference), Color32::BLACK)); + assert!(!expected.vertices.is_empty(), "real font ink must be present"); + assert_eq!(actual.indices, expected.indices); + assert_eq!(actual.vertices.len(), expected.vertices.len()); + for (actual, expected) in actual.vertices.iter().zip(&expected.vertices) { + assert_eq!(actual.uv, expected.uv); + assert_eq!(actual.color, expected.color); + assert!((actual.pos - expected.pos).length() * ppp < 1e-4, "absolute physical ink must remain unchanged"); + } + drop(shapes); + output.drop_without_applying_deltas(); + } + + /// At four scales and three widths, balloon wrapping keeps the original font rows and glyph + /// meshes, preserves logical and physical ink, and keeps RTL reading origins, requiring a real + /// covering face for every fixture. #[test] fn balloon_wrapper_keeps_real_font_rows_ink_and_reading_origins() { let (fonts, fixtures) = balloon_font_fixtures(); @@ -4940,112 +5106,14 @@ mod tests { job.halign = egui::Align::Min; job.halign_min_is_start = false; let original = ctx.fonts_mut(|fonts| fonts.layout_job(job)); - // Independent pre-normalization reading-space oracle: position the original - // shaped rows using their paragraph direction, then recompute complete bounds. - let mut reference = (*original).clone(); - reference.rect = Rect::from_min_size(Pos2::ZERO, vec2(width, original.rect.height())); - reference.mesh_bounds = Rect::NOTHING; - for row in &mut reference.rows { - let factor = if row.rtl_paragraph { 1.0 } else { 0.0 }; - row.pos.x = ((f64::from(width) - f64::from(row.size.x)) * factor * f64::from(ppp)).floor() as f32 / ppp; - reference.rect |= row.rect(); - reference.mesh_bounds |= row.visuals.mesh_bounds.translate(row.pos.to_vec2()); - } - if fixture.family != "Balloon Latin" { - assert!(original.rows.iter().any(|row| row.rtl_paragraph), "actual RTL shaping is required"); - if width == 0.0 { - assert!( - reference.rows.iter().any(|row| row.rtl_paragraph && row.pos.x < 0.0 && row.size.x > width), - "actual negative reading-space overflow is required" - ); - } - if width == 200.0 { - let row = &reference.rows[0]; - let (first, second) = if fixture.family == "Noto Sans Arabic" { ('ش', 'ي') } else { ('ש', 'ע') }; - let x = |c| row.glyphs.iter().find(|glyph| glyph.chr == c).unwrap().pos.x; - assert!(x(first) > x(second), "the first RTL word is physically right of the next"); - assert!(reference.rows.iter().find(|row| row.text() == "123").unwrap().rtl_paragraph, "neutral paragraph inherits RTL"); - assert!(!reference.rows.iter().find(|row| row.text() == "Latin").unwrap().rtl_paragraph); - } + let (reference, rtl) = balloon_reading_reference(fixture, &original, width, ppp); + if rtl { rtl_cases += 1; } - let reference = std::sync::Arc::new(reference); for rounding in [false, true] { ctx.tessellation_options_mut(|options| options.round_text_to_pixels = rounding); for origin in [pos2(37.25, 19.375), pos2(0.5 / ppp - 1e-5, 0.5 / ppp)] { - let mut clip = Rect::NOTHING; - let output = ctx.run_ui(egui::RawInput::default(), |ui| { - let aligned = balloon_text(ui.painter(), fixture.text.into(), font.clone(), Color32::BLACK, width); - let craft_ui::text::AlignedGalley::Aligned { galley, paint_offset, reading_rect } = &aligned else { - panic!("valid width must align") - }; - assert_eq!(aligned.height(), original.rect.height()); - assert_eq!(*reading_rect, Rect::from_min_size(Pos2::ZERO, vec2(width, original.rect.height()))); - assert_eq!(galley.job.halign, egui::Align::Min); - assert_eq!(galley.rect.left_top(), Pos2::ZERO); - assert_eq!(galley.text(), fixture.text); - assert_eq!(galley.rows.len(), original.rows.len()); - assert_eq!(galley.num_vertices, original.num_vertices); - assert_eq!(galley.num_indices, original.num_indices); - let case = format!("family={} ppp={ppp} width={width} rounding={rounding} origin={origin:?}", fixture.family); - let (mut empty_mesh_rows, mut drawn_mesh_rows) = (0, 0); - for (row_index, ((row, expected), old)) in galley.rows.iter().zip(&reference.rows).zip(&original.rows).enumerate() { - assert!(std::sync::Arc::ptr_eq(&row.row, &old.row), "glyph order/meshes remain shared: {case}, row={row_index}"); - assert_eq!(row.pos.y, old.pos.y, "row Y: {case}, row={row_index}"); - assert!((row.pos + *paint_offset - expected.pos).length() * ppp < 1e-4, "absolute row position: {case}, row={row_index}"); - assert!(galley.rect.contains_rect(row.rect()), "logical row containment: {case}, row={row_index} text={:?} logical={:?} galley={:?}", row.text(), row.rect(), galley.rect); - if row.visuals.mesh.vertices.is_empty() { - // Empty/whitespace rows still have logical height, but no - // finite ink rectangle: translating NOTHING yields NaNs. - empty_mesh_rows += 1; - assert!(row.visuals.mesh.indices.is_empty(), "empty mesh indices: {case}, row={row_index} text={:?}", row.text()); - assert_eq!(row.visuals.mesh_bounds, Rect::NOTHING, "empty ink sentinel: {case}, row={row_index} text={:?}", row.text()); - } else { - drawn_mesh_rows += 1; - let ink = row.visuals.mesh_bounds.translate(row.pos.to_vec2()); - assert!(ink.is_finite(), "finite row ink: {case}, row={row_index} text={:?} ink={ink:?}", row.text()); - assert!(galley.mesh_bounds.is_finite(), "finite galley ink: {case}, row={row_index} bounds={:?}", galley.mesh_bounds); - assert!(galley.mesh_bounds.contains_rect(ink), "finite ink containment: {case}, row={row_index} text={:?} ink={ink:?} galley={:?}", row.text(), galley.mesh_bounds); - for (vertex_index, vertex) in row.visuals.mesh.vertices.iter().enumerate() { - let pos = row.pos + vertex.pos.to_vec2(); - assert!(galley.mesh_bounds.contains(pos), "ink vertex containment: {case}, row={row_index} vertex={vertex_index} text={:?} pos={pos:?} galley={:?}", row.text(), galley.mesh_bounds); - } - } - } - assert!(empty_mesh_rows > 0 && drawn_mesh_rows > 0, "fixture must observe empty and nonempty meshes: {case}, empty={empty_mesh_rows} drawn={drawn_mesh_rows}"); - clip = ui.painter().clip_rect(); - aligned.paint(ui.painter(), origin, Color32::BLACK); - }); - let shapes: Vec<_> = output - .shapes - .iter() - .filter_map(|clipped| match &clipped.shape { - egui::Shape::Text(shape) => Some((clipped.clip_rect, shape)), - _ => None, - }) - .collect(); - assert_eq!(shapes.len(), 1, "the actual helper paint emits one shape"); - assert_eq!(shapes[0].0, clip, "painting retains the caller's clip"); - let options = ctx.tessellation_options(|options| *options); - let atlas_size = ctx.fonts(|fonts| fonts.image().size); - let tessellate = |shape: &egui::epaint::TextShape| { - let mut tessellator = egui::epaint::Tessellator::new(ppp, options, atlas_size, Vec::new()); - let mut mesh = egui::epaint::Mesh::default(); - tessellator.tessellate_text(shape, &mut mesh); - mesh - }; - let actual = tessellate(shapes[0].1); - let expected = tessellate(&egui::epaint::TextShape::new(origin, reference.clone(), Color32::BLACK)); - assert!(!expected.vertices.is_empty(), "real font ink must be present"); - assert_eq!(actual.indices, expected.indices); - assert_eq!(actual.vertices.len(), expected.vertices.len()); - for (actual, expected) in actual.vertices.iter().zip(&expected.vertices) { - assert_eq!(actual.uv, expected.uv); - assert_eq!(actual.color, expected.color); - assert!((actual.pos - expected.pos).length() * ppp < 1e-4, "absolute physical ink must remain unchanged"); - } - drop(shapes); - output.drop_without_applying_deltas(); + assert_balloon_paint_case(&ctx, fixture, &font, &original, &reference, width, origin); } } } @@ -5055,6 +5123,137 @@ mod tests { eprintln!("balloon fixture coverage: {} exact embedded faces, {rtl_cases} RTL geometry cases", fixtures.len()); } + fn assert_balloon_timestamp(when: &str) { + assert_eq!(when.len(), 16, "WordCraft comments carry a local YYYY-MM-DD HH:MM timestamp: {when:?}"); + let b = when.as_bytes(); + assert_eq!( + (b.get(4), b.get(7), b.get(10), b.get(13)), + (Some(&b'-'), Some(&b'-'), Some(&b' '), Some(&b':')), + "timestamp separators: {when:?}" + ); + assert!(b.iter().enumerate().all(|(i, c)| matches!(i, 4 | 7 | 10 | 13) || c.is_ascii_digit()), "timestamp fields are digits: {when:?}"); + } + + /// One expected read-only card line: find its text shape, prove its origin, glyph coverage and + /// paragraph reading edge, and return the next line's expected top. + fn assert_balloon_line( + ctx: &egui::Context, + output: &egui::FullOutput, + card: Rect, + family: &str, + expected_lines: &[(&str, f32)], + line_index: usize, + mut y: f32, + ) -> (f32, (String, f32, f32, Pos2, f32)) { + let (text, gap) = expected_lines[line_index]; + y += gap; + let shapes: Vec<_> = output + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(shape) if shape.galley.text() == text => Some((clipped.clip_rect, shape)), + _ => None, + }) + .collect(); + let occurrence = expected_lines[..line_index].iter().filter(|entry| entry.0 == text).count(); + let expected_occurrences = expected_lines.iter().filter(|entry| entry.0 == text).count(); + assert_eq!( + shapes.len(), + expected_occurrences, + "the actual read-only line must be painted once per occurrence: text={text:?} line={line_index}" + ); + let (clip, shape) = shapes[occurrence]; + assert_eq!(shape.galley.pixels_per_point, 1.5, "the actual card galley uses the fixture's physical scale: {text}"); + // Recreate the original shaping job independently of the wrapper. + // Complete normalized bounds may exceed its preserved reading height. + let original = ctx.fonts_mut(|fonts| fonts.layout_job((*shape.galley.job).clone())); + assert_eq!(original.rows.len(), shape.galley.rows.len(), "the wrapper preserves original row count: {text}"); + for (row_index, (row, original_row)) in shape.galley.rows.iter().zip(&original.rows).enumerate() { + assert_eq!(row.pos.y, original_row.pos.y, "the wrapper preserves original row Y: text={text:?} row={row_index}"); + } + let reading_height = original.rect.height(); + let geometry = (text.to_string(), reading_height, shape.galley.rect.height(), shape.pos, y); + assert!(card.contains_rect(clip) && clip.is_positive(), "bodies, timestamps and replies keep the actual card clip"); + assert!( + (shape.pos.y - y).abs() * 1.5 <= 0.501, + "same reading-origin Y and vertical gaps: text={text:?} line={line_index} expected={y} actual={} gap={gap}", + shape.pos.y + ); + assert!(shape.galley.rows.iter().any(|row| !row.visuals.mesh.vertices.is_empty()), "real glyph ink is painted"); + assert!( + ctx.fonts_mut(|fonts| text.chars().filter(|c| !c.is_control()).all(|c| fonts.has_glyph(&egui::FontId::proportional(14.0), c))), + "actual app fonts cover text without tofu" + ); + let width = card.width() - 16.0; + let origin_x = ((card.min.x + 10.0) * 1.5).round() / 1.5; + for (row_index, row) in shape.galley.rows.iter().enumerate() { + let expected_x = if row.rtl_paragraph { ((f64::from(width) - f64::from(row.size.x)) * 1.5).floor() as f32 / 1.5 } else { 0.0 }; + assert!( + (shape.pos.x + row.pos.x - origin_x - expected_x).abs() * 1.5 < 1e-3, + "same absolute paragraph reading edge: family={} text={text:?} row={row_index} ppp={} card={card:?} shape_pos={:?} row_pos={:?} origin_x={origin_x} expected_x={expected_x}", + family, + shape.galley.pixels_per_point, + shape.pos, + row.pos + ); + assert!(shape.galley.rect.contains_rect(row.rect())); + } + (y + reading_height, geometry) + } + + /// The read-only card paints exactly the author, local timestamp, body, reply author, + /// timestamp and reply lines, each on the real font's card geometry. + fn assert_balloon_card( + app: &WordApp, + ctx: &egui::Context, + output: &egui::FullOutput, + id: u32, + reply_id: u32, + fixture: &BalloonFontFixture, + ) -> bool { + let Some((_, card)) = app.canvas.balloon_rects.iter().find(|(i, _)| *i == id) else { + return false; + }; + assert_eq!(ctx.native_pixels_per_point(), Some(1.5), "the real app receives the fixture's monitor scale"); + assert_eq!(ctx.pixels_per_point(), 1.5, "Interface Auto retains the fixture's physical scale"); + assert_eq!(app.canvas.balloon_rects.len(), 1, "the reply stays in its parent's actual hit card"); + // Current main paints each comment's author, its local timestamp, then its body; + // a reply repeats that under a 9 pt rule. Model that sequence from the + // independently shaped originals, not from production's painted Y. + let comment_when = crate::local_time::comment_time(&app.session.doc.comments[&id]); + let reply_when = crate::local_time::comment_time(&app.session.doc.comments[&reply_id]); + for when in [&comment_when, &reply_when] { + assert_balloon_timestamp(when); + } + let body = fixture.text.trim(); + let reply = format!("{} (reply)", fixture.probe); + let expected_lines = [ + ("Balloon author", 0.0), + (comment_when.as_str(), 1.0), + (body, 4.0), + ("Reply author", 9.0), + (reply_when.as_str(), 1.0), + (reply.as_str(), 4.0), + ]; + let mut y = card.min.y + 6.0; + let mut line_geometry = Vec::new(); + for line_index in 0..expected_lines.len() { + let (next_y, geometry) = assert_balloon_line(ctx, output, *card, fixture.family, &expected_lines, line_index, y); + y = next_y; + line_geometry.push(geometry); + } + assert!( + (y + 6.0 - card.bottom()).abs() < 1e-3, + "card height still sums original author, timestamp, body and reply heights and gaps: family={} expected_bottom={} card={card:?} delta={} lines={line_geometry:?}", + fixture.family, + y + 6.0, + y + 6.0 - card.bottom() + ); + true + } + + /// Read-only comment and reply cards use the exact embedded faces and keep author, timestamp, body + /// and reply line geometry, including RTL cases, without editing the document. #[test] fn read_only_balloon_bodies_and_replies_keep_real_font_card_geometry() { let (_, fixtures) = balloon_font_fixtures(); @@ -5098,107 +5297,8 @@ mod tests { app.logic(ui.ctx()); app.ui(ui); }); - if frame >= 3 - && let Some((_, card)) = app.canvas.balloon_rects.iter().find(|(i, _)| *i == id) - { - assert_eq!(ctx.native_pixels_per_point(), Some(1.5), "the real app receives the fixture's monitor scale"); - assert_eq!(ctx.pixels_per_point(), 1.5, "Interface Auto retains the fixture's physical scale"); - assert_eq!(app.canvas.balloon_rects.len(), 1, "the reply stays in its parent's actual hit card"); - // Current main paints each comment's author, its local timestamp, then its body; - // a reply repeats that under a 9 pt rule. Model that sequence from the - // independently shaped originals, not from production's painted Y. - let comment_when = crate::local_time::comment_time(&app.session.doc.comments[&id]); - let reply_when = crate::local_time::comment_time(&app.session.doc.comments[&reply_id]); - for when in [&comment_when, &reply_when] { - assert_eq!(when.len(), 16, "WordCraft comments carry a local YYYY-MM-DD HH:MM timestamp: {when:?}"); - let b = when.as_bytes(); - assert_eq!( - (b.get(4), b.get(7), b.get(10), b.get(13)), - (Some(&b'-'), Some(&b'-'), Some(&b' '), Some(&b':')), - "timestamp separators: {when:?}" - ); - assert!( - b.iter().enumerate().all(|(i, c)| matches!(i, 4 | 7 | 10 | 13) || c.is_ascii_digit()), - "timestamp fields are digits: {when:?}" - ); - } - let expected_lines = [ - ("Balloon author", 0.0), - (comment_when.as_str(), 1.0), - (body, 4.0), - ("Reply author", 9.0), - (reply_when.as_str(), 1.0), - (reply.as_str(), 4.0), - ]; - let mut y = card.min.y + 6.0; - let mut line_geometry = Vec::new(); - for (line_index, &(text, gap)) in expected_lines.iter().enumerate() { - y += gap; - let shapes: Vec<_> = output - .shapes - .iter() - .filter_map(|clipped| match &clipped.shape { - egui::Shape::Text(shape) if shape.galley.text() == text => Some((clipped.clip_rect, shape)), - _ => None, - }) - .collect(); - let occurrence = expected_lines[..line_index].iter().filter(|entry| entry.0 == text).count(); - let expected_occurrences = expected_lines.iter().filter(|entry| entry.0 == text).count(); - assert_eq!( - shapes.len(), - expected_occurrences, - "the actual read-only line must be painted once per occurrence: text={text:?} line={line_index}" - ); - let (clip, shape) = shapes[occurrence]; - assert_eq!(shape.galley.pixels_per_point, 1.5, "the actual card galley uses the fixture's physical scale: {text}"); - // Recreate the original shaping job independently of the wrapper. - // Complete normalized bounds may exceed its preserved reading height. - let original = ctx.fonts_mut(|fonts| fonts.layout_job((*shape.galley.job).clone())); - assert_eq!(original.rows.len(), shape.galley.rows.len(), "the wrapper preserves original row count: {text}"); - for (row_index, (row, original_row)) in shape.galley.rows.iter().zip(&original.rows).enumerate() { - assert_eq!(row.pos.y, original_row.pos.y, "the wrapper preserves original row Y: text={text:?} row={row_index}"); - } - let reading_height = original.rect.height(); - line_geometry.push((text.to_string(), reading_height, shape.galley.rect.height(), shape.pos, y)); - assert!(card.contains_rect(clip) && clip.is_positive(), "bodies, timestamps and replies keep the actual card clip"); - assert!( - (shape.pos.y - y).abs() * 1.5 <= 0.501, - "same reading-origin Y and vertical gaps: text={text:?} line={line_index} expected={y} actual={} gap={gap}", - shape.pos.y - ); - assert!(shape.galley.rows.iter().any(|row| !row.visuals.mesh.vertices.is_empty()), "real glyph ink is painted"); - assert!( - ctx.fonts_mut(|fonts| text - .chars() - .filter(|c| !c.is_control()) - .all(|c| fonts.has_glyph(&egui::FontId::proportional(14.0), c))), - "actual app fonts cover text without tofu" - ); - let width = card.width() - 16.0; - let origin_x = ((card.min.x + 10.0) * 1.5).round() / 1.5; - for (row_index, row) in shape.galley.rows.iter().enumerate() { - let expected_x = - if row.rtl_paragraph { ((f64::from(width) - f64::from(row.size.x)) * 1.5).floor() as f32 / 1.5 } else { 0.0 }; - assert!( - (shape.pos.x + row.pos.x - origin_x - expected_x).abs() * 1.5 < 1e-3, - "same absolute paragraph reading edge: family={} text={text:?} row={row_index} ppp={} card={card:?} shape_pos={:?} row_pos={:?} origin_x={origin_x} expected_x={expected_x}", - fixture.family, - shape.galley.pixels_per_point, - shape.pos, - row.pos - ); - assert!(shape.galley.rect.contains_rect(row.rect())); - } - y += reading_height; - } - assert!( - (y + 6.0 - card.bottom()).abs() < 1e-3, - "card height still sums original author, timestamp, body and reply heights and gaps: family={} expected_bottom={} card={card:?} delta={} lines={line_geometry:?}", - fixture.family, - y + 6.0, - y + 6.0 - card.bottom() - ); - checked = true; + if frame >= 3 { + checked |= assert_balloon_card(&app, &ctx, &output, id, reply_id, fixture); } output.drop_without_applying_deltas(); } @@ -5211,6 +5311,7 @@ mod tests { eprintln!("actual balloon card coverage: {} faces, {rtl_cases} RTL author/timestamp/body/reply cases", fixtures.len()); } + /// Painted balloon text is clipped inside its comment card. #[test] fn read_only_balloon_text_is_clipped_to_its_card() { let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); @@ -7452,6 +7553,46 @@ mod tests { assert!(pointer_interrupted, "a pointer click still interrupts composition"); } + /// Kittest's `MissingGlyphPolicy::Panic` is right: a missing glyph is a bug. But the interface + /// fonts need not carry Japanese (craft-fonts is optional, and a CI Linux runner has no + /// Japanese system font), and these IME tests compose Japanese through the interface font + /// chain. Draw a stand-in for exactly those characters; every other missing glyph still panics. + fn kana_test_glyphs(ctx: &egui::Context) { + ctx.add_glyph_rasterizer(egui::GlyphRasterizer::new("wordcraft::canvas::tests::kana", |req| { + let kana = *req.family == egui::FontFamily::Proportional + && !req.cluster.is_empty() + && req.cluster.chars().all(|c| matches!(c, 'に' | 'ほ' | 'ん')); + kana.then(|| egui::RasterizedGlyph { + bitmap: egui::GlyphBitmap { + image: egui::ColorImage::new([1, 1], vec![egui::Color32::WHITE]), + offset_px: egui::vec2(0.0, 0.0), + is_color: false, + }, + advance_px: (req.font_size_px * 0.5).max(1.0), + }) + })); + } + + /// The fixture above must work with no Japanese font anywhere: only a bundled Latin face, the + /// strict missing-glyph policy, and the kana stand-in. + #[test] + fn the_ime_fixture_draws_kana_without_a_japanese_font() { + let ctx = egui::Context::default(); + ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic); + let mut fonts = egui::FontDefinitions::empty(); + fonts + .font_data + .insert("Latin".into(), std::sync::Arc::new(egui::FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); + fonts.families.insert(egui::FontFamily::Proportional, vec!["Latin".into()]); + ctx.set_fonts(fonts); + kana_test_glyphs(&ctx); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let g = ui.painter().layout_no_wrap("にほん".into(), crate::theme::regular(14.0), egui::Color32::WHITE); + assert_eq!(g.rows.first().map_or(0, |r| r.row.glyphs.len()), 3, "one glyph per composed kana"); + }); + output.textures_delta.clear(); + } + /// While an input method composes, the caret and the candidate window sit at its cursor inside /// the composition: the end of the range it reports, or the end of the text with no range. The /// range comes from the OS and is clamped, whatever it holds. @@ -7459,14 +7600,18 @@ mod tests { fn the_caret_follows_the_input_method_cursor_in_the_composition() { let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); app.run("document.setText", json!({"text": "The"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( - |ui, app: &mut WordApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1440.0, 900.0)) + .with_context_setup(kana_test_glyphs) + .with_step_dt(1.0 / 60.0) + .build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); for _ in 0..8 { h.step(); } @@ -7499,14 +7644,18 @@ mod tests { fn ime_descriptor_reports_visible_logical_text_and_exact_cause() { use egui::ImeChangeCause; let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("ab")), Default::default()); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( - |ui, app: &mut WordApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1440.0, 900.0)) + .with_context_setup(kana_test_glyphs) + .with_step_dt(1.0 / 60.0) + .build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); for _ in 0..8 { h.step(); } @@ -7796,14 +7945,18 @@ mod tests { #[test] fn ime_generation_changes_on_body_replacement_with_the_same_text() { let app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("hello")), Default::default()); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( - |ui, app: &mut WordApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(1440.0, 900.0)) + .with_context_setup(kana_test_glyphs) + .with_step_dt(1.0 / 60.0) + .build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); for _ in 0..8 { h.step(); } diff --git a/crates/ui-egui/src/caret_glide.rs b/crates/ui-egui/src/caret_glide.rs index 12b886f3ef..b325c3e13b 100644 --- a/crates/ui-egui/src/caret_glide.rs +++ b/crates/ui-egui/src/caret_glide.rs @@ -321,6 +321,7 @@ fn parse_ms(value: Option<&str>) -> f64 { } } +/// Stable id for the caret glide's temporary animation state. fn state_id() -> egui::Id { egui::Id::unique("caret_glide") } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 896db0d44d..b7e7c8119d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -275,6 +275,8 @@ impl MacTitleBar { } } +/// Draws the title bar with window dragging, AutoSave, quick access, account, community and search +/// controls, and the centred clickable document title. pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // macOS: shorter, so the system's traffic lights are centred in it; the tallest controls @@ -536,6 +538,8 @@ pub fn focus_ribbon(app: &mut WordApp, ctx: &egui::Context) { } } +/// Draws the status bar's page, word count, zoom and status controls, fitting a status message +/// with a tooltip; without a document the bar is empty. pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::bottom("status") diff --git a/crates/ui-egui/src/colors.rs b/crates/ui-egui/src/colors.rs index 0f2258ca79..211113b7be 100644 --- a/crates/ui-egui/src/colors.rs +++ b/crates/ui-egui/src/colors.rs @@ -247,14 +247,17 @@ pub enum Pick { More, } +/// Stable storage id for the Recent Colors list. fn recent_id() -> Id { Id::unique("wordcraft.recentColors") } +/// Stable storage id for the pending More Colors request. fn request_id() -> Id { Id::unique("wordcraft.moreColorsRequest") } +/// Stable storage id for the Colors dialog's open flag. fn open_flag_id() -> Id { Id::unique("wordcraft.colorsDialogOpen") } @@ -794,6 +797,8 @@ mod tests { assert_eq!((g.first().copied(), g.last().copied()), (Some(Rgb::WHITE), Some(Rgb::BLACK))); } + /// Assert that colour targets build the expected `format.color` or JSON-path command payload, and + /// that a field target has no command. #[test] fn targets_put_the_colour_where_the_command_wants_it() { assert_eq!(Target::cmd("format.color").command("1A2B3C"), Some(("format.color".into(), json!({"color": "1A2B3C"})))); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 673da748c7..5a1ae6387f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -125,6 +125,9 @@ pub(crate) fn close_refused(app: &WordApp, id: &str, params: &Value) -> Option<& /// The reply to a request that needs a document while none is open (#1129). const NO_DOCUMENT: &str = "no document is open; create one (file.new) or open one (file.open) first"; +/// Dispatches one JSON-lines control request: commands and inspection, parity, synthetic click, +/// drag, key, wheel and text input, screenshots and renders, resize, focus and quit, and unknown +/// ids as commands. It refuses closing unsaved work and document operations without a document. pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -363,6 +366,8 @@ pub(crate) struct SurfaceGeometry { } impl SurfaceGeometry { + /// Derives this frame's capturable surface from raw viewport data, rejecting a missing, non-finite + /// or zero-sized visible surface and recording minimized or occluded state. pub(crate) fn from_raw(raw: &egui::RawInput, ctx: &egui::Context) -> Option { let viewport = raw.viewports.get(&raw.viewport_id)?; let minimized = viewport.minimized == Some(true); @@ -387,6 +392,7 @@ impl SurfaceGeometry { Some(Self { viewport: rect.size(), pixels_per_point, max_texture_side, minimized, occluded }) } + /// Pairs the surface geometry with the document id a capture belongs to. pub(crate) fn with_document(self, document: u64) -> CaptureGeometry { CaptureGeometry { surface: self, document } } @@ -402,10 +408,13 @@ pub(crate) struct CaptureGeometry { } impl CaptureGeometry { + /// Returns the document id the capture was admitted for. pub(crate) fn document(self) -> u64 { self.document } + /// Explains why the surface cannot be captured, when it is minimized or fully occluded, and + /// returns `None` when it can. pub(crate) fn unavailable_reason(self) -> Option<&'static str> { if self.surface.minimized { Some("the window is minimized") @@ -490,23 +499,29 @@ pub(crate) struct Shots { } impl Shots { + /// Whether any screenshot request is outstanding. pub(crate) fn in_flight(&self) -> bool { !self.requests.is_empty() } + /// Sums, saturating, the admission bytes reserved by outstanding screenshot requests. fn reserved(&self) -> usize { self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.bytes)) } } +/// Builds a screenshot error reply carrying the request token. fn error_reply(token: u64, message: impl std::fmt::Display) -> Value { json!({"ok": false, "error": message.to_string(), "request": token}) } +/// Sends one screenshot error reply, ignoring a disconnected receiver. fn reject_shot(reply: Sender, token: u64, message: impl std::fmt::Display) { let _ = reply.send(error_reply(token, message)); } +/// Advances the screenshot token, wrapping and skipping zero so every request has a non-zero +/// token. fn next_token(app: &mut WordApp) -> u64 { let mut token = app.shots.token.wrapping_add(1); if token == 0 { @@ -677,6 +692,7 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { } impl Shot { + /// The screenshot callback's viewport: the root window. fn viewport(&self) -> egui::ViewportId { egui::ViewportId::ROOT } @@ -698,6 +714,8 @@ fn dimensions_are_a_rounded_variant(expected: [usize; 2], actual: [usize; 2]) -> expected[0].abs_diff(actual[0]) <= 1 && expected[1].abs_diff(actual[1]) <= 1 } +/// Rejects an empty, overflowing, underfilled or oversized image. A size one pixel away from the +/// expected surface is accepted as a rounding variant; a larger difference is stale. fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), ImageCheck> { let [width, height] = image.size; if width == 0 || height == 0 { @@ -728,6 +746,128 @@ fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), Ok(()) } +/// What one pass over an outstanding screenshot request decides. +enum ShotAction { + Keep, + Remove(Value), + Restart, +} + +/// Classify one pending request: deadline first, then admission state, then the completed slot. +fn take_screenshot_action(app: &mut WordApp, index: usize, now: f64, current: Option, epoch: u64) -> ShotAction { + let completed = { + let shot = &mut app.shots.requests[index]; + if now >= shot.deadline { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + return ShotAction::Remove(error_reply(shot.token, "no frame was presented before the screenshot timeout")); + } + if !shot.issued { + return ShotAction::Keep; + } + if let Some(reason) = current.and_then(CaptureGeometry::unavailable_reason) { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + return ShotAction::Remove(error_reply(shot.token, format!("screenshot failed: {reason} while the capture was pending"))); + } + if current.is_some_and(|geometry| geometry.document() != shot.document) { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + return ShotAction::Remove(error_reply(shot.token, "screenshot failed: the document changed while the capture was queued")); + } + shot.slot.as_ref().and_then(|slot| slot.take()) + }; + match completed { + None => ShotAction::Keep, + Some(Err(error)) => ShotAction::Remove(error_reply(app.shots.requests[index].token, error)), + Some(Ok(image)) => completed_screenshot_action(app, index, image, current, epoch), + } +} + +/// Judge one taken image: its geometry and epoch identity first, then the image's own validity. +fn completed_screenshot_action( + app: &mut WordApp, + index: usize, + image: std::sync::Arc, + current: Option, + epoch: u64, +) -> ShotAction { + let shot = &app.shots.requests[index]; + let stale = current != Some(shot.geometry) || epoch != shot.epoch; + if stale { + if shot.retries < SCREENSHOT_MAX_RETRIES { + return ShotAction::Restart; + } + return ShotAction::Remove(error_reply(shot.token, "screenshot geometry changed while it was captured; no image was written")); + } + match validate_image(&image, shot.expected) { + Err(ImageCheck::Stale(_)) if shot.retries < SCREENSHOT_MAX_RETRIES => ShotAction::Restart, + Err(ImageCheck::Stale(message) | ImageCheck::Invalid(message)) => ShotAction::Remove(error_reply(shot.token, message)), + Ok(()) => ShotAction::Remove(save_screenshot(image.as_ref(), shot.path.as_deref(), shot.token)), + } +} + +/// Drop one request at `index` with its reply, cancelling any slot still held. +fn remove_shot(app: &mut WordApp, index: usize, value: Value) { + let shot = app.shots.requests.remove(index); + if let Some(slot) = shot.slot { + slot.cancel(); + } + let _ = shot.reply.send(value); +} + +/// Drop one request at `index` and send an error reply for it. +fn reject_removed_shot(app: &mut WordApp, index: usize, message: impl std::fmt::Display) { + let shot = app.shots.requests.remove(index); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject_shot(shot.reply, shot.token, message); +} + +/// Reissue the request at `index` against the current geometry. Returns whether it was reissued: +/// a request removed for a refusal keeps `index` for the request that slides into its place. +fn restart_shot(app: &mut WordApp, ctx: &egui::Context, index: usize, now: f64, current: Option, epoch: u64) -> bool { + let Some(current) = current else { + reject_removed_shot(app, index, "screenshot failed: the window geometry is not known"); + return false; + }; + let Some(expected) = current.expected_size() else { + reject_removed_shot(app, index, "screenshot failed: the window size overflows the screenshot budget"); + return false; + }; + if let Some(reason) = current.oversize_reason() { + reject_removed_shot(app, index, reason); + return false; + } + let inline_reply = app.shots.requests[index].path.is_none(); + let Some(bytes) = current.reserved_bytes(inline_reply) else { + reject_removed_shot(app, index, "screenshot failed: the window size overflows the screenshot budget"); + return false; + }; + let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != index).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.bytes)); + if bytes > SCREENSHOT_MAX_IMAGE_BYTES || others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { + reject_removed_shot(app, index, "screenshot budget exceeded while retrying after a resize"); + return false; + } + let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), SCREENSHOT_MAX_IMAGE_BYTES); + let shot = &mut app.shots.requests[index]; + shot.slot = Some(slot); + shot.callback = Some(callback); + shot.issued = false; + shot.ready_at = now + 120.0; + shot.frames = 0; + shot.retries = shot.retries.saturating_add(1); + shot.geometry = current; + shot.expected = expected; + shot.bytes = bytes; + shot.epoch = epoch; + true +} + /// Called from `logic`: take each completed slot once, check the deadline **before** writing, /// validate request/document/geometry identity, and only then encode and reply. A late, stale or /// oversized image is dropped without a file; a document switch cancels instead of rebinding. @@ -737,119 +877,13 @@ pub(crate) fn drain_screenshots(app: &mut WordApp, ctx: &egui::Context) { let epoch = app.capture_epoch; let mut i = 0; while i < app.shots.requests.len() { - enum Action { - Keep, - Remove(Value), - Restart, - } - let action = { - let shot = &mut app.shots.requests[i]; - if now >= shot.deadline { - if let Some(slot) = shot.slot.take() { - slot.cancel(); + match take_screenshot_action(app, i, now, current, epoch) { + ShotAction::Keep => i += 1, + ShotAction::Remove(value) => remove_shot(app, i, value), + ShotAction::Restart => { + if restart_shot(app, ctx, i, now, current, epoch) { + i += 1; } - Action::Remove(error_reply(shot.token, "no frame was presented before the screenshot timeout")) - } else if !shot.issued { - Action::Keep - } else if let Some(reason) = current.and_then(CaptureGeometry::unavailable_reason) { - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - Action::Remove(error_reply(shot.token, format!("screenshot failed: {reason} while the capture was pending"))) - } else if current.is_some_and(|geometry| geometry.document() != shot.document) { - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - Action::Remove(error_reply(shot.token, "screenshot failed: the document changed while the capture was queued")) - } else { - match shot.slot.as_ref().and_then(|slot| slot.take()) { - None => Action::Keep, - Some(Err(error)) => Action::Remove(error_reply(shot.token, error)), - Some(Ok(image)) => { - let stale = current != Some(shot.geometry) || epoch != shot.epoch; - if stale { - if shot.retries < SCREENSHOT_MAX_RETRIES { - Action::Restart - } else { - Action::Remove(error_reply(shot.token, "screenshot geometry changed while it was captured; no image was written")) - } - } else { - match validate_image(&image, shot.expected) { - Err(ImageCheck::Stale(_)) if shot.retries < SCREENSHOT_MAX_RETRIES => Action::Restart, - Err(ImageCheck::Stale(message) | ImageCheck::Invalid(message)) => Action::Remove(error_reply(shot.token, message)), - Ok(()) => Action::Remove(save_screenshot(image.as_ref(), shot.path.as_deref(), shot.token)), - } - } - } - } - } - }; - match action { - Action::Keep => i += 1, - Action::Remove(value) => { - let shot = app.shots.requests.remove(i); - if let Some(slot) = shot.slot { - slot.cancel(); - } - let _ = shot.reply.send(value); - } - Action::Restart => { - let Some(current) = current else { - let shot = app.shots.requests.remove(i); - if let Some(slot) = shot.slot { - slot.cancel(); - } - reject_shot(shot.reply, shot.token, "screenshot failed: the window geometry is not known"); - continue; - }; - let Some(expected) = current.expected_size() else { - let shot = app.shots.requests.remove(i); - if let Some(slot) = shot.slot { - slot.cancel(); - } - reject_shot(shot.reply, shot.token, "screenshot failed: the window size overflows the screenshot budget"); - continue; - }; - if let Some(reason) = current.oversize_reason() { - let shot = app.shots.requests.remove(i); - if let Some(slot) = shot.slot { - slot.cancel(); - } - reject_shot(shot.reply, shot.token, reason); - continue; - } - let inline_reply = app.shots.requests[i].path.is_none(); - let Some(bytes) = current.reserved_bytes(inline_reply) else { - let shot = app.shots.requests.remove(i); - if let Some(slot) = shot.slot { - slot.cancel(); - } - reject_shot(shot.reply, shot.token, "screenshot failed: the window size overflows the screenshot budget"); - continue; - }; - let others = - app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.bytes)); - if bytes > SCREENSHOT_MAX_IMAGE_BYTES || others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { - let shot = app.shots.requests.remove(i); - if let Some(slot) = shot.slot { - slot.cancel(); - } - reject_shot(shot.reply, shot.token, "screenshot budget exceeded while retrying after a resize"); - continue; - } - let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), SCREENSHOT_MAX_IMAGE_BYTES); - let shot = &mut app.shots.requests[i]; - shot.slot = Some(slot); - shot.callback = Some(callback); - shot.issued = false; - shot.ready_at = now + 120.0; - shot.frames = 0; - shot.retries = shot.retries.saturating_add(1); - shot.geometry = current; - shot.expected = expected; - shot.bytes = bytes; - shot.epoch = epoch; - i += 1; } } } @@ -938,6 +972,8 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64) mod tests { use super::*; + /// Builds a visible root-surface fixture with the given viewport, scale and texture limit for + /// screenshot tests. fn surface(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> SurfaceGeometry { let ctx = egui::Context::default(); let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, viewport); @@ -955,10 +991,12 @@ mod tests { SurfaceGeometry::from_raw(&raw, &ctx).expect("a visible surface") } + /// Builds a capture geometry fixture for document id 7. fn geometry(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> CaptureGeometry { CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), document: 7 } } + /// Creates a black test image of the given size. fn image(width: usize, height: usize) -> egui::ColorImage { egui::ColorImage::filled([width, height], egui::Color32::BLACK) } @@ -1042,6 +1080,8 @@ mod screenshot_tests { } } + /// A screenshot path must end in `.png` and is refused otherwise; a PNG is written, while a + /// symlinked PNG target is not followed or overwritten. #[test] fn screenshot_path_must_be_png_and_does_not_follow_symlinks() { let dir = std::env::temp_dir().join(format!("wc-shot-{}", std::process::id())); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9ac00aedcb..ff6ac8c3bf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1436,6 +1436,9 @@ fn count_dialog_frame(ctx: &egui::Context, id: egui::Id) { } } +/// Takes the active dialog, resolves its translated title, hosts it as a non-resizable window, draws +/// its body, keeps it open when it stays, and gives the Save Changes prompt the AccessKit +/// alert-dialog role. pub fn show(app: &mut WordApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { ctx.data_mut(|d| d.remove::<(egui::Id, u8)>(dialog_top_key())); @@ -4189,6 +4192,7 @@ mod tests { /// open, Cancel then reading Close — and one undo brings the bookmark back. #[test] fn the_bookmark_dialog_goes_to_and_deletes_bookmarks() { + /// Clicks the labelled control inside the open Bookmark dialog and advances three frames. fn press(h: &mut egui_kittest::Harness<'_, WordApp>, label: &str) { use egui_kittest::kittest::Queryable; let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "Bookmark")))).expect("the dialog is open"); diff --git a/crates/ui-egui/src/dialogs_chart.rs b/crates/ui-egui/src/dialogs_chart.rs index c6d5441efd..a0f03a529a 100644 --- a/crates/ui-egui/src/dialogs_chart.rs +++ b/crates/ui-egui/src/dialogs_chart.rs @@ -369,6 +369,7 @@ mod tests { /// became `align(Align2)`: `RIGHT_CENTER` silently changed vertical alignment too. #[test] fn chart_value_cells_are_right_and_top_aligned() { + /// Finds the first text shape's position, descending into a vector of shapes. fn text_pos(shape: &egui::Shape) -> Option { match shape { egui::Shape::Text(text) => Some(text.pos), diff --git a/crates/ui-egui/src/dialogs_refs.rs b/crates/ui-egui/src/dialogs_refs.rs index 3988bdc7e4..73912c9362 100644 --- a/crates/ui-egui/src/dialogs_refs.rs +++ b/crates/ui-egui/src/dialogs_refs.rs @@ -312,6 +312,8 @@ impl CaptionForm { } } +/// Draws the Caption dialog's preview, text, label, position and numbering controls, with New +/// Label, Delete Label and Numbering jumps, and inserts the caption on OK. fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> Next { let caption_label = ui.label(tl!("Caption:")); ui.horizontal(|ui| { @@ -420,6 +422,9 @@ impl CrossRefForm { } } +/// Draws the Cross-reference dialog's reference-type and insert-reference-to selectors and the +/// target list, reloading the form when the type changes, and runs `insert.crossReference` on +/// Insert. fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next { let was = f.to.clone(); egui::Grid::new("xref_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { @@ -461,6 +466,8 @@ fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next { if cancel { Next::Close } else { Next::Stay } } +/// Draws the caption numbering format, chapter-number toggle and chapter level and separator +/// controls, returning to the caption dialog with the edited or original form. fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next { egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { let format_label = ui.label(tl!("Format:")); @@ -499,6 +506,8 @@ fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next { Next::Stay } +/// Draws the new caption label field with focus, sanitizes an accepted label, remembers custom +/// labels, and returns to the caption dialog. fn new_label_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> Next { let label = ui.label(tl!("Label:")); let r = ui.add(egui::TextEdit::singleline(&mut f.new_label).desired_width(260.0)).labelled_by(label.id); @@ -565,6 +574,8 @@ impl IndexForm { } } +/// Draws the index preview and type, columns, right-alignment, tab-leader and heading controls, +/// jumps to Mark Entry, and runs `references.index` on OK. fn index_ui(app: &mut WordApp, ui: &mut Ui, f: &mut IndexForm) -> Next { let page = |p: &str| p.to_string(); let lines: Vec<(usize, String, String)> = { @@ -664,6 +675,8 @@ impl MarkForm { } } +/// Draws the index-entry fields and page, cross-reference or bookmark-range options, with Mark +/// and Mark All running `references.markEntry`. fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next { heading(ui, "Index"); egui::Grid::new("xe").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { @@ -814,6 +827,8 @@ impl TocForm { } } +/// Draws the TOC preview and page-number, right-alignment, tab-leader, hyperlink and level +/// controls, with Options and Modify jumps and `references.toc` on OK. fn toc_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm) -> Next { let lines: Vec<(usize, String, String)> = (1..=f.levels.min(3)) .map(|l| { @@ -859,6 +874,8 @@ fn toc_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm) -> Next { if ok || cancel { Next::Close } else { Next::Stay } } +/// Draws the TOC sources: heading styles, per-style TOC levels, outline levels and TC entry +/// fields, with Reset. OK returns the edited form and Cancel the original. fn toc_options_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm, was: &TocForm) -> Next { heading(ui, "Collect entries from:"); ui.checkbox(&mut f.heading_styles, crate::i18n::fmt(tl!("Heading styles (levels 1–{n})"), &[("n", &f.levels.to_string())])); @@ -962,6 +979,8 @@ impl FiguresForm { } } +/// Draws the table-of-figures preview and label, include-label, page-number, right-alignment and +/// tab-leader controls, running `references.tableOfFigures` on OK. fn figures_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FiguresForm) -> Next { let lines: Vec<(usize, String, String)> = [(1, "1"), (2, "3")] .iter() @@ -1145,6 +1164,8 @@ impl SourceForm { } } +/// Draws a source's type and fields, optionally every bibliography field, and adds it as a +/// bibliography source or inserts a citation on OK. fn source_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SourceForm) -> Next { egui::Grid::new("src").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| { let kind_label = ui.label(tl!("Source type:")); diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 2285bab08d..7476b4b513 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -423,6 +423,8 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo cancel } +/// Draws AutoCorrect's capitalization and replacement checkboxes, the replace list with its +/// filter and add, replace and delete actions, and the exceptions editor. fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) { let o = &mut f.options; ui.checkbox(&mut o.two_initial_caps, tl!("Correct TWo INitial CApitals")); @@ -530,6 +532,7 @@ fn exceptions(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) { }); } +/// Filters the math-symbol table by the typed prefix and lists each `\name` with its character. fn math_tab(ui: &mut Ui, f: &mut AutoCorrectForm) { ui.label(egui::RichText::new(tl!("In equations, typing a name after a backslash replaces it with its symbol.")).small().weak()); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/dialogs_smart_art.rs b/crates/ui-egui/src/dialogs_smart_art.rs index 4546c162c0..6032d26328 100644 --- a/crates/ui-egui/src/dialogs_smart_art.rs +++ b/crates/ui-egui/src/dialogs_smart_art.rs @@ -321,6 +321,8 @@ pub(crate) enum SmartArtTypeResult { Changed, } +/// Appends typing to the selected SmartArt item, joining an active typing undo, and returns +/// whether the input was unhandled, consumed without a change, or changed an item. pub(crate) fn type_into(app: &mut WordApp, typed: &str) -> SmartArtTypeResult { let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else { return SmartArtTypeResult::NotHandled; diff --git a/crates/ui-egui/src/equation_tab.rs b/crates/ui-egui/src/equation_tab.rs index 41ea057013..38461322f9 100644 --- a/crates/ui-egui/src/equation_tab.rs +++ b/crates/ui-egui/src/equation_tab.rs @@ -48,11 +48,22 @@ fn paint_math(app: &mut WordApp, ui: &Ui, r: Rect, key: &str, linear: &str, node } } +/// Builds a readable fallback for a math preview from the interface fonts' glyph coverage: +/// covered characters and whitespace stay, while a missing glyph becomes its UnicodeMath name +/// or a `U+XXXX` codepoint. fn math_placeholder(ui: &Ui, linear: &str) -> String { let font = regular(11.0); + math_placeholder_with(linear, |c| ui.fonts_mut(|fonts| fonts.has_glyph(&font, c))) +} + +/// [`math_placeholder`] given which characters the interface fonts cover. A character they lack +/// becomes its UnicodeMath name (`┬` → `\below`), so the same template has a different +/// placeholder in a build without craft-fonts; the i18n audit recognizes the exact strings +/// (`placeholder_linears`) as math input rather than interface wording. +pub(crate) fn math_placeholder_with(linear: &str, covered: impl Fn(char) -> bool) -> String { let mut result = String::new(); for c in linear.chars() { - if c.is_whitespace() || ui.fonts_mut(|fonts| fonts.has_glyph(&font, c)) { + if c.is_whitespace() || covered(c) { result.push(c); } else if let Some(name) = wordcraft_doc::math_symbols::name_of(c) { result.push('\\'); @@ -65,6 +76,23 @@ fn math_placeholder(ui: &Ui, linear: &str) -> String { result } +/// The Equation tab's own big button picture. +const EQUATION_PICTURE: &str = "∑_(i=1)^n"; + +/// Every linear text the Equation tab can paint as a placeholder while a preview loads: the big +/// buttons' pictures, every template and built-in equation, and every symbol tile. +#[cfg(test)] +pub(crate) fn placeholder_linears() -> impl Iterator { + let pictures = STRUCTURES.iter().map(|g| button_picture(g.id).to_string()); + let templates = wordcraft_engine::math_gallery::templates().map(|t| t.linear.to_string()); + let symbols = SYMBOL_SETS.iter().flat_map(|(_, chars)| chars.chars()).map(|c| c.to_string()); + std::iter::once(EQUATION_PICTURE.to_string()) + .chain(pictures) + .chain(templates) + .chain(BUILT_INS.iter().map(|(_, _, linear)| (*linear).to_string())) + .chain(symbols) +} + /// A big ribbon button whose picture is an equation; opens `menu`. fn math_menu_button(ui: &mut Ui, app: &mut WordApp, picture: &str, label: &str, menu: impl FnOnce(&mut Ui, &mut WordApp)) { let label = tl!(label); @@ -267,6 +295,8 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) { }); } +/// Draws one equation-tab symbol button, paints its preview, shows the linear name and codepoint +/// in a tooltip, and returns whether it was clicked. fn symbol_button(ui: &mut Ui, app: &mut WordApp, c: char, t: &Tokens) -> bool { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); ui.painter().rect_filled(r, 2.0, t.input); @@ -285,7 +315,7 @@ fn symbol_button(ui: &mut Ui, app: &mut WordApp, c: char, t: &Tokens) -> bool { /// The Equation tab. pub fn show(app: &mut WordApp, ui: &mut Ui) { group(ui, "Tools", None, app, |ui, app| { - math_menu_button(ui, app, "∑_(i=1)^n", "Equation", builtin_menu); + math_menu_button(ui, app, EQUATION_PICTURE, "Equation", builtin_menu); ui.vertical(|ui| { ui.spacing_mut().item_spacing = vec2(1.0, 0.0); let latex = app.session.math_latex; @@ -403,6 +433,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { #[cfg(test)] mod placeholder_tests { + /// `∁` falls back to `\complement` in the placeholder while covered `x+y` is unchanged. #[test] fn a_missing_math_glyph_has_a_readable_linear_placeholder() { let ctx = egui::Context::default(); @@ -413,4 +444,13 @@ mod placeholder_tests { }); output.textures_delta.clear(); } + + #[test] + fn a_missing_math_glyph_falls_back_to_its_linear_name() { + // Without craft-fonts the interface fonts may have no `┬`: the Equation button's picture + // then reads `lim\below(n→∞)`. That is still math input, not interface wording. + let covered = |c: char| c.is_ascii() || matches!(c, '∞' | '→'); + assert_eq!(super::math_placeholder_with("lim┬(n→∞)", covered), "lim\\below(n→∞)"); + assert!(super::placeholder_linears().any(|linear| linear == "lim┬(n→∞)")); + } } diff --git a/crates/ui-egui/src/format_shape.rs b/crates/ui-egui/src/format_shape.rs index 2e90f8be8b..9fa67a1256 100644 --- a/crates/ui-egui/src/format_shape.rs +++ b/crates/ui-egui/src/format_shape.rs @@ -16,10 +16,12 @@ use wordcraft_layout::display::paint_for; use crate::WordApp; use crate::theme::Tokens; +/// Stable storage id for a fill drag's undo mark. fn mark_id() -> Id { Id::unique("format_shape.drag_mark") } +/// Stable storage id for the gradient stop being edited. fn stop_id() -> Id { Id::unique("format_shape.stop") } @@ -35,6 +37,7 @@ pub fn forget_drag(ctx: &egui::Context) { #[derive(Clone)] struct Variation(wordcraft_doc::Pos, Fill, Rgb); +/// Stable storage id for the gradient variation remembered for the last object. fn variation_id() -> Id { Id::unique("format_shape.variation") } @@ -126,6 +129,8 @@ pub fn pane(app: &mut WordApp, ui: &mut Ui) { } } +/// Draws a solid fill's colour field and transparency slider, applying each changed fill as a +/// whole. fn solid(app: &mut WordApp, ui: &mut Ui, theme: &[Rgb], color: Rgb, transparency: f32) { let mut c = color; if color_field(ui, theme, Id::unique("format_shape.solid_color"), &mut c) { @@ -211,6 +216,9 @@ fn kind_of(t: usize, focus: [f32; 4], angle: f32, scaled: bool) -> GradientKind } } +/// Draws the gradient preset gallery and type, direction and angle controls, the stop bar with +/// add and remove, and the selected stop's colour, position, transparency and rotate-with-shape +/// controls. fn gradient(app: &mut WordApp, ui: &mut Ui, theme: &[Rgb], g: &Gradient) { egui::Grid::new("format_shape.gradient").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { ui.label(tl!("Preset gradients")); diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 6c93bc0241..4b0eb0c684 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -115,15 +115,35 @@ fn english_words(text: &str) -> bool { /// Text that is right to show untranslated: what the author or the machine supplied, not the /// interface's own wording. -struct Exempt { +struct Exempt<'a> { /// The document's text (headings in the Navigation pane, the title in the title bar). body: String, /// Installed font families and the document's style names (pickers and editable fields). names: std::collections::HashSet, + /// The Equation tab's UnicodeMath placeholders (math input, not interface wording). + math: &'a std::collections::HashSet, } -impl Exempt { - fn new(app: &WordApp) -> Self { +/// The Equation tab's temporary UnicodeMath placeholders (while a preview loads). They are math +/// input, not interface wording; a character the interface fonts lack becomes its linear name +/// (`┬` → `\below`), so the exact strings are recognized rather than a pattern. +fn math_placeholders(ctx: &egui::Context) -> std::collections::HashSet { + let font = crate::theme::regular(11.0); + math_placeholders_with(|c| ctx.fonts_mut(|fonts| fonts.has_glyph(&font, c))) +} + +/// [`math_placeholders`] given which characters the interface fonts cover, so the exact-string +/// boundary can be tested without depending on the host's fonts. +fn math_placeholders_with(covered: impl Fn(char) -> bool) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + for linear in crate::equation_tab::placeholder_linears() { + out.insert(crate::equation_tab::math_placeholder_with(&linear, &covered)); + } + out +} + +impl<'a> Exempt<'a> { + fn new(app: &WordApp, math_placeholders: &'a std::collections::HashSet) -> Self { // Installed fonts, and the custom dictionary's words (File › Options › Proofing): the user's own. note_global_names(); let mut names: std::collections::HashSet = GLOBAL_NAMES.with_borrow(Clone::clone); @@ -152,9 +172,13 @@ impl Exempt { if let Some(sugg) = app.canvas.context_issue.as_ref().and_then(|i| i.get("suggestions")).and_then(Value::as_array) { names.extend(sugg.iter().filter_map(Value::as_str).map(str::to_string)); } - Exempt { body: app.session.doc.plain_text(wordcraft_doc::StoryRef::Body), names } + Exempt { body: app.session.doc.plain_text(wordcraft_doc::StoryRef::Body), names, math: math_placeholders } } + /// Whitelists text that is not interface wording: shortcuts and punctuation, status and Read + /// Aloud messages, theme font suffixes, icon lettering and account initials, math symbols and + /// equation placeholders, document body and known names, contributor handles, default style + /// names and fixed tokens. fn allows(&self, app: &WordApp, text: &str) -> bool { let plain = pseudo::unmarked(text); let plain = plain.trim().trim_end_matches(" —").trim(); @@ -178,6 +202,7 @@ impl Exempt { || is_shortcut() || self.body.contains(plain) || self.names.contains(plain) + || self.math.contains(plain) // the Equation tab's `lim\below(n→∞)`-style placeholders || plain.starts_with('@') // contributor handles || plain.starts_with("Style") || plain.starts_with("Table Style ") // a new style's default name || plain.contains('┬') || plain.contains('\u{2061}') // equation input (UnicodeMath), drawn as math @@ -187,6 +212,18 @@ impl Exempt { } } +#[test] +fn math_placeholders_are_exact_math_input_only() { + // CI's interface fonts lack `┬`, so the Equation tab paints the linear fallback. Only those + // exact placeholders are math input; the set is not a backslash or "contains ┬" pattern. + let set = math_placeholders_with(|c| c.is_ascii() || matches!(c, '∞' | '→')); + assert!(set.contains("lim\\below(n→∞)"), "the limit button's fallback"); + assert!(!set.contains("lim\u{252C}(n→∞)"), "the covered form is not what this font chain paints"); + assert!(set.contains("\\sum_(i=1)^n"), "the Equation button picture"); + assert!(set.contains("\\complement"), "a symbol tile's fallback"); + assert!(!set.contains("Translate"), "ordinary interface wording is not exempt"); +} + /// Fails on interface text drawn without going through the catalog, listed per UI state, and /// prints the missing-translation report (`report_catalog_gaps`). `cargo xtask i18n-report` runs it /// with `--nocapture`; `cargo xtask test` and `ci` run it that way instead of in the main test run. @@ -195,15 +232,16 @@ impl Exempt { #[test] fn untranslated_interface_text() { pseudo::set(true); + let ctx = egui::Context::default(); + let mut app = fresh_app(&ctx); + let math_placeholders = math_placeholders(&ctx); let mut found: BTreeMap> = BTreeMap::new(); let mut record = |app: &WordApp, state: &str, drawn: Vec| { - let exempt = Exempt::new(app); + let exempt = Exempt::new(app, &math_placeholders); for t in drawn.into_iter().filter(|t| english_words(t) && !exempt.allows(app, t)) { found.entry(t).or_default().push(state.to_string()); } }; - let ctx = egui::Context::default(); - let mut app = fresh_app(&ctx); let tabs: Vec<&str> = crate::ribbon::TABS.iter().copied().filter(|t| *t != "File").collect(); for tab in &tabs { let _ = app.run("ui.tab", json!({"tab": tab})); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 2b9326c25d..29bb93e12e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -900,7 +900,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { } "translate" | "language" => { pen.text(6.5, 7.0, 9.0, "A", c, true); - pen.text(13.5, 13.5, 9.0, "あ", a, false); + // あ drawn as strokes, like the rest of the icon set: the interface fonts need not + // carry Japanese (craft-fonts is optional), and a letter without a glyph drew a box. + pen.line_c(&[(11.6, 10.8), (15.4, 10.8)], a); + pen.line_c(&[(13.5, 8.8), (13.5, 13.9)], a); + pen.line_c(&[(11.0, 13.9), (16.2, 13.9)], a); + pen.circle(13.5, 16.1, 1.8, a); + pen.line_c(&[(15.0, 17.1), (15.8, 18.0)], a); } "deleteComment" => { pen.closed(&[(2.5, 3.5), (17.5, 3.5), (17.5, 13.5), (9.0, 13.5), (5.0, 17.0), (5.0, 13.5), (2.5, 13.5)], c); diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index a1b1ae3fff..a3bfd583b7 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -1030,10 +1030,14 @@ enum ImeProof { Certified, } +/// Wraps egui's unavailable-revision accessor for an IME mutation stamp. fn unavailable_revision(stamp: &egui::ImeMutationStamp) -> Option { stamp.unavailable_revision() } +/// Whether a pair of IME mutation stamps proves a transition: changed surrounding text, an +/// increase between unavailable revisions, or a mixed transition whose unavailable endpoint +/// carries a revision. fn stamp_transition_proven(before: &egui::ImeMutationStamp, after: &egui::ImeMutationStamp) -> bool { use egui::output::IMESurroundingText; match (before.surrounding_text(), after.surrounding_text()) { @@ -1049,6 +1053,8 @@ fn stamp_transition_proven(before: &egui::ImeMutationStamp, after: &egui::ImeMut } } +/// Classifies an IME outcome and stamp pair as no mutation, unproven mutation or certified. Only a +/// classified, untransformed content change with a proven stamp transition is certified. fn classify_ime_proof(outcome: ImeEditOutcome, before: &egui::ImeMutationStamp, after: &egui::ImeMutationStamp) -> ImeProof { if !outcome.classified { return if outcome.state_changed { ImeProof::UnprovenMutation } else { ImeProof::NoMutation }; @@ -1170,6 +1176,114 @@ fn restored_placeholder_end(s: &wordcraft_engine::Session, deleted_start: usize) (!placeholder.is_empty() && para.text.get(start..end) == Some(placeholder)).then_some(end) } +/// The exact state a refused IME delete-surrounding transaction restores. +struct ImeRollback { + snapshot: Option, + original: wordcraft_engine::Selection, + pending: Option, + goal_x: Option, +} + +impl ImeRollback { + fn apply(&mut self, app: &mut WordApp) { + let Some(snapshot) = self.snapshot.take() else { + return; + }; + app.session.restore(snapshot); + app.session.sel = self.original.clone(); + app.session.pending = self.pending.clone(); + app.session.goal_x = self.goal_x; + refuse_ime_request(app); + } +} + +/// Whether the edit is a classified content and state change that is not a transform. +fn ime_edit_satisfied(app: &mut WordApp) -> bool { + let outcome = app.session.ime_edit_outcome(); + outcome.classified && outcome.content_changed && outcome.state_changed && !outcome.transformed +} + +/// Delete one IME-surrounding range. Returns the paragraph's signed length change and the end of +/// a placeholder the deletion restored, or `None` after rolling back the whole transaction. +fn ime_delete_side( + app: &mut WordApp, + rollback: &mut ImeRollback, + cursor: &wordcraft_doc::Pos, + start: usize, + end: usize, + join_undo: bool, +) -> Option<(i64, Option)> { + if start >= end { + rollback.apply(app); + return None; + } + let Some(len_before) = app.session.doc.para_at(cursor).map(|p| p.len()) else { + rollback.apply(app); + return None; + }; + // A control already showing its placeholder cannot have received a new one from this + // deletion; its unchanged text must not be mistaken for a restored placeholder. + let showing_before = + app.session.doc.control_at(&wordcraft_doc::Pos { off: end, ..cursor.clone() }).is_some_and(|r| r.control.showing_placeholder); + app.session.sel = wordcraft_engine::Selection { + anchor: wordcraft_doc::Pos { off: start, ..cursor.clone() }, + focus: wordcraft_doc::Pos { off: end, ..cursor.clone() }, + }; + if join_undo { + app.session.join_next_undo(); + } + if app.run_ime("text.backspace", json!({})).is_err() || !ime_edit_satisfied(app) { + rollback.apply(app); + return None; + } + // A tracked delete of text this author inserted removes it from the stored paragraph; + // other tracked deletions only mark it. A content control emptied by the deletion gains + // its placeholder again, which can make the paragraph longer. Measure the signed change + // and require proof for growth: only a placeholder restored at or before the erased + // range can move the suffix right. + let Some(len_after) = app.session.doc.para_at(cursor).map(|p| p.len()) else { + rollback.apply(app); + return None; + }; + let Some(delta) = signed_len_delta(len_before, len_after) else { + rollback.apply(app); + return None; + }; + let restored = restored_placeholder_end(&app.session, start).filter(|_| !showing_before); + if delta > 0 && restored.is_none() { + // Growth that cannot be attributed to a placeholder insertion at/before the erased + // range has no provable suffix mapping: refuse the whole transaction. + rollback.apply(app); + return None; + } + // Keep the restored placeholder's end: for a caret it is the provable insertion point, + // also when a tracked deletion keeps the erased bytes in the stored paragraph. + Some((delta, restored)) +} + +/// The final caret/selection offsets after one or two surrounding deletions. A restored +/// placeholder end is the only provable position when one side inserted it; otherwise both +/// stored offsets move by the paragraph's signed length change. +fn ime_surrounding_offsets( + del_anchor: usize, + del_focus: usize, + shift: i64, + first_placeholder_end: Option, + second_placeholder_end: Option, +) -> Option<(usize, usize)> { + if let Some(end) = second_placeholder_end { + // A selection spanning the emptied control cannot be remapped through a placeholder + // insertion without guessing; only a caret has a provable position. + return (del_anchor == del_focus).then_some((end, end)); + } + if let (Some(end), true) = (first_placeholder_end, del_anchor == del_focus) { + // The first side restored the placeholder; its end is the caret's provable position even + // while an after-side deletion is applied beyond the control. + return Some((end, end)); + } + Some((shift_offset(del_anchor, shift)?, shift_offset(del_focus, shift)?)) +} + /// Both delete-surrounding paths: one visible-logical mapping for the query and the deletion. fn delete_surrounding_common(app: &mut WordApp, before: usize, after: usize, unit: wordcraft_doc::ime_text::DeleteUnit) { if before == 0 && after == 0 { @@ -1208,21 +1322,7 @@ fn delete_surrounding_common(app: &mut WordApp, before: usize, after: usize, uni // restored: `last_command`, macro recording, Go Back marks, `also_selected`, `typing`/ // `autocap` and canvas preedit (a refusal clears it). let snapshot = app.session.edit_snapshot(); - let rollback = |app: &mut WordApp, - snapshot: wordcraft_engine::EditSnapshot, - original: wordcraft_engine::Selection, - pending: Option, - goal_x: Option| { - app.session.restore(snapshot); - app.session.sel = original; - app.session.pending = pending; - app.session.goal_x = goal_x; - refuse_ime_request(app); - }; - let satisfied = |app: &mut WordApp| { - let outcome = app.session.ime_edit_outcome(); - outcome.classified && outcome.content_changed && outcome.state_changed && !outcome.transformed - }; + let mut rollback = ImeRollback { snapshot: Some(snapshot), original, pending: pending_before, goal_x: goal_x_before }; // Stored offsets of the suffix (and of the original selection) move by the paragraph's // signed length change. A placeholder can make that positive; subtracting a removed-byte // count cannot describe that, and would leave the second range and the caret inside the @@ -1231,67 +1331,21 @@ fn delete_surrounding_common(app: &mut WordApp, before: usize, after: usize, uni let mut deleted = false; let mut first_placeholder_end = None; if let Some(range) = del.before.clone() { - if range.start >= range.end { - rollback(app, snapshot, original, pending_before, goal_x_before); - return; - } - let Some(len_before) = app.session.doc.para_at(&cursor).map(|p| p.len()) else { - rollback(app, snapshot, original, pending_before, goal_x_before); + let Some((delta, restored)) = ime_delete_side(app, &mut rollback, &cursor, range.start, range.end, join) else { return; }; - // A control already showing its placeholder cannot have received a new one from this - // deletion; its unchanged text must not be mistaken for a restored placeholder. - let showing_before = - app.session.doc.control_at(&wordcraft_doc::Pos { off: range.end, ..cursor.clone() }).is_some_and(|r| r.control.showing_placeholder); - app.session.sel = wordcraft_engine::Selection { - anchor: wordcraft_doc::Pos { off: range.start, ..cursor.clone() }, - focus: wordcraft_doc::Pos { off: range.end, ..cursor.clone() }, - }; - if join { - app.session.join_next_undo(); - } - if app.run_ime("text.backspace", json!({})).is_err() || !satisfied(app) { - rollback(app, snapshot, original, pending_before, goal_x_before); - return; - } deleted = true; - // A tracked delete of text this author inserted removes it from the stored paragraph; - // other tracked deletions only mark it. A content control emptied by the deletion gains - // its placeholder again, which can make the paragraph longer. Measure the signed change - // and require proof for growth: only a placeholder restored at or before the erased - // range can move the suffix right. - let Some(len_after) = app.session.doc.para_at(&cursor).map(|p| p.len()) else { - rollback(app, snapshot, original, pending_before, goal_x_before); - return; - }; - let Some(delta) = signed_len_delta(len_before, len_after) else { - rollback(app, snapshot, original, pending_before, goal_x_before); - return; - }; - let restored = restored_placeholder_end(&app.session, range.start).filter(|_| !showing_before); - if delta > 0 && restored.is_none() { - // Growth that cannot be attributed to a placeholder insertion at/before the erased - // range has no provable suffix mapping: refuse the whole transaction. - rollback(app, snapshot, original, pending_before, goal_x_before); - return; - } - // Keep the restored placeholder's end: for a caret it is the provable insertion point, - // also when a tracked deletion keeps the erased bytes in the stored paragraph. first_placeholder_end = restored; shift = delta; } let mut second_placeholder_end = None; if let Some(range) = del.after { - let Some(len_before) = app.session.doc.para_at(&cursor).map(|p| p.len()) else { - rollback(app, snapshot, original, pending_before, goal_x_before); - return; - }; let Some(start) = shift_offset(range.start, shift) else { - rollback(app, snapshot, original, pending_before, goal_x_before); + rollback.apply(app); return; }; let Some(end) = shift_offset(range.end, shift) else { - rollback(app, snapshot, original, pending_before, goal_x_before); + rollback.apply(app); return; }; let in_bounds = app @@ -1300,72 +1354,27 @@ fn delete_surrounding_common(app: &mut WordApp, before: usize, after: usize, uni .para_at(&cursor) .is_some_and(|p| start < end && end <= p.len() && p.text.is_char_boundary(start) && p.text.is_char_boundary(end)); if !in_bounds { - rollback(app, snapshot, original, pending_before, goal_x_before); + rollback.apply(app); return; } // The mapped end is in the paragraph as the first side left it; a control already // showing its placeholder there is not one this side restores. - let showing_before = - app.session.doc.control_at(&wordcraft_doc::Pos { off: end, ..cursor.clone() }).is_some_and(|r| r.control.showing_placeholder); - app.session.sel = wordcraft_engine::Selection { - anchor: wordcraft_doc::Pos { off: start, ..cursor.clone() }, - focus: wordcraft_doc::Pos { off: end, ..cursor.clone() }, - }; - // A second erased side belongs to the same undo step as the first (and to the - // typing group when one is open), and so does the commit that follows. - if deleted || join { - app.session.join_next_undo(); - } - if app.run_ime("text.backspace", json!({})).is_err() || !satisfied(app) { - rollback(app, snapshot, original, pending_before, goal_x_before); + let Some((_, restored)) = ime_delete_side(app, &mut rollback, &cursor, start, end, deleted || join) else { return; - } + }; deleted = true; - // The after side can empty a different control and restore its placeholder too. That - // insertion changes the caret's position by its own length, not by the stored delta of - // the text beyond it, so map the caret to the restored placeholder's end explicitly. - let Some(len_after) = app.session.doc.para_at(&cursor).map(|p| p.len()) else { - rollback(app, snapshot, original, pending_before, goal_x_before); - return; - }; - let Some(delta) = signed_len_delta(len_before, len_after) else { - rollback(app, snapshot, original, pending_before, goal_x_before); - return; - }; - let restored = restored_placeholder_end(&app.session, start).filter(|_| !showing_before); - if delta > 0 && restored.is_none() { - // The after side grew the paragraph without a provable placeholder insertion: - // the final caret cannot be mapped safely. - rollback(app, snapshot, original, pending_before, goal_x_before); - return; - } second_placeholder_end = restored; } - let (anchor_off, focus_off) = if let Some(end) = second_placeholder_end { - // A selection spanning the emptied control cannot be remapped through a placeholder - // insertion without guessing; only a caret has a provable position. - if del.anchor != del.focus { - rollback(app, snapshot, original, pending_before, goal_x_before); - return; - } - (end, end) - } else if let (Some(end), true) = (first_placeholder_end, del.anchor == del.focus) { - // The first side restored the placeholder; its end is the caret's provable position even - // while an after-side deletion is applied beyond the control. - (end, end) - } else { - let (Some(anchor_off), Some(focus_off)) = (shift_offset(del.anchor, shift), shift_offset(del.focus, shift)) else { - rollback(app, snapshot, original, pending_before, goal_x_before); - return; - }; - (anchor_off, focus_off) + let Some((anchor_off, focus_off)) = ime_surrounding_offsets(del.anchor, del.focus, shift, first_placeholder_end, second_placeholder_end) else { + rollback.apply(app); + return; }; let valid = app.session.doc.para_at(&cursor).is_some_and(|p| { anchor_off <= p.len() && focus_off <= p.len() && p.text.is_char_boundary(anchor_off) && p.text.is_char_boundary(focus_off) }); if !valid { - rollback(app, snapshot, original, pending_before, goal_x_before); + rollback.apply(app); return; } app.session.sel = wordcraft_engine::Selection { @@ -1483,6 +1492,7 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { mod tests { use super::*; + /// Builds an IME edit outcome test fixture with `selection_changed` false. fn outcome(classified: bool, content: bool, state: bool, transformed: bool) -> ImeEditOutcome { ImeEditOutcome { classified, content_changed: content, state_changed: state, transformed, selection_changed: false } } @@ -2032,6 +2042,7 @@ mod tests { assert_eq!(body(&a), "риabм "); } + /// Builds an `ImeEvent::DeleteSurroundingBytes` event with the given before and after byte counts. fn surround_bytes(before_bytes: usize, after_bytes: usize) -> egui::Event { egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) } @@ -2349,12 +2360,15 @@ mod tests { assert_eq!(a.session.goal_x, Some(123.0), "the Up/Down goal column is restored"); } + /// Selects a body range, wraps it in a rich-text content control with `tag`, and sets its + /// placeholder. fn wrap_in_control(a: &mut WordApp, start: usize, end: usize, tag: &str, placeholder: &str) { a.session.sel = wordcraft_engine::Selection { anchor: wordcraft_doc::Pos::body(0, start), focus: wordcraft_doc::Pos::body(0, end) }; a.run("developer.richText", json!({"tag": tag})).unwrap(); a.run("developer.properties", json!({"control": tag, "placeholder": placeholder})).unwrap(); } + /// Finds the body content control with `tag`, panicking if it is absent. fn control(a: &WordApp, tag: &str) -> wordcraft_doc::ControlRange { a.session .doc diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index 1b07c0ec18..a874e21562 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -11,12 +11,15 @@ use crate::theme::{Tokens, semibold}; pub fn phase_id() -> egui::Id { egui::Id::unique("wc_keytips_phase") } +/// Stable id for the keytips' selected-tab state. pub fn tab_id() -> egui::Id { egui::Id::unique("wc_keytips_tab") } +/// Stable id for the keytips' letter badges. pub fn letters_id() -> egui::Id { egui::Id::unique("wc_keytips_letters") } +/// Stable id for the keytips' badge rectangles. pub fn rects_id() -> egui::Id { egui::Id::unique("wc_keytips_rects") } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 02aef90c21..f546dead9e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -711,6 +711,9 @@ impl SaveChoice { } impl WordApp { + /// Creates the app: adopts the system locale, then initializes the document session, UI state, + /// services, printing, canvas, dialogs, previews, control and screenshot state, font and theme + /// tracking. pub fn new(session: Session, services: Services) -> Self { // Numeral › System follows the system locale, found here rather than in layout. if let Some(tag) = sys_locale::get_locale() { @@ -2547,30 +2550,7 @@ impl WordApp { let opts = ctx.options(|o| o.input_options); ctx.input_mut(|i| i.smooth_scroll_delta = scroll::unsmoothed_scroll(i, &opts)); } - // Raw input is read in the known UI phase, first pass only. `logic` also runs hidden, - // where its input events are from the previous shown frame. Handle it before the font - // warm-up return: eframe does not preserve dropped files for a later frame, so a drop in - // the first two passes would otherwise disappear. - #[cfg(not(target_arch = "wasm32"))] - if ctx.current_pass_index() == 0 { - let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - if !dropped.is_empty() { - self.open_dropped_files(dropped); - let lang = i18n::Lang::from_pref(&self.ui.language); - self.install_ui_fonts(&ctx, lang); - } - } - // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as - // Word's pane does. This is an input action, so it runs in the first UI pass only; hidden - // `run_logic` ticks still see the previous shown frame's keys. - if ctx.current_pass_index() == 0 && self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { - let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); - let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); - if shift_tab || tab { - ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); - let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); - } - } + self.handle_ui_input_events(&ctx); // Fonts installed by `logic` take effect on the next frame. if self.fonts_frames < 2 { self.fonts_frames += 1; @@ -2587,6 +2567,42 @@ impl WordApp { backstage::forget_open_error(&ctx); backstage::forget_save_as_error(&ctx); } + self.paint_window(ui, &ctx, t); + self.show_overlays(ui, &ctx); + self.sync_title_and_frame(&ctx, t0); + self.flush_shot_and_synthetic(&ctx); + } + + /// Raw input read in the known UI phase, first pass only: dropped files and the SmartArt Text + /// Pane's Tab/Shift+Tab, before the font warm-up return. `logic` also runs hidden, where its + /// input events are from the previous shown frame. + fn handle_ui_input_events(&mut self, ctx: &egui::Context) { + // eframe does not preserve dropped files for a later frame, so a drop in the first two + // passes would otherwise disappear. + #[cfg(not(target_arch = "wasm32"))] + if ctx.current_pass_index() == 0 { + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + if !dropped.is_empty() { + self.open_dropped_files(dropped); + let lang = i18n::Lang::from_pref(&self.ui.language); + self.install_ui_fonts(ctx, lang); + } + } + // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as + // Word's pane does. This is an input action, so it runs in the first UI pass only; hidden + // `run_logic` ticks still see the previous shown frame's keys. + if ctx.current_pass_index() == 0 && self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { + let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); + let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); + if shift_tab || tab { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); + } + } + } + + /// Draw the File page, the empty-window chrome, or the normal document window. + fn paint_window(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, t: theme::Tokens) { if self.ui.backstage { backstage::show(self, ui); } else if !self.has_document() { @@ -2612,22 +2628,30 @@ impl WordApp { canvas::show(self, ui); }); if self.focus_ribbon { - chrome::focus_ribbon(self, &ctx); + chrome::focus_ribbon(self, ctx); } } - content_controls::popup(self, &ctx); - dialogs::show(self, &ctx); + } + + /// Dialogs, panes and other overlays drawn over the window. + fn show_overlays(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + content_controls::popup(self, ctx); + dialogs::show(self, ctx); #[cfg(not(target_arch = "wasm32"))] - file_browser::show(self, &ctx); - colors::show(self, &ctx); - zotero::show_alert(self, &ctx); - read_aloud::show(self, &ctx); - crate::keytips::show(self, &ctx, ui); - keys::global_shortcuts(self, &ctx); - self.refocus_after_chrome_click(&ctx); + file_browser::show(self, ctx); + colors::show(self, ctx); + zotero::show_alert(self, ctx); + read_aloud::show(self, ctx); + crate::keytips::show(self, ctx, ui); + keys::global_shortcuts(self, ctx); + self.refocus_after_chrome_click(ctx); if let Some(url) = self.canvas.open_url.take() { ctx.open_url(egui::OpenUrl::new_tab(url)); } + } + + /// Keep the window title and full-screen state in sync, then record this frame's time. + fn sync_title_and_frame(&mut self, ctx: &egui::Context, t0: f64) { let title = if self.has_document() { format!("{}{} - WordCraft", self.title_stem(), if self.session.dirty { " •" } else { "" }) } else { @@ -2637,15 +2661,18 @@ impl WordApp { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); self.sent_title = title; } - self.sync_full_screen(&ctx); + self.sync_full_screen(ctx); self.frame_ms = now_ms() - t0; // Typing and formatting land here, after `logic` has reported. self.report_dirty(); - // One callback command per admitted request. This is in `ui` so the captured frame is - // the layout just drawn; a discarded pass is not a layout anyone will see. The shot's - // `issued` flag makes repeated final passes harmless. + } + + /// One callback command per admitted request, and the synthetic-input queue's final pass. + fn flush_shot_and_synthetic(&mut self, ctx: &egui::Context) { + // This is in `ui` so the captured frame is the layout just drawn; a discarded pass is not + // a layout anyone will see. The shot's `issued` flag makes repeated final passes harmless. if !ctx.will_discard() { - control::issue_screenshots(self, &ctx); + control::issue_screenshots(self, ctx); } // The raw input was consumed by this host frame's passes; only a final UI pass proves // the step is done. A discarded pass keeps the queue for the next UI frame. @@ -2784,6 +2811,8 @@ impl WordApp { } } + /// Drains queued control requests, dispatches each through `control::handle`, sends its reply or + /// admits a screenshot, then puts the receiver back. fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { @@ -3490,6 +3519,9 @@ mod tests { WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) } + /// All title-bar inputs have accessible names. Clicking the title opens Rename Document, the + /// Rename button saves a new name, Escape cancels, a long name stays clickable, and rename + /// leaves the document clean. #[test] fn title_and_account_buttons_work() { use egui_kittest::kittest::Queryable; @@ -5091,6 +5123,7 @@ mod tests { assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Part(footer)), "Foot"); } + /// Runs one 1280 by 800 frame through the raw input hook, logic and UI, dropping texture deltas. fn control_frame(a: &mut WordApp, ctx: &egui::Context, focused: bool) { let mut raw = egui::RawInput { focused, @@ -5146,6 +5179,9 @@ mod tests { } } + /// Synthetic control text and keys target the focused background widget (the Find field), and + /// Escape closes it. Closing a dirty document offers Save Changes, and Escape cancels without + /// losing the document. #[test] fn control_keyboard_keeps_the_background_widget_target() { let ctx = egui::Context::default(); @@ -5184,6 +5220,8 @@ mod tests { assert_eq!(body_text(&a), "Seed", "Cancel preserves the unsaved document"); } + /// Control input respects read-only protection, and a frame without control keys leaves the native + /// focus state alone. #[test] fn control_keyboard_preserves_native_focus_and_document_protection() { let ctx = egui::Context::default(); @@ -9313,6 +9351,7 @@ mod tests { assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("find")); } + /// Builds a root-viewport raw input at `size`, optionally marked minimized and occluded. fn shot_raw(size: egui::Vec2, hidden: bool) -> egui::RawInput { let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, size); let mut raw = egui::RawInput { screen_rect: Some(rect), max_texture_side: Some(16_384), ..Default::default() }; @@ -9329,6 +9368,7 @@ mod tests { raw } + /// Runs one screenshot frame through the raw hook, logic and UI, and clears its texture deltas. fn shot_frame(app: &mut WordApp, ctx: &egui::Context, size: egui::Vec2) -> egui::FullOutput { let mut raw = shot_raw(size, false); app.raw_input_hook(ctx, &mut raw); @@ -9341,6 +9381,8 @@ mod tests { output } + /// Replaces each root screenshot command in the output with `ViewportCommand::Focus` and returns + /// its callback, so the test frame runs no real screenshot. fn take_shot_callbacks(output: &mut egui::FullOutput) -> Vec { let mut callbacks = Vec::new(); if let Some(viewport) = output.viewport_output.get_mut(&egui::ViewportId::ROOT) { @@ -9356,6 +9398,7 @@ mod tests { callbacks } + /// Creates an app with text, warms four screenshot frames, and returns it with a fresh context. fn screenshot_app(size: egui::Vec2) -> (WordApp, egui::Context) { let mut app = app(); app.run("document.setText", json!({"text": "Screenshot"})).unwrap(); @@ -9366,12 +9409,15 @@ mod tests { (app, ctx) } + /// Admits a screenshot through `control::admit_screenshot` with a fresh reply channel and returns + /// the receiver. fn admit(app: &mut WordApp, ctx: &egui::Context, path: Option) -> std::sync::mpsc::Receiver { let (reply, replies) = std::sync::mpsc::channel(); control::admit_screenshot(app, ctx, path, reply); replies } + /// Forces queued screenshot requests ready and runs one frame to collect their callbacks. fn issue_ready(app: &mut WordApp, ctx: &egui::Context, size: egui::Vec2) -> Vec { for shot in &mut app.shots.requests { shot.ready_at = 0.0; @@ -9398,9 +9444,11 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] impl egui::DroppedFile for DroppedText { + /// Exposes the dropped file's path. fn path(&self) -> &std::path::Path { &self.0 } + /// Reads the dropped file's bytes, stringifying I/O errors. fn bytes(&self) -> Result, String> { std::fs::read(&self.0).map_err(|e| e.to_string()) } @@ -9551,6 +9599,7 @@ mod tests { assert_eq!(replies.recv().unwrap()["ok"], false); } + /// A geometry change and change back still invalidates issued pixels and retries the request. #[test] fn screenshot_epoch_observes_a_raw_change_and_change_back() { let (mut app, ctx) = screenshot_app(egui::vec2(640.0, 480.0)); diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index a1e332fa07..d659329ea3 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -206,6 +206,8 @@ fn flag(app: &mut WordApp, ui: &mut Ui, label: &str, key: &str, value: bool) { } } +/// Draws General options: interface theme and size, mini toolbar, dark page, community button and +/// default apps; the Start screen; and personalization such as user name and initials. fn general(app: &mut WordApp, ui: &mut Ui) { section(ui, "Interface"); theme_picker(app, ui); @@ -482,6 +484,7 @@ fn language(app: &mut WordApp, ui: &mut Ui) { ui.label(RichText::new(text).small().weak()); } +/// Draws Accessibility options: the animation setting and Read Aloud speed and skip-citations. fn accessibility(app: &mut WordApp, ui: &mut Ui) { use wordcraft_engine::speech::{MAX_RATE, MIN_RATE}; section(ui, "Visual effects"); @@ -503,6 +506,8 @@ fn accessibility(app: &mut WordApp, ui: &mut Ui) { } } +/// Draws Advanced options: typing and overtype behavior, alignment guides, recent-document count, +/// measurement units, region, keytips, caret animation, file locations and customization. fn advanced(app: &mut WordApp, ui: &mut Ui) { section(ui, "Editing"); let pr = app.session.prefs.clone(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 9ed9217270..e4b34a8784 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -117,6 +117,7 @@ pub(crate) fn header(ui: &mut Ui, title: &str) -> bool { close } +/// Draws the Navigation pane: search box and tabs, page list, find results and heading outline. fn nav(app: &mut WordApp, ui: &mut Ui) { if header(ui, "Navigation") { let _ = app.run("view.navigationPane", json!({"value": false})); @@ -431,6 +432,8 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { }); } +/// Draws the Comments pane: New Comment and scrollable comment and reply cards with edit, go-to, +/// reply, resolve or reopen, and delete actions. fn comments(app: &mut WordApp, ui: &mut Ui) { if header(ui, "Comments") { let _ = app.run("view.commentsPane", json!({"value": false})); diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 75e60121ae..b8e73a012f 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -783,6 +783,8 @@ mod tests { assert!(dim.r() < 0xE0 && dim.r() > 0xA0, "{dim:?}"); } + /// Font-menu previews defer over budget, render on a later frame, and reuse cached entries + /// without consuming budget or deadlocking. #[test] fn font_menu_previews_render_defer_and_reuse_without_deadlocking() { // A nested Context lock used to hang the first uncached menu entry. Bound the wait so diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs index f467100f97..590f3909e4 100644 --- a/crates/ui-egui/src/printing.rs +++ b/crates/ui-egui/src/printing.rs @@ -19,6 +19,8 @@ pub fn commands(app: &crate::WordApp) -> Vec { ] } +/// Builds translated print labels, draws the shared print-settings panel, and shows the current +/// feedback message. pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) { let labels = Labels { printer: &translate("Printer"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 51b69514a8..f0feb3204b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -260,6 +260,8 @@ pub fn in_table_public(app: &WordApp) -> bool { app.session.sel.focus.path.cell().is_some() } +/// Draws the Read toolbar or the full ribbon, switching to and from the contextual Equation and +/// Outlining tabs and showing the available tab strip. pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); if app.session.view.read_mode { @@ -2279,6 +2281,8 @@ fn developer(app: &mut WordApp, ui: &mut Ui) { }); } +/// Draws the View tab's Views, Immersive, Page Movement, Show, Zoom, Dark Mode, Window and Macros +/// and Templates groups. fn view(app: &mut WordApp, ui: &mut Ui) { let v = app.session.view.clone(); group(ui, "Views", None, app, |ui, app| { @@ -2760,6 +2764,8 @@ fn shape_effects_menu(ui: &mut Ui, app: &mut WordApp, theme: &[wordcraft_doc::Rg }); } +/// Draws the Picture Format tab's Adjust, Picture Styles, Accessibility (Alt Text), Arrange and +/// Size groups. fn picture_format(app: &mut WordApp, ui: &mut Ui) { group(ui, "Adjust", None, app, |ui, app| { menu_button(ui, app, "picture", Some("Corrections"), "Brightness, contrast and sharpness", true, "picture.corrections", |ui, app| { @@ -3224,7 +3230,12 @@ mod tests { use wordcraft_doc::props::Underline; let mut a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Lanterns")), crate::Services::default()); a.run("select.all", json!({})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 700.0)).build_ui_state(|ui, app: &mut WordApp| underline_menu(ui, app), a); + // The app's real interface fonts carry the macOS shortcut symbols (⌘⇧⌥⌃); egui's + // bare test defaults do not. + let mut h = egui_kittest::Harness::builder() + .with_size(vec2(400.0, 700.0)) + .with_context_setup(|ctx| ctx.set_fonts(crate::theme::font_definitions(false, false))) + .build_ui_state(|ui, app: &mut WordApp| underline_menu(ui, app), a); h.run_steps(2); let run = |h: &egui_kittest::Harness<'_, WordApp>| h.state().session.doc.para_at(&wordcraft_doc::Pos::body(0, 0)).unwrap().runs[0].props.clone(); diff --git a/crates/ui-egui/src/ribbon_fit.rs b/crates/ui-egui/src/ribbon_fit.rs index c904b0c8fc..40a25d783e 100644 --- a/crates/ui-egui/src/ribbon_fit.rs +++ b/crates/ui-egui/src/ribbon_fit.rs @@ -92,18 +92,23 @@ struct Measured { order: HashMap>, } +/// Stable storage id for the ribbon fit plan of the current frame. fn frame_id() -> Id { Id::unique("ribbon_fit_frame") } +/// Stable storage id for widths measured from earlier ribbon frames. fn measured_id() -> Id { Id::unique("ribbon_fit_measured") } +/// Stable storage id for the ribbon's compact-layout choices. fn compact_id() -> Id { Id::unique("ribbon_fit_compact") } +/// Stable storage id for the points a group would save by drawing compact. fn saving_id() -> Id { Id::unique("ribbon_fit_saving") } +/// Stable storage id for whether this frame's ribbon fit plan is active. fn active_id() -> Id { Id::unique("ribbon_fit_active") } diff --git a/crates/ui-egui/src/scroll.rs b/crates/ui-egui/src/scroll.rs index 3e877e8583..84e8280092 100644 --- a/crates/ui-egui/src/scroll.rs +++ b/crates/ui-egui/src/scroll.rs @@ -416,6 +416,8 @@ mod tests { assert!(!m.is_active()); } + /// Builds a move-phase mouse-wheel event from an unknown source with the given unit, delta and + /// modifiers. fn wheel(unit: MouseWheelUnit, delta: Vec2, modifiers: Modifiers) -> Event { Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers, source: MouseWheelSource::Unknown } } diff --git a/crates/ui-egui/src/tests_perf.rs b/crates/ui-egui/src/tests_perf.rs index ff857474de..aa95e0ac12 100644 --- a/crates/ui-egui/src/tests_perf.rs +++ b/crates/ui-egui/src/tests_perf.rs @@ -34,6 +34,7 @@ fn harness(doc: Document) -> egui_kittest::Harness<'static, WordApp> { ) } +/// Queues a pointer move to (720, 500) and a vertical point-unit wheel event. fn wheel(h: &mut egui_kittest::Harness<'static, WordApp>, dy: f32) { h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(720.0, 500.0))); h.input_mut().events.push(egui::Event::MouseWheel { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index de0cbbe55d..3a068c8346 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -829,6 +829,9 @@ struct ComboSearch { view_h: f32, } +/// Draws an editable or searchable combo box: keeps temporary text and search state, supports +/// keyboard selection, popup rows with recent and preview sections, an arrow and hint, and +/// returns the chosen or committed text. fn combo_impl( ui: &mut Ui, id: &str, @@ -1247,6 +1250,8 @@ fn icon_text_button_size(ui: &Ui, text_w: f32, marker: bool, show_text: bool) -> pad.x + 16.0 + gap + text_w + marker_w + pad.x } +/// Allocates and paints a themed icon button with optional text, marker and accessible label, and +/// returns its response. fn icon_text_button_with(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId, marker: bool, show_text: bool) -> Response { let t = Tokens::get(ui.ctx()); let galley = ui.painter().layout_no_wrap(text.to_string(), font, Color32::PLACEHOLDER); diff --git a/crates/ui-egui/src/window_geometry.rs b/crates/ui-egui/src/window_geometry.rs index 5601eac941..1b456beb09 100644 --- a/crates/ui-egui/src/window_geometry.rs +++ b/crates/ui-egui/src/window_geometry.rs @@ -211,6 +211,8 @@ pub fn requested_size(size: egui::Vec2, max_side: f32) -> Option { mod tests { use super::*; + /// Builds a raw window-state fixture whose inner, outer and screen rectangles match and whose + /// monitor is `monitor`. fn raw(rect: egui::Rect, monitor: egui::Vec2) -> RawWindowState { RawWindowState { inner_rect: Some(rect), outer_rect: Some(rect), screen_rect: Some(rect), monitor_size: Some(monitor), ..Default::default() } } @@ -219,6 +221,8 @@ mod tests { WindowGeometry { position: Some([100.0, 50.0]), width: 1200.0, height: 800.0, maximized: false, monitor: Some([1920.0, 1080.0]) } } + /// Tracking a normal rectangle saves its position, size and monitor, and applies them to a + /// viewport builder. #[test] fn records_the_window_and_restores_it() { let rect = egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(1200.0, 800.0)); @@ -247,6 +251,9 @@ mod tests { assert_eq!(saved().maximize_after_open(), None, "a normal window needs no command"); } + /// On macOS a zoomed frame is saved as its zoomed frame rectangle without the maximized flag; + /// elsewhere the previous geometry is kept with the maximized state. Minimized and full-screen + /// frames change nothing. #[test] fn maximized_keeps_the_normal_size_and_position() { let full = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1920.0, 1040.0)); @@ -268,6 +275,8 @@ mod tests { assert_eq!(WindowGeometry::track(Some(saved()), &i), Some(saved())); } + /// With no position reported, the raw screen rectangle still supplies the size, and the restored + /// builder has no position. #[test] fn without_a_position_the_raw_screen_size_is_kept() { // Wayland reports no inner/outer rect, but RawInput still carries the content screen rect. @@ -297,6 +306,8 @@ mod tests { assert_eq!((g.position, g.width, g.height, g.monitor), (None, 1000.0, 700.0, Some([1920.0, 1080.0]))); } + /// Only the current frame's root viewport contributes inner, outer, content and monitor sizes; a + /// missing viewport or non-finite data leaves defaults. #[test] fn raw_window_state_reads_only_this_frames_viewport() { let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0)); @@ -339,6 +350,8 @@ mod tests { assert_eq!(saved().sanitized(), Some(saved())); } + /// A saved window on a different monitor is centred on it, or clamped to its origin when too + /// large; the same monitor, missing data, no saved position or a maximized window does nothing. #[test] fn a_window_whose_monitor_is_gone_is_moved_on_screen() { let on = egui::Rect::from_min_size(egui::pos2(2000.0, 50.0), egui::vec2(1200.0, 800.0)); @@ -355,6 +368,8 @@ mod tests { assert_eq!(saved().rescue_position(&RawWindowState::default()), None); } + /// The rescue waits for a known monitor size, then runs once (moving when needed) and clears the + /// pending geometry. #[test] fn the_rescue_waits_for_the_monitor_size() { let on = egui::Rect::from_min_size(egui::pos2(2000.0, 50.0), egui::vec2(1200.0, 800.0)); @@ -416,6 +431,9 @@ mod tests { assert_eq!(requested_size(egui::vec2(1200.0, 800.0), 0.0), None); } + /// An oversized default window shrinks and is centred to fit, while a restored window keeps its + /// place; maximized, full-screen, minimized and unknown-monitor frames, and frames without + /// rectangles, are left alone. #[test] fn a_window_too_big_for_its_screen_shrinks_to_fit() { let system = WindowGeometry::default(); diff --git a/crates/ui-egui/tests/it/native_print.rs b/crates/ui-egui/tests/it/native_print.rs index c74e6e2352..6ff294c2b8 100644 --- a/crates/ui-egui/tests/it/native_print.rs +++ b/crates/ui-egui/tests/it/native_print.rs @@ -5,6 +5,9 @@ use wordcraft_engine::Session; use wordcraft_ui_egui::printing::{Duplex, Feedback, Printer}; use wordcraft_ui_egui::{Services, WordApp}; +/// Print command metadata lists engine and app commands, exposes the settings and status fields, +/// and keeps submission disabled until a printer is selected. It never discovers printers, +/// submits, opens a dialog or Print page, or dirties the document. #[test] fn control_print_discovery_reports_metadata_without_host_side_effects() { use std::cell::Cell; @@ -55,6 +58,8 @@ fn control_print_discovery_reports_metadata_without_host_side_effects() { assert!(!app.printing.busy() && !app.session.dirty); } +/// Submission passes the current grayscale setting and collates by default; a spool failure +/// becomes Failed feedback and a status message. #[test] fn native_print_uses_selected_settings_and_reports_spool_errors() { let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); @@ -89,6 +94,8 @@ fn native_print_uses_selected_settings_and_reports_spool_errors() { assert!(app.status_msg.as_ref().unwrap().0.contains("printer is offline")); } +/// At 800 by 700 the print page keeps Refresh printers and Grayscale enabled; toggling Grayscale +/// updates the settings and readiness. #[test] fn print_settings_are_visible_in_a_narrow_window() { use egui_kittest::kittest::{NodeT, Queryable}; @@ -124,6 +131,7 @@ fn print_settings_are_visible_in_a_narrow_window() { assert!(harness.state().printing.ready()); } +/// Every invalid settings payload is rejected and leaves the previous settings unchanged. #[test] fn invalid_print_settings_are_rejected_without_partial_changes() { let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); @@ -147,6 +155,7 @@ fn invalid_print_settings_are_rejected_without_partial_changes() { } } +/// Two fast jobs each surface the same receipt, and consuming it clears the status. #[test] fn repeated_fast_jobs_each_surface_an_identical_receipt() { let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); @@ -170,6 +179,8 @@ fn repeated_fast_jobs_each_surface_an_identical_receipt() { } } +/// Invalid range, parity, copy and collate values send nothing; odd pages from 1-3 appear once in +/// one PDF, while three uncollated copies are delegated to the spooler. #[test] fn native_ranges_and_parity_do_not_duplicate_spooler_copies() { // A fixed creation date: without one each PDF is stamped with the current time, and the two @@ -210,6 +221,8 @@ fn native_ranges_and_parity_do_not_duplicate_spooler_copies() { assert!(!jobs[0].2, "uncollated copies reach the spooler"); } +/// Default or auto orientation sends `orientation-requested=3`, landscape sends 4 plus +/// `landscape=true`, and portrait sends 3. #[test] fn native_print_passes_orientation_options_to_spooler() { let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); From 14f0eec255ff555255b785dfab8866f9b8bc9512 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:27:35 +0100 Subject: [PATCH 17/50] Stretch justified lines that end in a manual line break (#3924) (#4023) --- ROADMAP.md | 4 +++- crates/doc/src/lib.rs | 5 +++++ crates/docx/src/read/mod.rs | 1 + crates/docx/src/write/mod.rs | 3 +++ crates/docx/tests/it/fixtures.rs | 16 +++++++++++++++ crates/layout/src/para.rs | 6 +++++- crates/layout/src/tests.rs | 35 ++++++++++++++++++++++++++++++++ docs/layout-parity.md | 5 +++-- 8 files changed, 71 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2d8ffb1762..7ae409ce8b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -154,6 +154,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-24: AutoFormat: a paragraph numbered through its style (a heading in a heading-linked Multilevel List) keeps `---` and `+---+` as text on Enter, as a paragraph numbered directly does. At the top of the document the border-line AutoFormat removed the characters and switched off the heading's number, and left no border. - 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982). - 2026-10-24: Spelling: with Ignore web and file addresses on, a web or e-mail address after a no-break space or an ideographic space is skipped, as it is after a plain space. Its words were marked as misspelled. +- 2026-10-23: Justified paragraphs: a line ending in a manual line break (Shift+Enter) is stretched to the right margin like a wrapped line, as Word does; it stayed short like the paragraph's last line. A document with Word's `w:doNotExpandShiftReturn` compatibility option keeps such lines short, and the option is read and written back (#3924). - 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663). - 2026-10-24: AutoFormat As You Type has "Set left- and first-indent with tabs and backspaces" (off by default): Tab at the start of a paragraph with text indents its first line, Tab at the start of a later line indents the whole paragraph, and Backspace at the start takes the indents back. - 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637). @@ -974,5 +975,6 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-17 | trivial | Progress log: saving into a folder that refuses a temporary file (#2138) | | 2026-10-19 | trivial | Progress log: personal templates in File › New, Save as Word Template (#2852) | | 2026-10-20 | trivial | Progress log: saving to a NAS share mounted by GVFS (#3003) | +| 2026-10-23 | trivial | Progress log: justified lines ending in a manual line break reach the margin (#3924) | | 2026-10-23 | trivial | Progress log: "NaN" in the Font dialog's Size box no longer closes the app (#3663) | | 2026-10-23 | trivial | Progress log: a table cell span from a `.json` file no longer overflows (#3637) | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d4fb9d42b0..c4275d5d9c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -639,6 +639,10 @@ pub struct Settings { /// after plus the second's space before, not the larger of the two. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub sum_paragraph_spacing: bool, + /// `w:doNotExpandShiftReturn`: a justified line that ends in a manual line break keeps its + /// natural spacing; by default Word stretches it to the margin like a wrapped line. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub do_not_expand_shift_return: bool, /// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`). /// Word ignores the layout override in compatibility mode 15, but the flag still round-trips. pub allow_space_of_same_style_in_table: bool, @@ -805,6 +809,7 @@ impl Default for Settings { compat_mode: COMPAT_MODE_CURRENT, roman_fonts: Vec::new(), sum_paragraph_spacing: false, + do_not_expand_shift_return: false, allow_space_of_same_style_in_table: false, grid_h: DEFAULT_GRID, grid_v: DEFAULT_GRID, diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 8eda14677a..414993b97c 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -1212,6 +1212,7 @@ impl Reader<'_> { s.compat_mode = m.clamp(11, 99); } s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off); + s.do_not_expand_shift_return = k.children("w:doNotExpandShiftReturn").any(on_off); } "w:trackRevisions" => s.track_changes = on_off(k), "w:doNotTrackMoves" => s.track_moves = !on_off(k), diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 1b6a61587c..b6a66186ad 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -1083,6 +1083,9 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O } w.open("w:compat", &[]); // CT_Compat: the older switches come before the `w:compatSetting`s. + if doc.settings.do_not_expand_shift_return { + w.empty("w:doNotExpandShiftReturn", &[]); + } if doc.settings.sum_paragraph_spacing { w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]); } diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index bf87ab9b71..f55e7be4bd 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -3572,6 +3572,22 @@ fn html_auto_spacing_off_is_read_and_kept() { assert_eq!(settings(""), (false, false)); } +/// `w:doNotExpandShiftReturn` in the settings' `w:compat`: justified lines ending in a manual line +/// break keep their spacing (#3924). +#[test] +fn do_not_expand_shift_return_is_read_and_kept() { + let settings = |compat: &str| { + let s = format!(r#"{compat}"#); + let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml")]; + let d = wordcraft_docx::read(&docx("", &rels, &[("word/settings.xml", &s)])).unwrap(); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + (d.settings.do_not_expand_shift_return, again.settings.do_not_expand_shift_return) + }; + assert_eq!(settings(""), (true, true)); + assert_eq!(settings(r#""#), (false, false)); + assert_eq!(settings(""), (false, false)); +} + /// A right triangle (`rtTriangle`) has its right angle at the bottom left: a cover page's white /// corner cut-outs drawn as isosceles triangles pointed the wrong way. #[test] diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index d2c62fc953..889488ccbb 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -2062,6 +2062,8 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio // Word 2013+ (compatibility mode 15) fits more text on a justified line by shrinking its // spaces (`space_room`); never on lines with tabs. let shrink_spaces = rp.align == Align::Justify && env.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT; + // Word stretches a justified line ending in a manual line break unless the document says not to. + let expand_break = !env.doc.settings.do_not_expand_shift_return; let hanging_at = if rp.indent_first < 0.0 { Some(rp.indent_left) } else { None }; // Hyphenation Options: the zone, and how many lines in a row may end with a hyphen. let zone = Some(env.doc.settings.hyphenation_zone).filter(|z| z.is_finite() && *z >= 0.0).unwrap_or(HYPHENATION_ZONE).min(1584.0); @@ -2583,7 +2585,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio *label_shift = shift; } } - let justify = display.is_none() && ((rp.align == Align::Justify && end == LineEnd::Wrap) || rp.align == Align::Distribute); + // A line ending in a manual line break is stretched too, unless `doNotExpandShiftReturn` says not to. + let stretch_end = end == LineEnd::Wrap || (end == LineEnd::LineBreak && expand_break); + let justify = display.is_none() && ((rp.align == Align::Justify && stretch_end) || rp.align == Align::Distribute); // A line that only fits with shrunk spaces is shrunk, the last line of the paragraph too. let shrink = display.is_none() && shrink_spaces && slack < 0.0 && last_tab.is_none(); if shrink { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index b697eaf946..cd3657f273 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4887,6 +4887,41 @@ fn space_shrinking_is_only_for_justified_text() { } } +/// #3924: a justified line that ends in a manual line break is stretched to the margin like a +/// wrapped one; the last line of the paragraph stays short. `w:doNotExpandShiftReturn` keeps it short. +#[test] +fn justified_line_ending_in_line_break_is_stretched() { + let text = "We tie the spend\nto a pipe"; + let ends = |no_expand: bool| { + let mut d = Document::from_text(""); + d.settings.do_not_expand_shift_return = no_expand; + let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()); + p.props = ParaProps { align: Some(Align::Justify), ..Default::default() }; + d.body = vec![wordcraft_doc::para_block(p)]; + let l = lay(&d); + let mut out = Vec::new(); + for pg in &l.pages { + for it in &pg.items { + if let Placed::Lines { para, l0, l1, x, .. } = it { + for li in *l0..*l1 { + let line = ¶.lines[li]; + let content = text[line.start..line.stop].trim_end().len(); + out.push(para.x_of(li, line.start + content).unwrap() + x); + } + } + } + } + out + }; + let stretched = ends(false); + assert_eq!(stretched.len(), 2, "{stretched:?}"); + assert!((stretched[0] - 540.0).abs() < 0.05, "the line before the break ends at {}", stretched[0]); + assert!(stretched[1] < 400.0, "the last line stays short: {}", stretched[1]); + let kept = ends(true); + assert_eq!(kept.len(), 2, "{kept:?}"); + assert!(kept[0] < 400.0, "doNotExpandShiftReturn keeps the line before the break short: {}", kept[0]); +} + #[test] fn lines_with_tabs_never_shrink() { let text = "Item\tWe tie the spend to a pipe and to the books, so it is in an ad hoc view of all of it. ".repeat(30); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index c443e7ca7d..e2eb61634f 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -84,7 +84,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Hidden text excluded from breaking and hyphenation | ✅ | #173 | — | | Empty paragraph with a hidden mark (`w:pPr/w:rPr/w:vanish`) | ✅ | Synthetic DOCX observed in Word for Windows 16.0.20527: no room, no list number, and the number is still counted (`1.`, hidden item, `3.`), in the body and in table cells. `layout::tests::empty_list_paragraph_with_a_hidden_mark_counts_but_shows_nothing`, #3982. A paragraph with text and a hidden mark shown together with the next one (Word) is #3986 | - | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages | — | -| Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. Most other legacy compat options remain ignored | 10–20 | +| Compatibility modes (Word 2003/2007/2010 layout for old files: `w:compat` options) | 🟡 | `allowSpaceOfSameStyleInTable` supported in modes 11/12/14; mode 15 retains contextual suppression. `doNotExpandShiftReturn` keeps justified lines that end in a manual line break short (#3924). Most other legacy compat options remain ignored | 10–20 | | Fields: STYLEREF in headers and footers (running chapter titles) | ✅ | Evaluated per page at layout time: the page's first (or with `\l` last) paragraph of the style, else the nearest before it; style name, id or heading level (a level finds the style named `heading N` whatever its id, so localized documents match, #1286); character styles; `\n \r \w \s \t \p` (`\r` treated as `\w`); body STYLEREF updated with the other fields (#775). Field results with right-to-left text (a Hebrew heading, "פרק א") are laid out with bidi reordering like ordinary text: the result is an isolate taking its first strong letter's direction (else the text's around the field), and each level run is shaped and mirrored in its own direction, so `{ PAGE }` in field-code view and `(Chapter 1)` keep their brackets in right-to-left paragraphs; this covers every field and note mark shown as text (#1670). Header paragraphs are cached on their per-page results, so typing in body text no STYLEREF shows doesn't lay the headers out again (#1286) | — | | Fields: PAGE, NUMPAGES, SECTIONPAGES, TOC page numbers, PAGEREF in headers and footers | ✅ | #52; totals in the body need a second pass once the page count is known (#451); SECTIONPAGES includes shared continuous-section start pages; a PAGE field in a paragraph that runs onto a later page (a page break inside it, or a natural split) shows that page's number, the rest of the paragraph broken again for the wider result (#1352); PAGEREF in headers and footers shows its bookmark's page, filled in after pagination like their totals (#1477); in the body it keeps its result until fields are updated | — | | Page-by-page comparison harness against Word | ❌ | Run Word locally on our own synthetic documents, export PDF, compare line breaks; Word output stays under `plan/` (never committed) | 8–12 | @@ -266,6 +266,7 @@ Installed static fonts also resolve by their OpenType PostScript name (name ID 6 | 2026-10-25 | trivial | A bookmark, comment anchor or other zero-width marker is no line-break opportunity: a word that doesn't fit after one no longer leaves a blank line, and one inside a word no longer splits it (#4193) | | 2026-10-25 | trivial | A no-break space is shaped as a space, so it is as wide as one even in fonts whose own U+00A0 glyph is wider; it still holds the line together (#4192). `layout::tests_nbsp::a_no_break_space_is_as_wide_as_a_space` | | 2026-10-22 | trivial | A table row that another row follows fits on a page only with room for the border below it, as checked in Word for Mac: a 60-row table with 3 pt borders kept one row more than Word on page 1 for bottom margins of 78.2–81.0 pt (`layout::tests::a_row_leaves_room_for_the_border_below_it`) | +| 2026-10-23 | trivial | A justified line ending in a manual line break is stretched to the right margin like a wrapped line, as Word does by default; with `w:doNotExpandShiftReturn` it keeps its natural spacing (#3924). `layout::tests::justified_line_ending_in_line_break_is_stretched` | | 2026-10-16 | trivial | Exact PostScript names resolve installed static faces without changing family lookup | | 2026-10-21 | trivial | Pin the font input of four geometric layout controls; assertions and renderer font policy unchanged. | | 2026-10-20 | trivial | A click below a floating table, outside its cells, goes to the paragraph beside or after it instead of a cell of its last row, as checked in Word (#2038) | From 0d2d1776dd7e4778db689e1745bf328cb2d7db0f Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:59:09 +0530 Subject: [PATCH 18/50] fix(formats): support backslash space, soft breaks and zero-width spaces in RTF (#2250) --- crates/formats/src/rtf.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 5bbb5a0d78..280b2feae2 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -2128,9 +2128,9 @@ impl Reader { "cell" => self.end_cell(), "nestcell" => self.end_para(true), "row" => self.end_row(), - "line" => self.ch('\n'), + "line" | "softline" => self.ch('\n'), "tab" => self.ch('\t'), - "page" => self.ch('\u{000C}'), + "page" | "softpage" => self.ch('\u{000C}'), "column" => self.ch('\u{000E}'), "emdash" => self.ch('—'), "endash" => self.ch('–'), @@ -2144,6 +2144,8 @@ impl Reader { "qmspace" => self.ch('\u{2005}'), "zwj" => self.ch('\u{200D}'), "zwnj" => self.ch('\u{200C}'), + "zwbo" => self.ch('\u{200B}'), + "zwnbo" => self.ch('\u{2060}'), "u" => { let u = (pv as i64).rem_euclid(65_536) as u16; if (0xD800..0xDC00).contains(&u) { @@ -2727,7 +2729,7 @@ fn parse_full(bytes: &[u8]) -> Result { i += 2; continue; } - b'~' => { + b'~' | b' ' => { r.ch('\u{00A0}'); i += 2; } @@ -4397,4 +4399,12 @@ mod tests { assert!(exported.contains("\\clvertalc")); assert!(exported.contains("\\clvertalb")); } + + #[test] + fn rtf_backslash_space_and_special_breaks() { + let rtf = br"{\rtf1\ansi A\ B\~C\zwbo D\softline E\softpage F\par}"; + let doc = import(rtf).unwrap(); + let p = doc.body.first().unwrap().as_para().unwrap(); + assert_eq!(p.text, "A\u{00A0}B\u{00A0}C\u{200B}D\nE\u{000C}F"); + } } From 1b0faddfa8b1ffc3d764ad46b078ffb92a5352fa Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:01:01 +0530 Subject: [PATCH 19/50] fix(formats): ignore block and inline HTML comments in markdown import (#2225) --- crates/formats/src/markdown.rs | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index bf19877017..da6843307a 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -5,7 +5,7 @@ //! bottom-border paragraph), bullet and ordered lists (nested by indentation), GFM pipe tables, //! and inlines: emphasis/strong (the CommonMark delimiter algorithm), `~~strike~~`, code spans, //! inline links, autolinks, images (`data:` URIs become pictures, others their alt text), -//! backslash escapes, entities, `
` and `` anchors. +//! backslash escapes, entities, HTML comments (``), `
` and `
` anchors. //! //! Export writes the same constructs; pictures become `![alt](media-name)` placeholders. @@ -351,6 +351,7 @@ fn interrupts(l: &str) -> bool { || thematic_break(t) || fence(t).is_some() || t.starts_with('>') + || t.starts_with("") { + break; + } + } + continue; + } if t.starts_with('>') { flush_para(&mut para, &mut out, depth, refs); let mut inner: Vec = Vec::new(); @@ -1016,6 +1031,12 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { } } b'<' => { + if let Some(pos) = s.get(i..).and_then(|r| r.strip_prefix("")) { + flush!(); + i += 4 + pos + 3; + text_start = i; + continue; + } let rest = s.get(i + 1..).unwrap_or(""); let end = within(rest, MAX_TAG).find('>'); let inner = end.and_then(|e| rest.get(..e)).unwrap_or(""); @@ -2052,4 +2073,13 @@ mod tests { assert_eq!(p3.text(), "one item"); assert!(p3.list.unwrap().ordered); } + + #[test] + fn html_comments_are_ignored_in_blocks_and_inlines() { + let md = "\n\nHello world!\n\n\n\nAfter comment."; + let flow = parse(md); + assert_eq!(flow.blocks.len(), 2); + let texts: Vec = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect(); + assert_eq!(texts, ["Hello world!", "After comment."]); + } } From b0b7e6b5ea7589450b01d74cb2a8645312bac370 Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 25 Oct 2026 15:32:31 +0100 Subject: [PATCH 20/50] Fix Markdown code span line endings (#3804) Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com> --- ROADMAP.md | 4 +- crates/formats/src/markdown.rs | 70 ++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 7 +++- docs/gaps.md | 3 +- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7ae409ce8b..6ee486709c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Markdown code spans keep line-broken text literal, #3738; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -161,6 +161,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-22: Spanish proofing (#3360, #3553): text in Spanish was never checked (it fell through to the languages without tools). Now Spain (`es-ES`), Mexico (`es-MX`), Latin America (`es-419` and the other countries of the Americas) and Uruguay (`es-UY`) each read their own installed dictionary, with fallbacks; suggestions restore accents first (`prueva` → `prueba`, `tambien` → `también`); Uruguayan Spanish accepts the `vos` forms, imperatives included (`vení`, `decime`, `decímelo`); repeated-word, spacing and missing `¿`/`¡` checks explain in Spanish; Review › Language, the status bar and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish. No dictionary data bundled; parity estimates unchanged - 2026-10-24: AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space (replacements, sentence capitals, ordinals and links). AutoCorrect left such a word as typed. - 2026-10-23: Table of contents: Update Table on a TOC set in columns (here Word's own two-column TOC, written by Word in Portuguese) keeps it in its columns (#3889). The page numbers stand at the column's right edge rather than the page's, so entries no longer run over the next column and push its page numbers off the page; the TOC styles Word stores under translated ids (`Sumrio1` for `toc 1`) are read as the TOC styles, so the entries keep their font, indents and spacing; and no blank line tops the first column. Update, save and reopen keep it so. The same keeps a Portuguese Word's TOC formatted as its TOC styles say through Update Table (#3891). +- 2026-10-22: Markdown inline code replaces each line ending with a space before export, including code in lists and table cells. A following heading, list marker or HTML tag stays literal code (#3738). - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. - 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs. - 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459). @@ -654,6 +655,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-20 | minor | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the session author can't change through `file.setAuthor` under protection, and the post-check compares the settings and the metadata as well as the text and the formatting | | 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password | | 2026-10-17 | trivial | Progress log: disabled-control reasons translated (#2678) | +| 2026-10-22 | trivial | Markdown code spans normalize line endings before export, keeping block markers literal (#3738); format readiness unchanged | | 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) | diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index da6843307a..e483cc928d 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -1302,6 +1302,7 @@ fn escape_lines(body: String) -> String { } fn code_span(t: &str) -> String { + let t = t.replace("\r\n", " ").replace(['\r', '\n'], " "); let mut longest = 0; let mut cur = 0; for c in t.chars() { @@ -1905,6 +1906,75 @@ mod tests { assert_eq!(exported, "`cmd | grep`"); } + #[test] + fn code_span_line_endings_stay_literal_on_export() { + let cases = [ + ("a\n# b", "a # b"), + ("a\n
b
", "a
b
"), + ("a\n- b", "a - b"), + ("a\n```", "a ```"), + ("a\r# b", "a # b"), + ("a\r\n# b", "a # b"), + ("a\n\n> b", "a > b"), + ("\na\n", " a "), + ("`a\n# b`", "`a # b`"), + ("cmd | grep", "cmd | grep"), + ]; + let shapes = [ + (Kind::Normal, None), + (Kind::Normal, Some(ListInfo { ordered: false, level: 0, start: 1 })), + (Kind::Code, Some(ListInfo { ordered: true, level: 0, start: 1 })), + ]; + for (source, expected) in cases { + for (kind, list) in shapes { + let mut p = Para::new(kind); + p.list = list; + p.push_text(source, &Fmt { code: true, ..Default::default() }); + let mut doc = model::to_doc(&model::Flow { blocks: vec![FBlock::Para(p)], ..Default::default() }); + // The flow builder removes CR controls; keep the native document's raw input. + let p = doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + p.text = source.to_string(); + p.runs[0].len = source.len(); + let bytes = crate::export("md", &doc).unwrap().unwrap(); + let md = String::from_utf8(bytes).unwrap(); + assert_eq!(md.trim_end().lines().count(), 1, "{source:?} {kind:?} {list:?} -> {md:?}"); + assert!(!md.contains('\r'), "{md:?}"); + let back = crate::import("md", md.as_bytes()).unwrap().unwrap(); + let flow = model::from_doc(&back); + let [FBlock::Para(q)] = flow.blocks.as_slice() else { panic!("{md:?}") }; + assert_eq!(q.text(), expected, "{source:?} {kind:?} {list:?} -> {md:?}"); + assert_eq!(q.list, list, "{md:?}"); + assert!(q.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.code)), "{md:?}"); + } + } + let fenced = "```\na\n# b\n```"; + assert_eq!(export(&import(fenced)).trim_end(), fenced); + } + + #[test] + fn code_span_line_endings_in_table_cells_stay_literal() { + for ending in ["\n", "\r", "\r\n"] { + let source = format!("a{ending}# b|c"); + let mut p = Para::default(); + p.push_text(&source, &Fmt { code: true, ..Default::default() }); + let cell = Cell { blocks: vec![FBlock::Para(p)], ..Default::default() }; + let table = FTable { rows: vec![vec![cell, Cell::default()], vec![Cell::default(), Cell::default()]], ..Default::default() }; + let mut doc = model::to_doc(&model::Flow { blocks: vec![FBlock::Table(table)], ..Default::default() }); + let p = doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path(vec![0, 0, 0, 0])).unwrap(); + p.text = source.clone(); + p.runs[0].len = source.len(); + let md = export(&doc); + let back = model::from_doc(&import(&md)); + let [FBlock::Table(table), ..] = back.blocks.as_slice() else { panic!("{md:?}") }; + assert!(back.blocks.iter().skip(1).all(|b| matches!(b, FBlock::Para(p) if p.text().is_empty())), "{md:?}"); + assert_eq!(table.rows.len(), 2, "{md:?}"); + assert_eq!(table.rows[0].len(), 2, "{md:?}"); + let [FBlock::Para(p)] = table.rows[0][0].blocks.as_slice() else { panic!("{md:?}") }; + assert_eq!(p.text(), "a # b|c", "{md:?}"); + assert!(p.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.code)), "{md:?}"); + } + } + #[test] fn table_code_pipe_export_import_round_trip() { let mut table = FTable::default(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 69f7ea577b..2ef85ea8a4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -249,6 +249,10 @@ Original binary fixtures and actual WordApp/Session file I/O cover Save, toolbar Imported text fields use the first nonempty cached result run's formatting on their paragraph span, including character styles. Empty results retain begin-marker formatting. Synthetic fixtures cover simple and complex fields, default inheritance and repeated saves. Mixed-format results remain represented by one span; field refresh policy is unchanged. +## Markdown inline code + +Inline code exports each LF, CR or CRLF line ending as one space, following [CommonMark code-span normalization](https://spec.commonmark.org/0.31.2/#code-spans). This keeps a following heading, list marker or HTML tag inside the code span, including code in lists and table cells (#3738). Leading and trailing spaces and backtick fences are preserved; fenced code blocks keep their line breaks. Synthetic export/reimport tests cover these cases. + ## Revision history > 2026-10-25 · Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`). @@ -262,6 +266,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | | 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) | | 2026-10-23 | trivial | DOCX: TOC styles Word stores under ids in another language (`Sumrio1` named `toc 1`, `CabealhodoSumrio` named `TOC Heading`) read as `TOC1`…`TOC9` and `TOCHeading` unless the file uses those ids, so Update Table builds the entries in the document's own TOC styles (#3891); a TOC whose heading ends a section (entries in columns after a continuous break) is written and read as the engine's TOC, with the page-number tab at the column's edge (#3889) | +| 2026-10-22 | trivial | Markdown code spans normalize line endings before export, keeping block markers literal (#3738); format readiness unchanged | | 2026-10-21 | trivial | HTML export writes ruby (furigana, phonetic guides) as `basereading`, in the base text's run formatting, and HTML import reads ``/``/`` back into one ruby object: `` fallback parentheses and the tags are not text, an unclosed element ends at the next block, and malformed ruby keeps its text. HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) | | 2026-10-21 | trivial | RTF import (#1653): the East Asian multibyte code pages are decoded — 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC/windows-949 (Korean) and 950 Big5 (Traditional Chinese), named by `\ansicpg`, a font's `\cpg` or `\fcharset128`/`129`/`134`/`136` (and the OEM charset 255 as 437), escaped (`\'hh`) or raw. Bytes are decoded as a run, so a lead byte and its trail byte decode together; a code page switch, a group end and the end of the document decode what is pending; invalid sequences read as U+FFFD and never lose the rest of the text. The decoder does no BOM sniffing, so a run that starts `EF BB BF` in GBK, Big5 or UHC stays text, and ASCII runs and picture hex no longer decode byte by byte. Single-byte code pages, `\uN?` escapes and their `\ucN` skips are unchanged | | 2026-10-21 | trivial | Tables: table style band sizes (`w:tblStyleRowBandSize`, `w:tblStyleColBandSize`) are written for our banded styles and read with zero kept; a style without them has no bands, as in Word, so banded rows shade the same rows in both (#3415) | diff --git a/docs/gaps.md b/docs/gaps.md index badccc6b32..ac06c115d5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,7 @@ # Where WordCraft falls short of Word > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -158,6 +158,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | 2026-10-22 | minor | Compare's new and revised untitled results clear the previous document's version snapshots; a rejected protected comparison or mailing result preserves the snapshots and file identity. No gap row; estimates unchanged. | | 2026-10-21 | trivial | #20 narrowed: a word with no break opportunity in it no longer makes the layout quadratic (#3412) | | 2026-10-20 | minor | The Document Inspector's removals are refused while the document tracks changes (they aren't tracked), and a version snapshot belongs to the document it was taken from. No gap row (enforcement fix); estimates unchanged | +| 2026-10-22 | trivial | Markdown code spans normalize line endings before export, keeping block markers literal (#3738); format readiness unchanged | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-21 | trivial | Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459) | | 2026-10-21 | trivial | #3172: the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are to a screen reader | From b37a732455d57d10c73d6414b2f88d9ca9d7742d Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:09:59 +0530 Subject: [PATCH 21/50] fix(formats): support negative timezone offsets and author fallback in ODT metadata (#2189) --- crates/formats/src/odt.rs | 61 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index e1f0fd8860..fd4c74dff0 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -2530,6 +2530,8 @@ fn read_meta(xml: &[u8]) -> Meta { let mut m = Meta::default(); let mut cur: Option = None; let mut keywords: Vec = Vec::new(); + let mut creator = String::new(); + let mut mod_date = String::new(); events(xml, |ev| match ev { Ev::Start(q, _) => cur = Some(q), Ev::End(_) => cur = None, @@ -2538,16 +2540,32 @@ fn read_meta(xml: &[u8]) -> Meta { Some("dc:subject") => m.subject.push_str(&t), Some("dc:description") => m.description.push_str(&t), Some("meta:initial-creator") => m.author.push_str(&t), - Some("dc:creator") if m.author.is_empty() => m.author.push_str(&t), + Some("dc:creator") => creator.push_str(&t), Some("meta:keyword") => keywords.push(t), Some("meta:creation-date") => m.created.push_str(&t), + Some("dc:date") => mod_date.push_str(&t), _ => {} }, }); + if m.author.is_empty() { + m.author = creator; + } + if m.created.is_empty() { + m.created = mod_date; + } m.keywords = keywords.join(", "); - if !m.created.is_empty() && !m.created.ends_with('Z') && !m.created.contains('+') { - let base = m.created.split('.').next().unwrap_or("").to_string(); - m.created = format!("{base}Z"); + let created = m.created.trim(); + if !created.is_empty() { + let has_tz = + created.ends_with(['Z', 'z']) || created.contains('+') || created.split_once('T').map(|(_, time)| time.contains('-')).unwrap_or(false); + if !has_tz { + let base = created.split('.').next().unwrap_or(""); + m.created = format!("{base}Z"); + } else { + m.created = created.to_string(); + } + } else { + m.created.clear(); } m } @@ -3830,4 +3848,39 @@ mod tests { assert!(!fmt(ps[1]).underline, "a line type does not override a line style of none"); assert!(!fmt(ps[1]).strike); } + + #[test] + fn meta_creation_date_timezones_and_fallbacks() { + let xml_neg = br#" + 2024-03-05T13:45:00-05:00 + Modifier + Original Author + "#; + let m = read_meta(xml_neg); + assert_eq!(m.created, "2024-03-05T13:45:00-05:00"); + assert_eq!(m.author, "Original Author"); + + let xml_neg_frac = br#" + 2024-03-05T13:45:00.500-05:00 + "#; + assert_eq!(read_meta(xml_neg_frac).created, "2024-03-05T13:45:00.500-05:00"); + + let xml_local_frac = br#" + 2024-03-05T13:45:00.123 + "#; + assert_eq!(read_meta(xml_local_frac).created, "2024-03-05T13:45:00Z"); + + let xml_z_lower = br#" + 2024-03-05T13:45:00z + "#; + assert_eq!(read_meta(xml_z_lower).created, "2024-03-05T13:45:00z"); + + let xml_dc_date_fallback = br#" + 2024-03-05T13:45:00Z + Solo Creator + "#; + let m2 = read_meta(xml_dc_date_fallback); + assert_eq!(m2.created, "2024-03-05T13:45:00Z"); + assert_eq!(m2.author, "Solo Creator"); + } } From 0696c556988da52fe4283154182945772fd60def Mon Sep 17 00:00:00 2001 From: divyanshu2074 <43274510+divyanshu2074@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:11:23 +0530 Subject: [PATCH 22/50] fix(docx): support sym hex prefixes and normalize break types and note references (#2413) --- crates/docx/src/read/story.rs | 15 +++++++++++---- crates/docx/tests/it/fixtures.rs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 2048ea30d5..03259b6fa8 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -724,7 +724,8 @@ impl Reader<'_> { if sc.fields.iter().any(|f| f.phase == Phase::Instr) { return; } - let c = match k.attr("w:type") { + let ty = k.attr("w:type").map(|t| t.trim().to_ascii_lowercase()); + let c = match ty.as_deref() { Some("page") => "\u{000C}", Some("column") => "\u{000E}", _ => "\n", @@ -739,7 +740,13 @@ impl Reader<'_> { "w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props), "w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props), "w:sym" => { - let ch = k.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20); + let h_str = k.attr("w:char").map(|h| { + let s = h.trim(); + let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s); + let s = s.strip_prefix("U+").or_else(|| s.strip_prefix("u+")).unwrap_or(s); + s.strip_prefix("#x").or_else(|| s.strip_prefix("#X")).unwrap_or(s) + }); + let ch = h_str.and_then(|h| u32::from_str_radix(h, 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20); if let Some(ch) = ch { let mut p = props.clone(); // The symbol's own font, kept apart from the run's (ECMA-376 §17.3.3.30). @@ -783,7 +790,7 @@ impl Reader<'_> { } } "w:footnoteReference" | "w:endnoteReference" => { - let foot = k.name == "w:footnoteReference"; + let foot = k.local() == "footnoteReference"; let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN); let map = if foot { &self.footnotes } else { &self.endnotes }; if let Some(id) = map.get(&fid).copied() { @@ -794,7 +801,7 @@ impl Reader<'_> { } // The note's own number at the start of its text: a reference to the note being read. "w:footnoteRef" | "w:endnoteRef" => { - let kind = if k.name == "w:footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote }; + let kind = if k.local() == "footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote }; if let Some((nk, id)) = self.current_note && nk == kind { diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index f55e7be4bd..e43814396b 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -339,6 +339,18 @@ fn pcl6_symbol_roundtrip() { } } +#[test] +fn sym_hex_prefixes_and_break_type_casing() { + let body = r##" + + + +"##; + let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap(); + let p = paras(&d); + assert_eq!(p[0].plain_text(), "\u{F0FC}\u{F061}\u{F061}\u{C}\u{E}\n"); +} + #[test] fn alternate_content_choice_and_fallback() { let body = r#" From a5526724aa02a78d007576b705a2bb3daaea876b Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Sun, 25 Oct 2026 14:43:03 +0000 Subject: [PATCH 23/50] Cover repeated DOCX style replacement across saves (#2469) Co-authored-by: Claude Opus 5.5 --- crates/docx/tests/it/fixtures.rs | 34 ++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 ++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index e43814396b..a7c382660f 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -48,6 +48,40 @@ fn read_body(body: &str) -> Document { wordcraft_docx::read(&docx(body, &[], &[])).unwrap() } +#[test] +fn repeated_style_ids_use_last() { + let styles = format!( + r#" + + + + + + "# + ); + let bytes = docx( + r#"Original style probe"#, + &[("styles", "styles", "styles.xml")], + &[("word/styles.xml", &styles)], + ); + let mut document = wordcraft_docx::read(&bytes).unwrap(); + for round in 0..=2 { + let style = document.styles.get("Repeated").unwrap(); + assert_eq!(style.name, "Last"); + assert_eq!(style.chr.size, Some(10.0)); + assert_eq!(style.chr.italic, Some(true)); + assert_eq!(style.chr.bold, None); + assert_eq!(style.chr.caps, Some(true)); + assert_eq!(style.chr.small_caps, None); + assert_eq!(style.para.space_before, Some(6.0)); + assert_eq!(style.para.space_after, None); + assert_eq!(document.styles.styles.iter().filter(|style| style.id == "Repeated").count(), 1); + if round < 2 { + document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap(); + } + } +} + #[test] fn paragraph_default_presence_survives_repeated_saves() { // Omission leaves paragraph defaults application-defined (ISO 29500 pPrDefault). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2ef85ea8a4..caed9879ce 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -253,6 +253,8 @@ Imported text fields use the first nonempty cached result run's formatting on th Inline code exports each LF, CR or CRLF line ending as one space, following [CommonMark code-span normalization](https://spec.commonmark.org/0.31.2/#code-spans). This keeps a following heading, list marker or HTML tag inside the code span, including code in lists and table cells (#3738). Leading and trailing spaces and backtick fences are preserved; fenced code blocks keep their line breaks. Synthetic export/reimport tests cover these cases. +Repeated DOCX style IDs use the last complete definition. The original regression also covers repeated save/reopen, including removal of earlier-only properties. + ## Revision history > 2026-10-25 · Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`). @@ -261,6 +263,7 @@ Inline code exports each LF, CR or CRLF line ending as one space, following [Com | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Repeated DOCX style IDs: repeated save/reopen regression for whole-definition replacement. | | 2026-10-25 | trivial | Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`) | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | From 4bf785c4cf16019b4d7d2b948c373c939e9fc30a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:44:31 +0100 Subject: [PATCH 24/50] Keep Arabic-Indic digits as typed in Arabic numeral mode (#3329) Fixes #3259 Co-authored-by: Claude Haiku 5.5 --- crates/layout/src/lib.rs | 14 +++------ crates/layout/src/tests_numeral.rs | 47 ++++++++++++++++++------------ 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 0d30535386..224e06475a 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -58,9 +58,9 @@ pub enum ViewMode { /// How digits look on screen (Word › File › Options › Advanced › Numeral). /// /// Display only: the document text keeps the typed codepoints, so find, spelling, copying and -/// the saved file all see the original digits. Extended Arabic-Indic digits (U+06F0–U+06F9, -/// Persian/Urdu ۰۱۲۳…) always show as typed; only ASCII and Arabic-Indic (U+0660–U+0669) digits -/// are substituted. +/// the saved file all see the original digits. Only ASCII digits are substituted: Arabic-Indic +/// (U+0660–U+0669) and Extended Arabic-Indic digits (U+06F0–U+06F9, Persian/Urdu ۰۱۲۳…) always +/// show as typed, so fonts that ligate them (e.g. the end-of-ayah mark U+06DD) keep working. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub enum NumeralMode { @@ -121,13 +121,7 @@ impl NumeralMode { NumeralMode::Context => rtl, NumeralMode::System => false, }; - if hindi { - if c.is_ascii_digit() { char::from_u32(0x660 + (c as u32 - '0' as u32)).unwrap_or(c) } else { c } - } else if ('\u{660}'..='\u{669}').contains(&c) { - (b'0' + (c as u32 - 0x660) as u8) as char - } else { - c - } + if hindi { if c.is_ascii_digit() { char::from_u32(0x660 + (c as u32 - '0' as u32)).unwrap_or(c) } else { c } } else { c } } } diff --git a/crates/layout/src/tests_numeral.rs b/crates/layout/src/tests_numeral.rs index 9de3e8ee8f..8bb1eb29e9 100644 --- a/crates/layout/src/tests_numeral.rs +++ b/crates/layout/src/tests_numeral.rs @@ -49,18 +49,18 @@ fn parse_accepts_the_four_modes_any_case() { } #[test] -fn map_substitutes_digits_both_ways() { +fn map_substitutes_ascii_digits_only() { for d in '0'..='9' { let hindi = char::from_u32(0x660 + (d as u32 - '0' as u32)).unwrap(); assert_eq!(NumeralMode::Hindi.map(d, false), hindi); assert_eq!(NumeralMode::Hindi.map(d, true), hindi, "Hindi mode ignores direction"); - assert_eq!(NumeralMode::Arabic.map(hindi, false), d); - assert_eq!(NumeralMode::Arabic.map(hindi, true), d, "Arabic mode converts back both ways"); + assert_eq!(NumeralMode::Arabic.map(hindi, false), hindi); + assert_eq!(NumeralMode::Arabic.map(hindi, true), hindi, "Arabic mode keeps typed Hindi digits"); assert_eq!(NumeralMode::Arabic.map(d, false), d); assert_eq!(NumeralMode::Context.map(d, true), hindi); assert_eq!(NumeralMode::Context.map(d, false), d); assert_eq!(NumeralMode::Context.map(hindi, true), hindi); - assert_eq!(NumeralMode::Context.map(hindi, false), d); + assert_eq!(NumeralMode::Context.map(hindi, false), hindi); } // Letters, punctuation and Persian digits are never touched. for c in ['A', 'ب', '.', ' ', '(', '۰', '۹'] { @@ -120,21 +120,11 @@ fn hindi_mode_reshapes_digits_but_keeps_offsets() { } #[test] -fn arabic_mode_converts_hindi_digits_back() { +fn arabic_mode_keeps_typed_hindi_digits() { let text = "عام ٢٠٢٤"; - let hindi = lay_opts(text, true, NumeralMode::Hindi); - let arabic = lay_opts(text, true, NumeralMode::Arabic); - let h = line_gids(&hindi, text); - let a = line_gids(&arabic, text); - assert_eq!(h.len(), a.len()); - let mut changed = 0; - for (n, (gh, ga)) in h.iter().zip(a.iter()).enumerate() { - if text.chars().nth(n).unwrap() == '٢' { - assert_ne!(gh, ga); - changed += 1; - } - } - assert!(changed > 0, "typed Hindi digits display Western in Arabic mode"); + let hindi = line_gids(&lay_opts(text, true, NumeralMode::Hindi), text); + let arabic = line_gids(&lay_opts(text, true, NumeralMode::Arabic), text); + assert_eq!(hindi, arabic, "typed Hindi digits display as typed in Arabic mode"); } #[test] @@ -176,3 +166,24 @@ fn remap_range_maps_display_bytes_to_original() { assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 99, 200), (4, 4)); assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 3, 1), (1, 2)); } + +#[test] +fn arabic_mode_keeps_arabic_indic_digits_as_typed() { + // Issue #3259: the end-of-ayah mark (U+06DD) ligates with the real Arabic-Indic digits that + // follow it, so those codepoints must reach the shaper unchanged in the default mode. + for n in 0..10u32 { + let hindi = char::from_u32(0x660 + n).unwrap(); + let ascii = char::from_u32('0' as u32 + n).unwrap(); + for m in [NumeralMode::default(), NumeralMode::Arabic, NumeralMode::System] { + assert_eq!(m.map(hindi, false), hindi, "{m:?} keeps {hindi:?} (ltr)"); + assert_eq!(m.map(hindi, true), hindi, "{m:?} keeps {hindi:?} (rtl)"); + } + let typed = format!("\u{6dd}{hindi}"); + let western = format!("\u{6dd}{ascii}"); + let t = line_gids(&lay_opts(&typed, true, NumeralMode::default()), &typed); + let w = line_gids(&lay_opts(&western, true, NumeralMode::default()), &western); + let h = line_gids(&lay_opts(&typed, true, NumeralMode::Hindi), &typed); + assert_ne!(t, w, "U+{:04X} is not shaped as ASCII {ascii:?} in the default mode", 0x660 + n); + assert_eq!(t, h, "U+{:04X} shapes the same as in Hindi mode", 0x660 + n); + } +} From d1db090a7cb9e2dd0404859a448e05e93d55dc20 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:48:42 +0100 Subject: [PATCH 25/50] Mini toolbar: the alignment buttons mark the alignment seen on the page, as the ribbon does (#3886) --- crates/ui-egui/src/mini_toolbar.rs | 67 +++++++++++++++++++++++++++++- docs/ui-parity.md | 3 +- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index 0af9e39e14..7e4d15657b 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -232,7 +232,16 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) { fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) { let st: Value = app.session.run("format.state", &json!({})).unwrap_or_default(); let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); - let align = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props).align).unwrap_or_default(); + // As on the ribbon, the buttons show alignment as seen on the page (#3579). + let align = app + .session + .doc + .para_at(&app.session.sel.focus) + .map(|p| { + let r = app.session.doc.styles.resolve_para(&p.props); + r.align.visual(r.bidi) + }) + .unwrap_or_default(); let row = |ui: &mut egui::Ui, app: &mut WordApp, add: &dyn Fn(&mut egui::Ui, &mut WordApp)| { ui.allocate_ui_with_layout(Vec2::new(ui.available_width(), ROW_H), Layout::left_to_right(Align::Center), |ui| { ui.set_min_height(ROW_H); @@ -924,4 +933,60 @@ mod tests { assert!(h.state().canvas.want_focus, "the swatch pick returns focus to the document"); assert!(format_state(&mut h).get("highlight").is_some_and(|v| !v.is_null()), "the first swatch applied a highlight"); } + + /// #3579: in a right-to-left paragraph the bar lit Align Left for a paragraph that sits on + /// the right. Like the ribbon, it marks the alignment as seen on the page, the one its buttons + /// set. Only the bar is drawn, so the ribbon's own alignment buttons can't answer for it. + #[test] + fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_paragraph() { + use egui_kittest::kittest::Queryable; + // A left-to-right paragraph first: a bar that always swapped the two would pass the RTL cases alone. + for (rtl, cmd, lit, unlit) in [ + (false, None, "Align Left", "Align Right"), + (true, None, "Align Right", "Align Left"), + (true, Some("para.alignLeft"), "Align Left", "Align Right"), + ] { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("سلام دنیا")), Default::default()); + a.run("select.all", json!({})).unwrap(); + if rtl { + a.run("para.rtl", json!({})).unwrap(); + } + if let Some(cmd) = cmd { + a.run(cmd, json!({})).unwrap(); + } + // The bar writes in the interface fonts, which take effect the frame after they are set. + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(500.0, 200.0)).build_ui_state( + move |ui, app: &mut WordApp| { + if !fonts { + crate::theme::install_fonts(ui.ctx()); + fonts = true; + return; + } + egui::Area::new(egui::Id::new("mini_align_probe")).fixed_pos(pos2(10.0, 10.0)).show(ui.ctx(), |ui| { + let t = Tokens::get(ui.ctx()); + content(app, ui, &t); + }); + }, + a, + ); + steps(&mut h); + let checked = Tokens::get(&h.ctx).checked; + let marked: Vec = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Rect(r) if r.fill == checked => Some(r.rect), + _ => None, + }) + .collect(); + let is_marked = |label: &str| { + let r = h.get_by_label(label).rect(); + marked.iter().any(|m| m.contains(r.center())) + }; + assert!(is_marked(lit), "rtl={rtl} {cmd:?}: {lit} is marked"); + assert!(!is_marked(unlit), "rtl={rtl} {cmd:?}: {unlit} is not marked"); + } + } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c974a537dd..2e9bb61305 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -83,6 +83,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | 2026-10-23 | trivial | Insert › Table: hovering the grid shows the table on the page, at the caret, as Word's Live Preview does; it follows the pointer and is laid out on a copy, so the document and Undo change only when a square is clicked (#3931) | | 2026-10-23 | trivial | Comment times: the Comments pane and the balloons show each comment's and reply's time in the user's time zone, under its author, as Word does. The file keeps UTC (`w16cex:dateUtc` when Word wrote one), a time without a zone shows as written, and the web app asks the browser for the zone (#2322, #2337) | | 2026-10-22 | trivial | Mail merge: First Record, Last Record, a record number box, and record choices for Edit Individual Documents and Print Documents (#3028) | +| 2026-10-23 | trivial | Mini toolbar: in a right-to-left paragraph the alignment buttons mark the alignment as seen on the page, as the ribbon does, so a start-aligned paragraph lights Align Right instead of Align Left; the buttons already set the alignment as seen on the page (#3579) | | 2026-10-22 | trivial | While editing a header, a single click in the footer (or the reverse, or another page's own header) edits that one with the caret where clicked; the click was placed in the header being edited, and the view scrolled back to it, so a double-click on the footer rarely landed | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-19 | trivial | Insert Table of Figures opens a Table of Figures dialog: caption label (Figure, Table, Equation, the user's own and those the document's captions use), Include label and number, page numbers, right margin and tab leader, with a preview; the ribbon button, KeyTip and command search reach it (#3013) | From 85db9cfef5b12f02dc78f3e67774924a0d5e68c7 Mon Sep 17 00:00:00 2001 From: Martin Ball <98639962+benandez@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:53:13 +0000 Subject: [PATCH 26/50] #4177: regex Replace expands capture groups (#4201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * With Regular expressions on, Replace with reads $1…$n, ${n}, ${name}, $name, $0/${0}, $$, \1…\9, \n (paragraph mark, splits the paragraph through the ^p path), \t and \\, alongside the ^ codes. Named groups resolve through meta::Regex::group_info(); missing, unknown and out-of-range groups expand to nothing. Plain and wildcard Replace are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) * #4177: review — special:false keeps regex group references Co-Authored-By: Claude Opus 5.5 (1M context) * #4177: review — \$ and \^ in regex Replace keep the character as typed Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 4 +- crates/engine/src/cmd/edit.rs | 7 ++- crates/engine/src/cmd/find_pattern.rs | 90 ++++++++++++++++++++++++++- crates/engine/src/tests.rs | 66 ++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 23 +++++++ docs/ui-parity.md | 7 ++- 6 files changed, 190 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6ee486709c..2d74a9a01a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Markdown code spans keep line-broken text literal, #3738; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: regex Replace expands capture groups, #4177; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -144,6 +144,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-23: AutoFormat As You Type: `*Bold* and _italic_ as real formatting`. With the option on, `*word*` typed and finished becomes bold and `_word_` italic, without the marks. It is off by default (`tools.autocorrect {"boldItalic"}`). - 2026-10-24: Parity catalog: Read Mode's View menu has its own rows. Column Width (`view.readColumnWidth`) and Page Color (`view.readPageColor`) count as live; Layout (`view.readLayout`) is listed as missing. Catalog 504/520 (96.9%). +- 2026-10-24: Find and Replace: with Regular expressions on, Replace with expands capture groups instead of inserting them as typed. `(\w+), (\w+)` replaced with `$2 $1` or `\2 \1` swaps the words, and `(?\d{4})-` with `${y}/` names a group; `$0` is the whole match, `$$` a dollar sign, `\t` a tab and `\n` a paragraph mark that splits the paragraph, as `^p` does. Missing or out-of-range groups expand to nothing, and the `^` codes still work (#4177). - 2026-10-23: DOCX: a document whose default font size is unset keeps its size when saved and reopened. The size was left out of the file, and the reader read the missing size as 10 pt while the document resolved it as 11 pt; the file now says 11 pt (`DEFAULT_FONT_SIZE`). Files from other programs without a size are read as before. - 2026-10-23: Insert › Table: hovering the grid shows the table on the page where it would go, as Word's Live Preview does, and the preview follows the pointer from square to square. It is laid out on a copy of the document, so nothing changes, and Undo has nothing new, until a square is clicked (#3931). - 2026-10-24: Exact line spacing fixes the floating-table contact boundary independently of paragraph-mark metrics (#3977). Both row selection and word skipping use it; original regressions cover oversized and undersized marks, contact and one-twip offsets in modes 11/12/14/15. Shape behavior is unchanged. @@ -640,6 +641,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). | | 2026-10-24 | trivial | Progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting | | 2026-10-24 | trivial | Progress log: the parity catalog counts Read Mode's View menu (Column Width, Page Color, Layout) | +| 2026-10-24 | trivial | Progress log: regex Replace expands capture groups (#4177) | | 2026-10-23 | trivial | Progress log: interface translations from storytold/craft-i18n through the `craft-i18n` crate | | 2026-10-23 | trivial | Progress log: comment times in local time (#2322, #2337) | | 2026-10-22 | trivial | Progress log: mail merge record navigation and record ranges for Finish & Merge and Print Documents (#3028) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 0dfb5e1495..641fe1f1f0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -142,7 +142,7 @@ pub fn specs() -> Vec { .params(r#"{"tab"?: "find|replace|goto" (default replace)}"#) .pure(), CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all) - .params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#), + .params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards; with regex also $1 ${n} ${name} $0 $$ \\1 \\n (paragraph mark) \\t), …options of edit.find}"#), CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto) .key("Mod+Alt+G / F5") .params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string (selects its text), "paragraph"?: n}; none opens Find and Replace on its Go To tab"#) @@ -459,7 +459,10 @@ pub(crate) fn search_full(s: &Session, story: StoryRef, with_rep: bool) -> Resul .configure(meta::Config::new().nfa_size_limit(Some(1 << 20))) .build(&c.pattern) .map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?; - let pieces = with_rep.then(|| find_pattern::parse_replace(&f.replace, &opts)); + let pieces = with_rep.then(|| { + let index = |name: &str| re.group_info().to_index(regex_automata::PatternID::ZERO, name); + find_pattern::resolve_names(find_pattern::parse_replace(&f.replace, &opts), &index) + }); let paths = s.doc.para_paths(story); // Paragraphs read as one text: consecutive siblings when the pattern spans paragraphs. let mut chunks: Vec> = Vec::new(); diff --git a/crates/engine/src/cmd/find_pattern.rs b/crates/engine/src/cmd/find_pattern.rs index 95cc4cd312..8d8f8db9ba 100644 --- a/crates/engine/src/cmd/find_pattern.rs +++ b/crates/engine/src/cmd/find_pattern.rs @@ -345,15 +345,20 @@ pub enum Piece { Lit(String), /// `^&`: the text found. Found, - /// `\n` with wildcards: group `n`. + /// `\n` with wildcards, or `$n` / `${n}` / `\n` with a regex: group `n` (0 is the match). Group(usize), + /// `${name}` / `$name` with a regex: a named group, resolved to a [`Piece::Group`] once the + /// pattern is built ([`resolve_names`]); one left unresolved expands to nothing. + Named(String), /// `^c`: the clipboard. Clip, } -/// Read Replace with text: `^` codes (unless `literal`), and `\1`–`\9` with wildcards. +/// Read Replace with text: `^` codes (unless `literal`), `\1`–`\9` with wildcards, and with a +/// regex also `$n`, `${n}`, `${name}`, `$name`, `$$`, `\n` (paragraph mark), `\t` and `\\`. pub fn parse_replace(with: &str, o: &Opts) -> Vec { - if o.literal || o.regex { + // With a regex, `special: false` turns off only the `^` codes; group references still expand. + if o.literal && !o.regex { return vec![Piece::Lit(with.to_string())]; } let chars: Vec = with.chars().collect(); @@ -367,6 +372,22 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec { let mut i = 0; while let Some(&c) = chars.get(i) { i += 1; + if o.regex && (c == '$' || c == '\\') { + match regex_ref(&chars, i, c) { + Some((Ref::Char(ch), used)) => { + cur.push(ch); + i += used; + } + Some((Ref::Group(pc), used)) => { + flush(&mut cur, &mut out); + out.extend(pc); + i += used; + } + // Not a reference or an escape: the character as written. + None => cur.push(c), + } + continue; + } if c == '\\' && o.wildcards { match chars.get(i) { Some(d) if d.is_ascii_digit() && *d != '0' => { @@ -384,6 +405,7 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec { } } if c == '^' + && !o.literal && let Some(&n) = chars.get(i) { if n == '&' || n == 'c' { @@ -409,6 +431,67 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec { out } +/// What a `$` or `\` starts in regex Replace with text. +enum Ref { + /// An escape: `$$`, `\n`, `\t`, `\\`. + Char(char), + /// A group reference; `None` for a number too large to be a group (expands to nothing). + Group(Option), +} + +/// A group named by its number or name, as the `regex` crate reads it. +fn group_ref(name: &str) -> Option { + if name.bytes().all(|b| b.is_ascii_digit()) { name.parse().ok().map(Piece::Group) } else { Some(Piece::Named(name.to_string())) } +} + +/// Read a regex reference or escape after `c` (`$` or `\`) at `chars[i..]`: what it is and how +/// many characters it uses after `c`, or `None` when `c` is a plain character. +fn regex_ref(chars: &[char], i: usize, c: char) -> Option<(Ref, usize)> { + let next = *chars.get(i)?; + if c == '\\' { + return match next { + 'n' => Some((Ref::Char(PARA_MARK), 1)), + 't' => Some((Ref::Char('\t'), 1)), + // `\$` and `\^` keep a `$` or `^` as typed (no group, no `^` code). + '\\' | '$' | '^' => Some((Ref::Char(next), 1)), + '1'..='9' => Some((Ref::Group(next.to_digit(10).map(|d| Piece::Group(d as usize))), 1)), + _ => None, + }; + } + if next == '$' { + return Some((Ref::Char('$'), 1)); + } + let rest = chars.get(i..).unwrap_or_default(); + if next == '{' { + // `${…}`: everything up to the closing brace; unclosed or empty, the `$` is literal. + let close = rest.iter().position(|&ch| ch == '}')?; + let name: String = rest.get(1..close)?.iter().collect(); + if name.is_empty() { + return None; + } + return Some((Ref::Group(group_ref(&name)), close + 1)); + } + // `$name`: the longest run of letters, digits and `_`. + let len = rest.iter().take_while(|ch| ch.is_ascii_alphanumeric() || **ch == '_').count(); + if len == 0 { + return None; + } + let name: String = rest.get(..len)?.iter().collect(); + Some((Ref::Group(group_ref(&name)), len)) +} + +/// Turn named group references into numbered ones with `index` (a name's group number); +/// an unknown name is dropped, so it expands to nothing. +pub fn resolve_names(pieces: Vec, index: &dyn Fn(&str) -> Option) -> Vec { + pieces + .into_iter() + .filter_map(|p| match p { + Piece::Named(n) => index(&n).map(Piece::Group), + p => Some(p), + }) + .collect() +} + /// Searched text as it is inserted again: marks become paragraph breaks. fn as_inserted(s: &str) -> String { s.replace(SECT_MARK, "\r") @@ -423,6 +506,7 @@ pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option out.push_str(&as_inserted(found)), Piece::Group(n) => out.push_str(&as_inserted(&group(*n).unwrap_or_default())), Piece::Clip => out.push_str(&clip.replace("\r\n", "\r").replace('\n', "\r")), + Piece::Named(_) => {} } } out diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3dbd4082c5..f44e5c7168 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -892,6 +892,72 @@ fn find_replace_wildcards() { assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2); } +#[test] +fn regex_replace_expands_groups() { + // #4177: with Regular expressions on, Replace with expands capture groups. + let rep = |text: &str, q: &str, with: &str| rep_mode(text, q, with, json!({"regex": true})); + let names = "Smith, John and Doe, Jane"; + // The issue's cases: `\n` and `$n` swap words; `${name}` names a group. + assert_eq!(rep(names, r"(\w+), (\w+)", r"\2 \1"), "John Smith and Jane Doe"); + assert_eq!(rep(names, r"(\w+), (\w+)", "$2 $1"), "John Smith and Jane Doe"); + assert_eq!(rep("2024-05", r"(?\d{4})-", "${y}/"), "2024/05"); + // Braced numbers, `$0` / `${0}` (whole match), `$$` (a dollar sign). + assert_eq!(rep("ab", "(a)(b)", "${2}${1}"), "ba"); + assert_eq!(rep("ab", "a", "[$0|${0}]"), "[a|a]b"); + assert_eq!(rep("5", r"(\d)", "$$$1"), "$5"); + // `$name` takes the longest run of letters, digits and `_` (the regex crate's rule). + assert_eq!(rep("ab", "(?a)", "$x_"), "b"); + assert_eq!(rep("ab", "(?a)", "${x}_"), "a_b"); + assert_eq!(rep("ab", "(a)", "$1a"), "b"); + // Missing, unknown and out-of-range groups expand to nothing. + assert_eq!(rep("ab", "(a)", "<$2>"), "<>b"); + assert_eq!(rep("ab", "(a)", r"<\9>"), "<>b"); + assert_eq!(rep("ab", "(a)", "<${nope}>"), "<>b"); + assert_eq!(rep("ab", "(a)", "<$99999999999999999999999>"), "<>b"); + assert_eq!(rep("ab", "(a)|(z)", "<$2>"), "<>b"); + // A `$` that starts no group, and an unclosed brace, stay as written. + assert_eq!(rep("ab", "a", "$ ${x"), "$ ${xb"); + // `\t` a tab, `\\` a backslash; other escapes stay as written. + assert_eq!(rep("ab", "a", r"\t\\\q"), "\t\\\\qb"); + // Word's `^` codes still work. + assert_eq!(rep("ab", "a", "^&^t^&"), "a\tab"); + // `\$` and `\^` keep the character as typed (CodeRabbit on #4177). + assert_eq!(rep("ab", "(a)", r"\$1\^p"), "$1^pb"); + // `special: false` turns off the `^` codes only; groups still expand (CodeRabbit on #4177). + assert_eq!(rep_mode("ab", "(a)", "$1^t", json!({"regex": true, "special": false})), "a^tb"); + // `\n` is a paragraph mark: it splits the paragraph, as `^p` does, in one undo step. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "one, two"})); + assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": r"(\w+), (\w+)", "with": r"$1\n$2", "regex": true}))["replaced"], 1); + assert_eq!(text(&s), "one\ntwo"); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "one, two"); + run(&mut s, "document.setText", json!({"text": "a-b"})); + run(&mut s, "edit.replaceAll", json!({"text": "-", "with": "^p", "regex": true})); + assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2); + // Plain and wildcard Replace are unchanged: `$1` is literal there. + assert_eq!(rep_mode("ab", "a", "$1", json!({})), "$1b"); + assert_eq!(rep_mode("ab", "(a)", "$1\\1", json!({"wildcards": true})), "$1ab"); + // Hostile Replace text never panics. + run(&mut s, "document.setText", json!({"text": "ab ab"})); + for with in ["$", "${", "${}", "${0", "$\u{e9}", "\\", "${\u{1F600}}", "$18446744073709551616", "${-1}", "$$$", "\\0\\n\\", "^"] { + let _ = s.run("edit.replaceAll", &json!({"text": "(a)(?b)?", "with": with, "regex": true})); + } +} + +/// The body text after Replace All of `q` with `with` in `text`, matching case, with `extra` options. +fn rep_mode(text: &str, q: &str, with: &str, extra: serde_json::Value) -> String { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": text})); + let mut v = json!({"text": q, "with": with, "matchCase": true}); + if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + run(&mut s, "edit.replaceAll", v); + self::text(&s) +} + #[test] fn hostile_find_patterns_are_bounded() { let mut s = s(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 35ef212353..f8baa0a095 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4980,6 +4980,29 @@ mod tests { assert!(h.state().dialog.is_none(), "a click in the box, away from the X, closes the dialog"); } + /// #4177: Replace All in the Find and Replace dialog, with Regular expressions on, expands + /// capture groups (`$2 $1`, `\2 \1`, `${name}`) instead of inserting them literally. + #[test] + fn replace_all_with_regex_groups_from_the_dialog() { + use egui_kittest::kittest::Queryable; + for (with, want) in [("$2 $1", "John Smith and Jane Doe"), (r"\2 \1", "John Smith and Jane Doe"), ("${last}!", "Smith! and Doe!")] { + let mut h = app_harness(app_with("Smith, John and Doe, Jane")); + h.state_mut().run("ui.dialog", json!({"name": "replace"})).unwrap(); + h.run_steps(2); + if let Some(Dialog::Find { query, replace, regex, .. }) = h.state_mut().dialog.as_mut() { + *query = r"(?\w+), (\w+)".into(); + *replace = with.into(); + *regex = true; + } else { + panic!("Replace opens the Find and Replace dialog"); + } + h.run_steps(2); + h.get_by_label("Replace All").click(); + h.run_steps(2); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), want, "{with}"); + } + } + fn app_harness(app: WordApp) -> egui_kittest::Harness<'static, WordApp> { egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( |ui, app: &mut WordApp| { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2e9bb61305..d689780488 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -66,6 +66,10 @@ Save Changes exposes the document-specific question as an alert-dialog name and The Language dialog reads effective character formatting, including document defaults, paragraph styles and character styles. Accepting it unchanged keeps inherited language and no-proof choices (#2717); explicit direct formatting still takes precedence. Other proofing and Windows-specific reports remain separate. +## Find and Replace: regular expressions (#4177) + +With **Regular expressions** on (Search Options), Replace with expands capture groups: `$1`…`$n`, `${n}`, `${name}` and `$name` (the longest run of letters, digits and `_`), `$0` / `${0}` (the whole match), and `\1`…`\9` as in wildcard Replace; `$$` is a dollar sign, `\t` a tab, `\\` a backslash, `\$` and `\^` a `$` or `^` as typed, and `\n` a paragraph mark, which splits the paragraph as `^p` does (Replace All stays one undo step). Word's `^&`, `^p`, `^t`, `^c` and the other `^` codes work there too (with Special characters off, only the `^` codes are taken literally). A group that doesn't exist, didn't take part in the match or is out of range expands to nothing; a `$` that starts no group, an unclosed `${`, and other `\` escapes stay as written. Plain and wildcard Replace are unchanged. Word has no regular-expression mode; this follows the Rust `regex` crate's Replace syntax. + ## Find navigation (#2096) Find selects the Results tab, including when it prefills selected text. Editing the search query also reveals matching or empty results. Manual Headings and Pages navigation remains available until the next search action. @@ -80,6 +84,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | 2026-10-24 | trivial | Read Mode: a Page color menu (None, Sepia, Inverse) beside Line spacing; the columns are drawn on white, sepia or a dark page, in place of the document's page color (`view.readPageColor`) | | 2026-10-24 | trivial | Reading Mode: Read Mode's View menu in the parity catalog; Column Width and Page Color work, Layout is missing | | 2026-10-24 | trivial | Review › Accept and Reject menus: Accept and Move to Next and Reject and Move to Next resolve the change at the caret and select the next one (`review.accept` / `review.reject` `{"next": true}`); Accept All Changes and Stop Tracking and Reject All Changes and Stop Tracking also turn Track Changes off, in one undo step (`review.acceptAll` / `review.rejectAll` `{"stopTracking": true}`) | +| 2026-10-24 | trivial | Find and Replace, Regular expressions: Replace with expands `$1`, `${n}`, `${name}`, `$0`, `\1`…`\9`, `$$`, `\n` (splits the paragraph) and `\t`; missing or out-of-range groups expand to nothing (#4177) | | 2026-10-23 | trivial | Insert › Table: hovering the grid shows the table on the page, at the caret, as Word's Live Preview does; it follows the pointer and is laid out on a copy, so the document and Undo change only when a square is clicked (#3931) | | 2026-10-23 | trivial | Comment times: the Comments pane and the balloons show each comment's and reply's time in the user's time zone, under its author, as Word does. The file keeps UTC (`w16cex:dateUtc` when Word wrote one), a time without a zone shows as written, and the web app asks the browser for the zone (#2322, #2337) | | 2026-10-22 | trivial | Mail merge: First Record, Last Record, a record number box, and record choices for Edit Individual Documents and Print Documents (#3028) | From b638caa2d2ca6959690e78be828bc4e24a345d52 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:01:31 +0100 Subject: [PATCH 27/50] Keep a document's own styles and defaults when applying a style set (#4013) Fixes #3650 Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/design.rs | 18 ++++++++++++----- crates/engine/src/tests.rs | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index d1fedc1602..eb39891d10 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -169,11 +169,19 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult { _ => {} } } - // Keep custom styles and fonts. - let customs: Vec<_> = s.doc.styles.styles.iter().filter(|x| !x.builtin).cloned().collect(); - fresh.styles.extend(customs); - fresh.default_chr.font = s.doc.styles.default_chr.font.clone(); - s.doc.styles = fresh; + // Overlay the set onto the document's sheet: every other style, the latent styles and the + // document defaults stay as they are (no set defines its own defaults). + for mut st in fresh.styles { + match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) { + Some(old) => { + st.default = old.default; + st.locked = old.locked; + st.builtin = old.builtin; + *old = st; + } + None => s.doc.styles.styles.push(st), + } + } let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone()); set_fonts(s, &h, &b); set_accent(s, accent); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f44e5c7168..1f273d8289 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10577,3 +10577,38 @@ fn export_png_refuses_a_path_that_is_not_png() { } let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn style_set_keeps_imported_styles_latent_styles_and_defaults() { + use wordcraft_doc::props::{Rgb, TextColor}; + use wordcraft_doc::styles::{Style, StyleKind}; + for name in ["basic", "lines", "shaded", "casual", "centered", "minimalist", "title"] { + let mut s = s(); + let mut body = s.doc.styles.get("Normal").unwrap().clone(); + body.id = "BodyText".into(); + body.name = "Body Text".into(); + body.builtin = true; + body.based_on = Some("Normal".into()); + body.chr.size = Some(20.0); + body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0))); + let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() }; + s.doc.styles.styles.push(body.clone()); + s.doc.styles.styles.push(grid.clone()); + s.doc.styles.latent_styles = Some("".into()); + s.doc.styles.default_chr.lang = Some("de-DE".into()); + s.doc.styles.default_chr.size = Some(11.0); + run(&mut s, "design.styleSet", json!({ "name": name })); + assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText"); + assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4"); + assert_eq!(s.doc.styles.latent_styles.as_deref(), Some(""), "{name}"); + assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"), "{name}"); + assert_eq!(s.doc.styles.default_chr.size, Some(11.0), "{name}"); + assert_eq!(s.doc.styles.styles.iter().filter(|x| x.id == "Heading1").count(), 1, "{name}"); + let saved = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let back = crate::io::open_bytes("x.docx", &saved).unwrap(); + assert!(back.styles.get("BodyText").is_some(), "{name}: BodyText after round trip"); + } + let mut s = s(); + run(&mut s, "design.styleSet", json!({ "name": "title" })); + assert_eq!(s.doc.styles.get("Title").unwrap().chr.size, Some(36.0)); +} From 50a1c34647e1fcae52fcc02daf92c3fce64c5651 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 25 Oct 2026 16:06:42 +0100 Subject: [PATCH 28/50] #1475: Scale all style font sizes at once (#2620) Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> --- ROADMAP.md | 2 + crates/engine/src/cmd/design.rs | 43 ++++++++++++++++++- crates/engine/src/tests.rs | 76 +++++++++++++++++++++++++++++++++ docs/ui-parity.md | 1 + 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2d74a9a01a..91e5ce842b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -303,6 +303,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line | | 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. | | 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. | +| 2026-10-17 | `design.scaleFontSizes` (Design › Document Formatting): grow or shrink every style's font size at once, by points (`delta`) or percent, rounded to half points and clamped to 1–1638 pt; inherited sizes follow, runs are untouched, one undo step (#1475) | | 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) | | 2026-10-15 | AutoCorrect recognizes `-->` as `→` (#2223), respecting replacement settings and undo without changing paragraph-start arrow bullets. | | 2026-10-15 | Numbered headings (#1793): the Multilevel List gallery offers four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1, Article I. / Section 1.01 / (a), I. / A. / 1., Chapter 1; `list.multilevelPreset`). The heading styles carry the numbers, so paragraphs already in a Heading style keep their style and level, any other selected paragraph (Normal, Quote, Title) becomes Heading 1 (checked in Word), a new Heading 2 is numbered 2.1 by itself and body text stays unnumbered; Numbering or typing `1. ` keeps a heading a heading | @@ -779,6 +780,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-16 | trivial | Progress log: a text frame in the paragraph defaults frames nothing (#2360) | | 2026-10-16 | trivial | Progress log: a text file saved over itself closes without asking (#2376) | | 2026-10-15 | trivial | Progress log: recovery copies and .json files store pictures as base64 (#586 follow-up) | +| 2026-10-17 | trivial | Progress log: Scale Font Sizes command (#1475) | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index eb39891d10..84ca5c8538 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -1,7 +1,7 @@ //! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders. use serde_json::{Value, json}; -use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing, Rgb, TextColor}; +use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor}; use wordcraft_doc::{Watermark, styles}; use super::sel_result; @@ -67,6 +67,8 @@ pub fn specs() -> Vec { .params(r#"{"name": "default|basic|lines|shaded|casual|centered|minimalist|title"}"#), CommandSpec::new("design.paragraphSpacing", "Paragraph Spacing", "Design › Document Formatting", para_spacing) .params(r#"{"value": "default|none|compact|tight|open|relaxed|double"}"#), + CommandSpec::new("design.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes) + .params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#), CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark) .params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#), CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| { @@ -204,6 +206,45 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } +/// Grow or shrink every style's font size at once. Only explicit style sizes change (including +/// table style parts); styles that inherit one (via `based_on` or the defaults) follow, and runs +/// keep their own direct formatting. +fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult { + let (delta, percent) = (p::f32(v, "delta"), p::f32(v, "percent")); + // A field that is present but not a number is an error, not "absent". + if (v.get("delta").is_some() && delta.is_none()) || (v.get("percent").is_some() && percent.is_none()) { + return Err(CmdError::Params("`delta` and `percent` must be numbers".into())); + } + if delta.is_some() == percent.is_some() { + return Err(CmdError::Params("give exactly one of `delta` (points) or `percent`".into())); + } + if delta.is_some_and(|d| !(-1638.0..=1638.0).contains(&d) || d == 0.0) || percent.is_some_and(|x| !(-99.0..=1000.0).contains(&x) || x == 0.0) { + return Err(CmdError::Params("`delta` must be non-zero within ±1638 pt and `percent` non-zero within -99..1000".into())); + } + let factor = 1.0 + percent.unwrap_or(0.0) / 100.0; + let scale = |size: f32| ((size * factor + delta.unwrap_or(0.0)).clamp(1.0, 1638.0) * 2.0).round() / 2.0; + let scale_chr = |c: &mut CharProps| { + c.size = c.size.map(scale); + c.size_cs = c.size_cs.map(scale); + }; + let st = &mut s.doc.styles; + // An unset document default resolves to the fallback size; make it explicit so inheriting styles follow. + st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE))); + st.default_chr.size_cs = st.default_chr.size_cs.map(scale); + for style in &mut st.styles { + scale_chr(&mut style.chr); + if let Some(t) = style.table.as_mut() { + for c in [&mut t.header_chr, &mut t.band_chr, &mut t.first_col_chr, &mut t.last_col_chr, &mut t.total_chr, &mut t.col_band_chr] { + scale_chr(c); + } + for part in t.regions.values_mut() { + scale_chr(&mut part.chr); + } + } + } + sel_result(s) +} + fn watermark(s: &mut Session, v: &Value) -> CmdResult { if p::bool(v, "remove").unwrap_or(false) { s.doc.settings.watermark = None; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1f273d8289..de0a6c4eb0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10612,3 +10612,79 @@ fn style_set_keeps_imported_styles_latent_styles_and_defaults() { run(&mut s, "design.styleSet", json!({ "name": "title" })); assert_eq!(s.doc.styles.get("Title").unwrap().chr.size, Some(36.0)); } + +fn style_size(s: &Session, id: &str) -> f32 { + s.doc.styles.resolve_char(Some(id), &Default::default()).size +} + +#[test] +fn scale_font_sizes_adds_points_to_every_style() { + let mut s = Session::new(crate::sample::sample_document()); + let ids: Vec = s.doc.styles.styles.iter().map(|x| x.id.clone()).collect(); + let before: Vec = ids.iter().map(|i| style_size(&s, i)).collect(); + let base = s.doc.styles.resolve_char(None, &Default::default()).size; + let runs = serde_json::to_string(&s.doc.body).unwrap(); + run(&mut s, "design.scaleFontSizes", json!({"delta": 2})); + // Every style (explicit or inherited) grows by exactly 2, once. + for (i, b) in ids.iter().zip(&before) { + assert_eq!(style_size(&s, i), b + 2.0, "{i}"); + } + assert_eq!(s.doc.styles.resolve_char(None, &Default::default()).size, base + 2.0); + assert_eq!(serde_json::to_string(&s.doc.body).unwrap(), runs, "runs untouched"); + run(&mut s, "edit.undo", json!({})); + for (i, b) in ids.iter().zip(&before) { + assert_eq!(style_size(&s, i), *b, "undo {i}"); + } +} + +#[test] +fn scale_font_sizes_percent_and_clamp_and_errors() { + let mut s = Session::new(crate::sample::sample_document()); + let n = style_size(&s, "Normal"); + run(&mut s, "design.scaleFontSizes", json!({"percent": 10})); + assert_eq!(style_size(&s, "Normal"), (n * 1.1 * 2.0).round() / 2.0); + run(&mut s, "design.scaleFontSizes", json!({"delta": -500})); + assert_eq!(style_size(&s, "Normal"), 1.0); + for bad in [ + json!({}), + json!({"delta": 1e9}), + json!({"percent": -100}), + json!({"percent": 1e9}), + json!({"delta": "x"}), + json!({"delta": 0}), + json!({"percent": 0}), + json!({"delta": 1, "percent": 10}), + json!({"delta": 2, "percent": "x"}), + json!({"percent": 10, "delta": null}), + ] { + assert!(s.run("design.scaleFontSizes", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() { + use wordcraft_doc::props::CharProps; + use wordcraft_doc::{Style, StyleKind, styles::TablePart}; + let mut s = s(); + s.doc.styles.default_chr.size = None; + s.doc.styles.default_chr.size_cs = Some(10.0); + let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() }; + ch.chr.size = Some(20.0); + ch.chr.size_cs = Some(22.0); + let mut tb = Style { id: "Grid".into(), name: "Grid".into(), kind: StyleKind::Table, ..Default::default() }; + let mut parts = wordcraft_doc::styles::TableStyleParts::default(); + parts.header_chr.size = Some(14.0); + parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0); + tb.table = Some(parts); + s.doc.styles.styles.extend([ch, tb]); + run(&mut s, "design.scaleFontSizes", json!({"delta": 2})); + let st = &s.doc.styles; + assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit"); + assert_eq!(st.default_chr.size_cs, Some(12.0)); + let big = CharProps { style: Some("Big".into()), ..Default::default() }; + assert_eq!(st.resolve_char(None, &big).size, 22.0); + assert_eq!(st.resolve_char(None, &big).size_cs, 24.0); + let grid = st.styles.iter().find(|x| x.id == "Grid").and_then(|x| x.table.as_ref()).unwrap(); + assert_eq!(grid.header_chr.size, Some(16.0)); + assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0)); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d689780488..49e532077d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -158,6 +158,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | 2026-10-16 | trivial | Rotation handle: pictures and shapes without text stories turn with the preview while the pointer is held; text boxes retain frame-only previews; release commits one `arrange.rotation` command, Escape cancels (#2277) | | 2026-10-16 | trivial | Save Changes prompt: a plain-text or Markdown file the document was opened from counts as saved once Save writes it, so closing after Ctrl+S asks nothing and Save in the prompt closes the window. Save As to such a format elsewhere is still a copy that leaves the document unsaved (#2376) | | 2026-10-16 | trivial | On-canvas objects: dragging an In Line with Text picture, shape or text box shows a dashed insertion cursor in the text and drops it there, still inline, instead of floating it with Square wrapping (`arrange.bounds` / `arrange.copyTo` `to`, #2233, #2234) | +| 2026-10-17 | trivial | Design › Document Formatting gains Scale Font Sizes (`design.scaleFontSizes`, +N pt or +N%, all styles at once; no Word equivalent) (#1475) | | 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) | | 2026-10-17 | minor | Insert › Drop Cap is a menu (None, Dropped, In margin, Drop Cap Options…) with a Drop Cap dialog (position, font, lines 1–10, distance 0–22 in), #2601; Modal dialogs 56 → 57 | | 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% | From 0cb2af4f4b60b12be738fcd8b9aff102da3b35df Mon Sep 17 00:00:00 2001 From: dhruvLearner8 <83899022+dhruvLearner8@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:11:20 -0600 Subject: [PATCH 29/50] =?UTF-8?q?AutoFormat=20As=20You=20Type:=20optional?= =?UTF-8?q?=20"Add=20a=20period=20with=20double-space",=20and=20an=20AutoC?= =?UTF-8?q?orrect=20Options=20button=20in=20Options=20=E2=80=BA=20Proofing?= =?UTF-8?q?=20(#2848)=20(#3318)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * AutoFormat As You Type: optional period with double-space, off by default (#2848) * Test the double-space option through the AutoCorrect dialog (#2848) * File > Options > Proofing: an AutoCorrect Options button opens the dialog (#476) * #2848: report doubleSpacePeriod in tools.autocorrect's params doc and result Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 25 ++++++++++++++++ crates/engine/src/cmd/tools.rs | 8 ++++- crates/engine/src/tests_typing.rs | 45 ++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs_review.rs | 42 ++++++++++++++++++++++++++ crates/ui-egui/src/options.rs | 13 +++++++- docs/typing-parity.md | 8 +++++ 6 files changed, 139 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 5761a3e94c..e0ab3e4dec 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -101,6 +101,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { let raw = p::bool(v, "raw").unwrap_or(false); typing_options(s, text); let t = if raw { None } else { autoformat(s, text) }; + let was_collapsed = s.sel.is_collapsed(); type_text(s, t.as_deref().unwrap_or(text))?; let trigger = text.chars().count() == 1 && text.chars().all(|c| c == ' ' || ",.;:!?".contains(c)); if raw || !trigger { @@ -117,6 +118,9 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { if text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes { dash_autoformat(s).inspect_err(|_| s.autocap = None)?; } + if text == " " && !listed && was_collapsed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.double_space_period { + double_space_period(s)?; + } if let Some((para, sel)) = typed && s.doc.para_at(&f) != Some(¶) { @@ -187,6 +191,27 @@ fn list_autoformat(s: &mut Session) -> Result { Ok(true) } +/// "word" + two spaces → "word. ": the second Space turns the first into a period, after a +/// letter, a digit or a closing bracket or quote; not after other punctuation, at the start of a +/// paragraph, after a tab, or for a third space (the text before them ends in a period by then). +fn double_space_period(s: &mut Session) -> Result<(), CmdError> { + let f = s.sel.focus.clone(); + let Some(para) = s.doc.para_at(&f) else { return Ok(()) }; + let Some(before) = para.text.get(..f.off) else { return Ok(()) }; + let Some(word) = before.strip_suffix(" ") else { return Ok(()) }; + if !word.chars().next_back().is_some_and(|c| c.is_alphanumeric() || ")]}\"'\u{201D}\u{2019}\u{00BB}\u{203A}".contains(c)) { + return Ok(()); + } + // The period takes the formatting of the space it replaces. + let at = word.len(); + let p = s.doc.para_mut(f.story, &f.path)?; + let props = p.props_at(at + 1).clone().without_symbol(); + p.delete(at, at + 1)?; + p.insert_text(at, ".", &props)?; + s.sel = Selection::caret(f); + Ok(()) +} + /// "word -- word" → en dash, "word--word" → em dash (on the space after). fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { let f = s.sel.focus.clone(); diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 4bc606ddc7..e42035bd0f 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -114,6 +114,9 @@ pub struct AutoCorrectPrefs { /// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs). /// Off by default. pub heading_styles: bool, + /// A second Space after a word, number or closing bracket or quote turns the first into a + /// period (macOS's "Add period with double-space"); off by default. + pub double_space_period: bool, /// The user's entries: added, or replacing a built-in one. pub entries: Vec<(String, String)>, /// Built-in entries the user deleted. @@ -152,6 +155,7 @@ impl Default for AutoCorrectPrefs { border_lines: true, tab_indent: false, heading_styles: false, + double_space_period: false, entries: Vec::new(), removed: Vec::new(), exceptions: Vec::new(), @@ -344,6 +348,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { ("borderLines", &mut ac.border_lines), ("tabIndent", &mut ac.tab_indent), ("headingStyles", &mut ac.heading_styles), + ("doubleSpacePeriod", &mut ac.double_space_period), ("autoAddFirstLetter", &mut ac.auto_add_first_letter), ("autoAddInitialCaps", &mut ac.auto_add_initial_caps), ] { @@ -420,6 +425,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { "borderLines": ac.border_lines, "tabIndent": ac.tab_indent, "headingStyles": ac.heading_styles, + "doubleSpacePeriod": ac.double_space_period, "autoAddFirstLetter": ac.auto_add_first_letter, "autoAddInitialCaps": ac.auto_add_initial_caps, "entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::>(), @@ -457,7 +463,7 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options) .params( - r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#, + r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "doubleSpacePeriod"?: bool (Add a period with double-space; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#, ) .pure(), CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard) diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 37ff3ccb38..007838f74c 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -3320,3 +3320,48 @@ fn tab_with_a_partial_selection_still_inserts_a_tab() { run(&mut s, "text.tab", json!({})); assert_eq!(paras(&s), vec![para("H\t world", None)]); } + +/// #2848: "Add a period with double-space" (an AutoFormat As You Type switch, off by default): +/// a second Space after a word, a number or a closing bracket or quote turns the first into a +/// period. Undo puts the two spaces back; other punctuation, a lone space and a run of three +/// spaces are left as typed. +#[test] +fn double_space_adds_a_period_when_asked() { + let with_option = |keys: &str| { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"doubleSpacePeriod": true})); + typ(&mut s, keys); + s + }; + let text = |s: &Session| s.doc.plain_text(StoryRef::Body); + // Off unless asked for. + assert_eq!(text(&typed("Hello world")), "Hello world"); + assert!(!s().prefs.autocorrect.double_space_period); + // On: the second space makes the period, and the next sentence starts with a capital. + assert_eq!(text(&with_option("Hello world")), "Hello. world"); + assert_eq!(text(&with_option("Hello world ")), "Hello. World "); + assert_eq!(text(&with_option("See page 5 Then")), "See page 5. Then"); + assert_eq!(text(&with_option("(it is done) Next")), "(it is done). Next"); + assert_eq!(text(&with_option("He said \"stop\" Next")), "He said “stop”. Next"); + // Undo brings the two spaces back, in one step. + let mut t = with_option("Hello "); + assert_eq!(text(&t), "Hello. "); + run(&mut t, "edit.undo", json!({})); + assert_eq!(text(&t), "Hello "); + // Left alone: other punctuation before the spaces, a lone space, spaces at the start of a + // paragraph or after a tab, and a third space. + for keys in ["Hello. x", "Hello, x", "Hello? x", "Hello; x", "Hello: x", "Hello x", " x", "\n x", "a\t x"] { + let want = typed(keys); + assert_eq!(text(&with_option(keys)), text(&want), "{keys:?}"); + } + assert_eq!(text(&with_option("Hello x")), "Hello. x"); + // A selection typed over, and the master switch. + let mut t = with_option("Hello world"); + run(&mut t, "select.all", json!({})); + run(&mut t, "text.insert", json!({"text": " "})); + assert_eq!(text(&t), " "); + let mut t = s(); + run(&mut t, "tools.autocorrect", json!({"doubleSpacePeriod": true, "enabled": false})); + typ(&mut t, "Hello world"); + assert_eq!(text(&t), "Hello world"); +} diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 14ca2b78df..da98f115a2 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -356,6 +356,7 @@ impl AutoCorrectForm { "borderLines": o.border_lines, "tabIndent": o.tab_indent, "headingStyles": o.heading_styles, + "doubleSpacePeriod": o.double_space_period, "autoAddFirstLetter": o.auto_add_first_letter, "autoAddInitialCaps": o.auto_add_initial_caps, }) @@ -401,6 +402,7 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo ui.checkbox(&mut o.bold_italic, tl!("*Bold* and _italic_ as real formatting")); ui.checkbox(&mut o.dashes, tl!("Hyphens (--) as dashes (—)")); ui.checkbox(&mut o.links, tl!("Internet addresses as links")); + ui.checkbox(&mut o.double_space_period, tl!("Add a period with double-space")); heading(ui, "Apply as you type"); ui.checkbox(&mut o.bullets, tl!("Automatic bulleted lists")); ui.checkbox(&mut o.numbering, tl!("Automatic numbered lists")); @@ -897,4 +899,44 @@ mod tests { } assert!(texts(&h).iter().any(|t| t.contains("(none)")), "no matches: the hint shows"); } + + /// #2848 through the real dialog: tick "Add a period with double-space" on the AutoFormat As + /// You Type tab, press OK, and a double Space typed in the page makes a period. + #[test] + fn the_double_space_option_is_set_in_the_dialog_and_works_when_typing() { + use egui_kittest::kittest::Queryable; + let mut app = app(""); + app.run("tools.autocorrect", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + h.get_by_label("AutoFormat As You Type").click(); + for _ in 0..3 { + h.step(); + } + h.get_by_label("Add a period with double-space").click(); + for _ in 0..3 { + h.step(); + } + h.get_by_label("OK").click(); + for _ in 0..4 { + h.step(); + } + assert!(h.state().dialog.is_none(), "OK closes the dialog"); + assert!(h.state().session.prefs.autocorrect.double_space_period, "the option is on"); + h.state_mut().canvas.focused = true; + for t in ["Hello", " ", " ", "world"] { + h.event(egui::Event::Text(t.to_string())); + h.step(); + } + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Hello. world"); + } } diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index b758d99935..42ec30346a 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -292,7 +292,11 @@ fn display(app: &mut WordApp, ui: &mut Ui) { } fn proofing(app: &mut WordApp, ui: &mut Ui) { - // #476 (AutoCorrect Options…) goes here, at the top. + section(ui, "AutoCorrect"); + if ui.button(tl!("AutoCorrect Options")).clicked() { + let _ = app.run("tools.autocorrect", json!({})); + } + ui.add_space(8.0); section(ui, "Spelling"); let pr = app.session.prefs.clone(); flag(app, ui, "Ignore words in capitals", "ignoreUppercase", pr.ignore_uppercase); @@ -1089,6 +1093,13 @@ mod tests { h.get_by_label("Ignore words in capitals").click(); h.run_steps(2); assert!(!h.state().session.prefs.ignore_uppercase); + // The AutoCorrect Options dialog opens from this pane. + assert!(h.state().dialog.is_none()); + h.get_by_label("AutoCorrect Options").click(); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("autoCorrect")); + h.state_mut().dialog = None; + h.run_steps(2); // #1063: the dictionaries folder and the dictionary each language found. assert!(h.query_by_label("Dictionaries folder:").is_some()); assert!(h.query_by_label_contains("Polish: ").is_some(), "the Polish dictionary's line"); diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 5ab504bfb5..a9c5c49443 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -156,6 +156,13 @@ ideographic space (`autocorrect_finds_the_word_after_a_wide_space`): is “ (U+201C) even though it shows on the right of the word, after a letter ” (U+201D); in an acronym (`צה"ל`) it becomes ” as in Word for Mac 16.113.4, not gershayim (״); `'` after a letter (`ג'ירפה`) is ’ as after any letter, with no geresh conversion (not yet checked in Word; `smart_quotes_in_hebrew_use_logical_order`, #1670). +- Add a period with double-space (File › Options › Proofing › AutoCorrect Options › AutoFormat As + You Type, `tools.autocorrect {"doubleSpacePeriod"}`; **off by default**, as macOS's system switch + is a per-user choice): a second Space after a letter, a digit or a closing bracket or quote turns + the first into a period (`Hello world`→`Hello. world`). Other punctuation before the spaces, + a lone space, the start of a paragraph, a tab, a selection typed over and a third space are left + as typed; Undo puts the two spaces back (`double_space_adds_a_period_when_asked`, #2848). Not + observed in Word; Word for Mac follows the macOS setting. - Correct TWo INitial CApitals: a finished word of three or more letters with two capitals and the rest lowercase loses its second capital (`WOrd`→`Word`, `TEh`→`The`); not the exceptions (`IDs`, `CDs`, `PCs`, `TVs`, … plus the user's, `tools.autocorrect {"addInitialCapsException"}`) @@ -427,6 +434,7 @@ again. | 2026-10-19 | trivial | Delete on whole selected cells (rows, columns, the table, a cell) leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too (#2802) | | 2026-10-19 | trivial | Ctrl+Tab (⌥Tab on macOS) types a tab character in a table cell (#3137) | | 2026-10-16 | trivial | AltGr on Linux (Wayland) types ś, ł, ż… instead of opening KeyTips: the modifiers event without Alt that follows the Right Alt key no longer reads as Alt let go, and a bare AltGr tap doesn't open KeyTips (#2388) | +| 2026-10-20 | trivial | AutoFormat As You Type: optional "Add a period with double-space" (off by default), undoable (#2848) | | 2026-10-18 | trivial | Typing over a selection takes the first selected character's formatting, also when the selection was made right to left (#2879) | | 2026-10-17 | trivial | A table inserted in a right-to-left paragraph is a right-to-left table, so Tab moves to the cell on the left (#2634) | | 2026-10-15 | trivial | Backspace after a section break and Delete before it remove the break, also one on the last paragraph's mark; the text takes the following section's setup and keeps the headers it showed (#2127) | From d5f23907dc430873f16393e2f519d955e0fcf2c1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:15:42 +0100 Subject: [PATCH 30/50] Bound total LCS work per review.compare (#4006) Fixes #3632 Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/tools.rs | 63 +++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index e42035bd0f..1f1aaa658e 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -1361,7 +1361,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult { // Tables match as a whole, by their text. Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))), }; - let ops = lcs_ops(&old.iter().map(key).collect::>(), &new.iter().map(key).collect::>()); + let mut budget = MAX_COMPARE_CELLS; + let ops = lcs_ops(&old.iter().map(key).collect::>(), &new.iter().map(key).collect::>(), &mut budget); let mut out: Vec> = Vec::new(); let (mut i, mut j) = (0usize, 0usize); let mut changes = 0usize; @@ -1397,7 +1398,7 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult { for x in 0..dels.max(inss) { let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None }); match (a.map(|x| &**x), b.map(|x| &**x)) { - (Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts))), + (Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts, &mut budget))), _ => { if let Some(x) = a { out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0))); @@ -1473,15 +1474,26 @@ enum Op { /// compared as deleted and inserted, never dropped. const MAX_LCS: usize = 4000; +/// Comparison table cells one compare may fill over all its tables (blocks and every changed +/// paragraph pair); once spent, the remaining middles are compared as deleted and inserted. +const MAX_COMPARE_CELLS: u64 = 50_000_000; + /// LCS edit script between two sequences: the common start and end are matched first, then the -/// middle (bounded size). -fn lcs_ops(a: &[T], b: &[T]) -> Vec { +/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the +/// middle is deleted and inserted whole. +fn lcs_ops(a: &[T], b: &[T], budget: &mut u64) -> Vec { let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count(); let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default()); let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count(); let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default()); let mut ops = vec![Op::Same; pre]; - let (n, m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS)); + let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS)); + let cells = (n as u64).saturating_mul(m as u64); + if cells > *budget { + (n, m) = (0, 0); + } else { + *budget -= cells; + } let mut t = vec![vec![0u32; m + 1]; n + 1]; for i in (0..n).rev() { for j in (0..m).rev() { @@ -1595,11 +1607,11 @@ fn tokens(s: &str, characters: bool) -> Vec { v } -fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions) -> Paragraph { +fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph { let (at, bt) = (a.plain_text(), b.plain_text()); let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF; let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars)); - let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::>(), &tb.iter().map(|t| opts.key(t)).collect::>()); + let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::>(), &tb.iter().map(|t| opts.key(t)).collect::>(), budget); // Only at character granularity: a matched whole word almost never happens by coincidence, // but a matched single character very often does (digits, punctuation), so only there does // a short interior match need folding back into its surrounding edit. @@ -2248,12 +2260,12 @@ mod tests { fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() { // "01.09" vs "19.10": a pure LCS finds a scrambled chain of single-character matches // ('1', '.', '0') threaded through what a reader expects to be one clean replacement. - let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0']); + let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0'], &mut { u64::MAX }); assert!(ops.contains(&Op::Same), "the raw LCS does find spurious matches here, or this test proves nothing"); assert_eq!(collapse_noise_matches(&ops), vec![Op::Del, Op::Del, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Ins, Op::Ins, Op::Ins]); // A plain single-character replacement flanked by a real common prefix and suffix has no // interior match to begin with (the DP's middle is pure delete/insert), so it's untouched. - let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f']); + let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f'], &mut { u64::MAX }); assert_eq!(collapse_noise_matches(&ops), ops); } @@ -2271,6 +2283,39 @@ mod tests { ); } + #[test] + fn compare_bounds_total_diff_work() { + // Many long, slightly different paragraphs: each pair alone fits one comparison table, + // but the work over all pairs must stay bounded. Pairs past the budget are compared + // whole (deleted and inserted), and every pair still counts as a change. + let words = 3000; + let paras = 12; + let doc = |rev: bool| -> String { + (0..paras) + .map(|p| (0..words).map(|w| if w % 10 == 0 && rev { format!("x{p}w{w}") } else { format!("p{p}w{w}") }).collect::>().join(" ")) + .collect::>() + .join("\n") + }; + let (original, revised) = (doc(false), doc(true)); + let mut s = Session::new(Document::from_text(&original)); + let r = s.run("review.compare", &json!({"text": revised})).unwrap(); + assert_eq!(r["changes"], paras); + // A pair diffed word by word keeps most of its text unmarked; one replaced whole keeps + // only its common start and end. + let word_diffed = |b: &Arc| { + let p = b.as_para().unwrap(); + let same: usize = p.run_ranges().filter(|(_, c)| c.ins.is_none() && c.del.is_none()).map(|(r, _)| r.len()).sum(); + same * 2 > p.text.len() + }; + assert!(word_diffed(&s.doc.body[0])); + let diffed = s.doc.body.iter().filter(|b| word_diffed(b)).count() as u64; + let cells = (words as u64) * (words as u64); + assert!(diffed * cells <= 50_000_000 + cells, "{diffed} pairs diffed word by word"); + assert!(!word_diffed(s.doc.body.last().unwrap())); + s.run("review.acceptAll", &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), revised); + } + #[test] fn macros_record_and_run() { let mut s = Session::new(Document::new()); From bf70f48bbd7d6b9b6c6ad8ca33d7b096fa00d714 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:17:58 +0100 Subject: [PATCH 31/50] Bound MCP stdio request and bridge reply line reads (#3950) * Bound MCP stdio request and bridge reply line reads Fixes #3685 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 * #3685: an unreadable reply from the app is final, not retried A reply that is too long (or not UTF-8) means the app already ran the request; retrying on a new connection could run a state-changing request twice. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/mcp/src/backend.rs | 52 +++++++++++++++++++++++++++++++---- crates/mcp/src/server.rs | 57 +++++++++++++++++++++++++++++++++++---- crates/mcp/src/tests.rs | 17 ++++++++++++ 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 78cec12b2c..a07c893913 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -1,12 +1,17 @@ //! Where MCP tool calls end up: a control-channel method call. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufReader, Write}; use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs}; use std::path::Path; use std::time::Duration; use serde_json::{Value, json}; +use crate::server::{BoundedLine, read_bounded_line}; + +/// Longest reply line accepted from the app (screenshots and `document.inspect` are large). +const MAX_REPLY_LINE: usize = 256 << 20; + /// Something that answers control-channel methods (`engine.execute`, `document.inspect`, /// `ui.pointer`, `ui.render`, …). See `wordcraft_ui_egui::control` for the full list. pub trait Backend { @@ -161,11 +166,11 @@ impl Remote { writer.write_all(line.as_bytes())?; writer.write_all(b"\n")?; writer.flush()?; - let mut reply = String::new(); - if reader.read_line(&mut reply)? == 0 { - return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); + match read_bounded_line(reader, MAX_REPLY_LINE)? { + BoundedLine::Line(reply) => Ok(reply), + BoundedLine::Eof => Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")), + BoundedLine::TooLong => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reply from app too long")), } - Ok(reply) } /// One request and its reply, with one retry on a new connection (the app may have @@ -189,6 +194,11 @@ impl Remote { Ok(r) => break r, Err(e) => { self.conn = None; + // A reply that arrived but can't be read (too long, not UTF-8): the app has + // run the request, so sending it again could run a state-changing one twice. + if e.kind() == std::io::ErrorKind::InvalidData { + return Err(format!("bad reply from app: {e}")); + } if !first { return Err(unreachable(&self.addr, e, self.connected)); } @@ -246,6 +256,7 @@ impl Backend for Remote { #[cfg(test)] mod tests { use super::*; + use std::io::BufRead; use std::net::{SocketAddr, TcpListener}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -335,6 +346,37 @@ mod tests { (port, rx) } + #[test] + fn an_unreadable_reply_is_not_retried() { + // The app answered something that can't be read (here not UTF-8; an over-long reply + // takes the same path): the request ran, so it must not be sent again. + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(AtomicUsize::new(0)); + let n = seen.clone(); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let n = n.clone(); + std::thread::spawn(move || { + let mut out = stream.try_clone().unwrap(); + for line in BufReader::new(stream).lines() { + if line.is_err() { + return; + } + n.fetch_add(1, Ordering::SeqCst); + if out.write_all(b"\xff\xfe\n").is_err() { + return; + } + } + }); + } + }); + let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k1")).unwrap(); + let e = r.call("engine.execute", json!({"command": "text.insert", "params": {"text": "x"}})).unwrap_err(); + assert!(e.contains("bad reply"), "{e}"); + assert_eq!(seen.load(Ordering::SeqCst), 1, "the request was sent once"); + } + fn fixed_key(key: &'static str) -> KeyFinder { Box::new(move |_| Ok(key.to_string())) } diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 63f6764c61..0bca29e643 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -1,6 +1,6 @@ //! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods. -use std::io::{BufRead, Write}; +use std::io::{BufRead, Read, Write}; use serde_json::{Value, json}; @@ -28,6 +28,49 @@ execute format.bold → inspect_document to verify → render_page to look at th pub const DOC_URI: &str = "wordcraft://document"; pub const COMMANDS_URI: &str = "wordcraft://commands"; +/// Longest request line accepted on stdin. +pub const MAX_REQUEST_LINE: usize = 1 << 20; + +/// One bounded line read. +pub(crate) enum BoundedLine { + Eof, + Line(String), + /// The line exceeded the limit; the rest of it has been discarded. + TooLong, +} + +/// Read one line of at most `max` bytes (excluding the newline) without buffering more than +/// `max + 1` bytes; an over-long line is skipped up to its newline. +pub(crate) fn read_bounded_line(reader: &mut impl BufRead, max: usize) -> std::io::Result { + let mut buf = Vec::new(); + reader.by_ref().take(max as u64 + 1).read_until(b'\n', &mut buf)?; + if buf.is_empty() { + return Ok(BoundedLine::Eof); + } + if buf.last() != Some(&b'\n') && buf.len() > max { + loop { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + break; + } + let found = chunk.iter().position(|&b| b == b'\n'); + let used = found.map_or(chunk.len(), |i| i + 1); + reader.consume(used); + if found.is_some() { + break; + } + } + return Ok(BoundedLine::TooLong); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + } + String::from_utf8(buf).map(BoundedLine::Line).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) +} + /// An MCP server bound to one backend. pub struct Server { backend: Box, @@ -58,10 +101,14 @@ impl Server { /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is /// the protocol stream). - pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - for line in input.lines() { - let line = line?; - if let Some(reply) = self.handle_line(&line) { + pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + loop { + let reply = match read_bounded_line(&mut input, MAX_REQUEST_LINE)? { + BoundedLine::Eof => break, + BoundedLine::Line(line) => self.handle_line(&line), + BoundedLine::TooLong => Some(error(Value::Null, INVALID_REQUEST, "request line too long").to_string()), + }; + if let Some(reply) = reply { output.write_all(reply.as_bytes())?; output.write_all(b"\n")?; output.flush()?; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 12ea973f1e..f23f72f751 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -228,3 +228,20 @@ fn type_text_paragraphs_reports_a_refusal_and_types_nothing_after_it() { assert_eq!(calls[0].0, "text.insert"); assert_eq!(calls[0].1["text"], "one\rtwo", "the line break is a paragraph break"); } + +/// #3685: an over-long request line gets an error reply and the server keeps serving. +#[test] +fn over_long_request_line_is_rejected_and_serving_continues() { + let mut s = Server::new(Box::new(Headless::default())); + let mut input = vec![b'a'; (1 << 20) + 10]; + input.push(b'\n'); + input.extend_from_slice(br#"{"jsonrpc":"2.0","id":5,"method":"ping"}"#); + input.push(b'\n'); + let mut out = Vec::new(); + s.serve(std::io::Cursor::new(input), &mut out).unwrap(); + let text = String::from_utf8(out).unwrap(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines[0].contains("too long"), "{}", lines[0]); + assert!(lines[1].contains(r#""id":5"#), "{}", lines[1]); +} From 14891616113093c2c5874ae5d2a93712d178c766 Mon Sep 17 00:00:00 2001 From: "William de O. Silva" <157059679+will1788@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:21:53 -0300 Subject: [PATCH 32/50] fix/canvas-picture-crop-activation (#2440) Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 58 ++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bb610baafa..ff98ce0552 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3502,6 +3502,7 @@ fn context_menu(app: &mut WordApp, ui: &mut Ui) { item(ui, app, "Change Picture…", "ui.changePicture", json!({})); item(ui, app, "Reset Picture", "picture.reset", json!({})); item(ui, app, "Size and Crop…", "ui.tab", json!({"tab": "Picture Format"})); + item(ui, app, "Crop", "ui.crop", json!({})); } if picture || object { // A numbered caption under (or over) the picture, shape, text box or chart (#1230). @@ -7176,6 +7177,63 @@ mod tests { assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("にほん")); } + #[test] + fn context_menu_crop_item_activates_crop_mode() { + use egui_kittest::kittest::Queryable; + use wordcraft_doc::para::{InlineObject, OBJ}; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + let pic = + InlineObject::Image { media: "m".into(), w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }; + app.session.doc.insert_object(&Pos::body(0, 0), pic, &Default::default()).unwrap(); + let end = Pos::body(0, OBJ.len_utf8()); + app.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": end})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..6 { + h.step(); + } + let (page, rect) = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).map(|o| (o.page, o.rect)).expect("laid out"); + let at = page_to_screen(h.state_mut(), page, rect.x + rect.w / 2.0, rect.y + rect.h / 2.0).expect("on screen"); + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + h.step(); + } + h.step(); + assert!(h.state().canvas.context_menu_open, "context menu is open on image"); + h.get_by_label("Crop ").click(); + for _ in 0..3 { + h.step(); + } + assert!(crate::crop::active(h.state()).is_some(), "crop mode is active after clicking Crop in context menu"); + } + + #[test] + fn crop_is_disabled_when_cursor_is_adjacent_without_selection() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + let pic = wordcraft_doc::para::InlineObject::Image { + media: "m".into(), + w: 200.0, + h: 100.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; + app.session.doc.insert_object(&Pos::body(0, 0), pic, &Default::default()).unwrap(); + // Caret is collapsed right at pos 0 (adjacent to the picture). + app.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0)); + assert!(crate::ribbon::has_picture_selected(&app.session), "adjacent caret sees picture in ribbon"); + assert!(!crate::widgets::enabled(&app, "ui.crop"), "ui.crop is disabled when not selected as object"); + assert!(crate::crop::active(&app).is_none()); + } + fn page_at_index(rects: &[Rect], p: Pos2) -> Option { nearest_page(rects, p) } From 40a7fe6be26045b1f6edf0f501bc33157320989a Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:34:23 +0100 Subject: [PATCH 33/50] Make three-column header/footer presets editable tables (#2197) (#2266) Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 4 + crates/engine/src/cmd/insert.rs | 32 +++++- crates/engine/tests/it/main.rs | 1 + .../engine/tests/it/three_column_headers.rs | 101 ++++++++++++++++++ crates/ui-egui/src/canvas.rs | 31 ++++-- docs/file-format-parity.md | 3 + docs/gaps.md | 3 + docs/typing-parity.md | 3 + docs/ui-parity.md | 3 + 9 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 crates/engine/tests/it/three_column_headers.rs diff --git a/ROADMAP.md b/ROADMAP.md index 91e5ce842b..7ff5171304 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,6 +99,8 @@ the two partial ones (real-world .docx, printing) complete, roughly. Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parity.md#feature-areas). +> **Three-column preset update:** 2026-10-15 · headers and footers use three editable, borderless table cells with guides while editing (#2197). Readiness estimates unchanged. + ## Languages | Language | Code | Status | UI strings | @@ -330,6 +332,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-15 | Layout › Columns with text selected puts only that text in columns, as in Word (#2024): a continuous section break goes where the selection starts and one where it ends (splitting a paragraph there; none at the document's start or end, or where a section already begins or ends), and the columns go on the section between, one undo step; More Columns… offers "Selected text" and picks it. Before, the whole section took the columns | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | +| 2026-10-15 | The Three columns header/footer preset inserts a borderless one-row, three-cell table spanning the section text width, with left/center/right alignment (#2197). Tab moves between the cells. Cell guides show while editing the story and disappear when it is closed, unless View Gridlines is enabled. | | 2026-10-12 | Table formulas: cell references and ranges (`=B2+B3`, `=SUM(B2:C3)`), arithmetic and comparisons, IF, AND, OR, NOT, TRUE, FALSE, DEFINED and the `\#` number format; ABOVE and LEFT sum the run of numbers next to the cell as Word does; an unknown function or a syntax error is an error instead of a sum; Update Field (F9) recalculates formula fields | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | | 2026-10-18 | Different First Page works from page 1 (#2826): Insert › Header and Edit Header with the caret on page 1, and a double-click there while another header is being edited, go into page 1's own header instead of the default one page 1 no longer shows. | @@ -783,6 +786,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-17 | trivial | Progress log: Scale Font Sizes command (#1475) | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | +| 2026-10-15 | trivial | Three-column header/footer presets use editable table cells and screen-only editing guides (#2197); readiness unchanged | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-12 | trivial | Progress log: one selected cell's border drags in its row only (#962) | | 2026-10-16 | trivial | Progress log: opening a DOCX no longer copies its pictures | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 42a242ef7f..a556f48dca 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap}; -use wordcraft_doc::props::{Align, CharProps, Rgb, TabAlign, TabStop, TextColor}; +use wordcraft_doc::props::{Align, Border, Borders, CharProps, Rgb, TabAlign, TabStop, TextColor}; use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block}; use super::{delete_selection, sel_result, split_para, type_text}; @@ -586,18 +586,42 @@ fn hf_part(s: &mut Session, header: bool) -> u32 { id } +/// Three independently editable cells spanning the section's text width. +fn three_column_hf(s: &Session, style: &str) -> wordcraft_doc::Blocks { + let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize; + // The section `section_mut` would pick, read without making anything unique. + let sections = s.doc.sections(); + let own = sections.iter().position(|(end, _)| block <= *end).filter(|i| i + 1 < sections.len()).and_then(|i| sections.get(i)); + let width = own.map_or_else(|| s.doc.last_section.text_width(), |(_, sec)| sec.text_width()); + let mut table = Table::new(1, 3, width); + table.props.style = Some("TableNormal".into()); + table.props.width = Some(width); + table.props.fixed = true; + // Explicit nil borders also override borders inherited from the document's table style. + table.props.borders = Some(Borders::all(Border::default())); + for row in &mut table.rows { + for (cell, align) in row.cells.iter_mut().zip([Align::Left, Align::Center, Align::Right]) { + let mut para = Paragraph::new().styled(style); + para.props.align = Some(align); + cell.blocks = vec![para_block(para)]; + } + } + // Keep a trailing paragraph so the story can also be edited after the table. + vec![std::sync::Arc::new(Block::Table(table)), para_block(Paragraph::new().styled(style))] +} + fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult { let id = hf_part(s, header); let style = if header { "Header" } else { "Footer" }; if let Some(text) = p::str(v, "text") { let blocks = text.split('\n').map(|l| para_block(Paragraph::with_text(l, CharProps::default()).styled(style))).collect(); s.doc.set_story(StoryRef::Part(id), blocks)?; + } else if p::str(v, "preset") == Some("blankThree") { + let blocks = three_column_hf(s, style); + s.doc.set_story(StoryRef::Part(id), blocks)?; } else if let Some(preset) = p::str(v, "preset") { let mut para = Paragraph::new().styled(style); match preset { - "blankThree" => { - para.insert_text(0, "[Type here]\t[Type here]\t[Type here]", &CharProps::default())?; - } "title" => { para.insert_text(0, if s.doc.core.title.is_empty() { "[Document title]" } else { &s.doc.core.title }, &CharProps::default())?; para.props.align = Some(Align::Center); diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 860d23ece7..5b00084fde 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -8,3 +8,4 @@ mod recovery_format; mod rtf_sections; #[cfg(not(target_arch = "wasm32"))] mod safe_save; +mod three_column_headers; diff --git a/crates/engine/tests/it/three_column_headers.rs b/crates/engine/tests/it/three_column_headers.rs new file mode 100644 index 0000000000..6312a03a48 --- /dev/null +++ b/crates/engine/tests/it/three_column_headers.rs @@ -0,0 +1,101 @@ +//! #2197: three-column header/footer presets are editable, borderless tables. +use serde_json::json; +use wordcraft_doc::props::Align; +use wordcraft_doc::{Block, Document, Path, Pos, StoryRef, Table}; +use wordcraft_engine::Session; + +fn table(doc: &Document, story: StoryRef) -> &Table { + let Block::Table(table) = &*doc.story(story).unwrap()[0] else { panic!("three columns must be a table") }; + table +} + +#[test] +fn three_columns_are_borderless_cells_with_the_story_style_and_section_width() { + for (command, header, style, existing) in [ + ("insert.header", true, "Header", false), + ("insert.header", true, "Header", true), + ("insert.footer", false, "Footer", false), + ("insert.footer", false, "Footer", true), + ] { + let mut doc = Document::from_text("Body text"); + doc.last_section.page_w = 540.0; + doc.last_section.margin_left = 54.0; + doc.last_section.margin_right = 54.0; + let width = doc.last_section.text_width(); + let mut s = Session::new(doc); + if existing { + s.run(command, &json!({"text": "Original story content"})).unwrap(); + s.run("insert.closeHeader", &json!({})).unwrap(); + } + let before = s.doc.clone(); + let depth = s.undo_depth(); + let id = s.run(command, &json!({"preset": "blankThree"})).unwrap()["story"].as_u64().unwrap() as u32; + let story = StoryRef::Part(id); + let t = table(&s.doc, story); + assert_eq!(t.rows.len(), 1); + assert_eq!(t.rows[0].cells.len(), 3); + assert_eq!(t.grid.len(), 3); + assert!((t.grid.iter().sum::() - width).abs() < 0.01); + assert!(t.grid.iter().all(|w| (*w - width / 3.0).abs() < 0.01)); + assert!(t.props.fixed); + assert!(!t.props.borders.as_ref().unwrap().any_visible()); + assert_ne!(t.props.style.as_deref(), Some("TableGrid")); + for (cell, align) in t.rows[0].cells.iter().zip([Align::Left, Align::Center, Align::Right]) { + let Block::Para(p) = &*cell.blocks[0] else { panic!("cell paragraph missing") }; + assert!(p.text.is_empty()); + assert_eq!(p.props.style.as_deref(), Some(style)); + assert_eq!(p.props.align, Some(align)); + } + assert_eq!(s.sel.focus, Pos { story, path: Path(vec![0, 0, 0, 0]), off: 0 }); + let after = s.doc.clone(); + assert_eq!(s.undo_depth(), depth + 1); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc, before); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc, after); + for (cell, text) in ["Left text", "Middle text", "Right text"].into_iter().enumerate() { + if cell > 0 { + s.run("text.tab", &json!({})).unwrap(); + } + assert_eq!(s.sel.focus, Pos { story, path: Path(vec![0, 0, cell as u32, 0]), off: 0 }); + s.run("text.insert", &json!({"text": text})).unwrap(); + } + let filled = s.doc.clone(); + s.run("edit.undo", &json!({})).unwrap(); + assert!(!s.doc.plain_text(story).contains("Right text")); + assert!(s.doc.plain_text(story).contains("Middle text")); + s.run("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc, filled); + s.run("select.text", &json!({"text": "Middle text"})).unwrap(); + s.run("format.bold", &json!({"value": true})).unwrap(); + let mut back = s.doc.clone(); + for _ in 0..2 { + back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap(); + let id = if header { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap(); + let t = table(&back, StoryRef::Part(id)); + assert_eq!(t.rows.len(), 1); + assert_eq!(t.rows[0].cells.len(), 3); + assert!((t.grid.iter().sum::() - width).abs() < 0.1); + assert!(t.props.fixed); + assert!(!t.props.borders.as_ref().unwrap().any_visible()); + for ((cell, text), align) in + t.rows[0].cells.iter().zip(["Left text", "Middle text", "Right text"]).zip([Align::Left, Align::Center, Align::Right]) + { + let Block::Para(p) = &*cell.blocks[0] else { panic!("cell lost") }; + assert_eq!(p.text, text); + assert_eq!(p.props.style.as_deref(), Some(style)); + assert_eq!(p.props.align, Some(align)); + assert_eq!(p.props_at(0).bold == Some(true), text == "Middle text"); + } + assert_eq!(back.plain_text(StoryRef::Body), "Body text"); + } + } +} + +#[test] +fn explicit_text_keeps_precedence_over_the_preset() { + let mut s = Session::new(Document::new()); + let id = s.run("insert.footer", &json!({"text": "Keep explicit text", "preset": "blankThree"})).unwrap()["story"].as_u64().unwrap() as u32; + assert_eq!(s.doc.plain_text(StoryRef::Part(id)), "Keep explicit text"); + assert!(matches!(&*s.doc.story(StoryRef::Part(id)).unwrap()[0], Block::Para(_))); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ff98ce0552..3ba32ad375 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -921,11 +921,13 @@ fn drawing_grid(body: wordcraft_geom::Rect, step: (f32, f32)) -> (Vec, Vec< (axis(body.x, body.w, step.0), axis(body.y, body.h, step.1)) } -/// The cells Table Layout › View Gridlines outlines on a page: the body's tables and the -/// header's and footer's (#1628), skipping cells covered by a merge. -fn gridline_cells(page: &Page) -> impl Iterator + '_ { - page.items.iter().chain(&page.header).chain(&page.footer).filter_map(|it| match it { - Placed::Cell { rect, covered: false, .. } => Some(*rect), +/// Global View Gridlines outlines every table (#1628). While editing a header/footer, also +/// outline that story's cells (#2197), without adding printable borders or changing the view flag. +fn gridline_cells(page: &Page, all: bool, editing: Option) -> impl Iterator + '_ { + // Automatic header/footer guides need not scan a potentially large body placement list. + let body = if all { page.items.as_slice() } else { &[] }; + body.iter().chain(&page.header).chain(&page.footer).filter_map(move |it| match it { + Placed::Cell { rect, covered: false, story, .. } if all || Some(*story) == editing => Some(*rect), _ => None, }) } @@ -1144,6 +1146,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let caret = layout.caret_on(&preview_sel(app).unwrap_or(app.session.sel_ends()).focus, app.session.page_hint); // Editing a header/footer (or a note) dims the body; once per frame, for every page. let dim_body = dims_body(app, &layout); + let grid_story = text_box_host(app, &layout).unwrap_or(app.session.sel.focus.story); + let grid_story = matches!(part_kind(app, grid_story), Some(PartKind::Header | PartKind::Footer)).then_some(grid_story); if let Some(c) = caret && !app.session.selects_whole_story() { @@ -1474,9 +1478,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { painter.line_segment([pos2(x0, sy), pos2(x1, sy)], stroke); } } - // Table Layout › View Gridlines: table cell outlines (on screen only). - if app.session.view.table_gridlines { - for rect in gridline_cells(page) { + // The current header/footer's cells have editing guides; explicit View Gridlines + // still outlines every table. Neither kind of guide is part of the document. + if app.session.view.table_gridlines || grid_story.is_some() { + for rect in gridline_cells(page, app.session.view.table_gridlines, grid_story) { let r = Rect::from_min_size( pos2(visual_sr.min.x + rect.x * page_scale, visual_sr.min.y + rect.y * page_scale), vec2(rect.w * page_scale, rect.h * page_scale), @@ -6410,12 +6415,18 @@ mod tests { }; let page = Page { items: vec![cell(100.0, false, StoryRef::Body), cell(110.0, true, StoryRef::Body)], - header: vec![cell(10.0, false, StoryRef::Part(1))], + header: vec![cell(10.0, false, StoryRef::Part(1)), cell(20.0, true, StoryRef::Part(1))], footer: vec![cell(200.0, false, StoryRef::Part(2))], ..Default::default() }; - let ys: Vec = gridline_cells(&page).map(|r| r.y).collect(); + let ys: Vec = gridline_cells(&page, true, None).map(|r| r.y).collect(); assert_eq!(ys, [100.0, 10.0, 200.0]); + let ys: Vec = gridline_cells(&page, true, Some(StoryRef::Part(2))).map(|r| r.y).collect(); + assert_eq!(ys, [100.0, 10.0, 200.0], "explicit View Gridlines still shows the other stories"); + let ys = |editing| gridline_cells(&page, false, editing).map(|r| r.y).collect::>(); + assert_eq!(ys(Some(StoryRef::Part(1))), [10.0]); + assert_eq!(ys(Some(StoryRef::Part(2))), [200.0]); + assert!(ys(None).is_empty(), "closing header/footer editing removes the automatic guides"); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index caed9879ce..b263e05f42 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -182,6 +182,8 @@ from these synthetic package checks, and native Word rendering/binding behavior Native `io::save_path_with` serializes (and, for password-protected DOCX, encrypts) before creating an exclusively owned sibling temporary file. It writes and synchronizes that file, closes it, then replaces the destination. Failed preparation or replacement keeps the original bytes; cleanup removes only the save's temporary file, on a best-effort basis. Existing `.tmp` files, directories and links are untouched. A new document gets the default mode (`0666` less the umask). When replacing an existing regular file, the Unix temporary file starts with mode `0600` and then takes the existing file's basic permissions. Serialization and format support are unchanged. Native `io::write_file` (Print to PDF, `file.printPdf`; Export Page as PNG, `file.exportPng`) writes through the same path (#1355). If the folder refuses the temporary file with a permission error (macOS privacy protection, a read-only folder; #2138), an existing destination is never written in place: it stays byte for byte as it was, and the error says to use Save As to another folder or to give WordCraft access to the folder (on macOS, System Settings › Privacy & Security › Files and Folders). Only a destination that doesn't exist yet is created directly (exclusively, so nothing already there is opened); a failed direct write removes it again. Unit tests cover a read-only folder on Unix; the macOS privacy case is not yet confirmed on a Mac. A folder that keeps no Unix modes refuses to set one with "Operation not supported" (a NAS share mounted by GVFS, `smb://` in the Linux file manager, refuses every mode change; #3003): the save goes on without copying the mode, since there is none to keep; any other failure to set it still stops the save and keeps the original. Unit tests cover the refusal; not yet confirmed on a real GVFS share. Synthetic CLI cases and `engine/tests/it/safe_save.rs` cover occupied names, links, blocked replacement, missing directories, Save As session state, password-protected saves and original-file protection on macOS. Unit tests inject preparation errors and interleave two prepared saves. Independent saves use separate files; the last successful replacement wins. This is not document locking or conflict detection. Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full conditions, power loss and network filesystems are not verified by these tests. The containing directory is not explicitly synchronized. Cleanup can fail when storage or permissions change, and process termination can leave a temporary file. No Word observation was needed for this filesystem change; it makes no new format-fidelity claim. +> **Three-column preset update:** 2026-10-15 · the header/footer `blankThree` preset creates an ordinary fixed-layout, borderless table with three equal cells and a trailing story paragraph (#2197). Regression checks cover cell text, width, left/center/right alignment, independent bold formatting and two DOCX save/reopen cycles. Editing guides are UI overlays and add no saved borders. Native Word interoperability is not established by these synthetic checks; readiness estimates unchanged. + ## What decides beta for formats 1. A **real-world DOCX corpus** opened, rendered and round-tripped, compared page by page with @@ -320,6 +322,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-16 | trivial | A `w:framePr` in `w:pPrDefault` (Apple Pages writes a sizeless one at the top left) frames no paragraph and isn't written back, as Word ignores it; headers, footers and table rows of such files lay out as in Word (#2360) | | 2026-10-15 | trivial | Native .json writes pictures and kept parts as base64 text instead of arrays of numbers (a 27 MiB picture: 36 MiB instead of 286 MiB); files with the old arrays still open (#586 follow-up) | | 2026-10-13 | trivial | A cell's explicit top vertical alignment (`w:vAlign w:val="top"`) is kept on read and write and beats a table style that centres or bottom-aligns (#1288) | +| 2026-10-15 | trivial | Three-column header/footer preset uses ordinary DOCX table cells; synthetic text, formatting, width and nil-border round-trip coverage (#2197) | | 2026-10-14 | trivial | Row table border exceptions survive DOCX saves | | 2026-10-12 | trivial | Fields keep `w:dirty` through DOCX round trips — on buffered fields and range-field begin markers alike — and computing a result (Update Field, Update Table) clears it like Word's F9, so a generator's placeholder TOC still refreshes when Word opens the file until WordCraft replaces the placeholder (#877) | | 2026-10-15 | trivial | ODT headers and footers (`style:header`/`-left`/`-first`, `style:footer`/…) read from each master page, so each section shows its own (#863) | diff --git a/docs/gaps.md b/docs/gaps.md index ac06c115d5..c5fb267999 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -91,6 +91,8 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc blocking gaps, and closing them lifts file formats, layout, stability and UI enough to take the full number from ~60% to ~75%. Everything else is depth on the way to full parity. +> **Three-column preset update:** 2026-10-15 · the header/footer preset uses editable table cells and screen-only editing guides (#2197). Synthetic command, undo/redo and repeated DOCX save/reopen checks cover this preset; broader template/layout gaps and readiness estimates remain unchanged. + ## By kind List toggles now recognise numbering inherited from paragraph styles (related to #3459). This closes that command-level case; the broader issue and readiness estimates are unchanged. See [typing-parity.md](typing-parity.md) for the original-fixture and round-trip coverage. @@ -223,6 +225,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | 2026-10-16 | trivial | #10 narrowed: pictures and shapes without text stories preview rotation while dragging (#2277); remaining object gaps and estimates unchanged | | 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | +| 2026-10-15 | trivial | Three-column header/footer preset uses a borderless table with editing guides (#2197); broader gaps and estimates unchanged | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | | 2026-10-16 | trivial | Gap #20: opening a DOCX no longer copies its pictures | | 2026-10-12 | minor | Gap #24 default shortcuts expanded (140 commands on macOS / 158 on Linux/Windows); stale gaps #9 (DOCX agile encryption already works, only encrypted .doc missing) and #15 (DOCX bibliography sources already saved, only desktop integrations missing) updated | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index a9c5c49443..c502dd6fbe 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -114,6 +114,8 @@ formatting of the text before the cursor. Not yet compared side by side in Word. | Change Case (Shift+F3, Home › Aa) (tracking on) | Word shows no insertion or deletion markup (checked with Formatting markup set to none, so Word may record it as a hidden formatting revision). **Differs:** WordCraft records the changed letters as a tracked deletion of the old and insertion of the new, since case isn't a run property a formatting revision could hold; Reject All restores the old case, Accept All keeps the new. Your own tracked insertion is rewritten in place; already-deleted text is left alone and doesn't count (with tracking off too), so changing case again (or Shift+F3) works from the visible text. Refused in content locked against editing | `tests_revision_checks::tracked_change_case_rejects_back_to_the_original_case`, `tracked_change_case_of_own_insertion_stays_an_insertion`, `tracked_change_case_twice_reads_only_the_live_text`, `tracked_change_case_handles_sharp_s_and_mixed_formatting`, `change_case_refuses_content_locked_against_editing` | | Unlink Fields (Ctrl+Shift+F9) (tracking on) | not tracked, like Word: the field's result stays as ordinary text. Refused in content locked against editing | `tests_revision_checks::tracked_unlink_keeps_the_result_as_plain_text`, `unlink_refuses_a_field_in_content_locked_against_editing` | +> **Three-column preset update:** 2026-10-15 · `blankThree` headers/footers start the caret in the first of three table cells (#2197). `text.tab` reaches the next cell and `text.insert` edits it through the existing command path. `crates/engine/tests/it/three_column_headers.rs` checks cell navigation, independent formatting, preset/text undo/redo and repeated DOCX save/reopen. + ## Editing keys Word's documented keyboard behaviour (Microsoft's shortcut list, feature names only); the @@ -435,6 +437,7 @@ again. | 2026-10-19 | trivial | Ctrl+Tab (⌥Tab on macOS) types a tab character in a table cell (#3137) | | 2026-10-16 | trivial | AltGr on Linux (Wayland) types ś, ł, ż… instead of opening KeyTips: the modifiers event without Alt that follows the Right Alt key no longer reads as Alt let go, and a bare AltGr tap doesn't open KeyTips (#2388) | | 2026-10-20 | trivial | AutoFormat As You Type: optional "Add a period with double-space" (off by default), undoable (#2848) | +| 2026-10-15 | trivial | Three-column header/footer preset: first-cell caret, Tab navigation, independent formatting and undo/redo regression coverage (#2197) | | 2026-10-18 | trivial | Typing over a selection takes the first selected character's formatting, also when the selection was made right to left (#2879) | | 2026-10-17 | trivial | A table inserted in a right-to-left paragraph is a right-to-left table, so Tab moves to the cell on the left (#2634) | | 2026-10-15 | trivial | Backspace after a section break and Delete before it remove the break, also one on the last paragraph's mark; the text takes the following section's setup and keeps the headers it showed (#2127) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 49e532077d..7cbb9de46b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -62,6 +62,8 @@ Document title opens Rename Document; the account badge opens Options › Genera | Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, also onto an empty line after Enter or Shift+Enter (#1119); File › Home and New's templates and recent documents are buttons named by their caption or file name, with the path when two file names are the same (#3176); not yet tried with real screen readers | full | 50% | 5–8 | | Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization | +> **Header/footer preset update:** 2026-10-15 · Three columns creates a one-row, three-cell borderless table (#2197). The active header/footer story has screen-only cell guides while editing, even with View Gridlines off. Closing the story removes automatic guides; explicit View Gridlines still outlines every table. No document border is changed by showing these guides. Readiness estimates unchanged. + Save Changes exposes the document-specific question as an alert-dialog name and the loss warning plus any failed-save explanation as its description (#1015). Full-app AccessKit coverage checks the focused choice's dialog ancestry, all three choices and cancellation. This is accessibility-tree evidence; native Windows screen-reader and spoken VoiceOver verification remain pending. Visual layout, wording and save policy are unchanged. The Language dialog reads effective character formatting, including document defaults, paragraph styles and character styles. Accepting it unchanged keeps inherited language and no-proof choices (#2717); explicit direct formatting still takes precedence. Other proofing and Windows-specific reports remain separate. @@ -105,6 +107,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | 2026-10-20 | trivial | The scrollbar page tip stays on one line: it broke into two or three lines ("Page 10 of" / "40") as the page number grew during a drag (#3341) | | 2026-10-19 | trivial | Format Page Numbers (Insert › Header & Footer › Page Number ▸ Format Page Numbers…, #1113): the dialog sets the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and whether the pages number on from the section before or start at a number; the ribbon item did nothing before (a plain command call that also cleared the section's start), and `layout.pageNumberFormat` keeps the start when `start` is left out and takes null to continue | | 2026-10-16 | trivial | KeyTips: AltGr (Right Alt with Level 3 Shift on Linux) is not an Alt tap, held or tapped, so AltGr+letter types instead of reaching the badges (#2388) | +| 2026-10-15 | trivial | Three-column header/footer table preset and automatic screen-only cell guides while editing (#2197) | | 2026-10-18 | trivial | Long navigation headings wrap onto further lines inside the pane instead of ending in an ellipsis; the row grows with the text and the pane still resizes (#759, #3015) | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-18 | trivial | Tables: a single selected cell's border drags in its row only (Select Cell, left-edge arrow, triple-click); checked against Word for Mac 16.113.4 | From 1b2f7a8c09f0fc382b769436911e2d3d248659ed Mon Sep 17 00:00:00 2001 From: SlavenDj <53652906+SlavenDj@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:39:07 +0100 Subject: [PATCH 34/50] #1845: filter-as-you-type command search in Customize Ribbon, QAT and Keyboard pickers (#1848) # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # interactive rebase in progress; onto d588e0b8 # Last command done (1 command done): # pick 6a1268ea # #1845: filter-as-you-type command search in Customize Ribbon, QAT and Keyboard pickers # No commands remaining. # You are currently rebasing branch 'feat/1845-command-filter' on 'd588e0b8'. # # Changes to be committed: # modified: crates/ui-egui/src/dialogs_keyboard.rs # modified: crates/ui-egui/src/dialogs_ribbon.rs # modified: docs/ui-parity.md # # Conflicts: # docs/ui-parity.md --- crates/ui-egui/src/dialogs_keyboard.rs | 71 +++++++++++++++++++++++++- crates/ui-egui/src/dialogs_ribbon.rs | 49 ++++++++++++++++-- docs/ui-parity.md | 5 +- 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs_keyboard.rs b/crates/ui-egui/src/dialogs_keyboard.rs index 156e648558..748fac2e81 100644 --- a/crates/ui-egui/src/dialogs_keyboard.rs +++ b/crates/ui-egui/src/dialogs_keyboard.rs @@ -23,6 +23,9 @@ pub struct KeyboardForm { pub category: String, /// The selected command id. pub command: String, + /// Filter-as-you-type query for the command list (#1845); UI-only dialog state, ignored + /// while empty. Never persisted and never a command parameter. + pub filter: String, /// The key picked in "Keys for this command" (for Remove). pub current: String, /// The key combination pressed in "New shortcut", canonical (`Mod+Shift+K`). @@ -72,6 +75,32 @@ pub(crate) fn commands(app: &WordApp, category: &str) -> Vec<(&'static str, Stri out } +/// The commands to list for `category`, narrowed by `query` (#1845): an empty query keeps the +/// category scope; any other text searches every registered command by its id and its English or +/// translated label, case-insensitive (the id lets "file.save" or "save" find Save). Sorted as +/// [`commands`]. +pub(crate) fn matching_commands(app: &WordApp, category: &str, query: &str) -> Vec<(String, String)> { + let q = crate::i18n::lowercase(query.trim()); + if q.is_empty() { + return commands(app, category).into_iter().map(|(id, l)| (id.to_string(), l)).collect(); + } + let mut out: Vec<(String, String)> = app + .session + .registry + .all() + .iter() + .filter(|s| s.id.to_lowercase().contains(&q) || s.label.to_lowercase().contains(&q) || crate::i18n::lowercase(tl!(s.label)).contains(&q)) + .map(|s| (s.id.to_string(), tl!(s.label).to_string())) + .collect(); + out.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()).then(a.0.cmp(&b.0))); + out +} + +/// Keep `wanted` when it is still listed, else fall back to the first row (#1845). +pub(crate) fn select_in(rows: &[(String, String)], wanted: &str) -> String { + if rows.iter().any(|(id, _)| id == wanted) { wanted.to_string() } else { rows.first().map(|(id, _)| id.clone()).unwrap_or_default() } +} + /// Only combinations with Mod, Ctrl or Alt, or function keys, can be assigned from the dialog /// (plain letters and Shift+letter type text). fn assignable(key: &str) -> bool { @@ -161,15 +190,21 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm) let (left, right) = (170.0, 300.0); heading(ui, "Choose a command"); + ui.add(egui::TextEdit::singleline(&mut f.filter).hint_text(tl!("Search")).desired_width(left + right + 8.0)); let mut cats: Vec<(String, String)> = categories(app).into_iter().map(|c| (c.clone(), tl!(&c).to_string())).collect(); cats.push((ALL.to_string(), tl!("All Commands").to_string())); - let cmds: Vec<(String, String)> = commands(app, &f.category).into_iter().map(|(id, l)| (id.to_string(), l)).collect(); + let cmds: Vec<(String, String)> = matching_commands(app, &f.category, &f.filter); + f.command = select_in(&cmds, &f.command); + if cmds.is_empty() { + ui.label(egui::RichText::new(tl!("No matching commands")).weak()); + } ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.label(tl!("Categories:")); if let Some(c) = list(ui, "kb_categories", vec2(left, 170.0), &cats, &f.category) { f.category = c; - f.command = commands(app, &f.category).first().map(|(id, _)| (*id).to_string()).unwrap_or_default(); + f.filter.clear(); + f.command = matching_commands(app, &f.category, &f.filter).first().map(|(id, _)| id.clone()).unwrap_or_default(); f.current.clear(); } }); @@ -450,4 +485,36 @@ mod tests { assert_eq!(h.state().1.command, first_of_home.0); assert!(row_in_view(&h, &first_of_home.1), "the new selection {} is in view", first_of_home.0); } + + /// #1845: the command filter searches every command by label or id, ignoring the category. + #[test] + fn command_filter_searches_all_commands() { + use super::{matching_commands, select_in}; + + let app = || WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + let a = app(); + // Empty query keeps the category scope. + let file_cmds = matching_commands(&a, "File", ""); + assert!(!file_cmds.is_empty()); + assert!( + file_cmds + .iter() + .all(|(id, _)| crate::i18n::location(a.session.registry.get(id).map(|s| s.location).unwrap_or_default()).starts_with("File")) + ); + assert!(file_cmds.iter().any(|(id, _)| id == "file.save")); + // A label finds its command from another category's scope. + let bold = matching_commands(&a, "File", "bold"); + assert!(bold.iter().any(|(id, _)| id == "format.bold")); + // Matching is case-insensitive and covers ids. + assert!(matching_commands(&a, "File", "SAVE").iter().any(|(id, _)| id == "file.save")); + assert!(matching_commands(&a, "File", "format.b").iter().any(|(id, _)| id == "format.bold")); + // Nothing matches: empty rows and an empty selection. + let none = matching_commands(&a, "File", "zzz-no-such-command"); + assert!(none.is_empty()); + assert_eq!(select_in(&none, "file.save"), ""); + // The selection survives when still listed, else falls back to the first row. + assert_eq!(select_in(&bold, "format.bold"), "format.bold"); + let first = bold.first().map(|(id, _)| id.clone()).unwrap_or_default(); + assert_eq!(select_in(&bold, "file.save"), first); + } } diff --git a/crates/ui-egui/src/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs_ribbon.rs index 1e0e7c2a7a..094e1b2775 100644 --- a/crates/ui-egui/src/dialogs_ribbon.rs +++ b/crates/ui-egui/src/dialogs_ribbon.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use crate::WordApp; use crate::dialogs::Dialog; -use crate::dialogs_keyboard::{categories, commands, list}; +use crate::dialogs_keyboard::{categories, commands, list, matching_commands, select_in}; use crate::theme::semibold; /// The dialog's two pages. @@ -39,6 +39,9 @@ pub struct RibbonForm { /// The left list: a category (empty for All Commands) and the command picked in it. pub category: String, pub command: String, + /// Filter-as-you-type query for the command list (#1845); UI-only dialog state, ignored + /// while empty. Never persisted and never a command parameter. + pub filter: String, /// The item picked in the ribbon tree. pub selected: TreeItem, /// The command picked in the Quick Access Toolbar list. @@ -97,16 +100,22 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b }); ui.separator(); let (left, right, h) = (150.0, 230.0, 300.0); + ui.add(egui::TextEdit::singleline(&mut f.filter).hint_text(tl!("Search")).desired_width(left + right + 8.0)); let mut cats: Vec<(String, String)> = categories(app).into_iter().map(|c| (c.clone(), tl!(&c).to_string())).collect(); cats.push((String::new(), tl!("All Commands").to_string())); - let cmds: Vec<(String, String)> = commands(app, &f.category).into_iter().map(|(id, l)| (id.to_string(), l)).collect(); + let cmds: Vec<(String, String)> = matching_commands(app, &f.category, &f.filter); + f.command = select_in(&cmds, &f.command); + if cmds.is_empty() { + ui.label(egui::RichText::new(tl!("No matching commands")).weak()); + } ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.label(egui::RichText::new(tl!("Choose a command")).font(semibold(12.5))); ui.horizontal_top(|ui| { if let Some(c) = list(ui, "rb_categories", vec2(left, h), &cats, &f.category) { f.category = c; - f.command = commands(app, &f.category).first().map(|(id, _)| (*id).to_string()).unwrap_or_default(); + f.filter.clear(); + f.command = matching_commands(app, &f.category, &f.filter).first().map(|(id, _)| id.clone()).unwrap_or_default(); } if let Some(c) = list(ui, "rb_commands", vec2(right, h), &cmds, &f.command) { f.command = c; @@ -466,6 +475,40 @@ mod tests { assert_eq!(a.ui.tab, "Insert", "a hidden tab isn't shown"); } + /// #1845: the shared command picker (Ribbon and Quick Access Toolbar pages) filters + /// as you type, and Add works from a filtered selection. + #[test] + fn command_filter_narrows_the_picker_and_add_works() { + use crate::dialogs_keyboard::{matching_commands, select_in}; + + let mut a = app(); + a.run("tools.customizeRibbon", json!({})).unwrap(); + assert!(matches!(a.dialog, Some(Dialog::CustomizeRibbon { .. }))); + if let Some(Dialog::CustomizeRibbon { form }) = &mut a.dialog { + form.filter = "bold".to_string(); + } + let (category, filter, current) = match &a.dialog { + Some(Dialog::CustomizeRibbon { form }) => (form.category.clone(), form.filter.clone(), form.command.clone()), + _ => panic!("expected the Customize Ribbon dialog"), + }; + // Empty filter keeps the category scope. + let unfiltered = matching_commands(&a, &category, ""); + assert!(!unfiltered.is_empty()); + // Typing searches every command, ignoring the category. + let cmds = matching_commands(&a, &category, &filter); + assert!(cmds.iter().any(|(id, _)| id == "format.bold")); + assert!(cmds.len() < a.session.registry.all().len()); + // The dialog's Add >> adds the filtered pick to the Quick Access Toolbar. + let picked = select_in(&cmds, ¤t); + assert!(!picked.is_empty()); + if let Some(Dialog::CustomizeRibbon { form }) = &mut a.dialog { + form.command = picked.clone(); + } + a.run("tools.customizeRibbon", json!({"qatAdd": picked})).unwrap(); + assert!(a.session.ribbon.qat().contains(&picked.as_str())); + assert!(a.session.ribbon.qat().contains(&"format.bold")); + } + /// #376: the Quick Access Toolbar's … button opens its menu, and the toolbar shows the user's /// commands. #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7cbb9de46b..4861ecc487 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (filter-as-you-type command search in the Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers, #1845; previously trivial: Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -19,7 +19,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Mini-toolbar on selection and text context menu | ✅ (#43, two rows #535; Recently Used Fonts in its font box #2068; the text context menu includes the same toolbar at a caret or selection, #2959) | ✅ | 80% | 1–2 | | Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); Shape Fill › Gradient: Light and Dark Variations of the shape's colour, More Gradients… (#1500); no eyedropper | ✅ | 90% | 1 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Its menu ends with Hide Quick Access Toolbar, which takes it out of the title bar; Show Quick Access Toolbar on the ribbon's right-click menu, or in Customize Ribbon › Quick Access Toolbar, brings it back; kept between runs; `ui.quickAccessToolbar` for scripts (#1945). Not shown below the ribbon | customizable | 92% | 0.5 | -| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | +| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file; filter-as-you-type command search in all three pickers (#1845) | ✅ | 85% | 1–2 | | Keyboard shortcuts | **137** commands bound in `CommandSpec::key`, 156 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Modal dialogs | **54** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style (every part: font, borders, shading, cell alignment, #714), Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264); Format Shape (#1500: Fill only: no fill, solid colour and transparency, gradient presets, type, direction, angle, stops bar, per-stop colour, position and transparency, rotate with shape; opened from Shape Fill › Gradient › More Gradients… and right-click › Format Shape…; drags preview live and undo in one step; no Line, Effects or Layout sections, no Format Picture) open | all | 70% | 4–6 | @@ -308,3 +308,4 @@ labels unambiguous. Those extra-number assignments have not been compared with W | 2026-10-15 | trivial | Double-clicking a page number in a later page's footer opens the footer there: the first click no longer moves the caret to the body text and scrolls the view up to it (#2103) | | 2026-10-20 | trivial | File › Home and New: each template (thumbnail and caption) and each recent document is one button named by its caption or file name (with the path when two recent file names are the same), so a screen reader announces it instead of unnamed text (#3176) | | 2026-10-23 | trivial | Font dialog: "NaN" or "inf" in the Size box previews the text at 12 pt instead of stopping the app while drawing the preview (#3663) | +| 2026-10-25 | trivial | Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers: filter-as-you-type command search across all commands (label or id, case-insensitive); clearing restores the category scope (#1845) | From 49731f3faad534ceed38746f1af1a9d25dc6f6f2 Mon Sep 17 00:00:00 2001 From: Max Murphy Date: Sun, 25 Oct 2026 16:44:31 +0100 Subject: [PATCH 35/50] =?UTF-8?q?#2982:=20File=20=E2=96=B8=20Info=20Custom?= =?UTF-8?q?=20Document=20Properties=20dialog=20(#2997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/engine/src/cmd/file.rs | 30 +- crates/engine/src/tests.rs | 46 +++ crates/ui-egui/src/backstage.rs | 9 + crates/ui-egui/src/dialogs.rs | 530 ++++++++++++++++++++++++++++++++ docs/gaps.md | 4 +- docs/ui-parity.md | 7 +- 7 files changed, 614 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7ff5171304..e9e648cf1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: regex Replace expands capture groups, #4177; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: regex Replace expands capture groups, #4177; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -218,6 +218,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-19 | A table-of-contents entry whose text just fits no longer leaves its page number alone at the start of the next line: the tab goes there with it, leader and all (#1840). | | 2026-10-19 | A Thai run with Latin letters and digits in a missing Thai font is drawn in one face on Linux too (#1549): the fallback no longer stops at Noto Sans Thai, which has no Latin letters, but finds an installed font covering the whole run (IBM Plex Sans Thai, say) and loads its whole family, so regular text gets the Regular face. Concurrent lookups no longer cache a miss. | | 2026-10-19 | Word signature lines (Insert › Signature Line) survive a save: a .docx saved by WordCraft keeps each one as a signature line, still signable in Word, with its suggested signer, instead of turning it into a plain picture. | +| 2026-10-25 | Custom document properties (#2982, split from #1798): File › Info › Properties › Advanced Properties… opens a Custom Document Properties dialog that lists the document's custom properties and adds, modifies and deletes them through the name/value form (text properties only; Word's number/date/yes-no types not offered). OK is one undoable `file.properties` step and marks the document changed; Cancel discards everything; names and values are validated against the engine's limits with the error shown in the dialog. Saved properties survive a DOCX round trip. | | 2026-10-15 | A picture anchored further down a page but placed higher up it (fixed on the page, or moved up from its paragraph) has the paragraphs above its anchor wrap around it too, instead of running behind it, also when that wrap pushes the anchor onto the next page (#2048) | | 2026-10-20 | Table sorting and formulas read and write decimal points for Cuban and Peruvian Spanish (`es-CU`, `es-PE`), so `1.234` is a decimal instead of 1234; grouped-number and result-formatting regressions retain Spain's decimal comma. | | 2026-10-20 | Set Proofing Language › Set As Default persists for future blank documents and plain-text files, independently of the interface language; existing DOCX and templates keep their languages, and rejected commands retain the previous saved default (#2882). | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f3eafb5da2..c40777241b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -416,15 +416,25 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult { let Some(m) = custom.as_object() else { return Err(CmdError::Params("`custom` must be an object of name → string or null".into())); }; + // Removals first: a case-only rename sends the old spelling as null and the new one + // with its value, and `set_custom_prop` matches names case-insensitively, so setting + // first would keep the old spelling (object keys are sorted, either spelling may lead). for (name, val) in m { - if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME { - return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters"))); + if val.is_null() { + s.doc.remove_custom_prop(name); } + } + for (name, val) in m { match val { - Value::Null => { - s.doc.remove_custom_prop(name); + // Removals took the name as stored above: a file opened from an older Word may + // hold one longer than today's limit, and it must still be deletable (#2982). + Value::Null => {} + Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => { + if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME { + return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters"))); + } + s.doc.set_custom_prop(name, x); } - Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => s.doc.set_custom_prop(name, x), _ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))), } } @@ -436,10 +446,12 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult { Ok(out) } -/// Longest custom property name (Word's limit). -const MAX_CUSTOM_NAME: usize = 255; -/// Largest custom property value we accept from a command. -const MAX_CUSTOM_VALUE: usize = 1024 * 1024; +/// Longest custom property name (Word's limit). The Custom Properties dialog validates with it +/// too (#2982). +pub const MAX_CUSTOM_NAME: usize = 255; +/// Largest custom property value we accept from a command. The Custom Properties dialog +/// validates with it too (#2982). +pub const MAX_CUSTOM_VALUE: usize = 1024 * 1024; fn info(s: &mut Session, _: &Value) -> CmdResult { let l = s.layout(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index de0a6c4eb0..461d5f14ab 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4487,6 +4487,21 @@ fn word_count_with_an_object_selected_counts_the_document() { assert_eq!(run(&mut s, "review.wordCount", json!({}))["words"].as_u64(), Some(1)); } +/// #2982: a property imported from a file made by an older Word may have a name longer than +/// today's limit. Such a name is still deletable (the dialog sends `null`), while setting or +/// renaming a property to it stays refused. +#[test] +fn custom_properties_delete_legacy_names_but_new_names_keep_the_limit() { + let mut s = s(); + let long = "x".repeat(crate::cmd::file::MAX_CUSTOM_NAME + 1); + s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "".into() }); + let mut custom = serde_json::Map::new(); + custom.insert(long.clone(), serde_json::Value::Null); + run(&mut s, "file.properties", json!({"custom": custom})); + assert!(s.doc.custom_props.is_empty(), "the legacy name could be deleted"); + assert!(s.run("file.properties", &json!({"custom": { (long): "x" }})).is_err(), "a new or updated name keeps the limit"); +} + #[test] fn custom_properties_set_read_remove_and_undo() { let mut s = s(); @@ -4504,6 +4519,37 @@ fn custom_properties_set_read_remove_and_undo() { assert_eq!(s.doc.custom_prop("Status"), Some("draft")); } +/// #2982: a case-only rename arrives as the old spelling with null and the new one with its +/// value. The removal must run first: object keys are sorted, so `Status` sorts before +/// `status`, and setting before removing would let the removal take the new spelling too. +#[test] +fn custom_properties_case_only_rename_keeps_the_new_spelling() { + let mut s = s(); + run(&mut s, "file.properties", json!({"custom": {"status": "draft"}})); + run(&mut s, "file.properties", json!({"custom": {"Status": "draft", "status": null}})); + let props: Vec<(&str, &str)> = s.doc.custom_props.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect(); + assert_eq!(props, [("Status", "draft")], "the property keeps the new spelling and its value"); +} + +/// #2982: what the Custom Properties dialog saves (through `file.properties`) survives a .docx +/// save and reopen. +#[test] +fn custom_properties_round_trip_through_docx() { + let mut s = s(); + run(&mut s, "file.properties", json!({"custom": {"Status": "draft", "Reviewer": "Ada L."}})); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(back.custom_prop("Status"), Some("draft")); + assert_eq!(back.custom_prop("Reviewer"), Some("Ada L.")); + assert_eq!(back.custom_props.len(), 2); + // One call removes one and adds another; the saved file follows the document. + run(&mut s, "file.properties", json!({"custom": {"Status": null, "Owner": "Ada L."}})); + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(back.custom_prop("Status"), None); + assert_eq!(back.custom_prop("Reviewer"), Some("Ada L.")); + assert_eq!(back.custom_prop("Owner"), Some("Ada L.")); + assert_eq!(back.custom_props.len(), 2); +} + /// `document_id` tells an edit from a replacement (the UI drops a "Save changes?" prompt /// about a document that has been replaced). #[test] diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 2e8a0ba6f7..a8c85b5467 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -649,6 +649,15 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) { if focused != app.info_editing.map(|(f, _)| f) { app.info_editing = None; } + // Advanced Properties › Custom (#2982): view and edit the document's custom properties. + ui.add_space(6.0); + if ui + .button(egui::RichText::new(tl!("Advanced Properties…")).font(medium(13.5))) + .on_hover_text(tl!("View and edit the document's custom properties.")) + .clicked() + { + let _ = app.run("ui.dialog", json!({"name": "customProperties"})); + } // Protect Document › Encrypt with Password (#55). ui.add_space(18.0); ui.label(egui::RichText::new(tl!("Protect Document")).font(semibold(15.0))); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f8baa0a095..466bfdb236 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -558,6 +558,166 @@ pub enum Dialog { }, /// Line Numbers, Hyphenation, Language, Envelopes and Labels (#407). Layout(Box), + /// File › Info › Properties › Advanced Properties › Custom: the document's custom + /// properties (#2982). + CustomProperties(Box), +} + +/// The Custom tab of File › Info › Properties › Advanced Properties (#2982): the document's +/// custom properties, edited as a working copy. OK sends the whole difference from what the +/// dialog opened with as one undoable `file.properties` step (marking the document changed and +/// refreshing File › Info); Cancel sends nothing. Text properties only for now: Word's +/// number/date/yes-no types aren't offered, and a property of another type keeps its stored +/// form — the dialog lists it but refuses to change it (Delete still removes one outright). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomPropertiesForm { + /// The working copy, in document order. + pub props: Vec, + /// What the document held when the dialog opened; OK sends only what differs. + start: Vec, + /// The row the list selection is on. + pub selected: usize, + /// The name/value boxes under the list. + pub name: String, + pub value: String, + /// The last error to show under the form (a refused name, a failed command). + pub message: String, +} + +impl CustomPropertiesForm { + pub(crate) fn read(app: &WordApp) -> Self { + let props = app.session.doc.custom_props.clone(); + let mut form = Self { props: props.clone(), start: props, selected: 0, name: String::new(), value: String::new(), message: String::new() }; + // The first row is highlighted, so its fields are ready to modify, as in Word. + form.select(0); + form + } + + /// Clicking a row: it fills the name and value boxes, as in Word. + pub(crate) fn select(&mut self, i: usize) { + self.selected = i; + self.message.clear(); + match self.props.get(i) { + Some(p) => { + self.name = p.name.clone(); + self.value = p.value.clone(); + } + None => { + self.name.clear(); + self.value.clear(); + } + } + } + + /// The typed name and value, validated against the engine's own limits + /// (`file.properties`, which refuses them the same way). + fn form(&self) -> Result<(String, String), String> { + let max_name = wordcraft_engine::cmd::file::MAX_CUSTOM_NAME; + let name = self.name.trim(); + if name.is_empty() || name.chars().count() > max_name { + return Err(crate::i18n::fmt(tl!("Property names are 1–{max} characters."), &[("max", &max_name.to_string())])); + } + if self.value.len() > wordcraft_engine::cmd::file::MAX_CUSTOM_VALUE { + let max = wordcraft_engine::cmd::file::MAX_CUSTOM_VALUE; + return Err(crate::i18n::fmt(tl!("Property values are at most {max} bytes."), &[("max", &max.to_string())])); + } + Ok((name.to_string(), self.value.clone())) + } + + /// Add: the typed name and value join the list, replacing a property of the same name + /// (names compare case-insensitively, as in Word and the engine). + pub(crate) fn add(&mut self) { + match self.form() { + Ok((name, value)) => { + self.message.clear(); + match self.props.iter().position(|p| p.name.eq_ignore_ascii_case(&name)) { + Some(i) if self.props[i].kind != "lpwstr" => { + self.message = tl!("Only text properties can be changed.").to_string(); + } + Some(i) => { + self.props[i].value = value; + self.selected = i; + } + None => { + self.props.push(wordcraft_doc::CustomProp { name, kind: "lpwstr".into(), value }); + self.selected = self.props.len().saturating_sub(1); + } + } + } + Err(m) => self.message = m, + } + } + + /// Modify: the selected property takes the typed name and value; a different name moves it + /// there (replacing a property already of that name). A refused form keeps the list as it + /// was. + pub(crate) fn modify(&mut self) { + let Some(old) = self.props.get(self.selected).cloned() else { + self.message = tl!("Select a property to change.").to_string(); + return; + }; + if old.kind != "lpwstr" { + self.message = tl!("Only text properties can be changed.").to_string(); + return; + } + let (name, value) = match self.form() { + Ok(f) => f, + Err(m) => { + self.message = m; + return; + } + }; + if self.props.iter().enumerate().any(|(i, p)| i != self.selected && p.kind != "lpwstr" && p.name.eq_ignore_ascii_case(&name)) { + self.message = tl!("Only text properties can be changed.").to_string(); + return; + } + self.message.clear(); + self.props.remove(self.selected); + match self.props.iter().position(|p| p.name.eq_ignore_ascii_case(&name)) { + Some(i) => { + self.props[i].value = value; + self.selected = i; + } + None => { + let i = self.selected.min(self.props.len()); + self.props.insert(i, wordcraft_doc::CustomProp { name, kind: old.kind.clone(), value }); + self.selected = i; + } + } + } + + /// Delete: the selected property leaves the list. + pub(crate) fn delete(&mut self) { + if self.selected < self.props.len() { + self.props.remove(self.selected); + self.select(self.selected.min(self.props.len().saturating_sub(1))); + } + } + + /// The one `file.properties` call OK makes: a property gone since the dialog opened is + /// null, one added or changed is its value. Untouched properties aren't sent, so one of + /// another type (Zotero's raw XML, a number) keeps its stored form. A rename that only + /// changes case sends the old spelling as null too, so the stored spelling follows it. + pub(crate) fn params(&self) -> Value { + let mut custom = serde_json::Map::new(); + for p in &self.start { + match self.props.iter().find(|q| q.name.eq_ignore_ascii_case(&p.name)) { + Some(q) if q.name == p.name => {} + _ => { + custom.insert(p.name.clone(), Value::Null); + } + } + } + for p in &self.props { + let changed = + self.start.iter().find(|q| q.name.eq_ignore_ascii_case(&p.name)).is_none_or(|was| was.name != p.name || was.value != p.value); + if changed { + custom.insert(p.name.clone(), json!(p.value)); + } + } + json!({"custom": custom}) + } } /// The Table Properties dialog's fields. Lengths are in the interface unit @@ -1021,6 +1181,7 @@ impl Dialog { Dialog::NoteOptions { .. } => "noteOptions", Dialog::Templates { .. } => "templates", Dialog::Organizer { .. } => "organizer", + Dialog::CustomProperties(..) => "customProperties", Dialog::Layout(d) => d.name(), } } @@ -1303,6 +1464,7 @@ impl Dialog { "noteOptions" => Dialog::NoteOptions { form: Box::new(crate::dialogs_notes::NoteForm::read(app)) }, "templates" => Dialog::Templates { form: Box::new(crate::dialogs_templates::TemplatesForm::read(app)) }, "organizer" => Dialog::Organizer { form: Box::new(crate::dialogs_templates::OrganizerForm::read(app, None)) }, + "customProperties" => Dialog::CustomProperties(Box::new(CustomPropertiesForm::read(app))), "controlProperties" => match crate::dialogs_controls::ControlForm::read(app) { Some(form) => Dialog::Control { form: Box::new(form) }, None => { @@ -1495,6 +1657,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::NoteOptions { .. } => "Footnote and Endnote", Dialog::Templates { .. } => "Templates and Add-ins", Dialog::Organizer { .. } => "Organizer", + Dialog::CustomProperties { .. } => "Custom Document Properties", Dialog::Layout(d) => d.title(), }; let id = egui::Id::new(("dialog", title)); @@ -2985,6 +3148,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { Dialog::NoteOptions { form } => crate::dialogs_notes::note_options(app, ui, form), Dialog::Templates { form } => crate::dialogs_templates::templates(app, ui, form), Dialog::Organizer { form } => crate::dialogs_templates::organizer(app, ui, form), + Dialog::CustomProperties(form) => custom_properties(app, ui, form), Dialog::Layout(d) => crate::dialogs_layout::body(app, ui, d), Dialog::FindRecipient { text, message } => { ui.horizontal(|ui| { @@ -3784,6 +3948,84 @@ fn close_button(ui: &mut Ui) -> bool { close } +/// A custom property's one-line list preview: its first line, at most 80 characters, with an +/// ellipsis when either is cut. The Value box keeps the whole value. +fn custom_value_preview(value: &str) -> String { + const MAX: usize = 80; + let first = value.lines().next().unwrap_or(""); + let mut chars = first.chars(); + let mut shown: String = chars.by_ref().take(MAX).collect(); + if chars.next().is_some() || first.len() < value.len() { + shown.push('…'); + } + shown +} + +/// The Custom tab of File › Info › Properties › Advanced Properties (#2982): the document's +/// custom properties with Add, Modify and Delete over a name/value form. OK is one undo step +/// (one `file.properties` call), Cancel leaves the document as it was. +fn custom_properties(app: &mut WordApp, ui: &mut Ui, f: &mut CustomPropertiesForm) -> bool { + let t = Tokens::get(ui.ctx()); + let mut clicked = None; + let rows = f.props.len().clamp(1, 8); + let row_h = ui.spacing().interact_size.y + ui.spacing().item_spacing.y; + egui::ScrollArea::vertical().id_salt("custom_props").max_height(rows as f32 * row_h + 4.0).auto_shrink([false, true]).show(ui, |ui| { + if f.props.is_empty() { + ui.label(egui::RichText::new(tl!("This document has no custom properties.")).color(t.text_dim)); + } + for (i, p) in f.props.iter().enumerate() { + let row = ui.selectable_label(f.selected == i, format!("{} = {}", p.name, custom_value_preview(&p.value))); + if row.clicked() { + clicked = Some(i); + } + } + }); + if let Some(i) = clicked { + f.select(i); + } + ui.horizontal(|ui| { + if ui.button(tl!("Add")).clicked() { + f.add(); + } + if ui.add_enabled(f.selected < f.props.len(), egui::Button::new(tl!("Modify"))).clicked() { + f.modify(); + } + if ui.add_enabled(f.selected < f.props.len(), egui::Button::new(tl!("Delete"))).clicked() { + f.delete(); + } + }); + ui.separator(); + egui::Grid::new("custom_props_form").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + let name = ui.label(tl!("Name:")); + ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(300.0).char_limit(wordcraft_engine::cmd::file::MAX_CUSTOM_NAME)) + .labelled_by(name.id); + ui.end_row(); + let value = ui.label(tl!("Value:")); + ui.add(egui::TextEdit::singleline(&mut f.value).desired_width(300.0)).labelled_by(value.id); + ui.end_row(); + }); + if !f.message.is_empty() { + ui.colored_label(ui.visuals().error_fg_color, f.message.as_str()); + } + ui.label(egui::RichText::new(tl!("Text properties only: Word's number, date and yes-no types aren't offered.")).small().weak()); + let (ok, cancel) = buttons(ui, tl!("OK")); + if cancel { + return true; + } + if ok { + let params = f.params(); + if params["custom"].as_object().is_some_and(serde_json::Map::is_empty) { + return true; + } + // One call: one undo step, the document marked changed, File › Info reads it afresh. + match app.run("file.properties", params) { + Ok(_) => return true, + Err(e) => f.message = e, + } + } + false +} + /// A style's description: its type, the formatting it sets and what it is based on. fn describe(st: &Value) -> String { let f = st.get("format").cloned().unwrap_or_default(); @@ -5391,4 +5633,292 @@ mod tests { tab(&mut h, true); assert_eq!(levels(h.state()), Some([0, 1, 2, 2, 3, 3].to_vec()), "Shift+Tab promotes them back"); } + + /// Type into a dialog text field, replacing what is there (as a user would: click, select + /// all, type over). + fn set_text_field(h: &mut egui_kittest::Harness<'_, WordApp>, label: &str, text: &str) { + use egui_kittest::kittest::Queryable; + h.get_by_role_and_label(egui::accesskit::Role::TextInput, label).click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.key_press(egui::Key::Backspace); + h.run_steps(1); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, label).type_text(text); + h.run_steps(2); + } + + /// #2982: the list keeps its full values but previews only the first line, at most 80 + /// characters, with an ellipsis when either is cut. + #[test] + fn custom_property_previews_stay_on_one_bounded_line() { + assert_eq!(custom_value_preview("short"), "short"); + assert_eq!(custom_value_preview(&"x".repeat(80)), "x".repeat(80), "exactly the limit is not cut"); + assert_eq!(custom_value_preview(&"x".repeat(81)), format!("{}…", "x".repeat(80))); + assert_eq!(custom_value_preview("first\nsecond"), "first…", "a later line is cut"); + let wide = "é".repeat(80); + assert_eq!(custom_value_preview(&wide), wide, "80 characters, not 80 bytes"); + let wide = "é".repeat(81); + assert_eq!(custom_value_preview(&wide).chars().count(), 81, "80 characters plus the ellipsis"); + assert!(custom_value_preview(&wide).ends_with('…')); + } + + /// #2982: File › Info's Advanced Properties… opens the Custom Properties dialog; it lists + /// the document's custom properties, and Add / Modify / Delete edit a working copy that OK + /// applies as one undoable `file.properties` step, Cancel discarding it. + #[test] + fn the_custom_properties_dialog_adds_edits_and_deletes_in_one_step() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = app_with("Report body"); + let _ = app.run("file.properties", json!({"custom": {"Status": "draft", "Owner": "Mira"}})); + let _ = app.run("ui.backstage", json!({"value": true, "page": "info"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Advanced Properties…").click(); + h.run_steps(4); + assert!(matches!(&h.state().dialog, Some(Dialog::CustomProperties(_))), "opens from File › Info"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("customProperties")); + assert!(h.query_by_label("Status = draft").is_some(), "the list shows the properties"); + // A row is selected, so the buttons act on it. + assert!(!h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").accesskit_node().is_disabled()); + assert!(!h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").accesskit_node().is_disabled()); + // Add one through the form. + set_text_field(&mut h, "Name:", "Reviewer"); + set_text_field(&mut h, "Value:", "Ada"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Reviewer = Ada").is_some(), "Add appends the typed property"); + // A duplicate name in another case replaces its value. + set_text_field(&mut h, "Name:", "status"); + set_text_field(&mut h, "Value:", "final"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Status = final").is_some(), "the duplicate replaced the value"); + // Selecting a row fills the boxes; Modify changes its value. + h.get_by_label("Reviewer = Ada").click(); + h.run_steps(2); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Name:").value().as_deref(), Some("Reviewer")); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Value:").value().as_deref(), Some("Ada")); + set_text_field(&mut h, "Value:", "Ada L."); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + assert!(h.query_by_label("Reviewer = Ada L.").is_some(), "Modify keeps the edited value"); + // Delete another property, so the modified value is saved before that one goes. + h.get_by_label("Owner = Mira").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").click(); + h.run_steps(2); + assert!(h.query_by_label("Owner = Mira").is_none(), "Delete removes the selected property"); + // OK: one call, so one undo step, marking the document changed. + let depth = h.state().session.undo_depth(); + h.state_mut().session.dirty = false; + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click(); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "OK closes the dialog"); + let props: Vec<(String, String)> = h.state().session.doc.custom_props.iter().map(|p| (p.name.clone(), p.value.clone())).collect(); + assert_eq!(props, [("Status".to_string(), "final".to_string()), ("Reviewer".to_string(), "Ada L.".to_string())]); + assert!(h.state().session.dirty, "the document is marked changed"); + assert_eq!(h.state().session.undo_depth(), depth + 1, "one undo step"); + // One undo brings the old values back. + let _ = h.state_mut().run("edit.undo", json!({})); + assert_eq!(h.state().session.doc.custom_prop("Status"), Some("draft"), "one undo step"); + assert_eq!(h.state().session.doc.custom_prop("Owner"), Some("Mira")); + assert_eq!(h.state().session.doc.custom_prop("Reviewer"), None); + } + + /// #2982: the highlighted first row is ready to modify: its name and value fill the boxes, + /// so clicking Modify straight away is not refused for an empty name. + #[test] + fn the_custom_properties_dialog_starts_ready_to_modify_the_first_property() { + use egui_kittest::kittest::Queryable; + let mut app = app_with("Report body"); + let _ = app.run("file.properties", json!({"custom": {"Status": "draft"}})); + let _ = app.run("ui.backstage", json!({"value": true, "page": "info"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Advanced Properties…").click(); + h.run_steps(4); + let role = egui::accesskit::Role::TextInput; + assert_eq!(h.get_by_role_and_label(role, "Name:").value().as_deref(), Some("Status")); + assert_eq!(h.get_by_role_and_label(role, "Value:").value().as_deref(), Some("draft")); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + assert!(h.query_by_label("Property names are 1–255 characters.").is_none(), "the prefilled name is accepted"); + assert!(h.query_by_label("Status = draft").is_some(), "Modify with no edits keeps the property"); + // A real edit through the same prefilled form reaches the document as one undo step. + let depth = h.state().session.undo_depth(); + set_text_field(&mut h, "Value:", "final"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click(); + h.run_steps(4); + assert_eq!(h.state().session.doc.custom_prop("Status"), Some("final")); + assert_eq!(h.state().session.undo_depth(), depth + 1, "one undoable change"); + } + + /// #2982: a rename that changes only the case of the name is still a rename. Modify and OK + /// must send the old spelling as a removal and the new one as a value, or the stored + /// spelling never changes. + #[test] + fn the_custom_properties_dialog_keeps_a_case_only_rename() { + use egui_kittest::kittest::Queryable; + let mut app = app_with("Report body"); + let _ = app.run("file.properties", json!({"custom": {"Status": "draft"}})); + let _ = app.run("ui.backstage", json!({"value": true, "page": "info"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Advanced Properties…").click(); + h.run_steps(4); + // Select the property and change only the case of its name, keeping the value. + h.get_by_label("Status = draft").click(); + h.run_steps(2); + set_text_field(&mut h, "Name:", "status"); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Value:").value().as_deref(), Some("draft")); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + assert!(h.query_by_label("status = draft").is_some(), "the list shows the new spelling"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click(); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "OK closes the dialog"); + let props: Vec<(String, String)> = h.state().session.doc.custom_props.iter().map(|p| (p.name.clone(), p.value.clone())).collect(); + assert_eq!(props, [("status".to_string(), "draft".to_string())], "the stored spelling follows the rename"); + } + + /// #2982: Cancel leaves the document as it was; empty names are refused with a message in + /// the dialog; the Name box stops at Word's limit; and the last deletion shows the empty + /// state with Modify and Delete disabled. + #[test] + fn the_custom_properties_dialog_cancel_changes_nothing_and_refuses_bad_names() { + use egui_kittest::kittest::{NodeT, Queryable}; + let mut app = app_with("Report body"); + let _ = app.run("file.properties", json!({"custom": {"Status": "draft"}})); + let _ = app.run("ui.backstage", json!({"value": true, "page": "info"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Advanced Properties…").click(); + h.run_steps(4); + let depth = h.state().session.undo_depth(); + // Empty and blank names are refused, with the message shown, keeping the list as it was. + set_text_field(&mut h, "Name:", ""); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Property names are 1–255 characters.").is_some(), "an empty name shows the message"); + set_text_field(&mut h, "Name:", " "); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Property names are 1–255 characters.").is_some(), "a whitespace name shows the message"); + assert!(h.query_by_label("Status = draft").is_some(), "nothing was added"); + // The Name box caps typing at Word's limit. + set_text_field(&mut h, "Name:", &"x".repeat(wordcraft_engine::cmd::file::MAX_CUSTOM_NAME + 20)); + let typed = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Name:").value().unwrap_or_default(); + assert_eq!(typed.chars().count(), wordcraft_engine::cmd::file::MAX_CUSTOM_NAME, "the Name box stops at the limit"); + // A valid entry, so Cancel has something to discard. + set_text_field(&mut h, "Name:", "Reviewer"); + set_text_field(&mut h, "Value:", "Ada"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Reviewer = Ada").is_some()); + // Deleting every row shows the empty state and disables Modify and Delete. + h.get_by_label("Reviewer = Ada").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").click(); + h.run_steps(2); + h.get_by_label("Status = draft").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").click(); + h.run_steps(2); + assert!(h.query_by_label("This document has no custom properties.").is_some(), "the empty list says so"); + assert!(h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").accesskit_node().is_disabled()); + assert!(h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").accesskit_node().is_disabled()); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Cancel").click(); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "Cancel closes the dialog"); + let names: Vec<&str> = h.state().session.doc.custom_props.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Status"], "the document is untouched"); + assert_eq!(h.state().session.undo_depth(), depth, "no step was recorded"); + } + + /// #2982: imported non-text properties (a number, Zotero's raw XML) stay as they are: Add + /// and Modify refuse to turn one into text, while Delete still removes it. + #[test] + fn the_custom_properties_dialog_leaves_imported_non_text_properties_alone() { + use egui_kittest::kittest::Queryable; + let mut app = app_with("Report body"); + let _ = app.run("file.properties", json!({"custom": {"Status": "draft"}})); + app.session.doc.custom_props.push(wordcraft_doc::CustomProp { name: "Count".into(), kind: "i4".into(), value: "7".into() }); + let _ = app.run("ui.backstage", json!({"value": true, "page": "info"})); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label("Advanced Properties…").click(); + h.run_steps(4); + assert!(h.query_by_label("Count = 7").is_some(), "the imported property is listed"); + // Add with its name is refused rather than replacing it with text. + set_text_field(&mut h, "Name:", "count"); + set_text_field(&mut h, "Value:", "8"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add").click(); + h.run_steps(2); + assert!(h.query_by_label("Only text properties can be changed.").is_some(), "Add refuses the non-text name"); + assert!(h.query_by_label("Count = 7").is_some(), "it keeps its value"); + // Modify of the non-text property itself is refused. + h.get_by_label("Count = 7").click(); + h.run_steps(2); + set_text_field(&mut h, "Value:", "8"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + assert!(h.query_by_label("Only text properties can be changed.").is_some(), "Modify refuses the non-text property"); + assert!(h.query_by_label("Count = 7").is_some(), "its value is unchanged"); + // Renaming a text property onto a non-text one is refused too. + h.get_by_label("Status = draft").click(); + h.run_steps(2); + set_text_field(&mut h, "Name:", "Count"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Modify").click(); + h.run_steps(2); + assert!(h.query_by_label("Only text properties can be changed.").is_some(), "a rename onto it is refused"); + assert!(h.query_by_label("Status = draft").is_some(), "the text property keeps its name"); + // Delete still removes it outright. + h.get_by_label("Count = 7").click(); + h.run_steps(2); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Delete").click(); + h.run_steps(2); + assert!(h.query_by_label("Count = 7").is_none(), "Delete removes a non-text property"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Cancel").click(); + h.run_steps(4); + let kinds: Vec<(&str, &str)> = h.state().session.doc.custom_props.iter().map(|p| (p.name.as_str(), p.kind.as_str())).collect(); + assert_eq!(kinds, [("Status", "lpwstr"), ("Count", "i4")], "Cancel leaves the imported property as it was"); + } } diff --git a/docs/gaps.md b/docs/gaps.md index c5fb267999..f7a0ad1099 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,7 @@ # Where WordCraft falls short of Word > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (#7: Custom Document Properties dialog, #2982; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -63,7 +63,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | -| 7 | **Dialog depth**: 60 modal dialogs vs Word's ~100 (Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | +| 7 | **Dialog depth**: 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries or grammar for the other ~41 languages Word proofs (text in them is left unmarked), and no hyphenation for them except Bulgarian and German | | `crates/proof` `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4861ecc487..851b0cd479 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (filter-as-you-type command search in the Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers, #1845; previously trivial: Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: filter-as-you-type command search in the Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers, #1845; previously trivial: Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -31,11 +31,11 @@ Document title opens Rename Document; the account badge opens Options › Genera | Modal dialogs | **56** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients (Filter and Sort, #778; Find in list and a merged-only view, #1576), Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. The Spike: Ctrl+F3 cuts to it, Ctrl+Shift+F3 inserts it and empties it (⌘F3, ⌘⇧F3 on macOS). Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 | -| Modal dialogs | **62** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Table of Figures (#3013), Source Manager, Create/Edit Source (#402); Cross-reference (#916); Drop Cap with Position, Font, Lines to drop and Distance from text, opened from Insert › Drop Cap › Drop Cap Options… (#2601); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, a number that doesn't divide its height refused with a divisor message, more than its height with the range message; 1 × 1 changes nothing; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592); Size, from Picture Format › Size's launcher: height and width in the interface unit (a size typed in any unit, `300 px` too), scale of the original or the opening size, Lock aspect ratio, the original size with its pixels and Reset (#2573); Format Page Numbers, from Insert › Page Number ▸ Format Page Numbers…: the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and Continue from previous section / Start at, #1113; Finish & Merge records (All, Current record, or From and To by record number), opened from Finish & Merge › Edit Individual Documents… and Print Documents… (#3028)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | +| Modal dialogs | **63** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Custom Document Properties (File › Info › Properties › Advanced Properties › Custom, #2982), Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Table of Figures (#3013), Source Manager, Create/Edit Source (#402); Cross-reference (#916); Drop Cap with Position, Font, Lines to drop and Distance from text, opened from Insert › Drop Cap › Drop Cap Options… (#2601); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, a number that doesn't divide its height refused with a divisor message, more than its height with the range message; 1 × 1 changes nothing; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592); Size, from Picture Format › Size's launcher: height and width in the interface unit (a size typed in any unit, `300 px` too), scale of the original or the opening size, Lock aspect ratio, the original size with its pixels and Reset (#2573); Format Page Numbers, from Insert › Page Number ▸ Format Page Numbers…: the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and Continue from previous section / Start at, #1113; Finish & Merge records (All, Current record, or From and To by record number), opened from Finish & Merge › Edit Individual Documents… and Print Documents… (#3028)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Insert Caption… (#1230) and Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (times in the user's own time zone, in the pane and the balloons, #2322, #2337; typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | -| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712); each side-menu item has an icon, drawn in code (#1714); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | +| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712); each side-menu item has an icon, drawn in code (#1714); Info › Properties › Advanced Properties… opens the Custom Document Properties dialog (#2982); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014); an In Line with Text object drags within the text as in Word: a dashed insertion cursor follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234) | ✅ | 75% | — | @@ -82,6 +82,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Custom Document Properties dialog: File › Info › Properties › Advanced Properties… opens it; the document's custom properties listed with Add, Modify and Delete over a name/value form (text properties only; Word's number/date/yes-no types not offered), OK one undoable `file.properties` step, Cancel discards; modal dialogs 62 → 63 (#2982) | | 2026-10-25 | trivial | Text Highlight Color shows the remembered colour on the ribbon and mini-toolbar buttons; No Color leaves the pen without a misleading yellow bar (#4087) | | 2026-10-24 | trivial | Read Mode: a Page color menu (None, Sepia, Inverse) beside Line spacing; the columns are drawn on white, sepia or a dark page, in place of the document's page color (`view.readPageColor`) | | 2026-10-24 | trivial | Reading Mode: Read Mode's View menu in the parity catalog; Column Width and Page Color work, Layout is missing | From ea5780d2ab02d284436a952d16a07788f90f03b3 Mon Sep 17 00:00:00 2001 From: Dmitrii Khlapov Date: Sun, 25 Oct 2026 16:48:13 +0100 Subject: [PATCH 36/50] Gap 9: open AES-encrypted Word files (Standard encryption) (#2628) * Gap 9: open AES-encrypted Word files (Standard encryption) Co-Authored-By: Claude Opus 5.5 * Gap 9: restore progress-doc rows lost in the main merge Co-Authored-By: Claude Opus 5.5 * Gap 9: Standard package layout test built from the spec Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/docbin/src/crypt.rs | 7 +- crates/docbin/src/tests/encrypted.rs | 3 + crates/docx/src/crypt.rs | 368 ++++++++++++++++++++++++++- crates/docx/src/lib.rs | 4 +- crates/docx/tests/it/malformed.rs | 43 ++++ crates/engine/src/io.rs | 3 +- crates/engine/src/tests.rs | 24 ++ docs/file-format-parity.md | 8 +- docs/gaps.md | 3 +- 10 files changed, 455 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e9e648cf1b..2049798220 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -193,6 +193,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-17 | Password-protected Word files from Word 2007 (Standard encryption: AES-128/192/256, SHA-1) open with their password, also when named `.doc` or `.dot`; a wrong or missing password is reported as for newer files. Word 97-2003 files with an AES header, which the public spec doesn't define, are refused with a clear message (gap 9) | | 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. | | 2026-10-23 | Interface translations come from storytold/craft-i18n, shared with the other Crafting Apps: the catalogs are embedded at build time from the commit in `craft-i18n.rev` and looked up through craft-libs' `craft-i18n` crate; `crates/ui-egui/src/i18n/*.tsv` are gone (translations now go to craft-i18n). Strings other apps already translate show translated; a build without `CRAFT_I18N_DIR` is English only | | 2026-10-25 | #4085 mark review: whole-cell and ordinary highlight targets include their selected paragraph marks; empty, inherited and partial-column controls added. | @@ -634,6 +635,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-17 | trivial | Progress log: Standard-encrypted (AES) Word files open (gap 9) | | 2026-10-25 | trivial | Progress log: Text Highlight Color buttons show the remembered colour, or no colour bar after No Color (#4087) | | 2026-10-23 | minor | A command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it. | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | diff --git a/crates/docbin/src/crypt.rs b/crates/docbin/src/crypt.rs index ee8a9477a4..9e139f6922 100644 --- a/crates/docbin/src/crypt.rs +++ b/crates/docbin/src/crypt.rs @@ -295,8 +295,13 @@ impl Cipher { if hflags & H_EXTERNAL != 0 { return Err(unsupported("the document uses an external encryption provider")); } + // [MS-OFFCRYPTO] defines AES only for the encrypted packages of Word 2007 and later + // (which open through the .docx reader whatever their extension); a Word 97-2003 file + // may only use XOR, RC4 or RC4 CryptoAPI, so there is no public scheme to follow here. if hflags & H_AES != 0 { - return Err(unsupported("AES-encrypted .doc files are not supported")); + return Err(unsupported( + "this Word 97-2003 file claims AES encryption, which is only defined for the newer encrypted-package format; it can't be opened", + )); } if hflags & H_CRYPTO_API == 0 { return Err(unsupported("unknown encryption header")); diff --git a/crates/docbin/src/tests/encrypted.rs b/crates/docbin/src/tests/encrypted.rs index 0ff98ac354..f6fcea9716 100644 --- a/crates/docbin/src/tests/encrypted.rs +++ b/crates/docbin/src/tests/encrypted.rs @@ -199,6 +199,9 @@ fn unsupported_variants_say_so() { let (h, k) = capi_header("pw", 128, hflags); let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err(); assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}"); + if hflags & 0x20 != 0 { + assert!(e.to_string().contains("only defined for the newer encrypted-package format"), "{e}"); + } } let (mut h, k) = rc4_header("pw"); h[0] = 9; diff --git a/crates/docx/src/crypt.rs b/crates/docx/src/crypt.rs index cf050bee88..2087d9bda0 100644 --- a/crates/docx/src/crypt.rs +++ b/crates/docx/src/crypt.rs @@ -9,6 +9,10 @@ //! a verifier that tells a wrong password apart; an HMAC over the encrypted stream checks that it //! hasn't been damaged or tampered with. //! +//! Older packages (Word 2007) use the *Standard* method (§2.3.4.5–§2.3.4.9): a binary +//! `EncryptionInfo`, one AES key derived from the password with SHA-1 and 50,000 spins, and the +//! whole package AES-ECB encrypted. [`decrypt`] reads both; [`encrypt`] writes agile only. +//! //! Files are hostile: every stream read is capped, every length is checked before slicing, and the //! spin count is bounded by the specification's maximum. Passwords and keys are wiped from memory //! when dropped ([`Zeroizing`]) and never logged. @@ -47,6 +51,20 @@ const BK_KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6]; const BK_HMAC_KEY: [u8; 8] = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6]; const BK_HMAC_VALUE: [u8; 8] = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33]; +/// Standard encryption (§2.3.2, §2.3.4.5–§2.3.4.7): `EncryptionHeader.Flags` bits, the AES and +/// SHA-1 algorithm ids, the provider type, the fixed spin count and the SHA-1 size. +const F_CRYPTO_API: u32 = 0x04; +const F_EXTERNAL: u32 = 0x10; +const F_AES: u32 = 0x20; +const STANDARD_FLAGS: u32 = F_CRYPTO_API | F_AES; +const ALG_AES_128: u32 = 0x660E; +const ALG_AES_192: u32 = 0x660F; +const ALG_AES_256: u32 = 0x6610; +const ALG_SHA1: u32 = 0x8004; +const PROV_RSA_AES: u32 = 0x18; +const STANDARD_SPIN_COUNT: u32 = 50_000; +const SHA1_SIZE: usize = 20; + /// An OLE compound file starts with these bytes ([MS-CFB] §2.2). const CFB_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; @@ -72,6 +90,9 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result, DocxErro let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| bad(format!("not a readable compound file ({e})")))?; let info = stream(&mut comp, "/EncryptionInfo", MAX_INFO)?; let package = stream(&mut comp, "/EncryptedPackage", bytes.len() as u64)?; + if matches!(info.get(..4), Some([2..=4, 0, 2, 0])) { + return decrypt_standard(&info, &package, password); + } let info = parse_info(&info)?; let Some(password) = password else { return Err(DocxError::PasswordRequired) }; let pe = &info.password; @@ -125,6 +146,92 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result, DocxErro Ok(out) } +/// [`decrypt`] for Standard encryption (§2.3.4.5–§2.3.4.9): one AES key from the password, +/// checked with the verifier, and the whole package AES-ECB encrypted after its 8-byte size. +fn decrypt_standard(info: &[u8], package: &[u8], password: Option<&str>) -> Result, DocxError> { + let s = parse_standard(info)?; + let Some(password) = password else { return Err(DocxError::PasswordRequired) }; + let key = standard_key(s.salt, password, s.key_bytes); + // The verifier (§2.3.4.9): a random value and its SHA-1, both encrypted with the key. + let verifier = Zeroizing::new(ecb(&key, s.verifier, false)?); + let hash = Zeroizing::new(ecb(&key, s.verifier_hash, false)?); + let hash = hash.get(..SHA1_SIZE).ok_or_else(|| bad("the password verifier hash is too short"))?; + if !same(&Hash::Sha1.hash(&[&verifier]), hash) { + return Err(DocxError::WrongPassword); + } + // The package (§2.3.4.4): StreamSize, then the data; bytes past the last block are ignored. + let size = package + .get(..8) + .and_then(|b| <[u8; 8]>::try_from(b).ok()) + .map(u64::from_le_bytes) + .ok_or_else(|| bad("the encrypted package is truncated"))?; + let data = package.get(8..).unwrap_or(&[]); + let size = usize::try_from(size).ok().filter(|s| *s <= data.len()).ok_or_else(|| bad("the encrypted package is truncated"))?; + let needed = size.div_ceil(BLOCK).saturating_mul(BLOCK); + let data = data.get(..needed).ok_or_else(|| bad("the encrypted package is truncated"))?; + let mut out = ecb(&key, data, false)?; + out.truncate(size); + Ok(out) +} + +/// What a Standard `EncryptionInfo` stream says (§2.3.4.5): the key size, and the verifier's salt +/// and encrypted values. +struct Standard<'a> { + key_bytes: usize, + salt: &'a [u8], + verifier: &'a [u8], + verifier_hash: &'a [u8], +} + +fn parse_standard(info: &[u8]) -> Result, DocxError> { + let short = || bad("EncryptionInfo is truncated"); + let u32_at = |at: usize| -> Result { + let b = at.checked_add(4).and_then(|end| info.get(at..end)).ok_or_else(short)?; + <[u8; 4]>::try_from(b).map(u32::from_le_bytes).map_err(|_| short()) + }; + let flags = u32_at(4)?; + if flags & F_EXTERNAL != 0 { + return Err(bad("this file uses extensible encryption, which WordCraft can't open")); + } + if flags & STANDARD_FLAGS != STANDARD_FLAGS { + return Err(bad("this file's encryption isn't AES, which WordCraft can't open")); + } + // EncryptionHeader (§2.3.2): Flags, SizeExtra, AlgID, AlgIDHash, KeySize, ProviderType, two + // reserved words, then the CSP name, which isn't needed. + let header_size = usize::try_from(u32_at(8)?).map_err(|_| short())?; + if header_size < 32 { + return Err(bad("the encryption header is too short")); + } + let verifier_at = header_size.checked_add(12).filter(|&v| v <= info.len()).ok_or_else(short)?; + // AlgID 0 and AlgIDHash 0 mean "set by the flags": AES-128 and SHA-1. + let bits = match u32_at(20)? { + 0 | ALG_AES_128 => 128, + ALG_AES_192 => 192, + ALG_AES_256 => 256, + id => return Err(bad(format!("encryption algorithm 0x{id:X} isn't supported"))), + }; + let hash_id = u32_at(24)?; + if !matches!(hash_id, 0 | ALG_SHA1) { + return Err(bad(format!("hash algorithm 0x{hash_id:X} isn't supported"))); + } + if u32_at(28)? != bits { + return Err(bad("the key size doesn't match the encryption algorithm")); + } + // EncryptionVerifier (§2.3.3): SaltSize (16), Salt, EncryptedVerifier, VerifierHashSize (20 + // for SHA-1), EncryptedVerifierHash (32 bytes for AES). + if u32_at(verifier_at)? != SALT as u32 { + return Err(bad("the salt isn't 16 bytes")); + } + let field = |at: usize, len: usize| verifier_at.checked_add(at).and_then(|a| info.get(a..a.checked_add(len)?)).ok_or_else(short); + let salt = field(4, SALT)?; + let verifier = field(4 + SALT, BLOCK)?; + if u32_at(verifier_at.saturating_add(4 + SALT + BLOCK))? != SHA1_SIZE as u32 { + return Err(bad("the password verifier hash isn't SHA-1")); + } + let verifier_hash = field(8 + SALT + BLOCK, 2 * BLOCK)?; + Ok(Standard { key_bytes: bits as usize / 8, salt, verifier, verifier_hash }) +} + /// Encrypt a zip package with `password` (agile encryption: AES-256-CBC, SHA-512, Word's spin /// count), as Word's "Encrypt with Password" does. pub fn encrypt(package: &[u8], password: &str) -> Result, DocxError> { @@ -213,6 +320,47 @@ pub fn encrypt_with_spin_count(package: &[u8], password: &str, spin_count: u32) Ok(comp.into_inner().into_inner()) } +/// A package encrypted with the *Standard* method (§2.3.4.5–§2.3.4.9: AES-ECB, SHA-1, 50,000 +/// spins), as Word 2007 wrote it, for tests only: WordCraft saves with the agile method +/// ([`encrypt`]). `key_bits` is 128, 192 or 256. The compound file holds just the two streams +/// [`decrypt`] reads. +#[doc(hidden)] +pub fn encrypt_standard_for_tests(package: &[u8], password: &str, key_bits: u32) -> Result, DocxError> { + check_password(password)?; + let alg_id = match key_bits { + 128 => ALG_AES_128, + 192 => ALG_AES_192, + 256 => ALG_AES_256, + _ => return Err(bad(format!("unsupported AES key size {key_bits}"))), + }; + let salt = random(SALT)?; + let verifier = random(SALT)?; + let key = standard_key(&salt, password, key_bits as usize / 8); + let mut header = Vec::new(); + for v in [STANDARD_FLAGS, 0, alg_id, ALG_SHA1, key_bits, PROV_RSA_AES, 0, 0] { + header.extend_from_slice(&v.to_le_bytes()); + } + // CSPName: an empty null-terminated UTF-16 string. + header.extend_from_slice(&[0, 0]); + let mut info = vec![3, 0, 2, 0]; + info.extend_from_slice(&STANDARD_FLAGS.to_le_bytes()); + info.extend_from_slice(&(header.len() as u32).to_le_bytes()); + info.extend_from_slice(&header); + info.extend_from_slice(&(SALT as u32).to_le_bytes()); + info.extend_from_slice(&salt); + info.extend_from_slice(&ecb(&key, &verifier, true)?); + info.extend_from_slice(&(SHA1_SIZE as u32).to_le_bytes()); + info.extend_from_slice(&ecb(&key, &padded(&Hash::Sha1.hash(&[&verifier])), true)?); + let mut stream = (package.len() as u64).to_le_bytes().to_vec(); + stream.extend_from_slice(&ecb(&key, &padded(package), true)?); + let io = |e: std::io::Error| bad(format!("couldn't write the compound file ({e})")); + let mut comp = cfb::CompoundFile::create_with_version(cfb::Version::V3, Cursor::new(Vec::new())).map_err(io)?; + comp.create_stream("/EncryptionInfo").map_err(io)?.write_all(&info).map_err(io)?; + comp.create_stream("/EncryptedPackage").map_err(io)?.write_all(&stream).map_err(io)?; + comp.flush().map_err(io)?; + Ok(comp.into_inner().into_inner()) +} + /// Refuse a password Word wouldn't accept: empty, or longer than [`MAX_PASSWORD_CHARS`]. pub fn check_password(password: &str) -> Result<(), DocxError> { if password.is_empty() { @@ -497,6 +645,45 @@ fn derive_key(hash: Hash, hn: &[u8], block_key: &[u8], len: usize) -> Zeroizing< k } +/// Standard encryption's key (§2.3.4.7): the SHA-1 password hash after 50,000 iterations, hashed +/// with block number 0; then X1 = SHA-1 of that hash XOR-ed into 64 bytes of 0x36, X2 the same +/// with 0x5C, and the key is the first `len` bytes of X1 + X2. +fn standard_key(salt: &[u8], password: &str, len: usize) -> Zeroizing> { + let hn = iterate(Hash::Sha1, salt, password, STANDARD_SPIN_COUNT); + let hfinal = Zeroizing::new(Hash::Sha1.hash(&[&hn, &0u32.to_le_bytes()])); + let x = |fill: u8| { + let mut buf = Zeroizing::new([fill; 64]); + for (b, h) in buf.iter_mut().zip(hfinal.iter()) { + *b ^= h; + } + Zeroizing::new(Hash::Sha1.hash(&[&buf[..]])) + }; + let mut key = Zeroizing::new(x(0x36).to_vec()); + key.extend_from_slice(&x(0x5C)); + key.truncate(len); + key +} + +/// AES-ECB without padding (Standard encryption, §2.3.4.7–§2.3.4.9): `data` must be whole blocks. +fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Result, DocxError> { + let aes = Aes::new(key)?; + let (blocks, rest) = data.as_chunks::(); + if !rest.is_empty() { + return Err(bad("encrypted data isn't a whole number of blocks")); + } + let mut out = Vec::with_capacity(data.len()); + for b in blocks { + let mut x = aes::Block::from(*b); + if encrypt { + aes.encrypt(&mut x); + } else { + aes.decrypt(&mut x); + } + out.extend_from_slice(&x); + } + Ok(out) +} + /// An initialization vector (§2.3.4.12): H(salt + blockKey), or the salt itself, truncated to /// the block size or padded with 0x36. fn make_iv(hash: Hash, salt: &[u8], block_key: Option<&[u8]>, block_size: usize) -> Vec { @@ -643,7 +830,6 @@ fn parse_info(info: &[u8]) -> Result { }; match (major, minor) { (4, 4) => {} - (2..=4, 2) => return Err(bad("this file uses Standard encryption (Office 2007), which WordCraft can't open yet")), (3 | 4, 3) => return Err(bad("this file uses extensible encryption, which WordCraft can't open")), _ => return Err(bad(format!("unknown encryption version {major}.{minor}"))), } @@ -992,4 +1178,184 @@ mod tests { assert!(!is_encrypted(&fake)); assert!(decrypt(&fake, Some("pw")).is_err()); } + + /// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the + /// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib) + /// following the specification's steps, so the encryptor and decryptor can't share a mistake. + #[test] + fn standard_key_matches_an_independent_derivation() { + let salt: Vec = (0..16).collect(); + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + assert_eq!(hex(&standard_key(&salt, "Password1234_", 16)), "340fc93a8e2154db8304d055df81cebc"); + assert_eq!(hex(&standard_key(&salt, "Password1234_", 32)), "340fc93a8e2154db8304d055df81cebcef5e89fb8e5eb520efdca76f3330e032"); + assert_eq!(standard_key(&salt, "Password1234_", 24).len(), 24); + // AES-ECB against FIPS-197 Appendix C.1 and C.3. + let pt: Vec = (0..16u8).map(|i| i * 0x11).collect(); + let k128: Vec = (0..16).collect(); + let k256: Vec = (0..32).collect(); + assert_eq!(hex(&ecb(&k128, &pt, true).unwrap()), "69c4e0d86a7b0430d8cdb78070b4c55a"); + assert_eq!(hex(&ecb(&k256, &pt, true).unwrap()), "8ea2b7ca516745bfeafc49904b496089"); + assert_eq!(ecb(&k256, &ecb(&k256, &pt, true).unwrap(), false).unwrap(), pt); + assert!(ecb(&k128, &pt[..15], false).is_err()); + } + + /// Standard encryption (Word 2007's AES-ECB method) opens with the right password, for each + /// AES key size, and says when the password is missing or wrong — as agile files do. + #[test] + fn standard_encryption_round_trips() { + let pkg = zip_package(); + for bits in [128, 192, 256] { + let enc = encrypt_standard_for_tests(&pkg, "pässwörd 🔑", bits).unwrap(); + assert!(is_encrypted(&enc)); + assert_eq!(decrypt(&enc, Some("pässwörd 🔑")), Ok(pkg.clone()), "AES-{bits}"); + assert_eq!(decrypt(&enc, None), Err(DocxError::PasswordRequired)); + assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword)); + assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired)); + } + let enc = encrypt_standard_for_tests(&pkg, "pw", 128).unwrap(); + let doc = crate::read_with_password(&enc, Some("pw")).unwrap(); + assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans")); + // Sizes on and off the block boundary, and empty. + for n in [0usize, 1, 15, 16, 17, SEGMENT + 3] { + let data: Vec = (0..n).map(|i| (i * 7) as u8).collect(); + assert_eq!(decrypt(&encrypt_standard_for_tests(&data, "x", 256).unwrap(), Some("x")).unwrap(), data); + } + assert!(encrypt_standard_for_tests(&pkg, "pw", 64).is_err()); + assert!(encrypt_standard_for_tests(&pkg, "", 128).is_err()); + } + + /// A Standard-encrypted file assembled byte by byte from [MS-OFFCRYPTO] §2.3.4.5, §2.3.2, §2.3.3 + /// and §2.3.4.4 with literal field values, without the test encryptor, so a layout mistake + /// shared by the encryptor and the reader can't pass. Only the key derivation and AES-ECB + /// (pinned by `standard_key_matches_an_independent_derivation`) are reused. + fn hand_built_standard(payload: &[u8], password: &str) -> Vec { + let salt: [u8; 16] = [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF]; + let verifier: [u8; 16] = *b"verifier-16bytes"; + let key = standard_key(&salt, password, 16); + let mut hash = sha1::Sha1::digest(verifier).to_vec(); + hash.resize(32, 0); // the hash, zero-padded to two AES blocks + let csp: Vec = "WordCraft test provider".encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect(); + + let mut header = Vec::new(); + header.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES + header.extend_from_slice(&0u32.to_le_bytes()); // SizeExtra + header.extend_from_slice(&0x660Eu32.to_le_bytes()); // AlgID: AES-128 + header.extend_from_slice(&0x8004u32.to_le_bytes()); // AlgIDHash: SHA-1 + header.extend_from_slice(&128u32.to_le_bytes()); // KeySize, in bits + header.extend_from_slice(&0x18u32.to_le_bytes()); // ProviderType: AES + header.extend_from_slice(&0u32.to_le_bytes()); // Reserved1 + header.extend_from_slice(&0u32.to_le_bytes()); // Reserved2 + header.extend_from_slice(&csp); // CSPName: UTF-16LE, NUL-terminated + + let mut info = Vec::new(); + info.extend_from_slice(&3u16.to_le_bytes()); // Version major + info.extend_from_slice(&2u16.to_le_bytes()); // Version minor + info.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES + info.extend_from_slice(&(header.len() as u32).to_le_bytes()); // HeaderSize + info.extend_from_slice(&header); + info.extend_from_slice(&16u32.to_le_bytes()); // SaltSize + info.extend_from_slice(&salt); // Salt + info.extend_from_slice(&ecb(&key, &verifier, true).unwrap()); // EncryptedVerifier + info.extend_from_slice(&20u32.to_le_bytes()); // VerifierHashSize (SHA-1) + info.extend_from_slice(&ecb(&key, &hash, true).unwrap()); // EncryptedVerifierHash + + let mut padded = payload.to_vec(); + padded.resize(payload.len().next_multiple_of(16), 0); + let mut package = (payload.len() as u64).to_le_bytes().to_vec(); // StreamSize + package.extend_from_slice(&ecb(&key, &padded, true).unwrap()); + package.extend_from_slice(&[0xEE, 0xEE, 0xEE]); // trailing padding the spec allows + cfb_with(&info, &package) + } + + #[test] + fn standard_package_built_by_hand_from_the_spec_opens() { + let payload: Vec = (0..37u8).map(|i| i.wrapping_mul(5)).collect(); + let file = hand_built_standard(&payload, "pw"); + assert!(is_encrypted(&file)); + assert_eq!(decrypt(&file, Some("pw")), Ok(payload.clone())); + assert_eq!(decrypt(&file, Some("PW")), Err(DocxError::WrongPassword)); + assert_eq!(decrypt(&file, None), Err(DocxError::PasswordRequired)); + // The test encryptor writes the same fixed fields: version and flags (bytes 0..8) and the + // header's eight words (12..44); HeaderSize differs with the CSP name, the rest is random. + let (hand, _) = streams(&file); + let (made, _) = streams(&encrypt_standard_for_tests(&payload, "pw", 128).unwrap()); + assert_eq!(hand[..8], made[..8]); + assert_eq!(hand[12..44], made[12..44]); + } + + /// The two streams of an encrypted package. + fn streams(file: &[u8]) -> (Vec, Vec) { + let mut comp = cfb::CompoundFile::open(Cursor::new(file)).unwrap(); + let mut info = Vec::new(); + comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap(); + let mut package = Vec::new(); + comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap(); + (info, package) + } + + #[test] + fn hostile_standard_encryption_info_errors_without_panicking() { + let data = b"a small package".to_vec(); + let (info, package) = streams(&encrypt_standard_for_tests(&data, "pw", 128).unwrap()); + // Layout: version 0, flags 4, header size 8, header 12 (flags, sizeExtra, algId 20, + // algIdHash 24, keySize 28, provider, 2 reserved, an empty CSP name), then the verifier at + // 46: saltSize, salt 50, encrypted verifier 66, verifierHashSize 82, its hash 86..118. + assert_eq!(info.len(), 118); + let put = |at: usize, v: u32| { + let mut i = info.clone(); + i[at..at + 4].copy_from_slice(&v.to_le_bytes()); + i + }; + let mut cases: Vec<(Vec, Vec)> = Vec::new(); + for n in 0..info.len() { + cases.push((info[..n].to_vec(), package.clone())); + } + for v in [0, 4, 31, 0x1000, u32::MAX] { + cases.push((put(8, v), package.clone())); + } + // No AES, no CryptoAPI, an external (extensible) provider. + for v in [F_CRYPTO_API, F_AES, STANDARD_FLAGS | F_EXTERNAL, 0] { + cases.push((put(4, v), package.clone())); + } + // RC4, MD5, key sizes that don't fit the algorithm. + cases.push((put(20, 0x6801), package.clone())); + cases.push((put(24, 0x8003), package.clone())); + for v in [0, 64, 192, 256, u32::MAX] { + cases.push((put(28, v), package.clone())); + } + for v in [0, 15, 17, u32::MAX] { + cases.push((put(46, v), package.clone())); + } + for v in [0, 16, 32, u32::MAX] { + cases.push((put(82, v), package.clone())); + } + // The package: truncated, an absurd StreamSize, a StreamSize past the data, data that + // isn't whole blocks. + for n in [0, 4, 8, 9, package.len() - 1] { + cases.push((info.clone(), package[..n].to_vec())); + } + for size in [u64::MAX, 1 << 40, package.len() as u64] { + let mut p = package.clone(); + p[..8].copy_from_slice(&size.to_le_bytes()); + cases.push((info.clone(), p)); + } + for (i, p) in cases { + let file = cfb_with(&i, &p); + assert!(decrypt(&file, Some("pw")).is_err()); + let _ = crate::read_with_password(&file, Some("pw")); + assert!(crate::read(&file).is_err()); + } + // Version 2.2 and 4.2 are Standard too; trailing bytes after the last block are ignored. + for v in [2u8, 4] { + let mut i = info.clone(); + i[0] = v; + assert_eq!(decrypt(&cfb_with(&i, &package), Some("pw")).unwrap(), data); + } + let mut p = package.clone(); + p.extend_from_slice(&[1, 2, 3]); + assert_eq!(decrypt(&cfb_with(&info, &p), Some("pw")).unwrap(), data); + // AlgID and AlgIDHash 0 mean "from the flags": AES-128 and SHA-1. + assert_eq!(decrypt(&cfb_with(&put(20, 0), &package), Some("pw")).unwrap(), data); + assert_eq!(decrypt(&cfb_with(&put(24, 0), &package), Some("pw")).unwrap(), data); + } } diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 5bdd744161..27cb0e6da1 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -25,8 +25,8 @@ mod xml; pub use bibliography::{MAX_SOURCES, Sources, read_sources}; pub use chart_spec::{chart_items, chart_xml}; pub use crypt::{ - DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count, - is_encrypted, protection_hash, + DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_standard_for_tests, + encrypt_with_spin_count, is_encrypted, protection_hash, }; pub use flat::{XmlKind, sniff_xml, write as write_flat}; pub use fonts::{EmbeddedFont, embedded_fonts}; diff --git a/crates/docx/tests/it/malformed.rs b/crates/docx/tests/it/malformed.rs index 0dcaeb8a9f..15a4b263c7 100644 --- a/crates/docx/tests/it/malformed.rs +++ b/crates/docx/tests/it/malformed.rs @@ -273,6 +273,49 @@ proptest! { } } +/// The streams of a Standard-encrypted package (gap 9), and a compound file holding them. +fn standard_streams() -> (Vec, Vec) { + use std::io::Read; + let file = wordcraft_docx::encrypt_standard_for_tests(&sample(), "pw", 128).unwrap(); + let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(file)).unwrap(); + let mut info = Vec::new(); + comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap(); + let mut package = Vec::new(); + comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap(); + (info, package) +} + +fn compound(info: &[u8], package: &[u8]) -> Vec { + let mut comp = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap(); + comp.create_stream("/EncryptionInfo").unwrap().write_all(info).unwrap(); + comp.create_stream("/EncryptedPackage").unwrap().write_all(package).unwrap(); + comp.flush().unwrap(); + comp.into_inner().into_inner() +} + +proptest! { + // Each case that parses runs the 50,000-spin key derivation, so fewer cases. + #![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })] + + #[test] + fn mutated_standard_encryption_never_panics(ops in proptest::collection::vec((0u8..3, any::(), any::(), any::()), 1..8)) { + let (mut info, mut package) = standard_streams(); + for (k, idx, byte, in_package) in ops { + let x = if in_package { &mut package } else { &mut info }; + let at = idx.index(x.len() + 1); + match k { + 0 => { if at < x.len() { x[at] = byte; } } + 1 => { x.truncate(at); } + _ => { x.splice(at..at, std::iter::repeat_n(byte, usize::from(byte % 9))); } + } + } + let file = compound(&info, &package); + let _ = wordcraft_docx::decrypt(&file, Some("pw")); + let _ = wordcraft_docx::read_with_password(&file, Some("pw")); + prop_assert!(read(&file).is_err()); + } +} + #[test] fn long_header_reference_chain_is_cut_off() { const HDR_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"; diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 27e7020535..fc4d3b74de 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -146,7 +146,8 @@ fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bo } /// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a -/// password ([MS-OFFCRYPTO] agile encryption, or the Word 97-2003 schemes) is decrypted with +/// password ([MS-OFFCRYPTO] agile or Standard encryption, under any Word extension, or the +/// Word 97-2003 schemes) is decrypted with /// `password` first. Also says /// whether the file was encrypted. Without the password needed the error satisfies /// [`needs_password`]; with a wrong one, [`wrong_password`]. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 461d5f14ab..b6f58b43c2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -56,6 +56,30 @@ fn encrypted_doc_asks_for_a_password_and_rejects_a_wrong_one() { assert!(crate::io::needs_password(&e), "{e}"); } +/// Gap 9: AES-encrypted Word files — Standard encryption (Word 2007) and agile packages — open +/// with the right password whatever Word extension they carry, and ask for one or refuse a wrong +/// one the same way. +#[test] +fn aes_encrypted_packages_open_under_any_word_extension() { + let mut doc = wordcraft_doc::Document::from_text("Old secret minutes"); + doc.core.title = "Minutes".into(); + let pkg = wordcraft_docx::write(&doc).unwrap(); + let standard = wordcraft_docx::encrypt_standard_for_tests(&pkg, "letmein", 128).unwrap(); + let agile = wordcraft_docx::encrypt_with_spin_count(&pkg, "letmein", 8).unwrap(); + for (kind, bytes) in [("standard", &standard), ("agile", &agile)] { + for name in ["old.doc", "old.docx", "old.dot", "noext"] { + let (d, encrypted) = crate::io::open_bytes_with(name, bytes, Some("letmein")).unwrap_or_else(|e| panic!("{kind} {name}: {e}")); + assert!(encrypted, "{kind} {name}"); + assert!(d.plain_text(StoryRef::Body).contains("Old secret minutes"), "{kind} {name}"); + assert_eq!(d.core.title, "Minutes"); + let e = crate::io::open_bytes_with(name, bytes, None).unwrap_err(); + assert!(crate::io::needs_password(&e), "{kind} {name}: {e}"); + let e = crate::io::open_bytes_with(name, bytes, Some("letmeout")).unwrap_err(); + assert!(crate::io::wrong_password(&e), "{kind} {name}: {e}"); + } + } +} + #[test] fn txt_open_decodes_windows_1252_and_unmarked_utf16() { // #433: the TXT open path sniffs encodings like the formats importer. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b263e05f42..41f370a1e4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -7,7 +7,7 @@ share of a typical real-world file's content and formatting that survives (read: write: Word opens it showing the same). Summary in [`target-app-parity.md`](target-app-parity.md); the work items are in [`gaps.md`](gaps.md). -**Dimension: ~60% (estimated), 179–290 h to full** (the sum of the rows below). The weighted table +**Dimension: ~60% (estimated), 175–287 h to full** (the sum of the rows below). The weighted table gives ~64%; we discount it to ~60% because DOCX fidelity has never been checked on real-world files. DOCX dominates the weight: a Word user sends and receives .docx; everything else is occasional. @@ -60,10 +60,9 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/it/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | | `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/it/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/it/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | | `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value | -| Encrypted/password-protected `.docx`/`.doc` | 1% | R/W | ❌ | ❌ | 0% | 6–10 | — | ECMA-376 Agile Encryption; `.doc` XOR, RC4 and RC4 CryptoAPI open with a password (`docbin` `crypt.rs`, #1079; synthetic tests only, untested against Word-written files; AES `.doc` refused). Issue #55 | | `.xlsx`/`.xlsm`/`.xls`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell, 64 MiB of cell text a sheet so a shared string repeated in every cell can't exhaust memory, #1287). Legacy `.xls` (Excel 97–2003 BIFF8, #778): SST/LABELSST, LABEL, NUMBER, RK/MULRK, BOOLERR and cached FORMULA results with the same date handling; Excel 5.0/95 and password-protected `.xls` are refused with a message; a CSV/TSV named `.xls` reads as text; an HTML page or XML spreadsheet named `.xls` gets a message saying which it is (#1287). Text lists (.csv/.tsv/.txt) decode UTF-8/UTF-16 by BOM or shape, else the detected legacy encoding (GBK/GB18030, Big5, Shift_JIS, EUC-KR, Windows code pages; `encoding` param overrides) (#778). Not shown: number formats other than dates (currency, percent, thousands separators); no Access or Outlook sources | -| Encrypted/password-protected `.docx` | 1% | R/W | ✅ | ✅ | ~60% | 2–4 | `crates/docx/src/crypt.rs` (6 unit tests), `crates/engine/src/tests.rs::password_protected_documents_round_trip` | ECMA-376 / [MS-OFFCRYPTO] agile encryption (AES-256-CBC, SHA-512): a protected `.docx` opens with its password and saves encrypted (#55, #281). Synthetic files only; never checked against Word-made protected files | -| Encrypted/password-protected `.doc` (RC4/XOR) | <1% | R | ❌ | — | 0% | 4–6 | `crates/docbin/src/tests.rs::encrypted_rejected` | Detected and refused (`DocbinError::Encrypted`, `docbin` fib). Not decrypted | +| Encrypted/password-protected `.docx` | 1% | R/W | ✅ | ✅ | ~60% | 1–2 | `crates/docx/src/crypt.rs` (9 unit tests), `tests/malformed.rs`, `crates/engine/src/tests.rs::password_protected_documents_round_trip`, `aes_encrypted_packages_open_under_any_word_extension` | ECMA-376 / [MS-OFFCRYPTO] agile encryption (AES-256-CBC, SHA-512): a protected `.docx` opens with its password and saves encrypted (#55, #281). Standard encryption (Word 2007: AES-128/192/256-ECB, SHA-1, 50,000 spins) opens too, and either kind opens when named `.doc`/`.dot` (gap 9); saving stays agile. Synthetic files only; never checked against Word-made protected files | +| Encrypted/password-protected `.doc` (XOR/RC4) | <1% | R | 🟡 | — | ~60% | 1–2 | `crates/docbin/src/tests/encrypted.rs` | XOR obfuscation, RC4 and RC4 CryptoAPI open with a password (`docbin` `crypt.rs`, #1079). An AES header (`fAES`) is refused with a clear message: [MS-OFFCRYPTO] defines AES only for encrypted packages, which open through the `.docx` reader (gap 9) | | `.md` Markdown | — | (no) | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word | | `.tex` LaTeX | — | — | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word (#11), incl. math | | Pictures for Insert › Picture, drag-and-drop and paste | — | R | ✅ | — | — | — | `insert_picture_takes_a_windows_metafile` (engine), `paste_picture.rs` | PNG, JPEG, GIF, WebP, BMP, and Windows Metafile `.wmf` / Enhanced Metafile `.emf` at their own frame size (#802), drawn by `crates/metafile`. Not yet: SVG, TIFF, ICO, HEIC | @@ -265,6 +264,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | Date | Change | Summary | |---|---|---| +| 2026-10-17 | trivial | Encryption rows: Standard (Word 2007, AES-ECB) encrypted packages open with a password, also when named `.doc`/`.dot`; AES headers in Word 97-2003 files are outside the public spec and refused (gap 9). The duplicate combined `.docx`/`.doc` row is removed; the `.doc` row reads 🟡 ~60% (it opens XOR/RC4/RC4 CryptoAPI); hours 2–4 → 1–2 and 4–6 → 1–2 (left: checks against Word-written files); total re-summed from the rows, 175–287 h | | 2026-10-25 | trivial | Repeated DOCX style IDs: repeated save/reopen regression for whole-definition replacement. | | 2026-10-25 | trivial | Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`) | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index f7a0ad1099..c5cd0341f6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -64,7 +64,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | | 7 | **Dialog depth**: 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | -| 9 | **Password-protected `.doc` files** (AES) can't be opened; password-protected `.docx` opens and saves (agile encryption, #55 closed by #281) | | `crates/docx/src/crypt.rs`; `crates/docbin/src/lib.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079, untested on Word-written files; AES `.doc` refused) | Corporate and legal users with old files | 3–5 | [file-format-parity.md](file-format-parity.md) | +| 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries or grammar for the other ~41 languages Word proofs (text in them is left unmarked), and no hyphenation for them except Bulgarian and German | | `crates/proof` `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | @@ -143,6 +143,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | Date | Change | Summary | |---|---|---| +| 2026-10-17 | trivial | Gap 9 narrowed: Standard (Word 2007, AES-ECB) encrypted packages open with a password under any Word extension; AES headers in Word 97-2003 files are outside the public spec and refused with a clear message; estimate 3–5 → 0 h, since the remaining AES-in-`.doc` case is refused by design | | 2026-10-25 | trivial | Small follow-ups from the field, table and AutoFormat batch (#4232) | | 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). | | 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). | From 4095cc341372f47e07b120a29260aac62fa38829 Mon Sep 17 00:00:00 2001 From: Martin Ball <98639962+benandez@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:32:19 +0000 Subject: [PATCH 37/50] Security: bounded file reads, Read Aloud and macOS signing temp file (#3680, #3666) (#3877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #3680: Read Aloud tidies in one linear pass and cuts space-free sentences Finding 56. tidy() repeated whole-string replacements until nothing changed; each pass removed one level of nested empty brackets, so 40,000 pairs took ~50 s in a debug build. It is now one pass that drops a closing bracket's empty pair as it goes, checked against the old algorithm on 20,000 generated strings. Sentences with no space to split at are cut at MAX_UTTERANCE (1,200) characters, and each sentence is sliced from a char vector instead of skipping from the paragraph's start. Fixes #3680 Co-Authored-By: Claude Opus 5.5 (1M context) * #3692: Paste Special reads only a regular picture file, bounded Finding 24. A copied path naming a device such as /dev/zero reports a length of 0, so the size check passed and std::fs::read read forever. The path and the opened handle must both be regular files, and at most the picture limit + 1 bytes are read. A named pipe swapped in between the check and the open can still block the open (no portable non-blocking open without platform flags); noted in the code. Co-Authored-By: Claude Opus 5.5 (1M context) * #3692: macOS signing import decodes the certificate into a private temp folder Finding 25. The .p12 bundle was written to a predictable name in the shared temp folder, where a planted link could redirect it. It now goes into a mktemp -d folder under the same temp root, removed on exit. The keychain is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) * #3666: engine file reads open once and check the handle; a FIFO is refused without blocking read_file_capped checked the path's metadata and then read the path again, so a file swapped or grown after the check was read whole; read_file_head opened the path with no type check, so saving to a FIFO .xml path (the Word 2003 sniff) blocked until a writer appeared. Both now go through open_regular_file (path checked before opening, handle checked after, opened once) and a bounded read; read_regular_file uses the same pieces. Tests: both helpers refuse a FIFO without blocking and a folder; read_file_capped refuses content over the cap. Fixes #3666 Co-Authored-By: Claude Opus 5.5 (1M context) * #3692: Paste Special reads picture files through the engine's bounded read Ultrareview finding 9: paste_picture kept a third copy of the regular-file bounded read; it now calls wordcraft_engine::io::read_regular_file (public, with a wasm stub). Co-Authored-By: Claude Opus 5.5 (1M context) * #3666: open a checked file non-blocking so a swap to a named pipe can't hang CodeRabbit: a path replaced by a FIFO between the regular-file check and File::open blocked in open, before the handle check could refuse it. On Unix the open now uses O_NONBLOCK (no effect on regular files; libc is already in the lockfile) and the handle check refuses the pipe. The FIFO tests' cleanup writers open non-blocking too, so a regression fails the test instead of hanging it. Co-Authored-By: Claude Opus 5.5 (1M context) * #3666: review — Paste Special refuses a picture file whose handle reports more than the cap before reading it Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o * #3692: review — macOS signing keychain lives in the private mktemp folder too; removed if the import fails Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o * #3666: review — libc comes from the workspace dependencies Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o * #3680: review — Read Aloud logs a warning when its sentence cap leaves text unread Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o * #3692: review — engine speech timing test gets a margin a busy CI machine can't break tidy_is_linear_on_nested_brackets: 2 s -> 10 s. The one-pass tidy takes ~13 ms on a loaded machine; tidy_by_replacing (the old tidy, kept in the tests) took 293 s on the same input. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --------- Co-authored-by: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 3 + crates/engine/Cargo.toml | 4 + crates/engine/src/io.rs | 88 +++++++++++++++----- crates/engine/src/speech.rs | 119 +++++++++++++++++++++++++--- crates/engine/src/tests.rs | 92 ++++++++++++++++++++- crates/ui-egui/src/paste_picture.rs | 41 ++++++++-- packaging/macos/import-cert.sh | 20 ++++- 8 files changed, 327 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b46326df88..fac55c6a28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6635,6 +6635,7 @@ dependencies = [ "getrandom 0.3.4", "image", "js-sys", + "libc", "log", "regex", "regex-automata", diff --git a/Cargo.toml b/Cargo.toml index a79a64b2b2..5c5724cfbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,9 @@ hmac = "0.13" base64 = "0.22" getrandom = "0.3" zeroize = "1" +# Unix open flags (O_NONBLOCK for the engine's checked file reads, #3666); already in the lock as a +# dependency of other crates. +libc = "0.2" # ui egui = "0.36" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index fc4353dae3..eb9efc20f5 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -36,6 +36,10 @@ js-sys = "0.3" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = { workspace = true } +[target.'cfg(unix)'.dependencies] +# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666). +libc = { workspace = true } + [dev-dependencies] encoding_rs = { workspace = true } zip = { workspace = true } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index fc4d3b74de..5af08451bf 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -528,21 +528,67 @@ impl LocalImages { /// Up to `limit + 1` bytes of the regular file at `path` (more than `limit` means too large), or /// `None` for anything else. A named pipe blocks when opened and a device reports a length of 0 /// and may never end, so the path is checked before opening, the opened handle is checked again, -/// and the read is bounded whatever length the file reports. +/// and the read is bounded whatever length the file reports. Public for the app (a copied picture +/// file, Paste Special). #[cfg(not(target_arch = "wasm32"))] -fn read_regular_file(path: &std::path::Path, limit: u64) -> Option> { - use std::io::Read; - if !std::fs::metadata(path).ok()?.is_file() { +pub fn read_regular_file(path: &std::path::Path, limit: u64) -> Option> { + let (file, meta) = open_regular_file(path).ok()?; + read_bounded(file, &meta, limit).ok() +} + +#[cfg(target_arch = "wasm32")] +pub fn read_regular_file(_path: &std::path::Path, _limit: u64) -> Option> { + None +} + +/// The bytes of the regular file at `path` when it holds at most `limit` of them; `None` for +/// anything else. A file whose opened handle reports more than `limit` bytes is refused before +/// anything is read; the read stays bounded for a file that grows after the check. Public for the +/// app (a copied picture file, Paste Special). +#[cfg(not(target_arch = "wasm32"))] +pub fn read_regular_file_within(path: &std::path::Path, limit: u64) -> Option> { + let (file, meta) = open_regular_file(path).ok()?; + if meta.len() > limit { return None; } - let file = std::fs::File::open(path).ok()?; - let meta = file.metadata().ok()?; + read_bounded(file, &meta, limit).ok().filter(|b| b.len() as u64 <= limit) +} + +#[cfg(target_arch = "wasm32")] +pub fn read_regular_file_within(_path: &std::path::Path, _limit: u64) -> Option> { + None +} + +/// The regular file at `path`, opened once, with the opened handle's metadata. The path is +/// checked before opening (a named pipe blocks in `open` until a writer appears) and the handle +/// after (the path may have been swapped in between); anything but a regular file is "not a file". +#[cfg(not(target_arch = "wasm32"))] +fn open_regular_file(path: &std::path::Path) -> Result<(std::fs::File, std::fs::Metadata), String> { + let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; if !meta.is_file() { - return None; + return Err(format!("{}: not a file", path.display())); } + let mut open = std::fs::OpenOptions::new(); + open.read(true); + // A path swapped for a named pipe since the check above would block `open`; non-blocking + // opens it at once (no effect on a regular file) and the handle check below refuses it. + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::custom_flags(&mut open, libc::O_NONBLOCK); + let file = open.open(path).map_err(|e| format!("{}: {e}", path.display()))?; + let meta = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?; + if !meta.is_file() { + return Err(format!("{}: not a file", path.display())); + } + Ok((file, meta)) +} + +/// Up to `limit + 1` bytes of `file` (more than `limit` means too large), however long it says it is. +#[cfg(not(target_arch = "wasm32"))] +fn read_bounded(file: std::fs::File, meta: &std::fs::Metadata, limit: u64) -> std::io::Result> { + use std::io::Read; let mut out = Vec::with_capacity(usize::try_from(meta.len().min(limit)).unwrap_or(0)); - file.take(limit.saturating_add(1)).read_to_end(&mut out).ok()?; - Some(out) + file.take(limit.saturating_add(1)).read_to_end(&mut out)?; + Ok(out) } /// Largest file [`read_file`] reads whole — the same 2 GB ceiling [`open_path_with`] gives a @@ -564,29 +610,33 @@ pub fn read_file(path: &str) -> Result, String> { } /// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before -/// anything is read (a font named by the user, which could be any size). +/// anything is read (a font named by the user, which could be any size). The file is opened once +/// and the read stops past `max`, so a file swapped or grown after the check is refused too (#3666). #[cfg(not(target_arch = "wasm32"))] pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result, String> { crate::file_access::check_read(path)?; - let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; - if !meta.is_file() { - return Err(format!("{path}: not a file")); - } + let too_large = || format!("{path}: the {what} is larger than {} MB", max >> 20); + let (file, meta) = open_regular_file(std::path::Path::new(path))?; if meta.len() > max { - return Err(format!("{path}: the {what} is larger than {} MB", max >> 20)); + return Err(too_large()); } - std::fs::read(path).map_err(|e| format!("{path}: {e}")) + let bytes = read_bounded(file, &meta, max).map_err(|e| format!("{path}: {e}"))?; + if bytes.len() as u64 > max { + return Err(too_large()); + } + Ok(bytes) } /// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation -/// read root when there is one: enough to tell a file's format by its start. +/// read root when there is one: enough to tell a file's format by its start. Only a regular file +/// is opened: a named pipe would block the open until a writer appeared (#3666). #[cfg(not(target_arch = "wasm32"))] pub fn read_file_head(path: &str, max: u64) -> Result, String> { use std::io::Read; crate::file_access::check_read(path)?; - let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let (file, _) = open_regular_file(std::path::Path::new(path))?; let mut head = Vec::new(); - f.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?; + file.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?; Ok(head) } diff --git a/crates/engine/src/speech.rs b/crates/engine/src/speech.rs index bbaa5fdbfd..74a6b165d6 100644 --- a/crates/engine/src/speech.rs +++ b/crates/engine/src/speech.rs @@ -25,6 +25,9 @@ use wordcraft_doc::{Document, InlineObject, Pos}; /// Longest sentence handed to the speech program (longer ones are split at a space). const MAX_SENTENCE: usize = 600; +/// Longest utterance, even with no space to split at (a long URL, a run of letters): it is cut +/// there, mid-word. +const MAX_UTTERANCE: usize = 2 * MAX_SENTENCE; /// Most sentences in one queue. const MAX_SENTENCES: usize = 20_000; /// Speed limits (1.0 = the normal speed). @@ -72,7 +75,8 @@ impl Utterance { } /// The sentences from `from` (to `to`, if given) in `from`'s story, in reading order. -/// With `skip_citations`, citation and bibliography fields are left out. +/// With `skip_citations`, citation and bibliography fields are left out. At most +/// [`MAX_SENTENCES`]; when that cuts the text short a warning is logged, so the stop is not silent. pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec { let story = from.story; // Spans to leave out: whole range fields, both markers included. @@ -128,12 +132,16 @@ pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: b } } } + // Sliced per sentence; skipping from the start each time was quadratic in the paragraph. + let text_chars: Vec = text.chars().collect(); for (a, b) in split_sentences(&text) { if out.len() >= MAX_SENTENCES { + log::warn!( + "Read Aloud: the queue holds at most {MAX_SENTENCES} sentences; the rest of the text is not read (start again from where it stops)" + ); return out; } - let chars: Vec = text.chars().skip(a).take(b - a).collect(); - let spoken = tidy(&chars.iter().collect::()); + let spoken = tidy(&text_chars.get(a..b).unwrap_or(&[]).iter().collect::()); if !spoken.chars().any(char::is_alphanumeric) { continue; } @@ -426,6 +434,14 @@ fn push_trimmed(chars: &[char], mut a: usize, mut b: usize, out: &mut Vec<(usize while b > a && chars.get(b - 1).is_some_and(|c| c.is_whitespace()) { b -= 1; } + // No space to split at (a long URL, a run of letters or full stops): cut the span hard. + while b - a > MAX_UTTERANCE { + out.push((a, a + MAX_UTTERANCE)); + a += MAX_UTTERANCE; + while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) { + a += 1; + } + } if a < b { out.push((a, b)); } @@ -450,18 +466,39 @@ fn is_abbreviation(chars: &[char], start: usize, dot: usize) -> bool { } /// Collapse whitespace and tidy what removed citations leave behind: "( )", "[]", " ." → ".". +/// One pass, linear in `s` (#3680): a closing bracket right after its opening one (or after it +/// and a space) removes the pair, so nested empty brackets "( [ ] )" go too. fn tidy(s: &str) -> String { - let mut t: String = s.split_whitespace().collect::>().join(" "); - loop { - let before = t.len(); - for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] { - t = t.replace(from, to); + let mut t = String::with_capacity(s.len()); + for c in s.chars() { + if c.is_whitespace() { + if !t.is_empty() && !t.ends_with(' ') { + t.push(' '); + } + continue; } - if t.len() == before { - break; + let open = match c { + ')' => Some('('), + ']' => Some('['), + _ => None, + }; + if let Some(open) = open { + let body = t.strip_suffix(' ').unwrap_or(&t); + if let Some(rest) = body.strip_suffix(open) { + let keep = rest.len(); + t.truncate(keep); + continue; + } } + if matches!(c, '.' | ',' | ';' | ':') && t.ends_with(' ') { + t.pop(); + } + t.push(c); } - t.trim().to_string() + if t.ends_with(' ') { + t.pop(); + } + t } /// Reading state. @@ -926,10 +963,70 @@ mod tests { assert!(spans.iter().all(|(a, b)| b - a <= MAX_SENTENCE + 5)); } + /// A word with no space to split at, or a run of punctuation, is still cut into utterances of + /// at most `MAX_UTTERANCE` characters, covering all of it (#3680). + #[test] + fn sentences_without_spaces_are_split() { + for t in ["x".repeat(10_000), "!".repeat(10_000), format!("Go {} now.", "a.".repeat(5_000))] { + let spans = split_sentences(&t); + assert!(spans.len() >= 8, "{} spans", spans.len()); + assert!(spans.iter().all(|(a, b)| a < b && b - a <= MAX_UTTERANCE)); + assert_eq!(spans.first().map(|s| s.0), Some(0)); + assert_eq!(spans.last().map(|s| s.1), Some(t.chars().count())); + assert!(spans.windows(2).all(|w| w[0].1 <= w[1].0)); + } + } + #[test] fn tidy_removes_citation_leftovers() { assert_eq!(tidy("as shown ( ) ."), "as shown."); assert_eq!(tidy("as shown [] , and"), "as shown, and"); + assert_eq!(tidy(" a ( ( [ ] ) ) b ( x ) [ ) ] ; "), "a b ( x ) [ ) ];"); + } + + /// The tidy that ran replacements until nothing changed, kept to check the one-pass tidy gives + /// the same text. + fn tidy_by_replacing(s: &str) -> String { + let mut t: String = s.split_whitespace().collect::>().join(" "); + loop { + let before = t.len(); + for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] { + t = t.replace(from, to); + } + if t.len() == before { + break; + } + } + t.trim().to_string() + } + + #[test] + fn tidy_matches_replacing_until_nothing_changes() { + let alphabet = ['(', ')', '[', ']', ' ', ' ', '\t', '\u{a0}', '.', ',', ';', ':', 'a', 'é']; + let mut seed: u64 = 0x3680; + for _ in 0..20_000 { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + let len = (seed >> 59) as usize + 1; + let s: String = (0..len) + .map(|i| { + let r = seed.rotate_left(5 * i as u32 + 1) ^ (i as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + alphabet[(r % alphabet.len() as u64) as usize] + }) + .collect(); + assert_eq!(tidy(&s), tidy_by_replacing(&s), "{s:?}"); + } + } + + /// Nested empty brackets are removed in one pass: 40,000 pairs took seconds when each pass + /// removed one level (#3680). + #[test] + fn tidy_is_linear_on_nested_brackets() { + let n = 40_000; + let started = std::time::Instant::now(); + assert_eq!(tidy(&format!("a {}{} b", "( [".repeat(n), "] )".repeat(n))), "a b"); + assert_eq!(tidy(&format!("{}x{}", "(".repeat(n), ")".repeat(n))).len(), 2 * n + 1); + // Milliseconds here; `tidy_by_replacing` (the old tidy) takes about 5 minutes on this input. + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); } #[test] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b6f58b43c2..d06188ec20 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4796,7 +4796,7 @@ fn opening_a_named_pipe_or_device_is_refused_without_blocking() { let got = rx.recv_timeout(std::time::Duration::from_secs(10)); if got.is_err() { // Unblock the stuck reader so the thread ends, then fail. - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); + let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo); } let _ = std::fs::remove_dir_all(&dir); let [pipe, device] = got.expect("opening a named pipe blocked"); @@ -4825,7 +4825,7 @@ fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() { let got = rx.recv_timeout(std::time::Duration::from_secs(10)); if got.is_err() { // Unblock the stuck reader so the thread ends, then fail. - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); + let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo); } let _ = std::fs::remove_dir_all(&dir); let doc = got.expect("opening the HTML file blocked on a named pipe").unwrap(); @@ -4855,7 +4855,7 @@ fn insert_picture_from_a_named_pipe_is_refused_without_blocking() { let got = rx.recv_timeout(std::time::Duration::from_secs(10)); if got.is_err() { // Unblock the stuck reader so the thread ends, then fail. - let _ = std::fs::OpenOptions::new().write(true).open(&fifo); + let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo); } let _ = std::fs::remove_dir_all(&dir); assert!(got.expect("reading a picture path blocked on a named pipe").is_err(), "a pipe is not a file to read"); @@ -4864,6 +4864,92 @@ fn insert_picture_from_a_named_pipe_is_refused_without_blocking() { } } +/// #3666: `read_file_capped` (fonts, citation sources, `read_file`) and `read_file_head` (the +/// Word 2003 check on saving to an `.xml` path) read only a regular file. A named pipe must be +/// refused, not opened: opening one blocks until a writer appears. +#[cfg(unix)] +#[test] +fn file_head_and_capped_reads_refuse_a_named_pipe_without_blocking() { + let dir = scratch_dir("read-helpers-fifo"); + let fifo = dir.join("pipe.xml"); + let made = std::process::Command::new("mkfifo").arg(&fifo).status(); + if !made.is_ok_and(|s| s.success()) { + let _ = std::fs::remove_dir_all(&dir); + eprintln!("mkfifo unavailable; skipping"); + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + let path = fifo.to_string_lossy().into_owned(); + std::thread::spawn(move || { + let head = crate::io::read_file_head(&path, 64 * 1024).map(|_| ()); + let capped = crate::io::read_file_capped(&path, 1 << 20, "font file").map(|_| ()); + let _ = tx.send([head, capped]); + }); + let got = rx.recv_timeout(std::time::Duration::from_secs(10)); + if got.is_err() { + // Unblock the stuck reader (twice: each helper may be stuck in turn) so the thread ends, then + // fail. Non-blocking: with no reader left the open fails at once instead of hanging the test. + let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo); + let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo); + } + let _ = std::fs::remove_dir_all(&dir); + let [head, capped] = got.expect("reading a named pipe blocked"); + assert!(head.unwrap_err().contains("not a file")); + assert!(capped.unwrap_err().contains("not a file")); +} + +/// #3666: a folder is not a file to read, whichever helper reads it. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn file_head_and_capped_reads_refuse_a_folder() { + let dir = scratch_dir("read-helpers-dir"); + let path = dir.to_string_lossy().into_owned(); + let head = crate::io::read_file_head(&path, 64); + let capped = crate::io::read_file_capped(&path, 1 << 20, "font file"); + let _ = std::fs::remove_dir_all(&dir); + assert!(head.unwrap_err().contains("not a file")); + assert!(capped.unwrap_err().contains("not a file")); +} + +/// #3666: `read_file_capped` reads a file of up to `max` bytes whole and refuses a larger one; +/// `read_file_head` returns at most `max` bytes. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn file_capped_read_refuses_content_over_the_cap() { + let dir = scratch_dir("read-helpers-cap"); + let file = dir.join("data.bin"); + std::fs::write(&file, vec![7u8; 100]).unwrap(); + let path = file.to_string_lossy().into_owned(); + let exact = crate::io::read_file_capped(&path, 100, "file"); + let over = crate::io::read_file_capped(&path, 99, "file"); + let head = crate::io::read_file_head(&path, 10); + let whole = crate::io::read_file_head(&path, 1000); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(exact.unwrap().len(), 100); + assert!(over.unwrap_err().contains("larger than")); + assert_eq!(head.unwrap(), vec![7u8; 10]); + assert_eq!(whole.unwrap().len(), 100); +} + +/// #3666: `read_regular_file_within` (Paste Special's copied picture file) reads a file of up to +/// `limit` bytes whole and refuses one whose handle reports more, before reading any of it. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn regular_file_read_within_a_limit_refuses_a_larger_file() { + let dir = scratch_dir("read-within"); + let file = dir.join("data.bin"); + std::fs::write(&file, vec![7u8; 100]).unwrap(); + let big = dir.join("big.bin"); + std::fs::File::create(&big).unwrap().set_len(64 << 20).unwrap(); + let exact = crate::io::read_regular_file_within(&file, 100); + let over = crate::io::read_regular_file_within(&file, 99); + let large = crate::io::read_regular_file_within(&big, 1 << 20); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(exact.map(|b| b.len()), Some(100)); + assert_eq!(over, None); + assert_eq!(large, None); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn html_picture_size_limit_holds_whatever_length_the_file_reports() { diff --git a/crates/ui-egui/src/paste_picture.rs b/crates/ui-egui/src/paste_picture.rs index ba3a598cf6..34172cc1c3 100644 --- a/crates/ui-egui/src/paste_picture.rs +++ b/crates/ui-egui/src/paste_picture.rs @@ -175,16 +175,19 @@ pub(crate) fn clipboard_png(app: &WordApp) -> Option> { ClipboardPicture::Encoded(bytes) => Some(bytes), ClipboardPicture::Files(files) => { let path = files.into_iter().take(MAX_FILES).find(|p| is_picture_file(p))?; - let size = std::fs::metadata(&path).ok()?.len(); - if size > MAX_FILE_BYTES.min(wordcraft_engine::cmd::paste::MAX_PICTURE_BYTES as u64 / 4 * 3) { - return None; - } - std::fs::read(path).ok() + read_picture_file(&path, MAX_FILE_BYTES.min(wordcraft_engine::cmd::paste::MAX_PICTURE_BYTES as u64 / 4 * 3)) } ClipboardPicture::Unreadable(_) => None, } } +/// The bytes of the regular file at `path` when it holds at most `limit` of them; `None` for +/// anything else, such as a device like `/dev/zero` that never ends (#3692). A file that says it +/// is larger is refused before any of it is read. +fn read_picture_file(path: &Path, limit: u64) -> Option> { + wordcraft_engine::io::read_regular_file_within(path, limit) +} + /// Paste text naming copied picture files: insert the pictures when the clipboard holds those /// files. False leaves the text to the usual paste. pub(crate) fn paste_named_files(app: &mut WordApp, text: &str) -> bool { @@ -341,4 +344,32 @@ mod tests { assert_eq!(pictures(&mut a), 2, "files: both pictures are kept"); assert!(!a.session.sel.is_collapsed() && crate::objects::selected(&a).is_some(), "files: the last picture is selected"); } + + /// Paste Special reads a copied picture file only when it is a regular file within the limit: + /// a folder, a device such as `/dev/zero` (length 0, never ends) or an oversized file offers + /// nothing instead of reading to end of file (#3692). + #[test] + fn paste_special_reads_only_regular_picture_files() { + let dir = std::env::temp_dir().join(format!("wc-paste-special-3692-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("folder.png")).unwrap(); + let png = png_from_rgba(4, 4, vec![200; 4 * 4 * 4]).unwrap(); + std::fs::write(dir.join("ok.png"), &png).unwrap(); + let big = std::fs::File::create(dir.join("big.png")).unwrap(); + big.set_len(MAX_FILE_BYTES + 1).unwrap(); + drop(big); + #[cfg(unix)] + std::os::unix::fs::symlink("/dev/zero", dir.join("zero.png")).unwrap(); + let offered = |name: &str| { + let path = dir.join(name); + clipboard_png(&app_with(move || Some(ClipboardPicture::Files(vec![path.clone()])))) + }; + assert_eq!(offered("ok.png"), Some(png.clone())); + assert_eq!(offered("folder.png"), None); + assert_eq!(offered("big.png"), None); + #[cfg(unix)] + assert_eq!(offered("zero.png"), None); + assert_eq!(read_picture_file(&dir.join("ok.png"), png.len() as u64 - 1), None, "one byte over the limit"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 72517ca07e..a4e79ea8b4 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -18,11 +18,25 @@ if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then fi TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" -KEYCHAIN="$TMP/wordcraft-signing.keychain-db" -CERT="$TMP/wordcraft-signing.p12" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" -trap 'rm -f "$CERT"' EXIT +# The decoded bundle and the keychain go in a fresh private directory with an unpredictable name: +# a predictable name in a shared temp folder could be planted beforehand (a link that sends the key +# somewhere readable, or a keychain someone else made) (#3692). The bundle is removed on exit; the +# keychain is kept for the signing steps that follow, unless this script fails. +WORK="$(mktemp -d "$TMP/wordcraft-signing.XXXXXXXX")" +CERT="$WORK/wordcraft-signing.p12" +KEYCHAIN="$WORK/wordcraft-signing.keychain-db" +cleanup() { + local status=$? + rm -f "$CERT" + if [ "$status" -ne 0 ]; then + security delete-keychain "$KEYCHAIN" 2>/dev/null || true + rm -rf "$WORK" + fi + exit "$status" +} +trap cleanup EXIT printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT" security create-keychain -p "$KC_PASS" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" From 04121fb020d3c9b8de98fa4d7c91d9159944f7dd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 09:53:34 -0400 Subject: [PATCH 38/50] feat: startup update prompt modal and direct self-updating Integrate `craft-update` pinned to storytold/craft-libs fe61243 to check for updates on startup, display the standard suite update modal with changelog notes, and support one-click in-app updating and restart. Ref: storytold/craft-libs#319 --- Cargo.toml | 1 + crates/ui-egui/Cargo.toml | 3 + crates/ui-egui/src/lib.rs | 10 ++ crates/ui-egui/src/ribbon.rs | 1 + crates/ui-egui/src/updates_ui.rs | 235 +++++++++++++++++++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 crates/ui-egui/src/updates_ui.rs diff --git a/Cargo.toml b/Cargo.toml index 74175e3767..98af94ec1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "fe61243ebfd7f9a7bea4cc326028e859d6aab052", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 3419c1a2c6..7a634c502e 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -47,6 +47,9 @@ chrono = { version = "0.4.45", default-features = false, features = ["clock"] } # The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32). js-sys = "0.3" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } + [dev-dependencies] cfb = { workspace = true } # Producer tests simulate a globally exhausted IME revision allocator through the engine's diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f546dead9e..da820325d2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -83,6 +83,7 @@ pub mod table_pen; pub mod table_resize; pub mod table_ruler; pub mod theme; +pub mod updates_ui; pub mod widgets; pub mod window_close; pub mod window_geometry; @@ -568,6 +569,7 @@ pub struct WordApp { pub mac_titlebar: chrome::MacTitleBar, /// Focus (#1678): the ribbon is dropped down from the Focus bar, over the page. pub(crate) focus_ribbon: bool, + pub(crate) updates_rt: updates_ui::Runtime, control_rx: Option>, /// Admitted `ui.screenshot` requests: count/byte budgets, retries and completion slots. shots: control::Shots, @@ -735,6 +737,7 @@ impl WordApp { integrated_titlebar: false, mac_titlebar: chrome::MacTitleBar::default(), focus_ribbon: false, + updates_rt: updates_ui::Runtime::default(), control_rx: None, shots: control::Shots::default(), capture_epoch: 0, @@ -1628,6 +1631,10 @@ impl WordApp { fn ui_command(&mut self, id: &str, p: &Value) -> Option> { let s = |k: &str| p.get(k).and_then(Value::as_str); Some(Ok(match id { + "app.checkForUpdates" | "help.checkForUpdates" => { + crate::updates_ui::check(self); + Value::Null + } "ui.tab" => { let tab = s("tab").unwrap_or("Home").to_string(); if tab == "File" { @@ -2244,6 +2251,7 @@ impl WordApp { /// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops. pub fn logic(&mut self, ctx: &egui::Context) { + updates_ui::tick(self, ctx); let lang = i18n::Lang::from_pref(&self.ui.language); i18n::set_current(lang); // New documents are written in the interface's language when WordCraft can proof it @@ -2637,6 +2645,7 @@ impl WordApp { fn show_overlays(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { content_controls::popup(self, ctx); dialogs::show(self, ctx); + updates_ui::show(self, ctx); #[cfg(not(target_arch = "wasm32"))] file_browser::show(self, ctx); colors::show(self, ctx); @@ -3018,6 +3027,7 @@ pub(crate) fn works_without_document(id: &str) -> bool { | "ui.discord" | "ui.caretAnimation" | "ui.startScreen" + | "app.checkForUpdates" ) || id.starts_with("help.") } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index f0feb3204b..833fab8de2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2431,6 +2431,7 @@ fn outlining(app: &mut WordApp, ui: &mut Ui) { fn help(app: &mut WordApp, ui: &mut Ui) { group(ui, "Help", None, app, |ui, app| { big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false); + big(ui, app, "checkForUpdates", "Check for\nUpdates…", "app.checkForUpdates", json!({}), false); big(ui, app, "discord", "Community\nDiscord", "ui.discord", json!({}), false); }); group(ui, "Agents", None, app, |ui, app| { diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs new file mode 100644 index 0000000000..6cd3f155e4 --- /dev/null +++ b/crates/ui-egui/src/updates_ui.rs @@ -0,0 +1,235 @@ +//! Software updates, background check at startup, and startup prompt modal. + +use crate::WordApp; + +pub(crate) struct Runtime { + startup_checked: bool, + manual_checked: bool, + pub(crate) manual_window_open: bool, + pub(crate) status_message: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) prompt: Option, + #[cfg(not(target_arch = "wasm32"))] + rx: Option, String>>>, + #[cfg(not(target_arch = "wasm32"))] + checking: bool, +} + +impl Default for Runtime { + fn default() -> Self { + Self { + startup_checked: false, + manual_checked: false, + manual_window_open: false, + status_message: None, + #[cfg(not(target_arch = "wasm32"))] + prompt: None, + #[cfg(not(target_arch = "wasm32"))] + rx: None, + #[cfg(not(target_arch = "wasm32"))] + checking: false, + } + } +} + +pub(crate) fn check(app: &mut WordApp) { + #[cfg(not(target_arch = "wasm32"))] + { + app.updates_rt.manual_checked = true; + app.updates_rt.manual_window_open = false; + app.updates_rt.status_message = None; + start_check(app); + } + #[cfg(target_arch = "wasm32")] + { + app.updates_rt.manual_window_open = true; + app.updates_rt.status_message = Some(tl!("Update check is not supported in the web browser.").to_string()); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn start_check(app: &mut WordApp) { + if app.updates_rt.checking { + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + app.updates_rt.rx = Some(rx); + app.updates_rt.checking = true; + + std::thread::spawn(move || { + let http = match craft_update::Http::new("WordCraft/Update") { + Ok(h) => h, + Err(e) => { + let _ = tx.send(Err(e)); + return; + } + }; + let res = craft_update::check_for_update(&craft_update::suite::WORDCRAFT, &http, env!("CARGO_PKG_VERSION")); + let _ = tx.send(res); + }); +} + +pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + if !app.updates_rt.startup_checked { + app.updates_rt.startup_checked = true; + start_check(app); + } + + let mut completed = None; + if let Some(rx) = &app.updates_rt.rx { + if let Ok(res) = rx.try_recv() { + completed = Some(res); + } + } + + if let Some(res) = completed { + app.updates_rt.rx = None; + app.updates_rt.checking = false; + match res { + Ok(Some(release)) => { + let mut prompt = + craft_update::StartupPrompt::new(&craft_update::suite::WORDCRAFT, release, env!("CARGO_PKG_VERSION")); + prompt.open = true; + app.updates_rt.prompt = Some(prompt); + ctx.request_repaint(); + } + Ok(None) => { + if app.updates_rt.manual_checked { + app.updates_rt.manual_checked = false; + app.updates_rt.status_message = + Some(format!("WordCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION"))); + app.updates_rt.manual_window_open = true; + ctx.request_repaint(); + } + } + Err(e) => { + if app.updates_rt.manual_checked { + app.updates_rt.manual_checked = false; + app.updates_rt.status_message = Some(format!("Could not check for updates:\n{e}")); + app.updates_rt.manual_window_open = true; + ctx.request_repaint(); + } + } + } + } + } +} + +pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + let mut download_url = None; + if let Some(prompt) = &mut app.updates_rt.prompt { + match prompt.show(ctx) { + craft_update::StartupPromptResponse::Pending => {} + craft_update::StartupPromptResponse::UpdateAndRestart => { + prompt.is_installing = true; + if let Some(install) = craft_update::self_update::current(&craft_update::suite::WORDCRAFT) { + let mut release = prompt.release.clone(); + std::thread::spawn(move || { + let http = match craft_update::Http::new("WordCraft/Update") { + Ok(h) => h, + Err(e) => { + log::warn!("could not initialize HTTP: {e}"); + return; + } + }; + if release.assets.is_empty() { + if let Ok(Some(full)) = craft_update::check_for_update( + &craft_update::suite::WORDCRAFT, + &http, + env!("CARGO_PKG_VERSION"), + ) { + release = full; + } + } + let paths = match craft_update::Paths::for_app(&craft_update::suite::WORDCRAFT) { + Ok(p) => p, + Err(e) => { + log::warn!("could not find paths: {e}"); + return; + } + }; + let stop = craft_update::Stop::default(); + let platform = match craft_update::Platform::current() { + Some(p) => p, + None => { + log::warn!("unsupported platform for self update"); + return; + } + }; + let last = || Ok(()); + match craft_update::self_update::update( + &craft_update::suite::WORDCRAFT, + &install, + &release, + platform, + &http, + &paths, + &stop, + &mut |_| {}, + &last, + Some(env!("CARGO_PKG_VERSION")), + ) { + Ok(()) => { + std::process::exit(0); + } + Err(e) => { + log::warn!("WordCraft self-update failed: {e}"); + } + } + }); + } else { + prompt.status_message = + Some("Cannot auto-update this installation type. Please download manually.".to_string()); + prompt.is_installing = false; + } + } + craft_update::StartupPromptResponse::Download => { + download_url = Some(prompt.release.html_url.clone()); + prompt.open = false; + } + craft_update::StartupPromptResponse::Later => { + prompt.open = false; + } + } + if !prompt.open { + app.updates_rt.prompt = None; + } + } + + if let Some(url) = download_url { + app.updates_rt.prompt = None; + ctx.open_url(egui::OpenUrl::new_tab(url)); + } + } + + if app.updates_rt.manual_window_open { + let mut open = true; + let mut close = false; + egui::Window::new(tl!("Check for Updates")) + .open(&mut open) + .resizable(false) + .collapsible(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + if let Some(msg) = &app.updates_rt.status_message { + ui.label(msg); + } else { + ui.label(tl!("Checking for updates…")); + } + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(tl!("Close")).clicked() { + close = true; + } + }); + }); + if !open || close { + app.updates_rt.manual_window_open = false; + app.updates_rt.status_message = None; + } + } +} From b39c1ba1a3aae4f69d5268379a282dfe6b9145fe Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 11:28:12 -0400 Subject: [PATCH 39/50] fix(updates): report self-update results to the prompt and finish through the app's close path Keeps the worker's failure visible instead of leaving the prompt installing, repaints while a check runs, and no longer calls process::exit past the unsaved-work prompt. --- crates/ui-egui/src/updates_ui.rs | 102 +++++++++++++++---------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 6cd3f155e4..33c83d928c 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -13,6 +13,8 @@ pub(crate) struct Runtime { rx: Option, String>>>, #[cfg(not(target_arch = "wasm32"))] checking: bool, + #[cfg(not(target_arch = "wasm32"))] + update_rx: Option>>, } impl Default for Runtime { @@ -28,6 +30,8 @@ impl Default for Runtime { rx: None, #[cfg(not(target_arch = "wasm32"))] checking: false, + #[cfg(not(target_arch = "wasm32"))] + update_rx: None, } } } @@ -77,6 +81,10 @@ pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) { start_check(app); } + if app.updates_rt.checking { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + let mut completed = None; if let Some(rx) = &app.updates_rt.rx { if let Ok(res) = rx.try_recv() { @@ -89,8 +97,7 @@ pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) { app.updates_rt.checking = false; match res { Ok(Some(release)) => { - let mut prompt = - craft_update::StartupPrompt::new(&craft_update::suite::WORDCRAFT, release, env!("CARGO_PKG_VERSION")); + let mut prompt = craft_update::StartupPrompt::new(&craft_update::suite::WORDCRAFT, release, env!("CARGO_PKG_VERSION")); prompt.open = true; app.updates_rt.prompt = Some(prompt); ctx.request_repaint(); @@ -122,68 +129,61 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { { let mut download_url = None; if let Some(prompt) = &mut app.updates_rt.prompt { + if let Some(result) = app.updates_rt.update_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { + app.updates_rt.update_rx = None; + match result { + // The app's close path asks about unsaved work; the restart helper relaunches once the process exits. + Ok(()) => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + Err(e) => { + prompt.status_message = Some(e); + prompt.is_installing = false; + } + } + } match prompt.show(ctx) { craft_update::StartupPromptResponse::Pending => {} craft_update::StartupPromptResponse::UpdateAndRestart => { prompt.is_installing = true; if let Some(install) = craft_update::self_update::current(&craft_update::suite::WORDCRAFT) { let mut release = prompt.release.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + app.updates_rt.update_rx = Some(rx); + let ctx = ctx.clone(); std::thread::spawn(move || { - let http = match craft_update::Http::new("WordCraft/Update") { - Ok(h) => h, - Err(e) => { - log::warn!("could not initialize HTTP: {e}"); - return; - } - }; - if release.assets.is_empty() { - if let Ok(Some(full)) = craft_update::check_for_update( - &craft_update::suite::WORDCRAFT, - &http, - env!("CARGO_PKG_VERSION"), - ) { + let mut run = || -> Result<(), String> { + let http = craft_update::Http::new("WordCraft/Update").map_err(|e| format!("could not initialize HTTP: {e}"))?; + if release.assets.is_empty() + && let Some(full) = + craft_update::check_for_update(&craft_update::suite::WORDCRAFT, &http, env!("CARGO_PKG_VERSION")) + .map_err(|e| format!("could not fetch release assets: {e}"))? + { release = full; } - } - let paths = match craft_update::Paths::for_app(&craft_update::suite::WORDCRAFT) { - Ok(p) => p, - Err(e) => { - log::warn!("could not find paths: {e}"); - return; - } + let paths = craft_update::Paths::for_app(&craft_update::suite::WORDCRAFT) + .map_err(|e| format!("could not find paths: {e}"))?; + let platform = craft_update::Platform::current().ok_or("unsupported platform for self update")?; + craft_update::self_update::update( + &craft_update::suite::WORDCRAFT, + &install, + &release, + platform, + &http, + &paths, + &craft_update::Stop::default(), + &mut |_| {}, + &|| Ok(()), + Some(env!("CARGO_PKG_VERSION")), + ) }; - let stop = craft_update::Stop::default(); - let platform = match craft_update::Platform::current() { - Some(p) => p, - None => { - log::warn!("unsupported platform for self update"); - return; - } - }; - let last = || Ok(()); - match craft_update::self_update::update( - &craft_update::suite::WORDCRAFT, - &install, - &release, - platform, - &http, - &paths, - &stop, - &mut |_| {}, - &last, - Some(env!("CARGO_PKG_VERSION")), - ) { - Ok(()) => { - std::process::exit(0); - } - Err(e) => { - log::warn!("WordCraft self-update failed: {e}"); - } + let result = run(); + if let Err(e) = &result { + log::warn!("self-update failed: {e}"); } + let _ = tx.send(result); + ctx.request_repaint(); }); } else { - prompt.status_message = - Some("Cannot auto-update this installation type. Please download manually.".to_string()); + prompt.status_message = Some("Cannot auto-update this installation type. Please download manually.".to_string()); prompt.is_installing = false; } } From a91e1b5ea47fdda92433905b809cd95ff6a1f0e4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 11:28:57 -0400 Subject: [PATCH 40/50] build: merge the craft-update dependency into the existing non-wasm table --- Cargo.lock | 229 ++++++++++++++++++++++++++++++++++++-- crates/ui-egui/Cargo.toml | 4 +- 2 files changed, 223 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e883847e1f..94e3488336 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1036,6 +1036,32 @@ dependencies = [ "egui", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=fe61243ebfd7f9a7bea4cc326028e859d6aab052#fe61243ebfd7f9a7bea4cc326028e859d6aab052" +dependencies = [ + "chrono", + "dirs", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip 8.6.0", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -1163,6 +1189,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1209,6 +1241,27 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -3057,6 +3110,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -3422,6 +3481,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.55" @@ -3694,6 +3759,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -3778,6 +3856,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3887,6 +3971,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -4149,6 +4242,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + [[package]] name = "regex" version = "1.13.1" @@ -4328,7 +4431,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4490,6 +4595,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "send_wrapper" @@ -5233,6 +5342,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia-path" version = "0.12.0" @@ -5474,6 +5613,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" + [[package]] name = "typenum" version = "1.20.1" @@ -5566,6 +5711,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5584,6 +5758,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5634,6 +5814,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -5858,7 +6048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -6483,6 +6673,16 @@ dependencies = [ "xml", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.31" @@ -6617,7 +6817,7 @@ dependencies = [ "image", "log", "proptest", - "quick-xml", + "quick-xml 0.41.0", "sha1 0.11.0", "sha2 0.11.0", "thiserror 2.0.21", @@ -6625,7 +6825,7 @@ dependencies = [ "wordcraft-doc", "wordcraft-geom", "zeroize", - "zip", + "zip 3.0.0", ] [[package]] @@ -6656,7 +6856,7 @@ dependencies = [ "wordcraft-proof", "wordcraft-render", "zeroize", - "zip", + "zip 3.0.0", ] [[package]] @@ -6684,10 +6884,10 @@ dependencies = [ "js-sys", "log", "proptest", - "quick-xml", + "quick-xml 0.41.0", "wordcraft-doc", "wordcraft-geom", - "zip", + "zip 3.0.0", ] [[package]] @@ -6791,6 +6991,7 @@ dependencies = [ "chrono", "craft-i18n", "craft-ui", + "craft-update", "egui", "egui_kittest", "image", @@ -6809,7 +7010,7 @@ dependencies = [ "wordcraft-render", "wordcraft-zotero", "zbus", - "zip", + "zip 3.0.0", ] [[package]] @@ -7242,6 +7443,20 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 7a634c502e..1a257045f9 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -42,14 +42,12 @@ zbus = "=5.19.0" # Comment times in the user's own time zone (`local_time.rs`); the web app asks the browser. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] chrono = { version = "0.4.45", default-features = false, features = ["clock"] } +craft-update = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32). js-sys = "0.3" -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-update = { workspace = true } - [dev-dependencies] cfb = { workspace = true } # Producer tests simulate a globally exhausted IME revision allocator through the engine's From 67ffe00b32d0cd815c7edd44b6340e85bdc38ad3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 11:58:13 -0400 Subject: [PATCH 41/50] build: pin craft-update to the dirs-free, zip 9 build Satisfies cargo-deny: no MPL-2.0 option-ext, no zip 8 advisories, and a versioned git dependency instead of a wildcard. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 98af94ec1f..74a647b5f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", rev = "fe61243ebfd7f9a7bea4cc326028e859d6aab052", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "fcd3441993ee238144ee56538ad81b4a333f1ed9", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. From 4148fb11715a49b9bf3bfd4aa0c354a90977ca69 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 13:31:04 -0400 Subject: [PATCH 42/50] refactor(updates): drive the flow with craft-update's Updater Startup check is opt-in for the desktop app, the Windows update helper is dispatched before argument parsing, and a successful update closes through the app's own close path. --- Cargo.toml | 2 +- apps/wordcraft/Cargo.toml | 3 + apps/wordcraft/src/main.rs | 6 + crates/ui-egui/src/updates_ui.rs | 217 ++++++++----------------------- 4 files changed, 61 insertions(+), 167 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 74a647b5f8..4beaf32b04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "fcd3441993ee238144ee56538ad81b4a333f1ed9", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "13ceaf2f31620531f39a54f6b994d581a6082a93", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 828066e336..f1d27bc589 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -83,3 +83,6 @@ pollster = "0.4" arboard = { workspace = true, features = ["image-data"] } # The control server's random key. getrandom = "0.3" + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 2073b50699..2486960195 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -337,6 +337,11 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) { /// geometry, opens requested documents or the Start screen, starts the control server when /// asked, and runs the eframe event loop. fn main() -> eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); logging::install_panic_hook(); @@ -445,6 +450,7 @@ fn main() -> eframe::Result { let mut session = Session::new(doc); session.system_region = region; let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx)); + app.set_startup_update_check(true); load_prefs(&mut app); load_user_dictionary(&mut app); // A region chosen in File › Options replaces the system's for the first blank diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 33c83d928c..4a429aa8e4 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -2,47 +2,48 @@ use crate::WordApp; +#[cfg(not(target_arch = "wasm32"))] +use craft_update::updater::{Action, Status, Updater}; + pub(crate) struct Runtime { - startup_checked: bool, - manual_checked: bool, + #[cfg(not(target_arch = "wasm32"))] + updater: Updater, + /// The desktop app turns the check at start on; tests and the web build leave it off. + #[cfg(not(target_arch = "wasm32"))] + startup_check: bool, + #[cfg(not(target_arch = "wasm32"))] + check_requested: bool, pub(crate) manual_window_open: bool, pub(crate) status_message: Option, - #[cfg(not(target_arch = "wasm32"))] - pub(crate) prompt: Option, - #[cfg(not(target_arch = "wasm32"))] - rx: Option, String>>>, - #[cfg(not(target_arch = "wasm32"))] - checking: bool, - #[cfg(not(target_arch = "wasm32"))] - update_rx: Option>>, } impl Default for Runtime { fn default() -> Self { Self { - startup_checked: false, - manual_checked: false, + #[cfg(not(target_arch = "wasm32"))] + updater: Updater::new(&craft_update::suite::WORDCRAFT, env!("CARGO_PKG_VERSION")), + #[cfg(not(target_arch = "wasm32"))] + startup_check: false, + #[cfg(not(target_arch = "wasm32"))] + check_requested: false, manual_window_open: false, status_message: None, - #[cfg(not(target_arch = "wasm32"))] - prompt: None, - #[cfg(not(target_arch = "wasm32"))] - rx: None, - #[cfg(not(target_arch = "wasm32"))] - checking: false, - #[cfg(not(target_arch = "wasm32"))] - update_rx: None, } } } +impl WordApp { + /// Whether the app asks for a newer release when it starts (off until the desktop app turns it on). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_startup_update_check(&mut self, on: bool) { + self.updates_rt.startup_check = on; + } +} + pub(crate) fn check(app: &mut WordApp) { #[cfg(not(target_arch = "wasm32"))] { - app.updates_rt.manual_checked = true; - app.updates_rt.manual_window_open = false; - app.updates_rt.status_message = None; - start_check(app); + app.updates_rt.check_requested = true; } #[cfg(target_arch = "wasm32")] { @@ -51,159 +52,37 @@ pub(crate) fn check(app: &mut WordApp) { } } -#[cfg(not(target_arch = "wasm32"))] -fn start_check(app: &mut WordApp) { - if app.updates_rt.checking { - return; - } - let (tx, rx) = std::sync::mpsc::channel(); - app.updates_rt.rx = Some(rx); - app.updates_rt.checking = true; - - std::thread::spawn(move || { - let http = match craft_update::Http::new("WordCraft/Update") { - Ok(h) => h, - Err(e) => { - let _ = tx.send(Err(e)); - return; - } - }; - let res = craft_update::check_for_update(&craft_update::suite::WORDCRAFT, &http, env!("CARGO_PKG_VERSION")); - let _ = tx.send(res); - }); -} - pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { - if !app.updates_rt.startup_checked { - app.updates_rt.startup_checked = true; - start_check(app); - } - - if app.updates_rt.checking { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } - - let mut completed = None; - if let Some(rx) = &app.updates_rt.rx { - if let Ok(res) = rx.try_recv() { - completed = Some(res); - } - } - - if let Some(res) = completed { - app.updates_rt.rx = None; - app.updates_rt.checking = false; - match res { - Ok(Some(release)) => { - let mut prompt = craft_update::StartupPrompt::new(&craft_update::suite::WORDCRAFT, release, env!("CARGO_PKG_VERSION")); - prompt.open = true; - app.updates_rt.prompt = Some(prompt); - ctx.request_repaint(); - } - Ok(None) => { - if app.updates_rt.manual_checked { - app.updates_rt.manual_checked = false; - app.updates_rt.status_message = - Some(format!("WordCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION"))); - app.updates_rt.manual_window_open = true; - ctx.request_repaint(); - } - } - Err(e) => { - if app.updates_rt.manual_checked { - app.updates_rt.manual_checked = false; - app.updates_rt.status_message = Some(format!("Could not check for updates:\n{e}")); - app.updates_rt.manual_window_open = true; - ctx.request_repaint(); - } - } - } + let rt = &mut app.updates_rt; + if std::mem::take(&mut rt.check_requested) { + rt.updater.check_now(ctx); + } else if rt.startup_check { + rt.updater.check_at_start(ctx); } + rt.updater.tick(ctx); } + #[cfg(target_arch = "wasm32")] + let _ = (app, ctx); } pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { - let mut download_url = None; - if let Some(prompt) = &mut app.updates_rt.prompt { - if let Some(result) = app.updates_rt.update_rx.as_ref().and_then(|rx| rx.try_recv().ok()) { - app.updates_rt.update_rx = None; - match result { - // The app's close path asks about unsaved work; the restart helper relaunches once the process exits. - Ok(()) => ctx.send_viewport_cmd(egui::ViewportCommand::Close), - Err(e) => { - prompt.status_message = Some(e); - prompt.is_installing = false; - } - } - } - match prompt.show(ctx) { - craft_update::StartupPromptResponse::Pending => {} - craft_update::StartupPromptResponse::UpdateAndRestart => { - prompt.is_installing = true; - if let Some(install) = craft_update::self_update::current(&craft_update::suite::WORDCRAFT) { - let mut release = prompt.release.clone(); - let (tx, rx) = std::sync::mpsc::channel(); - app.updates_rt.update_rx = Some(rx); - let ctx = ctx.clone(); - std::thread::spawn(move || { - let mut run = || -> Result<(), String> { - let http = craft_update::Http::new("WordCraft/Update").map_err(|e| format!("could not initialize HTTP: {e}"))?; - if release.assets.is_empty() - && let Some(full) = - craft_update::check_for_update(&craft_update::suite::WORDCRAFT, &http, env!("CARGO_PKG_VERSION")) - .map_err(|e| format!("could not fetch release assets: {e}"))? - { - release = full; - } - let paths = craft_update::Paths::for_app(&craft_update::suite::WORDCRAFT) - .map_err(|e| format!("could not find paths: {e}"))?; - let platform = craft_update::Platform::current().ok_or("unsupported platform for self update")?; - craft_update::self_update::update( - &craft_update::suite::WORDCRAFT, - &install, - &release, - platform, - &http, - &paths, - &craft_update::Stop::default(), - &mut |_| {}, - &|| Ok(()), - Some(env!("CARGO_PKG_VERSION")), - ) - }; - let result = run(); - if let Err(e) = &result { - log::warn!("self-update failed: {e}"); - } - let _ = tx.send(result); - ctx.request_repaint(); - }); - } else { - prompt.status_message = Some("Cannot auto-update this installation type. Please download manually.".to_string()); - prompt.is_installing = false; - } - } - craft_update::StartupPromptResponse::Download => { - download_url = Some(prompt.release.html_url.clone()); - prompt.open = false; - } - craft_update::StartupPromptResponse::Later => { - prompt.open = false; - } - } - if !prompt.open { - app.updates_rt.prompt = None; - } - } - - if let Some(url) = download_url { - app.updates_rt.prompt = None; - ctx.open_url(egui::OpenUrl::new_tab(url)); + match app.updates_rt.updater.show(ctx) { + Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), + Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + Action::None => {} } + // The window answers Help ▸ Check for Updates, so it follows the updater's status. + let rt = &mut app.updates_rt; + (rt.manual_window_open, rt.status_message) = match rt.updater.status() { + Status::Idle => (false, None), + Status::Checking => (true, None), + Status::UpToDate => (true, Some(format!("WordCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))), + Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))), + }; } if app.updates_rt.manual_window_open { @@ -232,4 +111,10 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { app.updates_rt.status_message = None; } } + + // Closing the window dismisses the outcome it showed. + #[cfg(not(target_arch = "wasm32"))] + if !app.updates_rt.manual_window_open { + app.updates_rt.updater.dismiss_status(); + } } From e6954843bc7b3e0b509482f2a2c8f16bf0edcbc4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 13:39:32 -0400 Subject: [PATCH 43/50] build: pin craft-update to craft-libs main (b9ba47f) --- Cargo.lock | 66 ++++++++++++++++++------------------------------------ Cargo.toml | 2 +- 2 files changed, 23 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 94e3488336..5343ab2e40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,10 +1039,9 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=fe61243ebfd7f9a7bea4cc326028e859d6aab052#fe61243ebfd7f9a7bea4cc326028e859d6aab052" +source = "git+https://github.com/storytold/craft-libs?rev=b9ba47f58902e839a8f102b824bb9f0beaac335c#b9ba47f58902e839a8f102b824bb9f0beaac335c" dependencies = [ "chrono", - "dirs", "dunce", "egui", "flate2", @@ -1059,7 +1058,7 @@ dependencies = [ "verifysign", "winreg", "winsafe", - "zip 8.6.0", + "zip 9.1.0", ] [[package]] @@ -1241,27 +1240,6 @@ dependencies = [ "ctutils", ] -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.59.0", -] - [[package]] name = "dispatch" version = "0.2.0" @@ -3095,6 +3073,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3481,12 +3471,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "orbclient" version = "0.3.55" @@ -4242,16 +4226,6 @@ dependencies = [ "bitflags 2.13.2", ] -[[package]] -name = "redox_users" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" -dependencies = [ - "libredox", - "thiserror 2.0.21", -] - [[package]] name = "regex" version = "1.13.1" @@ -5615,9 +5589,9 @@ dependencies = [ [[package]] name = "typed-path" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" [[package]] name = "typenum" @@ -6731,6 +6705,7 @@ dependencies = [ "chrono", "clipboard-win", "craft-print", + "craft-update", "dispatch2", "eframe", "egui", @@ -7445,15 +7420,18 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "9.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" dependencies = [ "crc32fast", "flate2", + "getrandom 0.4.3", "indexmap", "memchr", + "nix", "typed-path", + "windows-sys 0.61.2", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index 4beaf32b04..9279fe181c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "13ceaf2f31620531f39a54f6b994d581a6082a93", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b9ba47f58902e839a8f102b824bb9f0beaac335c", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. From e3d93581b763b2f602ae02cd839a00de2b75622c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 14:33:47 -0400 Subject: [PATCH 44/50] refactor(updates): drive the flow with craft-update's Updater Results are read independent of the prompt, a dropped worker is an error, failures return to the prompt, and no update starts over unsaved work. A manual check during the startup check is honored. --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/updates_ui.rs | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5343ab2e40..9e768c15fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=b9ba47f58902e839a8f102b824bb9f0beaac335c#b9ba47f58902e839a8f102b824bb9f0beaac335c" +source = "git+https://github.com/storytold/craft-libs?rev=5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc#5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index 9279fe181c..aa1b6d3725 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b9ba47f58902e839a8f102b824bb9f0beaac335c", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 4a429aa8e4..6f31ece2f1 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -70,7 +70,9 @@ pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) { pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { - match app.updates_rt.updater.show(ctx) { + // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + let clean = !(app.session.dirty || app.save_unconfirmed()); + match app.updates_rt.updater.show_if(ctx, clean) { Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::None => {} From dd2e2c860571b59ef4b6748392942ff5ed3155aa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 14:37:40 -0400 Subject: [PATCH 45/50] fix(updates): give the update command a ui. id so the ribbon button is enabled --- crates/ui-egui/src/lib.rs | 4 ++-- crates/ui-egui/src/ribbon.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index da820325d2..3a77229a7f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1631,7 +1631,7 @@ impl WordApp { fn ui_command(&mut self, id: &str, p: &Value) -> Option> { let s = |k: &str| p.get(k).and_then(Value::as_str); Some(Ok(match id { - "app.checkForUpdates" | "help.checkForUpdates" => { + "ui.checkForUpdates" | "help.checkForUpdates" => { crate::updates_ui::check(self); Value::Null } @@ -3027,7 +3027,7 @@ pub(crate) fn works_without_document(id: &str) -> bool { | "ui.discord" | "ui.caretAnimation" | "ui.startScreen" - | "app.checkForUpdates" + | "ui.checkForUpdates" ) || id.starts_with("help.") } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 833fab8de2..0f498994b5 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2431,7 +2431,7 @@ fn outlining(app: &mut WordApp, ui: &mut Ui) { fn help(app: &mut WordApp, ui: &mut Ui) { group(ui, "Help", None, app, |ui, app| { big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false); - big(ui, app, "checkForUpdates", "Check for\nUpdates…", "app.checkForUpdates", json!({}), false); + big(ui, app, "checkForUpdates", "Check for\nUpdates…", "ui.checkForUpdates", json!({}), false); big(ui, app, "discord", "Community\nDiscord", "ui.discord", json!({}), false); }); group(ui, "Agents", None, app, |ui, app| { From f01a107a40cb7f64ade8e1880b132431ce4d9b40 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:03:15 -0400 Subject: [PATCH 46/50] build: pin craft-update to craft-libs main (db19c37) --- Cargo.lock | 20 ++++++++++---------- Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e768c15fe..ed1d6710d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc#5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc" +source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" dependencies = [ "chrono", "dunce", @@ -1545,7 +1545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3516,7 +3516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4018,7 +4018,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4395,7 +4395,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4454,7 +4454,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5056,7 +5056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5241,7 +5241,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5607,7 +5607,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6278,7 +6278,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index aa1b6d3725..bedb555373 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "5b0f3c572a3b5c1e51bdb90d7ff788e0212ec1cc", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. From 62736d93dfe5e393c2d6f1458e2ad032de702770 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:17:06 -0400 Subject: [PATCH 47/50] fix(updates): address review feedback Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved. --- crates/ui-egui/src/updates_ui.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 6f31ece2f1..56cff7ea31 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -13,6 +13,9 @@ pub(crate) struct Runtime { startup_check: bool, #[cfg(not(target_arch = "wasm32"))] check_requested: bool, + /// The context of the last frame, to wake the UI when a check is queued from a menu callback. + #[cfg(not(target_arch = "wasm32"))] + repaint: Option, pub(crate) manual_window_open: bool, pub(crate) status_message: Option, } @@ -26,6 +29,8 @@ impl Default for Runtime { startup_check: false, #[cfg(not(target_arch = "wasm32"))] check_requested: false, + #[cfg(not(target_arch = "wasm32"))] + repaint: None, manual_window_open: false, status_message: None, } @@ -44,6 +49,9 @@ pub(crate) fn check(app: &mut WordApp) { #[cfg(not(target_arch = "wasm32"))] { app.updates_rt.check_requested = true; + if let Some(ctx) = &app.updates_rt.repaint { + ctx.request_repaint(); + } } #[cfg(target_arch = "wasm32")] { @@ -56,6 +64,7 @@ pub(crate) fn tick(app: &mut WordApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { let rt = &mut app.updates_rt; + rt.repaint = Some(ctx.clone()); if std::mem::take(&mut rt.check_requested) { rt.updater.check_now(ctx); } else if rt.startup_check { @@ -71,7 +80,7 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] { // The restart helper is scheduled before the app closes, so no update starts over unsaved work. - let clean = !(app.session.dirty || app.save_unconfirmed()); + let clean = !(app.session.dirty || app.save_unconfirmed() || app.printing.busy() || crate::panes::has_comment_drafts(app)); match app.updates_rt.updater.show_if(ctx, clean) { Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), From 57ba4b9ed84e68c0193607e1c47eb9a36eab3048 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 15:34:12 -0400 Subject: [PATCH 48/50] fix(updates): localize the update status messages and format the sources --- crates/ui-egui/src/updates_ui.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 56cff7ea31..c8aece1ffc 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -91,8 +91,10 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { (rt.manual_window_open, rt.status_message) = match rt.updater.status() { Status::Idle => (false, None), Status::Checking => (true, None), - Status::UpToDate => (true, Some(format!("WordCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))), - Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))), + Status::UpToDate => { + (true, Some(tl!("WordCraft {version} is currently the newest version available.").replace("{version}", env!("CARGO_PKG_VERSION")))) + } + Status::Failed(e) => (true, Some(tl!("Could not check for updates:\n{error}").replace("{error}", e))), }; } From ee03feb81d7ddf40d1061636572a21c6acbd79f2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:37:24 -0400 Subject: [PATCH 49/50] build: pin craft-update to craft-libs main (495e597) (#4345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the update prompt fix that draws its close mark and version pill without glyphs a font may lack, so the prompt no longer takes the app down when a bundled face has no ✕ or →. Part of tracking issue storytold/craft-libs#319 🤖 Generated with Codebuff Co-authored-by: Codebuff --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ed1d6710d1..9ab4c84855 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,7 +1039,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=db19c373c8d59a545972ca340987aecfb3611a4b#db19c373c8d59a545972ca340987aecfb3611a4b" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index bedb555373..80b5bcc1cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,7 @@ wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "db19c373c8d59a545972ca340987aecfb3611a4b", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. From a029d8aed11f43d4e48b993ba082052e1786abec Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 17:52:32 -0400 Subject: [PATCH 50/50] build: pin craft-update to craft-libs main (495e597) (#4352) Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down. Part of tracking issue storytold/craft-libs#319 --- Cargo.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9ab4c84855..dc052fa739 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1545,7 +1545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3516,7 +3516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4018,7 +4018,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4395,7 +4395,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4454,7 +4454,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5056,7 +5056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5241,7 +5241,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5607,7 +5607,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6278,7 +6278,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]]