| author | |
| committer | |
| log | 1c1d0b1a96f8a3a9a9d12d82a7f8089296a13163 |
| tree | 3ebbc99e43c0cd81a719017faaae91475d8e87e6 |
| parent | e00506af62d3a184a53b6dec1b40ac6bd6372c3c |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
49 files changed, 18683 insertions(+), 379 deletions(-)
Cargo.lock+3222-351| ... | ... | @@ -2,6 +2,103 @@ |
| 2 | 2 | # It is not intended for manual editing. |
| 3 | 3 | version = 4 |
| 4 | 4 | |
| 5 | [[package]] | |
| 6 | name = "ab_glyph" | |
| 7 | version = "0.2.32" | |
| 8 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" | |
| 10 | dependencies = [ | |
| 11 | "ab_glyph_rasterizer", | |
| 12 | "owned_ttf_parser", | |
| 13 | ] | |
| 14 | ||
| 15 | [[package]] | |
| 16 | name = "ab_glyph_rasterizer" | |
| 17 | version = "0.1.10" | |
| 18 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 19 | checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" | |
| 20 | ||
| 21 | [[package]] | |
| 22 | name = "accesskit" | |
| 23 | version = "0.25.0" | |
| 24 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 25 | checksum = "438a7081a65b95c668db56591a4fef9bc5dad275f448bd6223fc6949d823db38" | |
| 26 | dependencies = [ | |
| 27 | "uuid", | |
| 28 | ] | |
| 29 | ||
| 30 | [[package]] | |
| 31 | name = "accesskit_consumer" | |
| 32 | version = "0.39.0" | |
| 33 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 34 | checksum = "cc882fa0c9c24c53649e256311b51046cf36eca9a8f7e54ff4ef682160b0cb27" | |
| 35 | dependencies = [ | |
| 36 | "accesskit", | |
| 37 | "hashbrown 0.17.1", | |
| 38 | ] | |
| 39 | ||
| 40 | [[package]] | |
| 41 | name = "accesskit_ios" | |
| 42 | version = "0.2.0" | |
| 43 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 44 | checksum = "d5e9b29c6ba5f4d2e0662e9c562484f061ffc58f658479c538ecca8299ada1e9" | |
| 45 | dependencies = [ | |
| 46 | "accesskit", | |
| 47 | "accesskit_consumer", | |
| 48 | "hashbrown 0.17.1", | |
| 49 | "objc2 0.5.2", | |
| 50 | "objc2-foundation 0.2.2", | |
| 51 | "objc2-ui-kit", | |
| 52 | ] | |
| 53 | ||
| 54 | [[package]] | |
| 55 | name = "accesskit_macos" | |
| 56 | version = "0.27.0" | |
| 57 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 58 | checksum = "a3f278988416e5fb600f24d0cfffe31a249ebbff980fb9c5a3b5fbed2c579f73" | |
| 59 | dependencies = [ | |
| 60 | "accesskit", | |
| 61 | "accesskit_consumer", | |
| 62 | "hashbrown 0.17.1", | |
| 63 | "objc2 0.5.2", | |
| 64 | "objc2-app-kit 0.2.2", | |
| 65 | "objc2-foundation 0.2.2", | |
| 66 | ] | |
| 67 | ||
| 68 | [[package]] | |
| 69 | name = "accesskit_windows" | |
| 70 | version = "0.35.0" | |
| 71 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 72 | checksum = "a59e8d7160cbac991c1345c99153783ab96423644ccb1f20617cf80c97596aa1" | |
| 73 | dependencies = [ | |
| 74 | "accesskit", | |
| 75 | "accesskit_consumer", | |
| 76 | "hashbrown 0.17.1", | |
| 77 | "static_assertions", | |
| 78 | "windows", | |
| 79 | "windows-core", | |
| 80 | ] | |
| 81 | ||
| 82 | [[package]] | |
| 83 | name = "accesskit_winit" | |
| 84 | version = "0.34.0" | |
| 85 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 86 | checksum = "1eb960a51582305f94b3884a7ff54b3462abe38f0de95a9e7e04dd7ab7b757da" | |
| 87 | dependencies = [ | |
| 88 | "accesskit", | |
| 89 | "accesskit_ios", | |
| 90 | "accesskit_macos", | |
| 91 | "accesskit_windows", | |
| 92 | "raw-window-handle", | |
| 93 | "winit", | |
| 94 | ] | |
| 95 | ||
| 96 | [[package]] | |
| 97 | name = "adler2" | |
| 98 | version = "2.0.1" | |
| 99 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 100 | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 101 | ||
| 5 | 102 | [[package]] |
| 6 | 103 | name = "aead" |
| 7 | 104 | version = "0.6.1" |
| ... | ... | @@ -38,6 +135,103 @@ dependencies = [ |
| 38 | 135 | "ghash", |
| 39 | 136 | ] |
| 40 | 137 | |
| 138 | [[package]] | |
| 139 | name = "ahash" | |
| 140 | version = "0.8.12" | |
| 141 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 142 | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 143 | dependencies = [ | |
| 144 | "cfg-if", | |
| 145 | "getrandom 0.3.4", | |
| 146 | "once_cell", | |
| 147 | "version_check", | |
| 148 | "zerocopy", | |
| 149 | ] | |
| 150 | ||
| 151 | [[package]] | |
| 152 | name = "allocator-api2" | |
| 153 | version = "0.2.21" | |
| 154 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 155 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 156 | ||
| 157 | [[package]] | |
| 158 | name = "android-activity" | |
| 159 | version = "0.6.1" | |
| 160 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 161 | checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" | |
| 162 | dependencies = [ | |
| 163 | "android-properties", | |
| 164 | "bitflags 2.13.1", | |
| 165 | "cc", | |
| 166 | "jni", | |
| 167 | "libc", | |
| 168 | "log", | |
| 169 | "ndk", | |
| 170 | "ndk-context", | |
| 171 | "ndk-sys", | |
| 172 | "num_enum", | |
| 173 | "thiserror 2.0.20", | |
| 174 | ] | |
| 175 | ||
| 176 | [[package]] | |
| 177 | name = "android-properties" | |
| 178 | version = "0.2.2" | |
| 179 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 180 | checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" | |
| 181 | ||
| 182 | [[package]] | |
| 183 | name = "android_system_properties" | |
| 184 | version = "0.1.6" | |
| 185 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 186 | checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" | |
| 187 | dependencies = [ | |
| 188 | "libc", | |
| 189 | ] | |
| 190 | ||
| 191 | [[package]] | |
| 192 | name = "arboard" | |
| 193 | version = "3.6.1" | |
| 194 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 195 | checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" | |
| 196 | dependencies = [ | |
| 197 | "clipboard-win", | |
| 198 | "log", | |
| 199 | "objc2 0.6.4", | |
| 200 | "objc2-app-kit 0.3.2", | |
| 201 | "objc2-foundation 0.3.2", | |
| 202 | "parking_lot", | |
| 203 | "percent-encoding", | |
| 204 | "windows-sys 0.60.2", | |
| 205 | "x11rb", | |
| 206 | ] | |
| 207 | ||
| 208 | [[package]] | |
| 209 | name = "arrayref" | |
| 210 | version = "0.3.9" | |
| 211 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 212 | checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" | |
| 213 | ||
| 214 | [[package]] | |
| 215 | name = "arrayvec" | |
| 216 | version = "0.7.8" | |
| 217 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 218 | checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" | |
| 219 | ||
| 220 | [[package]] | |
| 221 | name = "as-raw-xcb-connection" | |
| 222 | version = "1.0.1" | |
| 223 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 224 | checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" | |
| 225 | ||
| 226 | [[package]] | |
| 227 | name = "ash" | |
| 228 | version = "0.38.0+1.3.281" | |
| 229 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 230 | checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" | |
| 231 | dependencies = [ | |
| 232 | "libloading", | |
| 233 | ] | |
| 234 | ||
| 41 | 235 | [[package]] |
| 42 | 236 | name = "async-trait" |
| 43 | 237 | version = "0.1.92" |
| ... | ... | @@ -49,12 +243,45 @@ dependencies = [ |
| 49 | 243 | "syn 3.0.5", |
| 50 | 244 | ] |
| 51 | 245 | |
| 246 | [[package]] | |
| 247 | name = "atomic-waker" | |
| 248 | version = "1.1.2" | |
| 249 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 250 | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 251 | ||
| 252 | [[package]] | |
| 253 | name = "autocfg" | |
| 254 | version = "1.5.1" | |
| 255 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 256 | checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 257 | ||
| 52 | 258 | [[package]] |
| 53 | 259 | name = "base64" |
| 54 | 260 | version = "0.22.1" |
| 55 | 261 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 56 | 262 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" |
| 57 | 263 | |
| 264 | [[package]] | |
| 265 | name = "bit-set" | |
| 266 | version = "0.10.0" | |
| 267 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 268 | checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" | |
| 269 | dependencies = [ | |
| 270 | "bit-vec", | |
| 271 | ] | |
| 272 | ||
| 273 | [[package]] | |
| 274 | name = "bit-vec" | |
| 275 | version = "0.9.1" | |
| 276 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 277 | checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" | |
| 278 | ||
| 279 | [[package]] | |
| 280 | name = "bitflags" | |
| 281 | version = "1.3.2" | |
| 282 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 283 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" | |
| 284 | ||
| 58 | 285 | [[package]] |
| 59 | 286 | name = "bitflags" |
| 60 | 287 | version = "2.13.1" |
| ... | ... | @@ -80,18 +307,88 @@ dependencies = [ |
| 80 | 307 | "hybrid-array", |
| 81 | 308 | ] |
| 82 | 309 | |
| 310 | [[package]] | |
| 311 | name = "block2" | |
| 312 | version = "0.5.1" | |
| 313 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 314 | checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" | |
| 315 | dependencies = [ | |
| 316 | "objc2 0.5.2", | |
| 317 | ] | |
| 318 | ||
| 319 | [[package]] | |
| 320 | name = "block2" | |
| 321 | version = "0.6.2" | |
| 322 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 323 | checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" | |
| 324 | dependencies = [ | |
| 325 | "objc2 0.6.4", | |
| 326 | ] | |
| 327 | ||
| 83 | 328 | [[package]] |
| 84 | 329 | name = "bumpalo" |
| 85 | 330 | version = "3.20.3" |
| 86 | 331 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 87 | 332 | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" |
| 88 | 333 | |
| 334 | [[package]] | |
| 335 | name = "bytemuck" | |
| 336 | version = "1.25.2" | |
| 337 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 338 | checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" | |
| 339 | dependencies = [ | |
| 340 | "bytemuck_derive", | |
| 341 | ] | |
| 342 | ||
| 343 | [[package]] | |
| 344 | name = "bytemuck_derive" | |
| 345 | version = "1.12.0" | |
| 346 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 347 | checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" | |
| 348 | dependencies = [ | |
| 349 | "proc-macro2", | |
| 350 | "quote", | |
| 351 | "syn 3.0.5", | |
| 352 | ] | |
| 353 | ||
| 354 | [[package]] | |
| 355 | name = "byteorder-lite" | |
| 356 | version = "0.1.0" | |
| 357 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 358 | checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" | |
| 359 | ||
| 89 | 360 | [[package]] |
| 90 | 361 | name = "bytes" |
| 91 | 362 | version = "1.12.1" |
| 92 | 363 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 93 | 364 | checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" |
| 94 | 365 | |
| 366 | [[package]] | |
| 367 | name = "calloop" | |
| 368 | version = "0.13.0" | |
| 369 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 370 | checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" | |
| 371 | dependencies = [ | |
| 372 | "bitflags 2.13.1", | |
| 373 | "log", | |
| 374 | "polling", | |
| 375 | "rustix 0.38.44", | |
| 376 | "slab", | |
| 377 | "thiserror 1.0.69", | |
| 378 | ] | |
| 379 | ||
| 380 | [[package]] | |
| 381 | name = "calloop-wayland-source" | |
| 382 | version = "0.3.0" | |
| 383 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 384 | checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" | |
| 385 | dependencies = [ | |
| 386 | "calloop", | |
| 387 | "rustix 0.38.44", | |
| 388 | "wayland-backend", | |
| 389 | "wayland-client", | |
| 390 | ] | |
| 391 | ||
| 95 | 392 | [[package]] |
| 96 | 393 | name = "cbc" |
| 97 | 394 | version = "0.2.1" |
| ... | ... | @@ -108,6 +405,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 108 | 405 | checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" |
| 109 | 406 | dependencies = [ |
| 110 | 407 | "find-msvc-tools", |
| 408 | "jobserver", | |
| 409 | "libc", | |
| 111 | 410 | "shlex", |
| 112 | 411 | ] |
| 113 | 412 | |
| ... | ... | @@ -147,6 +446,15 @@ dependencies = [ |
| 147 | 446 | "zeroize", |
| 148 | 447 | ] |
| 149 | 448 | |
| 449 | [[package]] | |
| 450 | name = "clipboard-win" | |
| 451 | version = "5.4.1" | |
| 452 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 453 | checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" | |
| 454 | dependencies = [ | |
| 455 | "error-code", | |
| 456 | ] | |
| 457 | ||
| 150 | 458 | [[package]] |
| 151 | 459 | name = "cmac" |
| 152 | 460 | version = "0.8.0" |
| ... | ... | @@ -164,12 +472,82 @@ version = "0.5.4" |
| 164 | 472 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 165 | 473 | checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" |
| 166 | 474 | |
| 475 | [[package]] | |
| 476 | name = "codespan-reporting" | |
| 477 | version = "0.13.1" | |
| 478 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 479 | checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" | |
| 480 | dependencies = [ | |
| 481 | "serde", | |
| 482 | "termcolor", | |
| 483 | "unicode-width", | |
| 484 | ] | |
| 485 | ||
| 486 | [[package]] | |
| 487 | name = "combine" | |
| 488 | version = "4.6.8" | |
| 489 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 490 | checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" | |
| 491 | dependencies = [ | |
| 492 | "bytes", | |
| 493 | "memchr", | |
| 494 | ] | |
| 495 | ||
| 496 | [[package]] | |
| 497 | name = "concurrent-queue" | |
| 498 | version = "2.5.0" | |
| 499 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 500 | checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | |
| 501 | dependencies = [ | |
| 502 | "crossbeam-utils", | |
| 503 | ] | |
| 504 | ||
| 167 | 505 | [[package]] |
| 168 | 506 | name = "const-oid" |
| 169 | 507 | version = "0.10.2" |
| 170 | 508 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 171 | 509 | checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" |
| 172 | 510 | |
| 511 | [[package]] | |
| 512 | name = "core-foundation" | |
| 513 | version = "0.9.4" | |
| 514 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 515 | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" | |
| 516 | dependencies = [ | |
| 517 | "core-foundation-sys", | |
| 518 | "libc", | |
| 519 | ] | |
| 520 | ||
| 521 | [[package]] | |
| 522 | name = "core-foundation-sys" | |
| 523 | version = "0.8.7" | |
| 524 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 525 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 526 | ||
| 527 | [[package]] | |
| 528 | name = "core-graphics" | |
| 529 | version = "0.23.2" | |
| 530 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 531 | checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" | |
| 532 | dependencies = [ | |
| 533 | "bitflags 1.3.2", | |
| 534 | "core-foundation", | |
| 535 | "core-graphics-types", | |
| 536 | "foreign-types", | |
| 537 | "libc", | |
| 538 | ] | |
| 539 | ||
| 540 | [[package]] | |
| 541 | name = "core-graphics-types" | |
| 542 | version = "0.1.3" | |
| 543 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 544 | checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" | |
| 545 | dependencies = [ | |
| 546 | "bitflags 1.3.2", | |
| 547 | "core-foundation", | |
| 548 | "libc", | |
| 549 | ] | |
| 550 | ||
| 173 | 551 | [[package]] |
| 174 | 552 | name = "cpubits" |
| 175 | 553 | version = "0.1.1" |
| ... | ... | @@ -185,13 +563,34 @@ dependencies = [ |
| 185 | 563 | "libc", |
| 186 | 564 | ] |
| 187 | 565 | |
| 566 | [[package]] | |
| 567 | name = "crc32fast" | |
| 568 | version = "1.5.1" | |
| 569 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 570 | checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" | |
| 571 | dependencies = [ | |
| 572 | "cfg-if", | |
| 573 | ] | |
| 574 | ||
| 575 | [[package]] | |
| 576 | name = "crossbeam-utils" | |
| 577 | version = "0.8.23" | |
| 578 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 579 | checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" | |
| 580 | ||
| 581 | [[package]] | |
| 582 | name = "crunchy" | |
| 583 | version = "0.2.4" | |
| 584 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 585 | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | |
| 586 | ||
| 188 | 587 | [[package]] |
| 189 | 588 | name = "crypto-common" |
| 190 | 589 | version = "0.2.2" |
| 191 | 590 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 192 | 591 | checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" |
| 193 | 592 | dependencies = [ |
| 194 | "getrandom", | |
| 593 | "getrandom 0.4.3", | |
| 195 | 594 | "hybrid-array", |
| 196 | 595 | "rand_core", |
| 197 | 596 | ] |
| ... | ... | @@ -214,6 +613,12 @@ dependencies = [ |
| 214 | 613 | "cmov", |
| 215 | 614 | ] |
| 216 | 615 | |
| 616 | [[package]] | |
| 617 | name = "cursor-icon" | |
| 618 | version = "1.2.0" | |
| 619 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 620 | checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" | |
| 621 | ||
| 217 | 622 | [[package]] |
| 218 | 623 | name = "dbl" |
| 219 | 624 | version = "0.5.0" |
| ... | ... | @@ -236,6 +641,63 @@ dependencies = [ |
| 236 | 641 | "zeroize", |
| 237 | 642 | ] |
| 238 | 643 | |
| 644 | [[package]] | |
| 645 | name = "dispatch" | |
| 646 | version = "0.2.0" | |
| 647 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 648 | checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" | |
| 649 | ||
| 650 | [[package]] | |
| 651 | name = "dispatch2" | |
| 652 | version = "0.3.1" | |
| 653 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 654 | checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" | |
| 655 | dependencies = [ | |
| 656 | "bitflags 2.13.1", | |
| 657 | "objc2 0.6.4", | |
| 658 | ] | |
| 659 | ||
| 660 | [[package]] | |
| 661 | name = "displaydoc" | |
| 662 | version = "0.2.7" | |
| 663 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 664 | checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" | |
| 665 | dependencies = [ | |
| 666 | "proc-macro2", | |
| 667 | "quote", | |
| 668 | "syn 3.0.5", | |
| 669 | ] | |
| 670 | ||
| 671 | [[package]] | |
| 672 | name = "dlib" | |
| 673 | version = "0.5.3" | |
| 674 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 675 | checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" | |
| 676 | dependencies = [ | |
| 677 | "libloading", | |
| 678 | ] | |
| 679 | ||
| 680 | [[package]] | |
| 681 | name = "document-features" | |
| 682 | version = "0.2.12" | |
| 683 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 684 | checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" | |
| 685 | dependencies = [ | |
| 686 | "litrs", | |
| 687 | ] | |
| 688 | ||
| 689 | [[package]] | |
| 690 | name = "downcast-rs" | |
| 691 | version = "1.2.1" | |
| 692 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 693 | checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" | |
| 694 | ||
| 695 | [[package]] | |
| 696 | name = "dpi" | |
| 697 | version = "0.1.2" | |
| 698 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 699 | checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" | |
| 700 | ||
| 239 | 701 | [[package]] |
| 240 | 702 | name = "equivalent" |
| 241 | 703 | version = "1.0.2" |
| ... | ... | @@ -249,9 +711,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 249 | 711 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" |
| 250 | 712 | dependencies = [ |
| 251 | 713 | "libc", |
| 252 | "windows-sys", | |
| 714 | "windows-sys 0.61.2", | |
| 253 | 715 | ] |
| 254 | 716 | |
| 717 | [[package]] | |
| 718 | name = "error-code" | |
| 719 | version = "3.4.0" | |
| 720 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 721 | checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" | |
| 722 | ||
| 255 | 723 | [[package]] |
| 256 | 724 | name = "fallible-iterator" |
| 257 | 725 | version = "0.3.0" |
| ... | ... | @@ -271,17 +739,94 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 271 | 739 | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" |
| 272 | 740 | |
| 273 | 741 | [[package]] |
| 274 | name = "find-msvc-tools" | |
| 742 | name = "fdeflate" | |
| 743 | version = "0.3.7" | |
| 744 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 745 | checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" | |
| 746 | dependencies = [ | |
| 747 | "simd-adler32", | |
| 748 | ] | |
| 749 | ||
| 750 | [[package]] | |
| 751 | name = "find-msvc-tools" | |
| 275 | 752 | version = "0.1.12" |
| 276 | 753 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 277 | 754 | checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" |
| 278 | 755 | |
| 756 | [[package]] | |
| 757 | name = "flate2" | |
| 758 | version = "1.1.10" | |
| 759 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 760 | checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" | |
| 761 | dependencies = [ | |
| 762 | "crc32fast", | |
| 763 | "miniz_oxide 0.9.1", | |
| 764 | "zlib-rs", | |
| 765 | ] | |
| 766 | ||
| 279 | 767 | [[package]] |
| 280 | 768 | name = "foldhash" |
| 281 | 769 | version = "0.2.0" |
| 282 | 770 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 283 | 771 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" |
| 284 | 772 | |
| 773 | [[package]] | |
| 774 | name = "font-types" | |
| 775 | version = "0.12.5" | |
| 776 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 777 | checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" | |
| 778 | dependencies = [ | |
| 779 | "bytemuck", | |
| 780 | ] | |
| 781 | ||
| 782 | [[package]] | |
| 783 | name = "fontique" | |
| 784 | version = "0.11.0" | |
| 785 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | |
| 786 | dependencies = [ | |
| 787 | "hashbrown 0.17.1", | |
| 788 | "linebender_resource_handle", | |
| 789 | "memmap2", | |
| 790 | "objc2 0.6.4", | |
| 791 | "objc2-core-foundation", | |
| 792 | "objc2-core-text", | |
| 793 | "objc2-foundation 0.3.2", | |
| 794 | "parlance", | |
| 795 | "read-fonts", | |
| 796 | "roxmltree", | |
| 797 | "smallvec", | |
| 798 | "windows", | |
| 799 | "windows-core", | |
| 800 | "yeslogic-fontconfig-sys", | |
| 801 | ] | |
| 802 | ||
| 803 | [[package]] | |
| 804 | name = "foreign-types" | |
| 805 | version = "0.5.0" | |
| 806 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 807 | checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" | |
| 808 | dependencies = [ | |
| 809 | "foreign-types-macros", | |
| 810 | "foreign-types-shared", | |
| 811 | ] | |
| 812 | ||
| 813 | [[package]] | |
| 814 | name = "foreign-types-macros" | |
| 815 | version = "0.2.4" | |
| 816 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 817 | checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" | |
| 818 | dependencies = [ | |
| 819 | "proc-macro2", | |
| 820 | "quote", | |
| 821 | "syn 3.0.5", | |
| 822 | ] | |
| 823 | ||
| 824 | [[package]] | |
| 825 | name = "foreign-types-shared" | |
| 826 | version = "0.3.1" | |
| 827 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 828 | checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" | |
| 829 | ||
| 285 | 830 | [[package]] |
| 286 | 831 | name = "futures-core" |
| 287 | 832 | version = "0.3.34" |
| ... | ... | @@ -306,6 +851,28 @@ dependencies = [ |
| 306 | 851 | "slab", |
| 307 | 852 | ] |
| 308 | 853 | |
| 854 | [[package]] | |
| 855 | name = "gethostname" | |
| 856 | version = "1.1.0" | |
| 857 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 858 | checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" | |
| 859 | dependencies = [ | |
| 860 | "rustix 1.1.4", | |
| 861 | "windows-link", | |
| 862 | ] | |
| 863 | ||
| 864 | [[package]] | |
| 865 | name = "getrandom" | |
| 866 | version = "0.3.4" | |
| 867 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 868 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" | |
| 869 | dependencies = [ | |
| 870 | "cfg-if", | |
| 871 | "libc", | |
| 872 | "r-efi 5.3.0", | |
| 873 | "wasip2", | |
| 874 | ] | |
| 875 | ||
| 309 | 876 | [[package]] |
| 310 | 877 | name = "getrandom" |
| 311 | 878 | version = "0.4.3" |
| ... | ... | @@ -314,7 +881,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" |
| 314 | 881 | dependencies = [ |
| 315 | 882 | "cfg-if", |
| 316 | 883 | "libc", |
| 317 | "r-efi", | |
| 884 | "r-efi 6.0.0", | |
| 318 | 885 | "rand_core", |
| 319 | 886 | ] |
| 320 | 887 | |
| ... | ... | @@ -327,12 +894,84 @@ dependencies = [ |
| 327 | 894 | "polyval", |
| 328 | 895 | ] |
| 329 | 896 | |
| 897 | [[package]] | |
| 898 | name = "gl_generator" | |
| 899 | version = "0.14.0" | |
| 900 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 901 | checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" | |
| 902 | dependencies = [ | |
| 903 | "khronos_api", | |
| 904 | "log", | |
| 905 | "xml-rs", | |
| 906 | ] | |
| 907 | ||
| 908 | [[package]] | |
| 909 | name = "glow" | |
| 910 | version = "0.17.0" | |
| 911 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 912 | checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" | |
| 913 | dependencies = [ | |
| 914 | "js-sys", | |
| 915 | "slotmap", | |
| 916 | "wasm-bindgen", | |
| 917 | "web-sys", | |
| 918 | ] | |
| 919 | ||
| 920 | [[package]] | |
| 921 | name = "glutin_wgl_sys" | |
| 922 | version = "0.6.1" | |
| 923 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 924 | checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" | |
| 925 | dependencies = [ | |
| 926 | "gl_generator", | |
| 927 | ] | |
| 928 | ||
| 929 | [[package]] | |
| 930 | name = "gpu-allocator" | |
| 931 | version = "0.28.0" | |
| 932 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 933 | checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" | |
| 934 | dependencies = [ | |
| 935 | "ash", | |
| 936 | "hashbrown 0.16.1", | |
| 937 | "log", | |
| 938 | "presser", | |
| 939 | "thiserror 2.0.20", | |
| 940 | "windows", | |
| 941 | ] | |
| 942 | ||
| 943 | [[package]] | |
| 944 | name = "half" | |
| 945 | version = "2.7.1" | |
| 946 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 947 | checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" | |
| 948 | dependencies = [ | |
| 949 | "cfg-if", | |
| 950 | "crunchy", | |
| 951 | "num-traits", | |
| 952 | "zerocopy", | |
| 953 | ] | |
| 954 | ||
| 955 | [[package]] | |
| 956 | name = "harfrust" | |
| 957 | version = "0.12.0" | |
| 958 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 959 | checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" | |
| 960 | dependencies = [ | |
| 961 | "bitflags 2.13.1", | |
| 962 | "bytemuck", | |
| 963 | "read-fonts", | |
| 964 | "smallvec", | |
| 965 | ] | |
| 966 | ||
| 330 | 967 | [[package]] |
| 331 | 968 | name = "hashbrown" |
| 332 | 969 | version = "0.16.1" |
| 333 | 970 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 334 | 971 | checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" |
| 335 | 972 | dependencies = [ |
| 973 | "allocator-api2", | |
| 974 | "equivalent", | |
| 336 | 975 | "foldhash", |
| 337 | 976 | ] |
| 338 | 977 | |
| ... | ... | @@ -354,6 +993,12 @@ dependencies = [ |
| 354 | 993 | "hashbrown 0.17.1", |
| 355 | 994 | ] |
| 356 | 995 | |
| 996 | [[package]] | |
| 997 | name = "hermit-abi" | |
| 998 | version = "0.5.3" | |
| 999 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1000 | checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" | |
| 1001 | ||
| 357 | 1002 | [[package]] |
| 358 | 1003 | name = "hmac" |
| 359 | 1004 | version = "0.13.0" |
| ... | ... | @@ -373,6 +1018,149 @@ dependencies = [ |
| 373 | 1018 | "zeroize", |
| 374 | 1019 | ] |
| 375 | 1020 | |
| 1021 | [[package]] | |
| 1022 | name = "icu_collections" | |
| 1023 | version = "2.3.0" | |
| 1024 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1025 | checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" | |
| 1026 | dependencies = [ | |
| 1027 | "displaydoc", | |
| 1028 | "potential_utf", | |
| 1029 | "utf8_iter", | |
| 1030 | "yoke", | |
| 1031 | "zerofrom", | |
| 1032 | "zerovec", | |
| 1033 | ] | |
| 1034 | ||
| 1035 | [[package]] | |
| 1036 | name = "icu_locale_core" | |
| 1037 | version = "2.3.0" | |
| 1038 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1039 | checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" | |
| 1040 | dependencies = [ | |
| 1041 | "displaydoc", | |
| 1042 | "litemap", | |
| 1043 | "serde", | |
| 1044 | "tinystr", | |
| 1045 | "writeable", | |
| 1046 | "zerovec", | |
| 1047 | ] | |
| 1048 | ||
| 1049 | [[package]] | |
| 1050 | name = "icu_locale_fallback" | |
| 1051 | version = "2.3.0" | |
| 1052 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1053 | checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" | |
| 1054 | dependencies = [ | |
| 1055 | "icu_locale_core", | |
| 1056 | "icu_locale_fallback_data", | |
| 1057 | "icu_provider", | |
| 1058 | "potential_utf", | |
| 1059 | "tinystr", | |
| 1060 | "zerovec", | |
| 1061 | ] | |
| 1062 | ||
| 1063 | [[package]] | |
| 1064 | name = "icu_locale_fallback_data" | |
| 1065 | version = "2.3.0" | |
| 1066 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1067 | checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" | |
| 1068 | ||
| 1069 | [[package]] | |
| 1070 | name = "icu_normalizer" | |
| 1071 | version = "2.3.0" | |
| 1072 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1073 | checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" | |
| 1074 | dependencies = [ | |
| 1075 | "icu_collections", | |
| 1076 | "icu_normalizer_data", | |
| 1077 | "icu_properties", | |
| 1078 | "icu_provider", | |
| 1079 | "smallvec", | |
| 1080 | "zerovec", | |
| 1081 | ] | |
| 1082 | ||
| 1083 | [[package]] | |
| 1084 | name = "icu_normalizer_data" | |
| 1085 | version = "2.3.0" | |
| 1086 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1087 | checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" | |
| 1088 | ||
| 1089 | [[package]] | |
| 1090 | name = "icu_properties" | |
| 1091 | version = "2.3.0" | |
| 1092 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1093 | checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" | |
| 1094 | dependencies = [ | |
| 1095 | "displaydoc", | |
| 1096 | "icu_collections", | |
| 1097 | "icu_locale_core", | |
| 1098 | "icu_properties_data", | |
| 1099 | "icu_provider", | |
| 1100 | "zerotrie", | |
| 1101 | "zerovec", | |
| 1102 | ] | |
| 1103 | ||
| 1104 | [[package]] | |
| 1105 | name = "icu_properties_data" | |
| 1106 | version = "2.3.0" | |
| 1107 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1108 | checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" | |
| 1109 | ||
| 1110 | [[package]] | |
| 1111 | name = "icu_provider" | |
| 1112 | version = "2.3.1" | |
| 1113 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1114 | checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" | |
| 1115 | dependencies = [ | |
| 1116 | "displaydoc", | |
| 1117 | "icu_locale_core", | |
| 1118 | "serde", | |
| 1119 | "stable_deref_trait", | |
| 1120 | "writeable", | |
| 1121 | "yoke", | |
| 1122 | "zerofrom", | |
| 1123 | "zerotrie", | |
| 1124 | "zerovec", | |
| 1125 | ] | |
| 1126 | ||
| 1127 | [[package]] | |
| 1128 | name = "icu_segmenter" | |
| 1129 | version = "2.3.0" | |
| 1130 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1131 | checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" | |
| 1132 | dependencies = [ | |
| 1133 | "icu_collections", | |
| 1134 | "icu_locale_fallback", | |
| 1135 | "icu_provider", | |
| 1136 | "icu_segmenter_data", | |
| 1137 | "potential_utf", | |
| 1138 | "smallvec", | |
| 1139 | "utf8_iter", | |
| 1140 | "zerovec", | |
| 1141 | ] | |
| 1142 | ||
| 1143 | [[package]] | |
| 1144 | name = "icu_segmenter_data" | |
| 1145 | version = "2.3.0" | |
| 1146 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1147 | checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" | |
| 1148 | ||
| 1149 | [[package]] | |
| 1150 | name = "image" | |
| 1151 | version = "0.25.10" | |
| 1152 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1153 | checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" | |
| 1154 | dependencies = [ | |
| 1155 | "bytemuck", | |
| 1156 | "byteorder-lite", | |
| 1157 | "moxcms", | |
| 1158 | "num-traits", | |
| 1159 | "png", | |
| 1160 | "zune-core", | |
| 1161 | "zune-jpeg", | |
| 1162 | ] | |
| 1163 | ||
| 376 | 1164 | [[package]] |
| 377 | 1165 | name = "indexmap" |
| 378 | 1166 | version = "2.14.2" |
| ... | ... | @@ -399,6 +1187,74 @@ version = "1.0.18" |
| 399 | 1187 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 400 | 1188 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" |
| 401 | 1189 | |
| 1190 | [[package]] | |
| 1191 | name = "jni" | |
| 1192 | version = "0.22.4" | |
| 1193 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1194 | checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" | |
| 1195 | dependencies = [ | |
| 1196 | "cfg-if", | |
| 1197 | "combine", | |
| 1198 | "jni-macros", | |
| 1199 | "jni-sys 0.4.1", | |
| 1200 | "log", | |
| 1201 | "simd_cesu8", | |
| 1202 | "thiserror 2.0.20", | |
| 1203 | "walkdir", | |
| 1204 | "windows-link", | |
| 1205 | ] | |
| 1206 | ||
| 1207 | [[package]] | |
| 1208 | name = "jni-macros" | |
| 1209 | version = "0.22.4" | |
| 1210 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1211 | checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" | |
| 1212 | dependencies = [ | |
| 1213 | "proc-macro2", | |
| 1214 | "quote", | |
| 1215 | "rustc_version", | |
| 1216 | "simd_cesu8", | |
| 1217 | "syn 2.0.119", | |
| 1218 | ] | |
| 1219 | ||
| 1220 | [[package]] | |
| 1221 | name = "jni-sys" | |
| 1222 | version = "0.3.1" | |
| 1223 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1224 | checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" | |
| 1225 | dependencies = [ | |
| 1226 | "jni-sys 0.4.1", | |
| 1227 | ] | |
| 1228 | ||
| 1229 | [[package]] | |
| 1230 | name = "jni-sys" | |
| 1231 | version = "0.4.1" | |
| 1232 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1233 | checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" | |
| 1234 | dependencies = [ | |
| 1235 | "jni-sys-macros", | |
| 1236 | ] | |
| 1237 | ||
| 1238 | [[package]] | |
| 1239 | name = "jni-sys-macros" | |
| 1240 | version = "0.4.1" | |
| 1241 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1242 | checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" | |
| 1243 | dependencies = [ | |
| 1244 | "quote", | |
| 1245 | "syn 2.0.119", | |
| 1246 | ] | |
| 1247 | ||
| 1248 | [[package]] | |
| 1249 | name = "jobserver" | |
| 1250 | version = "0.1.35" | |
| 1251 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1252 | checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" | |
| 1253 | dependencies = [ | |
| 1254 | "getrandom 0.4.3", | |
| 1255 | "libc", | |
| 1256 | ] | |
| 1257 | ||
| 402 | 1258 | [[package]] |
| 403 | 1259 | name = "js-sys" |
| 404 | 1260 | version = "0.3.105" |
| ... | ... | @@ -406,15 +1262,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 406 | 1262 | checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" |
| 407 | 1263 | dependencies = [ |
| 408 | 1264 | "cfg-if", |
| 1265 | "futures-util", | |
| 409 | 1266 | "wasm-bindgen", |
| 410 | 1267 | ] |
| 411 | 1268 | |
| 1269 | [[package]] | |
| 1270 | name = "khronos-egl" | |
| 1271 | version = "6.0.0" | |
| 1272 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1273 | checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" | |
| 1274 | dependencies = [ | |
| 1275 | "libc", | |
| 1276 | "libloading", | |
| 1277 | "pkg-config", | |
| 1278 | ] | |
| 1279 | ||
| 1280 | [[package]] | |
| 1281 | name = "khronos_api" | |
| 1282 | version = "3.1.0" | |
| 1283 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1284 | checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" | |
| 1285 | ||
| 412 | 1286 | [[package]] |
| 413 | 1287 | name = "libc" |
| 414 | 1288 | version = "0.2.189" |
| 415 | 1289 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 416 | 1290 | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" |
| 417 | 1291 | |
| 1292 | [[package]] | |
| 1293 | name = "libloading" | |
| 1294 | version = "0.8.9" | |
| 1295 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1296 | checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" | |
| 1297 | dependencies = [ | |
| 1298 | "cfg-if", | |
| 1299 | "windows-link", | |
| 1300 | ] | |
| 1301 | ||
| 1302 | [[package]] | |
| 1303 | name = "libm" | |
| 1304 | version = "0.2.16" | |
| 1305 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1306 | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" | |
| 1307 | ||
| 1308 | [[package]] | |
| 1309 | name = "libredox" | |
| 1310 | version = "0.1.23" | |
| 1311 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1312 | checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" | |
| 1313 | dependencies = [ | |
| 1314 | "bitflags 2.13.1", | |
| 1315 | "libc", | |
| 1316 | "plain", | |
| 1317 | "redox_syscall 0.9.4", | |
| 1318 | ] | |
| 1319 | ||
| 418 | 1320 | [[package]] |
| 419 | 1321 | name = "libsqlite3-sys" |
| 420 | 1322 | version = "0.38.2" |
| ... | ... | @@ -426,12 +1328,45 @@ dependencies = [ |
| 426 | 1328 | "vcpkg", |
| 427 | 1329 | ] |
| 428 | 1330 | |
| 1331 | [[package]] | |
| 1332 | name = "linebender_resource_handle" | |
| 1333 | version = "0.1.1" | |
| 1334 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1335 | checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" | |
| 1336 | ||
| 1337 | [[package]] | |
| 1338 | name = "linux-raw-sys" | |
| 1339 | version = "0.4.15" | |
| 1340 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1341 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" | |
| 1342 | ||
| 429 | 1343 | [[package]] |
| 430 | 1344 | name = "linux-raw-sys" |
| 431 | 1345 | version = "0.12.1" |
| 432 | 1346 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 433 | 1347 | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" |
| 434 | 1348 | |
| 1349 | [[package]] | |
| 1350 | name = "litemap" | |
| 1351 | version = "0.8.3" | |
| 1352 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1353 | checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" | |
| 1354 | ||
| 1355 | [[package]] | |
| 1356 | name = "litrs" | |
| 1357 | version = "1.0.0" | |
| 1358 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1359 | checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" | |
| 1360 | ||
| 1361 | [[package]] | |
| 1362 | name = "lock_api" | |
| 1363 | version = "0.4.14" | |
| 1364 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1365 | checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" | |
| 1366 | dependencies = [ | |
| 1367 | "scopeguard", | |
| 1368 | ] | |
| 1369 | ||
| 435 | 1370 | [[package]] |
| 436 | 1371 | name = "log" |
| 437 | 1372 | version = "0.4.34" |
| ... | ... | @@ -479,35 +1414,152 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 479 | 1414 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" |
| 480 | 1415 | |
| 481 | 1416 | [[package]] |
| 482 | name = "mio" | |
| 483 | version = "1.2.3" | |
| 1417 | name = "memmap2" | |
| 1418 | version = "0.9.11" | |
| 484 | 1419 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 485 | checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" | |
| 1420 | checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" | |
| 486 | 1421 | dependencies = [ |
| 487 | 1422 | "libc", |
| 488 | "wasi", | |
| 489 | "windows-sys", | |
| 490 | 1423 | ] |
| 491 | 1424 | |
| 492 | 1425 | [[package]] |
| 493 | name = "nix" | |
| 494 | version = "0.31.3" | |
| 1426 | name = "miniz_oxide" | |
| 1427 | version = "0.8.9" | |
| 495 | 1428 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 496 | checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" | |
| 1429 | checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" | |
| 497 | 1430 | dependencies = [ |
| 498 | "bitflags", | |
| 499 | "cfg-if", | |
| 500 | "cfg_aliases", | |
| 501 | "libc", | |
| 1431 | "adler2", | |
| 1432 | "simd-adler32", | |
| 502 | 1433 | ] |
| 503 | 1434 | |
| 504 | 1435 | [[package]] |
| 505 | name = "num_enum" | |
| 506 | version = "0.7.6" | |
| 1436 | name = "miniz_oxide" | |
| 1437 | version = "0.9.1" | |
| 507 | 1438 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 508 | checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" | |
| 1439 | checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" | |
| 509 | 1440 | dependencies = [ |
| 510 | "num_enum_derive", | |
| 1441 | "adler2", | |
| 1442 | "simd-adler32", | |
| 1443 | ] | |
| 1444 | ||
| 1445 | [[package]] | |
| 1446 | name = "mio" | |
| 1447 | version = "1.2.3" | |
| 1448 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1449 | checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" | |
| 1450 | dependencies = [ | |
| 1451 | "libc", | |
| 1452 | "wasi", | |
| 1453 | "windows-sys 0.61.2", | |
| 1454 | ] | |
| 1455 | ||
| 1456 | [[package]] | |
| 1457 | name = "moxcms" | |
| 1458 | version = "0.8.1" | |
| 1459 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1460 | checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" | |
| 1461 | dependencies = [ | |
| 1462 | "num-traits", | |
| 1463 | "pxfm", | |
| 1464 | ] | |
| 1465 | ||
| 1466 | [[package]] | |
| 1467 | name = "naga" | |
| 1468 | version = "30.0.1" | |
| 1469 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1470 | checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" | |
| 1471 | dependencies = [ | |
| 1472 | "arrayvec", | |
| 1473 | "bit-set", | |
| 1474 | "bitflags 2.13.1", | |
| 1475 | "cfg-if", | |
| 1476 | "cfg_aliases", | |
| 1477 | "codespan-reporting", | |
| 1478 | "half", | |
| 1479 | "hashbrown 0.17.1", | |
| 1480 | "indexmap", | |
| 1481 | "libm", | |
| 1482 | "log", | |
| 1483 | "naga-types", | |
| 1484 | "num-traits", | |
| 1485 | "once_cell", | |
| 1486 | "rustc-hash", | |
| 1487 | "spirv", | |
| 1488 | "thiserror 2.0.20", | |
| 1489 | "unicode-ident", | |
| 1490 | ] | |
| 1491 | ||
| 1492 | [[package]] | |
| 1493 | name = "naga-types" | |
| 1494 | version = "30.0.1" | |
| 1495 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1496 | checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" | |
| 1497 | dependencies = [ | |
| 1498 | "hashbrown 0.17.1", | |
| 1499 | "indexmap", | |
| 1500 | "rustc-hash", | |
| 1501 | "thiserror 2.0.20", | |
| 1502 | ] | |
| 1503 | ||
| 1504 | [[package]] | |
| 1505 | name = "ndk" | |
| 1506 | version = "0.9.0" | |
| 1507 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1508 | checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" | |
| 1509 | dependencies = [ | |
| 1510 | "bitflags 2.13.1", | |
| 1511 | "jni-sys 0.3.1", | |
| 1512 | "log", | |
| 1513 | "ndk-sys", | |
| 1514 | "num_enum", | |
| 1515 | "raw-window-handle", | |
| 1516 | "thiserror 1.0.69", | |
| 1517 | ] | |
| 1518 | ||
| 1519 | [[package]] | |
| 1520 | name = "ndk-context" | |
| 1521 | version = "0.1.1" | |
| 1522 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1523 | checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" | |
| 1524 | ||
| 1525 | [[package]] | |
| 1526 | name = "ndk-sys" | |
| 1527 | version = "0.6.0+11769913" | |
| 1528 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1529 | checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" | |
| 1530 | dependencies = [ | |
| 1531 | "jni-sys 0.3.1", | |
| 1532 | ] | |
| 1533 | ||
| 1534 | [[package]] | |
| 1535 | name = "nix" | |
| 1536 | version = "0.31.3" | |
| 1537 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1538 | checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" | |
| 1539 | dependencies = [ | |
| 1540 | "bitflags 2.13.1", | |
| 1541 | "cfg-if", | |
| 1542 | "cfg_aliases", | |
| 1543 | "libc", | |
| 1544 | ] | |
| 1545 | ||
| 1546 | [[package]] | |
| 1547 | name = "num-traits" | |
| 1548 | version = "0.2.19" | |
| 1549 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1550 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" | |
| 1551 | dependencies = [ | |
| 1552 | "autocfg", | |
| 1553 | "libm", | |
| 1554 | ] | |
| 1555 | ||
| 1556 | [[package]] | |
| 1557 | name = "num_enum" | |
| 1558 | version = "0.7.6" | |
| 1559 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1560 | checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" | |
| 1561 | dependencies = [ | |
| 1562 | "num_enum_derive", | |
| 511 | 1563 | "rustversion", |
| 512 | 1564 | ] |
| 513 | 1565 | |
| ... | ... | @@ -524,556 +1576,2375 @@ dependencies = [ |
| 524 | 1576 | ] |
| 525 | 1577 | |
| 526 | 1578 | [[package]] |
| 527 | name = "once_cell" | |
| 528 | version = "1.21.4" | |
| 1579 | name = "objc-sys" | |
| 1580 | version = "0.3.5" | |
| 529 | 1581 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 530 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | |
| 1582 | checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" | |
| 531 | 1583 | |
| 532 | 1584 | [[package]] |
| 533 | name = "onestore" | |
| 534 | version = "0.1.0" | |
| 1585 | name = "objc2" | |
| 1586 | version = "0.5.2" | |
| 1587 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1588 | checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" | |
| 535 | 1589 | dependencies = [ |
| 536 | "aes", | |
| 537 | "base64", | |
| 538 | "cbc", | |
| 539 | "getrandom", | |
| 540 | "md5", | |
| 541 | "nix", | |
| 542 | "roxmltree", | |
| 543 | "serde", | |
| 544 | "serde_json", | |
| 545 | "sha1", | |
| 546 | "subtle", | |
| 547 | "zeroize", | |
| 1590 | "objc-sys", | |
| 1591 | "objc2-encode", | |
| 548 | 1592 | ] |
| 549 | 1593 | |
| 550 | 1594 | [[package]] |
| 551 | name = "onestore-diagnostic" | |
| 552 | version = "0.1.0" | |
| 1595 | name = "objc2" | |
| 1596 | version = "0.6.4" | |
| 1597 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1598 | checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" | |
| 553 | 1599 | dependencies = [ |
| 554 | "onestore", | |
| 555 | "onestore-notebook", | |
| 556 | "serde", | |
| 557 | "serde_json", | |
| 1600 | "objc2-encode", | |
| 558 | 1601 | ] |
| 559 | 1602 | |
| 560 | 1603 | [[package]] |
| 561 | name = "onestore-notebook" | |
| 562 | version = "0.1.0" | |
| 1604 | name = "objc2-app-kit" | |
| 1605 | version = "0.2.2" | |
| 1606 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1607 | checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" | |
| 563 | 1608 | dependencies = [ |
| 564 | "onestore", | |
| 565 | "onestore-smb", | |
| 566 | "serde", | |
| 567 | "serde_json", | |
| 568 | "tempfile", | |
| 569 | "thiserror", | |
| 570 | "zeroize", | |
| 1609 | "bitflags 2.13.1", | |
| 1610 | "block2 0.5.1", | |
| 1611 | "libc", | |
| 1612 | "objc2 0.5.2", | |
| 1613 | "objc2-core-data", | |
| 1614 | "objc2-core-image", | |
| 1615 | "objc2-foundation 0.2.2", | |
| 1616 | "objc2-quartz-core 0.2.2", | |
| 571 | 1617 | ] |
| 572 | 1618 | |
| 573 | 1619 | [[package]] |
| 574 | name = "onestore-offline" | |
| 575 | version = "0.1.0" | |
| 1620 | name = "objc2-app-kit" | |
| 1621 | version = "0.3.2" | |
| 1622 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1623 | checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" | |
| 576 | 1624 | dependencies = [ |
| 577 | "onestore", | |
| 578 | "onestore-notebook", | |
| 579 | "onestore-smb", | |
| 580 | "rusqlite", | |
| 581 | "serde", | |
| 582 | "serde_json", | |
| 583 | "sha2", | |
| 584 | "tempfile", | |
| 585 | "thiserror", | |
| 1625 | "bitflags 2.13.1", | |
| 1626 | "objc2 0.6.4", | |
| 1627 | "objc2-core-graphics", | |
| 1628 | "objc2-foundation 0.3.2", | |
| 586 | 1629 | ] |
| 587 | 1630 | |
| 588 | 1631 | [[package]] |
| 589 | name = "onestore-smb" | |
| 590 | version = "0.1.0" | |
| 1632 | name = "objc2-cloud-kit" | |
| 1633 | version = "0.2.2" | |
| 1634 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1635 | checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" | |
| 591 | 1636 | dependencies = [ |
| 592 | "onestore", | |
| 593 | "serde_json", | |
| 594 | "smb2", | |
| 595 | "tokio", | |
| 1637 | "bitflags 2.13.1", | |
| 1638 | "block2 0.5.1", | |
| 1639 | "objc2 0.5.2", | |
| 1640 | "objc2-core-location", | |
| 1641 | "objc2-foundation 0.2.2", | |
| 596 | 1642 | ] |
| 597 | 1643 | |
| 598 | 1644 | [[package]] |
| 599 | name = "pbkdf2" | |
| 600 | version = "0.13.0" | |
| 1645 | name = "objc2-contacts" | |
| 1646 | version = "0.2.2" | |
| 601 | 1647 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 602 | checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" | |
| 1648 | checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" | |
| 603 | 1649 | dependencies = [ |
| 604 | "digest", | |
| 605 | "hmac", | |
| 1650 | "block2 0.5.1", | |
| 1651 | "objc2 0.5.2", | |
| 1652 | "objc2-foundation 0.2.2", | |
| 606 | 1653 | ] |
| 607 | 1654 | |
| 608 | 1655 | [[package]] |
| 609 | name = "pin-project-lite" | |
| 610 | version = "0.2.17" | |
| 1656 | name = "objc2-core-data" | |
| 1657 | version = "0.2.2" | |
| 611 | 1658 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 612 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 1659 | checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" | |
| 1660 | dependencies = [ | |
| 1661 | "bitflags 2.13.1", | |
| 1662 | "block2 0.5.1", | |
| 1663 | "objc2 0.5.2", | |
| 1664 | "objc2-foundation 0.2.2", | |
| 1665 | ] | |
| 613 | 1666 | |
| 614 | 1667 | [[package]] |
| 615 | name = "pkg-config" | |
| 616 | version = "0.3.34" | |
| 1668 | name = "objc2-core-foundation" | |
| 1669 | version = "0.3.2" | |
| 617 | 1670 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 618 | checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" | |
| 1671 | checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" | |
| 1672 | dependencies = [ | |
| 1673 | "bitflags 2.13.1", | |
| 1674 | "dispatch2", | |
| 1675 | "objc2 0.6.4", | |
| 1676 | ] | |
| 619 | 1677 | |
| 620 | 1678 | [[package]] |
| 621 | name = "polyval" | |
| 622 | version = "0.7.3" | |
| 1679 | name = "objc2-core-graphics" | |
| 1680 | version = "0.3.2" | |
| 623 | 1681 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 624 | checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" | |
| 1682 | checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" | |
| 625 | 1683 | dependencies = [ |
| 626 | "cpubits", | |
| 627 | "cpufeatures", | |
| 628 | "universal-hash", | |
| 1684 | "bitflags 2.13.1", | |
| 1685 | "dispatch2", | |
| 1686 | "objc2 0.6.4", | |
| 1687 | "objc2-core-foundation", | |
| 1688 | "objc2-io-surface", | |
| 629 | 1689 | ] |
| 630 | 1690 | |
| 631 | 1691 | [[package]] |
| 632 | name = "proc-macro-crate" | |
| 633 | version = "3.5.0" | |
| 1692 | name = "objc2-core-image" | |
| 1693 | version = "0.2.2" | |
| 634 | 1694 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 635 | checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" | |
| 1695 | checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" | |
| 636 | 1696 | dependencies = [ |
| 637 | "toml_edit", | |
| 1697 | "block2 0.5.1", | |
| 1698 | "objc2 0.5.2", | |
| 1699 | "objc2-foundation 0.2.2", | |
| 1700 | "objc2-metal 0.2.2", | |
| 638 | 1701 | ] |
| 639 | 1702 | |
| 640 | 1703 | [[package]] |
| 641 | name = "proc-macro2" | |
| 642 | version = "1.0.107" | |
| 1704 | name = "objc2-core-location" | |
| 1705 | version = "0.2.2" | |
| 643 | 1706 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 644 | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | |
| 1707 | checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" | |
| 645 | 1708 | dependencies = [ |
| 646 | "unicode-ident", | |
| 1709 | "block2 0.5.1", | |
| 1710 | "objc2 0.5.2", | |
| 1711 | "objc2-contacts", | |
| 1712 | "objc2-foundation 0.2.2", | |
| 647 | 1713 | ] |
| 648 | 1714 | |
| 649 | 1715 | [[package]] |
| 650 | name = "quote" | |
| 651 | version = "1.0.47" | |
| 1716 | name = "objc2-core-text" | |
| 1717 | version = "0.3.2" | |
| 652 | 1718 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 653 | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | |
| 1719 | checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" | |
| 654 | 1720 | dependencies = [ |
| 655 | "proc-macro2", | |
| 1721 | "bitflags 2.13.1", | |
| 1722 | "objc2-core-foundation", | |
| 656 | 1723 | ] |
| 657 | 1724 | |
| 658 | 1725 | [[package]] |
| 659 | name = "r-efi" | |
| 660 | version = "6.0.0" | |
| 1726 | name = "objc2-encode" | |
| 1727 | version = "4.1.0" | |
| 661 | 1728 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 662 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |
| 1729 | checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" | |
| 663 | 1730 | |
| 664 | 1731 | [[package]] |
| 665 | name = "rand_core" | |
| 666 | version = "0.10.1" | |
| 1732 | name = "objc2-foundation" | |
| 1733 | version = "0.2.2" | |
| 667 | 1734 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 668 | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" | |
| 1735 | checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" | |
| 1736 | dependencies = [ | |
| 1737 | "bitflags 2.13.1", | |
| 1738 | "block2 0.5.1", | |
| 1739 | "dispatch", | |
| 1740 | "libc", | |
| 1741 | "objc2 0.5.2", | |
| 1742 | ] | |
| 669 | 1743 | |
| 670 | 1744 | [[package]] |
| 671 | name = "roxmltree" | |
| 672 | version = "0.21.1" | |
| 1745 | name = "objc2-foundation" | |
| 1746 | version = "0.3.2" | |
| 673 | 1747 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 674 | checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" | |
| 1748 | checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" | |
| 675 | 1749 | dependencies = [ |
| 676 | "memchr", | |
| 1750 | "bitflags 2.13.1", | |
| 1751 | "objc2 0.6.4", | |
| 1752 | "objc2-core-foundation", | |
| 677 | 1753 | ] |
| 678 | 1754 | |
| 679 | 1755 | [[package]] |
| 680 | name = "rsqlite-vfs" | |
| 681 | version = "0.1.1" | |
| 1756 | name = "objc2-io-surface" | |
| 1757 | version = "0.3.2" | |
| 682 | 1758 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 683 | checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" | |
| 1759 | checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" | |
| 684 | 1760 | dependencies = [ |
| 685 | "hashbrown 0.16.1", | |
| 686 | "thiserror", | |
| 1761 | "bitflags 2.13.1", | |
| 1762 | "objc2 0.6.4", | |
| 1763 | "objc2-core-foundation", | |
| 687 | 1764 | ] |
| 688 | 1765 | |
| 689 | 1766 | [[package]] |
| 690 | name = "rusqlite" | |
| 691 | version = "0.40.2" | |
| 1767 | name = "objc2-link-presentation" | |
| 1768 | version = "0.2.2" | |
| 692 | 1769 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 693 | checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" | |
| 1770 | checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" | |
| 694 | 1771 | dependencies = [ |
| 695 | "bitflags", | |
| 696 | "fallible-iterator", | |
| 697 | "fallible-streaming-iterator", | |
| 698 | "hashlink", | |
| 699 | "libsqlite3-sys", | |
| 700 | "smallvec", | |
| 701 | "sqlite-wasm-rs", | |
| 1772 | "block2 0.5.1", | |
| 1773 | "objc2 0.5.2", | |
| 1774 | "objc2-app-kit 0.2.2", | |
| 1775 | "objc2-foundation 0.2.2", | |
| 702 | 1776 | ] |
| 703 | 1777 | |
| 704 | 1778 | [[package]] |
| 705 | name = "rustix" | |
| 706 | version = "1.1.4" | |
| 1779 | name = "objc2-metal" | |
| 1780 | version = "0.2.2" | |
| 707 | 1781 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 708 | checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" | |
| 1782 | checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" | |
| 709 | 1783 | dependencies = [ |
| 710 | "bitflags", | |
| 711 | "errno", | |
| 712 | "libc", | |
| 713 | "linux-raw-sys", | |
| 714 | "windows-sys", | |
| 1784 | "bitflags 2.13.1", | |
| 1785 | "block2 0.5.1", | |
| 1786 | "objc2 0.5.2", | |
| 1787 | "objc2-foundation 0.2.2", | |
| 715 | 1788 | ] |
| 716 | 1789 | |
| 717 | 1790 | [[package]] |
| 718 | name = "rustversion" | |
| 719 | version = "1.0.23" | |
| 1791 | name = "objc2-metal" | |
| 1792 | version = "0.3.2" | |
| 720 | 1793 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 721 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 1794 | checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" | |
| 1795 | dependencies = [ | |
| 1796 | "bitflags 2.13.1", | |
| 1797 | "block2 0.6.2", | |
| 1798 | "objc2 0.6.4", | |
| 1799 | "objc2-foundation 0.3.2", | |
| 1800 | ] | |
| 722 | 1801 | |
| 723 | 1802 | [[package]] |
| 724 | name = "serde" | |
| 725 | version = "1.0.229" | |
| 1803 | name = "objc2-quartz-core" | |
| 1804 | version = "0.2.2" | |
| 726 | 1805 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 727 | checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" | |
| 1806 | checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" | |
| 728 | 1807 | dependencies = [ |
| 729 | "serde_core", | |
| 730 | "serde_derive", | |
| 1808 | "bitflags 2.13.1", | |
| 1809 | "block2 0.5.1", | |
| 1810 | "objc2 0.5.2", | |
| 1811 | "objc2-foundation 0.2.2", | |
| 1812 | "objc2-metal 0.2.2", | |
| 1813 | ] | |
| 1814 | ||
| 1815 | [[package]] | |
| 1816 | name = "objc2-quartz-core" | |
| 1817 | version = "0.3.2" | |
| 1818 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1819 | checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" | |
| 1820 | dependencies = [ | |
| 1821 | "bitflags 2.13.1", | |
| 1822 | "objc2 0.6.4", | |
| 1823 | "objc2-core-foundation", | |
| 1824 | "objc2-core-graphics", | |
| 1825 | "objc2-foundation 0.3.2", | |
| 1826 | "objc2-metal 0.3.2", | |
| 1827 | ] | |
| 1828 | ||
| 1829 | [[package]] | |
| 1830 | name = "objc2-symbols" | |
| 1831 | version = "0.2.2" | |
| 1832 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1833 | checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" | |
| 1834 | dependencies = [ | |
| 1835 | "objc2 0.5.2", | |
| 1836 | "objc2-foundation 0.2.2", | |
| 1837 | ] | |
| 1838 | ||
| 1839 | [[package]] | |
| 1840 | name = "objc2-ui-kit" | |
| 1841 | version = "0.2.2" | |
| 1842 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1843 | checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" | |
| 1844 | dependencies = [ | |
| 1845 | "bitflags 2.13.1", | |
| 1846 | "block2 0.5.1", | |
| 1847 | "objc2 0.5.2", | |
| 1848 | "objc2-cloud-kit", | |
| 1849 | "objc2-core-data", | |
| 1850 | "objc2-core-image", | |
| 1851 | "objc2-core-location", | |
| 1852 | "objc2-foundation 0.2.2", | |
| 1853 | "objc2-link-presentation", | |
| 1854 | "objc2-quartz-core 0.2.2", | |
| 1855 | "objc2-symbols", | |
| 1856 | "objc2-uniform-type-identifiers", | |
| 1857 | "objc2-user-notifications", | |
| 1858 | ] | |
| 1859 | ||
| 1860 | [[package]] | |
| 1861 | name = "objc2-uniform-type-identifiers" | |
| 1862 | version = "0.2.2" | |
| 1863 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1864 | checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" | |
| 1865 | dependencies = [ | |
| 1866 | "block2 0.5.1", | |
| 1867 | "objc2 0.5.2", | |
| 1868 | "objc2-foundation 0.2.2", | |
| 1869 | ] | |
| 1870 | ||
| 1871 | [[package]] | |
| 1872 | name = "objc2-user-notifications" | |
| 1873 | version = "0.2.2" | |
| 1874 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1875 | checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" | |
| 1876 | dependencies = [ | |
| 1877 | "bitflags 2.13.1", | |
| 1878 | "block2 0.5.1", | |
| 1879 | "objc2 0.5.2", | |
| 1880 | "objc2-core-location", | |
| 1881 | "objc2-foundation 0.2.2", | |
| 1882 | ] | |
| 1883 | ||
| 1884 | [[package]] | |
| 1885 | name = "once_cell" | |
| 1886 | version = "1.21.4" | |
| 1887 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1888 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" | |
| 1889 | ||
| 1890 | [[package]] | |
| 1891 | name = "one-canvas" | |
| 1892 | version = "0.1.0" | |
| 1893 | dependencies = [ | |
| 1894 | "getrandom 0.4.3", | |
| 1895 | "onestore", | |
| 1896 | "parley", | |
| 1897 | "serde", | |
| 1898 | "serde_json", | |
| 1899 | "skrifa", | |
| 731 | 1900 | ] |
| 732 | 1901 | |
| 733 | 1902 | [[package]] |
| 734 | name = "serde_core" | |
| 735 | version = "1.0.229" | |
| 1903 | name = "one-canvas-app" | |
| 1904 | version = "0.1.0" | |
| 1905 | dependencies = [ | |
| 1906 | "accesskit", | |
| 1907 | "accesskit_consumer", | |
| 1908 | "accesskit_winit", | |
| 1909 | "arboard", | |
| 1910 | "objc2 0.5.2", | |
| 1911 | "objc2-app-kit 0.2.2", | |
| 1912 | "objc2-foundation 0.2.2", | |
| 1913 | "one-canvas", | |
| 1914 | "one-canvas-gpu", | |
| 1915 | "onestore", | |
| 1916 | "parley", | |
| 1917 | "pollster", | |
| 1918 | "wgpu", | |
| 1919 | "winit", | |
| 1920 | ] | |
| 1921 | ||
| 1922 | [[package]] | |
| 1923 | name = "one-canvas-gpu" | |
| 1924 | version = "0.1.0" | |
| 1925 | dependencies = [ | |
| 1926 | "bytemuck", | |
| 1927 | "image", | |
| 1928 | "one-canvas", | |
| 1929 | "onestore", | |
| 1930 | "parley", | |
| 1931 | "png", | |
| 1932 | "pollster", | |
| 1933 | "swash", | |
| 1934 | "wgpu", | |
| 1935 | ] | |
| 1936 | ||
| 1937 | [[package]] | |
| 1938 | name = "onestore" | |
| 1939 | version = "0.1.0" | |
| 1940 | dependencies = [ | |
| 1941 | "aes", | |
| 1942 | "base64", | |
| 1943 | "cbc", | |
| 1944 | "getrandom 0.4.3", | |
| 1945 | "md5", | |
| 1946 | "nix", | |
| 1947 | "roxmltree", | |
| 1948 | "serde", | |
| 1949 | "serde_json", | |
| 1950 | "sha1", | |
| 1951 | "subtle", | |
| 1952 | "zeroize", | |
| 1953 | ] | |
| 1954 | ||
| 1955 | [[package]] | |
| 1956 | name = "onestore-diagnostic" | |
| 1957 | version = "0.1.0" | |
| 1958 | dependencies = [ | |
| 1959 | "onestore", | |
| 1960 | "onestore-notebook", | |
| 1961 | "serde", | |
| 1962 | "serde_json", | |
| 1963 | ] | |
| 1964 | ||
| 1965 | [[package]] | |
| 1966 | name = "onestore-notebook" | |
| 1967 | version = "0.1.0" | |
| 1968 | dependencies = [ | |
| 1969 | "onestore", | |
| 1970 | "onestore-smb", | |
| 1971 | "serde", | |
| 1972 | "serde_json", | |
| 1973 | "tempfile", | |
| 1974 | "thiserror 2.0.20", | |
| 1975 | "zeroize", | |
| 1976 | ] | |
| 1977 | ||
| 1978 | [[package]] | |
| 1979 | name = "onestore-offline" | |
| 1980 | version = "0.1.0" | |
| 1981 | dependencies = [ | |
| 1982 | "onestore", | |
| 1983 | "onestore-notebook", | |
| 1984 | "onestore-smb", | |
| 1985 | "rusqlite", | |
| 1986 | "serde", | |
| 1987 | "serde_json", | |
| 1988 | "sha2", | |
| 1989 | "tempfile", | |
| 1990 | "thiserror 2.0.20", | |
| 1991 | ] | |
| 1992 | ||
| 1993 | [[package]] | |
| 1994 | name = "onestore-smb" | |
| 1995 | version = "0.1.0" | |
| 1996 | dependencies = [ | |
| 1997 | "onestore", | |
| 1998 | "serde_json", | |
| 1999 | "smb2", | |
| 2000 | "tokio", | |
| 2001 | ] | |
| 2002 | ||
| 2003 | [[package]] | |
| 2004 | name = "orbclient" | |
| 2005 | version = "0.3.55" | |
| 2006 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2007 | checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" | |
| 2008 | dependencies = [ | |
| 2009 | "libc", | |
| 2010 | "libredox", | |
| 2011 | ] | |
| 2012 | ||
| 2013 | [[package]] | |
| 2014 | name = "ordered-float" | |
| 2015 | version = "5.5.0" | |
| 2016 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2017 | checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" | |
| 2018 | dependencies = [ | |
| 2019 | "num-traits", | |
| 2020 | ] | |
| 2021 | ||
| 2022 | [[package]] | |
| 2023 | name = "owned_ttf_parser" | |
| 2024 | version = "0.25.1" | |
| 2025 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2026 | checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" | |
| 2027 | dependencies = [ | |
| 2028 | "ttf-parser", | |
| 2029 | ] | |
| 2030 | ||
| 2031 | [[package]] | |
| 2032 | name = "parking_lot" | |
| 2033 | version = "0.12.5" | |
| 2034 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2035 | checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" | |
| 2036 | dependencies = [ | |
| 2037 | "lock_api", | |
| 2038 | "parking_lot_core", | |
| 2039 | ] | |
| 2040 | ||
| 2041 | [[package]] | |
| 2042 | name = "parking_lot_core" | |
| 2043 | version = "0.9.12" | |
| 2044 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2045 | checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" | |
| 2046 | dependencies = [ | |
| 2047 | "cfg-if", | |
| 2048 | "libc", | |
| 2049 | "redox_syscall 0.5.18", | |
| 2050 | "smallvec", | |
| 2051 | "windows-link", | |
| 2052 | ] | |
| 2053 | ||
| 2054 | [[package]] | |
| 2055 | name = "parlance" | |
| 2056 | version = "0.1.0" | |
| 2057 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | |
| 2058 | ||
| 2059 | [[package]] | |
| 2060 | name = "parley" | |
| 2061 | version = "0.11.0" | |
| 2062 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | |
| 2063 | dependencies = [ | |
| 2064 | "fontique", | |
| 2065 | "hashbrown 0.17.1", | |
| 2066 | "linebender_resource_handle", | |
| 2067 | "parlance", | |
| 2068 | "parley_engine", | |
| 2069 | "skrifa", | |
| 2070 | "smallvec", | |
| 2071 | ] | |
| 2072 | ||
| 2073 | [[package]] | |
| 2074 | name = "parley_data" | |
| 2075 | version = "0.11.0" | |
| 2076 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | |
| 2077 | dependencies = [ | |
| 2078 | "icu_properties", | |
| 2079 | ] | |
| 2080 | ||
| 2081 | [[package]] | |
| 2082 | name = "parley_engine" | |
| 2083 | version = "0.11.0" | |
| 2084 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | |
| 2085 | dependencies = [ | |
| 2086 | "fontique", | |
| 2087 | "harfrust", | |
| 2088 | "hashbrown 0.17.1", | |
| 2089 | "icu_normalizer", | |
| 2090 | "icu_properties", | |
| 2091 | "icu_segmenter", | |
| 2092 | "linebender_resource_handle", | |
| 2093 | "parlance", | |
| 2094 | "parley_data", | |
| 2095 | "skrifa", | |
| 2096 | ] | |
| 2097 | ||
| 2098 | [[package]] | |
| 2099 | name = "pbkdf2" | |
| 2100 | version = "0.13.0" | |
| 2101 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2102 | checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" | |
| 2103 | dependencies = [ | |
| 2104 | "digest", | |
| 2105 | "hmac", | |
| 2106 | ] | |
| 2107 | ||
| 2108 | [[package]] | |
| 2109 | name = "percent-encoding" | |
| 2110 | version = "2.3.2" | |
| 2111 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2112 | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" | |
| 2113 | ||
| 2114 | [[package]] | |
| 2115 | name = "pin-project" | |
| 2116 | version = "1.1.13" | |
| 2117 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2118 | checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" | |
| 2119 | dependencies = [ | |
| 2120 | "pin-project-internal", | |
| 2121 | ] | |
| 2122 | ||
| 2123 | [[package]] | |
| 2124 | name = "pin-project-internal" | |
| 2125 | version = "1.1.13" | |
| 2126 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2127 | checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" | |
| 2128 | dependencies = [ | |
| 2129 | "proc-macro2", | |
| 2130 | "quote", | |
| 2131 | "syn 2.0.119", | |
| 2132 | ] | |
| 2133 | ||
| 2134 | [[package]] | |
| 2135 | name = "pin-project-lite" | |
| 2136 | version = "0.2.17" | |
| 2137 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2138 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" | |
| 2139 | ||
| 2140 | [[package]] | |
| 2141 | name = "pkg-config" | |
| 2142 | version = "0.3.34" | |
| 2143 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2144 | checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" | |
| 2145 | ||
| 2146 | [[package]] | |
| 2147 | name = "plain" | |
| 2148 | version = "0.2.3" | |
| 2149 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2150 | checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" | |
| 2151 | ||
| 2152 | [[package]] | |
| 2153 | name = "png" | |
| 2154 | version = "0.18.1" | |
| 2155 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2156 | checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" | |
| 2157 | dependencies = [ | |
| 2158 | "bitflags 2.13.1", | |
| 2159 | "crc32fast", | |
| 2160 | "fdeflate", | |
| 2161 | "flate2", | |
| 2162 | "miniz_oxide 0.8.9", | |
| 2163 | ] | |
| 2164 | ||
| 2165 | [[package]] | |
| 2166 | name = "polling" | |
| 2167 | version = "3.11.0" | |
| 2168 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2169 | checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" | |
| 2170 | dependencies = [ | |
| 2171 | "cfg-if", | |
| 2172 | "concurrent-queue", | |
| 2173 | "hermit-abi", | |
| 2174 | "pin-project-lite", | |
| 2175 | "rustix 1.1.4", | |
| 2176 | "windows-sys 0.61.2", | |
| 2177 | ] | |
| 2178 | ||
| 2179 | [[package]] | |
| 2180 | name = "pollster" | |
| 2181 | version = "0.4.0" | |
| 2182 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2183 | checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" | |
| 2184 | ||
| 2185 | [[package]] | |
| 2186 | name = "polyval" | |
| 2187 | version = "0.7.3" | |
| 2188 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2189 | checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" | |
| 2190 | dependencies = [ | |
| 2191 | "cpubits", | |
| 2192 | "cpufeatures", | |
| 2193 | "universal-hash", | |
| 2194 | ] | |
| 2195 | ||
| 2196 | [[package]] | |
| 2197 | name = "portable-atomic" | |
| 2198 | version = "1.15.0" | |
| 2199 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2200 | checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" | |
| 2201 | ||
| 2202 | [[package]] | |
| 2203 | name = "portable-atomic-util" | |
| 2204 | version = "0.2.8" | |
| 2205 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2206 | checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" | |
| 2207 | dependencies = [ | |
| 2208 | "portable-atomic", | |
| 2209 | ] | |
| 2210 | ||
| 2211 | [[package]] | |
| 2212 | name = "potential_utf" | |
| 2213 | version = "0.1.6" | |
| 2214 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2215 | checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" | |
| 2216 | dependencies = [ | |
| 2217 | "serde_core", | |
| 2218 | "writeable", | |
| 2219 | "zerovec", | |
| 2220 | ] | |
| 2221 | ||
| 2222 | [[package]] | |
| 2223 | name = "presser" | |
| 2224 | version = "0.3.1" | |
| 2225 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2226 | checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" | |
| 2227 | ||
| 2228 | [[package]] | |
| 2229 | name = "proc-macro-crate" | |
| 2230 | version = "3.5.0" | |
| 2231 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2232 | checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" | |
| 2233 | dependencies = [ | |
| 2234 | "toml_edit", | |
| 2235 | ] | |
| 2236 | ||
| 2237 | [[package]] | |
| 2238 | name = "proc-macro2" | |
| 2239 | version = "1.0.107" | |
| 2240 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2241 | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | |
| 2242 | dependencies = [ | |
| 2243 | "unicode-ident", | |
| 2244 | ] | |
| 2245 | ||
| 2246 | [[package]] | |
| 2247 | name = "profiling" | |
| 2248 | version = "1.0.18" | |
| 2249 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2250 | checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" | |
| 2251 | ||
| 2252 | [[package]] | |
| 2253 | name = "pxfm" | |
| 2254 | version = "0.1.30" | |
| 2255 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2256 | checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" | |
| 2257 | ||
| 2258 | [[package]] | |
| 2259 | name = "quick-xml" | |
| 2260 | version = "0.41.0" | |
| 2261 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2262 | checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" | |
| 2263 | dependencies = [ | |
| 2264 | "memchr", | |
| 2265 | ] | |
| 2266 | ||
| 2267 | [[package]] | |
| 2268 | name = "quote" | |
| 2269 | version = "1.0.47" | |
| 2270 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2271 | checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | |
| 2272 | dependencies = [ | |
| 2273 | "proc-macro2", | |
| 2274 | ] | |
| 2275 | ||
| 2276 | [[package]] | |
| 2277 | name = "r-efi" | |
| 2278 | version = "5.3.0" | |
| 2279 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2280 | checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" | |
| 2281 | ||
| 2282 | [[package]] | |
| 2283 | name = "r-efi" | |
| 2284 | version = "6.0.0" | |
| 2285 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2286 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |
| 2287 | ||
| 2288 | [[package]] | |
| 2289 | name = "rand_core" | |
| 2290 | version = "0.10.1" | |
| 2291 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2292 | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" | |
| 2293 | ||
| 2294 | [[package]] | |
| 2295 | name = "range-alloc" | |
| 2296 | version = "0.1.5" | |
| 2297 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2298 | checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" | |
| 2299 | ||
| 2300 | [[package]] | |
| 2301 | name = "raw-window-handle" | |
| 2302 | version = "0.6.2" | |
| 2303 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2304 | checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" | |
| 2305 | ||
| 2306 | [[package]] | |
| 2307 | name = "raw-window-metal" | |
| 2308 | version = "1.1.0" | |
| 2309 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2310 | checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" | |
| 2311 | dependencies = [ | |
| 2312 | "objc2 0.6.4", | |
| 2313 | "objc2-core-foundation", | |
| 2314 | "objc2-foundation 0.3.2", | |
| 2315 | "objc2-quartz-core 0.3.2", | |
| 2316 | ] | |
| 2317 | ||
| 2318 | [[package]] | |
| 2319 | name = "read-fonts" | |
| 2320 | version = "0.41.0" | |
| 2321 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2322 | checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" | |
| 2323 | dependencies = [ | |
| 2324 | "bytemuck", | |
| 2325 | "font-types", | |
| 2326 | "once_cell", | |
| 2327 | ] | |
| 2328 | ||
| 2329 | [[package]] | |
| 2330 | name = "redox_syscall" | |
| 2331 | version = "0.4.1" | |
| 2332 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2333 | checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" | |
| 2334 | dependencies = [ | |
| 2335 | "bitflags 1.3.2", | |
| 2336 | ] | |
| 2337 | ||
| 2338 | [[package]] | |
| 2339 | name = "redox_syscall" | |
| 2340 | version = "0.5.18" | |
| 2341 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2342 | checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" | |
| 2343 | dependencies = [ | |
| 2344 | "bitflags 2.13.1", | |
| 2345 | ] | |
| 2346 | ||
| 2347 | [[package]] | |
| 2348 | name = "redox_syscall" | |
| 2349 | version = "0.9.4" | |
| 2350 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2351 | checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" | |
| 2352 | dependencies = [ | |
| 2353 | "bitflags 2.13.1", | |
| 2354 | ] | |
| 2355 | ||
| 2356 | [[package]] | |
| 2357 | name = "renderdoc-sys" | |
| 2358 | version = "1.1.0" | |
| 2359 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2360 | checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" | |
| 2361 | ||
| 2362 | [[package]] | |
| 2363 | name = "roxmltree" | |
| 2364 | version = "0.21.1" | |
| 2365 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2366 | checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" | |
| 2367 | dependencies = [ | |
| 2368 | "memchr", | |
| 2369 | ] | |
| 2370 | ||
| 2371 | [[package]] | |
| 2372 | name = "rsqlite-vfs" | |
| 2373 | version = "0.1.1" | |
| 2374 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2375 | checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" | |
| 2376 | dependencies = [ | |
| 2377 | "hashbrown 0.16.1", | |
| 2378 | "thiserror 2.0.20", | |
| 2379 | ] | |
| 2380 | ||
| 2381 | [[package]] | |
| 2382 | name = "rusqlite" | |
| 2383 | version = "0.40.2" | |
| 2384 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2385 | checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" | |
| 2386 | dependencies = [ | |
| 2387 | "bitflags 2.13.1", | |
| 2388 | "fallible-iterator", | |
| 2389 | "fallible-streaming-iterator", | |
| 2390 | "hashlink", | |
| 2391 | "libsqlite3-sys", | |
| 2392 | "smallvec", | |
| 2393 | "sqlite-wasm-rs", | |
| 2394 | ] | |
| 2395 | ||
| 2396 | [[package]] | |
| 2397 | name = "rustc-hash" | |
| 2398 | version = "1.1.0" | |
| 2399 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2400 | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" | |
| 2401 | ||
| 2402 | [[package]] | |
| 2403 | name = "rustc_version" | |
| 2404 | version = "0.4.1" | |
| 2405 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2406 | checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" | |
| 2407 | dependencies = [ | |
| 2408 | "semver", | |
| 2409 | ] | |
| 2410 | ||
| 2411 | [[package]] | |
| 2412 | name = "rustix" | |
| 2413 | version = "0.38.44" | |
| 2414 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2415 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" | |
| 2416 | dependencies = [ | |
| 2417 | "bitflags 2.13.1", | |
| 2418 | "errno", | |
| 2419 | "libc", | |
| 2420 | "linux-raw-sys 0.4.15", | |
| 2421 | "windows-sys 0.59.0", | |
| 2422 | ] | |
| 2423 | ||
| 2424 | [[package]] | |
| 2425 | name = "rustix" | |
| 2426 | version = "1.1.4" | |
| 2427 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2428 | checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" | |
| 2429 | dependencies = [ | |
| 2430 | "bitflags 2.13.1", | |
| 2431 | "errno", | |
| 2432 | "libc", | |
| 2433 | "linux-raw-sys 0.12.1", | |
| 2434 | "windows-sys 0.61.2", | |
| 2435 | ] | |
| 2436 | ||
| 2437 | [[package]] | |
| 2438 | name = "rustversion" | |
| 2439 | version = "1.0.23" | |
| 2440 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2441 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" | |
| 2442 | ||
| 2443 | [[package]] | |
| 2444 | name = "same-file" | |
| 2445 | version = "1.0.6" | |
| 2446 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2447 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" | |
| 2448 | dependencies = [ | |
| 2449 | "winapi-util", | |
| 2450 | ] | |
| 2451 | ||
| 2452 | [[package]] | |
| 2453 | name = "scoped-tls" | |
| 2454 | version = "1.0.1" | |
| 2455 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2456 | checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" | |
| 2457 | ||
| 2458 | [[package]] | |
| 2459 | name = "scopeguard" | |
| 2460 | version = "1.2.0" | |
| 2461 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2462 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" | |
| 2463 | ||
| 2464 | [[package]] | |
| 2465 | name = "sctk-adwaita" | |
| 2466 | version = "0.10.1" | |
| 2467 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2468 | checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" | |
| 2469 | dependencies = [ | |
| 2470 | "ab_glyph", | |
| 2471 | "log", | |
| 2472 | "memmap2", | |
| 2473 | "smithay-client-toolkit", | |
| 2474 | "tiny-skia", | |
| 2475 | ] | |
| 2476 | ||
| 2477 | [[package]] | |
| 2478 | name = "semver" | |
| 2479 | version = "1.0.28" | |
| 2480 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2481 | checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" | |
| 2482 | ||
| 2483 | [[package]] | |
| 2484 | name = "serde" | |
| 2485 | version = "1.0.229" | |
| 2486 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2487 | checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" | |
| 2488 | dependencies = [ | |
| 2489 | "serde_core", | |
| 2490 | "serde_derive", | |
| 2491 | ] | |
| 2492 | ||
| 2493 | [[package]] | |
| 2494 | name = "serde_core" | |
| 2495 | version = "1.0.229" | |
| 2496 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2497 | checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" | |
| 2498 | dependencies = [ | |
| 2499 | "serde_derive", | |
| 2500 | ] | |
| 2501 | ||
| 2502 | [[package]] | |
| 2503 | name = "serde_derive" | |
| 2504 | version = "1.0.229" | |
| 2505 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2506 | checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" | |
| 2507 | dependencies = [ | |
| 2508 | "proc-macro2", | |
| 2509 | "quote", | |
| 2510 | "syn 3.0.5", | |
| 2511 | ] | |
| 2512 | ||
| 2513 | [[package]] | |
| 2514 | name = "serde_json" | |
| 2515 | version = "1.0.151" | |
| 2516 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2517 | checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" | |
| 2518 | dependencies = [ | |
| 2519 | "itoa", | |
| 2520 | "memchr", | |
| 2521 | "serde", | |
| 2522 | "serde_core", | |
| 2523 | "zmij", | |
| 2524 | ] | |
| 2525 | ||
| 2526 | [[package]] | |
| 2527 | name = "sha1" | |
| 2528 | version = "0.11.0" | |
| 2529 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2530 | checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" | |
| 2531 | dependencies = [ | |
| 2532 | "cfg-if", | |
| 2533 | "cpufeatures", | |
| 2534 | "digest", | |
| 2535 | ] | |
| 2536 | ||
| 2537 | [[package]] | |
| 2538 | name = "sha2" | |
| 2539 | version = "0.11.0" | |
| 2540 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2541 | checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" | |
| 2542 | dependencies = [ | |
| 2543 | "cfg-if", | |
| 2544 | "cpufeatures", | |
| 2545 | "digest", | |
| 2546 | ] | |
| 2547 | ||
| 2548 | [[package]] | |
| 2549 | name = "shlex" | |
| 2550 | version = "2.0.1" | |
| 2551 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2552 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 2553 | ||
| 2554 | [[package]] | |
| 2555 | name = "simd-adler32" | |
| 2556 | version = "0.3.10" | |
| 2557 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2558 | checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" | |
| 2559 | ||
| 2560 | [[package]] | |
| 2561 | name = "simd_cesu8" | |
| 2562 | version = "1.2.0" | |
| 2563 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2564 | checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" | |
| 2565 | dependencies = [ | |
| 2566 | "rustc_version", | |
| 2567 | "simdutf8", | |
| 2568 | ] | |
| 2569 | ||
| 2570 | [[package]] | |
| 2571 | name = "simdutf8" | |
| 2572 | version = "0.1.5" | |
| 2573 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2574 | checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" | |
| 2575 | ||
| 2576 | [[package]] | |
| 2577 | name = "skrifa" | |
| 2578 | version = "0.44.0" | |
| 2579 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2580 | checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" | |
| 2581 | dependencies = [ | |
| 2582 | "bytemuck", | |
| 2583 | "read-fonts", | |
| 2584 | ] | |
| 2585 | ||
| 2586 | [[package]] | |
| 2587 | name = "slab" | |
| 2588 | version = "0.4.12" | |
| 2589 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2590 | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | |
| 2591 | ||
| 2592 | [[package]] | |
| 2593 | name = "slotmap" | |
| 2594 | version = "1.1.1" | |
| 2595 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2596 | checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" | |
| 2597 | dependencies = [ | |
| 2598 | "version_check", | |
| 2599 | ] | |
| 2600 | ||
| 2601 | [[package]] | |
| 2602 | name = "smallvec" | |
| 2603 | version = "1.16.0" | |
| 2604 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2605 | checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" | |
| 2606 | ||
| 2607 | [[package]] | |
| 2608 | name = "smb2" | |
| 2609 | version = "0.21.0" | |
| 2610 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2611 | checksum = "9ef4f20cff3d39a131335d17df6146eada7851d7705252f2765b8717a0f52db8" | |
| 2612 | dependencies = [ | |
| 2613 | "aes", | |
| 2614 | "aes-gcm", | |
| 2615 | "async-trait", | |
| 2616 | "ccm", | |
| 2617 | "cmac", | |
| 2618 | "digest", | |
| 2619 | "futures-util", | |
| 2620 | "getrandom 0.4.3", | |
| 2621 | "hmac", | |
| 2622 | "log", | |
| 2623 | "lz4_flex", | |
| 2624 | "md-5", | |
| 2625 | "md4", | |
| 2626 | "num_enum", | |
| 2627 | "pbkdf2", | |
| 2628 | "sha1", | |
| 2629 | "sha2", | |
| 2630 | "thiserror 2.0.20", | |
| 2631 | "tokio", | |
| 2632 | ] | |
| 2633 | ||
| 2634 | [[package]] | |
| 2635 | name = "smithay-client-toolkit" | |
| 2636 | version = "0.19.2" | |
| 2637 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2638 | checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" | |
| 2639 | dependencies = [ | |
| 2640 | "bitflags 2.13.1", | |
| 2641 | "calloop", | |
| 2642 | "calloop-wayland-source", | |
| 2643 | "cursor-icon", | |
| 2644 | "libc", | |
| 2645 | "log", | |
| 2646 | "memmap2", | |
| 2647 | "rustix 0.38.44", | |
| 2648 | "thiserror 1.0.69", | |
| 2649 | "wayland-backend", | |
| 2650 | "wayland-client", | |
| 2651 | "wayland-csd-frame", | |
| 2652 | "wayland-cursor", | |
| 2653 | "wayland-protocols", | |
| 2654 | "wayland-protocols-wlr", | |
| 2655 | "wayland-scanner", | |
| 2656 | "xkeysym", | |
| 2657 | ] | |
| 2658 | ||
| 2659 | [[package]] | |
| 2660 | name = "smol_str" | |
| 2661 | version = "0.2.2" | |
| 2662 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2663 | checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" | |
| 2664 | dependencies = [ | |
| 2665 | "serde", | |
| 2666 | ] | |
| 2667 | ||
| 2668 | [[package]] | |
| 2669 | name = "socket2" | |
| 2670 | version = "0.6.5" | |
| 2671 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2672 | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | |
| 2673 | dependencies = [ | |
| 2674 | "libc", | |
| 2675 | "windows-sys 0.61.2", | |
| 2676 | ] | |
| 2677 | ||
| 2678 | [[package]] | |
| 2679 | name = "spirv" | |
| 2680 | version = "0.4.0+sdk-1.4.341.0" | |
| 2681 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2682 | checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" | |
| 2683 | dependencies = [ | |
| 2684 | "bitflags 2.13.1", | |
| 2685 | ] | |
| 2686 | ||
| 2687 | [[package]] | |
| 2688 | name = "sqlite-wasm-rs" | |
| 2689 | version = "0.5.5" | |
| 2690 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2691 | checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" | |
| 2692 | dependencies = [ | |
| 2693 | "cc", | |
| 2694 | "js-sys", | |
| 2695 | "rsqlite-vfs", | |
| 2696 | "wasm-bindgen", | |
| 2697 | ] | |
| 2698 | ||
| 2699 | [[package]] | |
| 2700 | name = "stable_deref_trait" | |
| 2701 | version = "1.2.1" | |
| 2702 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2703 | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" | |
| 2704 | ||
| 2705 | [[package]] | |
| 2706 | name = "static_assertions" | |
| 2707 | version = "1.1.0" | |
| 2708 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2709 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" | |
| 2710 | ||
| 2711 | [[package]] | |
| 2712 | name = "strict-num" | |
| 2713 | version = "0.1.1" | |
| 2714 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2715 | checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" | |
| 2716 | ||
| 2717 | [[package]] | |
| 2718 | name = "subtle" | |
| 2719 | version = "2.6.1" | |
| 2720 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2721 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" | |
| 2722 | ||
| 2723 | [[package]] | |
| 2724 | name = "swash" | |
| 2725 | version = "0.2.10" | |
| 2726 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2727 | checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" | |
| 2728 | dependencies = [ | |
| 2729 | "skrifa", | |
| 2730 | "yazi", | |
| 2731 | "zeno", | |
| 2732 | ] | |
| 2733 | ||
| 2734 | [[package]] | |
| 2735 | name = "syn" | |
| 2736 | version = "2.0.119" | |
| 2737 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2738 | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 2739 | dependencies = [ | |
| 2740 | "proc-macro2", | |
| 2741 | "quote", | |
| 2742 | "unicode-ident", | |
| 2743 | ] | |
| 2744 | ||
| 2745 | [[package]] | |
| 2746 | name = "syn" | |
| 2747 | version = "3.0.5" | |
| 2748 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2749 | checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" | |
| 2750 | dependencies = [ | |
| 2751 | "proc-macro2", | |
| 2752 | "quote", | |
| 2753 | "unicode-ident", | |
| 2754 | ] | |
| 2755 | ||
| 2756 | [[package]] | |
| 2757 | name = "synstructure" | |
| 2758 | version = "0.13.2" | |
| 2759 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2760 | checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" | |
| 2761 | dependencies = [ | |
| 2762 | "proc-macro2", | |
| 2763 | "quote", | |
| 2764 | "syn 2.0.119", | |
| 2765 | ] | |
| 2766 | ||
| 2767 | [[package]] | |
| 2768 | name = "tempfile" | |
| 2769 | version = "3.27.0" | |
| 2770 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2771 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 2772 | dependencies = [ | |
| 2773 | "fastrand", | |
| 2774 | "getrandom 0.4.3", | |
| 2775 | "once_cell", | |
| 2776 | "rustix 1.1.4", | |
| 2777 | "windows-sys 0.61.2", | |
| 2778 | ] | |
| 2779 | ||
| 2780 | [[package]] | |
| 2781 | name = "termcolor" | |
| 2782 | version = "1.4.1" | |
| 2783 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2784 | checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" | |
| 2785 | dependencies = [ | |
| 2786 | "winapi-util", | |
| 2787 | ] | |
| 2788 | ||
| 2789 | [[package]] | |
| 2790 | name = "thiserror" | |
| 2791 | version = "1.0.69" | |
| 2792 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2793 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" | |
| 2794 | dependencies = [ | |
| 2795 | "thiserror-impl 1.0.69", | |
| 2796 | ] | |
| 2797 | ||
| 2798 | [[package]] | |
| 2799 | name = "thiserror" | |
| 2800 | version = "2.0.20" | |
| 2801 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2802 | checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" | |
| 2803 | dependencies = [ | |
| 2804 | "thiserror-impl 2.0.20", | |
| 2805 | ] | |
| 2806 | ||
| 2807 | [[package]] | |
| 2808 | name = "thiserror-impl" | |
| 2809 | version = "1.0.69" | |
| 2810 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2811 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" | |
| 2812 | dependencies = [ | |
| 2813 | "proc-macro2", | |
| 2814 | "quote", | |
| 2815 | "syn 2.0.119", | |
| 2816 | ] | |
| 2817 | ||
| 2818 | [[package]] | |
| 2819 | name = "thiserror-impl" | |
| 2820 | version = "2.0.20" | |
| 2821 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2822 | checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" | |
| 2823 | dependencies = [ | |
| 2824 | "proc-macro2", | |
| 2825 | "quote", | |
| 2826 | "syn 3.0.5", | |
| 2827 | ] | |
| 2828 | ||
| 2829 | [[package]] | |
| 2830 | name = "tiny-skia" | |
| 2831 | version = "0.11.4" | |
| 2832 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2833 | checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" | |
| 2834 | dependencies = [ | |
| 2835 | "arrayref", | |
| 2836 | "arrayvec", | |
| 2837 | "bytemuck", | |
| 2838 | "cfg-if", | |
| 2839 | "log", | |
| 2840 | "tiny-skia-path", | |
| 2841 | ] | |
| 2842 | ||
| 2843 | [[package]] | |
| 2844 | name = "tiny-skia-path" | |
| 2845 | version = "0.11.4" | |
| 2846 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2847 | checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" | |
| 2848 | dependencies = [ | |
| 2849 | "arrayref", | |
| 2850 | "bytemuck", | |
| 2851 | "strict-num", | |
| 2852 | ] | |
| 2853 | ||
| 2854 | [[package]] | |
| 2855 | name = "tinystr" | |
| 2856 | version = "0.8.4" | |
| 2857 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2858 | checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" | |
| 2859 | dependencies = [ | |
| 2860 | "displaydoc", | |
| 2861 | "serde_core", | |
| 2862 | "zerovec", | |
| 2863 | ] | |
| 2864 | ||
| 2865 | [[package]] | |
| 2866 | name = "tokio" | |
| 2867 | version = "1.53.1" | |
| 2868 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2869 | checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" | |
| 2870 | dependencies = [ | |
| 2871 | "bytes", | |
| 2872 | "libc", | |
| 2873 | "mio", | |
| 2874 | "pin-project-lite", | |
| 2875 | "socket2", | |
| 2876 | "windows-sys 0.61.2", | |
| 2877 | ] | |
| 2878 | ||
| 2879 | [[package]] | |
| 2880 | name = "toml_datetime" | |
| 2881 | version = "1.1.1+spec-1.1.0" | |
| 2882 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2883 | checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" | |
| 2884 | dependencies = [ | |
| 2885 | "serde_core", | |
| 2886 | ] | |
| 2887 | ||
| 2888 | [[package]] | |
| 2889 | name = "toml_edit" | |
| 2890 | version = "0.25.13+spec-1.1.0" | |
| 2891 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2892 | checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" | |
| 2893 | dependencies = [ | |
| 2894 | "indexmap", | |
| 2895 | "toml_datetime", | |
| 2896 | "toml_parser", | |
| 2897 | "winnow", | |
| 2898 | ] | |
| 2899 | ||
| 2900 | [[package]] | |
| 2901 | name = "toml_parser" | |
| 2902 | version = "1.1.3+spec-1.1.0" | |
| 2903 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2904 | checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" | |
| 2905 | dependencies = [ | |
| 2906 | "winnow", | |
| 2907 | ] | |
| 2908 | ||
| 2909 | [[package]] | |
| 2910 | name = "tracing" | |
| 2911 | version = "0.1.44" | |
| 2912 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2913 | checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" | |
| 2914 | dependencies = [ | |
| 2915 | "pin-project-lite", | |
| 2916 | "tracing-core", | |
| 2917 | ] | |
| 2918 | ||
| 2919 | [[package]] | |
| 2920 | name = "tracing-core" | |
| 2921 | version = "0.1.36" | |
| 2922 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2923 | checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" | |
| 2924 | ||
| 2925 | [[package]] | |
| 2926 | name = "ttf-parser" | |
| 2927 | version = "0.25.1" | |
| 2928 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2929 | checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" | |
| 2930 | ||
| 2931 | [[package]] | |
| 2932 | name = "twox-hash" | |
| 2933 | version = "2.1.4" | |
| 2934 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2935 | checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" | |
| 2936 | ||
| 2937 | [[package]] | |
| 2938 | name = "typenum" | |
| 2939 | version = "1.20.1" | |
| 2940 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2941 | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" | |
| 2942 | ||
| 2943 | [[package]] | |
| 2944 | name = "unicode-ident" | |
| 2945 | version = "1.0.24" | |
| 2946 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2947 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 2948 | ||
| 2949 | [[package]] | |
| 2950 | name = "unicode-segmentation" | |
| 2951 | version = "1.13.3" | |
| 2952 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2953 | checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" | |
| 2954 | ||
| 2955 | [[package]] | |
| 2956 | name = "unicode-width" | |
| 2957 | version = "0.2.2" | |
| 2958 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2959 | checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" | |
| 2960 | ||
| 2961 | [[package]] | |
| 2962 | name = "universal-hash" | |
| 2963 | version = "0.6.1" | |
| 2964 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2965 | checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" | |
| 2966 | dependencies = [ | |
| 2967 | "crypto-common", | |
| 2968 | "ctutils", | |
| 2969 | ] | |
| 2970 | ||
| 2971 | [[package]] | |
| 2972 | name = "utf8_iter" | |
| 2973 | version = "1.0.4" | |
| 2974 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2975 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | |
| 2976 | ||
| 2977 | [[package]] | |
| 2978 | name = "uuid" | |
| 2979 | version = "1.26.1" | |
| 2980 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2981 | checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" | |
| 2982 | ||
| 2983 | [[package]] | |
| 2984 | name = "vcpkg" | |
| 2985 | version = "0.2.15" | |
| 2986 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2987 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | |
| 2988 | ||
| 2989 | [[package]] | |
| 2990 | name = "version_check" | |
| 2991 | version = "0.9.5" | |
| 2992 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2993 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" | |
| 2994 | ||
| 2995 | [[package]] | |
| 2996 | name = "walkdir" | |
| 2997 | version = "2.5.0" | |
| 2998 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2999 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" | |
| 3000 | dependencies = [ | |
| 3001 | "same-file", | |
| 3002 | "winapi-util", | |
| 3003 | ] | |
| 3004 | ||
| 3005 | [[package]] | |
| 3006 | name = "wasi" | |
| 3007 | version = "0.11.1+wasi-snapshot-preview1" | |
| 3008 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3009 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" | |
| 3010 | ||
| 3011 | [[package]] | |
| 3012 | name = "wasip2" | |
| 3013 | version = "1.0.4+wasi-0.2.12" | |
| 3014 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3015 | checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" | |
| 3016 | dependencies = [ | |
| 3017 | "wit-bindgen", | |
| 3018 | ] | |
| 3019 | ||
| 3020 | [[package]] | |
| 3021 | name = "wasm-bindgen" | |
| 3022 | version = "0.2.128" | |
| 3023 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3024 | checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" | |
| 3025 | dependencies = [ | |
| 3026 | "cfg-if", | |
| 3027 | "once_cell", | |
| 3028 | "rustversion", | |
| 3029 | "wasm-bindgen-macro", | |
| 3030 | "wasm-bindgen-shared", | |
| 3031 | ] | |
| 3032 | ||
| 3033 | [[package]] | |
| 3034 | name = "wasm-bindgen-futures" | |
| 3035 | version = "0.4.78" | |
| 3036 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3037 | checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" | |
| 3038 | dependencies = [ | |
| 3039 | "js-sys", | |
| 3040 | "wasm-bindgen", | |
| 3041 | ] | |
| 3042 | ||
| 3043 | [[package]] | |
| 3044 | name = "wasm-bindgen-macro" | |
| 3045 | version = "0.2.128" | |
| 3046 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3047 | checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" | |
| 3048 | dependencies = [ | |
| 3049 | "quote", | |
| 3050 | "wasm-bindgen-macro-support", | |
| 3051 | ] | |
| 3052 | ||
| 3053 | [[package]] | |
| 3054 | name = "wasm-bindgen-macro-support" | |
| 3055 | version = "0.2.128" | |
| 3056 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3057 | checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" | |
| 3058 | dependencies = [ | |
| 3059 | "bumpalo", | |
| 3060 | "proc-macro2", | |
| 3061 | "quote", | |
| 3062 | "syn 3.0.5", | |
| 3063 | "wasm-bindgen-shared", | |
| 3064 | ] | |
| 3065 | ||
| 3066 | [[package]] | |
| 3067 | name = "wasm-bindgen-shared" | |
| 3068 | version = "0.2.128" | |
| 3069 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3070 | checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" | |
| 3071 | dependencies = [ | |
| 3072 | "unicode-ident", | |
| 3073 | ] | |
| 3074 | ||
| 3075 | [[package]] | |
| 3076 | name = "wayland-backend" | |
| 3077 | version = "0.3.17" | |
| 3078 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3079 | checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" | |
| 3080 | dependencies = [ | |
| 3081 | "cc", | |
| 3082 | "downcast-rs", | |
| 3083 | "rustix 1.1.4", | |
| 3084 | "scoped-tls", | |
| 3085 | "smallvec", | |
| 3086 | "wayland-sys", | |
| 3087 | ] | |
| 3088 | ||
| 3089 | [[package]] | |
| 3090 | name = "wayland-client" | |
| 3091 | version = "0.31.15" | |
| 3092 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3093 | checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" | |
| 3094 | dependencies = [ | |
| 3095 | "bitflags 2.13.1", | |
| 3096 | "rustix 1.1.4", | |
| 3097 | "wayland-backend", | |
| 3098 | "wayland-scanner", | |
| 3099 | ] | |
| 3100 | ||
| 3101 | [[package]] | |
| 3102 | name = "wayland-csd-frame" | |
| 3103 | version = "0.3.0" | |
| 3104 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3105 | checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" | |
| 3106 | dependencies = [ | |
| 3107 | "bitflags 2.13.1", | |
| 3108 | "cursor-icon", | |
| 3109 | "wayland-backend", | |
| 3110 | ] | |
| 3111 | ||
| 3112 | [[package]] | |
| 3113 | name = "wayland-cursor" | |
| 3114 | version = "0.31.14" | |
| 3115 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3116 | checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" | |
| 3117 | dependencies = [ | |
| 3118 | "rustix 1.1.4", | |
| 3119 | "wayland-client", | |
| 3120 | "xcursor", | |
| 3121 | ] | |
| 3122 | ||
| 3123 | [[package]] | |
| 3124 | name = "wayland-protocols" | |
| 3125 | version = "0.32.13" | |
| 3126 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3127 | checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" | |
| 3128 | dependencies = [ | |
| 3129 | "bitflags 2.13.1", | |
| 3130 | "wayland-backend", | |
| 3131 | "wayland-client", | |
| 3132 | "wayland-scanner", | |
| 3133 | ] | |
| 3134 | ||
| 3135 | [[package]] | |
| 3136 | name = "wayland-protocols-plasma" | |
| 3137 | version = "0.3.12" | |
| 3138 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3139 | checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" | |
| 3140 | dependencies = [ | |
| 3141 | "bitflags 2.13.1", | |
| 3142 | "wayland-backend", | |
| 3143 | "wayland-client", | |
| 3144 | "wayland-protocols", | |
| 3145 | "wayland-scanner", | |
| 3146 | ] | |
| 3147 | ||
| 3148 | [[package]] | |
| 3149 | name = "wayland-protocols-wlr" | |
| 3150 | version = "0.3.12" | |
| 3151 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3152 | checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" | |
| 3153 | dependencies = [ | |
| 3154 | "bitflags 2.13.1", | |
| 3155 | "wayland-backend", | |
| 3156 | "wayland-client", | |
| 3157 | "wayland-protocols", | |
| 3158 | "wayland-scanner", | |
| 3159 | ] | |
| 3160 | ||
| 3161 | [[package]] | |
| 3162 | name = "wayland-scanner" | |
| 3163 | version = "0.31.11" | |
| 3164 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3165 | checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" | |
| 3166 | dependencies = [ | |
| 3167 | "proc-macro2", | |
| 3168 | "quick-xml", | |
| 3169 | "quote", | |
| 3170 | ] | |
| 3171 | ||
| 3172 | [[package]] | |
| 3173 | name = "wayland-sys" | |
| 3174 | version = "0.31.11" | |
| 3175 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3176 | checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" | |
| 3177 | dependencies = [ | |
| 3178 | "dlib", | |
| 3179 | "log", | |
| 3180 | "once_cell", | |
| 3181 | "pkg-config", | |
| 3182 | ] | |
| 3183 | ||
| 3184 | [[package]] | |
| 3185 | name = "web-sys" | |
| 3186 | version = "0.3.105" | |
| 3187 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3188 | checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" | |
| 3189 | dependencies = [ | |
| 3190 | "js-sys", | |
| 3191 | "wasm-bindgen", | |
| 3192 | ] | |
| 3193 | ||
| 3194 | [[package]] | |
| 3195 | name = "web-time" | |
| 3196 | version = "1.1.0" | |
| 3197 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3198 | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" | |
| 3199 | dependencies = [ | |
| 3200 | "js-sys", | |
| 3201 | "wasm-bindgen", | |
| 3202 | ] | |
| 3203 | ||
| 3204 | [[package]] | |
| 3205 | name = "wgpu" | |
| 3206 | version = "30.0.1" | |
| 3207 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3208 | checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" | |
| 3209 | dependencies = [ | |
| 3210 | "arrayvec", | |
| 3211 | "bitflags 2.13.1", | |
| 3212 | "bytemuck", | |
| 3213 | "cfg-if", | |
| 3214 | "cfg_aliases", | |
| 3215 | "document-features", | |
| 3216 | "hashbrown 0.17.1", | |
| 3217 | "js-sys", | |
| 3218 | "log", | |
| 3219 | "naga", | |
| 3220 | "parking_lot", | |
| 3221 | "portable-atomic", | |
| 3222 | "profiling", | |
| 3223 | "raw-window-handle", | |
| 3224 | "smallvec", | |
| 3225 | "static_assertions", | |
| 3226 | "wasm-bindgen", | |
| 3227 | "wasm-bindgen-futures", | |
| 3228 | "web-sys", | |
| 3229 | "wgpu-core", | |
| 3230 | "wgpu-hal", | |
| 3231 | "wgpu-types", | |
| 3232 | ] | |
| 3233 | ||
| 3234 | [[package]] | |
| 3235 | name = "wgpu-core" | |
| 3236 | version = "30.0.1" | |
| 3237 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3238 | checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" | |
| 3239 | dependencies = [ | |
| 3240 | "arrayvec", | |
| 3241 | "bit-set", | |
| 3242 | "bit-vec", | |
| 3243 | "bitflags 2.13.1", | |
| 3244 | "bytemuck", | |
| 3245 | "cfg_aliases", | |
| 3246 | "document-features", | |
| 3247 | "hashbrown 0.17.1", | |
| 3248 | "indexmap", | |
| 3249 | "log", | |
| 3250 | "naga", | |
| 3251 | "naga-types", | |
| 3252 | "once_cell", | |
| 3253 | "parking_lot", | |
| 3254 | "portable-atomic", | |
| 3255 | "profiling", | |
| 3256 | "raw-window-handle", | |
| 3257 | "rustc-hash", | |
| 3258 | "smallvec", | |
| 3259 | "thiserror 2.0.20", | |
| 3260 | "wgpu-core-deps-apple", | |
| 3261 | "wgpu-core-deps-emscripten", | |
| 3262 | "wgpu-core-deps-windows-linux-android", | |
| 3263 | "wgpu-hal", | |
| 3264 | "wgpu-naga-bridge", | |
| 3265 | "wgpu-types", | |
| 3266 | ] | |
| 3267 | ||
| 3268 | [[package]] | |
| 3269 | name = "wgpu-core-deps-apple" | |
| 3270 | version = "30.0.1" | |
| 3271 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3272 | checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" | |
| 3273 | dependencies = [ | |
| 3274 | "wgpu-hal", | |
| 3275 | ] | |
| 3276 | ||
| 3277 | [[package]] | |
| 3278 | name = "wgpu-core-deps-emscripten" | |
| 3279 | version = "30.0.1" | |
| 3280 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3281 | checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" | |
| 3282 | dependencies = [ | |
| 3283 | "wgpu-hal", | |
| 3284 | ] | |
| 3285 | ||
| 3286 | [[package]] | |
| 3287 | name = "wgpu-core-deps-windows-linux-android" | |
| 3288 | version = "30.0.1" | |
| 3289 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3290 | checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" | |
| 3291 | dependencies = [ | |
| 3292 | "wgpu-hal", | |
| 3293 | ] | |
| 3294 | ||
| 3295 | [[package]] | |
| 3296 | name = "wgpu-hal" | |
| 3297 | version = "30.0.1" | |
| 3298 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3299 | checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" | |
| 3300 | dependencies = [ | |
| 3301 | "android_system_properties", | |
| 3302 | "arrayvec", | |
| 3303 | "ash", | |
| 3304 | "bit-set", | |
| 3305 | "bitflags 2.13.1", | |
| 3306 | "block2 0.6.2", | |
| 3307 | "bytemuck", | |
| 3308 | "cfg-if", | |
| 3309 | "cfg_aliases", | |
| 3310 | "glow", | |
| 3311 | "glutin_wgl_sys", | |
| 3312 | "gpu-allocator", | |
| 3313 | "hashbrown 0.17.1", | |
| 3314 | "js-sys", | |
| 3315 | "khronos-egl", | |
| 3316 | "libc", | |
| 3317 | "libloading", | |
| 3318 | "log", | |
| 3319 | "naga", | |
| 3320 | "naga-types", | |
| 3321 | "ndk-sys", | |
| 3322 | "objc2 0.6.4", | |
| 3323 | "objc2-core-foundation", | |
| 3324 | "objc2-core-graphics", | |
| 3325 | "objc2-foundation 0.3.2", | |
| 3326 | "objc2-metal 0.3.2", | |
| 3327 | "objc2-quartz-core 0.3.2", | |
| 3328 | "once_cell", | |
| 3329 | "ordered-float", | |
| 3330 | "parking_lot", | |
| 3331 | "portable-atomic", | |
| 3332 | "portable-atomic-util", | |
| 3333 | "profiling", | |
| 3334 | "range-alloc", | |
| 3335 | "raw-window-handle", | |
| 3336 | "raw-window-metal", | |
| 3337 | "renderdoc-sys", | |
| 3338 | "smallvec", | |
| 3339 | "static_assertions", | |
| 3340 | "thiserror 2.0.20", | |
| 3341 | "wasm-bindgen", | |
| 3342 | "wayland-sys", | |
| 3343 | "web-sys", | |
| 3344 | "wgpu-naga-bridge", | |
| 3345 | "wgpu-types", | |
| 3346 | "windows", | |
| 3347 | "windows-core", | |
| 3348 | "windows-result", | |
| 3349 | ] | |
| 3350 | ||
| 3351 | [[package]] | |
| 3352 | name = "wgpu-naga-bridge" | |
| 3353 | version = "30.0.1" | |
| 3354 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3355 | checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" | |
| 3356 | dependencies = [ | |
| 3357 | "naga", | |
| 3358 | "wgpu-types", | |
| 3359 | ] | |
| 3360 | ||
| 3361 | [[package]] | |
| 3362 | name = "wgpu-types" | |
| 3363 | version = "30.0.1" | |
| 3364 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3365 | checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" | |
| 3366 | dependencies = [ | |
| 3367 | "bitflags 2.13.1", | |
| 3368 | "bytemuck", | |
| 3369 | "js-sys", | |
| 3370 | "log", | |
| 3371 | "naga-types", | |
| 3372 | "raw-window-handle", | |
| 3373 | "static_assertions", | |
| 3374 | "web-sys", | |
| 3375 | ] | |
| 3376 | ||
| 3377 | [[package]] | |
| 3378 | name = "winapi-util" | |
| 3379 | version = "0.1.11" | |
| 3380 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3381 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | |
| 3382 | dependencies = [ | |
| 3383 | "windows-sys 0.61.2", | |
| 3384 | ] | |
| 3385 | ||
| 3386 | [[package]] | |
| 3387 | name = "windows" | |
| 3388 | version = "0.62.2" | |
| 3389 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3390 | checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" | |
| 3391 | dependencies = [ | |
| 3392 | "windows-collections", | |
| 3393 | "windows-core", | |
| 3394 | "windows-future", | |
| 3395 | "windows-numerics", | |
| 3396 | ] | |
| 3397 | ||
| 3398 | [[package]] | |
| 3399 | name = "windows-collections" | |
| 3400 | version = "0.3.2" | |
| 3401 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3402 | checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" | |
| 3403 | dependencies = [ | |
| 3404 | "windows-core", | |
| 3405 | ] | |
| 3406 | ||
| 3407 | [[package]] | |
| 3408 | name = "windows-core" | |
| 3409 | version = "0.62.2" | |
| 3410 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3411 | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | |
| 3412 | dependencies = [ | |
| 3413 | "windows-implement", | |
| 3414 | "windows-interface", | |
| 3415 | "windows-link", | |
| 3416 | "windows-result", | |
| 3417 | "windows-strings", | |
| 3418 | ] | |
| 3419 | ||
| 3420 | [[package]] | |
| 3421 | name = "windows-future" | |
| 3422 | version = "0.3.2" | |
| 3423 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3424 | checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" | |
| 3425 | dependencies = [ | |
| 3426 | "windows-core", | |
| 3427 | "windows-link", | |
| 3428 | "windows-threading", | |
| 3429 | ] | |
| 3430 | ||
| 3431 | [[package]] | |
| 3432 | name = "windows-implement" | |
| 3433 | version = "0.60.2" | |
| 3434 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3435 | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | |
| 3436 | dependencies = [ | |
| 3437 | "proc-macro2", | |
| 3438 | "quote", | |
| 3439 | "syn 2.0.119", | |
| 3440 | ] | |
| 3441 | ||
| 3442 | [[package]] | |
| 3443 | name = "windows-interface" | |
| 3444 | version = "0.59.3" | |
| 3445 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3446 | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | |
| 3447 | dependencies = [ | |
| 3448 | "proc-macro2", | |
| 3449 | "quote", | |
| 3450 | "syn 2.0.119", | |
| 3451 | ] | |
| 3452 | ||
| 3453 | [[package]] | |
| 3454 | name = "windows-link" | |
| 3455 | version = "0.2.1" | |
| 3456 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3457 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |
| 3458 | ||
| 3459 | [[package]] | |
| 3460 | name = "windows-numerics" | |
| 3461 | version = "0.3.1" | |
| 3462 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3463 | checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" | |
| 3464 | dependencies = [ | |
| 3465 | "windows-core", | |
| 3466 | "windows-link", | |
| 3467 | ] | |
| 3468 | ||
| 3469 | [[package]] | |
| 3470 | name = "windows-result" | |
| 3471 | version = "0.4.1" | |
| 3472 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3473 | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | |
| 3474 | dependencies = [ | |
| 3475 | "windows-link", | |
| 3476 | ] | |
| 3477 | ||
| 3478 | [[package]] | |
| 3479 | name = "windows-strings" | |
| 3480 | version = "0.5.1" | |
| 3481 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3482 | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | |
| 3483 | dependencies = [ | |
| 3484 | "windows-link", | |
| 3485 | ] | |
| 3486 | ||
| 3487 | [[package]] | |
| 3488 | name = "windows-sys" | |
| 3489 | version = "0.52.0" | |
| 3490 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3491 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" | |
| 3492 | dependencies = [ | |
| 3493 | "windows-targets 0.52.6", | |
| 3494 | ] | |
| 3495 | ||
| 3496 | [[package]] | |
| 3497 | name = "windows-sys" | |
| 3498 | version = "0.59.0" | |
| 3499 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3500 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" | |
| 3501 | dependencies = [ | |
| 3502 | "windows-targets 0.52.6", | |
| 3503 | ] | |
| 3504 | ||
| 3505 | [[package]] | |
| 3506 | name = "windows-sys" | |
| 3507 | version = "0.60.2" | |
| 3508 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3509 | checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" | |
| 3510 | dependencies = [ | |
| 3511 | "windows-targets 0.53.5", | |
| 3512 | ] | |
| 3513 | ||
| 3514 | [[package]] | |
| 3515 | name = "windows-sys" | |
| 3516 | version = "0.61.2" | |
| 3517 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3518 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | |
| 3519 | dependencies = [ | |
| 3520 | "windows-link", | |
| 3521 | ] | |
| 3522 | ||
| 3523 | [[package]] | |
| 3524 | name = "windows-targets" | |
| 3525 | version = "0.52.6" | |
| 3526 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3527 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" | |
| 3528 | dependencies = [ | |
| 3529 | "windows_aarch64_gnullvm 0.52.6", | |
| 3530 | "windows_aarch64_msvc 0.52.6", | |
| 3531 | "windows_i686_gnu 0.52.6", | |
| 3532 | "windows_i686_gnullvm 0.52.6", | |
| 3533 | "windows_i686_msvc 0.52.6", | |
| 3534 | "windows_x86_64_gnu 0.52.6", | |
| 3535 | "windows_x86_64_gnullvm 0.52.6", | |
| 3536 | "windows_x86_64_msvc 0.52.6", | |
| 3537 | ] | |
| 3538 | ||
| 3539 | [[package]] | |
| 3540 | name = "windows-targets" | |
| 3541 | version = "0.53.5" | |
| 3542 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3543 | checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" | |
| 3544 | dependencies = [ | |
| 3545 | "windows-link", | |
| 3546 | "windows_aarch64_gnullvm 0.53.1", | |
| 3547 | "windows_aarch64_msvc 0.53.1", | |
| 3548 | "windows_i686_gnu 0.53.1", | |
| 3549 | "windows_i686_gnullvm 0.53.1", | |
| 3550 | "windows_i686_msvc 0.53.1", | |
| 3551 | "windows_x86_64_gnu 0.53.1", | |
| 3552 | "windows_x86_64_gnullvm 0.53.1", | |
| 3553 | "windows_x86_64_msvc 0.53.1", | |
| 3554 | ] | |
| 3555 | ||
| 3556 | [[package]] | |
| 3557 | name = "windows-threading" | |
| 3558 | version = "0.2.1" | |
| 3559 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3560 | checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" | |
| 3561 | dependencies = [ | |
| 3562 | "windows-link", | |
| 3563 | ] | |
| 3564 | ||
| 3565 | [[package]] | |
| 3566 | name = "windows_aarch64_gnullvm" | |
| 3567 | version = "0.52.6" | |
| 3568 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3569 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" | |
| 3570 | ||
| 3571 | [[package]] | |
| 3572 | name = "windows_aarch64_gnullvm" | |
| 3573 | version = "0.53.1" | |
| 3574 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3575 | checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" | |
| 3576 | ||
| 3577 | [[package]] | |
| 3578 | name = "windows_aarch64_msvc" | |
| 3579 | version = "0.52.6" | |
| 3580 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3581 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" | |
| 3582 | ||
| 3583 | [[package]] | |
| 3584 | name = "windows_aarch64_msvc" | |
| 3585 | version = "0.53.1" | |
| 3586 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3587 | checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" | |
| 3588 | ||
| 3589 | [[package]] | |
| 3590 | name = "windows_i686_gnu" | |
| 3591 | version = "0.52.6" | |
| 3592 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3593 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" | |
| 3594 | ||
| 3595 | [[package]] | |
| 3596 | name = "windows_i686_gnu" | |
| 3597 | version = "0.53.1" | |
| 736 | 3598 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 737 | checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" | |
| 738 | dependencies = [ | |
| 739 | "serde_derive", | |
| 740 | ] | |
| 3599 | checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" | |
| 741 | 3600 | |
| 742 | 3601 | [[package]] |
| 743 | name = "serde_derive" | |
| 744 | version = "1.0.229" | |
| 3602 | name = "windows_i686_gnullvm" | |
| 3603 | version = "0.52.6" | |
| 745 | 3604 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 746 | checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" | |
| 747 | dependencies = [ | |
| 748 | "proc-macro2", | |
| 749 | "quote", | |
| 750 | "syn 3.0.5", | |
| 751 | ] | |
| 3605 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" | |
| 752 | 3606 | |
| 753 | 3607 | [[package]] |
| 754 | name = "serde_json" | |
| 755 | version = "1.0.151" | |
| 3608 | name = "windows_i686_gnullvm" | |
| 3609 | version = "0.53.1" | |
| 756 | 3610 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 757 | checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" | |
| 758 | dependencies = [ | |
| 759 | "itoa", | |
| 760 | "memchr", | |
| 761 | "serde", | |
| 762 | "serde_core", | |
| 763 | "zmij", | |
| 764 | ] | |
| 3611 | checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" | |
| 765 | 3612 | |
| 766 | 3613 | [[package]] |
| 767 | name = "sha1" | |
| 768 | version = "0.11.0" | |
| 3614 | name = "windows_i686_msvc" | |
| 3615 | version = "0.52.6" | |
| 769 | 3616 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 770 | checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" | |
| 771 | dependencies = [ | |
| 772 | "cfg-if", | |
| 773 | "cpufeatures", | |
| 774 | "digest", | |
| 775 | ] | |
| 3617 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" | |
| 776 | 3618 | |
| 777 | 3619 | [[package]] |
| 778 | name = "sha2" | |
| 779 | version = "0.11.0" | |
| 3620 | name = "windows_i686_msvc" | |
| 3621 | version = "0.53.1" | |
| 780 | 3622 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 781 | checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" | |
| 782 | dependencies = [ | |
| 783 | "cfg-if", | |
| 784 | "cpufeatures", | |
| 785 | "digest", | |
| 786 | ] | |
| 3623 | checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" | |
| 787 | 3624 | |
| 788 | 3625 | [[package]] |
| 789 | name = "shlex" | |
| 790 | version = "2.0.1" | |
| 3626 | name = "windows_x86_64_gnu" | |
| 3627 | version = "0.52.6" | |
| 791 | 3628 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 792 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" | |
| 3629 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" | |
| 793 | 3630 | |
| 794 | 3631 | [[package]] |
| 795 | name = "slab" | |
| 796 | version = "0.4.12" | |
| 3632 | name = "windows_x86_64_gnu" | |
| 3633 | version = "0.53.1" | |
| 797 | 3634 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 798 | checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | |
| 3635 | checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" | |
| 799 | 3636 | |
| 800 | 3637 | [[package]] |
| 801 | name = "smallvec" | |
| 802 | version = "1.16.0" | |
| 3638 | name = "windows_x86_64_gnullvm" | |
| 3639 | version = "0.52.6" | |
| 803 | 3640 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 804 | checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" | |
| 3641 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" | |
| 805 | 3642 | |
| 806 | 3643 | [[package]] |
| 807 | name = "smb2" | |
| 808 | version = "0.21.0" | |
| 3644 | name = "windows_x86_64_gnullvm" | |
| 3645 | version = "0.53.1" | |
| 809 | 3646 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 810 | checksum = "9ef4f20cff3d39a131335d17df6146eada7851d7705252f2765b8717a0f52db8" | |
| 811 | dependencies = [ | |
| 812 | "aes", | |
| 813 | "aes-gcm", | |
| 814 | "async-trait", | |
| 815 | "ccm", | |
| 816 | "cmac", | |
| 817 | "digest", | |
| 818 | "futures-util", | |
| 819 | "getrandom", | |
| 820 | "hmac", | |
| 821 | "log", | |
| 822 | "lz4_flex", | |
| 823 | "md-5", | |
| 824 | "md4", | |
| 825 | "num_enum", | |
| 826 | "pbkdf2", | |
| 827 | "sha1", | |
| 828 | "sha2", | |
| 829 | "thiserror", | |
| 830 | "tokio", | |
| 831 | ] | |
| 3647 | checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" | |
| 832 | 3648 | |
| 833 | 3649 | [[package]] |
| 834 | name = "socket2" | |
| 835 | version = "0.6.5" | |
| 3650 | name = "windows_x86_64_msvc" | |
| 3651 | version = "0.52.6" | |
| 836 | 3652 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 837 | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | |
| 838 | dependencies = [ | |
| 839 | "libc", | |
| 840 | "windows-sys", | |
| 841 | ] | |
| 3653 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" | |
| 842 | 3654 | |
| 843 | 3655 | [[package]] |
| 844 | name = "sqlite-wasm-rs" | |
| 845 | version = "0.5.5" | |
| 3656 | name = "windows_x86_64_msvc" | |
| 3657 | version = "0.53.1" | |
| 846 | 3658 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 847 | checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" | |
| 3659 | checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" | |
| 3660 | ||
| 3661 | [[package]] | |
| 3662 | name = "winit" | |
| 3663 | version = "0.30.13" | |
| 3664 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3665 | checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" | |
| 848 | 3666 | dependencies = [ |
| 849 | "cc", | |
| 3667 | "ahash", | |
| 3668 | "android-activity", | |
| 3669 | "atomic-waker", | |
| 3670 | "bitflags 2.13.1", | |
| 3671 | "block2 0.5.1", | |
| 3672 | "bytemuck", | |
| 3673 | "calloop", | |
| 3674 | "cfg_aliases", | |
| 3675 | "concurrent-queue", | |
| 3676 | "core-foundation", | |
| 3677 | "core-graphics", | |
| 3678 | "cursor-icon", | |
| 3679 | "dpi", | |
| 850 | 3680 | "js-sys", |
| 851 | "rsqlite-vfs", | |
| 3681 | "libc", | |
| 3682 | "memmap2", | |
| 3683 | "ndk", | |
| 3684 | "objc2 0.5.2", | |
| 3685 | "objc2-app-kit 0.2.2", | |
| 3686 | "objc2-foundation 0.2.2", | |
| 3687 | "objc2-ui-kit", | |
| 3688 | "orbclient", | |
| 3689 | "percent-encoding", | |
| 3690 | "pin-project", | |
| 3691 | "raw-window-handle", | |
| 3692 | "redox_syscall 0.4.1", | |
| 3693 | "rustix 0.38.44", | |
| 3694 | "sctk-adwaita", | |
| 3695 | "smithay-client-toolkit", | |
| 3696 | "smol_str", | |
| 3697 | "tracing", | |
| 3698 | "unicode-segmentation", | |
| 852 | 3699 | "wasm-bindgen", |
| 3700 | "wasm-bindgen-futures", | |
| 3701 | "wayland-backend", | |
| 3702 | "wayland-client", | |
| 3703 | "wayland-protocols", | |
| 3704 | "wayland-protocols-plasma", | |
| 3705 | "web-sys", | |
| 3706 | "web-time", | |
| 3707 | "windows-sys 0.52.0", | |
| 3708 | "x11-dl", | |
| 3709 | "x11rb", | |
| 3710 | "xkbcommon-dl", | |
| 853 | 3711 | ] |
| 854 | 3712 | |
| 855 | 3713 | [[package]] |
| 856 | name = "subtle" | |
| 857 | version = "2.6.1" | |
| 3714 | name = "winnow" | |
| 3715 | version = "1.0.4" | |
| 858 | 3716 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 859 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" | |
| 3717 | checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" | |
| 3718 | dependencies = [ | |
| 3719 | "memchr", | |
| 3720 | ] | |
| 860 | 3721 | |
| 861 | 3722 | [[package]] |
| 862 | name = "syn" | |
| 863 | version = "2.0.119" | |
| 3723 | name = "wit-bindgen" | |
| 3724 | version = "0.57.1" | |
| 864 | 3725 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 865 | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 866 | dependencies = [ | |
| 867 | "proc-macro2", | |
| 868 | "quote", | |
| 869 | "unicode-ident", | |
| 870 | ] | |
| 3726 | checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" | |
| 871 | 3727 | |
| 872 | 3728 | [[package]] |
| 873 | name = "syn" | |
| 874 | version = "3.0.5" | |
| 3729 | name = "writeable" | |
| 3730 | version = "0.6.4" | |
| 875 | 3731 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 876 | checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" | |
| 877 | dependencies = [ | |
| 878 | "proc-macro2", | |
| 879 | "quote", | |
| 880 | "unicode-ident", | |
| 881 | ] | |
| 3732 | checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" | |
| 882 | 3733 | |
| 883 | 3734 | [[package]] |
| 884 | name = "tempfile" | |
| 885 | version = "3.27.0" | |
| 3735 | name = "x11-dl" | |
| 3736 | version = "2.21.0" | |
| 886 | 3737 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 887 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 3738 | checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" | |
| 888 | 3739 | dependencies = [ |
| 889 | "fastrand", | |
| 890 | "getrandom", | |
| 3740 | "libc", | |
| 891 | 3741 | "once_cell", |
| 892 | "rustix", | |
| 893 | "windows-sys", | |
| 3742 | "pkg-config", | |
| 894 | 3743 | ] |
| 895 | 3744 | |
| 896 | 3745 | [[package]] |
| 897 | name = "thiserror" | |
| 898 | version = "2.0.20" | |
| 3746 | name = "x11rb" | |
| 3747 | version = "0.13.2" | |
| 899 | 3748 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 900 | checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" | |
| 3749 | checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" | |
| 901 | 3750 | dependencies = [ |
| 902 | "thiserror-impl", | |
| 3751 | "as-raw-xcb-connection", | |
| 3752 | "gethostname", | |
| 3753 | "libc", | |
| 3754 | "libloading", | |
| 3755 | "once_cell", | |
| 3756 | "rustix 1.1.4", | |
| 3757 | "x11rb-protocol", | |
| 903 | 3758 | ] |
| 904 | 3759 | |
| 905 | 3760 | [[package]] |
| 906 | name = "thiserror-impl" | |
| 907 | version = "2.0.20" | |
| 3761 | name = "x11rb-protocol" | |
| 3762 | version = "0.13.2" | |
| 908 | 3763 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 909 | checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" | |
| 910 | dependencies = [ | |
| 911 | "proc-macro2", | |
| 912 | "quote", | |
| 913 | "syn 3.0.5", | |
| 914 | ] | |
| 3764 | checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" | |
| 915 | 3765 | |
| 916 | 3766 | [[package]] |
| 917 | name = "tokio" | |
| 918 | version = "1.53.1" | |
| 3767 | name = "xcursor" | |
| 3768 | version = "0.3.11" | |
| 919 | 3769 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 920 | checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" | |
| 921 | dependencies = [ | |
| 922 | "bytes", | |
| 923 | "libc", | |
| 924 | "mio", | |
| 925 | "pin-project-lite", | |
| 926 | "socket2", | |
| 927 | "windows-sys", | |
| 928 | ] | |
| 3770 | checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" | |
| 929 | 3771 | |
| 930 | 3772 | [[package]] |
| 931 | name = "toml_datetime" | |
| 932 | version = "1.1.1+spec-1.1.0" | |
| 3773 | name = "xkbcommon-dl" | |
| 3774 | version = "0.4.2" | |
| 933 | 3775 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 934 | checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" | |
| 3776 | checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" | |
| 935 | 3777 | dependencies = [ |
| 936 | "serde_core", | |
| 3778 | "bitflags 2.13.1", | |
| 3779 | "dlib", | |
| 3780 | "log", | |
| 3781 | "once_cell", | |
| 3782 | "xkeysym", | |
| 937 | 3783 | ] |
| 938 | 3784 | |
| 939 | 3785 | [[package]] |
| 940 | name = "toml_edit" | |
| 941 | version = "0.25.13+spec-1.1.0" | |
| 3786 | name = "xkeysym" | |
| 3787 | version = "0.2.1" | |
| 942 | 3788 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 943 | checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" | |
| 944 | dependencies = [ | |
| 945 | "indexmap", | |
| 946 | "toml_datetime", | |
| 947 | "toml_parser", | |
| 948 | "winnow", | |
| 949 | ] | |
| 3789 | checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" | |
| 950 | 3790 | |
| 951 | 3791 | [[package]] |
| 952 | name = "toml_parser" | |
| 953 | version = "1.1.3+spec-1.1.0" | |
| 3792 | name = "xml-rs" | |
| 3793 | version = "0.8.29" | |
| 954 | 3794 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 955 | checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" | |
| 956 | dependencies = [ | |
| 957 | "winnow", | |
| 958 | ] | |
| 3795 | checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" | |
| 959 | 3796 | |
| 960 | 3797 | [[package]] |
| 961 | name = "twox-hash" | |
| 962 | version = "2.1.4" | |
| 3798 | name = "yazi" | |
| 3799 | version = "0.2.1" | |
| 963 | 3800 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 964 | checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" | |
| 3801 | checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" | |
| 965 | 3802 | |
| 966 | 3803 | [[package]] |
| 967 | name = "typenum" | |
| 968 | version = "1.20.1" | |
| 3804 | name = "yeslogic-fontconfig-sys" | |
| 3805 | version = "6.0.1" | |
| 969 | 3806 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 970 | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" | |
| 3807 | checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" | |
| 3808 | dependencies = [ | |
| 3809 | "dlib", | |
| 3810 | "once_cell", | |
| 3811 | "pkg-config", | |
| 3812 | ] | |
| 971 | 3813 | |
| 972 | 3814 | [[package]] |
| 973 | name = "unicode-ident" | |
| 974 | version = "1.0.24" | |
| 3815 | name = "yoke" | |
| 3816 | version = "0.8.3" | |
| 975 | 3817 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 976 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| 3818 | checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" | |
| 3819 | dependencies = [ | |
| 3820 | "stable_deref_trait", | |
| 3821 | "yoke-derive", | |
| 3822 | "zerofrom", | |
| 3823 | ] | |
| 977 | 3824 | |
| 978 | 3825 | [[package]] |
| 979 | name = "universal-hash" | |
| 980 | version = "0.6.1" | |
| 3826 | name = "yoke-derive" | |
| 3827 | version = "0.8.2" | |
| 981 | 3828 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 982 | checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" | |
| 3829 | checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" | |
| 983 | 3830 | dependencies = [ |
| 984 | "crypto-common", | |
| 985 | "ctutils", | |
| 3831 | "proc-macro2", | |
| 3832 | "quote", | |
| 3833 | "syn 2.0.119", | |
| 3834 | "synstructure", | |
| 986 | 3835 | ] |
| 987 | 3836 | |
| 988 | 3837 | [[package]] |
| 989 | name = "vcpkg" | |
| 990 | version = "0.2.15" | |
| 3838 | name = "zeno" | |
| 3839 | version = "0.3.3" | |
| 991 | 3840 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 992 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" | |
| 3841 | checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" | |
| 993 | 3842 | |
| 994 | 3843 | [[package]] |
| 995 | name = "wasi" | |
| 996 | version = "0.11.1+wasi-snapshot-preview1" | |
| 3844 | name = "zerocopy" | |
| 3845 | version = "0.8.57" | |
| 997 | 3846 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 998 | checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" | |
| 3847 | checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" | |
| 3848 | dependencies = [ | |
| 3849 | "zerocopy-derive", | |
| 3850 | ] | |
| 999 | 3851 | |
| 1000 | 3852 | [[package]] |
| 1001 | name = "wasm-bindgen" | |
| 1002 | version = "0.2.128" | |
| 3853 | name = "zerocopy-derive" | |
| 3854 | version = "0.8.57" | |
| 1003 | 3855 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1004 | checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" | |
| 3856 | checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" | |
| 1005 | 3857 | dependencies = [ |
| 1006 | "cfg-if", | |
| 1007 | "once_cell", | |
| 1008 | "rustversion", | |
| 1009 | "wasm-bindgen-macro", | |
| 1010 | "wasm-bindgen-shared", | |
| 3858 | "proc-macro2", | |
| 3859 | "quote", | |
| 3860 | "syn 2.0.119", | |
| 1011 | 3861 | ] |
| 1012 | 3862 | |
| 1013 | 3863 | [[package]] |
| 1014 | name = "wasm-bindgen-macro" | |
| 1015 | version = "0.2.128" | |
| 3864 | name = "zerofrom" | |
| 3865 | version = "0.1.8" | |
| 1016 | 3866 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1017 | checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" | |
| 3867 | checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" | |
| 1018 | 3868 | dependencies = [ |
| 1019 | "quote", | |
| 1020 | "wasm-bindgen-macro-support", | |
| 3869 | "zerofrom-derive", | |
| 1021 | 3870 | ] |
| 1022 | 3871 | |
| 1023 | 3872 | [[package]] |
| 1024 | name = "wasm-bindgen-macro-support" | |
| 1025 | version = "0.2.128" | |
| 3873 | name = "zerofrom-derive" | |
| 3874 | version = "0.1.7" | |
| 1026 | 3875 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1027 | checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" | |
| 3876 | checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" | |
| 1028 | 3877 | dependencies = [ |
| 1029 | "bumpalo", | |
| 1030 | 3878 | "proc-macro2", |
| 1031 | 3879 | "quote", |
| 1032 | "syn 3.0.5", | |
| 1033 | "wasm-bindgen-shared", | |
| 3880 | "syn 2.0.119", | |
| 3881 | "synstructure", | |
| 1034 | 3882 | ] |
| 1035 | 3883 | |
| 1036 | 3884 | [[package]] |
| 1037 | name = "wasm-bindgen-shared" | |
| 1038 | version = "0.2.128" | |
| 3885 | name = "zeroize" | |
| 3886 | version = "1.9.0" | |
| 1039 | 3887 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1040 | checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" | |
| 1041 | dependencies = [ | |
| 1042 | "unicode-ident", | |
| 1043 | ] | |
| 3888 | checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" | |
| 1044 | 3889 | |
| 1045 | 3890 | [[package]] |
| 1046 | name = "windows-link" | |
| 1047 | version = "0.2.1" | |
| 3891 | name = "zerotrie" | |
| 3892 | version = "0.2.5" | |
| 1048 | 3893 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1049 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" | |
| 3894 | checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" | |
| 3895 | dependencies = [ | |
| 3896 | "displaydoc", | |
| 3897 | "yoke", | |
| 3898 | "zerofrom", | |
| 3899 | "zerovec", | |
| 3900 | ] | |
| 1050 | 3901 | |
| 1051 | 3902 | [[package]] |
| 1052 | name = "windows-sys" | |
| 1053 | version = "0.61.2" | |
| 3903 | name = "zerovec" | |
| 3904 | version = "0.11.8" | |
| 1054 | 3905 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1055 | checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" | |
| 3906 | checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" | |
| 1056 | 3907 | dependencies = [ |
| 1057 | "windows-link", | |
| 3908 | "serde", | |
| 3909 | "yoke", | |
| 3910 | "zerofrom", | |
| 3911 | "zerovec-derive", | |
| 1058 | 3912 | ] |
| 1059 | 3913 | |
| 1060 | 3914 | [[package]] |
| 1061 | name = "winnow" | |
| 1062 | version = "1.0.4" | |
| 3915 | name = "zerovec-derive" | |
| 3916 | version = "0.11.6" | |
| 1063 | 3917 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1064 | checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" | |
| 3918 | checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" | |
| 1065 | 3919 | dependencies = [ |
| 1066 | "memchr", | |
| 3920 | "proc-macro2", | |
| 3921 | "quote", | |
| 3922 | "syn 3.0.5", | |
| 1067 | 3923 | ] |
| 1068 | 3924 | |
| 1069 | 3925 | [[package]] |
| 1070 | name = "zeroize" | |
| 1071 | version = "1.9.0" | |
| 3926 | name = "zlib-rs" | |
| 3927 | version = "0.6.7" | |
| 1072 | 3928 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1073 | checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" | |
| 3929 | checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" | |
| 1074 | 3930 | |
| 1075 | 3931 | [[package]] |
| 1076 | 3932 | name = "zmij" |
| 1077 | 3933 | version = "1.0.23" |
| 1078 | 3934 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1079 | 3935 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" |
| 3936 | ||
| 3937 | [[package]] | |
| 3938 | name = "zune-core" | |
| 3939 | version = "0.5.3" | |
| 3940 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3941 | checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" | |
| 3942 | ||
| 3943 | [[package]] | |
| 3944 | name = "zune-jpeg" | |
| 3945 | version = "0.5.15" | |
| 3946 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3947 | checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" | |
| 3948 | dependencies = [ | |
| 3949 | "zune-core", | |
| 3950 | ] |
Cargo.toml+4| ... | ... | @@ -2,3 +2,7 @@ |
| 2 | 2 | members = ["crates/*"] |
| 3 | 3 | exclude = ["fuzz"] |
| 4 | 4 | resolver = "3" |
| 5 | ||
| 6 | [workspace.dependencies] | |
| 7 | parley = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759", features = ["complex-scripts"] } | |
| 8 | wgpu = "30.0.1" |
crates/canvas-app/Cargo.toml created+23| ... | ... | @@ -0,0 +1,23 @@ |
| 1 | [package] | |
| 2 | name = "one-canvas-app" | |
| 3 | version = "0.1.0" | |
| 4 | edition = "2024" | |
| 5 | publish = false | |
| 6 | ||
| 7 | [dependencies] | |
| 8 | one-canvas = { path = "../canvas" } | |
| 9 | one-canvas-gpu = { path = "../canvas-gpu" } | |
| 10 | onestore = { path = "../onestore" } | |
| 11 | pollster = "0.4" | |
| 12 | wgpu.workspace = true | |
| 13 | parley.workspace = true | |
| 14 | winit = "0.30.13" | |
| 15 | arboard = { version = "3.6.1", default-features = false } | |
| 16 | accesskit = "0.25.0" | |
| 17 | accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } | |
| 18 | objc2 = "0.5.2" | |
| 19 | objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent"] } | |
| 20 | objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread"] } | |
| 21 | ||
| 22 | [dev-dependencies] | |
| 23 | accesskit_consumer = "0.39.0" |
crates/canvas-app/src/accessibility.rs created+1312| ... | ... | @@ -0,0 +1,1312 @@ |
| 1 | use accesskit::{ | |
| 2 | Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo, | |
| 3 | TreeUpdate, | |
| 4 | }; | |
| 5 | use one_canvas::{ | |
| 6 | document::TextPosition, | |
| 7 | editor::{CanvasEditor, Selection, TextOutline}, | |
| 8 | text::EditError, | |
| 9 | }; | |
| 10 | use one_canvas_gpu::Viewport; | |
| 11 | use parley::Affinity; | |
| 12 | use std::{collections::HashMap, ops::Range}; | |
| 13 | ||
| 14 | pub const ROOT: NodeId = NodeId(0); | |
| 15 | ||
| 16 | struct Run { | |
| 17 | outline: onestore::ExGuid, | |
| 18 | id: NodeId, | |
| 19 | node: Node, | |
| 20 | positions: Vec<TextPosition>, | |
| 21 | } | |
| 22 | ||
| 23 | #[derive(Hash, PartialEq, Eq)] | |
| 24 | struct ParagraphKey { | |
| 25 | layout: u64, | |
| 26 | source: usize, | |
| 27 | next: Option<usize>, | |
| 28 | origin: [u32; 2], | |
| 29 | } | |
| 30 | ||
| 31 | pub struct Accessibility { | |
| 32 | outlines: Vec<(onestore::ExGuid, NodeId)>, | |
| 33 | runs: Vec<Run>, | |
| 34 | paragraphs: HashMap<ParagraphKey, Range<usize>>, | |
| 35 | read_only: Vec<NodeId>, | |
| 36 | next_id: u64, | |
| 37 | } | |
| 38 | ||
| 39 | impl Default for Accessibility { | |
| 40 | fn default() -> Self { | |
| 41 | Self { | |
| 42 | outlines: Vec::new(), | |
| 43 | runs: Vec::new(), | |
| 44 | paragraphs: HashMap::new(), | |
| 45 | read_only: Vec::new(), | |
| 46 | next_id: 1, | |
| 47 | } | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | impl Accessibility { | |
| 52 | /// Retire published runs without recycling their IDs. | |
| 53 | pub fn deactivate(&mut self) { | |
| 54 | self.runs.clear(); | |
| 55 | self.paragraphs.clear(); | |
| 56 | self.outlines.clear(); | |
| 57 | self.read_only.clear(); | |
| 58 | } | |
| 59 | ||
| 60 | pub fn append_read_only( | |
| 61 | &mut self, | |
| 62 | update: &mut TreeUpdate, | |
| 63 | scene: Option<&(one_canvas_gpu::page::PageScene, [f32; 2])>, | |
| 64 | viewport: Viewport, | |
| 65 | focus: Option<usize>, | |
| 66 | ) { | |
| 67 | let mut count = 0; | |
| 68 | if let Some((scene, offset)) = scene { | |
| 69 | for (index, object) in scene.read_only().enumerate() { | |
| 70 | if index == self.read_only.len() { | |
| 71 | let id = self.allocate(); | |
| 72 | self.read_only.push(id); | |
| 73 | } | |
| 74 | let id = self.read_only[index]; | |
| 75 | let mut node = Node::new(Role::Label); | |
| 76 | node.set_value(object.message); | |
| 77 | node.set_read_only(); | |
| 78 | node.add_action(Action::Focus); | |
| 79 | if focus == Some(index) { | |
| 80 | update.focus = id; | |
| 81 | } | |
| 82 | let [x0, y0, x1, y1] = object.rect; | |
| 83 | let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]); | |
| 84 | let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]); | |
| 85 | node.set_bounds(Rect::new(x(x0), y(y0), x(x1), y(y1))); | |
| 86 | update | |
| 87 | .nodes | |
| 88 | .iter_mut() | |
| 89 | .find(|(id, _)| *id == ROOT) | |
| 90 | .unwrap() | |
| 91 | .1 | |
| 92 | .push_child(id); | |
| 93 | update.nodes.push((id, node)); | |
| 94 | count += 1; | |
| 95 | } | |
| 96 | } | |
| 97 | self.read_only.truncate(count); | |
| 98 | } | |
| 99 | ||
| 100 | fn allocate(&mut self) -> NodeId { | |
| 101 | let id = NodeId(self.next_id); | |
| 102 | self.next_id = self | |
| 103 | .next_id | |
| 104 | .checked_add(1) | |
| 105 | .expect("Accessibility node IDs exhausted"); | |
| 106 | id | |
| 107 | } | |
| 108 | ||
| 109 | pub fn read_only_for_node(&self, node: NodeId) -> Option<usize> { | |
| 110 | self.read_only.iter().position(|id| *id == node) | |
| 111 | } | |
| 112 | ||
| 113 | pub fn outline_for_node(&self, node: NodeId) -> Option<onestore::ExGuid> { | |
| 114 | self.outlines | |
| 115 | .iter() | |
| 116 | .find(|(_, id)| *id == node) | |
| 117 | .map(|(outline, _)| *outline) | |
| 118 | } | |
| 119 | ||
| 120 | pub fn update( | |
| 121 | &mut self, | |
| 122 | editor: &CanvasEditor, | |
| 123 | viewport: Viewport, | |
| 124 | title: &str, | |
| 125 | preview: Option<(onestore::ExGuid, [f32; 2])>, | |
| 126 | ) -> Result<TreeUpdate, EditError> { | |
| 127 | let mut runs = Vec::new(); | |
| 128 | let mut outlines = Vec::new(); | |
| 129 | let mut paragraph_ranges = HashMap::new(); | |
| 130 | for outline in editor.outlines().iter().chain(editor.caret_outline()) { | |
| 131 | let field = self | |
| 132 | .outlines | |
| 133 | .iter() | |
| 134 | .find(|(id, _)| *id == outline.id) | |
| 135 | .map(|(_, node)| *node) | |
| 136 | .unwrap_or_else(|| self.allocate()); | |
| 137 | outlines.push((outline.id, field)); | |
| 138 | let mut paragraphs = outline.layouts().peekable(); | |
| 139 | while let Some((paragraph, shaped)) = paragraphs.next() { | |
| 140 | let layout = &shaped.text; | |
| 141 | let origin = shaped.origin; | |
| 142 | let key = ParagraphKey { | |
| 143 | layout: layout.id(), | |
| 144 | source: paragraph, | |
| 145 | next: paragraphs.peek().map(|(next, _)| *next), | |
| 146 | origin: origin.map(f32::to_bits), | |
| 147 | }; | |
| 148 | let start = runs.len(); | |
| 149 | if let Some(range) = self.paragraphs.get(&key) { | |
| 150 | runs.extend( | |
| 151 | self.runs[range.clone()] | |
| 152 | .iter() | |
| 153 | .map(|run| (run.outline, run.node.clone(), run.positions.clone())), | |
| 154 | ); | |
| 155 | paragraph_ranges.insert(key, start..runs.len()); | |
| 156 | continue; | |
| 157 | } | |
| 158 | let projection = &shaped.projection; | |
| 159 | let text = projection.text().text(); | |
| 160 | let boundaries: Vec<_> = projection.source_boundaries().collect(); | |
| 161 | let source_position = |byte| -> Result<TextPosition, EditError> { | |
| 162 | let index = boundaries | |
| 163 | .binary_search_by_key(&byte, |(byte, _)| *byte) | |
| 164 | .map_err(|_| EditError::InvalidRange)?; | |
| 165 | Ok(TextPosition { | |
| 166 | paragraph, | |
| 167 | offset: boundaries[index].1, | |
| 168 | }) | |
| 169 | }; | |
| 170 | let line_count = layout.lines().count(); | |
| 171 | for (line_index, (line, bounds)) in layout.lines().enumerate() { | |
| 172 | let line_start = runs.len(); | |
| 173 | let mut x = line.metrics().offset; | |
| 174 | let mut logical_runs = Vec::new(); | |
| 175 | for run in line.runs() { | |
| 176 | let advance = run.advance(); | |
| 177 | logical_runs.push((run, x)); | |
| 178 | x += advance; | |
| 179 | } | |
| 180 | logical_runs.sort_by_key(|(run, _)| run.text_range().start); | |
| 181 | for (run, x) in logical_runs { | |
| 182 | // AccessKit stores each character's byte length in a u8. | |
| 183 | let mut characters = Vec::new(); | |
| 184 | for cluster in run.clusters() { | |
| 185 | let range = cluster.text_range(); | |
| 186 | if range.is_empty() || range.start == text.len() { | |
| 187 | continue; | |
| 188 | } | |
| 189 | let cluster_text = | |
| 190 | text.get(range.clone()).ok_or(EditError::InvalidRange)?; | |
| 191 | if range.len() <= u8::MAX as usize { | |
| 192 | characters.push(( | |
| 193 | range, | |
| 194 | cluster.advance(), | |
| 195 | cluster.is_word_boundary(), | |
| 196 | )); | |
| 197 | } else { | |
| 198 | for (index, (offset, ch)) in cluster_text.char_indices().enumerate() | |
| 199 | { | |
| 200 | characters.push(( | |
| 201 | range.start + offset..range.start + offset + ch.len_utf8(), | |
| 202 | if index == 0 { cluster.advance() } else { 0.0 }, | |
| 203 | index == 0 && cluster.is_word_boundary(), | |
| 204 | )); | |
| 205 | } | |
| 206 | } | |
| 207 | } | |
| 208 | let mut advance = 0.0; | |
| 209 | for chunk in characters.chunks(256) { | |
| 210 | let width: f32 = chunk.iter().map(|(_, width, _)| width).sum(); | |
| 211 | let left = x + if run.is_rtl() { | |
| 212 | run.advance() - advance - width | |
| 213 | } else { | |
| 214 | advance | |
| 215 | }; | |
| 216 | let mut node = Node::new(Role::TextRun); | |
| 217 | node.set_bounds(Rect::new( | |
| 218 | f64::from(left + origin[0]), | |
| 219 | f64::from(bounds.top + origin[1]), | |
| 220 | f64::from(left + width + origin[0]), | |
| 221 | f64::from(bounds.top + bounds.height + origin[1]), | |
| 222 | )); | |
| 223 | node.set_text_direction(if run.is_rtl() { | |
| 224 | TextDirection::RightToLeft | |
| 225 | } else { | |
| 226 | TextDirection::LeftToRight | |
| 227 | }); | |
| 228 | node.set_font_size(run.font_size()); | |
| 229 | let start = chunk[0].0.start; | |
| 230 | let end = chunk.last().unwrap().0.end; | |
| 231 | node.set_value(text.get(start..end).ok_or(EditError::InvalidRange)?); | |
| 232 | node.set_character_lengths( | |
| 233 | chunk | |
| 234 | .iter() | |
| 235 | .map(|(range, _, _)| u8::try_from(range.len()).unwrap()) | |
| 236 | .collect::<Vec<_>>(), | |
| 237 | ); | |
| 238 | let mut offset = 0.0; | |
| 239 | node.set_character_positions( | |
| 240 | chunk | |
| 241 | .iter() | |
| 242 | .map(|(_, width, _)| { | |
| 243 | let position = offset; | |
| 244 | offset += width; | |
| 245 | position | |
| 246 | }) | |
| 247 | .collect::<Vec<_>>(), | |
| 248 | ); | |
| 249 | node.set_character_widths( | |
| 250 | chunk.iter().map(|(_, width, _)| *width).collect::<Vec<_>>(), | |
| 251 | ); | |
| 252 | node.set_word_starts( | |
| 253 | chunk | |
| 254 | .iter() | |
| 255 | .enumerate() | |
| 256 | .filter_map(|(index, (_, _, word))| word.then_some(index as u8)) | |
| 257 | .collect::<Vec<_>>(), | |
| 258 | ); | |
| 259 | let positions = chunk | |
| 260 | .iter() | |
| 261 | .map(|(range, _, _)| source_position(range.start)) | |
| 262 | .chain(std::iter::once(source_position(end))) | |
| 263 | .collect::<Result<Vec<_>, _>>()?; | |
| 264 | runs.push((outline.id, node, positions)); | |
| 265 | advance += width; | |
| 266 | } | |
| 267 | } | |
| 268 | // Empty lines still need a text position for selection and insertion. | |
| 269 | if runs.len() == line_start { | |
| 270 | let mut node = Node::new(Role::TextRun); | |
| 271 | node.set_value(""); | |
| 272 | node.set_character_lengths(Vec::<u8>::new()); | |
| 273 | node.set_character_positions(Vec::<f32>::new()); | |
| 274 | node.set_character_widths(Vec::<f32>::new()); | |
| 275 | node.set_text_direction(TextDirection::LeftToRight); | |
| 276 | node.set_bounds(Rect::new( | |
| 277 | f64::from(origin[0]), | |
| 278 | f64::from(bounds.top + origin[1]), | |
| 279 | f64::from(origin[0]), | |
| 280 | f64::from(bounds.top + bounds.height + origin[1]), | |
| 281 | )); | |
| 282 | runs.push(( | |
| 283 | outline.id, | |
| 284 | node, | |
| 285 | vec![source_position(bounds.source.start)?], | |
| 286 | )); | |
| 287 | } | |
| 288 | if line_index + 1 == line_count | |
| 289 | && let Some((next, _)) = paragraphs.peek() | |
| 290 | { | |
| 291 | let (_, node, source_positions) = runs.last_mut().unwrap(); | |
| 292 | let mut value = node.value().unwrap().to_owned(); | |
| 293 | value.push('\n'); | |
| 294 | node.set_value(value); | |
| 295 | let mut lengths = node.character_lengths().to_vec(); | |
| 296 | lengths.push(1); | |
| 297 | node.set_character_lengths(lengths); | |
| 298 | if let Some(rect) = node.bounds() { | |
| 299 | let mut positions = | |
| 300 | node.character_positions().unwrap_or_default().to_vec(); | |
| 301 | positions.push(rect.width() as f32); | |
| 302 | node.set_character_positions(positions); | |
| 303 | let mut widths = node.character_widths().unwrap_or_default().to_vec(); | |
| 304 | widths.push(4.0); | |
| 305 | node.set_character_widths(widths); | |
| 306 | } | |
| 307 | source_positions.push(TextPosition { | |
| 308 | paragraph: *next, | |
| 309 | offset: 0, | |
| 310 | }); | |
| 311 | } | |
| 312 | } | |
| 313 | if !shaped.tags.is_empty() { | |
| 314 | let descriptions = shaped | |
| 315 | .tags | |
| 316 | .iter() | |
| 317 | .map(|tag| { | |
| 318 | use one_canvas::outline::TagIcon; | |
| 319 | let fallback = match tag.icon { | |
| 320 | TagIcon::CheckBox { .. } => "To do", | |
| 321 | TagIcon::Question => "Question", | |
| 322 | TagIcon::Music => "Music", | |
| 323 | }; | |
| 324 | let label = if tag.label.is_empty() { | |
| 325 | fallback | |
| 326 | } else { | |
| 327 | &tag.label | |
| 328 | }; | |
| 329 | let state = match tag.icon { | |
| 330 | TagIcon::CheckBox { checked: true } => ", completed", | |
| 331 | TagIcon::CheckBox { checked: false } => ", incomplete", | |
| 332 | _ => "", | |
| 333 | }; | |
| 334 | format!( | |
| 335 | "{label}{state}{}", | |
| 336 | if tag.disabled { ", disabled" } else { "" } | |
| 337 | ) | |
| 338 | }) | |
| 339 | .collect::<Vec<_>>() | |
| 340 | .join("; "); | |
| 341 | runs[start].1.set_description(descriptions); | |
| 342 | } | |
| 343 | paragraph_ranges.insert(key, start..runs.len()); | |
| 344 | } | |
| 345 | } | |
| 346 | let mut runs: Vec<_> = runs | |
| 347 | .into_iter() | |
| 348 | .enumerate() | |
| 349 | .map(|(index, (outline, mut node, positions))| { | |
| 350 | node.clear_next_on_line(); | |
| 351 | node.clear_previous_on_line(); | |
| 352 | let id = match self.runs.get(index) { | |
| 353 | Some(old) | |
| 354 | if old.outline == outline | |
| 355 | && old.positions == positions | |
| 356 | && old.node.value() == node.value() => | |
| 357 | { | |
| 358 | old.id | |
| 359 | } | |
| 360 | _ => self.allocate(), | |
| 361 | }; | |
| 362 | Run { | |
| 363 | outline, | |
| 364 | id, | |
| 365 | node, | |
| 366 | positions, | |
| 367 | } | |
| 368 | }) | |
| 369 | .collect(); | |
| 370 | for index in 1..runs.len() { | |
| 371 | if runs[index - 1].outline == runs[index].outline | |
| 372 | && runs[index - 1].node.bounds().map(|r| r.y0) | |
| 373 | == runs[index].node.bounds().map(|r| r.y0) | |
| 374 | { | |
| 375 | let (before, after) = runs.split_at_mut(index); | |
| 376 | before[index - 1].node.set_next_on_line(after[0].id); | |
| 377 | after[0].node.set_previous_on_line(before[index - 1].id); | |
| 378 | } | |
| 379 | } | |
| 380 | self.runs = runs; | |
| 381 | self.paragraphs = paragraph_ranges; | |
| 382 | self.outlines = outlines; | |
| 383 | let mut root = Node::new(Role::Window); | |
| 384 | root.set_label(title); | |
| 385 | root.set_children(self.outlines.iter().map(|(_, id)| *id).collect::<Vec<_>>()); | |
| 386 | root.set_bounds(Rect::new( | |
| 387 | 0.0, | |
| 388 | 0.0, | |
| 389 | viewport.size[0] as f64, | |
| 390 | viewport.size[1] as f64, | |
| 391 | )); | |
| 392 | let mut nodes = vec![(ROOT, root)]; | |
| 393 | let mut focus = ROOT; | |
| 394 | for (index, (outline, (_, id))) in editor | |
| 395 | .outlines() | |
| 396 | .iter() | |
| 397 | .chain(editor.caret_outline()) | |
| 398 | .zip(&self.outlines) | |
| 399 | .enumerate() | |
| 400 | { | |
| 401 | let mut field = Node::new(Role::MultilineTextInput); | |
| 402 | field.set_label( | |
| 403 | if editor | |
| 404 | .caret_outline() | |
| 405 | .is_some_and(|caret| caret.id == outline.id) | |
| 406 | { | |
| 407 | "Text input".into() | |
| 408 | } else { | |
| 409 | format!("Text outline {}", index + 1) | |
| 410 | }, | |
| 411 | ); | |
| 412 | field.add_action(Action::Focus); | |
| 413 | field.add_action(Action::SetTextSelection); | |
| 414 | field.add_action(Action::ReplaceSelectedText); | |
| 415 | field.add_action(Action::SetValue); | |
| 416 | let runs = self.runs.iter().filter(|run| run.outline == outline.id); | |
| 417 | field.set_value( | |
| 418 | runs.clone() | |
| 419 | .filter_map(|run| run.node.value()) | |
| 420 | .collect::<String>(), | |
| 421 | ); | |
| 422 | field.set_children(runs.map(|run| run.id).collect::<Vec<_>>()); | |
| 423 | let origin = preview | |
| 424 | .filter(|(id, _)| *id == outline.id) | |
| 425 | .map(|(_, origin)| origin) | |
| 426 | .unwrap_or_else(|| outline.origin()); | |
| 427 | field.set_transform(Affine::new([ | |
| 428 | f64::from(viewport.scale), | |
| 429 | 0.0, | |
| 430 | 0.0, | |
| 431 | f64::from(viewport.scale), | |
| 432 | f64::from(viewport.origin[0]) + f64::from(origin[0]) * f64::from(viewport.scale), | |
| 433 | f64::from(viewport.origin[1]) + f64::from(origin[1]) * f64::from(viewport.scale), | |
| 434 | ])); | |
| 435 | let bounds = outline.bounds(); | |
| 436 | field.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height())); | |
| 437 | if outline.id == editor.active_outline().id { | |
| 438 | let Selection { | |
| 439 | positions: [anchor, caret], | |
| 440 | affinities, | |
| 441 | } = editor.selection(); | |
| 442 | field.set_text_selection(TextSelection { | |
| 443 | anchor: self.position(outline, anchor, affinities[0])?, | |
| 444 | focus: self.position(outline, caret, affinities[1])?, | |
| 445 | }); | |
| 446 | focus = *id; | |
| 447 | } | |
| 448 | nodes.push((*id, field)); | |
| 449 | } | |
| 450 | nodes.extend(self.runs.iter().map(|run| (run.id, run.node.clone()))); | |
| 451 | Ok(TreeUpdate { | |
| 452 | nodes, | |
| 453 | tree: Some(TreeInfo::new(ROOT)), | |
| 454 | tree_id: TreeId::ROOT, | |
| 455 | focus, | |
| 456 | }) | |
| 457 | } | |
| 458 | ||
| 459 | fn position( | |
| 460 | &self, | |
| 461 | outline: &TextOutline, | |
| 462 | mut position: TextPosition, | |
| 463 | affinity: Affinity, | |
| 464 | ) -> Result<accesskit::TextPosition, EditError> { | |
| 465 | let shaped = outline.paragraph_layout(position.paragraph)?; | |
| 466 | let projection = &shaped.projection; | |
| 467 | let layout = &shaped.text; | |
| 468 | let origin = shaped.origin; | |
| 469 | let cursor = layout.cursor( | |
| 470 | projection | |
| 471 | .text() | |
| 472 | .byte_offset(projection.visible_offset(position.offset)?)?, | |
| 473 | affinity, | |
| 474 | ); | |
| 475 | position.offset = projection.source_offset( | |
| 476 | projection.text().utf16_offset(cursor.index())?, | |
| 477 | Affinity::Downstream, | |
| 478 | )?; | |
| 479 | let caret = layout.caret(cursor, 0.0); | |
| 480 | let mut positions = self | |
| 481 | .runs | |
| 482 | .iter() | |
| 483 | .filter(|run| { | |
| 484 | run.outline == outline.id | |
| 485 | && run | |
| 486 | .node | |
| 487 | .bounds() | |
| 488 | .is_some_and(|rect| rect.y0 == f64::from(caret.y0 as f32 + origin[1])) | |
| 489 | }) | |
| 490 | .filter_map(|run| { | |
| 491 | run.positions | |
| 492 | .iter() | |
| 493 | .position(|p| *p == position) | |
| 494 | .map(|character_index| accesskit::TextPosition { | |
| 495 | node: run.id, | |
| 496 | character_index, | |
| 497 | }) | |
| 498 | }); | |
| 499 | match affinity { | |
| 500 | Affinity::Upstream => positions.next(), | |
| 501 | Affinity::Downstream => positions.next_back(), | |
| 502 | } | |
| 503 | .ok_or(EditError::InvalidRange) | |
| 504 | } | |
| 505 | ||
| 506 | pub fn selection( | |
| 507 | &self, | |
| 508 | outline: onestore::ExGuid, | |
| 509 | selection: TextSelection, | |
| 510 | ) -> Result<Selection, EditError> { | |
| 511 | let position = |position: accesskit::TextPosition| { | |
| 512 | let run = self | |
| 513 | .runs | |
| 514 | .iter() | |
| 515 | .find(|run| run.outline == outline && run.id == position.node) | |
| 516 | .ok_or(EditError::InvalidRange)?; | |
| 517 | let source = *run | |
| 518 | .positions | |
| 519 | .get(position.character_index) | |
| 520 | .ok_or(EditError::InvalidRange)?; | |
| 521 | let affinity = if position.character_index > 0 | |
| 522 | && position.character_index + 1 == run.positions.len() | |
| 523 | { | |
| 524 | Affinity::Upstream | |
| 525 | } else { | |
| 526 | Affinity::Downstream | |
| 527 | }; | |
| 528 | Ok::<_, EditError>((source, affinity)) | |
| 529 | }; | |
| 530 | let (anchor, anchor_affinity) = position(selection.anchor)?; | |
| 531 | let (focus, focus_affinity) = position(selection.focus)?; | |
| 532 | Ok(Selection { | |
| 533 | positions: [anchor, focus], | |
| 534 | affinities: [anchor_affinity, focus_affinity], | |
| 535 | }) | |
| 536 | } | |
| 537 | } | |
| 538 | ||
| 539 | #[cfg(test)] | |
| 540 | mod tests { | |
| 541 | use super::*; | |
| 542 | use one_canvas::{document::TextDocument, layout::TextEngine, text::Paragraph}; | |
| 543 | use onestore::document::Format; | |
| 544 | ||
| 545 | #[test] | |
| 546 | fn read_only_objects_keep_accessibility_identity_through_edits_and_view_changes() { | |
| 547 | use one_canvas::page::{Page, PageObject, Unsupported}; | |
| 548 | use one_canvas_gpu::page::PageScene; | |
| 549 | let page = Page { | |
| 550 | title: String::new(), | |
| 551 | margin_origin: [0.0; 2], | |
| 552 | definitions: Default::default(), | |
| 553 | objects: vec![PageObject::Unsupported(Unsupported { | |
| 554 | id: Default::default(), | |
| 555 | jcid: 0xdead, | |
| 556 | layout: onestore::document::Layout { | |
| 557 | x: Some(10.0), | |
| 558 | y: Some(20.0), | |
| 559 | max_width: Some(200.0), | |
| 560 | max_height: Some(60.0), | |
| 561 | ..Default::default() | |
| 562 | }, | |
| 563 | })], | |
| 564 | }; | |
| 565 | let mut engine = TextEngine::default(); | |
| 566 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | |
| 567 | let scene = (scene, [30.0, 40.0]); | |
| 568 | let mut access = Accessibility::default(); | |
| 569 | let mut identity = None; | |
| 570 | for scale in [1.0, 2.0, 0.5] { | |
| 571 | editor.insert(&mut engine, "annotation ").unwrap(); | |
| 572 | let viewport = Viewport { | |
| 573 | size: [800, 600], | |
| 574 | origin: [-200.0, 10.0], | |
| 575 | scale, | |
| 576 | }; | |
| 577 | let mut update = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 578 | access.append_read_only(&mut update, Some(&scene), viewport, None); | |
| 579 | let (id, node) = update | |
| 580 | .nodes | |
| 581 | .iter() | |
| 582 | .find(|(_, node)| node.role() == Role::Label) | |
| 583 | .unwrap(); | |
| 584 | if let Some(identity) = identity { | |
| 585 | assert_eq!(*id, identity); | |
| 586 | } | |
| 587 | identity = Some(*id); | |
| 588 | assert_eq!(node.value(), Some("Unsupported content\nRead-only")); | |
| 589 | assert!(node.is_read_only()); | |
| 590 | assert!(node.supports_action(Action::Focus)); | |
| 591 | for action in [ | |
| 592 | Action::SetValue, | |
| 593 | Action::ReplaceSelectedText, | |
| 594 | Action::SetTextSelection, | |
| 595 | ] { | |
| 596 | assert!(!node.supports_action(action)); | |
| 597 | } | |
| 598 | assert_eq!( | |
| 599 | node.bounds(), | |
| 600 | Some(Rect::new( | |
| 601 | f64::from(40.0 * scale - 200.0), | |
| 602 | f64::from(60.0 * scale + 10.0), | |
| 603 | f64::from(240.0 * scale - 200.0), | |
| 604 | f64::from(120.0 * scale + 10.0) | |
| 605 | )) | |
| 606 | ); | |
| 607 | assert_eq!(access.outline_for_node(*id), None); | |
| 608 | let tree = accesskit_consumer::Tree::new(update, true); | |
| 609 | assert_eq!( | |
| 610 | tree.state().focus().unwrap().document_range().text(), | |
| 611 | "annotation " | |
| 612 | ); | |
| 613 | let mut focused = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 614 | access.append_read_only(&mut focused, Some(&scene), viewport, Some(0)); | |
| 615 | assert_eq!(focused.focus, identity.unwrap()); | |
| 616 | assert_eq!(access.read_only_for_node(focused.focus), Some(0)); | |
| 617 | let tree = accesskit_consumer::Tree::new(focused, true); | |
| 618 | assert_eq!( | |
| 619 | tree.state().focus().unwrap().value().as_deref(), | |
| 620 | Some("Unsupported content\nRead-only") | |
| 621 | ); | |
| 622 | editor.undo(&mut engine).unwrap(); | |
| 623 | } | |
| 624 | access.deactivate(); | |
| 625 | assert!(access.read_only.is_empty()); | |
| 626 | } | |
| 627 | ||
| 628 | #[test] | |
| 629 | fn tag_descriptions_preserve_plain_text_and_survive_edit_undo_and_cache_reuse() { | |
| 630 | use one_canvas::page::{Definition, Outline}; | |
| 631 | use onestore::{ | |
| 632 | ExGuid, | |
| 633 | document::{Kind, Layout, Tag}, | |
| 634 | }; | |
| 635 | use std::collections::BTreeMap; | |
| 636 | let text = TextDocument::new( | |
| 637 | ["task one", "task two", "question", "music"] | |
| 638 | .into_iter() | |
| 639 | .map(|text| Paragraph::new(text.into(), Format::default())) | |
| 640 | .collect(), | |
| 641 | ) | |
| 642 | .unwrap(); | |
| 643 | let mut nodes = text.nodes().to_vec(); | |
| 644 | let mut definitions = BTreeMap::new(); | |
| 645 | for (index, (shape, label, status)) in [ | |
| 646 | (3, Some("Rehearsal"), 0), | |
| 647 | (3, Some("Rehearsal"), 1), | |
| 648 | (15, None, 1), | |
| 649 | (121, None, 3), | |
| 650 | ] | |
| 651 | .into_iter() | |
| 652 | .enumerate() | |
| 653 | { | |
| 654 | let id = ExGuid { | |
| 655 | n: index as u32 + 500, | |
| 656 | ..ExGuid::default() | |
| 657 | }; | |
| 658 | definitions.insert( | |
| 659 | id, | |
| 660 | Definition { | |
| 661 | kind: Kind::TagDefinition { | |
| 662 | label: label.map(str::to_owned), | |
| 663 | action_type: None, | |
| 664 | shape: Some(shape), | |
| 665 | color: None, | |
| 666 | highlight: None, | |
| 667 | }, | |
| 668 | format: Format::default(), | |
| 669 | }, | |
| 670 | ); | |
| 671 | nodes[index].text[0].tags.push(Tag { | |
| 672 | definition: Some(id), | |
| 673 | action_type: None, | |
| 674 | status, | |
| 675 | created: None, | |
| 676 | completed: None, | |
| 677 | start: None, | |
| 678 | due: None, | |
| 679 | task_id: None, | |
| 680 | extra_set: 0, | |
| 681 | }); | |
| 682 | } | |
| 683 | let mut engine = TextEngine::default(); | |
| 684 | let mut editor = CanvasEditor::from_outlines( | |
| 685 | &mut engine, | |
| 686 | vec![Outline { | |
| 687 | id: ExGuid { | |
| 688 | n: 900, | |
| 689 | ..ExGuid::default() | |
| 690 | }, | |
| 691 | layout: Layout { | |
| 692 | max_width: Some(120.0), | |
| 693 | ..Layout::default() | |
| 694 | }, | |
| 695 | indents: vec![18.0, 0.0], | |
| 696 | paragraphs: nodes, | |
| 697 | unsupported: Vec::new(), | |
| 698 | }], | |
| 699 | definitions, | |
| 700 | ) | |
| 701 | .unwrap(); | |
| 702 | let mut access = Accessibility::default(); | |
| 703 | let viewport = Viewport { | |
| 704 | size: [800, 600], | |
| 705 | origin: [48.0; 2], | |
| 706 | scale: 2.0, | |
| 707 | }; | |
| 708 | for step in 0..4 { | |
| 709 | if step == 2 { | |
| 710 | editor.insert(&mut engine, "new ").unwrap(); | |
| 711 | } | |
| 712 | if step == 3 { | |
| 713 | editor.undo(&mut engine).unwrap(); | |
| 714 | } | |
| 715 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 716 | assert_eq!( | |
| 717 | update | |
| 718 | .nodes | |
| 719 | .iter() | |
| 720 | .filter_map(|(_, n)| n.description()) | |
| 721 | .collect::<Vec<_>>(), | |
| 722 | [ | |
| 723 | "Rehearsal, incomplete", | |
| 724 | "Rehearsal, completed", | |
| 725 | "Question", | |
| 726 | "Music, disabled" | |
| 727 | ] | |
| 728 | ); | |
| 729 | let tree = accesskit_consumer::Tree::new(update, true); | |
| 730 | let text = tree.state().focus().unwrap().document_range().text(); | |
| 731 | assert_eq!( | |
| 732 | text, | |
| 733 | if step == 2 { | |
| 734 | "new task one\ntask two\nquestion\nmusic" | |
| 735 | } else { | |
| 736 | "task one\ntask two\nquestion\nmusic" | |
| 737 | } | |
| 738 | ); | |
| 739 | } | |
| 740 | } | |
| 741 | ||
| 742 | #[test] | |
| 743 | fn collapsed_paragraphs_keep_visible_accessibility_ranges_in_source_coordinates() { | |
| 744 | let document = TextDocument::new( | |
| 745 | [ | |
| 746 | "root", | |
| 747 | "folded", | |
| 748 | "hidden", | |
| 749 | "last", | |
| 750 | "folded tail", | |
| 751 | "hidden tail", | |
| 752 | ] | |
| 753 | .into_iter() | |
| 754 | .map(|text| Paragraph::new(text.into(), Format::default())) | |
| 755 | .collect(), | |
| 756 | ) | |
| 757 | .unwrap(); | |
| 758 | let mut nodes = document.nodes().to_vec(); | |
| 759 | for (child, parent, level) in [(1, 0, 2), (2, 1, 3), (4, 3, 2), (5, 4, 3)] { | |
| 760 | nodes[child].parent = Some(nodes[parent].id); | |
| 761 | nodes[child].level = level; | |
| 762 | } | |
| 763 | nodes[1].collapsed = true; | |
| 764 | nodes[4].collapsed = true; | |
| 765 | let mut engine = TextEngine::default(); | |
| 766 | let mut editor = | |
| 767 | CanvasEditor::new(&mut engine, TextDocument::from_nodes(nodes).unwrap(), 180.0) | |
| 768 | .unwrap(); | |
| 769 | let mut access = Accessibility::default(); | |
| 770 | let viewport = Viewport { | |
| 771 | size: [900, 700], | |
| 772 | scale: 2.0, | |
| 773 | origin: [13.0, 19.0], | |
| 774 | }; | |
| 775 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 776 | let tree = accesskit_consumer::Tree::new(update, true); | |
| 777 | let field = tree.state().focus().unwrap(); | |
| 778 | assert_eq!( | |
| 779 | field.document_range().text(), | |
| 780 | "root\nfolded\nlast\nfolded tail" | |
| 781 | ); | |
| 782 | assert!( | |
| 783 | access | |
| 784 | .runs | |
| 785 | .iter() | |
| 786 | .flat_map(|run| &run.positions) | |
| 787 | .all(|position| ![2, 5].contains(&position.paragraph)) | |
| 788 | ); | |
| 789 | let source = access | |
| 790 | .selection( | |
| 791 | editor.active_outline().id, | |
| 792 | field.document_range().to_text_selection(), | |
| 793 | ) | |
| 794 | .unwrap(); | |
| 795 | assert_eq!( | |
| 796 | source.positions[0], | |
| 797 | TextPosition { | |
| 798 | paragraph: 0, | |
| 799 | offset: 0 | |
| 800 | } | |
| 801 | ); | |
| 802 | assert_eq!( | |
| 803 | source.positions[1], | |
| 804 | TextPosition { | |
| 805 | paragraph: 4, | |
| 806 | offset: 11 | |
| 807 | } | |
| 808 | ); | |
| 809 | editor.select(source).unwrap(); | |
| 810 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 811 | let tree = accesskit_consumer::Tree::new(update, true); | |
| 812 | assert_eq!( | |
| 813 | tree.state() | |
| 814 | .focus() | |
| 815 | .unwrap() | |
| 816 | .text_selection() | |
| 817 | .unwrap() | |
| 818 | .text(), | |
| 819 | "root\nfolded\nlast\nfolded tail" | |
| 820 | ); | |
| 821 | editor | |
| 822 | .select( | |
| 823 | [TextPosition { | |
| 824 | paragraph: 4, | |
| 825 | offset: 0, | |
| 826 | }; 2] | |
| 827 | .into(), | |
| 828 | ) | |
| 829 | .unwrap(); | |
| 830 | access.update(&editor, viewport, "Test", None).unwrap(); | |
| 831 | let caret = editor.caret(1.0).unwrap(); | |
| 832 | assert_eq!(caret.x0, 27.0); | |
| 833 | let position = access | |
| 834 | .position( | |
| 835 | editor.active_outline(), | |
| 836 | editor.selection().positions[1], | |
| 837 | Affinity::Downstream, | |
| 838 | ) | |
| 839 | .unwrap(); | |
| 840 | let run = access | |
| 841 | .runs | |
| 842 | .iter() | |
| 843 | .find(|run| run.id == position.node) | |
| 844 | .unwrap(); | |
| 845 | assert_eq!(run.node.bounds().unwrap().x0, 27.0); | |
| 846 | assert_eq!(run.node.bounds().unwrap().y0, caret.y0); | |
| 847 | } | |
| 848 | ||
| 849 | #[test] | |
| 850 | fn consumer_reads_unicode_blank_paragraphs_and_hidden_fields() { | |
| 851 | let cases = [ | |
| 852 | "", | |
| 853 | "a\n\nשלום\nend", | |
| 854 | "café e\u{301} 🌳 👩👩👧👦 words wrap here", | |
| 855 | "\n\n", | |
| 856 | "a\nb\n", | |
| 857 | ]; | |
| 858 | let mut engine = TextEngine::default(); | |
| 859 | for text in cases | |
| 860 | .into_iter() | |
| 861 | .map(str::to_owned) | |
| 862 | .chain([format!("a{}", "\u{301}".repeat(300)), "x".repeat(600)]) | |
| 863 | { | |
| 864 | for width in [24.0, 480.0, 10000.0] { | |
| 865 | let document = TextDocument::new( | |
| 866 | text.split('\n') | |
| 867 | .map(|line| { | |
| 868 | Paragraph::from_runs([ | |
| 869 | ( | |
| 870 | String::from("secret"), | |
| 871 | Format { | |
| 872 | hidden: Some(true), | |
| 873 | ..Format::default() | |
| 874 | }, | |
| 875 | ), | |
| 876 | (line.to_owned(), Format::default()), | |
| 877 | ]) | |
| 878 | }) | |
| 879 | .collect(), | |
| 880 | ) | |
| 881 | .unwrap(); | |
| 882 | let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap(); | |
| 883 | let mut access = Accessibility::default(); | |
| 884 | let viewport = Viewport { | |
| 885 | size: [800, 600], | |
| 886 | origin: [48.0; 2], | |
| 887 | scale: 2.0, | |
| 888 | }; | |
| 889 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 890 | for (_, node) in &update.nodes { | |
| 891 | if node.role() == Role::TextRun { | |
| 892 | assert_eq!( | |
| 893 | node.character_lengths() | |
| 894 | .iter() | |
| 895 | .map(|n| *n as usize) | |
| 896 | .sum::<usize>(), | |
| 897 | node.value().unwrap().len() | |
| 898 | ); | |
| 899 | if let Some(positions) = node.character_positions() { | |
| 900 | assert_eq!(positions.len(), node.character_lengths().len()); | |
| 901 | } | |
| 902 | if let Some(widths) = node.character_widths() { | |
| 903 | assert_eq!(widths.len(), node.character_lengths().len()); | |
| 904 | } | |
| 905 | } | |
| 906 | } | |
| 907 | let tree = accesskit_consumer::Tree::new(update, true); | |
| 908 | let field = tree.state().focus().unwrap(); | |
| 909 | assert_eq!(field.document_range().text(), text, "width {width}"); | |
| 910 | let selection = field.document_range().to_text_selection(); | |
| 911 | let source_selection = access | |
| 912 | .selection(editor.active_outline().id, selection) | |
| 913 | .unwrap(); | |
| 914 | editor.select(source_selection).unwrap(); | |
| 915 | let selected = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 916 | let tree = accesskit_consumer::Tree::new(selected, true); | |
| 917 | assert_eq!( | |
| 918 | tree.state() | |
| 919 | .focus() | |
| 920 | .unwrap() | |
| 921 | .text_selection() | |
| 922 | .unwrap() | |
| 923 | .text(), | |
| 924 | text | |
| 925 | ); | |
| 926 | let stable_ids: Vec<_> = access.runs.iter().map(|run| run.id).collect(); | |
| 927 | access | |
| 928 | .update( | |
| 929 | &editor, | |
| 930 | Viewport { | |
| 931 | scale: 3.0, | |
| 932 | ..viewport | |
| 933 | }, | |
| 934 | "Test", | |
| 935 | None, | |
| 936 | ) | |
| 937 | .unwrap(); | |
| 938 | assert_eq!( | |
| 939 | stable_ids, | |
| 940 | access.runs.iter().map(|run| run.id).collect::<Vec<_>>() | |
| 941 | ); | |
| 942 | access.deactivate(); | |
| 943 | access.update(&editor, viewport, "Test", None).unwrap(); | |
| 944 | assert!( | |
| 945 | access | |
| 946 | .selection(editor.active_outline().id, selection) | |
| 947 | .is_err() | |
| 948 | ); | |
| 949 | let selection = access | |
| 950 | .update(&editor, viewport, "Test", None) | |
| 951 | .unwrap() | |
| 952 | .nodes[1] | |
| 953 | .1 | |
| 954 | .text_selection() | |
| 955 | .copied() | |
| 956 | .unwrap(); | |
| 957 | editor.insert(&mut engine, "replacement").unwrap(); | |
| 958 | access.update(&editor, viewport, "Test", None).unwrap(); | |
| 959 | assert!( | |
| 960 | access | |
| 961 | .selection(editor.active_outline().id, selection) | |
| 962 | .is_err() | |
| 963 | ); | |
| 964 | editor.undo(&mut engine).unwrap(); | |
| 965 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 966 | let tree = accesskit_consumer::Tree::new(update, true); | |
| 967 | assert_eq!(tree.state().focus().unwrap().document_range().text(), text); | |
| 968 | } | |
| 969 | } | |
| 970 | } | |
| 971 | ||
| 972 | #[test] | |
| 973 | fn caret_geometry_uses_visible_offsets_and_canvas_line_metrics() { | |
| 974 | let mut engine = TextEngine::default(); | |
| 975 | let document = TextDocument::new(vec![ | |
| 976 | Paragraph::from_runs([ | |
| 977 | ("abc ".into(), Format::default()), | |
| 978 | ( | |
| 979 | "hidden field".into(), | |
| 980 | Format { | |
| 981 | hidden: Some(true), | |
| 982 | ..Format::default() | |
| 983 | }, | |
| 984 | ), | |
| 985 | ("e\u{301} 🌳 words wrap here".into(), Format::default()), | |
| 986 | ]), | |
| 987 | Paragraph::new("".into(), Format::default()), | |
| 988 | Paragraph::new("abc שלום def عالم end".into(), Format::default()), | |
| 989 | Paragraph::new("last\nline\n".into(), Format::default()), | |
| 990 | ]) | |
| 991 | .unwrap(); | |
| 992 | let positions: Vec<_> = document | |
| 993 | .paragraphs() | |
| 994 | .enumerate() | |
| 995 | .flat_map(|(paragraph, text)| { | |
| 996 | text.text() | |
| 997 | .char_indices() | |
| 998 | .map(|(byte, _)| text.utf16_offset(byte).unwrap()) | |
| 999 | .chain(std::iter::once( | |
| 1000 | text.utf16_offset(text.text().len()).unwrap(), | |
| 1001 | )) | |
| 1002 | .map(move |offset| TextPosition { paragraph, offset }) | |
| 1003 | }) | |
| 1004 | .collect(); | |
| 1005 | let mut editor = CanvasEditor::new(&mut engine, document, 48.0).unwrap(); | |
| 1006 | let mut access = Accessibility::default(); | |
| 1007 | let viewport = Viewport { | |
| 1008 | size: [800, 600], | |
| 1009 | origin: [31.0, -57.0], | |
| 1010 | scale: 2.5, | |
| 1011 | }; | |
| 1012 | for (position, affinity) in positions.into_iter().flat_map(|position| { | |
| 1013 | [Affinity::Upstream, Affinity::Downstream].map(|affinity| (position, affinity)) | |
| 1014 | }) { | |
| 1015 | editor | |
| 1016 | .select(Selection { | |
| 1017 | positions: [position; 2], | |
| 1018 | affinities: [affinity; 2], | |
| 1019 | }) | |
| 1020 | .unwrap(); | |
| 1021 | let caret = editor.caret(0.0).unwrap(); | |
| 1022 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 1023 | let tree = accesskit_consumer::Tree::new(update, true); | |
| 1024 | let field = tree.state().focus().unwrap(); | |
| 1025 | assert_eq!( | |
| 1026 | field.document_range().text(), | |
| 1027 | "abc e\u{301} 🌳 words wrap here\n\nabc שלום def عالم end\nlast\nline\n" | |
| 1028 | ); | |
| 1029 | let rects = field.text_selection().unwrap().bounding_boxes(); | |
| 1030 | assert_eq!(rects.len(), 1, "{position:?}"); | |
| 1031 | let rect = rects[0]; | |
| 1032 | for (actual, expected) in [ | |
| 1033 | (rect.x0, caret.x0 * 2.5 + 31.0), | |
| 1034 | (rect.y0, caret.y0 * 2.5 - 57.0), | |
| 1035 | (rect.y1, caret.y1 * 2.5 - 57.0), | |
| 1036 | ] { | |
| 1037 | assert!( | |
| 1038 | (actual - expected).abs() < 0.0001, | |
| 1039 | "{position:?} {affinity:?}: {rect:?} vs {caret:?}" | |
| 1040 | ); | |
| 1041 | } | |
| 1042 | let selection = access | |
| 1043 | .selection( | |
| 1044 | editor.active_outline().id, | |
| 1045 | field.text_selection().unwrap().to_text_selection(), | |
| 1046 | ) | |
| 1047 | .unwrap(); | |
| 1048 | editor.select(selection).unwrap(); | |
| 1049 | assert_eq!( | |
| 1050 | editor.caret(0.0).unwrap(), | |
| 1051 | caret, | |
| 1052 | "{position:?} {affinity:?}: AX selection moved the caret" | |
| 1053 | ); | |
| 1054 | } | |
| 1055 | } | |
| 1056 | #[test] | |
| 1057 | fn keyboard_and_accessibility_preserve_visual_caret_at_wraps() { | |
| 1058 | use one_canvas::editor::Movement; | |
| 1059 | let mut engine = TextEngine::default(); | |
| 1060 | let document = TextDocument::new(vec![ | |
| 1061 | Paragraph::new( | |
| 1062 | "first words wrap here and continue".into(), | |
| 1063 | Format::default(), | |
| 1064 | ), | |
| 1065 | Paragraph::new("abc שלום def عالم end".into(), Format::default()), | |
| 1066 | Paragraph::new("last\nline".into(), Format::default()), | |
| 1067 | ]) | |
| 1068 | .unwrap(); | |
| 1069 | let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); | |
| 1070 | let mut access = Accessibility::default(); | |
| 1071 | let viewport = Viewport { | |
| 1072 | size: [800, 600], | |
| 1073 | origin: [0.0; 2], | |
| 1074 | scale: 1.0, | |
| 1075 | }; | |
| 1076 | for movement in [ | |
| 1077 | Movement::LineEnd, | |
| 1078 | Movement::Right, | |
| 1079 | Movement::Left, | |
| 1080 | Movement::Down, | |
| 1081 | Movement::LineStart, | |
| 1082 | Movement::LineEnd, | |
| 1083 | Movement::Down, | |
| 1084 | Movement::Down, | |
| 1085 | Movement::Down, | |
| 1086 | Movement::LineEnd, | |
| 1087 | Movement::Left, | |
| 1088 | Movement::Left, | |
| 1089 | Movement::Right, | |
| 1090 | Movement::LineStart, | |
| 1091 | Movement::Up, | |
| 1092 | Movement::Right, | |
| 1093 | Movement::LineEnd, | |
| 1094 | ] { | |
| 1095 | editor.move_selection(movement, false).unwrap(); | |
| 1096 | let caret = editor.caret(0.0).unwrap(); | |
| 1097 | let tree = accesskit_consumer::Tree::new( | |
| 1098 | access.update(&editor, viewport, "Test", None).unwrap(), | |
| 1099 | true, | |
| 1100 | ); | |
| 1101 | let field = tree.state().focus().unwrap(); | |
| 1102 | let selection = field.text_selection().unwrap(); | |
| 1103 | let rects = selection.bounding_boxes(); | |
| 1104 | assert_eq!(rects.len(), 1); | |
| 1105 | let rect = rects[0]; | |
| 1106 | for (actual, expected) in [ | |
| 1107 | (rect.x0, caret.x0), | |
| 1108 | (rect.y0, caret.y0), | |
| 1109 | (rect.y1, caret.y1), | |
| 1110 | ] { | |
| 1111 | assert!( | |
| 1112 | (actual - expected).abs() < 0.0001, | |
| 1113 | "{movement:?}: {rect:?} vs {caret:?}" | |
| 1114 | ); | |
| 1115 | } | |
| 1116 | let source_selection = access | |
| 1117 | .selection(editor.active_outline().id, selection.to_text_selection()) | |
| 1118 | .unwrap(); | |
| 1119 | editor.select(source_selection).unwrap(); | |
| 1120 | let restored = editor.caret(0.0).unwrap(); | |
| 1121 | assert_eq!( | |
| 1122 | restored, caret, | |
| 1123 | "{movement:?}: AX selection moved the caret" | |
| 1124 | ); | |
| 1125 | } | |
| 1126 | } | |
| 1127 | #[test] | |
| 1128 | fn cached_runs_match_reconstruction_through_editing_and_view_changes() { | |
| 1129 | let mut engine = TextEngine::default(); | |
| 1130 | let document = TextDocument::new( | |
| 1131 | [ | |
| 1132 | "one two three", | |
| 1133 | "", | |
| 1134 | "אבג 日本 👩🏽💻 e\u{301}", | |
| 1135 | "last paragraph", | |
| 1136 | ] | |
| 1137 | .into_iter() | |
| 1138 | .map(|text| Paragraph::new(text.into(), Format::default())) | |
| 1139 | .collect(), | |
| 1140 | ) | |
| 1141 | .unwrap(); | |
| 1142 | let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); | |
| 1143 | let mut cached = Accessibility::default(); | |
| 1144 | let mut rebuilt = Accessibility::default(); | |
| 1145 | let mut compare = |editor: &CanvasEditor, step: usize| { | |
| 1146 | let viewport = Viewport { | |
| 1147 | size: [800, 600], | |
| 1148 | origin: [step as f32 * 0.125, -17.25], | |
| 1149 | scale: 0.75 + (step % 5) as f32 * 0.5, | |
| 1150 | }; | |
| 1151 | let preview = step | |
| 1152 | .is_multiple_of(2) | |
| 1153 | .then_some((editor.active_outline().id, [13.5, -7.25])); | |
| 1154 | for repeat in 0..2 { | |
| 1155 | rebuilt.paragraphs.clear(); | |
| 1156 | let actual = cached.update(editor, viewport, "Test", preview).unwrap(); | |
| 1157 | let expected = rebuilt.update(editor, viewport, "Test", preview).unwrap(); | |
| 1158 | assert_eq!(actual.nodes, expected.nodes, "step {step}, repeat {repeat}"); | |
| 1159 | assert_eq!(actual.focus, expected.focus); | |
| 1160 | let tree = accesskit_consumer::Tree::new(actual, true); | |
| 1161 | let field = tree.state().focus().unwrap(); | |
| 1162 | let selection = field.document_range().to_text_selection(); | |
| 1163 | assert_eq!( | |
| 1164 | cached | |
| 1165 | .selection(editor.active_outline().id, selection) | |
| 1166 | .unwrap(), | |
| 1167 | rebuilt | |
| 1168 | .selection(editor.active_outline().id, selection) | |
| 1169 | .unwrap() | |
| 1170 | ); | |
| 1171 | let count = editor.outlines().iter().map(|o| o.layouts().count()).sum(); | |
| 1172 | assert_eq!(cached.paragraphs.len(), count); | |
| 1173 | } | |
| 1174 | }; | |
| 1175 | compare(&editor, 0); | |
| 1176 | let mut seed = 0x923e_u64; | |
| 1177 | let mut next = || { | |
| 1178 | seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); | |
| 1179 | (seed >> 32) as usize | |
| 1180 | }; | |
| 1181 | for step in 1..=160 { | |
| 1182 | let id = editor.outlines()[next() % editor.outlines().len()].id; | |
| 1183 | editor.focus_outline(id).unwrap(); | |
| 1184 | let positions: Vec<_> = editor | |
| 1185 | .active_outline() | |
| 1186 | .document() | |
| 1187 | .paragraphs() | |
| 1188 | .enumerate() | |
| 1189 | .flat_map(|(paragraph, text)| { | |
| 1190 | (0..=text.text().len()).filter_map(move |byte| { | |
| 1191 | text.utf16_offset(byte) | |
| 1192 | .ok() | |
| 1193 | .map(|offset| TextPosition { paragraph, offset }) | |
| 1194 | }) | |
| 1195 | }) | |
| 1196 | .collect(); | |
| 1197 | let anchor = positions[next() % positions.len()]; | |
| 1198 | let focus = if step % 3 == 0 { | |
| 1199 | positions[next() % positions.len()] | |
| 1200 | } else { | |
| 1201 | anchor | |
| 1202 | }; | |
| 1203 | editor.select([anchor, focus].into()).unwrap(); | |
| 1204 | match step % 10 { | |
| 1205 | 0 => editor.insert(&mut engine, "\n").unwrap(), | |
| 1206 | 1 => editor.insert(&mut engine, "abc 👩🏽💻 שלום e\u{301}").unwrap(), | |
| 1207 | 2 => { | |
| 1208 | editor.delete(&mut engine, true).unwrap(); | |
| 1209 | } | |
| 1210 | 3 => editor | |
| 1211 | .resize(&mut engine, 48.0 + (step % 7) as f32 * 31.25) | |
| 1212 | .unwrap(), | |
| 1213 | 4 => { | |
| 1214 | editor | |
| 1215 | .create_outline(&mut engine, [step as f32, -5.0], 97.0) | |
| 1216 | .unwrap(); | |
| 1217 | editor.insert(&mut engine, "annotation").unwrap(); | |
| 1218 | } | |
| 1219 | 5 => { | |
| 1220 | editor.undo(&mut engine).unwrap(); | |
| 1221 | } | |
| 1222 | 6 => { | |
| 1223 | editor.redo(&mut engine).unwrap(); | |
| 1224 | } | |
| 1225 | 7 => editor.move_outline(id, [17.5, step as f32]).unwrap(), | |
| 1226 | 8 => { | |
| 1227 | editor.compose(&mut engine, "仮\n名".into(), 1..1).unwrap(); | |
| 1228 | compare(&editor, step); | |
| 1229 | editor.cancel_composition(&mut engine).unwrap(); | |
| 1230 | } | |
| 1231 | _ => {} | |
| 1232 | } | |
| 1233 | compare(&editor, step); | |
| 1234 | } | |
| 1235 | while editor.undo(&mut engine).unwrap() { | |
| 1236 | compare(&editor, 161); | |
| 1237 | } | |
| 1238 | while editor.redo(&mut engine).unwrap() { | |
| 1239 | compare(&editor, 162); | |
| 1240 | } | |
| 1241 | } | |
| 1242 | #[test] | |
| 1243 | fn outlines_have_independent_accessibility_identity_and_selection() { | |
| 1244 | let mut engine = TextEngine::default(); | |
| 1245 | let document = | |
| 1246 | TextDocument::new(vec![Paragraph::new("lyrics".into(), Format::default())]).unwrap(); | |
| 1247 | let mut editor = CanvasEditor::new(&mut engine, document, 240.0).unwrap(); | |
| 1248 | let first = editor.active_outline().id; | |
| 1249 | let second = editor | |
| 1250 | .create_outline(&mut engine, [300.0, 40.0], 120.0) | |
| 1251 | .unwrap(); | |
| 1252 | editor.insert(&mut engine, "annotation 🌳").unwrap(); | |
| 1253 | let mut access = Accessibility::default(); | |
| 1254 | let viewport = Viewport { | |
| 1255 | size: [1000, 720], | |
| 1256 | scale: 2.0, | |
| 1257 | origin: [48.0; 2], | |
| 1258 | }; | |
| 1259 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 1260 | let fields: Vec<_> = update | |
| 1261 | .nodes | |
| 1262 | .iter() | |
| 1263 | .filter(|(_, node)| node.role() == Role::MultilineTextInput) | |
| 1264 | .map(|(id, node)| (*id, node.value().unwrap().to_owned())) | |
| 1265 | .collect(); | |
| 1266 | assert_eq!( | |
| 1267 | fields | |
| 1268 | .iter() | |
| 1269 | .map(|(_, text)| text.as_str()) | |
| 1270 | .collect::<Vec<_>>(), | |
| 1271 | ["lyrics", "annotation 🌳"] | |
| 1272 | ); | |
| 1273 | assert_ne!(fields[0].0, fields[1].0); | |
| 1274 | assert_eq!(access.outline_for_node(fields[0].0), Some(first)); | |
| 1275 | assert_eq!(access.outline_for_node(fields[1].0), Some(second)); | |
| 1276 | let tree = accesskit_consumer::Tree::new(update, true); | |
| 1277 | let field = tree.state().focus().unwrap(); | |
| 1278 | assert_eq!(field.document_range().text(), "annotation 🌳"); | |
| 1279 | let selection = field.document_range().to_text_selection(); | |
| 1280 | assert!(access.selection(first, selection).is_err()); | |
| 1281 | let source_selection = access.selection(second, selection).unwrap(); | |
| 1282 | editor.select(source_selection).unwrap(); | |
| 1283 | let preview = access | |
| 1284 | .update(&editor, viewport, "Test", Some((second, [400.0, 50.0]))) | |
| 1285 | .unwrap(); | |
| 1286 | let preview_tree = accesskit_consumer::Tree::new(preview, true); | |
| 1287 | let preview_field = preview_tree.state().focus().unwrap(); | |
| 1288 | let rect = preview_field.document_range().bounding_boxes()[0]; | |
| 1289 | assert_eq!(rect.x0, 848.0); | |
| 1290 | assert_eq!(rect.y0, 148.0); | |
| 1291 | assert_eq!(editor.active_outline().origin(), [300.0, 40.0]); | |
| 1292 | editor.move_outline(second, [400.0, 50.0]).unwrap(); | |
| 1293 | access.update(&editor, viewport, "Test", None).unwrap(); | |
| 1294 | assert_eq!(access.outline_for_node(fields[1].0), Some(second)); | |
| 1295 | assert!(access.selection(second, selection).is_ok()); | |
| 1296 | editor.undo(&mut engine).unwrap(); | |
| 1297 | editor.undo(&mut engine).unwrap(); | |
| 1298 | editor.undo(&mut engine).unwrap(); | |
| 1299 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 1300 | assert_eq!( | |
| 1301 | update | |
| 1302 | .nodes | |
| 1303 | .iter() | |
| 1304 | .filter(|(_, node)| node.role() == Role::MultilineTextInput) | |
| 1305 | .count(), | |
| 1306 | 1 | |
| 1307 | ); | |
| 1308 | assert_eq!(access.outline_for_node(fields[1].0), None); | |
| 1309 | assert!(access.selection(second, selection).is_err()); | |
| 1310 | assert_eq!(access.outline_for_node(fields[0].0), Some(first)); | |
| 1311 | } | |
| 1312 | } |
crates/canvas-app/src/macos.rs created+102| ... | ... | @@ -0,0 +1,102 @@ |
| 1 | use objc2::{ | |
| 2 | ClassType, DeclaredClass, declare_class, msg_send, msg_send_id, mutability, | |
| 3 | rc::{Allocated, Retained}, | |
| 4 | runtime::AnyObject, | |
| 5 | }; | |
| 6 | use objc2_app_kit::{NSAlert, NSAlertSecondButtonReturn, NSApplication, NSEvent, NSEventType}; | |
| 7 | use objc2_foundation::{MainThreadMarker, NSString}; | |
| 8 | use std::sync::OnceLock; | |
| 9 | use winit::{ | |
| 10 | error::EventLoopError, | |
| 11 | event_loop::{EventLoop, EventLoopProxy}, | |
| 12 | raw_window_handle::{HasWindowHandle, RawWindowHandle}, | |
| 13 | window::Window, | |
| 14 | }; | |
| 15 | ||
| 16 | static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new(); | |
| 17 | ||
| 18 | declare_class!( | |
| 19 | struct CanvasApplication; | |
| 20 | ||
| 21 | unsafe impl ClassType for CanvasApplication { | |
| 22 | type Super = NSApplication; | |
| 23 | type Mutability = mutability::MainThreadOnly; | |
| 24 | const NAME: &'static str = "OneCanvasApplication"; | |
| 25 | } | |
| 26 | ||
| 27 | impl DeclaredClass for CanvasApplication { | |
| 28 | type Ivars = (); | |
| 29 | } | |
| 30 | ||
| 31 | unsafe impl CanvasApplication { | |
| 32 | #[method_id(init)] | |
| 33 | fn init(this: Allocated<Self>) -> Retained<Self> { | |
| 34 | let this = this.set_ivars(()); | |
| 35 | unsafe { msg_send_id![super(this), init] } | |
| 36 | } | |
| 37 | ||
| 38 | #[method(terminate:)] | |
| 39 | fn terminate(&self, _sender: Option<&AnyObject>) { | |
| 40 | if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Quit); } | |
| 41 | } | |
| 42 | ||
| 43 | // NSAlert consumes Escape before it reaches cancelOperation:. | |
| 44 | #[method(sendEvent:)] | |
| 45 | fn send_event(&self, event: &NSEvent) { | |
| 46 | unsafe { | |
| 47 | if event.r#type() == NSEventType::KeyDown | |
| 48 | && self.modalWindow().is_some() | |
| 49 | && event.charactersIgnoringModifiers().is_some_and(|text| text.to_string() == "\u{1b}") | |
| 50 | { | |
| 51 | self.stopModal(); | |
| 52 | } else { | |
| 53 | let _: () = msg_send![super(self), sendEvent: event]; | |
| 54 | } | |
| 55 | } | |
| 56 | } | |
| 57 | } | |
| 58 | ); | |
| 59 | ||
| 60 | pub fn event_loop() -> Result<EventLoop<crate::UserEvent>, EventLoopError> { | |
| 61 | MainThreadMarker::new().expect("AppKit must start on the main thread"); | |
| 62 | // Create the application's own subclass before Winit obtains the singleton. | |
| 63 | let _: Retained<CanvasApplication> = | |
| 64 | unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] }; | |
| 65 | let event_loop = EventLoop::with_user_event().build()?; | |
| 66 | QUIT.set(event_loop.create_proxy()) | |
| 67 | .expect("Only one application event loop is created"); | |
| 68 | Ok(event_loop) | |
| 69 | } | |
| 70 | ||
| 71 | pub fn discard_changes() -> bool { | |
| 72 | let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); | |
| 73 | // The alert and its strings stay on AppKit's main thread for the modal call. | |
| 74 | unsafe { | |
| 75 | let alert = NSAlert::new(mtm); | |
| 76 | alert.setMessageText(&NSString::from_str("Discard this page?")); | |
| 77 | alert.setInformativeText(&NSString::from_str( | |
| 78 | "This temporary page has no saved copy. Closing it will discard your edits.", | |
| 79 | )); | |
| 80 | alert.addButtonWithTitle(&NSString::from_str("Keep Editing")); | |
| 81 | alert.addButtonWithTitle(&NSString::from_str("Discard Changes")); | |
| 82 | alert.runModal() == NSAlertSecondButtonReturn | |
| 83 | } | |
| 84 | } | |
| 85 | ||
| 86 | /// End AppKit preedit after a canvas action commits or cancels the composition. | |
| 87 | pub fn clear_marked_text(window: &Window) { | |
| 88 | MainThreadMarker::new().expect("Text input belongs to the main thread"); | |
| 89 | let RawWindowHandle::AppKit(handle) = | |
| 90 | window.window_handle().expect("Live AppKit window").as_raw() | |
| 91 | else { | |
| 92 | unreachable!() | |
| 93 | }; | |
| 94 | // Winit owns this view and implements the NSTextInputClient selectors. | |
| 95 | unsafe { | |
| 96 | let view = &*handle.ns_view.as_ptr().cast::<AnyObject>(); | |
| 97 | let marked: bool = msg_send![view, hasMarkedText]; | |
| 98 | if marked { | |
| 99 | let _: () = msg_send![view, unmarkText]; | |
| 100 | } | |
| 101 | } | |
| 102 | } |
crates/canvas-app/src/main.rs created+2077| ... | ... | @@ -0,0 +1,2077 @@ |
| 1 | mod accessibility; | |
| 2 | mod macos; | |
| 3 | #[cfg(test)] | |
| 4 | mod profile; | |
| 5 | ||
| 6 | use one_canvas::{ | |
| 7 | document::TextDocument, | |
| 8 | editor::{CanvasEditor, Movement, TextOutline}, | |
| 9 | layout::TextEngine, | |
| 10 | page::Page, | |
| 11 | text::Paragraph, | |
| 12 | }; | |
| 13 | use one_canvas_gpu::{Primitive, Renderer, Viewport, page::PageScene}; | |
| 14 | use onestore::document::Format; | |
| 15 | use std::{ | |
| 16 | error::Error, | |
| 17 | path::PathBuf, | |
| 18 | sync::Arc, | |
| 19 | time::{Duration, Instant}, | |
| 20 | }; | |
| 21 | use winit::{ | |
| 22 | application::ApplicationHandler, | |
| 23 | dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, | |
| 24 | event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, | |
| 25 | event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, | |
| 26 | keyboard::{Key, ModifiersState, NamedKey}, | |
| 27 | window::{Window, WindowId}, | |
| 28 | }; | |
| 29 | ||
| 30 | const HANDLE_HEIGHT: f32 = 6.75; | |
| 31 | const NEW_OUTLINE_WIDTH: f32 = 240.0; | |
| 32 | ||
| 33 | #[derive(Debug)] | |
| 34 | enum UserEvent { | |
| 35 | Quit, | |
| 36 | Accessibility(accesskit_winit::Event), | |
| 37 | } | |
| 38 | ||
| 39 | impl From<accesskit_winit::Event> for UserEvent { | |
| 40 | fn from(event: accesskit_winit::Event) -> Self { | |
| 41 | Self::Accessibility(event) | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | fn trace_input(event: &impl std::fmt::Debug) { | |
| 46 | if std::env::var_os("ONE_CANVAS_TRACE_INPUT").is_some() { | |
| 47 | eprintln!("Input {:?}: {event:?}", std::time::SystemTime::now()); | |
| 48 | } | |
| 49 | } | |
| 50 | ||
| 51 | fn read_only_shortcut(key: &Key, modifiers: ModifiersState) -> bool { | |
| 52 | key == &Key::Named(NamedKey::Escape) | |
| 53 | || (modifiers.control_key() && key == &Key::Named(NamedKey::Tab)) | |
| 54 | || (modifiers.super_key() | |
| 55 | && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift_key() && value.eq_ignore_ascii_case("n")))) | |
| 56 | } | |
| 57 | ||
| 58 | struct App { | |
| 59 | proxy: EventLoopProxy<UserEvent>, | |
| 60 | input: Option<Input>, | |
| 61 | substitutes: Vec<PathBuf>, | |
| 62 | state: Option<State>, | |
| 63 | startup_error: Option<Box<dyn Error>>, | |
| 64 | } | |
| 65 | ||
| 66 | enum Input { | |
| 67 | Notes { | |
| 68 | document: TextDocument, | |
| 69 | width: f32, | |
| 70 | reference: Option<Page>, | |
| 71 | }, | |
| 72 | Page(Page), | |
| 73 | } | |
| 74 | ||
| 75 | enum Drag { | |
| 76 | Text, | |
| 77 | Outline { | |
| 78 | id: onestore::ExGuid, | |
| 79 | grab: [f32; 2], | |
| 80 | pending_press: Option<[f32; 2]>, | |
| 81 | }, | |
| 82 | } | |
| 83 | ||
| 84 | struct State { | |
| 85 | window: Arc<Window>, | |
| 86 | instance: wgpu::Instance, | |
| 87 | surface: wgpu::Surface<'static>, | |
| 88 | config: wgpu::SurfaceConfiguration, | |
| 89 | renderer: Renderer, | |
| 90 | engine: TextEngine, | |
| 91 | editor: CanvasEditor, | |
| 92 | initial: Vec<(onestore::ExGuid, onestore::document::Layout, TextDocument)>, | |
| 93 | scene: Option<(PageScene, [f32; 2])>, | |
| 94 | viewport: Viewport, | |
| 95 | display_scale: f32, | |
| 96 | pointer: [f32; 2], | |
| 97 | drag: Option<Drag>, | |
| 98 | read_only_focus: Option<usize>, | |
| 99 | modifiers: ModifiersState, | |
| 100 | focused: bool, | |
| 101 | occluded: bool, | |
| 102 | caret: bool, | |
| 103 | blink_at: Instant, | |
| 104 | clipboard: arboard::Clipboard, | |
| 105 | access_adapter: accesskit_winit::Adapter, | |
| 106 | accessibility: accessibility::Accessibility, | |
| 107 | } | |
| 108 | ||
| 109 | impl State { | |
| 110 | async fn new( | |
| 111 | event_loop: &ActiveEventLoop, | |
| 112 | proxy: EventLoopProxy<UserEvent>, | |
| 113 | input: Input, | |
| 114 | substitutes: &[PathBuf], | |
| 115 | ) -> Result<Self, Box<dyn Error>> { | |
| 116 | let window = Arc::new( | |
| 117 | event_loop.create_window( | |
| 118 | Window::default_attributes() | |
| 119 | .with_visible(false) | |
| 120 | .with_title(match &input { | |
| 121 | Input::Notes { | |
| 122 | reference: Some(page), | |
| 123 | .. | |
| 124 | } => format!("{} · Reference and temporary notes", page.title), | |
| 125 | Input::Page(page) => format!("{} · Temporary page", page.title), | |
| 126 | Input::Notes { | |
| 127 | reference: None, .. | |
| 128 | } => "Untitled · Temporary page".into(), | |
| 129 | }) | |
| 130 | .with_inner_size(LogicalSize::new(1000.0, 720.0)), | |
| 131 | )?, | |
| 132 | ); | |
| 133 | let access_adapter = | |
| 134 | accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy); | |
| 135 | window.set_visible(true); | |
| 136 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle( | |
| 137 | Box::new(window.clone()), | |
| 138 | )); | |
| 139 | let surface = instance.create_surface(window.clone())?; | |
| 140 | let adapter = instance | |
| 141 | .request_adapter(&wgpu::RequestAdapterOptions { | |
| 142 | compatible_surface: Some(&surface), | |
| 143 | ..Default::default() | |
| 144 | }) | |
| 145 | .await?; | |
| 146 | let (device, queue) = adapter.request_device(&Default::default()).await?; | |
| 147 | let size = window.inner_size(); | |
| 148 | let config = surface | |
| 149 | .get_default_config(&adapter, size.width, size.height) | |
| 150 | .ok_or("No supported canvas surface")?; | |
| 151 | surface.configure(&device, &config); | |
| 152 | let renderer = Renderer::new(device, queue, config.format); | |
| 153 | let mut engine = TextEngine::default(); | |
| 154 | for path in substitutes { | |
| 155 | let target = engine | |
| 156 | .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; | |
| 157 | eprintln!("Using {} for {target}", path.display()); | |
| 158 | } | |
| 159 | let (editor, scene) = match input { | |
| 160 | Input::Notes { | |
| 161 | document, | |
| 162 | width, | |
| 163 | reference, | |
| 164 | } => { | |
| 165 | let editor = CanvasEditor::new(&mut engine, document, width)?; | |
| 166 | let scene = reference | |
| 167 | .map(|page| { | |
| 168 | PageScene::new(page, &mut engine).map(|scene| (scene, [width + 24.0, 0.0])) | |
| 169 | }) | |
| 170 | .transpose()?; | |
| 171 | (editor, scene) | |
| 172 | } | |
| 173 | Input::Page(page) => { | |
| 174 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; | |
| 175 | (editor, Some((scene, [0.0; 2]))) | |
| 176 | } | |
| 177 | }; | |
| 178 | let initial = editor | |
| 179 | .outlines() | |
| 180 | .iter() | |
| 181 | .map(|outline| { | |
| 182 | ( | |
| 183 | outline.id, | |
| 184 | outline.layout().clone(), | |
| 185 | outline.document().clone(), | |
| 186 | ) | |
| 187 | }) | |
| 188 | .collect(); | |
| 189 | let dpr = window.scale_factor() as f32; | |
| 190 | window.set_ime_allowed(true); | |
| 191 | window.set_cursor(winit::window::CursorIcon::Text); | |
| 192 | window.request_redraw(); | |
| 193 | eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); | |
| 194 | Ok(Self { | |
| 195 | window, | |
| 196 | instance, | |
| 197 | surface, | |
| 198 | config, | |
| 199 | renderer, | |
| 200 | engine, | |
| 201 | editor, | |
| 202 | initial, | |
| 203 | scene, | |
| 204 | viewport: Viewport { | |
| 205 | size: [size.width, size.height], | |
| 206 | scale: dpr * 96.0 / 72.0, | |
| 207 | origin: [48.0 * dpr; 2], | |
| 208 | }, | |
| 209 | display_scale: dpr, | |
| 210 | pointer: [0.0; 2], | |
| 211 | drag: None, | |
| 212 | read_only_focus: None, | |
| 213 | modifiers: ModifiersState::empty(), | |
| 214 | focused: true, | |
| 215 | occluded: false, | |
| 216 | caret: true, | |
| 217 | blink_at: Instant::now() + Duration::from_millis(500), | |
| 218 | clipboard: arboard::Clipboard::new()?, | |
| 219 | access_adapter, | |
| 220 | accessibility: accessibility::Accessibility::default(), | |
| 221 | }) | |
| 222 | } | |
| 223 | ||
| 224 | fn preview(&self) -> Option<(onestore::ExGuid, [f32; 2])> { | |
| 225 | let Drag::Outline { | |
| 226 | id, | |
| 227 | grab, | |
| 228 | pending_press, | |
| 229 | } = self.drag.as_ref()? | |
| 230 | else { | |
| 231 | return None; | |
| 232 | }; | |
| 233 | let origin = self | |
| 234 | .editor | |
| 235 | .outlines() | |
| 236 | .iter() | |
| 237 | .find(|outline| outline.id == *id)? | |
| 238 | .origin(); | |
| 239 | if pending_press.is_some() { | |
| 240 | return Some((*id, origin)); | |
| 241 | } | |
| 242 | let point = self.viewport.document_point(self.pointer); | |
| 243 | let position = [point[0] - grab[0], point[1] - grab[1]]; | |
| 244 | Some(( | |
| 245 | *id, | |
| 246 | if self.modifiers.alt_key() { | |
| 247 | position | |
| 248 | } else { | |
| 249 | snap_to_grid(position) | |
| 250 | }, | |
| 251 | )) | |
| 252 | } | |
| 253 | ||
| 254 | fn set_read_only_focus(&mut self, index: Option<usize>) { | |
| 255 | if self.read_only_focus != index { | |
| 256 | self.editor.finish_composition(); | |
| 257 | self.drag = None; | |
| 258 | self.read_only_focus = index; | |
| 259 | self.window.set_ime_allowed(index.is_none()); | |
| 260 | } | |
| 261 | } | |
| 262 | ||
| 263 | fn changed(&mut self) -> Result<(), Box<dyn Error>> { | |
| 264 | self.caret = true; | |
| 265 | self.blink_at = Instant::now() + Duration::from_millis(500); | |
| 266 | let mut rect = self.editor.caret(1.0)?; | |
| 267 | let origin = self | |
| 268 | .preview() | |
| 269 | .map(|(_, origin)| origin) | |
| 270 | .unwrap_or_else(|| self.editor.active_outline().origin()); | |
| 271 | rect.x0 += f64::from(origin[0]); | |
| 272 | rect.y0 += f64::from(origin[1]); | |
| 273 | rect.x1 += f64::from(origin[0]); | |
| 274 | rect.y1 += f64::from(origin[1]); | |
| 275 | let v = self.viewport; | |
| 276 | self.window.set_ime_cursor_area( | |
| 277 | PhysicalPosition::new( | |
| 278 | rect.x0 * f64::from(v.scale) + f64::from(v.origin[0]), | |
| 279 | rect.y0 * f64::from(v.scale) + f64::from(v.origin[1]), | |
| 280 | ), | |
| 281 | PhysicalSize::new( | |
| 282 | (rect.width() * f64::from(v.scale)).max(1.0), | |
| 283 | (rect.height() * f64::from(v.scale)).max(1.0), | |
| 284 | ), | |
| 285 | ); | |
| 286 | self.update_accessibility()?; | |
| 287 | self.window.request_redraw(); | |
| 288 | Ok(()) | |
| 289 | } | |
| 290 | ||
| 291 | fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> { | |
| 292 | let mut error = None; | |
| 293 | let preview = self.preview(); | |
| 294 | self.access_adapter.update_if_active(|| { | |
| 295 | match self.accessibility.update( | |
| 296 | &self.editor, | |
| 297 | self.viewport, | |
| 298 | &self.window.title(), | |
| 299 | preview, | |
| 300 | ) { | |
| 301 | Ok(mut update) => { | |
| 302 | self.accessibility.append_read_only( | |
| 303 | &mut update, | |
| 304 | self.scene.as_ref(), | |
| 305 | self.viewport, | |
| 306 | self.read_only_focus, | |
| 307 | ); | |
| 308 | update | |
| 309 | } | |
| 310 | Err(failure) => { | |
| 311 | error = Some(failure); | |
| 312 | self.accessibility.deactivate(); | |
| 313 | let mut root = accesskit::Node::new(accesskit::Role::Window); | |
| 314 | root.set_label(self.window.title()); | |
| 315 | accesskit::TreeUpdate { | |
| 316 | nodes: vec![(accessibility::ROOT, root)], | |
| 317 | tree: Some(accesskit::TreeInfo::new(accessibility::ROOT)), | |
| 318 | tree_id: accesskit::TreeId::ROOT, | |
| 319 | focus: accessibility::ROOT, | |
| 320 | } | |
| 321 | } | |
| 322 | } | |
| 323 | }); | |
| 324 | if let Some(error) = error { | |
| 325 | return Err(error.into()); | |
| 326 | } | |
| 327 | Ok(()) | |
| 328 | } | |
| 329 | ||
| 330 | fn access_action(&mut self, request: accesskit::ActionRequest) -> Result<(), Box<dyn Error>> { | |
| 331 | trace_input(&request); | |
| 332 | use accesskit::{Action, ActionData}; | |
| 333 | if request.target_tree != accesskit::TreeId::ROOT { | |
| 334 | return Ok(()); | |
| 335 | } | |
| 336 | if let Some(index) = self.accessibility.read_only_for_node(request.target_node) { | |
| 337 | if request.action == Action::Focus { | |
| 338 | self.set_read_only_focus(Some(index)); | |
| 339 | self.window.focus_window(); | |
| 340 | self.reveal_focus()?; | |
| 341 | self.changed()?; | |
| 342 | } | |
| 343 | return Ok(()); | |
| 344 | } | |
| 345 | let Some(outline) = self.accessibility.outline_for_node(request.target_node) else { | |
| 346 | return Ok(()); | |
| 347 | }; | |
| 348 | match (request.action, request.data) { | |
| 349 | (Action::Focus, _) => { | |
| 350 | self.editor.focus_outline(outline)?; | |
| 351 | self.window.focus_window(); | |
| 352 | } | |
| 353 | (Action::SetTextSelection, Some(ActionData::SetTextSelection(selection))) => { | |
| 354 | let selection = self.accessibility.selection(outline, selection)?; | |
| 355 | self.editor.focus_outline(outline)?; | |
| 356 | self.editor.select(selection)?; | |
| 357 | trace_input(&self.editor.selection()); | |
| 358 | } | |
| 359 | (Action::SetValue, Some(ActionData::Value(text))) => { | |
| 360 | self.editor.focus_outline(outline)?; | |
| 361 | self.editor.select_all()?; | |
| 362 | self.editor.commit_text(&mut self.engine, text.into())?; | |
| 363 | } | |
| 364 | (Action::ReplaceSelectedText, Some(ActionData::Value(text))) => { | |
| 365 | self.editor.focus_outline(outline)?; | |
| 366 | self.editor.commit_text(&mut self.engine, text.into())?; | |
| 367 | } | |
| 368 | _ => return Ok(()), | |
| 369 | } | |
| 370 | self.set_read_only_focus(None); | |
| 371 | self.reveal_focus()?; | |
| 372 | self.changed() | |
| 373 | } | |
| 374 | ||
| 375 | fn reveal_focus(&mut self) -> Result<(), Box<dyn Error>> { | |
| 376 | if self.viewport.size.contains(&0) { | |
| 377 | return Ok(()); | |
| 378 | } | |
| 379 | let rect = if let Some(index) = self.read_only_focus { | |
| 380 | let (scene, offset) = self.scene.as_ref().unwrap(); | |
| 381 | let [x0, y0, x1, y1] = scene.read_only().nth(index).unwrap().rect; | |
| 382 | parley::BoundingBox { | |
| 383 | x0: f64::from(x0 + offset[0]), | |
| 384 | y0: f64::from(y0 + offset[1]), | |
| 385 | x1: f64::from(x1 + offset[0]), | |
| 386 | y1: f64::from(y1 + offset[1]), | |
| 387 | } | |
| 388 | } else { | |
| 389 | let mut rect = self.editor.caret(1.0)?; | |
| 390 | let origin = self.editor.active_outline().origin(); | |
| 391 | rect.x0 += f64::from(origin[0]); | |
| 392 | rect.x1 += f64::from(origin[0]); | |
| 393 | rect.y0 += f64::from(origin[1]); | |
| 394 | rect.y1 += f64::from(origin[1]); | |
| 395 | rect | |
| 396 | }; | |
| 397 | let outline = self.editor.active_outline().bounds(); | |
| 398 | for (axis, (mut start, mut end)) in [(rect.x0, rect.x1), (rect.y0, rect.y1)] | |
| 399 | .into_iter() | |
| 400 | .enumerate() | |
| 401 | { | |
| 402 | let size = f64::from(self.viewport.size[axis]); | |
| 403 | let margin = f64::from(16.0 * self.display_scale).min(size * 0.25); | |
| 404 | let (outline_start, outline_end) = | |
| 405 | [(outline.x0, outline.x1), (outline.y0, outline.y1)][axis]; | |
| 406 | let outline_start = outline_start.min(start); | |
| 407 | let outline_end = outline_end.max(end); | |
| 408 | if self.read_only_focus.is_none() | |
| 409 | && (outline_end - outline_start) * f64::from(self.viewport.scale) | |
| 410 | <= size - margin * 2.0 | |
| 411 | { | |
| 412 | start = outline_start; | |
| 413 | end = outline_end; | |
| 414 | } | |
| 415 | let start = | |
| 416 | start * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); | |
| 417 | let end = end * f64::from(self.viewport.scale) + f64::from(self.viewport.origin[axis]); | |
| 418 | let shift = if start < margin { | |
| 419 | margin - start | |
| 420 | } else if end > size - margin { | |
| 421 | size - margin - end | |
| 422 | } else { | |
| 423 | 0.0 | |
| 424 | }; | |
| 425 | self.viewport.origin[axis] += shift as f32; | |
| 426 | } | |
| 427 | Ok(()) | |
| 428 | } | |
| 429 | ||
| 430 | fn zoom(&mut self, factor: f32) { | |
| 431 | let point = self.viewport.document_point(self.pointer); | |
| 432 | let dpr = self.window.scale_factor() as f32; | |
| 433 | self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0); | |
| 434 | self.viewport.origin = [ | |
| 435 | self.pointer[0] - point[0] * self.viewport.scale, | |
| 436 | self.pointer[1] - point[1] * self.viewport.scale, | |
| 437 | ]; | |
| 438 | } | |
| 439 | ||
| 440 | fn draw(&mut self) -> Result<(), Box<dyn Error>> { | |
| 441 | trace_input(&("Draw", self.viewport.origin, self.viewport.scale)); | |
| 442 | if self.occluded || self.viewport.size.contains(&0) { | |
| 443 | return Ok(()); | |
| 444 | } | |
| 445 | let mut reconfigure = false; | |
| 446 | let frame = match self.surface.get_current_texture() { | |
| 447 | wgpu::CurrentSurfaceTexture::Success(frame) => frame, | |
| 448 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { | |
| 449 | reconfigure = true; | |
| 450 | frame | |
| 451 | } | |
| 452 | wgpu::CurrentSurfaceTexture::Outdated => { | |
| 453 | self.surface.configure(&self.renderer.device, &self.config); | |
| 454 | self.window.request_redraw(); | |
| 455 | return Ok(()); | |
| 456 | } | |
| 457 | wgpu::CurrentSurfaceTexture::Lost => { | |
| 458 | self.surface = self.instance.create_surface(self.window.clone())?; | |
| 459 | self.surface.configure(&self.renderer.device, &self.config); | |
| 460 | self.window.request_redraw(); | |
| 461 | return Ok(()); | |
| 462 | } | |
| 463 | wgpu::CurrentSurfaceTexture::Timeout => { | |
| 464 | trace_input(&"Surface timeout"); | |
| 465 | return Ok(()); | |
| 466 | } | |
| 467 | wgpu::CurrentSurfaceTexture::Occluded => { | |
| 468 | trace_input(&"Surface occluded"); | |
| 469 | return Ok(()); | |
| 470 | } | |
| 471 | wgpu::CurrentSurfaceTexture::Validation => { | |
| 472 | return Err("Canvas surface validation failed".into()); | |
| 473 | } | |
| 474 | }; | |
| 475 | let primitives = page_primitives( | |
| 476 | &self.editor, | |
| 477 | self.scene.as_ref(), | |
| 478 | self.preview(), | |
| 479 | self.read_only_focus, | |
| 480 | self.caret && self.focused && !matches!(self.drag, Some(Drag::Outline { .. })), | |
| 481 | self.viewport.scale, | |
| 482 | self.display_scale, | |
| 483 | )?; | |
| 484 | self.renderer | |
| 485 | .draw( | |
| 486 | &frame.texture.create_view(&Default::default()), | |
| 487 | self.viewport, | |
| 488 | &primitives, | |
| 489 | ) | |
| 490 | .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; | |
| 491 | self.window.pre_present_notify(); | |
| 492 | self.renderer.queue.present(frame); | |
| 493 | trace_input(&"Present submitted"); | |
| 494 | if reconfigure { | |
| 495 | self.surface.configure(&self.renderer.device, &self.config); | |
| 496 | } | |
| 497 | Ok(()) | |
| 498 | } | |
| 499 | ||
| 500 | fn key(&mut self, key: &Key, text: Option<&str>) -> Result<(), Box<dyn Error>> { | |
| 501 | let shift = self.modifiers.shift_key(); | |
| 502 | let command = self.modifiers.super_key(); | |
| 503 | let option = self.modifiers.alt_key(); | |
| 504 | if self.read_only_focus.is_some() { | |
| 505 | if !read_only_shortcut(key, self.modifiers) { | |
| 506 | return Ok(()); | |
| 507 | } | |
| 508 | if key == &Key::Named(NamedKey::Escape) { | |
| 509 | self.set_read_only_focus(None); | |
| 510 | self.reveal_focus()?; | |
| 511 | return self.changed(); | |
| 512 | } | |
| 513 | } | |
| 514 | if matches!(self.drag, Some(Drag::Outline { .. })) { | |
| 515 | if matches!( | |
| 516 | key, | |
| 517 | Key::Named( | |
| 518 | NamedKey::Alt | |
| 519 | | NamedKey::AltGraph | |
| 520 | | NamedKey::Control | |
| 521 | | NamedKey::Shift | |
| 522 | | NamedKey::Super | |
| 523 | | NamedKey::Meta | |
| 524 | ) | |
| 525 | ) { | |
| 526 | return Ok(()); | |
| 527 | } | |
| 528 | self.drag = None; | |
| 529 | if key == &Key::Named(NamedKey::Escape) { | |
| 530 | return self.changed(); | |
| 531 | } | |
| 532 | } | |
| 533 | if command && option { | |
| 534 | let delta = match key { | |
| 535 | Key::Named(NamedKey::ArrowLeft) => Some([-1.0, 0.0]), | |
| 536 | Key::Named(NamedKey::ArrowRight) => Some([1.0, 0.0]), | |
| 537 | Key::Named(NamedKey::ArrowUp) => Some([0.0, -1.0]), | |
| 538 | Key::Named(NamedKey::ArrowDown) => Some([0.0, 1.0]), | |
| 539 | _ => None, | |
| 540 | }; | |
| 541 | if let Some(delta) = delta { | |
| 542 | let outline = self.editor.active_outline(); | |
| 543 | let origin = outline.origin(); | |
| 544 | let step = if shift { 10.0 } else { 1.0 }; | |
| 545 | self.editor.move_outline( | |
| 546 | outline.id, | |
| 547 | [origin[0] + delta[0] * step, origin[1] + delta[1] * step], | |
| 548 | )?; | |
| 549 | self.reveal_focus()?; | |
| 550 | return self.changed(); | |
| 551 | } | |
| 552 | } | |
| 553 | if self.modifiers.control_key() && key == &Key::Named(NamedKey::Tab) { | |
| 554 | let outlines = self.editor.outlines(); | |
| 555 | let count = outlines.len() | |
| 556 | + self | |
| 557 | .scene | |
| 558 | .as_ref() | |
| 559 | .map_or(0, |(scene, _)| scene.read_only().count()); | |
| 560 | if count == 0 { | |
| 561 | return self.changed(); | |
| 562 | } | |
| 563 | let index = self.read_only_focus.map_or_else( | |
| 564 | || { | |
| 565 | outlines | |
| 566 | .iter() | |
| 567 | .position(|outline| outline.id == self.editor.active_outline().id) | |
| 568 | }, | |
| 569 | |index| Some(outlines.len() + index), | |
| 570 | ); | |
| 571 | let next = index.map_or(if shift { count - 1 } else { 0 }, |index| { | |
| 572 | if shift { | |
| 573 | (index + count - 1) % count | |
| 574 | } else { | |
| 575 | (index + 1) % count | |
| 576 | } | |
| 577 | }); | |
| 578 | if next < outlines.len() { | |
| 579 | self.editor.focus_outline(outlines[next].id)?; | |
| 580 | self.set_read_only_focus(None); | |
| 581 | } else { | |
| 582 | self.set_read_only_focus(Some(next - outlines.len())); | |
| 583 | } | |
| 584 | self.reveal_focus()?; | |
| 585 | return self.changed(); | |
| 586 | } | |
| 587 | if command && let Key::Character(key) = key { | |
| 588 | match key.to_lowercase().as_str() { | |
| 589 | "n" if shift => { | |
| 590 | let position = if let Some(index) = self.read_only_focus { | |
| 591 | let (scene, offset) = self.scene.as_ref().unwrap(); | |
| 592 | let rect = scene.read_only().nth(index).unwrap().rect; | |
| 593 | [rect[2] + offset[0] + 24.0, rect[1] + offset[1]] | |
| 594 | } else { | |
| 595 | let bounds = self.editor.active_outline().bounds(); | |
| 596 | [bounds.x1 as f32 + 24.0, bounds.y0 as f32] | |
| 597 | }; | |
| 598 | self.editor.place_caret( | |
| 599 | &mut self.engine, | |
| 600 | snap_to_grid(position), | |
| 601 | NEW_OUTLINE_WIDTH, | |
| 602 | )?; | |
| 603 | self.set_read_only_focus(None); | |
| 604 | } | |
| 605 | "a" => self.editor.select_all()?, | |
| 606 | "z" => { | |
| 607 | if shift { | |
| 608 | self.editor.redo(&mut self.engine)?; | |
| 609 | } else { | |
| 610 | self.editor.undo(&mut self.engine)?; | |
| 611 | } | |
| 612 | } | |
| 613 | "c" | "x" => { | |
| 614 | let [anchor, focus] = self.editor.selection().positions; | |
| 615 | let selected = self | |
| 616 | .editor | |
| 617 | .active_outline() | |
| 618 | .document() | |
| 619 | .slice(anchor.min(focus)..anchor.max(focus))?; | |
| 620 | let text = selected | |
| 621 | .iter() | |
| 622 | .map(|paragraph| { | |
| 623 | paragraph | |
| 624 | .project() | |
| 625 | .map(|projection| projection.text().text().to_owned()) | |
| 626 | }) | |
| 627 | .collect::<Result<Vec<_>, _>>()? | |
| 628 | .join("\n"); | |
| 629 | if !text.is_empty() { | |
| 630 | self.clipboard.set_text(text)?; | |
| 631 | if key.eq_ignore_ascii_case("x") { | |
| 632 | self.editor.insert(&mut self.engine, "")?; | |
| 633 | } | |
| 634 | } | |
| 635 | } | |
| 636 | "v" => { | |
| 637 | let text = self.clipboard.get_text()?; | |
| 638 | self.editor.commit_text(&mut self.engine, text)?; | |
| 639 | } | |
| 640 | "+" | "=" => { | |
| 641 | self.zoom(1.1); | |
| 642 | return self.changed(); | |
| 643 | } | |
| 644 | "-" => { | |
| 645 | self.zoom(1.0 / 1.1); | |
| 646 | return self.changed(); | |
| 647 | } | |
| 648 | "0" => { | |
| 649 | self.zoom((self.display_scale * 96.0 / 72.0) / self.viewport.scale); | |
| 650 | return self.changed(); | |
| 651 | } | |
| 652 | _ => return Ok(()), | |
| 653 | } | |
| 654 | } else { | |
| 655 | let movement = match key { | |
| 656 | Key::Named(NamedKey::ArrowLeft) => Some(if command { | |
| 657 | Movement::LineStart | |
| 658 | } else if option { | |
| 659 | Movement::WordLeft | |
| 660 | } else { | |
| 661 | Movement::Left | |
| 662 | }), | |
| 663 | Key::Named(NamedKey::ArrowRight) => Some(if command { | |
| 664 | Movement::LineEnd | |
| 665 | } else if option { | |
| 666 | Movement::WordRight | |
| 667 | } else { | |
| 668 | Movement::Right | |
| 669 | }), | |
| 670 | Key::Named(NamedKey::ArrowUp) => Some(Movement::Up), | |
| 671 | Key::Named(NamedKey::ArrowDown) => Some(Movement::Down), | |
| 672 | Key::Named(NamedKey::Home) => Some(Movement::LineStart), | |
| 673 | Key::Named(NamedKey::End) => Some(Movement::LineEnd), | |
| 674 | _ => None, | |
| 675 | }; | |
| 676 | if let Some(movement) = movement { | |
| 677 | self.editor.move_selection(movement, shift)?; | |
| 678 | } else if self.editor.marked_range().is_none() { | |
| 679 | match key { | |
| 680 | Key::Named(NamedKey::Backspace) => { | |
| 681 | self.editor.delete(&mut self.engine, true)?; | |
| 682 | } | |
| 683 | Key::Named(NamedKey::Delete) => { | |
| 684 | self.editor.delete(&mut self.engine, false)?; | |
| 685 | } | |
| 686 | Key::Named(NamedKey::Enter) => self.editor.insert(&mut self.engine, "\n")?, | |
| 687 | Key::Named(NamedKey::Tab) => self.editor.insert(&mut self.engine, "\t")?, | |
| 688 | _ if !command && !self.modifiers.control_key() => { | |
| 689 | if let Some(text) = text | |
| 690 | .filter(|text| !text.is_empty() && !text.chars().any(char::is_control)) | |
| 691 | { | |
| 692 | self.editor.insert(&mut self.engine, text)?; | |
| 693 | } | |
| 694 | } | |
| 695 | _ => {} | |
| 696 | } | |
| 697 | } else if key == &Key::Named(NamedKey::Escape) { | |
| 698 | self.editor.cancel_composition(&mut self.engine)?; | |
| 699 | } | |
| 700 | } | |
| 701 | self.reveal_focus()?; | |
| 702 | self.changed() | |
| 703 | } | |
| 704 | } | |
| 705 | ||
| 706 | impl App { | |
| 707 | fn close(&self, event_loop: &ActiveEventLoop) { | |
| 708 | if self.state.as_ref().is_none_or(|state| { | |
| 709 | state | |
| 710 | .editor | |
| 711 | .caret_outline() | |
| 712 | .is_none_or(TextOutline::is_empty) | |
| 713 | && state | |
| 714 | .initial | |
| 715 | .iter() | |
| 716 | .map(|(id, layout, document)| (id, layout, document)) | |
| 717 | .eq(state | |
| 718 | .editor | |
| 719 | .outlines() | |
| 720 | .iter() | |
| 721 | .map(|outline| (&outline.id, outline.layout(), outline.document()))) | |
| 722 | }) || macos::discard_changes() | |
| 723 | { | |
| 724 | event_loop.exit(); | |
| 725 | } | |
| 726 | } | |
| 727 | } | |
| 728 | ||
| 729 | impl ApplicationHandler<UserEvent> for App { | |
| 730 | fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { | |
| 731 | let event = match event { | |
| 732 | UserEvent::Quit => { | |
| 733 | self.close(event_loop); | |
| 734 | return; | |
| 735 | } | |
| 736 | UserEvent::Accessibility(event) => event, | |
| 737 | }; | |
| 738 | let Some(state) = &mut self.state else { | |
| 739 | return; | |
| 740 | }; | |
| 741 | if event.window_id != state.window.id() { | |
| 742 | return; | |
| 743 | } | |
| 744 | let was_marked = state.editor.marked_range().is_some(); | |
| 745 | let result = match event.window_event { | |
| 746 | accesskit_winit::WindowEvent::InitialTreeRequested => state.update_accessibility(), | |
| 747 | accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request), | |
| 748 | accesskit_winit::WindowEvent::AccessibilityDeactivated => { | |
| 749 | state.accessibility.deactivate(); | |
| 750 | Ok(()) | |
| 751 | } | |
| 752 | }; | |
| 753 | if was_marked && state.editor.marked_range().is_none() { | |
| 754 | macos::clear_marked_text(&state.window); | |
| 755 | } | |
| 756 | if let Err(error) = result { | |
| 757 | eprintln!("{error}"); | |
| 758 | } | |
| 759 | } | |
| 760 | ||
| 761 | fn resumed(&mut self, event_loop: &ActiveEventLoop) { | |
| 762 | if self.state.is_some() { | |
| 763 | return; | |
| 764 | } | |
| 765 | match pollster::block_on(State::new( | |
| 766 | event_loop, | |
| 767 | self.proxy.clone(), | |
| 768 | self.input.take().unwrap(), | |
| 769 | &self.substitutes, | |
| 770 | )) { | |
| 771 | Ok(state) => self.state = Some(state), | |
| 772 | Err(error) => { | |
| 773 | self.startup_error = Some(error); | |
| 774 | event_loop.exit(); | |
| 775 | } | |
| 776 | } | |
| 777 | } | |
| 778 | ||
| 779 | fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) { | |
| 780 | if matches!( | |
| 781 | event, | |
| 782 | WindowEvent::KeyboardInput { .. } | |
| 783 | | WindowEvent::Ime(_) | |
| 784 | | WindowEvent::MouseInput { .. } | |
| 785 | | WindowEvent::CursorMoved { .. } | |
| 786 | | WindowEvent::ModifiersChanged(_) | |
| 787 | | WindowEvent::Focused(_) | |
| 788 | ) { | |
| 789 | trace_input(&event); | |
| 790 | } | |
| 791 | if let Some(state) = &mut self.state { | |
| 792 | state.access_adapter.process_event(&state.window, &event); | |
| 793 | } | |
| 794 | if matches!(event, WindowEvent::CloseRequested) { | |
| 795 | self.close(event_loop); | |
| 796 | return; | |
| 797 | } | |
| 798 | let Some(state) = &mut self.state else { | |
| 799 | return; | |
| 800 | }; | |
| 801 | let was_marked = | |
| 802 | !matches!(event, WindowEvent::Ime(_)) && state.editor.marked_range().is_some(); | |
| 803 | let result = (|| -> Result<(), Box<dyn Error>> { | |
| 804 | match event { | |
| 805 | WindowEvent::RedrawRequested => state.draw()?, | |
| 806 | WindowEvent::Resized(size) => { | |
| 807 | state.viewport.size = [size.width, size.height]; | |
| 808 | if size.width > 0 && size.height > 0 { | |
| 809 | state.config.width = size.width; | |
| 810 | state.config.height = size.height; | |
| 811 | state | |
| 812 | .surface | |
| 813 | .configure(&state.renderer.device, &state.config); | |
| 814 | state.changed()?; | |
| 815 | } | |
| 816 | } | |
| 817 | WindowEvent::ScaleFactorChanged { scale_factor, .. } => { | |
| 818 | let scale = scale_factor as f32; | |
| 819 | let ratio = scale / state.display_scale; | |
| 820 | state.viewport.scale *= ratio; | |
| 821 | state.viewport.origin[0] *= ratio; | |
| 822 | state.viewport.origin[1] *= ratio; | |
| 823 | state.display_scale = scale; | |
| 824 | state.renderer.clear_glyph_cache(); | |
| 825 | state.changed()?; | |
| 826 | } | |
| 827 | WindowEvent::Focused(focused) => { | |
| 828 | state.focused = focused; | |
| 829 | if !focused { | |
| 830 | state.drag = None; | |
| 831 | } | |
| 832 | state.changed()?; | |
| 833 | } | |
| 834 | WindowEvent::Occluded(occluded) => { | |
| 835 | trace_input(&("Window occluded", occluded)); | |
| 836 | state.occluded = occluded; | |
| 837 | if !occluded { | |
| 838 | state.changed()?; | |
| 839 | } | |
| 840 | } | |
| 841 | WindowEvent::ModifiersChanged(modifiers) => { | |
| 842 | state.modifiers = modifiers.state(); | |
| 843 | if matches!(state.drag, Some(Drag::Outline { .. })) { | |
| 844 | state.changed()?; | |
| 845 | } | |
| 846 | } | |
| 847 | WindowEvent::CursorMoved { position, .. } => { | |
| 848 | state.pointer = [position.x as f32, position.y as f32]; | |
| 849 | match &mut state.drag { | |
| 850 | Some(Drag::Text) => { | |
| 851 | let point = state.viewport.document_point(state.pointer); | |
| 852 | let origin = state.editor.active_outline().origin(); | |
| 853 | state.editor.select_at( | |
| 854 | point[0] - origin[0], | |
| 855 | point[1] - origin[1], | |
| 856 | true, | |
| 857 | )?; | |
| 858 | state.changed()?; | |
| 859 | } | |
| 860 | Some(Drag::Outline { pending_press, .. }) => { | |
| 861 | if pending_press.is_some_and(|press| { | |
| 862 | (0..2).any(|axis| { | |
| 863 | (state.pointer[axis] - press[axis]).abs() | |
| 864 | > 2.0 * state.display_scale | |
| 865 | }) | |
| 866 | }) { | |
| 867 | *pending_press = None; | |
| 868 | } | |
| 869 | state.changed()?; | |
| 870 | } | |
| 871 | None => {} | |
| 872 | } | |
| 873 | } | |
| 874 | WindowEvent::MouseInput { | |
| 875 | state: button_state, | |
| 876 | button: MouseButton::Left, | |
| 877 | .. | |
| 878 | } => { | |
| 879 | let point = state.viewport.document_point(state.pointer); | |
| 880 | if button_state == ElementState::Pressed { | |
| 881 | match page_hit_test( | |
| 882 | &state.editor, | |
| 883 | state.scene.as_ref(), | |
| 884 | point, | |
| 885 | state.display_scale / state.viewport.scale, | |
| 886 | ) { | |
| 887 | Some(Hit::ReadOnly(index)) => state.set_read_only_focus(Some(index)), | |
| 888 | Some(Hit::Handle { id, grab }) => { | |
| 889 | state.set_read_only_focus(None); | |
| 890 | state.editor.focus_outline(id)?; | |
| 891 | state.drag = Some(Drag::Outline { | |
| 892 | id, | |
| 893 | grab, | |
| 894 | pending_press: Some(state.pointer), | |
| 895 | }); | |
| 896 | } | |
| 897 | Some(Hit::Text { id, point }) => { | |
| 898 | let extend = state.modifiers.shift_key() | |
| 899 | && state.read_only_focus.is_none() | |
| 900 | && id == state.editor.active_outline().id; | |
| 901 | state.set_read_only_focus(None); | |
| 902 | state.editor.focus_outline(id)?; | |
| 903 | state.editor.select_at(point[0], point[1], extend)?; | |
| 904 | state.drag = Some(Drag::Text); | |
| 905 | } | |
| 906 | None => { | |
| 907 | let position = [ | |
| 908 | point[0], | |
| 909 | point[1] - 7.0 * state.display_scale / state.viewport.scale, | |
| 910 | ]; | |
| 911 | let position = if state.modifiers.alt_key() { | |
| 912 | position | |
| 913 | } else { | |
| 914 | snap_to_grid(position) | |
| 915 | }; | |
| 916 | state.editor.place_caret( | |
| 917 | &mut state.engine, | |
| 918 | position, | |
| 919 | NEW_OUTLINE_WIDTH, | |
| 920 | )?; | |
| 921 | state.set_read_only_focus(None); | |
| 922 | state.drag = Some(Drag::Text); | |
| 923 | } | |
| 924 | } | |
| 925 | } else { | |
| 926 | let preview = state.preview(); | |
| 927 | state.drag = None; | |
| 928 | if let Some((id, origin)) = preview { | |
| 929 | state.editor.move_outline(id, origin)?; | |
| 930 | } | |
| 931 | } | |
| 932 | state.changed()?; | |
| 933 | } | |
| 934 | WindowEvent::MouseWheel { delta, .. } => { | |
| 935 | let dpr = state.window.scale_factor() as f32; | |
| 936 | let delta = match delta { | |
| 937 | MouseScrollDelta::LineDelta(x, y) => [x * 32.0 * dpr, y * 32.0 * dpr], | |
| 938 | MouseScrollDelta::PixelDelta(p) => [p.x as f32, p.y as f32], | |
| 939 | }; | |
| 940 | if state.modifiers.super_key() { | |
| 941 | state.zoom((delta[1] * 0.005).exp()); | |
| 942 | } else { | |
| 943 | state.viewport.origin[0] += delta[0]; | |
| 944 | state.viewport.origin[1] += delta[1]; | |
| 945 | } | |
| 946 | state.changed()?; | |
| 947 | } | |
| 948 | WindowEvent::KeyboardInput { event, .. } | |
| 949 | if event.state == ElementState::Pressed => | |
| 950 | { | |
| 951 | state.key(&event.logical_key, event.text.as_deref())?; | |
| 952 | } | |
| 953 | WindowEvent::Ime(_) if state.read_only_focus.is_some() => {} | |
| 954 | WindowEvent::Ime(Ime::Preedit(text, cursor)) => { | |
| 955 | if text.is_empty() { | |
| 956 | state.editor.cancel_composition(&mut state.engine)?; | |
| 957 | } else { | |
| 958 | let (start, end) = cursor.unwrap_or((text.len(), text.len())); | |
| 959 | let start: u32 = text | |
| 960 | .get(..start) | |
| 961 | .ok_or("IME range splits UTF-8")? | |
| 962 | .encode_utf16() | |
| 963 | .count() | |
| 964 | .try_into()?; | |
| 965 | let end: u32 = text | |
| 966 | .get(..end) | |
| 967 | .ok_or("IME range splits UTF-8")? | |
| 968 | .encode_utf16() | |
| 969 | .count() | |
| 970 | .try_into()?; | |
| 971 | state.editor.compose(&mut state.engine, text, start..end)?; | |
| 972 | } | |
| 973 | state.reveal_focus()?; | |
| 974 | state.changed()?; | |
| 975 | } | |
| 976 | WindowEvent::Ime(Ime::Commit(text)) => { | |
| 977 | state.editor.commit_text(&mut state.engine, text)?; | |
| 978 | state.reveal_focus()?; | |
| 979 | state.changed()?; | |
| 980 | } | |
| 981 | WindowEvent::Ime(Ime::Disabled) => { | |
| 982 | state.editor.cancel_composition(&mut state.engine)?; | |
| 983 | state.reveal_focus()?; | |
| 984 | state.changed()?; | |
| 985 | } | |
| 986 | _ => {} | |
| 987 | } | |
| 988 | Ok(()) | |
| 989 | })(); | |
| 990 | if was_marked && state.editor.marked_range().is_none() { | |
| 991 | macos::clear_marked_text(&state.window); | |
| 992 | } | |
| 993 | if let Err(error) = result { | |
| 994 | eprintln!("{error}"); | |
| 995 | } | |
| 996 | } | |
| 997 | ||
| 998 | fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { | |
| 999 | let Some(state) = &mut self.state else { | |
| 1000 | return; | |
| 1001 | }; | |
| 1002 | let [anchor, focus] = state.editor.selection().positions; | |
| 1003 | if state.focused && !state.occluded && state.read_only_focus.is_none() && anchor == focus { | |
| 1004 | let now = Instant::now(); | |
| 1005 | if now >= state.blink_at { | |
| 1006 | state.caret = !state.caret; | |
| 1007 | state.blink_at = now + Duration::from_millis(500); | |
| 1008 | state.window.request_redraw(); | |
| 1009 | } | |
| 1010 | event_loop.set_control_flow(ControlFlow::WaitUntil(state.blink_at)); | |
| 1011 | } else { | |
| 1012 | event_loop.set_control_flow(ControlFlow::Wait); | |
| 1013 | } | |
| 1014 | } | |
| 1015 | } | |
| 1016 | ||
| 1017 | #[derive(Debug, PartialEq)] | |
| 1018 | enum Hit { | |
| 1019 | Text { | |
| 1020 | id: onestore::ExGuid, | |
| 1021 | point: [f32; 2], | |
| 1022 | }, | |
| 1023 | Handle { | |
| 1024 | id: onestore::ExGuid, | |
| 1025 | grab: [f32; 2], | |
| 1026 | }, | |
| 1027 | ReadOnly(usize), | |
| 1028 | } | |
| 1029 | ||
| 1030 | fn page_hit_test( | |
| 1031 | editor: &CanvasEditor, | |
| 1032 | scene: Option<&(PageScene, [f32; 2])>, | |
| 1033 | point: [f32; 2], | |
| 1034 | pixel: f32, | |
| 1035 | ) -> Option<Hit> { | |
| 1036 | let hit = |outline: &TextOutline, offset: [f32; 2]| { | |
| 1037 | let (bounds, body_top) = outline_chrome(outline, pixel); | |
| 1038 | let local = [point[0] - offset[0], point[1] - offset[1]]; | |
| 1039 | let [x, y] = local; | |
| 1040 | if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top { | |
| 1041 | return Some(Hit::Handle { | |
| 1042 | id: outline.id, | |
| 1043 | grab: [ | |
| 1044 | point[0] - outline.origin()[0], | |
| 1045 | point[1] - outline.origin()[1], | |
| 1046 | ], | |
| 1047 | }); | |
| 1048 | } | |
| 1049 | if (x >= bounds[0] && x <= bounds[2] && y >= body_top && y <= bounds[3]) | |
| 1050 | || outline.layouts().any(|(_, paragraph)| { | |
| 1051 | paragraph.tags.iter().any(|tag| { | |
| 1052 | let origin = outline.origin(); | |
| 1053 | let x = origin[0] + tag.origin[0]; | |
| 1054 | let y = origin[1] + paragraph.origin[1] + tag.origin[1]; | |
| 1055 | let size = one_canvas::outline::ParagraphTag::SIZE; | |
| 1056 | (x..=x + size).contains(&local[0]) && (y..=y + size).contains(&local[1]) | |
| 1057 | }) | |
| 1058 | }) | |
| 1059 | { | |
| 1060 | return Some(Hit::Text { | |
| 1061 | id: outline.id, | |
| 1062 | point: [ | |
| 1063 | local[0] - outline.origin()[0], | |
| 1064 | local[1] - outline.origin()[1], | |
| 1065 | ], | |
| 1066 | }); | |
| 1067 | } | |
| 1068 | None | |
| 1069 | }; | |
| 1070 | if let Some(hit) = editor | |
| 1071 | .outlines() | |
| 1072 | .iter() | |
| 1073 | .rev() | |
| 1074 | .filter(|outline| scene.is_none_or(|(scene, _)| !scene.contains_outline(outline.id))) | |
| 1075 | .find_map(|outline| hit(outline, [0.0; 2])) | |
| 1076 | { | |
| 1077 | return Some(hit); | |
| 1078 | } | |
| 1079 | let (scene, offset) = scene?; | |
| 1080 | match scene.hit_test([point[0] - offset[0], point[1] - offset[1]], |id| { | |
| 1081 | editor | |
| 1082 | .outlines() | |
| 1083 | .iter() | |
| 1084 | .find(|outline| outline.id == id) | |
| 1085 | .and_then(|outline| hit(outline, *offset)) | |
| 1086 | })? { | |
| 1087 | one_canvas_gpu::page::SceneHit::Outline(hit) => Some(hit), | |
| 1088 | one_canvas_gpu::page::SceneHit::ReadOnly(index) => Some(Hit::ReadOnly(index)), | |
| 1089 | } | |
| 1090 | } | |
| 1091 | ||
| 1092 | fn page_primitives<'a>( | |
| 1093 | editor: &'a CanvasEditor, | |
| 1094 | scene: Option<&'a (PageScene, [f32; 2])>, | |
| 1095 | preview: Option<(onestore::ExGuid, [f32; 2])>, | |
| 1096 | read_only_focus: Option<usize>, | |
| 1097 | show_caret: bool, | |
| 1098 | scale: f32, | |
| 1099 | display_scale: f32, | |
| 1100 | ) -> Result<Vec<Primitive<'a>>, Box<dyn Error>> { | |
| 1101 | let mut primitives = Vec::new(); | |
| 1102 | let paint = |id, offset: [f32; 2], primitives: &mut Vec<_>| { | |
| 1103 | let outline = editor | |
| 1104 | .outlines() | |
| 1105 | .iter() | |
| 1106 | .find(|outline| outline.id == id) | |
| 1107 | .ok_or(one_canvas_gpu::page::SceneError::MissingOutline)?; | |
| 1108 | let origin = preview | |
| 1109 | .filter(|(id, _)| *id == outline.id) | |
| 1110 | .map(|(_, origin)| origin) | |
| 1111 | .unwrap_or_else(|| outline.origin()); | |
| 1112 | append_outline( | |
| 1113 | (read_only_focus.is_none() && outline.id == editor.active_outline().id) | |
| 1114 | .then_some(editor), | |
| 1115 | outline, | |
| 1116 | [origin[0] + offset[0], origin[1] + offset[1]], | |
| 1117 | show_caret, | |
| 1118 | scale, | |
| 1119 | display_scale / scale, | |
| 1120 | primitives, | |
| 1121 | ) | |
| 1122 | }; | |
| 1123 | if let Some((scene, origin)) = scene { | |
| 1124 | scene.append_primitives_with(&mut primitives, *origin, &paint)?; | |
| 1125 | } | |
| 1126 | for outline in editor | |
| 1127 | .outlines() | |
| 1128 | .iter() | |
| 1129 | .filter(|outline| scene.is_none_or(|(scene, _)| !scene.contains_outline(outline.id))) | |
| 1130 | { | |
| 1131 | paint(outline.id, [0.0; 2], &mut primitives)?; | |
| 1132 | } | |
| 1133 | if read_only_focus.is_none() | |
| 1134 | && let Some(outline) = editor.caret_outline() | |
| 1135 | { | |
| 1136 | append_outline( | |
| 1137 | Some(editor), | |
| 1138 | outline, | |
| 1139 | outline.origin(), | |
| 1140 | show_caret, | |
| 1141 | scale, | |
| 1142 | display_scale / scale, | |
| 1143 | &mut primitives, | |
| 1144 | )?; | |
| 1145 | } | |
| 1146 | if let Some(index) = read_only_focus { | |
| 1147 | let (scene, offset) = scene.unwrap(); | |
| 1148 | let [x0, y0, x1, y1] = scene.read_only().nth(index).unwrap().rect; | |
| 1149 | let [x0, y0, x1, y1] = [ | |
| 1150 | x0 + offset[0], | |
| 1151 | y0 + offset[1], | |
| 1152 | x1 + offset[0], | |
| 1153 | y1 + offset[1], | |
| 1154 | ]; | |
| 1155 | let border = 2.0 / scale; | |
| 1156 | for rect in [ | |
| 1157 | [x0, y0, x1, y0 + border], | |
| 1158 | [x0, y1 - border, x1, y1], | |
| 1159 | [x0, y0, x0 + border, y1], | |
| 1160 | [x1 - border, y0, x1, y1], | |
| 1161 | ] { | |
| 1162 | primitives.push(Primitive::Rect { | |
| 1163 | rect, | |
| 1164 | color: [0.25, 0.45, 0.7, 1.0], | |
| 1165 | }); | |
| 1166 | } | |
| 1167 | } | |
| 1168 | Ok(primitives) | |
| 1169 | } | |
| 1170 | ||
| 1171 | fn snap_to_grid(point: [f32; 2]) -> [f32; 2] { | |
| 1172 | std::array::from_fn(|axis| { | |
| 1173 | let offset = [0.0, 14.4][axis]; | |
| 1174 | let cell = (point[axis] - offset) / 18.0; | |
| 1175 | let nearest = cell.round(); | |
| 1176 | // Native midpoints remain free; account for the source coordinate's float precision. | |
| 1177 | if ((cell - nearest).abs() - 0.5).abs() <= f32::EPSILON * cell.abs().max(1.0) { | |
| 1178 | point[axis] | |
| 1179 | } else { | |
| 1180 | nearest * 18.0 + offset | |
| 1181 | } | |
| 1182 | }) | |
| 1183 | } | |
| 1184 | ||
| 1185 | fn outline_chrome(outline: &TextOutline, pixel: f32) -> ([f32; 4], f32) { | |
| 1186 | let bounds = outline.bounds(); | |
| 1187 | // Native chrome combines page-scaled gutters with a fixed screen inset. | |
| 1188 | let inset = 5.0 * pixel; | |
| 1189 | let body_top = bounds.y0 as f32 - inset; | |
| 1190 | ( | |
| 1191 | [ | |
| 1192 | bounds.x0 as f32 - 7.5 - inset, | |
| 1193 | body_top - HANDLE_HEIGHT, | |
| 1194 | bounds.x1 as f32 + inset, | |
| 1195 | bounds.y1 as f32 + HANDLE_HEIGHT + inset, | |
| 1196 | ], | |
| 1197 | body_top, | |
| 1198 | ) | |
| 1199 | } | |
| 1200 | ||
| 1201 | fn append_outline<'a>( | |
| 1202 | editor: Option<&'a CanvasEditor>, | |
| 1203 | outline: &'a TextOutline, | |
| 1204 | origin: [f32; 2], | |
| 1205 | show_caret: bool, | |
| 1206 | scale: f32, | |
| 1207 | pixel: f32, | |
| 1208 | primitives: &mut Vec<Primitive<'a>>, | |
| 1209 | ) -> Result<(), Box<dyn Error>> { | |
| 1210 | let [x, y] = origin; | |
| 1211 | let (bounds, body_top) = outline_chrome(outline, pixel); | |
| 1212 | let [dx, dy] = [x - outline.origin()[0], y - outline.origin()[1]]; | |
| 1213 | let [left, top, right, bottom] = [ | |
| 1214 | bounds[0] + dx, | |
| 1215 | bounds[1] + dy, | |
| 1216 | bounds[2] + dx, | |
| 1217 | bounds[3] + dy, | |
| 1218 | ]; | |
| 1219 | let body_top = body_top + dy; | |
| 1220 | let border = if editor.is_some() { | |
| 1221 | [0.25, 0.45, 0.7, 0.65] | |
| 1222 | } else { | |
| 1223 | [0.5, 0.5, 0.5, 0.2] | |
| 1224 | }; | |
| 1225 | if editor.is_none_or(|editor| editor.caret_outline().is_none()) { | |
| 1226 | for rect in [ | |
| 1227 | [left, top, right, body_top], | |
| 1228 | [left, body_top, left + pixel, bottom], | |
| 1229 | [right - pixel, body_top, right, bottom], | |
| 1230 | [left + pixel, bottom - pixel, right - pixel, bottom], | |
| 1231 | ] { | |
| 1232 | primitives.push(Primitive::Rect { | |
| 1233 | rect, | |
| 1234 | color: border, | |
| 1235 | }); | |
| 1236 | } | |
| 1237 | } | |
| 1238 | for (_, paragraph) in outline.layouts() { | |
| 1239 | let layout = &paragraph.text; | |
| 1240 | let paragraph_origin = paragraph.origin; | |
| 1241 | for (rect, color) in layout.backgrounds() { | |
| 1242 | primitives.push(Primitive::Rect { | |
| 1243 | rect: [ | |
| 1244 | rect.x0 as f32 + x + paragraph_origin[0], | |
| 1245 | rect.y0 as f32 + y + paragraph_origin[1], | |
| 1246 | rect.x1 as f32 + x + paragraph_origin[0], | |
| 1247 | rect.y1 as f32 + y + paragraph_origin[1], | |
| 1248 | ], | |
| 1249 | color: one_canvas_gpu::colorref(color), | |
| 1250 | }); | |
| 1251 | } | |
| 1252 | } | |
| 1253 | if let Some(editor) = editor { | |
| 1254 | for rect in editor.selection_rects()? { | |
| 1255 | primitives.push(Primitive::Rect { | |
| 1256 | rect: [ | |
| 1257 | rect.x0 as f32 + x, | |
| 1258 | rect.y0 as f32 + y, | |
| 1259 | rect.x1 as f32 + x, | |
| 1260 | rect.y1 as f32 + y, | |
| 1261 | ], | |
| 1262 | color: [0.55, 0.73, 1.0, 0.5], | |
| 1263 | }); | |
| 1264 | } | |
| 1265 | } | |
| 1266 | for (_, paragraph) in outline.layouts() { | |
| 1267 | let layout = &paragraph.text; | |
| 1268 | let paragraph_origin = paragraph.origin; | |
| 1269 | primitives.push(Primitive::Text { | |
| 1270 | layout, | |
| 1271 | origin: [x + paragraph_origin[0], y + paragraph_origin[1]], | |
| 1272 | }); | |
| 1273 | for (layout, origin) in &paragraph.markers { | |
| 1274 | primitives.push(Primitive::Text { | |
| 1275 | layout, | |
| 1276 | origin: [x + origin[0], y + (origin[1] + paragraph_origin[1])], | |
| 1277 | }); | |
| 1278 | } | |
| 1279 | for tag in &paragraph.tags { | |
| 1280 | primitives.push(Primitive::Tag { | |
| 1281 | tag, | |
| 1282 | origin: [x, y + paragraph_origin[1]], | |
| 1283 | }); | |
| 1284 | } | |
| 1285 | } | |
| 1286 | if let Some(editor) = editor { | |
| 1287 | for rect in editor.marked_rects()? { | |
| 1288 | primitives.push(Primitive::Rect { | |
| 1289 | rect: [ | |
| 1290 | rect.x0 as f32 + x, | |
| 1291 | rect.y1 as f32 + y - 1.0 / scale, | |
| 1292 | rect.x1 as f32 + x, | |
| 1293 | rect.y1 as f32 + y, | |
| 1294 | ], | |
| 1295 | color: [0.0, 0.0, 0.0, 1.0], | |
| 1296 | }); | |
| 1297 | } | |
| 1298 | let [anchor, focus] = editor.selection().positions; | |
| 1299 | if show_caret && anchor == focus { | |
| 1300 | let rect = editor.caret(1.0 / scale)?; | |
| 1301 | primitives.push(Primitive::Rect { | |
| 1302 | rect: [ | |
| 1303 | rect.x0 as f32 + x, | |
| 1304 | rect.y0 as f32 + y, | |
| 1305 | rect.x1 as f32 + x, | |
| 1306 | rect.y1 as f32 + y, | |
| 1307 | ], | |
| 1308 | color: [0.0, 0.0, 0.0, 1.0], | |
| 1309 | }); | |
| 1310 | } | |
| 1311 | } | |
| 1312 | Ok(()) | |
| 1313 | } | |
| 1314 | ||
| 1315 | fn main() -> Result<(), Box<dyn Error>> { | |
| 1316 | let mut args = std::env::args_os().skip(1); | |
| 1317 | let mut positional = Vec::new(); | |
| 1318 | let mut substitutes = Vec::new(); | |
| 1319 | let mut reference = None; | |
| 1320 | let mut editable = false; | |
| 1321 | while let Some(arg) = args.next() { | |
| 1322 | if arg == "--substitute-font" { | |
| 1323 | substitutes.push(PathBuf::from( | |
| 1324 | args.next() | |
| 1325 | .ok_or("Provide a font file after --substitute-font.")?, | |
| 1326 | )); | |
| 1327 | } else if arg == "--reference" || arg == "--page" { | |
| 1328 | if reference.is_some() { | |
| 1329 | return Err("Only one page can be opened.".into()); | |
| 1330 | } | |
| 1331 | editable = arg == "--page"; | |
| 1332 | let path = args | |
| 1333 | .next() | |
| 1334 | .ok_or("Provide a section file and page title after the page option.")?; | |
| 1335 | let title = args | |
| 1336 | .next() | |
| 1337 | .ok_or("Provide a page title after the section file.")?; | |
| 1338 | let bytes = std::fs::read(path)?; | |
| 1339 | let store = onestore::Store::parse(&bytes)?; | |
| 1340 | let index = onestore::RevisionIndex::parse(&store)?; | |
| 1341 | reference = Some(Page::from_document( | |
| 1342 | &onestore::document::Document::parse(&index)?, | |
| 1343 | title | |
| 1344 | .to_str() | |
| 1345 | .ok_or("The page title must be valid Unicode.")?, | |
| 1346 | )?); | |
| 1347 | } else { | |
| 1348 | positional.push(arg); | |
| 1349 | } | |
| 1350 | } | |
| 1351 | if editable && !positional.is_empty() { | |
| 1352 | return Err( | |
| 1353 | "Use --page with a section file and page title, without a text file or width.".into(), | |
| 1354 | ); | |
| 1355 | } | |
| 1356 | if positional.len() > 2 { | |
| 1357 | return Err( | |
| 1358 | "Usage: one-canvas-app [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE] [--substitute-font FONT_FILE]..." | |
| 1359 | .into(), | |
| 1360 | ); | |
| 1361 | } | |
| 1362 | let text = positional | |
| 1363 | .first() | |
| 1364 | .map(std::fs::read_to_string) | |
| 1365 | .transpose()? | |
| 1366 | .unwrap_or_default(); | |
| 1367 | let width = positional | |
| 1368 | .get(1) | |
| 1369 | .map(|value| value.to_string_lossy().parse::<f32>()) | |
| 1370 | .transpose()? | |
| 1371 | .unwrap_or(if reference.is_some() { | |
| 1372 | NEW_OUTLINE_WIDTH | |
| 1373 | } else { | |
| 1374 | 480.0 | |
| 1375 | }); | |
| 1376 | let input = if editable { | |
| 1377 | Input::Page(reference.unwrap()) | |
| 1378 | } else { | |
| 1379 | Input::Notes { | |
| 1380 | document: TextDocument::new( | |
| 1381 | text.split('\n') | |
| 1382 | .map(|line| Paragraph::new(line.to_owned(), Format::default())) | |
| 1383 | .collect(), | |
| 1384 | )?, | |
| 1385 | width, | |
| 1386 | reference, | |
| 1387 | } | |
| 1388 | }; | |
| 1389 | let event_loop = macos::event_loop()?; | |
| 1390 | let mut app = App { | |
| 1391 | proxy: event_loop.create_proxy(), | |
| 1392 | input: Some(input), | |
| 1393 | substitutes, | |
| 1394 | state: None, | |
| 1395 | startup_error: None, | |
| 1396 | }; | |
| 1397 | event_loop.run_app(&mut app)?; | |
| 1398 | app.startup_error.map_or(Ok(()), Err) | |
| 1399 | } | |
| 1400 | ||
| 1401 | #[cfg(test)] | |
| 1402 | mod tests { | |
| 1403 | use super::*; | |
| 1404 | ||
| 1405 | #[test] | |
| 1406 | fn native_grid_matches_drag_offsets_midpoints_and_zoom() { | |
| 1407 | for (pixels, points) in [ | |
| 1408 | (5.0, 0.0), | |
| 1409 | (6.0, 0.0), | |
| 1410 | (7.0, 0.0), | |
| 1411 | (8.0, 0.0), | |
| 1412 | (11.0, 0.0), | |
| 1413 | (12.0, 9.0), | |
| 1414 | (13.0, 18.0), | |
| 1415 | (17.0, 18.0), | |
| 1416 | (18.0, 18.0), | |
| 1417 | (19.0, 18.0), | |
| 1418 | (25.0, 18.0), | |
| 1419 | (37.0, 36.0), | |
| 1420 | (-6.0, 0.0), | |
| 1421 | (-7.0, 0.0), | |
| 1422 | (-12.0, -9.0), | |
| 1423 | (-13.0, -18.0), | |
| 1424 | (-19.0, -18.0), | |
| 1425 | ] { | |
| 1426 | let origin = [486.0, 230.40001]; | |
| 1427 | let proposed = origin.map(|v| v + pixels * 72.0 / 96.0); | |
| 1428 | let result = snap_to_grid(proposed); | |
| 1429 | for axis in 0..2 { | |
| 1430 | assert!((result[axis] - origin[axis] - points).abs() < 0.00004); | |
| 1431 | } | |
| 1432 | } | |
| 1433 | for pixels in [6.0, 7.0, 12.0, 13.0, 19.0] { | |
| 1434 | let result = snap_to_grid([491.25 + pixels * 0.75, 235.65 + pixels * 0.75]); | |
| 1435 | assert!((result[0] - 504.0).abs() < 0.00004); | |
| 1436 | assert!((result[1] - 248.4).abs() < 0.00004); | |
| 1437 | } | |
| 1438 | for (scale, delta, expected) in [ | |
| 1439 | (96.0 / 72.0, [27.0, 31.0], [54.0, 104.4]), | |
| 1440 | (96.0 / 72.0, [13.0, 17.0], [54.0, 104.4]), | |
| 1441 | (192.0 / 72.0, [13.0, 17.0], [36.0, 104.4]), | |
| 1442 | ] { | |
| 1443 | for dpr in [1.0, 2.0] { | |
| 1444 | let origin = [36.0, 90.0]; | |
| 1445 | let proposed = | |
| 1446 | std::array::from_fn(|axis| origin[axis] + delta[axis] * dpr / (scale * dpr)); | |
| 1447 | let result = snap_to_grid(proposed); | |
| 1448 | for axis in 0..2 { | |
| 1449 | assert!((result[axis] - expected[axis]).abs() < 0.00004); | |
| 1450 | } | |
| 1451 | } | |
| 1452 | } | |
| 1453 | for point in [[9.0, 5.4], [-9.0, -12.6], [423.0, 239.4]] { | |
| 1454 | assert_eq!(snap_to_grid(point), point); | |
| 1455 | } | |
| 1456 | } | |
| 1457 | ||
| 1458 | #[test] | |
| 1459 | fn native_fresh_click_grid_uses_screen_offset_at_both_zoom_levels() { | |
| 1460 | for (x, y, scale, expected) in [ | |
| 1461 | (900.0, 500.0, 96.0 / 72.0, [639.0, 302.4]), | |
| 1462 | (901.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), | |
| 1463 | (906.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), | |
| 1464 | (913.0, 500.0, 96.0 / 72.0, [648.0, 302.4]), | |
| 1465 | (900.0, 501.0, 96.0 / 72.0, [639.0, 302.4]), | |
| 1466 | (900.0, 506.0, 96.0 / 72.0, [639.0, 320.4]), | |
| 1467 | (900.0, 520.0, 96.0 / 72.0, [639.0, 320.4]), | |
| 1468 | (900.0, 500.0, 192.0 / 72.0, [324.0, 158.4]), | |
| 1469 | ] { | |
| 1470 | for dpr in [1.0, 2.0] { | |
| 1471 | let viewport = Viewport { | |
| 1472 | size: [(1600.0 * dpr) as u32, (900.0 * dpr) as u32], | |
| 1473 | origin: [48.0 * dpr, 83.0 * dpr], | |
| 1474 | scale: scale * dpr, | |
| 1475 | }; | |
| 1476 | let point = viewport.document_point([x * dpr, y * dpr]); | |
| 1477 | let result = snap_to_grid([point[0], point[1] - 7.0 * dpr / viewport.scale]); | |
| 1478 | for axis in 0..2 { | |
| 1479 | assert!((result[axis] - expected[axis]).abs() < 0.00004); | |
| 1480 | } | |
| 1481 | } | |
| 1482 | } | |
| 1483 | } | |
| 1484 | ||
| 1485 | #[test] | |
| 1486 | fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { | |
| 1487 | let mut engine = TextEngine::default(); | |
| 1488 | let mut editor = CanvasEditor::new( | |
| 1489 | &mut engine, | |
| 1490 | TextDocument::new(vec![Paragraph::new(String::new(), Default::default())]).unwrap(), | |
| 1491 | 240.0, | |
| 1492 | ) | |
| 1493 | .unwrap(); | |
| 1494 | editor | |
| 1495 | .place_caret(&mut engine, [120.0, 100.0], 240.0) | |
| 1496 | .unwrap(); | |
| 1497 | let mut access = accessibility::Accessibility::default(); | |
| 1498 | let viewport = Viewport { | |
| 1499 | size: [1000, 720], | |
| 1500 | scale: 96.0 / 72.0, | |
| 1501 | origin: [48.0; 2], | |
| 1502 | }; | |
| 1503 | let initial = access.update(&editor, viewport, "Page", None).unwrap(); | |
| 1504 | let id = editor.active_outline().id; | |
| 1505 | assert_eq!(access.outline_for_node(initial.focus), Some(id)); | |
| 1506 | let rectangles = |editor: &CanvasEditor| { | |
| 1507 | page_primitives(editor, None, None, None, true, viewport.scale, 1.0) | |
| 1508 | .unwrap() | |
| 1509 | .into_iter() | |
| 1510 | .filter_map(|p| match p { | |
| 1511 | Primitive::Rect { rect, color } => Some((rect, color)), | |
| 1512 | _ => None, | |
| 1513 | }) | |
| 1514 | .collect::<Vec<_>>() | |
| 1515 | }; | |
| 1516 | assert_eq!(rectangles(&editor).len(), 1); | |
| 1517 | assert_eq!( | |
| 1518 | page_hit_test(&editor, None, [120.0, 100.0], 1.0 / viewport.scale), | |
| 1519 | None | |
| 1520 | ); | |
| 1521 | editor.compose(&mut engine, "に".into(), 1..1).unwrap(); | |
| 1522 | assert!(editor.outlines().is_empty()); | |
| 1523 | assert!( | |
| 1524 | rectangles(&editor) | |
| 1525 | .iter() | |
| 1526 | .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0]) | |
| 1527 | ); | |
| 1528 | let preedit = access.update(&editor, viewport, "Page", None).unwrap(); | |
| 1529 | assert_eq!(preedit.focus, initial.focus); | |
| 1530 | editor.commit_text(&mut engine, "日本".into()).unwrap(); | |
| 1531 | let committed = access.update(&editor, viewport, "Page", None).unwrap(); | |
| 1532 | assert_eq!(committed.focus, initial.focus); | |
| 1533 | assert_eq!(rectangles(&editor).len(), 5); | |
| 1534 | editor.undo(&mut engine).unwrap(); | |
| 1535 | assert!(editor.outlines().is_empty()); | |
| 1536 | assert_eq!(rectangles(&editor).len(), 1); | |
| 1537 | let empty = access.update(&editor, viewport, "Page", None).unwrap(); | |
| 1538 | assert_eq!(empty.focus, committed.focus); | |
| 1539 | assert_eq!(access.outline_for_node(committed.focus), Some(id)); | |
| 1540 | editor.redo(&mut engine).unwrap(); | |
| 1541 | editor.select_all().unwrap(); | |
| 1542 | editor.delete(&mut engine, true).unwrap(); | |
| 1543 | assert!(editor.outlines().is_empty()); | |
| 1544 | assert_eq!(rectangles(&editor).len(), 1); | |
| 1545 | let retired = access.update(&editor, viewport, "Page", None).unwrap(); | |
| 1546 | assert_eq!(retired.focus, committed.focus); | |
| 1547 | assert_eq!(access.outline_for_node(retired.focus), Some(id)); | |
| 1548 | editor.undo(&mut engine).unwrap(); | |
| 1549 | assert_eq!(rectangles(&editor).len(), 5); | |
| 1550 | let restored = access.update(&editor, viewport, "Page", None).unwrap(); | |
| 1551 | assert_eq!(restored.focus, committed.focus); | |
| 1552 | editor.redo(&mut engine).unwrap(); | |
| 1553 | assert_eq!(rectangles(&editor).len(), 1); | |
| 1554 | assert!(editor.caret_outline().unwrap().is_empty()); | |
| 1555 | editor.insert(&mut engine, " ").unwrap(); | |
| 1556 | assert!(editor.caret_outline().unwrap().is_empty()); | |
| 1557 | editor.compose(&mut engine, "に".into(), 1..1).unwrap(); | |
| 1558 | assert!(!editor.caret_outline().unwrap().is_empty()); | |
| 1559 | } | |
| 1560 | ||
| 1561 | #[test] | |
| 1562 | fn native_chrome_geometry_preserves_text_and_matches_hit_regions_across_zoom_and_dpi() { | |
| 1563 | let mut engine = TextEngine::default(); | |
| 1564 | let format = Format { | |
| 1565 | font: Some("Arial".into()), | |
| 1566 | font_size: Some(11.0), | |
| 1567 | ..Default::default() | |
| 1568 | }; | |
| 1569 | let mut editor = CanvasEditor::new(&mut engine, TextDocument::new(vec![ | |
| 1570 | Paragraph::new("HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789 HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), format.clone()), | |
| 1571 | Paragraph::new("Second paragraph abcdefghijklmnopqrstuvwxyz.".into(), format), | |
| 1572 | ]).unwrap(), 220.0).unwrap(); | |
| 1573 | let id = editor.active_outline().id; | |
| 1574 | editor.move_outline(id, [36.0, 90.0]).unwrap(); | |
| 1575 | let document = editor.active_outline().document().clone(); | |
| 1576 | let layout_ids: Vec<_> = editor | |
| 1577 | .active_outline() | |
| 1578 | .layouts() | |
| 1579 | .map(|(_, p)| p.text.id()) | |
| 1580 | .collect(); | |
| 1581 | for (zoom, expected) in [ | |
| 1582 | (0.5, [62.0, 134.0, 223.0, 219.0]), | |
| 1583 | (1.0, [81.0, 189.0, 394.0, 348.0]), | |
| 1584 | (1.5, [101.0, 245.0, 565.0, 479.0]), | |
| 1585 | (2.0, [120.0, 300.0, 736.0, 608.0]), | |
| 1586 | ] { | |
| 1587 | for dpr in [1.0, 2.0] { | |
| 1588 | let scale = zoom * (96.0 / 72.0) * dpr; | |
| 1589 | let pixel = dpr / scale; | |
| 1590 | let (frame, body_top) = outline_chrome(editor.active_outline(), pixel); | |
| 1591 | for (index, coordinate) in frame.iter().enumerate() { | |
| 1592 | let origin = if index % 2 == 0 { 48.0 } else { 83.0 }; | |
| 1593 | let screen = coordinate * scale / dpr + origin; | |
| 1594 | assert!( | |
| 1595 | (screen - expected[index]).abs() <= 1.5, | |
| 1596 | "zoom={zoom} dpr={dpr} edge={index}: {screen} vs {}", | |
| 1597 | expected[index] | |
| 1598 | ); | |
| 1599 | } | |
| 1600 | let header = [36.0, (frame[1] + body_top) / 2.0]; | |
| 1601 | assert!( | |
| 1602 | matches!(page_hit_test(&editor, None, header, pixel), Some(Hit::Handle {id: hit, ..}) if hit == id) | |
| 1603 | ); | |
| 1604 | let padding = [frame[0] + pixel, 90.0]; | |
| 1605 | assert!( | |
| 1606 | matches!(page_hit_test(&editor, None, padding, pixel), Some(Hit::Text {id: hit, ..}) if hit == id) | |
| 1607 | ); | |
| 1608 | assert_eq!( | |
| 1609 | page_hit_test(&editor, None, [frame[0] - pixel, 90.0], pixel), | |
| 1610 | None | |
| 1611 | ); | |
| 1612 | let primitives = | |
| 1613 | page_primitives(&editor, None, None, None, false, scale, dpr).unwrap(); | |
| 1614 | assert!( | |
| 1615 | matches!(&primitives[0], Primitive::Rect {rect, ..} if *rect == [frame[0], frame[1], frame[2], body_top]) | |
| 1616 | ); | |
| 1617 | assert!( | |
| 1618 | primitives.iter().any( | |
| 1619 | |p| matches!(p, Primitive::Text {origin, ..} if *origin == [36.0, 90.0]) | |
| 1620 | ) | |
| 1621 | ); | |
| 1622 | } | |
| 1623 | } | |
| 1624 | assert_eq!(editor.active_outline().document(), &document); | |
| 1625 | assert_eq!( | |
| 1626 | editor | |
| 1627 | .active_outline() | |
| 1628 | .layouts() | |
| 1629 | .map(|(_, p)| p.text.id()) | |
| 1630 | .collect::<Vec<_>>(), | |
| 1631 | layout_ids | |
| 1632 | ); | |
| 1633 | } | |
| 1634 | ||
| 1635 | #[test] | |
| 1636 | fn a_later_text_body_takes_priority_over_an_earlier_drag_handle() { | |
| 1637 | let mut engine = TextEngine::default(); | |
| 1638 | let mut editor = CanvasEditor::new( | |
| 1639 | &mut engine, | |
| 1640 | TextDocument::new(vec![Paragraph::new("first".into(), Default::default())]).unwrap(), | |
| 1641 | 240.0, | |
| 1642 | ) | |
| 1643 | .unwrap(); | |
| 1644 | let first = editor.active_outline().id; | |
| 1645 | editor.move_outline(first, [0.0, 20.0]).unwrap(); | |
| 1646 | assert_eq!( | |
| 1647 | page_hit_test(&editor, None, [5.0, 12.0], 1.0), | |
| 1648 | Some(Hit::Handle { | |
| 1649 | id: first, | |
| 1650 | grab: [5.0, -8.0] | |
| 1651 | }) | |
| 1652 | ); | |
| 1653 | let second = editor | |
| 1654 | .create_outline(&mut engine, [0.0, 10.0], 240.0) | |
| 1655 | .unwrap(); | |
| 1656 | assert_eq!( | |
| 1657 | page_hit_test(&editor, None, [5.0, 12.0], 1.0), | |
| 1658 | Some(Hit::Text { | |
| 1659 | id: second, | |
| 1660 | point: [5.0, 2.0] | |
| 1661 | }) | |
| 1662 | ); | |
| 1663 | editor.undo(&mut engine).unwrap(); | |
| 1664 | assert_eq!( | |
| 1665 | page_hit_test(&editor, None, [5.0, 12.0], 1.0), | |
| 1666 | Some(Hit::Handle { | |
| 1667 | id: first, | |
| 1668 | grab: [5.0, -8.0] | |
| 1669 | }) | |
| 1670 | ); | |
| 1671 | } | |
| 1672 | ||
| 1673 | #[test] | |
| 1674 | fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { | |
| 1675 | use one_canvas::page::{Outline, PageObject, Unsupported}; | |
| 1676 | for readonly_on_top in [false, true] { | |
| 1677 | let mut engine = TextEngine::default(); | |
| 1678 | let document = TextDocument::new(vec![Paragraph::new( | |
| 1679 | "Imported text".into(), | |
| 1680 | Default::default(), | |
| 1681 | )]) | |
| 1682 | .unwrap(); | |
| 1683 | let id = onestore::ExGuid { | |
| 1684 | guid: [5; 16], | |
| 1685 | n: 1, | |
| 1686 | }; | |
| 1687 | let mut objects = vec![ | |
| 1688 | PageObject::Outline(Outline { | |
| 1689 | id, | |
| 1690 | layout: onestore::document::Layout { | |
| 1691 | x: Some(30.0), | |
| 1692 | y: Some(40.0), | |
| 1693 | max_width: Some(180.0), | |
| 1694 | ..Default::default() | |
| 1695 | }, | |
| 1696 | indents: vec![18.0, 0.0], | |
| 1697 | paragraphs: document.nodes().to_vec(), | |
| 1698 | unsupported: Vec::new(), | |
| 1699 | }), | |
| 1700 | PageObject::Unsupported(Unsupported { | |
| 1701 | id: Default::default(), | |
| 1702 | jcid: 0xdead, | |
| 1703 | layout: onestore::document::Layout { | |
| 1704 | x: Some(20.0), | |
| 1705 | y: Some(20.0), | |
| 1706 | max_width: Some(200.0), | |
| 1707 | max_height: Some(100.0), | |
| 1708 | ..Default::default() | |
| 1709 | }, | |
| 1710 | }), | |
| 1711 | ]; | |
| 1712 | if !readonly_on_top { | |
| 1713 | objects.reverse(); | |
| 1714 | } | |
| 1715 | let page = Page { | |
| 1716 | title: String::new(), | |
| 1717 | margin_origin: [0.0; 2], | |
| 1718 | definitions: Default::default(), | |
| 1719 | objects, | |
| 1720 | }; | |
| 1721 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | |
| 1722 | let scene = (scene, [0.0; 2]); | |
| 1723 | let imported_layout = editor | |
| 1724 | .active_outline() | |
| 1725 | .layouts() | |
| 1726 | .next() | |
| 1727 | .unwrap() | |
| 1728 | .1 | |
| 1729 | .text | |
| 1730 | .id(); | |
| 1731 | let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0) | |
| 1732 | .unwrap() | |
| 1733 | .into_iter() | |
| 1734 | .filter_map(|p| match p { | |
| 1735 | Primitive::Text { layout, .. } => Some(layout.id()), | |
| 1736 | _ => None, | |
| 1737 | }) | |
| 1738 | .collect(); | |
| 1739 | assert_eq!(texts.len(), 2); | |
| 1740 | assert_eq!(texts[usize::from(!readonly_on_top)], imported_layout); | |
| 1741 | let expected = if readonly_on_top { | |
| 1742 | Hit::ReadOnly(0) | |
| 1743 | } else { | |
| 1744 | Hit::Text { | |
| 1745 | id, | |
| 1746 | point: [10.0, 5.0], | |
| 1747 | } | |
| 1748 | }; | |
| 1749 | assert_eq!( | |
| 1750 | page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), | |
| 1751 | Some(expected) | |
| 1752 | ); | |
| 1753 | let header = page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0); | |
| 1754 | if readonly_on_top { | |
| 1755 | assert_eq!(header, Some(Hit::ReadOnly(0))); | |
| 1756 | } else { | |
| 1757 | assert_eq!( | |
| 1758 | header, | |
| 1759 | Some(Hit::Handle { | |
| 1760 | id, | |
| 1761 | grab: [10.0, -8.0] | |
| 1762 | }) | |
| 1763 | ); | |
| 1764 | } | |
| 1765 | let annotation = editor | |
| 1766 | .create_outline(&mut engine, [30.0, 40.0], 180.0) | |
| 1767 | .unwrap(); | |
| 1768 | assert_eq!( | |
| 1769 | page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), | |
| 1770 | Some(Hit::Text { | |
| 1771 | id: annotation, | |
| 1772 | point: [10.0, 5.0] | |
| 1773 | }) | |
| 1774 | ); | |
| 1775 | assert_eq!( | |
| 1776 | page_hit_test(&editor, Some(&scene), [40.0, 32.0], 1.0), | |
| 1777 | Some(Hit::Handle { | |
| 1778 | id: annotation, | |
| 1779 | grab: [10.0, -8.0] | |
| 1780 | }) | |
| 1781 | ); | |
| 1782 | let texts: Vec<_> = page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0) | |
| 1783 | .unwrap() | |
| 1784 | .into_iter() | |
| 1785 | .filter_map(|p| match p { | |
| 1786 | Primitive::Text { layout, .. } => Some(layout.id()), | |
| 1787 | _ => None, | |
| 1788 | }) | |
| 1789 | .collect(); | |
| 1790 | assert_eq!( | |
| 1791 | *texts.last().unwrap(), | |
| 1792 | editor | |
| 1793 | .active_outline() | |
| 1794 | .layouts() | |
| 1795 | .next() | |
| 1796 | .unwrap() | |
| 1797 | .1 | |
| 1798 | .text | |
| 1799 | .id() | |
| 1800 | ); | |
| 1801 | editor.move_outline(annotation, [500.0, 500.0]).unwrap(); | |
| 1802 | assert!( | |
| 1803 | !matches!(page_hit_test(&editor, Some(&scene), [40.0,45.0], 1.0), Some(Hit::Text { id, .. }) if id == annotation) | |
| 1804 | ); | |
| 1805 | editor.undo(&mut engine).unwrap(); | |
| 1806 | assert_eq!( | |
| 1807 | page_hit_test(&editor, Some(&scene), [40.0, 45.0], 1.0), | |
| 1808 | Some(Hit::Text { | |
| 1809 | id: annotation, | |
| 1810 | point: [10.0, 5.0] | |
| 1811 | }) | |
| 1812 | ); | |
| 1813 | editor.undo(&mut engine).unwrap(); | |
| 1814 | assert_eq!(editor.outlines().len(), 1); | |
| 1815 | assert_eq!(editor.active_outline().document(), &document); | |
| 1816 | assert_eq!( | |
| 1817 | page_hit_test(&editor, Some(&scene), [10.0, 10.0], 1.0), | |
| 1818 | None | |
| 1819 | ); | |
| 1820 | } | |
| 1821 | } | |
| 1822 | ||
| 1823 | #[test] | |
| 1824 | fn read_only_focus_accepts_navigation_without_edit_shortcuts() { | |
| 1825 | for modifiers in [ | |
| 1826 | ModifiersState::empty(), | |
| 1827 | ModifiersState::SHIFT, | |
| 1828 | ModifiersState::ALT, | |
| 1829 | ModifiersState::SUPER, | |
| 1830 | ModifiersState::SUPER | ModifiersState::SHIFT, | |
| 1831 | ModifiersState::SUPER | ModifiersState::ALT, | |
| 1832 | ] { | |
| 1833 | for key in [ | |
| 1834 | Key::Character("a".into()), | |
| 1835 | Key::Character("v".into()), | |
| 1836 | Key::Character("c".into()), | |
| 1837 | Key::Character("x".into()), | |
| 1838 | Key::Character("z".into()), | |
| 1839 | Key::Character("🌳".into()), | |
| 1840 | Key::Named(NamedKey::Backspace), | |
| 1841 | Key::Named(NamedKey::Delete), | |
| 1842 | Key::Named(NamedKey::Enter), | |
| 1843 | Key::Named(NamedKey::ArrowLeft), | |
| 1844 | Key::Named(NamedKey::ArrowRight), | |
| 1845 | Key::Named(NamedKey::ArrowUp), | |
| 1846 | Key::Named(NamedKey::ArrowDown), | |
| 1847 | ] { | |
| 1848 | assert!( | |
| 1849 | !read_only_shortcut(&key, modifiers), | |
| 1850 | "{key:?} {modifiers:?}" | |
| 1851 | ); | |
| 1852 | } | |
| 1853 | } | |
| 1854 | for (key, modifiers) in [ | |
| 1855 | (Key::Named(NamedKey::Escape), ModifiersState::empty()), | |
| 1856 | (Key::Named(NamedKey::Tab), ModifiersState::CONTROL), | |
| 1857 | ( | |
| 1858 | Key::Named(NamedKey::Tab), | |
| 1859 | ModifiersState::CONTROL | ModifiersState::SHIFT, | |
| 1860 | ), | |
| 1861 | ( | |
| 1862 | Key::Character("N".into()), | |
| 1863 | ModifiersState::SUPER | ModifiersState::SHIFT, | |
| 1864 | ), | |
| 1865 | (Key::Character("+".into()), ModifiersState::SUPER), | |
| 1866 | (Key::Character("0".into()), ModifiersState::SUPER), | |
| 1867 | (Key::Character("-".into()), ModifiersState::SUPER), | |
| 1868 | ] { | |
| 1869 | assert!(read_only_shortcut(&key, modifiers)); | |
| 1870 | } | |
| 1871 | assert!(!read_only_shortcut( | |
| 1872 | &Key::Character("n".into()), | |
| 1873 | ModifiersState::SUPER | |
| 1874 | )); | |
| 1875 | assert!(!read_only_shortcut( | |
| 1876 | &Key::Named(NamedKey::Tab), | |
| 1877 | ModifiersState::empty() | |
| 1878 | )); | |
| 1879 | } | |
| 1880 | ||
| 1881 | #[test] | |
| 1882 | fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { | |
| 1883 | use one_canvas::page::{PageObject, Unsupported}; | |
| 1884 | let mut engine = TextEngine::default(); | |
| 1885 | let mut editor = CanvasEditor::new( | |
| 1886 | &mut engine, | |
| 1887 | TextDocument::new(vec![Paragraph::new("Draft".into(), Default::default())]).unwrap(), | |
| 1888 | 240.0, | |
| 1889 | ) | |
| 1890 | .unwrap(); | |
| 1891 | let page = Page { | |
| 1892 | title: String::new(), | |
| 1893 | margin_origin: [0.0; 2], | |
| 1894 | definitions: Default::default(), | |
| 1895 | objects: vec![PageObject::Unsupported(Unsupported { | |
| 1896 | id: Default::default(), | |
| 1897 | jcid: 0xdead, | |
| 1898 | layout: onestore::document::Layout { | |
| 1899 | x: Some(100.0), | |
| 1900 | y: Some(80.0), | |
| 1901 | max_width: Some(180.0), | |
| 1902 | max_height: Some(80.0), | |
| 1903 | ..Default::default() | |
| 1904 | }, | |
| 1905 | })], | |
| 1906 | }; | |
| 1907 | let scene = (PageScene::new(page, &mut engine).unwrap(), [20.0, 30.0]); | |
| 1908 | let original = editor.active_outline().document().clone(); | |
| 1909 | let rectangles = |primitives: Vec<Primitive<'_>>| { | |
| 1910 | primitives | |
| 1911 | .into_iter() | |
| 1912 | .filter_map(|p| match p { | |
| 1913 | Primitive::Rect { rect, color } => Some((rect, color)), | |
| 1914 | _ => None, | |
| 1915 | }) | |
| 1916 | .collect::<Vec<_>>() | |
| 1917 | }; | |
| 1918 | for scale in [1.0, 2.0] { | |
| 1919 | let text = rectangles( | |
| 1920 | page_primitives(&editor, Some(&scene), None, None, true, scale, 1.0).unwrap(), | |
| 1921 | ); | |
| 1922 | assert!(text.iter().any(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0])); | |
| 1923 | let selected = rectangles( | |
| 1924 | page_primitives(&editor, Some(&scene), None, Some(0), true, scale, 1.0).unwrap(), | |
| 1925 | ); | |
| 1926 | assert!( | |
| 1927 | !selected | |
| 1928 | .iter() | |
| 1929 | .any(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0]) | |
| 1930 | ); | |
| 1931 | assert_eq!( | |
| 1932 | selected, | |
| 1933 | rectangles( | |
| 1934 | page_primitives(&editor, Some(&scene), None, Some(0), false, scale, 1.0) | |
| 1935 | .unwrap() | |
| 1936 | ) | |
| 1937 | ); | |
| 1938 | assert_eq!( | |
| 1939 | selected.last().unwrap().0, | |
| 1940 | [300.0 - 2.0 / scale, 110.0, 300.0, 190.0] | |
| 1941 | ); | |
| 1942 | } | |
| 1943 | editor.select_all().unwrap(); | |
| 1944 | let text = rectangles( | |
| 1945 | page_primitives(&editor, Some(&scene), None, None, false, 1.0, 1.0).unwrap(), | |
| 1946 | ); | |
| 1947 | assert!( | |
| 1948 | text.iter() | |
| 1949 | .any(|(_, color)| *color == [0.55, 0.73, 1.0, 0.5]) | |
| 1950 | ); | |
| 1951 | let selected = rectangles( | |
| 1952 | page_primitives(&editor, Some(&scene), None, Some(0), true, 1.0, 1.0).unwrap(), | |
| 1953 | ); | |
| 1954 | assert!( | |
| 1955 | !selected | |
| 1956 | .iter() | |
| 1957 | .any(|(_, color)| *color == [0.55, 0.73, 1.0, 0.5]) | |
| 1958 | ); | |
| 1959 | assert_eq!(editor.active_outline().document(), &original); | |
| 1960 | } | |
| 1961 | ||
| 1962 | #[test] | |
| 1963 | #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] | |
| 1964 | fn imported_page_widgets_and_accessibility_follow_edits() { | |
| 1965 | let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); | |
| 1966 | let page = { | |
| 1967 | let store = onestore::Store::parse(&bytes).unwrap(); | |
| 1968 | let index = onestore::RevisionIndex::parse(&store).unwrap(); | |
| 1969 | Page::from_document( | |
| 1970 | &onestore::document::Document::parse(&index).unwrap(), | |
| 1971 | &std::env::var("CANVAS_TEST_PAGE").unwrap(), | |
| 1972 | ) | |
| 1973 | .unwrap() | |
| 1974 | }; | |
| 1975 | drop(bytes); | |
| 1976 | let mut engine = TextEngine::default(); | |
| 1977 | if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { | |
| 1978 | engine | |
| 1979 | .register_substitute(parley::fontique::Blob::new(Arc::new( | |
| 1980 | std::fs::read(font).unwrap(), | |
| 1981 | ))) | |
| 1982 | .unwrap(); | |
| 1983 | } | |
| 1984 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | |
| 1985 | let scene = (scene, [0.0; 2]); | |
| 1986 | let viewport = Viewport { | |
| 1987 | size: [1000, 720], | |
| 1988 | scale: 96.0 / 72.0, | |
| 1989 | origin: [48.0; 2], | |
| 1990 | }; | |
| 1991 | let mut tag_hits = 0; | |
| 1992 | for outline in editor.outlines() { | |
| 1993 | for (_, paragraph) in outline.layouts() { | |
| 1994 | for tag in &paragraph.tags { | |
| 1995 | let point = [ | |
| 1996 | outline.origin()[0] | |
| 1997 | + tag.origin[0] | |
| 1998 | + one_canvas::outline::ParagraphTag::SIZE / 2.0, | |
| 1999 | outline.origin()[1] | |
| 2000 | + paragraph.origin[1] | |
| 2001 | + tag.origin[1] | |
| 2002 | + one_canvas::outline::ParagraphTag::SIZE / 2.0, | |
| 2003 | ]; | |
| 2004 | assert!( | |
| 2005 | matches!(page_hit_test(&editor, Some(&scene), point, 1.0), Some(Hit::Text { id, .. }) if id == outline.id), | |
| 2006 | "Tag center {point:?} did not target outline {:?}", | |
| 2007 | outline.id | |
| 2008 | ); | |
| 2009 | tag_hits += 1; | |
| 2010 | } | |
| 2011 | } | |
| 2012 | } | |
| 2013 | eprintln!("Verified {tag_hits} imported tag-gutter targets"); | |
| 2014 | let mut access = accessibility::Accessibility::default(); | |
| 2015 | let tags = |editor: &CanvasEditor| { | |
| 2016 | page_primitives(editor, Some(&scene), None, None, true, viewport.scale, 1.0) | |
| 2017 | .unwrap() | |
| 2018 | .into_iter() | |
| 2019 | .filter_map(|primitive| match primitive { | |
| 2020 | Primitive::Tag { tag, origin } => Some((tag.clone(), origin)), | |
| 2021 | _ => None, | |
| 2022 | }) | |
| 2023 | .collect::<Vec<_>>() | |
| 2024 | }; | |
| 2025 | let original_tags = tags(&editor); | |
| 2026 | let ids: Vec<_> = editor.outlines().iter().map(|outline| outline.id).collect(); | |
| 2027 | for id in ids { | |
| 2028 | editor.focus_outline(id).unwrap(); | |
| 2029 | let original = editor.active_outline().document().clone(); | |
| 2030 | editor | |
| 2031 | .select( | |
| 2032 | [one_canvas::document::TextPosition { | |
| 2033 | paragraph: 0, | |
| 2034 | offset: 0, | |
| 2035 | }; 2] | |
| 2036 | .into(), | |
| 2037 | ) | |
| 2038 | .unwrap(); | |
| 2039 | let before = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 2040 | let before = accesskit_consumer::Tree::new(before, true); | |
| 2041 | let before = before.state().focus().unwrap().document_range().text(); | |
| 2042 | editor.insert(&mut engine, "CANVAS CHECK ").unwrap(); | |
| 2043 | { | |
| 2044 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 2045 | let tree = accesskit_consumer::Tree::new(update, true); | |
| 2046 | assert_eq!( | |
| 2047 | tree.state().focus().unwrap().document_range().text(), | |
| 2048 | format!("CANVAS CHECK {before}") | |
| 2049 | ); | |
| 2050 | let primitives = | |
| 2051 | page_primitives(&editor, Some(&scene), None, None, true, viewport.scale, 1.0) | |
| 2052 | .unwrap(); | |
| 2053 | assert!(!primitives.is_empty()); | |
| 2054 | let edited_tags = tags(&editor); | |
| 2055 | assert_eq!(edited_tags.len(), original_tags.len()); | |
| 2056 | for ((edited, _), (original, _)) in edited_tags.iter().zip(&original_tags) { | |
| 2057 | assert_eq!(edited.icon, original.icon); | |
| 2058 | assert_eq!(edited.label, original.label); | |
| 2059 | assert_eq!(edited.disabled, original.disabled); | |
| 2060 | } | |
| 2061 | } | |
| 2062 | editor.undo(&mut engine).unwrap(); | |
| 2063 | assert_eq!(editor.active_outline().document(), &original); | |
| 2064 | assert_eq!(tags(&editor), original_tags); | |
| 2065 | let update = access.update(&editor, viewport, "Test", None).unwrap(); | |
| 2066 | let tree = accesskit_consumer::Tree::new(update, true); | |
| 2067 | assert_eq!( | |
| 2068 | tree.state().focus().unwrap().document_range().text(), | |
| 2069 | before | |
| 2070 | ); | |
| 2071 | } | |
| 2072 | eprintln!( | |
| 2073 | "Rendered and restored {} imported outline widgets and accessibility fields", | |
| 2074 | editor.outlines().len() | |
| 2075 | ); | |
| 2076 | } | |
| 2077 | } |
crates/canvas-app/src/profile.rs created+192| ... | ... | @@ -0,0 +1,192 @@ |
| 1 | use super::*; | |
| 2 | use one_canvas::document::TextPosition; | |
| 3 | use std::{hint::black_box, process::Command}; | |
| 4 | ||
| 5 | fn timed<T>(case: &str, phase: &str, sample: usize, operation: impl FnOnce() -> T) -> T { | |
| 6 | let start = Instant::now(); | |
| 7 | let result = operation(); | |
| 8 | eprintln!( | |
| 9 | "canvas_profile\t{case}\t{phase}\t{sample}\t{}", | |
| 10 | start.elapsed().as_nanos() | |
| 11 | ); | |
| 12 | result | |
| 13 | } | |
| 14 | ||
| 15 | fn resident(case: &str, phase: &str, sample: usize) { | |
| 16 | let output = Command::new("/bin/ps") | |
| 17 | .args(["-o", "rss=", "-p", &std::process::id().to_string()]) | |
| 18 | .output() | |
| 19 | .unwrap(); | |
| 20 | assert!(output.status.success()); | |
| 21 | let kib: u64 = std::str::from_utf8(&output.stdout) | |
| 22 | .unwrap() | |
| 23 | .trim() | |
| 24 | .parse() | |
| 25 | .unwrap(); | |
| 26 | eprintln!("canvas_rss_kib\t{case}\t{phase}\t{sample}\t{kib}"); | |
| 27 | } | |
| 28 | ||
| 29 | fn layout_ids(editor: &CanvasEditor) -> Vec<u64> { | |
| 30 | editor | |
| 31 | .outlines() | |
| 32 | .iter() | |
| 33 | .flat_map(|outline| outline.layouts().map(|(_, paragraph)| paragraph.text.id())) | |
| 34 | .collect() | |
| 35 | } | |
| 36 | ||
| 37 | fn editing_cost( | |
| 38 | case: &str, | |
| 39 | engine: &mut TextEngine, | |
| 40 | editor: &mut CanvasEditor, | |
| 41 | scene: Option<&(PageScene, [f32; 2])>, | |
| 42 | ) { | |
| 43 | let viewport = Viewport { | |
| 44 | size: [1000, 720], | |
| 45 | scale: 96.0 / 72.0, | |
| 46 | origin: [48.0; 2], | |
| 47 | }; | |
| 48 | let initial: Vec<_> = timed(case, "opening_snapshot", 0, || { | |
| 49 | editor | |
| 50 | .outlines() | |
| 51 | .iter() | |
| 52 | .map(|outline| outline.document().clone()) | |
| 53 | .collect() | |
| 54 | }); | |
| 55 | let mut access = accessibility::Accessibility::default(); | |
| 56 | let update = timed(case, "ax_first", 0, || { | |
| 57 | access.update(editor, viewport, "Profile", None).unwrap() | |
| 58 | }); | |
| 59 | eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len()); | |
| 60 | drop(update); | |
| 61 | let count: usize = editor | |
| 62 | .outlines() | |
| 63 | .iter() | |
| 64 | .map(|outline| outline.document().nodes().len()) | |
| 65 | .sum(); | |
| 66 | eprintln!("canvas_count\t{case}\tparagraphs\t0\t{count}"); | |
| 67 | for sample in 0..32 { | |
| 68 | let index = sample % editor.outlines().len(); | |
| 69 | let id = editor.outlines()[index].id; | |
| 70 | editor.focus_outline(id).unwrap(); | |
| 71 | let paragraph = if sample % 2 == 0 { | |
| 72 | 0 | |
| 73 | } else { | |
| 74 | editor.active_outline().layouts().last().unwrap().0 | |
| 75 | }; | |
| 76 | editor | |
| 77 | .select( | |
| 78 | [TextPosition { | |
| 79 | paragraph, | |
| 80 | offset: 0, | |
| 81 | }; 2] | |
| 82 | .into(), | |
| 83 | ) | |
| 84 | .unwrap(); | |
| 85 | let original_layouts = layout_ids(editor); | |
| 86 | timed(case, "insert", sample, || { | |
| 87 | editor.insert(engine, "x").unwrap() | |
| 88 | }); | |
| 89 | let edited_layouts = layout_ids(editor); | |
| 90 | let changed = original_layouts | |
| 91 | .iter() | |
| 92 | .zip(&edited_layouts) | |
| 93 | .filter(|(a, b)| a != b) | |
| 94 | .count(); | |
| 95 | assert_eq!(changed, 1); | |
| 96 | eprintln!("canvas_count\t{case}\tchanged_layouts\t{sample}\t{changed}"); | |
| 97 | let primitives = timed(case, "frame_primitives", sample, || { | |
| 98 | let primitives = | |
| 99 | page_primitives(editor, scene, None, None, true, viewport.scale, 1.0).unwrap(); | |
| 100 | black_box(&primitives); | |
| 101 | primitives.len() | |
| 102 | }); | |
| 103 | eprintln!("canvas_count\t{case}\tprimitives\t{sample}\t{primitives}"); | |
| 104 | timed(case, "ax_insert", sample, || { | |
| 105 | black_box(access.update(editor, viewport, "Profile", None).unwrap()); | |
| 106 | }); | |
| 107 | assert_eq!(edited_layouts, layout_ids(editor)); | |
| 108 | timed(case, "undo", sample, || { | |
| 109 | assert!(editor.undo(engine).unwrap()) | |
| 110 | }); | |
| 111 | timed(case, "ax_undo", sample, || { | |
| 112 | black_box(access.update(editor, viewport, "Profile", None).unwrap()); | |
| 113 | }); | |
| 114 | assert_eq!(editor.active_outline().document(), &initial[index]); | |
| 115 | } | |
| 116 | resident(case, "after_edits", 0); | |
| 117 | } | |
| 118 | ||
| 119 | #[test] | |
| 120 | #[ignore = "manual release canvas pipeline timing probe"] | |
| 121 | fn canvas_pipeline_cost() { | |
| 122 | if let Some(path) = std::env::var_os("CANVAS_TEST_SECTION") { | |
| 123 | let bytes = timed("native", "read", 0, || std::fs::read(path).unwrap()); | |
| 124 | let title = std::env::var("CANVAS_TEST_PAGE").unwrap(); | |
| 125 | let mut engine = timed("native", "engine", 0, TextEngine::default); | |
| 126 | if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { | |
| 127 | timed("native", "font", 0, || { | |
| 128 | engine | |
| 129 | .register_substitute(parley::fontique::Blob::new(Arc::new( | |
| 130 | std::fs::read(font).unwrap(), | |
| 131 | ))) | |
| 132 | .unwrap() | |
| 133 | }); | |
| 134 | } | |
| 135 | let cycles = std::env::var("CANVAS_PROFILE_CYCLES") | |
| 136 | .map(|value| value.parse::<usize>().unwrap()) | |
| 137 | .unwrap_or(16); | |
| 138 | assert!(cycles > 0); | |
| 139 | for cycle in 0..cycles { | |
| 140 | let page = timed("native", "parse", cycle, || { | |
| 141 | let store = onestore::Store::parse(&bytes).unwrap(); | |
| 142 | let index = onestore::RevisionIndex::parse(&store).unwrap(); | |
| 143 | Page::from_document( | |
| 144 | &onestore::document::Document::parse(&index).unwrap(), | |
| 145 | &title, | |
| 146 | ) | |
| 147 | .unwrap() | |
| 148 | }); | |
| 149 | let (scene, mut editor) = timed("native", "scene", cycle, || { | |
| 150 | PageScene::from_page(page, &mut engine).unwrap() | |
| 151 | }); | |
| 152 | let scene = (scene, [0.0; 2]); | |
| 153 | if cycle == 0 { | |
| 154 | editing_cost("native", &mut engine, &mut editor, Some(&scene)); | |
| 155 | } else { | |
| 156 | black_box( | |
| 157 | page_primitives(&editor, Some(&scene), None, None, false, 96.0 / 72.0, 1.0) | |
| 158 | .unwrap(), | |
| 159 | ); | |
| 160 | } | |
| 161 | resident("native", "open", cycle); | |
| 162 | drop(editor); | |
| 163 | drop(scene); | |
| 164 | resident("native", "closed", cycle); | |
| 165 | } | |
| 166 | } else { | |
| 167 | for count in [100, 1_000, 5_000] { | |
| 168 | let case = format!("synthetic_{count}"); | |
| 169 | let mut engine = timed(&case, "engine", 0, TextEngine::default); | |
| 170 | let document = timed(&case, "document", 0, || { | |
| 171 | TextDocument::new( | |
| 172 | (0..count) | |
| 173 | .map(|_| { | |
| 174 | Paragraph::new( | |
| 175 | "A paragraph of plain text with several words.".into(), | |
| 176 | Format::default(), | |
| 177 | ) | |
| 178 | }) | |
| 179 | .collect(), | |
| 180 | ) | |
| 181 | .unwrap() | |
| 182 | }); | |
| 183 | let mut editor = timed(&case, "scene", 0, || { | |
| 184 | CanvasEditor::new(&mut engine, document, 480.0).unwrap() | |
| 185 | }); | |
| 186 | editing_cost(&case, &mut engine, &mut editor, None); | |
| 187 | drop(editor); | |
| 188 | drop(engine); | |
| 189 | resident(&case, "closed", 0); | |
| 190 | } | |
| 191 | } | |
| 192 | } |
crates/canvas-gpu/Cargo.toml created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | [package] | |
| 2 | name = "one-canvas-gpu" | |
| 3 | version = "0.1.0" | |
| 4 | edition = "2024" | |
| 5 | publish = false | |
| 6 | ||
| 7 | [dependencies] | |
| 8 | onestore = { path = "../onestore" } | |
| 9 | one-canvas = { path = "../canvas" } | |
| 10 | bytemuck = { version = "1.25", features = ["derive"] } | |
| 11 | swash = "0.2.10" | |
| 12 | parley.workspace = true | |
| 13 | wgpu.workspace = true | |
| 14 | image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } | |
| 15 | ||
| 16 | [dev-dependencies] | |
| 17 | pollster = "0.4" | |
| 18 | png = "0.18" |
crates/canvas-gpu/examples/render_page.rs created+125| ... | ... | @@ -0,0 +1,125 @@ |
| 1 | use one_canvas::{layout::TextEngine, page::Page}; | |
| 2 | use one_canvas_gpu::{Renderer, Viewport, page::PageScene}; | |
| 3 | use onestore::{RevisionIndex, Store, document::Document}; | |
| 4 | use std::{env, fs, sync::Arc, time::Duration}; | |
| 5 | ||
| 6 | fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| 7 | let mut args = env::args().skip(1).peekable(); | |
| 8 | let path = args | |
| 9 | .next() | |
| 10 | .ok_or("Expected a section snapshot, page title and new PNG path")?; | |
| 11 | let title = args.next().ok_or("Expected a page title")?; | |
| 12 | let destination = args.next().ok_or("Expected a new PNG path")?; | |
| 13 | let origin_y: f32 = if args | |
| 14 | .peek() | |
| 15 | .is_some_and(|value| value != "--substitute-font") | |
| 16 | { | |
| 17 | args.next().unwrap().parse()? | |
| 18 | } else { | |
| 19 | 0.0 | |
| 20 | }; | |
| 21 | let mut engine = TextEngine::default(); | |
| 22 | while let Some(option) = args.next() { | |
| 23 | if option != "--substitute-font" { | |
| 24 | return Err(format!("Unknown option: {option}").into()); | |
| 25 | } | |
| 26 | let path = args | |
| 27 | .next() | |
| 28 | .ok_or("Provide a font file after --substitute-font.")?; | |
| 29 | engine.register_substitute(parley::fontique::Blob::new(Arc::new(fs::read(path)?)))?; | |
| 30 | } | |
| 31 | let page = { | |
| 32 | let bytes = fs::read(path)?; | |
| 33 | let store = Store::parse(&bytes)?; | |
| 34 | let index = RevisionIndex::parse(&store)?; | |
| 35 | Page::from_document(&Document::parse(&index)?, &title)? | |
| 36 | }; | |
| 37 | let margin_origin = page.margin_origin; | |
| 38 | let object_count = page.objects.len(); | |
| 39 | let scene = PageScene::new(page, &mut engine)?; | |
| 40 | let mut primitives = Vec::new(); | |
| 41 | scene.append_primitives(&mut primitives, [0.0; 2])?; | |
| 42 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | |
| 43 | let adapter = pollster::block_on(instance.request_adapter(&Default::default()))?; | |
| 44 | let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; | |
| 45 | let size = [1386_u32, 759]; | |
| 46 | let format = wgpu::TextureFormat::Rgba8UnormSrgb; | |
| 47 | let target = device.create_texture(&wgpu::TextureDescriptor { | |
| 48 | label: Some("Native page comparison"), | |
| 49 | size: wgpu::Extent3d { | |
| 50 | width: size[0], | |
| 51 | height: size[1], | |
| 52 | depth_or_array_layers: 1, | |
| 53 | }, | |
| 54 | mip_level_count: 1, | |
| 55 | sample_count: 1, | |
| 56 | dimension: wgpu::TextureDimension::D2, | |
| 57 | format, | |
| 58 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, | |
| 59 | view_formats: &[], | |
| 60 | }); | |
| 61 | let row_bytes = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); | |
| 62 | let buffer = device.create_buffer(&wgpu::BufferDescriptor { | |
| 63 | label: Some("Native page comparison readback"), | |
| 64 | size: u64::from(row_bytes) * u64::from(size[1]), | |
| 65 | usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, | |
| 66 | mapped_at_creation: false, | |
| 67 | }); | |
| 68 | let mut renderer = Renderer::new(device, queue, format); | |
| 69 | renderer | |
| 70 | .draw( | |
| 71 | &target.create_view(&Default::default()), | |
| 72 | Viewport { | |
| 73 | size, | |
| 74 | scale: 96.0 / 72.0, | |
| 75 | origin: [ | |
| 76 | (36.0 - margin_origin[0]) * (96.0 / 72.0), | |
| 77 | (14.4 - margin_origin[1]) * (96.0 / 72.0) + origin_y, | |
| 78 | ], | |
| 79 | }, | |
| 80 | &primitives, | |
| 81 | ) | |
| 82 | .map_err(|e| format!("Page comparison rendering failed: {e:?}"))?; | |
| 83 | let mut encoder = renderer.device.create_command_encoder(&Default::default()); | |
| 84 | encoder.copy_texture_to_buffer( | |
| 85 | target.as_image_copy(), | |
| 86 | wgpu::TexelCopyBufferInfo { | |
| 87 | buffer: &buffer, | |
| 88 | layout: wgpu::TexelCopyBufferLayout { | |
| 89 | offset: 0, | |
| 90 | bytes_per_row: Some(row_bytes), | |
| 91 | rows_per_image: Some(size[1]), | |
| 92 | }, | |
| 93 | }, | |
| 94 | wgpu::Extent3d { | |
| 95 | width: size[0], | |
| 96 | height: size[1], | |
| 97 | depth_or_array_layers: 1, | |
| 98 | }, | |
| 99 | ); | |
| 100 | renderer.queue.submit([encoder.finish()]); | |
| 101 | let (sender, receiver) = std::sync::mpsc::channel(); | |
| 102 | buffer.map_async(wgpu::MapMode::Read, .., move |result| { | |
| 103 | let _ = sender.send(result); | |
| 104 | }); | |
| 105 | renderer.device.poll(wgpu::PollType::Wait { | |
| 106 | submission_index: None, | |
| 107 | timeout: Some(Duration::from_secs(10)), | |
| 108 | })?; | |
| 109 | receiver.recv_timeout(Duration::from_secs(10))??; | |
| 110 | let mapped = buffer.get_mapped_range(..)?; | |
| 111 | let pixels: Vec<_> = mapped | |
| 112 | .chunks_exact(row_bytes as usize) | |
| 113 | .flat_map(|row| row[..size[0] as usize * 4].iter().copied()) | |
| 114 | .collect(); | |
| 115 | let mut encoder = png::Encoder::new(fs::File::create_new(destination)?, size[0], size[1]); | |
| 116 | encoder.set_color(png::ColorType::Rgba); | |
| 117 | encoder.set_depth(png::BitDepth::Eight); | |
| 118 | encoder.write_header()?.write_image_data(&pixels)?; | |
| 119 | eprintln!( | |
| 120 | "Rendered {} page objects at 100% / 96 DPI, origin Y {origin_y} px using {:?}", | |
| 121 | object_count, | |
| 122 | adapter.get_info() | |
| 123 | ); | |
| 124 | Ok(()) | |
| 125 | } |
crates/canvas-gpu/src/canvas.wgsl created+19| ... | ... | @@ -0,0 +1,19 @@ |
| 1 | struct Vertex { | |
| 2 | @builtin(position) position: vec4<f32>, | |
| 3 | @location(0) uv: vec2<f32>, | |
| 4 | @location(1) color: vec4<f32>, | |
| 5 | } | |
| 6 | ||
| 7 | @group(0) @binding(0) var atlas: texture_2d<f32>; | |
| 8 | @group(0) @binding(1) var atlas_sampler: sampler; | |
| 9 | ||
| 10 | @vertex | |
| 11 | fn vertex(@location(0) position: vec2<f32>, @location(1) uv: vec2<f32>, | |
| 12 | @location(2) color: vec4<f32>) -> Vertex { | |
| 13 | return Vertex(vec4<f32>(position, 0.0, 1.0), uv, color); | |
| 14 | } | |
| 15 | ||
| 16 | @fragment | |
| 17 | fn fragment(input: Vertex) -> @location(0) vec4<f32> { | |
| 18 | return textureSample(atlas, atlas_sampler, input.uv) * input.color; | |
| 19 | } |
crates/canvas-gpu/src/lib.rs created+1346| ... | ... | @@ -0,0 +1,1346 @@ |
| 1 | pub mod page; | |
| 2 | #[cfg(test)] | |
| 3 | mod profile; | |
| 4 | mod tags; | |
| 5 | ||
| 6 | use bytemuck::{Pod, Zeroable}; | |
| 7 | use image::ImageDecoder; | |
| 8 | use one_canvas::{ | |
| 9 | layout::TextLayout, | |
| 10 | outline::{ParagraphTag, TagIcon}, | |
| 11 | }; | |
| 12 | use parley::{PositionedLayoutItem, fontique::Blob}; | |
| 13 | use std::{ | |
| 14 | collections::{HashMap, HashSet}, | |
| 15 | ops::Range, | |
| 16 | }; | |
| 17 | use swash::{ | |
| 18 | FontRef, | |
| 19 | scale::{Render, ScaleContext, Source, StrikeWith, image::Content}, | |
| 20 | zeno::{Angle, Format, Transform, Vector}, | |
| 21 | }; | |
| 22 | ||
| 23 | const ATLAS_SIZE: u32 = 2048; | |
| 24 | const MAX_GLYPHS: usize = 8192; | |
| 25 | const MAX_VERTICES: usize = 65_536; | |
| 26 | const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; | |
| 27 | const MAX_IMAGES: usize = 256; | |
| 28 | ||
| 29 | #[derive(Clone)] | |
| 30 | pub struct RasterImage { | |
| 31 | size: [u32; 2], | |
| 32 | pixels: Blob<u8>, | |
| 33 | } | |
| 34 | ||
| 35 | impl RasterImage { | |
| 36 | pub fn decode(encoded: &[u8]) -> Result<Self, RenderError> { | |
| 37 | if encoded.len() as u64 > MAX_IMAGE_BYTES { | |
| 38 | return Err(RenderError::ImageBudget); | |
| 39 | } | |
| 40 | let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; | |
| 41 | let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); | |
| 42 | let mut limits = image::Limits::default(); | |
| 43 | limits.max_image_width = Some(16_384); | |
| 44 | limits.max_image_height = Some(16_384); | |
| 45 | limits.max_alloc = Some(MAX_IMAGE_BYTES); | |
| 46 | reader.limits(limits.clone()); | |
| 47 | let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; | |
| 48 | let (width, height) = decoder.dimensions(); | |
| 49 | if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES | |
| 50 | || decoder.total_bytes() > MAX_IMAGE_BYTES | |
| 51 | { | |
| 52 | return Err(RenderError::ImageBudget); | |
| 53 | } | |
| 54 | limits | |
| 55 | .reserve(decoder.total_bytes()) | |
| 56 | .map_err(RenderError::ImageDecode)?; | |
| 57 | decoder | |
| 58 | .set_limits(limits) | |
| 59 | .map_err(RenderError::ImageDecode)?; | |
| 60 | let pixels = image::DynamicImage::from_decoder(decoder) | |
| 61 | .map_err(RenderError::ImageDecode)? | |
| 62 | .into_rgba8(); | |
| 63 | Self::new([width, height], pixels.into_raw()) | |
| 64 | } | |
| 65 | ||
| 66 | /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. | |
| 67 | pub fn new(size: [u32; 2], mut pixels: Vec<u8>) -> Result<Self, RenderError> { | |
| 68 | let bytes = u64::from(size[0]) | |
| 69 | .checked_mul(u64::from(size[1])) | |
| 70 | .and_then(|v| v.checked_mul(4)) | |
| 71 | .ok_or(RenderError::InvalidImage)?; | |
| 72 | if size.contains(&0) || bytes != pixels.len() as u64 { | |
| 73 | return Err(RenderError::InvalidImage); | |
| 74 | } | |
| 75 | if bytes > MAX_IMAGE_BYTES { | |
| 76 | return Err(RenderError::ImageBudget); | |
| 77 | } | |
| 78 | // Premultiply in linear light before filtering; transparent RGB must not bleed at edges. | |
| 79 | for pixel in pixels.chunks_exact_mut(4) { | |
| 80 | let alpha = pixel[3]; | |
| 81 | if alpha == 255 { | |
| 82 | continue; | |
| 83 | } | |
| 84 | let linear = colorref(u32::from_le_bytes(pixel.try_into().unwrap())); | |
| 85 | for (byte, value) in pixel[..3].iter_mut().zip(linear) { | |
| 86 | let value = value * f32::from(alpha) / 255.0; | |
| 87 | *byte = srgb_byte(value); | |
| 88 | } | |
| 89 | } | |
| 90 | Ok(Self { | |
| 91 | size, | |
| 92 | pixels: pixels.into(), | |
| 93 | }) | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | struct CachedImage { | |
| 98 | binding: wgpu::BindGroup, | |
| 99 | bytes: u64, | |
| 100 | } | |
| 101 | ||
| 102 | struct Batch { | |
| 103 | vertices: Range<u32>, | |
| 104 | image: Option<u64>, | |
| 105 | } | |
| 106 | ||
| 107 | #[repr(C)] | |
| 108 | #[derive(Clone, Copy, Pod, Zeroable)] | |
| 109 | struct Vertex { | |
| 110 | position: [f32; 2], | |
| 111 | uv: [f32; 2], | |
| 112 | color: [f32; 4], | |
| 113 | } | |
| 114 | ||
| 115 | #[derive(Hash, PartialEq, Eq)] | |
| 116 | enum AtlasKey { | |
| 117 | Text { | |
| 118 | font: u64, | |
| 119 | index: u32, | |
| 120 | glyph: u16, | |
| 121 | size: u32, | |
| 122 | coords: Vec<i16>, | |
| 123 | phase: [u8; 2], | |
| 124 | embolden: bool, | |
| 125 | skew: u32, | |
| 126 | }, | |
| 127 | Tag { | |
| 128 | icon: TagIcon, | |
| 129 | size: u32, | |
| 130 | phase: [u8; 2], | |
| 131 | }, | |
| 132 | } | |
| 133 | ||
| 134 | #[derive(Clone, Copy)] | |
| 135 | struct AtlasGlyph { | |
| 136 | x: u32, | |
| 137 | y: u32, | |
| 138 | width: u32, | |
| 139 | height: u32, | |
| 140 | left: i32, | |
| 141 | top: i32, | |
| 142 | color: bool, | |
| 143 | } | |
| 144 | ||
| 145 | #[derive(Clone, Copy)] | |
| 146 | pub struct Viewport { | |
| 147 | pub size: [u32; 2], | |
| 148 | /// Physical pixels per document point. | |
| 149 | pub scale: f32, | |
| 150 | /// Physical pixel position of the document origin. | |
| 151 | pub origin: [f32; 2], | |
| 152 | } | |
| 153 | ||
| 154 | impl Viewport { | |
| 155 | pub fn document_point(&self, point: [f32; 2]) -> [f32; 2] { | |
| 156 | [ | |
| 157 | (point[0] - self.origin[0]) / self.scale, | |
| 158 | (point[1] - self.origin[1]) / self.scale, | |
| 159 | ] | |
| 160 | } | |
| 161 | ||
| 162 | fn visible_image_rect(&self, rect: [f32; 4]) -> Result<Option<[f32; 4]>, RenderError> { | |
| 163 | let rect = [ | |
| 164 | rect[0] * self.scale + self.origin[0], | |
| 165 | rect[1] * self.scale + self.origin[1], | |
| 166 | rect[2] * self.scale + self.origin[0], | |
| 167 | rect[3] * self.scale + self.origin[1], | |
| 168 | ]; | |
| 169 | if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { | |
| 170 | return Err(RenderError::InvalidPrimitive); | |
| 171 | } | |
| 172 | Ok((rect[0] < self.size[0] as f32 | |
| 173 | && rect[1] < self.size[1] as f32 | |
| 174 | && rect[2] > 0.0 | |
| 175 | && rect[3] > 0.0 | |
| 176 | && rect[0] < rect[2] | |
| 177 | && rect[1] < rect[3]) | |
| 178 | .then_some(rect)) | |
| 179 | } | |
| 180 | } | |
| 181 | ||
| 182 | /// Coordinates are document points; colors are linear RGBA. | |
| 183 | pub enum Primitive<'a> { | |
| 184 | Tag { | |
| 185 | tag: &'a ParagraphTag, | |
| 186 | origin: [f32; 2], | |
| 187 | }, | |
| 188 | Text { | |
| 189 | layout: &'a TextLayout, | |
| 190 | origin: [f32; 2], | |
| 191 | }, | |
| 192 | Rect { | |
| 193 | rect: [f32; 4], | |
| 194 | color: [f32; 4], | |
| 195 | }, | |
| 196 | Image { | |
| 197 | image: &'a RasterImage, | |
| 198 | rect: [f32; 4], | |
| 199 | }, | |
| 200 | } | |
| 201 | ||
| 202 | #[derive(Debug)] | |
| 203 | pub enum RenderError { | |
| 204 | AtlasFull, | |
| 205 | FrameTooLarge, | |
| 206 | UnreadableFont, | |
| 207 | UnsupportedGlyph, | |
| 208 | InvalidViewport, | |
| 209 | InvalidPrimitive, | |
| 210 | InvalidImage, | |
| 211 | ImageBudget, | |
| 212 | ImageTooLarge, | |
| 213 | ImageDecode(image::ImageError), | |
| 214 | } | |
| 215 | ||
| 216 | pub struct Renderer { | |
| 217 | pub device: wgpu::Device, | |
| 218 | pub queue: wgpu::Queue, | |
| 219 | pipeline: wgpu::RenderPipeline, | |
| 220 | image_pipeline: wgpu::RenderPipeline, | |
| 221 | atlas: wgpu::Texture, | |
| 222 | bind_group: wgpu::BindGroup, | |
| 223 | image_sampler: wgpu::Sampler, | |
| 224 | vertex_buffer: wgpu::Buffer, | |
| 225 | vertices: Vec<Vertex>, | |
| 226 | glyphs: HashMap<AtlasKey, Option<AtlasGlyph>>, | |
| 227 | images: HashMap<u64, CachedImage>, | |
| 228 | batches: Vec<Batch>, | |
| 229 | scaler: ScaleContext, | |
| 230 | pen: [u32; 2], | |
| 231 | row_height: u32, | |
| 232 | } | |
| 233 | ||
| 234 | impl Renderer { | |
| 235 | pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { | |
| 236 | let shader = device.create_shader_module(wgpu::include_wgsl!("canvas.wgsl")); | |
| 237 | let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { | |
| 238 | label: Some("Canvas texture"), | |
| 239 | entries: &[ | |
| 240 | wgpu::BindGroupLayoutEntry { | |
| 241 | binding: 0, | |
| 242 | visibility: wgpu::ShaderStages::FRAGMENT, | |
| 243 | ty: wgpu::BindingType::Texture { | |
| 244 | sample_type: wgpu::TextureSampleType::Float { filterable: true }, | |
| 245 | view_dimension: wgpu::TextureViewDimension::D2, | |
| 246 | multisampled: false, | |
| 247 | }, | |
| 248 | count: None, | |
| 249 | }, | |
| 250 | wgpu::BindGroupLayoutEntry { | |
| 251 | binding: 1, | |
| 252 | visibility: wgpu::ShaderStages::FRAGMENT, | |
| 253 | ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), | |
| 254 | count: None, | |
| 255 | }, | |
| 256 | ], | |
| 257 | }); | |
| 258 | let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { | |
| 259 | label: Some("Canvas"), | |
| 260 | bind_group_layouts: &[Some(&binding_layout)], | |
| 261 | ..Default::default() | |
| 262 | }); | |
| 263 | let make_pipeline = |blend| { | |
| 264 | device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { | |
| 265 | label: Some("Canvas"), | |
| 266 | layout: Some(&pipeline_layout), | |
| 267 | vertex: wgpu::VertexState { | |
| 268 | module: &shader, | |
| 269 | entry_point: Some("vertex"), | |
| 270 | compilation_options: Default::default(), | |
| 271 | buffers: &[Some(wgpu::VertexBufferLayout { | |
| 272 | array_stride: size_of::<Vertex>() as u64, | |
| 273 | step_mode: wgpu::VertexStepMode::Vertex, | |
| 274 | attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4], | |
| 275 | })], | |
| 276 | }, | |
| 277 | fragment: Some(wgpu::FragmentState { | |
| 278 | module: &shader, | |
| 279 | entry_point: Some("fragment"), | |
| 280 | compilation_options: Default::default(), | |
| 281 | targets: &[Some(wgpu::ColorTargetState { | |
| 282 | format, | |
| 283 | blend: Some(blend), | |
| 284 | write_mask: wgpu::ColorWrites::ALL, | |
| 285 | })], | |
| 286 | }), | |
| 287 | primitive: Default::default(), | |
| 288 | depth_stencil: None, | |
| 289 | multisample: Default::default(), | |
| 290 | multiview_mask: None, | |
| 291 | cache: None, | |
| 292 | }) | |
| 293 | }; | |
| 294 | let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING); | |
| 295 | let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); | |
| 296 | let atlas = device.create_texture(&wgpu::TextureDescriptor { | |
| 297 | label: Some("Glyph atlas"), | |
| 298 | size: wgpu::Extent3d { | |
| 299 | width: ATLAS_SIZE, | |
| 300 | height: ATLAS_SIZE, | |
| 301 | depth_or_array_layers: 1, | |
| 302 | }, | |
| 303 | mip_level_count: 1, | |
| 304 | sample_count: 1, | |
| 305 | dimension: wgpu::TextureDimension::D2, | |
| 306 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 307 | usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | |
| 308 | view_formats: &[], | |
| 309 | }); | |
| 310 | let view = atlas.create_view(&Default::default()); | |
| 311 | let sampler = device.create_sampler(&wgpu::SamplerDescriptor { | |
| 312 | mag_filter: wgpu::FilterMode::Nearest, | |
| 313 | min_filter: wgpu::FilterMode::Nearest, | |
| 314 | ..Default::default() | |
| 315 | }); | |
| 316 | let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 317 | label: Some("Glyph atlas"), | |
| 318 | layout: &pipeline.get_bind_group_layout(0), | |
| 319 | entries: &[ | |
| 320 | wgpu::BindGroupEntry { | |
| 321 | binding: 0, | |
| 322 | resource: wgpu::BindingResource::TextureView(&view), | |
| 323 | }, | |
| 324 | wgpu::BindGroupEntry { | |
| 325 | binding: 1, | |
| 326 | resource: wgpu::BindingResource::Sampler(&sampler), | |
| 327 | }, | |
| 328 | ], | |
| 329 | }); | |
| 330 | let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { | |
| 331 | label: Some("Canvas vertices"), | |
| 332 | size: (MAX_VERTICES * size_of::<Vertex>()) as u64, | |
| 333 | usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, | |
| 334 | mapped_at_creation: false, | |
| 335 | }); | |
| 336 | let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { | |
| 337 | label: Some("Canvas image filtering"), | |
| 338 | mag_filter: wgpu::FilterMode::Linear, | |
| 339 | min_filter: wgpu::FilterMode::Linear, | |
| 340 | ..Default::default() | |
| 341 | }); | |
| 342 | queue.write_texture( | |
| 343 | atlas.as_image_copy(), | |
| 344 | &[255; 4], | |
| 345 | wgpu::TexelCopyBufferLayout { | |
| 346 | offset: 0, | |
| 347 | bytes_per_row: Some(4), | |
| 348 | rows_per_image: Some(1), | |
| 349 | }, | |
| 350 | wgpu::Extent3d { | |
| 351 | width: 1, | |
| 352 | height: 1, | |
| 353 | depth_or_array_layers: 1, | |
| 354 | }, | |
| 355 | ); | |
| 356 | Self { | |
| 357 | device, | |
| 358 | queue, | |
| 359 | pipeline, | |
| 360 | image_pipeline, | |
| 361 | atlas, | |
| 362 | bind_group, | |
| 363 | image_sampler, | |
| 364 | vertex_buffer, | |
| 365 | vertices: Vec::new(), | |
| 366 | glyphs: HashMap::new(), | |
| 367 | images: HashMap::new(), | |
| 368 | batches: Vec::new(), | |
| 369 | scaler: ScaleContext::with_max_entries(32), | |
| 370 | pen: [1, 0], | |
| 371 | row_height: 1, | |
| 372 | } | |
| 373 | } | |
| 374 | ||
| 375 | pub fn clear_glyph_cache(&mut self) { | |
| 376 | self.glyphs.clear(); | |
| 377 | self.pen = [1, 0]; | |
| 378 | self.row_height = 1; | |
| 379 | } | |
| 380 | ||
| 381 | pub fn draw( | |
| 382 | &mut self, | |
| 383 | target: &wgpu::TextureView, | |
| 384 | viewport: Viewport, | |
| 385 | primitives: &[Primitive<'_>], | |
| 386 | ) -> Result<(), RenderError> { | |
| 387 | if viewport.size.contains(&0) | |
| 388 | || !viewport.scale.is_finite() | |
| 389 | || viewport.scale <= 0.0 | |
| 390 | || viewport.origin.iter().any(|v| !v.is_finite()) | |
| 391 | { | |
| 392 | return Err(RenderError::InvalidViewport); | |
| 393 | } | |
| 394 | let mut active_images = HashSet::new(); | |
| 395 | let mut image_bytes = 0; | |
| 396 | for primitive in primitives { | |
| 397 | if let Primitive::Image { image, rect } = primitive | |
| 398 | && viewport.visible_image_rect(*rect)?.is_some() | |
| 399 | && active_images.insert(image.pixels.id()) | |
| 400 | { | |
| 401 | image_bytes += image.pixels.as_ref().len() as u64; | |
| 402 | if image_bytes > MAX_IMAGE_BYTES || active_images.len() > MAX_IMAGES { | |
| 403 | return Err(RenderError::ImageBudget); | |
| 404 | } | |
| 405 | } | |
| 406 | } | |
| 407 | for attempt in 0..2 { | |
| 408 | self.vertices.clear(); | |
| 409 | self.batches.clear(); | |
| 410 | match self.prepare(viewport, primitives, &active_images) { | |
| 411 | Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), | |
| 412 | result => { | |
| 413 | result?; | |
| 414 | break; | |
| 415 | } | |
| 416 | } | |
| 417 | } | |
| 418 | self.queue | |
| 419 | .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); | |
| 420 | let mut encoder = self.device.create_command_encoder(&Default::default()); | |
| 421 | { | |
| 422 | let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { | |
| 423 | label: Some("Canvas"), | |
| 424 | color_attachments: &[Some(wgpu::RenderPassColorAttachment { | |
| 425 | view: target, | |
| 426 | depth_slice: None, | |
| 427 | resolve_target: None, | |
| 428 | ops: wgpu::Operations { | |
| 429 | load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), | |
| 430 | store: wgpu::StoreOp::Store, | |
| 431 | }, | |
| 432 | })], | |
| 433 | ..Default::default() | |
| 434 | }); | |
| 435 | pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); | |
| 436 | for batch in &self.batches { | |
| 437 | pass.set_pipeline(if batch.image.is_some() { | |
| 438 | &self.image_pipeline | |
| 439 | } else { | |
| 440 | &self.pipeline | |
| 441 | }); | |
| 442 | let binding = batch | |
| 443 | .image | |
| 444 | .map(|id| &self.images[&id].binding) | |
| 445 | .unwrap_or(&self.bind_group); | |
| 446 | pass.set_bind_group(0, binding, &[]); | |
| 447 | pass.draw(batch.vertices.clone(), 0..1); | |
| 448 | } | |
| 449 | } | |
| 450 | self.queue.submit([encoder.finish()]); | |
| 451 | Ok(()) | |
| 452 | } | |
| 453 | ||
| 454 | fn prepare( | |
| 455 | &mut self, | |
| 456 | viewport: Viewport, | |
| 457 | primitives: &[Primitive<'_>], | |
| 458 | active_images: &HashSet<u64>, | |
| 459 | ) -> Result<(), RenderError> { | |
| 460 | for primitive in primitives { | |
| 461 | let start = self.vertices.len() as u32; | |
| 462 | let mut image_id = None; | |
| 463 | match primitive { | |
| 464 | Primitive::Tag { tag, origin } => { | |
| 465 | let origin = [ | |
| 466 | viewport.origin[0] + (origin[0] + tag.origin[0]) * viewport.scale, | |
| 467 | viewport.origin[1] + (origin[1] + tag.origin[1]) * viewport.scale, | |
| 468 | ]; | |
| 469 | self.tag(Viewport { origin, ..viewport }, tag)?; | |
| 470 | } | |
| 471 | Primitive::Text { layout, origin } => { | |
| 472 | let origin = [ | |
| 473 | viewport.origin[0] + origin[0] * viewport.scale, | |
| 474 | viewport.origin[1] + origin[1] * viewport.scale, | |
| 475 | ]; | |
| 476 | if origin.iter().any(|v| !v.is_finite()) { | |
| 477 | return Err(RenderError::InvalidPrimitive); | |
| 478 | } | |
| 479 | self.text(Viewport { origin, ..viewport }, layout)?; | |
| 480 | } | |
| 481 | Primitive::Rect { rect, color } => { | |
| 482 | self.quad( | |
| 483 | viewport, | |
| 484 | [ | |
| 485 | rect[0] * viewport.scale + viewport.origin[0], | |
| 486 | rect[1] * viewport.scale + viewport.origin[1], | |
| 487 | rect[2] * viewport.scale + viewport.origin[0], | |
| 488 | rect[3] * viewport.scale + viewport.origin[1], | |
| 489 | ], | |
| 490 | [0.5 / ATLAS_SIZE as f32; 4], | |
| 491 | *color, | |
| 492 | )?; | |
| 493 | } | |
| 494 | Primitive::Image { image, rect } => { | |
| 495 | if let Some(rect) = viewport.visible_image_rect(*rect)? { | |
| 496 | self.image(image, active_images)?; | |
| 497 | image_id = Some(image.pixels.id()); | |
| 498 | self.quad(viewport, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?; | |
| 499 | } | |
| 500 | } | |
| 501 | } | |
| 502 | let end = self.vertices.len() as u32; | |
| 503 | if start != end { | |
| 504 | if let Some(last) = self.batches.last_mut() | |
| 505 | && last.image == image_id | |
| 506 | { | |
| 507 | last.vertices.end = end; | |
| 508 | } else { | |
| 509 | self.batches.push(Batch { | |
| 510 | vertices: start..end, | |
| 511 | image: image_id, | |
| 512 | }); | |
| 513 | } | |
| 514 | } | |
| 515 | } | |
| 516 | Ok(()) | |
| 517 | } | |
| 518 | ||
| 519 | fn image(&mut self, image: &RasterImage, active: &HashSet<u64>) -> Result<(), RenderError> { | |
| 520 | if self.images.contains_key(&image.pixels.id()) { | |
| 521 | return Ok(()); | |
| 522 | } | |
| 523 | if image | |
| 524 | .size | |
| 525 | .iter() | |
| 526 | .any(|v| *v > self.device.limits().max_texture_dimension_2d) | |
| 527 | { | |
| 528 | return Err(RenderError::ImageTooLarge); | |
| 529 | } | |
| 530 | let bytes = image.pixels.as_ref().len() as u64; | |
| 531 | while self.images.values().map(|i| i.bytes).sum::<u64>() + bytes > MAX_IMAGE_BYTES | |
| 532 | || self.images.len() >= MAX_IMAGES | |
| 533 | { | |
| 534 | let id = self | |
| 535 | .images | |
| 536 | .keys() | |
| 537 | .find(|id| !active.contains(id)) | |
| 538 | .copied() | |
| 539 | .ok_or(RenderError::ImageBudget)?; | |
| 540 | self.images.remove(&id); | |
| 541 | } | |
| 542 | let size = wgpu::Extent3d { | |
| 543 | width: image.size[0], | |
| 544 | height: image.size[1], | |
| 545 | depth_or_array_layers: 1, | |
| 546 | }; | |
| 547 | let texture = self.device.create_texture(&wgpu::TextureDescriptor { | |
| 548 | label: Some("Canvas image"), | |
| 549 | size, | |
| 550 | mip_level_count: 1, | |
| 551 | sample_count: 1, | |
| 552 | dimension: wgpu::TextureDimension::D2, | |
| 553 | format: wgpu::TextureFormat::Rgba8UnormSrgb, | |
| 554 | usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, | |
| 555 | view_formats: &[], | |
| 556 | }); | |
| 557 | self.queue.write_texture( | |
| 558 | texture.as_image_copy(), | |
| 559 | image.pixels.as_ref(), | |
| 560 | wgpu::TexelCopyBufferLayout { | |
| 561 | offset: 0, | |
| 562 | bytes_per_row: Some(image.size[0] * 4), | |
| 563 | rows_per_image: Some(image.size[1]), | |
| 564 | }, | |
| 565 | size, | |
| 566 | ); | |
| 567 | let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { | |
| 568 | label: Some("Canvas image"), | |
| 569 | layout: &self.pipeline.get_bind_group_layout(0), | |
| 570 | entries: &[ | |
| 571 | wgpu::BindGroupEntry { | |
| 572 | binding: 0, | |
| 573 | resource: wgpu::BindingResource::TextureView( | |
| 574 | &texture.create_view(&Default::default()), | |
| 575 | ), | |
| 576 | }, | |
| 577 | wgpu::BindGroupEntry { | |
| 578 | binding: 1, | |
| 579 | resource: wgpu::BindingResource::Sampler(&self.image_sampler), | |
| 580 | }, | |
| 581 | ], | |
| 582 | }); | |
| 583 | self.images | |
| 584 | .insert(image.pixels.id(), CachedImage { binding, bytes }); | |
| 585 | Ok(()) | |
| 586 | } | |
| 587 | ||
| 588 | fn text(&mut self, viewport: Viewport, layout: &TextLayout) -> Result<(), RenderError> { | |
| 589 | let scale = viewport.scale; | |
| 590 | let origin = viewport.origin; | |
| 591 | for (line, line_box) in layout.lines() { | |
| 592 | let top = line_box.top * scale + origin[1]; | |
| 593 | if top + line_box.height * scale < 0.0 || top > viewport.size[1] as f32 { | |
| 594 | continue; | |
| 595 | } | |
| 596 | for item in line.items() { | |
| 597 | let PositionedLayoutItem::GlyphRun(glyph_run) = item else { | |
| 598 | continue; | |
| 599 | }; | |
| 600 | let run = glyph_run.run(); | |
| 601 | let data = &run.font().font; | |
| 602 | let size = run.font_size() * scale; | |
| 603 | if !size.is_finite() || size > ATLAS_SIZE as f32 { | |
| 604 | return Err(RenderError::AtlasFull); | |
| 605 | } | |
| 606 | let synthesis = run.synthesis(); | |
| 607 | let coords: Vec<i16> = run | |
| 608 | .normalized_coords() | |
| 609 | .iter() | |
| 610 | .map(|c| c.to_bits()) | |
| 611 | .collect(); | |
| 612 | let color = glyph_run.style().brush.color; | |
| 613 | for glyph in glyph_run.positioned_glyphs() { | |
| 614 | let x = glyph.x * scale + origin[0]; | |
| 615 | let y = | |
| 616 | (glyph.y + line_box.baseline - line.metrics().baseline) * scale + origin[1]; | |
| 617 | // Quantization affects raster coverage only, never advances or line breaks. | |
| 618 | let x = (x * 4.0).round() * 0.25; | |
| 619 | let y = (y * 4.0).round() * 0.25; | |
| 620 | let glyph_id = glyph | |
| 621 | .id | |
| 622 | .try_into() | |
| 623 | .map_err(|_| RenderError::UnsupportedGlyph)?; | |
| 624 | let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; | |
| 625 | let key = AtlasKey::Text { | |
| 626 | font: data.data.id(), | |
| 627 | index: data.index, | |
| 628 | glyph: glyph_id, | |
| 629 | size: size.to_bits(), | |
| 630 | coords: coords.clone(), | |
| 631 | phase, | |
| 632 | embolden: synthesis.embolden(), | |
| 633 | skew: synthesis.skew().unwrap_or(0.0).to_bits(), | |
| 634 | }; | |
| 635 | let cached = if let Some(cached) = self.glyphs.get(&key) { | |
| 636 | *cached | |
| 637 | } else { | |
| 638 | if self.glyphs.len() >= MAX_GLYPHS { | |
| 639 | return Err(RenderError::AtlasFull); | |
| 640 | } | |
| 641 | let font = FontRef::from_index(data.data.as_ref(), data.index as usize) | |
| 642 | .ok_or(RenderError::UnreadableFont)?; | |
| 643 | let mut scaler = self | |
| 644 | .scaler | |
| 645 | .builder_with_id(font, [data.data.id(), u64::from(data.index)]) | |
| 646 | .size(size) | |
| 647 | .hint(false) | |
| 648 | .normalized_coords(coords.iter().copied()) | |
| 649 | .build(); | |
| 650 | let mut render = Render::new(&[ | |
| 651 | Source::ColorOutline(0), | |
| 652 | Source::ColorBitmap(StrikeWith::BestFit), | |
| 653 | Source::Outline, | |
| 654 | ]); | |
| 655 | // Swash rasterizes outlines in Y-up coordinates. | |
| 656 | render.format(Format::Alpha).offset(Vector::new( | |
| 657 | f32::from(phase[0]) * 0.25, | |
| 658 | -f32::from(phase[1]) * 0.25, | |
| 659 | )); | |
| 660 | if synthesis.embolden() { | |
| 661 | render.embolden(size / 24.0); | |
| 662 | } | |
| 663 | if let Some(skew) = synthesis.skew() { | |
| 664 | render.transform(Some(Transform::skew( | |
| 665 | Angle::from_degrees(skew), | |
| 666 | Angle::from_degrees(0.0), | |
| 667 | ))); | |
| 668 | } | |
| 669 | let image = render.render(&mut scaler, glyph_id); | |
| 670 | let cached = if let Some(image) = | |
| 671 | image.filter(|i| i.placement.width > 0 && i.placement.height > 0) | |
| 672 | { | |
| 673 | Some(self.upload(image)?) | |
| 674 | } else { | |
| 675 | None | |
| 676 | }; | |
| 677 | self.glyphs.insert(key, cached); | |
| 678 | cached | |
| 679 | }; | |
| 680 | if let Some(glyph) = cached { | |
| 681 | let left = x.floor() + glyph.left as f32; | |
| 682 | let top = y.floor() - glyph.top as f32; | |
| 683 | let atlas_size = ATLAS_SIZE as f32; | |
| 684 | self.quad( | |
| 685 | viewport, | |
| 686 | [ | |
| 687 | left, | |
| 688 | top, | |
| 689 | left + glyph.width as f32, | |
| 690 | top + glyph.height as f32, | |
| 691 | ], | |
| 692 | [ | |
| 693 | glyph.x as f32 / atlas_size, | |
| 694 | glyph.y as f32 / atlas_size, | |
| 695 | (glyph.x + glyph.width) as f32 / atlas_size, | |
| 696 | (glyph.y + glyph.height) as f32 / atlas_size, | |
| 697 | ], | |
| 698 | if glyph.color { | |
| 699 | [1.0; 4] | |
| 700 | } else { | |
| 701 | colorref(color) | |
| 702 | }, | |
| 703 | )?; | |
| 704 | } | |
| 705 | } | |
| 706 | let metrics = run.font_metrics(); | |
| 707 | for (decoration, offset, thickness) in [ | |
| 708 | ( | |
| 709 | &glyph_run.style().underline, | |
| 710 | metrics.underline_offset, | |
| 711 | metrics.underline_size, | |
| 712 | ), | |
| 713 | ( | |
| 714 | &glyph_run.style().strikethrough, | |
| 715 | metrics.strikethrough_offset, | |
| 716 | metrics.strikethrough_size, | |
| 717 | ), | |
| 718 | ] { | |
| 719 | if let Some(decoration) = decoration { | |
| 720 | let x = glyph_run.offset() * scale + origin[0]; | |
| 721 | let y = (line_box.baseline - decoration.offset.unwrap_or(offset)) * scale | |
| 722 | + origin[1]; | |
| 723 | self.quad( | |
| 724 | viewport, | |
| 725 | [ | |
| 726 | x, | |
| 727 | y, | |
| 728 | x + glyph_run.advance() * scale, | |
| 729 | y + (decoration.size.unwrap_or(thickness) * scale).max(1.0), | |
| 730 | ], | |
| 731 | [0.5 / ATLAS_SIZE as f32; 4], | |
| 732 | colorref(decoration.brush.color), | |
| 733 | )?; | |
| 734 | } | |
| 735 | } | |
| 736 | } | |
| 737 | } | |
| 738 | Ok(()) | |
| 739 | } | |
| 740 | ||
| 741 | fn upload(&mut self, image: swash::scale::image::Image) -> Result<AtlasGlyph, RenderError> { | |
| 742 | let p = image.placement; | |
| 743 | if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE { | |
| 744 | return Err(RenderError::AtlasFull); | |
| 745 | } | |
| 746 | if self.pen[0] + p.width + 1 > ATLAS_SIZE { | |
| 747 | self.pen[0] = 0; | |
| 748 | self.pen[1] += self.row_height + 1; | |
| 749 | self.row_height = 0; | |
| 750 | } | |
| 751 | if self.pen[1] + p.height + 1 > ATLAS_SIZE { | |
| 752 | return Err(RenderError::AtlasFull); | |
| 753 | } | |
| 754 | let cached = AtlasGlyph { | |
| 755 | x: self.pen[0], | |
| 756 | y: self.pen[1], | |
| 757 | width: p.width, | |
| 758 | height: p.height, | |
| 759 | left: p.left, | |
| 760 | top: p.top, | |
| 761 | color: image.content == Content::Color, | |
| 762 | }; | |
| 763 | let rgba = match image.content { | |
| 764 | Content::Mask => image | |
| 765 | .data | |
| 766 | .iter() | |
| 767 | .flat_map(|a| [255, 255, 255, *a]) | |
| 768 | .collect(), | |
| 769 | _ => image.data, | |
| 770 | }; | |
| 771 | self.queue.write_texture( | |
| 772 | wgpu::TexelCopyTextureInfo { | |
| 773 | origin: wgpu::Origin3d { | |
| 774 | x: cached.x, | |
| 775 | y: cached.y, | |
| 776 | z: 0, | |
| 777 | }, | |
| 778 | ..self.atlas.as_image_copy() | |
| 779 | }, | |
| 780 | &rgba, | |
| 781 | wgpu::TexelCopyBufferLayout { | |
| 782 | offset: 0, | |
| 783 | bytes_per_row: Some(p.width * 4), | |
| 784 | rows_per_image: Some(p.height), | |
| 785 | }, | |
| 786 | wgpu::Extent3d { | |
| 787 | width: p.width, | |
| 788 | height: p.height, | |
| 789 | depth_or_array_layers: 1, | |
| 790 | }, | |
| 791 | ); | |
| 792 | self.pen[0] += p.width + 1; | |
| 793 | self.row_height = self.row_height.max(p.height); | |
| 794 | Ok(cached) | |
| 795 | } | |
| 796 | ||
| 797 | fn tag(&mut self, viewport: Viewport, tag: &ParagraphTag) -> Result<(), RenderError> { | |
| 798 | let size = ParagraphTag::SIZE * viewport.scale; | |
| 799 | let [x, y] = viewport.origin.map(|v| (v * 4.0).round() * 0.25); | |
| 800 | if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { | |
| 801 | return Err(RenderError::InvalidPrimitive); | |
| 802 | } | |
| 803 | if x + size + 1.0 < 0.0 | |
| 804 | || y + size + 1.0 < 0.0 | |
| 805 | || x - 1.0 > viewport.size[0] as f32 | |
| 806 | || y - 1.0 > viewport.size[1] as f32 | |
| 807 | { | |
| 808 | return Ok(()); | |
| 809 | } | |
| 810 | if size + 3.0 > ATLAS_SIZE as f32 { | |
| 811 | return Err(RenderError::AtlasFull); | |
| 812 | } | |
| 813 | let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; | |
| 814 | let key = AtlasKey::Tag { | |
| 815 | icon: tag.icon, | |
| 816 | size: size.to_bits(), | |
| 817 | phase, | |
| 818 | }; | |
| 819 | let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { | |
| 820 | *glyph | |
| 821 | } else { | |
| 822 | if self.glyphs.len() >= MAX_GLYPHS { | |
| 823 | return Err(RenderError::AtlasFull); | |
| 824 | } | |
| 825 | let glyph = self.upload(tags::rasterize(tag.icon, size, phase))?; | |
| 826 | self.glyphs.insert(key, Some(glyph)); | |
| 827 | glyph | |
| 828 | }; | |
| 829 | let left = x.floor(); | |
| 830 | let top = y.floor(); | |
| 831 | let atlas = ATLAS_SIZE as f32; | |
| 832 | self.quad( | |
| 833 | viewport, | |
| 834 | [ | |
| 835 | left, | |
| 836 | top, | |
| 837 | left + glyph.width as f32, | |
| 838 | top + glyph.height as f32, | |
| 839 | ], | |
| 840 | [ | |
| 841 | glyph.x as f32 / atlas, | |
| 842 | glyph.y as f32 / atlas, | |
| 843 | (glyph.x + glyph.width) as f32 / atlas, | |
| 844 | (glyph.y + glyph.height) as f32 / atlas, | |
| 845 | ], | |
| 846 | [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], | |
| 847 | ) | |
| 848 | } | |
| 849 | ||
| 850 | fn quad( | |
| 851 | &mut self, | |
| 852 | viewport: Viewport, | |
| 853 | rect: [f32; 4], | |
| 854 | uv: [f32; 4], | |
| 855 | color: [f32; 4], | |
| 856 | ) -> Result<(), RenderError> { | |
| 857 | if rect.iter().chain(&color).any(|v| !v.is_finite()) | |
| 858 | || rect[2] < rect[0] | |
| 859 | || rect[3] < rect[1] | |
| 860 | { | |
| 861 | return Err(RenderError::InvalidPrimitive); | |
| 862 | } | |
| 863 | if rect[2] < 0.0 | |
| 864 | || rect[3] < 0.0 | |
| 865 | || rect[0] > viewport.size[0] as f32 | |
| 866 | || rect[1] > viewport.size[1] as f32 | |
| 867 | { | |
| 868 | return Ok(()); | |
| 869 | } | |
| 870 | if self.vertices.len() + 6 > MAX_VERTICES { | |
| 871 | return Err(RenderError::FrameTooLarge); | |
| 872 | } | |
| 873 | let x0 = rect[0] * 2.0 / viewport.size[0] as f32 - 1.0; | |
| 874 | let x1 = rect[2] * 2.0 / viewport.size[0] as f32 - 1.0; | |
| 875 | let y0 = 1.0 - rect[1] * 2.0 / viewport.size[1] as f32; | |
| 876 | let y1 = 1.0 - rect[3] * 2.0 / viewport.size[1] as f32; | |
| 877 | if [x0, x1, y0, y1].iter().any(|v| !v.is_finite()) { | |
| 878 | return Err(RenderError::InvalidPrimitive); | |
| 879 | } | |
| 880 | for (position, uv) in [ | |
| 881 | ([x0, y0], [uv[0], uv[1]]), | |
| 882 | ([x0, y1], [uv[0], uv[3]]), | |
| 883 | ([x1, y1], [uv[2], uv[3]]), | |
| 884 | ([x0, y0], [uv[0], uv[1]]), | |
| 885 | ([x1, y1], [uv[2], uv[3]]), | |
| 886 | ([x1, y0], [uv[2], uv[1]]), | |
| 887 | ] { | |
| 888 | self.vertices.push(Vertex { | |
| 889 | position, | |
| 890 | uv, | |
| 891 | color, | |
| 892 | }); | |
| 893 | } | |
| 894 | Ok(()) | |
| 895 | } | |
| 896 | } | |
| 897 | ||
| 898 | fn srgb_byte(value: f32) -> u8 { | |
| 899 | let srgb = if value <= 0.0031308 { | |
| 900 | value * 12.92 | |
| 901 | } else { | |
| 902 | 1.055 * value.powf(1.0 / 2.4) - 0.055 | |
| 903 | }; | |
| 904 | (srgb * 255.0).round() as u8 | |
| 905 | } | |
| 906 | ||
| 907 | pub fn colorref(color: u32) -> [f32; 4] { | |
| 908 | let linear = |byte: u32| { | |
| 909 | let value = (byte & 255) as f32 / 255.0; | |
| 910 | if value <= 0.04045 { | |
| 911 | value / 12.92 | |
| 912 | } else { | |
| 913 | ((value + 0.055) / 1.055).powf(2.4) | |
| 914 | } | |
| 915 | }; | |
| 916 | [linear(color), linear(color >> 8), linear(color >> 16), 1.0] | |
| 917 | } | |
| 918 | ||
| 919 | #[cfg(test)] | |
| 920 | mod tests { | |
| 921 | use super::*; | |
| 922 | use one_canvas::{layout::TextEngine, text::Paragraph}; | |
| 923 | use onestore::document::Format as TextFormat; | |
| 924 | use std::time::Duration; | |
| 925 | ||
| 926 | #[test] | |
| 927 | fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() { | |
| 928 | let mut encoded = Vec::new(); | |
| 929 | let mut encoder = png::Encoder::new(&mut encoded, 2, 1); | |
| 930 | encoder.set_color(png::ColorType::Rgba); | |
| 931 | encoder.set_depth(png::BitDepth::Eight); | |
| 932 | let rgba = [255, 64, 0, 255, 0, 128, 255, 128]; | |
| 933 | encoder | |
| 934 | .write_header() | |
| 935 | .unwrap() | |
| 936 | .write_image_data(&rgba) | |
| 937 | .unwrap(); | |
| 938 | let decoded = RasterImage::decode(&encoded).unwrap(); | |
| 939 | assert_eq!(decoded.size, [2, 1]); | |
| 940 | assert_eq!(decoded.pixels.as_ref(), [255, 64, 0, 255, 0, 93, 188, 128]); | |
| 941 | for end in 0..encoded.len() - 12 { | |
| 942 | assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}"); | |
| 943 | } | |
| 944 | ||
| 945 | let mut jpeg = Vec::new(); | |
| 946 | image::codecs::jpeg::JpegEncoder::new(&mut jpeg) | |
| 947 | .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) | |
| 948 | .unwrap(); | |
| 949 | let decoded = RasterImage::decode(&jpeg).unwrap(); | |
| 950 | assert_eq!(decoded.size, [4, 4]); | |
| 951 | assert_eq!(decoded.pixels.as_ref(), [255; 4 * 4 * 4]); | |
| 952 | ||
| 953 | for size in [[8192, 8192], [16_385, 1]] { | |
| 954 | let mut header = Vec::new(); | |
| 955 | let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); | |
| 956 | encoder.set_color(png::ColorType::Rgba); | |
| 957 | encoder.set_depth(png::BitDepth::Eight); | |
| 958 | let mut writer = encoder.write_header().unwrap(); | |
| 959 | writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); | |
| 960 | drop(writer); | |
| 961 | let error = RasterImage::decode(&header).err().unwrap(); | |
| 962 | if size[0] == 8192 { | |
| 963 | assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); | |
| 964 | } else { | |
| 965 | assert!( | |
| 966 | matches!( | |
| 967 | error, | |
| 968 | RenderError::ImageDecode(image::ImageError::Limits(_)) | |
| 969 | ), | |
| 970 | "{error:?}" | |
| 971 | ); | |
| 972 | } | |
| 973 | } | |
| 974 | } | |
| 975 | ||
| 976 | #[test] | |
| 977 | fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { | |
| 978 | assert!(matches!( | |
| 979 | RasterImage::new([0, 1], Vec::new()), | |
| 980 | Err(RenderError::InvalidImage) | |
| 981 | )); | |
| 982 | assert!(matches!( | |
| 983 | RasterImage::new([2, 2], vec![255; 15]), | |
| 984 | Err(RenderError::InvalidImage) | |
| 985 | )); | |
| 986 | assert!(matches!( | |
| 987 | RasterImage::new([u32::MAX; 2], Vec::new()), | |
| 988 | Err(RenderError::InvalidImage) | |
| 989 | )); | |
| 990 | let image = RasterImage::new([1, 1], vec![1, 2, 3, 255]).unwrap(); | |
| 991 | assert_eq!(image.pixels.id(), image.clone().pixels.id()); | |
| 992 | } | |
| 993 | ||
| 994 | #[test] | |
| 995 | #[ignore = "requires a native GPU adapter"] | |
| 996 | fn eviction_and_repaint_reproduce_pixels() { | |
| 997 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | |
| 998 | let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); | |
| 999 | let (device, queue) = | |
| 1000 | pollster::block_on(adapter.request_device(&Default::default())).unwrap(); | |
| 1001 | let format = wgpu::TextureFormat::Rgba8UnormSrgb; | |
| 1002 | let target = device.create_texture(&wgpu::TextureDescriptor { | |
| 1003 | label: Some("Canvas readback test"), | |
| 1004 | size: wgpu::Extent3d { | |
| 1005 | width: 512, | |
| 1006 | height: 256, | |
| 1007 | depth_or_array_layers: 1, | |
| 1008 | }, | |
| 1009 | mip_level_count: 1, | |
| 1010 | sample_count: 1, | |
| 1011 | dimension: wgpu::TextureDimension::D2, | |
| 1012 | format, | |
| 1013 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, | |
| 1014 | view_formats: &[], | |
| 1015 | }); | |
| 1016 | let readback = device.create_buffer(&wgpu::BufferDescriptor { | |
| 1017 | label: Some("Canvas readback"), | |
| 1018 | size: 512 * 256 * 4, | |
| 1019 | usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, | |
| 1020 | mapped_at_creation: false, | |
| 1021 | }); | |
| 1022 | let mut renderer = Renderer::new(device, queue, format); | |
| 1023 | let mut engine = TextEngine::default(); | |
| 1024 | let layout = engine | |
| 1025 | .layout( | |
| 1026 | &Paragraph::new( | |
| 1027 | "Hello 🌳\nCafé e\u{301} שלום".into(), | |
| 1028 | TextFormat { | |
| 1029 | underline: Some(true), | |
| 1030 | ..TextFormat::default() | |
| 1031 | }, | |
| 1032 | ), | |
| 1033 | 220.0, | |
| 1034 | ) | |
| 1035 | .unwrap(); | |
| 1036 | let viewport = Viewport { | |
| 1037 | size: [512, 256], | |
| 1038 | scale: 2.0, | |
| 1039 | origin: [24.0; 2], | |
| 1040 | }; | |
| 1041 | let mut captures = Vec::new(); | |
| 1042 | let image = RasterImage::new( | |
| 1043 | [2, 2], | |
| 1044 | vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0], | |
| 1045 | ) | |
| 1046 | .unwrap(); | |
| 1047 | let transparent_edge = | |
| 1048 | RasterImage::new([2, 1], vec![255, 0, 0, 255, 0, 0, 255, 0]).unwrap(); | |
| 1049 | let tags = [ | |
| 1050 | TagIcon::CheckBox { checked: false }, | |
| 1051 | TagIcon::CheckBox { checked: true }, | |
| 1052 | TagIcon::Question, | |
| 1053 | TagIcon::Music, | |
| 1054 | ] | |
| 1055 | .map(|icon| ParagraphTag { | |
| 1056 | icon, | |
| 1057 | origin: [0.0; 2], | |
| 1058 | label: String::new(), | |
| 1059 | disabled: false, | |
| 1060 | }); | |
| 1061 | let phase_layout = engine | |
| 1062 | .layout( | |
| 1063 | &Paragraph::new( | |
| 1064 | "H".into(), | |
| 1065 | TextFormat { | |
| 1066 | font: Some("Arial".into()), | |
| 1067 | font_size: Some(11.0), | |
| 1068 | ..TextFormat::default() | |
| 1069 | }, | |
| 1070 | ), | |
| 1071 | 20.0, | |
| 1072 | ) | |
| 1073 | .unwrap(); | |
| 1074 | let mut primitives = vec![ | |
| 1075 | Primitive::Rect { | |
| 1076 | rect: [0.0, 0.0, 70.0, 14.0], | |
| 1077 | color: [0.55, 0.73, 1.0, 0.5], | |
| 1078 | }, | |
| 1079 | Primitive::Text { | |
| 1080 | layout: &layout, | |
| 1081 | origin: [0.0; 2], | |
| 1082 | }, | |
| 1083 | Primitive::Text { | |
| 1084 | layout: &layout, | |
| 1085 | origin: [8.0, 64.0], | |
| 1086 | }, | |
| 1087 | Primitive::Image { | |
| 1088 | image: &image, | |
| 1089 | rect: [128.0, 8.0, 160.0, 40.0], | |
| 1090 | }, | |
| 1091 | Primitive::Rect { | |
| 1092 | rect: [128.0, 8.0, 132.0, 12.0], | |
| 1093 | color: [0.0, 0.0, 1.0, 1.0], | |
| 1094 | }, | |
| 1095 | Primitive::Image { | |
| 1096 | image: &transparent_edge, | |
| 1097 | rect: [176.0, 8.0, 208.0, 40.0], | |
| 1098 | }, | |
| 1099 | Primitive::Tag { | |
| 1100 | tag: &tags[0], | |
| 1101 | origin: [128.125, 80.25], | |
| 1102 | }, | |
| 1103 | Primitive::Tag { | |
| 1104 | tag: &tags[1], | |
| 1105 | origin: [148.125, 80.25], | |
| 1106 | }, | |
| 1107 | Primitive::Tag { | |
| 1108 | tag: &tags[2], | |
| 1109 | origin: [168.125, 80.25], | |
| 1110 | }, | |
| 1111 | Primitive::Tag { | |
| 1112 | tag: &tags[3], | |
| 1113 | origin: [188.125, 80.25], | |
| 1114 | }, | |
| 1115 | ]; | |
| 1116 | primitives.extend((0..9).map(|step| Primitive::Text { | |
| 1117 | layout: &phase_layout, | |
| 1118 | origin: [8.0 + 24.0 * step as f32, 96.0 + 0.125 * step as f32], | |
| 1119 | })); | |
| 1120 | for pass in 0..4 { | |
| 1121 | if pass == 2 { | |
| 1122 | renderer.clear_glyph_cache(); | |
| 1123 | renderer.images.clear(); | |
| 1124 | } else if pass == 3 { | |
| 1125 | renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format); | |
| 1126 | } | |
| 1127 | renderer | |
| 1128 | .draw( | |
| 1129 | &target.create_view(&Default::default()), | |
| 1130 | viewport, | |
| 1131 | &primitives, | |
| 1132 | ) | |
| 1133 | .unwrap(); | |
| 1134 | let mut encoder = renderer.device.create_command_encoder(&Default::default()); | |
| 1135 | encoder.copy_texture_to_buffer( | |
| 1136 | target.as_image_copy(), | |
| 1137 | wgpu::TexelCopyBufferInfo { | |
| 1138 | buffer: &readback, | |
| 1139 | layout: wgpu::TexelCopyBufferLayout { | |
| 1140 | offset: 0, | |
| 1141 | bytes_per_row: Some(512 * 4), | |
| 1142 | rows_per_image: Some(256), | |
| 1143 | }, | |
| 1144 | }, | |
| 1145 | wgpu::Extent3d { | |
| 1146 | width: 512, | |
| 1147 | height: 256, | |
| 1148 | depth_or_array_layers: 1, | |
| 1149 | }, | |
| 1150 | ); | |
| 1151 | renderer.queue.submit([encoder.finish()]); | |
| 1152 | let (sender, receiver) = std::sync::mpsc::channel(); | |
| 1153 | readback.map_async(wgpu::MapMode::Read, .., move |result| { | |
| 1154 | sender.send(result).unwrap(); | |
| 1155 | }); | |
| 1156 | renderer | |
| 1157 | .device | |
| 1158 | .poll(wgpu::PollType::Wait { | |
| 1159 | submission_index: None, | |
| 1160 | timeout: Some(Duration::from_secs(5)), | |
| 1161 | }) | |
| 1162 | .unwrap(); | |
| 1163 | receiver | |
| 1164 | .recv_timeout(Duration::from_secs(5)) | |
| 1165 | .unwrap() | |
| 1166 | .unwrap(); | |
| 1167 | captures.push(readback.get_mapped_range(..).unwrap().to_vec()); | |
| 1168 | readback.unmap(); | |
| 1169 | } | |
| 1170 | let centroids: Vec<_> = (0..9) | |
| 1171 | .map(|step| { | |
| 1172 | let mut weight = 0.0_f64; | |
| 1173 | let mut moment = 0.0_f64; | |
| 1174 | for y in 212..252 { | |
| 1175 | for x in (40 + 48 * step)..(72 + 48 * step) { | |
| 1176 | let value = captures[0][(y * 512 + x) * 4]; | |
| 1177 | let coverage = 1.0 - f64::from(colorref(u32::from(value))[0]); | |
| 1178 | weight += coverage; | |
| 1179 | moment += coverage * y as f64; | |
| 1180 | } | |
| 1181 | } | |
| 1182 | assert!(weight > 10.0, "phase sample {step} was not painted"); | |
| 1183 | moment / weight | |
| 1184 | }) | |
| 1185 | .collect(); | |
| 1186 | for (step, centroid) in centroids.iter().enumerate() { | |
| 1187 | let movement = centroid - centroids[0]; | |
| 1188 | assert!( | |
| 1189 | (movement - step as f64 * 0.25).abs() < 0.06, | |
| 1190 | "quarter-pixel step {step} moved the glyph by {movement} px" | |
| 1191 | ); | |
| 1192 | } | |
| 1193 | assert!(!renderer.glyphs.is_empty()); | |
| 1194 | assert!(renderer.glyphs.len() <= MAX_GLYPHS); | |
| 1195 | assert_eq!( | |
| 1196 | renderer | |
| 1197 | .glyphs | |
| 1198 | .keys() | |
| 1199 | .filter(|key| matches!(key, AtlasKey::Tag { .. })) | |
| 1200 | .count(), | |
| 1201 | 4 | |
| 1202 | ); | |
| 1203 | for x in [280, 320, 360, 400] { | |
| 1204 | let colored = (184..210) | |
| 1205 | .flat_map(|y| (x..x + 26).map(move |x| (y * 512 + x) * 4)) | |
| 1206 | .filter(|offset| captures[0][*offset..*offset + 3].iter().any(|v| *v < 180)) | |
| 1207 | .count(); | |
| 1208 | assert!(colored > 10, "tag at {x} was not painted"); | |
| 1209 | } | |
| 1210 | let count = renderer.glyphs.len(); | |
| 1211 | renderer | |
| 1212 | .draw( | |
| 1213 | &target.create_view(&Default::default()), | |
| 1214 | viewport, | |
| 1215 | &[Primitive::Tag { | |
| 1216 | tag: &tags[0], | |
| 1217 | origin: [10000.0; 2], | |
| 1218 | }], | |
| 1219 | ) | |
| 1220 | .unwrap(); | |
| 1221 | assert_eq!(renderer.glyphs.len(), count); | |
| 1222 | assert!( | |
| 1223 | captures[0] | |
| 1224 | .chunks_exact(4) | |
| 1225 | .filter(|pixel| pixel[0] < 200) | |
| 1226 | .count() | |
| 1227 | > 100 | |
| 1228 | ); | |
| 1229 | assert_eq!(captures[0], captures[1]); | |
| 1230 | assert_eq!(captures[0], captures[2]); | |
| 1231 | assert_eq!(captures[0], captures[3]); | |
| 1232 | for (x, y, color) in [ | |
| 1233 | (288, 48, [255, 0, 0, 255]), | |
| 1234 | (336, 96, [255; 4]), | |
| 1235 | (284, 44, [0, 0, 255, 255]), | |
| 1236 | ] { | |
| 1237 | let offset = (y * 512 + x) * 4; | |
| 1238 | assert_eq!(captures[0][offset..offset + 4], color); | |
| 1239 | } | |
| 1240 | let edge = &captures[0][(48 * 512 + 408) * 4..(48 * 512 + 408) * 4 + 4]; | |
| 1241 | assert_eq!(edge[0], 255, "{edge:?}"); | |
| 1242 | assert_eq!(edge[1], edge[2], "{edge:?}"); | |
| 1243 | assert!((180..=200).contains(&edge[1]), "{edge:?}"); | |
| 1244 | assert_eq!(renderer.images.len(), 2); | |
| 1245 | for primitive in [ | |
| 1246 | Primitive::Rect { | |
| 1247 | rect: [0.0, 0.0, f32::NAN, 1.0], | |
| 1248 | color: [1.0; 4], | |
| 1249 | }, | |
| 1250 | Primitive::Rect { | |
| 1251 | rect: [2.0, 0.0, 1.0, 1.0], | |
| 1252 | color: [1.0; 4], | |
| 1253 | }, | |
| 1254 | Primitive::Text { | |
| 1255 | layout: &layout, | |
| 1256 | origin: [f32::MAX; 2], | |
| 1257 | }, | |
| 1258 | ] { | |
| 1259 | assert!(matches!( | |
| 1260 | renderer.draw( | |
| 1261 | &target.create_view(&Default::default()), | |
| 1262 | viewport, | |
| 1263 | &[primitive] | |
| 1264 | ), | |
| 1265 | Err(RenderError::InvalidPrimitive) | |
| 1266 | )); | |
| 1267 | } | |
| 1268 | let too_many: Vec<_> = (0..=MAX_IMAGES) | |
| 1269 | .map(|_| RasterImage::new([1, 1], vec![255; 4]).unwrap()) | |
| 1270 | .collect(); | |
| 1271 | let primitives: Vec<_> = too_many | |
| 1272 | .iter() | |
| 1273 | .map(|image| Primitive::Image { | |
| 1274 | image, | |
| 1275 | rect: [0.0, 0.0, 1.0, 1.0], | |
| 1276 | }) | |
| 1277 | .collect(); | |
| 1278 | assert!(matches!( | |
| 1279 | renderer.draw( | |
| 1280 | &target.create_view(&Default::default()), | |
| 1281 | viewport, | |
| 1282 | &primitives | |
| 1283 | ), | |
| 1284 | Err(RenderError::ImageBudget) | |
| 1285 | )); | |
| 1286 | let primitives: Vec<_> = too_many | |
| 1287 | .iter() | |
| 1288 | .map(|image| Primitive::Image { | |
| 1289 | image, | |
| 1290 | rect: [-20.0, -20.0, -19.0, -19.0], | |
| 1291 | }) | |
| 1292 | .collect(); | |
| 1293 | renderer | |
| 1294 | .draw( | |
| 1295 | &target.create_view(&Default::default()), | |
| 1296 | viewport, | |
| 1297 | &primitives, | |
| 1298 | ) | |
| 1299 | .unwrap(); | |
| 1300 | assert_eq!(renderer.images.len(), 2); | |
| 1301 | let mut seen = HashSet::from([image.pixels.id(), transparent_edge.pixels.id()]); | |
| 1302 | let mut previous = None; | |
| 1303 | for _ in 0..5 { | |
| 1304 | let large = RasterImage::new([2048; 2], vec![255; 2048 * 2048 * 4]).unwrap(); | |
| 1305 | seen.insert(large.pixels.id()); | |
| 1306 | let mut primitives = vec![Primitive::Image { | |
| 1307 | image: &large, | |
| 1308 | rect: [0.0, 0.0, 16.0, 16.0], | |
| 1309 | }]; | |
| 1310 | if let Some(image) = &previous { | |
| 1311 | primitives.push(Primitive::Image { | |
| 1312 | image, | |
| 1313 | rect: [16.0, 0.0, 32.0, 16.0], | |
| 1314 | }); | |
| 1315 | } | |
| 1316 | renderer | |
| 1317 | .draw( | |
| 1318 | &target.create_view(&Default::default()), | |
| 1319 | viewport, | |
| 1320 | &primitives, | |
| 1321 | ) | |
| 1322 | .unwrap(); | |
| 1323 | assert!(renderer.images.contains_key(&large.pixels.id())); | |
| 1324 | if let Some(image) = &previous { | |
| 1325 | assert!(renderer.images.contains_key(&image.pixels.id())); | |
| 1326 | } | |
| 1327 | assert!(renderer.images.values().map(|i| i.bytes).sum::<u64>() <= MAX_IMAGE_BYTES); | |
| 1328 | previous = Some(large); | |
| 1329 | } | |
| 1330 | assert!(renderer.images.len() <= MAX_IMAGES); | |
| 1331 | assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); | |
| 1332 | renderer.images.clear(); | |
| 1333 | renderer | |
| 1334 | .draw( | |
| 1335 | &target.create_view(&Default::default()), | |
| 1336 | viewport, | |
| 1337 | &[Primitive::Image { | |
| 1338 | image: &image, | |
| 1339 | rect: [128.0, 8.0, 160.0, 40.0], | |
| 1340 | }], | |
| 1341 | ) | |
| 1342 | .unwrap(); | |
| 1343 | assert_eq!(renderer.images.len(), 1); | |
| 1344 | assert!(renderer.images.contains_key(&image.pixels.id())); | |
| 1345 | } | |
| 1346 | } |
crates/canvas-gpu/src/page.rs created+923| ... | ... | @@ -0,0 +1,923 @@ |
| 1 | use crate::{Primitive, RasterImage, RenderError, colorref}; | |
| 2 | use one_canvas::{ | |
| 3 | editor::{CanvasEditor, EditorError, TextOutline}, | |
| 4 | layout::{LayoutError, TextEngine, TextLayout}, | |
| 5 | outline::OutlineLayout, | |
| 6 | page::{Page, PageObject}, | |
| 7 | text::Paragraph, | |
| 8 | }; | |
| 9 | use std::fmt; | |
| 10 | ||
| 11 | /// Retained drawing data in the source page's coordinate system. | |
| 12 | pub struct PageScene { | |
| 13 | objects: Vec<Content>, | |
| 14 | } | |
| 15 | ||
| 16 | #[derive(Debug, PartialEq)] | |
| 17 | pub enum SceneHit<T> { | |
| 18 | Outline(T), | |
| 19 | ReadOnly(usize), | |
| 20 | } | |
| 21 | ||
| 22 | enum Content { | |
| 23 | Outline { | |
| 24 | origin: [f32; 2], | |
| 25 | layout: OutlineLayout, | |
| 26 | }, | |
| 27 | Image { | |
| 28 | origin: [f32; 2], | |
| 29 | image: RasterImage, | |
| 30 | size: [f32; 2], | |
| 31 | }, | |
| 32 | Editable(onestore::ExGuid), | |
| 33 | ReadOnly(Box<ReadOnlyObject>), | |
| 34 | } | |
| 35 | ||
| 36 | pub struct ReadOnlyObject { | |
| 37 | pub source: PageObject, | |
| 38 | pub rect: [f32; 4], | |
| 39 | pub message: &'static str, | |
| 40 | label: TextLayout, | |
| 41 | } | |
| 42 | ||
| 43 | impl ReadOnlyObject { | |
| 44 | fn new( | |
| 45 | source: PageObject, | |
| 46 | margin: [f32; 2], | |
| 47 | message: &'static str, | |
| 48 | engine: &mut TextEngine, | |
| 49 | ) -> Result<Box<Self>, SceneError> { | |
| 50 | let (layout, offset) = match &source { | |
| 51 | PageObject::Outline(v) => (&v.layout, [0.0; 2]), | |
| 52 | PageObject::Title(v) => (&v.layout, margin), | |
| 53 | PageObject::Image(v) => (&v.layout, [0.0; 2]), | |
| 54 | PageObject::Unsupported(v) => (&v.layout, [0.0; 2]), | |
| 55 | }; | |
| 56 | let x = layout.x.unwrap_or(0.0) + offset[0]; | |
| 57 | let y = layout.y.unwrap_or(0.0) + offset[1]; | |
| 58 | let width = layout.max_width.unwrap_or(160.0); | |
| 59 | let height = layout.max_height.unwrap_or(42.0); | |
| 60 | if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 { | |
| 61 | return Err(SceneError::InvalidGeometry); | |
| 62 | } | |
| 63 | let width = width.max(160.0); | |
| 64 | let label = engine.layout( | |
| 65 | &Paragraph::new( | |
| 66 | message.into(), | |
| 67 | onestore::document::Format { | |
| 68 | font: Some("Arial".into()), | |
| 69 | font_size: Some(11.0), | |
| 70 | color: Some(0x005d554e), | |
| 71 | ..Default::default() | |
| 72 | }, | |
| 73 | ), | |
| 74 | width - 16.0, | |
| 75 | )?; | |
| 76 | let height = height.max(label.height() + 16.0); | |
| 77 | let rect = [x, y, x + width, y + height]; | |
| 78 | if rect.iter().any(|v| !v.is_finite()) { | |
| 79 | return Err(SceneError::InvalidGeometry); | |
| 80 | } | |
| 81 | Ok(Box::new(Self { | |
| 82 | source, | |
| 83 | rect, | |
| 84 | message, | |
| 85 | label, | |
| 86 | })) | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | #[derive(Debug)] | |
| 91 | pub enum SceneError { | |
| 92 | Layout(LayoutError), | |
| 93 | Editor(EditorError), | |
| 94 | MissingOutline, | |
| 95 | Image(RenderError), | |
| 96 | MissingImage, | |
| 97 | InvalidGeometry, | |
| 98 | } | |
| 99 | ||
| 100 | impl fmt::Display for SceneError { | |
| 101 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 102 | match self { | |
| 103 | Self::Layout(error) => error.fmt(f), | |
| 104 | Self::Editor(error) => error.fmt(f), | |
| 105 | Self::MissingOutline => f.write_str("An editable outline is missing from this page."), | |
| 106 | Self::Image(error) => write!(f, "Image rendering failed: {error:?}"), | |
| 107 | Self::MissingImage => f.write_str("An image is missing its data or dimensions."), | |
| 108 | Self::InvalidGeometry => { | |
| 109 | f.write_str("The page contains invalid object dimensions or positions.") | |
| 110 | } | |
| 111 | } | |
| 112 | } | |
| 113 | } | |
| 114 | impl std::error::Error for SceneError {} | |
| 115 | impl From<LayoutError> for SceneError { | |
| 116 | fn from(error: LayoutError) -> Self { | |
| 117 | Self::Layout(error) | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | impl PageScene { | |
| 122 | pub fn new(mut page: Page, engine: &mut TextEngine) -> Result<Self, SceneError> { | |
| 123 | Self::build(&mut page, engine, false).map(|(scene, _)| scene) | |
| 124 | } | |
| 125 | ||
| 126 | pub fn from_page( | |
| 127 | mut page: Page, | |
| 128 | engine: &mut TextEngine, | |
| 129 | ) -> Result<(Self, CanvasEditor), SceneError> { | |
| 130 | let (scene, mut outlines) = Self::build(&mut page, engine, true)?; | |
| 131 | if outlines.is_empty() { | |
| 132 | let x = scene | |
| 133 | .read_only() | |
| 134 | .map(|object| object.rect[2]) | |
| 135 | .fold(0.0_f32, f32::max); | |
| 136 | outlines.push( | |
| 137 | TextOutline::new( | |
| 138 | engine, | |
| 139 | one_canvas::document::TextDocument::new(vec![Paragraph::new( | |
| 140 | String::new(), | |
| 141 | Default::default(), | |
| 142 | )]) | |
| 143 | .map_err(EditorError::from) | |
| 144 | .map_err(SceneError::Editor)?, | |
| 145 | 240.0, | |
| 146 | [if x > 0.0 { x + 24.0 } else { 0.0 }, 0.0], | |
| 147 | ) | |
| 148 | .map_err(SceneError::Editor)?, | |
| 149 | ); | |
| 150 | } | |
| 151 | let editor = CanvasEditor::from_text_outlines(outlines, page.definitions) | |
| 152 | .map_err(SceneError::Editor)?; | |
| 153 | Ok((scene, editor)) | |
| 154 | } | |
| 155 | ||
| 156 | fn build( | |
| 157 | page: &mut Page, | |
| 158 | engine: &mut TextEngine, | |
| 159 | editable: bool, | |
| 160 | ) -> Result<(Self, Vec<TextOutline>), SceneError> { | |
| 161 | if page.margin_origin.iter().any(|v| !v.is_finite()) { | |
| 162 | return Err(SceneError::InvalidGeometry); | |
| 163 | } | |
| 164 | let mut objects = Vec::new(); | |
| 165 | let mut outlines = Vec::new(); | |
| 166 | let mut image_bytes = 0_u64; | |
| 167 | for object in std::mem::take(&mut page.objects) { | |
| 168 | match &object { | |
| 169 | PageObject::Outline(outline) => { | |
| 170 | let origin = [ | |
| 171 | outline.layout.x.unwrap_or(0.0), | |
| 172 | outline.layout.y.unwrap_or(0.0), | |
| 173 | ]; | |
| 174 | if origin.iter().any(|v| !v.is_finite()) { | |
| 175 | return Err(SceneError::InvalidGeometry); | |
| 176 | } | |
| 177 | if editable { | |
| 178 | match TextOutline::from_outline(engine, outline, &page.definitions) { | |
| 179 | Ok(outline) => { | |
| 180 | objects.push(Content::Editable(outline.id)); | |
| 181 | outlines.push(outline); | |
| 182 | } | |
| 183 | Err(EditorError::Layout(LayoutError::UnsupportedContent)) => objects | |
| 184 | .push(Content::ReadOnly(ReadOnlyObject::new( | |
| 185 | object, | |
| 186 | page.margin_origin, | |
| 187 | "Unsupported content\nRead-only", | |
| 188 | engine, | |
| 189 | )?)), | |
| 190 | Err(error) => return Err(SceneError::Editor(error)), | |
| 191 | } | |
| 192 | } else { | |
| 193 | match outline.layout(engine, &page.definitions) { | |
| 194 | Ok(layout) => objects.push(Content::Outline { origin, layout }), | |
| 195 | Err(LayoutError::UnsupportedContent) => { | |
| 196 | objects.push(Content::ReadOnly(ReadOnlyObject::new( | |
| 197 | object, | |
| 198 | page.margin_origin, | |
| 199 | "Unsupported content\nRead-only", | |
| 200 | engine, | |
| 201 | )?)) | |
| 202 | } | |
| 203 | Err(error) => return Err(error.into()), | |
| 204 | } | |
| 205 | } | |
| 206 | } | |
| 207 | PageObject::Title(title) => { | |
| 208 | let layouts = match title.layout(engine, &page.definitions) { | |
| 209 | Ok(layouts) => layouts, | |
| 210 | Err(LayoutError::UnsupportedContent) => { | |
| 211 | objects.push(Content::ReadOnly(ReadOnlyObject::new( | |
| 212 | object, | |
| 213 | page.margin_origin, | |
| 214 | "Unsupported content\nRead-only", | |
| 215 | engine, | |
| 216 | )?)); | |
| 217 | continue; | |
| 218 | } | |
| 219 | Err(error) => return Err(error.into()), | |
| 220 | }; | |
| 221 | for (origin, layout) in layouts { | |
| 222 | let origin = [ | |
| 223 | title.layout.x.unwrap_or(0.0) + origin[0] + page.margin_origin[0], | |
| 224 | title.layout.y.unwrap_or(0.0) + origin[1] + page.margin_origin[1], | |
| 225 | ]; | |
| 226 | if origin.iter().any(|v| !v.is_finite()) { | |
| 227 | return Err(SceneError::InvalidGeometry); | |
| 228 | } | |
| 229 | objects.push(Content::Outline { origin, layout }); | |
| 230 | } | |
| 231 | } | |
| 232 | PageObject::Image(source) => { | |
| 233 | if source.bytes.is_none() | |
| 234 | || source.layout.max_width.is_none() | |
| 235 | || source.layout.max_height.is_none() | |
| 236 | { | |
| 237 | objects.push(Content::ReadOnly(ReadOnlyObject::new( | |
| 238 | object, | |
| 239 | page.margin_origin, | |
| 240 | "Image unavailable\nRead-only", | |
| 241 | engine, | |
| 242 | )?)); | |
| 243 | continue; | |
| 244 | } | |
| 245 | let origin = [ | |
| 246 | source.layout.x.unwrap_or(0.0), | |
| 247 | source.layout.y.unwrap_or(0.0), | |
| 248 | ]; | |
| 249 | let size = [ | |
| 250 | source.layout.max_width.ok_or(SceneError::MissingImage)?, | |
| 251 | source.layout.max_height.ok_or(SceneError::MissingImage)?, | |
| 252 | ]; | |
| 253 | if origin.iter().any(|v| !v.is_finite()) | |
| 254 | || size.iter().any(|v| !v.is_finite() || *v <= 0.0) | |
| 255 | { | |
| 256 | return Err(SceneError::InvalidGeometry); | |
| 257 | } | |
| 258 | let image = RasterImage::decode( | |
| 259 | source.bytes.as_deref().ok_or(SceneError::MissingImage)?, | |
| 260 | ) | |
| 261 | .map_err(SceneError::Image)?; | |
| 262 | image_bytes += image.pixels.as_ref().len() as u64; | |
| 263 | if image_bytes > crate::MAX_IMAGE_BYTES { | |
| 264 | return Err(SceneError::Image(RenderError::ImageBudget)); | |
| 265 | } | |
| 266 | objects.push(Content::Image { | |
| 267 | origin, | |
| 268 | image, | |
| 269 | size, | |
| 270 | }); | |
| 271 | } | |
| 272 | PageObject::Unsupported(_) => objects.push(Content::ReadOnly(ReadOnlyObject::new( | |
| 273 | object, | |
| 274 | page.margin_origin, | |
| 275 | "Unsupported content\nRead-only", | |
| 276 | engine, | |
| 277 | )?)), | |
| 278 | } | |
| 279 | } | |
| 280 | Ok((Self { objects }, outlines)) | |
| 281 | } | |
| 282 | ||
| 283 | pub fn read_only(&self) -> impl Iterator<Item = &ReadOnlyObject> { | |
| 284 | self.objects.iter().filter_map(|object| match object { | |
| 285 | Content::ReadOnly(object) => Some(object.as_ref()), | |
| 286 | _ => None, | |
| 287 | }) | |
| 288 | } | |
| 289 | ||
| 290 | pub fn contains_outline(&self, id: onestore::ExGuid) -> bool { | |
| 291 | self.objects | |
| 292 | .iter() | |
| 293 | .any(|object| matches!(object, Content::Editable(candidate) if *candidate == id)) | |
| 294 | } | |
| 295 | ||
| 296 | /// The point and outline hit callback use scene coordinates. | |
| 297 | pub fn hit_test<T>( | |
| 298 | &self, | |
| 299 | point: [f32; 2], | |
| 300 | mut outline: impl FnMut(onestore::ExGuid) -> Option<T>, | |
| 301 | ) -> Option<SceneHit<T>> { | |
| 302 | let mut readonly = self.read_only().count(); | |
| 303 | for object in self.objects.iter().rev() { | |
| 304 | match object { | |
| 305 | Content::Editable(id) => { | |
| 306 | if let Some(hit) = outline(*id) { | |
| 307 | return Some(SceneHit::Outline(hit)); | |
| 308 | } | |
| 309 | } | |
| 310 | Content::ReadOnly(object) => { | |
| 311 | readonly -= 1; | |
| 312 | let [x0, y0, x1, y1] = object.rect; | |
| 313 | if (x0..=x1).contains(&point[0]) && (y0..=y1).contains(&point[1]) { | |
| 314 | return Some(SceneHit::ReadOnly(readonly)); | |
| 315 | } | |
| 316 | } | |
| 317 | Content::Outline { .. } | Content::Image { .. } => {} | |
| 318 | } | |
| 319 | } | |
| 320 | None | |
| 321 | } | |
| 322 | ||
| 323 | /// The offset is in document points. | |
| 324 | pub fn append_primitives<'a>( | |
| 325 | &'a self, | |
| 326 | primitives: &mut Vec<Primitive<'a>>, | |
| 327 | offset: [f32; 2], | |
| 328 | ) -> Result<(), SceneError> { | |
| 329 | self.append_primitives_with(primitives, offset, |_, _, _| { | |
| 330 | Err(SceneError::MissingOutline) | |
| 331 | }) | |
| 332 | } | |
| 333 | ||
| 334 | pub fn append_primitives_with<'a, E>( | |
| 335 | &'a self, | |
| 336 | primitives: &mut Vec<Primitive<'a>>, | |
| 337 | offset: [f32; 2], | |
| 338 | mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec<Primitive<'a>>) -> Result<(), E>, | |
| 339 | ) -> Result<(), E> { | |
| 340 | for content in &self.objects { | |
| 341 | let origin = match content { | |
| 342 | Content::Outline { origin, .. } | Content::Image { origin, .. } => origin, | |
| 343 | Content::ReadOnly(object) => { | |
| 344 | let [x0, y0, x1, y1] = object.rect; | |
| 345 | let rect = [ | |
| 346 | x0 + offset[0], | |
| 347 | y0 + offset[1], | |
| 348 | x1 + offset[0], | |
| 349 | y1 + offset[1], | |
| 350 | ]; | |
| 351 | primitives.push(Primitive::Rect { | |
| 352 | rect, | |
| 353 | color: colorref(0x00e4ddd6), | |
| 354 | }); | |
| 355 | primitives.push(Primitive::Rect { | |
| 356 | rect: [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0], | |
| 357 | color: colorref(0x00faf7f3), | |
| 358 | }); | |
| 359 | primitives.push(Primitive::Text { | |
| 360 | layout: &object.label, | |
| 361 | origin: [rect[0] + 8.0, rect[1] + 8.0], | |
| 362 | }); | |
| 363 | continue; | |
| 364 | } | |
| 365 | Content::Editable(id) => { | |
| 366 | outline(*id, offset, primitives)?; | |
| 367 | continue; | |
| 368 | } | |
| 369 | }; | |
| 370 | let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; | |
| 371 | match content { | |
| 372 | Content::Image { image, size, .. } => primitives.push(Primitive::Image { | |
| 373 | image, | |
| 374 | rect: [ | |
| 375 | object_origin[0], | |
| 376 | object_origin[1], | |
| 377 | object_origin[0] + size[0], | |
| 378 | object_origin[1] + size[1], | |
| 379 | ], | |
| 380 | }), | |
| 381 | Content::Outline { | |
| 382 | layout: outline, .. | |
| 383 | } => { | |
| 384 | for paragraph in &outline.paragraphs { | |
| 385 | let origin = [ | |
| 386 | object_origin[0] + paragraph.origin[0], | |
| 387 | object_origin[1] + paragraph.origin[1], | |
| 388 | ]; | |
| 389 | for (rect, color) in paragraph.text.backgrounds() { | |
| 390 | primitives.push(Primitive::Rect { | |
| 391 | rect: [ | |
| 392 | rect.x0 as f32 + origin[0], | |
| 393 | rect.y0 as f32 + origin[1], | |
| 394 | rect.x1 as f32 + origin[0], | |
| 395 | rect.y1 as f32 + origin[1], | |
| 396 | ], | |
| 397 | color: colorref(color), | |
| 398 | }); | |
| 399 | } | |
| 400 | primitives.push(Primitive::Text { | |
| 401 | layout: &paragraph.text, | |
| 402 | origin, | |
| 403 | }); | |
| 404 | for (layout, origin) in &paragraph.markers { | |
| 405 | primitives.push(Primitive::Text { | |
| 406 | layout, | |
| 407 | origin: [ | |
| 408 | object_origin[0] + origin[0], | |
| 409 | object_origin[1] + (origin[1] + paragraph.origin[1]), | |
| 410 | ], | |
| 411 | }); | |
| 412 | } | |
| 413 | for tag in &paragraph.tags { | |
| 414 | primitives.push(Primitive::Tag { | |
| 415 | tag, | |
| 416 | origin: [object_origin[0], object_origin[1] + paragraph.origin[1]], | |
| 417 | }); | |
| 418 | } | |
| 419 | } | |
| 420 | } | |
| 421 | Content::Editable(_) | Content::ReadOnly(_) => unreachable!(), | |
| 422 | } | |
| 423 | } | |
| 424 | Ok(()) | |
| 425 | } | |
| 426 | } | |
| 427 | ||
| 428 | #[cfg(test)] | |
| 429 | mod tests { | |
| 430 | use super::*; | |
| 431 | use one_canvas::page::Image; | |
| 432 | use onestore::{ExGuid, document::Layout}; | |
| 433 | use std::{collections::BTreeMap, sync::Arc}; | |
| 434 | ||
| 435 | #[test] | |
| 436 | fn reverse_hit_order_keeps_read_only_focus_indices() { | |
| 437 | let objects = [0.0, 20.0] | |
| 438 | .into_iter() | |
| 439 | .map(|position| { | |
| 440 | PageObject::Unsupported(one_canvas::page::Unsupported { | |
| 441 | id: ExGuid { | |
| 442 | guid: [position as u8; 16], | |
| 443 | n: 1, | |
| 444 | }, | |
| 445 | jcid: 0xdead, | |
| 446 | layout: Layout { | |
| 447 | x: Some(position), | |
| 448 | y: Some(position), | |
| 449 | max_width: Some(160.0), | |
| 450 | max_height: Some(80.0), | |
| 451 | ..Default::default() | |
| 452 | }, | |
| 453 | }) | |
| 454 | }) | |
| 455 | .collect(); | |
| 456 | let page = Page { | |
| 457 | title: String::new(), | |
| 458 | margin_origin: [0.0; 2], | |
| 459 | definitions: BTreeMap::new(), | |
| 460 | objects, | |
| 461 | }; | |
| 462 | let scene = PageScene::new(page, &mut TextEngine::default()).unwrap(); | |
| 463 | assert_eq!( | |
| 464 | scene.hit_test::<()>([30.0, 30.0], |_| unreachable!()), | |
| 465 | Some(SceneHit::ReadOnly(1)) | |
| 466 | ); | |
| 467 | assert_eq!( | |
| 468 | scene.hit_test::<()>([5.0, 5.0], |_| unreachable!()), | |
| 469 | Some(SceneHit::ReadOnly(0)) | |
| 470 | ); | |
| 471 | assert_eq!( | |
| 472 | scene.hit_test::<()>([300.0, 300.0], |_| unreachable!()), | |
| 473 | None | |
| 474 | ); | |
| 475 | } | |
| 476 | ||
| 477 | #[test] | |
| 478 | fn unsupported_tags_and_titles_leave_supported_outlines_editable() { | |
| 479 | use one_canvas::{ | |
| 480 | document::TextDocument, | |
| 481 | page::{Definition, Outline, Title}, | |
| 482 | }; | |
| 483 | use onestore::document::{Kind, Tag}; | |
| 484 | for title in [false, true] { | |
| 485 | let definition = ExGuid { | |
| 486 | guid: [9; 16], | |
| 487 | n: 1, | |
| 488 | }; | |
| 489 | let make_outline = |text: &str| { | |
| 490 | let document = | |
| 491 | TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]) | |
| 492 | .unwrap(); | |
| 493 | Outline { | |
| 494 | id: ExGuid { | |
| 495 | guid: [u8::from(text == "Editable") + 1; 16], | |
| 496 | n: 1, | |
| 497 | }, | |
| 498 | layout: Layout { | |
| 499 | max_width: Some(200.0), | |
| 500 | ..Default::default() | |
| 501 | }, | |
| 502 | indents: vec![18.0, 0.0], | |
| 503 | paragraphs: document.nodes().to_vec(), | |
| 504 | unsupported: Vec::new(), | |
| 505 | } | |
| 506 | }; | |
| 507 | let mut tagged = make_outline("Tagged"); | |
| 508 | tagged.paragraphs[0].tags.push(Tag { | |
| 509 | definition: Some(definition), | |
| 510 | status: 1, | |
| 511 | action_type: None, | |
| 512 | created: None, | |
| 513 | completed: None, | |
| 514 | start: None, | |
| 515 | due: None, | |
| 516 | task_id: None, | |
| 517 | extra_set: 0, | |
| 518 | }); | |
| 519 | let unsupported = if title { | |
| 520 | PageObject::Title(Title { | |
| 521 | id: ExGuid::default(), | |
| 522 | layout: Layout { | |
| 523 | x: Some(10.0), | |
| 524 | y: Some(20.0), | |
| 525 | ..Default::default() | |
| 526 | }, | |
| 527 | outlines: vec![tagged], | |
| 528 | }) | |
| 529 | } else { | |
| 530 | PageObject::Outline(tagged) | |
| 531 | }; | |
| 532 | let page = Page { | |
| 533 | title: String::new(), | |
| 534 | margin_origin: [36.0, 14.0], | |
| 535 | objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))], | |
| 536 | definitions: BTreeMap::from([( | |
| 537 | definition, | |
| 538 | Definition { | |
| 539 | kind: Kind::TagDefinition { | |
| 540 | shape: Some(999), | |
| 541 | label: Some("Unknown icon".into()), | |
| 542 | color: None, | |
| 543 | highlight: None, | |
| 544 | action_type: None, | |
| 545 | }, | |
| 546 | format: Default::default(), | |
| 547 | }, | |
| 548 | )]), | |
| 549 | }; | |
| 550 | let mut engine = TextEngine::default(); | |
| 551 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | |
| 552 | assert_eq!(editor.outlines().len(), 1); | |
| 553 | let readonly = scene.read_only().next().unwrap(); | |
| 554 | assert_eq!(scene.read_only().count(), 1); | |
| 555 | if title { | |
| 556 | assert_eq!(&readonly.rect[..2], &[46.0, 34.0]); | |
| 557 | } | |
| 558 | editor.insert(&mut engine, "Still ").unwrap(); | |
| 559 | assert_eq!( | |
| 560 | editor | |
| 561 | .active_outline() | |
| 562 | .document() | |
| 563 | .paragraphs() | |
| 564 | .next() | |
| 565 | .unwrap() | |
| 566 | .text(), | |
| 567 | "Still Editable" | |
| 568 | ); | |
| 569 | editor.undo(&mut engine).unwrap(); | |
| 570 | assert_eq!( | |
| 571 | editor | |
| 572 | .active_outline() | |
| 573 | .document() | |
| 574 | .paragraphs() | |
| 575 | .next() | |
| 576 | .unwrap() | |
| 577 | .text(), | |
| 578 | "Editable" | |
| 579 | ); | |
| 580 | } | |
| 581 | } | |
| 582 | ||
| 583 | #[test] | |
| 584 | fn unsupported_sources_stay_owned_and_read_only_across_edit_and_undo() { | |
| 585 | use one_canvas::{ | |
| 586 | document::TextDocument, | |
| 587 | page::{Outline, Unsupported}, | |
| 588 | }; | |
| 589 | let document = TextDocument::new(vec![Paragraph::new( | |
| 590 | "preserved text".into(), | |
| 591 | Default::default(), | |
| 592 | )]) | |
| 593 | .unwrap(); | |
| 594 | let original = document.nodes().to_vec(); | |
| 595 | let unknown = Unsupported { | |
| 596 | id: ExGuid { | |
| 597 | guid: [8; 16], | |
| 598 | n: 1, | |
| 599 | }, | |
| 600 | jcid: 0xdead, | |
| 601 | layout: Layout::default(), | |
| 602 | }; | |
| 603 | let page = Page { | |
| 604 | title: String::new(), | |
| 605 | margin_origin: [0.0; 2], | |
| 606 | definitions: BTreeMap::new(), | |
| 607 | objects: vec![ | |
| 608 | PageObject::Outline(Outline { | |
| 609 | id: ExGuid { | |
| 610 | guid: [7; 16], | |
| 611 | n: 1, | |
| 612 | }, | |
| 613 | layout: Layout { | |
| 614 | x: Some(50.0), | |
| 615 | y: Some(70.0), | |
| 616 | max_width: Some(200.0), | |
| 617 | ..Default::default() | |
| 618 | }, | |
| 619 | indents: vec![18.0, 0.0], | |
| 620 | paragraphs: original.clone(), | |
| 621 | unsupported: vec![unknown.clone()], | |
| 622 | }), | |
| 623 | PageObject::Image(Image { | |
| 624 | id: ExGuid::default(), | |
| 625 | layout: Layout { | |
| 626 | y: Some(150.0), | |
| 627 | ..Default::default() | |
| 628 | }, | |
| 629 | bytes: None, | |
| 630 | alt: Some("missing diagram".into()), | |
| 631 | background: false, | |
| 632 | }), | |
| 633 | PageObject::Unsupported(unknown.clone()), | |
| 634 | ], | |
| 635 | }; | |
| 636 | let mut engine = TextEngine::default(); | |
| 637 | let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); | |
| 638 | assert_eq!(scene.read_only().count(), 3); | |
| 639 | assert!( | |
| 640 | editor.active_outline().origin()[0] | |
| 641 | > scene | |
| 642 | .read_only() | |
| 643 | .map(|object| object.rect[2]) | |
| 644 | .fold(0.0_f32, f32::max) | |
| 645 | ); | |
| 646 | let labels: Vec<_> = scene.read_only().map(|object| object.label.id()).collect(); | |
| 647 | for edit in [true, false] { | |
| 648 | if edit { | |
| 649 | editor.insert(&mut engine, "new annotation").unwrap(); | |
| 650 | } else { | |
| 651 | editor.undo(&mut engine).unwrap(); | |
| 652 | } | |
| 653 | let readonly: Vec<_> = scene.read_only().collect(); | |
| 654 | let PageObject::Outline(outline) = &readonly[0].source else { | |
| 655 | panic!() | |
| 656 | }; | |
| 657 | assert_eq!(outline.paragraphs, original); | |
| 658 | assert_eq!( | |
| 659 | outline.unsupported.as_slice(), | |
| 660 | std::slice::from_ref(&unknown) | |
| 661 | ); | |
| 662 | let PageObject::Image(image) = &readonly[1].source else { | |
| 663 | panic!() | |
| 664 | }; | |
| 665 | assert_eq!(image.alt.as_deref(), Some("missing diagram")); | |
| 666 | assert!(image.bytes.is_none()); | |
| 667 | let PageObject::Unsupported(source) = &readonly[2].source else { | |
| 668 | panic!() | |
| 669 | }; | |
| 670 | assert_eq!(source, &unknown); | |
| 671 | assert_eq!( | |
| 672 | readonly | |
| 673 | .iter() | |
| 674 | .map(|object| object.label.id()) | |
| 675 | .collect::<Vec<_>>(), | |
| 676 | labels | |
| 677 | ); | |
| 678 | let primitives = paint_editor(&scene, &editor); | |
| 679 | assert_eq!(primitives.len(), 9); | |
| 680 | assert_eq!( | |
| 681 | primitives | |
| 682 | .iter() | |
| 683 | .filter(|p| matches!(p, Primitive::Text { .. })) | |
| 684 | .count(), | |
| 685 | 3 | |
| 686 | ); | |
| 687 | } | |
| 688 | assert_eq!( | |
| 689 | editor | |
| 690 | .active_outline() | |
| 691 | .document() | |
| 692 | .paragraphs() | |
| 693 | .next() | |
| 694 | .unwrap() | |
| 695 | .text(), | |
| 696 | "" | |
| 697 | ); | |
| 698 | } | |
| 699 | ||
| 700 | fn paint_editor<'a>(scene: &'a PageScene, editor: &'a CanvasEditor) -> Vec<Primitive<'a>> { | |
| 701 | let mut primitives = Vec::new(); | |
| 702 | scene | |
| 703 | .append_primitives_with(&mut primitives, [0.0; 2], |id, offset, primitives| { | |
| 704 | let outline = editor | |
| 705 | .outlines() | |
| 706 | .iter() | |
| 707 | .find(|outline| outline.id == id) | |
| 708 | .ok_or(SceneError::MissingOutline)?; | |
| 709 | for (_, paragraph) in outline.layouts() { | |
| 710 | primitives.push(Primitive::Text { | |
| 711 | layout: &paragraph.text, | |
| 712 | origin: [ | |
| 713 | outline.origin()[0] + paragraph.origin[0] + offset[0], | |
| 714 | outline.origin()[1] + paragraph.origin[1] + offset[1], | |
| 715 | ], | |
| 716 | }); | |
| 717 | } | |
| 718 | Ok::<_, SceneError>(()) | |
| 719 | }) | |
| 720 | .unwrap(); | |
| 721 | primitives | |
| 722 | } | |
| 723 | ||
| 724 | #[test] | |
| 725 | fn editable_outlines_keep_paint_order_and_images_across_edit_and_undo() { | |
| 726 | use one_canvas::{document::TextDocument, page::Outline, text::Paragraph}; | |
| 727 | let outline = |x, text: &str| { | |
| 728 | let document = | |
| 729 | TextDocument::new(vec![Paragraph::new(text.into(), Default::default())]).unwrap(); | |
| 730 | PageObject::Outline(Outline { | |
| 731 | id: ExGuid { | |
| 732 | guid: [x as u8; 16], | |
| 733 | n: 1, | |
| 734 | }, | |
| 735 | layout: Layout { | |
| 736 | x: Some(x), | |
| 737 | max_width: Some(180.0), | |
| 738 | width_set_by_user: Some(true), | |
| 739 | ..Default::default() | |
| 740 | }, | |
| 741 | indents: vec![18.0, 0.0], | |
| 742 | paragraphs: document.nodes().to_vec(), | |
| 743 | unsupported: Vec::new(), | |
| 744 | }) | |
| 745 | }; | |
| 746 | let mut bytes = Vec::new(); | |
| 747 | { | |
| 748 | let mut encoder = png::Encoder::new(&mut bytes, 1, 1); | |
| 749 | encoder.set_color(png::ColorType::Rgba); | |
| 750 | encoder.set_depth(png::BitDepth::Eight); | |
| 751 | encoder | |
| 752 | .write_header() | |
| 753 | .unwrap() | |
| 754 | .write_image_data(&[17, 34, 51, 255]) | |
| 755 | .unwrap(); | |
| 756 | } | |
| 757 | let page = || Page { | |
| 758 | title: String::new(), | |
| 759 | margin_origin: [0.0; 2], | |
| 760 | definitions: BTreeMap::new(), | |
| 761 | objects: vec![ | |
| 762 | outline(2.0, "first"), | |
| 763 | PageObject::Image(Image { | |
| 764 | id: ExGuid::default(), | |
| 765 | layout: Layout { | |
| 766 | x: Some(5.0), | |
| 767 | y: Some(7.0), | |
| 768 | max_width: Some(40.0), | |
| 769 | max_height: Some(30.0), | |
| 770 | ..Default::default() | |
| 771 | }, | |
| 772 | bytes: Some(Arc::from(bytes.clone())), | |
| 773 | alt: None, | |
| 774 | background: false, | |
| 775 | }), | |
| 776 | outline(50.0, "last"), | |
| 777 | ], | |
| 778 | }; | |
| 779 | let mut engine = TextEngine::default(); | |
| 780 | let reference = PageScene::new(page(), &mut engine).unwrap(); | |
| 781 | let (scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap(); | |
| 782 | let mut reference_primitives = Vec::new(); | |
| 783 | reference | |
| 784 | .append_primitives(&mut reference_primitives, [0.0; 2]) | |
| 785 | .unwrap(); | |
| 786 | let image_id; | |
| 787 | { | |
| 788 | let actual = paint_editor(&scene, &editor); | |
| 789 | assert_eq!(actual.len(), 3); | |
| 790 | for (actual, expected) in actual.iter().zip(&reference_primitives) { | |
| 791 | match (actual, expected) { | |
| 792 | ( | |
| 793 | Primitive::Text { | |
| 794 | layout: a, | |
| 795 | origin: x, | |
| 796 | }, | |
| 797 | Primitive::Text { | |
| 798 | layout: b, | |
| 799 | origin: y, | |
| 800 | }, | |
| 801 | ) => { | |
| 802 | assert_eq!(x, y); | |
| 803 | assert_eq!( | |
| 804 | a.lines().next().unwrap().0.metrics().advance, | |
| 805 | b.lines().next().unwrap().0.metrics().advance | |
| 806 | ); | |
| 807 | } | |
| 808 | ( | |
| 809 | Primitive::Image { image: a, rect: x }, | |
| 810 | Primitive::Image { image: b, rect: y }, | |
| 811 | ) => { | |
| 812 | assert_eq!(x, y); | |
| 813 | assert_eq!(a.pixels.as_ref(), b.pixels.as_ref()); | |
| 814 | } | |
| 815 | _ => panic!("paint order changed"), | |
| 816 | } | |
| 817 | } | |
| 818 | let Primitive::Image { image, .. } = &actual[1] else { | |
| 819 | panic!() | |
| 820 | }; | |
| 821 | image_id = image.pixels.id(); | |
| 822 | } | |
| 823 | let original = editor.active_outline().document().clone(); | |
| 824 | editor.insert(&mut engine, "new words ").unwrap(); | |
| 825 | { | |
| 826 | let actual = paint_editor(&scene, &editor); | |
| 827 | let Primitive::Text { layout, .. } = &actual[0] else { | |
| 828 | panic!() | |
| 829 | }; | |
| 830 | let Primitive::Text { layout: before, .. } = &reference_primitives[0] else { | |
| 831 | panic!() | |
| 832 | }; | |
| 833 | assert!( | |
| 834 | layout.lines().next().unwrap().0.metrics().advance | |
| 835 | > before.lines().next().unwrap().0.metrics().advance | |
| 836 | ); | |
| 837 | let Primitive::Image { image, .. } = &actual[1] else { | |
| 838 | panic!() | |
| 839 | }; | |
| 840 | assert_eq!(image.pixels.id(), image_id); | |
| 841 | } | |
| 842 | editor.undo(&mut engine).unwrap(); | |
| 843 | assert_eq!(editor.active_outline().document(), &original); | |
| 844 | let actual = paint_editor(&scene, &editor); | |
| 845 | let Primitive::Text { layout, .. } = &actual[0] else { | |
| 846 | panic!() | |
| 847 | }; | |
| 848 | let Primitive::Text { layout: before, .. } = &reference_primitives[0] else { | |
| 849 | panic!() | |
| 850 | }; | |
| 851 | assert_eq!( | |
| 852 | layout.lines().next().unwrap().0.metrics().advance, | |
| 853 | before.lines().next().unwrap().0.metrics().advance | |
| 854 | ); | |
| 855 | assert!(matches!( | |
| 856 | scene.append_primitives(&mut Vec::new(), [0.0; 2]), | |
| 857 | Err(SceneError::MissingOutline) | |
| 858 | )); | |
| 859 | } | |
| 860 | ||
| 861 | #[test] | |
| 862 | fn scene_owns_decoded_images_and_reuses_identity_across_translated_frames() { | |
| 863 | let mut encoded = Vec::new(); | |
| 864 | { | |
| 865 | let mut encoder = png::Encoder::new(&mut encoded, 1, 1); | |
| 866 | encoder.set_color(png::ColorType::Rgba); | |
| 867 | encoder.set_depth(png::BitDepth::Eight); | |
| 868 | encoder | |
| 869 | .write_header() | |
| 870 | .unwrap() | |
| 871 | .write_image_data(&[255, 0, 0, 255]) | |
| 872 | .unwrap(); | |
| 873 | } | |
| 874 | let page = |width| Page { | |
| 875 | title: String::new(), | |
| 876 | margin_origin: [0.0; 2], | |
| 877 | definitions: BTreeMap::new(), | |
| 878 | objects: vec![PageObject::Image(Image { | |
| 879 | id: ExGuid::default(), | |
| 880 | layout: Layout { | |
| 881 | x: Some(2.0), | |
| 882 | y: Some(5.0), | |
| 883 | max_width: Some(width), | |
| 884 | max_height: Some(4.0), | |
| 885 | ..Default::default() | |
| 886 | }, | |
| 887 | bytes: Some(Arc::from(encoded.clone())), | |
| 888 | alt: None, | |
| 889 | background: false, | |
| 890 | })], | |
| 891 | }; | |
| 892 | let mut engine = TextEngine::default(); | |
| 893 | let scene = PageScene::new(page(3.0), &mut engine).unwrap(); | |
| 894 | for invalid in [f32::NAN, f32::INFINITY, -1.0, 0.0] { | |
| 895 | assert!(matches!( | |
| 896 | PageScene::new(page(invalid), &mut engine), | |
| 897 | Err(SceneError::InvalidGeometry) | |
| 898 | )); | |
| 899 | } | |
| 900 | let mut primitives = Vec::new(); | |
| 901 | scene.append_primitives(&mut primitives, [0.0; 2]).unwrap(); | |
| 902 | scene | |
| 903 | .append_primitives(&mut primitives, [10.0, -20.0]) | |
| 904 | .unwrap(); | |
| 905 | let [ | |
| 906 | Primitive::Image { | |
| 907 | image: first, | |
| 908 | rect: a, | |
| 909 | }, | |
| 910 | Primitive::Image { | |
| 911 | image: second, | |
| 912 | rect: b, | |
| 913 | }, | |
| 914 | ] = primitives.as_slice() | |
| 915 | else { | |
| 916 | panic!() | |
| 917 | }; | |
| 918 | assert_eq!(*a, [2.0, 5.0, 5.0, 9.0]); | |
| 919 | assert_eq!(*b, [12.0, -15.0, 15.0, -11.0]); | |
| 920 | assert_eq!(first.pixels.id(), second.pixels.id()); | |
| 921 | assert_eq!(first.pixels.as_ref(), [255, 0, 0, 255]); | |
| 922 | } | |
| 923 | } |
crates/canvas-gpu/src/profile.rs created+150| ... | ... | @@ -0,0 +1,150 @@ |
| 1 | use super::*; | |
| 2 | use one_canvas::{layout::TextEngine, page::Page, text::Paragraph}; | |
| 3 | use std::{ | |
| 4 | sync::Arc, | |
| 5 | time::{Duration, Instant}, | |
| 6 | }; | |
| 7 | ||
| 8 | #[test] | |
| 9 | #[ignore = "manual release GPU timing and cache occupancy probe"] | |
| 10 | fn renderer_cost() { | |
| 11 | let mut engine = TextEngine::default(); | |
| 12 | if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { | |
| 13 | engine | |
| 14 | .register_substitute(Blob::new(Arc::new(std::fs::read(font).unwrap()))) | |
| 15 | .unwrap(); | |
| 16 | } | |
| 17 | let source = std::env::var_os("CANVAS_TEST_SECTION").map(|path| std::fs::read(path).unwrap()); | |
| 18 | let title = std::env::var("CANVAS_TEST_PAGE").unwrap_or_default(); | |
| 19 | let mut scene = source | |
| 20 | .as_ref() | |
| 21 | .map(|bytes| load_scene(bytes, &title, &mut engine)); | |
| 22 | let layout = source.is_none().then(|| { | |
| 23 | engine | |
| 24 | .layout( | |
| 25 | &Paragraph::new( | |
| 26 | "A paragraph with café, trees 🌳 and a few words.\n".repeat(2000), | |
| 27 | Default::default(), | |
| 28 | ), | |
| 29 | 360.0, | |
| 30 | ) | |
| 31 | .unwrap() | |
| 32 | }); | |
| 33 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | |
| 34 | let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); | |
| 35 | eprintln!("canvas_gpu_adapter\t{:?}", adapter.get_info()); | |
| 36 | let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap(); | |
| 37 | let size = [1386, 759]; | |
| 38 | let format = wgpu::TextureFormat::Rgba8UnormSrgb; | |
| 39 | let target = device.create_texture(&wgpu::TextureDescriptor { | |
| 40 | label: Some("Canvas profile target"), | |
| 41 | size: wgpu::Extent3d { | |
| 42 | width: size[0], | |
| 43 | height: size[1], | |
| 44 | depth_or_array_layers: 1, | |
| 45 | }, | |
| 46 | mip_level_count: 1, | |
| 47 | sample_count: 1, | |
| 48 | dimension: wgpu::TextureDimension::D2, | |
| 49 | format, | |
| 50 | usage: wgpu::TextureUsages::RENDER_ATTACHMENT, | |
| 51 | view_formats: &[], | |
| 52 | }); | |
| 53 | let view = target.create_view(&Default::default()); | |
| 54 | let mut renderer = Renderer::new(device, queue, format); | |
| 55 | eprintln!( | |
| 56 | "canvas_gpu_allocated_bytes\tatlas\t{}\tvertex_buffer\t{}\ttarget\t{}", | |
| 57 | u64::from(renderer.atlas.width()) * u64::from(renderer.atlas.height()) * 4, | |
| 58 | renderer.vertex_buffer.size(), | |
| 59 | u64::from(size[0]) * u64::from(size[1]) * 4 | |
| 60 | ); | |
| 61 | for phase in [ | |
| 62 | "warm", | |
| 63 | "pan", | |
| 64 | "zoom", | |
| 65 | "evict", | |
| 66 | "offscreen_x", | |
| 67 | "offscreen_y", | |
| 68 | "reopen", | |
| 69 | ] { | |
| 70 | if phase == "reopen" && source.is_none() { | |
| 71 | continue; | |
| 72 | } | |
| 73 | if phase.starts_with("offscreen") { | |
| 74 | renderer.clear_glyph_cache(); | |
| 75 | renderer.images.clear(); | |
| 76 | } | |
| 77 | for sample in 0..32 { | |
| 78 | if phase == "evict" { | |
| 79 | renderer.clear_glyph_cache(); | |
| 80 | renderer.images.clear(); | |
| 81 | } | |
| 82 | let mut viewport = Viewport { | |
| 83 | size, | |
| 84 | scale: 96.0 / 72.0, | |
| 85 | origin: [0.0; 2], | |
| 86 | }; | |
| 87 | match phase { | |
| 88 | "pan" => viewport.origin[1] = -(sample as f32 * 63.25), | |
| 89 | "zoom" => viewport.scale *= [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0][sample % 8], | |
| 90 | "offscreen_x" => viewport.origin[0] = 1_000_000.0, | |
| 91 | "offscreen_y" => viewport.origin[1] = 1_000_000.0, | |
| 92 | "reopen" => { | |
| 93 | scene = Some(load_scene(source.as_ref().unwrap(), &title, &mut engine)); | |
| 94 | } | |
| 95 | _ => {} | |
| 96 | } | |
| 97 | let mut primitives = Vec::new(); | |
| 98 | if let Some(scene) = &scene { | |
| 99 | scene.append_primitives(&mut primitives, [0.0; 2]).unwrap(); | |
| 100 | } else { | |
| 101 | primitives.push(Primitive::Text { | |
| 102 | layout: layout.as_ref().unwrap(), | |
| 103 | origin: [36.0, 90.0], | |
| 104 | }); | |
| 105 | } | |
| 106 | let start = Instant::now(); | |
| 107 | renderer.draw(&view, viewport, &primitives).unwrap(); | |
| 108 | let submitted = start.elapsed().as_nanos(); | |
| 109 | renderer | |
| 110 | .device | |
| 111 | .poll(wgpu::PollType::Wait { | |
| 112 | submission_index: None, | |
| 113 | timeout: Some(Duration::from_secs(10)), | |
| 114 | }) | |
| 115 | .unwrap(); | |
| 116 | let completed = start.elapsed().as_nanos(); | |
| 117 | let image_bytes: u64 = renderer.images.values().map(|image| image.bytes).sum(); | |
| 118 | let atlas_texels: u64 = renderer | |
| 119 | .glyphs | |
| 120 | .values() | |
| 121 | .flatten() | |
| 122 | .map(|glyph| u64::from(glyph.width) * u64::from(glyph.height)) | |
| 123 | .sum(); | |
| 124 | assert!(renderer.glyphs.len() <= MAX_GLYPHS); | |
| 125 | assert!(renderer.images.len() <= MAX_IMAGES && image_bytes <= MAX_IMAGE_BYTES); | |
| 126 | assert!(renderer.vertices.len() <= MAX_VERTICES); | |
| 127 | eprintln!( | |
| 128 | "canvas_gpu_sample\t{phase}\t{sample}\t{submitted}\t{completed}\t{}\t{atlas_texels}\t{}\t{image_bytes}\t{}\t{}\t{}\t{}\t{}\t{}", | |
| 129 | renderer.glyphs.len(), | |
| 130 | renderer.images.len(), | |
| 131 | renderer.vertices.len(), | |
| 132 | renderer.batches.len(), | |
| 133 | renderer.vertices.capacity() * size_of::<Vertex>(), | |
| 134 | renderer.batches.capacity() * size_of::<Batch>(), | |
| 135 | renderer.glyphs.capacity(), | |
| 136 | renderer.images.capacity() | |
| 137 | ); | |
| 138 | } | |
| 139 | } | |
| 140 | } | |
| 141 | ||
| 142 | fn load_scene(bytes: &[u8], title: &str, engine: &mut TextEngine) -> page::PageScene { | |
| 143 | let store = onestore::Store::parse(bytes).unwrap(); | |
| 144 | let index = onestore::RevisionIndex::parse(&store).unwrap(); | |
| 145 | page::PageScene::new( | |
| 146 | Page::from_document(&onestore::document::Document::parse(&index).unwrap(), title).unwrap(), | |
| 147 | engine, | |
| 148 | ) | |
| 149 | .unwrap() | |
| 150 | } |
crates/canvas-gpu/src/tags.rs created+120| ... | ... | @@ -0,0 +1,120 @@ |
| 1 | use one_canvas::outline::TagIcon; | |
| 2 | use swash::{ | |
| 3 | scale::image::{Content, Image}, | |
| 4 | zeno::{Mask, Placement, Transform, Vector}, | |
| 5 | }; | |
| 6 | ||
| 7 | pub(crate) fn rasterize(icon: TagIcon, size: f32, phase: [u8; 2]) -> Image { | |
| 8 | let layers = match icon { | |
| 9 | TagIcon::CheckBox { checked } => { | |
| 10 | let mut layers = vec![ | |
| 11 | ("M0 0H16V16H0Z", 0xf7b691, 0xef9864), | |
| 12 | ("M1 1H15V15H1Z", 0xffffff, 0xffffff), | |
| 13 | ("M2 2H14V14H2Z", 0xf6af7c, 0xe98755), | |
| 14 | ("M3 3H13V13H3Z", 0xffffff, 0xf5e6d6), | |
| 15 | ]; | |
| 16 | if checked { | |
| 17 | layers.push(("M2 8L6 11L13 3L14 5L6 14L1 10Z", 0x778cf4, 0x2544c8)); | |
| 18 | } | |
| 19 | layers | |
| 20 | } | |
| 21 | TagIcon::Question => vec![( | |
| 22 | "M8 5C8 1 15 1 15 5C15 7 11 8 11 10H9C9 6 13 6 13 5C13 3 10 3 10 5ZM9 12H11V14H9Z", | |
| 23 | 0xe83ace, | |
| 24 | 0x8f008f, | |
| 25 | )], | |
| 26 | TagIcon::Music => vec![ | |
| 27 | ( | |
| 28 | "M7 1C10 1 9 3 12 4C14 5 15 7 13 9C13 6 10 7 9 5V11C9 15 2 17 1 13C0 10 4 8 7 9Z", | |
| 29 | 0xf0bb9c, | |
| 30 | 0xa35546, | |
| 31 | ), | |
| 32 | ("M2 11C3 9 6 9 7 10C7 11 4 13 2 12Z", 0xffe8dc, 0xda9786), | |
| 33 | ], | |
| 34 | }; | |
| 35 | let width = (size + 1.0).ceil() as u32; | |
| 36 | let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize]; | |
| 37 | for (path, top, bottom) in layers { | |
| 38 | let (mask, _) = Mask::new(path) | |
| 39 | .transform(Some(Transform::scale(size / 16.0, size / 16.0))) | |
| 40 | .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25)) | |
| 41 | .size(width, width) | |
| 42 | .render(); | |
| 43 | let top = super::colorref(top); | |
| 44 | let bottom = super::colorref(bottom); | |
| 45 | for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { | |
| 46 | let alpha = f32::from(coverage) / 255.0; | |
| 47 | let y = ((index / width as usize) as f32 / size).min(1.0); | |
| 48 | for channel in 0..3 { | |
| 49 | let color = top[channel] + (bottom[channel] - top[channel]) * y; | |
| 50 | pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha); | |
| 51 | } | |
| 52 | pixel[3] = alpha + pixel[3] * (1.0 - alpha); | |
| 53 | } | |
| 54 | } | |
| 55 | let data = pixels | |
| 56 | .into_iter() | |
| 57 | .flat_map(|pixel| { | |
| 58 | let alpha = pixel[3]; | |
| 59 | let channel = |value: f32| { | |
| 60 | let value = if alpha > 0.0 { value / alpha } else { 0.0 }; | |
| 61 | super::srgb_byte(value) | |
| 62 | }; | |
| 63 | [ | |
| 64 | channel(pixel[0]), | |
| 65 | channel(pixel[1]), | |
| 66 | channel(pixel[2]), | |
| 67 | (alpha * 255.0).round() as u8, | |
| 68 | ] | |
| 69 | }) | |
| 70 | .collect(); | |
| 71 | Image { | |
| 72 | content: Content::Color, | |
| 73 | placement: Placement { | |
| 74 | left: 0, | |
| 75 | top: 0, | |
| 76 | width, | |
| 77 | height: width, | |
| 78 | }, | |
| 79 | data, | |
| 80 | ..Image::default() | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | #[cfg(test)] | |
| 85 | mod tests { | |
| 86 | use super::*; | |
| 87 | ||
| 88 | #[test] | |
| 89 | fn tag_masks_keep_transparency_and_checked_state_across_scales() { | |
| 90 | for size in [8.0, 16.0, 32.0, 57.5] { | |
| 91 | for phase in [[0, 0], [1, 2], [3, 3]] { | |
| 92 | let mut masks = Vec::new(); | |
| 93 | for icon in [ | |
| 94 | TagIcon::CheckBox { checked: false }, | |
| 95 | TagIcon::CheckBox { checked: true }, | |
| 96 | TagIcon::Question, | |
| 97 | TagIcon::Music, | |
| 98 | ] { | |
| 99 | let image = rasterize(icon, size, phase); | |
| 100 | assert_eq!( | |
| 101 | image.data.len(), | |
| 102 | (image.placement.width * image.placement.height * 4) as usize | |
| 103 | ); | |
| 104 | assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); | |
| 105 | assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); | |
| 106 | assert!( | |
| 107 | image | |
| 108 | .data | |
| 109 | .chunks_exact(4) | |
| 110 | .filter(|p| p[3] == 0) | |
| 111 | .all(|p| p[..3] == [0; 3]) | |
| 112 | ); | |
| 113 | masks.push(image.data); | |
| 114 | } | |
| 115 | assert_ne!(masks[0], masks[1]); | |
| 116 | assert!(masks[1].chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); | |
| 117 | } | |
| 118 | } | |
| 119 | } | |
| 120 | } |
crates/canvas/Cargo.toml created+21| ... | ... | @@ -0,0 +1,21 @@ |
| 1 | [package] | |
| 2 | name = "one-canvas" | |
| 3 | version = "0.1.0" | |
| 4 | edition = "2024" | |
| 5 | publish = false | |
| 6 | ||
| 7 | [dependencies] | |
| 8 | onestore = { path = "../onestore" } | |
| 9 | parley.workspace = true | |
| 10 | serde = { version = "1.0.229", features = ["derive"] } | |
| 11 | serde_json = "1.0.151" | |
| 12 | skrifa = "0.44.0" | |
| 13 | getrandom = "0.4.3" | |
| 14 | ||
| 15 | [[bin]] | |
| 16 | name = "layout-probe" | |
| 17 | path = "src/bin/layout_probe.rs" | |
| 18 | ||
| 19 | [[bin]] | |
| 20 | name = "page-probe" | |
| 21 | path = "src/bin/page_probe.rs" |
crates/canvas/src/bin/layout_probe.rs created+116| ... | ... | @@ -0,0 +1,116 @@ |
| 1 | use one_canvas::{layout::TextEngine, text::Paragraph}; | |
| 2 | use onestore::document::Format; | |
| 3 | use serde::{Deserialize, Serialize}; | |
| 4 | use serde_json::json; | |
| 5 | use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId}; | |
| 6 | use std::{env, fs, io, sync::Arc}; | |
| 7 | ||
| 8 | #[derive(Deserialize)] | |
| 9 | #[serde(deny_unknown_fields)] | |
| 10 | struct Case { | |
| 11 | id: String, | |
| 12 | width: f32, | |
| 13 | runs: Vec<Run>, | |
| 14 | } | |
| 15 | ||
| 16 | #[derive(Deserialize, Serialize)] | |
| 17 | #[serde(deny_unknown_fields)] | |
| 18 | struct Run { | |
| 19 | text: String, | |
| 20 | font: String, | |
| 21 | size: f32, | |
| 22 | bold: bool, | |
| 23 | italic: bool, | |
| 24 | } | |
| 25 | ||
| 26 | fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| 27 | let mut args = env::args_os().skip(1); | |
| 28 | let path = args.next().ok_or("Expected a text-case JSON file")?; | |
| 29 | let cases: Vec<Case> = serde_json::from_slice(&fs::read(path)?)?; | |
| 30 | let mut engine = TextEngine::default(); | |
| 31 | while let Some(path) = args.next() { | |
| 32 | if path == "--substitute-font" { | |
| 33 | let path = args | |
| 34 | .next() | |
| 35 | .ok_or("Provide a font file after --substitute-font.")?; | |
| 36 | engine.register_substitute(parley::fontique::Blob::new(Arc::new(fs::read(path)?)))?; | |
| 37 | continue; | |
| 38 | } | |
| 39 | let data = parley::fontique::Blob::new(Arc::new(fs::read(path)?)); | |
| 40 | if engine | |
| 41 | .fonts | |
| 42 | .collection | |
| 43 | .register_fonts(data, None) | |
| 44 | .is_empty() | |
| 45 | { | |
| 46 | return Err("The supplied file contains no supported fonts".into()); | |
| 47 | } | |
| 48 | } | |
| 49 | let mut results = Vec::new(); | |
| 50 | for case in cases { | |
| 51 | if !case.width.is_finite() || case.width <= 0.0 || case.runs.is_empty() { | |
| 52 | return Err( | |
| 53 | format!("{}: expected positive width and at least one run", case.id).into(), | |
| 54 | ); | |
| 55 | } | |
| 56 | let text: String = case.runs.iter().map(|r| r.text.as_str()).collect(); | |
| 57 | let paragraph = Paragraph::from_runs(case.runs.iter().map(|run| { | |
| 58 | ( | |
| 59 | run.text.clone(), | |
| 60 | Format { | |
| 61 | font: Some(run.font.clone()), | |
| 62 | font_size: Some(run.size), | |
| 63 | bold: Some(run.bold), | |
| 64 | italic: Some(run.italic), | |
| 65 | ..Format::default() | |
| 66 | }, | |
| 67 | ) | |
| 68 | })); | |
| 69 | let layout = engine | |
| 70 | .layout(&paragraph, case.width) | |
| 71 | .map_err(|error| format!("{}: {error}", case.id))?; | |
| 72 | let lines: Vec<_> = layout.lines().map(|(line, native)| { | |
| 73 | let range = native.source.clone(); | |
| 74 | let metrics = line.metrics(); | |
| 75 | let runs: Vec<_> = line.runs().map(|run| { | |
| 76 | let data = &run.font().font; | |
| 77 | let font = FontRef::from_index(data.data.as_ref(), data.index) | |
| 78 | .expect("Parley returned an unreadable font"); | |
| 79 | let os2 = font.os2().ok(); | |
| 80 | json!({ | |
| 81 | "face": font.localized_strings(StringId::POSTSCRIPT_NAME).english_or_first().map(|s| s.to_string()), | |
| 82 | "version": font.localized_strings(StringId::VERSION_STRING).english_or_first().map(|s| s.to_string()), | |
| 83 | "size": run.font_size(), | |
| 84 | "normalized_variation_coords": run.normalized_coords().iter().map(|c| c.to_bits()).collect::<Vec<_>>(), | |
| 85 | "units_per_em": font.head().ok().map(|h| h.units_per_em()), | |
| 86 | "win_ascent": os2.as_ref().map(|o| o.us_win_ascent()), | |
| 87 | "win_descent": os2.as_ref().map(|o| o.us_win_descent()), | |
| 88 | }) | |
| 89 | }).collect(); | |
| 90 | json!({ | |
| 91 | "start_utf16": text[..range.start].encode_utf16().count(), | |
| 92 | "end_utf16": text[..range.end].encode_utf16().count(), | |
| 93 | "text": &text[range], | |
| 94 | "advance": metrics.advance, | |
| 95 | "trailing_whitespace": metrics.trailing_whitespace, | |
| 96 | "baseline": metrics.baseline, | |
| 97 | "height": metrics.line_height, | |
| 98 | "windows_baseline": native.baseline, | |
| 99 | "windows_height": native.height, | |
| 100 | "runs": runs, | |
| 101 | }) | |
| 102 | }).collect(); | |
| 103 | results.push( | |
| 104 | json!({"id": case.id, "width": case.width, "requested_runs": case.runs, | |
| 105 | "height": lines.iter().map(|l| l["height"].as_f64().unwrap()).sum::<f64>(), | |
| 106 | "windows_height": layout.height(), "lines": lines}), | |
| 107 | ); | |
| 108 | } | |
| 109 | serde_json::to_writer_pretty( | |
| 110 | io::stdout().lock(), | |
| 111 | &json!({ | |
| 112 | "engine": "parley", "units": "points", "cases": results, | |
| 113 | }), | |
| 114 | )?; | |
| 115 | Ok(()) | |
| 116 | } |
crates/canvas/src/bin/page_probe.rs created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | use one_canvas::{ | |
| 2 | layout::TextEngine, | |
| 3 | outline::OutlineLayout, | |
| 4 | page::{Outline, Page, PageObject}, | |
| 5 | }; | |
| 6 | use onestore::{RevisionIndex, Store, document::Document}; | |
| 7 | use serde_json::json; | |
| 8 | use std::{env, fs, io, sync::Arc, time::Instant}; | |
| 9 | ||
| 10 | fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| 11 | let mut args = env::args().skip(1); | |
| 12 | let path = args | |
| 13 | .next() | |
| 14 | .ok_or("Expected a section snapshot and exact page title")?; | |
| 15 | let title = args.next().ok_or("Expected an exact page title")?; | |
| 16 | let mut substitutes = Vec::new(); | |
| 17 | while let Some(option) = args.next() { | |
| 18 | if option != "--substitute-font" { | |
| 19 | return Err(format!("Unknown option: {option}").into()); | |
| 20 | } | |
| 21 | substitutes.push( | |
| 22 | args.next() | |
| 23 | .ok_or("Provide a font file after --substitute-font.")?, | |
| 24 | ); | |
| 25 | } | |
| 26 | let start = Instant::now(); | |
| 27 | let bytes = fs::read(path)?; | |
| 28 | let store = Store::parse(&bytes)?; | |
| 29 | let index = RevisionIndex::parse(&store)?; | |
| 30 | let document = Document::parse(&index)?; | |
| 31 | let page = Page::from_document(&document, &title)?; | |
| 32 | drop(document); | |
| 33 | drop(index); | |
| 34 | drop(store); | |
| 35 | drop(bytes); | |
| 36 | let import_ms = start.elapsed().as_secs_f64() * 1000.0; | |
| 37 | let start = Instant::now(); | |
| 38 | let mut engine = TextEngine::default(); | |
| 39 | for path in substitutes { | |
| 40 | engine.register_substitute(parley::fontique::Blob::new(Arc::new(fs::read(path)?)))?; | |
| 41 | } | |
| 42 | let mut objects = Vec::new(); | |
| 43 | let mut title_areas = Vec::new(); | |
| 44 | for object in &page.objects { | |
| 45 | match object { | |
| 46 | PageObject::Outline(outline) => objects.push(outline_json( | |
| 47 | outline, | |
| 48 | &outline.layout(&mut engine, &page.definitions)?, | |
| 49 | None, | |
| 50 | )?), | |
| 51 | PageObject::Title(title) => { | |
| 52 | title_areas.push(json!({"id": title.id, "layout": title.layout, | |
| 53 | "outlines": title.outlines.iter().map(|o| o.id).collect::<Vec<_>>()})); | |
| 54 | for (outline, (origin, layout)) in title | |
| 55 | .outlines | |
| 56 | .iter() | |
| 57 | .zip(title.layout(&mut engine, &page.definitions)?) | |
| 58 | { | |
| 59 | objects.push(outline_json(outline, &layout, Some(origin))?); | |
| 60 | } | |
| 61 | } | |
| 62 | PageObject::Image(image) => objects.push(json!({"kind": "image", "id": image.id, | |
| 63 | "layout": image.layout, "bytes": image.bytes.as_ref().map(|b| b.len()), | |
| 64 | "alt": image.alt, "background": image.background})), | |
| 65 | PageObject::Unsupported(object) => objects.push(json!({"kind": "unsupported", | |
| 66 | "id": object.id, "layout": object.layout, "jcid": object.jcid})), | |
| 67 | } | |
| 68 | } | |
| 69 | serde_json::to_writer_pretty( | |
| 70 | io::stdout().lock(), | |
| 71 | &json!({ | |
| 72 | "title": page.title, "title_areas": title_areas, "margin_origin": page.margin_origin, "objects": objects, | |
| 73 | "definitions": page.definitions.iter().map(|(id, definition)| (id.to_string(), json!({ | |
| 74 | "kind": definition.kind, "format": definition.format, | |
| 75 | }))).collect::<serde_json::Map<_, _>>(), "import_ms": import_ms, | |
| 76 | "layout_ms": start.elapsed().as_secs_f64() * 1000.0, | |
| 77 | "measurement": "Outline layout applies indentation, paragraph spacing, collapsed descendants and bullet height; titles use retained parent geometry and stacked child outlines; tags and numbered markers are separate", | |
| 78 | }), | |
| 79 | )?; | |
| 80 | Ok(()) | |
| 81 | } | |
| 82 | ||
| 83 | fn outline_json( | |
| 84 | outline: &Outline, | |
| 85 | laid_out: &OutlineLayout, | |
| 86 | title_origin: Option<[f32; 2]>, | |
| 87 | ) -> Result<serde_json::Value, Box<dyn std::error::Error>> { | |
| 88 | let mut paragraphs = Vec::new(); | |
| 89 | for paragraph in &outline.paragraphs { | |
| 90 | let placed = laid_out.paragraphs.iter().find(|p| p.id == paragraph.id); | |
| 91 | let source = paragraph.combined_text(); | |
| 92 | let projection; | |
| 93 | let visible = if let Some(placed) = placed { | |
| 94 | placed.projection.text() | |
| 95 | } else { | |
| 96 | projection = source.project()?; | |
| 97 | projection.text() | |
| 98 | }; | |
| 99 | let layout = placed.map(|p| &p.text); | |
| 100 | let lines: Vec<_> = layout | |
| 101 | .as_ref() | |
| 102 | .into_iter() | |
| 103 | .flat_map(|layout| layout.lines()) | |
| 104 | .map(|(line, bounds)| { | |
| 105 | let range = bounds.source.clone(); | |
| 106 | json!({ | |
| 107 | "start_utf16": visible.utf16_offset(range.start).unwrap(), | |
| 108 | "end_utf16": visible.utf16_offset(range.end).unwrap(), | |
| 109 | "text": &visible.text()[range], | |
| 110 | "advance": line.metrics().advance, | |
| 111 | "baseline": bounds.baseline, | |
| 112 | "height": bounds.height, | |
| 113 | }) | |
| 114 | }) | |
| 115 | .collect(); | |
| 116 | paragraphs.push(json!({ | |
| 117 | "id": paragraph.id, "parent": paragraph.parent, "level": paragraph.level, | |
| 118 | "format": paragraph.format, "collapsed": paragraph.collapsed, | |
| 119 | "lists": paragraph.lists, | |
| 120 | "tags": paragraph.tags.iter().chain(paragraph.text.iter().flat_map(|t| &t.tags)).collect::<Vec<_>>(), | |
| 121 | "source_utf16": source.text().encode_utf16().count(), | |
| 122 | "visible_utf16": visible.text().encode_utf16().count(), | |
| 123 | "visible_text": visible.text(), "spans": visible.spans().iter().map(|s| json!({"end_utf8": s.end, "format": s.format})).collect::<Vec<_>>(), | |
| 124 | "unsupported": paragraph.unsupported.iter().map(|u| u.jcid).collect::<Vec<_>>(), | |
| 125 | "height": layout.as_ref().map(|l| l.height()), "lines": lines, | |
| 126 | "origin": placed.map(|p| p.origin), | |
| 127 | "tag_icons": placed.map(|p| p.tags.iter().map(|tag| json!({ | |
| 128 | "icon": format!("{:?}", tag.icon), "label": tag.label, "disabled": tag.disabled, | |
| 129 | "origin": [tag.origin[0], tag.origin[1] + p.origin[1]], | |
| 130 | })).collect::<Vec<_>>()), | |
| 131 | "markers": placed.map(|p| p.markers.iter().map(|(layout, origin)| json!({ | |
| 132 | "origin": [origin[0], origin[1] + p.origin[1]], "height": layout.height(), | |
| 133 | "baseline": layout.lines().next().unwrap().1.baseline, | |
| 134 | "advance": layout.lines().next().unwrap().0.metrics().advance, | |
| 135 | })).collect::<Vec<_>>()), | |
| 136 | })); | |
| 137 | } | |
| 138 | Ok( | |
| 139 | json!({"kind": "outline", "id": outline.id, "layout": outline.layout, | |
| 140 | "indents": outline.indents, "is_title": title_origin.is_some(), "title_origin": title_origin, "paragraphs": paragraphs, | |
| 141 | "size": laid_out.size, | |
| 142 | "unsupported": outline.unsupported.iter().map(|u| u.jcid).collect::<Vec<_>>() }), | |
| 143 | ) | |
| 144 | } |
crates/canvas/src/document.rs created+698| ... | ... | @@ -0,0 +1,698 @@ |
| 1 | use crate::{ | |
| 2 | page::{PageParagraph, TextObject}, | |
| 3 | text::{EditError, Paragraph, new_id}, | |
| 4 | }; | |
| 5 | use onestore::document::Format; | |
| 6 | use std::{ | |
| 7 | collections::{BTreeMap, BTreeSet}, | |
| 8 | ops::Range, | |
| 9 | }; | |
| 10 | ||
| 11 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] | |
| 12 | pub struct TextPosition { | |
| 13 | pub paragraph: usize, | |
| 14 | pub offset: u32, | |
| 15 | } | |
| 16 | ||
| 17 | /// Each editable paragraph owns one rich-text object; structural edits require flat content. | |
| 18 | #[derive(Clone, Debug, PartialEq)] | |
| 19 | pub struct TextDocument { | |
| 20 | nodes: Vec<PageParagraph>, | |
| 21 | } | |
| 22 | ||
| 23 | #[derive(Clone, Debug, PartialEq)] | |
| 24 | pub(crate) struct DocumentEdit { | |
| 25 | pub range: Range<usize>, | |
| 26 | pub replacement: Vec<PageParagraph>, | |
| 27 | } | |
| 28 | ||
| 29 | fn node(text: Paragraph, format: Format) -> Result<PageParagraph, EditError> { | |
| 30 | Ok(PageParagraph { | |
| 31 | id: new_id()?, | |
| 32 | parent: None, | |
| 33 | level: 1, | |
| 34 | format, | |
| 35 | text: vec![TextObject { | |
| 36 | id: new_id()?, | |
| 37 | text, | |
| 38 | tags: Vec::new(), | |
| 39 | }], | |
| 40 | unsupported: Vec::new(), | |
| 41 | lists: Vec::new(), | |
| 42 | tags: Vec::new(), | |
| 43 | collapsed: false, | |
| 44 | }) | |
| 45 | } | |
| 46 | ||
| 47 | fn validate_nodes<'a>(nodes: impl Iterator<Item = &'a PageParagraph>) -> Result<(), EditError> { | |
| 48 | let mut ids = BTreeSet::new(); | |
| 49 | let mut parents = BTreeMap::new(); | |
| 50 | for node in nodes { | |
| 51 | if node.text.len() != 1 || !node.unsupported.is_empty() { | |
| 52 | return Err(EditError::UnsupportedContent); | |
| 53 | } | |
| 54 | if !ids.insert(node.id) || !ids.insert(node.text[0].id) { | |
| 55 | return Err(EditError::InvalidStructure); | |
| 56 | } | |
| 57 | if node.level == 0 | |
| 58 | || node | |
| 59 | .parent | |
| 60 | .is_some_and(|id| parents.get(&id).is_none_or(|level| *level >= node.level)) | |
| 61 | { | |
| 62 | return Err(EditError::InvalidStructure); | |
| 63 | } | |
| 64 | parents.insert(node.id, node.level); | |
| 65 | } | |
| 66 | Ok(()) | |
| 67 | } | |
| 68 | ||
| 69 | fn validate_text(nodes: &[PageParagraph]) -> Result<(), EditError> { | |
| 70 | for node in nodes { | |
| 71 | let text = &node.text[0].text; | |
| 72 | text.utf16_offset(text.text().len())?; | |
| 73 | } | |
| 74 | Ok(()) | |
| 75 | } | |
| 76 | ||
| 77 | impl TextDocument { | |
| 78 | pub fn new(paragraphs: Vec<Paragraph>) -> Result<Self, EditError> { | |
| 79 | Self::from_nodes( | |
| 80 | paragraphs | |
| 81 | .into_iter() | |
| 82 | .map(|text| node(text, Format::default())) | |
| 83 | .collect::<Result<_, _>>()?, | |
| 84 | ) | |
| 85 | } | |
| 86 | ||
| 87 | pub fn from_nodes(nodes: Vec<PageParagraph>) -> Result<Self, EditError> { | |
| 88 | if nodes.is_empty() { | |
| 89 | return Err(EditError::InvalidRange); | |
| 90 | } | |
| 91 | validate_nodes(nodes.iter())?; | |
| 92 | validate_text(&nodes)?; | |
| 93 | Ok(Self { nodes }) | |
| 94 | } | |
| 95 | ||
| 96 | pub fn nodes(&self) -> &[PageParagraph] { | |
| 97 | &self.nodes | |
| 98 | } | |
| 99 | ||
| 100 | pub(crate) fn validate_flat(&self) -> Result<(), EditError> { | |
| 101 | if self.nodes.iter().any(|node| { | |
| 102 | !node.lists.is_empty() | |
| 103 | || !node.tags.is_empty() | |
| 104 | || node.collapsed | |
| 105 | || node.parent.is_some() | |
| 106 | || node.level != 1 | |
| 107 | || !node.text[0].tags.is_empty() | |
| 108 | }) { | |
| 109 | return Err(EditError::UnsupportedContent); | |
| 110 | } | |
| 111 | Ok(()) | |
| 112 | } | |
| 113 | ||
| 114 | pub fn paragraphs(&self) -> impl ExactSizeIterator<Item = &Paragraph> + DoubleEndedIterator { | |
| 115 | self.nodes.iter().map(|node| &node.text[0].text) | |
| 116 | } | |
| 117 | ||
| 118 | pub fn slice(&self, range: Range<TextPosition>) -> Result<Vec<Paragraph>, EditError> { | |
| 119 | if range.start > range.end { | |
| 120 | return Err(EditError::InvalidRange); | |
| 121 | } | |
| 122 | let nodes = self | |
| 123 | .nodes | |
| 124 | .get(range.start.paragraph..=range.end.paragraph) | |
| 125 | .ok_or(EditError::InvalidRange)?; | |
| 126 | nodes | |
| 127 | .iter() | |
| 128 | .enumerate() | |
| 129 | .map(|(index, node)| { | |
| 130 | let paragraph = &node.text[0].text; | |
| 131 | let start = if index == 0 { range.start.offset } else { 0 }; | |
| 132 | let end = if index == nodes.len() - 1 { | |
| 133 | range.end.offset | |
| 134 | } else { | |
| 135 | paragraph.utf16_offset(paragraph.text().len())? | |
| 136 | }; | |
| 137 | paragraph.slice(start..end) | |
| 138 | }) | |
| 139 | .collect() | |
| 140 | } | |
| 141 | ||
| 142 | pub(crate) fn replace( | |
| 143 | &self, | |
| 144 | range: Range<TextPosition>, | |
| 145 | replacement: Vec<Paragraph>, | |
| 146 | ) -> Result<DocumentEdit, EditError> { | |
| 147 | if range.start > range.end { | |
| 148 | return Err(EditError::InvalidRange); | |
| 149 | } | |
| 150 | if range.start.paragraph != range.end.paragraph || replacement.len() != 1 { | |
| 151 | self.validate_flat()?; | |
| 152 | } | |
| 153 | let first = self | |
| 154 | .nodes | |
| 155 | .get(range.start.paragraph) | |
| 156 | .ok_or(EditError::InvalidRange)?; | |
| 157 | let last = self | |
| 158 | .nodes | |
| 159 | .get(range.end.paragraph) | |
| 160 | .ok_or(EditError::InvalidRange)?; | |
| 161 | let mut prefix = first.text[0].text.slice(0..range.start.offset)?; | |
| 162 | let last_text = &last.text[0].text; | |
| 163 | let suffix = | |
| 164 | last_text.slice(range.end.offset..last_text.utf16_offset(last_text.text().len())?)?; | |
| 165 | let mut replacement = replacement.into_iter(); | |
| 166 | prefix.append(replacement.next().ok_or(EditError::InvalidRange)?)?; | |
| 167 | let mut head = first.clone(); | |
| 168 | head.text[0].text = prefix; | |
| 169 | let mut nodes = vec![head]; | |
| 170 | let following = replacement.len(); | |
| 171 | for (index, text) in replacement.enumerate() { | |
| 172 | if index + 1 == following && range.start.paragraph != range.end.paragraph { | |
| 173 | let mut end = last.clone(); | |
| 174 | end.text[0].text = text; | |
| 175 | nodes.push(end); | |
| 176 | } else { | |
| 177 | nodes.push(node(text, first.format.clone())?); | |
| 178 | } | |
| 179 | } | |
| 180 | if !suffix.text().is_empty() { | |
| 181 | let end = nodes.last_mut().unwrap(); | |
| 182 | end.text[0].text.append(suffix)?; | |
| 183 | } | |
| 184 | Ok(DocumentEdit { | |
| 185 | range: range.start.paragraph..range.end.paragraph + 1, | |
| 186 | replacement: nodes, | |
| 187 | }) | |
| 188 | } | |
| 189 | ||
| 190 | pub(crate) fn validate_edit(&self, edit: &DocumentEdit) -> Result<(), EditError> { | |
| 191 | if edit.range.start > edit.range.end | |
| 192 | || edit.range.end > self.nodes.len() | |
| 193 | || (edit.range.len() == self.nodes.len() && edit.replacement.is_empty()) | |
| 194 | { | |
| 195 | return Err(EditError::InvalidRange); | |
| 196 | } | |
| 197 | validate_nodes( | |
| 198 | self.nodes[..edit.range.start] | |
| 199 | .iter() | |
| 200 | .chain(&edit.replacement) | |
| 201 | .chain(&self.nodes[edit.range.end..]), | |
| 202 | )?; | |
| 203 | validate_text(&edit.replacement) | |
| 204 | } | |
| 205 | ||
| 206 | pub(crate) fn apply(&mut self, edit: DocumentEdit) -> Result<DocumentEdit, EditError> { | |
| 207 | self.validate_edit(&edit)?; | |
| 208 | let end = edit | |
| 209 | .range | |
| 210 | .start | |
| 211 | .checked_add(edit.replacement.len()) | |
| 212 | .ok_or(EditError::TextTooLong)?; | |
| 213 | let range = edit.range.start..end; | |
| 214 | let replacement = self.nodes.splice(edit.range, edit.replacement).collect(); | |
| 215 | Ok(DocumentEdit { range, replacement }) | |
| 216 | } | |
| 217 | } | |
| 218 | ||
| 219 | #[cfg(test)] | |
| 220 | mod tests { | |
| 221 | use super::*; | |
| 222 | use onestore::document::Format; | |
| 223 | ||
| 224 | fn position(paragraph: usize, offset: u32) -> TextPosition { | |
| 225 | TextPosition { paragraph, offset } | |
| 226 | } | |
| 227 | ||
| 228 | #[test] | |
| 229 | #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] | |
| 230 | fn imported_nodes_preserve_identity_through_edit_and_undo() { | |
| 231 | use crate::page::{Page, PageObject}; | |
| 232 | use onestore::{RevisionIndex, Store, document::Document}; | |
| 233 | let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); | |
| 234 | let page = { | |
| 235 | let store = Store::parse(&bytes).unwrap(); | |
| 236 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 237 | Page::from_document( | |
| 238 | &Document::parse(&index).unwrap(), | |
| 239 | &std::env::var("CANVAS_TEST_PAGE").unwrap(), | |
| 240 | ) | |
| 241 | .unwrap() | |
| 242 | }; | |
| 243 | drop(bytes); | |
| 244 | let mut outlines = 0; | |
| 245 | let mut paragraphs = 0; | |
| 246 | for outline in page.objects.iter().flat_map(|object| match object { | |
| 247 | PageObject::Outline(outline) => std::slice::from_ref(outline), | |
| 248 | PageObject::Title(title) => &title.outlines, | |
| 249 | _ => &[], | |
| 250 | }) { | |
| 251 | let original = TextDocument::from_nodes(outline.paragraphs.clone()).unwrap(); | |
| 252 | for paragraph in 0..original.nodes().len() { | |
| 253 | let mut document = original.clone(); | |
| 254 | let position = TextPosition { | |
| 255 | paragraph, | |
| 256 | offset: 0, | |
| 257 | }; | |
| 258 | let format = document | |
| 259 | .paragraphs() | |
| 260 | .nth(paragraph) | |
| 261 | .unwrap() | |
| 262 | .format_at(0) | |
| 263 | .unwrap() | |
| 264 | .clone(); | |
| 265 | let edit = document | |
| 266 | .replace( | |
| 267 | position..position, | |
| 268 | vec![Paragraph::new("probe".into(), format)], | |
| 269 | ) | |
| 270 | .unwrap(); | |
| 271 | let undo = document.apply(edit).unwrap(); | |
| 272 | let edited = document.clone(); | |
| 273 | assert_eq!(edited.nodes()[paragraph].id, original.nodes()[paragraph].id); | |
| 274 | assert_eq!( | |
| 275 | edited.nodes()[paragraph].text[0].id, | |
| 276 | original.nodes()[paragraph].text[0].id | |
| 277 | ); | |
| 278 | let mut restored_text = edited.nodes().to_vec(); | |
| 279 | restored_text[paragraph].text[0].text = | |
| 280 | original.nodes()[paragraph].text[0].text.clone(); | |
| 281 | assert_eq!(restored_text, original.nodes()); | |
| 282 | let redo = document.apply(undo).unwrap(); | |
| 283 | assert_eq!(document, original); | |
| 284 | document.apply(redo).unwrap(); | |
| 285 | assert_eq!(document, edited); | |
| 286 | paragraphs += 1; | |
| 287 | } | |
| 288 | assert_eq!(outline.paragraphs, original.nodes()); | |
| 289 | outlines += 1; | |
| 290 | } | |
| 291 | assert!(outlines > 0); | |
| 292 | eprintln!( | |
| 293 | "Edited and restored {outlines} imported outlines containing {paragraphs} paragraphs" | |
| 294 | ); | |
| 295 | } | |
| 296 | ||
| 297 | #[test] | |
| 298 | fn structural_edits_preserve_surviving_node_identity_and_undo_all_metadata() { | |
| 299 | let mut source = ["A🌲B", "", "tail"] | |
| 300 | .into_iter() | |
| 301 | .enumerate() | |
| 302 | .map(|(index, text)| { | |
| 303 | node( | |
| 304 | Paragraph::new(text.into(), Format::default()), | |
| 305 | Format { | |
| 306 | language: Some(1033 + index as u32), | |
| 307 | ..Format::default() | |
| 308 | }, | |
| 309 | ) | |
| 310 | .unwrap() | |
| 311 | }) | |
| 312 | .collect::<Vec<_>>(); | |
| 313 | source[2].text[0].text = Paragraph::new( | |
| 314 | "tail".into(), | |
| 315 | Format { | |
| 316 | bold: Some(true), | |
| 317 | ..Format::default() | |
| 318 | }, | |
| 319 | ); | |
| 320 | let original = TextDocument::from_nodes(source).unwrap(); | |
| 321 | let mut document = original.clone(); | |
| 322 | let edit = document | |
| 323 | .replace( | |
| 324 | position(0, 1)..position(2, 2), | |
| 325 | ["x", "y", "z"] | |
| 326 | .into_iter() | |
| 327 | .map(|s| Paragraph::new(s.into(), Format::default())) | |
| 328 | .collect(), | |
| 329 | ) | |
| 330 | .unwrap(); | |
| 331 | let undo = document.apply(edit).unwrap(); | |
| 332 | assert_eq!( | |
| 333 | document | |
| 334 | .paragraphs() | |
| 335 | .map(Paragraph::text) | |
| 336 | .collect::<Vec<_>>(), | |
| 337 | ["Ax", "y", "zil"] | |
| 338 | ); | |
| 339 | for index in [0, 2] { | |
| 340 | assert_eq!(document.nodes()[index].id, original.nodes()[index].id); | |
| 341 | assert_eq!( | |
| 342 | document.nodes()[index].text[0].id, | |
| 343 | original.nodes()[index].text[0].id | |
| 344 | ); | |
| 345 | assert_eq!( | |
| 346 | document.nodes()[index].format, | |
| 347 | original.nodes()[index].format | |
| 348 | ); | |
| 349 | } | |
| 350 | let old_ids: BTreeSet<_> = original | |
| 351 | .nodes() | |
| 352 | .iter() | |
| 353 | .flat_map(|n| [n.id, n.text[0].id]) | |
| 354 | .collect(); | |
| 355 | assert!(!old_ids.contains(&document.nodes()[1].id)); | |
| 356 | assert!(!old_ids.contains(&document.nodes()[1].text[0].id)); | |
| 357 | let edited = document.clone(); | |
| 358 | let redo = document.apply(undo).unwrap(); | |
| 359 | assert_eq!(document, original); | |
| 360 | document.apply(redo).unwrap(); | |
| 361 | assert_eq!(document, edited); | |
| 362 | let mut split = original.clone(); | |
| 363 | let edit = split | |
| 364 | .replace( | |
| 365 | position(0, 1)..position(0, 1), | |
| 366 | vec![Paragraph::new(String::new(), Format::default()); 2], | |
| 367 | ) | |
| 368 | .unwrap(); | |
| 369 | split.apply(edit).unwrap(); | |
| 370 | assert_eq!(split.nodes()[0].id, original.nodes()[0].id); | |
| 371 | assert!(!old_ids.contains(&split.nodes()[1].id)); | |
| 372 | assert_eq!(&split.nodes()[2..], &original.nodes()[1..]); | |
| 373 | } | |
| 374 | ||
| 375 | #[test] | |
| 376 | fn replacement_cannot_alias_existing_objects_or_publish_unsupported_nodes() { | |
| 377 | let mut document = | |
| 378 | TextDocument::new(vec![Paragraph::new("text".into(), Format::default())]).unwrap(); | |
| 379 | let original = document.clone(); | |
| 380 | let duplicate = DocumentEdit { | |
| 381 | range: 1..1, | |
| 382 | replacement: original.nodes().to_vec(), | |
| 383 | }; | |
| 384 | assert_eq!(document.apply(duplicate), Err(EditError::InvalidStructure)); | |
| 385 | let mut unsupported = original.nodes().to_vec(); | |
| 386 | unsupported[0] | |
| 387 | .text | |
| 388 | .push(original.nodes()[0].text[0].clone()); | |
| 389 | assert_eq!( | |
| 390 | TextDocument::from_nodes(unsupported.clone()), | |
| 391 | Err(EditError::UnsupportedContent) | |
| 392 | ); | |
| 393 | assert_eq!( | |
| 394 | document.apply(DocumentEdit { | |
| 395 | range: 0..1, | |
| 396 | replacement: unsupported | |
| 397 | }), | |
| 398 | Err(EditError::UnsupportedContent) | |
| 399 | ); | |
| 400 | assert_eq!(document, original); | |
| 401 | } | |
| 402 | ||
| 403 | #[test] | |
| 404 | fn paragraph_links_require_an_earlier_parent_at_a_shallower_level() { | |
| 405 | let mut nodes = TextDocument::new( | |
| 406 | ["parent", "child", "grandchild"] | |
| 407 | .into_iter() | |
| 408 | .map(|text| Paragraph::new(text.into(), Format::default())) | |
| 409 | .collect(), | |
| 410 | ) | |
| 411 | .unwrap() | |
| 412 | .nodes; | |
| 413 | nodes[1].parent = Some(nodes[0].id); | |
| 414 | nodes[1].level = 3; | |
| 415 | nodes[2].parent = Some(nodes[1].id); | |
| 416 | nodes[2].level = 5; | |
| 417 | let original = TextDocument::from_nodes(nodes.clone()).unwrap(); | |
| 418 | for parent in [nodes[2].id, nodes[0].text[0].id, new_id().unwrap()] { | |
| 419 | let mut invalid = nodes.clone(); | |
| 420 | invalid[1].parent = Some(parent); | |
| 421 | assert_eq!( | |
| 422 | TextDocument::from_nodes(invalid), | |
| 423 | Err(EditError::InvalidStructure) | |
| 424 | ); | |
| 425 | } | |
| 426 | for level in [0, 1] { | |
| 427 | let mut invalid = nodes.clone(); | |
| 428 | invalid[1].level = level; | |
| 429 | assert_eq!( | |
| 430 | TextDocument::from_nodes(invalid), | |
| 431 | Err(EditError::InvalidStructure) | |
| 432 | ); | |
| 433 | } | |
| 434 | let mut document = original.clone(); | |
| 435 | assert_eq!( | |
| 436 | document.apply(DocumentEdit { | |
| 437 | range: 0..1, | |
| 438 | replacement: Vec::new() | |
| 439 | }), | |
| 440 | Err(EditError::InvalidStructure) | |
| 441 | ); | |
| 442 | assert_eq!(document, original); | |
| 443 | } | |
| 444 | ||
| 445 | #[test] | |
| 446 | fn editing_nested_tagged_text_preserves_metadata_and_refuses_structural_changes() { | |
| 447 | let mut nodes = TextDocument::new( | |
| 448 | ["parent", "a🌳e\u{301}z", "child"] | |
| 449 | .into_iter() | |
| 450 | .map(|text| Paragraph::new(text.into(), Format::default())) | |
| 451 | .collect(), | |
| 452 | ) | |
| 453 | .unwrap() | |
| 454 | .nodes; | |
| 455 | nodes[1].parent = Some(nodes[0].id); | |
| 456 | nodes[1].level = 2; | |
| 457 | nodes[1].lists.push(new_id().unwrap()); | |
| 458 | nodes[1].tags.push(onestore::document::Tag { | |
| 459 | definition: Some(new_id().unwrap()), | |
| 460 | action_type: Some(0), | |
| 461 | status: 1, | |
| 462 | created: Some(123), | |
| 463 | completed: None, | |
| 464 | start: Some(124), | |
| 465 | due: Some(456), | |
| 466 | task_id: Some([7; 16]), | |
| 467 | extra_set: 0, | |
| 468 | }); | |
| 469 | nodes[1].text[0].tags = nodes[1].tags.clone(); | |
| 470 | nodes[1].collapsed = true; | |
| 471 | nodes[2].parent = Some(nodes[1].id); | |
| 472 | nodes[2].level = 3; | |
| 473 | let original = TextDocument::from_nodes(nodes).unwrap(); | |
| 474 | let text = original.paragraphs().nth(1).unwrap(); | |
| 475 | let offsets: Vec<_> = text | |
| 476 | .text() | |
| 477 | .char_indices() | |
| 478 | .map(|(byte, _)| text.utf16_offset(byte).unwrap()) | |
| 479 | .chain(std::iter::once( | |
| 480 | text.utf16_offset(text.text().len()).unwrap(), | |
| 481 | )) | |
| 482 | .collect(); | |
| 483 | for &start in &offsets { | |
| 484 | for &end in offsets.iter().filter(|&&end| end >= start) { | |
| 485 | let mut document = original.clone(); | |
| 486 | let edit = document | |
| 487 | .replace( | |
| 488 | position(1, start)..position(1, end), | |
| 489 | vec![Paragraph::new( | |
| 490 | "👩🏽💻".into(), | |
| 491 | Format { | |
| 492 | bold: Some(true), | |
| 493 | ..Format::default() | |
| 494 | }, | |
| 495 | )], | |
| 496 | ) | |
| 497 | .unwrap(); | |
| 498 | let undo = document.apply(edit).unwrap(); | |
| 499 | let edited = document.clone(); | |
| 500 | let mut restored = edited.nodes().to_vec(); | |
| 501 | restored[1].text[0].text = text.clone(); | |
| 502 | assert_eq!(restored, original.nodes()); | |
| 503 | let redo = document.apply(undo).unwrap(); | |
| 504 | assert_eq!(document, original); | |
| 505 | document.apply(redo).unwrap(); | |
| 506 | assert_eq!(document, edited); | |
| 507 | } | |
| 508 | } | |
| 509 | for (range, count) in [ | |
| 510 | (position(1, 0)..position(1, 0), 2), | |
| 511 | (position(0, 6)..position(1, 0), 1), | |
| 512 | (position(1, 0)..position(2, 0), 1), | |
| 513 | ] { | |
| 514 | assert_eq!( | |
| 515 | original.replace( | |
| 516 | range, | |
| 517 | vec![Paragraph::new(String::new(), Format::default()); count] | |
| 518 | ), | |
| 519 | Err(EditError::UnsupportedContent) | |
| 520 | ); | |
| 521 | } | |
| 522 | } | |
| 523 | ||
| 524 | #[test] | |
| 525 | fn split_join_and_undo_preserve_empty_paragraph_formats() { | |
| 526 | let regular = Format::default(); | |
| 527 | let bold = Format { | |
| 528 | bold: Some(true), | |
| 529 | ..Format::default() | |
| 530 | }; | |
| 531 | let original = TextDocument::new(vec![ | |
| 532 | Paragraph::new("left".into(), regular.clone()), | |
| 533 | Paragraph::new(String::new(), bold.clone()), | |
| 534 | Paragraph::new("right".into(), bold.clone()), | |
| 535 | ]) | |
| 536 | .unwrap(); | |
| 537 | let mut document = original.clone(); | |
| 538 | let split = document | |
| 539 | .replace( | |
| 540 | position(0, 2)..position(0, 2), | |
| 541 | vec![Paragraph::new(String::new(), regular.clone()); 2], | |
| 542 | ) | |
| 543 | .unwrap(); | |
| 544 | let undo = document.apply(split).unwrap(); | |
| 545 | assert_eq!( | |
| 546 | document | |
| 547 | .paragraphs() | |
| 548 | .map(Paragraph::text) | |
| 549 | .collect::<Vec<_>>(), | |
| 550 | ["le", "ft", "", "right"] | |
| 551 | ); | |
| 552 | let redo = document.apply(undo).unwrap(); | |
| 553 | assert_eq!(document, original); | |
| 554 | document.apply(redo).unwrap(); | |
| 555 | let join = document | |
| 556 | .replace( | |
| 557 | position(1, 2)..position(3, 0), | |
| 558 | vec![Paragraph::new(String::new(), regular)], | |
| 559 | ) | |
| 560 | .unwrap(); | |
| 561 | let before_join = document.clone(); | |
| 562 | let undo = document.apply(join).unwrap(); | |
| 563 | assert_eq!(document.paragraphs().nth(1).unwrap().text(), "ftright"); | |
| 564 | assert_eq!( | |
| 565 | document.paragraphs().nth(1).unwrap().spans()[1].format, | |
| 566 | bold | |
| 567 | ); | |
| 568 | document.apply(undo).unwrap(); | |
| 569 | assert_eq!(document, before_join); | |
| 570 | } | |
| 571 | ||
| 572 | #[test] | |
| 573 | fn every_scalar_range_replaces_across_paragraphs_and_round_trips() { | |
| 574 | let regular = Format::default(); | |
| 575 | let bold = Format { | |
| 576 | bold: Some(true), | |
| 577 | ..Format::default() | |
| 578 | }; | |
| 579 | let original = TextDocument::new(vec![ | |
| 580 | Paragraph::from_runs([ | |
| 581 | ("a🌳".into(), regular.clone()), | |
| 582 | ("e\u{301}".into(), bold.clone()), | |
| 583 | ]), | |
| 584 | Paragraph::new(String::new(), bold.clone()), | |
| 585 | Paragraph::new("日本z".into(), regular.clone()), | |
| 586 | ]) | |
| 587 | .unwrap(); | |
| 588 | let positions: Vec<_> = original | |
| 589 | .paragraphs() | |
| 590 | .enumerate() | |
| 591 | .flat_map(|(index, paragraph)| { | |
| 592 | (0..=paragraph.text().len()).filter_map(move |byte| { | |
| 593 | paragraph | |
| 594 | .utf16_offset(byte) | |
| 595 | .ok() | |
| 596 | .map(|offset| position(index, offset)) | |
| 597 | }) | |
| 598 | }) | |
| 599 | .collect(); | |
| 600 | let replacements = [ | |
| 601 | vec![Paragraph::new(String::new(), regular.clone())], | |
| 602 | vec![Paragraph::new("x👩🏽💻".into(), bold.clone())], | |
| 603 | vec![ | |
| 604 | Paragraph::new(String::new(), bold.clone()), | |
| 605 | Paragraph::new(String::new(), regular.clone()), | |
| 606 | ], | |
| 607 | vec![ | |
| 608 | Paragraph::new("one".into(), bold.clone()), | |
| 609 | Paragraph::new(String::new(), regular.clone()), | |
| 610 | Paragraph::new("two".into(), bold), | |
| 611 | ], | |
| 612 | ]; | |
| 613 | let plain = original | |
| 614 | .paragraphs() | |
| 615 | .map(Paragraph::text) | |
| 616 | .collect::<Vec<_>>() | |
| 617 | .join("\n"); | |
| 618 | for &start in &positions { | |
| 619 | for &end in positions.iter().filter(|&&end| end >= start) { | |
| 620 | for replacement in &replacements { | |
| 621 | let mut document = original.clone(); | |
| 622 | let planned = document.replace(start..end, replacement.clone()).unwrap(); | |
| 623 | assert_eq!(document, original); | |
| 624 | let undo = document.apply(planned).unwrap(); | |
| 625 | assert!(document.paragraphs().len() > 0); | |
| 626 | let byte = |position: TextPosition| { | |
| 627 | original | |
| 628 | .paragraphs() | |
| 629 | .take(position.paragraph) | |
| 630 | .map(|p| p.text().len() + 1) | |
| 631 | .sum::<usize>() | |
| 632 | + original | |
| 633 | .paragraphs() | |
| 634 | .nth(position.paragraph) | |
| 635 | .unwrap() | |
| 636 | .byte_offset(position.offset) | |
| 637 | .unwrap() | |
| 638 | }; | |
| 639 | let mut expected = plain.clone(); | |
| 640 | expected.replace_range( | |
| 641 | byte(start)..byte(end), | |
| 642 | &replacement | |
| 643 | .iter() | |
| 644 | .map(Paragraph::text) | |
| 645 | .collect::<Vec<_>>() | |
| 646 | .join("\n"), | |
| 647 | ); | |
| 648 | assert_eq!( | |
| 649 | document | |
| 650 | .paragraphs() | |
| 651 | .map(Paragraph::text) | |
| 652 | .collect::<Vec<_>>() | |
| 653 | .join("\n"), | |
| 654 | expected | |
| 655 | ); | |
| 656 | let after = document.clone(); | |
| 657 | let redo = document.apply(undo).unwrap(); | |
| 658 | assert_eq!(document, original, "{start:?}..{end:?}"); | |
| 659 | document.apply(redo).unwrap(); | |
| 660 | assert_eq!(document, after); | |
| 661 | } | |
| 662 | } | |
| 663 | } | |
| 664 | } | |
| 665 | ||
| 666 | #[test] | |
| 667 | fn invalid_ranges_and_last_paragraph_removal_are_atomic() { | |
| 668 | let original = | |
| 669 | TextDocument::new(vec![Paragraph::new("🌳".into(), Format::default())]).unwrap(); | |
| 670 | for range in [ | |
| 671 | position(0, 1)..position(0, 2), | |
| 672 | position(0, 0)..position(1, 0), | |
| 673 | position(0, 2)..position(0, 0), | |
| 674 | ] { | |
| 675 | assert!( | |
| 676 | original | |
| 677 | .replace( | |
| 678 | range, | |
| 679 | vec![Paragraph::new(String::new(), Format::default())] | |
| 680 | ) | |
| 681 | .is_err() | |
| 682 | ); | |
| 683 | } | |
| 684 | for range in [0..1, 0..2, Range { start: 2, end: 1 }] { | |
| 685 | let mut document = original.clone(); | |
| 686 | assert!( | |
| 687 | document | |
| 688 | .apply(DocumentEdit { | |
| 689 | range, | |
| 690 | replacement: Vec::new() | |
| 691 | }) | |
| 692 | .is_err() | |
| 693 | ); | |
| 694 | assert_eq!(document, original); | |
| 695 | } | |
| 696 | assert!(TextDocument::new(Vec::new()).is_err()); | |
| 697 | } | |
| 698 | } |
crates/canvas/src/editor.rs created+3139| ... | ... | @@ -0,0 +1,3139 @@ |
| 1 | use crate::{ | |
| 2 | document::{DocumentEdit, TextDocument, TextPosition}, | |
| 3 | layout::{LayoutError, TextEngine}, | |
| 4 | outline::{OutlineLayout, ParagraphLayout, arrange, visible_paragraphs}, | |
| 5 | page::{Definition, Outline}, | |
| 6 | text::{EditError, Paragraph}, | |
| 7 | }; | |
| 8 | use onestore::ExGuid; | |
| 9 | use parley::{ | |
| 10 | Affinity, BoundingBox, | |
| 11 | editing::{Cursor, Selection as ParagraphSelection}, | |
| 12 | }; | |
| 13 | use std::{ | |
| 14 | collections::{BTreeMap, BTreeSet}, | |
| 15 | fmt, | |
| 16 | ops::Range, | |
| 17 | }; | |
| 18 | ||
| 19 | #[derive(Clone, Copy, Debug)] | |
| 20 | pub enum Movement { | |
| 21 | Left, | |
| 22 | Right, | |
| 23 | Up, | |
| 24 | Down, | |
| 25 | WordLeft, | |
| 26 | WordRight, | |
| 27 | LineStart, | |
| 28 | LineEnd, | |
| 29 | } | |
| 30 | ||
| 31 | #[derive(Debug)] | |
| 32 | pub enum EditorError { | |
| 33 | Edit(EditError), | |
| 34 | Layout(LayoutError), | |
| 35 | } | |
| 36 | ||
| 37 | impl fmt::Display for EditorError { | |
| 38 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 39 | match self { | |
| 40 | Self::Edit(error) => error.fmt(f), | |
| 41 | Self::Layout(error) => error.fmt(f), | |
| 42 | } | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | impl std::error::Error for EditorError {} | |
| 47 | ||
| 48 | impl From<EditError> for EditorError { | |
| 49 | fn from(error: EditError) -> Self { | |
| 50 | Self::Edit(error) | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | impl From<LayoutError> for EditorError { | |
| 55 | fn from(error: LayoutError) -> Self { | |
| 56 | Self::Layout(error) | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 61 | pub struct Selection { | |
| 62 | pub positions: [TextPosition; 2], | |
| 63 | pub affinities: [Affinity; 2], | |
| 64 | } | |
| 65 | ||
| 66 | impl From<[TextPosition; 2]> for Selection { | |
| 67 | fn from(positions: [TextPosition; 2]) -> Self { | |
| 68 | Self { | |
| 69 | positions, | |
| 70 | affinities: [Affinity::Downstream; 2], | |
| 71 | } | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | pub struct CanvasEditor { | |
| 76 | outlines: Vec<TextOutline>, | |
| 77 | definitions: BTreeMap<ExGuid, Definition>, | |
| 78 | active: Focus, | |
| 79 | undo: Vec<History>, | |
| 80 | redo: Vec<History>, | |
| 81 | composition: Option<Composition>, | |
| 82 | preferred_x: Option<f32>, | |
| 83 | } | |
| 84 | ||
| 85 | enum Focus { | |
| 86 | Outline(usize), | |
| 87 | Caret { | |
| 88 | outline: Box<TextOutline>, | |
| 89 | index: usize, | |
| 90 | }, | |
| 91 | } | |
| 92 | ||
| 93 | pub struct TextOutline { | |
| 94 | pub id: onestore::ExGuid, | |
| 95 | layout: onestore::document::Layout, | |
| 96 | document: TextDocument, | |
| 97 | indents: Vec<f32>, | |
| 98 | shaped: OutlineLayout, | |
| 99 | selection: Selection, | |
| 100 | } | |
| 101 | ||
| 102 | impl TextOutline { | |
| 103 | /// A single plain paragraph containing only ASCII spaces or no text. | |
| 104 | pub fn is_empty(&self) -> bool { | |
| 105 | self.document.nodes().len() == 1 | |
| 106 | && self.document.validate_flat().is_ok() | |
| 107 | && self | |
| 108 | .document | |
| 109 | .paragraphs() | |
| 110 | .next() | |
| 111 | .unwrap() | |
| 112 | .text() | |
| 113 | .bytes() | |
| 114 | .all(|byte| byte == b' ') | |
| 115 | } | |
| 116 | ||
| 117 | pub fn new( | |
| 118 | engine: &mut TextEngine, | |
| 119 | document: TextDocument, | |
| 120 | width: f32, | |
| 121 | position: [f32; 2], | |
| 122 | ) -> Result<Self, EditorError> { | |
| 123 | if !position.iter().all(|value| value.is_finite()) { | |
| 124 | return Err(EditError::InvalidRange.into()); | |
| 125 | } | |
| 126 | let indents = vec![18.0, 0.0, 27.0, 27.0]; | |
| 127 | let shaped = visible_paragraphs(document.nodes()) | |
| 128 | .map(|paragraph| { | |
| 129 | ParagraphLayout::shape(engine, paragraph, width, &indents, &BTreeMap::new()) | |
| 130 | }) | |
| 131 | .collect::<Result<Vec<_>, _>>()?; | |
| 132 | let shaped = OutlineLayout::new(shaped, width, true)?; | |
| 133 | Ok(Self { | |
| 134 | id: crate::text::new_id()?, | |
| 135 | layout: onestore::document::Layout { | |
| 136 | x: Some(position[0]), | |
| 137 | y: Some(position[1]), | |
| 138 | max_width: Some(width), | |
| 139 | width_set_by_user: Some(true), | |
| 140 | ..Default::default() | |
| 141 | }, | |
| 142 | document, | |
| 143 | indents, | |
| 144 | shaped, | |
| 145 | selection: [TextPosition { | |
| 146 | paragraph: 0, | |
| 147 | offset: 0, | |
| 148 | }; 2] | |
| 149 | .into(), | |
| 150 | }) | |
| 151 | } | |
| 152 | ||
| 153 | pub fn from_outline( | |
| 154 | engine: &mut TextEngine, | |
| 155 | outline: &Outline, | |
| 156 | definitions: &BTreeMap<ExGuid, Definition>, | |
| 157 | ) -> Result<Self, EditorError> { | |
| 158 | if [outline.layout.x, outline.layout.y] | |
| 159 | .into_iter() | |
| 160 | .flatten() | |
| 161 | .any(|value| !value.is_finite()) | |
| 162 | { | |
| 163 | return Err(EditError::InvalidRange.into()); | |
| 164 | } | |
| 165 | let shaped = outline.layout(engine, definitions)?; | |
| 166 | let document = TextDocument::from_nodes(outline.paragraphs.clone())?; | |
| 167 | Ok(Self { | |
| 168 | id: outline.id, | |
| 169 | layout: outline.layout.clone(), | |
| 170 | indents: outline.indents.clone(), | |
| 171 | document, | |
| 172 | shaped, | |
| 173 | selection: [TextPosition { | |
| 174 | paragraph: 0, | |
| 175 | offset: 0, | |
| 176 | }; 2] | |
| 177 | .into(), | |
| 178 | }) | |
| 179 | } | |
| 180 | ||
| 181 | fn snapshot(&self) -> Outline { | |
| 182 | Outline { | |
| 183 | id: self.id, | |
| 184 | layout: self.layout.clone(), | |
| 185 | indents: self.indents.clone(), | |
| 186 | paragraphs: self.document.nodes().to_vec(), | |
| 187 | unsupported: Vec::new(), | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | pub fn document(&self) -> &TextDocument { | |
| 192 | &self.document | |
| 193 | } | |
| 194 | ||
| 195 | pub fn layout(&self) -> &onestore::document::Layout { | |
| 196 | &self.layout | |
| 197 | } | |
| 198 | ||
| 199 | pub fn origin(&self) -> [f32; 2] { | |
| 200 | [self.layout.x.unwrap_or(0.0), self.layout.y.unwrap_or(0.0)] | |
| 201 | } | |
| 202 | ||
| 203 | pub fn bounds(&self) -> BoundingBox { | |
| 204 | let [x, y] = self.origin().map(f64::from); | |
| 205 | BoundingBox { | |
| 206 | x0: x, | |
| 207 | y0: y, | |
| 208 | x1: x + f64::from(self.shaped.size[0]), | |
| 209 | y1: y + f64::from(self.shaped.size[1]), | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | pub fn layouts(&self) -> impl Iterator<Item = (usize, &ParagraphLayout)> { | |
| 214 | let mut shaped = self.shaped.paragraphs.iter().peekable(); | |
| 215 | self.document | |
| 216 | .nodes() | |
| 217 | .iter() | |
| 218 | .enumerate() | |
| 219 | .filter_map(move |(index, node)| { | |
| 220 | if shaped | |
| 221 | .peek() | |
| 222 | .is_some_and(|paragraph| paragraph.id == node.id) | |
| 223 | { | |
| 224 | Some((index, shaped.next().unwrap())) | |
| 225 | } else { | |
| 226 | None | |
| 227 | } | |
| 228 | }) | |
| 229 | } | |
| 230 | ||
| 231 | fn visible_index(&self, source: usize) -> Result<usize, EditError> { | |
| 232 | let id = self | |
| 233 | .document | |
| 234 | .nodes() | |
| 235 | .get(source) | |
| 236 | .ok_or(EditError::InvalidRange)? | |
| 237 | .id; | |
| 238 | self.shaped | |
| 239 | .paragraphs | |
| 240 | .iter() | |
| 241 | .position(|paragraph| paragraph.id == id) | |
| 242 | .ok_or(EditError::InvalidRange) | |
| 243 | } | |
| 244 | ||
| 245 | fn source_index(&self, visible: usize) -> usize { | |
| 246 | let id = self.shaped.paragraphs[visible].id; | |
| 247 | self.document | |
| 248 | .nodes() | |
| 249 | .iter() | |
| 250 | .position(|paragraph| paragraph.id == id) | |
| 251 | .unwrap() | |
| 252 | } | |
| 253 | ||
| 254 | pub fn paragraph_layout(&self, source: usize) -> Result<&ParagraphLayout, EditError> { | |
| 255 | Ok(&self.shaped.paragraphs[self.visible_index(source)?]) | |
| 256 | } | |
| 257 | } | |
| 258 | ||
| 259 | struct Composition { | |
| 260 | original: TextChange, | |
| 261 | range: Range<TextPosition>, | |
| 262 | } | |
| 263 | ||
| 264 | impl ParagraphLayout { | |
| 265 | fn cursor(&self, source: u32, affinity: Affinity) -> Result<Cursor, EditError> { | |
| 266 | let visible = self.projection.visible_offset(source)?; | |
| 267 | let byte = self.projection.text().byte_offset(visible)?; | |
| 268 | Ok(self.text.cursor(byte, affinity)) | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | #[derive(Clone)] | |
| 273 | struct TextChange { | |
| 274 | edit: DocumentEdit, | |
| 275 | selection: Selection, | |
| 276 | } | |
| 277 | ||
| 278 | enum RestoreFocus { | |
| 279 | Outline(ExGuid), | |
| 280 | Caret { | |
| 281 | source: Box<Outline>, | |
| 282 | selection: Selection, | |
| 283 | index: usize, | |
| 284 | }, | |
| 285 | } | |
| 286 | ||
| 287 | enum History { | |
| 288 | Text { | |
| 289 | outline: onestore::ExGuid, | |
| 290 | change: TextChange, | |
| 291 | }, | |
| 292 | Position { | |
| 293 | outline: onestore::ExGuid, | |
| 294 | position: [Option<f32>; 2], | |
| 295 | }, | |
| 296 | Remove { | |
| 297 | outline: onestore::ExGuid, | |
| 298 | focus: RestoreFocus, | |
| 299 | }, | |
| 300 | Insert { | |
| 301 | index: usize, | |
| 302 | outline: Box<TextOutline>, | |
| 303 | focus: RestoreFocus, | |
| 304 | }, | |
| 305 | Restore { | |
| 306 | index: usize, | |
| 307 | source: Box<Outline>, | |
| 308 | selection: Selection, | |
| 309 | focus: RestoreFocus, | |
| 310 | }, | |
| 311 | } | |
| 312 | ||
| 313 | impl CanvasEditor { | |
| 314 | pub fn new( | |
| 315 | engine: &mut TextEngine, | |
| 316 | document: TextDocument, | |
| 317 | width: f32, | |
| 318 | ) -> Result<Self, EditorError> { | |
| 319 | let outline = TextOutline::new(engine, document, width, [0.0; 2])?; | |
| 320 | let (outlines, active) = if outline.is_empty() { | |
| 321 | ( | |
| 322 | Vec::new(), | |
| 323 | Focus::Caret { | |
| 324 | outline: Box::new(outline), | |
| 325 | index: 0, | |
| 326 | }, | |
| 327 | ) | |
| 328 | } else { | |
| 329 | (vec![outline], Focus::Outline(0)) | |
| 330 | }; | |
| 331 | Ok(Self { | |
| 332 | outlines, | |
| 333 | definitions: BTreeMap::new(), | |
| 334 | active, | |
| 335 | undo: Vec::new(), | |
| 336 | redo: Vec::new(), | |
| 337 | composition: None, | |
| 338 | preferred_x: None, | |
| 339 | }) | |
| 340 | } | |
| 341 | ||
| 342 | pub fn from_outlines( | |
| 343 | engine: &mut TextEngine, | |
| 344 | outlines: Vec<Outline>, | |
| 345 | definitions: BTreeMap<ExGuid, Definition>, | |
| 346 | ) -> Result<Self, EditorError> { | |
| 347 | let outlines = outlines | |
| 348 | .iter() | |
| 349 | .map(|outline| TextOutline::from_outline(engine, outline, &definitions)) | |
| 350 | .collect::<Result<_, _>>()?; | |
| 351 | Self::from_text_outlines(outlines, definitions) | |
| 352 | } | |
| 353 | ||
| 354 | pub fn from_text_outlines( | |
| 355 | outlines: Vec<TextOutline>, | |
| 356 | definitions: BTreeMap<ExGuid, Definition>, | |
| 357 | ) -> Result<Self, EditorError> { | |
| 358 | if outlines.is_empty() { | |
| 359 | return Err(EditError::InvalidRange.into()); | |
| 360 | } | |
| 361 | let mut ids = BTreeSet::new(); | |
| 362 | for outline in &outlines { | |
| 363 | for id in | |
| 364 | std::iter::once(outline.id).chain( | |
| 365 | outline.document.nodes().iter().flat_map(|p| { | |
| 366 | std::iter::once(p.id).chain(p.text.iter().map(|text| text.id)) | |
| 367 | }), | |
| 368 | ) | |
| 369 | { | |
| 370 | if !ids.insert(id) { | |
| 371 | return Err(EditError::InvalidStructure.into()); | |
| 372 | } | |
| 373 | } | |
| 374 | } | |
| 375 | Ok(Self { | |
| 376 | outlines, | |
| 377 | definitions, | |
| 378 | active: Focus::Outline(0), | |
| 379 | undo: Vec::new(), | |
| 380 | redo: Vec::new(), | |
| 381 | composition: None, | |
| 382 | preferred_x: None, | |
| 383 | }) | |
| 384 | } | |
| 385 | ||
| 386 | pub fn outlines(&self) -> &[TextOutline] { | |
| 387 | &self.outlines | |
| 388 | } | |
| 389 | pub fn active_outline(&self) -> &TextOutline { | |
| 390 | match &self.active { | |
| 391 | Focus::Outline(index) => &self.outlines[*index], | |
| 392 | Focus::Caret { outline, .. } => outline, | |
| 393 | } | |
| 394 | } | |
| 395 | ||
| 396 | fn active_outline_mut(&mut self) -> &mut TextOutline { | |
| 397 | match &mut self.active { | |
| 398 | Focus::Outline(index) => &mut self.outlines[*index], | |
| 399 | Focus::Caret { outline, .. } => outline, | |
| 400 | } | |
| 401 | } | |
| 402 | ||
| 403 | /// Provisional input geometry outside the document's outline collection. | |
| 404 | pub fn caret_outline(&self) -> Option<&TextOutline> { | |
| 405 | match &self.active { | |
| 406 | Focus::Caret { outline, .. } => Some(outline), | |
| 407 | Focus::Outline(_) => None, | |
| 408 | } | |
| 409 | } | |
| 410 | ||
| 411 | pub fn place_caret( | |
| 412 | &mut self, | |
| 413 | engine: &mut TextEngine, | |
| 414 | position: [f32; 2], | |
| 415 | width: f32, | |
| 416 | ) -> Result<(), EditorError> { | |
| 417 | let document = TextDocument::new(vec![Paragraph::new(String::new(), Default::default())])?; | |
| 418 | let outline = TextOutline::new(engine, document, width, position)?; | |
| 419 | self.finish_composition(); | |
| 420 | self.active = Focus::Caret { | |
| 421 | outline: Box::new(outline), | |
| 422 | index: self.outlines.len(), | |
| 423 | }; | |
| 424 | self.preferred_x = None; | |
| 425 | Ok(()) | |
| 426 | } | |
| 427 | ||
| 428 | pub fn focus_outline(&mut self, id: onestore::ExGuid) -> Result<(), EditError> { | |
| 429 | if self.active_outline().id != id && !self.outlines.iter().any(|outline| outline.id == id) { | |
| 430 | return Err(EditError::InvalidRange); | |
| 431 | } | |
| 432 | self.finish_composition(); | |
| 433 | if let Some(index) = self.outlines.iter().position(|outline| outline.id == id) { | |
| 434 | self.active = Focus::Outline(index); | |
| 435 | } | |
| 436 | self.preferred_x = None; | |
| 437 | Ok(()) | |
| 438 | } | |
| 439 | ||
| 440 | pub fn create_outline( | |
| 441 | &mut self, | |
| 442 | engine: &mut TextEngine, | |
| 443 | position: [f32; 2], | |
| 444 | width: f32, | |
| 445 | ) -> Result<onestore::ExGuid, EditorError> { | |
| 446 | let document = TextDocument::new(vec![Paragraph::new( | |
| 447 | String::new(), | |
| 448 | onestore::document::Format::default(), | |
| 449 | )])?; | |
| 450 | let outline = TextOutline::new(engine, document, width, position)?; | |
| 451 | let id = outline.id; | |
| 452 | self.finish_composition(); | |
| 453 | self.undo.push(History::Remove { | |
| 454 | outline: id, | |
| 455 | focus: match &self.active { | |
| 456 | Focus::Outline(index) => RestoreFocus::Outline(self.outlines[*index].id), | |
| 457 | Focus::Caret { outline, index } => RestoreFocus::Caret { | |
| 458 | source: Box::new(outline.snapshot()), | |
| 459 | selection: outline.selection, | |
| 460 | index: *index, | |
| 461 | }, | |
| 462 | }, | |
| 463 | }); | |
| 464 | self.redo.clear(); | |
| 465 | self.active = Focus::Outline(self.outlines.len()); | |
| 466 | self.outlines.push(outline); | |
| 467 | self.preferred_x = None; | |
| 468 | Ok(id) | |
| 469 | } | |
| 470 | ||
| 471 | pub fn move_outline( | |
| 472 | &mut self, | |
| 473 | id: onestore::ExGuid, | |
| 474 | position: [f32; 2], | |
| 475 | ) -> Result<(), EditError> { | |
| 476 | if !position.iter().all(|value| value.is_finite()) { | |
| 477 | return Err(EditError::InvalidRange); | |
| 478 | } | |
| 479 | let outline = if self.active_outline().id == id { | |
| 480 | self.active_outline() | |
| 481 | } else { | |
| 482 | self.outlines | |
| 483 | .iter() | |
| 484 | .find(|outline| outline.id == id) | |
| 485 | .ok_or(EditError::InvalidRange)? | |
| 486 | }; | |
| 487 | if position == outline.origin() { | |
| 488 | return Ok(()); | |
| 489 | } | |
| 490 | self.finish_composition(); | |
| 491 | if self.caret_outline().is_some_and(|outline| outline.id == id) { | |
| 492 | let outline = self.active_outline_mut(); | |
| 493 | outline.layout.x = Some(position[0]); | |
| 494 | outline.layout.y = Some(position[1]); | |
| 495 | self.preferred_x = None; | |
| 496 | return Ok(()); | |
| 497 | } | |
| 498 | let index = self | |
| 499 | .outlines | |
| 500 | .iter() | |
| 501 | .position(|outline| outline.id == id) | |
| 502 | .unwrap(); | |
| 503 | let layout = &mut self.outlines[index].layout; | |
| 504 | self.undo.push(History::Position { | |
| 505 | outline: id, | |
| 506 | position: [layout.x, layout.y], | |
| 507 | }); | |
| 508 | self.redo.clear(); | |
| 509 | layout.x = Some(position[0]); | |
| 510 | layout.y = Some(position[1]); | |
| 511 | self.active = Focus::Outline(index); | |
| 512 | self.preferred_x = None; | |
| 513 | Ok(()) | |
| 514 | } | |
| 515 | ||
| 516 | pub fn selection(&self) -> Selection { | |
| 517 | self.active_outline().selection | |
| 518 | } | |
| 519 | ||
| 520 | pub fn resize(&mut self, engine: &mut TextEngine, width: f32) -> Result<(), EditorError> { | |
| 521 | let outline = self.active_outline(); | |
| 522 | if outline.layout.reserved_width.or(outline.layout.max_width) == Some(width) | |
| 523 | && outline.layout.width_set_by_user == Some(true) | |
| 524 | { | |
| 525 | return Ok(()); | |
| 526 | } | |
| 527 | let paragraphs = visible_paragraphs(outline.document.nodes()) | |
| 528 | .map(|paragraph| { | |
| 529 | ParagraphLayout::shape( | |
| 530 | engine, | |
| 531 | paragraph, | |
| 532 | width, | |
| 533 | &outline.indents, | |
| 534 | &self.definitions, | |
| 535 | ) | |
| 536 | }) | |
| 537 | .collect::<Result<_, _>>()?; | |
| 538 | let shaped = OutlineLayout::new(paragraphs, width, true)?; | |
| 539 | let outline = self.active_outline_mut(); | |
| 540 | outline.layout.max_width = Some(width); | |
| 541 | outline.layout.reserved_width = None; | |
| 542 | outline.layout.width_set_by_user = Some(true); | |
| 543 | outline.shaped = shaped; | |
| 544 | self.preferred_x = None; | |
| 545 | Ok(()) | |
| 546 | } | |
| 547 | ||
| 548 | pub fn select(&mut self, selection: Selection) -> Result<(), EditError> { | |
| 549 | for position in selection.positions { | |
| 550 | self.active_outline().visible_index(position.paragraph)?; | |
| 551 | self.active_outline() | |
| 552 | .document | |
| 553 | .paragraphs() | |
| 554 | .nth(position.paragraph) | |
| 555 | .ok_or(EditError::InvalidRange)? | |
| 556 | .byte_offset(position.offset)?; | |
| 557 | } | |
| 558 | self.finish_composition(); | |
| 559 | self.preferred_x = None; | |
| 560 | self.active_outline_mut().selection = selection; | |
| 561 | Ok(()) | |
| 562 | } | |
| 563 | ||
| 564 | pub fn select_all(&mut self) -> Result<(), EditError> { | |
| 565 | let paragraph = self | |
| 566 | .active_outline() | |
| 567 | .source_index(self.active_outline().shaped.paragraphs.len() - 1); | |
| 568 | let last = self | |
| 569 | .active_outline() | |
| 570 | .document | |
| 571 | .paragraphs() | |
| 572 | .nth(paragraph) | |
| 573 | .unwrap(); | |
| 574 | self.select( | |
| 575 | [ | |
| 576 | TextPosition { | |
| 577 | paragraph: 0, | |
| 578 | offset: 0, | |
| 579 | }, | |
| 580 | TextPosition { | |
| 581 | paragraph, | |
| 582 | offset: last.utf16_offset(last.text().len())?, | |
| 583 | }, | |
| 584 | ] | |
| 585 | .into(), | |
| 586 | ) | |
| 587 | } | |
| 588 | ||
| 589 | pub fn select_at(&mut self, x: f32, y: f32, extend: bool) -> Result<(), EditError> { | |
| 590 | if !x.is_finite() || !y.is_finite() { | |
| 591 | return Err(EditError::InvalidRange); | |
| 592 | } | |
| 593 | let index = self | |
| 594 | .active_outline() | |
| 595 | .shaped | |
| 596 | .paragraphs | |
| 597 | .partition_point(|paragraph| paragraph.origin[1] <= y) | |
| 598 | .saturating_sub(1); | |
| 599 | let paragraph = &self.active_outline().shaped.paragraphs[index]; | |
| 600 | let cursor = paragraph | |
| 601 | .text | |
| 602 | .hit_test(x - paragraph.origin[0], y - paragraph.origin[1]); | |
| 603 | let visible = paragraph.projection.text().utf16_offset(cursor.index())?; | |
| 604 | let position = TextPosition { | |
| 605 | paragraph: self.active_outline().source_index(index), | |
| 606 | offset: paragraph | |
| 607 | .projection | |
| 608 | .source_offset(visible, cursor.affinity())?, | |
| 609 | }; | |
| 610 | self.finish_composition(); | |
| 611 | self.preferred_x = None; | |
| 612 | if !extend { | |
| 613 | self.active_outline_mut().selection.positions[0] = position; | |
| 614 | self.active_outline_mut().selection.affinities[0] = cursor.affinity(); | |
| 615 | } | |
| 616 | self.active_outline_mut().selection.positions[1] = position; | |
| 617 | self.active_outline_mut().selection.affinities[1] = cursor.affinity(); | |
| 618 | Ok(()) | |
| 619 | } | |
| 620 | ||
| 621 | pub fn caret(&self, width: f32) -> Result<BoundingBox, EditError> { | |
| 622 | let focus = self.active_outline().selection.positions[1]; | |
| 623 | let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?; | |
| 624 | let cursor = | |
| 625 | paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?; | |
| 626 | let mut rect = paragraph.text.caret(cursor, width); | |
| 627 | rect.x0 += f64::from(paragraph.origin[0]); | |
| 628 | rect.x1 += f64::from(paragraph.origin[0]); | |
| 629 | rect.y0 += f64::from(paragraph.origin[1]); | |
| 630 | rect.y1 += f64::from(paragraph.origin[1]); | |
| 631 | Ok(rect) | |
| 632 | } | |
| 633 | ||
| 634 | pub fn move_selection(&mut self, movement: Movement, extend: bool) -> Result<(), EditError> { | |
| 635 | self.finish_composition(); | |
| 636 | let [anchor, focus] = self.active_outline().selection.positions; | |
| 637 | let horizontal = matches!( | |
| 638 | movement, | |
| 639 | Movement::Left | Movement::Right | Movement::WordLeft | Movement::WordRight | |
| 640 | ); | |
| 641 | let left = matches!(movement, Movement::Left | Movement::WordLeft); | |
| 642 | if horizontal && !extend && anchor.paragraph != focus.paragraph { | |
| 643 | let index = usize::from(if left { anchor > focus } else { anchor < focus }); | |
| 644 | self.active_outline_mut().selection.positions = | |
| 645 | [self.active_outline().selection.positions[index]; 2]; | |
| 646 | self.active_outline_mut().selection.affinities = | |
| 647 | [self.active_outline().selection.affinities[index]; 2]; | |
| 648 | self.preferred_x = None; | |
| 649 | return Ok(()); | |
| 650 | } | |
| 651 | let mut index = self.active_outline().visible_index(focus.paragraph)?; | |
| 652 | let paragraph = &self.active_outline().shaped.paragraphs[index]; | |
| 653 | let cursor = | |
| 654 | paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?; | |
| 655 | let mut next; | |
| 656 | if matches!(movement, Movement::Up | Movement::Down) { | |
| 657 | let caret = paragraph.text.caret(cursor, 1.0); | |
| 658 | let x = self | |
| 659 | .preferred_x | |
| 660 | .unwrap_or(caret.x0 as f32 + paragraph.origin[0]); | |
| 661 | let line = paragraph | |
| 662 | .text | |
| 663 | .lines() | |
| 664 | .position(|(_, line)| f64::from(line.top) == caret.y0) | |
| 665 | .unwrap(); | |
| 666 | let target = if matches!(movement, Movement::Up) { | |
| 667 | if line > 0 { | |
| 668 | paragraph.text.lines().nth(line - 1).map(|(_, line)| line) | |
| 669 | } else if index > 0 { | |
| 670 | index -= 1; | |
| 671 | self.active_outline().shaped.paragraphs[index] | |
| 672 | .text | |
| 673 | .lines() | |
| 674 | .last() | |
| 675 | .map(|(_, line)| line) | |
| 676 | } else { | |
| 677 | None | |
| 678 | } | |
| 679 | } else if let Some((_, line)) = paragraph.text.lines().nth(line + 1) { | |
| 680 | Some(line) | |
| 681 | } else if index + 1 < self.active_outline().shaped.paragraphs.len() { | |
| 682 | index += 1; | |
| 683 | self.active_outline().shaped.paragraphs[index] | |
| 684 | .text | |
| 685 | .lines() | |
| 686 | .next() | |
| 687 | .map(|(_, line)| line) | |
| 688 | } else { | |
| 689 | None | |
| 690 | }; | |
| 691 | next = target | |
| 692 | .map(|line| { | |
| 693 | self.active_outline().shaped.paragraphs[index] | |
| 694 | .text | |
| 695 | .hit_test( | |
| 696 | x - self.active_outline().shaped.paragraphs[index].origin[0], | |
| 697 | line.top + line.height * 0.5, | |
| 698 | ) | |
| 699 | }) | |
| 700 | .unwrap_or(cursor); | |
| 701 | self.preferred_x = Some(x); | |
| 702 | } else { | |
| 703 | let local = if anchor.paragraph == focus.paragraph { | |
| 704 | ParagraphSelection::new( | |
| 705 | paragraph | |
| 706 | .cursor(anchor.offset, self.active_outline().selection.affinities[0])?, | |
| 707 | cursor, | |
| 708 | ) | |
| 709 | } else { | |
| 710 | cursor.into() | |
| 711 | }; | |
| 712 | let layout = &paragraph.text.shaped; | |
| 713 | next = match movement { | |
| 714 | Movement::Left => local.previous_visual(layout, extend), | |
| 715 | Movement::Right => local.next_visual(layout, extend), | |
| 716 | Movement::WordLeft => local.previous_visual_word(layout, extend), | |
| 717 | Movement::WordRight => local.next_visual_word(layout, extend), | |
| 718 | Movement::LineStart => local.line_start(layout, extend), | |
| 719 | Movement::LineEnd => local.line_end(layout, extend), | |
| 720 | Movement::Up | Movement::Down => unreachable!(), | |
| 721 | } | |
| 722 | .focus(); | |
| 723 | if horizontal && next == cursor && (extend || anchor == focus) { | |
| 724 | let neighbor = if left { | |
| 725 | index.checked_sub(1) | |
| 726 | } else { | |
| 727 | (index + 1 < self.active_outline().shaped.paragraphs.len()).then_some(index + 1) | |
| 728 | }; | |
| 729 | if let Some(neighbor) = neighbor { | |
| 730 | index = neighbor; | |
| 731 | let paragraph = &self.active_outline().shaped.paragraphs[index]; | |
| 732 | let line = if left { | |
| 733 | paragraph.text.lines().last() | |
| 734 | } else { | |
| 735 | paragraph.text.lines().next() | |
| 736 | } | |
| 737 | .unwrap() | |
| 738 | .1; | |
| 739 | next = paragraph.text.hit_test( | |
| 740 | if left { f32::MAX } else { -1.0 }, | |
| 741 | line.top + line.height * 0.5, | |
| 742 | ); | |
| 743 | if matches!(movement, Movement::WordLeft | Movement::WordRight) { | |
| 744 | next = if left { | |
| 745 | next.previous_visual_word(&paragraph.text.shaped) | |
| 746 | } else { | |
| 747 | next.next_visual_word(&paragraph.text.shaped) | |
| 748 | }; | |
| 749 | } | |
| 750 | } | |
| 751 | } | |
| 752 | } | |
| 753 | if !matches!(movement, Movement::Up | Movement::Down) { | |
| 754 | self.preferred_x = None; | |
| 755 | } | |
| 756 | let paragraph = &self.active_outline().shaped.paragraphs[index]; | |
| 757 | let visible = paragraph.projection.text().utf16_offset(next.index())?; | |
| 758 | let position = TextPosition { | |
| 759 | paragraph: self.active_outline().source_index(index), | |
| 760 | offset: paragraph | |
| 761 | .projection | |
| 762 | .source_offset(visible, next.affinity())?, | |
| 763 | }; | |
| 764 | if !extend { | |
| 765 | self.active_outline_mut().selection.positions[0] = position; | |
| 766 | self.active_outline_mut().selection.affinities[0] = next.affinity(); | |
| 767 | } | |
| 768 | self.active_outline_mut().selection.positions[1] = position; | |
| 769 | self.active_outline_mut().selection.affinities[1] = next.affinity(); | |
| 770 | Ok(()) | |
| 771 | } | |
| 772 | ||
| 773 | pub fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> { | |
| 774 | self.rectangles(self.active_outline().selection) | |
| 775 | } | |
| 776 | ||
| 777 | pub fn marked_rects(&self) -> Result<Vec<BoundingBox>, EditError> { | |
| 778 | match &self.composition { | |
| 779 | Some(composition) => self.rectangles(Selection { | |
| 780 | positions: [composition.range.start, composition.range.end], | |
| 781 | affinities: [Affinity::Downstream, Affinity::Upstream], | |
| 782 | }), | |
| 783 | None => Ok(Vec::new()), | |
| 784 | } | |
| 785 | } | |
| 786 | ||
| 787 | fn rectangles(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> { | |
| 788 | let [anchor, focus] = selection.positions; | |
| 789 | if anchor == focus { | |
| 790 | return Ok(Vec::new()); | |
| 791 | } | |
| 792 | let (start, end, affinities) = if anchor < focus { | |
| 793 | (anchor, focus, selection.affinities) | |
| 794 | } else { | |
| 795 | ( | |
| 796 | focus, | |
| 797 | anchor, | |
| 798 | [selection.affinities[1], selection.affinities[0]], | |
| 799 | ) | |
| 800 | }; | |
| 801 | let mut rectangles = Vec::new(); | |
| 802 | for (index, paragraph) in self | |
| 803 | .active_outline() | |
| 804 | .layouts() | |
| 805 | .filter(|(index, _)| *index >= start.paragraph && *index <= end.paragraph) | |
| 806 | { | |
| 807 | let source = self | |
| 808 | .active_outline() | |
| 809 | .document | |
| 810 | .paragraphs() | |
| 811 | .nth(index) | |
| 812 | .unwrap(); | |
| 813 | let first = if index == start.paragraph { | |
| 814 | paragraph.cursor(start.offset, affinities[0])? | |
| 815 | } else { | |
| 816 | paragraph.cursor(0, Affinity::Downstream)? | |
| 817 | }; | |
| 818 | let last = if index == end.paragraph { | |
| 819 | paragraph.cursor(end.offset, affinities[1])? | |
| 820 | } else { | |
| 821 | paragraph.cursor( | |
| 822 | source.utf16_offset(source.text().len())?, | |
| 823 | Affinity::Upstream, | |
| 824 | )? | |
| 825 | }; | |
| 826 | let mut local = paragraph | |
| 827 | .text | |
| 828 | .selection(ParagraphSelection::new(first, last)); | |
| 829 | if index < end.paragraph { | |
| 830 | local.push(paragraph.text.caret(last, 4.0)); | |
| 831 | } | |
| 832 | for mut rect in local { | |
| 833 | rect.x0 += f64::from(paragraph.origin[0]); | |
| 834 | rect.x1 += f64::from(paragraph.origin[0]); | |
| 835 | rect.y0 += f64::from(paragraph.origin[1]); | |
| 836 | rect.y1 += f64::from(paragraph.origin[1]); | |
| 837 | rectangles.push(rect); | |
| 838 | } | |
| 839 | } | |
| 840 | Ok(rectangles) | |
| 841 | } | |
| 842 | ||
| 843 | pub fn replace( | |
| 844 | &mut self, | |
| 845 | engine: &mut TextEngine, | |
| 846 | replacement: Vec<Paragraph>, | |
| 847 | ) -> Result<(), EditorError> { | |
| 848 | let start = self.active_outline().selection.positions[0] | |
| 849 | .min(self.active_outline().selection.positions[1]); | |
| 850 | let end = self.active_outline().selection.positions[0] | |
| 851 | .max(self.active_outline().selection.positions[1]); | |
| 852 | let last = replacement.last().ok_or(EditError::InvalidRange)?; | |
| 853 | let offset = last.utf16_offset(last.text().len())?; | |
| 854 | let offset = if replacement.len() == 1 { | |
| 855 | start | |
| 856 | .offset | |
| 857 | .checked_add(offset) | |
| 858 | .ok_or(EditError::TextTooLong)? | |
| 859 | } else { | |
| 860 | offset | |
| 861 | }; | |
| 862 | let caret = TextPosition { | |
| 863 | paragraph: start | |
| 864 | .paragraph | |
| 865 | .checked_add(replacement.len() - 1) | |
| 866 | .ok_or(EditError::TextTooLong)?, | |
| 867 | offset, | |
| 868 | }; | |
| 869 | let edit = self | |
| 870 | .active_outline() | |
| 871 | .document | |
| 872 | .replace(start..end, replacement)?; | |
| 873 | self.commit( | |
| 874 | engine, | |
| 875 | edit, | |
| 876 | Selection { | |
| 877 | positions: [caret; 2], | |
| 878 | affinities: [Affinity::Upstream; 2], | |
| 879 | }, | |
| 880 | ) | |
| 881 | } | |
| 882 | ||
| 883 | pub fn insert(&mut self, engine: &mut TextEngine, text: &str) -> Result<(), EditorError> { | |
| 884 | let start = self.active_outline().selection.positions[0] | |
| 885 | .min(self.active_outline().selection.positions[1]); | |
| 886 | let format = self | |
| 887 | .active_outline() | |
| 888 | .document | |
| 889 | .paragraphs() | |
| 890 | .nth(start.paragraph) | |
| 891 | .unwrap() | |
| 892 | .format_at(start.offset)?; | |
| 893 | let replacement = text | |
| 894 | .split('\n') | |
| 895 | .map(|line| Paragraph::new(line.to_owned(), format.clone())) | |
| 896 | .collect(); | |
| 897 | self.replace(engine, replacement) | |
| 898 | } | |
| 899 | ||
| 900 | pub fn delete(&mut self, engine: &mut TextEngine, backward: bool) -> Result<bool, EditorError> { | |
| 901 | let [anchor, focus] = self.active_outline().selection.positions; | |
| 902 | let mut range = anchor.min(focus)..anchor.max(focus); | |
| 903 | if range.is_empty() { | |
| 904 | let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?; | |
| 905 | let cursor = | |
| 906 | paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?; | |
| 907 | if let Some(cluster) = | |
| 908 | cursor.logical_clusters(&paragraph.text.shaped)[usize::from(!backward)] | |
| 909 | { | |
| 910 | let visible = paragraph.projection.text(); | |
| 911 | let bytes = cluster.text_range(); | |
| 912 | range.start.offset = paragraph | |
| 913 | .projection | |
| 914 | .source_offset(visible.utf16_offset(bytes.start)?, Affinity::Downstream)?; | |
| 915 | range.end.offset = paragraph | |
| 916 | .projection | |
| 917 | .source_offset(visible.utf16_offset(bytes.end)?, Affinity::Upstream)?; | |
| 918 | } else if backward && focus.paragraph > 0 { | |
| 919 | let previous = self | |
| 920 | .active_outline() | |
| 921 | .document | |
| 922 | .paragraphs() | |
| 923 | .nth(focus.paragraph - 1) | |
| 924 | .unwrap(); | |
| 925 | range.start = TextPosition { | |
| 926 | paragraph: focus.paragraph - 1, | |
| 927 | offset: previous.utf16_offset(previous.text().len())?, | |
| 928 | }; | |
| 929 | range.end.offset = 0; | |
| 930 | } else if !backward | |
| 931 | && focus.paragraph + 1 < self.active_outline().document.nodes().len() | |
| 932 | { | |
| 933 | let current = self | |
| 934 | .active_outline() | |
| 935 | .document | |
| 936 | .paragraphs() | |
| 937 | .nth(focus.paragraph) | |
| 938 | .unwrap(); | |
| 939 | range.start.offset = current.utf16_offset(current.text().len())?; | |
| 940 | range.end = TextPosition { | |
| 941 | paragraph: focus.paragraph + 1, | |
| 942 | offset: 0, | |
| 943 | }; | |
| 944 | } else { | |
| 945 | return Ok(false); | |
| 946 | } | |
| 947 | } | |
| 948 | let caret = range.start; | |
| 949 | let format = self | |
| 950 | .active_outline() | |
| 951 | .document | |
| 952 | .paragraphs() | |
| 953 | .nth(caret.paragraph) | |
| 954 | .unwrap() | |
| 955 | .format_at(caret.offset)? | |
| 956 | .clone(); | |
| 957 | let edit = self | |
| 958 | .active_outline() | |
| 959 | .document | |
| 960 | .replace(range, vec![Paragraph::new(String::new(), format)])?; | |
| 961 | self.commit( | |
| 962 | engine, | |
| 963 | edit, | |
| 964 | Selection { | |
| 965 | positions: [caret; 2], | |
| 966 | affinities: [Affinity::Upstream; 2], | |
| 967 | }, | |
| 968 | )?; | |
| 969 | Ok(true) | |
| 970 | } | |
| 971 | ||
| 972 | pub fn undo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> { | |
| 973 | if self.composition.is_some() { | |
| 974 | return self.cancel_composition(engine); | |
| 975 | } | |
| 976 | let Some(history) = self.undo.pop() else { | |
| 977 | return Ok(false); | |
| 978 | }; | |
| 979 | match self.apply_history(engine, history) { | |
| 980 | Ok(inverse) => { | |
| 981 | self.redo.push(inverse); | |
| 982 | Ok(true) | |
| 983 | } | |
| 984 | Err((history, error)) => { | |
| 985 | self.undo.push(history); | |
| 986 | Err(error) | |
| 987 | } | |
| 988 | } | |
| 989 | } | |
| 990 | ||
| 991 | pub fn redo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> { | |
| 992 | self.finish_composition(); | |
| 993 | let Some(history) = self.redo.pop() else { | |
| 994 | return Ok(false); | |
| 995 | }; | |
| 996 | match self.apply_history(engine, history) { | |
| 997 | Ok(inverse) => { | |
| 998 | self.undo.push(inverse); | |
| 999 | Ok(true) | |
| 1000 | } | |
| 1001 | Err((history, error)) => { | |
| 1002 | self.redo.push(history); | |
| 1003 | Err(error) | |
| 1004 | } | |
| 1005 | } | |
| 1006 | } | |
| 1007 | ||
| 1008 | fn apply_history( | |
| 1009 | &mut self, | |
| 1010 | engine: &mut TextEngine, | |
| 1011 | history: History, | |
| 1012 | ) -> Result<History, (History, EditorError)> { | |
| 1013 | let valid = match &history { | |
| 1014 | History::Text { outline, .. } | History::Position { outline, .. } => { | |
| 1015 | self.outlines.iter().any(|item| item.id == *outline) | |
| 1016 | } | |
| 1017 | History::Remove { outline, focus } => { | |
| 1018 | self.outlines.iter().any(|item| item.id == *outline) | |
| 1019 | && match focus { | |
| 1020 | RestoreFocus::Outline(focus) => { | |
| 1021 | outline != focus && self.outlines.iter().any(|item| item.id == *focus) | |
| 1022 | } | |
| 1023 | RestoreFocus::Caret { .. } => true, | |
| 1024 | } | |
| 1025 | } | |
| 1026 | History::Insert { index, .. } | History::Restore { index, .. } => { | |
| 1027 | *index <= self.outlines.len() | |
| 1028 | } | |
| 1029 | }; | |
| 1030 | if !valid { | |
| 1031 | return Err((history, EditError::InvalidRange.into())); | |
| 1032 | } | |
| 1033 | let inverse = match history { | |
| 1034 | History::Text { outline, change } => { | |
| 1035 | let index = self | |
| 1036 | .outlines | |
| 1037 | .iter() | |
| 1038 | .position(|item| item.id == outline) | |
| 1039 | .unwrap(); | |
| 1040 | let previous = std::mem::replace(&mut self.active, Focus::Outline(index)); | |
| 1041 | let inverse = match self.apply(engine, change.edit.clone(), change.selection, false) | |
| 1042 | { | |
| 1043 | Ok(change) => change, | |
| 1044 | Err(error) => { | |
| 1045 | self.active = previous; | |
| 1046 | return Err((History::Text { outline, change }, error)); | |
| 1047 | } | |
| 1048 | }; | |
| 1049 | History::Text { | |
| 1050 | outline, | |
| 1051 | change: inverse, | |
| 1052 | } | |
| 1053 | } | |
| 1054 | History::Position { outline, position } => { | |
| 1055 | let index = self | |
| 1056 | .outlines | |
| 1057 | .iter() | |
| 1058 | .position(|item| item.id == outline) | |
| 1059 | .unwrap(); | |
| 1060 | let layout = &mut self.outlines[index].layout; | |
| 1061 | let inverse = History::Position { | |
| 1062 | outline, | |
| 1063 | position: [layout.x, layout.y], | |
| 1064 | }; | |
| 1065 | [layout.x, layout.y] = position; | |
| 1066 | self.active = Focus::Outline(index); | |
| 1067 | inverse | |
| 1068 | } | |
| 1069 | History::Remove { outline, focus } => { | |
| 1070 | let index = self | |
| 1071 | .outlines | |
| 1072 | .iter() | |
| 1073 | .position(|item| item.id == outline) | |
| 1074 | .unwrap(); | |
| 1075 | let next_focus = match &focus { | |
| 1076 | RestoreFocus::Caret { | |
| 1077 | source, | |
| 1078 | selection, | |
| 1079 | index, | |
| 1080 | } => match TextOutline::from_outline(engine, source, &self.definitions) { | |
| 1081 | Ok(mut outline) => { | |
| 1082 | outline.selection = *selection; | |
| 1083 | Focus::Caret { | |
| 1084 | outline: Box::new(outline), | |
| 1085 | index: *index, | |
| 1086 | } | |
| 1087 | } | |
| 1088 | Err(error) => return Err((History::Remove { outline, focus }, error)), | |
| 1089 | }, | |
| 1090 | RestoreFocus::Outline(id) => { | |
| 1091 | let previous = self | |
| 1092 | .outlines | |
| 1093 | .iter() | |
| 1094 | .position(|outline| outline.id == *id) | |
| 1095 | .unwrap(); | |
| 1096 | Focus::Outline(previous - usize::from(previous > index)) | |
| 1097 | } | |
| 1098 | }; | |
| 1099 | let outline = Box::new(self.outlines.remove(index)); | |
| 1100 | self.active = next_focus; | |
| 1101 | History::Insert { | |
| 1102 | index, | |
| 1103 | outline, | |
| 1104 | focus, | |
| 1105 | } | |
| 1106 | } | |
| 1107 | History::Restore { | |
| 1108 | index, | |
| 1109 | source, | |
| 1110 | selection, | |
| 1111 | focus, | |
| 1112 | } => { | |
| 1113 | let mut outline = | |
| 1114 | match TextOutline::from_outline(engine, &source, &self.definitions) { | |
| 1115 | Ok(outline) => outline, | |
| 1116 | Err(error) => { | |
| 1117 | return Err(( | |
| 1118 | History::Restore { | |
| 1119 | index, | |
| 1120 | source, | |
| 1121 | selection, | |
| 1122 | focus, | |
| 1123 | }, | |
| 1124 | error, | |
| 1125 | )); | |
| 1126 | } | |
| 1127 | }; | |
| 1128 | outline.selection = selection; | |
| 1129 | return self.apply_history( | |
| 1130 | engine, | |
| 1131 | History::Insert { | |
| 1132 | index, | |
| 1133 | outline: Box::new(outline), | |
| 1134 | focus, | |
| 1135 | }, | |
| 1136 | ); | |
| 1137 | } | |
| 1138 | History::Insert { | |
| 1139 | index, | |
| 1140 | outline, | |
| 1141 | focus, | |
| 1142 | } => { | |
| 1143 | let id = outline.id; | |
| 1144 | self.outlines.insert(index, *outline); | |
| 1145 | self.active = Focus::Outline(index); | |
| 1146 | History::Remove { outline: id, focus } | |
| 1147 | } | |
| 1148 | }; | |
| 1149 | self.preferred_x = None; | |
| 1150 | Ok(inverse) | |
| 1151 | } | |
| 1152 | ||
| 1153 | pub fn marked_range(&self) -> Option<Range<TextPosition>> { | |
| 1154 | self.composition | |
| 1155 | .as_ref() | |
| 1156 | .map(|composition| composition.range.clone()) | |
| 1157 | } | |
| 1158 | ||
| 1159 | pub fn compose( | |
| 1160 | &mut self, | |
| 1161 | engine: &mut TextEngine, | |
| 1162 | text: String, | |
| 1163 | selected: Range<u32>, | |
| 1164 | ) -> Result<(), EditorError> { | |
| 1165 | if selected.start > selected.end { | |
| 1166 | return Err(EditError::InvalidRange.into()); | |
| 1167 | } | |
| 1168 | let range = self.marked_range().unwrap_or_else(|| { | |
| 1169 | self.active_outline().selection.positions[0] | |
| 1170 | .min(self.active_outline().selection.positions[1]) | |
| 1171 | ..self.active_outline().selection.positions[0] | |
| 1172 | .max(self.active_outline().selection.positions[1]) | |
| 1173 | }); | |
| 1174 | let positions = [ | |
| 1175 | inserted_position(range.start, &text, selected.start)?, | |
| 1176 | inserted_position(range.start, &text, selected.end)?, | |
| 1177 | ]; | |
| 1178 | let end = inserted_position( | |
| 1179 | range.start, | |
| 1180 | &text, | |
| 1181 | text.encode_utf16() | |
| 1182 | .count() | |
| 1183 | .try_into() | |
| 1184 | .map_err(|_| EditError::TextTooLong)?, | |
| 1185 | )?; | |
| 1186 | let format = self | |
| 1187 | .active_outline() | |
| 1188 | .document | |
| 1189 | .paragraphs() | |
| 1190 | .nth(range.start.paragraph) | |
| 1191 | .unwrap() | |
| 1192 | .format_at(range.start.offset)?; | |
| 1193 | let replacement = text | |
| 1194 | .split('\n') | |
| 1195 | .map(|part| Paragraph::new(part.to_owned(), format.clone())) | |
| 1196 | .collect(); | |
| 1197 | let edit = self | |
| 1198 | .active_outline() | |
| 1199 | .document | |
| 1200 | .replace(range.clone(), replacement)?; | |
| 1201 | let inverse = self.apply( | |
| 1202 | engine, | |
| 1203 | edit, | |
| 1204 | Selection { | |
| 1205 | positions, | |
| 1206 | affinities: [Affinity::Downstream; 2], | |
| 1207 | }, | |
| 1208 | false, | |
| 1209 | )?; | |
| 1210 | let original = if let Some(composition) = self.composition.take() { | |
| 1211 | let mut original = composition.original; | |
| 1212 | original.edit.range = inverse.edit.range; | |
| 1213 | original | |
| 1214 | } else { | |
| 1215 | inverse | |
| 1216 | }; | |
| 1217 | self.composition = Some(Composition { | |
| 1218 | original, | |
| 1219 | range: range.start..end, | |
| 1220 | }); | |
| 1221 | Ok(()) | |
| 1222 | } | |
| 1223 | ||
| 1224 | pub fn finish_composition(&mut self) { | |
| 1225 | if let Some(composition) = self.composition.take() { | |
| 1226 | self.record_change(composition.original); | |
| 1227 | } | |
| 1228 | } | |
| 1229 | ||
| 1230 | pub fn cancel_composition(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> { | |
| 1231 | let Some(composition) = &self.composition else { | |
| 1232 | return Ok(false); | |
| 1233 | }; | |
| 1234 | let original = composition.original.clone(); | |
| 1235 | self.apply(engine, original.edit, original.selection, false)?; | |
| 1236 | self.composition = None; | |
| 1237 | Ok(true) | |
| 1238 | } | |
| 1239 | ||
| 1240 | pub fn commit_text( | |
| 1241 | &mut self, | |
| 1242 | engine: &mut TextEngine, | |
| 1243 | text: String, | |
| 1244 | ) -> Result<(), EditorError> { | |
| 1245 | if self.composition.is_some() { | |
| 1246 | let end = text | |
| 1247 | .encode_utf16() | |
| 1248 | .count() | |
| 1249 | .try_into() | |
| 1250 | .map_err(|_| EditError::TextTooLong)?; | |
| 1251 | self.compose(engine, text, end..end)?; | |
| 1252 | self.finish_composition(); | |
| 1253 | Ok(()) | |
| 1254 | } else { | |
| 1255 | self.insert(engine, &text) | |
| 1256 | } | |
| 1257 | } | |
| 1258 | ||
| 1259 | fn commit( | |
| 1260 | &mut self, | |
| 1261 | engine: &mut TextEngine, | |
| 1262 | edit: DocumentEdit, | |
| 1263 | selection: Selection, | |
| 1264 | ) -> Result<(), EditorError> { | |
| 1265 | let inverse = self.apply(engine, edit, selection, true)?; | |
| 1266 | self.record_change(inverse); | |
| 1267 | Ok(()) | |
| 1268 | } | |
| 1269 | ||
| 1270 | fn record_change(&mut self, change: TextChange) { | |
| 1271 | if self.caret_outline().is_some() { | |
| 1272 | if self.active_outline().is_empty() { | |
| 1273 | return; | |
| 1274 | } | |
| 1275 | let Focus::Caret { index, .. } = self.active else { | |
| 1276 | unreachable!() | |
| 1277 | }; | |
| 1278 | let Focus::Caret { outline, .. } = | |
| 1279 | std::mem::replace(&mut self.active, Focus::Outline(index)) | |
| 1280 | else { | |
| 1281 | unreachable!(); | |
| 1282 | }; | |
| 1283 | let mut source = outline.snapshot(); | |
| 1284 | source.paragraphs = change.edit.replacement; | |
| 1285 | self.undo.push(History::Remove { | |
| 1286 | outline: outline.id, | |
| 1287 | focus: RestoreFocus::Caret { | |
| 1288 | source: Box::new(source), | |
| 1289 | selection: change.selection, | |
| 1290 | index, | |
| 1291 | }, | |
| 1292 | }); | |
| 1293 | self.outlines.insert(index, *outline); | |
| 1294 | } else if self.active_outline().is_empty() { | |
| 1295 | let Focus::Outline(index) = self.active else { | |
| 1296 | unreachable!() | |
| 1297 | }; | |
| 1298 | let outline = self.outlines.remove(index); | |
| 1299 | debug_assert_eq!(change.edit.range, 0..1); | |
| 1300 | let mut source = outline.snapshot(); | |
| 1301 | source.paragraphs = change.edit.replacement; | |
| 1302 | self.undo.push(History::Restore { | |
| 1303 | index, | |
| 1304 | source: Box::new(source), | |
| 1305 | selection: change.selection, | |
| 1306 | focus: RestoreFocus::Caret { | |
| 1307 | source: Box::new(outline.snapshot()), | |
| 1308 | selection: outline.selection, | |
| 1309 | index, | |
| 1310 | }, | |
| 1311 | }); | |
| 1312 | self.active = Focus::Caret { | |
| 1313 | outline: Box::new(outline), | |
| 1314 | index, | |
| 1315 | }; | |
| 1316 | } else { | |
| 1317 | self.undo.push(History::Text { | |
| 1318 | outline: self.active_outline().id, | |
| 1319 | change, | |
| 1320 | }); | |
| 1321 | } | |
| 1322 | self.redo.clear(); | |
| 1323 | } | |
| 1324 | ||
| 1325 | fn apply( | |
| 1326 | &mut self, | |
| 1327 | engine: &mut TextEngine, | |
| 1328 | edit: DocumentEdit, | |
| 1329 | selection: Selection, | |
| 1330 | finish_composition: bool, | |
| 1331 | ) -> Result<TextChange, EditorError> { | |
| 1332 | let outline = self.active_outline(); | |
| 1333 | outline.document.validate_edit(&edit)?; | |
| 1334 | let width = outline | |
| 1335 | .layout | |
| 1336 | .reserved_width | |
| 1337 | .or(outline.layout.max_width) | |
| 1338 | .unwrap(); | |
| 1339 | let visible_start = outline | |
| 1340 | .layouts() | |
| 1341 | .take_while(|(i, _)| *i < edit.range.start) | |
| 1342 | .count(); | |
| 1343 | let visible_end = outline | |
| 1344 | .layouts() | |
| 1345 | .take_while(|(i, _)| *i < edit.range.end) | |
| 1346 | .count(); | |
| 1347 | if visible_end - visible_start != edit.range.len() { | |
| 1348 | return Err(EditError::UnsupportedContent.into()); | |
| 1349 | } | |
| 1350 | let shaped = edit | |
| 1351 | .replacement | |
| 1352 | .iter() | |
| 1353 | .map(|paragraph| { | |
| 1354 | ParagraphLayout::shape( | |
| 1355 | engine, | |
| 1356 | paragraph, | |
| 1357 | width, | |
| 1358 | &outline.indents, | |
| 1359 | &self.definitions, | |
| 1360 | ) | |
| 1361 | }) | |
| 1362 | .collect::<Result<Vec<_>, _>>()?; | |
| 1363 | let proposed_layouts = || { | |
| 1364 | outline.shaped.paragraphs[..visible_start] | |
| 1365 | .iter() | |
| 1366 | .chain(&shaped) | |
| 1367 | .chain(&outline.shaped.paragraphs[visible_end..]) | |
| 1368 | }; | |
| 1369 | let (origins, size) = arrange( | |
| 1370 | proposed_layouts(), | |
| 1371 | width, | |
| 1372 | outline.layout.width_set_by_user == Some(true), | |
| 1373 | )?; | |
| 1374 | for position in selection.positions { | |
| 1375 | let node = outline.document.nodes()[..edit.range.start] | |
| 1376 | .iter() | |
| 1377 | .chain(&edit.replacement) | |
| 1378 | .chain(&outline.document.nodes()[edit.range.end..]) | |
| 1379 | .nth(position.paragraph) | |
| 1380 | .ok_or(EditError::InvalidRange)?; | |
| 1381 | node.text[0].text.byte_offset(position.offset)?; | |
| 1382 | if !proposed_layouts().any(|paragraph| paragraph.id == node.id) { | |
| 1383 | return Err(EditError::InvalidRange.into()); | |
| 1384 | } | |
| 1385 | } | |
| 1386 | if finish_composition { | |
| 1387 | self.finish_composition(); | |
| 1388 | } | |
| 1389 | let outline = self.active_outline_mut(); | |
| 1390 | let inverse = outline.document.apply(edit)?; | |
| 1391 | outline | |
| 1392 | .shaped | |
| 1393 | .paragraphs | |
| 1394 | .splice(visible_start..visible_end, shaped); | |
| 1395 | for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) { | |
| 1396 | paragraph.origin[1] = y; | |
| 1397 | } | |
| 1398 | outline.shaped.size = size; | |
| 1399 | self.preferred_x = None; | |
| 1400 | let previous_selection = | |
| 1401 | std::mem::replace(&mut self.active_outline_mut().selection, selection); | |
| 1402 | Ok(TextChange { | |
| 1403 | edit: inverse, | |
| 1404 | selection: previous_selection, | |
| 1405 | }) | |
| 1406 | } | |
| 1407 | } | |
| 1408 | ||
| 1409 | fn inserted_position( | |
| 1410 | mut position: TextPosition, | |
| 1411 | text: &str, | |
| 1412 | offset: u32, | |
| 1413 | ) -> Result<TextPosition, EditError> { | |
| 1414 | let mut units = 0_u32; | |
| 1415 | for character in text.chars() { | |
| 1416 | if units == offset { | |
| 1417 | return Ok(position); | |
| 1418 | } | |
| 1419 | units = units | |
| 1420 | .checked_add(character.len_utf16() as u32) | |
| 1421 | .ok_or(EditError::TextTooLong)?; | |
| 1422 | if units > offset { | |
| 1423 | return Err(EditError::InvalidRange); | |
| 1424 | } | |
| 1425 | if character == '\n' { | |
| 1426 | position.paragraph = position | |
| 1427 | .paragraph | |
| 1428 | .checked_add(1) | |
| 1429 | .ok_or(EditError::TextTooLong)?; | |
| 1430 | position.offset = 0; | |
| 1431 | } else { | |
| 1432 | position.offset = position | |
| 1433 | .offset | |
| 1434 | .checked_add(character.len_utf16() as u32) | |
| 1435 | .ok_or(EditError::TextTooLong)?; | |
| 1436 | } | |
| 1437 | } | |
| 1438 | if units == offset { | |
| 1439 | Ok(position) | |
| 1440 | } else { | |
| 1441 | Err(EditError::InvalidRange) | |
| 1442 | } | |
| 1443 | } | |
| 1444 | ||
| 1445 | #[cfg(test)] | |
| 1446 | mod tests { | |
| 1447 | use super::*; | |
| 1448 | use onestore::document::Format; | |
| 1449 | ||
| 1450 | #[test] | |
| 1451 | fn retiring_text_restores_source_selection_order_and_geometry_through_history() { | |
| 1452 | let mut engine = TextEngine::default(); | |
| 1453 | let mut outlines = Vec::new(); | |
| 1454 | for text in ["before", "bold 🌳\nannotation", "after"] { | |
| 1455 | let document = TextDocument::new( | |
| 1456 | text.split('\n') | |
| 1457 | .map(|line| { | |
| 1458 | Paragraph::new( | |
| 1459 | line.into(), | |
| 1460 | Format { | |
| 1461 | bold: Some(true), | |
| 1462 | ..Default::default() | |
| 1463 | }, | |
| 1464 | ) | |
| 1465 | }) | |
| 1466 | .collect(), | |
| 1467 | ) | |
| 1468 | .unwrap(); | |
| 1469 | outlines.push(TextOutline::new(&mut engine, document, 123.0, [43.5, 230.4]).unwrap()); | |
| 1470 | } | |
| 1471 | let id = outlines[1].id; | |
| 1472 | let document = outlines[1].document.clone(); | |
| 1473 | let geometry = layout_snapshot(&outlines[1].shaped); | |
| 1474 | let layout = outlines[1].layout.clone(); | |
| 1475 | let ids = outlines | |
| 1476 | .iter() | |
| 1477 | .map(|outline| outline.id) | |
| 1478 | .collect::<Vec<_>>(); | |
| 1479 | let mut editor = CanvasEditor::from_text_outlines(outlines, BTreeMap::new()).unwrap(); | |
| 1480 | editor.focus_outline(id).unwrap(); | |
| 1481 | editor.select_all().unwrap(); | |
| 1482 | let selection = editor.selection(); | |
| 1483 | editor.delete(&mut engine, true).unwrap(); | |
| 1484 | assert_eq!( | |
| 1485 | editor.outlines.iter().map(|o| o.id).collect::<Vec<_>>(), | |
| 1486 | [ids[0], ids[2]] | |
| 1487 | ); | |
| 1488 | assert_eq!(editor.caret_outline().unwrap().id, id); | |
| 1489 | assert_eq!(editor.active_outline().layout, layout); | |
| 1490 | editor.insert(&mut engine, "again").unwrap(); | |
| 1491 | assert_eq!( | |
| 1492 | editor.outlines.iter().map(|o| o.id).collect::<Vec<_>>(), | |
| 1493 | ids | |
| 1494 | ); | |
| 1495 | assert_eq!(editor.active_outline().id, id); | |
| 1496 | editor.undo(&mut engine).unwrap(); | |
| 1497 | assert_eq!(editor.caret_outline().unwrap().id, id); | |
| 1498 | editor | |
| 1499 | .place_caret(&mut engine, [900.0, 500.0], 77.0) | |
| 1500 | .unwrap(); | |
| 1501 | editor.undo(&mut engine).unwrap(); | |
| 1502 | assert_eq!(editor.active_outline().document, document); | |
| 1503 | assert_eq!(editor.active_outline().layout, layout); | |
| 1504 | assert_eq!(editor.selection(), selection); | |
| 1505 | assert_eq!(layout_snapshot(&editor.active_outline().shaped), geometry); | |
| 1506 | assert_eq!( | |
| 1507 | editor.outlines.iter().map(|o| o.id).collect::<Vec<_>>(), | |
| 1508 | ids | |
| 1509 | ); | |
| 1510 | editor.redo(&mut engine).unwrap(); | |
| 1511 | assert_eq!(editor.outlines.len(), 2); | |
| 1512 | assert_eq!(editor.active_outline().id, id); | |
| 1513 | assert_eq!(editor.active_outline().origin(), [43.5, 230.4]); | |
| 1514 | editor.redo(&mut engine).unwrap(); | |
| 1515 | assert_eq!(editor.active_outline().id, id); | |
| 1516 | assert_eq!( | |
| 1517 | editor.outlines.iter().map(|o| o.id).collect::<Vec<_>>(), | |
| 1518 | ids | |
| 1519 | ); | |
| 1520 | assert_eq!( | |
| 1521 | editor.outlines[0] | |
| 1522 | .document | |
| 1523 | .paragraphs() | |
| 1524 | .next() | |
| 1525 | .unwrap() | |
| 1526 | .text(), | |
| 1527 | "before" | |
| 1528 | ); | |
| 1529 | assert_eq!( | |
| 1530 | editor.outlines[2] | |
| 1531 | .document | |
| 1532 | .paragraphs() | |
| 1533 | .next() | |
| 1534 | .unwrap() | |
| 1535 | .text(), | |
| 1536 | "after" | |
| 1537 | ); | |
| 1538 | } | |
| 1539 | ||
| 1540 | #[test] | |
| 1541 | fn empty_composition_commits_safely_when_focus_or_position_changes() { | |
| 1542 | let mut engine = TextEngine::default(); | |
| 1543 | for action in 0..5 { | |
| 1544 | let mut outlines = Vec::new(); | |
| 1545 | for text in ["delete", "keep"] { | |
| 1546 | let document = | |
| 1547 | TextDocument::new(vec![Paragraph::new(text.into(), Format::default())]) | |
| 1548 | .unwrap(); | |
| 1549 | outlines.push(TextOutline::new(&mut engine, document, 240.0, [0.0; 2]).unwrap()); | |
| 1550 | } | |
| 1551 | let first = outlines[0].id; | |
| 1552 | let second = outlines[1].id; | |
| 1553 | let original = outlines[0].document.clone(); | |
| 1554 | let mut editor = CanvasEditor::from_text_outlines(outlines, BTreeMap::new()).unwrap(); | |
| 1555 | editor.select_all().unwrap(); | |
| 1556 | editor.compose(&mut engine, String::new(), 0..0).unwrap(); | |
| 1557 | assert_eq!(editor.outlines.len(), 2); | |
| 1558 | editor.cancel_composition(&mut engine).unwrap(); | |
| 1559 | assert_eq!(editor.active_outline().document, original); | |
| 1560 | editor.compose(&mut engine, String::new(), 0..0).unwrap(); | |
| 1561 | match action { | |
| 1562 | 0 => editor.focus_outline(first).unwrap(), | |
| 1563 | 1 => editor.focus_outline(second).unwrap(), | |
| 1564 | 2 => editor.move_outline(first, [30.0, 40.0]).unwrap(), | |
| 1565 | 3 => editor.move_outline(second, [30.0, 40.0]).unwrap(), | |
| 1566 | _ => editor.commit_text(&mut engine, String::new()).unwrap(), | |
| 1567 | } | |
| 1568 | assert!(editor.marked_range().is_none()); | |
| 1569 | assert_eq!(editor.outlines.len(), 1); | |
| 1570 | assert_eq!(editor.outlines[0].id, second); | |
| 1571 | assert_eq!( | |
| 1572 | editor.outlines[0] | |
| 1573 | .document | |
| 1574 | .paragraphs() | |
| 1575 | .next() | |
| 1576 | .unwrap() | |
| 1577 | .text(), | |
| 1578 | "keep" | |
| 1579 | ); | |
| 1580 | if action == 3 { | |
| 1581 | editor.undo(&mut engine).unwrap(); | |
| 1582 | } | |
| 1583 | editor.undo(&mut engine).unwrap(); | |
| 1584 | assert_eq!(editor.active_outline().id, first); | |
| 1585 | assert_eq!(editor.active_outline().document, original); | |
| 1586 | assert_eq!(editor.outlines.len(), 2); | |
| 1587 | editor.redo(&mut engine).unwrap(); | |
| 1588 | assert_eq!(editor.outlines.len(), 1); | |
| 1589 | } | |
| 1590 | let document = | |
| 1591 | TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(); | |
| 1592 | let mut editor = CanvasEditor::new(&mut engine, document.clone(), 240.0).unwrap(); | |
| 1593 | editor.compose(&mut engine, "preedit".into(), 0..7).unwrap(); | |
| 1594 | editor.delete(&mut engine, true).unwrap(); | |
| 1595 | assert_eq!(editor.undo.len(), 2); | |
| 1596 | assert!(editor.outlines.is_empty()); | |
| 1597 | editor.undo(&mut engine).unwrap(); | |
| 1598 | assert_eq!( | |
| 1599 | editor | |
| 1600 | .active_outline() | |
| 1601 | .document | |
| 1602 | .paragraphs() | |
| 1603 | .next() | |
| 1604 | .unwrap() | |
| 1605 | .text(), | |
| 1606 | "preedit" | |
| 1607 | ); | |
| 1608 | editor.undo(&mut engine).unwrap(); | |
| 1609 | assert_eq!(editor.active_outline().document, document); | |
| 1610 | assert!(editor.outlines.is_empty()); | |
| 1611 | } | |
| 1612 | ||
| 1613 | #[test] | |
| 1614 | fn space_only_caret_preserves_spacing_but_blank_paragraphs_and_metadata_are_content() { | |
| 1615 | let mut engine = TextEngine::default(); | |
| 1616 | let document = | |
| 1617 | TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(); | |
| 1618 | let mut editor = CanvasEditor::new(&mut engine, document, 240.0).unwrap(); | |
| 1619 | editor.insert(&mut engine, " ").unwrap(); | |
| 1620 | assert!(editor.outlines.is_empty()); | |
| 1621 | assert!(editor.undo.is_empty()); | |
| 1622 | let spaces = editor.active_outline().document.clone(); | |
| 1623 | let selection = editor.selection(); | |
| 1624 | editor.insert(&mut engine, "x").unwrap(); | |
| 1625 | assert_eq!(editor.outlines.len(), 1); | |
| 1626 | editor.delete(&mut engine, true).unwrap(); | |
| 1627 | assert!(editor.outlines.is_empty()); | |
| 1628 | assert_eq!(editor.active_outline().document, spaces); | |
| 1629 | for _ in 0..2 { | |
| 1630 | editor.undo(&mut engine).unwrap(); | |
| 1631 | } | |
| 1632 | assert!(editor.outlines.is_empty()); | |
| 1633 | assert_eq!(editor.active_outline().document, spaces); | |
| 1634 | assert_eq!(editor.selection(), selection); | |
| 1635 | editor.insert(&mut engine, "\n").unwrap(); | |
| 1636 | assert_eq!(editor.outlines.len(), 1); | |
| 1637 | assert_eq!(editor.active_outline().document.nodes().len(), 2); | |
| 1638 | for text in ["\t", "\u{a0}", "\u{200b}"] { | |
| 1639 | editor.select_all().unwrap(); | |
| 1640 | editor.insert(&mut engine, text).unwrap(); | |
| 1641 | assert_eq!(editor.outlines.len(), 1); | |
| 1642 | } | |
| 1643 | let mut source = editor.active_outline().snapshot(); | |
| 1644 | source.paragraphs[0].collapsed = true; | |
| 1645 | let mut editor = | |
| 1646 | CanvasEditor::from_outlines(&mut engine, vec![source], BTreeMap::new()).unwrap(); | |
| 1647 | editor.select_all().unwrap(); | |
| 1648 | editor.delete(&mut engine, true).unwrap(); | |
| 1649 | assert_eq!(editor.outlines.len(), 1); | |
| 1650 | assert!(editor.active_outline().document.nodes()[0].collapsed); | |
| 1651 | } | |
| 1652 | ||
| 1653 | #[test] | |
| 1654 | fn caret_placement_is_not_document_history_and_first_input_is_one_undo() { | |
| 1655 | let mut engine = TextEngine::default(); | |
| 1656 | let original = TextDocument::new(vec![Paragraph::new( | |
| 1657 | "Existing notes".into(), | |
| 1658 | Format::default(), | |
| 1659 | )]) | |
| 1660 | .unwrap(); | |
| 1661 | let mut editor = CanvasEditor::new(&mut engine, original.clone(), 240.0).unwrap(); | |
| 1662 | let existing = editor.active_outline().id; | |
| 1663 | for step in 0..100 { | |
| 1664 | editor | |
| 1665 | .place_caret(&mut engine, [step as f32, 100.0], 120.0) | |
| 1666 | .unwrap(); | |
| 1667 | assert_eq!(editor.outlines.len(), 1); | |
| 1668 | assert!(editor.undo.is_empty()); | |
| 1669 | assert!(editor.redo.is_empty()); | |
| 1670 | } | |
| 1671 | let id = editor.active_outline().id; | |
| 1672 | let empty = editor.active_outline().document.clone(); | |
| 1673 | editor | |
| 1674 | .insert(&mut engine, "annotation\nsecond line") | |
| 1675 | .unwrap(); | |
| 1676 | let document = editor.active_outline().document.clone(); | |
| 1677 | assert_eq!(editor.active_outline().id, id); | |
| 1678 | assert!(editor.caret_outline().is_none()); | |
| 1679 | assert_eq!(editor.outlines.len(), 2); | |
| 1680 | assert_eq!(editor.undo.len(), 1); | |
| 1681 | editor.undo(&mut engine).unwrap(); | |
| 1682 | assert_eq!(editor.outlines.len(), 1); | |
| 1683 | assert_eq!(editor.active_outline().document, empty); | |
| 1684 | assert_eq!(editor.active_outline().origin(), [99.0, 100.0]); | |
| 1685 | editor | |
| 1686 | .place_caret(&mut engine, [600.0, 400.0], 180.0) | |
| 1687 | .unwrap(); | |
| 1688 | editor.insert(&mut engine, "").unwrap(); | |
| 1689 | assert_eq!(editor.redo.len(), 1); | |
| 1690 | editor.redo(&mut engine).unwrap(); | |
| 1691 | assert_eq!(editor.active_outline().id, id); | |
| 1692 | assert_eq!(editor.active_outline().document, document); | |
| 1693 | assert_eq!(editor.active_outline().origin(), [99.0, 100.0]); | |
| 1694 | editor.focus_outline(existing).unwrap(); | |
| 1695 | assert_eq!(editor.active_outline().document, original); | |
| 1696 | } | |
| 1697 | ||
| 1698 | #[test] | |
| 1699 | fn provisional_input_preserves_the_empty_document_and_redo_until_commit() { | |
| 1700 | let mut engine = TextEngine::default(); | |
| 1701 | let document = TextDocument::new(vec![Paragraph::new( | |
| 1702 | String::new(), | |
| 1703 | Format { | |
| 1704 | bold: Some(true), | |
| 1705 | ..Default::default() | |
| 1706 | }, | |
| 1707 | )]) | |
| 1708 | .unwrap(); | |
| 1709 | let mut editor = CanvasEditor::new(&mut engine, document.clone(), 240.0).unwrap(); | |
| 1710 | assert!(editor.outlines.is_empty()); | |
| 1711 | editor.insert(&mut engine, "first").unwrap(); | |
| 1712 | editor.undo(&mut engine).unwrap(); | |
| 1713 | let id = editor.active_outline().id; | |
| 1714 | editor.compose(&mut engine, "に".into(), 1..1).unwrap(); | |
| 1715 | assert!(editor.outlines.is_empty()); | |
| 1716 | assert_eq!(editor.redo.len(), 1); | |
| 1717 | assert!( | |
| 1718 | editor | |
| 1719 | .place_caret(&mut engine, [f32::NAN, 0.0], 240.0) | |
| 1720 | .is_err() | |
| 1721 | ); | |
| 1722 | assert_eq!(editor.active_outline().id, id); | |
| 1723 | assert!(editor.marked_range().is_some()); | |
| 1724 | editor.cancel_composition(&mut engine).unwrap(); | |
| 1725 | assert_eq!(editor.active_outline().document, document); | |
| 1726 | assert_eq!(editor.redo.len(), 1); | |
| 1727 | editor.compose(&mut engine, "に".into(), 1..1).unwrap(); | |
| 1728 | editor.commit_text(&mut engine, "日本".into()).unwrap(); | |
| 1729 | assert_eq!(editor.outlines.len(), 1); | |
| 1730 | assert_eq!(editor.active_outline().id, id); | |
| 1731 | assert_eq!(editor.undo.len(), 1); | |
| 1732 | assert!(editor.redo.is_empty()); | |
| 1733 | editor.undo(&mut engine).unwrap(); | |
| 1734 | assert!(editor.outlines.is_empty()); | |
| 1735 | assert_eq!(editor.active_outline().document, document); | |
| 1736 | } | |
| 1737 | ||
| 1738 | fn ys(outline: &TextOutline) -> Vec<f32> { | |
| 1739 | outline | |
| 1740 | .shaped | |
| 1741 | .paragraphs | |
| 1742 | .iter() | |
| 1743 | .map(|paragraph| paragraph.origin[1]) | |
| 1744 | .collect() | |
| 1745 | } | |
| 1746 | ||
| 1747 | fn layout_snapshot(layout: &OutlineLayout) -> serde_json::Value { | |
| 1748 | serde_json::json!({ | |
| 1749 | "size": layout.size, | |
| 1750 | "paragraphs": layout.paragraphs.iter().map(|paragraph| serde_json::json!({ | |
| 1751 | "id": paragraph.id, "origin": paragraph.origin, | |
| 1752 | "text": paragraph.projection.text().text(), | |
| 1753 | "lines": paragraph.text.lines().map(|(line, bounds)| serde_json::json!({ | |
| 1754 | "source": bounds.source, "top": bounds.top, "baseline": bounds.baseline, | |
| 1755 | "height": bounds.height, "advance": line.metrics().advance, | |
| 1756 | })).collect::<Vec<_>>(), | |
| 1757 | "markers": paragraph.markers.iter().map(|(text, origin)| serde_json::json!({ | |
| 1758 | "origin": origin, "height": text.height(), | |
| 1759 | "baseline": text.lines().next().unwrap().1.baseline, | |
| 1760 | "advance": text.lines().next().unwrap().0.metrics().advance, | |
| 1761 | })).collect::<Vec<_>>(), | |
| 1762 | })).collect::<Vec<_>>(), | |
| 1763 | }) | |
| 1764 | } | |
| 1765 | ||
| 1766 | #[test] | |
| 1767 | fn native_outline_omits_outer_paragraph_spacing_through_split_and_undo() { | |
| 1768 | use crate::page::{Page, PageObject}; | |
| 1769 | use onestore::{RevisionIndex, Store, document::Document}; | |
| 1770 | let store = Store::parse(include_bytes!( | |
| 1771 | "../../../resources/canvas/baseline-anchors.one" | |
| 1772 | )) | |
| 1773 | .unwrap(); | |
| 1774 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 1775 | let page = | |
| 1776 | Page::from_document(&Document::parse(&index).unwrap(), "Baseline anchors").unwrap(); | |
| 1777 | let outline = page | |
| 1778 | .objects | |
| 1779 | .into_iter() | |
| 1780 | .find_map(|object| match object { | |
| 1781 | PageObject::Outline(outline) | |
| 1782 | if outline.paragraphs[0].combined_text().text() == "Spacing first" => | |
| 1783 | { | |
| 1784 | Some(outline) | |
| 1785 | } | |
| 1786 | _ => None, | |
| 1787 | }) | |
| 1788 | .unwrap(); | |
| 1789 | let mut engine = TextEngine::default(); | |
| 1790 | let mut editor = | |
| 1791 | CanvasEditor::from_outlines(&mut engine, vec![outline], page.definitions).unwrap(); | |
| 1792 | let source = editor.active_outline().document.nodes().to_vec(); | |
| 1793 | for step in 0..3 { | |
| 1794 | let outline = editor.active_outline(); | |
| 1795 | let paragraphs = &outline.shaped.paragraphs; | |
| 1796 | assert_eq!(paragraphs[0].origin[1], 0.0); | |
| 1797 | for pair in paragraphs.windows(2) { | |
| 1798 | assert!( | |
| 1799 | (pair[1].origin[1] - pair[0].origin[1] - pair[0].text.height() - 144.0).abs() | |
| 1800 | < 0.0001 | |
| 1801 | ); | |
| 1802 | } | |
| 1803 | let height = paragraphs.iter().map(|p| p.text.height()).sum::<f32>() | |
| 1804 | + (paragraphs.len() - 1) as f32 * 144.0; | |
| 1805 | assert!((outline.shaped.size[1] - height).abs() < 0.0001); | |
| 1806 | match step { | |
| 1807 | 0 => editor.insert(&mut engine, "\n").unwrap(), | |
| 1808 | 1 => assert!(editor.undo(&mut engine).unwrap()), | |
| 1809 | _ => assert_eq!(outline.document.nodes(), source), | |
| 1810 | } | |
| 1811 | } | |
| 1812 | } | |
| 1813 | ||
| 1814 | #[test] | |
| 1815 | fn resizing_an_automatic_outline_sets_its_explicit_width_and_preserves_spacing_metadata() { | |
| 1816 | let document = TextDocument::new(vec![Paragraph::new( | |
| 1817 | "short".into(), | |
| 1818 | Format { | |
| 1819 | space_after: Some(7.0), | |
| 1820 | ..Format::default() | |
| 1821 | }, | |
| 1822 | )]) | |
| 1823 | .unwrap(); | |
| 1824 | let source = document.nodes().to_vec(); | |
| 1825 | let outline = Outline { | |
| 1826 | id: crate::text::new_id().unwrap(), | |
| 1827 | layout: onestore::document::Layout { | |
| 1828 | max_width: Some(220.0), | |
| 1829 | reserved_width: Some(180.0), | |
| 1830 | width_set_by_user: Some(false), | |
| 1831 | ..Default::default() | |
| 1832 | }, | |
| 1833 | indents: vec![18.0, 0.0], | |
| 1834 | paragraphs: source.clone(), | |
| 1835 | unsupported: Vec::new(), | |
| 1836 | }; | |
| 1837 | let mut engine = TextEngine::default(); | |
| 1838 | let mut editor = | |
| 1839 | CanvasEditor::from_outlines(&mut engine, vec![outline], BTreeMap::new()).unwrap(); | |
| 1840 | assert!(editor.active_outline().bounds().width() < 180.0); | |
| 1841 | let height = editor | |
| 1842 | .active_outline() | |
| 1843 | .paragraph_layout(0) | |
| 1844 | .unwrap() | |
| 1845 | .text | |
| 1846 | .height(); | |
| 1847 | assert_eq!(editor.active_outline().bounds().height(), f64::from(height)); | |
| 1848 | editor.resize(&mut engine, 180.0).unwrap(); | |
| 1849 | assert_eq!(editor.active_outline().bounds().width(), 180.0); | |
| 1850 | assert_eq!(editor.active_outline().layout.reserved_width, None); | |
| 1851 | assert_eq!(editor.active_outline().layout.width_set_by_user, Some(true)); | |
| 1852 | assert_eq!(editor.active_outline().document.nodes(), source); | |
| 1853 | let cached = editor | |
| 1854 | .active_outline() | |
| 1855 | .paragraph_layout(0) | |
| 1856 | .unwrap() | |
| 1857 | .text | |
| 1858 | .shaped | |
| 1859 | .styles() | |
| 1860 | .as_ptr(); | |
| 1861 | editor.resize(&mut engine, 180.0).unwrap(); | |
| 1862 | assert_eq!( | |
| 1863 | editor | |
| 1864 | .active_outline() | |
| 1865 | .paragraph_layout(0) | |
| 1866 | .unwrap() | |
| 1867 | .text | |
| 1868 | .shaped | |
| 1869 | .styles() | |
| 1870 | .as_ptr(), | |
| 1871 | cached | |
| 1872 | ); | |
| 1873 | } | |
| 1874 | ||
| 1875 | #[test] | |
| 1876 | fn imported_indentation_markers_and_collapsed_children_share_edit_geometry() { | |
| 1877 | let document = TextDocument::new( | |
| 1878 | [ | |
| 1879 | "First line", | |
| 1880 | "Indented text with enough words to wrap", | |
| 1881 | "Collapsed", | |
| 1882 | "Hidden", | |
| 1883 | "Last", | |
| 1884 | ] | |
| 1885 | .into_iter() | |
| 1886 | .map(|s| Paragraph::new(s.into(), Format::default())) | |
| 1887 | .collect(), | |
| 1888 | ) | |
| 1889 | .unwrap(); | |
| 1890 | let mut nodes = document.nodes().to_vec(); | |
| 1891 | nodes[1].parent = Some(nodes[0].id); | |
| 1892 | nodes[1].level = 2; | |
| 1893 | nodes[2].parent = Some(nodes[0].id); | |
| 1894 | nodes[2].level = 2; | |
| 1895 | nodes[2].collapsed = true; | |
| 1896 | nodes[3].parent = Some(nodes[2].id); | |
| 1897 | nodes[3].level = 3; | |
| 1898 | let marker = crate::text::new_id().unwrap(); | |
| 1899 | nodes[1].lists.push(marker); | |
| 1900 | let definitions = BTreeMap::from([( | |
| 1901 | marker, | |
| 1902 | Definition { | |
| 1903 | kind: onestore::document::Kind::List { | |
| 1904 | font: Some("Courier New".into()), | |
| 1905 | format: Some("○".into()), | |
| 1906 | restart: None, | |
| 1907 | bullet: Some(4), | |
| 1908 | }, | |
| 1909 | format: Format { | |
| 1910 | font_size: Some(22.0), | |
| 1911 | ..Format::default() | |
| 1912 | }, | |
| 1913 | }, | |
| 1914 | )]); | |
| 1915 | let outline = Outline { | |
| 1916 | id: crate::text::new_id().unwrap(), | |
| 1917 | layout: onestore::document::Layout { | |
| 1918 | x: Some(30.0), | |
| 1919 | y: Some(45.0), | |
| 1920 | max_width: Some(150.0), | |
| 1921 | width_set_by_user: Some(true), | |
| 1922 | ..Default::default() | |
| 1923 | }, | |
| 1924 | indents: vec![18.0, 0.0, 27.0], | |
| 1925 | paragraphs: nodes.clone(), | |
| 1926 | unsupported: Vec::new(), | |
| 1927 | }; | |
| 1928 | let mut engine = TextEngine::default(); | |
| 1929 | let expected = layout_snapshot(&outline.layout(&mut engine, &definitions).unwrap()); | |
| 1930 | let id = outline.id; | |
| 1931 | let mut editor = | |
| 1932 | CanvasEditor::from_outlines(&mut engine, vec![outline], definitions).unwrap(); | |
| 1933 | assert_eq!(editor.active_outline().id, id); | |
| 1934 | assert_eq!(editor.active_outline().origin(), [30.0, 45.0]); | |
| 1935 | assert_eq!( | |
| 1936 | editor | |
| 1937 | .active_outline() | |
| 1938 | .layouts() | |
| 1939 | .map(|(i, _)| i) | |
| 1940 | .collect::<Vec<_>>(), | |
| 1941 | [0, 1, 2, 4] | |
| 1942 | ); | |
| 1943 | assert_eq!(layout_snapshot(&editor.active_outline().shaped), expected); | |
| 1944 | let second = editor.active_outline().paragraph_layout(1).unwrap(); | |
| 1945 | assert_eq!(second.origin[0], 27.0); | |
| 1946 | let y = second.origin[1] + second.text.lines().next().unwrap().1.height * 0.5; | |
| 1947 | editor.select_at(27.0, y, false).unwrap(); | |
| 1948 | assert_eq!( | |
| 1949 | editor.selection().positions[1], | |
| 1950 | TextPosition { | |
| 1951 | paragraph: 1, | |
| 1952 | offset: 0 | |
| 1953 | } | |
| 1954 | ); | |
| 1955 | assert_eq!(editor.caret(1.0).unwrap().x0, 27.0); | |
| 1956 | let retained = editor | |
| 1957 | .active_outline() | |
| 1958 | .paragraph_layout(4) | |
| 1959 | .unwrap() | |
| 1960 | .text | |
| 1961 | .shaped | |
| 1962 | .styles() | |
| 1963 | .as_ptr(); | |
| 1964 | editor.insert(&mut engine, "More words ").unwrap(); | |
| 1965 | assert_eq!( | |
| 1966 | editor | |
| 1967 | .active_outline() | |
| 1968 | .paragraph_layout(4) | |
| 1969 | .unwrap() | |
| 1970 | .text | |
| 1971 | .shaped | |
| 1972 | .styles() | |
| 1973 | .as_ptr(), | |
| 1974 | retained | |
| 1975 | ); | |
| 1976 | let rebuilt = Outline { | |
| 1977 | id, | |
| 1978 | layout: editor.active_outline().layout.clone(), | |
| 1979 | indents: vec![18.0, 0.0, 27.0], | |
| 1980 | paragraphs: editor.active_outline().document.nodes().to_vec(), | |
| 1981 | unsupported: Vec::new(), | |
| 1982 | } | |
| 1983 | .layout(&mut engine, &editor.definitions) | |
| 1984 | .unwrap(); | |
| 1985 | assert_eq!( | |
| 1986 | layout_snapshot(&editor.active_outline().shaped), | |
| 1987 | layout_snapshot(&rebuilt) | |
| 1988 | ); | |
| 1989 | let edited = layout_snapshot(&editor.active_outline().shaped); | |
| 1990 | let before = editor.active_outline().document.clone(); | |
| 1991 | let selection = editor.selection(); | |
| 1992 | assert!(editor.insert(&mut engine, "\n").is_err()); | |
| 1993 | assert!(editor.resize(&mut engine, 20.0).is_err()); | |
| 1994 | assert_eq!(editor.active_outline().document, before); | |
| 1995 | assert_eq!(editor.selection(), selection); | |
| 1996 | assert_eq!(layout_snapshot(&editor.active_outline().shaped), edited); | |
| 1997 | editor.undo(&mut engine).unwrap(); | |
| 1998 | assert_eq!(editor.active_outline().document.nodes(), nodes); | |
| 1999 | assert_eq!(layout_snapshot(&editor.active_outline().shaped), expected); | |
| 2000 | editor.redo(&mut engine).unwrap(); | |
| 2001 | assert_eq!(layout_snapshot(&editor.active_outline().shaped), edited); | |
| 2002 | assert!( | |
| 2003 | editor | |
| 2004 | .select( | |
| 2005 | [TextPosition { | |
| 2006 | paragraph: 3, | |
| 2007 | offset: 0 | |
| 2008 | }; 2] | |
| 2009 | .into() | |
| 2010 | ) | |
| 2011 | .is_err() | |
| 2012 | ); | |
| 2013 | editor | |
| 2014 | .select( | |
| 2015 | [TextPosition { | |
| 2016 | paragraph: 2, | |
| 2017 | offset: 9, | |
| 2018 | }; 2] | |
| 2019 | .into(), | |
| 2020 | ) | |
| 2021 | .unwrap(); | |
| 2022 | editor.move_selection(Movement::Right, false).unwrap(); | |
| 2023 | assert_eq!(editor.selection().positions[1].paragraph, 4); | |
| 2024 | editor.move_selection(Movement::Left, false).unwrap(); | |
| 2025 | assert_eq!(editor.selection().positions[1].paragraph, 2); | |
| 2026 | editor.move_selection(Movement::Down, false).unwrap(); | |
| 2027 | assert_eq!(editor.selection().positions[1].paragraph, 4); | |
| 2028 | editor.move_selection(Movement::Up, false).unwrap(); | |
| 2029 | assert_eq!(editor.selection().positions[1].paragraph, 2); | |
| 2030 | editor.select_all().unwrap(); | |
| 2031 | assert!(!editor.selection_rects().unwrap().is_empty()); | |
| 2032 | } | |
| 2033 | ||
| 2034 | #[test] | |
| 2035 | #[ignore = "requires CANVAS_TEST_SECTION and CANVAS_TEST_PAGE private fixture inputs"] | |
| 2036 | fn imported_editor_reflows_and_restores_native_outline_geometry() { | |
| 2037 | use crate::page::{Page, PageObject}; | |
| 2038 | use onestore::{RevisionIndex, Store, document::Document}; | |
| 2039 | let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); | |
| 2040 | let page = { | |
| 2041 | let store = Store::parse(&bytes).unwrap(); | |
| 2042 | let index = RevisionIndex::parse(&store).unwrap(); | |
| 2043 | Page::from_document( | |
| 2044 | &Document::parse(&index).unwrap(), | |
| 2045 | &std::env::var("CANVAS_TEST_PAGE").unwrap(), | |
| 2046 | ) | |
| 2047 | .unwrap() | |
| 2048 | }; | |
| 2049 | drop(bytes); | |
| 2050 | let mut engine = TextEngine::default(); | |
| 2051 | if let Some(font) = std::env::var_os("CANVAS_TEST_SUBSTITUTE") { | |
| 2052 | engine | |
| 2053 | .register_substitute(parley::fontique::Blob::new(std::sync::Arc::new( | |
| 2054 | std::fs::read(font).unwrap(), | |
| 2055 | ))) | |
| 2056 | .unwrap(); | |
| 2057 | } | |
| 2058 | let outlines: Vec<_> = page | |
| 2059 | .objects | |
| 2060 | .into_iter() | |
| 2061 | .filter_map(|object| match object { | |
| 2062 | PageObject::Outline(outline) => Some(outline), | |
| 2063 | _ => None, | |
| 2064 | }) | |
| 2065 | .collect(); | |
| 2066 | let original: Vec<_> = outlines | |
| 2067 | .iter() | |
| 2068 | .map(|outline| { | |
| 2069 | ( | |
| 2070 | outline.id, | |
| 2071 | outline.layout.clone(), | |
| 2072 | outline.paragraphs.clone(), | |
| 2073 | layout_snapshot(&outline.layout(&mut engine, &page.definitions).unwrap()), | |
| 2074 | ) | |
| 2075 | }) | |
| 2076 | .collect(); | |
| 2077 | let mut editor = | |
| 2078 | CanvasEditor::from_outlines(&mut engine, outlines, page.definitions).unwrap(); | |
| 2079 | let mut count = 0; | |
| 2080 | for (id, source_layout, nodes, expected) in original { | |
| 2081 | editor.focus_outline(id).unwrap(); | |
| 2082 | assert_eq!(editor.active_outline().layout, source_layout); | |
| 2083 | assert_eq!(layout_snapshot(&editor.active_outline().shaped), expected); | |
| 2084 | let visible: Vec<_> = editor.active_outline().layouts().map(|(i, _)| i).collect(); | |
| 2085 | for paragraph in visible { | |
| 2086 | let text = &nodes[paragraph].text[0].text; | |
| 2087 | let byte = text | |
| 2088 | .text() | |
| 2089 | .char_indices() | |
| 2090 | .nth(text.text().chars().count() / 2) | |
| 2091 | .map(|(byte, _)| byte) | |
| 2092 | .unwrap_or(0); | |
| 2093 | let position = TextPosition { | |
| 2094 | paragraph, | |
| 2095 | offset: text.utf16_offset(byte).unwrap(), | |
| 2096 | }; | |
| 2097 | editor.select([position; 2].into()).unwrap(); | |
| 2098 | editor.insert(&mut engine, "probe ").unwrap(); | |
| 2099 | let edited = editor.active_outline().document.clone(); | |
| 2100 | let rebuilt = Outline { | |
| 2101 | id, | |
| 2102 | layout: source_layout.clone(), | |
| 2103 | indents: editor.active_outline().indents.clone(), | |
| 2104 | paragraphs: edited.nodes().to_vec(), | |
| 2105 | unsupported: Vec::new(), | |
| 2106 | } | |
| 2107 | .layout(&mut engine, &editor.definitions) | |
| 2108 | .unwrap(); | |
| 2109 | let after = layout_snapshot(&editor.active_outline().shaped); | |
| 2110 | assert_eq!(after, layout_snapshot(&rebuilt)); | |
| 2111 | editor.undo(&mut engine).unwrap(); | |
| 2112 | assert_eq!(editor.active_outline().document.nodes(), nodes); | |
| 2113 | assert_eq!(layout_snapshot(&editor.active_outline().shaped), expected); | |
| 2114 | editor.redo(&mut engine).unwrap(); | |
| 2115 | assert_eq!(editor.active_outline().document, edited); | |
| 2116 | assert_eq!(layout_snapshot(&editor.active_outline().shaped), after); | |
| 2117 | editor.undo(&mut engine).unwrap(); | |
| 2118 | count += 1; | |
| 2119 | } | |
| 2120 | } | |
| 2121 | eprintln!( | |
| 2122 | "Reflowed and restored {count} visible paragraphs in {} native outlines", | |
| 2123 | editor.outlines().len() | |
| 2124 | ); | |
| 2125 | assert!(count > 0); | |
| 2126 | } | |
| 2127 | ||
| 2128 | #[test] | |
| 2129 | fn visual_navigation_does_not_bounce_between_opposite_direction_paragraphs() { | |
| 2130 | let mut engine = TextEngine::default(); | |
| 2131 | let document = TextDocument::new( | |
| 2132 | ["a", "שלום", "end"] | |
| 2133 | .into_iter() | |
| 2134 | .map(|text| Paragraph::new(text.into(), Format::default())) | |
| 2135 | .collect(), | |
| 2136 | ) | |
| 2137 | .unwrap(); | |
| 2138 | let mut editor = CanvasEditor::new(&mut engine, document, 100.0).unwrap(); | |
| 2139 | let first = TextPosition { | |
| 2140 | paragraph: 0, | |
| 2141 | offset: 0, | |
| 2142 | }; | |
| 2143 | let last = TextPosition { | |
| 2144 | paragraph: 2, | |
| 2145 | offset: 3, | |
| 2146 | }; | |
| 2147 | for _ in 0..32 { | |
| 2148 | editor.move_selection(Movement::Right, false).unwrap(); | |
| 2149 | if editor.selection().positions[1] == last { | |
| 2150 | break; | |
| 2151 | } | |
| 2152 | } | |
| 2153 | assert_eq!(editor.selection().positions[1], last); | |
| 2154 | for _ in 0..32 { | |
| 2155 | editor.move_selection(Movement::Left, false).unwrap(); | |
| 2156 | if editor.selection().positions[1] == first { | |
| 2157 | break; | |
| 2158 | } | |
| 2159 | } | |
| 2160 | assert_eq!(editor.selection().positions[1], first); | |
| 2161 | } | |
| 2162 | ||
| 2163 | #[test] | |
| 2164 | fn styled_reversed_selection_survives_reflow_undo_and_navigation_reuses_layout() { | |
| 2165 | let mut engine = TextEngine::default(); | |
| 2166 | let original = TextDocument::new(vec![Paragraph::from_runs([ | |
| 2167 | ("plain ".into(), Format::default()), | |
| 2168 | ( | |
| 2169 | "bold text".into(), | |
| 2170 | Format { | |
| 2171 | bold: Some(true), | |
| 2172 | ..Format::default() | |
| 2173 | }, | |
| 2174 | ), | |
| 2175 | ])]) | |
| 2176 | .unwrap(); | |
| 2177 | let mut editor = CanvasEditor::new(&mut engine, original.clone(), 100.0).unwrap(); | |
| 2178 | let anchor = TextPosition { | |
| 2179 | paragraph: 0, | |
| 2180 | offset: 10, | |
| 2181 | }; | |
| 2182 | let focus = TextPosition { | |
| 2183 | paragraph: 0, | |
| 2184 | offset: 2, | |
| 2185 | }; | |
| 2186 | editor.select([anchor, focus].into()).unwrap(); | |
| 2187 | editor.insert(&mut engine, "🌳").unwrap(); | |
| 2188 | let after = editor.active_outline().document.clone(); | |
| 2189 | editor.resize(&mut engine, 35.0).unwrap(); | |
| 2190 | editor.undo(&mut engine).unwrap(); | |
| 2191 | assert_eq!(editor.active_outline().document, original); | |
| 2192 | assert_eq!(editor.selection().positions, [anchor, focus]); | |
| 2193 | editor.redo(&mut engine).unwrap(); | |
| 2194 | assert_eq!(editor.active_outline().document, after); | |
| 2195 | assert_eq!(editor.selection().positions[1].offset, 4); | |
| 2196 | let styles = editor.active_outline().shaped.paragraphs[0] | |
| 2197 | .text | |
| 2198 | .shaped | |
| 2199 | .styles() | |
| 2200 | .as_ptr(); | |
| 2201 | for movement in [ | |
| 2202 | Movement::Left, | |
| 2203 | Movement::WordRight, | |
| 2204 | Movement::Down, | |
| 2205 | Movement::Up, | |
| 2206 | Movement::LineStart, | |
| 2207 | Movement::LineEnd, | |
| 2208 | ] { | |
| 2209 | editor.move_selection(movement, false).unwrap(); | |
| 2210 | assert_eq!( | |
| 2211 | editor.active_outline().shaped.paragraphs[0] | |
| 2212 | .text | |
| 2213 | .shaped | |
| 2214 | .styles() | |
| 2215 | .as_ptr(), | |
| 2216 | styles | |
| 2217 | ); | |
| 2218 | } | |
| 2219 | editor.resize(&mut engine, 35.0).unwrap(); | |
| 2220 | editor.select_at(15.0, 20.0, true).unwrap(); | |
| 2221 | assert_eq!( | |
| 2222 | editor.active_outline().shaped.paragraphs[0] | |
| 2223 | .text | |
| 2224 | .shaped | |
| 2225 | .styles() | |
| 2226 | .as_ptr(), | |
| 2227 | styles | |
| 2228 | ); | |
| 2229 | } | |
| 2230 | ||
| 2231 | #[test] | |
| 2232 | fn multiline_composition_is_provisional_and_commits_as_one_structural_edit() { | |
| 2233 | let mut engine = TextEngine::default(); | |
| 2234 | let original = TextDocument::new(vec![ | |
| 2235 | Paragraph::new("abcd".into(), Format::default()), | |
| 2236 | Paragraph::new( | |
| 2237 | "middle".into(), | |
| 2238 | Format { | |
| 2239 | bold: Some(true), | |
| 2240 | ..Format::default() | |
| 2241 | }, | |
| 2242 | ), | |
| 2243 | Paragraph::new("tail".into(), Format::default()), | |
| 2244 | Paragraph::new("untouched".into(), Format::default()), | |
| 2245 | ]) | |
| 2246 | .unwrap(); | |
| 2247 | let mut editor = CanvasEditor::new(&mut engine, original.clone(), 70.0).unwrap(); | |
| 2248 | let untouched = editor.active_outline().shaped.paragraphs[3] | |
| 2249 | .text | |
| 2250 | .shaped | |
| 2251 | .styles() | |
| 2252 | .as_ptr(); | |
| 2253 | let start = TextPosition { | |
| 2254 | paragraph: 0, | |
| 2255 | offset: 1, | |
| 2256 | }; | |
| 2257 | let end = TextPosition { | |
| 2258 | paragraph: 2, | |
| 2259 | offset: 2, | |
| 2260 | }; | |
| 2261 | editor.select([end, start].into()).unwrap(); | |
| 2262 | editor.compose(&mut engine, "ni\nhao".into(), 2..3).unwrap(); | |
| 2263 | assert_eq!( | |
| 2264 | editor.selection().positions, | |
| 2265 | [ | |
| 2266 | TextPosition { | |
| 2267 | paragraph: 0, | |
| 2268 | offset: 3 | |
| 2269 | }, | |
| 2270 | TextPosition { | |
| 2271 | paragraph: 1, | |
| 2272 | offset: 0 | |
| 2273 | } | |
| 2274 | ] | |
| 2275 | ); | |
| 2276 | assert_eq!( | |
| 2277 | editor.marked_range(), | |
| 2278 | Some( | |
| 2279 | start..TextPosition { | |
| 2280 | paragraph: 1, | |
| 2281 | offset: 3 | |
| 2282 | } | |
| 2283 | ) | |
| 2284 | ); | |
| 2285 | editor | |
| 2286 | .compose(&mut engine, "e\u{301}".into(), 1..1) | |
| 2287 | .unwrap(); | |
| 2288 | assert_eq!( | |
| 2289 | editor.selection().positions, | |
| 2290 | [TextPosition { | |
| 2291 | paragraph: 0, | |
| 2292 | offset: 2 | |
| 2293 | }; 2] | |
| 2294 | ); | |
| 2295 | editor.commit_text(&mut engine, "日\n本".into()).unwrap(); | |
| 2296 | assert_eq!( | |
| 2297 | editor | |
| 2298 | .active_outline() | |
| 2299 | .document | |
| 2300 | .paragraphs() | |
| 2301 | .map(Paragraph::text) | |
| 2302 | .collect::<Vec<_>>(), | |
| 2303 | ["a日", "本il", "untouched"] | |
| 2304 | ); | |
| 2305 | assert!(editor.marked_range().is_none()); | |
| 2306 | assert_eq!( | |
| 2307 | editor.active_outline().shaped.paragraphs[2] | |
| 2308 | .text | |
| 2309 | .shaped | |
| 2310 | .styles() | |
| 2311 | .as_ptr(), | |
| 2312 | untouched | |
| 2313 | ); | |
| 2314 | editor.undo(&mut engine).unwrap(); | |
| 2315 | assert_eq!(editor.active_outline().document, original); | |
| 2316 | assert_eq!(editor.selection().positions, [end, start]); | |
| 2317 | assert!(!editor.undo(&mut engine).unwrap()); | |
| 2318 | editor.redo(&mut engine).unwrap(); | |
| 2319 | assert_eq!( | |
| 2320 | editor | |
| 2321 | .active_outline() | |
| 2322 | .document | |
| 2323 | .paragraphs() | |
| 2324 | .nth(1) | |
| 2325 | .unwrap() | |
| 2326 | .text(), | |
| 2327 | "本il" | |
| 2328 | ); | |
| 2329 | } | |
| 2330 | ||
| 2331 | #[test] | |
| 2332 | fn composition_failure_and_cancel_preserve_source_selection_and_redo() { | |
| 2333 | let mut engine = TextEngine::default(); | |
| 2334 | let original = TextDocument::new(vec![ | |
| 2335 | Paragraph::new("first".into(), Format::default()), | |
| 2336 | Paragraph::new("last".into(), Format::default()), | |
| 2337 | ]) | |
| 2338 | .unwrap(); | |
| 2339 | let mut editor = CanvasEditor::new(&mut engine, original.clone(), 70.0).unwrap(); | |
| 2340 | editor.insert(&mut engine, "!").unwrap(); | |
| 2341 | editor.undo(&mut engine).unwrap(); | |
| 2342 | let first = TextPosition { | |
| 2343 | paragraph: 0, | |
| 2344 | offset: 0, | |
| 2345 | }; | |
| 2346 | let last = TextPosition { | |
| 2347 | paragraph: 1, | |
| 2348 | offset: 4, | |
| 2349 | }; | |
| 2350 | editor.select([last, first].into()).unwrap(); | |
| 2351 | editor | |
| 2352 | .compose(&mut engine, "pre\nedit".into(), 8..8) | |
| 2353 | .unwrap(); | |
| 2354 | let provisional = editor.active_outline().document.clone(); | |
| 2355 | let selection = editor.selection().positions; | |
| 2356 | let marked = editor.marked_range(); | |
| 2357 | assert!(editor.compose(&mut engine, "🌳".into(), 1..1).is_err()); | |
| 2358 | assert_eq!(editor.active_outline().document, provisional); | |
| 2359 | assert_eq!(editor.selection().positions, selection); | |
| 2360 | assert_eq!(editor.marked_range(), marked); | |
| 2361 | editor.resize(&mut engine, 25.0).unwrap(); | |
| 2362 | editor.cancel_composition(&mut engine).unwrap(); | |
| 2363 | assert_eq!(editor.active_outline().document, original); | |
| 2364 | assert_eq!(editor.selection().positions, [last, first]); | |
| 2365 | editor.redo(&mut engine).unwrap(); | |
| 2366 | assert_eq!( | |
| 2367 | editor | |
| 2368 | .active_outline() | |
| 2369 | .document | |
| 2370 | .paragraphs() | |
| 2371 | .next() | |
| 2372 | .unwrap() | |
| 2373 | .text(), | |
| 2374 | "!first" | |
| 2375 | ); | |
| 2376 | } | |
| 2377 | ||
| 2378 | #[test] | |
| 2379 | fn navigation_crosses_paragraphs_and_keeps_the_preferred_column() { | |
| 2380 | let mut engine = TextEngine::default(); | |
| 2381 | let document = TextDocument::new(vec![ | |
| 2382 | Paragraph::new("abcdefghij".into(), Format::default()), | |
| 2383 | Paragraph::new(String::new(), Format::default()), | |
| 2384 | Paragraph::new("abcdefghij".into(), Format::default()), | |
| 2385 | ]) | |
| 2386 | .unwrap(); | |
| 2387 | let mut editor = CanvasEditor::new(&mut engine, document, 200.0).unwrap(); | |
| 2388 | let start = TextPosition { | |
| 2389 | paragraph: 0, | |
| 2390 | offset: 8, | |
| 2391 | }; | |
| 2392 | editor.select([start, start].into()).unwrap(); | |
| 2393 | let x = editor.caret(1.0).unwrap().x0; | |
| 2394 | editor.move_selection(Movement::Down, false).unwrap(); | |
| 2395 | assert_eq!( | |
| 2396 | editor.selection().positions[1], | |
| 2397 | TextPosition { | |
| 2398 | paragraph: 1, | |
| 2399 | offset: 0 | |
| 2400 | } | |
| 2401 | ); | |
| 2402 | editor.move_selection(Movement::Down, false).unwrap(); | |
| 2403 | assert_eq!( | |
| 2404 | editor.selection().positions[1], | |
| 2405 | TextPosition { | |
| 2406 | paragraph: 2, | |
| 2407 | offset: 8 | |
| 2408 | } | |
| 2409 | ); | |
| 2410 | assert_eq!(editor.caret(1.0).unwrap().x0, x); | |
| 2411 | editor.move_selection(Movement::Up, true).unwrap(); | |
| 2412 | editor.move_selection(Movement::Up, true).unwrap(); | |
| 2413 | assert_eq!( | |
| 2414 | editor.selection().positions, | |
| 2415 | [ | |
| 2416 | TextPosition { | |
| 2417 | paragraph: 2, | |
| 2418 | offset: 8 | |
| 2419 | }, | |
| 2420 | start | |
| 2421 | ] | |
| 2422 | ); | |
| 2423 | editor.move_selection(Movement::Left, false).unwrap(); | |
| 2424 | assert_eq!(editor.selection().positions, [start; 2]); | |
| 2425 | let boundary = TextPosition { | |
| 2426 | paragraph: 1, | |
| 2427 | offset: 0, | |
| 2428 | }; | |
| 2429 | editor.select([boundary, boundary].into()).unwrap(); | |
| 2430 | editor.move_selection(Movement::Left, false).unwrap(); | |
| 2431 | assert_eq!( | |
| 2432 | editor.selection().positions[1], | |
| 2433 | TextPosition { | |
| 2434 | paragraph: 0, | |
| 2435 | offset: 10 | |
| 2436 | } | |
| 2437 | ); | |
| 2438 | editor.move_selection(Movement::Right, false).unwrap(); | |
| 2439 | assert_eq!(editor.selection().positions[1], boundary); | |
| 2440 | } | |
| 2441 | ||
| 2442 | #[test] | |
| 2443 | fn typing_enter_and_boundary_delete_form_one_reversible_history() { | |
| 2444 | let mut engine = TextEngine::default(); | |
| 2445 | let original = | |
| 2446 | TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]).unwrap(); | |
| 2447 | let mut editor = CanvasEditor::new(&mut engine, original.clone(), 80.0).unwrap(); | |
| 2448 | assert!(!editor.delete(&mut engine, true).unwrap()); | |
| 2449 | assert!(!editor.delete(&mut engine, false).unwrap()); | |
| 2450 | editor.insert(&mut engine, "left\n\nright").unwrap(); | |
| 2451 | assert_eq!( | |
| 2452 | editor | |
| 2453 | .active_outline() | |
| 2454 | .document | |
| 2455 | .paragraphs() | |
| 2456 | .map(Paragraph::text) | |
| 2457 | .collect::<Vec<_>>(), | |
| 2458 | ["left", "", "right"] | |
| 2459 | ); | |
| 2460 | let point = TextPosition { | |
| 2461 | paragraph: 2, | |
| 2462 | offset: 0, | |
| 2463 | }; | |
| 2464 | editor.select([point, point].into()).unwrap(); | |
| 2465 | editor.delete(&mut engine, true).unwrap(); | |
| 2466 | assert_eq!( | |
| 2467 | editor | |
| 2468 | .active_outline() | |
| 2469 | .document | |
| 2470 | .paragraphs() | |
| 2471 | .map(Paragraph::text) | |
| 2472 | .collect::<Vec<_>>(), | |
| 2473 | ["left", "right"] | |
| 2474 | ); | |
| 2475 | let point = TextPosition { | |
| 2476 | paragraph: 0, | |
| 2477 | offset: 4, | |
| 2478 | }; | |
| 2479 | editor.select([point, point].into()).unwrap(); | |
| 2480 | editor.delete(&mut engine, false).unwrap(); | |
| 2481 | assert_eq!( | |
| 2482 | editor | |
| 2483 | .active_outline() | |
| 2484 | .document | |
| 2485 | .paragraphs() | |
| 2486 | .next() | |
| 2487 | .unwrap() | |
| 2488 | .text(), | |
| 2489 | "leftright" | |
| 2490 | ); | |
| 2491 | for _ in 0..3 { | |
| 2492 | assert!(editor.undo(&mut engine).unwrap()); | |
| 2493 | } | |
| 2494 | assert_eq!(editor.active_outline().document, original); | |
| 2495 | for _ in 0..3 { | |
| 2496 | assert!(editor.redo(&mut engine).unwrap()); | |
| 2497 | } | |
| 2498 | assert_eq!( | |
| 2499 | editor | |
| 2500 | .active_outline() | |
| 2501 | .document | |
| 2502 | .paragraphs() | |
| 2503 | .next() | |
| 2504 | .unwrap() | |
| 2505 | .text(), | |
| 2506 | "leftright" | |
| 2507 | ); | |
| 2508 | } | |
| 2509 | ||
| 2510 | #[test] | |
| 2511 | fn grapheme_deletion_preserves_adjacent_hidden_fields() { | |
| 2512 | let mut engine = TextEngine::default(); | |
| 2513 | for grapheme in ["e\u{301}", "👩🏽💻", "🇨🇦", "👩👩👧👦"] { | |
| 2514 | let original = TextDocument::new(vec![Paragraph::from_runs([ | |
| 2515 | ( | |
| 2516 | "field".into(), | |
| 2517 | Format { | |
| 2518 | hidden: Some(true), | |
| 2519 | ..Format::default() | |
| 2520 | }, | |
| 2521 | ), | |
| 2522 | (format!("a{grapheme}z"), Format::default()), | |
| 2523 | ])]) | |
| 2524 | .unwrap(); | |
| 2525 | let mut editor = CanvasEditor::new(&mut engine, original.clone(), 80.0).unwrap(); | |
| 2526 | for backward in [false, true] { | |
| 2527 | let offset = 6 + if backward { | |
| 2528 | grapheme.encode_utf16().count() as u32 | |
| 2529 | } else { | |
| 2530 | 0 | |
| 2531 | }; | |
| 2532 | let point = TextPosition { | |
| 2533 | paragraph: 0, | |
| 2534 | offset, | |
| 2535 | }; | |
| 2536 | editor.select([point, point].into()).unwrap(); | |
| 2537 | editor.delete(&mut engine, backward).unwrap(); | |
| 2538 | assert_eq!( | |
| 2539 | editor | |
| 2540 | .active_outline() | |
| 2541 | .document | |
| 2542 | .paragraphs() | |
| 2543 | .next() | |
| 2544 | .unwrap() | |
| 2545 | .text(), | |
| 2546 | "fieldaz" | |
| 2547 | ); | |
| 2548 | assert_eq!( | |
| 2549 | editor.active_outline().shaped.paragraphs[0] | |
| 2550 | .projection | |
| 2551 | .text() | |
| 2552 | .text(), | |
| 2553 | "az" | |
| 2554 | ); | |
| 2555 | editor.undo(&mut engine).unwrap(); | |
| 2556 | assert_eq!(editor.active_outline().document, original); | |
| 2557 | assert_eq!(editor.selection().positions, [point; 2]); | |
| 2558 | } | |
| 2559 | } | |
| 2560 | } | |
| 2561 | ||
| 2562 | #[test] | |
| 2563 | fn pointer_selection_maps_hidden_source_and_paragraph_boundaries() { | |
| 2564 | let mut engine = TextEngine::default(); | |
| 2565 | let text = TextDocument::new(vec![ | |
| 2566 | Paragraph::from_runs([ | |
| 2567 | ("first ".into(), Format::default()), | |
| 2568 | ( | |
| 2569 | "hidden🌳".into(), | |
| 2570 | Format { | |
| 2571 | hidden: Some(true), | |
| 2572 | ..Format::default() | |
| 2573 | }, | |
| 2574 | ), | |
| 2575 | ("visible words wrap here".into(), Format::default()), | |
| 2576 | ]), | |
| 2577 | Paragraph::new(String::new(), Format::default()), | |
| 2578 | Paragraph::new("last".into(), Format::default()), | |
| 2579 | ]) | |
| 2580 | .unwrap(); | |
| 2581 | let mut editor = CanvasEditor::new(&mut engine, text.clone(), 70.0).unwrap(); | |
| 2582 | let last = TextPosition { | |
| 2583 | paragraph: 2, | |
| 2584 | offset: 4, | |
| 2585 | }; | |
| 2586 | editor.select([last, last].into()).unwrap(); | |
| 2587 | let caret = editor.caret(1.0).unwrap(); | |
| 2588 | editor | |
| 2589 | .select_at(caret.x0 as f32, ((caret.y0 + caret.y1) / 2.0) as f32, false) | |
| 2590 | .unwrap(); | |
| 2591 | assert_eq!(editor.selection().positions, [last; 2]); | |
| 2592 | let first = TextPosition { | |
| 2593 | paragraph: 0, | |
| 2594 | offset: 0, | |
| 2595 | }; | |
| 2596 | editor.select([last, first].into()).unwrap(); | |
| 2597 | let rectangles = editor.selection_rects().unwrap(); | |
| 2598 | assert!( | |
| 2599 | rectangles | |
| 2600 | .iter() | |
| 2601 | .any(|r| r.y0 == f64::from(ys(editor.active_outline())[1]) && r.width() == 4.0) | |
| 2602 | ); | |
| 2603 | editor | |
| 2604 | .replace( | |
| 2605 | &mut engine, | |
| 2606 | vec![Paragraph::new("replacement".into(), Format::default())], | |
| 2607 | ) | |
| 2608 | .unwrap(); | |
| 2609 | editor.undo(&mut engine).unwrap(); | |
| 2610 | assert_eq!(editor.active_outline().document, text); | |
| 2611 | assert_eq!(editor.selection().positions, [last, first]); | |
| 2612 | ||
| 2613 | let source = &editor.active_outline().shaped.paragraphs[0]; | |
| 2614 | let cursor = source.text.cursor(6, Affinity::Downstream); | |
| 2615 | let caret = source.text.caret(cursor, 1.0); | |
| 2616 | editor | |
| 2617 | .select_at(caret.x0 as f32, ((caret.y0 + caret.y1) / 2.0) as f32, false) | |
| 2618 | .unwrap(); | |
| 2619 | let selected = editor.selection().positions[1]; | |
| 2620 | assert!(selected.offset == 6 || selected.offset == 14); | |
| 2621 | assert_eq!(editor.caret(1.0).unwrap().y0, caret.y0); | |
| 2622 | } | |
| 2623 | ||
| 2624 | #[test] | |
| 2625 | fn mixed_structural_edit_history_matches_full_layout_recomputation() { | |
| 2626 | let mut engine = TextEngine::default(); | |
| 2627 | let text = TextDocument::new(vec![ | |
| 2628 | Paragraph::new("one two three".into(), Format::default()), | |
| 2629 | Paragraph::new("日本 🌳 e\u{301}".into(), Format::default()), | |
| 2630 | ]) | |
| 2631 | .unwrap(); | |
| 2632 | let mut editor = CanvasEditor::new(&mut engine, text, 72.0).unwrap(); | |
| 2633 | let mut seed = 0x8d45_u64; | |
| 2634 | let mut next = || { | |
| 2635 | seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); | |
| 2636 | seed as usize | |
| 2637 | }; | |
| 2638 | let mut before = Vec::new(); | |
| 2639 | let mut after = Vec::new(); | |
| 2640 | for step in 0..64 { | |
| 2641 | let positions: Vec<_> = editor | |
| 2642 | .active_outline() | |
| 2643 | .document | |
| 2644 | .paragraphs() | |
| 2645 | .enumerate() | |
| 2646 | .flat_map(|(paragraph, text)| { | |
| 2647 | (0..=text.text().len()).filter_map(move |byte| { | |
| 2648 | text.utf16_offset(byte) | |
| 2649 | .ok() | |
| 2650 | .map(|offset| TextPosition { paragraph, offset }) | |
| 2651 | }) | |
| 2652 | }) | |
| 2653 | .collect(); | |
| 2654 | let anchor = positions[next() % positions.len()]; | |
| 2655 | let focus = positions[next() % positions.len()]; | |
| 2656 | editor.select([anchor, focus].into()).unwrap(); | |
| 2657 | before.push(( | |
| 2658 | editor.active_outline().document.clone(), | |
| 2659 | editor.selection().positions, | |
| 2660 | )); | |
| 2661 | let format = Format { | |
| 2662 | bold: Some(step % 2 == 0), | |
| 2663 | font_size: Some(10.0 + (step % 4) as f32), | |
| 2664 | space_before: Some((step % 3) as f32), | |
| 2665 | space_after: Some((step % 5) as f32), | |
| 2666 | line_spacing: Some(12.0 + (step % 2) as f32), | |
| 2667 | ..Format::default() | |
| 2668 | }; | |
| 2669 | let replacement = match step % 4 { | |
| 2670 | 0 => vec![Paragraph::new(String::new(), format); 2], | |
| 2671 | 1 => vec![Paragraph::new("x👩🏽💻".into(), format)], | |
| 2672 | 2 => vec![Paragraph::new(String::new(), format)], | |
| 2673 | _ => vec![ | |
| 2674 | Paragraph::new("wrapped words".into(), format.clone()), | |
| 2675 | Paragraph::new("tail".into(), format), | |
| 2676 | ], | |
| 2677 | }; | |
| 2678 | editor.replace(&mut engine, replacement).unwrap(); | |
| 2679 | after.push(editor.active_outline().document.clone()); | |
| 2680 | let rebuilt = | |
| 2681 | CanvasEditor::new(&mut engine, editor.active_outline().document.clone(), 72.0) | |
| 2682 | .unwrap(); | |
| 2683 | assert_eq!( | |
| 2684 | ys(editor.active_outline()), | |
| 2685 | ys(rebuilt.active_outline()), | |
| 2686 | "step {step}" | |
| 2687 | ); | |
| 2688 | for (actual, expected) in editor | |
| 2689 | .active_outline() | |
| 2690 | .shaped | |
| 2691 | .paragraphs | |
| 2692 | .iter() | |
| 2693 | .zip(&rebuilt.active_outline().shaped.paragraphs) | |
| 2694 | { | |
| 2695 | let geometry = |p: &ParagraphLayout| { | |
| 2696 | p.text | |
| 2697 | .lines() | |
| 2698 | .map(|(line, b)| { | |
| 2699 | ( | |
| 2700 | b.source.clone(), | |
| 2701 | b.top, | |
| 2702 | b.baseline, | |
| 2703 | b.height, | |
| 2704 | line.metrics().advance, | |
| 2705 | ) | |
| 2706 | }) | |
| 2707 | .collect::<Vec<_>>() | |
| 2708 | }; | |
| 2709 | assert_eq!(geometry(actual), geometry(expected), "step {step}"); | |
| 2710 | } | |
| 2711 | } | |
| 2712 | for (text, selection) in before.into_iter().rev() { | |
| 2713 | assert!(editor.undo(&mut engine).unwrap()); | |
| 2714 | assert_eq!(editor.active_outline().document, text); | |
| 2715 | assert_eq!(editor.selection().positions, selection); | |
| 2716 | } | |
| 2717 | assert!(!editor.undo(&mut engine).unwrap()); | |
| 2718 | for text in after { | |
| 2719 | assert!(editor.redo(&mut engine).unwrap()); | |
| 2720 | assert_eq!(editor.active_outline().document, text); | |
| 2721 | } | |
| 2722 | assert!(!editor.redo(&mut engine).unwrap()); | |
| 2723 | } | |
| 2724 | ||
| 2725 | #[test] | |
| 2726 | fn structural_edits_reflow_only_affected_paragraphs_and_restore_selection() { | |
| 2727 | let mut engine = TextEngine::default(); | |
| 2728 | let text = TextDocument::new(vec![ | |
| 2729 | Paragraph::new("before".into(), Format::default()), | |
| 2730 | Paragraph::new("split this paragraph".into(), Format::default()), | |
| 2731 | Paragraph::new("after".into(), Format::default()), | |
| 2732 | ]) | |
| 2733 | .unwrap(); | |
| 2734 | let mut editor = CanvasEditor::new(&mut engine, text.clone(), 60.0).unwrap(); | |
| 2735 | let before = editor.active_outline().shaped.paragraphs[0] | |
| 2736 | .text | |
| 2737 | .shaped | |
| 2738 | .styles() | |
| 2739 | .as_ptr(); | |
| 2740 | let after = editor.active_outline().shaped.paragraphs[2] | |
| 2741 | .text | |
| 2742 | .shaped | |
| 2743 | .styles() | |
| 2744 | .as_ptr(); | |
| 2745 | editor.resize(&mut engine, 60.0).unwrap(); | |
| 2746 | assert_eq!( | |
| 2747 | editor.active_outline().shaped.paragraphs[0] | |
| 2748 | .text | |
| 2749 | .shaped | |
| 2750 | .styles() | |
| 2751 | .as_ptr(), | |
| 2752 | before | |
| 2753 | ); | |
| 2754 | let point = TextPosition { | |
| 2755 | paragraph: 1, | |
| 2756 | offset: 6, | |
| 2757 | }; | |
| 2758 | editor.select([point, point].into()).unwrap(); | |
| 2759 | editor | |
| 2760 | .replace( | |
| 2761 | &mut engine, | |
| 2762 | vec![Paragraph::new(String::new(), Format::default()); 2], | |
| 2763 | ) | |
| 2764 | .unwrap(); | |
| 2765 | assert_eq!( | |
| 2766 | editor | |
| 2767 | .active_outline() | |
| 2768 | .document | |
| 2769 | .paragraphs() | |
| 2770 | .map(Paragraph::text) | |
| 2771 | .collect::<Vec<_>>(), | |
| 2772 | ["before", "split ", "this paragraph", "after"] | |
| 2773 | ); | |
| 2774 | assert_eq!( | |
| 2775 | editor.selection().positions, | |
| 2776 | [TextPosition { | |
| 2777 | paragraph: 2, | |
| 2778 | offset: 0 | |
| 2779 | }; 2] | |
| 2780 | ); | |
| 2781 | assert_eq!( | |
| 2782 | editor.active_outline().shaped.paragraphs[0] | |
| 2783 | .text | |
| 2784 | .shaped | |
| 2785 | .styles() | |
| 2786 | .as_ptr(), | |
| 2787 | before | |
| 2788 | ); | |
| 2789 | assert_eq!( | |
| 2790 | editor.active_outline().shaped.paragraphs[3] | |
| 2791 | .text | |
| 2792 | .shaped | |
| 2793 | .styles() | |
| 2794 | .as_ptr(), | |
| 2795 | after | |
| 2796 | ); | |
| 2797 | let rebuilt = | |
| 2798 | CanvasEditor::new(&mut engine, editor.active_outline().document.clone(), 60.0).unwrap(); | |
| 2799 | assert_eq!(ys(editor.active_outline()), ys(rebuilt.active_outline())); | |
| 2800 | for (actual, expected) in editor | |
| 2801 | .active_outline() | |
| 2802 | .shaped | |
| 2803 | .paragraphs | |
| 2804 | .iter() | |
| 2805 | .zip(&rebuilt.active_outline().shaped.paragraphs) | |
| 2806 | { | |
| 2807 | let lines = |p: &ParagraphLayout| { | |
| 2808 | p.text | |
| 2809 | .lines() | |
| 2810 | .map(|(_, b)| (b.source.clone(), b.top, b.baseline, b.height)) | |
| 2811 | .collect::<Vec<_>>() | |
| 2812 | }; | |
| 2813 | assert_eq!(lines(actual), lines(expected)); | |
| 2814 | } | |
| 2815 | editor.undo(&mut engine).unwrap(); | |
| 2816 | assert_eq!(editor.active_outline().document, text); | |
| 2817 | assert_eq!(editor.selection().positions, [point; 2]); | |
| 2818 | assert_eq!( | |
| 2819 | editor.active_outline().shaped.paragraphs[2] | |
| 2820 | .text | |
| 2821 | .shaped | |
| 2822 | .styles() | |
| 2823 | .as_ptr(), | |
| 2824 | after | |
| 2825 | ); | |
| 2826 | editor.redo(&mut engine).unwrap(); | |
| 2827 | assert_eq!( | |
| 2828 | editor.active_outline().document, | |
| 2829 | rebuilt.active_outline().document | |
| 2830 | ); | |
| 2831 | let selection = editor.selection().positions; | |
| 2832 | editor.resize(&mut engine, 30.0).unwrap(); | |
| 2833 | let rebuilt = | |
| 2834 | CanvasEditor::new(&mut engine, editor.active_outline().document.clone(), 30.0).unwrap(); | |
| 2835 | assert_eq!(ys(editor.active_outline()), ys(rebuilt.active_outline())); | |
| 2836 | assert_eq!(editor.selection().positions, selection); | |
| 2837 | assert!(editor.resize(&mut engine, f32::NAN).is_err()); | |
| 2838 | assert_eq!(ys(editor.active_outline()), ys(rebuilt.active_outline())); | |
| 2839 | editor.undo(&mut engine).unwrap(); | |
| 2840 | let rebuilt = | |
| 2841 | CanvasEditor::new(&mut engine, editor.active_outline().document.clone(), 30.0).unwrap(); | |
| 2842 | assert_eq!(ys(editor.active_outline()), ys(rebuilt.active_outline())); | |
| 2843 | } | |
| 2844 | ||
| 2845 | #[test] | |
| 2846 | fn failed_layout_preserves_text_selection_history_and_cached_layout() { | |
| 2847 | let mut engine = TextEngine::default(); | |
| 2848 | let text = | |
| 2849 | TextDocument::new(vec![Paragraph::new("original".into(), Format::default())]).unwrap(); | |
| 2850 | let mut editor = CanvasEditor::new(&mut engine, text.clone(), 60.0).unwrap(); | |
| 2851 | editor | |
| 2852 | .replace( | |
| 2853 | &mut engine, | |
| 2854 | vec![Paragraph::new("!".into(), Format::default())], | |
| 2855 | ) | |
| 2856 | .unwrap(); | |
| 2857 | editor.undo(&mut engine).unwrap(); | |
| 2858 | let before = editor.active_outline().shaped.paragraphs[0] | |
| 2859 | .text | |
| 2860 | .shaped | |
| 2861 | .styles() | |
| 2862 | .as_ptr(); | |
| 2863 | for format in [ | |
| 2864 | Format { | |
| 2865 | font_size: Some(f32::NAN), | |
| 2866 | ..Format::default() | |
| 2867 | }, | |
| 2868 | Format { | |
| 2869 | line_spacing: Some(-1.0), | |
| 2870 | ..Format::default() | |
| 2871 | }, | |
| 2872 | Format { | |
| 2873 | space_after: Some(f32::INFINITY), | |
| 2874 | ..Format::default() | |
| 2875 | }, | |
| 2876 | ] { | |
| 2877 | assert!( | |
| 2878 | editor | |
| 2879 | .replace(&mut engine, vec![Paragraph::new("bad".into(), format)]) | |
| 2880 | .is_err() | |
| 2881 | ); | |
| 2882 | assert_eq!(editor.active_outline().document, text); | |
| 2883 | assert_eq!( | |
| 2884 | editor.selection().positions, | |
| 2885 | [TextPosition { | |
| 2886 | paragraph: 0, | |
| 2887 | offset: 0 | |
| 2888 | }; 2] | |
| 2889 | ); | |
| 2890 | assert_eq!( | |
| 2891 | editor.active_outline().shaped.paragraphs[0] | |
| 2892 | .text | |
| 2893 | .shaped | |
| 2894 | .styles() | |
| 2895 | .as_ptr(), | |
| 2896 | before | |
| 2897 | ); | |
| 2898 | } | |
| 2899 | assert!(editor.redo(&mut engine).unwrap()); | |
| 2900 | assert_eq!( | |
| 2901 | editor | |
| 2902 | .active_outline() | |
| 2903 | .document | |
| 2904 | .paragraphs() | |
| 2905 | .next() | |
| 2906 | .unwrap() | |
| 2907 | .text(), | |
| 2908 | "!original" | |
| 2909 | ); | |
| 2910 | } | |
| 2911 | #[test] | |
| 2912 | fn outline_creation_movement_and_typing_share_one_history() { | |
| 2913 | let mut engine = TextEngine::default(); | |
| 2914 | let original = | |
| 2915 | TextDocument::new(vec![Paragraph::new("lyrics".into(), Format::default())]).unwrap(); | |
| 2916 | let mut editor = CanvasEditor::new(&mut engine, original.clone(), 240.0).unwrap(); | |
| 2917 | let first = editor.active_outline().id; | |
| 2918 | editor.insert(&mut engine, "A ").unwrap(); | |
| 2919 | let second = editor | |
| 2920 | .create_outline(&mut engine, [300.0, 0.0], 120.0) | |
| 2921 | .unwrap(); | |
| 2922 | editor | |
| 2923 | .insert(&mut engine, "annotation\nsecond line") | |
| 2924 | .unwrap(); | |
| 2925 | let annotation = editor.active_outline().document.clone(); | |
| 2926 | let pointers: Vec<_> = editor | |
| 2927 | .outlines | |
| 2928 | .iter() | |
| 2929 | .map(|outline| outline.shaped.paragraphs[0].text.shaped.styles().as_ptr()) | |
| 2930 | .collect(); | |
| 2931 | editor.move_outline(first, [-50.0, 60.0]).unwrap(); | |
| 2932 | assert_eq!(editor.active_outline().id, first); | |
| 2933 | assert_eq!( | |
| 2934 | editor | |
| 2935 | .outlines | |
| 2936 | .iter() | |
| 2937 | .map(|outline| outline.shaped.paragraphs[0].text.shaped.styles().as_ptr()) | |
| 2938 | .collect::<Vec<_>>(), | |
| 2939 | pointers | |
| 2940 | ); | |
| 2941 | assert_eq!(editor.outlines[1].document, annotation); | |
| 2942 | editor.undo(&mut engine).unwrap(); | |
| 2943 | assert_eq!(editor.active_outline().origin(), [0.0; 2]); | |
| 2944 | editor.undo(&mut engine).unwrap(); | |
| 2945 | assert_eq!(editor.active_outline().id, second); | |
| 2946 | assert_eq!( | |
| 2947 | editor | |
| 2948 | .active_outline() | |
| 2949 | .document | |
| 2950 | .paragraphs() | |
| 2951 | .next() | |
| 2952 | .unwrap() | |
| 2953 | .text(), | |
| 2954 | "" | |
| 2955 | ); | |
| 2956 | let retained = editor.active_outline().shaped.paragraphs[0] | |
| 2957 | .text | |
| 2958 | .shaped | |
| 2959 | .styles() | |
| 2960 | .as_ptr(); | |
| 2961 | editor.undo(&mut engine).unwrap(); | |
| 2962 | assert_eq!(editor.outlines.len(), 1); | |
| 2963 | assert_eq!(editor.active_outline().id, first); | |
| 2964 | editor.undo(&mut engine).unwrap(); | |
| 2965 | assert_eq!(editor.active_outline().document, original); | |
| 2966 | assert!(!editor.undo(&mut engine).unwrap()); | |
| 2967 | editor.redo(&mut engine).unwrap(); | |
| 2968 | editor.redo(&mut engine).unwrap(); | |
| 2969 | assert_eq!(editor.active_outline().id, second); | |
| 2970 | assert_eq!( | |
| 2971 | editor.active_outline().shaped.paragraphs[0] | |
| 2972 | .text | |
| 2973 | .shaped | |
| 2974 | .styles() | |
| 2975 | .as_ptr(), | |
| 2976 | retained | |
| 2977 | ); | |
| 2978 | editor.redo(&mut engine).unwrap(); | |
| 2979 | assert_eq!(editor.active_outline().document, annotation); | |
| 2980 | editor.redo(&mut engine).unwrap(); | |
| 2981 | assert_eq!(editor.active_outline().id, first); | |
| 2982 | assert_eq!(editor.active_outline().origin(), [-50.0, 60.0]); | |
| 2983 | assert!(!editor.redo(&mut engine).unwrap()); | |
| 2984 | } | |
| 2985 | ||
| 2986 | #[test] | |
| 2987 | fn invalid_outline_operations_preserve_composition_and_history() { | |
| 2988 | let mut engine = TextEngine::default(); | |
| 2989 | let original = | |
| 2990 | TextDocument::new(vec![Paragraph::new("original".into(), Format::default())]).unwrap(); | |
| 2991 | let mut editor = CanvasEditor::new(&mut engine, original.clone(), 240.0).unwrap(); | |
| 2992 | let first = editor.active_outline().id; | |
| 2993 | editor.compose(&mut engine, "é".into(), 1..1).unwrap(); | |
| 2994 | let provisional = editor.active_outline().document.clone(); | |
| 2995 | for position in [[f32::NAN, 0.0], [0.0, f32::INFINITY]] { | |
| 2996 | assert!(editor.create_outline(&mut engine, position, 120.0).is_err()); | |
| 2997 | assert!(editor.move_outline(first, position).is_err()); | |
| 2998 | } | |
| 2999 | assert!(editor.create_outline(&mut engine, [0.0; 2], 0.0).is_err()); | |
| 3000 | assert!(editor.focus_outline(onestore::ExGuid::default()).is_err()); | |
| 3001 | assert!( | |
| 3002 | editor | |
| 3003 | .move_outline(onestore::ExGuid::default(), [1.0; 2]) | |
| 3004 | .is_err() | |
| 3005 | ); | |
| 3006 | assert_eq!(editor.active_outline().document, provisional); | |
| 3007 | assert!(editor.marked_range().is_some()); | |
| 3008 | assert!(editor.undo.is_empty()); | |
| 3009 | assert!(editor.redo.is_empty()); | |
| 3010 | let second = editor | |
| 3011 | .create_outline(&mut engine, [300.0, 0.0], 120.0) | |
| 3012 | .unwrap(); | |
| 3013 | assert!(editor.marked_range().is_none()); | |
| 3014 | editor.undo(&mut engine).unwrap(); | |
| 3015 | assert_eq!(editor.active_outline().document, provisional); | |
| 3016 | editor.undo(&mut engine).unwrap(); | |
| 3017 | assert_eq!(editor.active_outline().document, original); | |
| 3018 | editor.redo(&mut engine).unwrap(); | |
| 3019 | let replacement = editor | |
| 3020 | .create_outline(&mut engine, [500.0, 0.0], 120.0) | |
| 3021 | .unwrap(); | |
| 3022 | assert_ne!(replacement, second); | |
| 3023 | assert!(!editor.redo(&mut engine).unwrap()); | |
| 3024 | assert_eq!(editor.outlines[0].document, provisional); | |
| 3025 | } | |
| 3026 | #[test] | |
| 3027 | fn mixed_outline_history_restores_every_source_and_placement() { | |
| 3028 | let mut engine = TextEngine::default(); | |
| 3029 | let original = TextDocument::new(vec![Paragraph::from_runs([ | |
| 3030 | ( | |
| 3031 | "hidden".into(), | |
| 3032 | Format { | |
| 3033 | hidden: Some(true), | |
| 3034 | ..Format::default() | |
| 3035 | }, | |
| 3036 | ), | |
| 3037 | ("lyrics 🌳".into(), Format::default()), | |
| 3038 | ])]) | |
| 3039 | .unwrap(); | |
| 3040 | let mut editor = CanvasEditor::new(&mut engine, original, 120.0).unwrap(); | |
| 3041 | let snapshot = |editor: &CanvasEditor| { | |
| 3042 | editor | |
| 3043 | .outlines | |
| 3044 | .iter() | |
| 3045 | .map(|outline| (outline.id, outline.layout.clone(), outline.document.clone())) | |
| 3046 | .collect::<Vec<_>>() | |
| 3047 | }; | |
| 3048 | let mut steps = Vec::new(); | |
| 3049 | let mut random = 0x29a7_u32; | |
| 3050 | for step in 0..80 { | |
| 3051 | random = random.wrapping_mul(1664525).wrapping_add(1013904223); | |
| 3052 | let index = random as usize % editor.outlines.len(); | |
| 3053 | let id = editor.outlines[index].id; | |
| 3054 | editor.focus_outline(id).unwrap(); | |
| 3055 | let before = snapshot(&editor); | |
| 3056 | let undo_count = editor.undo.len(); | |
| 3057 | match step % 5 { | |
| 3058 | 0 if editor.outlines.len() < 8 => { | |
| 3059 | editor | |
| 3060 | .create_outline( | |
| 3061 | &mut engine, | |
| 3062 | [step as f32 * 9.0 - 100.0, step as f32 * 3.0], | |
| 3063 | 72.0 + step as f32, | |
| 3064 | ) | |
| 3065 | .unwrap(); | |
| 3066 | } | |
| 3067 | 1 => { | |
| 3068 | editor | |
| 3069 | .insert( | |
| 3070 | &mut engine, | |
| 3071 | ["x", "é", "🌳", "a\nשלום"][random as usize % 4], | |
| 3072 | ) | |
| 3073 | .unwrap(); | |
| 3074 | } | |
| 3075 | 2 => { | |
| 3076 | editor.delete(&mut engine, random & 1 == 0).unwrap(); | |
| 3077 | } | |
| 3078 | 3 => { | |
| 3079 | editor | |
| 3080 | .compose(&mut engine, "e\u{301}".into(), 2..2) | |
| 3081 | .unwrap(); | |
| 3082 | editor.finish_composition(); | |
| 3083 | } | |
| 3084 | _ => { | |
| 3085 | let [x, y] = editor.active_outline().origin(); | |
| 3086 | editor.move_outline(id, [x + 7.0, y - 3.0]).unwrap(); | |
| 3087 | } | |
| 3088 | } | |
| 3089 | if editor.undo.len() == undo_count { | |
| 3090 | assert_eq!(snapshot(&editor), before); | |
| 3091 | continue; | |
| 3092 | } | |
| 3093 | assert_eq!(editor.undo.len(), undo_count + 1); | |
| 3094 | steps.push((before, snapshot(&editor))); | |
| 3095 | for outline in &editor.outlines { | |
| 3096 | let rebuilt = TextOutline::new( | |
| 3097 | &mut engine, | |
| 3098 | outline.document.clone(), | |
| 3099 | outline.layout.max_width.unwrap(), | |
| 3100 | outline.origin(), | |
| 3101 | ) | |
| 3102 | .unwrap(); | |
| 3103 | assert_eq!(ys(outline), ys(&rebuilt)); | |
| 3104 | for (actual, expected) in outline | |
| 3105 | .shaped | |
| 3106 | .paragraphs | |
| 3107 | .iter() | |
| 3108 | .zip(&rebuilt.shaped.paragraphs) | |
| 3109 | { | |
| 3110 | assert_eq!(actual.projection.text(), expected.projection.text()); | |
| 3111 | assert_eq!(actual.text.height(), expected.text.height()); | |
| 3112 | assert_eq!( | |
| 3113 | actual | |
| 3114 | .text | |
| 3115 | .lines() | |
| 3116 | .map(|(_, line)| (line.source.clone(), line.baseline)) | |
| 3117 | .collect::<Vec<_>>(), | |
| 3118 | expected | |
| 3119 | .text | |
| 3120 | .lines() | |
| 3121 | .map(|(_, line)| (line.source.clone(), line.baseline)) | |
| 3122 | .collect::<Vec<_>>() | |
| 3123 | ); | |
| 3124 | } | |
| 3125 | } | |
| 3126 | } | |
| 3127 | assert!(steps.len() > 60); | |
| 3128 | for (before, _) in steps.iter().rev() { | |
| 3129 | assert!(editor.undo(&mut engine).unwrap()); | |
| 3130 | assert_eq!(&snapshot(&editor), before); | |
| 3131 | } | |
| 3132 | assert!(!editor.undo(&mut engine).unwrap()); | |
| 3133 | for (_, after) in &steps { | |
| 3134 | assert!(editor.redo(&mut engine).unwrap()); | |
| 3135 | assert_eq!(&snapshot(&editor), after); | |
| 3136 | } | |
| 3137 | assert!(!editor.redo(&mut engine).unwrap()); | |
| 3138 | } | |
| 3139 | } |
crates/canvas/src/layout.rs created+657| ... | ... | @@ -0,0 +1,657 @@ |
| 1 | use crate::text::Paragraph; | |
| 2 | use parley::{ | |
| 3 | Affinity, Alignment, AlignmentOptions, BoundingBox, FontContext, FontFamily, FontStyle, | |
| 4 | FontWeight, Layout, LayoutContext, OverflowWrap, PositionedLayoutItem, StyleProperty, | |
| 5 | editing::{Cursor, Selection}, | |
| 6 | }; | |
| 7 | use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId}; | |
| 8 | use std::{ | |
| 9 | collections::BTreeSet, | |
| 10 | fmt, | |
| 11 | ops::Range, | |
| 12 | sync::atomic::{AtomicU64, Ordering}, | |
| 13 | }; | |
| 14 | ||
| 15 | #[derive(Default)] | |
| 16 | pub struct TextEngine { | |
| 17 | pub fonts: FontContext, | |
| 18 | context: LayoutContext<TextBrush>, | |
| 19 | arial_substitutes: BTreeSet<u64>, | |
| 20 | } | |
| 21 | ||
| 22 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 23 | pub struct TextBrush { | |
| 24 | pub color: u32, | |
| 25 | pub highlight: Option<u32>, | |
| 26 | } | |
| 27 | ||
| 28 | #[derive(Clone, Debug)] | |
| 29 | pub struct LineBox { | |
| 30 | pub source: Range<usize>, | |
| 31 | pub top: f32, | |
| 32 | pub baseline: f32, | |
| 33 | pub height: f32, | |
| 34 | } | |
| 35 | ||
| 36 | pub struct TextLayout { | |
| 37 | id: u64, | |
| 38 | pub(crate) shaped: Layout<TextBrush>, | |
| 39 | lines: Vec<LineBox>, | |
| 40 | } | |
| 41 | ||
| 42 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 43 | pub enum LayoutError { | |
| 44 | InvalidWidth, | |
| 45 | InvalidFontSize, | |
| 46 | InvalidSourceRange, | |
| 47 | InvalidFontMetrics, | |
| 48 | InvalidIndentation, | |
| 49 | InvalidSpacing, | |
| 50 | InvalidList, | |
| 51 | UnsupportedContent, | |
| 52 | UnsupportedSubstituteFont, | |
| 53 | } | |
| 54 | ||
| 55 | impl fmt::Display for LayoutError { | |
| 56 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 57 | match self { | |
| 58 | Self::UnsupportedSubstituteFont => write!(f, "Choose an Arimo or Carlito font file."), | |
| 59 | _ => write!(f, "Text layout failed: {self:?}"), | |
| 60 | } | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | impl std::error::Error for LayoutError {} | |
| 65 | ||
| 66 | impl TextEngine { | |
| 67 | /// Register Arimo as Arial or Carlito as Calibri before creating layouts. | |
| 68 | pub fn register_substitute( | |
| 69 | &mut self, | |
| 70 | data: parley::fontique::Blob<u8>, | |
| 71 | ) -> Result<&'static str, LayoutError> { | |
| 72 | let font = | |
| 73 | FontRef::new(data.as_ref()).map_err(|_| LayoutError::UnsupportedSubstituteFont)?; | |
| 74 | let family = font | |
| 75 | .localized_strings(StringId::TYPOGRAPHIC_FAMILY_NAME) | |
| 76 | .english_or_first() | |
| 77 | .or_else(|| { | |
| 78 | font.localized_strings(StringId::FAMILY_NAME) | |
| 79 | .english_or_first() | |
| 80 | }) | |
| 81 | .map(|name| name.to_string()) | |
| 82 | .ok_or(LayoutError::UnsupportedSubstituteFont)?; | |
| 83 | let target = match family.as_str() { | |
| 84 | "Arimo" => "Arial", | |
| 85 | "Carlito" => "Calibri", | |
| 86 | _ => return Err(LayoutError::UnsupportedSubstituteFont), | |
| 87 | }; | |
| 88 | let id = data.id(); | |
| 89 | if target == "Arial" { | |
| 90 | let head = font.head().map_err(|_| LayoutError::InvalidFontMetrics)?; | |
| 91 | let hhea = font.hhea().map_err(|_| LayoutError::InvalidFontMetrics)?; | |
| 92 | if head.units_per_em() == 0 | |
| 93 | || hhea.ascender().to_i16() <= 0 | |
| 94 | || hhea.descender().to_i16() > 0 | |
| 95 | { | |
| 96 | return Err(LayoutError::InvalidFontMetrics); | |
| 97 | } | |
| 98 | } | |
| 99 | let registered = self.fonts.collection.register_fonts( | |
| 100 | data, | |
| 101 | Some(parley::fontique::FontInfoOverride { | |
| 102 | family_name: Some(target), | |
| 103 | ..Default::default() | |
| 104 | }), | |
| 105 | ); | |
| 106 | if registered.is_empty() { | |
| 107 | return Err(LayoutError::UnsupportedSubstituteFont); | |
| 108 | } | |
| 109 | if target == "Arial" { | |
| 110 | self.arial_substitutes.insert(id); | |
| 111 | } | |
| 112 | Ok(target) | |
| 113 | } | |
| 114 | ||
| 115 | pub fn layout(&mut self, paragraph: &Paragraph, width: f32) -> Result<TextLayout, LayoutError> { | |
| 116 | if !width.is_finite() || width <= 0.0 { | |
| 117 | return Err(LayoutError::InvalidWidth); | |
| 118 | } | |
| 119 | let text = paragraph.text(); | |
| 120 | let mut builder = self | |
| 121 | .context | |
| 122 | .ranged_builder(&mut self.fonts, text, 1.0, false); | |
| 123 | builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::BreakWord)); | |
| 124 | let mut start = 0; | |
| 125 | for (index, span) in paragraph.spans().iter().enumerate() { | |
| 126 | let format = &span.format; | |
| 127 | let size = format.font_size.unwrap_or(11.0); | |
| 128 | if !size.is_finite() || size <= 0.0 { | |
| 129 | return Err(LayoutError::InvalidFontSize); | |
| 130 | } | |
| 131 | let properties = [ | |
| 132 | StyleProperty::FontFamily(FontFamily::named( | |
| 133 | format.font.as_deref().unwrap_or("Arial"), | |
| 134 | )), | |
| 135 | StyleProperty::FontSize(size), | |
| 136 | StyleProperty::FontWeight(if format.bold == Some(true) { | |
| 137 | FontWeight::BOLD | |
| 138 | } else { | |
| 139 | FontWeight::NORMAL | |
| 140 | }), | |
| 141 | StyleProperty::FontStyle(if format.italic == Some(true) { | |
| 142 | FontStyle::Italic | |
| 143 | } else { | |
| 144 | FontStyle::Normal | |
| 145 | }), | |
| 146 | StyleProperty::Brush(TextBrush { | |
| 147 | color: format.color.unwrap_or(0), | |
| 148 | highlight: format.highlight.filter(|color| *color != 0xff000000), | |
| 149 | }), | |
| 150 | StyleProperty::Underline(format.underline.unwrap_or(false)), | |
| 151 | StyleProperty::Strikethrough(format.strike.unwrap_or(false)), | |
| 152 | ]; | |
| 153 | for property in properties { | |
| 154 | if index == 0 { | |
| 155 | builder.push_default(property.clone()); | |
| 156 | } | |
| 157 | builder.push(property, start..span.end); | |
| 158 | } | |
| 159 | start = span.end; | |
| 160 | } | |
| 161 | let mut shaped = builder.build(text); | |
| 162 | shaped.break_all_lines(Some(width)); | |
| 163 | shaped.align(Alignment::Start, AlignmentOptions::default()); | |
| 164 | let mut lines = Vec::with_capacity(shaped.len()); | |
| 165 | let mut top = 0.0_f64; | |
| 166 | let mut source_end = 0; | |
| 167 | for line in shaped.lines() { | |
| 168 | if !line.metrics().advance.is_finite() | |
| 169 | || !line.metrics().trailing_whitespace.is_finite() | |
| 170 | { | |
| 171 | return Err(LayoutError::InvalidFontMetrics); | |
| 172 | } | |
| 173 | // Parley gives its synthetic empty line a 0..1 range. | |
| 174 | let range = if text.is_empty() { | |
| 175 | 0..0 | |
| 176 | } else { | |
| 177 | line.text_range() | |
| 178 | }; | |
| 179 | if range.start != source_end | |
| 180 | || range.end < range.start | |
| 181 | || !text.is_char_boundary(range.end) | |
| 182 | { | |
| 183 | return Err(LayoutError::InvalidSourceRange); | |
| 184 | } | |
| 185 | source_end = range.end; | |
| 186 | let mut ascent = 0.0_f32; | |
| 187 | let mut descent = 0.0_f32; | |
| 188 | for run in line.runs() { | |
| 189 | let data = &run.font().font; | |
| 190 | let font = FontRef::from_index(data.data.as_ref(), data.index) | |
| 191 | .map_err(|_| LayoutError::InvalidFontMetrics)?; | |
| 192 | let (a, d) = if self.arial_substitutes.contains(&data.data.id()) { | |
| 193 | // Arimo's hhea extents match Arial's Windows extents; omit hhea line gap. | |
| 194 | let head = font.head().map_err(|_| LayoutError::InvalidFontMetrics)?; | |
| 195 | let hhea = font.hhea().map_err(|_| LayoutError::InvalidFontMetrics)?; | |
| 196 | let scale = run.font_size() / f32::from(head.units_per_em()); | |
| 197 | ( | |
| 198 | f32::from(hhea.ascender().to_i16()) * scale, | |
| 199 | -f32::from(hhea.descender().to_i16()) * scale, | |
| 200 | ) | |
| 201 | } else if let (Ok(head), Ok(os2)) = (font.head(), font.os2()) | |
| 202 | && head.units_per_em() != 0 | |
| 203 | && (os2.us_win_ascent() != 0 || os2.us_win_descent() != 0) | |
| 204 | { | |
| 205 | let scale = run.font_size() / f32::from(head.units_per_em()); | |
| 206 | ( | |
| 207 | f32::from(os2.us_win_ascent()) * scale, | |
| 208 | f32::from(os2.us_win_descent()) * scale, | |
| 209 | ) | |
| 210 | } else { | |
| 211 | let metrics = run.font_metrics(); | |
| 212 | (metrics.ascent, metrics.descent) | |
| 213 | }; | |
| 214 | if !a.is_finite() || !d.is_finite() || a < 0.0 || d < 0.0 { | |
| 215 | return Err(LayoutError::InvalidFontMetrics); | |
| 216 | } | |
| 217 | ascent = ascent.max(a); | |
| 218 | descent = descent.max(d); | |
| 219 | } | |
| 220 | let height = ascent + descent; | |
| 221 | if height <= 0.0 || !height.is_finite() { | |
| 222 | return Err(LayoutError::InvalidFontMetrics); | |
| 223 | } | |
| 224 | lines.push(LineBox { | |
| 225 | source: range, | |
| 226 | top: top as f32, | |
| 227 | baseline: (top + f64::from(ascent)) as f32, | |
| 228 | height, | |
| 229 | }); | |
| 230 | top += f64::from(height); | |
| 231 | if !(top as f32).is_finite() { | |
| 232 | return Err(LayoutError::InvalidFontMetrics); | |
| 233 | } | |
| 234 | } | |
| 235 | if source_end != text.len() || lines.is_empty() { | |
| 236 | return Err(LayoutError::InvalidSourceRange); | |
| 237 | } | |
| 238 | static NEXT_ID: AtomicU64 = AtomicU64::new(0); | |
| 239 | let id = NEXT_ID | |
| 240 | .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1)) | |
| 241 | .expect("Text layout identities exhausted"); | |
| 242 | Ok(TextLayout { id, shaped, lines }) | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | impl TextLayout { | |
| 247 | /// Process-unique identity for caches of an externally immutable layout. | |
| 248 | pub fn id(&self) -> u64 { | |
| 249 | self.id | |
| 250 | } | |
| 251 | ||
| 252 | pub(crate) fn minimum_line_height(&mut self, minimum: f32) -> Result<(), LayoutError> { | |
| 253 | if !minimum.is_finite() || minimum < 0.0 { | |
| 254 | return Err(LayoutError::InvalidSpacing); | |
| 255 | } | |
| 256 | if minimum == 0.0 { | |
| 257 | return Ok(()); | |
| 258 | } | |
| 259 | let mut top = 0.0_f64; | |
| 260 | for line in &mut self.lines { | |
| 261 | let ascent = line.baseline - line.top; | |
| 262 | line.height = line.height.max(minimum); | |
| 263 | line.top = top as f32; | |
| 264 | line.baseline = (top + f64::from(ascent)) as f32; | |
| 265 | top += f64::from(line.height); | |
| 266 | if !(top as f32).is_finite() { | |
| 267 | return Err(LayoutError::InvalidSpacing); | |
| 268 | } | |
| 269 | } | |
| 270 | Ok(()) | |
| 271 | } | |
| 272 | ||
| 273 | pub fn lines(&self) -> impl Iterator<Item = (parley::Line<'_, TextBrush>, &LineBox)> { | |
| 274 | self.shaped.lines().zip(&self.lines) | |
| 275 | } | |
| 276 | ||
| 277 | pub fn backgrounds(&self) -> impl Iterator<Item = (BoundingBox, u32)> { | |
| 278 | self.lines().flat_map(|(line, bounds)| { | |
| 279 | line.items().filter_map(move |item| { | |
| 280 | let PositionedLayoutItem::GlyphRun(run) = item else { | |
| 281 | return None; | |
| 282 | }; | |
| 283 | let color = run.style().brush.highlight?; | |
| 284 | Some(( | |
| 285 | BoundingBox { | |
| 286 | x0: f64::from(run.offset()), | |
| 287 | y0: f64::from(bounds.top), | |
| 288 | x1: f64::from(run.offset() + run.advance()), | |
| 289 | y1: f64::from(bounds.top + bounds.height), | |
| 290 | }, | |
| 291 | color, | |
| 292 | )) | |
| 293 | }) | |
| 294 | }) | |
| 295 | } | |
| 296 | ||
| 297 | pub fn height(&self) -> f32 { | |
| 298 | let last = self.lines.last().unwrap(); | |
| 299 | last.top + last.height | |
| 300 | } | |
| 301 | ||
| 302 | pub fn cursor(&self, byte: usize, affinity: Affinity) -> Cursor { | |
| 303 | Cursor::from_byte_index(&self.shaped, byte, affinity) | |
| 304 | } | |
| 305 | ||
| 306 | pub fn hit_test(&self, x: f32, y: f32) -> Cursor { | |
| 307 | let index = self | |
| 308 | .lines | |
| 309 | .partition_point(|line| line.top + line.height <= y) | |
| 310 | .min(self.lines.len() - 1); | |
| 311 | let metrics = *self.shaped.get(index).unwrap().metrics(); | |
| 312 | Cursor::from_point( | |
| 313 | &self.shaped, | |
| 314 | x, | |
| 315 | (metrics.block_min_coord + metrics.block_max_coord) * 0.5, | |
| 316 | ) | |
| 317 | } | |
| 318 | ||
| 319 | pub fn caret(&self, cursor: Cursor, width: f32) -> BoundingBox { | |
| 320 | let mut rect = cursor.geometry(&self.shaped, width); | |
| 321 | let index = self | |
| 322 | .shaped | |
| 323 | .lines() | |
| 324 | .position(|line| rect.y0 < f64::from(line.metrics().block_max_coord)) | |
| 325 | .unwrap_or(self.lines.len() - 1); | |
| 326 | let line = &self.lines[index]; | |
| 327 | rect.y0 = f64::from(line.top); | |
| 328 | rect.y1 = f64::from(line.top + line.height); | |
| 329 | rect | |
| 330 | } | |
| 331 | ||
| 332 | pub fn selection(&self, selection: Selection) -> Vec<BoundingBox> { | |
| 333 | selection | |
| 334 | .geometry(&self.shaped) | |
| 335 | .into_iter() | |
| 336 | .map(|(mut rect, index)| { | |
| 337 | let line = &self.lines[index]; | |
| 338 | rect.y0 = f64::from(line.top); | |
| 339 | rect.y1 = f64::from(line.top + line.height); | |
| 340 | rect | |
| 341 | }) | |
| 342 | .collect() | |
| 343 | } | |
| 344 | } | |
| 345 | ||
| 346 | #[cfg(test)] | |
| 347 | mod tests { | |
| 348 | use super::*; | |
| 349 | use onestore::document::Format; | |
| 350 | ||
| 351 | #[test] | |
| 352 | fn rejected_substitute_leaves_font_selection_unchanged() { | |
| 353 | let mut engine = TextEngine::default(); | |
| 354 | let paragraph = Paragraph::new("first words wrap here".into(), Format::default()); | |
| 355 | let before = engine.layout(&paragraph, 72.0).unwrap(); | |
| 356 | for data in [Vec::new(), vec![0; 512], b"not a font".to_vec()] { | |
| 357 | let result = | |
| 358 | engine.register_substitute(parley::fontique::Blob::new(std::sync::Arc::new(data))); | |
| 359 | assert_eq!(result.unwrap_err(), LayoutError::UnsupportedSubstituteFont); | |
| 360 | } | |
| 361 | assert!(engine.arial_substitutes.is_empty()); | |
| 362 | let after = engine.layout(&paragraph, 72.0).unwrap(); | |
| 363 | assert_eq!(before.height(), after.height()); | |
| 364 | let lines = |layout: &TextLayout| { | |
| 365 | layout | |
| 366 | .lines() | |
| 367 | .map(|(_, line)| (line.source.clone(), line.baseline)) | |
| 368 | .collect::<Vec<_>>() | |
| 369 | }; | |
| 370 | assert_eq!(lines(&before), lines(&after)); | |
| 371 | assert_eq!( | |
| 372 | before | |
| 373 | .lines() | |
| 374 | .next() | |
| 375 | .unwrap() | |
| 376 | .0 | |
| 377 | .runs() | |
| 378 | .next() | |
| 379 | .unwrap() | |
| 380 | .font() | |
| 381 | .font | |
| 382 | .data | |
| 383 | .id(), | |
| 384 | after | |
| 385 | .lines() | |
| 386 | .next() | |
| 387 | .unwrap() | |
| 388 | .0 | |
| 389 | .runs() | |
| 390 | .next() | |
| 391 | .unwrap() | |
| 392 | .font() | |
| 393 | .font | |
| 394 | .data | |
| 395 | .id() | |
| 396 | ); | |
| 397 | } | |
| 398 | ||
| 399 | #[test] | |
| 400 | fn highlights_share_line_geometry_without_changing_wraps() { | |
| 401 | let paragraph = Paragraph::from_runs([ | |
| 402 | ( | |
| 403 | "A highlighted ".into(), | |
| 404 | Format { | |
| 405 | highlight: Some(0x0000ffff), | |
| 406 | ..Format::default() | |
| 407 | }, | |
| 408 | ), | |
| 409 | ( | |
| 410 | "phrase and ".into(), | |
| 411 | Format { | |
| 412 | highlight: Some(0), | |
| 413 | ..Format::default() | |
| 414 | }, | |
| 415 | ), | |
| 416 | ( | |
| 417 | "automatic background".into(), | |
| 418 | Format { | |
| 419 | highlight: Some(0xff000000), | |
| 420 | ..Format::default() | |
| 421 | }, | |
| 422 | ), | |
| 423 | ]); | |
| 424 | let mut engine = TextEngine::default(); | |
| 425 | let layout = engine.layout(&paragraph, 80.0).unwrap(); | |
| 426 | let plain = engine | |
| 427 | .layout( | |
| 428 | &Paragraph::new(paragraph.text().into(), Format::default()), | |
| 429 | 80.0, | |
| 430 | ) | |
| 431 | .unwrap(); | |
| 432 | assert_eq!( | |
| 433 | layout | |
| 434 | .lines() | |
| 435 | .map(|(_, l)| l.source.clone()) | |
| 436 | .collect::<Vec<_>>(), | |
| 437 | plain | |
| 438 | .lines() | |
| 439 | .map(|(_, l)| l.source.clone()) | |
| 440 | .collect::<Vec<_>>() | |
| 441 | ); | |
| 442 | let backgrounds = layout.backgrounds().collect::<Vec<_>>(); | |
| 443 | assert!(backgrounds.iter().any(|(_, color)| *color == 0)); | |
| 444 | assert!(backgrounds.iter().any(|(_, color)| *color == 0x0000ffff)); | |
| 445 | assert!(!backgrounds.iter().any(|(_, color)| *color == 0xff000000)); | |
| 446 | for (rect, _) in backgrounds { | |
| 447 | assert!(rect.width() > 0.0); | |
| 448 | assert!( | |
| 449 | layout | |
| 450 | .lines() | |
| 451 | .any(|(_, line)| rect.y0 == f64::from(line.top) | |
| 452 | && rect.y1 == f64::from(line.top + line.height)) | |
| 453 | ); | |
| 454 | } | |
| 455 | } | |
| 456 | ||
| 457 | #[test] | |
| 458 | fn color_boundaries_preserve_contextual_shaping_and_wrapping() { | |
| 459 | let mut engine = TextEngine::default(); | |
| 460 | for text in ["office affinity", "العربية سلام", "ae\u{301}👩🏽💻z"] { | |
| 461 | let plain = Paragraph::new(text.into(), Format::default()); | |
| 462 | let colored = Paragraph::from_runs(text.chars().enumerate().map(|(index, ch)| { | |
| 463 | ( | |
| 464 | ch.to_string(), | |
| 465 | Format { | |
| 466 | color: Some(if index % 2 == 0 { 0xff } else { 0xff0000 }), | |
| 467 | ..Format::default() | |
| 468 | }, | |
| 469 | ) | |
| 470 | })); | |
| 471 | for width in [1.0, 40.0, 110.0, 1000.0] { | |
| 472 | let plain = engine.layout(&plain, width).unwrap(); | |
| 473 | let colored = engine.layout(&colored, width).unwrap(); | |
| 474 | assert_eq!(plain.lines.len(), colored.lines.len()); | |
| 475 | for ((plain_line, plain_box), (colored_line, colored_box)) in | |
| 476 | plain.lines().zip(colored.lines()) | |
| 477 | { | |
| 478 | assert_eq!(plain_box.source, colored_box.source); | |
| 479 | assert_eq!(plain_box.baseline, colored_box.baseline); | |
| 480 | assert_eq!(plain_box.height, colored_box.height); | |
| 481 | let glyphs = |line: parley::Line<'_, TextBrush>| { | |
| 482 | line.items() | |
| 483 | .flat_map(|item| match item { | |
| 484 | PositionedLayoutItem::GlyphRun(run) => run | |
| 485 | .positioned_glyphs() | |
| 486 | .map(|glyph| { | |
| 487 | ( | |
| 488 | run.run().font().font.data.id(), | |
| 489 | glyph.id, | |
| 490 | glyph.x, | |
| 491 | glyph.y, | |
| 492 | ) | |
| 493 | }) | |
| 494 | .collect::<Vec<_>>(), | |
| 495 | PositionedLayoutItem::InlineBox(_) => unreachable!(), | |
| 496 | }) | |
| 497 | .collect::<Vec<_>>() | |
| 498 | }; | |
| 499 | let plain_glyphs = glyphs(plain_line); | |
| 500 | let colored_glyphs = glyphs(colored_line); | |
| 501 | assert_eq!(plain_glyphs.len(), colored_glyphs.len()); | |
| 502 | for (a, b) in plain_glyphs.into_iter().zip(colored_glyphs) { | |
| 503 | assert_eq!((a.0, a.1), (b.0, b.1), "{text}, width {width}"); | |
| 504 | assert!((a.2 - b.2).abs() < 0.001 && (a.3 - b.3).abs() < 0.001); | |
| 505 | } | |
| 506 | } | |
| 507 | } | |
| 508 | } | |
| 509 | } | |
| 510 | ||
| 511 | #[test] | |
| 512 | fn empty_paragraph_has_source_position_and_caret() { | |
| 513 | let paragraph = Paragraph::new(String::new(), Format::default()); | |
| 514 | let layout = TextEngine::default().layout(&paragraph, 100.0).unwrap(); | |
| 515 | assert_eq!(layout.lines().next().unwrap().1.source, 0..0); | |
| 516 | assert!(layout.height() > 0.0); | |
| 517 | let cursor = layout.hit_test(50.0, 50.0); | |
| 518 | assert_eq!(cursor.index(), 0); | |
| 519 | let caret = layout.caret(cursor, 1.0); | |
| 520 | assert_eq!(caret.y0, 0.0); | |
| 521 | assert_eq!(caret.y1, f64::from(layout.height())); | |
| 522 | } | |
| 523 | ||
| 524 | #[test] | |
| 525 | fn long_words_wrap_with_complete_source_and_usable_carets() { | |
| 526 | let paragraph = Paragraph::new( | |
| 527 | "Wrapped HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789".into(), | |
| 528 | Format::default(), | |
| 529 | ); | |
| 530 | let width = 110.0; | |
| 531 | let layout = TextEngine::default().layout(&paragraph, width).unwrap(); | |
| 532 | let mut end = 0; | |
| 533 | for (line, bounds) in layout.lines() { | |
| 534 | assert!(line.metrics().advance - line.metrics().trailing_whitespace <= width); | |
| 535 | assert_eq!(bounds.source.start, end); | |
| 536 | end = bounds.source.end; | |
| 537 | } | |
| 538 | assert_eq!(end, paragraph.text().len()); | |
| 539 | for byte in 0..=end { | |
| 540 | for affinity in [Affinity::Upstream, Affinity::Downstream] { | |
| 541 | let rect = layout.caret(layout.cursor(byte, affinity), 0.0); | |
| 542 | let hit = layout.hit_test(rect.x0 as f32, ((rect.y0 + rect.y1) * 0.5) as f32); | |
| 543 | assert_eq!(hit.index(), byte, "byte {byte}, {affinity:?}, {rect:?}"); | |
| 544 | } | |
| 545 | } | |
| 546 | } | |
| 547 | ||
| 548 | #[test] | |
| 549 | fn wrapped_carets_hit_the_same_source_position() { | |
| 550 | let paragraph = Paragraph::new( | |
| 551 | "A quiet evening under the trees, with notes beside every line.".into(), | |
| 552 | Format::default(), | |
| 553 | ); | |
| 554 | let layout = TextEngine::default().layout(&paragraph, 110.0).unwrap(); | |
| 555 | assert!(layout.lines.len() > 1); | |
| 556 | for byte in 0..=paragraph.text().len() { | |
| 557 | for affinity in [Affinity::Upstream, Affinity::Downstream] { | |
| 558 | let cursor = layout.cursor(byte, affinity); | |
| 559 | let rect = layout.caret(cursor, 0.0); | |
| 560 | let hit = layout.hit_test(rect.x0 as f32, ((rect.y0 + rect.y1) * 0.5) as f32); | |
| 561 | assert_eq!(hit.index(), byte, "byte {byte}, {affinity:?}, {rect:?}"); | |
| 562 | } | |
| 563 | } | |
| 564 | } | |
| 565 | ||
| 566 | #[test] | |
| 567 | fn visual_navigation_keeps_combining_and_emoji_sequences_intact() { | |
| 568 | let graphemes = ["a", "e\u{301}", "👩🏽💻", "🇨🇦", "👩👩👧👦", "z"]; | |
| 569 | let text = graphemes.concat(); | |
| 570 | let paragraph = Paragraph::new(text, Format::default()); | |
| 571 | let layout = TextEngine::default().layout(&paragraph, 1000.0).unwrap(); | |
| 572 | let mut byte = 0; | |
| 573 | let mut cursor = layout.cursor(0, Affinity::Downstream); | |
| 574 | for grapheme in graphemes { | |
| 575 | byte += grapheme.len(); | |
| 576 | cursor = cursor.next_visual(&layout.shaped); | |
| 577 | assert_eq!(cursor.index(), byte); | |
| 578 | } | |
| 579 | for grapheme in graphemes.into_iter().rev() { | |
| 580 | byte -= grapheme.len(); | |
| 581 | cursor = cursor.previous_visual(&layout.shaped); | |
| 582 | assert_eq!(cursor.index(), byte); | |
| 583 | } | |
| 584 | } | |
| 585 | ||
| 586 | #[test] | |
| 587 | fn emoji_only_lines_have_nonzero_metrics() { | |
| 588 | let paragraph = Paragraph::new("👩👩👧👦".into(), Format::default()); | |
| 589 | let layout = TextEngine::default().layout(&paragraph, 100.0).unwrap(); | |
| 590 | assert!(layout.height() > 0.0); | |
| 591 | let caret = layout.caret(layout.cursor(0, Affinity::Downstream), 1.0); | |
| 592 | assert!(caret.height() > 0.0); | |
| 593 | } | |
| 594 | ||
| 595 | #[test] | |
| 596 | fn bidi_hit_testing_stays_on_grapheme_boundaries() { | |
| 597 | let graphemes = [ | |
| 598 | "a", | |
| 599 | " ", | |
| 600 | "ש", | |
| 601 | "ל", | |
| 602 | "ו", | |
| 603 | "ם", | |
| 604 | " ", | |
| 605 | "1", | |
| 606 | "2", | |
| 607 | "3", | |
| 608 | " ", | |
| 609 | "👩🏽💻", | |
| 610 | " ", | |
| 611 | "e\u{301}", | |
| 612 | ]; | |
| 613 | let mut boundaries = vec![0]; | |
| 614 | for grapheme in graphemes { | |
| 615 | boundaries.push(boundaries.last().unwrap() + grapheme.len()); | |
| 616 | } | |
| 617 | let paragraph = Paragraph::new(graphemes.concat(), Format::default()); | |
| 618 | let mut engine = TextEngine::default(); | |
| 619 | for width in [1.0, 10.0, 40.0, 80.0, 1000.0] { | |
| 620 | let layout = engine.layout(&paragraph, width).unwrap(); | |
| 621 | for (_, line) in layout.lines() { | |
| 622 | assert!(boundaries.contains(&line.source.start)); | |
| 623 | assert!(boundaries.contains(&line.source.end)); | |
| 624 | } | |
| 625 | for y in (-10..layout.height() as i32 + 10).step_by(3) { | |
| 626 | for x in (-10..width as i32 + 10).step_by(3) { | |
| 627 | let cursor = layout.hit_test(x as f32, y as f32); | |
| 628 | assert!( | |
| 629 | boundaries.contains(&cursor.index()), | |
| 630 | "{width}: {x}, {y} -> {cursor:?}" | |
| 631 | ); | |
| 632 | let caret = layout.caret(cursor, 1.0); | |
| 633 | assert!(caret.x0.is_finite() && caret.y0.is_finite() && caret.height() > 0.0); | |
| 634 | } | |
| 635 | } | |
| 636 | } | |
| 637 | } | |
| 638 | ||
| 639 | #[test] | |
| 640 | fn selection_uses_the_same_line_boxes_as_hit_testing() { | |
| 641 | let paragraph = Paragraph::new( | |
| 642 | "one two three four five six seven".into(), | |
| 643 | Format::default(), | |
| 644 | ); | |
| 645 | let layout = TextEngine::default().layout(&paragraph, 60.0).unwrap(); | |
| 646 | let selection = Selection::from(layout.cursor(0, Affinity::Downstream)) | |
| 647 | .extend(layout.cursor(paragraph.text().len(), Affinity::Upstream)); | |
| 648 | let rects = layout.selection(selection); | |
| 649 | assert_eq!(rects.len(), layout.lines.len()); | |
| 650 | for (rect, line) in rects.iter().zip(&layout.lines) { | |
| 651 | assert_eq!(rect.y0, f64::from(line.top)); | |
| 652 | assert_eq!(rect.y1, f64::from(line.top + line.height)); | |
| 653 | let cursor = layout.hit_test(rect.x0 as f32 + 0.01, ((rect.y0 + rect.y1) * 0.5) as f32); | |
| 654 | assert_eq!(cursor.index(), line.source.start); | |
| 655 | } | |
| 656 | } | |
| 657 | } |
crates/canvas/src/lib.rs created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | pub mod document; | |
| 2 | pub mod editor; | |
| 3 | pub mod layout; | |
| 4 | pub mod outline; | |
| 5 | pub mod page; | |
| 6 | pub mod text; |
crates/canvas/src/outline.rs created+646| ... | ... | @@ -0,0 +1,646 @@ |
| 1 | use crate::{ | |
| 2 | layout::{LayoutError, TextEngine, TextLayout}, | |
| 3 | page::{Definition, Outline, PageParagraph, Title}, | |
| 4 | text::{Paragraph, TextProjection}, | |
| 5 | }; | |
| 6 | use onestore::{ | |
| 7 | ExGuid, | |
| 8 | document::{Format, Kind}, | |
| 9 | }; | |
| 10 | use std::collections::{BTreeMap, BTreeSet}; | |
| 11 | ||
| 12 | pub struct OutlineLayout { | |
| 13 | pub paragraphs: Vec<ParagraphLayout>, | |
| 14 | pub size: [f32; 2], | |
| 15 | } | |
| 16 | ||
| 17 | pub struct ParagraphLayout { | |
| 18 | pub id: ExGuid, | |
| 19 | pub origin: [f32; 2], | |
| 20 | pub projection: TextProjection, | |
| 21 | pub text: TextLayout, | |
| 22 | /// Marker x is outline-local; y is paragraph-local so reflow cannot accumulate rounding drift. | |
| 23 | pub markers: Vec<(TextLayout, [f32; 2])>, | |
| 24 | pub tags: Vec<ParagraphTag>, | |
| 25 | } | |
| 26 | ||
| 27 | #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] | |
| 28 | pub enum TagIcon { | |
| 29 | CheckBox { checked: bool }, | |
| 30 | Question, | |
| 31 | Music, | |
| 32 | } | |
| 33 | ||
| 34 | #[derive(Clone, Debug, PartialEq)] | |
| 35 | pub struct ParagraphTag { | |
| 36 | pub icon: TagIcon, | |
| 37 | /// Coordinates are outline-local in x and paragraph-local in y. | |
| 38 | pub origin: [f32; 2], | |
| 39 | pub label: String, | |
| 40 | pub disabled: bool, | |
| 41 | } | |
| 42 | ||
| 43 | impl ParagraphTag { | |
| 44 | pub const SIZE: f32 = 12.0; | |
| 45 | } | |
| 46 | ||
| 47 | impl ParagraphLayout { | |
| 48 | pub(crate) fn shape( | |
| 49 | engine: &mut TextEngine, | |
| 50 | paragraph: &PageParagraph, | |
| 51 | width: f32, | |
| 52 | indents: &[f32], | |
| 53 | definitions: &BTreeMap<ExGuid, Definition>, | |
| 54 | ) -> Result<Self, LayoutError> { | |
| 55 | if !width.is_finite() || width <= 0.0 { | |
| 56 | return Err(LayoutError::InvalidWidth); | |
| 57 | } | |
| 58 | if indents.len() < 2 | |
| 59 | || indents.iter().any(|v| !v.is_finite() || *v < 0.0) | |
| 60 | || paragraph.level == 0 | |
| 61 | { | |
| 62 | return Err(LayoutError::InvalidIndentation); | |
| 63 | } | |
| 64 | if !paragraph.unsupported.is_empty() { | |
| 65 | return Err(LayoutError::UnsupportedContent); | |
| 66 | } | |
| 67 | let known = (paragraph.level as usize).min(indents.len() - 1); | |
| 68 | let indent = (indents[1..=known] | |
| 69 | .iter() | |
| 70 | .map(|v| f64::from(*v)) | |
| 71 | .sum::<f64>() | |
| 72 | + f64::from(paragraph.level - known as u32) * f64::from(*indents.last().unwrap())) | |
| 73 | as f32; | |
| 74 | if !indent.is_finite() || indent >= width { | |
| 75 | return Err(LayoutError::InvalidIndentation); | |
| 76 | } | |
| 77 | let projection = paragraph | |
| 78 | .combined_text() | |
| 79 | .project() | |
| 80 | .map_err(|_| LayoutError::InvalidSourceRange)?; | |
| 81 | let format = &projection.text().spans()[0].format; | |
| 82 | let spacing = format.list_spacing.unwrap_or(7.2); | |
| 83 | if !spacing.is_finite() || spacing < 0.0 { | |
| 84 | return Err(LayoutError::InvalidSpacing); | |
| 85 | } | |
| 86 | let mut text = engine.layout(projection.text(), width - indent)?; | |
| 87 | let mut markers = Vec::new(); | |
| 88 | let mut marker_x = indent; | |
| 89 | for id in paragraph.lists.iter().rev() { | |
| 90 | let definition = definitions.get(id).ok_or(LayoutError::InvalidList)?; | |
| 91 | let Kind::List { | |
| 92 | font, | |
| 93 | format: Some(value), | |
| 94 | .. | |
| 95 | } = &definition.kind | |
| 96 | else { | |
| 97 | return Err(LayoutError::InvalidList); | |
| 98 | }; | |
| 99 | if value.contains('\u{fffd}') { | |
| 100 | return Err(LayoutError::UnsupportedContent); | |
| 101 | } | |
| 102 | let marker = Paragraph::new( | |
| 103 | value.clone(), | |
| 104 | Format { | |
| 105 | font: font.clone().or_else(|| definition.format.font.clone()), | |
| 106 | font_size: definition.format.font_size.or(format.font_size), | |
| 107 | color: definition.format.color, | |
| 108 | ..Format::default() | |
| 109 | }, | |
| 110 | ); | |
| 111 | let layout = engine.layout(&marker, width)?; | |
| 112 | if layout.lines().count() != 1 { | |
| 113 | return Err(LayoutError::InvalidList); | |
| 114 | } | |
| 115 | let (line, metrics) = layout.lines().next().unwrap(); | |
| 116 | marker_x -= spacing + line.metrics().advance; | |
| 117 | if !marker_x.is_finite() { | |
| 118 | return Err(LayoutError::InvalidSpacing); | |
| 119 | } | |
| 120 | let y = text.lines().next().unwrap().1.baseline - metrics.baseline; | |
| 121 | markers.push((layout, [marker_x, y])); | |
| 122 | } | |
| 123 | text.minimum_line_height(format.line_spacing.unwrap_or(0.0))?; | |
| 124 | let mut tags = Vec::new(); | |
| 125 | for tag in paragraph | |
| 126 | .tags | |
| 127 | .iter() | |
| 128 | .chain(paragraph.text.iter().flat_map(|text| &text.tags)) | |
| 129 | { | |
| 130 | let Some(Definition { | |
| 131 | kind: | |
| 132 | Kind::TagDefinition { | |
| 133 | shape, | |
| 134 | label, | |
| 135 | color, | |
| 136 | highlight, | |
| 137 | .. | |
| 138 | }, | |
| 139 | .. | |
| 140 | }) = tag.definition.as_ref().and_then(|id| definitions.get(id)) | |
| 141 | else { | |
| 142 | return Err(LayoutError::UnsupportedContent); | |
| 143 | }; | |
| 144 | if color.is_some() || highlight.is_some() || tag.status & 4 != 0 { | |
| 145 | return Err(LayoutError::UnsupportedContent); | |
| 146 | } | |
| 147 | let icon = match shape { | |
| 148 | Some(0) => continue, | |
| 149 | Some(3) => TagIcon::CheckBox { | |
| 150 | checked: tag.status & 1 != 0, | |
| 151 | }, | |
| 152 | Some(15) => TagIcon::Question, | |
| 153 | Some(121) => TagIcon::Music, | |
| 154 | _ => return Err(LayoutError::UnsupportedContent), | |
| 155 | }; | |
| 156 | marker_x -= 20.25; | |
| 157 | tags.push(ParagraphTag { | |
| 158 | icon, | |
| 159 | origin: [marker_x, 0.0], | |
| 160 | label: label.clone().unwrap_or_default(), | |
| 161 | disabled: tag.status & 2 != 0, | |
| 162 | }); | |
| 163 | } | |
| 164 | Ok(Self { | |
| 165 | id: paragraph.id, | |
| 166 | origin: [indent, 0.0], | |
| 167 | projection, | |
| 168 | text, | |
| 169 | markers, | |
| 170 | tags, | |
| 171 | }) | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 175 | pub(crate) fn visible_paragraphs(nodes: &[PageParagraph]) -> impl Iterator<Item = &PageParagraph> { | |
| 176 | let mut hidden = BTreeSet::new(); | |
| 177 | nodes.iter().filter(move |paragraph| { | |
| 178 | let invisible = paragraph | |
| 179 | .parent | |
| 180 | .is_some_and(|parent| hidden.contains(&parent)); | |
| 181 | if invisible || paragraph.collapsed { | |
| 182 | hidden.insert(paragraph.id); | |
| 183 | } | |
| 184 | !invisible | |
| 185 | }) | |
| 186 | } | |
| 187 | ||
| 188 | pub(crate) fn arrange<'a>( | |
| 189 | paragraphs: impl Iterator<Item = &'a ParagraphLayout>, | |
| 190 | width: f32, | |
| 191 | width_set_by_user: bool, | |
| 192 | ) -> Result<(Vec<f32>, [f32; 2]), LayoutError> { | |
| 193 | let mut origins = Vec::new(); | |
| 194 | let mut bottom = 0.0_f64; | |
| 195 | let mut previous_after = 0.0_f32; | |
| 196 | let mut content_width = 0.0_f32; | |
| 197 | for paragraph in paragraphs { | |
| 198 | let format = &paragraph.projection.text().spans()[0].format; | |
| 199 | let before = format.space_before.unwrap_or(0.0); | |
| 200 | let after = format.space_after.unwrap_or(0.0); | |
| 201 | if [before, after].iter().any(|v| !v.is_finite() || *v < 0.0) { | |
| 202 | return Err(LayoutError::InvalidSpacing); | |
| 203 | } | |
| 204 | if !origins.is_empty() { | |
| 205 | bottom += f64::from(previous_after.max(before)); | |
| 206 | } | |
| 207 | origins.push(bottom as f32); | |
| 208 | bottom += f64::from( | |
| 209 | paragraph | |
| 210 | .markers | |
| 211 | .iter() | |
| 212 | .map(|(m, _)| m.height()) | |
| 213 | .fold(paragraph.text.height(), f32::max), | |
| 214 | ); | |
| 215 | previous_after = after; | |
| 216 | if !(bottom as f32).is_finite() { | |
| 217 | return Err(LayoutError::InvalidSpacing); | |
| 218 | } | |
| 219 | content_width = content_width.max( | |
| 220 | paragraph.origin[0] | |
| 221 | + paragraph | |
| 222 | .text | |
| 223 | .lines() | |
| 224 | .map(|(line, _)| line.metrics().advance) | |
| 225 | .fold(0.0, f32::max), | |
| 226 | ); | |
| 227 | } | |
| 228 | let height = bottom as f32; | |
| 229 | if !height.is_finite() || !content_width.is_finite() { | |
| 230 | return Err(LayoutError::InvalidSpacing); | |
| 231 | } | |
| 232 | Ok(( | |
| 233 | origins, | |
| 234 | [ | |
| 235 | if width_set_by_user { | |
| 236 | width | |
| 237 | } else { | |
| 238 | content_width.max(36.0) | |
| 239 | }, | |
| 240 | height, | |
| 241 | ], | |
| 242 | )) | |
| 243 | } | |
| 244 | ||
| 245 | impl OutlineLayout { | |
| 246 | pub(crate) fn new( | |
| 247 | mut paragraphs: Vec<ParagraphLayout>, | |
| 248 | width: f32, | |
| 249 | width_set_by_user: bool, | |
| 250 | ) -> Result<Self, LayoutError> { | |
| 251 | if !width.is_finite() || width <= 0.0 { | |
| 252 | return Err(LayoutError::InvalidWidth); | |
| 253 | } | |
| 254 | let (origins, size) = arrange(paragraphs.iter(), width, width_set_by_user)?; | |
| 255 | for (paragraph, y) in paragraphs.iter_mut().zip(origins) { | |
| 256 | paragraph.origin[1] = y; | |
| 257 | } | |
| 258 | Ok(Self { paragraphs, size }) | |
| 259 | } | |
| 260 | } | |
| 261 | ||
| 262 | impl Outline { | |
| 263 | pub fn layout( | |
| 264 | &self, | |
| 265 | engine: &mut TextEngine, | |
| 266 | definitions: &BTreeMap<ExGuid, Definition>, | |
| 267 | ) -> Result<OutlineLayout, LayoutError> { | |
| 268 | let width = self | |
| 269 | .layout | |
| 270 | .reserved_width | |
| 271 | .or(self.layout.max_width) | |
| 272 | .ok_or(LayoutError::InvalidWidth)?; | |
| 273 | self.layout_with_width(engine, definitions, width) | |
| 274 | } | |
| 275 | ||
| 276 | fn layout_with_width( | |
| 277 | &self, | |
| 278 | engine: &mut TextEngine, | |
| 279 | definitions: &BTreeMap<ExGuid, Definition>, | |
| 280 | width: f32, | |
| 281 | ) -> Result<OutlineLayout, LayoutError> { | |
| 282 | if self.indents.len() < 2 || self.indents.iter().any(|v| !v.is_finite() || *v < 0.0) { | |
| 283 | return Err(LayoutError::InvalidIndentation); | |
| 284 | } | |
| 285 | if !self.unsupported.is_empty() { | |
| 286 | return Err(LayoutError::UnsupportedContent); | |
| 287 | } | |
| 288 | let paragraphs = visible_paragraphs(&self.paragraphs) | |
| 289 | .map(|p| ParagraphLayout::shape(engine, p, width, &self.indents, definitions)) | |
| 290 | .collect::<Result<_, _>>()?; | |
| 291 | OutlineLayout::new( | |
| 292 | paragraphs, | |
| 293 | width, | |
| 294 | self.layout.width_set_by_user == Some(true), | |
| 295 | ) | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | impl Title { | |
| 300 | pub fn layout( | |
| 301 | &self, | |
| 302 | engine: &mut TextEngine, | |
| 303 | definitions: &BTreeMap<ExGuid, Definition>, | |
| 304 | ) -> Result<Vec<([f32; 2], OutlineLayout)>, LayoutError> { | |
| 305 | if [self.layout.x, self.layout.y] | |
| 306 | .into_iter() | |
| 307 | .flatten() | |
| 308 | .any(|v| !v.is_finite()) | |
| 309 | { | |
| 310 | return Err(LayoutError::InvalidSpacing); | |
| 311 | } | |
| 312 | let mut layouts = Vec::new(); | |
| 313 | let mut bottom = 0.0_f32; | |
| 314 | for outline in &self.outlines { | |
| 315 | let width = outline | |
| 316 | .layout | |
| 317 | .reserved_width | |
| 318 | .or(outline.layout.max_width) | |
| 319 | .unwrap_or(f32::MAX); | |
| 320 | let layout = outline.layout_with_width(engine, definitions, width)?; | |
| 321 | let origin = [ | |
| 322 | outline.layout.x.unwrap_or(0.0), | |
| 323 | bottom + outline.layout.y.unwrap_or(0.0), | |
| 324 | ]; | |
| 325 | let height = outline.layout.max_height.unwrap_or(0.0); | |
| 326 | if origin.iter().any(|v| !v.is_finite()) || !height.is_finite() || height < 0.0 { | |
| 327 | return Err(LayoutError::InvalidSpacing); | |
| 328 | } | |
| 329 | bottom = origin[1] + layout.size[1].max(height); | |
| 330 | if !bottom.is_finite() { | |
| 331 | return Err(LayoutError::InvalidSpacing); | |
| 332 | } | |
| 333 | layouts.push((origin, layout)); | |
| 334 | } | |
| 335 | Ok(layouts) | |
| 336 | } | |
| 337 | } | |
| 338 | ||
| 339 | #[cfg(test)] | |
| 340 | mod tests { | |
| 341 | use super::*; | |
| 342 | use crate::page::{PageParagraph, TextObject}; | |
| 343 | use onestore::document::Layout; | |
| 344 | ||
| 345 | fn paragraph(n: u32, text: &str, level: u32, parent: Option<u32>) -> PageParagraph { | |
| 346 | PageParagraph { | |
| 347 | id: ExGuid { | |
| 348 | n, | |
| 349 | ..ExGuid::default() | |
| 350 | }, | |
| 351 | parent: parent.map(|n| ExGuid { | |
| 352 | n, | |
| 353 | ..ExGuid::default() | |
| 354 | }), | |
| 355 | level, | |
| 356 | format: Format::default(), | |
| 357 | text: vec![TextObject { | |
| 358 | id: ExGuid { | |
| 359 | n: n + 100, | |
| 360 | ..ExGuid::default() | |
| 361 | }, | |
| 362 | text: Paragraph::new(text.into(), Format::default()), | |
| 363 | tags: Vec::new(), | |
| 364 | }], | |
| 365 | unsupported: Vec::new(), | |
| 366 | lists: Vec::new(), | |
| 367 | tags: Vec::new(), | |
| 368 | collapsed: false, | |
| 369 | } | |
| 370 | } | |
| 371 | ||
| 372 | #[test] | |
| 373 | fn tags_preserve_line_geometry_and_follow_list_indentation() { | |
| 374 | use onestore::document::Tag; | |
| 375 | let id = ExGuid { | |
| 376 | n: 500, | |
| 377 | ..ExGuid::default() | |
| 378 | }; | |
| 379 | let mut node = paragraph(1, "Text that wraps across several lines", 2, None); | |
| 380 | let mut engine = TextEngine::default(); | |
| 381 | let mut definitions = BTreeMap::new(); | |
| 382 | let plain = | |
| 383 | ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions) | |
| 384 | .unwrap(); | |
| 385 | node.text[0].tags.push(Tag { | |
| 386 | definition: Some(id), | |
| 387 | status: 3, | |
| 388 | action_type: None, | |
| 389 | created: None, | |
| 390 | completed: None, | |
| 391 | start: None, | |
| 392 | due: None, | |
| 393 | task_id: None, | |
| 394 | extra_set: 0, | |
| 395 | }); | |
| 396 | for (shape, expected) in [ | |
| 397 | (3, TagIcon::CheckBox { checked: true }), | |
| 398 | (15, TagIcon::Question), | |
| 399 | (121, TagIcon::Music), | |
| 400 | ] { | |
| 401 | definitions.insert( | |
| 402 | id, | |
| 403 | Definition { | |
| 404 | kind: Kind::TagDefinition { | |
| 405 | shape: Some(shape), | |
| 406 | label: Some("Label".into()), | |
| 407 | action_type: None, | |
| 408 | color: None, | |
| 409 | highlight: None, | |
| 410 | }, | |
| 411 | format: Format::default(), | |
| 412 | }, | |
| 413 | ); | |
| 414 | let tagged = | |
| 415 | ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions) | |
| 416 | .unwrap(); | |
| 417 | assert_eq!(tagged.tags[0].icon, expected); | |
| 418 | assert_eq!(tagged.tags[0].origin, [6.75, 0.0]); | |
| 419 | assert_eq!(tagged.tags[0].label, "Label"); | |
| 420 | assert!(tagged.tags[0].disabled); | |
| 421 | assert_eq!(tagged.text.height(), plain.text.height()); | |
| 422 | assert_eq!( | |
| 423 | tagged | |
| 424 | .text | |
| 425 | .lines() | |
| 426 | .map(|(_, b)| (&b.source, b.baseline)) | |
| 427 | .collect::<Vec<_>>(), | |
| 428 | plain | |
| 429 | .text | |
| 430 | .lines() | |
| 431 | .map(|(_, b)| (&b.source, b.baseline)) | |
| 432 | .collect::<Vec<_>>() | |
| 433 | ); | |
| 434 | } | |
| 435 | let list = ExGuid { | |
| 436 | n: 501, | |
| 437 | ..ExGuid::default() | |
| 438 | }; | |
| 439 | definitions.insert( | |
| 440 | list, | |
| 441 | Definition { | |
| 442 | kind: Kind::List { | |
| 443 | font: Some("Arial".into()), | |
| 444 | format: Some("•".into()), | |
| 445 | bullet: None, | |
| 446 | restart: None, | |
| 447 | }, | |
| 448 | format: Format::default(), | |
| 449 | }, | |
| 450 | ); | |
| 451 | node.lists.push(list); | |
| 452 | let tagged = | |
| 453 | ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions) | |
| 454 | .unwrap(); | |
| 455 | assert_eq!(tagged.tags[0].origin[0], tagged.markers[0].1[0] - 20.25); | |
| 456 | assert_eq!(tagged.tags[0].origin[1], 0.0); | |
| 457 | let Kind::TagDefinition { shape, .. } = &mut definitions.get_mut(&id).unwrap().kind else { | |
| 458 | unreachable!() | |
| 459 | }; | |
| 460 | *shape = Some(999); | |
| 461 | assert!(matches!( | |
| 462 | ParagraphLayout::shape(&mut engine, &node, 120.0, &[18.0, 0.0, 27.0], &definitions), | |
| 463 | Err(LayoutError::UnsupportedContent) | |
| 464 | )); | |
| 465 | } | |
| 466 | ||
| 467 | #[test] | |
| 468 | fn title_accepts_intrinsic_width_and_keeps_date_after_wrapped_title() { | |
| 469 | let mut title = Title { | |
| 470 | id: ExGuid::default(), | |
| 471 | layout: Layout::default(), | |
| 472 | outlines: ["A title with enough words to wrap", "A date"] | |
| 473 | .into_iter() | |
| 474 | .enumerate() | |
| 475 | .map(|(index, text)| Outline { | |
| 476 | id: ExGuid { | |
| 477 | n: index as u32, | |
| 478 | ..ExGuid::default() | |
| 479 | }, | |
| 480 | layout: Layout { | |
| 481 | max_height: Some(21.6), | |
| 482 | ..Layout::default() | |
| 483 | }, | |
| 484 | indents: vec![18.0, 0.0], | |
| 485 | paragraphs: vec![paragraph(index as u32, text, 1, None)], | |
| 486 | unsupported: Vec::new(), | |
| 487 | }) | |
| 488 | .collect(), | |
| 489 | }; | |
| 490 | let mut engine = TextEngine::default(); | |
| 491 | let definitions = BTreeMap::new(); | |
| 492 | assert!(matches!( | |
| 493 | title.outlines[0].layout(&mut engine, &definitions), | |
| 494 | Err(LayoutError::InvalidWidth) | |
| 495 | )); | |
| 496 | let layouts = title.layout(&mut engine, &definitions).unwrap(); | |
| 497 | assert_eq!(layouts[0].1.paragraphs[0].text.lines().count(), 1); | |
| 498 | assert_eq!(layouts[1].0, [0.0, 21.6]); | |
| 499 | title.outlines[0].layout.max_width = Some(70.0); | |
| 500 | let layouts = title.layout(&mut engine, &definitions).unwrap(); | |
| 501 | assert!(layouts[0].1.paragraphs[0].text.lines().count() > 1); | |
| 502 | assert_eq!(layouts[1].0[1], layouts[0].1.size[1]); | |
| 503 | for value in [f32::NAN, f32::INFINITY, -1.0] { | |
| 504 | title.outlines[0].layout.max_height = Some(value); | |
| 505 | assert!(matches!( | |
| 506 | title.layout(&mut engine, &definitions), | |
| 507 | Err(LayoutError::InvalidSpacing) | |
| 508 | )); | |
| 509 | } | |
| 510 | title.outlines[0].layout.max_height = Some(21.6); | |
| 511 | title.layout.x = Some(f32::NAN); | |
| 512 | assert!(matches!( | |
| 513 | title.layout(&mut engine, &definitions), | |
| 514 | Err(LayoutError::InvalidSpacing) | |
| 515 | )); | |
| 516 | } | |
| 517 | ||
| 518 | #[test] | |
| 519 | fn collapses_descendants_and_uses_the_larger_adjacent_spacing() { | |
| 520 | let mut first = paragraph(1, "First", 1, None); | |
| 521 | first.text[0].text = Paragraph::new( | |
| 522 | "First".into(), | |
| 523 | Format { | |
| 524 | space_after: Some(4.0), | |
| 525 | ..Format::default() | |
| 526 | }, | |
| 527 | ); | |
| 528 | let mut second = paragraph(2, "Second", 2, Some(1)); | |
| 529 | second.text[0].text = Paragraph::new( | |
| 530 | "Second".into(), | |
| 531 | Format { | |
| 532 | space_before: Some(7.0), | |
| 533 | space_after: Some(3.0), | |
| 534 | ..Format::default() | |
| 535 | }, | |
| 536 | ); | |
| 537 | second.collapsed = true; | |
| 538 | let mut outline = Outline { | |
| 539 | id: ExGuid::default(), | |
| 540 | layout: Layout { | |
| 541 | max_width: Some(300.0), | |
| 542 | ..Layout::default() | |
| 543 | }, | |
| 544 | indents: vec![18.0, 0.0, 27.0], | |
| 545 | paragraphs: vec![ | |
| 546 | first, | |
| 547 | second, | |
| 548 | paragraph(3, "Hidden", 6, Some(2)), | |
| 549 | paragraph(4, "Last", 1, None), | |
| 550 | ], | |
| 551 | unsupported: Vec::new(), | |
| 552 | }; | |
| 553 | let mut engine = TextEngine::default(); | |
| 554 | let layout = outline.layout(&mut engine, &BTreeMap::new()).unwrap(); | |
| 555 | assert_eq!( | |
| 556 | layout.paragraphs.iter().map(|p| p.id.n).collect::<Vec<_>>(), | |
| 557 | [1, 2, 4] | |
| 558 | ); | |
| 559 | assert_eq!( | |
| 560 | layout.paragraphs[1].origin, | |
| 561 | [27.0, layout.paragraphs[0].text.height() + 7.0] | |
| 562 | ); | |
| 563 | assert_eq!( | |
| 564 | layout.paragraphs[2].origin[1], | |
| 565 | layout.paragraphs[1].origin[1] + layout.paragraphs[1].text.height() + 3.0 | |
| 566 | ); | |
| 567 | outline.paragraphs[1].collapsed = false; | |
| 568 | let expanded = outline.layout(&mut engine, &BTreeMap::new()).unwrap(); | |
| 569 | assert_eq!(expanded.paragraphs[2].origin[0], 135.0); | |
| 570 | assert!(expanded.size[1] > layout.size[1]); | |
| 571 | outline.indents[1] = f32::NAN; | |
| 572 | assert!(matches!( | |
| 573 | outline.layout(&mut engine, &BTreeMap::new()), | |
| 574 | Err(LayoutError::InvalidIndentation) | |
| 575 | )); | |
| 576 | } | |
| 577 | ||
| 578 | #[test] | |
| 579 | fn marker_height_expands_the_paragraph_box_without_changing_text_line_advances() { | |
| 580 | let marker_id = ExGuid { | |
| 581 | n: 99, | |
| 582 | ..ExGuid::default() | |
| 583 | }; | |
| 584 | let mut short = paragraph(1, "Short", 2, None); | |
| 585 | short.lists.push(marker_id); | |
| 586 | let mut long = paragraph( | |
| 587 | 2, | |
| 588 | "A long paragraph wraps onto several separate lines of text.", | |
| 589 | 2, | |
| 590 | None, | |
| 591 | ); | |
| 592 | long.lists.push(marker_id); | |
| 593 | let outline = Outline { | |
| 594 | id: ExGuid::default(), | |
| 595 | layout: Layout { | |
| 596 | max_width: Some(110.0), | |
| 597 | width_set_by_user: Some(true), | |
| 598 | ..Layout::default() | |
| 599 | }, | |
| 600 | indents: vec![18.0, 0.0, 27.0], | |
| 601 | paragraphs: vec![short, long, paragraph(3, "Last", 1, None)], | |
| 602 | unsupported: Vec::new(), | |
| 603 | }; | |
| 604 | let definitions = BTreeMap::from([( | |
| 605 | marker_id, | |
| 606 | Definition { | |
| 607 | kind: Kind::List { | |
| 608 | font: Some("Courier New".into()), | |
| 609 | format: Some("○".into()), | |
| 610 | restart: None, | |
| 611 | bullet: Some(4), | |
| 612 | }, | |
| 613 | format: Format { | |
| 614 | font_size: Some(22.0), | |
| 615 | ..Format::default() | |
| 616 | }, | |
| 617 | }, | |
| 618 | )]); | |
| 619 | let mut engine = TextEngine::default(); | |
| 620 | let layout = outline.layout(&mut engine, &definitions).unwrap(); | |
| 621 | let short = &layout.paragraphs[0]; | |
| 622 | let long = &layout.paragraphs[1]; | |
| 623 | assert!(short.markers[0].0.height() > short.text.height()); | |
| 624 | assert!(long.text.height() > long.markers[0].0.height()); | |
| 625 | assert_eq!(long.origin[1], short.markers[0].0.height()); | |
| 626 | assert_eq!( | |
| 627 | layout.paragraphs[2].origin[1], | |
| 628 | long.origin[1] + long.text.height() | |
| 629 | ); | |
| 630 | let plain = engine.layout(long.projection.text(), 83.0).unwrap(); | |
| 631 | assert_eq!( | |
| 632 | long.text | |
| 633 | .lines() | |
| 634 | .map(|(_, l)| l.baseline) | |
| 635 | .collect::<Vec<_>>(), | |
| 636 | plain.lines().map(|(_, l)| l.baseline).collect::<Vec<_>>() | |
| 637 | ); | |
| 638 | assert_eq!( | |
| 639 | short.origin[1] | |
| 640 | + short.markers[0].1[1] | |
| 641 | + short.markers[0].0.lines().next().unwrap().1.baseline, | |
| 642 | short.origin[1] + short.text.lines().next().unwrap().1.baseline | |
| 643 | ); | |
| 644 | assert_eq!(layout.size[0], 110.0); | |
| 645 | } | |
| 646 | } |
crates/canvas/src/page.rs created+665| ... | ... | @@ -0,0 +1,665 @@ |
| 1 | use crate::text::Paragraph; | |
| 2 | use onestore::{ | |
| 3 | ExGuid, | |
| 4 | document::{Document, Format, Kind, Layout, Revision, Tag}, | |
| 5 | }; | |
| 6 | use std::{ | |
| 7 | collections::{BTreeMap, BTreeSet}, | |
| 8 | sync::Arc, | |
| 9 | }; | |
| 10 | ||
| 11 | pub struct Page { | |
| 12 | pub title: String, | |
| 13 | pub margin_origin: [f32; 2], | |
| 14 | pub objects: Vec<PageObject>, | |
| 15 | pub definitions: BTreeMap<ExGuid, Definition>, | |
| 16 | } | |
| 17 | ||
| 18 | pub struct Definition { | |
| 19 | pub kind: Kind<'static>, | |
| 20 | pub format: Format, | |
| 21 | } | |
| 22 | ||
| 23 | pub enum PageObject { | |
| 24 | Outline(Outline), | |
| 25 | Title(Title), | |
| 26 | Image(Image), | |
| 27 | Unsupported(Unsupported), | |
| 28 | } | |
| 29 | ||
| 30 | pub struct Title { | |
| 31 | pub id: ExGuid, | |
| 32 | pub layout: Layout, | |
| 33 | pub outlines: Vec<Outline>, | |
| 34 | } | |
| 35 | ||
| 36 | pub struct Outline { | |
| 37 | pub id: ExGuid, | |
| 38 | pub layout: Layout, | |
| 39 | pub indents: Vec<f32>, | |
| 40 | pub paragraphs: Vec<PageParagraph>, | |
| 41 | pub unsupported: Vec<Unsupported>, | |
| 42 | } | |
| 43 | ||
| 44 | #[derive(Clone, Debug, PartialEq)] | |
| 45 | pub struct PageParagraph { | |
| 46 | pub id: ExGuid, | |
| 47 | pub parent: Option<ExGuid>, | |
| 48 | pub level: u32, | |
| 49 | pub format: Format, | |
| 50 | pub text: Vec<TextObject>, | |
| 51 | pub unsupported: Vec<Unsupported>, | |
| 52 | pub lists: Vec<ExGuid>, | |
| 53 | pub tags: Vec<Tag>, | |
| 54 | pub collapsed: bool, | |
| 55 | } | |
| 56 | ||
| 57 | #[derive(Clone, Debug, PartialEq)] | |
| 58 | pub struct TextObject { | |
| 59 | pub id: ExGuid, | |
| 60 | pub text: Paragraph, | |
| 61 | pub tags: Vec<Tag>, | |
| 62 | } | |
| 63 | ||
| 64 | pub struct Image { | |
| 65 | pub id: ExGuid, | |
| 66 | pub layout: Layout, | |
| 67 | pub bytes: Option<Arc<[u8]>>, | |
| 68 | pub alt: Option<String>, | |
| 69 | pub background: bool, | |
| 70 | } | |
| 71 | ||
| 72 | #[derive(Clone, Debug, PartialEq)] | |
| 73 | pub struct Unsupported { | |
| 74 | pub id: ExGuid, | |
| 75 | pub jcid: u32, | |
| 76 | pub layout: Layout, | |
| 77 | } | |
| 78 | ||
| 79 | impl Page { | |
| 80 | pub fn from_document(document: &Document<'_>, title: &str) -> Result<Self, onestore::Error> { | |
| 81 | let mut selected = None; | |
| 82 | for (space, id) in document.pages()? { | |
| 83 | let space = &document.spaces[&space]; | |
| 84 | let revision = &space.revisions[&space.contexts[&ExGuid::default()]]; | |
| 85 | if page_title(revision, id) == Some(title) { | |
| 86 | if selected.is_some() { | |
| 87 | return Err(onestore::Error { | |
| 88 | offset: 0, | |
| 89 | message: "More than one active page has the requested title", | |
| 90 | }); | |
| 91 | } | |
| 92 | selected = Some((revision, id)); | |
| 93 | } | |
| 94 | } | |
| 95 | let (revision, id) = selected.ok_or(onestore::Error { | |
| 96 | offset: 0, | |
| 97 | message: "No active page has the requested title", | |
| 98 | })?; | |
| 99 | Self::from_revision(revision, id) | |
| 100 | } | |
| 101 | ||
| 102 | pub fn from_revision(revision: &Revision<'_>, id: ExGuid) -> Result<Self, onestore::Error> { | |
| 103 | let invalid = |message| onestore::Error { offset: 0, message }; | |
| 104 | let default_format = Format::default(); | |
| 105 | let style = |id: Option<ExGuid>| -> Result<&Format, onestore::Error> { | |
| 106 | let Some(id) = id else { | |
| 107 | return Ok(&default_format); | |
| 108 | }; | |
| 109 | let node = revision | |
| 110 | .nodes | |
| 111 | .get(&id) | |
| 112 | .ok_or_else(|| invalid("Missing canvas paragraph style"))?; | |
| 113 | if !matches!(node.kind, Kind::Style { .. }) { | |
| 114 | return Err(invalid("Canvas paragraph style has the wrong type")); | |
| 115 | } | |
| 116 | Ok(&node.format) | |
| 117 | }; | |
| 118 | let root = revision | |
| 119 | .nodes | |
| 120 | .get(&id) | |
| 121 | .ok_or_else(|| invalid("Missing canvas page"))?; | |
| 122 | let Kind::Page { | |
| 123 | margin_origin_x, | |
| 124 | margin_origin_y, | |
| 125 | .. | |
| 126 | } = &root.kind | |
| 127 | else { | |
| 128 | return Err(invalid("Canvas root is not a page")); | |
| 129 | }; | |
| 130 | let mut page = Self { | |
| 131 | title: page_title(revision, id).unwrap_or_default().to_owned(), | |
| 132 | margin_origin: [ | |
| 133 | margin_origin_x.unwrap_or(0.0), | |
| 134 | margin_origin_y.unwrap_or(0.0), | |
| 135 | ], | |
| 136 | objects: Vec::new(), | |
| 137 | definitions: BTreeMap::new(), | |
| 138 | }; | |
| 139 | let mut roots: Vec<_> = root | |
| 140 | .children | |
| 141 | .iter() | |
| 142 | .chain(&root.structure) | |
| 143 | .rev() | |
| 144 | .map(|id| (*id, None)) | |
| 145 | .collect(); | |
| 146 | let mut seen = BTreeSet::new(); | |
| 147 | while let Some((id, title_index)) = roots.pop() { | |
| 148 | if !seen.insert(id) { | |
| 149 | return Err(invalid("Repeated canvas page object")); | |
| 150 | } | |
| 151 | let node = revision | |
| 152 | .nodes | |
| 153 | .get(&id) | |
| 154 | .ok_or_else(|| invalid("Missing canvas page object"))?; | |
| 155 | if title_index.is_some() && !matches!(node.kind, Kind::Outline { .. }) { | |
| 156 | return Err(invalid("Canvas title child is not an outline")); | |
| 157 | } | |
| 158 | match &node.kind { | |
| 159 | Kind::Title => { | |
| 160 | let index = page.objects.len(); | |
| 161 | page.objects.push(PageObject::Title(Title { | |
| 162 | id, | |
| 163 | layout: node.layout.clone(), | |
| 164 | outlines: Vec::new(), | |
| 165 | })); | |
| 166 | roots.extend(node.children.iter().rev().map(|id| (*id, Some(index)))); | |
| 167 | } | |
| 168 | Kind::Outline { indents } => { | |
| 169 | let mut outline = Outline { | |
| 170 | id, | |
| 171 | layout: node.layout.clone(), | |
| 172 | indents: indents.clone(), | |
| 173 | paragraphs: Vec::new(), | |
| 174 | unsupported: Vec::new(), | |
| 175 | }; | |
| 176 | let mut pending: Vec<_> = node | |
| 177 | .children | |
| 178 | .iter() | |
| 179 | .rev() | |
| 180 | .map(|id| { | |
| 181 | ( | |
| 182 | *id, | |
| 183 | None, | |
| 184 | u32::from(node.child_level.unwrap_or(0)), | |
| 185 | node.format.clone(), | |
| 186 | ) | |
| 187 | }) | |
| 188 | .collect(); | |
| 189 | while let Some((id, parent, level, inherited)) = pending.pop() { | |
| 190 | if !seen.insert(id) { | |
| 191 | return Err(invalid("Repeated canvas outline object")); | |
| 192 | } | |
| 193 | let node = revision | |
| 194 | .nodes | |
| 195 | .get(&id) | |
| 196 | .ok_or_else(|| invalid("Missing canvas outline object"))?; | |
| 197 | let next_level = level | |
| 198 | .checked_add(u32::from(node.child_level.unwrap_or(0))) | |
| 199 | .ok_or_else(|| invalid("Canvas outline level overflow"))?; | |
| 200 | match &node.kind { | |
| 201 | Kind::Paragraph { | |
| 202 | lists, | |
| 203 | paragraph_style, | |
| 204 | collapse_state, | |
| 205 | } => { | |
| 206 | let format = node | |
| 207 | .format | |
| 208 | .inherit(style(*paragraph_style)?) | |
| 209 | .inherit(&inherited); | |
| 210 | let mut text = Vec::new(); | |
| 211 | let mut unsupported = Vec::new(); | |
| 212 | for id in &node.content { | |
| 213 | let content = revision.nodes.get(id).ok_or_else(|| { | |
| 214 | invalid("Missing canvas paragraph content") | |
| 215 | })?; | |
| 216 | if let Kind::RichText { | |
| 217 | paragraph_style, .. | |
| 218 | } = &content.kind | |
| 219 | { | |
| 220 | let runs = revision.text_runs(*id)?; | |
| 221 | let text_content = if runs.is_empty() { | |
| 222 | Paragraph::new( | |
| 223 | String::new(), | |
| 224 | content | |
| 225 | .format | |
| 226 | .inherit(style(*paragraph_style)?) | |
| 227 | .inherit(&format), | |
| 228 | ) | |
| 229 | } else { | |
| 230 | Paragraph::from_runs(runs.into_iter().map(|run| { | |
| 231 | (run.text.to_owned(), run.format.inherit(&format)) | |
| 232 | })) | |
| 233 | }; | |
| 234 | text.push(TextObject { | |
| 235 | id: *id, | |
| 236 | text: text_content, | |
| 237 | tags: content.tags.clone(), | |
| 238 | }); | |
| 239 | } else { | |
| 240 | unsupported.push(Unsupported { | |
| 241 | id: *id, | |
| 242 | jcid: content.jcid, | |
| 243 | layout: content.layout.clone(), | |
| 244 | }); | |
| 245 | } | |
| 246 | } | |
| 247 | for (id, is_list) in lists.iter().map(|id| (id, true)).chain( | |
| 248 | node.tags | |
| 249 | .iter() | |
| 250 | .chain(text.iter().flat_map(|t| &t.tags)) | |
| 251 | .filter_map(|tag| tag.definition.as_ref()) | |
| 252 | .map(|id| (id, false)), | |
| 253 | ) { | |
| 254 | let definition = revision.nodes.get(id).ok_or_else(|| { | |
| 255 | invalid("Missing canvas list or tag definition") | |
| 256 | })?; | |
| 257 | let kind = match &definition.kind { | |
| 258 | Kind::List { | |
| 259 | font, | |
| 260 | format, | |
| 261 | restart, | |
| 262 | bullet, | |
| 263 | } if is_list => Kind::List { | |
| 264 | font: font.clone(), | |
| 265 | format: format.clone(), | |
| 266 | restart: *restart, | |
| 267 | bullet: *bullet, | |
| 268 | }, | |
| 269 | Kind::TagDefinition { | |
| 270 | label, | |
| 271 | action_type, | |
| 272 | shape, | |
| 273 | color, | |
| 274 | highlight, | |
| 275 | } if !is_list => Kind::TagDefinition { | |
| 276 | label: label.clone(), | |
| 277 | action_type: *action_type, | |
| 278 | shape: *shape, | |
| 279 | color: *color, | |
| 280 | highlight: *highlight, | |
| 281 | }, | |
| 282 | _ => { | |
| 283 | return Err(invalid( | |
| 284 | "Canvas list or tag definition has the wrong type", | |
| 285 | )); | |
| 286 | } | |
| 287 | }; | |
| 288 | page.definitions.insert( | |
| 289 | *id, | |
| 290 | Definition { | |
| 291 | kind, | |
| 292 | format: definition.format.clone(), | |
| 293 | }, | |
| 294 | ); | |
| 295 | } | |
| 296 | outline.paragraphs.push(PageParagraph { | |
| 297 | id, | |
| 298 | parent, | |
| 299 | level, | |
| 300 | format: format.clone(), | |
| 301 | text, | |
| 302 | unsupported, | |
| 303 | lists: lists.clone(), | |
| 304 | tags: node.tags.clone(), | |
| 305 | collapsed: *collapse_state == Some(1), | |
| 306 | }); | |
| 307 | pending.extend( | |
| 308 | node.children.iter().rev().map(|child| { | |
| 309 | (*child, Some(id), next_level, format.clone()) | |
| 310 | }), | |
| 311 | ); | |
| 312 | } | |
| 313 | Kind::OutlineGroup => { | |
| 314 | pending.extend(node.children.iter().rev().map(|child| { | |
| 315 | (*child, parent, next_level, node.format.inherit(&inherited)) | |
| 316 | })) | |
| 317 | } | |
| 318 | _ => outline.unsupported.push(Unsupported { | |
| 319 | id, | |
| 320 | jcid: node.jcid, | |
| 321 | layout: node.layout.clone(), | |
| 322 | }), | |
| 323 | } | |
| 324 | } | |
| 325 | if let Some(index) = title_index { | |
| 326 | let PageObject::Title(title) = &mut page.objects[index] else { | |
| 327 | unreachable!() | |
| 328 | }; | |
| 329 | title.outlines.push(outline); | |
| 330 | } else { | |
| 331 | page.objects.push(PageObject::Outline(outline)); | |
| 332 | } | |
| 333 | } | |
| 334 | Kind::Image { | |
| 335 | container, | |
| 336 | alt, | |
| 337 | background, | |
| 338 | .. | |
| 339 | } => { | |
| 340 | let bytes = if let Some(container) = container { | |
| 341 | let data = revision | |
| 342 | .nodes | |
| 343 | .get(container) | |
| 344 | .ok_or_else(|| invalid("Missing canvas image data"))?; | |
| 345 | match &data.kind { | |
| 346 | Kind::File { payload, .. } => payload.map(Arc::from), | |
| 347 | _ => return Err(invalid("Canvas image data has the wrong type")), | |
| 348 | } | |
| 349 | } else { | |
| 350 | None | |
| 351 | }; | |
| 352 | page.objects.push(PageObject::Image(Image { | |
| 353 | id, | |
| 354 | layout: node.layout.clone(), | |
| 355 | bytes, | |
| 356 | alt: alt.clone(), | |
| 357 | background: background.unwrap_or(false), | |
| 358 | })); | |
| 359 | } | |
| 360 | _ => page.objects.push(PageObject::Unsupported(Unsupported { | |
| 361 | id, | |
| 362 | jcid: node.jcid, | |
| 363 | layout: node.layout.clone(), | |
| 364 | })), | |
| 365 | } | |
| 366 | } | |
| 367 | Ok(page) | |
| 368 | } | |
| 369 | } | |
| 370 | ||
| 371 | fn page_title<'a>(revision: &'a Revision<'_>, id: ExGuid) -> Option<&'a str> { | |
| 372 | revision | |
| 373 | .roots | |
| 374 | .get(&2) | |
| 375 | .and_then(|id| revision.nodes.get(id)) | |
| 376 | .and_then(|node| { | |
| 377 | if let Kind::Metadata { title, .. } = &node.kind { | |
| 378 | title.as_deref() | |
| 379 | } else { | |
| 380 | None | |
| 381 | } | |
| 382 | }) | |
| 383 | .or_else(|| { | |
| 384 | if let Kind::Page { | |
| 385 | alternate_title, .. | |
| 386 | } = &revision.nodes.get(&id)?.kind | |
| 387 | { | |
| 388 | alternate_title.as_deref() | |
| 389 | } else { | |
| 390 | None | |
| 391 | } | |
| 392 | }) | |
| 393 | } | |
| 394 | ||
| 395 | impl PageParagraph { | |
| 396 | pub fn combined_text(&self) -> Paragraph { | |
| 397 | if self.text.is_empty() { | |
| 398 | return Paragraph::new(String::new(), self.format.clone()); | |
| 399 | } | |
| 400 | Paragraph::from_runs(self.text.iter().flat_map(|object| { | |
| 401 | let mut start = 0; | |
| 402 | object.text.spans().iter().map(move |span| { | |
| 403 | let text = object.text.text()[start..span.end].to_owned(); | |
| 404 | start = span.end; | |
| 405 | (text, span.format.clone()) | |
| 406 | }) | |
| 407 | })) | |
| 408 | } | |
| 409 | } | |
| 410 | ||
| 411 | #[cfg(test)] | |
| 412 | mod tests { | |
| 413 | use super::*; | |
| 414 | use onestore::document::{Element, TextRun}; | |
| 415 | ||
| 416 | fn id(n: u32) -> ExGuid { | |
| 417 | ExGuid { | |
| 418 | n, | |
| 419 | ..ExGuid::default() | |
| 420 | } | |
| 421 | } | |
| 422 | ||
| 423 | fn element(kind: Kind<'_>) -> Element<'_> { | |
| 424 | Element { | |
| 425 | jcid: 0, | |
| 426 | children: Vec::new(), | |
| 427 | content: Vec::new(), | |
| 428 | structure: Vec::new(), | |
| 429 | spaces: Vec::new(), | |
| 430 | child_level: None, | |
| 431 | layout: Layout::default(), | |
| 432 | format: Format::default(), | |
| 433 | created: None, | |
| 434 | modified: None, | |
| 435 | original_author: None, | |
| 436 | latest_author: None, | |
| 437 | media_ids: Vec::new(), | |
| 438 | media_time_ms: None, | |
| 439 | tags: Vec::new(), | |
| 440 | kind, | |
| 441 | extra: Vec::new(), | |
| 442 | } | |
| 443 | } | |
| 444 | ||
| 445 | fn revision() -> Revision<'static> { | |
| 446 | let mut page = element(Kind::Page { | |
| 447 | alternate_title: Some("Fallback".into()), | |
| 448 | level: None, | |
| 449 | width: None, | |
| 450 | height: None, | |
| 451 | margin_origin_x: Some(36.0), | |
| 452 | margin_origin_y: Some(12.0), | |
| 453 | rtl: None, | |
| 454 | }); | |
| 455 | page.children.push(id(2)); | |
| 456 | let mut outline = element(Kind::Outline { | |
| 457 | indents: vec![18.0, 0.0, 36.0], | |
| 458 | }); | |
| 459 | outline.children.push(id(3)); | |
| 460 | outline.child_level = Some(1); | |
| 461 | outline.format.font_size = Some(9.0); | |
| 462 | outline.format.bold = Some(true); | |
| 463 | let mut paragraph = element(Kind::Paragraph { | |
| 464 | lists: Vec::new(), | |
| 465 | paragraph_style: Some(id(5)), | |
| 466 | collapse_state: Some(1), | |
| 467 | }); | |
| 468 | paragraph.content.push(id(4)); | |
| 469 | paragraph.format.bold = Some(false); | |
| 470 | let text = element(Kind::RichText { | |
| 471 | text: "ab".into(), | |
| 472 | runs: vec![TextRun { | |
| 473 | start: 0, | |
| 474 | end: 2, | |
| 475 | format: None, | |
| 476 | extra_set: None, | |
| 477 | }], | |
| 478 | paragraph_style: None, | |
| 479 | boilerplate: false, | |
| 480 | }); | |
| 481 | let mut style = element(Kind::Style { | |
| 482 | name: Some("Body".into()), | |
| 483 | }); | |
| 484 | style.format.font_size = Some(12.0); | |
| 485 | let metadata = element(Kind::Metadata { | |
| 486 | title: Some("Page title".into()), | |
| 487 | level: None, | |
| 488 | }); | |
| 489 | Revision { | |
| 490 | roots: BTreeMap::from([(2, id(6))]), | |
| 491 | nodes: BTreeMap::from([ | |
| 492 | (id(1), page), | |
| 493 | (id(2), outline), | |
| 494 | (id(3), paragraph), | |
| 495 | (id(4), text), | |
| 496 | (id(5), style), | |
| 497 | (id(6), metadata), | |
| 498 | ]), | |
| 499 | } | |
| 500 | } | |
| 501 | ||
| 502 | #[test] | |
| 503 | fn owns_page_content_and_resolves_style_before_ancestor_defaults() { | |
| 504 | let page = { | |
| 505 | let mut source = revision(); | |
| 506 | source.nodes.get_mut(&id(4)).unwrap().tags.push(Tag { | |
| 507 | definition: Some(id(7)), | |
| 508 | action_type: None, | |
| 509 | status: 0, | |
| 510 | created: Some(123), | |
| 511 | completed: None, | |
| 512 | start: None, | |
| 513 | due: None, | |
| 514 | task_id: None, | |
| 515 | extra_set: 0, | |
| 516 | }); | |
| 517 | source.nodes.insert( | |
| 518 | id(7), | |
| 519 | element(Kind::TagDefinition { | |
| 520 | label: Some("To Do".into()), | |
| 521 | action_type: Some(0), | |
| 522 | shape: Some(3), | |
| 523 | color: None, | |
| 524 | highlight: None, | |
| 525 | }), | |
| 526 | ); | |
| 527 | let before = serde_json::to_vec(&source).unwrap(); | |
| 528 | let page = Page::from_revision(&source, id(1)).unwrap(); | |
| 529 | assert_eq!(before, serde_json::to_vec(&source).unwrap()); | |
| 530 | page | |
| 531 | }; | |
| 532 | assert_eq!(page.title, "Page title"); | |
| 533 | assert_eq!(page.margin_origin, [36.0, 12.0]); | |
| 534 | let PageObject::Outline(outline) = &page.objects[0] else { | |
| 535 | panic!() | |
| 536 | }; | |
| 537 | assert_eq!(outline.paragraphs.len(), 1); | |
| 538 | let paragraph = &outline.paragraphs[0]; | |
| 539 | assert!(paragraph.collapsed); | |
| 540 | assert_eq!(paragraph.level, 1); | |
| 541 | assert_eq!(paragraph.parent, None); | |
| 542 | let text = paragraph.combined_text(); | |
| 543 | assert_eq!(text.text(), "ab"); | |
| 544 | assert_eq!(text.spans()[0].format.font_size, Some(12.0)); | |
| 545 | assert_eq!(text.spans()[0].format.bold, Some(false)); | |
| 546 | assert_eq!(paragraph.text[0].tags[0].created, Some(123)); | |
| 547 | assert!(matches!(&page.definitions[&id(7)].kind, | |
| 548 | Kind::TagDefinition { label: Some(label), shape: Some(3), .. } if label == "To Do")); | |
| 549 | } | |
| 550 | ||
| 551 | #[test] | |
| 552 | fn preserves_paint_order_nested_parents_and_owned_image_payloads() { | |
| 553 | let bytes = vec![1, 2, 3, 4]; | |
| 554 | let mut source = revision(); | |
| 555 | let image = element(Kind::Image { | |
| 556 | container: Some(id(8)), | |
| 557 | filename: None, | |
| 558 | alt: Some("Image".into()), | |
| 559 | picture_width: None, | |
| 560 | picture_height: None, | |
| 561 | background: Some(true), | |
| 562 | printout: None, | |
| 563 | link: None, | |
| 564 | }); | |
| 565 | let file = element(Kind::File { | |
| 566 | reference: onestore::FileDataReference::Internal([0; 16]), | |
| 567 | extension: "png".into(), | |
| 568 | payload: Some(bytes.as_slice()), | |
| 569 | }); | |
| 570 | let mut title = element(Kind::Title); | |
| 571 | title.layout.x = Some(12.0); | |
| 572 | title.layout.y = Some(24.0); | |
| 573 | title.children.push(id(10)); | |
| 574 | let title_outline = element(Kind::Outline { | |
| 575 | indents: Vec::new(), | |
| 576 | }); | |
| 577 | let mut group = element(Kind::OutlineGroup); | |
| 578 | group.children.push(id(12)); | |
| 579 | group.child_level = Some(1); | |
| 580 | let child = element(Kind::Paragraph { | |
| 581 | lists: Vec::new(), | |
| 582 | paragraph_style: None, | |
| 583 | collapse_state: None, | |
| 584 | }); | |
| 585 | source.nodes.extend([ | |
| 586 | (id(7), image), | |
| 587 | (id(8), file), | |
| 588 | (id(9), title), | |
| 589 | (id(10), title_outline), | |
| 590 | (id(11), group), | |
| 591 | (id(12), child), | |
| 592 | ]); | |
| 593 | source | |
| 594 | .nodes | |
| 595 | .get_mut(&id(1)) | |
| 596 | .unwrap() | |
| 597 | .children | |
| 598 | .insert(0, id(7)); | |
| 599 | source.nodes.get_mut(&id(1)).unwrap().structure.push(id(9)); | |
| 600 | source.nodes.get_mut(&id(3)).unwrap().children.push(id(11)); | |
| 601 | source.nodes.get_mut(&id(3)).unwrap().child_level = Some(1); | |
| 602 | let page = Page::from_revision(&source, id(1)).unwrap(); | |
| 603 | drop(source); | |
| 604 | drop(bytes); | |
| 605 | assert_eq!(page.objects.len(), 3); | |
| 606 | let PageObject::Image(image) = &page.objects[0] else { | |
| 607 | panic!() | |
| 608 | }; | |
| 609 | assert_eq!(image.bytes.as_deref(), Some([1, 2, 3, 4].as_slice())); | |
| 610 | let PageObject::Outline(outline) = &page.objects[1] else { | |
| 611 | panic!() | |
| 612 | }; | |
| 613 | assert_eq!(outline.paragraphs[1].id, id(12)); | |
| 614 | assert_eq!(outline.paragraphs[1].parent, Some(id(3))); | |
| 615 | assert_eq!(outline.paragraphs[1].level, 3); | |
| 616 | let PageObject::Title(title) = &page.objects[2] else { | |
| 617 | panic!() | |
| 618 | }; | |
| 619 | assert_eq!(title.id, id(9)); | |
| 620 | assert_eq!([title.layout.x, title.layout.y], [Some(12.0), Some(24.0)]); | |
| 621 | assert_eq!(title.outlines[0].id, id(10)); | |
| 622 | assert_eq!(title.outlines[0].layout.x, None); | |
| 623 | } | |
| 624 | ||
| 625 | #[test] | |
| 626 | fn rejects_title_children_that_are_not_outlines() { | |
| 627 | let mut source = revision(); | |
| 628 | let mut title = element(Kind::Title); | |
| 629 | title.children.push(id(8)); | |
| 630 | source.nodes.insert(id(7), title); | |
| 631 | source.nodes.insert(id(8), element(Kind::Title)); | |
| 632 | source.nodes.get_mut(&id(1)).unwrap().structure.push(id(7)); | |
| 633 | assert_eq!( | |
| 634 | Page::from_revision(&source, id(1)).err().unwrap().message, | |
| 635 | "Canvas title child is not an outline" | |
| 636 | ); | |
| 637 | } | |
| 638 | ||
| 639 | #[test] | |
| 640 | fn validates_empty_text_styles_missing_references_and_cycles() { | |
| 641 | let mut source = revision(); | |
| 642 | source.nodes.get_mut(&id(4)).unwrap().kind = Kind::RichText { | |
| 643 | text: String::new(), | |
| 644 | runs: Vec::new(), | |
| 645 | paragraph_style: Some(id(5)), | |
| 646 | boilerplate: false, | |
| 647 | }; | |
| 648 | source.nodes.get_mut(&id(3)).unwrap().format.font_size = Some(10.0); | |
| 649 | let page = Page::from_revision(&source, id(1)).unwrap(); | |
| 650 | let PageObject::Outline(outline) = &page.objects[0] else { | |
| 651 | panic!() | |
| 652 | }; | |
| 653 | assert_eq!( | |
| 654 | outline.paragraphs[0].combined_text().spans()[0] | |
| 655 | .format | |
| 656 | .font_size, | |
| 657 | Some(12.0) | |
| 658 | ); | |
| 659 | source.nodes.get_mut(&id(3)).unwrap().children.push(id(2)); | |
| 660 | assert!(Page::from_revision(&source, id(1)).is_err()); | |
| 661 | source.nodes.get_mut(&id(3)).unwrap().children.clear(); | |
| 662 | source.nodes.remove(&id(5)); | |
| 663 | assert!(Page::from_revision(&source, id(1)).is_err()); | |
| 664 | } | |
| 665 | } |
crates/canvas/src/text.rs created+543| ... | ... | @@ -0,0 +1,543 @@ |
| 1 | use onestore::document::Format; | |
| 2 | use parley::Affinity; | |
| 3 | use std::{fmt, ops::Range}; | |
| 4 | ||
| 5 | #[derive(Clone, Debug, PartialEq)] | |
| 6 | pub struct Span { | |
| 7 | /// Exclusive UTF-8 boundary; the start is the preceding span's end. | |
| 8 | pub end: usize, | |
| 9 | pub format: Format, | |
| 10 | } | |
| 11 | ||
| 12 | #[derive(Clone, Debug, PartialEq)] | |
| 13 | /// Editable text styles are coalesced independently of serialized run boundaries. | |
| 14 | pub struct Paragraph { | |
| 15 | text: String, | |
| 16 | spans: Vec<Span>, | |
| 17 | } | |
| 18 | ||
| 19 | pub struct TextProjection { | |
| 20 | text: Paragraph, | |
| 21 | spans: Vec<ProjectedSpan>, | |
| 22 | source_len: u32, | |
| 23 | } | |
| 24 | ||
| 25 | struct ProjectedSpan { | |
| 26 | visible: Range<u32>, | |
| 27 | source_start: u32, | |
| 28 | } | |
| 29 | ||
| 30 | impl TextProjection { | |
| 31 | pub fn text(&self) -> &Paragraph { | |
| 32 | &self.text | |
| 33 | } | |
| 34 | ||
| 35 | /// Maps visible UTF-8 boundaries to downstream source UTF-16 positions in one pass. | |
| 36 | pub fn source_boundaries(&self) -> impl Iterator<Item = (usize, u32)> + '_ { | |
| 37 | let mut spans = self.spans.iter().peekable(); | |
| 38 | let mut visible = 0; | |
| 39 | self.text | |
| 40 | .text | |
| 41 | .char_indices() | |
| 42 | .map(move |(byte, character)| { | |
| 43 | while spans.peek().is_some_and(|span| span.visible.end <= visible) { | |
| 44 | spans.next(); | |
| 45 | } | |
| 46 | let span = spans.peek().unwrap(); | |
| 47 | let source = span.source_start + (visible - span.visible.start); | |
| 48 | visible += character.len_utf16() as u32; | |
| 49 | (byte, source) | |
| 50 | }) | |
| 51 | .chain(std::iter::once((self.text.text.len(), self.source_len))) | |
| 52 | } | |
| 53 | ||
| 54 | /// Affinity selects the side of a hidden field at a visible boundary. | |
| 55 | pub fn source_offset(&self, visible: u32, affinity: Affinity) -> Result<u32, EditError> { | |
| 56 | self.text.byte_offset(visible)?; | |
| 57 | Ok(match affinity { | |
| 58 | Affinity::Downstream => self | |
| 59 | .spans | |
| 60 | .iter() | |
| 61 | .find(|span| span.visible.end > visible) | |
| 62 | .map(|span| span.source_start + (visible - span.visible.start)) | |
| 63 | .unwrap_or(self.source_len), | |
| 64 | Affinity::Upstream => self | |
| 65 | .spans | |
| 66 | .iter() | |
| 67 | .rev() | |
| 68 | .find(|span| span.visible.start < visible) | |
| 69 | .map(|span| span.source_start + (visible - span.visible.start)) | |
| 70 | .unwrap_or(0), | |
| 71 | }) | |
| 72 | } | |
| 73 | ||
| 74 | /// Hidden source positions collapse to their visible boundary. | |
| 75 | pub fn visible_offset(&self, source: u32) -> Result<u32, EditError> { | |
| 76 | if source > self.source_len { | |
| 77 | return Err(EditError::InvalidRange); | |
| 78 | } | |
| 79 | for span in &self.spans { | |
| 80 | if source < span.source_start { | |
| 81 | return Ok(span.visible.start); | |
| 82 | } | |
| 83 | let length = span.visible.end - span.visible.start; | |
| 84 | if source < span.source_start + length { | |
| 85 | let visible = span.visible.start + source - span.source_start; | |
| 86 | self.text.byte_offset(visible)?; | |
| 87 | return Ok(visible); | |
| 88 | } | |
| 89 | } | |
| 90 | self.text.utf16_offset(self.text.text.len()) | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | #[derive(Clone, Debug, PartialEq)] | |
| 95 | pub struct Edit { | |
| 96 | pub range: Range<u32>, | |
| 97 | pub replacement: Paragraph, | |
| 98 | } | |
| 99 | ||
| 100 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 101 | pub enum EditError { | |
| 102 | InvalidRange, | |
| 103 | TextTooLong, | |
| 104 | InvalidStructure, | |
| 105 | UnsupportedContent, | |
| 106 | Identity(getrandom::Error), | |
| 107 | } | |
| 108 | ||
| 109 | impl fmt::Display for EditError { | |
| 110 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 111 | f.write_str(match self { | |
| 112 | Self::Identity(error) => return error.fmt(f), | |
| 113 | Self::UnsupportedContent => { | |
| 114 | "This paragraph contains content the text editor cannot edit" | |
| 115 | } | |
| 116 | Self::InvalidRange => "Text range is outside the paragraph or splits a surrogate pair", | |
| 117 | Self::TextTooLong => "Text exceeds the UTF-16 offset range", | |
| 118 | Self::InvalidStructure => { | |
| 119 | "This outline has duplicate objects or broken paragraph links" | |
| 120 | } | |
| 121 | }) | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | impl std::error::Error for EditError {} | |
| 126 | ||
| 127 | pub(crate) fn new_id() -> Result<onestore::ExGuid, EditError> { | |
| 128 | let mut guid = [0; 16]; | |
| 129 | getrandom::fill(&mut guid).map_err(EditError::Identity)?; | |
| 130 | guid[7] = (guid[7] & 0x0f) | 0x40; | |
| 131 | guid[8] = (guid[8] & 0x3f) | 0x80; | |
| 132 | Ok(onestore::ExGuid { guid, n: 0 }) | |
| 133 | } | |
| 134 | ||
| 135 | impl Paragraph { | |
| 136 | pub fn project(&self) -> Result<TextProjection, EditError> { | |
| 137 | let mut runs = Vec::new(); | |
| 138 | let mut spans: Vec<ProjectedSpan> = Vec::new(); | |
| 139 | let mut byte = 0; | |
| 140 | let mut source = 0_u32; | |
| 141 | let mut visible = 0_u32; | |
| 142 | for span in &self.spans { | |
| 143 | let fragment = &self.text[byte..span.end]; | |
| 144 | let length: u32 = fragment | |
| 145 | .encode_utf16() | |
| 146 | .count() | |
| 147 | .try_into() | |
| 148 | .map_err(|_| EditError::TextTooLong)?; | |
| 149 | let source_end = source.checked_add(length).ok_or(EditError::TextTooLong)?; | |
| 150 | if span.format.hidden != Some(true) { | |
| 151 | runs.push((fragment.to_owned(), span.format.clone())); | |
| 152 | if length > 0 { | |
| 153 | if let Some(last) = spans.last_mut() | |
| 154 | && last.source_start + (last.visible.end - last.visible.start) == source | |
| 155 | { | |
| 156 | last.visible.end += length; | |
| 157 | } else { | |
| 158 | spans.push(ProjectedSpan { | |
| 159 | visible: visible..visible + length, | |
| 160 | source_start: source, | |
| 161 | }); | |
| 162 | } | |
| 163 | visible += length; | |
| 164 | } | |
| 165 | } | |
| 166 | byte = span.end; | |
| 167 | source = source_end; | |
| 168 | } | |
| 169 | if runs.is_empty() { | |
| 170 | let mut format = self.spans[0].format.clone(); | |
| 171 | format.hidden = Some(false); | |
| 172 | runs.push((String::new(), format)); | |
| 173 | } | |
| 174 | Ok(TextProjection { | |
| 175 | text: Self::from_runs(runs), | |
| 176 | spans, | |
| 177 | source_len: source, | |
| 178 | }) | |
| 179 | } | |
| 180 | ||
| 181 | pub fn new(text: String, format: Format) -> Self { | |
| 182 | let end = text.len(); | |
| 183 | Self { | |
| 184 | text, | |
| 185 | spans: vec![Span { end, format }], | |
| 186 | } | |
| 187 | } | |
| 188 | ||
| 189 | pub fn from_runs(runs: impl IntoIterator<Item = (String, Format)>) -> Self { | |
| 190 | let mut text = String::new(); | |
| 191 | let mut spans: Vec<Span> = Vec::new(); | |
| 192 | for (fragment, format) in runs { | |
| 193 | text.push_str(&fragment); | |
| 194 | if let Some(last) = spans.last_mut() { | |
| 195 | if last.format == format { | |
| 196 | last.end = text.len(); | |
| 197 | continue; | |
| 198 | } | |
| 199 | if fragment.is_empty() && !text.is_empty() { | |
| 200 | continue; | |
| 201 | } | |
| 202 | if last.end == 0 { | |
| 203 | spans.clear(); | |
| 204 | } | |
| 205 | } | |
| 206 | spans.push(Span { | |
| 207 | end: text.len(), | |
| 208 | format, | |
| 209 | }); | |
| 210 | } | |
| 211 | if spans.is_empty() { | |
| 212 | spans.push(Span { | |
| 213 | end: 0, | |
| 214 | format: Format::default(), | |
| 215 | }); | |
| 216 | } | |
| 217 | Self { text, spans } | |
| 218 | } | |
| 219 | ||
| 220 | pub fn text(&self) -> &str { | |
| 221 | &self.text | |
| 222 | } | |
| 223 | ||
| 224 | pub fn spans(&self) -> &[Span] { | |
| 225 | &self.spans | |
| 226 | } | |
| 227 | ||
| 228 | pub fn byte_offset(&self, utf16: u32) -> Result<usize, EditError> { | |
| 229 | let mut units = 0_u64; | |
| 230 | for (byte, character) in self.text.char_indices() { | |
| 231 | if units == u64::from(utf16) { | |
| 232 | return Ok(byte); | |
| 233 | } | |
| 234 | units += character.len_utf16() as u64; | |
| 235 | if units > u64::from(utf16) { | |
| 236 | return Err(EditError::InvalidRange); | |
| 237 | } | |
| 238 | } | |
| 239 | if units == u64::from(utf16) { | |
| 240 | Ok(self.text.len()) | |
| 241 | } else { | |
| 242 | Err(EditError::InvalidRange) | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | pub fn utf16_offset(&self, byte: usize) -> Result<u32, EditError> { | |
| 247 | self.text | |
| 248 | .get(..byte) | |
| 249 | .ok_or(EditError::InvalidRange)? | |
| 250 | .encode_utf16() | |
| 251 | .count() | |
| 252 | .try_into() | |
| 253 | .map_err(|_| EditError::TextTooLong) | |
| 254 | } | |
| 255 | ||
| 256 | pub fn format_at(&self, utf16: u32) -> Result<&Format, EditError> { | |
| 257 | let byte = self.byte_offset(utf16)?; | |
| 258 | let index = self.spans.partition_point(|span| span.end < byte); | |
| 259 | Ok(&self.spans[index].format) | |
| 260 | } | |
| 261 | ||
| 262 | pub fn slice(&self, range: Range<u32>) -> Result<Self, EditError> { | |
| 263 | if range.start > range.end { | |
| 264 | return Err(EditError::InvalidRange); | |
| 265 | } | |
| 266 | let start = self.byte_offset(range.start)?; | |
| 267 | let end = self.byte_offset(range.end)?; | |
| 268 | if start == end { | |
| 269 | return Ok(Self::new( | |
| 270 | String::new(), | |
| 271 | self.format_at(range.start)?.clone(), | |
| 272 | )); | |
| 273 | } | |
| 274 | let mut previous = 0; | |
| 275 | let mut spans = Vec::new(); | |
| 276 | for span in &self.spans { | |
| 277 | if previous < end && span.end > start { | |
| 278 | spans.push(Span { | |
| 279 | end: span.end.min(end) - start, | |
| 280 | format: span.format.clone(), | |
| 281 | }); | |
| 282 | } | |
| 283 | previous = span.end; | |
| 284 | } | |
| 285 | Ok(Self { | |
| 286 | text: self.text[start..end].to_owned(), | |
| 287 | spans, | |
| 288 | }) | |
| 289 | } | |
| 290 | ||
| 291 | /// Returns the inverse edit; applying that inverse returns a redo operation. | |
| 292 | pub fn apply(&mut self, edit: Edit) -> Result<Edit, EditError> { | |
| 293 | let end = self.utf16_offset(self.text.len())?; | |
| 294 | let removed = self.slice(edit.range.clone())?; | |
| 295 | let inserted = edit.replacement.utf16_offset(edit.replacement.text.len())?; | |
| 296 | let new_end = edit | |
| 297 | .range | |
| 298 | .start | |
| 299 | .checked_add(inserted) | |
| 300 | .ok_or(EditError::TextTooLong)?; | |
| 301 | let resulting_len = end | |
| 302 | .checked_sub(edit.range.end - edit.range.start) | |
| 303 | .and_then(|n| n.checked_add(inserted)) | |
| 304 | .ok_or(EditError::TextTooLong)?; | |
| 305 | if resulting_len == 0 { | |
| 306 | *self = edit.replacement; | |
| 307 | return Ok(Edit { | |
| 308 | range: edit.range.start..new_end, | |
| 309 | replacement: removed, | |
| 310 | }); | |
| 311 | } | |
| 312 | let prefix = self.slice(0..edit.range.start)?; | |
| 313 | let suffix = self.slice(edit.range.end..end)?; | |
| 314 | let mut runs = Vec::new(); | |
| 315 | for part in [prefix, edit.replacement, suffix] { | |
| 316 | let mut start = 0; | |
| 317 | for span in part.spans { | |
| 318 | if span.end > start { | |
| 319 | runs.push((part.text[start..span.end].to_owned(), span.format)); | |
| 320 | } | |
| 321 | start = span.end; | |
| 322 | } | |
| 323 | } | |
| 324 | *self = Self::from_runs(runs); | |
| 325 | Ok(Edit { | |
| 326 | range: edit.range.start..new_end, | |
| 327 | replacement: removed, | |
| 328 | }) | |
| 329 | } | |
| 330 | ||
| 331 | pub fn append(&mut self, other: Self) -> Result<Edit, EditError> { | |
| 332 | let end = self.utf16_offset(self.text.len())?; | |
| 333 | self.apply(Edit { | |
| 334 | range: end..end, | |
| 335 | replacement: other, | |
| 336 | }) | |
| 337 | } | |
| 338 | } | |
| 339 | ||
| 340 | #[cfg(test)] | |
| 341 | mod tests { | |
| 342 | use super::*; | |
| 343 | ||
| 344 | fn regular() -> Format { | |
| 345 | Format { | |
| 346 | font: Some("Arial".into()), | |
| 347 | font_size: Some(11.0), | |
| 348 | ..Format::default() | |
| 349 | } | |
| 350 | } | |
| 351 | ||
| 352 | fn bold() -> Format { | |
| 353 | Format { | |
| 354 | bold: Some(true), | |
| 355 | ..regular() | |
| 356 | } | |
| 357 | } | |
| 358 | ||
| 359 | #[test] | |
| 360 | fn hidden_fields_keep_source_offsets_at_both_sides_of_a_gap() { | |
| 361 | let hidden = Format { | |
| 362 | hidden: Some(true), | |
| 363 | ..regular() | |
| 364 | }; | |
| 365 | let source = Paragraph::from_runs([ | |
| 366 | ("A".into(), regular()), | |
| 367 | ("🌳".into(), hidden.clone()), | |
| 368 | ("e\u{301}".into(), bold()), | |
| 369 | ("X".into(), hidden), | |
| 370 | ]); | |
| 371 | let original = source.clone(); | |
| 372 | let projection = source.project().unwrap(); | |
| 373 | assert_eq!(projection.text().text(), "Ae\u{301}"); | |
| 374 | assert_eq!( | |
| 375 | projection.source_boundaries().collect::<Vec<_>>(), | |
| 376 | [(0, 0), (1, 3), (2, 4), (4, 6)] | |
| 377 | ); | |
| 378 | for (visible, upstream, downstream) in [(0, 0, 0), (1, 1, 3), (2, 4, 4), (3, 5, 6)] { | |
| 379 | assert_eq!( | |
| 380 | projection.source_offset(visible, Affinity::Upstream), | |
| 381 | Ok(upstream) | |
| 382 | ); | |
| 383 | assert_eq!( | |
| 384 | projection.source_offset(visible, Affinity::Downstream), | |
| 385 | Ok(downstream) | |
| 386 | ); | |
| 387 | } | |
| 388 | for (source, visible) in [(0, 0), (1, 1), (2, 1), (3, 1), (4, 2), (5, 3), (6, 3)] { | |
| 389 | assert_eq!(projection.visible_offset(source), Ok(visible)); | |
| 390 | } | |
| 391 | assert_eq!(source, original); | |
| 392 | } | |
| 393 | ||
| 394 | #[test] | |
| 395 | fn projection_validates_visible_surrogates_and_preserves_empty_field_boundaries() { | |
| 396 | let visible = Paragraph::new("a🌳z".into(), regular()).project().unwrap(); | |
| 397 | assert_eq!( | |
| 398 | visible.source_offset(2, Affinity::Downstream), | |
| 399 | Err(EditError::InvalidRange) | |
| 400 | ); | |
| 401 | assert_eq!(visible.visible_offset(2), Err(EditError::InvalidRange)); | |
| 402 | let hidden = Paragraph::new( | |
| 403 | "🌳".into(), | |
| 404 | Format { | |
| 405 | hidden: Some(true), | |
| 406 | ..regular() | |
| 407 | }, | |
| 408 | ) | |
| 409 | .project() | |
| 410 | .unwrap(); | |
| 411 | assert!(hidden.text().text().is_empty()); | |
| 412 | assert_eq!(hidden.source_boundaries().collect::<Vec<_>>(), [(0, 2)]); | |
| 413 | assert_eq!( | |
| 414 | visible.source_boundaries().collect::<Vec<_>>(), | |
| 415 | [(0, 0), (1, 1), (5, 3), (6, 4)] | |
| 416 | ); | |
| 417 | assert_eq!(hidden.source_offset(0, Affinity::Upstream), Ok(0)); | |
| 418 | assert_eq!(hidden.source_offset(0, Affinity::Downstream), Ok(2)); | |
| 419 | assert_eq!(hidden.visible_offset(1), Ok(0)); | |
| 420 | assert_eq!(hidden.visible_offset(3), Err(EditError::InvalidRange)); | |
| 421 | } | |
| 422 | ||
| 423 | #[test] | |
| 424 | fn offsets_distinguish_bytes_utf16_and_scalars() { | |
| 425 | let text = Paragraph::new("a🌳e\u{301}".into(), regular()); | |
| 426 | for (utf16, byte) in [(0, 0), (1, 1), (3, 5), (4, 6), (5, 8)] { | |
| 427 | assert_eq!(text.byte_offset(utf16), Ok(byte)); | |
| 428 | assert_eq!(text.utf16_offset(byte), Ok(utf16)); | |
| 429 | } | |
| 430 | assert_eq!(text.byte_offset(2), Err(EditError::InvalidRange)); | |
| 431 | assert_eq!(text.utf16_offset(2), Err(EditError::InvalidRange)); | |
| 432 | assert_eq!(text.byte_offset(6), Err(EditError::InvalidRange)); | |
| 433 | assert_eq!(text.utf16_offset(9), Err(EditError::InvalidRange)); | |
| 434 | } | |
| 435 | ||
| 436 | #[test] | |
| 437 | fn replacement_preserves_styles_outside_selection() { | |
| 438 | let mut text = Paragraph::from_runs([ | |
| 439 | ("plain ".into(), regular()), | |
| 440 | ("bold".into(), bold()), | |
| 441 | (" end".into(), regular()), | |
| 442 | ]); | |
| 443 | let original = text.clone(); | |
| 444 | let edit = Edit { | |
| 445 | range: 4..8, | |
| 446 | replacement: Paragraph::new("🌳".into(), bold()), | |
| 447 | }; | |
| 448 | let undo = text.apply(edit).unwrap(); | |
| 449 | assert_eq!(text.text(), "plai🌳ld end"); | |
| 450 | assert_eq!( | |
| 451 | text.spans() | |
| 452 | .iter() | |
| 453 | .map(|s| (s.end, s.format.bold)) | |
| 454 | .collect::<Vec<_>>(), | |
| 455 | [(4, None), (10, Some(true)), (14, None)] | |
| 456 | ); | |
| 457 | let after = text.clone(); | |
| 458 | let redo = text.apply(undo).unwrap(); | |
| 459 | assert_eq!(text, original); | |
| 460 | text.apply(redo).unwrap(); | |
| 461 | assert_eq!(text, after); | |
| 462 | } | |
| 463 | ||
| 464 | #[test] | |
| 465 | fn invalid_edits_leave_text_and_styles_unchanged() { | |
| 466 | let original = Paragraph::new("a🌳b".into(), regular()); | |
| 467 | for (start, end) in [(2, 3), (0, 2), (3, 2), (0, 5)] { | |
| 468 | let mut text = original.clone(); | |
| 469 | assert_eq!( | |
| 470 | text.apply(Edit { | |
| 471 | range: start..end, | |
| 472 | replacement: Paragraph::new("x".into(), bold()) | |
| 473 | }), | |
| 474 | Err(EditError::InvalidRange) | |
| 475 | ); | |
| 476 | assert_eq!(text, original); | |
| 477 | } | |
| 478 | } | |
| 479 | ||
| 480 | #[test] | |
| 481 | fn undo_restores_empty_paragraph_format() { | |
| 482 | let mut text = Paragraph::new(String::new(), regular()); | |
| 483 | let original = text.clone(); | |
| 484 | let undo = text | |
| 485 | .apply(Edit { | |
| 486 | range: 0..0, | |
| 487 | replacement: Paragraph::new("bold".into(), bold()), | |
| 488 | }) | |
| 489 | .unwrap(); | |
| 490 | text.apply(undo).unwrap(); | |
| 491 | assert_eq!(text, original); | |
| 492 | } | |
| 493 | ||
| 494 | #[test] | |
| 495 | fn every_scalar_range_round_trips_styled_unicode_edits() { | |
| 496 | let original = Paragraph::from_runs([ | |
| 497 | ("a🌳".into(), regular()), | |
| 498 | ("e\u{301}שלום".into(), bold()), | |
| 499 | ("Z".into(), regular()), | |
| 500 | ]); | |
| 501 | let boundaries: Vec<_> = original | |
| 502 | .text() | |
| 503 | .char_indices() | |
| 504 | .map(|(n, _)| n) | |
| 505 | .chain([original.text().len()]) | |
| 506 | .collect(); | |
| 507 | for &start in &boundaries { | |
| 508 | for &end in boundaries.iter().filter(|&&end| end >= start) { | |
| 509 | let range = | |
| 510 | original.utf16_offset(start).unwrap()..original.utf16_offset(end).unwrap(); | |
| 511 | for replacement in ["", "👩👩👧👦", "xyz", "\u{301}"] { | |
| 512 | let mut edited = original.clone(); | |
| 513 | let undo = edited | |
| 514 | .apply(Edit { | |
| 515 | range: range.clone(), | |
| 516 | replacement: Paragraph::new(replacement.into(), bold()), | |
| 517 | }) | |
| 518 | .unwrap(); | |
| 519 | assert_eq!( | |
| 520 | edited.text(), | |
| 521 | format!( | |
| 522 | "{}{replacement}{}", | |
| 523 | &original.text()[..start], | |
| 524 | &original.text()[end..] | |
| 525 | ) | |
| 526 | ); | |
| 527 | let after = edited.clone(); | |
| 528 | let redo = edited.apply(undo).unwrap(); | |
| 529 | assert_eq!(edited, original); | |
| 530 | edited.apply(redo).unwrap(); | |
| 531 | assert_eq!(edited, after); | |
| 532 | } | |
| 533 | } | |
| 534 | let at = original.utf16_offset(start).unwrap(); | |
| 535 | let mut left = original.slice(0..at).unwrap(); | |
| 536 | let right = original | |
| 537 | .slice(at..original.utf16_offset(original.text.len()).unwrap()) | |
| 538 | .unwrap(); | |
| 539 | left.append(right).unwrap(); | |
| 540 | assert_eq!(left, original); | |
| 541 | } | |
| 542 | } | |
| 543 | } |
crates/onestore-offline/src/outline.rs+1-1| ... | ... | @@ -47,7 +47,7 @@ fn observe( |
| 47 | 47 | vec![ |
| 48 | 48 | json!(node.layout.max_width), |
| 49 | 49 | json!(node.layout.width_set_by_user.unwrap_or(false)), |
| 50 | json!(node.extra[0].iter().find(|field| field.id == 0x14001cdb)), | |
| 50 | json!(node.layout.reserved_width), | |
| 51 | 51 | ] |
| 52 | 52 | } |
| 53 | 53 | (onestore::OutlineEdit::Collapsed(_), Kind::Paragraph { collapse_state, .. }) => { |
crates/onestore-offline/tests/sync/outline.rs+2-14| ... | ... | @@ -685,13 +685,7 @@ fn a_new_native_wrap_reservation_requires_review_before_width_replacement() { |
| 685 | 685 | old["layout"]["width_set_by_user"], |
| 686 | 686 | remote["layout"]["width_set_by_user"] |
| 687 | 687 | ); |
| 688 | assert!( | |
| 689 | remote["extra"][0] | |
| 690 | .as_array() | |
| 691 | .unwrap() | |
| 692 | .iter() | |
| 693 | .any(|field| field["id"] == 0x14001cdb_u32) | |
| 694 | ); | |
| 688 | assert!(remote["layout"]["reserved_width"].is_number()); | |
| 695 | 689 | let directory = tempfile::tempdir().unwrap(); |
| 696 | 690 | let cache = Replica::create(directory.path().join("cache.sqlite"), source).unwrap(); |
| 697 | 691 | let id = cache |
| ... | ... | @@ -721,11 +715,5 @@ fn a_new_native_wrap_reservation_requires_review_before_width_replacement() { |
| 721 | 715 | assert_eq!(after["layout"]["x"], remote["layout"]["x"]); |
| 722 | 716 | assert_eq!(after["layout"]["y"], remote["layout"]["y"]); |
| 723 | 717 | assert_eq!(after["layout"]["max_width"], 144.0); |
| 724 | assert!( | |
| 725 | !after["extra"][0] | |
| 726 | .as_array() | |
| 727 | .unwrap() | |
| 728 | .iter() | |
| 729 | .any(|field| field["id"] == 0x14001cdb_u32) | |
| 730 | ); | |
| 718 | assert!(after["layout"]["reserved_width"].is_null()); | |
| 731 | 719 | } |
crates/onestore/src/document.rs+4| ... | ... | @@ -81,6 +81,7 @@ pub struct Layout { |
| 81 | 81 | /// An explicit user width when true; false or absent leaves an automatic layout hint. |
| 82 | 82 | pub width_set_by_user: Option<bool>, |
| 83 | 83 | pub max_height: Option<f32>, |
| 84 | pub reserved_width: Option<f32>, | |
| 84 | 85 | } |
| 85 | 86 | |
| 86 | 87 | macro_rules! format_fields { |
| ... | ... | @@ -119,6 +120,7 @@ format_fields! { |
| 119 | 120 | space_before: f32, |
| 120 | 121 | space_after: f32, |
| 121 | 122 | line_spacing: f32, |
| 123 | list_spacing: f32, | |
| 122 | 124 | } |
| 123 | 125 | |
| 124 | 126 | #[derive(Debug, Serialize)] |
| ... | ... | @@ -876,6 +878,7 @@ impl<'a> Element<'a> { |
| 876 | 878 | max_width: f.float(0x14001c1b, 36.0)?, |
| 877 | 879 | width_set_by_user: f.boolean(0x08001cbd)?, |
| 878 | 880 | max_height: f.float(0x14001c1c, 36.0)?, |
| 881 | reserved_width: f.float(0x14001cdb, 36.0)?, | |
| 879 | 882 | }; |
| 880 | 883 | let format = Format { |
| 881 | 884 | bold: f.boolean(0x08001c04)?, |
| ... | ... | @@ -899,6 +902,7 @@ impl<'a> Element<'a> { |
| 899 | 902 | space_before: f.float(0x1400342e, 36.0)?, |
| 900 | 903 | space_after: f.float(0x1400342f, 36.0)?, |
| 901 | 904 | line_spacing: f.float(0x14003430, 36.0)?, |
| 905 | list_spacing: f.float(0x14001ccb, 36.0)?, | |
| 902 | 906 | }; |
| 903 | 907 | let mut tags = Vec::new(); |
| 904 | 908 | if let Some(property) = f.take(0x40003489) { |
crates/onestore/tests/outline.rs+2-10| ... | ... | @@ -227,11 +227,7 @@ fn resizing_a_native_reserved_width_preserves_content() { |
| 227 | 227 | .unwrap(); |
| 228 | 228 | let space = &document.spaces[&sid]; |
| 229 | 229 | let before = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 230 | assert!( | |
| 231 | before.nodes[&object].extra[0] | |
| 232 | .iter() | |
| 233 | .any(|p| p.id == 0x14001cdb) | |
| 234 | ); | |
| 230 | assert!(before.nodes[&object].layout.reserved_width.is_some()); | |
| 235 | 231 | let edit = PreparedEdit::outline( |
| 236 | 232 | source, |
| 237 | 233 | sid, |
| ... | ... | @@ -247,11 +243,7 @@ fn resizing_a_native_reserved_width_preserves_content() { |
| 247 | 243 | let after_document = Document::parse(&after_index).unwrap(); |
| 248 | 244 | let space = &after_document.spaces[&sid]; |
| 249 | 245 | let after = &space.revisions[&space.contexts[&ExGuid::default()]]; |
| 250 | assert!( | |
| 251 | !after.nodes[&object].extra[0] | |
| 252 | .iter() | |
| 253 | .any(|p| p.id == 0x14001cdb) | |
| 254 | ); | |
| 246 | assert_eq!(after.nodes[&object].layout.reserved_width, None); | |
| 255 | 247 | assert_eq!(after.nodes[&object].layout.max_width, Some(144.0)); |
| 256 | 248 | for (id, node) in &before.nodes { |
| 257 | 249 | assert_eq!(node.children, after.nodes[id].children); |
fuzz/Cargo.lock+622-3| ... | ... | @@ -57,6 +57,26 @@ version = "3.20.3" |
| 57 | 57 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 58 | 58 | checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" |
| 59 | 59 | |
| 60 | [[package]] | |
| 61 | name = "bytemuck" | |
| 62 | version = "1.25.2" | |
| 63 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 64 | checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" | |
| 65 | dependencies = [ | |
| 66 | "bytemuck_derive", | |
| 67 | ] | |
| 68 | ||
| 69 | [[package]] | |
| 70 | name = "bytemuck_derive" | |
| 71 | version = "1.12.0" | |
| 72 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 73 | checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" | |
| 74 | dependencies = [ | |
| 75 | "proc-macro2", | |
| 76 | "quote", | |
| 77 | "syn 3.0.5", | |
| 78 | ] | |
| 79 | ||
| 60 | 80 | [[package]] |
| 61 | 81 | name = "cbc" |
| 62 | 82 | version = "0.2.1" |
| ... | ... | @@ -144,6 +164,26 @@ dependencies = [ |
| 144 | 164 | "zeroize", |
| 145 | 165 | ] |
| 146 | 166 | |
| 167 | [[package]] | |
| 168 | name = "displaydoc" | |
| 169 | version = "0.2.7" | |
| 170 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 171 | checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" | |
| 172 | dependencies = [ | |
| 173 | "proc-macro2", | |
| 174 | "quote", | |
| 175 | "syn 3.0.5", | |
| 176 | ] | |
| 177 | ||
| 178 | [[package]] | |
| 179 | name = "dlib" | |
| 180 | version = "0.5.3" | |
| 181 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 182 | checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" | |
| 183 | dependencies = [ | |
| 184 | "libloading", | |
| 185 | ] | |
| 186 | ||
| 147 | 187 | [[package]] |
| 148 | 188 | name = "errno" |
| 149 | 189 | version = "0.3.14" |
| ... | ... | @@ -184,6 +224,36 @@ version = "0.2.0" |
| 184 | 224 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 185 | 225 | checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" |
| 186 | 226 | |
| 227 | [[package]] | |
| 228 | name = "font-types" | |
| 229 | version = "0.12.5" | |
| 230 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 231 | checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" | |
| 232 | dependencies = [ | |
| 233 | "bytemuck", | |
| 234 | ] | |
| 235 | ||
| 236 | [[package]] | |
| 237 | name = "fontique" | |
| 238 | version = "0.11.0" | |
| 239 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | |
| 240 | dependencies = [ | |
| 241 | "hashbrown 0.17.1", | |
| 242 | "linebender_resource_handle", | |
| 243 | "memmap2", | |
| 244 | "objc2", | |
| 245 | "objc2-core-foundation", | |
| 246 | "objc2-core-text", | |
| 247 | "objc2-foundation", | |
| 248 | "parlance", | |
| 249 | "read-fonts", | |
| 250 | "roxmltree", | |
| 251 | "smallvec", | |
| 252 | "windows", | |
| 253 | "windows-core", | |
| 254 | "yeslogic-fontconfig-sys", | |
| 255 | ] | |
| 256 | ||
| 187 | 257 | [[package]] |
| 188 | 258 | name = "getrandom" |
| 189 | 259 | version = "0.4.3" |
| ... | ... | @@ -195,6 +265,18 @@ dependencies = [ |
| 195 | 265 | "r-efi", |
| 196 | 266 | ] |
| 197 | 267 | |
| 268 | [[package]] | |
| 269 | name = "harfrust" | |
| 270 | version = "0.12.0" | |
| 271 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 272 | checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" | |
| 273 | dependencies = [ | |
| 274 | "bitflags", | |
| 275 | "bytemuck", | |
| 276 | "read-fonts", | |
| 277 | "smallvec", | |
| 278 | ] | |
| 279 | ||
| 198 | 280 | [[package]] |
| 199 | 281 | name = "hashbrown" |
| 200 | 282 | version = "0.16.1" |
| ... | ... | @@ -232,6 +314,134 @@ dependencies = [ |
| 232 | 314 | "zeroize", |
| 233 | 315 | ] |
| 234 | 316 | |
| 317 | [[package]] | |
| 318 | name = "icu_collections" | |
| 319 | version = "2.3.0" | |
| 320 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 321 | checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" | |
| 322 | dependencies = [ | |
| 323 | "displaydoc", | |
| 324 | "potential_utf", | |
| 325 | "utf8_iter", | |
| 326 | "yoke", | |
| 327 | "zerofrom", | |
| 328 | "zerovec", | |
| 329 | ] | |
| 330 | ||
| 331 | [[package]] | |
| 332 | name = "icu_locale_core" | |
| 333 | version = "2.3.0" | |
| 334 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 335 | checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" | |
| 336 | dependencies = [ | |
| 337 | "displaydoc", | |
| 338 | "litemap", | |
| 339 | "serde", | |
| 340 | "tinystr", | |
| 341 | "writeable", | |
| 342 | "zerovec", | |
| 343 | ] | |
| 344 | ||
| 345 | [[package]] | |
| 346 | name = "icu_locale_fallback" | |
| 347 | version = "2.3.0" | |
| 348 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 349 | checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" | |
| 350 | dependencies = [ | |
| 351 | "icu_locale_core", | |
| 352 | "icu_locale_fallback_data", | |
| 353 | "icu_provider", | |
| 354 | "potential_utf", | |
| 355 | "tinystr", | |
| 356 | "zerovec", | |
| 357 | ] | |
| 358 | ||
| 359 | [[package]] | |
| 360 | name = "icu_locale_fallback_data" | |
| 361 | version = "2.3.0" | |
| 362 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 363 | checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" | |
| 364 | ||
| 365 | [[package]] | |
| 366 | name = "icu_normalizer" | |
| 367 | version = "2.3.0" | |
| 368 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 369 | checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" | |
| 370 | dependencies = [ | |
| 371 | "icu_collections", | |
| 372 | "icu_normalizer_data", | |
| 373 | "icu_properties", | |
| 374 | "icu_provider", | |
| 375 | "smallvec", | |
| 376 | "zerovec", | |
| 377 | ] | |
| 378 | ||
| 379 | [[package]] | |
| 380 | name = "icu_normalizer_data" | |
| 381 | version = "2.3.0" | |
| 382 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 383 | checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" | |
| 384 | ||
| 385 | [[package]] | |
| 386 | name = "icu_properties" | |
| 387 | version = "2.3.0" | |
| 388 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 389 | checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" | |
| 390 | dependencies = [ | |
| 391 | "displaydoc", | |
| 392 | "icu_collections", | |
| 393 | "icu_locale_core", | |
| 394 | "icu_properties_data", | |
| 395 | "icu_provider", | |
| 396 | "zerotrie", | |
| 397 | "zerovec", | |
| 398 | ] | |
| 399 | ||
| 400 | [[package]] | |
| 401 | name = "icu_properties_data" | |
| 402 | version = "2.3.0" | |
| 403 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 404 | checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" | |
| 405 | ||
| 406 | [[package]] | |
| 407 | name = "icu_provider" | |
| 408 | version = "2.3.1" | |
| 409 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 410 | checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" | |
| 411 | dependencies = [ | |
| 412 | "displaydoc", | |
| 413 | "icu_locale_core", | |
| 414 | "serde", | |
| 415 | "stable_deref_trait", | |
| 416 | "writeable", | |
| 417 | "yoke", | |
| 418 | "zerofrom", | |
| 419 | "zerotrie", | |
| 420 | "zerovec", | |
| 421 | ] | |
| 422 | ||
| 423 | [[package]] | |
| 424 | name = "icu_segmenter" | |
| 425 | version = "2.3.0" | |
| 426 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 427 | checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" | |
| 428 | dependencies = [ | |
| 429 | "icu_collections", | |
| 430 | "icu_locale_fallback", | |
| 431 | "icu_provider", | |
| 432 | "icu_segmenter_data", | |
| 433 | "potential_utf", | |
| 434 | "smallvec", | |
| 435 | "utf8_iter", | |
| 436 | "zerovec", | |
| 437 | ] | |
| 438 | ||
| 439 | [[package]] | |
| 440 | name = "icu_segmenter_data" | |
| 441 | version = "2.3.0" | |
| 442 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 443 | checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" | |
| 444 | ||
| 235 | 445 | [[package]] |
| 236 | 446 | name = "inout" |
| 237 | 447 | version = "0.2.2" |
| ... | ... | @@ -284,6 +494,16 @@ dependencies = [ |
| 284 | 494 | "cc", |
| 285 | 495 | ] |
| 286 | 496 | |
| 497 | [[package]] | |
| 498 | name = "libloading" | |
| 499 | version = "0.8.9" | |
| 500 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 501 | checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" | |
| 502 | dependencies = [ | |
| 503 | "cfg-if", | |
| 504 | "windows-link", | |
| 505 | ] | |
| 506 | ||
| 287 | 507 | [[package]] |
| 288 | 508 | name = "libsqlite3-sys" |
| 289 | 509 | version = "0.38.2" |
| ... | ... | @@ -295,12 +515,24 @@ dependencies = [ |
| 295 | 515 | "vcpkg", |
| 296 | 516 | ] |
| 297 | 517 | |
| 518 | [[package]] | |
| 519 | name = "linebender_resource_handle" | |
| 520 | version = "0.1.1" | |
| 521 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 522 | checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" | |
| 523 | ||
| 298 | 524 | [[package]] |
| 299 | 525 | name = "linux-raw-sys" |
| 300 | 526 | version = "0.12.1" |
| 301 | 527 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 302 | 528 | checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" |
| 303 | 529 | |
| 530 | [[package]] | |
| 531 | name = "litemap" | |
| 532 | version = "0.8.3" | |
| 533 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 534 | checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" | |
| 535 | ||
| 304 | 536 | [[package]] |
| 305 | 537 | name = "md5" |
| 306 | 538 | version = "0.8.1" |
| ... | ... | @@ -313,6 +545,15 @@ version = "2.8.3" |
| 313 | 545 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 314 | 546 | checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" |
| 315 | 547 | |
| 548 | [[package]] | |
| 549 | name = "memmap2" | |
| 550 | version = "0.9.11" | |
| 551 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 552 | checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" | |
| 553 | dependencies = [ | |
| 554 | "libc", | |
| 555 | ] | |
| 556 | ||
| 316 | 557 | [[package]] |
| 317 | 558 | name = "nix" |
| 318 | 559 | version = "0.31.3" |
| ... | ... | @@ -325,12 +566,68 @@ dependencies = [ |
| 325 | 566 | "libc", |
| 326 | 567 | ] |
| 327 | 568 | |
| 569 | [[package]] | |
| 570 | name = "objc2" | |
| 571 | version = "0.6.4" | |
| 572 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 573 | checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" | |
| 574 | dependencies = [ | |
| 575 | "objc2-encode", | |
| 576 | ] | |
| 577 | ||
| 578 | [[package]] | |
| 579 | name = "objc2-core-foundation" | |
| 580 | version = "0.3.2" | |
| 581 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 582 | checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" | |
| 583 | dependencies = [ | |
| 584 | "bitflags", | |
| 585 | ] | |
| 586 | ||
| 587 | [[package]] | |
| 588 | name = "objc2-core-text" | |
| 589 | version = "0.3.2" | |
| 590 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 591 | checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" | |
| 592 | dependencies = [ | |
| 593 | "bitflags", | |
| 594 | "objc2-core-foundation", | |
| 595 | ] | |
| 596 | ||
| 597 | [[package]] | |
| 598 | name = "objc2-encode" | |
| 599 | version = "4.1.0" | |
| 600 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 601 | checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" | |
| 602 | ||
| 603 | [[package]] | |
| 604 | name = "objc2-foundation" | |
| 605 | version = "0.3.2" | |
| 606 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 607 | checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" | |
| 608 | dependencies = [ | |
| 609 | "bitflags", | |
| 610 | "objc2", | |
| 611 | ] | |
| 612 | ||
| 328 | 613 | [[package]] |
| 329 | 614 | name = "once_cell" |
| 330 | 615 | version = "1.21.4" |
| 331 | 616 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 332 | 617 | checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" |
| 333 | 618 | |
| 619 | [[package]] | |
| 620 | name = "one-canvas" | |
| 621 | version = "0.1.0" | |
| 622 | dependencies = [ | |
| 623 | "getrandom", | |
| 624 | "onestore", | |
| 625 | "parley", | |
| 626 | "serde", | |
| 627 | "serde_json", | |
| 628 | "skrifa", | |
| 629 | ] | |
| 630 | ||
| 334 | 631 | [[package]] |
| 335 | 632 | name = "onestore" |
| 336 | 633 | version = "0.1.0" |
| ... | ... | @@ -354,6 +651,7 @@ version = "0.0.0" |
| 354 | 651 | dependencies = [ |
| 355 | 652 | "libfuzzer-sys", |
| 356 | 653 | "md5", |
| 654 | "one-canvas", | |
| 357 | 655 | "onestore", |
| 358 | 656 | "onestore-offline", |
| 359 | 657 | "serde_json", |
| ... | ... | @@ -383,12 +681,67 @@ dependencies = [ |
| 383 | 681 | "thiserror", |
| 384 | 682 | ] |
| 385 | 683 | |
| 684 | [[package]] | |
| 685 | name = "parlance" | |
| 686 | version = "0.1.0" | |
| 687 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | |
| 688 | ||
| 689 | [[package]] | |
| 690 | name = "parley" | |
| 691 | version = "0.11.0" | |
| 692 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | |
| 693 | dependencies = [ | |
| 694 | "fontique", | |
| 695 | "hashbrown 0.17.1", | |
| 696 | "linebender_resource_handle", | |
| 697 | "parlance", | |
| 698 | "parley_engine", | |
| 699 | "skrifa", | |
| 700 | "smallvec", | |
| 701 | ] | |
| 702 | ||
| 703 | [[package]] | |
| 704 | name = "parley_data" | |
| 705 | version = "0.11.0" | |
| 706 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | |
| 707 | dependencies = [ | |
| 708 | "icu_properties", | |
| 709 | ] | |
| 710 | ||
| 711 | [[package]] | |
| 712 | name = "parley_engine" | |
| 713 | version = "0.11.0" | |
| 714 | source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759" | |
| 715 | dependencies = [ | |
| 716 | "fontique", | |
| 717 | "harfrust", | |
| 718 | "hashbrown 0.17.1", | |
| 719 | "icu_normalizer", | |
| 720 | "icu_properties", | |
| 721 | "icu_segmenter", | |
| 722 | "linebender_resource_handle", | |
| 723 | "parlance", | |
| 724 | "parley_data", | |
| 725 | "skrifa", | |
| 726 | ] | |
| 727 | ||
| 386 | 728 | [[package]] |
| 387 | 729 | name = "pkg-config" |
| 388 | 730 | version = "0.3.34" |
| 389 | 731 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 390 | 732 | checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" |
| 391 | 733 | |
| 734 | [[package]] | |
| 735 | name = "potential_utf" | |
| 736 | version = "0.1.6" | |
| 737 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 738 | checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" | |
| 739 | dependencies = [ | |
| 740 | "serde_core", | |
| 741 | "writeable", | |
| 742 | "zerovec", | |
| 743 | ] | |
| 744 | ||
| 392 | 745 | [[package]] |
| 393 | 746 | name = "proc-macro2" |
| 394 | 747 | version = "1.0.107" |
| ... | ... | @@ -413,6 +766,17 @@ version = "6.0.0" |
| 413 | 766 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 414 | 767 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" |
| 415 | 768 | |
| 769 | [[package]] | |
| 770 | name = "read-fonts" | |
| 771 | version = "0.41.0" | |
| 772 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 773 | checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" | |
| 774 | dependencies = [ | |
| 775 | "bytemuck", | |
| 776 | "font-types", | |
| 777 | "once_cell", | |
| 778 | ] | |
| 779 | ||
| 416 | 780 | [[package]] |
| 417 | 781 | name = "roxmltree" |
| 418 | 782 | version = "0.21.1" |
| ... | ... | @@ -493,7 +857,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" |
| 493 | 857 | dependencies = [ |
| 494 | 858 | "proc-macro2", |
| 495 | 859 | "quote", |
| 496 | "syn", | |
| 860 | "syn 3.0.5", | |
| 497 | 861 | ] |
| 498 | 862 | |
| 499 | 863 | [[package]] |
| ... | ... | @@ -537,6 +901,16 @@ version = "2.0.1" |
| 537 | 901 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 538 | 902 | checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" |
| 539 | 903 | |
| 904 | [[package]] | |
| 905 | name = "skrifa" | |
| 906 | version = "0.44.0" | |
| 907 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 908 | checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" | |
| 909 | dependencies = [ | |
| 910 | "bytemuck", | |
| 911 | "read-fonts", | |
| 912 | ] | |
| 913 | ||
| 540 | 914 | [[package]] |
| 541 | 915 | name = "smallvec" |
| 542 | 916 | version = "1.16.0" |
| ... | ... | @@ -555,12 +929,29 @@ dependencies = [ |
| 555 | 929 | "wasm-bindgen", |
| 556 | 930 | ] |
| 557 | 931 | |
| 932 | [[package]] | |
| 933 | name = "stable_deref_trait" | |
| 934 | version = "1.2.1" | |
| 935 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 936 | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" | |
| 937 | ||
| 558 | 938 | [[package]] |
| 559 | 939 | name = "subtle" |
| 560 | 940 | version = "2.6.1" |
| 561 | 941 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 562 | 942 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" |
| 563 | 943 | |
| 944 | [[package]] | |
| 945 | name = "syn" | |
| 946 | version = "2.0.119" | |
| 947 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 948 | checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" | |
| 949 | dependencies = [ | |
| 950 | "proc-macro2", | |
| 951 | "quote", | |
| 952 | "unicode-ident", | |
| 953 | ] | |
| 954 | ||
| 564 | 955 | [[package]] |
| 565 | 956 | name = "syn" |
| 566 | 957 | version = "3.0.5" |
| ... | ... | @@ -572,6 +963,17 @@ dependencies = [ |
| 572 | 963 | "unicode-ident", |
| 573 | 964 | ] |
| 574 | 965 | |
| 966 | [[package]] | |
| 967 | name = "synstructure" | |
| 968 | version = "0.13.2" | |
| 969 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 970 | checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" | |
| 971 | dependencies = [ | |
| 972 | "proc-macro2", | |
| 973 | "quote", | |
| 974 | "syn 2.0.119", | |
| 975 | ] | |
| 976 | ||
| 575 | 977 | [[package]] |
| 576 | 978 | name = "tempfile" |
| 577 | 979 | version = "3.27.0" |
| ... | ... | @@ -602,7 +1004,18 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" |
| 602 | 1004 | dependencies = [ |
| 603 | 1005 | "proc-macro2", |
| 604 | 1006 | "quote", |
| 605 | "syn", | |
| 1007 | "syn 3.0.5", | |
| 1008 | ] | |
| 1009 | ||
| 1010 | [[package]] | |
| 1011 | name = "tinystr" | |
| 1012 | version = "0.8.4" | |
| 1013 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1014 | checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" | |
| 1015 | dependencies = [ | |
| 1016 | "displaydoc", | |
| 1017 | "serde_core", | |
| 1018 | "zerovec", | |
| 606 | 1019 | ] |
| 607 | 1020 | |
| 608 | 1021 | [[package]] |
| ... | ... | @@ -617,6 +1030,12 @@ version = "1.0.24" |
| 617 | 1030 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 618 | 1031 | checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" |
| 619 | 1032 | |
| 1033 | [[package]] | |
| 1034 | name = "utf8_iter" | |
| 1035 | version = "1.0.4" | |
| 1036 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1037 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | |
| 1038 | ||
| 620 | 1039 | [[package]] |
| 621 | 1040 | name = "vcpkg" |
| 622 | 1041 | version = "0.2.15" |
| ... | ... | @@ -655,7 +1074,7 @@ dependencies = [ |
| 655 | 1074 | "bumpalo", |
| 656 | 1075 | "proc-macro2", |
| 657 | 1076 | "quote", |
| 658 | "syn", | |
| 1077 | "syn 3.0.5", | |
| 659 | 1078 | "wasm-bindgen-shared", |
| 660 | 1079 | ] |
| 661 | 1080 | |
| ... | ... | @@ -668,12 +1087,107 @@ dependencies = [ |
| 668 | 1087 | "unicode-ident", |
| 669 | 1088 | ] |
| 670 | 1089 | |
| 1090 | [[package]] | |
| 1091 | name = "windows" | |
| 1092 | version = "0.62.2" | |
| 1093 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1094 | checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" | |
| 1095 | dependencies = [ | |
| 1096 | "windows-collections", | |
| 1097 | "windows-core", | |
| 1098 | "windows-future", | |
| 1099 | "windows-numerics", | |
| 1100 | ] | |
| 1101 | ||
| 1102 | [[package]] | |
| 1103 | name = "windows-collections" | |
| 1104 | version = "0.3.2" | |
| 1105 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1106 | checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" | |
| 1107 | dependencies = [ | |
| 1108 | "windows-core", | |
| 1109 | ] | |
| 1110 | ||
| 1111 | [[package]] | |
| 1112 | name = "windows-core" | |
| 1113 | version = "0.62.2" | |
| 1114 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1115 | checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" | |
| 1116 | dependencies = [ | |
| 1117 | "windows-implement", | |
| 1118 | "windows-interface", | |
| 1119 | "windows-link", | |
| 1120 | "windows-result", | |
| 1121 | "windows-strings", | |
| 1122 | ] | |
| 1123 | ||
| 1124 | [[package]] | |
| 1125 | name = "windows-future" | |
| 1126 | version = "0.3.2" | |
| 1127 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1128 | checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" | |
| 1129 | dependencies = [ | |
| 1130 | "windows-core", | |
| 1131 | "windows-link", | |
| 1132 | "windows-threading", | |
| 1133 | ] | |
| 1134 | ||
| 1135 | [[package]] | |
| 1136 | name = "windows-implement" | |
| 1137 | version = "0.60.2" | |
| 1138 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1139 | checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" | |
| 1140 | dependencies = [ | |
| 1141 | "proc-macro2", | |
| 1142 | "quote", | |
| 1143 | "syn 2.0.119", | |
| 1144 | ] | |
| 1145 | ||
| 1146 | [[package]] | |
| 1147 | name = "windows-interface" | |
| 1148 | version = "0.59.3" | |
| 1149 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1150 | checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" | |
| 1151 | dependencies = [ | |
| 1152 | "proc-macro2", | |
| 1153 | "quote", | |
| 1154 | "syn 2.0.119", | |
| 1155 | ] | |
| 1156 | ||
| 671 | 1157 | [[package]] |
| 672 | 1158 | name = "windows-link" |
| 673 | 1159 | version = "0.2.1" |
| 674 | 1160 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 675 | 1161 | checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" |
| 676 | 1162 | |
| 1163 | [[package]] | |
| 1164 | name = "windows-numerics" | |
| 1165 | version = "0.3.1" | |
| 1166 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1167 | checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" | |
| 1168 | dependencies = [ | |
| 1169 | "windows-core", | |
| 1170 | "windows-link", | |
| 1171 | ] | |
| 1172 | ||
| 1173 | [[package]] | |
| 1174 | name = "windows-result" | |
| 1175 | version = "0.4.1" | |
| 1176 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1177 | checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" | |
| 1178 | dependencies = [ | |
| 1179 | "windows-link", | |
| 1180 | ] | |
| 1181 | ||
| 1182 | [[package]] | |
| 1183 | name = "windows-strings" | |
| 1184 | version = "0.5.1" | |
| 1185 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1186 | checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" | |
| 1187 | dependencies = [ | |
| 1188 | "windows-link", | |
| 1189 | ] | |
| 1190 | ||
| 677 | 1191 | [[package]] |
| 678 | 1192 | name = "windows-sys" |
| 679 | 1193 | version = "0.61.2" |
| ... | ... | @@ -683,12 +1197,117 @@ dependencies = [ |
| 683 | 1197 | "windows-link", |
| 684 | 1198 | ] |
| 685 | 1199 | |
| 1200 | [[package]] | |
| 1201 | name = "windows-threading" | |
| 1202 | version = "0.2.1" | |
| 1203 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1204 | checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" | |
| 1205 | dependencies = [ | |
| 1206 | "windows-link", | |
| 1207 | ] | |
| 1208 | ||
| 1209 | [[package]] | |
| 1210 | name = "writeable" | |
| 1211 | version = "0.6.4" | |
| 1212 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1213 | checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" | |
| 1214 | ||
| 1215 | [[package]] | |
| 1216 | name = "yeslogic-fontconfig-sys" | |
| 1217 | version = "6.0.1" | |
| 1218 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1219 | checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" | |
| 1220 | dependencies = [ | |
| 1221 | "dlib", | |
| 1222 | "once_cell", | |
| 1223 | "pkg-config", | |
| 1224 | ] | |
| 1225 | ||
| 1226 | [[package]] | |
| 1227 | name = "yoke" | |
| 1228 | version = "0.8.3" | |
| 1229 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1230 | checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" | |
| 1231 | dependencies = [ | |
| 1232 | "stable_deref_trait", | |
| 1233 | "yoke-derive", | |
| 1234 | "zerofrom", | |
| 1235 | ] | |
| 1236 | ||
| 1237 | [[package]] | |
| 1238 | name = "yoke-derive" | |
| 1239 | version = "0.8.2" | |
| 1240 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1241 | checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" | |
| 1242 | dependencies = [ | |
| 1243 | "proc-macro2", | |
| 1244 | "quote", | |
| 1245 | "syn 2.0.119", | |
| 1246 | "synstructure", | |
| 1247 | ] | |
| 1248 | ||
| 1249 | [[package]] | |
| 1250 | name = "zerofrom" | |
| 1251 | version = "0.1.8" | |
| 1252 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1253 | checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" | |
| 1254 | dependencies = [ | |
| 1255 | "zerofrom-derive", | |
| 1256 | ] | |
| 1257 | ||
| 1258 | [[package]] | |
| 1259 | name = "zerofrom-derive" | |
| 1260 | version = "0.1.7" | |
| 1261 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1262 | checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" | |
| 1263 | dependencies = [ | |
| 1264 | "proc-macro2", | |
| 1265 | "quote", | |
| 1266 | "syn 2.0.119", | |
| 1267 | "synstructure", | |
| 1268 | ] | |
| 1269 | ||
| 686 | 1270 | [[package]] |
| 687 | 1271 | name = "zeroize" |
| 688 | 1272 | version = "1.9.0" |
| 689 | 1273 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 690 | 1274 | checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" |
| 691 | 1275 | |
| 1276 | [[package]] | |
| 1277 | name = "zerotrie" | |
| 1278 | version = "0.2.5" | |
| 1279 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1280 | checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" | |
| 1281 | dependencies = [ | |
| 1282 | "displaydoc", | |
| 1283 | "yoke", | |
| 1284 | "zerofrom", | |
| 1285 | "zerovec", | |
| 1286 | ] | |
| 1287 | ||
| 1288 | [[package]] | |
| 1289 | name = "zerovec" | |
| 1290 | version = "0.11.8" | |
| 1291 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1292 | checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" | |
| 1293 | dependencies = [ | |
| 1294 | "serde", | |
| 1295 | "yoke", | |
| 1296 | "zerofrom", | |
| 1297 | "zerovec-derive", | |
| 1298 | ] | |
| 1299 | ||
| 1300 | [[package]] | |
| 1301 | name = "zerovec-derive" | |
| 1302 | version = "0.11.6" | |
| 1303 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1304 | checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" | |
| 1305 | dependencies = [ | |
| 1306 | "proc-macro2", | |
| 1307 | "quote", | |
| 1308 | "syn 3.0.5", | |
| 1309 | ] | |
| 1310 | ||
| 692 | 1311 | [[package]] |
| 693 | 1312 | name = "zmij" |
| 694 | 1313 | version = "1.0.23" |
fuzz/Cargo.toml+8| ... | ... | @@ -11,6 +11,7 @@ cargo-fuzz = true |
| 11 | 11 | libfuzzer-sys = "0.4" |
| 12 | 12 | onestore = { path = "../crates/onestore", features = ["protected"] } |
| 13 | 13 | onestore-offline = { path = "../crates/onestore-offline" } |
| 14 | one-canvas = { path = "../crates/canvas" } | |
| 14 | 15 | tempfile = "3" |
| 15 | 16 | md5 = "0.8.1" |
| 16 | 17 | serde_json = "1" |
| ... | ... | @@ -128,3 +129,10 @@ path = "fuzz_targets/offline_page.rs" |
| 128 | 129 | test = false |
| 129 | 130 | doc = false |
| 130 | 131 | bench = false |
| 132 | ||
| 133 | [[bin]] | |
| 134 | name = "canvas-editor" | |
| 135 | path = "fuzz_targets/canvas_editor.rs" | |
| 136 | test = false | |
| 137 | doc = false | |
| 138 | bench = false |
fuzz/fuzz_targets/canvas_editor.rs created+192| ... | ... | @@ -0,0 +1,192 @@ |
| 1 | #![no_main] | |
| 2 | use libfuzzer_sys::fuzz_target; | |
| 3 | use one_canvas::{ | |
| 4 | document::{TextDocument, TextPosition}, | |
| 5 | editor::{CanvasEditor, TextOutline}, | |
| 6 | layout::TextEngine, | |
| 7 | text::Paragraph, | |
| 8 | }; | |
| 9 | use onestore::document::Format; | |
| 10 | use std::cell::RefCell; | |
| 11 | ||
| 12 | thread_local! { | |
| 13 | static ENGINE: RefCell<TextEngine> = RefCell::new(TextEngine::default()); | |
| 14 | } | |
| 15 | ||
| 16 | fuzz_target!(|input: &[u8]| { | |
| 17 | ENGINE.with_borrow_mut(|engine| { | |
| 18 | let mut width = | |
| 19 | [1.0, 10.0, 72.0, 240.0][usize::from(input.first().copied().unwrap_or(0)) % 4]; | |
| 20 | let document = TextDocument::new( | |
| 21 | ["office e\u{301} 👩🏽💻", "العربية 日本"] | |
| 22 | .map(|text| Paragraph::new(text.into(), Format::default())) | |
| 23 | .into(), | |
| 24 | ) | |
| 25 | .unwrap(); | |
| 26 | let mut editor = CanvasEditor::new(engine, document, width).unwrap(); | |
| 27 | for step in input.chunks_exact(8).take(32) { | |
| 28 | if step[6] & 128 != 0 { | |
| 29 | editor.place_caret(engine, [0.0; 2], width).unwrap(); | |
| 30 | } | |
| 31 | let mut positions = Vec::new(); | |
| 32 | let mut source = String::new(); | |
| 33 | for (paragraph, text) in editor.active_outline().document().paragraphs().enumerate() { | |
| 34 | if paragraph != 0 { | |
| 35 | source.push('\n'); | |
| 36 | } | |
| 37 | let base = source.len(); | |
| 38 | let mut offset = 0; | |
| 39 | for (byte, character) in text.text().char_indices() { | |
| 40 | positions.push((TextPosition { paragraph, offset }, base + byte)); | |
| 41 | offset += character.len_utf16() as u32; | |
| 42 | } | |
| 43 | positions.push((TextPosition { paragraph, offset }, base + text.text().len())); | |
| 44 | source.push_str(text.text()); | |
| 45 | } | |
| 46 | let (anchor, a) = positions[usize::from(step[0]) % positions.len()]; | |
| 47 | let (focus, b) = positions[usize::from(step[1]) % positions.len()]; | |
| 48 | editor.select([anchor, focus].into()).unwrap(); | |
| 49 | let before = editor.active_outline().document().clone(); | |
| 50 | let selection = editor.selection(); | |
| 51 | let text = [ | |
| 52 | "", | |
| 53 | "a", | |
| 54 | "e\u{301}", | |
| 55 | "👩🏽💻", | |
| 56 | "\n", | |
| 57 | "left\nright", | |
| 58 | "العربية", | |
| 59 | "日本", | |
| 60 | ][usize::from(step[2]) % 8]; | |
| 61 | let text = if text.is_empty() && editor.caret_outline().is_some() { | |
| 62 | "x" | |
| 63 | } else { | |
| 64 | text | |
| 65 | }; | |
| 66 | if step[3] & 1 != 0 { | |
| 67 | let end = text.encode_utf16().count() as u32; | |
| 68 | editor.compose(engine, text.into(), end..end).unwrap(); | |
| 69 | assert!(editor.cancel_composition(engine).unwrap()); | |
| 70 | assert_eq!(editor.active_outline().document(), &before); | |
| 71 | assert_eq!(editor.selection(), selection); | |
| 72 | } | |
| 73 | let format = Format { | |
| 74 | font_size: Some(10.0 + f32::from(step[4] % 8)), | |
| 75 | bold: Some(step[5] & 1 != 0), | |
| 76 | italic: Some(step[5] & 2 != 0), | |
| 77 | ..Format::default() | |
| 78 | }; | |
| 79 | editor | |
| 80 | .replace( | |
| 81 | engine, | |
| 82 | text.split('\n') | |
| 83 | .map(|part| Paragraph::new(part.into(), format.clone())) | |
| 84 | .collect(), | |
| 85 | ) | |
| 86 | .unwrap(); | |
| 87 | source.replace_range(a.min(b)..a.max(b), text); | |
| 88 | assert_eq!( | |
| 89 | editor | |
| 90 | .active_outline() | |
| 91 | .document() | |
| 92 | .paragraphs() | |
| 93 | .map(|p| p.text()) | |
| 94 | .collect::<Vec<_>>() | |
| 95 | .join("\n"), | |
| 96 | source | |
| 97 | ); | |
| 98 | let after = editor.active_outline().document().clone(); | |
| 99 | let after_selection = editor.selection(); | |
| 100 | assert!(editor.undo(engine).unwrap()); | |
| 101 | assert_eq!(editor.active_outline().document(), &before); | |
| 102 | assert_eq!(editor.selection(), selection); | |
| 103 | assert!(editor.redo(engine).unwrap()); | |
| 104 | assert_eq!(editor.active_outline().document(), &after); | |
| 105 | assert_eq!(editor.selection(), after_selection); | |
| 106 | if step[5] & 128 != 0 && editor.caret_outline().is_none() { | |
| 107 | let outlines = editor | |
| 108 | .outlines() | |
| 109 | .iter() | |
| 110 | .map(|outline| { | |
| 111 | ( | |
| 112 | outline.id, | |
| 113 | outline.layout().clone(), | |
| 114 | outline.document().clone(), | |
| 115 | ) | |
| 116 | }) | |
| 117 | .collect::<Vec<_>>(); | |
| 118 | let id = editor.active_outline().id; | |
| 119 | editor.select_all().unwrap(); | |
| 120 | let selection = editor.selection(); | |
| 121 | editor.delete(engine, true).unwrap(); | |
| 122 | assert!(editor.outlines().iter().all(|outline| outline.id != id)); | |
| 123 | assert_eq!(editor.caret_outline().unwrap().id, id); | |
| 124 | editor.place_caret(engine, [50.0, 90.0], width).unwrap(); | |
| 125 | for _ in 0..2 { | |
| 126 | assert!(editor.undo(engine).unwrap()); | |
| 127 | assert_eq!(editor.selection(), selection); | |
| 128 | assert_eq!( | |
| 129 | editor | |
| 130 | .outlines() | |
| 131 | .iter() | |
| 132 | .map(|outline| { | |
| 133 | ( | |
| 134 | outline.id, | |
| 135 | outline.layout().clone(), | |
| 136 | outline.document().clone(), | |
| 137 | ) | |
| 138 | }) | |
| 139 | .collect::<Vec<_>>(), | |
| 140 | outlines | |
| 141 | ); | |
| 142 | assert!(editor.redo(engine).unwrap()); | |
| 143 | assert_eq!(editor.caret_outline().unwrap().id, id); | |
| 144 | } | |
| 145 | assert!(editor.undo(engine).unwrap()); | |
| 146 | } | |
| 147 | if step[6] & 1 != 0 { | |
| 148 | width = [1.0, 10.0, 72.0, 240.0][usize::from(step[7]) % 4]; | |
| 149 | editor.resize(engine, width).unwrap(); | |
| 150 | } | |
| 151 | let rebuilt = CanvasEditor::new(engine, after, width).unwrap(); | |
| 152 | let geometry = |outline: &TextOutline| { | |
| 153 | outline | |
| 154 | .layouts() | |
| 155 | .map(|(index, paragraph)| { | |
| 156 | ( | |
| 157 | index, | |
| 158 | paragraph.origin, | |
| 159 | paragraph | |
| 160 | .text | |
| 161 | .lines() | |
| 162 | .map(|(line, bounds)| { | |
| 163 | ( | |
| 164 | bounds.source.clone(), | |
| 165 | bounds.top, | |
| 166 | bounds.baseline, | |
| 167 | bounds.height, | |
| 168 | line.metrics().advance, | |
| 169 | ) | |
| 170 | }) | |
| 171 | .collect::<Vec<_>>(), | |
| 172 | ) | |
| 173 | }) | |
| 174 | .collect::<Vec<_>>() | |
| 175 | }; | |
| 176 | assert_eq!( | |
| 177 | geometry(editor.active_outline()), | |
| 178 | geometry(rebuilt.active_outline()) | |
| 179 | ); | |
| 180 | assert_eq!( | |
| 181 | editor.active_outline().bounds(), | |
| 182 | rebuilt.active_outline().bounds() | |
| 183 | ); | |
| 184 | let caret = editor.caret(1.0).unwrap(); | |
| 185 | assert!( | |
| 186 | [caret.x0, caret.y0, caret.x1, caret.y1] | |
| 187 | .iter() | |
| 188 | .all(|v| v.is_finite()) | |
| 189 | ); | |
| 190 | } | |
| 191 | }); | |
| 192 | }); |
tools/canvas/README.md created+232| ... | ... | @@ -0,0 +1,232 @@ |
| 1 | # Canvas experiments | |
| 2 | ||
| 3 | The [active goal and design record](../../resources/canvas-research.md#active-implementation-goal) define the implementation scope. | |
| 4 | ||
| 5 | ## macOS text canvas | |
| 6 | ||
| 7 | The host opens nonempty UTF-8 text as paragraphs in one temporary text outline. Its editor reuses `onestore::document::Format`, retains paragraph layout across selection changes, and keeps notebook storage outside the editing boundary. The window title and close/quit guard expose the temporary lifetime. Escape and Return dismiss the discard dialog and keep the page open. | |
| 8 | ||
| 9 | ```sh | |
| 10 | python3 tools/canvas/build_macos.py | |
| 11 | "target/One Canvas.app/Contents/MacOS/OneCanvas" | |
| 12 | cargo test -p one-canvas | |
| 13 | cargo test -p one-canvas-gpu -- --ignored | |
| 14 | ``` | |
| 15 | ||
| 16 | The builder ad-hoc signs and verifies the local bundle. To preserve an existing app during review, provide a new bundle path and a distinct identifier together: | |
| 17 | ||
| 18 | ```sh | |
| 19 | python3 tools/canvas/build_macos.py --release --output '/PATH/One Canvas Review.app' --bundle-id dev.onecanvas.review | |
| 20 | ``` | |
| 21 | ||
| 22 | The explicit output path must not exist. Its filename supplies the bundle's display name; the default build continues to update `target/One Canvas.app`. Local ad-hoc signing does not provide a Developer ID or notarization. | |
| 23 | ||
| 24 | Omit both arguments for a blank page with provisional caret placement, or provide a UTF-8 file and optional document width (default 480 pt). Typing, drag selection, arrow/word/line navigation, Backspace/Delete, clipboard shortcuts, Cmd-Z/Shift-Cmd-Z and IME use the shared editor. Scrolling pans; Cmd-wheel or Cmd-plus/minus zooms without changing document width, and Cmd-0 returns to 100%. The GPU test requires an available native adapter and compares readback pixels before/after repaint and forced glyph/image-cache eviction. | |
| 25 | ||
| 26 | ```text | |
| 27 | onestore::Format | |
| 28 | ↓ | |
| 29 | one-canvas text edits · inverse history · composition · paragraph layout · structural edits | |
| 30 | ↓ | |
| 31 | one-canvas-gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches | |
| 32 | ↓ | |
| 33 | one-canvas-app Winit window/input · AppKit quit guard · clipboard · AccessKit | |
| 34 | ``` | |
| 35 | ||
| 36 | The renderer allocates a 2048² RGBA glyph atlas and a 2 MiB vertex buffer, limits combined text-glyph and tag-icon entries to 8192, and rebuilds a frame after atlas eviction. Font scaling uses 32 cache entries. These limits bound those caches; they are not a process-memory or worst-case rasterization budget. Drawing and hit-testing share Windows-metric line boxes. First-baseline fidelity and substitute-font metric selection remain experimental; the `windows_*` probe values expose that policy independently from Parley's original metrics. | |
| 37 | ||
| 38 | Drawing accepts a flat sequence of positioned text layouts, tag icons, rectangles and immutable raster images. Consecutive primitives with the same texture share a batch without changing paint order. The host emits selection, composition and caret rectangles through that same interface; repainting multiple placements reuses the retained layout and glyph cache. | |
| 39 | ||
| 40 | Glyph coverage uses quarter-pixel phases with the vertical sign converted to Swash's Y-up coordinates. The GPU readback test checks nine successive quarter-pixel translations by their linear-light ink centroids, then requires identical pixels on repaint, cache eviction and renderer reconstruction on the same device. The native caret experiment exposed the previous inverted vertical phase; correcting raster placement does not change line metrics or wrapping. | |
| 41 | ||
| 42 | Image textures have a separate 64 MiB / 256-entry cache. Offscreen images do not upload or consume the active-frame budget; a frame whose visible images exceed that budget returns an error. Under pressure, eviction preserves every image needed by the current frame. PNG/JPEG decoding checks encoded size, dimensions and decoded output size, with a best-effort codec allocation limit. These limits do not bound all CPU buffers or driver allocations. Images use linear filtering of premultiplied, linear-light colors; glyphs retain their own sampling/blending policy. Decoded color values are currently interpreted as sRGB; ICC and orientation metadata are not transformed by this experiment. | |
| 43 | ||
| 44 | ## Note tags | |
| 45 | ||
| 46 | Paragraph layout retains blue checkbox (shape 3), question mark (15) and musical note (121) tags from paragraph and rich-text nodes. Their 12 pt tiles precede the list marker or text origin with a 20.25 pt gutter; they do not change text advances, wrapping or line height. These dimensions cover the three reference pages' 17 tags. Hand-authored vector paths use Swash's existing rasterizer and the text atlas, including its subpixel phases, offscreen culling and eviction. No Office bitmap assets or tag fonts are bundled. | |
| 47 | ||
| 48 | Checkboxes display completion; noncheckable tags ignore the completion bit required by the file format. Disabled tags dim. Accessibility descriptions expose source labels and checkbox/disabled state without changing selectable text. Tag state is read-only; text edits and undo retain it. Clicking a tag gutter focuses its outline. Unsupported icon shapes, task tags and tag color/highlight overrides return `UnsupportedContent` during layout. | |
| 49 | ||
| 50 | ## Optional font substitution | |
| 51 | ||
| 52 | The host and `layout-probe` accept repeated `--substitute-font FONT_FILE` options. Supply Arimo regular/italic variable fonts or Carlito regular/bold/italic/bold-italic files. Each file is registered only in this process, as Arial or Calibri respectively; this explicitly replaces that family even when a system copy exists. Stored `Format.font` values remain unchanged. No font files are installed or bundled by this option. | |
| 53 | ||
| 54 | ```sh | |
| 55 | "target/One Canvas.app/Contents/MacOS/OneCanvas" TEXT_FILE 240 --substitute-font ARIMO_FILE | |
| 56 | cargo run -p one-canvas --bin layout-probe -- resources/canvas/text-cases.json --substitute-font ARIMO_FILE --substitute-font CARLITO_FILE | |
| 57 | ``` | |
| 58 | ||
| 59 | Arimo's horizontal-header ascent/descent match the measured Arial Windows extents; its larger OS/2 Windows extents do not. The substitute uses those horizontal-header extents without line gap, scoped to the registered font data. Carlito uses its OS/2 Windows extents. Other fonts selected for missing glyphs retain their own metrics, including emoji. The comparator reports actual `canvas_height` and `canvas_height_residual` separately from the raw Windows-table hypothesis. | |
| 60 | ||
| 61 | Arimo 1.341 and Carlito 1.104 match all 14 recoverable native ASCII wrap controls, with maximum outline-height residual 0.000054 pt. This does not establish matching coverage or advances for every script: the mixed Hebrew/Arabic control rewraps under substitution and resolves Arabic to Geeza Pro on this Mac. The 500-case Unicode source-coverage test passes with these substitutes across 1804 visual lines. Font files, hashes, native comparisons and host paste/undo captures remain in the external evidence bundle. | |
| 62 | ||
| 63 | ## Structural text editing | |
| 64 | ||
| 65 | `TextDocument` owns a nonempty sequence of `PageParagraph` nodes, each with one rich-text object. Paragraph and rich-text identities survive edits; split/join preserves surviving identities and allocates new ones only for new objects. Construction and replacement reject duplicate identities, missing or forward parent links, and children at the same or shallower indentation level as their parent. In-paragraph replacement retains modeled hierarchy, lists, tags, collapse state and paragraph formatting. Structural replacement requires flat, untagged content. Document changes are internal to the canvas crate; callers edit through `CanvasEditor`. | |
| 66 | ||
| 67 | `CanvasEditor` owns independently positioned text outlines and one undo history, retaining one visible projection and layout per paragraph, with source UTF-16 selections and affinity at wrap boundaries. `CanvasEditor::from_outlines` takes owned outlines and their list/tag definitions, preserving source coordinates, widths, indentation and identities. Rendering and editing share `ParagraphLayout` shaping and outline arrangement, including list-marker height and adjacent spacing. Paragraph spacing contributes only between visible paragraphs; leading and trailing spacing properties remain stored without expanding the outline boundary. Collapsed descendants retain source nodes while navigation, hit-testing and selection geometry use visible paragraphs. LF insertion splits flat paragraphs; boundary Backspace/Delete joins them. Replacements stage every affected paragraph's layout before publishing the edit. Undo restores full affected nodes, including their identities, empty styles and reversed selections; untouched paragraphs retain their layouts. Resizing keeps source selection and history while recomputing geometry at the new width. | |
| 68 | ||
| 69 | The macOS host uses this same editor for typing, pointer/keyboard selection, clipboard text, IME, split/join and undo. Click blank canvas or press Cmd-Shift-N to place a provisional caret with a 240 pt text constraint; the keyboard command places it beside the focused outline or read-only object. The first committed input creates an outline in one undo step; placement alone preserves history and does not dirty the page. Drag an outline's top bar to move it on the default page grid. Hold Option for free pointer placement or dragging; Escape cancels a drag. Use Cmd-Option-arrows (1 pt; Shift makes it 10 pt). Ctrl-Tab and Ctrl-Shift-Tab cycle text outlines and read-only placeholders. A drag previews placement and commits one undo step on release; Escape or loss of window focus cancels the preview. Creation, movement and text edits share one history, while untouched outlines retain their text layouts. Composition can provisionally replace a selection spanning paragraphs; cancellation restores the original paragraphs, selection and redo, while commitment creates one undo step. Vertical navigation retains its preferred column across short or empty paragraphs. Horizontal navigation enters the neighboring paragraph at its visual edge, including when text direction changes. | |
| 70 | ||
| 71 | Core tests compare 220 Unicode range/replacement combinations with plain-text replacement, and compare incremental layout with full recomputation through a 64-edit structural history and an 80-step multi-outline history followed by complete undo/redo. They also exercise hidden-field mapping, paragraph-separator selection, grapheme deletion, multiline IME and failed-layout atomicity. Live macOS captures verify Enter, Backspace join, undo/redo, Option-E dead-key commit and the close guard. Native Pinyin input verifies Escape cancellation over single- and cross-paragraph selections: original text, selection and redo survive. Accepting a candidate across paragraphs commits one undo step. Input-event logs distinguish empty preedit cancellation from candidate commitment. Escape commits the spacing acute accent in both this host and a native TextEdit control; that dead-key case is not evidence of composition cancellation. | |
| 72 | ||
| 73 | `cargo +nightly fuzz run canvas-editor /absolute/corpus-directory -- -max_total_time=60 -max_len=256 -artifact_prefix=/absolute/artifact-directory/` runs the canvas state machine under AddressSanitizer. Create both external directories first. Each input applies up to 32 Unicode range replacements with mixed formatting, optional provisional composition/cancellation, undo/redo and width changes. Plain-text replacement is checked independently; paragraph metadata and selections must survive undo/redo, and retained line geometry must match a freshly constructed editor. The target uses installed fonts and a retained text engine; it does not read notebooks or exercise GPU drawing. Keep generated corpus and failure artifacts outside the repository. | |
| 74 | ||
| 75 | The private import check edits and restores each body and title paragraph after dropping the source file bytes. It compares complete modeled nodes before/after undo and redo, including metadata on nested and tagged content. It exercises the document boundary without launching or modifying the live app: | |
| 76 | ||
| 77 | ```sh | |
| 78 | CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE cargo test -p one-canvas imported_nodes_preserve_identity_through_edit_and_undo -- --ignored --nocapture | |
| 79 | ``` | |
| 80 | ||
| 81 | The editor integration check additionally edits every visible body paragraph, compares incremental geometry with fresh outline layout, and requires undo/redo to restore the original/edited geometry exactly. All 269 body paragraphs in the three frozen references pass. Set `CANVAS_TEST_SUBSTITUTE` to the optional Carlito font file when reproducing that check: | |
| 82 | ||
| 83 | ```sh | |
| 84 | CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_REGULAR_FILE cargo test -p one-canvas imported_editor_reflows_and_restores_native_outline_geometry -- --ignored --nocapture | |
| 85 | ``` | |
| 86 | ||
| 87 | ## Native text accessibility | |
| 88 | ||
| 89 | AccessKit exposes each text outline as an independent editable macOS text area. Logical text runs carry the canvas line boxes, cluster advances and source positions, including blank paragraphs and hidden-field projection. They use the editor's retained projections and explicit source indices; separators connect the next visible paragraph across collapsed children. Native selection, whole-field replacement and keyboard edits share the editor history. Focus transfers retire native AppKit preedit when the canvas commits or cancels composition, preventing dead-key state from transferring to another outline. Selection/navigation reveals the caret; pan and zoom keep their own viewport behavior. Accessibility updates run only when the adapter is active and the host changes, not for caret-blink redraws. Cached paragraph runs reuse text and character geometry when the shaped-layout identity, source index, next visible paragraph and local origin match; reflow or changed source mappings invalidate them. The cache retains only the current page’s paragraph ranges and clears on adapter deactivation. IDs survive geometry/selection updates; changed run text or source mappings receive fresh IDs so obsolete action positions are rejected. | |
| 90 | ||
| 91 | ```sh | |
| 92 | cargo test -p one-canvas-app | |
| 93 | ONE_CANVAS_TRACE_INPUT=1 "target/One Canvas.app/Contents/MacOS/OneCanvas" SYNTHETIC_TEXT_FILE | |
| 94 | ``` | |
| 95 | ||
| 96 | Input tracing logs Winit keyboard/IME, pointer, modifier and focus events, AccessKit action payloads and applied accessibility selections to stderr, including text. Consumer tests check Unicode/hidden-field mappings, stale actions, undo, exact document text and transformed caret geometry in both affinities at wraps and bidirectional boundaries; native captures exercise AX value/selection setters. Those checks do not substitute for a VoiceOver walkthrough. | |
| 97 | ||
| 98 | ## Reference pages in the macOS host | |
| 99 | ||
| 100 | ```sh | |
| 101 | "target/One Canvas.app/Contents/MacOS/OneCanvas" --reference PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE | |
| 102 | ``` | |
| 103 | ||
| 104 | This integration mode keeps imported objects read-only and opens a separate editable canvas to their left. The initial notes width defaults to 240 pt in this mode, and the reference starts 24 pt to its right. Existing text-file and width arguments also work. Typing, outline creation/movement, paste and undo operate on the temporary notes. The close guard protects those notes. Reference text is rendered but is not exposed through the host’s editable accessibility fields. | |
| 105 | ||
| 106 | The host and offscreen renderer both use `one_canvas_gpu::page::PageScene`. Layout and image decoding happen when the scene is built; drawing reuses that data. Scene coordinates stay in source document points, while the comparison example applies its captured margin normalization through the viewport. All three shared-scene outputs are pixel-identical to their preceding title-rendering captures. Decoded image residency is capped at 64 MiB per scene, in addition to the renderer’s GPU cache budget; encoded source payloads and temporary decoder allocations are separate. | |
| 107 | ||
| 108 | The host sleeps its caret timer while macOS reports the window occluded and requests a redraw when visibility returns. Input tracing includes draw attempts, surface occlusion/timeouts and submitted presents; submission is not a measurement of display completion. Startup failures propagate a nonzero exit code after the event loop exits. | |
| 109 | ||
| 110 | ## Editing an imported page | |
| 111 | ||
| 112 | ```sh | |
| 113 | "target/One Canvas.app/Contents/MacOS/OneCanvas" --page PRIVATE_SECTION_COPY EXACT_PAGE_TITLE --substitute-font CARLITO_REGULAR_FILE | |
| 114 | ``` | |
| 115 | ||
| 116 | `--page` opens the imported body outlines as temporary editable objects in their source positions. It accepts a section copy and exact page title, without the separate text-file/width arguments. Titles, dates and images remain fixed. Character editing, selection, IME and undo use the same outline model as new pages. Existing structured or tagged paragraphs accept in-paragraph edits; their structural split/join remains restricted. Newly created flat outlines support the full structural workflow. The app never writes the section file. | |
| 117 | ||
| 118 | `PageScene::from_page` transfers body outlines and definitions to `CanvasEditor`, retaining static drawing data and ordered outline identities in the scene. Each dynamic slot invokes the host's outline painter; moving or editing an outline changes its drawing without re-decoding images or duplicating its paragraph layout. New outlines draw after the imported page. The close guard compares every outline's identity, layout and document against the opening state, so changing any imported outline or creating one requires confirmation before discarding. | |
| 119 | ||
| 120 | The app's private integration test exercises the actual outline painter and accessibility consumer for each imported outline, then verifies text restoration through undo: | |
| 121 | ||
| 122 | ```sh | |
| 123 | CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_REGULAR_FILE cargo test -p one-canvas-app imported_page_widgets_and_accessibility_follow_edits -- --ignored --nocapture | |
| 124 | ``` | |
| 125 | ||
| 126 | ## Unsupported page content | |
| 127 | ||
| 128 | Unsupported top-level objects, outlines or titles render as read-only placeholders in source order. Missing image payloads or dimensions produce an image-unavailable placeholder. The scene owns the imported source values for those objects; the importer’s `Unsupported` record contains identity, class and layout metadata, not an opaque copy of unparsed notebook bytes. Supported body outlines still enter the editor. If none exist, the page receives a separate empty annotation outline to the right of the placeholders. | |
| 129 | ||
| 130 | Placeholders use source positions and at least 160 pt width, expanding their height to contain the status text. This is an explicit substitute for unavailable rendering, not a geometric reconstruction. Pointer presses focus placeholders, hide the previous text caret/selection and retire its IME composition. Typing, deletion, paste, text-selection commands, undo/redo and outline movement are suppressed while a placeholder has focus. Escape restores the previous text focus; Ctrl-Tab cycles through text outlines and placeholders. Zoom remains available, and Cmd-Shift-N creates an editable annotation beside the focused object. AccessKit exposes placeholder focus, status and bounds without text-edit actions; identities persist across editor updates and viewport changes. Focus reveals offscreen placeholders and suspends caret-blink scheduling. Hit-testing follows paint order for overlapping editable outlines and placeholders: new annotations draw last and receive hits first, while imported objects resolve from front to back. Each outline’s header, body and tag gutters participate at its own position in that order; static titles and decoded images have no editable hit target. Invalid geometry, malformed image data and resource-budget failures remain errors. | |
| 131 | ||
| 132 | `TextOutline::from_outline` shapes supported content once before the scene transfers the prepared outline into `CanvasEditor::from_text_outlines`. The document and layout fields are read-only outside the editor, keeping their retained geometry tied to editor operations. The source-based `CanvasEditor::from_outlines` constructor uses the same path. | |
| 133 | ||
| 134 | ## Owned page import | |
| 135 | ||
| 136 | `Page::from_revision` copies one page's text, outline hierarchy, list/tag definitions and image payloads into canvas-owned values. The source revision and file bytes can then be dropped. Hidden text has a separate visible projection with UTF-16 mappings on both sides of a hidden field; the full source paragraph remains available. This import path does not write a notebook. | |
| 137 | ||
| 138 | ```sh | |
| 139 | cargo run -p one-canvas --bin page-probe -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE > PRIVATE_PAGE_JSON | |
| 140 | python3 tools/canvas/compare_page.py PRIVATE_PAGE_JSON NATIVE_PAGE_XML > PRIVATE_COMPARISON_JSON | |
| 141 | ``` | |
| 142 | ||
| 143 | The page probe uses retained outline layout: cumulative indentation, collapsed descendants, adjacent paragraph spacing, minimum line spacing and bullet layout. Title containers retain their IDs, parent geometry and ordered child outlines. Children use intrinsic text width when no width is stored; each following child starts after the larger of the preceding child’s content height and stored suggested height. The probe emits title parent geometry once in `title_areas`, linked to the flattened outline diagnostics by ID. The comparator identifies outlines by their complete paragraph text and rejects ambiguous matches. `--allow-native-empty-nbsp` explicitly records the observed native conversion of a sole nonbreaking space to an empty paragraph; it does not report those paragraphs as exact text matches. | |
| 144 | ||
| 145 | The PDF companion verifies the matching native XML first, then recovers paragraph wraps from its adjacent PDF export: | |
| 146 | ||
| 147 | ```sh | |
| 148 | python3 tools/canvas/compare_page_pdf.py PRIVATE_PAGE_JSON NATIVE_PAGE_XML --allow-native-empty-nbsp > PRIVATE_LINE_COMPARISON_JSON | |
| 149 | ``` | |
| 150 | ||
| 151 | Complete-outline text identifies repeated paragraphs. Otherwise, a paragraph needs unique source context; repeated PDF instances must agree on every recovered break. Glyph boxes group visual lines even when bold/regular PDF runs have slightly different baselines. The report retains each instance's original baseline range and print-scale observation. Current source probes match all 159 nonblank body paragraphs across the three references (214 visible lines); 110 whitespace-only paragraphs have no PDF glyph-position evidence. This establishes body wrap agreement for these captures, not screen baselines, empty-line placement or title/tag rendering. | |
| 152 | ||
| 153 | `--include-titles` also compares title/date/time paragraphs against PDF glyphs. All eight such paragraphs in the three references match with Carlito explicitly substituted for Calibri. Both `page-probe` and `render_page` accept repeated `--substitute-font FONT_FILE` arguments after their positional arguments, using the same substitution path as the app. The title comparison screenshot uses the existing image-derived viewport translations; no title-specific translation is fitted. Wrap agreement does not establish title screen baselines or general title alignment behavior. | |
| 154 | ||
| 155 | All three private reference pages pass owned import after the source document is dropped. All 15 body outline heights agree with native XML within 0.002 pt. Album's Courier New bullet height participates in the whole paragraph box; expanding the first text line instead incorrectly adds space to wrapped paragraphs. The importer retains note tags attached to rich-text objects as well as paragraph nodes. These are total-height measurements, not proof of matching line breaks or first baselines. Full reports remain in the external evidence bundle. | |
| 156 | ||
| 157 | ```sh | |
| 158 | cargo run -p one-canvas-gpu --example render_page -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE NEW_PNG_PATH | |
| 159 | ``` | |
| 160 | ||
| 161 | This comparison renderer draws body text, title text, date/time fields, bullet glyphs, supported tag icons, highlights and images in source object order at 100% / 96 DPI into the frozen viewport size, writing only a new PNG. It normalizes the three measured page margin origins to 36 pt / 14.4 pt. The title container is placed relative to that normalized margin. The optional fourth argument is the physical Y position of the document origin (default zero). Image-region comparison against the frozen viewport finds native translations of +6 px for Video and +30 px for Lore. Those are measured capture transforms, not paragraph-baseline corrections or a general scrolling rule; the native screenshots and geometry reports accompany these images. | |
| 162 | ||
| 163 | ## Text and native-reference probes | |
| 164 | ||
| 165 | `resources/canvas/text-cases.json` is the input shared by the Rust/Parley probe, the Swift/Core Text probe and the synthetic native-page generator. Probe coordinates are logical points, without pixel quantization. Both probes accept additional font-file paths after the JSON path and register those fonts only within the process. | |
| 166 | ||
| 167 | ```sh | |
| 168 | cargo run -p one-canvas --bin layout-probe -- resources/canvas/text-cases.json > PARLEY_JSON | |
| 169 | swift tools/canvas/core_text_probe.swift resources/canvas/text-cases.json > CORE_TEXT_JSON | |
| 170 | python3 tools/canvas/native_fixture.py resources/canvas/text-cases.json NEW_NOTEBOOK_DIRECTORY | |
| 171 | python3 tools/native_runner.py NEW_NOTEBOOK_DIRECTORY NEW_CAPTURE_DIRECTORY --author tools/native/pages.ps1 --pdf --screenshots | |
| 172 | python3 tools/canvas/compare.py resources/canvas/text-cases.json PARLEY_JSON CORE_TEXT_JSON NEW_CAPTURE_DIRECTORY/read > COMPARISON_JSON | |
| 173 | python3 -m unittest discover -s tools/canvas -p 'test_*.py' | |
| 174 | python3 tools/canvas/range_stress.py NEW_STRESS_DIRECTORY target/debug/layout-probe | |
| 175 | python3 tools/canvas/range_stress.py NEW_CORE_TEXT_STRESS_DIRECTORY swift tools/canvas/core_text_probe.swift | |
| 176 | ``` | |
| 177 | ||
| 178 | The comparison and its tests require `pdfplumber`. The native runner uses the existing disposable Windows lab. Choose new artifact destinations outside version control. Original notebooks are never capture destinations. Font substitutes can be tested using a derived case file with just the requested font names changed; the comparator records those overrides and rejects changed text, sizes, styles or widths. It also verifies the native capture's input fixture against the canonical cases. | |
| 179 | ||
| 180 | Native PDF ranges are supplementary evidence: the comparator accepts only unambiguous ASCII content and groups characters by intersecting vertical glyph boxes, preserving mixed-style lines without fitting to expected canvas wraps. Unmapped PDF glyphs and ambiguous matches do not produce passing expectations. Screen captures still require a recorded zoom/DPI before becoming the frozen raster reference. | |
| 181 | ||
| 182 | For controlled screen captures, run `native_runner.py` with `--inspect --pdf`. Once it reports the clone is ready for inspection, run `capture_view.py CLONE CAPTURE/read/page-NNN.xml NEW_VIEW_DIRECTORY --scrolls 2` for a long page, or omit the scroll option for a short page. The tool targets OneNote's main frame, fixes 1600×900/100%, records measured DPI and captures overlapping views. The zoom screenshot verifies the visible setting. Creating `CAPTURE/finish` asks the runner to collect final artifacts and delete the clone; the captured view's source XML is then under `before-read`. Inspect the images before accepting them as references. | |
| 183 | ||
| 184 | `compare_scroll.py` measures downward integer scrolling between native captures from identical foreground rows, then scores the overlapping foreground pixels. It reports all candidates and leaves the offset unset when the best score is ambiguous. Blank or unrelated captures cannot establish an offset. The comparison requires unchanged horizontal position and at least 64 pixels of overlap; it does not use canvas geometry. The selected 1600×900 captures use this recorded content crop: | |
| 185 | ||
| 186 | ```sh | |
| 187 | python3 tools/canvas/compare_scroll.py NATIVE_TOP.png NATIVE_SCROLLED.png --crop 48 83 1434 842 | |
| 188 | ``` | |
| 189 | ||
| 190 | Use the measured offset to render the corresponding viewport with `render_page` by subtracting it from the page's top-view translation. Keep top-view registration separate: the Video and Lore comparisons establish their translations from image regions, independently of text layout. Pixel-color bounds alone cannot establish a text baseline when caret, outline-border or background pixels overlap the text region. | |
| 191 | ||
| 192 | The [native baseline fixture](../../resources/canvas/baseline-anchors.md) adds three opaque image anchors and paragraph-spacing controls. `compare_baselines.py PROBE_JSON NATIVE_XML NATIVE_PDF` matches decoded image pixels, rejects ambiguous or masked images, and reports baseline residuals under independently measured image translations. It preserves wrap mismatches and unregistered pages; no passing tolerance is inferred from the canvas output. | |
| 193 | ||
| 194 | The initial native experiment establishes three useful controls: | |
| 195 | ||
| 196 | - Ordinary Arial outline heights, including mixed sizes, agree with sums of Windows ascent/descent metrics within 0.00004 pt; first-baseline placement is a separate measurement. | |
| 197 | - The tested COM imports clamp requested outline widths below 72 pt and remove wholly empty outlines. Those outcomes must not be mistaken for paragraph-engine failures or generalized into an editor-wide width restriction. | |
| 198 | - This Mac lacks Calibri: both engines initially resolve it to Helvetica. Resolved faces accompany results so matching a few wrap offsets cannot conceal substitution. | |
| 199 | ||
| 200 | Carlito matches the initial native Calibri wrap cases, including bold. Arimo matches the tested Arial advances, but its Windows metric tables have larger extents than Arial's; blindly selecting those tables would add vertical drift. Its horizontal-header ascent/descent match Arial in these probes. Font substitution therefore needs a measured metric policy as well as matching advances. | |
| 201 | ||
| 202 | The [Parley dependency](../../Cargo.toml) pins upstream source for the experiment. Published 0.11.1 returned a line boundary inside an emoji's UTF-8 encoding in the deterministic stress cases. An isolated two-location correction removed that failure but still lost a leading space in another case; those local changes were not adopted. The unmodified upstream commit in the manifest passes all 500 source-coverage cases, as does Core Text, and retains the measured native ASCII break agreement. This establishes a tested candidate, not whole-canvas compatibility. | |
| 203 | ||
| 204 | Full native captures, candidate font binaries/licenses and probe outputs remain in the external evidence bundle linked from the research session. The current tools are experiments, not the finished editor. | |
| 205 | ||
| 206 | ## CPU pipeline profiling | |
| 207 | ||
| 208 | The opt-in release probe measures engine construction, document baseline cloning, editing, undo, the host’s production page primitive collection and accessibility construction. Synthetic cases contain 100, 1,000 and 5,000 paragraphs. Each runs 32 insertion/undo samples alternating between the first and last paragraph; assertions require one changed layout per insertion, retained layouts through drawing/accessibility, and exact document restoration after undo. The accessibility regression compares cached output with full reconstruction through 160 mixed editing operations and complete undo/redo, including composition, source index changes, resize and viewport transforms. | |
| 209 | ||
| 210 | ```sh | |
| 211 | cargo test --release -p one-canvas-app canvas_pipeline_cost -- --ignored --nocapture --test-threads=1 | |
| 212 | CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_REGULAR_FILE cargo test --release -p one-canvas-app canvas_pipeline_cost -- --ignored --nocapture --test-threads=1 | |
| 213 | ``` | |
| 214 | ||
| 215 | Private-page mode reads the section once and measures 16 parse/open/close cycles with one retained text engine, recording first construction separately from repeated cycles. Set `CANVAS_PROFILE_CYCLES` to a positive count for longer retention runs. It samples this process’s resident memory through macOS `ps` with each page open and closed. Tab-separated `canvas_profile` records report nanoseconds; `canvas_count` records report object counts; `canvas_rss_kib` records report KiB. The test harness can prefix the first record on a line. Times exclude log output, layout identity scans and memory sampling. Primitive collection includes its temporary allocation and destruction but excludes GPU preparation, submission and presentation. File reads can hit the OS cache. Resident memory includes allocator retention and is neither live allocation accounting nor GPU occupancy; these measurements do not establish input-to-present latency or a process memory limit. | |
| 216 | ||
| 217 | The GPU probe uses a 1386×759 offscreen target and runs 32 samples each for repeated drawing, vertical pan, eight zoom levels, forced eviction, horizontal/vertical offscreen placement, and native page reconstruction. Without private inputs it draws 2,000 synthetic text lines. The first repeated-draw sample includes initial pipeline and atlas work; later samples reuse the renderer. Native reconstruction preserves the text engine and renderer while replacing the decoded scene. Parsing, layout, primitive collection and logging are outside the measured draw interval. | |
| 218 | ||
| 219 | ```sh | |
| 220 | cargo test --release -p one-canvas-gpu renderer_cost -- --ignored --nocapture | |
| 221 | CANVAS_TEST_SECTION=PRIVATE_SECTION_COPY CANVAS_TEST_PAGE=EXACT_PAGE_TITLE CANVAS_TEST_SUBSTITUTE=CARLITO_FILE cargo test --release -p one-canvas-gpu renderer_cost -- --ignored --nocapture | |
| 222 | ``` | |
| 223 | ||
| 224 | Each `canvas_gpu_sample` row contains phase, sample, draw/submit nanoseconds, elapsed nanoseconds through queue completion, glyph/tag entries, occupied atlas texels, image entries, image texture bytes, vertices, batches, CPU vertex capacity bytes, CPU batch capacity bytes, and glyph/image table capacities. GPU resources are reported at their requested sizes; driver overhead, staging buffers, allocator overhead and the font scaler's internal cache are excluded. Queue completion includes CPU preparation and waiting; it is neither GPU execution time nor input-to-display latency. Each sample waits for the queue, so this experiment does not measure display frame pacing. Assertions enforce the existing entry, vertex and image-byte budgets throughout every phase. | |
| 225 | ||
| 226 | ## Native interaction captures | |
| 227 | ||
| 228 | `capture_interaction.py` records one saved AutoHotkey script, a settled screenshot and a subsequent COM page export while `native_runner.py --inspect` holds a disposable clone. It checks that the source page belongs to the supplied target and refuses an existing output directory. Script bytes stay at their original path; the input record includes their hash. Finish and remove the clone through the owning runner after collecting the sequence. | |
| 229 | ||
| 230 | ```sh | |
| 231 | python3 tools/canvas/capture_interaction.py CLONE NATIVE_RUN/read/page-000.xml ACTION.ahk NEW_OUTPUT_DIRECTORY | |
| 232 | ``` |
tools/canvas/baseline_fixture.py created+69| ... | ... | @@ -0,0 +1,69 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Create a synthetic native baseline page with independent raster coordinate anchors.""" | |
| 3 | import argparse | |
| 4 | import base64 | |
| 5 | import json | |
| 6 | from pathlib import Path | |
| 7 | import struct | |
| 8 | import xml.etree.ElementTree as ET | |
| 9 | import zlib | |
| 10 | ||
| 11 | from native_fixture import NS | |
| 12 | ||
| 13 | ||
| 14 | def build(): | |
| 15 | def chunk(kind, data): | |
| 16 | return struct.pack('!I', len(data)) + kind + data + struct.pack('!I', zlib.crc32(kind + data)) | |
| 17 | ||
| 18 | page = ET.Element(f'{{{NS}}}Page', {'name': 'Baseline anchors'}) | |
| 19 | ET.SubElement(page, f'{{{NS}}}QuickStyleDef', { | |
| 20 | 'index': '0', 'name': 'p', 'font': 'Calibri', 'fontSize': '11', | |
| 21 | 'fontColor': 'automatic', 'highlightColor': 'automatic', 'spaceBefore': '0', 'spaceAfter': '0', | |
| 22 | }) | |
| 23 | ET.SubElement(page, f'{{{NS}}}QuickStyleDef', { | |
| 24 | 'index': '1', 'name': 'h2', 'font': 'Arial', 'fontSize': '11', 'bold': 'false', | |
| 25 | 'fontColor': 'automatic', 'highlightColor': 'automatic', 'spaceBefore': '3', 'spaceAfter': '4', | |
| 26 | }) | |
| 27 | title = ET.SubElement(page, f'{{{NS}}}Title') | |
| 28 | oe = ET.SubElement(title, f'{{{NS}}}OE') | |
| 29 | ET.SubElement(oe, f'{{{NS}}}T').text = 'Baseline anchors' | |
| 30 | for index, (x, y, width, height) in enumerate([ | |
| 31 | (20.125, 60.25, 11, 13), (440.5, 355.375, 17, 19), (25.75, 650.875, 23, 29), | |
| 32 | ]): | |
| 33 | color = bytes(255 if channel == index else 0 for channel in range(3)) | |
| 34 | pixels = (b'\x00' + color * (width * 2)) * (height * 2) | |
| 35 | png = (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('!2I5B', width * 2, height * 2, 8, 2, 0, 0, 0)) | |
| 36 | + chunk(b'IDAT', zlib.compress(pixels)) + chunk(b'IEND', b'')) | |
| 37 | image = ET.SubElement(page, f'{{{NS}}}Image', {'format': 'png'}) | |
| 38 | ET.SubElement(image, f'{{{NS}}}Position', {'x': str(x), 'y': str(y), 'z': str(index)}) | |
| 39 | ET.SubElement(image, f'{{{NS}}}Size', {'width': str(width), 'height': str(height), 'isSetByUser': 'true'}) | |
| 40 | ET.SubElement(image, f'{{{NS}}}Data').text = base64.b64encode(png).decode() | |
| 41 | for index, (y, width, font, size, paragraphs) in enumerate([ | |
| 42 | (90, 300, 'Arial', 11, ['Arial eleven HAMBURGEFONTS']), | |
| 43 | (150, 300, 'Arial', 16, ['Arial sixteen HAMBURGEFONTS']), | |
| 44 | (210, 300, 'Calibri', 11, ['Calibri eleven HAMBURGEFONTS']), | |
| 45 | (270, 300, 'Calibri', 17, ['Calibri seventeen HAMBURGEFONTS']), | |
| 46 | (330, 110, 'Arial', 11, ['Wrapped HAMBURGEFONTS abcdefghijklmnopqrstuvwxyz 0123456789']), | |
| 47 | (420, 300, 'Arial', 11, ['Mixed <span style="font-family:Arial;font-size:16pt">HAMBURGEFONTS</span> small']), | |
| 48 | (490, 300, 'Arial', 11, ['Spacing first', '', 'Spacing last']), | |
| 49 | (570, 300, 'Calibri', 11, ['Blank first', '', 'Blank last']), | |
| 50 | ]): | |
| 51 | outline = ET.SubElement(page, f'{{{NS}}}Outline') | |
| 52 | ET.SubElement(outline, f'{{{NS}}}Position', {'x': '120', 'y': str(y), 'z': str(index + 3)}) | |
| 53 | ET.SubElement(outline, f'{{{NS}}}Size', {'width': str(width), 'height': '20', 'isSetByUser': 'true'}) | |
| 54 | children = ET.SubElement(outline, f'{{{NS}}}OEChildren') | |
| 55 | for text in paragraphs: | |
| 56 | style = f'font-family:{font};font-size:{size}pt' | |
| 57 | oe = ET.SubElement(children, f'{{{NS}}}OE', {'style': style}) | |
| 58 | if y == 490: | |
| 59 | oe.set('quickStyleIndex', '1') | |
| 60 | ET.SubElement(oe, f'{{{NS}}}T').text = text | |
| 61 | return {'sections': ['Baselines.one'], 'pages': [{'section': 'Baselines.one', 'xml': ET.tostring(page, encoding='unicode')}]} | |
| 62 | ||
| 63 | ||
| 64 | if __name__ == '__main__': | |
| 65 | parser = argparse.ArgumentParser(description=__doc__) | |
| 66 | parser.add_argument('destination', type=Path) | |
| 67 | args = parser.parse_args() | |
| 68 | args.destination.mkdir(parents=True, exist_ok=False) | |
| 69 | (args.destination / 'fixture.json').write_text(json.dumps(build(), indent=2) + '\n') |
tools/canvas/build_macos.py created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Build the local macOS canvas bundle.""" | |
| 3 | import argparse | |
| 4 | import json | |
| 5 | from pathlib import Path | |
| 6 | import plistlib | |
| 7 | import shutil | |
| 8 | import subprocess | |
| 9 | ||
| 10 | parser = argparse.ArgumentParser(description=__doc__) | |
| 11 | parser.add_argument('--release', action='store_true') | |
| 12 | parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path') | |
| 13 | parser.add_argument('--bundle-id', help='Bundle identifier for the separate app') | |
| 14 | args = parser.parse_args() | |
| 15 | if bool(args.output) != bool(args.bundle_id): | |
| 16 | parser.error('Use --output and --bundle-id together.') | |
| 17 | if args.output and (args.output.suffix != '.app' or args.output.exists()): | |
| 18 | parser.error('Choose a new output path ending in .app.') | |
| 19 | root = Path(__file__).resolve().parents[2] | |
| 20 | subprocess.run(['cargo', 'build', '-p', 'one-canvas-app'] + (['--release'] if args.release else []), cwd=root, check=True) | |
| 21 | metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root)) | |
| 22 | target = Path(metadata['target_directory']) | |
| 23 | bundle = args.output.resolve() if args.output else target / 'One Canvas.app' | |
| 24 | if args.output: | |
| 25 | bundle.mkdir(parents=True, exist_ok=False) | |
| 26 | binary = bundle / 'Contents/MacOS/OneCanvas' | |
| 27 | binary.parent.mkdir(parents=True, exist_ok=True) | |
| 28 | pending = binary.with_suffix('.next') | |
| 29 | shutil.copy2(target / ('release' if args.release else 'debug') / 'one-canvas-app', pending) | |
| 30 | pending.replace(binary) | |
| 31 | (bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({ | |
| 32 | 'CFBundleExecutable': binary.name, | |
| 33 | 'CFBundleIdentifier': args.bundle_id or 'dev.onecanvas.app', | |
| 34 | 'CFBundleName': bundle.stem, | |
| 35 | 'CFBundleDisplayName': bundle.stem, | |
| 36 | 'CFBundlePackageType': 'APPL', | |
| 37 | 'CFBundleVersion': '1', | |
| 38 | 'NSHighResolutionCapable': True, | |
| 39 | 'NSPrincipalClass': 'NSApplication', | |
| 40 | })) | |
| 41 | subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True) | |
| 42 | subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True) | |
| 43 | print(bundle) |
tools/canvas/capture_interaction.py created+56| ... | ... | @@ -0,0 +1,56 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Record one native input script, screenshot and page XML in an inspected clone.""" | |
| 3 | import argparse | |
| 4 | import base64 | |
| 5 | import hashlib | |
| 6 | import json | |
| 7 | from pathlib import Path | |
| 8 | import re | |
| 9 | import sys | |
| 10 | import xml.etree.ElementTree as ET | |
| 11 | ||
| 12 | sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'w7')) | |
| 13 | import mcp_win7 as windows | |
| 14 | ||
| 15 | ||
| 16 | def capture(target, page, script, output): | |
| 17 | machine = json.loads((page.parent.parent / 'machine.json').read_text()) | |
| 18 | if machine['name'] != target: | |
| 19 | raise ValueError('The page belongs to a different native clone.') | |
| 20 | page_id = ET.parse(page).getroot().attrib['ID'] | |
| 21 | if not re.fullmatch(r'\{[0-9A-Fa-f-]+\}\{[0-9]+\}\{[0-9A-Fa-f]+\}', page_id): | |
| 22 | raise ValueError('The page identity cannot be used for native export.') | |
| 23 | source = script.read_bytes() | |
| 24 | output.mkdir(parents=True, exist_ok=False) | |
| 25 | result = windows.do_exec(source.decode('utf-8'), target=target, | |
| 26 | timeout_ms=60000, shot_delay_ms=600) | |
| 27 | record = {key: value for key, value in result.items() if key != 'png_b64'} | |
| 28 | record.update(target=target, page_id=page_id, script=str(script.resolve()), | |
| 29 | script_sha256=hashlib.sha256(source).hexdigest(), | |
| 30 | source_xml_sha256=hashlib.sha256(page.read_bytes()).hexdigest()) | |
| 31 | (output / 'input.json').write_text(json.dumps(record, indent=2) + '\n') | |
| 32 | if result.get('error') or result.get('exit') != 0 or not result.get('png_b64'): | |
| 33 | raise RuntimeError('Native input failed; inspect input.json.') | |
| 34 | (output / 'screen.png').write_bytes(base64.b64decode(result['png_b64'], validate=True)) | |
| 35 | remote = r'C:\one-tests\interaction.xml' | |
| 36 | command = ('powershell -NoProfile -Command "' | |
| 37 | "$app = New-Object -ComObject OneNote.Application; $xml = ''; " | |
| 38 | f"$app.GetPageContent('{page_id}', [ref]$xml, 1, 1); " | |
| 39 | f"[IO.File]::WriteAllText('{remote}', $xml, [Text.Encoding]::UTF8)\"") | |
| 40 | result = windows.do_cmd(command, target=target) | |
| 41 | (output / 'export.json').write_text(json.dumps(result, indent=2) + '\n') | |
| 42 | if result.get('error') or result.get('exit') != 0: | |
| 43 | raise RuntimeError('Native export failed; inspect export.json.') | |
| 44 | result = windows.do_get(remote, output / 'page.xml', target) | |
| 45 | if result.get('error'): | |
| 46 | raise RuntimeError(result['error']) | |
| 47 | ||
| 48 | ||
| 49 | if __name__ == '__main__': | |
| 50 | parser = argparse.ArgumentParser(description=__doc__) | |
| 51 | parser.add_argument('target') | |
| 52 | parser.add_argument('page_xml', type=Path) | |
| 53 | parser.add_argument('script', type=Path) | |
| 54 | parser.add_argument('output', type=Path) | |
| 55 | args = parser.parse_args() | |
| 56 | capture(args.target, args.page_xml, args.script, args.output) |
tools/canvas/capture_view.py created+106| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Capture a native page at 100% in a disposable clone held by native_runner.py --inspect.""" | |
| 3 | import argparse | |
| 4 | import base64 | |
| 5 | import hashlib | |
| 6 | import json | |
| 7 | from pathlib import Path | |
| 8 | import re | |
| 9 | import sys | |
| 10 | import xml.etree.ElementTree as ET | |
| 11 | ||
| 12 | ROOT = Path(__file__).resolve().parents[2] | |
| 13 | sys.path.insert(0, str(ROOT / 'tools/w7')) | |
| 14 | import mcp_win7 as windows | |
| 15 | ||
| 16 | ||
| 17 | def capture(target, page, output, scrolls): | |
| 18 | machine = json.loads((page.parent.parent / 'machine.json').read_text()) | |
| 19 | if machine['name'] != target: | |
| 20 | raise ValueError('The native capture belongs to a different disposable clone') | |
| 21 | page_id = ET.parse(page).getroot().attrib['ID'] | |
| 22 | if not re.fullmatch(r'\{[0-9A-Fa-f-]+\}\{[0-9]+\}\{[0-9A-Fa-f]+\}', page_id): | |
| 23 | raise ValueError('Invalid native page identity') | |
| 24 | if not 0 <= scrolls <= 20: | |
| 25 | raise ValueError('Choose between zero and twenty scroll steps') | |
| 26 | output.mkdir(parents=True, exist_ok=False) | |
| 27 | setup = ''' | |
| 28 | dm := Buffer(220, 0) | |
| 29 | NumPut("UShort", 220, dm, 68) | |
| 30 | if !DllCall("EnumDisplaySettingsW", "Ptr", 0, "UInt", -1, "Ptr", dm) | |
| 31 | throw Error("Unable to read display mode") | |
| 32 | if NumGet(dm, 172, "UInt") != 1600 || NumGet(dm, 176, "UInt") != 900 { | |
| 33 | NumPut("UInt", 0x180000, dm, 72) | |
| 34 | NumPut("UInt", 1600, dm, 172) | |
| 35 | NumPut("UInt", 900, dm, 176) | |
| 36 | result := DllCall("ChangeDisplaySettingsW", "Ptr", dm, "UInt", 0, "Int") | |
| 37 | if result != 0 | |
| 38 | throw Error("Display mode rejected: " result) | |
| 39 | } | |
| 40 | Sleep(300) | |
| 41 | app := ComObject("OneNote.Application") | |
| 42 | ''' | |
| 43 | setup += f'app.NavigateTo("{page_id}", "", false)\n' | |
| 44 | setup += ''' | |
| 45 | hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 10) | |
| 46 | WinMaximize(hwnd) | |
| 47 | WinActivate(hwnd) | |
| 48 | WinWaitActive(hwnd,, 10) | |
| 49 | Send("{Esc}") | |
| 50 | Sleep(200) | |
| 51 | Send("!w") | |
| 52 | Sleep(200) | |
| 53 | Send("q") | |
| 54 | Sleep(100) | |
| 55 | Send("^a") | |
| 56 | SendText("100%") | |
| 57 | Send("{Enter}{Esc}") | |
| 58 | Sleep(200) | |
| 59 | Send("!w") | |
| 60 | dc := DllCall("GetDC", "Ptr", 0, "Ptr") | |
| 61 | FileAppend("dpi=" DllCall("GetDeviceCaps", "Ptr", dc, "Int", 88) "," DllCall("GetDeviceCaps", "Ptr", dc, "Int", 90) "`n", "*") | |
| 62 | DllCall("ReleaseDC", "Ptr", 0, "Ptr", dc) | |
| 63 | FileAppend("screen=" SysGet(0) "x" SysGet(1) "`n", "*") | |
| 64 | ''' | |
| 65 | steps = [('zoom', setup), ('top', ''' | |
| 66 | Send("{Esc}{Esc}") | |
| 67 | WinActivate("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE") | |
| 68 | MouseMove(1000, 400) | |
| 69 | Send("{WheelUp 100}") | |
| 70 | MouseMove(800, 12) | |
| 71 | ''')] | |
| 72 | steps += [(f'scroll-{index + 1}', ''' | |
| 73 | MouseMove(1000, 400) | |
| 74 | Send("{WheelDown 8}") | |
| 75 | MouseMove(800, 12) | |
| 76 | ''') for index in range(scrolls)] | |
| 77 | records = [] | |
| 78 | for name, script in steps: | |
| 79 | script = 'OnError((exception, mode) => (FileAppend(exception.Message, "**"), ExitApp(1)))\n' + script | |
| 80 | if name != 'zoom': | |
| 81 | script += '\nhwnd := WinExist("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE")\nDllCall("RedrawWindow", "Ptr", hwnd, "Ptr", 0, "Ptr", 0, "UInt", 0x585)\n' | |
| 82 | (output / f'{name}.ahk').write_text(script) | |
| 83 | result = windows.do_exec(script, target=target, timeout_ms=30000, shot_delay_ms=700) | |
| 84 | record = {key: value for key, value in result.items() if key != 'png_b64'} | |
| 85 | (output / f'{name}.json').write_text(json.dumps(record, indent=2) + '\n') | |
| 86 | if result.get('error') or result.get('exit') != 0 or not result.get('png_b64'): | |
| 87 | raise RuntimeError(f'Native capture failed: {name}; inspect its JSON record') | |
| 88 | data = base64.b64decode(result['png_b64'], validate=True) | |
| 89 | (output / f'{name}.png').write_bytes(data) | |
| 90 | records.append({'frame': name, 'sha256': hashlib.sha256(data).hexdigest()}) | |
| 91 | (output / 'manifest.json').write_text(json.dumps({ | |
| 92 | 'target': target, 'source_xml_name': page.name, 'source_page_id': page_id, | |
| 93 | 'source_sha256': hashlib.sha256(page.read_bytes()).hexdigest(), | |
| 94 | 'zoom_percent': 100, 'screen': [1600, 900], 'wheel_notches_per_step': 8, | |
| 95 | 'frames': records, | |
| 96 | }, indent=2) + '\n') | |
| 97 | ||
| 98 | ||
| 99 | if __name__ == '__main__': | |
| 100 | parser = argparse.ArgumentParser(description=__doc__) | |
| 101 | parser.add_argument('target') | |
| 102 | parser.add_argument('page_xml', type=Path) | |
| 103 | parser.add_argument('output', type=Path) | |
| 104 | parser.add_argument('--scrolls', type=int, default=0) | |
| 105 | args = parser.parse_args() | |
| 106 | capture(args.target, args.page_xml, args.output, args.scrolls) |
tools/canvas/compare.py created+143| ... | ... | @@ -0,0 +1,143 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Compare text probe output with native XML geometry and recoverable PDF line ranges.""" | |
| 3 | import argparse | |
| 4 | import json | |
| 5 | from pathlib import Path | |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | ||
| 8 | import pdfplumber | |
| 9 | from native_fixture import build | |
| 10 | ||
| 11 | NS = {'one': 'http://schemas.microsoft.com/office/onenote/2010/onenote'} | |
| 12 | ||
| 13 | ||
| 14 | def pdf_lines(chars): | |
| 15 | lines = [] | |
| 16 | for char in sorted(chars, key=lambda c: c['matrix'][5], reverse=True): | |
| 17 | overlaps = [line for line in lines if max(line[0], char['y0']) < min(line[1], char['y1'])] | |
| 18 | if len(overlaps) > 1: | |
| 19 | return None | |
| 20 | if overlaps: | |
| 21 | line = overlaps[0] | |
| 22 | line[0] = max(line[0], char['y0']) | |
| 23 | line[1] = min(line[1], char['y1']) | |
| 24 | line[2].append(char) | |
| 25 | else: | |
| 26 | lines.append([char['y0'], char['y1'], [char]]) | |
| 27 | return [sorted(line[2], key=lambda c: c['x0']) for line in lines] | |
| 28 | ||
| 29 | ||
| 30 | def pdf_line_ends(page, source): | |
| 31 | if not source or not source.isascii(): | |
| 32 | return None | |
| 33 | groups = pdf_lines(page.chars) | |
| 34 | if groups is None: | |
| 35 | return None | |
| 36 | lines = [''.join(c['text'] for c in chars) for chars in groups] | |
| 37 | compact_source = ''.join(source.split()) | |
| 38 | matches = [] | |
| 39 | for start in range(len(lines)): | |
| 40 | for end in range(start + 1, len(lines) + 1): | |
| 41 | if ''.join(''.join(lines[start:end]).split()) == compact_source: | |
| 42 | matches.append(lines[start:end]) | |
| 43 | if len(matches) != 1: | |
| 44 | return None | |
| 45 | offset = 0 | |
| 46 | ends = [] | |
| 47 | for line in matches[0]: | |
| 48 | for character in ''.join(line.split()): | |
| 49 | while offset < len(source) and source[offset].isspace(): | |
| 50 | offset += 1 | |
| 51 | if offset == len(source) or source[offset] != character: | |
| 52 | return None | |
| 53 | offset += 1 | |
| 54 | while offset < len(source) and source[offset].isspace(): | |
| 55 | offset += 1 | |
| 56 | ends.append(offset) | |
| 57 | return ends if offset == len(source) else None | |
| 58 | ||
| 59 | ||
| 60 | def compare(cases, parley, core_text, native): | |
| 61 | if json.loads((native.parent / 'notebook' / 'fixture.json').read_text(encoding='utf-8-sig')) != build(cases): | |
| 62 | raise ValueError('Native capture was generated from different input cases') | |
| 63 | parley = {case['id']: case for case in parley['cases']} | |
| 64 | core_text = {case['id']: case for case in core_text['cases']} | |
| 65 | pages = {} | |
| 66 | for path in native.glob('page-*.xml'): | |
| 67 | page = ET.parse(path).getroot() | |
| 68 | name = page.get('name') | |
| 69 | if name in pages: | |
| 70 | raise ValueError(f'Duplicate native page: {name}') | |
| 71 | pages[name] = (path, page) | |
| 72 | ids = {case['id'] for case in cases} | |
| 73 | if len(ids) != len(cases) or ids != parley.keys() or ids != core_text.keys() or ids != pages.keys(): | |
| 74 | raise ValueError('Input and captured case identities must match exactly') | |
| 75 | results = [] | |
| 76 | for case in cases: | |
| 77 | name = case['id'] | |
| 78 | p, c = parley[name], core_text[name] | |
| 79 | for result in (p, c): | |
| 80 | if result['width'] != case['width']: | |
| 81 | raise ValueError(f'{name}: probe used a different width') | |
| 82 | actual = [{k: v for k, v in run.items() if k != 'font'} for run in result['requested_runs']] | |
| 83 | expected = [{k: v for k, v in run.items() if k != 'font'} for run in case['runs']] | |
| 84 | if actual != expected: | |
| 85 | raise ValueError(f'{name}: probe used different text or styles') | |
| 86 | path, page = pages[name] | |
| 87 | sizes = page.findall('one:Outline/one:Size', NS) | |
| 88 | source = ''.join(run['text'] for run in case['runs']) | |
| 89 | native_ends = None | |
| 90 | with pdfplumber.open(path.with_suffix('.pdf')) as pdf: | |
| 91 | if len(pdf.pages) == 1: | |
| 92 | native_ends = pdf_line_ends(pdf.pages[0], source) | |
| 93 | dimensions = [{k: float(size.attrib[k]) for k in ('width', 'height')} for size in sizes] | |
| 94 | width_changed = len(dimensions) != 1 or abs(dimensions[0]['width'] - case['width']) > 0.001 | |
| 95 | windows_height = 0.0 | |
| 96 | for line in p['lines']: | |
| 97 | runs = line['runs'] | |
| 98 | if not runs or any(r[k] is None for r in runs for k in ('win_ascent', 'win_descent', 'units_per_em')): | |
| 99 | windows_height = None | |
| 100 | break | |
| 101 | windows_height += max(r['win_ascent'] * r['size'] / r['units_per_em'] for r in runs) | |
| 102 | windows_height += max(r['win_descent'] * r['size'] / r['units_per_em'] for r in runs) | |
| 103 | ends = {engine: [line['end_utf16'] for line in result['lines']] | |
| 104 | for engine, result in [('parley', p), ('core_text', c)]} | |
| 105 | results.append({ | |
| 106 | 'id': name, | |
| 107 | 'requested_font_override': {engine: [r['font'] for r in result['requested_runs']] | |
| 108 | for engine, result in [('parley', p), ('core_text', c)]} | |
| 109 | if any(result['requested_runs'] != case['runs'] for result in (p, c)) else None, | |
| 110 | 'requested_width': case['width'], | |
| 111 | 'native_outlines': dimensions, | |
| 112 | 'native_changed_width_or_removed_outline': width_changed, | |
| 113 | 'native_pdf_end_utf16': native_ends, | |
| 114 | 'end_utf16': ends, | |
| 115 | 'parley_default_height': p['height'], | |
| 116 | 'canvas_height': p.get('windows_height'), | |
| 117 | 'canvas_height_residual': (p['windows_height'] - dimensions[0]['height']) | |
| 118 | if p.get('windows_height') is not None and not width_changed else None, | |
| 119 | 'windows_metric_height_hypothesis': windows_height, | |
| 120 | 'height_residual_hypothesis': (windows_height - dimensions[0]['height']) | |
| 121 | if windows_height is not None and not width_changed else None, | |
| 122 | 'parley_resolved_faces': sorted({r['face'] for line in p['lines'] for r in line['runs']}), | |
| 123 | 'core_text_requested_faces_resolved': c['requested_faces_resolved'], | |
| 124 | 'same_width_pdf_break_agreement': {engine: values == native_ends for engine, values in ends.items()} | |
| 125 | if native_ends is not None and not width_changed else None, | |
| 126 | }) | |
| 127 | return {'cases': results, | |
| 128 | 'limits': ['PDF ranges are supplementary evidence, recovered only for unambiguous ASCII text.', | |
| 129 | 'Metric hypotheses use the Mac-resolved fonts and do not establish matching native font identity.', | |
| 130 | 'Width changes and removed empty outlines require separate native cases.', | |
| 131 | 'XML height alone does not establish first-baseline placement.']} | |
| 132 | ||
| 133 | ||
| 134 | if __name__ == '__main__': | |
| 135 | parser = argparse.ArgumentParser(description=__doc__) | |
| 136 | parser.add_argument('cases', type=Path) | |
| 137 | parser.add_argument('parley', type=Path) | |
| 138 | parser.add_argument('core_text', type=Path) | |
| 139 | parser.add_argument('native_read', type=Path) | |
| 140 | args = parser.parse_args() | |
| 141 | result = compare(json.loads(args.cases.read_text()), json.loads(args.parley.read_text()), | |
| 142 | json.loads(args.core_text.read_text()), args.native_read) | |
| 143 | print(json.dumps(result, indent=2)) |
tools/canvas/compare_baselines.py created+118| ... | ... | @@ -0,0 +1,118 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Compare PDF baselines using identical opaque image anchors, independently of text layout.""" | |
| 3 | import argparse | |
| 4 | import base64 | |
| 5 | from collections import Counter, defaultdict | |
| 6 | import hashlib | |
| 7 | import io | |
| 8 | import json | |
| 9 | import math | |
| 10 | from pathlib import Path | |
| 11 | import statistics | |
| 12 | import xml.etree.ElementTree as ET | |
| 13 | ||
| 14 | import pdfplumber | |
| 15 | from PIL import Image | |
| 16 | ||
| 17 | from compare_page_pdf import compare | |
| 18 | from native_fixture import NS | |
| 19 | ||
| 20 | ||
| 21 | def pdf_rgb(image): | |
| 22 | stream = image['stream'] | |
| 23 | if any(key in stream.attrs for key in ['Mask', 'SMask', 'Decode']) or image.get('imagemask'): | |
| 24 | return None | |
| 25 | space = image['colorspace'] | |
| 26 | names = [getattr(value, 'name', None) for value in space] | |
| 27 | size = tuple(image['srcsize']) | |
| 28 | if names == ['DeviceRGB'] and image['bits'] == 8: | |
| 29 | return Image.frombytes('RGB', size, stream.get_data()).tobytes() | |
| 30 | if names[:2] == ['Indexed', 'DeviceRGB'] and len(space) == 4 and image['bits'] in (4, 8) and isinstance(space[3], bytes): | |
| 31 | pixels = Image.frombytes('P', size, stream.get_data(), 'raw', 'P;4' if image['bits'] == 4 else 'P') | |
| 32 | if len(space[3]) != (space[2] + 1) * 3 or pixels.getextrema()[1] > space[2]: | |
| 33 | return None | |
| 34 | pixels.putpalette(space[3]) | |
| 35 | return pixels.convert('RGB').tobytes() | |
| 36 | return None | |
| 37 | ||
| 38 | ||
| 39 | def register_images(xml, pages): | |
| 40 | sources = [] | |
| 41 | for node in xml.findall(f'{{{NS}}}Image'): | |
| 42 | data = node.findtext(f'{{{NS}}}Data') | |
| 43 | if not data: | |
| 44 | continue | |
| 45 | with Image.open(io.BytesIO(base64.b64decode(''.join(data.split()), validate=True))) as image: | |
| 46 | if image.convert('RGBA').getchannel('A').getextrema() != (255, 255): | |
| 47 | continue | |
| 48 | key = (image.size, hashlib.sha256(image.convert('RGB').tobytes()).hexdigest()) | |
| 49 | position = node.find(f'{{{NS}}}Position') | |
| 50 | size = node.find(f'{{{NS}}}Size') | |
| 51 | rect = [float(position.get('x')), float(position.get('y')), float(size.get('width')), float(size.get('height'))] | |
| 52 | if not all(map(math.isfinite, rect)) or min(rect[2:]) <= 0: | |
| 53 | raise ValueError('Invalid source image geometry') | |
| 54 | sources.append((key, node.get('objectID'), rect)) | |
| 55 | counts = Counter(key for key, _, _ in sources) | |
| 56 | candidates = defaultdict(list) | |
| 57 | for index, page in enumerate(pages): | |
| 58 | for image in page.images: | |
| 59 | pixels = pdf_rgb(image) | |
| 60 | if pixels is not None: | |
| 61 | candidates[(tuple(image['srcsize']), hashlib.sha256(pixels).hexdigest())].append((index, image)) | |
| 62 | registrations = defaultdict(list) | |
| 63 | for key, identity, rect in sources: | |
| 64 | matches = candidates[key] | |
| 65 | if counts[key] != 1 or len(matches) != 1: | |
| 66 | continue | |
| 67 | index, image = matches[0] | |
| 68 | pdf_rect = [image['x0'], image['top'], image['x1'], image['bottom']] | |
| 69 | if not all(map(math.isfinite, pdf_rect)) or pdf_rect[2] <= pdf_rect[0] or pdf_rect[3] <= pdf_rect[1]: | |
| 70 | raise ValueError('Invalid PDF image geometry') | |
| 71 | registrations[index].append({ | |
| 72 | 'source_id': identity, 'pixel_sha256': key[1], 'source_xywh': rect, 'pdf_rect': pdf_rect, | |
| 73 | 'translation': [pdf_rect[0] - rect[0], pdf_rect[1] - rect[1]], | |
| 74 | 'extent_scale': [(pdf_rect[2] - pdf_rect[0]) / rect[2], (pdf_rect[3] - pdf_rect[1]) / rect[3]], | |
| 75 | }) | |
| 76 | return dict(registrations) | |
| 77 | ||
| 78 | ||
| 79 | def baseline_report(probe, xml, pages): | |
| 80 | anchors = register_images(xml, pages) | |
| 81 | wraps = compare(probe, pages) | |
| 82 | outlines = {o['id']: o for o in probe['objects'] if o['kind'] == 'outline' and not o['is_title']} | |
| 83 | rows = [] | |
| 84 | for paragraph in wraps['paragraphs']: | |
| 85 | if paragraph['status'] != 'matched': | |
| 86 | rows.append({**paragraph, 'lines': []}) | |
| 87 | continue | |
| 88 | outline = outlines[paragraph['outline_id']] | |
| 89 | for instance in paragraph['native_instances']: | |
| 90 | page = instance['pdf_page'] - 1 | |
| 91 | registration = anchors.get(page, []) | |
| 92 | row = {'outline_id': paragraph['outline_id'], 'paragraph_id': paragraph['paragraph_id'], 'pdf_page': page + 1, 'lines': []} | |
| 93 | rows.append(row) | |
| 94 | if len({a['source_xywh'][1] for a in registration}) < 2: | |
| 95 | row['status'] = 'insufficient_image_anchors' | |
| 96 | continue | |
| 97 | row['status'] = 'measured_translation_residuals' | |
| 98 | offsets = [a['translation'][1] for a in registration] | |
| 99 | for native, local in zip(instance['baseline_ranges_from_pdf_top'], paragraph['canvas_baselines_in_outline'], strict=True): | |
| 100 | document = outline['layout']['y'] + local | |
| 101 | row['lines'].append({ | |
| 102 | 'canvas_document_baseline': document, 'native_pdf_baseline_range': native, | |
| 103 | 'residual_using_median_anchor_translation': [v - document - statistics.median(offsets) for v in native], | |
| 104 | 'residual_range_over_anchor_translations': [native[0] - document - max(offsets), native[1] - document - min(offsets)], | |
| 105 | }) | |
| 106 | return {'anchors_by_pdf_page': {str(page + 1): rows for page, rows in anchors.items()}, | |
| 107 | 'wrap_counts': wraps['counts'], 'paragraphs': rows, | |
| 108 | 'limits': 'Translation candidates come only from identical opaque images. Their spread and extent scales remain visible; no text-fitted registration or baseline acceptance tolerance is applied. PDF coordinates do not establish screen glyph baselines.'} | |
| 109 | ||
| 110 | ||
| 111 | if __name__ == '__main__': | |
| 112 | parser = argparse.ArgumentParser(description=__doc__) | |
| 113 | parser.add_argument('probe', type=Path) | |
| 114 | parser.add_argument('native_xml', type=Path) | |
| 115 | parser.add_argument('native_pdf', type=Path) | |
| 116 | args = parser.parse_args() | |
| 117 | with pdfplumber.open(args.native_pdf) as pdf: | |
| 118 | print(json.dumps(baseline_report(json.loads(args.probe.read_text()), ET.parse(args.native_xml).getroot(), pdf.pages), indent=2)) |
tools/canvas/compare_page.py created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Compare owned-page probe geometry with native XML after exact text matching.""" | |
| 3 | import argparse | |
| 4 | from html.parser import HTMLParser | |
| 5 | import json | |
| 6 | from pathlib import Path | |
| 7 | import xml.etree.ElementTree as ET | |
| 8 | ||
| 9 | NS = {'one': 'http://schemas.microsoft.com/office/onenote/2010/onenote'} | |
| 10 | ||
| 11 | ||
| 12 | class Text(HTMLParser): | |
| 13 | def __init__(self, markup): | |
| 14 | super().__init__(convert_charrefs=True) | |
| 15 | self.parts = [] | |
| 16 | self.feed(markup) | |
| 17 | self.close() | |
| 18 | ||
| 19 | def handle_data(self, data): | |
| 20 | self.parts.append(data) | |
| 21 | ||
| 22 | def handle_starttag(self, tag, attrs): | |
| 23 | if tag == 'br': | |
| 24 | self.parts.append('\n') | |
| 25 | ||
| 26 | ||
| 27 | def compare(probe, native, allow_native_empty_nbsp=False): | |
| 28 | if probe['title'] != native.get('name'): | |
| 29 | raise ValueError('Source and native page titles differ') | |
| 30 | outlines = native.findall('one:Outline', NS) | |
| 31 | available = [] | |
| 32 | for outline in outlines: | |
| 33 | paragraphs = outline.findall('.//one:OE', NS) | |
| 34 | text = tuple(''.join(''.join(Text(t.text or '').parts) | |
| 35 | for t in p.findall('one:T', NS)) for p in paragraphs) | |
| 36 | if any(p.find('one:Image', NS) is not None or p.find('one:Table', NS) is not None | |
| 37 | for p in paragraphs): | |
| 38 | raise ValueError('Native outline includes non-text paragraph content') | |
| 39 | available.append((text, outline)) | |
| 40 | results = [] | |
| 41 | for outline in probe['objects']: | |
| 42 | if outline['kind'] != 'outline' or outline['is_title']: | |
| 43 | continue | |
| 44 | paragraphs = outline['paragraphs'] | |
| 45 | if outline['unsupported'] or any(p['unsupported'] for p in paragraphs): | |
| 46 | raise ValueError('Source outline includes unsupported content') | |
| 47 | text = tuple(p['visible_text'] for p in paragraphs) | |
| 48 | match_text = tuple('' if t == '\u00a0' else t for t in text) if allow_native_empty_nbsp else text | |
| 49 | matches = [(index, native) for index, (candidate, native) in enumerate(available) | |
| 50 | if candidate == match_text] | |
| 51 | if len(matches) != 1: | |
| 52 | raise ValueError(f'{outline["id"]}: expected one native outline text match; got {len(matches)}') | |
| 53 | index, matched = matches[0] | |
| 54 | del available[index] | |
| 55 | position = matched.find('one:Position', NS).attrib | |
| 56 | size = matched.find('one:Size', NS).attrib | |
| 57 | layout = outline['layout'] | |
| 58 | height = outline['size'][1] if outline.get('size') else sum(p['height'] for p in paragraphs) | |
| 59 | results.append({ | |
| 60 | 'source_id': outline['id'], 'native_id': matched.get('objectID'), | |
| 61 | 'paragraphs': len(paragraphs), | |
| 62 | 'text_matches_exactly': text == match_text, | |
| 63 | 'native_empty_nbsp_paragraphs': [i for i, (a, b) in enumerate(zip(text, match_text)) if a != b], | |
| 64 | 'source_width': layout['max_width'], 'native_width': float(size['width']), | |
| 65 | 'layout_height': height, 'native_height': float(size['height']), | |
| 66 | 'height_residual': height - float(size['height']), | |
| 67 | 'inferred_native_margin_origin': [float(position[axis]) - layout[axis] + margin | |
| 68 | for axis, margin in zip(('x', 'y'), probe['margin_origin'])], | |
| 69 | 'nested_paragraphs': sum(p['parent'] is not None for p in paragraphs), | |
| 70 | 'list_paragraphs': sum(bool(p['lists']) for p in paragraphs), | |
| 71 | }) | |
| 72 | if available: | |
| 73 | raise ValueError('Native capture contains unmatched outlines') | |
| 74 | return {'title': probe['title'], 'outlines': results, | |
| 75 | 'limits': ['Text matching identifies outlines; this comparison does not verify run formatting.', | |
| 76 | probe['measurement'], | |
| 77 | 'Total height does not establish wrap offsets or first-baseline placement.', | |
| 78 | 'Inferred margin origins are observations, not a general page-origin rule.']} | |
| 79 | ||
| 80 | ||
| 81 | if __name__ == '__main__': | |
| 82 | parser = argparse.ArgumentParser(description=__doc__) | |
| 83 | parser.add_argument('probe', type=Path) | |
| 84 | parser.add_argument('native_xml', type=Path) | |
| 85 | parser.add_argument('--allow-native-empty-nbsp', action='store_true', | |
| 86 | help='Record native empty paragraphs replacing a sole source NBSP') | |
| 87 | args = parser.parse_args() | |
| 88 | print(json.dumps(compare(json.loads(args.probe.read_text()), ET.parse(args.native_xml).getroot(), | |
| 89 | args.allow_native_empty_nbsp), indent=2)) |
tools/canvas/compare_page_pdf.py created+116| ... | ... | @@ -0,0 +1,116 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Recover page-paragraph wrap evidence from the matching native PDF export.""" | |
| 3 | import argparse | |
| 4 | from collections import Counter | |
| 5 | import json | |
| 6 | from pathlib import Path | |
| 7 | from types import SimpleNamespace | |
| 8 | import xml.etree.ElementTree as ET | |
| 9 | ||
| 10 | import pdfplumber | |
| 11 | from compare import pdf_line_ends, pdf_lines | |
| 12 | from compare_page import compare as compare_xml | |
| 13 | ||
| 14 | ||
| 15 | def occurrences(text, needle): | |
| 16 | start = 0 | |
| 17 | while needle and (start := text.find(needle, start)) != -1: | |
| 18 | yield start | |
| 19 | start += 1 | |
| 20 | ||
| 21 | ||
| 22 | def compare(probe, pages, include_titles=False): | |
| 23 | outlines = [o for o in probe['objects'] if o['kind'] == 'outline' and (include_titles or not o['is_title'])] | |
| 24 | source_paragraphs = [''.join(p['visible_text'].split()) for o in outlines for p in o['paragraphs']] | |
| 25 | streams = [] | |
| 26 | for page in pages: | |
| 27 | chars = [dict(char, text=scalar) for char in page.chars | |
| 28 | for scalar in char['text'] if not scalar.isspace()] | |
| 29 | streams.append((''.join(c['text'] for c in chars), chars, page.height)) | |
| 30 | results = [] | |
| 31 | for outline in outlines: | |
| 32 | compact = [''.join(p['visible_text'].split()) for p in outline['paragraphs']] | |
| 33 | whole = ''.join(compact) | |
| 34 | outline_matches = [(page, start) for page, (text, _, _) in enumerate(streams) | |
| 35 | for start in occurrences(text, whole)] | |
| 36 | offset = 0 | |
| 37 | for index, (paragraph, text) in enumerate(zip(outline['paragraphs'], compact)): | |
| 38 | row = {'outline_id': outline['id'], 'paragraph_id': paragraph['id'], 'paragraph_index': index, | |
| 39 | 'is_title': outline['is_title'], | |
| 40 | 'canvas_end_utf16': [line['end_utf16'] for line in paragraph['lines']], | |
| 41 | 'native_end_utf16': None, 'breaks_match': None} | |
| 42 | results.append(row) | |
| 43 | if not text: | |
| 44 | row['status'] = 'no_visible_glyphs' | |
| 45 | continue | |
| 46 | if outline_matches: | |
| 47 | matches = [(page, start + offset) for page, start in outline_matches] | |
| 48 | row['match_context'] = 'complete_outline' | |
| 49 | else: | |
| 50 | matches = [(page, start) for page, (stream, _, _) in enumerate(streams) | |
| 51 | for start in occurrences(stream, text)] | |
| 52 | row['match_context'] = 'unique_paragraph' | |
| 53 | if sum(len(list(occurrences(source, text))) for source in source_paragraphs) != 1: | |
| 54 | matches = [] | |
| 55 | row['match_context'] = 'ambiguous_source_context' | |
| 56 | offset += len(text) | |
| 57 | if not matches: | |
| 58 | row['status'] = 'unresolved_match' | |
| 59 | continue | |
| 60 | first_byte = len(paragraph['visible_text'][:next(i for i, c in enumerate(paragraph['visible_text']) | |
| 61 | if not c.isspace())].encode('utf-8')) | |
| 62 | span = next(s for s in paragraph['spans'] if first_byte < s['end_utf8']) | |
| 63 | size = span['format'].get('font_size') | |
| 64 | if size is None: | |
| 65 | size = 11.0 | |
| 66 | instances = [] | |
| 67 | row['native_instances'] = instances | |
| 68 | for page, start in matches: | |
| 69 | _, chars, height = streams[page] | |
| 70 | matched = chars[start:start + len(text)] | |
| 71 | ends = pdf_line_ends(SimpleNamespace(chars=matched), paragraph['visible_text']) | |
| 72 | instance = {'pdf_page': page + 1, 'end_utf16': ends} | |
| 73 | instances.append(instance) | |
| 74 | groups = pdf_lines(matched) | |
| 75 | if groups is None: | |
| 76 | continue | |
| 77 | baselines = [(min(c['matrix'][5] for c in group), max(c['matrix'][5] for c in group)) | |
| 78 | for group in groups] | |
| 79 | instance['baseline_ranges_from_pdf_top'] = [[height - high, height - low] for low, high in baselines] | |
| 80 | instance['line_advance_ranges_pdf'] = [[a[0] - b[1], a[1] - b[0]] | |
| 81 | for a, b in zip(baselines, baselines[1:])] | |
| 82 | # Exported font sizes expose print scaling without fitting against canvas geometry. | |
| 83 | instance['first_glyph_nominal_font_scale'] = matched[0]['size'] / size | |
| 84 | ends = instances[0]['end_utf16'] | |
| 85 | if ends is None or any(instance['end_utf16'] != ends for instance in instances) or any( | |
| 86 | not line['text'].strip() for line in paragraph['lines']): | |
| 87 | row['status'] = 'unrecoverable_line_ranges' | |
| 88 | continue | |
| 89 | row['native_end_utf16'] = ends | |
| 90 | row['breaks_match'] = ends == row['canvas_end_utf16'] | |
| 91 | row['status'] = 'matched' if row['breaks_match'] else 'different_wraps' | |
| 92 | row['canvas_baselines_in_outline'] = [paragraph['origin'][1] + line['baseline'] | |
| 93 | for line in paragraph['lines']] | |
| 94 | row['canvas_line_advances'] = [b['baseline'] - a['baseline'] | |
| 95 | for a, b in zip(paragraph['lines'], paragraph['lines'][1:])] | |
| 96 | return {'title': probe['title'], 'counts': dict(Counter(row['status'] for row in results)), | |
| 97 | 'paragraphs': results, | |
| 98 | 'limits': ['Recovered wrap offsets require unambiguous ASCII paragraph text.', | |
| 99 | 'Complete-outline context disambiguates repeated paragraph text when available.', | |
| 100 | 'Repeated PDF occurrences must agree on every recovered break; all instances remain in the report.', | |
| 101 | 'PDF baseline coordinates include print scaling and pagination; they are not screen-baseline acceptance.', | |
| 102 | 'Empty paragraphs have no PDF glyph position; their geometry requires other evidence.']} | |
| 103 | ||
| 104 | ||
| 105 | if __name__ == '__main__': | |
| 106 | parser = argparse.ArgumentParser(description=__doc__) | |
| 107 | parser.add_argument('probe', type=Path) | |
| 108 | parser.add_argument('native_xml', type=Path) | |
| 109 | parser.add_argument('--allow-native-empty-nbsp', action='store_true') | |
| 110 | parser.add_argument('--include-titles', action='store_true') | |
| 111 | args = parser.parse_args() | |
| 112 | probe = json.loads(args.probe.read_text()) | |
| 113 | compare_xml(probe, ET.parse(args.native_xml).getroot(), args.allow_native_empty_nbsp) | |
| 114 | with pdfplumber.open(args.native_xml.with_suffix('.pdf')) as pdf: | |
| 115 | result = compare(probe, pdf.pages, args.include_titles) | |
| 116 | print(json.dumps(result, indent=2)) |
tools/canvas/compare_scroll.py created+70| ... | ... | @@ -0,0 +1,70 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Measure vertical scrolling from overlapping native screenshots.""" | |
| 3 | import argparse | |
| 4 | from collections import Counter, defaultdict | |
| 5 | import json | |
| 6 | from pathlib import Path | |
| 7 | ||
| 8 | import numpy as np | |
| 9 | from PIL import Image | |
| 10 | ||
| 11 | ||
| 12 | def compare(before, after, minimum_overlap=64): | |
| 13 | if (before.shape != after.shape or before.ndim != 3 or before.shape[2] != 3 | |
| 14 | or before.dtype != np.uint8 or after.dtype != np.uint8): | |
| 15 | raise ValueError('Screenshots must have the same RGB dimensions') | |
| 16 | height = before.shape[0] | |
| 17 | if not 1 <= minimum_overlap <= height: | |
| 18 | raise ValueError('Minimum overlap must fit inside the screenshot') | |
| 19 | rows = defaultdict(list) | |
| 20 | for y, row in enumerate(before): | |
| 21 | if (row.min(axis=1) < 210).any(): | |
| 22 | rows[row.tobytes()].append(y) | |
| 23 | votes = Counter() | |
| 24 | for y, row in enumerate(after): | |
| 25 | for old_y in rows.get(row.tobytes(), ()): | |
| 26 | delta = old_y - y | |
| 27 | if 0 <= delta <= height - minimum_overlap: | |
| 28 | votes[delta] += 1 | |
| 29 | if not votes: | |
| 30 | raise ValueError('No exact foreground rows establish a vertical overlap') | |
| 31 | candidates = [] | |
| 32 | for delta, count in votes.items(): | |
| 33 | a, b = before[delta:], after[:height - delta] | |
| 34 | foreground = (a.min(axis=2) < 210) | (b.min(axis=2) < 210) | |
| 35 | equal = (a == b).all(axis=2) | |
| 36 | candidates.append({ | |
| 37 | 'scroll_y': delta, 'overlap_height': len(a), 'identical_rows': count, | |
| 38 | 'foreground_pixels': int(foreground.sum()), | |
| 39 | 'foreground_equal_fraction': float(equal[foreground].mean()), | |
| 40 | 'foreground_mean_absolute_error': float( | |
| 41 | np.abs(a.astype(np.int16) - b.astype(np.int16))[foreground].mean()), | |
| 42 | }) | |
| 43 | candidates.sort(key=lambda c: c['foreground_equal_fraction'], reverse=True) | |
| 44 | best = candidates[0] | |
| 45 | ambiguous = len(candidates) > 1 and ( | |
| 46 | candidates[1]['foreground_equal_fraction'] == best['foreground_equal_fraction']) | |
| 47 | return {'scroll_y': None if ambiguous else best['scroll_y'], | |
| 48 | 'ambiguous': ambiguous, 'candidates': candidates, | |
| 49 | 'limits': ['Only downward integer scrolling with unchanged horizontal position is measured.', | |
| 50 | 'Candidates require identical foreground rows; a failure is not evidence of no scrolling.', | |
| 51 | 'Foreground excludes pixels whose three channels are all at least 210.']} | |
| 52 | ||
| 53 | ||
| 54 | if __name__ == '__main__': | |
| 55 | parser = argparse.ArgumentParser(description=__doc__) | |
| 56 | parser.add_argument('before', type=Path) | |
| 57 | parser.add_argument('after', type=Path) | |
| 58 | parser.add_argument('--crop', type=int, nargs=4, metavar=('LEFT', 'TOP', 'RIGHT', 'BOTTOM')) | |
| 59 | args = parser.parse_args() | |
| 60 | images = [] | |
| 61 | for path in (args.before, args.after): | |
| 62 | with Image.open(path) as image: | |
| 63 | image = image.convert('RGB') | |
| 64 | if args.crop: | |
| 65 | left, top, right, bottom = args.crop | |
| 66 | if not (0 <= left < right <= image.width and 0 <= top < bottom <= image.height): | |
| 67 | parser.error('The crop must fit inside each screenshot') | |
| 68 | image = image.crop(args.crop) | |
| 69 | images.append(np.asarray(image)) | |
| 70 | print(json.dumps(compare(*images), indent=2)) |
tools/canvas/core_text_probe.swift created+91| ... | ... | @@ -0,0 +1,91 @@ |
| 1 | import CoreText | |
| 2 | import Foundation | |
| 3 | ||
| 4 | struct Run: Codable { | |
| 5 | let text: String | |
| 6 | let font: String | |
| 7 | let size: Double | |
| 8 | let bold: Bool | |
| 9 | let italic: Bool | |
| 10 | } | |
| 11 | ||
| 12 | struct Case: Decodable { | |
| 13 | let id: String | |
| 14 | let width: Double | |
| 15 | let runs: [Run] | |
| 16 | } | |
| 17 | ||
| 18 | guard CommandLine.arguments.count >= 2 else { | |
| 19 | fatalError("Expected a text-case JSON file and optional font files") | |
| 20 | } | |
| 21 | for path in CommandLine.arguments.dropFirst(2) { | |
| 22 | var error: Unmanaged<CFError>? | |
| 23 | guard CTFontManagerRegisterFontsForURL(URL(fileURLWithPath: path) as CFURL, .process, &error) else { | |
| 24 | throw error!.takeRetainedValue() | |
| 25 | } | |
| 26 | } | |
| 27 | let data = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1])) | |
| 28 | let cases = try JSONDecoder().decode([Case].self, from: data) | |
| 29 | var results: [[String: Any]] = [] | |
| 30 | for item in cases { | |
| 31 | precondition(item.width.isFinite && item.width > 0 && !item.runs.isEmpty) | |
| 32 | let text = NSMutableAttributedString(string: "") | |
| 33 | var emptyFont: CTFont? | |
| 34 | var resolvedFonts: [String] = [] | |
| 35 | for run in item.runs { | |
| 36 | precondition(run.size.isFinite && run.size > 0) | |
| 37 | let base = CTFontCreateWithName(run.font as CFString, run.size, nil) | |
| 38 | var traits: CTFontSymbolicTraits = [] | |
| 39 | if run.bold { traits.insert(.boldTrait) } | |
| 40 | if run.italic { traits.insert(.italicTrait) } | |
| 41 | let font = CTFontCreateCopyWithSymbolicTraits(base, run.size, nil, traits, [.boldTrait, .italicTrait]) ?? base | |
| 42 | emptyFont = emptyFont ?? font | |
| 43 | resolvedFonts.append(CTFontCopyPostScriptName(font) as String) | |
| 44 | text.append(NSAttributedString(string: run.text, attributes: [ | |
| 45 | NSAttributedString.Key(kCTFontAttributeName as String): font, | |
| 46 | ])) | |
| 47 | } | |
| 48 | let typesetter = CTTypesetterCreateWithAttributedString(text) | |
| 49 | var start = 0 | |
| 50 | var lines: [[String: Any]] = [] | |
| 51 | repeat { | |
| 52 | let count = text.length == 0 ? 0 : CTTypesetterSuggestLineBreak(typesetter, start, item.width) | |
| 53 | precondition(count > 0 || text.length == 0, "Typesetter made no progress") | |
| 54 | let line = CTTypesetterCreateLine(typesetter, CFRange(location: start, length: count)) | |
| 55 | var ascent: CGFloat = 0 | |
| 56 | var descent: CGFloat = 0 | |
| 57 | var leading: CGFloat = 0 | |
| 58 | let advance = CTLineGetTypographicBounds(line, &ascent, &descent, &leading) | |
| 59 | if text.length == 0 { | |
| 60 | ascent = CTFontGetAscent(emptyFont!) | |
| 61 | descent = CTFontGetDescent(emptyFont!) | |
| 62 | leading = CTFontGetLeading(emptyFont!) | |
| 63 | } | |
| 64 | let faces = (CTLineGetGlyphRuns(line) as! [CTRun]).map { run -> [String: Any] in | |
| 65 | let attributes = CTRunGetAttributes(run) as NSDictionary | |
| 66 | let font = attributes[kCTFontAttributeName] as! CTFont | |
| 67 | var result: [String: Any] = ["face": CTFontCopyPostScriptName(font) as String, | |
| 68 | "size": CTFontGetSize(font)] | |
| 69 | if let version = CTFontCopyName(font, kCTFontVersionNameKey) { | |
| 70 | result["version"] = version as String | |
| 71 | } | |
| 72 | return result | |
| 73 | } | |
| 74 | lines.append([ | |
| 75 | "start_utf16": start, "end_utf16": start + count, | |
| 76 | "text": (text.string as NSString).substring(with: NSRange(location: start, length: count)), | |
| 77 | "advance": advance, "trailing_whitespace": CTLineGetTrailingWhitespaceWidth(line), | |
| 78 | "ascent": ascent, "descent": descent, "leading": leading, | |
| 79 | "runs": faces, | |
| 80 | ]) | |
| 81 | start += count | |
| 82 | } while start < text.length | |
| 83 | results.append(["id": item.id, "width": item.width, | |
| 84 | "requested_runs": try JSONSerialization.jsonObject(with: JSONEncoder().encode(item.runs)), | |
| 85 | "requested_faces_resolved": resolvedFonts, "lines": lines]) | |
| 86 | } | |
| 87 | let output: [String: Any] = [ | |
| 88 | "engine": "Core Text typesetter", "os": ProcessInfo.processInfo.operatingSystemVersionString, | |
| 89 | "units": "points", "cases": results, | |
| 90 | ] | |
| 91 | FileHandle.standardOutput.write(try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys])) |
tools/canvas/native_fixture.py created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Build synthetic OneNote pages for tools/native_runner.py --author tools/native/pages.ps1.""" | |
| 3 | import argparse | |
| 4 | import html | |
| 5 | import json | |
| 6 | import math | |
| 7 | from pathlib import Path | |
| 8 | import xml.etree.ElementTree as ET | |
| 9 | ||
| 10 | NS = 'http://schemas.microsoft.com/office/onenote/2010/onenote' | |
| 11 | ET.register_namespace('one', NS) | |
| 12 | ||
| 13 | ||
| 14 | def build(cases): | |
| 15 | pages = [] | |
| 16 | seen = set() | |
| 17 | for case in cases: | |
| 18 | if case['id'] in seen: | |
| 19 | raise ValueError('Duplicate case identity') | |
| 20 | seen.add(case['id']) | |
| 21 | if not math.isfinite(case['width']) or case['width'] <= 0 or not case['runs']: | |
| 22 | raise ValueError('Expected positive width and at least one run') | |
| 23 | page = ET.Element(f'{{{NS}}}Page', {'name': case['id']}) | |
| 24 | title = ET.SubElement(page, f'{{{NS}}}Title') | |
| 25 | title_oe = ET.SubElement(title, f'{{{NS}}}OE') | |
| 26 | ET.SubElement(title_oe, f'{{{NS}}}T').text = case['id'] | |
| 27 | outline = ET.SubElement(page, f'{{{NS}}}Outline') | |
| 28 | ET.SubElement(outline, f'{{{NS}}}Position', {'x': '36', 'y': '90', 'z': '0'}) | |
| 29 | ET.SubElement(outline, f'{{{NS}}}Size', {'width': str(case['width']), 'height': '20', 'isSetByUser': 'true'}) | |
| 30 | children = ET.SubElement(outline, f'{{{NS}}}OEChildren') | |
| 31 | fragments = [] | |
| 32 | styles = [] | |
| 33 | for run in case['runs']: | |
| 34 | if not math.isfinite(run['size']) or run['size'] <= 0: | |
| 35 | raise ValueError('Expected positive font size') | |
| 36 | style = (f"font-family:{run['font']};font-size:{run['size']}pt;" | |
| 37 | f"font-weight:{'bold' if run['bold'] else 'normal'};" | |
| 38 | f"font-style:{'italic' if run['italic'] else 'normal'}") | |
| 39 | styles.append(style) | |
| 40 | fragments.append(f'<span style="{html.escape(style, quote=True)}">{html.escape(run["text"])}</span>') | |
| 41 | oe = ET.SubElement(children, f'{{{NS}}}OE', {'style': styles[0]}) | |
| 42 | ET.SubElement(oe, f'{{{NS}}}T').text = ''.join(fragments) | |
| 43 | pages.append({'section': 'Layout.one', 'xml': ET.tostring(page, encoding='unicode')}) | |
| 44 | return {'sections': ['Layout.one'], 'pages': pages} | |
| 45 | ||
| 46 | ||
| 47 | if __name__ == '__main__': | |
| 48 | parser = argparse.ArgumentParser(description=__doc__) | |
| 49 | parser.add_argument('cases', type=Path) | |
| 50 | parser.add_argument('destination', type=Path) | |
| 51 | args = parser.parse_args() | |
| 52 | fixture = build(json.loads(args.cases.read_text())) | |
| 53 | args.destination.mkdir(parents=True, exist_ok=False) | |
| 54 | (args.destination / 'fixture.json').write_text(json.dumps(fixture, ensure_ascii=False, indent=2) + '\n') |
tools/canvas/range_stress.py created+68| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | """Check source coverage across deterministic mixed-Unicode layout cases.""" | |
| 3 | import argparse | |
| 4 | import json | |
| 5 | from pathlib import Path | |
| 6 | import random | |
| 7 | import subprocess | |
| 8 | ||
| 9 | ||
| 10 | def cases(): | |
| 11 | rng = random.Random(20260906) | |
| 12 | tokens = ['a', ' ', 'é', 'é', 'שלום', 'العربية', '👩🏽💻', '👩👩👧👦', '🇨🇦', '\u00a0', 'ffi', '\u200d'] | |
| 13 | result = [] | |
| 14 | for index in range(500): | |
| 15 | text = ''.join(rng.choice(tokens) for _ in range(rng.randrange(35))) | |
| 16 | result.append({'id': str(index), 'width': rng.uniform(1, 250), | |
| 17 | 'runs': [{'text': text, 'font': 'Arial', 'size': rng.uniform(7, 25), | |
| 18 | 'bold': False, 'italic': False}]}) | |
| 19 | return result | |
| 20 | ||
| 21 | ||
| 22 | def check(source, output): | |
| 23 | if len(source) != len(output['cases']): | |
| 24 | raise ValueError('Probe did not return every case') | |
| 25 | count = 0 | |
| 26 | for case, result in zip(source, output['cases']): | |
| 27 | if case['id'] != result['id']: | |
| 28 | raise ValueError('Probe returned different case identities') | |
| 29 | raw = ''.join(run['text'] for run in case['runs']).encode('utf-16-le') | |
| 30 | end = 0 | |
| 31 | for line in result['lines']: | |
| 32 | if line['start_utf16'] != end or not end <= line['end_utf16'] <= len(raw) // 2: | |
| 33 | raise ValueError(f"{case['id']}: noncontiguous or out-of-bounds source ranges") | |
| 34 | start, end = line['start_utf16'], line['end_utf16'] | |
| 35 | if raw[2 * start:2 * end].decode('utf-16-le') != line['text']: | |
| 36 | raise ValueError(f"{case['id']}: line text differs from its source range") | |
| 37 | count += 1 | |
| 38 | if end * 2 != len(raw): | |
| 39 | raise ValueError(f"{case['id']}: layout does not cover all source text") | |
| 40 | return count | |
| 41 | ||
| 42 | ||
| 43 | if __name__ == '__main__': | |
| 44 | parser = argparse.ArgumentParser(description=__doc__) | |
| 45 | parser.add_argument('output', type=Path) | |
| 46 | parser.add_argument('probe', nargs=argparse.REMAINDER) | |
| 47 | args = parser.parse_args() | |
| 48 | if not args.probe: | |
| 49 | parser.error('Provide a probe command after the output directory') | |
| 50 | args.output.mkdir(parents=True, exist_ok=False) | |
| 51 | source = cases() | |
| 52 | path = args.output / 'cases.json' | |
| 53 | path.write_text(json.dumps(source, ensure_ascii=False, indent=2) + '\n') | |
| 54 | command = args.probe + [str(path)] | |
| 55 | (args.output / 'command.json').write_text(json.dumps(command) + '\n') | |
| 56 | with (args.output / 'output.json').open('w') as stdout, (args.output / 'stderr.txt').open('w') as stderr: | |
| 57 | result = subprocess.run(command, stdout=stdout, stderr=stderr) | |
| 58 | summary = {'seed': 20260906, 'cases': len(source), 'exit': result.returncode} | |
| 59 | if result.returncode == 0: | |
| 60 | try: | |
| 61 | summary['lines_checked'] = check(source, json.loads((args.output / 'output.json').read_text())) | |
| 62 | except (ValueError, KeyError) as error: | |
| 63 | summary['error'] = str(error) | |
| 64 | else: | |
| 65 | summary['error'] = 'Probe rejected a generated case; see stderr.txt' | |
| 66 | (args.output / 'summary.json').write_text(json.dumps(summary, indent=2) + '\n') | |
| 67 | print(json.dumps(summary)) | |
| 68 | raise SystemExit(1 if 'error' in summary else 0) |
tools/canvas/test_compare.py created+67| ... | ... | @@ -0,0 +1,67 @@ |
| 1 | import unittest | |
| 2 | from types import SimpleNamespace | |
| 3 | ||
| 4 | from compare import pdf_line_ends | |
| 5 | from range_stress import check | |
| 6 | ||
| 7 | ||
| 8 | def page(lines): | |
| 9 | return SimpleNamespace(chars=[ | |
| 10 | {'text': character, 'x0': x, 'y0': baseline - 2, 'y1': baseline + 8, 'matrix': (1, 0, 0, 1, x, baseline)} | |
| 11 | for baseline, text in lines for x, character in enumerate(text) | |
| 12 | ]) | |
| 13 | ||
| 14 | ||
| 15 | class PdfRanges(unittest.TestCase): | |
| 16 | def test_preserves_source_spaces_at_wraps(self): | |
| 17 | native = page([(700, 'Title'), (600, 'one two'), (580, 'three'), (30, 'Footer')]) | |
| 18 | self.assertEqual(pdf_line_ends(native, ' one two three '), [13, 20]) | |
| 19 | ||
| 20 | def test_style_baseline_offsets_stay_on_the_same_visual_line(self): | |
| 21 | native = page([(600, 'a'), (599.83, 'b'), (580, 'c')]) | |
| 22 | native.chars[1]['x0'] = 1 | |
| 23 | self.assertEqual(pdf_line_ends(native, 'ab c'), [3, 4]) | |
| 24 | ||
| 25 | def test_long_word_has_no_invented_whitespace(self): | |
| 26 | self.assertEqual(pdf_line_ends(page([(600, 'extra'), (580, 'ordinary')]), 'extraordinary'), [5, 13]) | |
| 27 | ||
| 28 | def test_missing_pdf_characters_are_not_accepted(self): | |
| 29 | self.assertIsNone(pdf_line_ends(page([(600, 'one tree')]), 'one three')) | |
| 30 | ||
| 31 | def test_ambiguous_match_is_not_selected_arbitrarily(self): | |
| 32 | self.assertIsNone(pdf_line_ends(page([(600, 'same'), (580, 'same')]), 'same')) | |
| 33 | ||
| 34 | def test_non_ascii_requires_other_evidence(self): | |
| 35 | self.assertIsNone(pdf_line_ends(page([(600, 'café')]), 'café')) | |
| 36 | ||
| 37 | ||
| 38 | class SourceCoverage(unittest.TestCase): | |
| 39 | def test_utf16_offsets_preserve_supplementary_characters(self): | |
| 40 | source = [{'id': 'emoji', 'runs': [{'text': 'a🌳b'}]}] | |
| 41 | output = {'cases': [{'id': 'emoji', 'lines': [ | |
| 42 | {'start_utf16': 0, 'end_utf16': 3, 'text': 'a🌳'}, | |
| 43 | {'start_utf16': 3, 'end_utf16': 4, 'text': 'b'}, | |
| 44 | ]}]} | |
| 45 | self.assertEqual(check(source, output), 2) | |
| 46 | output['cases'][0]['lines'][0]['end_utf16'] = 2 | |
| 47 | with self.assertRaises(UnicodeDecodeError): | |
| 48 | check(source, output) | |
| 49 | ||
| 50 | def test_dropped_leading_space_is_detected(self): | |
| 51 | source = [{'id': 'space', 'runs': [{'text': ' a'}]}] | |
| 52 | output = {'cases': [{'id': 'space', 'lines': [ | |
| 53 | {'start_utf16': 1, 'end_utf16': 2, 'text': 'a'}, | |
| 54 | ]}]} | |
| 55 | with self.assertRaisesRegex(ValueError, 'noncontiguous'): | |
| 56 | check(source, output) | |
| 57 | ||
| 58 | def test_empty_paragraph_covers_an_empty_source(self): | |
| 59 | source = [{'id': 'empty', 'runs': [{'text': ''}]}] | |
| 60 | output = {'cases': [{'id': 'empty', 'lines': [ | |
| 61 | {'start_utf16': 0, 'end_utf16': 0, 'text': ''}, | |
| 62 | ]}]} | |
| 63 | self.assertEqual(check(source, output), 1) | |
| 64 | ||
| 65 | ||
| 66 | if __name__ == '__main__': | |
| 67 | unittest.main() |
tools/canvas/test_compare_baselines.py created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import base64 | |
| 2 | import io | |
| 3 | from types import SimpleNamespace | |
| 4 | import unittest | |
| 5 | from unittest.mock import patch | |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | ||
| 8 | from PIL import Image | |
| 9 | ||
| 10 | from baseline_fixture import build | |
| 11 | from compare_baselines import baseline_report, pdf_rgb, register_images | |
| 12 | from native_fixture import NS | |
| 13 | ||
| 14 | ||
| 15 | class BaselineAnchors(unittest.TestCase): | |
| 16 | def setUp(self): | |
| 17 | self.xml = ET.fromstring(build()['pages'][0]['xml']) | |
| 18 | self.images = [] | |
| 19 | for node in self.xml.findall(f'{{{NS}}}Image'): | |
| 20 | with Image.open(io.BytesIO(base64.b64decode(node.findtext(f'{{{NS}}}Data')))) as image: | |
| 21 | pixels, dimensions = image.convert('RGB').tobytes(), image.size | |
| 22 | position, size = node.find(f'{{{NS}}}Position'), node.find(f'{{{NS}}}Size') | |
| 23 | x, y = float(position.get('x')) + 36, float(position.get('y')) + 21.6 | |
| 24 | self.images.append({ | |
| 25 | 'srcsize': dimensions, 'bits': 8, 'colorspace': [SimpleNamespace(name='DeviceRGB')], | |
| 26 | 'x0': x, 'x1': x + float(size.get('width')), 'top': y, 'bottom': y + float(size.get('height')), | |
| 27 | 'stream': SimpleNamespace(attrs={}, get_data=lambda data=pixels: data), | |
| 28 | }) | |
| 29 | ||
| 30 | def test_exact_pixels_establish_translation(self): | |
| 31 | anchors = register_images(self.xml, [SimpleNamespace(images=self.images)])[0] | |
| 32 | self.assertEqual(len(anchors), 3) | |
| 33 | for anchor in anchors: | |
| 34 | self.assertAlmostEqual(anchor['translation'][0], 36) | |
| 35 | self.assertAlmostEqual(anchor['translation'][1], 21.6) | |
| 36 | self.assertEqual(anchor['extent_scale'], [1, 1]) | |
| 37 | ||
| 38 | def test_same_dimensions_with_changed_pixels_are_not_an_anchor(self): | |
| 39 | self.images[0]['stream'].get_data = lambda: b'\0' * (22 * 26 * 3) | |
| 40 | anchors = register_images(self.xml, [SimpleNamespace(images=self.images)])[0] | |
| 41 | self.assertEqual(len(anchors), 2) | |
| 42 | self.assertTrue(all(a['source_xywh'][0] != 20.125 for a in anchors)) | |
| 43 | ||
| 44 | def test_duplicates_and_masks_are_not_usable(self): | |
| 45 | self.images.append(self.images[0]) | |
| 46 | self.images[1]['stream'].attrs['SMask'] = object() | |
| 47 | anchors = register_images(self.xml, [SimpleNamespace(images=self.images)])[0] | |
| 48 | self.assertEqual(len(anchors), 1) | |
| 49 | self.assertEqual(anchors[0]['source_xywh'][0], 25.75) | |
| 50 | ||
| 51 | def test_indexed_four_bit_pixels_respect_row_padding(self): | |
| 52 | colors = b'\xff\0\0\0\xff\0\0\0\xff' | |
| 53 | image = {'srcsize': (3, 2), 'bits': 4, | |
| 54 | 'colorspace': [SimpleNamespace(name='Indexed'), SimpleNamespace(name='DeviceRGB'), 2, colors], | |
| 55 | 'stream': SimpleNamespace(attrs={}, get_data=lambda: b'\x01\x20\x01\x20')} | |
| 56 | self.assertEqual(pdf_rgb(image), colors * 2) | |
| 57 | ||
| 58 | def test_text_geometry_cannot_change_image_registration(self): | |
| 59 | probe = {'objects': [{'kind': 'outline', 'id': 'o', 'is_title': False, 'layout': {'y': 400}}]} | |
| 60 | wraps = {'counts': {'matched': 1}, 'paragraphs': [{ | |
| 61 | 'status': 'matched', 'outline_id': 'o', 'paragraph_id': 'p', 'canvas_baselines_in_outline': [10], | |
| 62 | 'native_instances': [{'pdf_page': 1, 'baseline_ranges_from_pdf_top': [[430, 430]]}], | |
| 63 | }]} | |
| 64 | with patch('compare_baselines.compare', return_value=wraps): | |
| 65 | before = baseline_report(probe, self.xml, [SimpleNamespace(images=self.images)]) | |
| 66 | probe['objects'][0]['layout']['y'] += 20 | |
| 67 | after = baseline_report(probe, self.xml, [SimpleNamespace(images=self.images)]) | |
| 68 | self.assertEqual(before['anchors_by_pdf_page'], after['anchors_by_pdf_page']) | |
| 69 | self.assertAlmostEqual(before['paragraphs'][0]['lines'][0]['residual_using_median_anchor_translation'][0], -1.6) | |
| 70 | self.assertAlmostEqual(after['paragraphs'][0]['lines'][0]['residual_using_median_anchor_translation'][0], -21.6) | |
| 71 | ||
| 72 | def test_wrap_mismatch_remains_visible_without_baseline_measurements(self): | |
| 73 | wraps = {'counts': {'different_wraps': 1}, 'paragraphs': [{'status': 'different_wraps', 'paragraph_id': 'p'}]} | |
| 74 | with patch('compare_baselines.compare', return_value=wraps): | |
| 75 | report = baseline_report({'objects': []}, self.xml, [SimpleNamespace(images=self.images)]) | |
| 76 | self.assertEqual(report['paragraphs'], [{'status': 'different_wraps', 'paragraph_id': 'p', 'lines': []}]) | |
| 77 | ||
| 78 | ||
| 79 | if __name__ == '__main__': | |
| 80 | unittest.main() |
tools/canvas/test_compare_page.py created+63| ... | ... | @@ -0,0 +1,63 @@ |
| 1 | import unittest | |
| 2 | import xml.etree.ElementTree as ET | |
| 3 | ||
| 4 | from compare_page import compare | |
| 5 | ||
| 6 | ||
| 7 | class PageComparisonTests(unittest.TestCase): | |
| 8 | def test_matches_reordered_outlines_and_preserves_html_whitespace(self): | |
| 9 | native = ET.fromstring('''<Page xmlns="http://schemas.microsoft.com/office/onenote/2010/onenote" name="Page"> | |
| 10 | <Outline objectID="second"><Position x="70" y="30"/><Size width="90" height="14"/> | |
| 11 | <OEChildren><OE><T>B</T></OE></OEChildren></Outline> | |
| 12 | <Outline objectID="first"><Position x="36" y="20"/><Size width="100" height="28"/> | |
| 13 | <OEChildren><OE><T>&lt;b&gt;A&amp;nbsp;&lt;/b&gt;&lt;br/&gt;B</T></OE><OE><T/></OE></OEChildren></Outline> | |
| 14 | </Page>''') | |
| 15 | probe = {'title': 'Page', 'margin_origin': [36.5, 14.4], 'measurement': 'Raw paragraphs', | |
| 16 | 'objects': [ | |
| 17 | {'kind': 'outline', 'is_title': False, 'unsupported': [], 'id': 'a', | |
| 18 | 'layout': {'x': 36.5, 'y': 20, 'max_width': 100}, | |
| 19 | 'paragraphs': [{'visible_text': text, 'unsupported': [], 'height': 14, | |
| 20 | 'parent': None, 'lists': []} for text in ['A\u00a0\nB', '']]}, | |
| 21 | {'kind': 'outline', 'is_title': False, 'unsupported': [], 'id': 'b', | |
| 22 | 'layout': {'x': 70.5, 'y': 30, 'max_width': 90}, | |
| 23 | 'paragraphs': [{'visible_text': 'B', 'unsupported': [], 'height': 14, | |
| 24 | 'parent': None, 'lists': []}]}, | |
| 25 | ]} | |
| 26 | result = compare(probe, native) | |
| 27 | self.assertEqual([o['native_id'] for o in result['outlines']], ['first', 'second']) | |
| 28 | self.assertEqual(result['outlines'][0]['height_residual'], 0) | |
| 29 | self.assertEqual(result['outlines'][0]['inferred_native_margin_origin'], [36, 14.4]) | |
| 30 | probe['objects'][0]['paragraphs'][0]['visible_text'] = 'A \nB' | |
| 31 | with self.assertRaises(ValueError): | |
| 32 | compare(probe, native) | |
| 33 | ||
| 34 | def test_native_empty_nbsp_requires_explicit_normalization_and_is_reported(self): | |
| 35 | native = ET.fromstring('''<Page xmlns="http://schemas.microsoft.com/office/onenote/2010/onenote" name="Page"> | |
| 36 | <Outline><Position x="0" y="0"/><Size width="100" height="14"/> | |
| 37 | <OEChildren><OE><T/></OE></OEChildren></Outline> | |
| 38 | </Page>''') | |
| 39 | probe = {'title': 'Page', 'margin_origin': [0, 0], 'measurement': 'Raw paragraphs', 'objects': [ | |
| 40 | {'kind': 'outline', 'is_title': False, 'unsupported': [], 'id': 'a', | |
| 41 | 'layout': {'x': 0, 'y': 0, 'max_width': 100}, | |
| 42 | 'paragraphs': [{'visible_text': '\u00a0', 'unsupported': [], 'height': 14, | |
| 43 | 'parent': None, 'lists': []}]}]} | |
| 44 | with self.assertRaises(ValueError): | |
| 45 | compare(probe, native) | |
| 46 | result = compare(probe, native, allow_native_empty_nbsp=True)['outlines'][0] | |
| 47 | self.assertFalse(result['text_matches_exactly']) | |
| 48 | self.assertEqual(result['native_empty_nbsp_paragraphs'], [0]) | |
| 49 | ||
| 50 | def test_rejects_ambiguous_identical_content(self): | |
| 51 | native = ET.fromstring('''<Page xmlns="http://schemas.microsoft.com/office/onenote/2010/onenote" name="Page"> | |
| 52 | <Outline><OEChildren><OE><T>same</T></OE></OEChildren></Outline> | |
| 53 | <Outline><OEChildren><OE><T>same</T></OE></OEChildren></Outline> | |
| 54 | </Page>''') | |
| 55 | probe = {'title': 'Page', 'objects': [ | |
| 56 | {'kind': 'outline', 'is_title': False, 'unsupported': [], 'id': 'a', | |
| 57 | 'paragraphs': [{'visible_text': 'same', 'unsupported': []}]}]} | |
| 58 | with self.assertRaises(ValueError): | |
| 59 | compare(probe, native) | |
| 60 | ||
| 61 | ||
| 62 | if __name__ == '__main__': | |
| 63 | unittest.main() |
tools/canvas/test_compare_page_pdf.py created+78| ... | ... | @@ -0,0 +1,78 @@ |
| 1 | import unittest | |
| 2 | from types import SimpleNamespace | |
| 3 | ||
| 4 | from compare import pdf_lines | |
| 5 | from compare_page_pdf import compare | |
| 6 | ||
| 7 | ||
| 8 | def probe(texts, ends=None): | |
| 9 | return {'title': 'Test', 'objects': [{'kind': 'outline', 'is_title': False, 'id': 'outline', | |
| 10 | 'paragraphs': [{'id': str(i), 'visible_text': text, 'origin': [0, i * 12], | |
| 11 | 'spans': [{'end_utf8': len(text.encode()), 'format': {'font_size': 10}}], | |
| 12 | 'lines': [{'text': text, 'end_utf16': len(text) if ends is None else ends[i], | |
| 13 | 'baseline': 8}]} | |
| 14 | for i, text in enumerate(texts)]}]} | |
| 15 | ||
| 16 | ||
| 17 | def page(lines): | |
| 18 | return SimpleNamespace(height=800, chars=[ | |
| 19 | {'text': ch, 'x0': x * 5, 'y0': y - 2, 'y1': y + 8, 'size': 10, | |
| 20 | 'matrix': (1, 0, 0, 1, x * 5, y)} | |
| 21 | for y, text in lines for x, ch in enumerate(text)]) | |
| 22 | ||
| 23 | ||
| 24 | class PagePdf(unittest.TestCase): | |
| 25 | def test_title_opt_in_requires_its_own_native_glyphs(self): | |
| 26 | source = probe(['body']) | |
| 27 | title = probe(['title'])['objects'][0] | |
| 28 | title.update(id='title', is_title=True) | |
| 29 | source['objects'].append(title) | |
| 30 | native = page([(700, 'title'), (680, 'body')]) | |
| 31 | self.assertEqual(compare(source, [native])['counts'], {'matched': 1}) | |
| 32 | result = compare(source, [native], include_titles=True) | |
| 33 | self.assertEqual(result['counts'], {'matched': 2}) | |
| 34 | self.assertTrue(result['paragraphs'][1]['is_title']) | |
| 35 | missing = compare(source, [page([(680, 'body')])], include_titles=True) | |
| 36 | self.assertEqual(missing['paragraphs'][1]['status'], 'unresolved_match') | |
| 37 | ||
| 38 | def test_outline_context_resolves_repeated_paragraphs(self): | |
| 39 | result = compare(probe(['same', 'same']), [page([(700, 'same'), (680, 'same')])]) | |
| 40 | self.assertEqual(result['counts'], {'matched': 2}) | |
| 41 | self.assertEqual([p['native_instances'][0]['baseline_ranges_from_pdf_top'] | |
| 42 | for p in result['paragraphs']], [[[100, 100]], [[120, 120]]]) | |
| 43 | ||
| 44 | def test_different_wraps_are_reported(self): | |
| 45 | result = compare(probe(['one two']), [page([(700, 'one'), (680, 'two')])]) | |
| 46 | row = result['paragraphs'][0] | |
| 47 | self.assertEqual(row['status'], 'different_wraps') | |
| 48 | self.assertEqual(row['native_end_utf16'], [4, 7]) | |
| 49 | self.assertFalse(row['breaks_match']) | |
| 50 | ||
| 51 | def test_repeated_exports_must_agree(self): | |
| 52 | source = probe(['one two']) | |
| 53 | one_line = page([(700, 'one two')]) | |
| 54 | matching = compare(source, [one_line, one_line])['paragraphs'][0] | |
| 55 | self.assertTrue(matching['breaks_match']) | |
| 56 | self.assertEqual(len(matching['native_instances']), 2) | |
| 57 | different = compare(source, [one_line, page([(700, 'one'), (680, 'two')])])['paragraphs'][0] | |
| 58 | self.assertEqual(different['status'], 'unrecoverable_line_ranges') | |
| 59 | self.assertIsNone(different['breaks_match']) | |
| 60 | ||
| 61 | def test_incomplete_outline_does_not_use_ambiguous_source_substrings(self): | |
| 62 | result = compare(probe(['one', 'one other']), [page([(700, 'one extra one other')])]) | |
| 63 | self.assertEqual(result['paragraphs'][0]['status'], 'unresolved_match') | |
| 64 | self.assertEqual(result['paragraphs'][0]['match_context'], 'ambiguous_source_context') | |
| 65 | ||
| 66 | def test_empty_text_is_not_a_geometry_pass(self): | |
| 67 | result = compare(probe(['', ' ', '\u00a0']), [page([])]) | |
| 68 | self.assertEqual(result['counts'], {'no_visible_glyphs': 3}) | |
| 69 | self.assertTrue(all(p['breaks_match'] is None for p in result['paragraphs'])) | |
| 70 | ||
| 71 | def test_overlapping_line_candidates_are_not_chosen_arbitrarily(self): | |
| 72 | native = page([(700, 'a'), (680, 'b'), (660, 'c')]) | |
| 73 | native.chars[-1]['y1'] = 720 | |
| 74 | self.assertIsNone(pdf_lines(native.chars)) | |
| 75 | ||
| 76 | ||
| 77 | if __name__ == '__main__': | |
| 78 | unittest.main() |
tools/canvas/test_compare_scroll.py created+41| ... | ... | @@ -0,0 +1,41 @@ |
| 1 | import unittest | |
| 2 | ||
| 3 | import numpy as np | |
| 4 | ||
| 5 | from compare_scroll import compare | |
| 6 | ||
| 7 | ||
| 8 | class ScrollTests(unittest.TestCase): | |
| 9 | def test_overlap_ignores_blank_rows_and_survives_a_changed_caret(self): | |
| 10 | page = np.full((120, 20, 3), 255, dtype=np.uint8) | |
| 11 | for y in range(0, len(page), 3): | |
| 12 | page[y, 2:8] = [y, 40, 80] | |
| 13 | before, after = page[:90].copy(), page[30:].copy() | |
| 14 | after[20:25, 12] = 0 | |
| 15 | result = compare(before, after, 20) | |
| 16 | self.assertEqual(result['scroll_y'], 30) | |
| 17 | self.assertFalse(result['ambiguous']) | |
| 18 | self.assertGreater(result['candidates'][0]['foreground_equal_fraction'], 0.95) | |
| 19 | ||
| 20 | def test_repeated_foreground_has_no_unique_offset(self): | |
| 21 | image = np.full((40, 20, 3), 255, dtype=np.uint8) | |
| 22 | image[::4, 2:8] = 0 | |
| 23 | result = compare(image, image, 20) | |
| 24 | self.assertTrue(result['ambiguous']) | |
| 25 | self.assertIsNone(result['scroll_y']) | |
| 26 | ||
| 27 | def test_blank_or_unrelated_images_cannot_establish_scroll(self): | |
| 28 | white = np.full((40, 20, 3), 255, dtype=np.uint8) | |
| 29 | with self.assertRaises(ValueError): | |
| 30 | compare(white, white, 20) | |
| 31 | before, after = white.copy(), white.copy() | |
| 32 | before[:, 2:8] = [30, 40, 80] | |
| 33 | after[:, 2:8] = [31, 40, 80] | |
| 34 | with self.assertRaises(ValueError): | |
| 35 | compare(before, after, 20) | |
| 36 | with self.assertRaises(ValueError): | |
| 37 | compare(before, after[:-1], 20) | |
| 38 | ||
| 39 | ||
| 40 | if __name__ == '__main__': | |
| 41 | unittest.main() |