diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52068cea1..e6ba1400e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: + # Same craft-fonts commit as release.yml. + 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 3b0ac9ef6..05d6bbb7b 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -27,3 +27,5 @@ jobs: freebsd: # 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: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fceaad8d..dfd9910eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,7 @@ jobs: with: app-name: wordcraft app-title: WordCraft + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index b272bea7b..5f1eba265 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,6 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `egui` dependency: `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Small egui special-icon subset; embedded by default_fonts, retained in FontDefinitions::default() | +| `egui` dependency: `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Parent monochrome icon/emoji face embedded by monochrome_emoji_fonts; separate from the egui-icons subset | | `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) | | `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation | | `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) | diff --git a/Cargo.lock b/Cargo.lock index fac55c6a2..e883847e1 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", @@ -1036,7 +1031,7 @@ dependencies = [ [[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=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ "egui", ] @@ -1281,9 +1276,8 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1291,9 +1285,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1315,6 +1308,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1326,9 +1320,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1346,9 +1339,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1366,31 +1358,34 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1402,9 +1397,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1429,9 +1423,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1479,9 +1472,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1497,16 +1489,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1927,22 +1920,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" @@ -1957,7 +1934,7 @@ dependencies = [ "png", "skrifa 0.44.0", "smallvec", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -2179,7 +2156,7 @@ dependencies = [ "hayro-syntax", "kurbo", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash 2.1.3", "siphasher", "skrifa 0.42.1", @@ -2479,9 +2456,9 @@ checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_pattern" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f" dependencies = [ "displaydoc", "either", @@ -2779,12 +2756,12 @@ 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]] @@ -3589,8 +3566,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", ] @@ -3601,7 +3588,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]] @@ -3610,8 +3607,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", @@ -3626,6 +3636,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" @@ -3922,7 +3941,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4356,7 +4375,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4472,6 +4491,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" @@ -4948,7 +4973,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]] @@ -5505,6 +5530,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-script" version = "0.5.8" @@ -5570,21 +5601,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" @@ -5601,18 +5617,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" @@ -5621,13 +5625,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]] @@ -6402,8 +6406,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=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -6772,7 +6775,7 @@ version = "0.11.0" dependencies = [ "image", "log", - "vello_cpu 0.2.0", + "vello_cpu", "wordcraft-doc", "wordcraft-fonts", "wordcraft-geom", @@ -6797,7 +6800,7 @@ dependencies = [ "serde_json", "sys-locale", "toml", - "unicode-bidi", + "web-time", "wordcraft-doc", "wordcraft-engine", "wordcraft-fonts", @@ -7000,9 +7003,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7204,9 +7207,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "serde", "yoke", @@ -7216,9 +7219,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", @@ -7318,3 +7321,23 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 5c5724cfb..74175e376 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,10 +78,12 @@ zeroize = "1" libc = "0.2" # 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"] } @@ -114,3 +116,18 @@ 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 = "17744430be327d0257496a6a31c8d38fe20a6756" } +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 8e7c3d511..7fd6e6120 100644 --- a/NOTICE +++ b/NOTICE @@ -95,14 +95,32 @@ list (author, source, license) is in ATTRIBUTION.md. FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0. +- Application-drawn UI icons (crates/ui-egui/src/icons.rs) are original work, MIT OR Apache-2.0; + the bundled egui font icons have the separate notice below. Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources. The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision -c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing +17744430be327d0257496a6a31c8d38fe20a6756. The backend retains its MIT OR Apache-2.0 licensing and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md. The backend patch prevents finalization during an unwind and rejects a writer after failed cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled. No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included. + +Bundled egui font icons + +The canonical egui0.37 dependency includes egui-icons.ttf (a small subset of emoji-icon-font) +through default_fonts, and emoji-icon-font.ttf through monochrome_emoji_fonts. Both are embedded +by the UI font definitions. Their upstream project is https://github.com/jslegers/emoji-icon-font; +the exact copyright and permission notice from the pinned egui dependency follows. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/ROADMAP.md b/ROADMAP.md index 204979822..2d0b45ddd 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: 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-25 · **Change:** trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open; 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. @@ -140,6 +140,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-25: Interface catalogs are still the shared storytold/craft-i18n ones embedded from `craft-i18n.rev` (`9205108c…`): 27 WordCraft languages with English fallback for missing strings, and the equation “Delete Around Caret in Equation” translation preserved. Native-speaker review remains human. +- 2026-10-25: Input methods (egui 0.37): character- and byte-counted surrounding deletions, a fresh composition after body/equation replacement, rejection of queued stale replacements before mutation, transactional equation deletes and one undo group, with tracked and placeholder length mapping. Synthetic regressions document these paths; managed CI passed, and native IME acceptance (#155–#164) plus browser IME behavior remain open. - 2026-10-25: Text Highlight Color shows the last colour as the button's bar in the ribbon and mini toolbar; after No Color it shows the pen without a misleading yellow bar, and the button removes highlighting (#4087). - 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. - 2026-10-23: cu20we: paragraph-relative box floats start above effective space before; header, footer, cell and text-box layout regressions pass. Framed paragraph positioning is unchanged; no new native Word comparison. @@ -635,6 +637,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open | | 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. | diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index ee92078d3..7fa947f02 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -146,22 +146,10 @@ 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 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); @@ -180,11 +168,34 @@ impl eframe::App for WebShell { } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.app.raw_input_hook(raw); + /// 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. + 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 e255bafc5..828066e33 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 } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 85410340d..2073b5069 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -27,39 +27,47 @@ 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 { + /// 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.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 +93,13 @@ impl eframe::App for App { } } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + /// 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. + 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); @@ -320,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(); @@ -480,6 +496,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 52975e2c0..5e8489b71 100644 --- a/apps/wordcraft/src/print.rs +++ b/apps/wordcraft/src/print.rs @@ -1,11 +1,16 @@ //! 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(); 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 1fbe354a0..fd264c675 100644 --- a/craft-i18n.rev +++ b/craft-i18n.rev @@ -1 +1 @@ -04600cbb1508f1b15927ec574a5e32e5f376fd8b +9205108cfdced5177706264038848b68b2422d2b diff --git a/crates/doc/src/ime_text.rs b/crates/doc/src/ime_text.rs new file mode 100644 index 000000000..2c4ed70a1 --- /dev/null +++ b/crates/doc/src/ime_text.rs @@ -0,0 +1,712 @@ +//! 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}; + + /// 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 { + 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 + } + + /// 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)]); + 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())); + } + + /// 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(); + 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"); + } + + /// 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(); + 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, ""); + } + + /// 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()); + 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()); + } + + /// 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"); + 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()); + } + + /// 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"); + 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"); + } + + /// 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"); + 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"); + } + + /// 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"); + 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); + } + + /// 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"); + 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 c4275d5d9..b3ac326ae 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 eb9efc20f..0982769cd 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, features = ["image"] } wordcraft-doc = { workspace = true } diff --git a/crates/engine/src/cmd/equation.rs b/crates/engine/src/cmd/equation.rs index a9aea6204..f7be739b8 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 } @@ -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) @@ -40,6 +43,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 Caret 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 +106,73 @@ 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 { + /// 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, + content_changed: effect.content_changed, + state_changed: effect.state_changed || effect.content_changed, + transformed, + selection_changed: false, + }); +} + +/// 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() }); +} + /// 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). @@ -163,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(); @@ -178,14 +239,27 @@ 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) } +/// 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)) { @@ -200,8 +274,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) } @@ -218,20 +295,30 @@ 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)?; + 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 +330,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult { pos = p; changed = true; } + if changed { + transformed = true; + } if c == ' ' && changed { continue; } @@ -253,9 +343,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() { @@ -265,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 { @@ -272,7 +364,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,9 +375,12 @@ 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) } +/// 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() { @@ -296,7 +391,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 +400,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) } @@ -317,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"); @@ -324,48 +422,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 +478,96 @@ 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()))?; - // 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})) +} +/// 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; 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 +588,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 +596,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) } @@ -452,11 +621,14 @@ 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; 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 +640,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,26 +650,32 @@ 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) } +/// 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()))?; - 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) } +/// 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") { @@ -505,10 +684,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 +696,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 +719,7 @@ fn number(s: &mut Session, v: &Value) -> CmdResult { m.pos = MathPos::default(); } s.clamp_selection(); + apply_effect(s, effect, false); result(s) } @@ -562,17 +743,20 @@ 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()))?; 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) } @@ -601,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(); @@ -614,11 +800,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) } @@ -858,14 +1045,24 @@ 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. 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 +1138,239 @@ 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:#?}"); } + + /// 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) { + math.nodes.clear(); + } + } + + /// 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(); + 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"); + } + + /// 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(); + 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"); + } + + /// 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(); + 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"); + } + + /// 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 "] { + 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:?}"); + } + } + + /// 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(); + 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); + } + + /// 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(); + 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:?}"); + } + + /// 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(); + 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); + } + + /// 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(); + 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" + ); + } + + /// 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(); + 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()); + } + + /// 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(); + 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"); + } + + /// 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] { + 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}"); + } + } + + /// 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(); + 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 c40777241..b09ae0185 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(), @@ -188,6 +192,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 763fad036..d8d08f083 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,38 @@ 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> { +/// 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() { 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,6 +392,7 @@ 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; // Typed line breaks are the Enter key between the lines: in a one-line control it does // nothing, so the lines run together and the control stays one paragraph (pasted text keeps // its breaks, as before). @@ -318,10 +400,12 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> for part in text.split(['\r', '\u{2029}']) { if !first && !no_breaks { 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 @@ -338,7 +422,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 4e23cf8ef..e0d1da2a3 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 e0ab3e4de..ee7ebaafa 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 { @@ -96,15 +96,26 @@ 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, dash and double-space 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); typing_options(s, text); - let t = if raw { None } else { autoformat(s, text) }; + let smart_quote = if raw { None } else { autoformat(s, text) }; let was_collapsed = s.sel.is_collapsed(); - type_text(s, t.as_deref().unwrap_or(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. @@ -115,12 +126,23 @@ 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)?; if text == " " && !listed && was_collapsed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.double_space_period { double_space_period(s)?; } + // 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. It also covers the double-space period. + 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(¶) { @@ -137,6 +159,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) } @@ -213,10 +236,10 @@ fn double_space_period(s: &mut Session) -> Result<(), CmdError> { } /// "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(' ')) @@ -227,6 +250,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(' ') @@ -236,6 +260,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 @@ -246,8 +271,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. @@ -886,11 +912,22 @@ 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; 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(); @@ -901,7 +938,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. @@ -981,11 +1025,20 @@ 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; 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(); @@ -993,7 +1046,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 1f1aaa658..695501aea 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -435,6 +435,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, _| { @@ -527,6 +532,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(); } @@ -1338,6 +1347,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 { @@ -1419,6 +1430,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 0158a6054..a3c004ac8 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; @@ -172,6 +202,8 @@ mod tests_bidi; #[cfg(test)] mod tests_fonts; #[cfg(test)] +mod tests_ime_noop; +#[cfg(test)] mod tests_revision_checks; #[cfg(test)] mod tests_typing; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 4bf1265d3..94e93b7b6 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)] @@ -327,8 +327,10 @@ 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 whether its group has a step) -/// 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, +/// whether the group has a step of its own, and the dirty flag. See +/// [`Session::edit_snapshot`]. pub struct EditSnapshot { doc: Document, sel: Selection, @@ -341,8 +343,14 @@ 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, join_anchor: bool, 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`] @@ -423,6 +431,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, /// Whether the undo group has a step of its own for the next joined command to merge /// into. A command that changed nothing leaves the group without one, so the next /// command in the group starts the step the group needs. @@ -437,6 +453,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). @@ -526,13 +567,45 @@ 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); + +/// 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) +} + /// 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). @@ -723,11 +796,21 @@ impl Session { redo: Vec::new(), typing_open: false, join_next: false, + equation_join_next: false, + ime_text_join: None, join_anchor: false, 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, @@ -834,6 +917,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; @@ -1117,6 +1223,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 @@ -1194,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 @@ -1211,6 +1350,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 } @@ -1219,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 }; @@ -1230,9 +1374,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; @@ -1299,12 +1462,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; @@ -1328,8 +1495,9 @@ 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 whether its - /// group has a step to merge into) and `dirty`. `rev`, + /// the limit), `redo`, the open typing group, the pending `join_next_undo`, + /// `join_next_equation_edit` and body replacement promises, whether the typing group has a + /// step of its own, 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. @@ -1375,6 +1543,8 @@ impl Session { redo, typing_open, join_next, + equation_join_next, + ime_text_join, join_anchor, autocap: _, rev: _, @@ -1395,6 +1565,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: _, @@ -1430,8 +1610,11 @@ 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(), join_anchor: *join_anchor, dirty: *dirty, + ime_body_epoch: *ime_body_epoch, } } @@ -1439,7 +1622,21 @@ 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, join_anchor, dirty } = snap; + let EditSnapshot { + doc, + sel, + history_len, + evicted, + head, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + 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()); @@ -1454,6 +1651,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.join_anchor = join_anchor; self.touch(); self.dirty = dirty; @@ -1521,6 +1721,87 @@ 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 + } + + /// 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() }, + 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(); + } + + /// 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) { + 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 + } + /// Whether the command changed nothing: the document, the selection, the formatting /// waiting at the caret and the column selection are as they were before it ran. /// @@ -1534,9 +1815,24 @@ impl Session { /// 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}"))); } @@ -1574,6 +1870,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(), @@ -1648,6 +1948,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 rev_before = self.rev; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params))); let mut result = match result { @@ -1727,6 +2028,28 @@ 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. + // A command that changed nothing is not an edit (#23), so it is not an IME + // state change either: otherwise Backspace at the document start would still + // signal an application change and move the settled IME revision. + if !ime_outcome.classified && mutates && !no_op { + 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 && !no_op && (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()); } @@ -1749,8 +2072,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; } @@ -1872,3 +2203,95 @@ 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; + + /// 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); + 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); + } + + /// 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")); + 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"); + } + + /// 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")); + 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"); + } + + /// 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()); + 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"); + } + + /// 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")); + 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()); + } + + /// 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")); + 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 d06188ec2..e6a594d51 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10734,6 +10734,115 @@ 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"); +} + +/// 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(); + 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 "); + + let mut session = s(); + run(&mut session, "tools.autocorrect", json!({"doubleSpacePeriod": true})); + for c in "Hello ".chars() { + run(&mut session, "text.insert", json!({"text": c.to_string()})); + } + let outcome = session.ime_edit_outcome(); + assert!(outcome.transformed && outcome.content_changed && outcome.state_changed, "double-space period: {outcome:?}"); + assert_eq!(text(&session), "Hello. "); + run(&mut session, "edit.undo", json!({})); + assert_eq!(text(&session), "Hello ", "undo restores the spaces in one step"); +} + #[test] fn style_set_keeps_imported_styles_latent_styles_and_defaults() { use wordcraft_doc::props::{Rgb, TextColor}; diff --git a/crates/engine/src/tests_ime_noop.rs b/crates/engine/src/tests_ime_noop.rs new file mode 100644 index 000000000..73956e68d --- /dev/null +++ b/crates/engine/src/tests_ime_noop.rs @@ -0,0 +1,122 @@ +//! Regressions where main's "a command that changes nothing is not an edit" (#23) meets the +//! migration's input-method application-change and state-revision tracking. +//! +//! Kept in its own module so the merged `tests.rs` is not touched. + +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}")) +} + +/// An *unclassified* no-op (`text.backspace` at the very start of the body) is not an edit and +/// must not look like one to the input method: no effect flags, no application-change signal, no +/// settled-revision move, and no history/dirty/revision side effects. +#[test] +fn a_no_op_command_is_not_an_ime_state_change() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "x"})); + run(&mut s, "caret.docStart", json!({})); + assert!(s.pending.is_none(), "caret movement dropped any pending formatting"); + s.take_pending_ime_application_change(); + let revision = s.ime_state_revision().expect("an IME revision"); + let rev = s.rev(); + let depth = s.undo_depth(); + let dirty = s.dirty; + + run(&mut s, "text.backspace", json!({})); + + let outcome = s.ime_edit_outcome(); + assert!(!outcome.classified, "the handler leaves this no-op unclassified: {outcome:?}"); + assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}"); + assert!(!s.take_pending_ime_application_change(), "a no-op is not an application change"); + assert_eq!(s.ime_state_revision(), Some(revision), "the settled IME revision did not move"); + assert_eq!((s.undo_depth(), s.dirty, s.rev()), (depth, dirty, rev)); +} + +/// `document.setText` with identical text replaces the body under the same document id, so the +/// IME replacement epoch must advance — but because nothing observable changed it is not an edit +/// and must not signal an application-origin change (main's #23 no-op rule). +#[test] +fn a_same_text_set_text_advances_the_body_epoch_without_an_application_change() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Same body"})); + s.take_pending_ime_application_change(); + let epoch = s.ime_body_epoch(); + let document_id = s.document_id(); + let rev = s.rev(); + let depth = s.undo_depth(); + + run(&mut s, "document.setText", json!({"text": "Same body"})); + + assert!(s.ime_body_epoch() > epoch, "the body is replaced again under the same document id"); + assert_eq!(s.document_id(), document_id, "the document id itself is unchanged"); + assert!(!s.take_pending_ime_application_change(), "an identical setText is not an application change"); + assert_eq!((s.undo_depth(), s.rev()), (depth, rev)); +} + +/// The IME body replacement promise (`DeleteSurrounding`, then the commit that replaces the +/// erased text) joins only a `text.insert` at the selection the deletion left. Tab, an ordinary +/// command, consumes it: it is no IME replacement receipt itself, and the commit after it owns a +/// new Undo step instead of joining the deletion. +#[test] +fn tab_between_an_ime_replacement_and_its_commit_keeps_the_steps_apart() { + let text = |s: &Session| s.doc.plain_text(wordcraft_doc::StoryRef::Body); + + // The promise itself joins the commit at the unchanged selection. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "ab"})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(text(&s), "a", "DeleteSurrounding erased the b"); + s.join_next_ime_text_replacement(); + let depth = s.undo_depth(); + run(&mut s, "text.insert", json!({"text": "c"})); + assert_eq!(text(&s), "ac"); + assert_eq!(s.undo_depth(), depth, "the commit joins the deletion's step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "ab", "one Undo restores the deletion and its commit"); + + // Tab consumes the promise: the commit no longer joins the deletion. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "ab"})); + run(&mut s, "text.backspace", json!({})); + s.join_next_ime_text_replacement(); + let depth = s.undo_depth(); + run(&mut s, "text.tab", json!({})); + let outcome = s.ime_edit_outcome(); + assert!(!outcome.classified, "Tab is not an IME replacement receipt: {outcome:?}"); + assert_eq!(s.undo_depth(), depth + 1, "Tab owns its Undo step"); + run(&mut s, "text.insert", json!({"text": "c"})); + assert_eq!(text(&s), "a\tc"); + assert_eq!(s.undo_depth(), depth + 2, "the commit after Tab starts its own Undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "a\t", "Undo takes back only the commit"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "a", "then the Tab"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "ab", "and the IME deletion on its own"); + + // Returning directly to the promised selection must not resurrect a promise consumed by + // Tab. Do not use a caret command here: that command would itself consume a leaked promise. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "ab"})); + run(&mut s, "text.backspace", json!({})); + s.join_next_ime_text_replacement(); + let promised_selection = s.sel.clone(); + run(&mut s, "text.tab", json!({})); + let depth = s.undo_depth(); + s.sel = promised_selection; + run(&mut s, "text.insert", json!({"text": "c"})); + assert_eq!(text(&s), "ac\t"); + assert_eq!(s.undo_depth(), depth + 1, "returning to the old selection cannot reuse the consumed promise"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "a\t", "only the new insert is undone"); +} diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 007838f74..331554f00 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -3321,6 +3321,27 @@ fn tab_with_a_partial_selection_still_inserts_a_tab() { assert_eq!(paras(&s), vec![para("H\t world", None)]); } +/// Tab with a whole paragraph selected indents it as one Undo step: the selection stays while +/// the indent is applied, and Undo restores both the indentation and that same selection. A +/// partial selection still types a tab character (the test above), and Tab in a table cell still +/// navigates (the table tests above). +#[test] +fn tab_with_a_whole_paragraph_selected_undoes_indent_and_selection_together() { + let mut s = typed("Hello world"); + run(&mut s, "select.paragraph", json!({})); + let selected = s.sel.clone(); + let depth = s.undo_depth(); + run(&mut s, "text.tab", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello world", "Tab indents, it types no tab"); + assert_eq!(s.sel, selected, "the whole paragraph stays selected"); + assert_eq!(indents(&s), (Some(36.0), None)); + assert_eq!(s.undo_depth(), depth + 1, "one paragraph, one Undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(indents(&s), (None, None), "Undo takes the indent back"); + assert_eq!(s.sel, selected, "and restores the selection the indent was applied to"); + assert_eq!(paras(&s), vec![para("Hello 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 diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index abfc5b2f6..17f25b076 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -2010,6 +2010,23 @@ 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 + // 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()); + } + #[test] fn a_family_named_for_a_heavy_weight_is_that_heavy() { let face = |bytes: &'static [u8], family: &str| { diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 889488ccb..11e8f95c8 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. @@ -1916,6 +1920,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, @@ -1926,6 +1943,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(), @@ -1950,6 +1968,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; @@ -2281,24 +2304,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. @@ -2333,11 +2359,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 @@ -3031,6 +3060,29 @@ 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() + } + + /// 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) @@ -3048,6 +3100,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); } @@ -3125,11 +3184,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(); @@ -3157,6 +3217,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 cd3657f27..7ed459c39 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -11,6 +11,202 @@ 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 +} + +/// 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 { + 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 +} + +/// 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 { + 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() + ); + 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()) { + 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); + } + } + if layout.pages.len() > 8 { + let _ = writeln!(out, "omitted {} later pages", layout.pages.len() - 8); + } + out +} + #[derive(Clone, Copy)] enum SpacingLocation { Cell, @@ -4743,11 +4939,11 @@ 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; - 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; @@ -4756,12 +4952,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; @@ -5469,14 +5680,43 @@ 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; 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()))]; @@ -5486,6 +5726,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() @@ -5497,7 +5749,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] @@ -6297,44 +6553,120 @@ 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; - // 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 -/// `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 = 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 t = Table::new(rows, 1, 468.0); - build(&mut t); +/// 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.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(t)).unwrap(); - let l = lay(&d); - let pages = l - .pages + 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"); + } + (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 { + 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); + } + } + } + } +} + +/// 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)| { + 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() +} + +/// 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 = @@ -6342,7 +6674,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) } @@ -6407,17 +6760,41 @@ 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; // 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| { @@ -6428,10 +6805,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 18fb611d3..9662bd68c 100644 --- a/crates/layout/src/tests_ligature.rs +++ b/crates/layout/src/tests_ligature.rs @@ -171,6 +171,102 @@ 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}"); +} + +/// 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] { + 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); + } + } +} + +/// 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] { + 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. @@ -219,3 +315,131 @@ fn a_hyphenation_point_never_splits_a_ligature() { } } } + +/// The paragraph's text without its inline-object markers (`U+FFFC`), and for every character +/// start of that text the byte offset it has in the paragraph (plus the end). +fn without_markers(p: &wordcraft_doc::Paragraph) -> (String, Vec<(usize, usize)>) { + let mut text = String::new(); + let mut at = Vec::new(); + for (i, c) in p.text.char_indices() { + if c == wordcraft_doc::para::OBJ { + continue; + } + at.push((text.len(), i)); + text.push(c); + } + at.push((text.len(), p.text.len())); + (text, at) +} + +/// "x👩‍💻y" with the emoji's components at two sizes, justified; optionally with a zero-width +/// marker at each end of the grapheme and of the paragraph. Every marker sits on a grapheme +/// boundary: a marker *inside* one is a `U+FFFC` in the text and splits it in the document model. +fn marked_grapheme_document(marks: bool, bidi: bool) -> Document { + 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(); + { + let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + p.props.bidi = Some(bidi); + p.props.align = Some(wordcraft_doc::props::Align::Justify); + } + if marks { + // From the paragraph's end, so the earlier insertions keep their offsets. + let marks = [ + (13, wordcraft_doc::para::InlineObject::BookmarkStart { name: "g".into() }), + (12, wordcraft_doc::para::InlineObject::CommentStart { id: 7 }), + (1, wordcraft_doc::para::InlineObject::ControlStart { control: Box::default() }), + (0, wordcraft_doc::para::InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }), + ]; + for (at, mark) in marks { + d.insert_object(&Pos::body(0, at), mark, &Default::default()).unwrap(); + } + } + d +} + +/// Text clusters on each line, not counting zero-width markers: a marker may not move text +/// between lines. +fn text_cluster_counts(pl: &ParaLayout) -> Vec { + pl.lines.iter().map(|l| pl.clusters.get(l.c0..l.c1).map_or(0, |cs| cs.iter().filter(|c| c.kind != crate::para::ClKind::Marker).count())).collect() +} + +/// A bookmark, comment, content-control and field marker at the edges of a mixed-style emoji +/// grapheme (and of the paragraph) add no line of their own and move no break into the grapheme, +/// at narrow and wide widths, LTR and bidi. Their line-break skip and the grapheme's atomic +/// caret stops must agree. +#[test] +fn markers_at_a_mixed_style_graphemes_edges_keep_it_atomic() { + const START: usize = 1; // the emoji's first byte in the text without markers + const INSIDE: usize = 8; // its size change: a drawing segment start inside the grapheme + for bidi in [false, true] { + for width in [18.0, 32.0, 48.0, 180.0] { + let mut plain_doc = marked_grapheme_document(false, bidi); + plain_doc.last_section.page_w = 144.0 + width; + let plain = lay(&plain_doc); + let plain_counts = text_cluster_counts(para_layout(&plain)); + if width == 18.0 { + assert!(plain_counts.len() > 1, "the narrow fixture must exercise wrapping"); + } + + let mut d = marked_grapheme_document(true, bidi); + d.last_section.page_w = 144.0 + width; + let l = lay(&d); + let pl = para_layout(&l); + let p = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let (clean, at) = without_markers(p); + assert_eq!(clean, "x\u{1F469}\u{200D}\u{1F4BB}y", "{bidi}/{width}: markers are not text"); + let para_off = |clean_off: usize| at.binary_search_by_key(&clean_off, |e| e.0).ok().and_then(|k| at.get(k)).map(|e| e.1); + let clean_starts = wordcraft_doc::cluster::cluster_starts(&clean); + let mut boundaries: Vec = clean_starts.iter().filter_map(|&o| para_off(o)).collect(); + // The paragraph's end is a caret stop on the last line but not a grapheme start. + boundaries.extend(para_off(clean.len())); + let markers: Vec = p.text.char_indices().filter(|&(_, c)| c == wordcraft_doc::para::OBJ).map(|(i, _)| i).collect(); + assert_eq!(markers.len(), 4, "{bidi}/{width}: the fixture carries all four zero-width markers"); + for &marker in &markers { + let clean_offset = p.text[..marker].chars().filter(|&c| c != wordcraft_doc::para::OBJ).map(char::len_utf8).sum::(); + assert!(clean_starts.contains(&clean_offset) || clean_offset == clean.len(), "marker must sit on a clean grapheme edge"); + } + assert_eq!(pl.clusters.iter().filter(|c| c.kind == crate::para::ClKind::Marker).count(), 4); + let start = para_off(START).unwrap(); + let inside = para_off(INSIDE).unwrap(); + let interior: Vec = clean.char_indices().map(|(i, _)| i).filter(|o| !clean_starts.contains(o)).filter_map(¶_off).collect(); + assert!(interior.contains(&inside), "{bidi}/{width}: {inside} must be inside the grapheme: {interior:?}"); + let allowed = |off: usize| boundaries.contains(&off) || markers.contains(&off); + + assert_eq!(text_cluster_counts(pl), plain_counts, "{bidi}/{width}: markers must not move text between lines"); + assert!(pl.clusters.iter().any(|c| c.start == inside), "{bidi}/{width}: the size change must still split the drawing"); + for (li, line) in pl.lines.iter().enumerate() { + assert!( + pl.clusters.get(line.c0..line.c1).is_some_and(|cs| cs.iter().any(|c| c.kind != crate::para::ClKind::Marker)), + "{bidi}/{width}: line {li} holds only markers: {line:?}" + ); + for off in [line.start, line.stop] { + assert!(!interior.contains(&off), "{bidi}/{width}: line {li} ends or starts inside the grapheme at {off}: {line:?}"); + assert!(allowed(off), "{bidi}/{width}: line {li} boundary {off} is not a grapheme boundary: {line:?}"); + } + for off in pl.line_offsets(li) { + assert!(!interior.contains(&off), "{bidi}/{width}: caret stop {off} inside the grapheme"); + assert!(allowed(off), "{bidi}/{width}: caret stop {off} is not a grapheme boundary"); + } + 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..=64 { + let off = pl.off_at_x(li, left + (right - left) * sample as f32 / 64.0); + assert!(!interior.contains(&off), "{bidi}/{width}: a click on line {li} reached {off} inside the grapheme"); + } + } + let li = pl.line_of(start); + assert_eq!( + pl.x_of(li, inside).expect("interior offset resolves"), + pl.x_of(li, start).expect("grapheme start resolves"), + "{bidi}/{width}: an explicit interior offset uses the grapheme's leading edge" + ); + } + } +} diff --git a/crates/layout/src/tests_textbox.rs b/crates/layout/src/tests_textbox.rs index aef5932ed..5148e770c 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 @@ -186,25 +189,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 1aa3e8b36..3419c1a2c 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,7 +9,7 @@ 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 = "25da1314d443171b4adeaad2cd9454b273f35c6f" } # 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, ahead of craft-ui's: lazy per-locale parsing @@ -17,16 +17,16 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } wordcraft-geom = { workspace = true, features = ["image"] } 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: @@ -49,6 +49,12 @@ 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 b2ef7fc42..791e9d706 100644 --- a/crates/ui-egui/examples/perf_probe.rs +++ b/crates/ui-egui/examples/perf_probe.rs @@ -16,9 +16,12 @@ 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()); - 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(); @@ -32,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()); @@ -108,6 +113,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 d818ed94d..4ccfc1e53 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -84,9 +84,11 @@ 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()); - 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 196240949..6963c5e37 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -44,14 +44,25 @@ pub struct A11yCache { struct Key { layout: Arc, doc: Id, + /// Whether the page shows Insert › Table's preview copy rather than the document: the two + /// can otherwise share a layout identity only across the preview's start and end, which must + /// rebuild the tree even when the page's rectangles and caret are unchanged. + previewing: bool, pages: (usize, usize), rects: Vec, sel: (Pos, Pos), } 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 && self.pages == o.pages && self.rects == o.rects && self.sel == o.sel + Arc::ptr_eq(&self.layout, &o.layout) + && self.doc == o.doc + && self.previewing == o.previewing + && self.pages == o.pages + && self.rects == o.rects + && self.sel == o.sel } } @@ -80,10 +91,24 @@ pub fn expose(app: &mut crate::WordApp, ctx: &egui::Context, doc_id: Id, layout: return; } let pages = page_window(rects, view).unwrap_or((0, 0)); - let sel = (app.session.sel.anchor.clone(), app.session.sel.focus.clone()); - let key = Key { layout: layout.clone(), doc: doc_id, pages, rects: rects.get(pages.0..pages.1).unwrap_or_default().to_vec(), sel }; + // The page may show Insert › Table's preview copy while the grid is hovered (#3931): describe + // the same document and selection the pages and caret are drawn from, not the real document + // that the preview's layout does not belong to. + let preview = crate::canvas::preview_sel(app); + let sel = preview.map_or_else(|| (app.session.sel.anchor.clone(), app.session.sel.focus.clone()), |sel| (sel.anchor.clone(), sel.focus.clone())); + let key = Key { + layout: layout.clone(), + doc: doc_id, + previewing: preview.is_some(), + pages, + rects: rects.get(pages.0..pages.1).unwrap_or_default().to_vec(), + sel, + }; if !app.canvas.a11y.key.as_ref().is_some_and(|k| k.same(&key)) { - app.canvas.a11y.tree = build(&app.session.doc, layout, doc_id, rects, pages, (&key.sel.0, &key.sel.1)); + // Build before writing the cache: the preview document borrows `app`, and the tree is + // owned, so the borrow ends with this statement. + let tree = build(crate::canvas::page_doc(app), layout, doc_id, rects, pages, (&key.sel.0, &key.sel.1)); + app.canvas.a11y.tree = tree; app.canvas.a11y.key = Some(key); } let title = app.title_stem(); @@ -435,6 +460,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); @@ -546,6 +572,68 @@ mod tests { update.expect("AccessKit output") } + /// What AccessKit is told after a few frames in which Insert › Table's grid hovers `rows` × + /// `cols`: the picker sets the request from the popup after the ribbon draws, and the canvas + /// lays out and describes the page from that preview. + fn described_with_table_preview(app: &mut crate::WordApp, ctx: &egui::Context, rows: usize, cols: usize) -> accesskit::TreeUpdate { + ctx.enable_accesskit(); + let mut update = None; + for _ in 0..4 { + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + time: Some(ctx.input(|i| i.time) + 0.1), + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + // Set it in every pass, as the grid does while its square is hovered. + app.canvas.table_preview = Some((rows, cols)); + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + update = out.platform_output.accesskit_update.take(); + } + update.expect("AccessKit output") + } + + /// #3931 review: the page shows a previewed table while Insert › Table's grid is hovered, so + /// the AccessKit tree must describe that page's table, not the real document's block at the + /// preview's path. In the preview copy path `[0]` is a 2 × 2 table; in the real document it is + /// the paragraph "Lanterns". Describing the real document with the preview layout made the + /// table node lose its dimensions (and its cells their text) whenever a screen reader was on. + #[test] + fn the_preview_table_is_described_from_the_previewed_page() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Lanterns\nKites")), Default::default()); + let update = described_with_table_preview(&mut app, &egui::Context::default(), 2, 2); + let nodes: HashMap = update.nodes.iter().map(|(id, n)| (*id, n)).collect(); + let tables: Vec<_> = nodes.iter().filter(|(_, n)| n.role() == Role::Table).map(|(id, _)| *id).collect(); + assert_eq!(tables.len(), 1, "the previewed page has one table"); + let table = nodes[&tables[0]]; + assert_eq!(table.row_count(), Some(2), "the 2 × 2 preview's rows, not the real paragraph at its path"); + assert_eq!(table.column_count(), Some(2), "the 2 × 2 preview's columns"); + + // The preview's caret is in the table; the document's own caret names a paragraph that + // the preview layout no longer has at that path, so it must not reach the tree instead. + let (_, doc) = nodes.iter().find(|(_, n)| n.role() == Role::Document).expect("a document node"); + let selection = doc.text_selection().expect("the preview's caret is described"); + let mut parent = HashMap::new(); + for (id, n) in &nodes { + for c in n.children() { + parent.insert(*c, *id); + } + } + let mut at = Some(selection.focus.node); + let mut in_table = false; + while let Some(id) = at { + if id == tables[0] { + in_table = true; + break; + } + at = parent.get(&id).copied(); + } + assert!(in_table, "the described caret is the preview's, inside the preview table"); + } + /// #488: with AccessKit on, the canvas describes the document: a heading with its level, a /// paragraph, a list item in a list, a table's rows and cells and a link, with the text in /// text runs and the caret at its paragraph and character. @@ -702,7 +790,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 25d2c82c6..cfc70fc25 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 a8c85b546..c2a99251f 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. @@ -53,7 +55,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 +64,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 +78,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 +126,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)); } @@ -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| { @@ -231,7 +235,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 +247,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 +261,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 +329,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() { @@ -404,8 +408,9 @@ struct PersonalListing { templates: Vec, } +/// Stable storage id for the personal-template listing cache. fn personal_listing_id() -> egui::Id { - egui::Id::new("backstage_personal_templates") + egui::Id::unique("backstage_personal_templates") } #[cfg(test)] @@ -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::new("backstage_personal_error") + 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))); @@ -521,15 +529,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())); @@ -537,8 +543,9 @@ 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::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 +560,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 @@ -1115,14 +1122,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 3ba32ad37..e80a5334a 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 @@ -240,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(), @@ -298,6 +314,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(), @@ -982,16 +1003,19 @@ fn shown_layout(app: &mut WordApp) -> std::sync::Arc { /// The document the pages of [`shown_layout`] are drawn from: the previewed copy while the page /// shows Insert › Table's preview, whose paragraph paths are the layout's (#3931). -fn page_doc(app: &WordApp) -> &wordcraft_doc::Document { +pub(crate) fn page_doc(app: &WordApp) -> &wordcraft_doc::Document { app.canvas.table_preview_layout.as_ref().map_or(&app.session.doc, |(_, doc, _, _)| doc) } /// The previewed copy's selection while the page shows Insert › Table's preview: the caret and /// selection are drawn from it, as the document's own name other places in the preview's layout. -fn preview_sel(app: &WordApp) -> Option<&wordcraft_engine::Selection> { +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(); @@ -1279,6 +1303,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); @@ -1287,7 +1313,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); } @@ -1513,7 +1539,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); @@ -1590,9 +1616,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; @@ -1648,14 +1678,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)); } } } @@ -1676,7 +1699,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( @@ -1725,14 +1748,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); @@ -1743,7 +1759,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. @@ -1778,8 +1794,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. @@ -1787,6 +1803,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(); } @@ -1800,6 +1819,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. @@ -1909,7 +1932,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); @@ -2146,9 +2169,9 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re // Over the page the card goes under the commented line, not on it. let top = (anchor.y + if over_page { 4.0 } else { -12.0 }).max(next_y); let active = app.canvas.balloon == Some(*id); - // Lay out a read-only balloon's text: the comment, then each reply under a rule, each - // with who wrote it and when. (galley, gap above, a rule above) - let mut lines: Vec<(std::sync::Arc, f32, bool)> = Vec::new(); + // Lay out a read-only balloon's text: the comment, then each reply under a rule, + // each with who wrote it and when. (galley, gap above, a rule above) + let mut lines: Vec<(craft_ui::text::AlignedGalley, f32, bool)> = Vec::new(); let card = if active { Rect::from_min_size(pos2(x0, top), vec2(w, app.canvas.balloon_h.max(24.0))) } else { @@ -2165,7 +2188,7 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re let text = app.session.doc.plain_text(StoryRef::Part(r.part)); lines.push((balloon_text(painter, text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0, false)); } - let h = lines.iter().map(|(g, gap, _)| g.size().y + gap).sum::() + 12.0; + let h = lines.iter().map(|(g, gap, _)| g.height() + gap).sum::() + 12.0; Rect::from_min_size(pos2(x0, top), vec2(w, h)) }; if !card.intersects(clip) { @@ -2187,6 +2210,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 (g, gap, rule) in lines { y += gap; @@ -2194,11 +2218,11 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re // A reply starts: a hairline between it and what it answers. 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); + let h = g.height(); + g.paint(&text_painter, pos2(card.min.x + 10.0, y), 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); } @@ -2246,120 +2270,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. -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) +/// 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) -> craft_ui::text::AlignedGalley { + 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, each with who wrote it and when, as @@ -2371,7 +2292,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; @@ -2502,7 +2423,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) @@ -2566,6 +2487,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) { @@ -2901,7 +2825,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); }); } @@ -3007,7 +2931,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() @@ -3021,7 +2945,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() { @@ -3032,7 +2956,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() { @@ -3281,7 +3205,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() @@ -4047,9 +3971,17 @@ 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 { 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); @@ -4872,8 +4804,15 @@ 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) + } + + /// 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(); + 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); @@ -4881,8 +4820,42 @@ 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); - for r in &g.rows { - out.extend(r.row.glyphs.iter().map(|gl| (gl.chr, r.pos.x + gl.pos.x))); + let shape = g.text_shape(Pos2::ZERO, Color32::BLACK); + let atlas = ui.fonts(|fonts| fonts.image()); + for r in &shape.galley.rows { + out.extend(r.row.glyphs.iter().map(|gl| (gl.chr, shape.pos.x + 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!( + shape.pos.x + r.pos.x + glyph.max_x() <= width + 1.0 / ppp, + "wrapped glyph {:?}: origin {}, max {}, row width {}, limit {} (text {:?})", + glyph.chr, + shape.pos.x + 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(); @@ -4900,45 +4873,486 @@ 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()) + struct BalloonFontFixture { + family: &'static str, + probe: &'static str, + text: &'static str, + } + + /// Explicit embedded bytes only: no OS font discovery or implicit default-font fallback. + /// Optional-input builds always exercise Latin; qualification must exercise both RTL faces. + fn balloon_font_fixtures() -> (egui::FontDefinitions, Vec) { + let mut fonts = egui::FontDefinitions::empty(); + fonts.font_data.insert( + "Balloon Latin".into(), + std::sync::Arc::new(egui::FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))), + ); + let mut names = vec!["Balloon Latin".to_string()]; + let mut fixtures = + vec![BalloonFontFixture { family: "Balloon Latin", probe: "Reading", text: "Reading long_unbreakable_text\n123\nLatin\n" }]; + for (family, script, probe, text) in [ + ("Noto Sans Arabic", "Arab", "شركة", "شركة اي\n123\nالربط (APIs) هنا\nLatin\n"), + ("Noto Sans Hebrew", "Hebr", "שלום", "שלום עולם\n123\nשלום (APIs) עולם\nLatin\n"), + ] { + if let Some(face) = wordcraft_fonts::CRAFT_FONTS.iter().find(|face| face.family == family && face.style == "Regular") { + assert!(face.scripts.contains(&script)); + assert!(!face.bytes.is_empty()); + fonts.font_data.insert(family.into(), std::sync::Arc::new(egui::FontData::from_static(face.bytes))); + names.push(family.to_string()); + fixtures.push(BalloonFontFixture { family, probe, text }); + } + } + if option_env!("CRAFT_FONTS_REQUIRED").is_some() || std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() { + assert_eq!(fixtures.len(), 3, "qualification requires both exact embedded Arabic and Hebrew faces"); + } + for name in &names { + fonts.families.insert(egui::FontFamily::Name(name.clone().into()), vec![name.clone()]); + } + fonts.families.insert(egui::FontFamily::Proportional, names.clone()); + fonts.families.insert(egui::FontFamily::Monospace, names); + (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 }; - // 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), ""); + 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 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 balloon_wrapper_keeps_real_font_rows_ink_and_reading_origins() { + let (fonts, fixtures) = balloon_font_fixtures(); + let mut rtl_cases = 0; + for ppp in [1.0, 1.25, 1.5, 2.0] { + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(ppp); + ctx.set_fonts(fonts.clone()); + ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); + for fixture in &fixtures { + let probe_font = egui::FontId::new(14.0, egui::FontFamily::Name(fixture.family.into())); + assert!( + ctx.fonts_mut(|fonts| fonts.has_glyphs(&probe_font, fixture.probe)), + "the selected face must actually cover {}", + fixture.family + ); + for width in [0.0, 23.3, 200.0] { + let font = egui::FontId::proportional(14.0); + assert!( + ctx.fonts_mut(|fonts| fixture.text.chars().filter(|c| !c.is_control()).all(|c| fonts.has_glyph(&font, c))), + "no missing/tofu fixture characters" + ); + let mut job = egui::text::LayoutJob::simple(fixture.text.into(), font.clone(), Color32::BLACK, width); + job.direction = egui::epaint::text::TextDirection::Auto; + job.halign = egui::Align::Min; + job.halign_min_is_start = false; + let original = ctx.fonts_mut(|fonts| fonts.layout_job(job)); + let (reference, rtl) = balloon_reading_reference(fixture, &original, width, ppp); + if rtl { + rtl_cases += 1; + } + 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)] { + assert_balloon_paint_case(&ctx, fixture, &font, &original, &reference, width, origin); + } + } + } + } + } + assert_eq!(rtl_cases, (fixtures.len() - 1) * 12); + 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(); + let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new()); + let mut rtl_cases = 0; + for fixture in &fixtures { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + if fixture.family != "Balloon Latin" { + let face = db.face(fixture.family, "Regular"); + assert_eq!(face.family, fixture.family, "do not accept a substitute fixture face"); + let embedded = wordcraft_fonts::CRAFT_FONTS.iter().find(|font| font.family == fixture.family && font.style == "Regular").unwrap(); + assert_eq!(face.data(), embedded.bytes); + assert!(fixture.probe.chars().all(|c| face.covers(c))); + app.canvas.ime_faces.push(face); + rtl_cases += 1; + } + app.session.author = "Balloon author".into(); + app.run("text.insert", json!({"text": "Some text here"})).unwrap(); + app.run("select.text", json!({"text": "text"})).unwrap(); + let body = fixture.text.trim(); + let id = app.run("review.newComment", json!({"text": body})).unwrap()["id"].as_u64().unwrap() as u32; + app.session.author = "Reply author".into(); + let reply = format!("{} (reply)", fixture.probe); + let reply_id = app.run("review.reply", json!({"id": id, "text": reply})).unwrap()["id"].as_u64().unwrap() as u32; + app.run("view.commentsPane", json!({"value": false})).unwrap(); + app.canvas.balloon = None; + let rev = app.session.rev(); + let ctx = egui::Context::default(); + ctx.tessellation_options_mut(|options| options.round_text_to_pixels = true); + let mut checked = false; + for frame in 0..6 { + let mut input = egui::RawInput { + time: Some(f64::from(frame) / 60.0), + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), + ..Default::default() + }; + // Interface Auto follows the monitor scale; setting only the context's + // UI zoom would be reset by the real app before these settling frames. + input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(1.5); + let output = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + if frame >= 3 { + checked |= assert_balloon_card(&app, &ctx, &output, id, reply_id, fixture); + } + output.drop_without_applying_deltas(); + } + assert!(checked, "actual author, timestamp, body and reply shapes must be observed"); + assert_eq!(app.session.rev(), rev, "painting never edits the document"); + assert_eq!(app.session.doc.plain_text(StoryRef::Part(app.session.doc.comments[&id].part)), body); + assert_eq!(app.session.doc.plain_text(StoryRef::Part(app.session.doc.comments[&reply_id].part)), reply); + } + assert_eq!(rtl_cases, fixtures.len() - 1); + 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()); + 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] @@ -5751,7 +6165,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); } @@ -7141,11 +7556,53 @@ 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"); + } + + /// 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 @@ -7155,14 +7612,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(); } @@ -7188,6 +7649,790 @@ 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_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(); + } + 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_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(); + } + 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"); + } + #[test] fn context_menu_crop_item_activates_crop_mode() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/caret_glide.rs b/crates/ui-egui/src/caret_glide.rs index 0feb2b971..b325c3e13 100644 --- a/crates/ui-egui/src/caret_glide.rs +++ b/crates/ui-egui/src/caret_glide.rs @@ -321,8 +321,9 @@ fn parse_ms(value: Option<&str>) -> f64 { } } +/// Stable id for the caret glide's temporary animation state. 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 60053a871..b7e7c8119 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… @@ -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 @@ -289,7 +291,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 +349,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 +407,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 +432,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 +508,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 +524,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); @@ -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") @@ -568,7 +572,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 +656,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 +686,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 +713,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 e5c0d22e3..211113b7b 100644 --- a/crates/ui-egui/src/colors.rs +++ b/crates/ui-egui/src/colors.rs @@ -247,16 +247,19 @@ pub enum Pick { More, } +/// Stable storage id for the Recent Colors list. fn recent_id() -> Id { - Id::new("wordcraft.recentColors") + Id::unique("wordcraft.recentColors") } +/// Stable storage id for the pending More Colors request. fn request_id() -> Id { - Id::new("wordcraft.moreColorsRequest") + Id::unique("wordcraft.moreColorsRequest") } +/// Stable storage id for the Colors dialog's open flag. fn open_flag_id() -> Id { - Id::new("wordcraft.colorsDialogOpen") + Id::unique("wordcraft.colorsDialogOpen") } /// Removes and returns a temporary value. @@ -449,7 +452,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); @@ -794,12 +797,14 @@ 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"})))); 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 +842,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 2febf5963..923be1d21 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 34c8f18ef..5a1ae6387 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; @@ -123,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); @@ -244,7 +249,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,28 +317,755 @@ 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 { + /// 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); + 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 }) + } + + /// 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 } + } +} + +/// 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 { + /// 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") + } 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 { + /// 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 { + 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 { + /// The screenshot callback's viewport: the root window. + 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 +} + +/// 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 { + 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(()) +} + +/// 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. +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() { + 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; + } + } + } + } + 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 !path.to_ascii_lowercase().ends_with(".png") { - return json!({"ok": false, "error": format!("{path}: a screenshot path must end in .png")}); + return error_reply(token, format!("{path}: a screenshot path must end in .png")); } if let Err(e) = wordcraft_engine::io::write_file(path, &png) { - return json!({"ok": false, "error": e}); + return error_reply(token, 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::*; + + /// 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); + 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") + } + + /// 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) + } + + /// 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])); + } } #[cfg(test)] @@ -348,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())); @@ -356,12 +1090,12 @@ mod screenshot_tests { let img = egui::ColorImage::filled([2, 2], egui::Color32::RED); let txt = dir.join("shot.txt"); - let r = save_screenshot(&img, txt.to_str()); + let r = save_screenshot(&img, txt.to_str(), 101); assert_eq!(r["ok"], false); assert!(!txt.exists()); let png = dir.join("shot.png"); - let r = save_screenshot(&img, png.to_str()); + let r = save_screenshot(&img, png.to_str(), 102); assert_eq!(r["ok"], true); assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG")); @@ -371,7 +1105,7 @@ mod screenshot_tests { std::fs::write(&victim, "keep").unwrap(); let link = dir.join("link.png"); std::os::unix::fs::symlink(&victim, &link).unwrap(); - let _ = save_screenshot(&img, link.to_str()); + let _ = save_screenshot(&img, link.to_str(), 103); assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep"); } @@ -379,7 +1113,7 @@ mod screenshot_tests { std::fs::create_dir_all(&other).unwrap(); let roots = wordcraft_engine::file_access::AutomationRoots::new(None, Some(&other)).unwrap(); let outside = dir.join("outside.png"); - let r = wordcraft_engine::file_access::confine(roots.as_ref(), || save_screenshot(&img, outside.to_str())); + let r = wordcraft_engine::file_access::confine(roots.as_ref(), || save_screenshot(&img, outside.to_str(), 104)); assert_eq!(r["ok"], false); assert!(!outside.exists()); } diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index f571b33b6..be72aa333 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 d2fa918c0..a5a0dbc1d 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 466bfdb23..eb82bdf31 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1098,7 +1098,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()); @@ -1521,7 +1521,7 @@ impl Dialog { /// Grid picker for Insert › Table (hover to size and preview on the page, 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())]) @@ -1568,7 +1568,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. @@ -1598,6 +1598,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())); @@ -1660,7 +1663,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::CustomProperties { .. } => "Custom Document Properties", 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); @@ -1801,10 +1804,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; } @@ -1936,8 +1941,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; @@ -2031,15 +2036,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 { @@ -2053,8 +2058,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(); }); @@ -2129,43 +2135,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); @@ -2223,8 +2230,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(); } @@ -2234,8 +2241,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; } }); @@ -2411,17 +2418,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))); @@ -2440,10 +2451,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| { @@ -2517,7 +2528,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())); @@ -2675,26 +2686,27 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.label(tl!("Base text:")); ui.label(egui::RichText::new(base.as_str()).strong()); ui.end_row(); - ui.label(tl!("Ruby text:")); - let r = ui.text_edit_singleline(ruby); + let ruby_label = ui.label(tl!("Ruby text:")); + let r = ui.text_edit_singleline(ruby).labelled_by(ruby_label.id); // Typing goes to the Ruby text box, not the selected text in the document (#3914). if std::mem::take(focus) { r.request_focus(); } ui.end_row(); - ui.label(tl!("Alignment:")); + let align_label = ui.label(tl!("Alignment:")); let shown = ALIGNS.iter().find(|(k, _)| k == align).map_or("Center", |(_, l)| l); - egui::ComboBox::from_id_salt("phonetic_align").selected_text(tl!(shown)).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("phonetic_align").selected_text(tl!(shown)).show_ui(ui, |ui| { for (k, l) in ALIGNS { ui.selectable_value(align, k.to_string(), tl!(l)); } }); + combo.response.labelled_by(align_label.id); ui.end_row(); - ui.label(tl!("Offset (pt):")); - ui.add(egui::DragValue::new(offset).speed(0.5).range(-100.0..=100.0)); + let offset_label = ui.label(tl!("Offset (pt):")); + ui.add(egui::DragValue::new(offset).speed(0.5).range(-100.0..=100.0)).labelled_by(offset_label.id); ui.end_row(); - ui.label(tl!("Size (pt):")); - ui.add(egui::DragValue::new(size).speed(0.5).range(1.0..=200.0)); + let size_label = ui.label(tl!("Size (pt):")); + ui.add(egui::DragValue::new(size).speed(0.5).range(1.0..=200.0)).labelled_by(size_label.id); ui.end_row(); }); let mut remove = false; @@ -2733,8 +2745,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); @@ -2748,16 +2760,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(); }); @@ -3171,13 +3183,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; } }); @@ -3352,7 +3364,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() { @@ -3380,7 +3393,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); } @@ -3688,7 +3704,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. @@ -3704,7 +3720,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); } } @@ -3777,8 +3793,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); @@ -3854,7 +3870,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); @@ -4076,7 +4092,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})); @@ -4138,20 +4154,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()); } @@ -4327,13 +4348,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); }); } @@ -4412,9 +4434,10 @@ 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::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(); @@ -4601,6 +4624,8 @@ mod tests { /// fills the box and does not replace the selected text in the document. #[test] fn phonetic_guide_ruby_box_takes_the_keyboard_on_open() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; let mut app = app_with("Unchanged document"); app.dialog = Some(Dialog::Phonetic { base: "Unchanged".into(), @@ -4613,6 +4638,15 @@ mod tests { }); let mut h = app_harness(app); h.run_steps(4); + // Every editable field has a name, and the Ruby box takes the keyboard on open. + assert!(h.get_by_role_and_label(Role::TextInput, "Ruby text:").is_focused(), "the Ruby text box takes the keyboard on open"); + assert_eq!( + h.get_by_role_and_label(Role::ComboBox, "Alignment:").value().as_deref(), + Some("Center"), + "Alignment is named and shows the current alignment" + ); + h.get_by_role_and_label(Role::SpinButton, "Offset (pt):"); + h.get_by_role_and_label(Role::SpinButton, "Size (pt):"); h.event(egui::Event::Text("ab".into())); h.run_steps(2); match h.state().dialog.as_ref() { @@ -4784,6 +4818,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(); @@ -5153,7 +5189,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:?}"); @@ -5204,7 +5240,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; @@ -5532,7 +5568,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 89df79f9e..a0f03a529 100644 --- a/crates/ui-egui/src/dialogs_chart.rs +++ b/crates/ui-egui/src/dialogs_chart.rs @@ -288,7 +288,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()); } } @@ -337,6 +337,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::*; @@ -358,6 +365,30 @@ mod tests { 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() { + /// 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), + 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:?})"); + } + /// Default names are saved into the document, so an RTL interface must not add isolate marks. #[test] fn default_names_in_an_rtl_interface_have_no_isolates() { diff --git a/crates/ui-egui/src/dialogs_keyboard.rs b/crates/ui-egui/src/dialogs_keyboard.rs index 748fac2e8..f2578fae5 100644 --- a/crates/ui-egui/src/dialogs_keyboard.rs +++ b/crates/ui-egui/src/dialogs_keyboard.rs @@ -140,7 +140,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)); @@ -467,6 +469,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 ac6b8d0f3..ab3db4657 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 03575bcf6..34654ea8f 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 0dfccb4e3..73912c936 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", "________")]; @@ -311,25 +312,28 @@ 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 { - 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")); @@ -418,17 +422,20 @@ 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| { - 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 { @@ -459,28 +466,31 @@ 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| { - 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(); }); }); @@ -496,9 +506,11 @@ 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 { - 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(); } @@ -562,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)> = { @@ -587,16 +601,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(""); @@ -661,33 +675,35 @@ 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| { - 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"); @@ -811,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| { @@ -824,17 +842,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; @@ -856,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())])); @@ -867,8 +887,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(); } }); @@ -958,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() @@ -969,23 +992,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); }); }); }); @@ -1140,26 +1164,30 @@ 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| { - 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 da98f115a..e14d29f09 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -425,6 +425,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")); @@ -444,11 +446,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). @@ -532,11 +534,12 @@ 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| { - 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 977c184be..6032d2632 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,45 @@ 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, +} + +/// 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; + }; + 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 +355,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 +365,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 bcf359f7e..b7bbc1f8f 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 43ca8ab8a..38461322f 100644 --- a/crates/ui-egui/src/equation_tab.rs +++ b/crates/ui-egui/src/equation_tab.rs @@ -40,11 +40,59 @@ 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); } } } +/// 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() || covered(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 +} + +/// 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); @@ -154,8 +202,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 +226,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(); @@ -247,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); @@ -256,14 +306,16 @@ 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() } /// 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; @@ -378,3 +430,27 @@ 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(); + 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(); + } + + #[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/file_browser.rs b/crates/ui-egui/src/file_browser.rs index 843ed0571..4f274e8e7 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -310,7 +310,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)); @@ -348,7 +348,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(); @@ -440,11 +440,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 685008d57..9fa67a125 100644 --- a/crates/ui-egui/src/format_shape.rs +++ b/crates/ui-egui/src/format_shape.rs @@ -16,12 +16,14 @@ 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::new("format_shape.drag_mark") + Id::unique("format_shape.drag_mark") } +/// Stable storage id for the gradient stop being edited. 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 @@ -35,8 +37,9 @@ 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::new("format_shape.variation") + Id::unique("format_shape.variation") } /// The colour Shape Fill › Gradient builds from for the object at `pos` filled with `fill`: @@ -126,9 +129,11 @@ 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::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 +145,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 @@ -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")); @@ -289,7 +297,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 +368,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 +444,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 95239eff7..4b0eb0c68 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(); @@ -167,13 +191,18 @@ 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() || 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 @@ -183,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. @@ -191,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/ibeam.rs b/crates/ui-egui/src/ibeam.rs index 2678962bc..ccb811c80 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/icons.rs b/crates/ui-egui/src/icons.rs index 2b9326c25..29bb93e12 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/ink.rs b/crates/ui-egui/src/ink.rs index 2eebb6bf7..dc25e145f 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 abf951390..a3bfd583b 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,288 @@ 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, + reset_request: None, + 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, +} + +/// 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()) { + (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() + } + } +} + +/// 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 }; + } + 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 +1105,338 @@ 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() { - return; + 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 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 { - return; + let start = r.content_start().off; + if start > deleted_start { + return None; } - 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(); - } - if app.run("text.backspace", json!({})).is_ok() { - app.canvas.ime_join_text = Some(props); - } else { - // Refused (a protected document): leave the caret where it was. - app.session.sel = wordcraft_engine::Selection::caret(caret); + 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) +} + +/// 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 { + return; + } + // 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 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; + } + 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 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 + // placeholder. + let mut shift = 0i64; + let mut deleted = false; + let mut first_placeholder_end = None; + if let Some(range) = del.before.clone() { + let Some((delta, restored)) = ime_delete_side(app, &mut rollback, &cursor, range.start, range.end, join) else { + return; + }; + deleted = true; + first_placeholder_end = restored; + shift = delta; + } + let mut second_placeholder_end = None; + if let Some(range) = del.after { + let Some(start) = shift_offset(range.start, shift) else { + rollback.apply(app); + return; + }; + let Some(end) = shift_offset(range.end, shift) else { + rollback.apply(app); + 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.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 Some((_, restored)) = ime_delete_side(app, &mut rollback, &cursor, start, end, deleted || join) else { + return; + }; + deleted = true; + second_placeholder_end = restored; + } + 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.apply(app); + 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 +1492,60 @@ 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 } + } + + /// 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 +1936,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 +2042,97 @@ 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 }) + } + + /// 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 +2240,462 @@ 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"); + } + + /// 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 + .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 a6b88c8fe..a874e2156 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -9,16 +9,19 @@ 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") } +/// Stable id for the keytips' selected-tab state. pub fn tab_id() -> egui::Id { - egui::Id::new("wc_keytips_tab") + egui::Id::unique("wc_keytips_tab") } +/// Stable id for the keytips' letter badges. pub fn letters_id() -> egui::Id { - egui::Id::new("wc_keytips_letters") + egui::Id::unique("wc_keytips_letters") } +/// Stable id for the keytips' badge rectangles. 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 +866,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 ccddf1d23..f546dead9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -569,10 +569,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`). @@ -647,6 +658,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, @@ -697,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() { @@ -719,10 +736,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, @@ -761,6 +781,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, @@ -901,6 +922,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`). @@ -1082,8 +1114,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. @@ -2204,17 +2263,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 @@ -2271,6 +2319,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); @@ -2302,45 +2359,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) { @@ -2424,13 +2496,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() { @@ -2450,7 +2535,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. @@ -2463,6 +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)); } + 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; @@ -2479,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() { @@ -2504,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 { @@ -2529,12 +2661,32 @@ 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, 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); + } + // 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 /// the window, and follow the window when the system takes it in or out of full screen (the /// macOS title bar button, the window manager): in is Full Screen, out ends all three. Leaving @@ -2582,7 +2734,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; @@ -2659,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() { @@ -2667,65 +2821,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 @@ -2956,19 +3056,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 @@ -3425,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; @@ -3437,9 +3534,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()); @@ -3851,7 +3951,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:?}"); @@ -3884,7 +3984,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"); @@ -3925,6 +4025,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 { @@ -3970,6 +4071,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 { @@ -4072,7 +4174,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"); @@ -4284,6 +4392,7 @@ mod tests { delta: egui::vec2(0.0, -65.0), phase: egui::TouchPhase::Start, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }], screen, ); @@ -4430,6 +4539,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; @@ -4485,6 +4595,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(); @@ -4550,6 +4661,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)]); @@ -4675,6 +4787,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 { @@ -4974,6 +5087,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 { @@ -5009,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, @@ -5016,7 +5131,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); @@ -5064,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(); @@ -5076,7 +5194,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); } @@ -5102,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(); @@ -5124,7 +5244,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); } @@ -5171,6 +5291,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 { @@ -5266,7 +5387,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); @@ -6230,7 +6351,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(); @@ -6291,6 +6412,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); @@ -8484,7 +8606,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); @@ -9105,8 +9227,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"); @@ -9176,7 +9299,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(); @@ -9228,6 +9351,530 @@ 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() }; + 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 + } + + /// 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); + let mut output = ctx.run_ui(raw, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }); + output.textures_delta.clear(); + 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) { + 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 + } + + /// 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(); + let ctx = egui::Context::default(); + for _ in 0..4 { + shot_frame(&mut app, &ctx, size); + } + (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; + 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 { + /// 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()) + } + } + + /// 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); + } + + /// 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)); + 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 7e4d15657..97a10c24d 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); @@ -269,7 +269,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})); @@ -548,7 +548,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) { @@ -800,7 +800,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); @@ -896,7 +902,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); }); }, @@ -915,10 +921,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); }); @@ -963,7 +969,7 @@ mod tests { fonts = true; return; } - egui::Area::new(egui::Id::new("mini_align_probe")).fixed_pos(pos2(10.0, 10.0)).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("mini_align_probe")).fixed_pos(pos2(10.0, 10.0)).show(ui.ctx(), |ui| { let t = Tokens::get(ui.ctx()); content(app, ui, &t); }); diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 42ec30346..f9661b372 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() { @@ -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); @@ -236,20 +238,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})); @@ -321,11 +324,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) { @@ -345,7 +350,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 { @@ -415,10 +420,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,6 +488,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"); @@ -489,8 +497,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})); } @@ -501,6 +510,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(); @@ -518,19 +529,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"})); } @@ -540,16 +551,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"})); } @@ -559,6 +571,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; @@ -603,13 +616,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) { @@ -638,14 +651,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); }); } @@ -658,8 +672,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"})); } @@ -669,6 +683,7 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) { } } }); + combo.response.labelled_by(label.id); }); ui.label( RichText::new(tl!( @@ -684,14 +699,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 { @@ -713,8 +729,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})); } @@ -724,6 +740,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()); } @@ -822,13 +839,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 })); } @@ -1161,7 +1179,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 8cd3f8659..e4b34a878 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -117,12 +117,13 @@ 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})); 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); @@ -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})); @@ -461,7 +464,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() @@ -472,7 +475,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() { @@ -483,7 +486,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)); } @@ -575,7 +578,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 ba9d8a529..b8e73a012 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)); } } } @@ -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 @@ -791,9 +793,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 a963bc57e..fa8b7c2a7 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 22f774cfb..590f3909e 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"), @@ -32,7 +34,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 0d3d0fb25..8a8fafa95 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 516d5c70f..f0feb3204 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 { @@ -366,7 +368,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"})); } @@ -415,7 +417,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); } @@ -478,7 +480,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 { @@ -508,11 +510,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 { @@ -549,12 +559,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 { @@ -570,9 +583,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(); } }); } @@ -617,7 +629,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); @@ -707,7 +719,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); @@ -1019,9 +1031,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() { @@ -2114,9 +2131,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())); @@ -2264,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| { @@ -2341,7 +2360,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); @@ -2745,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| { @@ -2783,7 +2804,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() { @@ -2858,7 +2879,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(); @@ -2897,7 +2918,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() { @@ -3209,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 563393584..40a25d783 100644 --- a/crates/ui-egui/src/ribbon_fit.rs +++ b/crates/ui-egui/src/ribbon_fit.rs @@ -92,20 +92,25 @@ struct Measured { order: HashMap>, } +/// Stable storage id for the ribbon fit plan of the current frame. fn frame_id() -> Id { - Id::new("ribbon_fit_frame") + Id::unique("ribbon_fit_frame") } +/// Stable storage id for widths measured from earlier ribbon frames. fn measured_id() -> Id { - Id::new("ribbon_fit_measured") + Id::unique("ribbon_fit_measured") } +/// Stable storage id for the ribbon's compact-layout choices. fn compact_id() -> Id { - Id::new("ribbon_fit_compact") + Id::unique("ribbon_fit_compact") } +/// Stable storage id for the points a group would save by drawing compact. fn saving_id() -> Id { - Id::new("ribbon_fit_saving") + Id::unique("ribbon_fit_saving") } +/// Stable storage id for whether this frame's ribbon fit plan is active. 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 9b68e9031..9cd8c5171 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 c8d6ccb86..84e828009 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); @@ -414,8 +416,10 @@ 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 } + 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 +436,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 63bad91d3..39f48876c 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 45126f649..aa95e0ac1 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 { @@ -41,6 +42,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 abd8af357..b3495822d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -920,6 +920,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 b56a13852..3a068c834 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 } @@ -332,10 +332,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; } @@ -401,7 +401,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; } @@ -476,7 +476,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); } @@ -489,7 +489,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 @@ -509,7 +509,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); @@ -522,7 +522,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; @@ -748,7 +748,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 @@ -765,7 +765,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). @@ -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, @@ -841,9 +844,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; @@ -877,7 +880,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(); @@ -933,7 +936,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. @@ -1157,7 +1160,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 @@ -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); @@ -1255,7 +1260,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); @@ -1288,7 +1293,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) }; @@ -1321,8 +1326,8 @@ pub fn dialog_tabs(ui: &mut Ui, labels: &[String], selected: usize) -> Option 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 e17769d64..1b456beb0 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,18 +211,22 @@ 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() } + /// 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() } } fn saved() -> WindowGeometry { 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)); - 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))); @@ -200,12 +251,15 @@ 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)); - 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 +269,73 @@ 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())); } + /// 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_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]))); + } + + /// 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)); + 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() }; @@ -244,36 +350,40 @@ 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)); // 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); } + /// 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)); // 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 +400,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), @@ -321,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(); @@ -363,6 +476,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 e5c4ef32b..88e69b1e8 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 585131e51..6ff294c2b 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; @@ -42,7 +45,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"] { @@ -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()); @@ -69,7 +74,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"); @@ -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}; @@ -98,7 +105,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(|_, _, _, _| { @@ -124,10 +131,11 @@ 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()); - 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"}), @@ -147,10 +155,11 @@ 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()); - 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(); @@ -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 @@ -184,7 +195,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(); @@ -210,11 +221,13 @@ 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()); 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 +264,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 000000000..014677d5b --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1426 @@ +# WordCraft: egui 0.37 migration plan + +**Status:** current-main reconciliation through `b4da59f0c38b506c36629b1d812ad7c0519fc238` passed all eight mandatory CI stages on 2026-10-25: 4,270 passing test executions, zero failures and 4 ignored (`egui037-wordcraft-main-refresh-ci-20261025-0603`). This includes main's encrypted-DOCX integrity requirement and selected-paragraph-mark highlight behavior. The dependency pins and lockfile are unchanged. A fresh review verified that the merge preserves the IME documentation and upstream fixes. Earlier counts and source snapshots below are historical. + +Read-only comment balloons now consume the explicit `AlignedGalley` result from craft-ui at +`25da1314d443171b4adeaad2cd9454b273f35c6f`: height comes from the requested reading area, +and painting retains the existing reading origin and card clip while preserving overwide +right-to-left rows through an explicit offset. Added geometry/ink and actual card/body/reply +regressions use bundled Latin fonts and require exact embedded Arabic/Hebrew faces in the +canonical `CRAFT_FONTS_REQUIRED=1` lane. Optional-input builds still exercise Latin geometry. +The earlier 616-file migration/current-main PDF overlay and its CI run remain historical +baseline evidence. The current full CI qualifies the API/dependency followup, preview fix, +phonetic labels and preserved notice/packaging source repair. +Native GUI/IME/capture/save-reopen traces, package notice verification, paired performance, +platform/installer execution and app publication remain separate gates. This records local +work, not publication or complete platform acceptance. Historical notes below remain history. +**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` `b076417b5880bd4154bd22f00695b1d28c6bea83` (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 +`6d518fc799681571963842f95b55a6b68431b15a`, winit at +`a517b3846ee8457d84ee4975110bfda6797c44dc`, and craft-ui at +`25da1314d443171b4adeaad2cd9454b273f35c6f`. The regenerated lock records these exact +revisions and passed the dependency-source audit. The current-main shared PDF, printing and +locale update uses craft-pdf, krilla, craft-print and craft-i18n at +`17744430be327d0257496a6a31c8d38fe20a6756`. These are immutable canonical Git URL pins. +The predecessor passed full CI; the latest main integration is undergoing its own full CI. +The translation catalog is pinned +separately to the reviewed `9205108cfdced5177706264038848b68b2422d2b`; it is not a Cargo dependency. +Publication observations and +authority remain separate from this local status. 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 `b076417b5880bd4154bd22f00695b1d28c6bea83` 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. + + +### Bundled-font notice completion — pending package verification + +Canonical egui0.37 default_fonts embeds `EGUI_ICONS` (the small emoji-icon-font subset), and +monochrome_emoji_fonts embeds its parent emoji-icon-font face. WordCraft starts from +`FontDefinitions::default()` in `theme.rs`. NOTICE now carries the exact upstream MIT text, +including Copyright (c) 2014 John Slegers, and ATTRIBUTION identifies both dependency assets. +The PDF notice names the actual pinned 17744430 revision. Shared Linux/web/CLI staging, macOS app +resources, Windows portable ZIP and unconditional MSI notice components now include NOTICE +and ATTRIBUTION independently of optional craft-fonts. The existing optional OFL rules remain. +This documentation/packaging follow-up changes no runtime/font/manifests/lock bytes; its asset, +staging and fresh review gates are pending. Final packages and platform execution remain +unqualified. This scoped repair is separate from the six new craft-fonts faces and does not +claim a complete historical bundled-font audit. Exact upstream license bytes are preserved in +the local commit evidence. diff --git a/docs/gaps.md b/docs/gaps.md index c5cd0341f..e1af69a46 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 (#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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; 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). @@ -14,6 +14,8 @@ The work list. One entry per shortfall, ranked by how much it stops a Word user wall-clock estimate, and which parity document tracks it. Agents: pick from the top, check open PRs first (`gh pr list`), and update this file and the parity doc when a gap closes. +egui 0.37 migration record: the interface catalogs remain the shared storytold/craft-i18n ones embedded from `craft-i18n.rev` (`9205108cfdced5177706264038848b68b2422d2b`; 27 WordCraft languages, English fallback), and the equation “Delete Around Caret in Equation” translation is preserved. The input-method path handles character- and byte-counted surrounding deletions, starts a new composition after a body or equation replacement, rejects queued stale replacements before mutation, keeps equation deletion transactional in one undo group, and maps tracked and placeholder length changes. The new regressions are documented, but managed CI passed; native IME acceptance (#155–#164), browser IME behavior and native-speaker catalog review remain open. No ranked gap or readiness estimate changes. + Native save reliability: synthetic cases on macOS reproduced loss through the predictable temporary-file name. Saves now use owned, exclusively created siblings, synchronize before replacement and clean up on failure; originals survive pre-replacement errors. This narrows save-path reliability under gap #3; the listed field failures and broader stability work remain open. See [save-path guarantees](file-format-parity.md#native-save-path-guarantees). Row table border exceptions (`w:tblPrEx/w:tblBorders`) are read, drawn and saved. Other row table exceptions, including widths and cell margins, remain unsupported. @@ -143,6 +145,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | egui 0.37 migration: interface catalogs remain pinned by `craft-i18n.rev` (`9205108…`; 27 languages, English fallback); IME surrounding deletions and composition resets are implemented with regressions, but managed CI passed; native IME acceptance and native-speaker catalog review remain open; no gap or readiness change | | 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). | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index e2eb61634..0251aba93 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 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 +> **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: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; 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, @@ -243,6 +243,16 @@ 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 > 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`). @@ -256,10 +266,11 @@ 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 | 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-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 | | 2026-10-24 | trivial | Floating tables count exact line-bottom contact; shapes keep strict overlap (#3977); original boundary/skip regressions in modes 11/12/14/15, expected Word rule from cyril265; readiness estimates unchanged | +| 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-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 f3168c184..a4cdc48e4 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 (Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; 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 @@ -223,6 +223,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Preserved “Delete Around Caret in Equation” in all 27 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/roadmap.md b/docs/roadmap.md index b622b4764..586736e25 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # WordCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are @@ -62,7 +62,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT, LaTeX, `.doc` import) | done (first versions); `.doc` write, encryption, minor formats missing | 60–100 h | | M13 | Performance budgets | on track (1.4 ms relayout); large real-world documents unmeasured | 10–20 h | | M14 | 1.0 polish, packaging, signing | releases v0.1.0–v0.4.0 published (signed macOS, Windows MSI x64/x86/arm64, Linux, FreeBSD, web) | — | -| M15 | Localization (12 key languages, RTL interface, proofing languages) | 15 UI languages; English, Polish and Portuguese proofing plus Czech spelling and generic checks (system Hunspell; English hyphenation fallback), a proofing language per run | 70–115 h | +| M15 | Localization (12 key languages, RTL interface, proofing languages) | 27 UI languages from the shared catalogs pinned by `craft-i18n.rev` (`9205108c…`); proofing: English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish, plus Bulgarian and German hyphenation only; native-speaker review and native IME acceptance (#155–#164) remain open | 70–115 h | | M16 | Right-to-left and East Asian typography | RTL paragraphs done (#207) | 30–45 h | ## Next after beta @@ -74,6 +74,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…); input-method surrounding-delete and commit handling has synthetic regressions only, with managed CI passing; native acceptance (#155–#164) and native-speaker review remain open | | 2026-10-18 | trivial | M6: Manage Sources imports Word's `Sources.xml` (#2640) | | 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/docs/shared-pdf.md b/docs/shared-pdf.md index e8b6acb46..99b36bb16 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 -`9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef`. Rust 1.95 or newer is required. The revision must be +`17744430be327d0257496a6a31c8d38fe20a6756`. 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. diff --git a/docs/typing-parity.md b/docs/typing-parity.md index c502dd6fb..f98a1897b 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-25 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; 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 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-25 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: 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: AutoCorrect finds the word after a no-break space or an ideographic space; 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 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 @@ -370,8 +370,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, @@ -394,6 +394,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 @@ -424,11 +492,13 @@ again. | Date | Change | Summary | |---|---|---| -| 2026-10-24 | trivial | AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space | | 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). | +| 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-24 | trivial | AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space | | 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 | AutoFormat As You Type: `*Bold* and _italic_ as real formatting` turns `*word*` bold and `_word_` italic without the marks; off by default | +| 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/env.sh b/packaging/env.sh index 2701c7cbd..e7cfa8e8c 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -49,7 +49,7 @@ warn() { # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f - for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do + for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE ATTRIBUTION.md; do if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" diff --git a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml index c1f9a0be1..424ccc0f0 100644 --- a/packaging/linux/flatpak/ai.storyteller.wordcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.wordcraft.yml @@ -49,6 +49,7 @@ modules: build-commands: - cargo --offline build --release --locked -p wordcraft -p wordcraft-cli - install -Dm755 target/release/wordcraft /app/bin/wordcraft + - install -Dm644 NOTICE /app/share/doc/wordcraft/NOTICE - install -Dm755 target/release/wordcraft-cli /app/bin/wordcraft-cli - install -Dm644 packaging/linux/ai.storyteller.wordcraft.desktop /app/share/applications/ai.storyteller.wordcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.wordcraft.mime.xml /app/share/mime/packages/ai.storyteller.wordcraft.xml @@ -70,6 +71,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: 9205108cfdced5177706264038848b68b2422d2b dest: craft-i18n - cargo-sources.json diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index ee0e4c041..e12e837a0 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -131,7 +131,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/macos/package.sh b/packaging/macos/package.sh index 6e603bfec..8da33a7d9 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -104,7 +104,7 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$WORK/bin/wordcraft" "$APP/Contents/MacOS/WordCraft" cp "$ROOT/assets/app-icon/wordcraft.icns" "$APP/Contents/Resources/WordCraft.icns" # Licences of the embedded craft-fonts fonts (only when built with CRAFT_FONTS_DIR). -copy_font_licences "$APP/Contents/Resources" +copy_docs "$APP/Contents/Resources" sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ -e "s/@BUILD_SHA@/${WORDCRAFT_BUILD_SHA:-unknown}/g" \ "$HERE/Info.plist.in" >"$APP/Contents/Info.plist" diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index c791ffbd1..522352759 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -93,6 +93,24 @@ 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') +# Required notices ship in every MSI, independently of optional craft-fonts. +$AppNotices = Join-Path $Stage 'app-notices' +New-Item -ItemType Directory -Force -Path $AppNotices | Out-Null +Copy-Item -LiteralPath (Join-Path $Root 'NOTICE'), (Join-Path $Root 'ATTRIBUTION.md') -Destination $AppNotices + +# ---- 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 +148,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 "AppNoticesDir=$AppNotices" -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. @@ -141,7 +160,7 @@ $Portable = Join-Path $TargetDir "windows-package\wordcraft-$Version-windows-$Ar Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable -foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { +foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } } diff --git a/packaging/windows/wordcraft.wxs b/packaging/windows/wordcraft.wxs index c0fa1f0aa..27aa89ca2 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. --> + + + + + + + + + + + + + + +