authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-06 16:24:32-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-06 16:24:32-07:00
log1c1d0b1a96f8a3a9a9d12d82a7f8089296a13163
tree3ebbc99e43c0cd81a719017faaae91475d8e87e6
parente00506af62d3a184a53b6dec1b40ac6bd6372c3c
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: initial canvas implementation


49 files changed, 18683 insertions(+), 379 deletions(-)

Cargo.lock+3222-351
......@@ -2,6 +2,103 @@
22# It is not intended for manual editing.
33version = 4
44
5[[package]]
6name = "ab_glyph"
7version = "0.2.32"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
10dependencies = [
11 "ab_glyph_rasterizer",
12 "owned_ttf_parser",
13]
14
15[[package]]
16name = "ab_glyph_rasterizer"
17version = "0.1.10"
18source = "registry+https://github.com/rust-lang/crates.io-index"
19checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
20
21[[package]]
22name = "accesskit"
23version = "0.25.0"
24source = "registry+https://github.com/rust-lang/crates.io-index"
25checksum = "438a7081a65b95c668db56591a4fef9bc5dad275f448bd6223fc6949d823db38"
26dependencies = [
27 "uuid",
28]
29
30[[package]]
31name = "accesskit_consumer"
32version = "0.39.0"
33source = "registry+https://github.com/rust-lang/crates.io-index"
34checksum = "cc882fa0c9c24c53649e256311b51046cf36eca9a8f7e54ff4ef682160b0cb27"
35dependencies = [
36 "accesskit",
37 "hashbrown 0.17.1",
38]
39
40[[package]]
41name = "accesskit_ios"
42version = "0.2.0"
43source = "registry+https://github.com/rust-lang/crates.io-index"
44checksum = "d5e9b29c6ba5f4d2e0662e9c562484f061ffc58f658479c538ecca8299ada1e9"
45dependencies = [
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]]
55name = "accesskit_macos"
56version = "0.27.0"
57source = "registry+https://github.com/rust-lang/crates.io-index"
58checksum = "a3f278988416e5fb600f24d0cfffe31a249ebbff980fb9c5a3b5fbed2c579f73"
59dependencies = [
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]]
69name = "accesskit_windows"
70version = "0.35.0"
71source = "registry+https://github.com/rust-lang/crates.io-index"
72checksum = "a59e8d7160cbac991c1345c99153783ab96423644ccb1f20617cf80c97596aa1"
73dependencies = [
74 "accesskit",
75 "accesskit_consumer",
76 "hashbrown 0.17.1",
77 "static_assertions",
78 "windows",
79 "windows-core",
80]
81
82[[package]]
83name = "accesskit_winit"
84version = "0.34.0"
85source = "registry+https://github.com/rust-lang/crates.io-index"
86checksum = "1eb960a51582305f94b3884a7ff54b3462abe38f0de95a9e7e04dd7ab7b757da"
87dependencies = [
88 "accesskit",
89 "accesskit_ios",
90 "accesskit_macos",
91 "accesskit_windows",
92 "raw-window-handle",
93 "winit",
94]
95
96[[package]]
97name = "adler2"
98version = "2.0.1"
99source = "registry+https://github.com/rust-lang/crates.io-index"
100checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
101
5102[[package]]
6103name = "aead"
7104version = "0.6.1"
......@@ -38,6 +135,103 @@ dependencies = [
38135 "ghash",
39136]
40137
138[[package]]
139name = "ahash"
140version = "0.8.12"
141source = "registry+https://github.com/rust-lang/crates.io-index"
142checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
143dependencies = [
144 "cfg-if",
145 "getrandom 0.3.4",
146 "once_cell",
147 "version_check",
148 "zerocopy",
149]
150
151[[package]]
152name = "allocator-api2"
153version = "0.2.21"
154source = "registry+https://github.com/rust-lang/crates.io-index"
155checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
156
157[[package]]
158name = "android-activity"
159version = "0.6.1"
160source = "registry+https://github.com/rust-lang/crates.io-index"
161checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
162dependencies = [
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]]
177name = "android-properties"
178version = "0.2.2"
179source = "registry+https://github.com/rust-lang/crates.io-index"
180checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
181
182[[package]]
183name = "android_system_properties"
184version = "0.1.6"
185source = "registry+https://github.com/rust-lang/crates.io-index"
186checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
187dependencies = [
188 "libc",
189]
190
191[[package]]
192name = "arboard"
193version = "3.6.1"
194source = "registry+https://github.com/rust-lang/crates.io-index"
195checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
196dependencies = [
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]]
209name = "arrayref"
210version = "0.3.9"
211source = "registry+https://github.com/rust-lang/crates.io-index"
212checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
213
214[[package]]
215name = "arrayvec"
216version = "0.7.8"
217source = "registry+https://github.com/rust-lang/crates.io-index"
218checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
219
220[[package]]
221name = "as-raw-xcb-connection"
222version = "1.0.1"
223source = "registry+https://github.com/rust-lang/crates.io-index"
224checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
225
226[[package]]
227name = "ash"
228version = "0.38.0+1.3.281"
229source = "registry+https://github.com/rust-lang/crates.io-index"
230checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
231dependencies = [
232 "libloading",
233]
234
41235[[package]]
42236name = "async-trait"
43237version = "0.1.92"
......@@ -49,12 +243,45 @@ dependencies = [
49243 "syn 3.0.5",
50244]
51245
246[[package]]
247name = "atomic-waker"
248version = "1.1.2"
249source = "registry+https://github.com/rust-lang/crates.io-index"
250checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
251
252[[package]]
253name = "autocfg"
254version = "1.5.1"
255source = "registry+https://github.com/rust-lang/crates.io-index"
256checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
257
52258[[package]]
53259name = "base64"
54260version = "0.22.1"
55261source = "registry+https://github.com/rust-lang/crates.io-index"
56262checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
57263
264[[package]]
265name = "bit-set"
266version = "0.10.0"
267source = "registry+https://github.com/rust-lang/crates.io-index"
268checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
269dependencies = [
270 "bit-vec",
271]
272
273[[package]]
274name = "bit-vec"
275version = "0.9.1"
276source = "registry+https://github.com/rust-lang/crates.io-index"
277checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
278
279[[package]]
280name = "bitflags"
281version = "1.3.2"
282source = "registry+https://github.com/rust-lang/crates.io-index"
283checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
284
58285[[package]]
59286name = "bitflags"
60287version = "2.13.1"
......@@ -80,18 +307,88 @@ dependencies = [
80307 "hybrid-array",
81308]
82309
310[[package]]
311name = "block2"
312version = "0.5.1"
313source = "registry+https://github.com/rust-lang/crates.io-index"
314checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
315dependencies = [
316 "objc2 0.5.2",
317]
318
319[[package]]
320name = "block2"
321version = "0.6.2"
322source = "registry+https://github.com/rust-lang/crates.io-index"
323checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
324dependencies = [
325 "objc2 0.6.4",
326]
327
83328[[package]]
84329name = "bumpalo"
85330version = "3.20.3"
86331source = "registry+https://github.com/rust-lang/crates.io-index"
87332checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
88333
334[[package]]
335name = "bytemuck"
336version = "1.25.2"
337source = "registry+https://github.com/rust-lang/crates.io-index"
338checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
339dependencies = [
340 "bytemuck_derive",
341]
342
343[[package]]
344name = "bytemuck_derive"
345version = "1.12.0"
346source = "registry+https://github.com/rust-lang/crates.io-index"
347checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
348dependencies = [
349 "proc-macro2",
350 "quote",
351 "syn 3.0.5",
352]
353
354[[package]]
355name = "byteorder-lite"
356version = "0.1.0"
357source = "registry+https://github.com/rust-lang/crates.io-index"
358checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
359
89360[[package]]
90361name = "bytes"
91362version = "1.12.1"
92363source = "registry+https://github.com/rust-lang/crates.io-index"
93364checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
94365
366[[package]]
367name = "calloop"
368version = "0.13.0"
369source = "registry+https://github.com/rust-lang/crates.io-index"
370checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
371dependencies = [
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]]
381name = "calloop-wayland-source"
382version = "0.3.0"
383source = "registry+https://github.com/rust-lang/crates.io-index"
384checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
385dependencies = [
386 "calloop",
387 "rustix 0.38.44",
388 "wayland-backend",
389 "wayland-client",
390]
391
95392[[package]]
96393name = "cbc"
97394version = "0.2.1"
......@@ -108,6 +405,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
108405checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
109406dependencies = [
110407 "find-msvc-tools",
408 "jobserver",
409 "libc",
111410 "shlex",
112411]
113412
......@@ -147,6 +446,15 @@ dependencies = [
147446 "zeroize",
148447]
149448
449[[package]]
450name = "clipboard-win"
451version = "5.4.1"
452source = "registry+https://github.com/rust-lang/crates.io-index"
453checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
454dependencies = [
455 "error-code",
456]
457
150458[[package]]
151459name = "cmac"
152460version = "0.8.0"
......@@ -164,12 +472,82 @@ version = "0.5.4"
164472source = "registry+https://github.com/rust-lang/crates.io-index"
165473checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
166474
475[[package]]
476name = "codespan-reporting"
477version = "0.13.1"
478source = "registry+https://github.com/rust-lang/crates.io-index"
479checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
480dependencies = [
481 "serde",
482 "termcolor",
483 "unicode-width",
484]
485
486[[package]]
487name = "combine"
488version = "4.6.8"
489source = "registry+https://github.com/rust-lang/crates.io-index"
490checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
491dependencies = [
492 "bytes",
493 "memchr",
494]
495
496[[package]]
497name = "concurrent-queue"
498version = "2.5.0"
499source = "registry+https://github.com/rust-lang/crates.io-index"
500checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
501dependencies = [
502 "crossbeam-utils",
503]
504
167505[[package]]
168506name = "const-oid"
169507version = "0.10.2"
170508source = "registry+https://github.com/rust-lang/crates.io-index"
171509checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
172510
511[[package]]
512name = "core-foundation"
513version = "0.9.4"
514source = "registry+https://github.com/rust-lang/crates.io-index"
515checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
516dependencies = [
517 "core-foundation-sys",
518 "libc",
519]
520
521[[package]]
522name = "core-foundation-sys"
523version = "0.8.7"
524source = "registry+https://github.com/rust-lang/crates.io-index"
525checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
526
527[[package]]
528name = "core-graphics"
529version = "0.23.2"
530source = "registry+https://github.com/rust-lang/crates.io-index"
531checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
532dependencies = [
533 "bitflags 1.3.2",
534 "core-foundation",
535 "core-graphics-types",
536 "foreign-types",
537 "libc",
538]
539
540[[package]]
541name = "core-graphics-types"
542version = "0.1.3"
543source = "registry+https://github.com/rust-lang/crates.io-index"
544checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
545dependencies = [
546 "bitflags 1.3.2",
547 "core-foundation",
548 "libc",
549]
550
173551[[package]]
174552name = "cpubits"
175553version = "0.1.1"
......@@ -185,13 +563,34 @@ dependencies = [
185563 "libc",
186564]
187565
566[[package]]
567name = "crc32fast"
568version = "1.5.1"
569source = "registry+https://github.com/rust-lang/crates.io-index"
570checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
571dependencies = [
572 "cfg-if",
573]
574
575[[package]]
576name = "crossbeam-utils"
577version = "0.8.23"
578source = "registry+https://github.com/rust-lang/crates.io-index"
579checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
580
581[[package]]
582name = "crunchy"
583version = "0.2.4"
584source = "registry+https://github.com/rust-lang/crates.io-index"
585checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
586
188587[[package]]
189588name = "crypto-common"
190589version = "0.2.2"
191590source = "registry+https://github.com/rust-lang/crates.io-index"
192591checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
193592dependencies = [
194 "getrandom",
593 "getrandom 0.4.3",
195594 "hybrid-array",
196595 "rand_core",
197596]
......@@ -214,6 +613,12 @@ dependencies = [
214613 "cmov",
215614]
216615
616[[package]]
617name = "cursor-icon"
618version = "1.2.0"
619source = "registry+https://github.com/rust-lang/crates.io-index"
620checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
621
217622[[package]]
218623name = "dbl"
219624version = "0.5.0"
......@@ -236,6 +641,63 @@ dependencies = [
236641 "zeroize",
237642]
238643
644[[package]]
645name = "dispatch"
646version = "0.2.0"
647source = "registry+https://github.com/rust-lang/crates.io-index"
648checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
649
650[[package]]
651name = "dispatch2"
652version = "0.3.1"
653source = "registry+https://github.com/rust-lang/crates.io-index"
654checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
655dependencies = [
656 "bitflags 2.13.1",
657 "objc2 0.6.4",
658]
659
660[[package]]
661name = "displaydoc"
662version = "0.2.7"
663source = "registry+https://github.com/rust-lang/crates.io-index"
664checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
665dependencies = [
666 "proc-macro2",
667 "quote",
668 "syn 3.0.5",
669]
670
671[[package]]
672name = "dlib"
673version = "0.5.3"
674source = "registry+https://github.com/rust-lang/crates.io-index"
675checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
676dependencies = [
677 "libloading",
678]
679
680[[package]]
681name = "document-features"
682version = "0.2.12"
683source = "registry+https://github.com/rust-lang/crates.io-index"
684checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
685dependencies = [
686 "litrs",
687]
688
689[[package]]
690name = "downcast-rs"
691version = "1.2.1"
692source = "registry+https://github.com/rust-lang/crates.io-index"
693checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
694
695[[package]]
696name = "dpi"
697version = "0.1.2"
698source = "registry+https://github.com/rust-lang/crates.io-index"
699checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
700
239701[[package]]
240702name = "equivalent"
241703version = "1.0.2"
......@@ -249,9 +711,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
249711checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
250712dependencies = [
251713 "libc",
252 "windows-sys",
714 "windows-sys 0.61.2",
253715]
254716
717[[package]]
718name = "error-code"
719version = "3.4.0"
720source = "registry+https://github.com/rust-lang/crates.io-index"
721checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
722
255723[[package]]
256724name = "fallible-iterator"
257725version = "0.3.0"
......@@ -271,17 +739,94 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
271739checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
272740
273741[[package]]
274name = "find-msvc-tools"
742name = "fdeflate"
743version = "0.3.7"
744source = "registry+https://github.com/rust-lang/crates.io-index"
745checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
746dependencies = [
747 "simd-adler32",
748]
749
750[[package]]
751name = "find-msvc-tools"
275752version = "0.1.12"
276753source = "registry+https://github.com/rust-lang/crates.io-index"
277754checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
278755
756[[package]]
757name = "flate2"
758version = "1.1.10"
759source = "registry+https://github.com/rust-lang/crates.io-index"
760checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
761dependencies = [
762 "crc32fast",
763 "miniz_oxide 0.9.1",
764 "zlib-rs",
765]
766
279767[[package]]
280768name = "foldhash"
281769version = "0.2.0"
282770source = "registry+https://github.com/rust-lang/crates.io-index"
283771checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
284772
773[[package]]
774name = "font-types"
775version = "0.12.5"
776source = "registry+https://github.com/rust-lang/crates.io-index"
777checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
778dependencies = [
779 "bytemuck",
780]
781
782[[package]]
783name = "fontique"
784version = "0.11.0"
785source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
786dependencies = [
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]]
804name = "foreign-types"
805version = "0.5.0"
806source = "registry+https://github.com/rust-lang/crates.io-index"
807checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
808dependencies = [
809 "foreign-types-macros",
810 "foreign-types-shared",
811]
812
813[[package]]
814name = "foreign-types-macros"
815version = "0.2.4"
816source = "registry+https://github.com/rust-lang/crates.io-index"
817checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
818dependencies = [
819 "proc-macro2",
820 "quote",
821 "syn 3.0.5",
822]
823
824[[package]]
825name = "foreign-types-shared"
826version = "0.3.1"
827source = "registry+https://github.com/rust-lang/crates.io-index"
828checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
829
285830[[package]]
286831name = "futures-core"
287832version = "0.3.34"
......@@ -306,6 +851,28 @@ dependencies = [
306851 "slab",
307852]
308853
854[[package]]
855name = "gethostname"
856version = "1.1.0"
857source = "registry+https://github.com/rust-lang/crates.io-index"
858checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
859dependencies = [
860 "rustix 1.1.4",
861 "windows-link",
862]
863
864[[package]]
865name = "getrandom"
866version = "0.3.4"
867source = "registry+https://github.com/rust-lang/crates.io-index"
868checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
869dependencies = [
870 "cfg-if",
871 "libc",
872 "r-efi 5.3.0",
873 "wasip2",
874]
875
309876[[package]]
310877name = "getrandom"
311878version = "0.4.3"
......@@ -314,7 +881,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
314881dependencies = [
315882 "cfg-if",
316883 "libc",
317 "r-efi",
884 "r-efi 6.0.0",
318885 "rand_core",
319886]
320887
......@@ -327,12 +894,84 @@ dependencies = [
327894 "polyval",
328895]
329896
897[[package]]
898name = "gl_generator"
899version = "0.14.0"
900source = "registry+https://github.com/rust-lang/crates.io-index"
901checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
902dependencies = [
903 "khronos_api",
904 "log",
905 "xml-rs",
906]
907
908[[package]]
909name = "glow"
910version = "0.17.0"
911source = "registry+https://github.com/rust-lang/crates.io-index"
912checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
913dependencies = [
914 "js-sys",
915 "slotmap",
916 "wasm-bindgen",
917 "web-sys",
918]
919
920[[package]]
921name = "glutin_wgl_sys"
922version = "0.6.1"
923source = "registry+https://github.com/rust-lang/crates.io-index"
924checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
925dependencies = [
926 "gl_generator",
927]
928
929[[package]]
930name = "gpu-allocator"
931version = "0.28.0"
932source = "registry+https://github.com/rust-lang/crates.io-index"
933checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
934dependencies = [
935 "ash",
936 "hashbrown 0.16.1",
937 "log",
938 "presser",
939 "thiserror 2.0.20",
940 "windows",
941]
942
943[[package]]
944name = "half"
945version = "2.7.1"
946source = "registry+https://github.com/rust-lang/crates.io-index"
947checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
948dependencies = [
949 "cfg-if",
950 "crunchy",
951 "num-traits",
952 "zerocopy",
953]
954
955[[package]]
956name = "harfrust"
957version = "0.12.0"
958source = "registry+https://github.com/rust-lang/crates.io-index"
959checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
960dependencies = [
961 "bitflags 2.13.1",
962 "bytemuck",
963 "read-fonts",
964 "smallvec",
965]
966
330967[[package]]
331968name = "hashbrown"
332969version = "0.16.1"
333970source = "registry+https://github.com/rust-lang/crates.io-index"
334971checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
335972dependencies = [
973 "allocator-api2",
974 "equivalent",
336975 "foldhash",
337976]
338977
......@@ -354,6 +993,12 @@ dependencies = [
354993 "hashbrown 0.17.1",
355994]
356995
996[[package]]
997name = "hermit-abi"
998version = "0.5.3"
999source = "registry+https://github.com/rust-lang/crates.io-index"
1000checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
1001
3571002[[package]]
3581003name = "hmac"
3591004version = "0.13.0"
......@@ -373,6 +1018,149 @@ dependencies = [
3731018 "zeroize",
3741019]
3751020
1021[[package]]
1022name = "icu_collections"
1023version = "2.3.0"
1024source = "registry+https://github.com/rust-lang/crates.io-index"
1025checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
1026dependencies = [
1027 "displaydoc",
1028 "potential_utf",
1029 "utf8_iter",
1030 "yoke",
1031 "zerofrom",
1032 "zerovec",
1033]
1034
1035[[package]]
1036name = "icu_locale_core"
1037version = "2.3.0"
1038source = "registry+https://github.com/rust-lang/crates.io-index"
1039checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
1040dependencies = [
1041 "displaydoc",
1042 "litemap",
1043 "serde",
1044 "tinystr",
1045 "writeable",
1046 "zerovec",
1047]
1048
1049[[package]]
1050name = "icu_locale_fallback"
1051version = "2.3.0"
1052source = "registry+https://github.com/rust-lang/crates.io-index"
1053checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
1054dependencies = [
1055 "icu_locale_core",
1056 "icu_locale_fallback_data",
1057 "icu_provider",
1058 "potential_utf",
1059 "tinystr",
1060 "zerovec",
1061]
1062
1063[[package]]
1064name = "icu_locale_fallback_data"
1065version = "2.3.0"
1066source = "registry+https://github.com/rust-lang/crates.io-index"
1067checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
1068
1069[[package]]
1070name = "icu_normalizer"
1071version = "2.3.0"
1072source = "registry+https://github.com/rust-lang/crates.io-index"
1073checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
1074dependencies = [
1075 "icu_collections",
1076 "icu_normalizer_data",
1077 "icu_properties",
1078 "icu_provider",
1079 "smallvec",
1080 "zerovec",
1081]
1082
1083[[package]]
1084name = "icu_normalizer_data"
1085version = "2.3.0"
1086source = "registry+https://github.com/rust-lang/crates.io-index"
1087checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
1088
1089[[package]]
1090name = "icu_properties"
1091version = "2.3.0"
1092source = "registry+https://github.com/rust-lang/crates.io-index"
1093checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
1094dependencies = [
1095 "displaydoc",
1096 "icu_collections",
1097 "icu_locale_core",
1098 "icu_properties_data",
1099 "icu_provider",
1100 "zerotrie",
1101 "zerovec",
1102]
1103
1104[[package]]
1105name = "icu_properties_data"
1106version = "2.3.0"
1107source = "registry+https://github.com/rust-lang/crates.io-index"
1108checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
1109
1110[[package]]
1111name = "icu_provider"
1112version = "2.3.1"
1113source = "registry+https://github.com/rust-lang/crates.io-index"
1114checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
1115dependencies = [
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]]
1128name = "icu_segmenter"
1129version = "2.3.0"
1130source = "registry+https://github.com/rust-lang/crates.io-index"
1131checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
1132dependencies = [
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]]
1144name = "icu_segmenter_data"
1145version = "2.3.0"
1146source = "registry+https://github.com/rust-lang/crates.io-index"
1147checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
1148
1149[[package]]
1150name = "image"
1151version = "0.25.10"
1152source = "registry+https://github.com/rust-lang/crates.io-index"
1153checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
1154dependencies = [
1155 "bytemuck",
1156 "byteorder-lite",
1157 "moxcms",
1158 "num-traits",
1159 "png",
1160 "zune-core",
1161 "zune-jpeg",
1162]
1163
3761164[[package]]
3771165name = "indexmap"
3781166version = "2.14.2"
......@@ -399,6 +1187,74 @@ version = "1.0.18"
3991187source = "registry+https://github.com/rust-lang/crates.io-index"
4001188checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
4011189
1190[[package]]
1191name = "jni"
1192version = "0.22.4"
1193source = "registry+https://github.com/rust-lang/crates.io-index"
1194checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
1195dependencies = [
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]]
1208name = "jni-macros"
1209version = "0.22.4"
1210source = "registry+https://github.com/rust-lang/crates.io-index"
1211checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
1212dependencies = [
1213 "proc-macro2",
1214 "quote",
1215 "rustc_version",
1216 "simd_cesu8",
1217 "syn 2.0.119",
1218]
1219
1220[[package]]
1221name = "jni-sys"
1222version = "0.3.1"
1223source = "registry+https://github.com/rust-lang/crates.io-index"
1224checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
1225dependencies = [
1226 "jni-sys 0.4.1",
1227]
1228
1229[[package]]
1230name = "jni-sys"
1231version = "0.4.1"
1232source = "registry+https://github.com/rust-lang/crates.io-index"
1233checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
1234dependencies = [
1235 "jni-sys-macros",
1236]
1237
1238[[package]]
1239name = "jni-sys-macros"
1240version = "0.4.1"
1241source = "registry+https://github.com/rust-lang/crates.io-index"
1242checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
1243dependencies = [
1244 "quote",
1245 "syn 2.0.119",
1246]
1247
1248[[package]]
1249name = "jobserver"
1250version = "0.1.35"
1251source = "registry+https://github.com/rust-lang/crates.io-index"
1252checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
1253dependencies = [
1254 "getrandom 0.4.3",
1255 "libc",
1256]
1257
4021258[[package]]
4031259name = "js-sys"
4041260version = "0.3.105"
......@@ -406,15 +1262,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4061262checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
4071263dependencies = [
4081264 "cfg-if",
1265 "futures-util",
4091266 "wasm-bindgen",
4101267]
4111268
1269[[package]]
1270name = "khronos-egl"
1271version = "6.0.0"
1272source = "registry+https://github.com/rust-lang/crates.io-index"
1273checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
1274dependencies = [
1275 "libc",
1276 "libloading",
1277 "pkg-config",
1278]
1279
1280[[package]]
1281name = "khronos_api"
1282version = "3.1.0"
1283source = "registry+https://github.com/rust-lang/crates.io-index"
1284checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
1285
4121286[[package]]
4131287name = "libc"
4141288version = "0.2.189"
4151289source = "registry+https://github.com/rust-lang/crates.io-index"
4161290checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
4171291
1292[[package]]
1293name = "libloading"
1294version = "0.8.9"
1295source = "registry+https://github.com/rust-lang/crates.io-index"
1296checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
1297dependencies = [
1298 "cfg-if",
1299 "windows-link",
1300]
1301
1302[[package]]
1303name = "libm"
1304version = "0.2.16"
1305source = "registry+https://github.com/rust-lang/crates.io-index"
1306checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1307
1308[[package]]
1309name = "libredox"
1310version = "0.1.23"
1311source = "registry+https://github.com/rust-lang/crates.io-index"
1312checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
1313dependencies = [
1314 "bitflags 2.13.1",
1315 "libc",
1316 "plain",
1317 "redox_syscall 0.9.4",
1318]
1319
4181320[[package]]
4191321name = "libsqlite3-sys"
4201322version = "0.38.2"
......@@ -426,12 +1328,45 @@ dependencies = [
4261328 "vcpkg",
4271329]
4281330
1331[[package]]
1332name = "linebender_resource_handle"
1333version = "0.1.1"
1334source = "registry+https://github.com/rust-lang/crates.io-index"
1335checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
1336
1337[[package]]
1338name = "linux-raw-sys"
1339version = "0.4.15"
1340source = "registry+https://github.com/rust-lang/crates.io-index"
1341checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
1342
4291343[[package]]
4301344name = "linux-raw-sys"
4311345version = "0.12.1"
4321346source = "registry+https://github.com/rust-lang/crates.io-index"
4331347checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
4341348
1349[[package]]
1350name = "litemap"
1351version = "0.8.3"
1352source = "registry+https://github.com/rust-lang/crates.io-index"
1353checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
1354
1355[[package]]
1356name = "litrs"
1357version = "1.0.0"
1358source = "registry+https://github.com/rust-lang/crates.io-index"
1359checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
1360
1361[[package]]
1362name = "lock_api"
1363version = "0.4.14"
1364source = "registry+https://github.com/rust-lang/crates.io-index"
1365checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1366dependencies = [
1367 "scopeguard",
1368]
1369
4351370[[package]]
4361371name = "log"
4371372version = "0.4.34"
......@@ -479,35 +1414,152 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4791414checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
4801415
4811416[[package]]
482name = "mio"
483version = "1.2.3"
1417name = "memmap2"
1418version = "0.9.11"
4841419source = "registry+https://github.com/rust-lang/crates.io-index"
485checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
1420checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
4861421dependencies = [
4871422 "libc",
488 "wasi",
489 "windows-sys",
4901423]
4911424
4921425[[package]]
493name = "nix"
494version = "0.31.3"
1426name = "miniz_oxide"
1427version = "0.8.9"
4951428source = "registry+https://github.com/rust-lang/crates.io-index"
496checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
1429checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
4971430dependencies = [
498 "bitflags",
499 "cfg-if",
500 "cfg_aliases",
501 "libc",
1431 "adler2",
1432 "simd-adler32",
5021433]
5031434
5041435[[package]]
505name = "num_enum"
506version = "0.7.6"
1436name = "miniz_oxide"
1437version = "0.9.1"
5071438source = "registry+https://github.com/rust-lang/crates.io-index"
508checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
1439checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
5091440dependencies = [
510 "num_enum_derive",
1441 "adler2",
1442 "simd-adler32",
1443]
1444
1445[[package]]
1446name = "mio"
1447version = "1.2.3"
1448source = "registry+https://github.com/rust-lang/crates.io-index"
1449checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
1450dependencies = [
1451 "libc",
1452 "wasi",
1453 "windows-sys 0.61.2",
1454]
1455
1456[[package]]
1457name = "moxcms"
1458version = "0.8.1"
1459source = "registry+https://github.com/rust-lang/crates.io-index"
1460checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
1461dependencies = [
1462 "num-traits",
1463 "pxfm",
1464]
1465
1466[[package]]
1467name = "naga"
1468version = "30.0.1"
1469source = "registry+https://github.com/rust-lang/crates.io-index"
1470checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
1471dependencies = [
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]]
1493name = "naga-types"
1494version = "30.0.1"
1495source = "registry+https://github.com/rust-lang/crates.io-index"
1496checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
1497dependencies = [
1498 "hashbrown 0.17.1",
1499 "indexmap",
1500 "rustc-hash",
1501 "thiserror 2.0.20",
1502]
1503
1504[[package]]
1505name = "ndk"
1506version = "0.9.0"
1507source = "registry+https://github.com/rust-lang/crates.io-index"
1508checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
1509dependencies = [
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]]
1520name = "ndk-context"
1521version = "0.1.1"
1522source = "registry+https://github.com/rust-lang/crates.io-index"
1523checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
1524
1525[[package]]
1526name = "ndk-sys"
1527version = "0.6.0+11769913"
1528source = "registry+https://github.com/rust-lang/crates.io-index"
1529checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
1530dependencies = [
1531 "jni-sys 0.3.1",
1532]
1533
1534[[package]]
1535name = "nix"
1536version = "0.31.3"
1537source = "registry+https://github.com/rust-lang/crates.io-index"
1538checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
1539dependencies = [
1540 "bitflags 2.13.1",
1541 "cfg-if",
1542 "cfg_aliases",
1543 "libc",
1544]
1545
1546[[package]]
1547name = "num-traits"
1548version = "0.2.19"
1549source = "registry+https://github.com/rust-lang/crates.io-index"
1550checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1551dependencies = [
1552 "autocfg",
1553 "libm",
1554]
1555
1556[[package]]
1557name = "num_enum"
1558version = "0.7.6"
1559source = "registry+https://github.com/rust-lang/crates.io-index"
1560checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
1561dependencies = [
1562 "num_enum_derive",
5111563 "rustversion",
5121564]
5131565
......@@ -524,556 +1576,2375 @@ dependencies = [
5241576]
5251577
5261578[[package]]
527name = "once_cell"
528version = "1.21.4"
1579name = "objc-sys"
1580version = "0.3.5"
5291581source = "registry+https://github.com/rust-lang/crates.io-index"
530checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1582checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
5311583
5321584[[package]]
533name = "onestore"
534version = "0.1.0"
1585name = "objc2"
1586version = "0.5.2"
1587source = "registry+https://github.com/rust-lang/crates.io-index"
1588checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
5351589dependencies = [
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",
5481592]
5491593
5501594[[package]]
551name = "onestore-diagnostic"
552version = "0.1.0"
1595name = "objc2"
1596version = "0.6.4"
1597source = "registry+https://github.com/rust-lang/crates.io-index"
1598checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
5531599dependencies = [
554 "onestore",
555 "onestore-notebook",
556 "serde",
557 "serde_json",
1600 "objc2-encode",
5581601]
5591602
5601603[[package]]
561name = "onestore-notebook"
562version = "0.1.0"
1604name = "objc2-app-kit"
1605version = "0.2.2"
1606source = "registry+https://github.com/rust-lang/crates.io-index"
1607checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
5631608dependencies = [
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",
5711617]
5721618
5731619[[package]]
574name = "onestore-offline"
575version = "0.1.0"
1620name = "objc2-app-kit"
1621version = "0.3.2"
1622source = "registry+https://github.com/rust-lang/crates.io-index"
1623checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
5761624dependencies = [
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",
5861629]
5871630
5881631[[package]]
589name = "onestore-smb"
590version = "0.1.0"
1632name = "objc2-cloud-kit"
1633version = "0.2.2"
1634source = "registry+https://github.com/rust-lang/crates.io-index"
1635checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
5911636dependencies = [
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",
5961642]
5971643
5981644[[package]]
599name = "pbkdf2"
600version = "0.13.0"
1645name = "objc2-contacts"
1646version = "0.2.2"
6011647source = "registry+https://github.com/rust-lang/crates.io-index"
602checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
1648checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
6031649dependencies = [
604 "digest",
605 "hmac",
1650 "block2 0.5.1",
1651 "objc2 0.5.2",
1652 "objc2-foundation 0.2.2",
6061653]
6071654
6081655[[package]]
609name = "pin-project-lite"
610version = "0.2.17"
1656name = "objc2-core-data"
1657version = "0.2.2"
6111658source = "registry+https://github.com/rust-lang/crates.io-index"
612checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1659checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
1660dependencies = [
1661 "bitflags 2.13.1",
1662 "block2 0.5.1",
1663 "objc2 0.5.2",
1664 "objc2-foundation 0.2.2",
1665]
6131666
6141667[[package]]
615name = "pkg-config"
616version = "0.3.34"
1668name = "objc2-core-foundation"
1669version = "0.3.2"
6171670source = "registry+https://github.com/rust-lang/crates.io-index"
618checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
1671checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
1672dependencies = [
1673 "bitflags 2.13.1",
1674 "dispatch2",
1675 "objc2 0.6.4",
1676]
6191677
6201678[[package]]
621name = "polyval"
622version = "0.7.3"
1679name = "objc2-core-graphics"
1680version = "0.3.2"
6231681source = "registry+https://github.com/rust-lang/crates.io-index"
624checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
1682checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
6251683dependencies = [
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",
6291689]
6301690
6311691[[package]]
632name = "proc-macro-crate"
633version = "3.5.0"
1692name = "objc2-core-image"
1693version = "0.2.2"
6341694source = "registry+https://github.com/rust-lang/crates.io-index"
635checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
1695checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
6361696dependencies = [
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",
6381701]
6391702
6401703[[package]]
641name = "proc-macro2"
642version = "1.0.107"
1704name = "objc2-core-location"
1705version = "0.2.2"
6431706source = "registry+https://github.com/rust-lang/crates.io-index"
644checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
1707checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
6451708dependencies = [
646 "unicode-ident",
1709 "block2 0.5.1",
1710 "objc2 0.5.2",
1711 "objc2-contacts",
1712 "objc2-foundation 0.2.2",
6471713]
6481714
6491715[[package]]
650name = "quote"
651version = "1.0.47"
1716name = "objc2-core-text"
1717version = "0.3.2"
6521718source = "registry+https://github.com/rust-lang/crates.io-index"
653checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
1719checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
6541720dependencies = [
655 "proc-macro2",
1721 "bitflags 2.13.1",
1722 "objc2-core-foundation",
6561723]
6571724
6581725[[package]]
659name = "r-efi"
660version = "6.0.0"
1726name = "objc2-encode"
1727version = "4.1.0"
6611728source = "registry+https://github.com/rust-lang/crates.io-index"
662checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1729checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
6631730
6641731[[package]]
665name = "rand_core"
666version = "0.10.1"
1732name = "objc2-foundation"
1733version = "0.2.2"
6671734source = "registry+https://github.com/rust-lang/crates.io-index"
668checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
1735checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
1736dependencies = [
1737 "bitflags 2.13.1",
1738 "block2 0.5.1",
1739 "dispatch",
1740 "libc",
1741 "objc2 0.5.2",
1742]
6691743
6701744[[package]]
671name = "roxmltree"
672version = "0.21.1"
1745name = "objc2-foundation"
1746version = "0.3.2"
6731747source = "registry+https://github.com/rust-lang/crates.io-index"
674checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
1748checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
6751749dependencies = [
676 "memchr",
1750 "bitflags 2.13.1",
1751 "objc2 0.6.4",
1752 "objc2-core-foundation",
6771753]
6781754
6791755[[package]]
680name = "rsqlite-vfs"
681version = "0.1.1"
1756name = "objc2-io-surface"
1757version = "0.3.2"
6821758source = "registry+https://github.com/rust-lang/crates.io-index"
683checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
1759checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
6841760dependencies = [
685 "hashbrown 0.16.1",
686 "thiserror",
1761 "bitflags 2.13.1",
1762 "objc2 0.6.4",
1763 "objc2-core-foundation",
6871764]
6881765
6891766[[package]]
690name = "rusqlite"
691version = "0.40.2"
1767name = "objc2-link-presentation"
1768version = "0.2.2"
6921769source = "registry+https://github.com/rust-lang/crates.io-index"
693checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
1770checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
6941771dependencies = [
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",
7021776]
7031777
7041778[[package]]
705name = "rustix"
706version = "1.1.4"
1779name = "objc2-metal"
1780version = "0.2.2"
7071781source = "registry+https://github.com/rust-lang/crates.io-index"
708checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
1782checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
7091783dependencies = [
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",
7151788]
7161789
7171790[[package]]
718name = "rustversion"
719version = "1.0.23"
1791name = "objc2-metal"
1792version = "0.3.2"
7201793source = "registry+https://github.com/rust-lang/crates.io-index"
721checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
1794checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
1795dependencies = [
1796 "bitflags 2.13.1",
1797 "block2 0.6.2",
1798 "objc2 0.6.4",
1799 "objc2-foundation 0.3.2",
1800]
7221801
7231802[[package]]
724name = "serde"
725version = "1.0.229"
1803name = "objc2-quartz-core"
1804version = "0.2.2"
7261805source = "registry+https://github.com/rust-lang/crates.io-index"
727checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
1806checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
7281807dependencies = [
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]]
1816name = "objc2-quartz-core"
1817version = "0.3.2"
1818source = "registry+https://github.com/rust-lang/crates.io-index"
1819checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
1820dependencies = [
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]]
1830name = "objc2-symbols"
1831version = "0.2.2"
1832source = "registry+https://github.com/rust-lang/crates.io-index"
1833checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
1834dependencies = [
1835 "objc2 0.5.2",
1836 "objc2-foundation 0.2.2",
1837]
1838
1839[[package]]
1840name = "objc2-ui-kit"
1841version = "0.2.2"
1842source = "registry+https://github.com/rust-lang/crates.io-index"
1843checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
1844dependencies = [
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]]
1861name = "objc2-uniform-type-identifiers"
1862version = "0.2.2"
1863source = "registry+https://github.com/rust-lang/crates.io-index"
1864checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
1865dependencies = [
1866 "block2 0.5.1",
1867 "objc2 0.5.2",
1868 "objc2-foundation 0.2.2",
1869]
1870
1871[[package]]
1872name = "objc2-user-notifications"
1873version = "0.2.2"
1874source = "registry+https://github.com/rust-lang/crates.io-index"
1875checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
1876dependencies = [
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]]
1885name = "once_cell"
1886version = "1.21.4"
1887source = "registry+https://github.com/rust-lang/crates.io-index"
1888checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1889
1890[[package]]
1891name = "one-canvas"
1892version = "0.1.0"
1893dependencies = [
1894 "getrandom 0.4.3",
1895 "onestore",
1896 "parley",
1897 "serde",
1898 "serde_json",
1899 "skrifa",
7311900]
7321901
7331902[[package]]
734name = "serde_core"
735version = "1.0.229"
1903name = "one-canvas-app"
1904version = "0.1.0"
1905dependencies = [
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]]
1923name = "one-canvas-gpu"
1924version = "0.1.0"
1925dependencies = [
1926 "bytemuck",
1927 "image",
1928 "one-canvas",
1929 "onestore",
1930 "parley",
1931 "png",
1932 "pollster",
1933 "swash",
1934 "wgpu",
1935]
1936
1937[[package]]
1938name = "onestore"
1939version = "0.1.0"
1940dependencies = [
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]]
1956name = "onestore-diagnostic"
1957version = "0.1.0"
1958dependencies = [
1959 "onestore",
1960 "onestore-notebook",
1961 "serde",
1962 "serde_json",
1963]
1964
1965[[package]]
1966name = "onestore-notebook"
1967version = "0.1.0"
1968dependencies = [
1969 "onestore",
1970 "onestore-smb",
1971 "serde",
1972 "serde_json",
1973 "tempfile",
1974 "thiserror 2.0.20",
1975 "zeroize",
1976]
1977
1978[[package]]
1979name = "onestore-offline"
1980version = "0.1.0"
1981dependencies = [
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]]
1994name = "onestore-smb"
1995version = "0.1.0"
1996dependencies = [
1997 "onestore",
1998 "serde_json",
1999 "smb2",
2000 "tokio",
2001]
2002
2003[[package]]
2004name = "orbclient"
2005version = "0.3.55"
2006source = "registry+https://github.com/rust-lang/crates.io-index"
2007checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747"
2008dependencies = [
2009 "libc",
2010 "libredox",
2011]
2012
2013[[package]]
2014name = "ordered-float"
2015version = "5.5.0"
2016source = "registry+https://github.com/rust-lang/crates.io-index"
2017checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
2018dependencies = [
2019 "num-traits",
2020]
2021
2022[[package]]
2023name = "owned_ttf_parser"
2024version = "0.25.1"
2025source = "registry+https://github.com/rust-lang/crates.io-index"
2026checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
2027dependencies = [
2028 "ttf-parser",
2029]
2030
2031[[package]]
2032name = "parking_lot"
2033version = "0.12.5"
2034source = "registry+https://github.com/rust-lang/crates.io-index"
2035checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
2036dependencies = [
2037 "lock_api",
2038 "parking_lot_core",
2039]
2040
2041[[package]]
2042name = "parking_lot_core"
2043version = "0.9.12"
2044source = "registry+https://github.com/rust-lang/crates.io-index"
2045checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
2046dependencies = [
2047 "cfg-if",
2048 "libc",
2049 "redox_syscall 0.5.18",
2050 "smallvec",
2051 "windows-link",
2052]
2053
2054[[package]]
2055name = "parlance"
2056version = "0.1.0"
2057source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
2058
2059[[package]]
2060name = "parley"
2061version = "0.11.0"
2062source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
2063dependencies = [
2064 "fontique",
2065 "hashbrown 0.17.1",
2066 "linebender_resource_handle",
2067 "parlance",
2068 "parley_engine",
2069 "skrifa",
2070 "smallvec",
2071]
2072
2073[[package]]
2074name = "parley_data"
2075version = "0.11.0"
2076source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
2077dependencies = [
2078 "icu_properties",
2079]
2080
2081[[package]]
2082name = "parley_engine"
2083version = "0.11.0"
2084source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
2085dependencies = [
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]]
2099name = "pbkdf2"
2100version = "0.13.0"
2101source = "registry+https://github.com/rust-lang/crates.io-index"
2102checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
2103dependencies = [
2104 "digest",
2105 "hmac",
2106]
2107
2108[[package]]
2109name = "percent-encoding"
2110version = "2.3.2"
2111source = "registry+https://github.com/rust-lang/crates.io-index"
2112checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
2113
2114[[package]]
2115name = "pin-project"
2116version = "1.1.13"
2117source = "registry+https://github.com/rust-lang/crates.io-index"
2118checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
2119dependencies = [
2120 "pin-project-internal",
2121]
2122
2123[[package]]
2124name = "pin-project-internal"
2125version = "1.1.13"
2126source = "registry+https://github.com/rust-lang/crates.io-index"
2127checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
2128dependencies = [
2129 "proc-macro2",
2130 "quote",
2131 "syn 2.0.119",
2132]
2133
2134[[package]]
2135name = "pin-project-lite"
2136version = "0.2.17"
2137source = "registry+https://github.com/rust-lang/crates.io-index"
2138checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
2139
2140[[package]]
2141name = "pkg-config"
2142version = "0.3.34"
2143source = "registry+https://github.com/rust-lang/crates.io-index"
2144checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
2145
2146[[package]]
2147name = "plain"
2148version = "0.2.3"
2149source = "registry+https://github.com/rust-lang/crates.io-index"
2150checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
2151
2152[[package]]
2153name = "png"
2154version = "0.18.1"
2155source = "registry+https://github.com/rust-lang/crates.io-index"
2156checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
2157dependencies = [
2158 "bitflags 2.13.1",
2159 "crc32fast",
2160 "fdeflate",
2161 "flate2",
2162 "miniz_oxide 0.8.9",
2163]
2164
2165[[package]]
2166name = "polling"
2167version = "3.11.0"
2168source = "registry+https://github.com/rust-lang/crates.io-index"
2169checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
2170dependencies = [
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]]
2180name = "pollster"
2181version = "0.4.0"
2182source = "registry+https://github.com/rust-lang/crates.io-index"
2183checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
2184
2185[[package]]
2186name = "polyval"
2187version = "0.7.3"
2188source = "registry+https://github.com/rust-lang/crates.io-index"
2189checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
2190dependencies = [
2191 "cpubits",
2192 "cpufeatures",
2193 "universal-hash",
2194]
2195
2196[[package]]
2197name = "portable-atomic"
2198version = "1.15.0"
2199source = "registry+https://github.com/rust-lang/crates.io-index"
2200checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
2201
2202[[package]]
2203name = "portable-atomic-util"
2204version = "0.2.8"
2205source = "registry+https://github.com/rust-lang/crates.io-index"
2206checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
2207dependencies = [
2208 "portable-atomic",
2209]
2210
2211[[package]]
2212name = "potential_utf"
2213version = "0.1.6"
2214source = "registry+https://github.com/rust-lang/crates.io-index"
2215checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
2216dependencies = [
2217 "serde_core",
2218 "writeable",
2219 "zerovec",
2220]
2221
2222[[package]]
2223name = "presser"
2224version = "0.3.1"
2225source = "registry+https://github.com/rust-lang/crates.io-index"
2226checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
2227
2228[[package]]
2229name = "proc-macro-crate"
2230version = "3.5.0"
2231source = "registry+https://github.com/rust-lang/crates.io-index"
2232checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
2233dependencies = [
2234 "toml_edit",
2235]
2236
2237[[package]]
2238name = "proc-macro2"
2239version = "1.0.107"
2240source = "registry+https://github.com/rust-lang/crates.io-index"
2241checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
2242dependencies = [
2243 "unicode-ident",
2244]
2245
2246[[package]]
2247name = "profiling"
2248version = "1.0.18"
2249source = "registry+https://github.com/rust-lang/crates.io-index"
2250checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
2251
2252[[package]]
2253name = "pxfm"
2254version = "0.1.30"
2255source = "registry+https://github.com/rust-lang/crates.io-index"
2256checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
2257
2258[[package]]
2259name = "quick-xml"
2260version = "0.41.0"
2261source = "registry+https://github.com/rust-lang/crates.io-index"
2262checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
2263dependencies = [
2264 "memchr",
2265]
2266
2267[[package]]
2268name = "quote"
2269version = "1.0.47"
2270source = "registry+https://github.com/rust-lang/crates.io-index"
2271checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
2272dependencies = [
2273 "proc-macro2",
2274]
2275
2276[[package]]
2277name = "r-efi"
2278version = "5.3.0"
2279source = "registry+https://github.com/rust-lang/crates.io-index"
2280checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
2281
2282[[package]]
2283name = "r-efi"
2284version = "6.0.0"
2285source = "registry+https://github.com/rust-lang/crates.io-index"
2286checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
2287
2288[[package]]
2289name = "rand_core"
2290version = "0.10.1"
2291source = "registry+https://github.com/rust-lang/crates.io-index"
2292checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
2293
2294[[package]]
2295name = "range-alloc"
2296version = "0.1.5"
2297source = "registry+https://github.com/rust-lang/crates.io-index"
2298checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
2299
2300[[package]]
2301name = "raw-window-handle"
2302version = "0.6.2"
2303source = "registry+https://github.com/rust-lang/crates.io-index"
2304checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
2305
2306[[package]]
2307name = "raw-window-metal"
2308version = "1.1.0"
2309source = "registry+https://github.com/rust-lang/crates.io-index"
2310checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
2311dependencies = [
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]]
2319name = "read-fonts"
2320version = "0.41.0"
2321source = "registry+https://github.com/rust-lang/crates.io-index"
2322checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
2323dependencies = [
2324 "bytemuck",
2325 "font-types",
2326 "once_cell",
2327]
2328
2329[[package]]
2330name = "redox_syscall"
2331version = "0.4.1"
2332source = "registry+https://github.com/rust-lang/crates.io-index"
2333checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
2334dependencies = [
2335 "bitflags 1.3.2",
2336]
2337
2338[[package]]
2339name = "redox_syscall"
2340version = "0.5.18"
2341source = "registry+https://github.com/rust-lang/crates.io-index"
2342checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
2343dependencies = [
2344 "bitflags 2.13.1",
2345]
2346
2347[[package]]
2348name = "redox_syscall"
2349version = "0.9.4"
2350source = "registry+https://github.com/rust-lang/crates.io-index"
2351checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
2352dependencies = [
2353 "bitflags 2.13.1",
2354]
2355
2356[[package]]
2357name = "renderdoc-sys"
2358version = "1.1.0"
2359source = "registry+https://github.com/rust-lang/crates.io-index"
2360checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
2361
2362[[package]]
2363name = "roxmltree"
2364version = "0.21.1"
2365source = "registry+https://github.com/rust-lang/crates.io-index"
2366checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
2367dependencies = [
2368 "memchr",
2369]
2370
2371[[package]]
2372name = "rsqlite-vfs"
2373version = "0.1.1"
2374source = "registry+https://github.com/rust-lang/crates.io-index"
2375checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
2376dependencies = [
2377 "hashbrown 0.16.1",
2378 "thiserror 2.0.20",
2379]
2380
2381[[package]]
2382name = "rusqlite"
2383version = "0.40.2"
2384source = "registry+https://github.com/rust-lang/crates.io-index"
2385checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
2386dependencies = [
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]]
2397name = "rustc-hash"
2398version = "1.1.0"
2399source = "registry+https://github.com/rust-lang/crates.io-index"
2400checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
2401
2402[[package]]
2403name = "rustc_version"
2404version = "0.4.1"
2405source = "registry+https://github.com/rust-lang/crates.io-index"
2406checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
2407dependencies = [
2408 "semver",
2409]
2410
2411[[package]]
2412name = "rustix"
2413version = "0.38.44"
2414source = "registry+https://github.com/rust-lang/crates.io-index"
2415checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
2416dependencies = [
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]]
2425name = "rustix"
2426version = "1.1.4"
2427source = "registry+https://github.com/rust-lang/crates.io-index"
2428checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
2429dependencies = [
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]]
2438name = "rustversion"
2439version = "1.0.23"
2440source = "registry+https://github.com/rust-lang/crates.io-index"
2441checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
2442
2443[[package]]
2444name = "same-file"
2445version = "1.0.6"
2446source = "registry+https://github.com/rust-lang/crates.io-index"
2447checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
2448dependencies = [
2449 "winapi-util",
2450]
2451
2452[[package]]
2453name = "scoped-tls"
2454version = "1.0.1"
2455source = "registry+https://github.com/rust-lang/crates.io-index"
2456checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
2457
2458[[package]]
2459name = "scopeguard"
2460version = "1.2.0"
2461source = "registry+https://github.com/rust-lang/crates.io-index"
2462checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
2463
2464[[package]]
2465name = "sctk-adwaita"
2466version = "0.10.1"
2467source = "registry+https://github.com/rust-lang/crates.io-index"
2468checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
2469dependencies = [
2470 "ab_glyph",
2471 "log",
2472 "memmap2",
2473 "smithay-client-toolkit",
2474 "tiny-skia",
2475]
2476
2477[[package]]
2478name = "semver"
2479version = "1.0.28"
2480source = "registry+https://github.com/rust-lang/crates.io-index"
2481checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
2482
2483[[package]]
2484name = "serde"
2485version = "1.0.229"
2486source = "registry+https://github.com/rust-lang/crates.io-index"
2487checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
2488dependencies = [
2489 "serde_core",
2490 "serde_derive",
2491]
2492
2493[[package]]
2494name = "serde_core"
2495version = "1.0.229"
2496source = "registry+https://github.com/rust-lang/crates.io-index"
2497checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
2498dependencies = [
2499 "serde_derive",
2500]
2501
2502[[package]]
2503name = "serde_derive"
2504version = "1.0.229"
2505source = "registry+https://github.com/rust-lang/crates.io-index"
2506checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
2507dependencies = [
2508 "proc-macro2",
2509 "quote",
2510 "syn 3.0.5",
2511]
2512
2513[[package]]
2514name = "serde_json"
2515version = "1.0.151"
2516source = "registry+https://github.com/rust-lang/crates.io-index"
2517checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
2518dependencies = [
2519 "itoa",
2520 "memchr",
2521 "serde",
2522 "serde_core",
2523 "zmij",
2524]
2525
2526[[package]]
2527name = "sha1"
2528version = "0.11.0"
2529source = "registry+https://github.com/rust-lang/crates.io-index"
2530checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
2531dependencies = [
2532 "cfg-if",
2533 "cpufeatures",
2534 "digest",
2535]
2536
2537[[package]]
2538name = "sha2"
2539version = "0.11.0"
2540source = "registry+https://github.com/rust-lang/crates.io-index"
2541checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
2542dependencies = [
2543 "cfg-if",
2544 "cpufeatures",
2545 "digest",
2546]
2547
2548[[package]]
2549name = "shlex"
2550version = "2.0.1"
2551source = "registry+https://github.com/rust-lang/crates.io-index"
2552checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
2553
2554[[package]]
2555name = "simd-adler32"
2556version = "0.3.10"
2557source = "registry+https://github.com/rust-lang/crates.io-index"
2558checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
2559
2560[[package]]
2561name = "simd_cesu8"
2562version = "1.2.0"
2563source = "registry+https://github.com/rust-lang/crates.io-index"
2564checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
2565dependencies = [
2566 "rustc_version",
2567 "simdutf8",
2568]
2569
2570[[package]]
2571name = "simdutf8"
2572version = "0.1.5"
2573source = "registry+https://github.com/rust-lang/crates.io-index"
2574checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
2575
2576[[package]]
2577name = "skrifa"
2578version = "0.44.0"
2579source = "registry+https://github.com/rust-lang/crates.io-index"
2580checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
2581dependencies = [
2582 "bytemuck",
2583 "read-fonts",
2584]
2585
2586[[package]]
2587name = "slab"
2588version = "0.4.12"
2589source = "registry+https://github.com/rust-lang/crates.io-index"
2590checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
2591
2592[[package]]
2593name = "slotmap"
2594version = "1.1.1"
2595source = "registry+https://github.com/rust-lang/crates.io-index"
2596checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
2597dependencies = [
2598 "version_check",
2599]
2600
2601[[package]]
2602name = "smallvec"
2603version = "1.16.0"
2604source = "registry+https://github.com/rust-lang/crates.io-index"
2605checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
2606
2607[[package]]
2608name = "smb2"
2609version = "0.21.0"
2610source = "registry+https://github.com/rust-lang/crates.io-index"
2611checksum = "9ef4f20cff3d39a131335d17df6146eada7851d7705252f2765b8717a0f52db8"
2612dependencies = [
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]]
2635name = "smithay-client-toolkit"
2636version = "0.19.2"
2637source = "registry+https://github.com/rust-lang/crates.io-index"
2638checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
2639dependencies = [
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]]
2660name = "smol_str"
2661version = "0.2.2"
2662source = "registry+https://github.com/rust-lang/crates.io-index"
2663checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
2664dependencies = [
2665 "serde",
2666]
2667
2668[[package]]
2669name = "socket2"
2670version = "0.6.5"
2671source = "registry+https://github.com/rust-lang/crates.io-index"
2672checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
2673dependencies = [
2674 "libc",
2675 "windows-sys 0.61.2",
2676]
2677
2678[[package]]
2679name = "spirv"
2680version = "0.4.0+sdk-1.4.341.0"
2681source = "registry+https://github.com/rust-lang/crates.io-index"
2682checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
2683dependencies = [
2684 "bitflags 2.13.1",
2685]
2686
2687[[package]]
2688name = "sqlite-wasm-rs"
2689version = "0.5.5"
2690source = "registry+https://github.com/rust-lang/crates.io-index"
2691checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
2692dependencies = [
2693 "cc",
2694 "js-sys",
2695 "rsqlite-vfs",
2696 "wasm-bindgen",
2697]
2698
2699[[package]]
2700name = "stable_deref_trait"
2701version = "1.2.1"
2702source = "registry+https://github.com/rust-lang/crates.io-index"
2703checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
2704
2705[[package]]
2706name = "static_assertions"
2707version = "1.1.0"
2708source = "registry+https://github.com/rust-lang/crates.io-index"
2709checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
2710
2711[[package]]
2712name = "strict-num"
2713version = "0.1.1"
2714source = "registry+https://github.com/rust-lang/crates.io-index"
2715checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
2716
2717[[package]]
2718name = "subtle"
2719version = "2.6.1"
2720source = "registry+https://github.com/rust-lang/crates.io-index"
2721checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
2722
2723[[package]]
2724name = "swash"
2725version = "0.2.10"
2726source = "registry+https://github.com/rust-lang/crates.io-index"
2727checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e"
2728dependencies = [
2729 "skrifa",
2730 "yazi",
2731 "zeno",
2732]
2733
2734[[package]]
2735name = "syn"
2736version = "2.0.119"
2737source = "registry+https://github.com/rust-lang/crates.io-index"
2738checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
2739dependencies = [
2740 "proc-macro2",
2741 "quote",
2742 "unicode-ident",
2743]
2744
2745[[package]]
2746name = "syn"
2747version = "3.0.5"
2748source = "registry+https://github.com/rust-lang/crates.io-index"
2749checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
2750dependencies = [
2751 "proc-macro2",
2752 "quote",
2753 "unicode-ident",
2754]
2755
2756[[package]]
2757name = "synstructure"
2758version = "0.13.2"
2759source = "registry+https://github.com/rust-lang/crates.io-index"
2760checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
2761dependencies = [
2762 "proc-macro2",
2763 "quote",
2764 "syn 2.0.119",
2765]
2766
2767[[package]]
2768name = "tempfile"
2769version = "3.27.0"
2770source = "registry+https://github.com/rust-lang/crates.io-index"
2771checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
2772dependencies = [
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]]
2781name = "termcolor"
2782version = "1.4.1"
2783source = "registry+https://github.com/rust-lang/crates.io-index"
2784checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
2785dependencies = [
2786 "winapi-util",
2787]
2788
2789[[package]]
2790name = "thiserror"
2791version = "1.0.69"
2792source = "registry+https://github.com/rust-lang/crates.io-index"
2793checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
2794dependencies = [
2795 "thiserror-impl 1.0.69",
2796]
2797
2798[[package]]
2799name = "thiserror"
2800version = "2.0.20"
2801source = "registry+https://github.com/rust-lang/crates.io-index"
2802checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
2803dependencies = [
2804 "thiserror-impl 2.0.20",
2805]
2806
2807[[package]]
2808name = "thiserror-impl"
2809version = "1.0.69"
2810source = "registry+https://github.com/rust-lang/crates.io-index"
2811checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
2812dependencies = [
2813 "proc-macro2",
2814 "quote",
2815 "syn 2.0.119",
2816]
2817
2818[[package]]
2819name = "thiserror-impl"
2820version = "2.0.20"
2821source = "registry+https://github.com/rust-lang/crates.io-index"
2822checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
2823dependencies = [
2824 "proc-macro2",
2825 "quote",
2826 "syn 3.0.5",
2827]
2828
2829[[package]]
2830name = "tiny-skia"
2831version = "0.11.4"
2832source = "registry+https://github.com/rust-lang/crates.io-index"
2833checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
2834dependencies = [
2835 "arrayref",
2836 "arrayvec",
2837 "bytemuck",
2838 "cfg-if",
2839 "log",
2840 "tiny-skia-path",
2841]
2842
2843[[package]]
2844name = "tiny-skia-path"
2845version = "0.11.4"
2846source = "registry+https://github.com/rust-lang/crates.io-index"
2847checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
2848dependencies = [
2849 "arrayref",
2850 "bytemuck",
2851 "strict-num",
2852]
2853
2854[[package]]
2855name = "tinystr"
2856version = "0.8.4"
2857source = "registry+https://github.com/rust-lang/crates.io-index"
2858checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
2859dependencies = [
2860 "displaydoc",
2861 "serde_core",
2862 "zerovec",
2863]
2864
2865[[package]]
2866name = "tokio"
2867version = "1.53.1"
2868source = "registry+https://github.com/rust-lang/crates.io-index"
2869checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
2870dependencies = [
2871 "bytes",
2872 "libc",
2873 "mio",
2874 "pin-project-lite",
2875 "socket2",
2876 "windows-sys 0.61.2",
2877]
2878
2879[[package]]
2880name = "toml_datetime"
2881version = "1.1.1+spec-1.1.0"
2882source = "registry+https://github.com/rust-lang/crates.io-index"
2883checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
2884dependencies = [
2885 "serde_core",
2886]
2887
2888[[package]]
2889name = "toml_edit"
2890version = "0.25.13+spec-1.1.0"
2891source = "registry+https://github.com/rust-lang/crates.io-index"
2892checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
2893dependencies = [
2894 "indexmap",
2895 "toml_datetime",
2896 "toml_parser",
2897 "winnow",
2898]
2899
2900[[package]]
2901name = "toml_parser"
2902version = "1.1.3+spec-1.1.0"
2903source = "registry+https://github.com/rust-lang/crates.io-index"
2904checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
2905dependencies = [
2906 "winnow",
2907]
2908
2909[[package]]
2910name = "tracing"
2911version = "0.1.44"
2912source = "registry+https://github.com/rust-lang/crates.io-index"
2913checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2914dependencies = [
2915 "pin-project-lite",
2916 "tracing-core",
2917]
2918
2919[[package]]
2920name = "tracing-core"
2921version = "0.1.36"
2922source = "registry+https://github.com/rust-lang/crates.io-index"
2923checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2924
2925[[package]]
2926name = "ttf-parser"
2927version = "0.25.1"
2928source = "registry+https://github.com/rust-lang/crates.io-index"
2929checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
2930
2931[[package]]
2932name = "twox-hash"
2933version = "2.1.4"
2934source = "registry+https://github.com/rust-lang/crates.io-index"
2935checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a"
2936
2937[[package]]
2938name = "typenum"
2939version = "1.20.1"
2940source = "registry+https://github.com/rust-lang/crates.io-index"
2941checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
2942
2943[[package]]
2944name = "unicode-ident"
2945version = "1.0.24"
2946source = "registry+https://github.com/rust-lang/crates.io-index"
2947checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
2948
2949[[package]]
2950name = "unicode-segmentation"
2951version = "1.13.3"
2952source = "registry+https://github.com/rust-lang/crates.io-index"
2953checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
2954
2955[[package]]
2956name = "unicode-width"
2957version = "0.2.2"
2958source = "registry+https://github.com/rust-lang/crates.io-index"
2959checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
2960
2961[[package]]
2962name = "universal-hash"
2963version = "0.6.1"
2964source = "registry+https://github.com/rust-lang/crates.io-index"
2965checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
2966dependencies = [
2967 "crypto-common",
2968 "ctutils",
2969]
2970
2971[[package]]
2972name = "utf8_iter"
2973version = "1.0.4"
2974source = "registry+https://github.com/rust-lang/crates.io-index"
2975checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2976
2977[[package]]
2978name = "uuid"
2979version = "1.26.1"
2980source = "registry+https://github.com/rust-lang/crates.io-index"
2981checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
2982
2983[[package]]
2984name = "vcpkg"
2985version = "0.2.15"
2986source = "registry+https://github.com/rust-lang/crates.io-index"
2987checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2988
2989[[package]]
2990name = "version_check"
2991version = "0.9.5"
2992source = "registry+https://github.com/rust-lang/crates.io-index"
2993checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2994
2995[[package]]
2996name = "walkdir"
2997version = "2.5.0"
2998source = "registry+https://github.com/rust-lang/crates.io-index"
2999checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
3000dependencies = [
3001 "same-file",
3002 "winapi-util",
3003]
3004
3005[[package]]
3006name = "wasi"
3007version = "0.11.1+wasi-snapshot-preview1"
3008source = "registry+https://github.com/rust-lang/crates.io-index"
3009checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
3010
3011[[package]]
3012name = "wasip2"
3013version = "1.0.4+wasi-0.2.12"
3014source = "registry+https://github.com/rust-lang/crates.io-index"
3015checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
3016dependencies = [
3017 "wit-bindgen",
3018]
3019
3020[[package]]
3021name = "wasm-bindgen"
3022version = "0.2.128"
3023source = "registry+https://github.com/rust-lang/crates.io-index"
3024checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
3025dependencies = [
3026 "cfg-if",
3027 "once_cell",
3028 "rustversion",
3029 "wasm-bindgen-macro",
3030 "wasm-bindgen-shared",
3031]
3032
3033[[package]]
3034name = "wasm-bindgen-futures"
3035version = "0.4.78"
3036source = "registry+https://github.com/rust-lang/crates.io-index"
3037checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
3038dependencies = [
3039 "js-sys",
3040 "wasm-bindgen",
3041]
3042
3043[[package]]
3044name = "wasm-bindgen-macro"
3045version = "0.2.128"
3046source = "registry+https://github.com/rust-lang/crates.io-index"
3047checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
3048dependencies = [
3049 "quote",
3050 "wasm-bindgen-macro-support",
3051]
3052
3053[[package]]
3054name = "wasm-bindgen-macro-support"
3055version = "0.2.128"
3056source = "registry+https://github.com/rust-lang/crates.io-index"
3057checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
3058dependencies = [
3059 "bumpalo",
3060 "proc-macro2",
3061 "quote",
3062 "syn 3.0.5",
3063 "wasm-bindgen-shared",
3064]
3065
3066[[package]]
3067name = "wasm-bindgen-shared"
3068version = "0.2.128"
3069source = "registry+https://github.com/rust-lang/crates.io-index"
3070checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
3071dependencies = [
3072 "unicode-ident",
3073]
3074
3075[[package]]
3076name = "wayland-backend"
3077version = "0.3.17"
3078source = "registry+https://github.com/rust-lang/crates.io-index"
3079checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
3080dependencies = [
3081 "cc",
3082 "downcast-rs",
3083 "rustix 1.1.4",
3084 "scoped-tls",
3085 "smallvec",
3086 "wayland-sys",
3087]
3088
3089[[package]]
3090name = "wayland-client"
3091version = "0.31.15"
3092source = "registry+https://github.com/rust-lang/crates.io-index"
3093checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
3094dependencies = [
3095 "bitflags 2.13.1",
3096 "rustix 1.1.4",
3097 "wayland-backend",
3098 "wayland-scanner",
3099]
3100
3101[[package]]
3102name = "wayland-csd-frame"
3103version = "0.3.0"
3104source = "registry+https://github.com/rust-lang/crates.io-index"
3105checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
3106dependencies = [
3107 "bitflags 2.13.1",
3108 "cursor-icon",
3109 "wayland-backend",
3110]
3111
3112[[package]]
3113name = "wayland-cursor"
3114version = "0.31.14"
3115source = "registry+https://github.com/rust-lang/crates.io-index"
3116checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
3117dependencies = [
3118 "rustix 1.1.4",
3119 "wayland-client",
3120 "xcursor",
3121]
3122
3123[[package]]
3124name = "wayland-protocols"
3125version = "0.32.13"
3126source = "registry+https://github.com/rust-lang/crates.io-index"
3127checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
3128dependencies = [
3129 "bitflags 2.13.1",
3130 "wayland-backend",
3131 "wayland-client",
3132 "wayland-scanner",
3133]
3134
3135[[package]]
3136name = "wayland-protocols-plasma"
3137version = "0.3.12"
3138source = "registry+https://github.com/rust-lang/crates.io-index"
3139checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
3140dependencies = [
3141 "bitflags 2.13.1",
3142 "wayland-backend",
3143 "wayland-client",
3144 "wayland-protocols",
3145 "wayland-scanner",
3146]
3147
3148[[package]]
3149name = "wayland-protocols-wlr"
3150version = "0.3.12"
3151source = "registry+https://github.com/rust-lang/crates.io-index"
3152checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
3153dependencies = [
3154 "bitflags 2.13.1",
3155 "wayland-backend",
3156 "wayland-client",
3157 "wayland-protocols",
3158 "wayland-scanner",
3159]
3160
3161[[package]]
3162name = "wayland-scanner"
3163version = "0.31.11"
3164source = "registry+https://github.com/rust-lang/crates.io-index"
3165checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
3166dependencies = [
3167 "proc-macro2",
3168 "quick-xml",
3169 "quote",
3170]
3171
3172[[package]]
3173name = "wayland-sys"
3174version = "0.31.11"
3175source = "registry+https://github.com/rust-lang/crates.io-index"
3176checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
3177dependencies = [
3178 "dlib",
3179 "log",
3180 "once_cell",
3181 "pkg-config",
3182]
3183
3184[[package]]
3185name = "web-sys"
3186version = "0.3.105"
3187source = "registry+https://github.com/rust-lang/crates.io-index"
3188checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
3189dependencies = [
3190 "js-sys",
3191 "wasm-bindgen",
3192]
3193
3194[[package]]
3195name = "web-time"
3196version = "1.1.0"
3197source = "registry+https://github.com/rust-lang/crates.io-index"
3198checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
3199dependencies = [
3200 "js-sys",
3201 "wasm-bindgen",
3202]
3203
3204[[package]]
3205name = "wgpu"
3206version = "30.0.1"
3207source = "registry+https://github.com/rust-lang/crates.io-index"
3208checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
3209dependencies = [
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]]
3235name = "wgpu-core"
3236version = "30.0.1"
3237source = "registry+https://github.com/rust-lang/crates.io-index"
3238checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
3239dependencies = [
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]]
3269name = "wgpu-core-deps-apple"
3270version = "30.0.1"
3271source = "registry+https://github.com/rust-lang/crates.io-index"
3272checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
3273dependencies = [
3274 "wgpu-hal",
3275]
3276
3277[[package]]
3278name = "wgpu-core-deps-emscripten"
3279version = "30.0.1"
3280source = "registry+https://github.com/rust-lang/crates.io-index"
3281checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
3282dependencies = [
3283 "wgpu-hal",
3284]
3285
3286[[package]]
3287name = "wgpu-core-deps-windows-linux-android"
3288version = "30.0.1"
3289source = "registry+https://github.com/rust-lang/crates.io-index"
3290checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f"
3291dependencies = [
3292 "wgpu-hal",
3293]
3294
3295[[package]]
3296name = "wgpu-hal"
3297version = "30.0.1"
3298source = "registry+https://github.com/rust-lang/crates.io-index"
3299checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
3300dependencies = [
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]]
3352name = "wgpu-naga-bridge"
3353version = "30.0.1"
3354source = "registry+https://github.com/rust-lang/crates.io-index"
3355checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081"
3356dependencies = [
3357 "naga",
3358 "wgpu-types",
3359]
3360
3361[[package]]
3362name = "wgpu-types"
3363version = "30.0.1"
3364source = "registry+https://github.com/rust-lang/crates.io-index"
3365checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
3366dependencies = [
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]]
3378name = "winapi-util"
3379version = "0.1.11"
3380source = "registry+https://github.com/rust-lang/crates.io-index"
3381checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
3382dependencies = [
3383 "windows-sys 0.61.2",
3384]
3385
3386[[package]]
3387name = "windows"
3388version = "0.62.2"
3389source = "registry+https://github.com/rust-lang/crates.io-index"
3390checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
3391dependencies = [
3392 "windows-collections",
3393 "windows-core",
3394 "windows-future",
3395 "windows-numerics",
3396]
3397
3398[[package]]
3399name = "windows-collections"
3400version = "0.3.2"
3401source = "registry+https://github.com/rust-lang/crates.io-index"
3402checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
3403dependencies = [
3404 "windows-core",
3405]
3406
3407[[package]]
3408name = "windows-core"
3409version = "0.62.2"
3410source = "registry+https://github.com/rust-lang/crates.io-index"
3411checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
3412dependencies = [
3413 "windows-implement",
3414 "windows-interface",
3415 "windows-link",
3416 "windows-result",
3417 "windows-strings",
3418]
3419
3420[[package]]
3421name = "windows-future"
3422version = "0.3.2"
3423source = "registry+https://github.com/rust-lang/crates.io-index"
3424checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
3425dependencies = [
3426 "windows-core",
3427 "windows-link",
3428 "windows-threading",
3429]
3430
3431[[package]]
3432name = "windows-implement"
3433version = "0.60.2"
3434source = "registry+https://github.com/rust-lang/crates.io-index"
3435checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
3436dependencies = [
3437 "proc-macro2",
3438 "quote",
3439 "syn 2.0.119",
3440]
3441
3442[[package]]
3443name = "windows-interface"
3444version = "0.59.3"
3445source = "registry+https://github.com/rust-lang/crates.io-index"
3446checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
3447dependencies = [
3448 "proc-macro2",
3449 "quote",
3450 "syn 2.0.119",
3451]
3452
3453[[package]]
3454name = "windows-link"
3455version = "0.2.1"
3456source = "registry+https://github.com/rust-lang/crates.io-index"
3457checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
3458
3459[[package]]
3460name = "windows-numerics"
3461version = "0.3.1"
3462source = "registry+https://github.com/rust-lang/crates.io-index"
3463checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
3464dependencies = [
3465 "windows-core",
3466 "windows-link",
3467]
3468
3469[[package]]
3470name = "windows-result"
3471version = "0.4.1"
3472source = "registry+https://github.com/rust-lang/crates.io-index"
3473checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
3474dependencies = [
3475 "windows-link",
3476]
3477
3478[[package]]
3479name = "windows-strings"
3480version = "0.5.1"
3481source = "registry+https://github.com/rust-lang/crates.io-index"
3482checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
3483dependencies = [
3484 "windows-link",
3485]
3486
3487[[package]]
3488name = "windows-sys"
3489version = "0.52.0"
3490source = "registry+https://github.com/rust-lang/crates.io-index"
3491checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
3492dependencies = [
3493 "windows-targets 0.52.6",
3494]
3495
3496[[package]]
3497name = "windows-sys"
3498version = "0.59.0"
3499source = "registry+https://github.com/rust-lang/crates.io-index"
3500checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
3501dependencies = [
3502 "windows-targets 0.52.6",
3503]
3504
3505[[package]]
3506name = "windows-sys"
3507version = "0.60.2"
3508source = "registry+https://github.com/rust-lang/crates.io-index"
3509checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
3510dependencies = [
3511 "windows-targets 0.53.5",
3512]
3513
3514[[package]]
3515name = "windows-sys"
3516version = "0.61.2"
3517source = "registry+https://github.com/rust-lang/crates.io-index"
3518checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
3519dependencies = [
3520 "windows-link",
3521]
3522
3523[[package]]
3524name = "windows-targets"
3525version = "0.52.6"
3526source = "registry+https://github.com/rust-lang/crates.io-index"
3527checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
3528dependencies = [
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]]
3540name = "windows-targets"
3541version = "0.53.5"
3542source = "registry+https://github.com/rust-lang/crates.io-index"
3543checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
3544dependencies = [
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]]
3557name = "windows-threading"
3558version = "0.2.1"
3559source = "registry+https://github.com/rust-lang/crates.io-index"
3560checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
3561dependencies = [
3562 "windows-link",
3563]
3564
3565[[package]]
3566name = "windows_aarch64_gnullvm"
3567version = "0.52.6"
3568source = "registry+https://github.com/rust-lang/crates.io-index"
3569checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
3570
3571[[package]]
3572name = "windows_aarch64_gnullvm"
3573version = "0.53.1"
3574source = "registry+https://github.com/rust-lang/crates.io-index"
3575checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
3576
3577[[package]]
3578name = "windows_aarch64_msvc"
3579version = "0.52.6"
3580source = "registry+https://github.com/rust-lang/crates.io-index"
3581checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
3582
3583[[package]]
3584name = "windows_aarch64_msvc"
3585version = "0.53.1"
3586source = "registry+https://github.com/rust-lang/crates.io-index"
3587checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
3588
3589[[package]]
3590name = "windows_i686_gnu"
3591version = "0.52.6"
3592source = "registry+https://github.com/rust-lang/crates.io-index"
3593checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
3594
3595[[package]]
3596name = "windows_i686_gnu"
3597version = "0.53.1"
7363598source = "registry+https://github.com/rust-lang/crates.io-index"
737checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
738dependencies = [
739 "serde_derive",
740]
3599checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
7413600
7423601[[package]]
743name = "serde_derive"
744version = "1.0.229"
3602name = "windows_i686_gnullvm"
3603version = "0.52.6"
7453604source = "registry+https://github.com/rust-lang/crates.io-index"
746checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
747dependencies = [
748 "proc-macro2",
749 "quote",
750 "syn 3.0.5",
751]
3605checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
7523606
7533607[[package]]
754name = "serde_json"
755version = "1.0.151"
3608name = "windows_i686_gnullvm"
3609version = "0.53.1"
7563610source = "registry+https://github.com/rust-lang/crates.io-index"
757checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
758dependencies = [
759 "itoa",
760 "memchr",
761 "serde",
762 "serde_core",
763 "zmij",
764]
3611checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
7653612
7663613[[package]]
767name = "sha1"
768version = "0.11.0"
3614name = "windows_i686_msvc"
3615version = "0.52.6"
7693616source = "registry+https://github.com/rust-lang/crates.io-index"
770checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
771dependencies = [
772 "cfg-if",
773 "cpufeatures",
774 "digest",
775]
3617checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
7763618
7773619[[package]]
778name = "sha2"
779version = "0.11.0"
3620name = "windows_i686_msvc"
3621version = "0.53.1"
7803622source = "registry+https://github.com/rust-lang/crates.io-index"
781checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
782dependencies = [
783 "cfg-if",
784 "cpufeatures",
785 "digest",
786]
3623checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
7873624
7883625[[package]]
789name = "shlex"
790version = "2.0.1"
3626name = "windows_x86_64_gnu"
3627version = "0.52.6"
7913628source = "registry+https://github.com/rust-lang/crates.io-index"
792checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
3629checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
7933630
7943631[[package]]
795name = "slab"
796version = "0.4.12"
3632name = "windows_x86_64_gnu"
3633version = "0.53.1"
7973634source = "registry+https://github.com/rust-lang/crates.io-index"
798checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
3635checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
7993636
8003637[[package]]
801name = "smallvec"
802version = "1.16.0"
3638name = "windows_x86_64_gnullvm"
3639version = "0.52.6"
8033640source = "registry+https://github.com/rust-lang/crates.io-index"
804checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
3641checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
8053642
8063643[[package]]
807name = "smb2"
808version = "0.21.0"
3644name = "windows_x86_64_gnullvm"
3645version = "0.53.1"
8093646source = "registry+https://github.com/rust-lang/crates.io-index"
810checksum = "9ef4f20cff3d39a131335d17df6146eada7851d7705252f2765b8717a0f52db8"
811dependencies = [
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]
3647checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
8323648
8333649[[package]]
834name = "socket2"
835version = "0.6.5"
3650name = "windows_x86_64_msvc"
3651version = "0.52.6"
8363652source = "registry+https://github.com/rust-lang/crates.io-index"
837checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
838dependencies = [
839 "libc",
840 "windows-sys",
841]
3653checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
8423654
8433655[[package]]
844name = "sqlite-wasm-rs"
845version = "0.5.5"
3656name = "windows_x86_64_msvc"
3657version = "0.53.1"
8463658source = "registry+https://github.com/rust-lang/crates.io-index"
847checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
3659checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
3660
3661[[package]]
3662name = "winit"
3663version = "0.30.13"
3664source = "registry+https://github.com/rust-lang/crates.io-index"
3665checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
8483666dependencies = [
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",
8503680 "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",
8523699 "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",
8533711]
8543712
8553713[[package]]
856name = "subtle"
857version = "2.6.1"
3714name = "winnow"
3715version = "1.0.4"
8583716source = "registry+https://github.com/rust-lang/crates.io-index"
859checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
3717checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
3718dependencies = [
3719 "memchr",
3720]
8603721
8613722[[package]]
862name = "syn"
863version = "2.0.119"
3723name = "wit-bindgen"
3724version = "0.57.1"
8643725source = "registry+https://github.com/rust-lang/crates.io-index"
865checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
866dependencies = [
867 "proc-macro2",
868 "quote",
869 "unicode-ident",
870]
3726checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
8713727
8723728[[package]]
873name = "syn"
874version = "3.0.5"
3729name = "writeable"
3730version = "0.6.4"
8753731source = "registry+https://github.com/rust-lang/crates.io-index"
876checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
877dependencies = [
878 "proc-macro2",
879 "quote",
880 "unicode-ident",
881]
3732checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
8823733
8833734[[package]]
884name = "tempfile"
885version = "3.27.0"
3735name = "x11-dl"
3736version = "2.21.0"
8863737source = "registry+https://github.com/rust-lang/crates.io-index"
887checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
3738checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
8883739dependencies = [
889 "fastrand",
890 "getrandom",
3740 "libc",
8913741 "once_cell",
892 "rustix",
893 "windows-sys",
3742 "pkg-config",
8943743]
8953744
8963745[[package]]
897name = "thiserror"
898version = "2.0.20"
3746name = "x11rb"
3747version = "0.13.2"
8993748source = "registry+https://github.com/rust-lang/crates.io-index"
900checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
3749checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
9013750dependencies = [
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",
9033758]
9043759
9053760[[package]]
906name = "thiserror-impl"
907version = "2.0.20"
3761name = "x11rb-protocol"
3762version = "0.13.2"
9083763source = "registry+https://github.com/rust-lang/crates.io-index"
909checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
910dependencies = [
911 "proc-macro2",
912 "quote",
913 "syn 3.0.5",
914]
3764checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
9153765
9163766[[package]]
917name = "tokio"
918version = "1.53.1"
3767name = "xcursor"
3768version = "0.3.11"
9193769source = "registry+https://github.com/rust-lang/crates.io-index"
920checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
921dependencies = [
922 "bytes",
923 "libc",
924 "mio",
925 "pin-project-lite",
926 "socket2",
927 "windows-sys",
928]
3770checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
9293771
9303772[[package]]
931name = "toml_datetime"
932version = "1.1.1+spec-1.1.0"
3773name = "xkbcommon-dl"
3774version = "0.4.2"
9333775source = "registry+https://github.com/rust-lang/crates.io-index"
934checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
3776checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
9353777dependencies = [
936 "serde_core",
3778 "bitflags 2.13.1",
3779 "dlib",
3780 "log",
3781 "once_cell",
3782 "xkeysym",
9373783]
9383784
9393785[[package]]
940name = "toml_edit"
941version = "0.25.13+spec-1.1.0"
3786name = "xkeysym"
3787version = "0.2.1"
9423788source = "registry+https://github.com/rust-lang/crates.io-index"
943checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
944dependencies = [
945 "indexmap",
946 "toml_datetime",
947 "toml_parser",
948 "winnow",
949]
3789checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
9503790
9513791[[package]]
952name = "toml_parser"
953version = "1.1.3+spec-1.1.0"
3792name = "xml-rs"
3793version = "0.8.29"
9543794source = "registry+https://github.com/rust-lang/crates.io-index"
955checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
956dependencies = [
957 "winnow",
958]
3795checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
9593796
9603797[[package]]
961name = "twox-hash"
962version = "2.1.4"
3798name = "yazi"
3799version = "0.2.1"
9633800source = "registry+https://github.com/rust-lang/crates.io-index"
964checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a"
3801checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
9653802
9663803[[package]]
967name = "typenum"
968version = "1.20.1"
3804name = "yeslogic-fontconfig-sys"
3805version = "6.0.1"
9693806source = "registry+https://github.com/rust-lang/crates.io-index"
970checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
3807checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
3808dependencies = [
3809 "dlib",
3810 "once_cell",
3811 "pkg-config",
3812]
9713813
9723814[[package]]
973name = "unicode-ident"
974version = "1.0.24"
3815name = "yoke"
3816version = "0.8.3"
9753817source = "registry+https://github.com/rust-lang/crates.io-index"
976checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
3818checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
3819dependencies = [
3820 "stable_deref_trait",
3821 "yoke-derive",
3822 "zerofrom",
3823]
9773824
9783825[[package]]
979name = "universal-hash"
980version = "0.6.1"
3826name = "yoke-derive"
3827version = "0.8.2"
9813828source = "registry+https://github.com/rust-lang/crates.io-index"
982checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
3829checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
9833830dependencies = [
984 "crypto-common",
985 "ctutils",
3831 "proc-macro2",
3832 "quote",
3833 "syn 2.0.119",
3834 "synstructure",
9863835]
9873836
9883837[[package]]
989name = "vcpkg"
990version = "0.2.15"
3838name = "zeno"
3839version = "0.3.3"
9913840source = "registry+https://github.com/rust-lang/crates.io-index"
992checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
3841checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
9933842
9943843[[package]]
995name = "wasi"
996version = "0.11.1+wasi-snapshot-preview1"
3844name = "zerocopy"
3845version = "0.8.57"
9973846source = "registry+https://github.com/rust-lang/crates.io-index"
998checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
3847checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
3848dependencies = [
3849 "zerocopy-derive",
3850]
9993851
10003852[[package]]
1001name = "wasm-bindgen"
1002version = "0.2.128"
3853name = "zerocopy-derive"
3854version = "0.8.57"
10033855source = "registry+https://github.com/rust-lang/crates.io-index"
1004checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
3856checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
10053857dependencies = [
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",
10113861]
10123862
10133863[[package]]
1014name = "wasm-bindgen-macro"
1015version = "0.2.128"
3864name = "zerofrom"
3865version = "0.1.8"
10163866source = "registry+https://github.com/rust-lang/crates.io-index"
1017checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
3867checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
10183868dependencies = [
1019 "quote",
1020 "wasm-bindgen-macro-support",
3869 "zerofrom-derive",
10213870]
10223871
10233872[[package]]
1024name = "wasm-bindgen-macro-support"
1025version = "0.2.128"
3873name = "zerofrom-derive"
3874version = "0.1.7"
10263875source = "registry+https://github.com/rust-lang/crates.io-index"
1027checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
3876checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
10283877dependencies = [
1029 "bumpalo",
10303878 "proc-macro2",
10313879 "quote",
1032 "syn 3.0.5",
1033 "wasm-bindgen-shared",
3880 "syn 2.0.119",
3881 "synstructure",
10343882]
10353883
10363884[[package]]
1037name = "wasm-bindgen-shared"
1038version = "0.2.128"
3885name = "zeroize"
3886version = "1.9.0"
10393887source = "registry+https://github.com/rust-lang/crates.io-index"
1040checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
1041dependencies = [
1042 "unicode-ident",
1043]
3888checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
10443889
10453890[[package]]
1046name = "windows-link"
1047version = "0.2.1"
3891name = "zerotrie"
3892version = "0.2.5"
10483893source = "registry+https://github.com/rust-lang/crates.io-index"
1049checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
3894checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
3895dependencies = [
3896 "displaydoc",
3897 "yoke",
3898 "zerofrom",
3899 "zerovec",
3900]
10503901
10513902[[package]]
1052name = "windows-sys"
1053version = "0.61.2"
3903name = "zerovec"
3904version = "0.11.8"
10543905source = "registry+https://github.com/rust-lang/crates.io-index"
1055checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
3906checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
10563907dependencies = [
1057 "windows-link",
3908 "serde",
3909 "yoke",
3910 "zerofrom",
3911 "zerovec-derive",
10583912]
10593913
10603914[[package]]
1061name = "winnow"
1062version = "1.0.4"
3915name = "zerovec-derive"
3916version = "0.11.6"
10633917source = "registry+https://github.com/rust-lang/crates.io-index"
1064checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
3918checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
10653919dependencies = [
1066 "memchr",
3920 "proc-macro2",
3921 "quote",
3922 "syn 3.0.5",
10673923]
10683924
10693925[[package]]
1070name = "zeroize"
1071version = "1.9.0"
3926name = "zlib-rs"
3927version = "0.6.7"
10723928source = "registry+https://github.com/rust-lang/crates.io-index"
1073checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
3929checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
10743930
10753931[[package]]
10763932name = "zmij"
10773933version = "1.0.23"
10783934source = "registry+https://github.com/rust-lang/crates.io-index"
10793935checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
3936
3937[[package]]
3938name = "zune-core"
3939version = "0.5.3"
3940source = "registry+https://github.com/rust-lang/crates.io-index"
3941checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
3942
3943[[package]]
3944name = "zune-jpeg"
3945version = "0.5.15"
3946source = "registry+https://github.com/rust-lang/crates.io-index"
3947checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
3948dependencies = [
3949 "zune-core",
3950]
Cargo.toml+4
......@@ -2,3 +2,7 @@
22members = ["crates/*"]
33exclude = ["fuzz"]
44resolver = "3"
5
6[workspace.dependencies]
7parley = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759", features = ["complex-scripts"] }
8wgpu = "30.0.1"
crates/canvas-app/Cargo.toml created+23
......@@ -0,0 +1,23 @@
1[package]
2name = "one-canvas-app"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8one-canvas = { path = "../canvas" }
9one-canvas-gpu = { path = "../canvas-gpu" }
10onestore = { path = "../onestore" }
11pollster = "0.4"
12wgpu.workspace = true
13parley.workspace = true
14winit = "0.30.13"
15arboard = { version = "3.6.1", default-features = false }
16accesskit = "0.25.0"
17accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
18objc2 = "0.5.2"
19objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent"] }
20objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread"] }
21
22[dev-dependencies]
23accesskit_consumer = "0.39.0"
crates/canvas-app/src/accessibility.rs created+1312
......@@ -0,0 +1,1312 @@
1use accesskit::{
2 Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo,
3 TreeUpdate,
4};
5use one_canvas::{
6 document::TextPosition,
7 editor::{CanvasEditor, Selection, TextOutline},
8 text::EditError,
9};
10use one_canvas_gpu::Viewport;
11use parley::Affinity;
12use std::{collections::HashMap, ops::Range};
13
14pub const ROOT: NodeId = NodeId(0);
15
16struct Run {
17 outline: onestore::ExGuid,
18 id: NodeId,
19 node: Node,
20 positions: Vec<TextPosition>,
21}
22
23#[derive(Hash, PartialEq, Eq)]
24struct ParagraphKey {
25 layout: u64,
26 source: usize,
27 next: Option<usize>,
28 origin: [u32; 2],
29}
30
31pub 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
39impl 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
51impl 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)]
540mod 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 @@
1use objc2::{
2 ClassType, DeclaredClass, declare_class, msg_send, msg_send_id, mutability,
3 rc::{Allocated, Retained},
4 runtime::AnyObject,
5};
6use objc2_app_kit::{NSAlert, NSAlertSecondButtonReturn, NSApplication, NSEvent, NSEventType};
7use objc2_foundation::{MainThreadMarker, NSString};
8use std::sync::OnceLock;
9use winit::{
10 error::EventLoopError,
11 event_loop::{EventLoop, EventLoopProxy},
12 raw_window_handle::{HasWindowHandle, RawWindowHandle},
13 window::Window,
14};
15
16static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
17
18declare_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
60pub 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
71pub 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.
87pub 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 @@
1mod accessibility;
2mod macos;
3#[cfg(test)]
4mod profile;
5
6use one_canvas::{
7 document::TextDocument,
8 editor::{CanvasEditor, Movement, TextOutline},
9 layout::TextEngine,
10 page::Page,
11 text::Paragraph,
12};
13use one_canvas_gpu::{Primitive, Renderer, Viewport, page::PageScene};
14use onestore::document::Format;
15use std::{
16 error::Error,
17 path::PathBuf,
18 sync::Arc,
19 time::{Duration, Instant},
20};
21use 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
30const HANDLE_HEIGHT: f32 = 6.75;
31const NEW_OUTLINE_WIDTH: f32 = 240.0;
32
33#[derive(Debug)]
34enum UserEvent {
35 Quit,
36 Accessibility(accesskit_winit::Event),
37}
38
39impl From<accesskit_winit::Event> for UserEvent {
40 fn from(event: accesskit_winit::Event) -> Self {
41 Self::Accessibility(event)
42 }
43}
44
45fn 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
51fn 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
58struct 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
66enum Input {
67 Notes {
68 document: TextDocument,
69 width: f32,
70 reference: Option<Page>,
71 },
72 Page(Page),
73}
74
75enum Drag {
76 Text,
77 Outline {
78 id: onestore::ExGuid,
79 grab: [f32; 2],
80 pending_press: Option<[f32; 2]>,
81 },
82}
83
84struct 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
109impl 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
706impl 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
729impl 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)]
1018enum 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
1030fn 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
1092fn 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
1171fn 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
1185fn 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
1201fn 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
1315fn 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)]
1402mod 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 @@
1use super::*;
2use one_canvas::document::TextPosition;
3use std::{hint::black_box, process::Command};
4
5fn 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
15fn 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
29fn 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
37fn 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"]
121fn 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]
2name = "one-canvas-gpu"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8onestore = { path = "../onestore" }
9one-canvas = { path = "../canvas" }
10bytemuck = { version = "1.25", features = ["derive"] }
11swash = "0.2.10"
12parley.workspace = true
13wgpu.workspace = true
14image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
15
16[dev-dependencies]
17pollster = "0.4"
18png = "0.18"
crates/canvas-gpu/examples/render_page.rs created+125
......@@ -0,0 +1,125 @@
1use one_canvas::{layout::TextEngine, page::Page};
2use one_canvas_gpu::{Renderer, Viewport, page::PageScene};
3use onestore::{RevisionIndex, Store, document::Document};
4use std::{env, fs, sync::Arc, time::Duration};
5
6fn 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 @@
1struct 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
11fn 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
17fn 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 @@
1pub mod page;
2#[cfg(test)]
3mod profile;
4mod tags;
5
6use bytemuck::{Pod, Zeroable};
7use image::ImageDecoder;
8use one_canvas::{
9 layout::TextLayout,
10 outline::{ParagraphTag, TagIcon},
11};
12use parley::{PositionedLayoutItem, fontique::Blob};
13use std::{
14 collections::{HashMap, HashSet},
15 ops::Range,
16};
17use swash::{
18 FontRef,
19 scale::{Render, ScaleContext, Source, StrikeWith, image::Content},
20 zeno::{Angle, Format, Transform, Vector},
21};
22
23const ATLAS_SIZE: u32 = 2048;
24const MAX_GLYPHS: usize = 8192;
25const MAX_VERTICES: usize = 65_536;
26const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
27const MAX_IMAGES: usize = 256;
28
29#[derive(Clone)]
30pub struct RasterImage {
31 size: [u32; 2],
32 pixels: Blob<u8>,
33}
34
35impl 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
97struct CachedImage {
98 binding: wgpu::BindGroup,
99 bytes: u64,
100}
101
102struct Batch {
103 vertices: Range<u32>,
104 image: Option<u64>,
105}
106
107#[repr(C)]
108#[derive(Clone, Copy, Pod, Zeroable)]
109struct Vertex {
110 position: [f32; 2],
111 uv: [f32; 2],
112 color: [f32; 4],
113}
114
115#[derive(Hash, PartialEq, Eq)]
116enum 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)]
135struct 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)]
146pub 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
154impl 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.
183pub 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)]
203pub 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
216pub 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
234impl 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
898fn 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
907pub 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)]
920mod 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 @@
1use crate::{Primitive, RasterImage, RenderError, colorref};
2use one_canvas::{
3 editor::{CanvasEditor, EditorError, TextOutline},
4 layout::{LayoutError, TextEngine, TextLayout},
5 outline::OutlineLayout,
6 page::{Page, PageObject},
7 text::Paragraph,
8};
9use std::fmt;
10
11/// Retained drawing data in the source page's coordinate system.
12pub struct PageScene {
13 objects: Vec<Content>,
14}
15
16#[derive(Debug, PartialEq)]
17pub enum SceneHit<T> {
18 Outline(T),
19 ReadOnly(usize),
20}
21
22enum 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
36pub struct ReadOnlyObject {
37 pub source: PageObject,
38 pub rect: [f32; 4],
39 pub message: &'static str,
40 label: TextLayout,
41}
42
43impl 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)]
91pub enum SceneError {
92 Layout(LayoutError),
93 Editor(EditorError),
94 MissingOutline,
95 Image(RenderError),
96 MissingImage,
97 InvalidGeometry,
98}
99
100impl 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}
114impl std::error::Error for SceneError {}
115impl From<LayoutError> for SceneError {
116 fn from(error: LayoutError) -> Self {
117 Self::Layout(error)
118 }
119}
120
121impl 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)]
429mod 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 @@
1use super::*;
2use one_canvas::{layout::TextEngine, page::Page, text::Paragraph};
3use std::{
4 sync::Arc,
5 time::{Duration, Instant},
6};
7
8#[test]
9#[ignore = "manual release GPU timing and cache occupancy probe"]
10fn 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
142fn 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 @@
1use one_canvas::outline::TagIcon;
2use swash::{
3 scale::image::{Content, Image},
4 zeno::{Mask, Placement, Transform, Vector},
5};
6
7pub(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)]
85mod 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]
2name = "one-canvas"
3version = "0.1.0"
4edition = "2024"
5publish = false
6
7[dependencies]
8onestore = { path = "../onestore" }
9parley.workspace = true
10serde = { version = "1.0.229", features = ["derive"] }
11serde_json = "1.0.151"
12skrifa = "0.44.0"
13getrandom = "0.4.3"
14
15[[bin]]
16name = "layout-probe"
17path = "src/bin/layout_probe.rs"
18
19[[bin]]
20name = "page-probe"
21path = "src/bin/page_probe.rs"
crates/canvas/src/bin/layout_probe.rs created+116
......@@ -0,0 +1,116 @@
1use one_canvas::{layout::TextEngine, text::Paragraph};
2use onestore::document::Format;
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId};
6use std::{env, fs, io, sync::Arc};
7
8#[derive(Deserialize)]
9#[serde(deny_unknown_fields)]
10struct Case {
11 id: String,
12 width: f32,
13 runs: Vec<Run>,
14}
15
16#[derive(Deserialize, Serialize)]
17#[serde(deny_unknown_fields)]
18struct Run {
19 text: String,
20 font: String,
21 size: f32,
22 bold: bool,
23 italic: bool,
24}
25
26fn 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 @@
1use one_canvas::{
2 layout::TextEngine,
3 outline::OutlineLayout,
4 page::{Outline, Page, PageObject},
5};
6use onestore::{RevisionIndex, Store, document::Document};
7use serde_json::json;
8use std::{env, fs, io, sync::Arc, time::Instant};
9
10fn 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
83fn 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 @@
1use crate::{
2 page::{PageParagraph, TextObject},
3 text::{EditError, Paragraph, new_id},
4};
5use onestore::document::Format;
6use std::{
7 collections::{BTreeMap, BTreeSet},
8 ops::Range,
9};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
12pub 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)]
19pub struct TextDocument {
20 nodes: Vec<PageParagraph>,
21}
22
23#[derive(Clone, Debug, PartialEq)]
24pub(crate) struct DocumentEdit {
25 pub range: Range<usize>,
26 pub replacement: Vec<PageParagraph>,
27}
28
29fn 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
47fn 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
69fn 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
77impl 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)]
220mod 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 @@
1use 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};
8use onestore::ExGuid;
9use parley::{
10 Affinity, BoundingBox,
11 editing::{Cursor, Selection as ParagraphSelection},
12};
13use std::{
14 collections::{BTreeMap, BTreeSet},
15 fmt,
16 ops::Range,
17};
18
19#[derive(Clone, Copy, Debug)]
20pub enum Movement {
21 Left,
22 Right,
23 Up,
24 Down,
25 WordLeft,
26 WordRight,
27 LineStart,
28 LineEnd,
29}
30
31#[derive(Debug)]
32pub enum EditorError {
33 Edit(EditError),
34 Layout(LayoutError),
35}
36
37impl 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
46impl std::error::Error for EditorError {}
47
48impl From<EditError> for EditorError {
49 fn from(error: EditError) -> Self {
50 Self::Edit(error)
51 }
52}
53
54impl From<LayoutError> for EditorError {
55 fn from(error: LayoutError) -> Self {
56 Self::Layout(error)
57 }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct Selection {
62 pub positions: [TextPosition; 2],
63 pub affinities: [Affinity; 2],
64}
65
66impl 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
75pub 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
85enum Focus {
86 Outline(usize),
87 Caret {
88 outline: Box<TextOutline>,
89 index: usize,
90 },
91}
92
93pub 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
102impl 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
259struct Composition {
260 original: TextChange,
261 range: Range<TextPosition>,
262}
263
264impl 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)]
273struct TextChange {
274 edit: DocumentEdit,
275 selection: Selection,
276}
277
278enum RestoreFocus {
279 Outline(ExGuid),
280 Caret {
281 source: Box<Outline>,
282 selection: Selection,
283 index: usize,
284 },
285}
286
287enum 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
313impl 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
1409fn 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)]
1446mod 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 @@
1use crate::text::Paragraph;
2use parley::{
3 Affinity, Alignment, AlignmentOptions, BoundingBox, FontContext, FontFamily, FontStyle,
4 FontWeight, Layout, LayoutContext, OverflowWrap, PositionedLayoutItem, StyleProperty,
5 editing::{Cursor, Selection},
6};
7use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId};
8use std::{
9 collections::BTreeSet,
10 fmt,
11 ops::Range,
12 sync::atomic::{AtomicU64, Ordering},
13};
14
15#[derive(Default)]
16pub 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)]
23pub struct TextBrush {
24 pub color: u32,
25 pub highlight: Option<u32>,
26}
27
28#[derive(Clone, Debug)]
29pub struct LineBox {
30 pub source: Range<usize>,
31 pub top: f32,
32 pub baseline: f32,
33 pub height: f32,
34}
35
36pub struct TextLayout {
37 id: u64,
38 pub(crate) shaped: Layout<TextBrush>,
39 lines: Vec<LineBox>,
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum LayoutError {
44 InvalidWidth,
45 InvalidFontSize,
46 InvalidSourceRange,
47 InvalidFontMetrics,
48 InvalidIndentation,
49 InvalidSpacing,
50 InvalidList,
51 UnsupportedContent,
52 UnsupportedSubstituteFont,
53}
54
55impl 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
64impl std::error::Error for LayoutError {}
65
66impl 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
246impl 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)]
347mod 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 @@
1pub mod document;
2pub mod editor;
3pub mod layout;
4pub mod outline;
5pub mod page;
6pub mod text;
crates/canvas/src/outline.rs created+646
......@@ -0,0 +1,646 @@
1use crate::{
2 layout::{LayoutError, TextEngine, TextLayout},
3 page::{Definition, Outline, PageParagraph, Title},
4 text::{Paragraph, TextProjection},
5};
6use onestore::{
7 ExGuid,
8 document::{Format, Kind},
9};
10use std::collections::{BTreeMap, BTreeSet};
11
12pub struct OutlineLayout {
13 pub paragraphs: Vec<ParagraphLayout>,
14 pub size: [f32; 2],
15}
16
17pub 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)]
28pub enum TagIcon {
29 CheckBox { checked: bool },
30 Question,
31 Music,
32}
33
34#[derive(Clone, Debug, PartialEq)]
35pub 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
43impl ParagraphTag {
44 pub const SIZE: f32 = 12.0;
45}
46
47impl 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
175pub(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
188pub(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
245impl 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
262impl 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
299impl 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)]
340mod 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 @@
1use crate::text::Paragraph;
2use onestore::{
3 ExGuid,
4 document::{Document, Format, Kind, Layout, Revision, Tag},
5};
6use std::{
7 collections::{BTreeMap, BTreeSet},
8 sync::Arc,
9};
10
11pub 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
18pub struct Definition {
19 pub kind: Kind<'static>,
20 pub format: Format,
21}
22
23pub enum PageObject {
24 Outline(Outline),
25 Title(Title),
26 Image(Image),
27 Unsupported(Unsupported),
28}
29
30pub struct Title {
31 pub id: ExGuid,
32 pub layout: Layout,
33 pub outlines: Vec<Outline>,
34}
35
36pub 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)]
45pub 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)]
58pub struct TextObject {
59 pub id: ExGuid,
60 pub text: Paragraph,
61 pub tags: Vec<Tag>,
62}
63
64pub 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)]
73pub struct Unsupported {
74 pub id: ExGuid,
75 pub jcid: u32,
76 pub layout: Layout,
77}
78
79impl 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
371fn 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
395impl 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)]
412mod 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 @@
1use onestore::document::Format;
2use parley::Affinity;
3use std::{fmt, ops::Range};
4
5#[derive(Clone, Debug, PartialEq)]
6pub 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.
14pub struct Paragraph {
15 text: String,
16 spans: Vec<Span>,
17}
18
19pub struct TextProjection {
20 text: Paragraph,
21 spans: Vec<ProjectedSpan>,
22 source_len: u32,
23}
24
25struct ProjectedSpan {
26 visible: Range<u32>,
27 source_start: u32,
28}
29
30impl 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)]
95pub struct Edit {
96 pub range: Range<u32>,
97 pub replacement: Paragraph,
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub enum EditError {
102 InvalidRange,
103 TextTooLong,
104 InvalidStructure,
105 UnsupportedContent,
106 Identity(getrandom::Error),
107}
108
109impl 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
125impl std::error::Error for EditError {}
126
127pub(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
135impl 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)]
341mod 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(
4747 vec![
4848 json!(node.layout.max_width),
4949 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),
5151 ]
5252 }
5353 (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() {
685685 old["layout"]["width_set_by_user"],
686686 remote["layout"]["width_set_by_user"]
687687 );
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());
695689 let directory = tempfile::tempdir().unwrap();
696690 let cache = Replica::create(directory.path().join("cache.sqlite"), source).unwrap();
697691 let id = cache
......@@ -721,11 +715,5 @@ fn a_new_native_wrap_reservation_requires_review_before_width_replacement() {
721715 assert_eq!(after["layout"]["x"], remote["layout"]["x"]);
722716 assert_eq!(after["layout"]["y"], remote["layout"]["y"]);
723717 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());
731719}
crates/onestore/src/document.rs+4
......@@ -81,6 +81,7 @@ pub struct Layout {
8181 /// An explicit user width when true; false or absent leaves an automatic layout hint.
8282 pub width_set_by_user: Option<bool>,
8383 pub max_height: Option<f32>,
84 pub reserved_width: Option<f32>,
8485}
8586
8687macro_rules! format_fields {
......@@ -119,6 +120,7 @@ format_fields! {
119120 space_before: f32,
120121 space_after: f32,
121122 line_spacing: f32,
123 list_spacing: f32,
122124}
123125
124126#[derive(Debug, Serialize)]
......@@ -876,6 +878,7 @@ impl<'a> Element<'a> {
876878 max_width: f.float(0x14001c1b, 36.0)?,
877879 width_set_by_user: f.boolean(0x08001cbd)?,
878880 max_height: f.float(0x14001c1c, 36.0)?,
881 reserved_width: f.float(0x14001cdb, 36.0)?,
879882 };
880883 let format = Format {
881884 bold: f.boolean(0x08001c04)?,
......@@ -899,6 +902,7 @@ impl<'a> Element<'a> {
899902 space_before: f.float(0x1400342e, 36.0)?,
900903 space_after: f.float(0x1400342f, 36.0)?,
901904 line_spacing: f.float(0x14003430, 36.0)?,
905 list_spacing: f.float(0x14001ccb, 36.0)?,
902906 };
903907 let mut tags = Vec::new();
904908 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() {
227227 .unwrap();
228228 let space = &document.spaces[&sid];
229229 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());
235231 let edit = PreparedEdit::outline(
236232 source,
237233 sid,
......@@ -247,11 +243,7 @@ fn resizing_a_native_reserved_width_preserves_content() {
247243 let after_document = Document::parse(&after_index).unwrap();
248244 let space = &after_document.spaces[&sid];
249245 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);
255247 assert_eq!(after.nodes[&object].layout.max_width, Some(144.0));
256248 for (id, node) in &before.nodes {
257249 assert_eq!(node.children, after.nodes[id].children);
fuzz/Cargo.lock+622-3
......@@ -57,6 +57,26 @@ version = "3.20.3"
5757source = "registry+https://github.com/rust-lang/crates.io-index"
5858checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
5959
60[[package]]
61name = "bytemuck"
62version = "1.25.2"
63source = "registry+https://github.com/rust-lang/crates.io-index"
64checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
65dependencies = [
66 "bytemuck_derive",
67]
68
69[[package]]
70name = "bytemuck_derive"
71version = "1.12.0"
72source = "registry+https://github.com/rust-lang/crates.io-index"
73checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
74dependencies = [
75 "proc-macro2",
76 "quote",
77 "syn 3.0.5",
78]
79
6080[[package]]
6181name = "cbc"
6282version = "0.2.1"
......@@ -144,6 +164,26 @@ dependencies = [
144164 "zeroize",
145165]
146166
167[[package]]
168name = "displaydoc"
169version = "0.2.7"
170source = "registry+https://github.com/rust-lang/crates.io-index"
171checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
172dependencies = [
173 "proc-macro2",
174 "quote",
175 "syn 3.0.5",
176]
177
178[[package]]
179name = "dlib"
180version = "0.5.3"
181source = "registry+https://github.com/rust-lang/crates.io-index"
182checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
183dependencies = [
184 "libloading",
185]
186
147187[[package]]
148188name = "errno"
149189version = "0.3.14"
......@@ -184,6 +224,36 @@ version = "0.2.0"
184224source = "registry+https://github.com/rust-lang/crates.io-index"
185225checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
186226
227[[package]]
228name = "font-types"
229version = "0.12.5"
230source = "registry+https://github.com/rust-lang/crates.io-index"
231checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
232dependencies = [
233 "bytemuck",
234]
235
236[[package]]
237name = "fontique"
238version = "0.11.0"
239source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
240dependencies = [
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
187257[[package]]
188258name = "getrandom"
189259version = "0.4.3"
......@@ -195,6 +265,18 @@ dependencies = [
195265 "r-efi",
196266]
197267
268[[package]]
269name = "harfrust"
270version = "0.12.0"
271source = "registry+https://github.com/rust-lang/crates.io-index"
272checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
273dependencies = [
274 "bitflags",
275 "bytemuck",
276 "read-fonts",
277 "smallvec",
278]
279
198280[[package]]
199281name = "hashbrown"
200282version = "0.16.1"
......@@ -232,6 +314,134 @@ dependencies = [
232314 "zeroize",
233315]
234316
317[[package]]
318name = "icu_collections"
319version = "2.3.0"
320source = "registry+https://github.com/rust-lang/crates.io-index"
321checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
322dependencies = [
323 "displaydoc",
324 "potential_utf",
325 "utf8_iter",
326 "yoke",
327 "zerofrom",
328 "zerovec",
329]
330
331[[package]]
332name = "icu_locale_core"
333version = "2.3.0"
334source = "registry+https://github.com/rust-lang/crates.io-index"
335checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
336dependencies = [
337 "displaydoc",
338 "litemap",
339 "serde",
340 "tinystr",
341 "writeable",
342 "zerovec",
343]
344
345[[package]]
346name = "icu_locale_fallback"
347version = "2.3.0"
348source = "registry+https://github.com/rust-lang/crates.io-index"
349checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
350dependencies = [
351 "icu_locale_core",
352 "icu_locale_fallback_data",
353 "icu_provider",
354 "potential_utf",
355 "tinystr",
356 "zerovec",
357]
358
359[[package]]
360name = "icu_locale_fallback_data"
361version = "2.3.0"
362source = "registry+https://github.com/rust-lang/crates.io-index"
363checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
364
365[[package]]
366name = "icu_normalizer"
367version = "2.3.0"
368source = "registry+https://github.com/rust-lang/crates.io-index"
369checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
370dependencies = [
371 "icu_collections",
372 "icu_normalizer_data",
373 "icu_properties",
374 "icu_provider",
375 "smallvec",
376 "zerovec",
377]
378
379[[package]]
380name = "icu_normalizer_data"
381version = "2.3.0"
382source = "registry+https://github.com/rust-lang/crates.io-index"
383checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
384
385[[package]]
386name = "icu_properties"
387version = "2.3.0"
388source = "registry+https://github.com/rust-lang/crates.io-index"
389checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
390dependencies = [
391 "displaydoc",
392 "icu_collections",
393 "icu_locale_core",
394 "icu_properties_data",
395 "icu_provider",
396 "zerotrie",
397 "zerovec",
398]
399
400[[package]]
401name = "icu_properties_data"
402version = "2.3.0"
403source = "registry+https://github.com/rust-lang/crates.io-index"
404checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
405
406[[package]]
407name = "icu_provider"
408version = "2.3.1"
409source = "registry+https://github.com/rust-lang/crates.io-index"
410checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
411dependencies = [
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]]
424name = "icu_segmenter"
425version = "2.3.0"
426source = "registry+https://github.com/rust-lang/crates.io-index"
427checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
428dependencies = [
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]]
440name = "icu_segmenter_data"
441version = "2.3.0"
442source = "registry+https://github.com/rust-lang/crates.io-index"
443checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
444
235445[[package]]
236446name = "inout"
237447version = "0.2.2"
......@@ -284,6 +494,16 @@ dependencies = [
284494 "cc",
285495]
286496
497[[package]]
498name = "libloading"
499version = "0.8.9"
500source = "registry+https://github.com/rust-lang/crates.io-index"
501checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
502dependencies = [
503 "cfg-if",
504 "windows-link",
505]
506
287507[[package]]
288508name = "libsqlite3-sys"
289509version = "0.38.2"
......@@ -295,12 +515,24 @@ dependencies = [
295515 "vcpkg",
296516]
297517
518[[package]]
519name = "linebender_resource_handle"
520version = "0.1.1"
521source = "registry+https://github.com/rust-lang/crates.io-index"
522checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
523
298524[[package]]
299525name = "linux-raw-sys"
300526version = "0.12.1"
301527source = "registry+https://github.com/rust-lang/crates.io-index"
302528checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
303529
530[[package]]
531name = "litemap"
532version = "0.8.3"
533source = "registry+https://github.com/rust-lang/crates.io-index"
534checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
535
304536[[package]]
305537name = "md5"
306538version = "0.8.1"
......@@ -313,6 +545,15 @@ version = "2.8.3"
313545source = "registry+https://github.com/rust-lang/crates.io-index"
314546checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
315547
548[[package]]
549name = "memmap2"
550version = "0.9.11"
551source = "registry+https://github.com/rust-lang/crates.io-index"
552checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
553dependencies = [
554 "libc",
555]
556
316557[[package]]
317558name = "nix"
318559version = "0.31.3"
......@@ -325,12 +566,68 @@ dependencies = [
325566 "libc",
326567]
327568
569[[package]]
570name = "objc2"
571version = "0.6.4"
572source = "registry+https://github.com/rust-lang/crates.io-index"
573checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
574dependencies = [
575 "objc2-encode",
576]
577
578[[package]]
579name = "objc2-core-foundation"
580version = "0.3.2"
581source = "registry+https://github.com/rust-lang/crates.io-index"
582checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
583dependencies = [
584 "bitflags",
585]
586
587[[package]]
588name = "objc2-core-text"
589version = "0.3.2"
590source = "registry+https://github.com/rust-lang/crates.io-index"
591checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
592dependencies = [
593 "bitflags",
594 "objc2-core-foundation",
595]
596
597[[package]]
598name = "objc2-encode"
599version = "4.1.0"
600source = "registry+https://github.com/rust-lang/crates.io-index"
601checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
602
603[[package]]
604name = "objc2-foundation"
605version = "0.3.2"
606source = "registry+https://github.com/rust-lang/crates.io-index"
607checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
608dependencies = [
609 "bitflags",
610 "objc2",
611]
612
328613[[package]]
329614name = "once_cell"
330615version = "1.21.4"
331616source = "registry+https://github.com/rust-lang/crates.io-index"
332617checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
333618
619[[package]]
620name = "one-canvas"
621version = "0.1.0"
622dependencies = [
623 "getrandom",
624 "onestore",
625 "parley",
626 "serde",
627 "serde_json",
628 "skrifa",
629]
630
334631[[package]]
335632name = "onestore"
336633version = "0.1.0"
......@@ -354,6 +651,7 @@ version = "0.0.0"
354651dependencies = [
355652 "libfuzzer-sys",
356653 "md5",
654 "one-canvas",
357655 "onestore",
358656 "onestore-offline",
359657 "serde_json",
......@@ -383,12 +681,67 @@ dependencies = [
383681 "thiserror",
384682]
385683
684[[package]]
685name = "parlance"
686version = "0.1.0"
687source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
688
689[[package]]
690name = "parley"
691version = "0.11.0"
692source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
693dependencies = [
694 "fontique",
695 "hashbrown 0.17.1",
696 "linebender_resource_handle",
697 "parlance",
698 "parley_engine",
699 "skrifa",
700 "smallvec",
701]
702
703[[package]]
704name = "parley_data"
705version = "0.11.0"
706source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
707dependencies = [
708 "icu_properties",
709]
710
711[[package]]
712name = "parley_engine"
713version = "0.11.0"
714source = "git+https://github.com/linebender/parley?rev=4f67747d3690bb21d25cc70b674ab0d269595759#4f67747d3690bb21d25cc70b674ab0d269595759"
715dependencies = [
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
386728[[package]]
387729name = "pkg-config"
388730version = "0.3.34"
389731source = "registry+https://github.com/rust-lang/crates.io-index"
390732checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
391733
734[[package]]
735name = "potential_utf"
736version = "0.1.6"
737source = "registry+https://github.com/rust-lang/crates.io-index"
738checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
739dependencies = [
740 "serde_core",
741 "writeable",
742 "zerovec",
743]
744
392745[[package]]
393746name = "proc-macro2"
394747version = "1.0.107"
......@@ -413,6 +766,17 @@ version = "6.0.0"
413766source = "registry+https://github.com/rust-lang/crates.io-index"
414767checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
415768
769[[package]]
770name = "read-fonts"
771version = "0.41.0"
772source = "registry+https://github.com/rust-lang/crates.io-index"
773checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
774dependencies = [
775 "bytemuck",
776 "font-types",
777 "once_cell",
778]
779
416780[[package]]
417781name = "roxmltree"
418782version = "0.21.1"
......@@ -493,7 +857,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
493857dependencies = [
494858 "proc-macro2",
495859 "quote",
496 "syn",
860 "syn 3.0.5",
497861]
498862
499863[[package]]
......@@ -537,6 +901,16 @@ version = "2.0.1"
537901source = "registry+https://github.com/rust-lang/crates.io-index"
538902checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
539903
904[[package]]
905name = "skrifa"
906version = "0.44.0"
907source = "registry+https://github.com/rust-lang/crates.io-index"
908checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
909dependencies = [
910 "bytemuck",
911 "read-fonts",
912]
913
540914[[package]]
541915name = "smallvec"
542916version = "1.16.0"
......@@ -555,12 +929,29 @@ dependencies = [
555929 "wasm-bindgen",
556930]
557931
932[[package]]
933name = "stable_deref_trait"
934version = "1.2.1"
935source = "registry+https://github.com/rust-lang/crates.io-index"
936checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
937
558938[[package]]
559939name = "subtle"
560940version = "2.6.1"
561941source = "registry+https://github.com/rust-lang/crates.io-index"
562942checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
563943
944[[package]]
945name = "syn"
946version = "2.0.119"
947source = "registry+https://github.com/rust-lang/crates.io-index"
948checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
949dependencies = [
950 "proc-macro2",
951 "quote",
952 "unicode-ident",
953]
954
564955[[package]]
565956name = "syn"
566957version = "3.0.5"
......@@ -572,6 +963,17 @@ dependencies = [
572963 "unicode-ident",
573964]
574965
966[[package]]
967name = "synstructure"
968version = "0.13.2"
969source = "registry+https://github.com/rust-lang/crates.io-index"
970checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
971dependencies = [
972 "proc-macro2",
973 "quote",
974 "syn 2.0.119",
975]
976
575977[[package]]
576978name = "tempfile"
577979version = "3.27.0"
......@@ -602,7 +1004,18 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
6021004dependencies = [
6031005 "proc-macro2",
6041006 "quote",
605 "syn",
1007 "syn 3.0.5",
1008]
1009
1010[[package]]
1011name = "tinystr"
1012version = "0.8.4"
1013source = "registry+https://github.com/rust-lang/crates.io-index"
1014checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
1015dependencies = [
1016 "displaydoc",
1017 "serde_core",
1018 "zerovec",
6061019]
6071020
6081021[[package]]
......@@ -617,6 +1030,12 @@ version = "1.0.24"
6171030source = "registry+https://github.com/rust-lang/crates.io-index"
6181031checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
6191032
1033[[package]]
1034name = "utf8_iter"
1035version = "1.0.4"
1036source = "registry+https://github.com/rust-lang/crates.io-index"
1037checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1038
6201039[[package]]
6211040name = "vcpkg"
6221041version = "0.2.15"
......@@ -655,7 +1074,7 @@ dependencies = [
6551074 "bumpalo",
6561075 "proc-macro2",
6571076 "quote",
658 "syn",
1077 "syn 3.0.5",
6591078 "wasm-bindgen-shared",
6601079]
6611080
......@@ -668,12 +1087,107 @@ dependencies = [
6681087 "unicode-ident",
6691088]
6701089
1090[[package]]
1091name = "windows"
1092version = "0.62.2"
1093source = "registry+https://github.com/rust-lang/crates.io-index"
1094checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
1095dependencies = [
1096 "windows-collections",
1097 "windows-core",
1098 "windows-future",
1099 "windows-numerics",
1100]
1101
1102[[package]]
1103name = "windows-collections"
1104version = "0.3.2"
1105source = "registry+https://github.com/rust-lang/crates.io-index"
1106checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
1107dependencies = [
1108 "windows-core",
1109]
1110
1111[[package]]
1112name = "windows-core"
1113version = "0.62.2"
1114source = "registry+https://github.com/rust-lang/crates.io-index"
1115checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
1116dependencies = [
1117 "windows-implement",
1118 "windows-interface",
1119 "windows-link",
1120 "windows-result",
1121 "windows-strings",
1122]
1123
1124[[package]]
1125name = "windows-future"
1126version = "0.3.2"
1127source = "registry+https://github.com/rust-lang/crates.io-index"
1128checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
1129dependencies = [
1130 "windows-core",
1131 "windows-link",
1132 "windows-threading",
1133]
1134
1135[[package]]
1136name = "windows-implement"
1137version = "0.60.2"
1138source = "registry+https://github.com/rust-lang/crates.io-index"
1139checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
1140dependencies = [
1141 "proc-macro2",
1142 "quote",
1143 "syn 2.0.119",
1144]
1145
1146[[package]]
1147name = "windows-interface"
1148version = "0.59.3"
1149source = "registry+https://github.com/rust-lang/crates.io-index"
1150checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
1151dependencies = [
1152 "proc-macro2",
1153 "quote",
1154 "syn 2.0.119",
1155]
1156
6711157[[package]]
6721158name = "windows-link"
6731159version = "0.2.1"
6741160source = "registry+https://github.com/rust-lang/crates.io-index"
6751161checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
6761162
1163[[package]]
1164name = "windows-numerics"
1165version = "0.3.1"
1166source = "registry+https://github.com/rust-lang/crates.io-index"
1167checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
1168dependencies = [
1169 "windows-core",
1170 "windows-link",
1171]
1172
1173[[package]]
1174name = "windows-result"
1175version = "0.4.1"
1176source = "registry+https://github.com/rust-lang/crates.io-index"
1177checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
1178dependencies = [
1179 "windows-link",
1180]
1181
1182[[package]]
1183name = "windows-strings"
1184version = "0.5.1"
1185source = "registry+https://github.com/rust-lang/crates.io-index"
1186checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
1187dependencies = [
1188 "windows-link",
1189]
1190
6771191[[package]]
6781192name = "windows-sys"
6791193version = "0.61.2"
......@@ -683,12 +1197,117 @@ dependencies = [
6831197 "windows-link",
6841198]
6851199
1200[[package]]
1201name = "windows-threading"
1202version = "0.2.1"
1203source = "registry+https://github.com/rust-lang/crates.io-index"
1204checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
1205dependencies = [
1206 "windows-link",
1207]
1208
1209[[package]]
1210name = "writeable"
1211version = "0.6.4"
1212source = "registry+https://github.com/rust-lang/crates.io-index"
1213checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
1214
1215[[package]]
1216name = "yeslogic-fontconfig-sys"
1217version = "6.0.1"
1218source = "registry+https://github.com/rust-lang/crates.io-index"
1219checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
1220dependencies = [
1221 "dlib",
1222 "once_cell",
1223 "pkg-config",
1224]
1225
1226[[package]]
1227name = "yoke"
1228version = "0.8.3"
1229source = "registry+https://github.com/rust-lang/crates.io-index"
1230checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
1231dependencies = [
1232 "stable_deref_trait",
1233 "yoke-derive",
1234 "zerofrom",
1235]
1236
1237[[package]]
1238name = "yoke-derive"
1239version = "0.8.2"
1240source = "registry+https://github.com/rust-lang/crates.io-index"
1241checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
1242dependencies = [
1243 "proc-macro2",
1244 "quote",
1245 "syn 2.0.119",
1246 "synstructure",
1247]
1248
1249[[package]]
1250name = "zerofrom"
1251version = "0.1.8"
1252source = "registry+https://github.com/rust-lang/crates.io-index"
1253checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
1254dependencies = [
1255 "zerofrom-derive",
1256]
1257
1258[[package]]
1259name = "zerofrom-derive"
1260version = "0.1.7"
1261source = "registry+https://github.com/rust-lang/crates.io-index"
1262checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
1263dependencies = [
1264 "proc-macro2",
1265 "quote",
1266 "syn 2.0.119",
1267 "synstructure",
1268]
1269
6861270[[package]]
6871271name = "zeroize"
6881272version = "1.9.0"
6891273source = "registry+https://github.com/rust-lang/crates.io-index"
6901274checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
6911275
1276[[package]]
1277name = "zerotrie"
1278version = "0.2.5"
1279source = "registry+https://github.com/rust-lang/crates.io-index"
1280checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
1281dependencies = [
1282 "displaydoc",
1283 "yoke",
1284 "zerofrom",
1285 "zerovec",
1286]
1287
1288[[package]]
1289name = "zerovec"
1290version = "0.11.8"
1291source = "registry+https://github.com/rust-lang/crates.io-index"
1292checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
1293dependencies = [
1294 "serde",
1295 "yoke",
1296 "zerofrom",
1297 "zerovec-derive",
1298]
1299
1300[[package]]
1301name = "zerovec-derive"
1302version = "0.11.6"
1303source = "registry+https://github.com/rust-lang/crates.io-index"
1304checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
1305dependencies = [
1306 "proc-macro2",
1307 "quote",
1308 "syn 3.0.5",
1309]
1310
6921311[[package]]
6931312name = "zmij"
6941313version = "1.0.23"
fuzz/Cargo.toml+8
......@@ -11,6 +11,7 @@ cargo-fuzz = true
1111libfuzzer-sys = "0.4"
1212onestore = { path = "../crates/onestore", features = ["protected"] }
1313onestore-offline = { path = "../crates/onestore-offline" }
14one-canvas = { path = "../crates/canvas" }
1415tempfile = "3"
1516md5 = "0.8.1"
1617serde_json = "1"
......@@ -128,3 +129,10 @@ path = "fuzz_targets/offline_page.rs"
128129test = false
129130doc = false
130131bench = false
132
133[[bin]]
134name = "canvas-editor"
135path = "fuzz_targets/canvas_editor.rs"
136test = false
137doc = false
138bench = false
fuzz/fuzz_targets/canvas_editor.rs created+192
......@@ -0,0 +1,192 @@
1#![no_main]
2use libfuzzer_sys::fuzz_target;
3use one_canvas::{
4 document::{TextDocument, TextPosition},
5 editor::{CanvasEditor, TextOutline},
6 layout::TextEngine,
7 text::Paragraph,
8};
9use onestore::document::Format;
10use std::cell::RefCell;
11
12thread_local! {
13 static ENGINE: RefCell<TextEngine> = RefCell::new(TextEngine::default());
14}
15
16fuzz_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
3The [active goal and design record](../../resources/canvas-research.md#active-implementation-goal) define the implementation scope.
4
5## macOS text canvas
6
7The 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
10python3 tools/canvas/build_macos.py
11"target/One Canvas.app/Contents/MacOS/OneCanvas"
12cargo test -p one-canvas
13cargo test -p one-canvas-gpu -- --ignored
14```
15
16The 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
19python3 tools/canvas/build_macos.py --release --output '/PATH/One Canvas Review.app' --bundle-id dev.onecanvas.review
20```
21
22The 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
24Omit 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
27onestore::Format
28 ↓
29one-canvas text edits · inverse history · composition · paragraph layout · structural edits
30 ↓
31one-canvas-gpu Swash glyph rasterization · custom wgpu quads · bounded glyph/image caches
32 ↓
33one-canvas-app Winit window/input · AppKit quit guard · clipboard · AccessKit
34```
35
36The 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
38Drawing 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
40Glyph 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
42Image 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
46Paragraph 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
48Checkboxes 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
52The 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
56cargo run -p one-canvas --bin layout-probe -- resources/canvas/text-cases.json --substitute-font ARIMO_FILE --substitute-font CARLITO_FILE
57```
58
59Arimo'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
61Arimo 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
69The 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
71Core 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
75The 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
78CANVAS_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
81The 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
84CANVAS_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
89AccessKit 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
92cargo test -p one-canvas-app
93ONE_CANVAS_TRACE_INPUT=1 "target/One Canvas.app/Contents/MacOS/OneCanvas" SYNTHETIC_TEXT_FILE
94```
95
96Input 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
104This 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
106The 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
108The 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
120The 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
123CANVAS_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
128Unsupported 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
130Placeholders 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
139cargo run -p one-canvas --bin page-probe -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE > PRIVATE_PAGE_JSON
140python3 tools/canvas/compare_page.py PRIVATE_PAGE_JSON NATIVE_PAGE_XML > PRIVATE_COMPARISON_JSON
141```
142
143The 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
145The PDF companion verifies the matching native XML first, then recovers paragraph wraps from its adjacent PDF export:
146
147```sh
148python3 tools/canvas/compare_page_pdf.py PRIVATE_PAGE_JSON NATIVE_PAGE_XML --allow-native-empty-nbsp > PRIVATE_LINE_COMPARISON_JSON
149```
150
151Complete-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
155All 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
158cargo run -p one-canvas-gpu --example render_page -- PRIVATE_SECTION_COPY EXACT_PAGE_TITLE NEW_PNG_PATH
159```
160
161This 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
168cargo run -p one-canvas --bin layout-probe -- resources/canvas/text-cases.json > PARLEY_JSON
169swift tools/canvas/core_text_probe.swift resources/canvas/text-cases.json > CORE_TEXT_JSON
170python3 tools/canvas/native_fixture.py resources/canvas/text-cases.json NEW_NOTEBOOK_DIRECTORY
171python3 tools/native_runner.py NEW_NOTEBOOK_DIRECTORY NEW_CAPTURE_DIRECTORY --author tools/native/pages.ps1 --pdf --screenshots
172python3 tools/canvas/compare.py resources/canvas/text-cases.json PARLEY_JSON CORE_TEXT_JSON NEW_CAPTURE_DIRECTORY/read > COMPARISON_JSON
173python3 -m unittest discover -s tools/canvas -p 'test_*.py'
174python3 tools/canvas/range_stress.py NEW_STRESS_DIRECTORY target/debug/layout-probe
175python3 tools/canvas/range_stress.py NEW_CORE_TEXT_STRESS_DIRECTORY swift tools/canvas/core_text_probe.swift
176```
177
178The 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
180Native 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
182For 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
187python3 tools/canvas/compare_scroll.py NATIVE_TOP.png NATIVE_SCROLLED.png --crop 48 83 1434 842
188```
189
190Use 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
192The [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
194The 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
200Carlito 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
202The [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
204Full 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
208The 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
211cargo test --release -p one-canvas-app canvas_pipeline_cost -- --ignored --nocapture --test-threads=1
212CANVAS_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
215Private-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
217The 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
220cargo test --release -p one-canvas-gpu renderer_cost -- --ignored --nocapture
221CANVAS_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
224Each `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
231python3 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."""
3import argparse
4import base64
5import json
6from pathlib import Path
7import struct
8import xml.etree.ElementTree as ET
9import zlib
10
11from native_fixture import NS
12
13
14def 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
64if __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."""
3import argparse
4import json
5from pathlib import Path
6import plistlib
7import shutil
8import subprocess
9
10parser = argparse.ArgumentParser(description=__doc__)
11parser.add_argument('--release', action='store_true')
12parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')
13parser.add_argument('--bundle-id', help='Bundle identifier for the separate app')
14args = parser.parse_args()
15if bool(args.output) != bool(args.bundle_id):
16 parser.error('Use --output and --bundle-id together.')
17if args.output and (args.output.suffix != '.app' or args.output.exists()):
18 parser.error('Choose a new output path ending in .app.')
19root = Path(__file__).resolve().parents[2]
20subprocess.run(['cargo', 'build', '-p', 'one-canvas-app'] + (['--release'] if args.release else []), cwd=root, check=True)
21metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root))
22target = Path(metadata['target_directory'])
23bundle = args.output.resolve() if args.output else target / 'One Canvas.app'
24if args.output:
25 bundle.mkdir(parents=True, exist_ok=False)
26binary = bundle / 'Contents/MacOS/OneCanvas'
27binary.parent.mkdir(parents=True, exist_ok=True)
28pending = binary.with_suffix('.next')
29shutil.copy2(target / ('release' if args.release else 'debug') / 'one-canvas-app', pending)
30pending.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}))
41subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True)
42subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True)
43print(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."""
3import argparse
4import base64
5import hashlib
6import json
7from pathlib import Path
8import re
9import sys
10import xml.etree.ElementTree as ET
11
12sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'w7'))
13import mcp_win7 as windows
14
15
16def 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
49if __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."""
3import argparse
4import base64
5import hashlib
6import json
7from pathlib import Path
8import re
9import sys
10import xml.etree.ElementTree as ET
11
12ROOT = Path(__file__).resolve().parents[2]
13sys.path.insert(0, str(ROOT / 'tools/w7'))
14import mcp_win7 as windows
15
16
17def 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 = '''
28dm := Buffer(220, 0)
29NumPut("UShort", 220, dm, 68)
30if !DllCall("EnumDisplaySettingsW", "Ptr", 0, "UInt", -1, "Ptr", dm)
31 throw Error("Unable to read display mode")
32if 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}
40Sleep(300)
41app := ComObject("OneNote.Application")
42'''
43 setup += f'app.NavigateTo("{page_id}", "", false)\n'
44 setup += '''
45hwnd := WinWait("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE",, 10)
46WinMaximize(hwnd)
47WinActivate(hwnd)
48WinWaitActive(hwnd,, 10)
49Send("{Esc}")
50Sleep(200)
51Send("!w")
52Sleep(200)
53Send("q")
54Sleep(100)
55Send("^a")
56SendText("100%")
57Send("{Enter}{Esc}")
58Sleep(200)
59Send("!w")
60dc := DllCall("GetDC", "Ptr", 0, "Ptr")
61FileAppend("dpi=" DllCall("GetDeviceCaps", "Ptr", dc, "Int", 88) "," DllCall("GetDeviceCaps", "Ptr", dc, "Int", 90) "`n", "*")
62DllCall("ReleaseDC", "Ptr", 0, "Ptr", dc)
63FileAppend("screen=" SysGet(0) "x" SysGet(1) "`n", "*")
64'''
65 steps = [('zoom', setup), ('top', '''
66Send("{Esc}{Esc}")
67WinActivate("ahk_class Framework::CFrame ahk_exe ONENOTE.EXE")
68MouseMove(1000, 400)
69Send("{WheelUp 100}")
70MouseMove(800, 12)
71''')]
72 steps += [(f'scroll-{index + 1}', '''
73MouseMove(1000, 400)
74Send("{WheelDown 8}")
75MouseMove(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
99if __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."""
3import argparse
4import json
5from pathlib import Path
6import xml.etree.ElementTree as ET
7
8import pdfplumber
9from native_fixture import build
10
11NS = {'one': 'http://schemas.microsoft.com/office/onenote/2010/onenote'}
12
13
14def 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
30def 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
60def 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
134if __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."""
3import argparse
4import base64
5from collections import Counter, defaultdict
6import hashlib
7import io
8import json
9import math
10from pathlib import Path
11import statistics
12import xml.etree.ElementTree as ET
13
14import pdfplumber
15from PIL import Image
16
17from compare_page_pdf import compare
18from native_fixture import NS
19
20
21def 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
39def 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
79def 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
111if __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."""
3import argparse
4from html.parser import HTMLParser
5import json
6from pathlib import Path
7import xml.etree.ElementTree as ET
8
9NS = {'one': 'http://schemas.microsoft.com/office/onenote/2010/onenote'}
10
11
12class 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
27def 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
81if __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."""
3import argparse
4from collections import Counter
5import json
6from pathlib import Path
7from types import SimpleNamespace
8import xml.etree.ElementTree as ET
9
10import pdfplumber
11from compare import pdf_line_ends, pdf_lines
12from compare_page import compare as compare_xml
13
14
15def occurrences(text, needle):
16 start = 0
17 while needle and (start := text.find(needle, start)) != -1:
18 yield start
19 start += 1
20
21
22def 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
105if __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."""
3import argparse
4from collections import Counter, defaultdict
5import json
6from pathlib import Path
7
8import numpy as np
9from PIL import Image
10
11
12def 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
54if __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 @@
1import CoreText
2import Foundation
3
4struct Run: Codable {
5 let text: String
6 let font: String
7 let size: Double
8 let bold: Bool
9 let italic: Bool
10}
11
12struct Case: Decodable {
13 let id: String
14 let width: Double
15 let runs: [Run]
16}
17
18guard CommandLine.arguments.count >= 2 else {
19 fatalError("Expected a text-case JSON file and optional font files")
20}
21for 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}
27let data = try Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[1]))
28let cases = try JSONDecoder().decode([Case].self, from: data)
29var results: [[String: Any]] = []
30for 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}
87let output: [String: Any] = [
88 "engine": "Core Text typesetter", "os": ProcessInfo.processInfo.operatingSystemVersionString,
89 "units": "points", "cases": results,
90]
91FileHandle.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."""
3import argparse
4import html
5import json
6import math
7from pathlib import Path
8import xml.etree.ElementTree as ET
9
10NS = 'http://schemas.microsoft.com/office/onenote/2010/onenote'
11ET.register_namespace('one', NS)
12
13
14def 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
47if __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."""
3import argparse
4import json
5from pathlib import Path
6import random
7import subprocess
8
9
10def 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
22def 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
43if __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 @@
1import unittest
2from types import SimpleNamespace
3
4from compare import pdf_line_ends
5from range_stress import check
6
7
8def 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
15class 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
38class 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
66if __name__ == '__main__':
67 unittest.main()
tools/canvas/test_compare_baselines.py created+80
......@@ -0,0 +1,80 @@
1import base64
2import io
3from types import SimpleNamespace
4import unittest
5from unittest.mock import patch
6import xml.etree.ElementTree as ET
7
8from PIL import Image
9
10from baseline_fixture import build
11from compare_baselines import baseline_report, pdf_rgb, register_images
12from native_fixture import NS
13
14
15class 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
79if __name__ == '__main__':
80 unittest.main()
tools/canvas/test_compare_page.py created+63
......@@ -0,0 +1,63 @@
1import unittest
2import xml.etree.ElementTree as ET
3
4from compare_page import compare
5
6
7class 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
62if __name__ == '__main__':
63 unittest.main()
tools/canvas/test_compare_page_pdf.py created+78
......@@ -0,0 +1,78 @@
1import unittest
2from types import SimpleNamespace
3
4from compare import pdf_lines
5from compare_page_pdf import compare
6
7
8def 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
17def 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
24class 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
77if __name__ == '__main__':
78 unittest.main()
tools/canvas/test_compare_scroll.py created+41
......@@ -0,0 +1,41 @@
1import unittest
2
3import numpy as np
4
5from compare_scroll import compare
6
7
8class 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
40if __name__ == '__main__':
41 unittest.main()