authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:22-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 04:23:49-07:00
log20f70854dfb31efe4ad51f2844399553822889c1
treef05a00236db345f7fc983838d6eb450ca8b9abfa
parent46b2c3bf9ebd9f51be378aaf1835936a82f4ddb9
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: Linux, bundled Calibri-compatible fonts, our own template art, notebooks in progress

Linux builds (X11/Wayland, fontconfig, zenity dialogs, per-platform key and caret tables, square corners, Wayland clipboard). Carlito/Arimo/Tinos/Cousine stand in for missing Office fonts; the font picker leads with presets and recent fonts, previews each family and ranks with nucleo; list rows no longer animate, only the popup height eases. Text on highlights stays legible in dark mode. Pages and fixtures carry our own template renderings; the view stops at a picture's corner and drops outside the page cancel. Pages reopen where they were left. Conflict pages get OneNote's bar, menu and highlight. A headless --screenshot renders the README images from a committed sample notebook. Notebook management has begun: settings, reopening notebooks, the notebook sidebar and Open Notebook. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5

100 files changed, 9214 insertions(+), 1922 deletions(-)

Cargo.lock+682-6
......@@ -27,6 +27,20 @@ dependencies = [
2727 "uuid",
2828]
2929
30[[package]]
31name = "accesskit_atspi_common"
32version = "0.20.0"
33source = "registry+https://github.com/rust-lang/crates.io-index"
34checksum = "c9d47ad644916f6cb7e432a5ca0dbd7cc78281a9772881057bb72d4aadb93257"
35dependencies = [
36 "accesskit",
37 "accesskit_consumer",
38 "atspi-common",
39 "phf",
40 "serde",
41 "zvariant",
42]
43
3044[[package]]
3145name = "accesskit_consumer"
3246version = "0.39.0"
......@@ -65,6 +79,24 @@ dependencies = [
6579 "objc2-foundation 0.2.2",
6680]
6781
82[[package]]
83name = "accesskit_unix"
84version = "0.23.0"
85source = "registry+https://github.com/rust-lang/crates.io-index"
86checksum = "665c9079b7325a8168a6793437a172dda44b19a05af8ed52d7e32abaada996fe"
87dependencies = [
88 "accesskit",
89 "accesskit_atspi_common",
90 "async-channel",
91 "async-executor",
92 "async-task",
93 "atspi",
94 "futures-lite",
95 "futures-util",
96 "serde",
97 "zbus",
98]
99
68100[[package]]
69101name = "accesskit_windows"
70102version = "0.35.0"
......@@ -88,6 +120,7 @@ dependencies = [
88120 "accesskit",
89121 "accesskit_ios",
90122 "accesskit_macos",
123 "accesskit_unix",
91124 "accesskit_windows",
92125 "raw-window-handle",
93126 "winit",
......@@ -232,6 +265,126 @@ dependencies = [
232265 "libloading",
233266]
234267
268[[package]]
269name = "async-broadcast"
270version = "0.7.2"
271source = "registry+https://github.com/rust-lang/crates.io-index"
272checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
273dependencies = [
274 "event-listener",
275 "event-listener-strategy",
276 "futures-core",
277 "pin-project-lite",
278]
279
280[[package]]
281name = "async-channel"
282version = "2.5.0"
283source = "registry+https://github.com/rust-lang/crates.io-index"
284checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
285dependencies = [
286 "concurrent-queue",
287 "event-listener-strategy",
288 "futures-core",
289 "pin-project-lite",
290]
291
292[[package]]
293name = "async-executor"
294version = "1.14.0"
295source = "registry+https://github.com/rust-lang/crates.io-index"
296checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
297dependencies = [
298 "async-task",
299 "concurrent-queue",
300 "fastrand",
301 "futures-lite",
302 "pin-project-lite",
303 "slab",
304]
305
306[[package]]
307name = "async-io"
308version = "2.6.0"
309source = "registry+https://github.com/rust-lang/crates.io-index"
310checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
311dependencies = [
312 "autocfg",
313 "cfg-if",
314 "concurrent-queue",
315 "futures-io",
316 "futures-lite",
317 "parking",
318 "polling",
319 "rustix 1.1.4",
320 "slab",
321 "windows-sys 0.61.2",
322]
323
324[[package]]
325name = "async-lock"
326version = "3.4.2"
327source = "registry+https://github.com/rust-lang/crates.io-index"
328checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
329dependencies = [
330 "event-listener",
331 "event-listener-strategy",
332 "pin-project-lite",
333]
334
335[[package]]
336name = "async-process"
337version = "2.5.0"
338source = "registry+https://github.com/rust-lang/crates.io-index"
339checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
340dependencies = [
341 "async-channel",
342 "async-io",
343 "async-lock",
344 "async-signal",
345 "async-task",
346 "blocking",
347 "cfg-if",
348 "event-listener",
349 "futures-lite",
350 "rustix 1.1.4",
351]
352
353[[package]]
354name = "async-recursion"
355version = "1.1.1"
356source = "registry+https://github.com/rust-lang/crates.io-index"
357checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
358dependencies = [
359 "proc-macro2",
360 "quote",
361 "syn 2.0.119",
362]
363
364[[package]]
365name = "async-signal"
366version = "0.2.14"
367source = "registry+https://github.com/rust-lang/crates.io-index"
368checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
369dependencies = [
370 "async-io",
371 "async-lock",
372 "atomic-waker",
373 "cfg-if",
374 "futures-core",
375 "futures-io",
376 "rustix 1.1.4",
377 "signal-hook-registry",
378 "slab",
379 "windows-sys 0.61.2",
380]
381
382[[package]]
383name = "async-task"
384version = "4.7.1"
385source = "registry+https://github.com/rust-lang/crates.io-index"
386checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
387
235388[[package]]
236389name = "async-trait"
237390version = "0.1.92"
......@@ -249,6 +402,43 @@ version = "1.1.2"
249402source = "registry+https://github.com/rust-lang/crates.io-index"
250403checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
251404
405[[package]]
406name = "atspi"
407version = "0.29.0"
408source = "registry+https://github.com/rust-lang/crates.io-index"
409checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
410dependencies = [
411 "atspi-common",
412 "atspi-proxies",
413]
414
415[[package]]
416name = "atspi-common"
417version = "0.13.0"
418source = "registry+https://github.com/rust-lang/crates.io-index"
419checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d"
420dependencies = [
421 "enumflags2",
422 "serde",
423 "static_assertions",
424 "zbus",
425 "zbus-lockstep",
426 "zbus-lockstep-macros",
427 "zbus_names",
428 "zvariant",
429]
430
431[[package]]
432name = "atspi-proxies"
433version = "0.13.0"
434source = "registry+https://github.com/rust-lang/crates.io-index"
435checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
436dependencies = [
437 "atspi-common",
438 "serde",
439 "zbus",
440]
441
252442[[package]]
253443name = "autocfg"
254444version = "1.5.1"
......@@ -325,6 +515,19 @@ dependencies = [
325515 "objc2 0.6.4",
326516]
327517
518[[package]]
519name = "blocking"
520version = "1.7.0"
521source = "registry+https://github.com/rust-lang/crates.io-index"
522checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
523dependencies = [
524 "async-channel",
525 "async-task",
526 "futures-io",
527 "futures-lite",
528 "piper",
529]
530
328531[[package]]
329532name = "bumpalo"
330533version = "3.20.3"
......@@ -377,25 +580,52 @@ dependencies = [
377580 "thiserror 1.0.69",
378581]
379582
583[[package]]
584name = "calloop"
585version = "0.14.4"
586source = "registry+https://github.com/rust-lang/crates.io-index"
587checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
588dependencies = [
589 "bitflags 2.13.1",
590 "polling",
591 "rustix 1.1.4",
592 "slab",
593 "tracing",
594]
595
380596[[package]]
381597name = "calloop-wayland-source"
382598version = "0.3.0"
383599source = "registry+https://github.com/rust-lang/crates.io-index"
384600checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
385601dependencies = [
386 "calloop",
602 "calloop 0.13.0",
387603 "rustix 0.38.44",
388604 "wayland-backend",
389605 "wayland-client",
390606]
391607
608[[package]]
609name = "calloop-wayland-source"
610version = "0.4.1"
611source = "registry+https://github.com/rust-lang/crates.io-index"
612checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
613dependencies = [
614 "calloop 0.14.4",
615 "rustix 1.1.4",
616 "wayland-backend",
617 "wayland-client",
618]
619
392620[[package]]
393621name = "canvas"
394622version = "0.1.0"
395623dependencies = [
396624 "accesskit",
397625 "accesskit_consumer",
626 "color_quant",
398627 "draw",
628 "image",
399629 "onestore",
400630 "parley",
401631 "png",
......@@ -744,6 +974,33 @@ dependencies = [
744974 "wgpu",
745975]
746976
977[[package]]
978name = "endi"
979version = "1.1.1"
980source = "registry+https://github.com/rust-lang/crates.io-index"
981checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
982
983[[package]]
984name = "enumflags2"
985version = "0.7.12"
986source = "registry+https://github.com/rust-lang/crates.io-index"
987checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
988dependencies = [
989 "enumflags2_derive",
990 "serde",
991]
992
993[[package]]
994name = "enumflags2_derive"
995version = "0.7.12"
996source = "registry+https://github.com/rust-lang/crates.io-index"
997checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
998dependencies = [
999 "proc-macro2",
1000 "quote",
1001 "syn 2.0.119",
1002]
1003
7471004[[package]]
7481005name = "equivalent"
7491006version = "1.0.2"
......@@ -775,6 +1032,26 @@ dependencies = [
7751032 "num-traits",
7761033]
7771034
1035[[package]]
1036name = "event-listener"
1037version = "5.4.2"
1038source = "registry+https://github.com/rust-lang/crates.io-index"
1039checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
1040dependencies = [
1041 "parking",
1042 "pin-project-lite",
1043]
1044
1045[[package]]
1046name = "event-listener-strategy"
1047version = "0.5.4"
1048source = "registry+https://github.com/rust-lang/crates.io-index"
1049checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
1050dependencies = [
1051 "event-listener",
1052 "pin-project-lite",
1053]
1054
7781055[[package]]
7791056name = "fallible-iterator"
7801057version = "0.3.0"
......@@ -900,6 +1177,36 @@ version = "0.3.34"
9001177source = "registry+https://github.com/rust-lang/crates.io-index"
9011178checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
9021179
1180[[package]]
1181name = "futures-io"
1182version = "0.3.34"
1183source = "registry+https://github.com/rust-lang/crates.io-index"
1184checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
1185
1186[[package]]
1187name = "futures-lite"
1188version = "2.6.1"
1189source = "registry+https://github.com/rust-lang/crates.io-index"
1190checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
1191dependencies = [
1192 "fastrand",
1193 "futures-core",
1194 "futures-io",
1195 "parking",
1196 "pin-project-lite",
1197]
1198
1199[[package]]
1200name = "futures-macro"
1201version = "0.3.34"
1202source = "registry+https://github.com/rust-lang/crates.io-index"
1203checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
1204dependencies = [
1205 "proc-macro2",
1206 "quote",
1207 "syn 3.0.5",
1208]
1209
9031210[[package]]
9041211name = "futures-task"
9051212version = "0.3.34"
......@@ -913,6 +1220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
9131220checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
9141221dependencies = [
9151222 "futures-core",
1223 "futures-macro",
9161224 "futures-task",
9171225 "pin-project-lite",
9181226 "slab",
......@@ -1076,6 +1384,12 @@ version = "0.5.3"
10761384source = "registry+https://github.com/rust-lang/crates.io-index"
10771385checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
10781386
1387[[package]]
1388name = "hex"
1389version = "0.4.3"
1390source = "registry+https://github.com/rust-lang/crates.io-index"
1391checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1392
10791393[[package]]
10801394name = "hmac"
10811395version = "0.13.0"
......@@ -1520,6 +1834,15 @@ dependencies = [
15201834 "libc",
15211835]
15221836
1837[[package]]
1838name = "memoffset"
1839version = "0.9.1"
1840source = "registry+https://github.com/rust-lang/crates.io-index"
1841checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
1842dependencies = [
1843 "autocfg",
1844]
1845
15231846[[package]]
15241847name = "miniz_oxide"
15251848version = "0.8.9"
......@@ -1557,9 +1880,11 @@ version = "0.1.0"
15571880dependencies = [
15581881 "canvas",
15591882 "draw",
1883 "notebook",
15601884 "onestore",
15611885 "parley",
15621886 "pollster",
1887 "serde",
15631888 "serde_json",
15641889 "wgpu",
15651890]
......@@ -1671,6 +1996,16 @@ dependencies = [
16711996 "zeroize",
16721997]
16731998
1999[[package]]
2000name = "nucleo-matcher"
2001version = "0.3.1"
2002source = "registry+https://github.com/rust-lang/crates.io-index"
2003checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
2004dependencies = [
2005 "memchr",
2006 "unicode-segmentation",
2007]
2008
16742009[[package]]
16752010name = "num-traits"
16762011version = "0.2.19"
......@@ -2053,6 +2388,16 @@ dependencies = [
20532388 "num-traits",
20542389]
20552390
2391[[package]]
2392name = "ordered-stream"
2393version = "0.2.0"
2394source = "registry+https://github.com/rust-lang/crates.io-index"
2395checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
2396dependencies = [
2397 "futures-core",
2398 "pin-project-lite",
2399]
2400
20562401[[package]]
20572402name = "owned_ttf_parser"
20582403version = "0.25.1"
......@@ -2062,6 +2407,12 @@ dependencies = [
20622407 "ttf-parser",
20632408]
20642409
2410[[package]]
2411name = "parking"
2412version = "2.2.1"
2413source = "registry+https://github.com/rust-lang/crates.io-index"
2414checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2415
20652416[[package]]
20662417name = "parking_lot"
20672418version = "0.12.5"
......@@ -2145,6 +2496,49 @@ version = "2.3.2"
21452496source = "registry+https://github.com/rust-lang/crates.io-index"
21462497checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
21472498
2499[[package]]
2500name = "phf"
2501version = "0.14.0"
2502source = "registry+https://github.com/rust-lang/crates.io-index"
2503checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
2504dependencies = [
2505 "phf_macros",
2506 "phf_shared",
2507 "serde",
2508]
2509
2510[[package]]
2511name = "phf_generator"
2512version = "0.14.0"
2513source = "registry+https://github.com/rust-lang/crates.io-index"
2514checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
2515dependencies = [
2516 "fastrand",
2517 "phf_shared",
2518]
2519
2520[[package]]
2521name = "phf_macros"
2522version = "0.14.0"
2523source = "registry+https://github.com/rust-lang/crates.io-index"
2524checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
2525dependencies = [
2526 "phf_generator",
2527 "phf_shared",
2528 "proc-macro2",
2529 "quote",
2530 "syn 2.0.119",
2531]
2532
2533[[package]]
2534name = "phf_shared"
2535version = "0.14.0"
2536source = "registry+https://github.com/rust-lang/crates.io-index"
2537checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
2538dependencies = [
2539 "siphasher",
2540]
2541
21482542[[package]]
21492543name = "pico-args"
21502544version = "0.5.0"
......@@ -2177,6 +2571,17 @@ version = "0.2.17"
21772571source = "registry+https://github.com/rust-lang/crates.io-index"
21782572checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
21792573
2574[[package]]
2575name = "piper"
2576version = "0.2.5"
2577source = "registry+https://github.com/rust-lang/crates.io-index"
2578checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
2579dependencies = [
2580 "atomic-waker",
2581 "fastrand",
2582 "futures-io",
2583]
2584
21802585[[package]]
21812586name = "pkg-config"
21822587version = "0.3.34"
......@@ -2549,7 +2954,7 @@ dependencies = [
25492954 "ab_glyph",
25502955 "log",
25512956 "memmap2",
2552 "smithay-client-toolkit",
2957 "smithay-client-toolkit 0.19.2",
25532958 "tiny-skia 0.11.4",
25542959]
25552960
......@@ -2602,6 +3007,17 @@ dependencies = [
26023007 "zmij",
26033008]
26043009
3010[[package]]
3011name = "serde_repr"
3012version = "0.1.21"
3013source = "registry+https://github.com/rust-lang/crates.io-index"
3014checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
3015dependencies = [
3016 "proc-macro2",
3017 "quote",
3018 "syn 3.0.5",
3019]
3020
26053021[[package]]
26063022name = "sha1"
26073023version = "0.11.0"
......@@ -2630,6 +3046,16 @@ version = "2.0.1"
26303046source = "registry+https://github.com/rust-lang/crates.io-index"
26313047checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
26323048
3049[[package]]
3050name = "signal-hook-registry"
3051version = "1.4.8"
3052source = "registry+https://github.com/rust-lang/crates.io-index"
3053checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
3054dependencies = [
3055 "errno",
3056 "libc",
3057]
3058
26333059[[package]]
26343060name = "simd-adler32"
26353061version = "0.3.10"
......@@ -2732,8 +3158,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
27323158checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
27333159dependencies = [
27343160 "bitflags 2.13.1",
2735 "calloop",
2736 "calloop-wayland-source",
3161 "calloop 0.13.0",
3162 "calloop-wayland-source 0.3.0",
27373163 "cursor-icon",
27383164 "libc",
27393165 "log",
......@@ -2750,6 +3176,44 @@ dependencies = [
27503176 "xkeysym",
27513177]
27523178
3179[[package]]
3180name = "smithay-client-toolkit"
3181version = "0.20.0"
3182source = "registry+https://github.com/rust-lang/crates.io-index"
3183checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
3184dependencies = [
3185 "bitflags 2.13.1",
3186 "calloop 0.14.4",
3187 "calloop-wayland-source 0.4.1",
3188 "cursor-icon",
3189 "libc",
3190 "log",
3191 "memmap2",
3192 "rustix 1.1.4",
3193 "thiserror 2.0.20",
3194 "wayland-backend",
3195 "wayland-client",
3196 "wayland-csd-frame",
3197 "wayland-cursor",
3198 "wayland-protocols",
3199 "wayland-protocols-experimental",
3200 "wayland-protocols-misc",
3201 "wayland-protocols-wlr",
3202 "wayland-scanner",
3203 "xkeysym",
3204]
3205
3206[[package]]
3207name = "smithay-clipboard"
3208version = "0.7.3"
3209source = "registry+https://github.com/rust-lang/crates.io-index"
3210checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
3211dependencies = [
3212 "libc",
3213 "smithay-client-toolkit 0.20.0",
3214 "wayland-backend",
3215]
3216
27533217[[package]]
27543218name = "smol_str"
27553219version = "0.2.2"
......@@ -2768,6 +3232,8 @@ dependencies = [
27683232 "arboard",
27693233 "canvas",
27703234 "draw",
3235 "fontique",
3236 "libc",
27713237 "notebook",
27723238 "objc2 0.5.2",
27733239 "objc2-app-kit 0.2.2",
......@@ -2776,9 +3242,13 @@ dependencies = [
27763242 "parley",
27773243 "png",
27783244 "pollster",
3245 "serde",
3246 "serde_json",
3247 "smithay-clipboard",
27793248 "ui",
27803249 "wgpu",
27813250 "winit",
3251 "zbus",
27823252]
27833253
27843254[[package]]
......@@ -3081,15 +3551,31 @@ version = "0.1.44"
30813551source = "registry+https://github.com/rust-lang/crates.io-index"
30823552checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
30833553dependencies = [
3554 "log",
30843555 "pin-project-lite",
3556 "tracing-attributes",
30853557 "tracing-core",
30863558]
30873559
3560[[package]]
3561name = "tracing-attributes"
3562version = "0.1.31"
3563source = "registry+https://github.com/rust-lang/crates.io-index"
3564checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
3565dependencies = [
3566 "proc-macro2",
3567 "quote",
3568 "syn 2.0.119",
3569]
3570
30883571[[package]]
30893572name = "tracing-core"
30903573version = "0.1.36"
30913574source = "registry+https://github.com/rust-lang/crates.io-index"
30923575checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
3576dependencies = [
3577 "once_cell",
3578]
30933579
30943580[[package]]
30953581name = "ttf-parser"
......@@ -3109,11 +3595,23 @@ version = "1.20.1"
31093595source = "registry+https://github.com/rust-lang/crates.io-index"
31103596checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
31113597
3598[[package]]
3599name = "uds_windows"
3600version = "1.2.1"
3601source = "registry+https://github.com/rust-lang/crates.io-index"
3602checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
3603dependencies = [
3604 "memoffset",
3605 "tempfile",
3606 "windows-sys 0.61.2",
3607]
3608
31123609[[package]]
31133610name = "ui"
31143611version = "0.1.0"
31153612dependencies = [
31163613 "draw",
3614 "nucleo-matcher",
31173615 "parley",
31183616 "png",
31193617 "pollster",
......@@ -3179,6 +3677,11 @@ name = "uuid"
31793677version = "1.26.1"
31803678source = "registry+https://github.com/rust-lang/crates.io-index"
31813679checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
3680dependencies = [
3681 "js-sys",
3682 "serde_core",
3683 "wasm-bindgen",
3684]
31823685
31833686[[package]]
31843687name = "vcpkg"
......@@ -3332,6 +3835,32 @@ dependencies = [
33323835 "wayland-scanner",
33333836]
33343837
3838[[package]]
3839name = "wayland-protocols-experimental"
3840version = "20250721.0.1"
3841source = "registry+https://github.com/rust-lang/crates.io-index"
3842checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
3843dependencies = [
3844 "bitflags 2.13.1",
3845 "wayland-backend",
3846 "wayland-client",
3847 "wayland-protocols",
3848 "wayland-scanner",
3849]
3850
3851[[package]]
3852name = "wayland-protocols-misc"
3853version = "0.3.12"
3854source = "registry+https://github.com/rust-lang/crates.io-index"
3855checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
3856dependencies = [
3857 "bitflags 2.13.1",
3858 "wayland-backend",
3859 "wayland-client",
3860 "wayland-protocols",
3861 "wayland-scanner",
3862]
3863
33353864[[package]]
33363865name = "wayland-protocols-plasma"
33373866version = "0.3.12"
......@@ -3876,7 +4405,7 @@ dependencies = [
38764405 "bitflags 2.13.1",
38774406 "block2 0.5.1",
38784407 "bytemuck",
3879 "calloop",
4408 "calloop 0.13.0",
38804409 "cfg_aliases",
38814410 "concurrent-queue",
38824411 "core-foundation",
......@@ -3898,7 +4427,7 @@ dependencies = [
38984427 "redox_syscall 0.4.1",
38994428 "rustix 0.38.44",
39004429 "sctk-adwaita",
3901 "smithay-client-toolkit",
4430 "smithay-client-toolkit 0.19.2",
39024431 "smol_str",
39034432 "tracing",
39044433 "unicode-segmentation",
......@@ -4040,6 +4569,112 @@ dependencies = [
40404569 "synstructure",
40414570]
40424571
4572[[package]]
4573name = "zbus"
4574version = "5.19.0"
4575source = "registry+https://github.com/rust-lang/crates.io-index"
4576checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
4577dependencies = [
4578 "async-broadcast",
4579 "async-executor",
4580 "async-io",
4581 "async-lock",
4582 "async-process",
4583 "async-recursion",
4584 "async-task",
4585 "async-trait",
4586 "blocking",
4587 "enumflags2",
4588 "event-listener",
4589 "futures-core",
4590 "futures-lite",
4591 "hex",
4592 "libc",
4593 "ordered-stream",
4594 "rustix 1.1.4",
4595 "serde",
4596 "serde_repr",
4597 "tracing",
4598 "uds_windows",
4599 "uuid",
4600 "windows-sys 0.61.2",
4601 "winnow",
4602 "zbus_macros",
4603 "zbus_names",
4604 "zvariant",
4605]
4606
4607[[package]]
4608name = "zbus-lockstep"
4609version = "0.5.2"
4610source = "registry+https://github.com/rust-lang/crates.io-index"
4611checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
4612dependencies = [
4613 "zbus_xml",
4614 "zvariant",
4615]
4616
4617[[package]]
4618name = "zbus-lockstep-macros"
4619version = "0.5.2"
4620source = "registry+https://github.com/rust-lang/crates.io-index"
4621checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0"
4622dependencies = [
4623 "proc-macro2",
4624 "quote",
4625 "syn 2.0.119",
4626 "zbus-lockstep",
4627 "zbus_xml",
4628 "zvariant",
4629]
4630
4631[[package]]
4632name = "zbus_macros"
4633version = "5.19.0"
4634source = "registry+https://github.com/rust-lang/crates.io-index"
4635checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
4636dependencies = [
4637 "proc-macro-crate",
4638 "proc-macro2",
4639 "quote",
4640 "syn 3.0.5",
4641 "zbus_names",
4642 "zvariant",
4643 "zvariant_utils",
4644]
4645
4646[[package]]
4647name = "zbus_names"
4648version = "4.3.4"
4649source = "registry+https://github.com/rust-lang/crates.io-index"
4650checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
4651dependencies = [
4652 "serde",
4653 "winnow",
4654 "zvariant",
4655]
4656
4657[[package]]
4658name = "zbus_xml"
4659version = "5.2.1"
4660source = "registry+https://github.com/rust-lang/crates.io-index"
4661checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a"
4662dependencies = [
4663 "serde",
4664 "winnow",
4665 "zbus_names",
4666 "zvariant",
4667]
4668
4669[[package]]
4670name = "zcheapstr"
4671version = "1.1.0"
4672source = "registry+https://github.com/rust-lang/crates.io-index"
4673checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
4674dependencies = [
4675 "serde",
4676]
4677
40434678[[package]]
40444679name = "zeno"
40454680version = "0.3.3"
......@@ -4154,3 +4789,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
41544789dependencies = [
41554790 "zune-core",
41564791]
4792
4793[[package]]
4794name = "zvariant"
4795version = "5.15.0"
4796source = "registry+https://github.com/rust-lang/crates.io-index"
4797checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
4798dependencies = [
4799 "endi",
4800 "enumflags2",
4801 "serde",
4802 "winnow",
4803 "zcheapstr",
4804 "zvariant_derive",
4805 "zvariant_utils",
4806]
4807
4808[[package]]
4809name = "zvariant_derive"
4810version = "5.15.0"
4811source = "registry+https://github.com/rust-lang/crates.io-index"
4812checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
4813dependencies = [
4814 "proc-macro-crate",
4815 "proc-macro2",
4816 "quote",
4817 "syn 3.0.5",
4818 "zvariant_utils",
4819]
4820
4821[[package]]
4822name = "zvariant_utils"
4823version = "4.2.0"
4824source = "registry+https://github.com/rust-lang/crates.io-index"
4825checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
4826dependencies = [
4827 "proc-macro2",
4828 "quote",
4829 "serde",
4830 "syn 3.0.5",
4831 "winnow",
4832]
Cargo.toml+3
......@@ -5,6 +5,9 @@ resolver = "3"
55
66[workspace.dependencies]
77parley = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759", features = ["complex-scripts"] }
8# Parley's own fontique, for its features: Cargo shares a git dependency only between
9# entries naming the same revision.
10fontique = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759" }
811wgpu = "30.0.1"
912
1013# Shaping, rasterization and GPU submission stay optimised in debug builds, where
crates/canvas/Cargo.toml+4-2
......@@ -5,7 +5,7 @@ edition = "2024"
55publish = false
66
77[features]
8gpu = ["draw/render", "dep:resvg", "dep:sha2"]
8gpu = ["draw/render", "dep:color_quant", "dep:image", "dep:png", "dep:resvg", "dep:sha2"]
99# How a page responds to input and presents itself to assistive technology.
1010interaction = ["gpu", "dep:accesskit"]
1111
......@@ -18,6 +18,9 @@ serde_json = "1.0.151"
1818skrifa = "0.44.0"
1919
2020accesskit = { version = "0.25.0", optional = true }
21color_quant = { version = "1.1", optional = true }
22image = { version = "0.25", default-features = false, features = ["jpeg"], optional = true }
23png = { version = "0.18", optional = true }
2124resvg = { version = "0.48.1", default-features = false, optional = true }
2225sha2 = { version = "0.11", optional = true }
2326
......@@ -32,7 +35,6 @@ path = "src/bin/page_probe.rs"
3235[dev-dependencies]
3336accesskit_consumer = "0.39.0"
3437pollster = "0.4"
35png = "0.18"
3638wgpu.workspace = true
3739
3840[[example]]
crates/canvas/assets/backgrounds/hearts.svg+8-8
......@@ -1,11 +1,11 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 171 552" preserveAspectRatio="none">
22<defs><path id="heart" d="M0 10C-5 6-10.5 1.5-10.5-3.5C-10.5-8-7-10.5-4-10.5C-2-10.5-.5-9.2 0-7.5C.5-9.2 2-10.5 4-10.5C7-10.5 10.5-8 10.5-3.5C10.5 1.5 5 6 0 10Z"/></defs>
3<g transform="translate(70 58) rotate(-35) scale(5.6)"><use href="#heart" fill="#dcbccf"/><use href="#heart" fill="#ddd1d9" transform="translate(-1.2 -1.4) scale(.8)"/></g>
4<g transform="translate(130 108) rotate(90) scale(4.3)"><use href="#heart" fill="#99d3a8"/><use href="#heart" fill="#bee2ac" transform="translate(-1.2 -1.4) scale(.8)"/></g>
5<g transform="translate(62 166) rotate(-70) scale(4.3)"><use href="#heart" fill="#da91ba"/><use href="#heart" fill="#dcbccf" transform="translate(-1.2 -1.4) scale(.8)"/></g>
6<g transform="translate(118 230) rotate(35) scale(4.8)"><use href="#heart" fill="#b0dede"/><use href="#heart" fill="#d2ebe5" transform="translate(-1.2 -1.4) scale(.8)"/></g>
7<g transform="translate(52 322) rotate(-135) scale(4.6)"><use href="#heart" fill="#b0dede"/><use href="#heart" fill="#d2ebe5" transform="translate(-1.2 -1.4) scale(.8)"/></g>
8<g transform="translate(110 382) rotate(110) scale(4.3)"><use href="#heart" fill="#da91ba"/><use href="#heart" fill="#dcbccf" transform="translate(-1.2 -1.4) scale(.8)"/></g>
9<g transform="translate(40 443) rotate(-120) scale(4.3)"><use href="#heart" fill="#99d3a8"/><use href="#heart" fill="#bee2ac" transform="translate(-1.2 -1.4) scale(.8)"/></g>
10<g transform="translate(100 497) rotate(172) scale(5.6)"><use href="#heart" fill="#dcbccf"/><use href="#heart" fill="#ddd1d9" transform="translate(-1.2 -1.4) scale(.8)"/></g>
3<g transform="translate(64 64) rotate(-27) scale(5.2 6.1)"><use href="#heart" fill="#dcbccf"/><use href="#heart" fill="#ddd1d9" transform="translate(-1.2 -1.4) scale(.8)"/></g>
4<g transform="translate(123 123) rotate(44) scale(3.6 4.1)"><use href="#heart" fill="#99d3a8"/><use href="#heart" fill="#bee2ac" transform="translate(-1.2 -1.4) scale(.8)"/></g>
5<g transform="translate(72 178) rotate(-43) scale(3.8 4.1)"><use href="#heart" fill="#da91ba"/><use href="#heart" fill="#dcbccf" transform="translate(-1.2 -1.4) scale(.8)"/></g>
6<g transform="translate(116 242) rotate(38) scale(4.0 4.6)"><use href="#heart" fill="#b0dede"/><use href="#heart" fill="#d2ebe5" transform="translate(-1.2 -1.4) scale(.8)"/></g>
7<g transform="translate(57 306) rotate(-137) scale(4.0 4.6)"><use href="#heart" fill="#b0dede"/><use href="#heart" fill="#d2ebe5" transform="translate(-1.2 -1.4) scale(.8)"/></g>
8<g transform="translate(112 376) rotate(145) scale(3.8 4.1)"><use href="#heart" fill="#da91ba"/><use href="#heart" fill="#dcbccf" transform="translate(-1.2 -1.4) scale(.8)"/></g>
9<g transform="translate(45 428) rotate(-138) scale(3.8 4.1)"><use href="#heart" fill="#99d3a8"/><use href="#heart" fill="#bee2ac" transform="translate(-1.2 -1.4) scale(.8)"/></g>
10<g transform="translate(100 496) rotate(156) scale(5.2 6.1)"><use href="#heart" fill="#dcbccf"/><use href="#heart" fill="#ddd1d9" transform="translate(-1.2 -1.4) scale(.8)"/></g>
1111</svg>
crates/canvas/assets/backgrounds/pushpins-corner.svg+1-1
......@@ -1,6 +1,6 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 177 123" preserveAspectRatio="none">
22<defs>
3<radialGradient id="reach" cx="40" cy="20" r="1" gradientTransform="translate(40 20) scale(180 120) translate(-40 -20)" gradientUnits="userSpaceOnUse"><stop offset="0.2" stop-color="white"/><stop offset="1" stop-color="white" stop-opacity="0"/></radialGradient><mask id="glow"><rect width="177" height="123" fill="url(#reach)"/></mask>
3<radialGradient id="reach" cx="40" cy="20" r="1" gradientTransform="translate(40 20) scale(125 90) translate(-40 -20)" gradientUnits="userSpaceOnUse"><stop offset="0.2" stop-color="white"/><stop offset="1" stop-color="white" stop-opacity="0"/></radialGradient><mask id="glow"><rect width="177" height="123" fill="url(#reach)"/></mask>
44<linearGradient id="navy" x2="1"><stop offset="0" stop-color="#1f2d4a"/><stop offset=".45" stop-color="#6d7f9f"/><stop offset="1" stop-color="#1f2d4a"/></linearGradient>
55<g id="pin">
66<path d="M-11-30C-11-36 11-36 11-30L8-12C16-8 18 0 15 4H-15C-18 0-16-8-8-12Z"/>
crates/canvas/assets/fonts/Arimo-Italic.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Arimo-Italic.ttf differ
crates/canvas/assets/fonts/Arimo-OFL.txt created+93
......@@ -0,0 +1,93 @@
1Copyright 2026 The Arimo Project Authors (https://github.com/googlefonts/arimo)
2
3This Font Software is licensed under the SIL Open Font License, Version 1.1.
4This license is copied below, and is also available with a FAQ at:
5https://openfontlicense.org
6
7
8-----------------------------------------------------------
9SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10-----------------------------------------------------------
11
12PREAMBLE
13The goals of the Open Font License (OFL) are to stimulate worldwide
14development of collaborative font projects, to support the font creation
15efforts of academic and linguistic communities, and to provide a free and
16open framework in which fonts may be shared and improved in partnership
17with others.
18
19The OFL allows the licensed fonts to be used, studied, modified and
20redistributed freely as long as they are not sold by themselves. The
21fonts, including any derivative works, can be bundled, embedded,
22redistributed and/or sold with any software provided that any reserved
23names are not used by derivative works. The fonts and derivatives,
24however, cannot be released under any other type of license. The
25requirement for fonts to remain under this license does not apply
26to any document created using the fonts or their derivatives.
27
28DEFINITIONS
29"Font Software" refers to the set of files released by the Copyright
30Holder(s) under this license and clearly marked as such. This may
31include source files, build scripts and documentation.
32
33"Reserved Font Name" refers to any names specified as such after the
34copyright statement(s).
35
36"Original Version" refers to the collection of Font Software components as
37distributed by the Copyright Holder(s).
38
39"Modified Version" refers to any derivative made by adding to, deleting,
40or substituting -- in part or in whole -- any of the components of the
41Original Version, by changing formats or by porting the Font Software to a
42new environment.
43
44"Author" refers to any designer, engineer, programmer, technical
45writer or other person who contributed to the Font Software.
46
47PERMISSION & CONDITIONS
48Permission is hereby granted, free of charge, to any person obtaining
49a copy of the Font Software, to use, study, copy, merge, embed, modify,
50redistribute, and sell modified and unmodified copies of the Font
51Software, subject to the following conditions:
52
531) Neither the Font Software nor any of its individual components,
54in Original or Modified Versions, may be sold by itself.
55
562) Original or Modified Versions of the Font Software may be bundled,
57redistributed and/or sold with any software, provided that each copy
58contains the above copyright notice and this license. These can be
59included either as stand-alone text files, human-readable headers or
60in the appropriate machine-readable metadata fields within text or
61binary files as long as those fields can be easily viewed by the user.
62
633) No Modified Version of the Font Software may use the Reserved Font
64Name(s) unless explicit written permission is granted by the corresponding
65Copyright Holder. This restriction only applies to the primary font name as
66presented to the users.
67
684) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69Software shall not be used to promote, endorse or advertise any
70Modified Version, except to acknowledge the contribution(s) of the
71Copyright Holder(s) and the Author(s) or with their explicit written
72permission.
73
745) The Font Software, modified or unmodified, in part or in whole,
75must be distributed entirely under this license, and must not be
76distributed under any other license. The requirement for fonts to
77remain under this license does not apply to any document created
78using the Font Software.
79
80TERMINATION
81This license becomes null and void if any of the above conditions are
82not met.
83
84DISCLAIMER
85THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93OTHER DEALINGS IN THE FONT SOFTWARE.
crates/canvas/assets/fonts/Arimo.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Arimo.ttf differ
crates/canvas/assets/fonts/Carlito-Bold.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Carlito-Bold.ttf differ
crates/canvas/assets/fonts/Carlito-BoldItalic.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Carlito-BoldItalic.ttf differ
crates/canvas/assets/fonts/Carlito-Italic.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Carlito-Italic.ttf differ
crates/canvas/assets/fonts/Carlito-OFL.txt created+94
......@@ -0,0 +1,94 @@
1Copyright 2013 The Carlito Project Authors (https://github.com/googlefonts/carlito), with Reserved Font Name "Carlito"
2
3
4This Font Software is licensed under the SIL Open Font License, Version 1.1.
5This license is copied below, and is also available with a FAQ at:
6https://scripts.sil.org/OFL
7
8
9-----------------------------------------------------------
10SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
11-----------------------------------------------------------
12
13PREAMBLE
14The goals of the Open Font License (OFL) are to stimulate worldwide
15development of collaborative font projects, to support the font creation
16efforts of academic and linguistic communities, and to provide a free and
17open framework in which fonts may be shared and improved in partnership
18with others.
19
20The OFL allows the licensed fonts to be used, studied, modified and
21redistributed freely as long as they are not sold by themselves. The
22fonts, including any derivative works, can be bundled, embedded,
23redistributed and/or sold with any software provided that any reserved
24names are not used by derivative works. The fonts and derivatives,
25however, cannot be released under any other type of license. The
26requirement for fonts to remain under this license does not apply
27to any document created using the fonts or their derivatives.
28
29DEFINITIONS
30"Font Software" refers to the set of files released by the Copyright
31Holder(s) under this license and clearly marked as such. This may
32include source files, build scripts and documentation.
33
34"Reserved Font Name" refers to any names specified as such after the
35copyright statement(s).
36
37"Original Version" refers to the collection of Font Software components as
38distributed by the Copyright Holder(s).
39
40"Modified Version" refers to any derivative made by adding to, deleting,
41or substituting -- in part or in whole -- any of the components of the
42Original Version, by changing formats or by porting the Font Software to a
43new environment.
44
45"Author" refers to any designer, engineer, programmer, technical
46writer or other person who contributed to the Font Software.
47
48PERMISSION & CONDITIONS
49Permission is hereby granted, free of charge, to any person obtaining
50a copy of the Font Software, to use, study, copy, merge, embed, modify,
51redistribute, and sell modified and unmodified copies of the Font
52Software, subject to the following conditions:
53
541) Neither the Font Software nor any of its individual components,
55in Original or Modified Versions, may be sold by itself.
56
572) Original or Modified Versions of the Font Software may be bundled,
58redistributed and/or sold with any software, provided that each copy
59contains the above copyright notice and this license. These can be
60included either as stand-alone text files, human-readable headers or
61in the appropriate machine-readable metadata fields within text or
62binary files as long as those fields can be easily viewed by the user.
63
643) No Modified Version of the Font Software may use the Reserved Font
65Name(s) unless explicit written permission is granted by the corresponding
66Copyright Holder. This restriction only applies to the primary font name as
67presented to the users.
68
694) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
70Software shall not be used to promote, endorse or advertise any
71Modified Version, except to acknowledge the contribution(s) of the
72Copyright Holder(s) and the Author(s) or with their explicit written
73permission.
74
755) The Font Software, modified or unmodified, in part or in whole,
76must be distributed entirely under this license, and must not be
77distributed under any other license. The requirement for fonts to
78remain under this license does not apply to any document created
79using the Font Software.
80
81TERMINATION
82This license becomes null and void if any of the above conditions are
83not met.
84
85DISCLAIMER
86THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
87EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
88MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
89OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
90COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
91INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
92DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
93FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
94OTHER DEALINGS IN THE FONT SOFTWARE.
crates/canvas/assets/fonts/Carlito-Regular.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Carlito-Regular.ttf differ
crates/canvas/assets/fonts/Cousine-Bold.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Cousine-Bold.ttf differ
crates/canvas/assets/fonts/Cousine-BoldItalic.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Cousine-BoldItalic.ttf differ
crates/canvas/assets/fonts/Cousine-Italic.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Cousine-Italic.ttf differ
crates/canvas/assets/fonts/Cousine-OFL.txt created+93
......@@ -0,0 +1,93 @@
1Copyright 2026 The Cousine Project Authors (https://github.com/googlefonts/cousine)
2
3This Font Software is licensed under the SIL Open Font License, Version 1.1.
4This license is copied below, and is also available with a FAQ at:
5https://openfontlicense.org
6
7
8-----------------------------------------------------------
9SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10-----------------------------------------------------------
11
12PREAMBLE
13The goals of the Open Font License (OFL) are to stimulate worldwide
14development of collaborative font projects, to support the font creation
15efforts of academic and linguistic communities, and to provide a free and
16open framework in which fonts may be shared and improved in partnership
17with others.
18
19The OFL allows the licensed fonts to be used, studied, modified and
20redistributed freely as long as they are not sold by themselves. The
21fonts, including any derivative works, can be bundled, embedded,
22redistributed and/or sold with any software provided that any reserved
23names are not used by derivative works. The fonts and derivatives,
24however, cannot be released under any other type of license. The
25requirement for fonts to remain under this license does not apply
26to any document created using the fonts or their derivatives.
27
28DEFINITIONS
29"Font Software" refers to the set of files released by the Copyright
30Holder(s) under this license and clearly marked as such. This may
31include source files, build scripts and documentation.
32
33"Reserved Font Name" refers to any names specified as such after the
34copyright statement(s).
35
36"Original Version" refers to the collection of Font Software components as
37distributed by the Copyright Holder(s).
38
39"Modified Version" refers to any derivative made by adding to, deleting,
40or substituting -- in part or in whole -- any of the components of the
41Original Version, by changing formats or by porting the Font Software to a
42new environment.
43
44"Author" refers to any designer, engineer, programmer, technical
45writer or other person who contributed to the Font Software.
46
47PERMISSION & CONDITIONS
48Permission is hereby granted, free of charge, to any person obtaining
49a copy of the Font Software, to use, study, copy, merge, embed, modify,
50redistribute, and sell modified and unmodified copies of the Font
51Software, subject to the following conditions:
52
531) Neither the Font Software nor any of its individual components,
54in Original or Modified Versions, may be sold by itself.
55
562) Original or Modified Versions of the Font Software may be bundled,
57redistributed and/or sold with any software, provided that each copy
58contains the above copyright notice and this license. These can be
59included either as stand-alone text files, human-readable headers or
60in the appropriate machine-readable metadata fields within text or
61binary files as long as those fields can be easily viewed by the user.
62
633) No Modified Version of the Font Software may use the Reserved Font
64Name(s) unless explicit written permission is granted by the corresponding
65Copyright Holder. This restriction only applies to the primary font name as
66presented to the users.
67
684) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69Software shall not be used to promote, endorse or advertise any
70Modified Version, except to acknowledge the contribution(s) of the
71Copyright Holder(s) and the Author(s) or with their explicit written
72permission.
73
745) The Font Software, modified or unmodified, in part or in whole,
75must be distributed entirely under this license, and must not be
76distributed under any other license. The requirement for fonts to
77remain under this license does not apply to any document created
78using the Font Software.
79
80TERMINATION
81This license becomes null and void if any of the above conditions are
82not met.
83
84DISCLAIMER
85THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
crates/canvas/assets/fonts/Cousine-Regular.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Cousine-Regular.ttf differ
crates/canvas/assets/fonts/Tinos-Bold.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Tinos-Bold.ttf differ
crates/canvas/assets/fonts/Tinos-BoldItalic.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Tinos-BoldItalic.ttf differ
crates/canvas/assets/fonts/Tinos-Italic.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Tinos-Italic.ttf differ
crates/canvas/assets/fonts/Tinos-OFL.txt created+93
......@@ -0,0 +1,93 @@
1Copyright 2026 The Tinos Project Authors (https://github.com/googlefonts/tinos)
2
3This Font Software is licensed under the SIL Open Font License, Version 1.1.
4This license is copied below, and is also available with a FAQ at:
5https://openfontlicense.org
6
7
8-----------------------------------------------------------
9SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10-----------------------------------------------------------
11
12PREAMBLE
13The goals of the Open Font License (OFL) are to stimulate worldwide
14development of collaborative font projects, to support the font creation
15efforts of academic and linguistic communities, and to provide a free and
16open framework in which fonts may be shared and improved in partnership
17with others.
18
19The OFL allows the licensed fonts to be used, studied, modified and
20redistributed freely as long as they are not sold by themselves. The
21fonts, including any derivative works, can be bundled, embedded,
22redistributed and/or sold with any software provided that any reserved
23names are not used by derivative works. The fonts and derivatives,
24however, cannot be released under any other type of license. The
25requirement for fonts to remain under this license does not apply
26to any document created using the fonts or their derivatives.
27
28DEFINITIONS
29"Font Software" refers to the set of files released by the Copyright
30Holder(s) under this license and clearly marked as such. This may
31include source files, build scripts and documentation.
32
33"Reserved Font Name" refers to any names specified as such after the
34copyright statement(s).
35
36"Original Version" refers to the collection of Font Software components as
37distributed by the Copyright Holder(s).
38
39"Modified Version" refers to any derivative made by adding to, deleting,
40or substituting -- in part or in whole -- any of the components of the
41Original Version, by changing formats or by porting the Font Software to a
42new environment.
43
44"Author" refers to any designer, engineer, programmer, technical
45writer or other person who contributed to the Font Software.
46
47PERMISSION & CONDITIONS
48Permission is hereby granted, free of charge, to any person obtaining
49a copy of the Font Software, to use, study, copy, merge, embed, modify,
50redistribute, and sell modified and unmodified copies of the Font
51Software, subject to the following conditions:
52
531) Neither the Font Software nor any of its individual components,
54in Original or Modified Versions, may be sold by itself.
55
562) Original or Modified Versions of the Font Software may be bundled,
57redistributed and/or sold with any software, provided that each copy
58contains the above copyright notice and this license. These can be
59included either as stand-alone text files, human-readable headers or
60in the appropriate machine-readable metadata fields within text or
61binary files as long as those fields can be easily viewed by the user.
62
633) No Modified Version of the Font Software may use the Reserved Font
64Name(s) unless explicit written permission is granted by the corresponding
65Copyright Holder. This restriction only applies to the primary font name as
66presented to the users.
67
684) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69Software shall not be used to promote, endorse or advertise any
70Modified Version, except to acknowledge the contribution(s) of the
71Copyright Holder(s) and the Author(s) or with their explicit written
72permission.
73
745) The Font Software, modified or unmodified, in part or in whole,
75must be distributed entirely under this license, and must not be
76distributed under any other license. The requirement for fonts to
77remain under this license does not apply to any document created
78using the Font Software.
79
80TERMINATION
81This license becomes null and void if any of the above conditions are
82not met.
83
84DISCLAIMER
85THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
crates/canvas/assets/fonts/Tinos-Regular.ttf created
Binary files /dev/null and b/crates/canvas/assets/fonts/Tinos-Regular.ttf differ
crates/canvas/src/document.rs+20-14
......@@ -250,7 +250,12 @@ fn divides_link(text: &Paragraph, offset: u32) -> Result<bool, EditError> {
250250/// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets
251251/// an equation or embedded object at the seam or carries one to another paragraph: their run
252252/// data belongs to the whole paragraph, and OneNote has not been seen dividing them.
253fn moves_object(first: &Paragraph, start: u32, last: &Paragraph, end: u32) -> Result<bool, EditError> {
253fn moves_object(
254 first: &Paragraph,
255 start: u32,
256 last: &Paragraph,
257 end: u32,
258) -> Result<bool, EditError> {
254259 let object = |text: &Paragraph, span: usize| {
255260 let format = &text.spans()[span].format;
256261 [format.math, format.embedded_object].contains(&Some(true))
......@@ -759,19 +764,20 @@ impl TextDocument {
759764 // The joined text lies in the upper paragraph, whose spacing and alignment it takes.
760765 let paragraph = &above.spans()[0].format;
761766 let mut start = 0;
762 text.text.append(Paragraph::from_runs(below.spans().iter().map(|span| {
763 let run = below.text()[start..span.end].to_owned();
764 start = span.end;
765 let format = Format {
766 alignment: paragraph.alignment,
767 space_before: paragraph.space_before,
768 space_after: paragraph.space_after,
769 line_spacing: paragraph.line_spacing,
770 list_spacing: paragraph.list_spacing,
771 ..span.format.inherit(&reset)
772 };
773 (run, format)
774 })))?;
767 text.text
768 .append(Paragraph::from_runs(below.spans().iter().map(|span| {
769 let run = below.text()[start..span.end].to_owned();
770 start = span.end;
771 let format = Format {
772 alignment: paragraph.alignment,
773 space_before: paragraph.space_before,
774 space_after: paragraph.space_after,
775 line_spacing: paragraph.line_spacing,
776 list_spacing: paragraph.list_spacing,
777 ..span.format.inherit(&reset)
778 };
779 (run, format)
780 })))?;
775781 }
776782 let moves = BTreeMap::from([(bottom.id, &head)]);
777783 let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?;
crates/canvas/src/editor.rs+158-20
......@@ -109,6 +109,7 @@ struct PageHeader {
109109 identity: Option<[u8; 16]>,
110110 created: Option<u64>,
111111 margin_origin: [f32; 2],
112 color: Option<u32>,
112113 areas: Vec<page::TitleArea>,
113114}
114115
......@@ -398,6 +399,59 @@ impl TextOutline {
398399 Ok(&self.shaped.paragraphs[self.visible_index(source)?])
399400 }
400401
402 /// The shown paragraphs with their source text: those a collapsed parent hides have no
403 /// layout and are left out.
404 fn shown(&self) -> impl Iterator<Item = (usize, &Paragraph)> {
405 self.layouts()
406 .filter_map(|(index, _)| Some((index, self.document.paragraph(index)?)))
407 }
408
409 /// The outline's text as platform text input sees it: the shown paragraphs joined by
410 /// `\n`, addressed in UTF-16 units by `utf16_offset` and `utf16_position`.
411 pub fn shown_text(&self) -> String {
412 self.shown()
413 .map(|(_, paragraph)| paragraph.text())
414 .collect::<Vec<_>>()
415 .join("\n")
416 }
417
418 /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the
419 /// next shown one.
420 pub fn utf16_offset(&self, position: TextPosition) -> Result<u32, EditError> {
421 let mut offset = 0;
422 for (index, paragraph) in self.shown() {
423 if index == position.paragraph {
424 return Ok(offset + position.offset);
425 }
426 if index > position.paragraph {
427 return Ok(offset);
428 }
429 offset += paragraph.utf16_offset(paragraph.text().len())? + 1;
430 }
431 Ok(offset.saturating_sub(1))
432 }
433
434 /// The source position at `offset` in `shown_text`, clamped to its end.
435 pub fn utf16_position(&self, offset: u32) -> Result<TextPosition, EditError> {
436 let mut rest = offset;
437 let mut end = None;
438 for (index, paragraph) in self.shown() {
439 let length = paragraph.utf16_offset(paragraph.text().len())?;
440 if rest <= length {
441 return Ok(TextPosition {
442 paragraph: index,
443 offset: rest,
444 });
445 }
446 rest -= length + 1;
447 end = Some(TextPosition {
448 paragraph: index,
449 offset: length,
450 });
451 }
452 end.ok_or(EditError::InvalidRange)
453 }
454
401455 fn column(&self, x: f32, current: ExGuid) -> Result<Vec<usize>, EditError> {
402456 let paragraphs = self
403457 .shaped
......@@ -711,6 +765,7 @@ impl CanvasEditor {
711765 identity: page.identity,
712766 created: page.created,
713767 margin_origin: page.margin_origin,
768 color: page.color,
714769 areas,
715770 };
716771 let mut ids = BTreeSet::new();
......@@ -972,6 +1027,7 @@ impl CanvasEditor {
9721027 .map(PageDate::timestamp)
9731028 .or(self.header.created),
9741029 margin_origin: self.header.margin_origin,
1030 color: self.header.color,
9751031 objects,
9761032 definitions,
9771033 })
......@@ -1029,16 +1085,31 @@ impl CanvasEditor {
10291085 .fold(text_bottom, f32::max)
10301086 }
10311087
1088 /// Where the caret goes on leaving `title`: the first body line under its header, on
1089 /// OneNote's 18-point grid.
1090 fn body_start_of(&self, title: &TextOutline) -> [f32; 2] {
1091 let bottom = self.header_bottom(title.id, title.bounds().y1 as f32);
1092 [
1093 title.origin()[0],
1094 ((((f64::from(bottom) - 14.4) / 18.0).ceil() + 1.0) * 18.0 + 14.4) as f32,
1095 ]
1096 }
1097
1098 /// Where the page's first body line starts, in points, under its title.
1099 pub fn body_start(&self) -> Option<[f32; 2]> {
1100 let title = self
1101 .visible_outlines()
1102 .chain(self.caret_outline())
1103 .find(|outline| outline.title)?;
1104 Some(self.body_start_of(title))
1105 }
1106
10321107 pub fn leave_title(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
10331108 let title = self.active_outline();
10341109 if !title.title {
10351110 return Ok(());
10361111 }
1037 let bottom = self.header_bottom(title.id, title.bounds().y1 as f32);
1038 let position = [
1039 title.origin()[0],
1040 ((((f64::from(bottom) - 14.4) / 18.0).ceil() + 1.0) * 18.0 + 14.4) as f32,
1041 ];
1112 let position = self.body_start_of(title);
10421113 if let Some((id, origin)) = self
10431114 .outlines()
10441115 .iter()
......@@ -1896,10 +1967,19 @@ impl CanvasEditor {
18961967 }
18971968
18981969 pub fn caret(&self, width: f32) -> Result<BoundingBox, EditError> {
1899 let focus = self.active_outline().selection.positions[1];
1970 let selection = self.active_outline().selection;
1971 self.caret_at(selection.positions[1], selection.affinities[1], width)
1972 }
1973
1974 /// The caret at `focus` in the active outline, in its coordinates.
1975 pub fn caret_at(
1976 &self,
1977 focus: TextPosition,
1978 affinity: Affinity,
1979 width: f32,
1980 ) -> Result<BoundingBox, EditError> {
19001981 let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?;
1901 let cursor =
1902 paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?;
1982 let cursor = paragraph.cursor(focus.offset, affinity)?;
19031983 let mut rect = paragraph.text.caret(cursor, width);
19041984 rect.x0 += f64::from(paragraph.origin[0]);
19051985 rect.x1 += f64::from(paragraph.origin[0]);
......@@ -2325,12 +2405,12 @@ impl CanvasEditor {
23252405 }
23262406
23272407 pub fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
2328 self.rectangles(self.active_outline().selection)
2408 self.range_rects(self.active_outline().selection)
23292409 }
23302410
23312411 pub fn marked_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
23322412 match &self.composition {
2333 Some(composition) => self.rectangles(Selection {
2413 Some(composition) => self.range_rects(Selection {
23342414 positions: [composition.range.start, composition.range.end],
23352415 affinities: [Affinity::Downstream, Affinity::Upstream],
23362416 }),
......@@ -2338,7 +2418,8 @@ impl CanvasEditor {
23382418 }
23392419 }
23402420
2341 fn rectangles(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
2421 /// Highlight rectangles of `selection` in the active outline, in its coordinates.
2422 pub fn range_rects(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
23422423 let [anchor, focus] = selection.positions;
23432424 if anchor == focus {
23442425 return Ok(Vec::new());
......@@ -2515,8 +2596,10 @@ impl CanvasEditor {
25152596 let (start, end) = (anchor.min(focus), anchor.max(focus));
25162597 let format = self.typing_format(start)?;
25172598 let document = &self.active_outline().document;
2518 let mut edit =
2519 document.replace(start..end, vec![Paragraph::new(String::new(), format.clone()); 2])?;
2599 let mut edit = document.replace(
2600 start..end,
2601 vec![Paragraph::new(String::new(), format.clone()); 2],
2602 )?;
25202603 let nodes = document.container(edit.container)?;
25212604 let next = nodes
25222605 .get(crate::document::subtree_end(nodes, edit.range.start))
......@@ -2554,10 +2637,8 @@ impl CanvasEditor {
25542637 .find(|id| style(Some(*id)) == Some("p"));
25552638 // The heading's run formatting carries over under the body style (`c9-h1-*`).
25562639 let run = format.over(&self.style_format(head.style)?);
2557 tail.text_mut().unwrap().text = Paragraph::new(
2558 String::new(),
2559 run.inherit(&self.style_format(tail.style)?),
2560 );
2640 tail.text_mut().unwrap().text =
2641 Paragraph::new(String::new(), run.inherit(&self.style_format(tail.style)?));
25612642 }
25622643 let caret = TextPosition {
25632644 paragraph: start.paragraph + 1,
......@@ -2765,6 +2846,14 @@ impl CanvasEditor {
27652846 Ok(true)
27662847 }
27672848
2849 pub fn can_undo(&self) -> bool {
2850 !self.undo.is_empty()
2851 }
2852
2853 pub fn can_redo(&self) -> bool {
2854 !self.redo.is_empty()
2855 }
2856
27682857 pub fn undo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
27692858 if self.composition.is_some() {
27702859 return self.cancel_composition(engine);
......@@ -4984,6 +5073,7 @@ mod tests {
49845073 identity: None,
49855074 created: None,
49865075 margin_origin: [36.0, 14.4],
5076 color: None,
49875077 definitions: BTreeMap::new(),
49885078 objects: vec![PageObject::Image(background), PageObject::Image(picture)],
49895079 },
......@@ -5107,6 +5197,7 @@ mod tests {
51075197 identity: None,
51085198 created: None,
51095199 margin_origin: [36.0, 14.4],
5200 color: None,
51105201 definitions: BTreeMap::new(),
51115202 objects,
51125203 },
......@@ -5164,6 +5255,7 @@ mod tests {
51645255 identity: None,
51655256 created: None,
51665257 margin_origin: [36.0, 14.4],
5258 color: None,
51675259 definitions: BTreeMap::new(),
51685260 objects: vec![PageObject::Outline(source)],
51695261 },
......@@ -5303,6 +5395,7 @@ mod tests {
53035395 identity: None,
53045396 created: None,
53055397 margin_origin: [36.0, 14.4],
5398 color: None,
53065399 definitions: BTreeMap::new(),
53075400 objects: vec![
53085401 PageObject::Outline(mixed),
......@@ -5393,6 +5486,7 @@ mod tests {
53935486 identity: None,
53945487 created: None,
53955488 margin_origin: [36.0, 14.4],
5489 color: None,
53965490 definitions: BTreeMap::new(),
53975491 objects,
53985492 },
......@@ -6900,6 +6994,50 @@ mod tests {
69006994 );
69016995 }
69026996
6997 #[test]
6998 fn platform_text_offsets_skip_collapsed_children() {
6999 let document = TextDocument::new(
7000 ["Collapsed", "Hidden", "Last"]
7001 .into_iter()
7002 .map(|s| Paragraph::new(s.into(), Format::default()))
7003 .collect(),
7004 )
7005 .unwrap();
7006 let mut nodes = document.nodes().to_vec();
7007 nodes[0].collapsed = true;
7008 nodes[1].parent = Some(nodes[0].id);
7009 nodes[1].level = 2;
7010 let outline = Outline {
7011 title: false,
7012 min_width: None,
7013 id: onestore::page::text::new_id().unwrap(),
7014 layout: onestore::document::Layout {
7015 max_width: Some(150.0),
7016 ..Default::default()
7017 },
7018 indents: Vec::new(),
7019 paragraphs: nodes,
7020 unsupported: Vec::new(),
7021 };
7022 let mut engine = TextEngine::default();
7023 let editor =
7024 CanvasEditor::from_outlines(&mut engine, vec![outline], BTreeMap::new()).unwrap();
7025 let outline = editor.active_outline();
7026 assert_eq!(outline.shown_text(), "Collapsed\nLast");
7027 let at = |paragraph, offset| TextPosition { paragraph, offset };
7028 for (position, offset) in [
7029 (at(0, 3), 3),
7030 (at(1, 2), 10),
7031 (at(2, 0), 10),
7032 (at(2, 4), 14),
7033 ] {
7034 assert_eq!(outline.utf16_offset(position).unwrap(), offset);
7035 }
7036 for (offset, position) in [(9, at(0, 9)), (10, at(2, 0)), (99, at(2, 4))] {
7037 assert_eq!(outline.utf16_position(offset).unwrap(), position);
7038 }
7039 }
7040
69037041 #[test]
69047042 fn imported_indentation_markers_and_collapsed_children_share_edit_geometry() {
69057043 let document = TextDocument::new(
......@@ -8610,7 +8748,7 @@ mod tests {
86108748 }
86118749
86128750 #[test]
8613 fn an_edited_page_writes_back_through_the_page_writer() {
8751 fn an_edited_page_writes_back_as_its_ops() {
86148752 let mut engine = TextEngine::default();
86158753 let (space, source) = corpus_page(CORPUS[2], "Split middle");
86168754 let mut editor = CanvasEditor::from_page(source.clone(), &mut engine).unwrap();
......@@ -8623,8 +8761,8 @@ mod tests {
86238761 editor.focus_outline(body).unwrap();
86248762 editor.insert(&mut engine, "Edited ").unwrap();
86258763 let page = editor.page().unwrap();
8626 let written = onestore::PreparedEdit::page(CORPUS[2], space, &page, "Author").unwrap();
8627 let reread = corpus_pages(written.as_bytes())
8764 let written = ops::saved(CORPUS[2], space, &mut editor);
8765 let reread = corpus_pages(&written)
86288766 .into_iter()
86298767 .find_map(|(candidate, page)| (candidate == space).then_some(page))
86308768 .unwrap();
crates/canvas/src/editor/evidence.rs+2-3
......@@ -784,9 +784,8 @@ fn structural_edits_on_native_pages_survive_the_page_writer() {
784784 press(&mut editor, &mut engine, *key);
785785 }
786786 let edited = settled(editor.page().unwrap());
787 let written = onestore::PreparedEdit::page(&section, space, &edited, "Author")
788 .unwrap_or_else(|error| panic!("{title}: {error}"));
789 let (_, reread) = page(written.as_bytes(), title);
787 let written = super::ops::saved(&section, space, &mut editor);
788 let (_, reread) = page(&written, title);
790789 assert_eq!(settled(reread).objects, edited.objects, "{title}");
791790 }
792791}
crates/canvas/src/editor/format.rs+2-2
......@@ -1544,8 +1544,8 @@ mod tests {
15441544 }
15451545 let state = editor.format_state().unwrap();
15461546 let edited = settled(editor.page().unwrap());
1547 let written = onestore::PreparedEdit::page(SECTION, space, &edited, "Author").unwrap();
1548 let (_, reread) = page(written.as_bytes());
1547 let written = super::ops::saved(SECTION, space, &mut editor);
1548 let (_, reread) = page(&written);
15491549 let reread = settled(reread);
15501550 assert_eq!(reread.objects, edited.objects);
15511551 let mut editor = CanvasEditor::from_page(reread, &mut engine).unwrap();
crates/canvas/src/editor/ops.rs+21
......@@ -359,6 +359,7 @@ impl CanvasEditor {
359359 identity: None,
360360 created: None,
361361 margin_origin: [0.0; 2],
362 color: None,
362363 objects,
363364 definitions: named(&self.definitions, &[&outline.paragraphs]),
364365 };
......@@ -456,6 +457,26 @@ impl CanvasEditor {
456457 }
457458}
458459
460/// `image` with the ops `editor` recorded since they were last taken applied to the page in
461/// `space` and sealed, as the application saves an edit.
462#[cfg(test)]
463pub(crate) fn saved(image: &[u8], space: ExGuid, editor: &mut CanvasEditor) -> Vec<u8> {
464 let arena = onestore::Arena::default();
465 let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap();
466 let ops = editor.take_ops().unwrap();
467 let ops = ops
468 .into_iter()
469 .map(|op| op::Op::Page { space, op })
470 .collect();
471 let edit = op::Edit {
472 at: 134_000_000_000_000_000,
473 ops,
474 };
475 section.apply("Author", &edit).unwrap();
476 section.seal().unwrap();
477 section.image()
478}
479
459480#[cfg(test)]
460481mod tests {
461482 use super::*;
crates/canvas/src/gpu/mod.rs+63
......@@ -52,6 +52,22 @@ impl Paper {
5252 ink: [0.0, 0.0, 0.0, 1.0],
5353 };
5454
55 /// A fill OneNote draws on white paper, moved onto this paper: as far from it in
56 /// OKLab lightness as from white, in the same hue and chroma, so text keeps its
57 /// contrast on it.
58 pub(crate) fn tint(&self, light: [f32; 4]) -> [f32; 4] {
59 let [lightness, a, b] = draw::oklab(light);
60 let [paper, ..] = draw::oklab(self.color);
61 let distance = 1.0 - lightness;
62 let lightness = if paper < 0.5 {
63 paper + distance
64 } else {
65 paper - distance
66 };
67 let [red, green, blue] = draw::from_oklab([lightness, a, b]);
68 [red, green, blue, light[3]]
69 }
70
5571 /// A near-neutral colour OneNote draws on white paper, moved onto this paper: its
5672 /// darkness becomes ink and its tint tints the paper.
5773 pub(crate) fn shade(&self, light: [f32; 4]) -> [f32; 4] {
......@@ -87,6 +103,11 @@ impl Glyphs for TextLayout {
87103 .or(brush.highlight.filter(|&color| color != 0).map(|_| 0))
88104 .map(colorref)
89105 },
106 run.style()
107 .brush
108 .highlight
109 .filter(|&color| color != 0)
110 .map(colorref),
90111 paint,
91112 )?;
92113 }
......@@ -150,6 +171,48 @@ mod tests {
150171 use onestore::page::text::Paragraph;
151172 use std::time::Duration;
152173
174 #[test]
175 fn runs_carry_their_highlight_as_what_lies_behind_them() {
176 let run = |highlight, color| {
177 (
178 "text".into(),
179 Format {
180 highlight,
181 color,
182 ..Format::default()
183 },
184 )
185 };
186 let text = TextEngine::default()
187 .layout(
188 &Paragraph::from_runs([
189 run(None, None),
190 run(Some(0x0000ffff), None),
191 run(Some(0x0000ffff), Some(0x000000ff)),
192 run(Some(0), None),
193 ]),
194 400.0,
195 )
196 .unwrap();
197 let mut painted = Vec::new();
198 text.runs(&mut |run| {
199 painted.push((run.color, run.backdrop));
200 Ok(())
201 })
202 .unwrap();
203 let [black, red, yellow] = [0, 0x000000ff, 0x0000ffff].map(colorref);
204 assert_eq!(
205 painted,
206 [
207 (None, None),
208 (Some(black), Some(yellow)),
209 (Some(red), Some(yellow)),
210 // A black highlight paints in the ink, as its automatic text does.
211 (None, None),
212 ]
213 );
214 }
215
153216 #[test]
154217 #[ignore = "requires a native GPU adapter"]
155218 fn tags_and_page_text_paint_through_the_renderer() {
crates/canvas/src/gpu/page.rs+93-45
......@@ -10,6 +10,7 @@ use crate::{
1010 layout::{LayoutError, TextEngine},
1111};
1212use background::Background;
13pub use background::{template_art, template_picture};
1314use draw::Primitive;
1415use onestore::page::Page;
1516use picture::Picture;
......@@ -492,11 +493,12 @@ impl PageScene {
492493 })
493494 }
494495
495 /// Bounds of noneditable content in page coordinates.
496 /// Bounds of noneditable content in page coordinates, each with whether OneNote's view
497 /// leaves room beyond it: a picture's corner is as far as the view reaches.
496498 pub fn content_bounds<'a>(
497499 &'a self,
498500 editor: &'a CanvasEditor,
499 ) -> impl Iterator<Item = [f32; 4]> + 'a {
501 ) -> impl Iterator<Item = ([f32; 4], bool)> + 'a {
500502 self.objects(Some(editor))
501503 .into_iter()
502504 .flatten()
......@@ -515,12 +517,15 @@ impl PageScene {
515517 Some(editor),
516518 )
517519 .ok()?;
518 Some([
519 origin[0],
520 origin[1],
521 origin[0] + layout.size[0],
522 origin[1] + layout.size[1],
523 ])
520 Some((
521 [
522 origin[0],
523 origin[1],
524 origin[0] + layout.size[0],
525 origin[1] + layout.size[1],
526 ],
527 true,
528 ))
524529 }
525530 Content::Date { below_title } => {
526531 let date = editor.date()?;
......@@ -534,21 +539,27 @@ impl PageScene {
534539 )
535540 .ok()?;
536541 let size = date.layout().size;
537 Some([
538 origin[0],
539 origin[1],
540 origin[0] + size[0],
541 origin[1] + size[1],
542 ])
542 Some((
543 [
544 origin[0],
545 origin[1],
546 origin[0] + size[0],
547 origin[1] + size[1],
548 ],
549 true,
550 ))
543551 }
544 Content::Image(source) => Some([
545 source.layout.x.unwrap_or(0.0),
546 source.layout.y.unwrap_or(0.0),
547 source.layout.x.unwrap_or(0.0) + source.layout.max_width?,
548 source.layout.y.unwrap_or(0.0) + source.layout.max_height?,
549 ]),
550 Content::Ink(ink) => crate::editor::page::ink_bounds(ink),
551 Content::ReadOnly(object) => Some(object.rect()),
552 Content::Image(source) => Some((
553 [
554 source.layout.x.unwrap_or(0.0),
555 source.layout.y.unwrap_or(0.0),
556 source.layout.x.unwrap_or(0.0) + source.layout.max_width?,
557 source.layout.y.unwrap_or(0.0) + source.layout.max_height?,
558 ],
559 false,
560 )),
561 Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)),
562 Content::ReadOnly(object) => Some((object.rect(), true)),
552563 Content::Editable(_) => None,
553564 })
554565 }
......@@ -787,7 +798,12 @@ impl PageScene {
787798 };
788799 outline.append_table_primitives(primitives, object_origin, paper);
789800 let everything = [f32::NEG_INFINITY, f32::INFINITY];
790 outline.append_background_primitives(primitives, object_origin, everything, paper);
801 outline.append_background_primitives(
802 primitives,
803 object_origin,
804 everything,
805 paper,
806 );
791807 self.append_outline_objects(outline, object_origin, None, paper, primitives);
792808 for (index, paragraph) in outline.visible(everything) {
793809 outline.append_paragraph_primitives(
......@@ -828,6 +844,7 @@ impl crate::outline::OutlineLayout {
828844
829845 /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black
830846 /// highlight paints in the paper's ink, censoring the automatic text on it in any theme.
847 /// A paragraph's band spans its outline, or its cell, tinted for the paper.
831848 pub fn append_background_primitives(
832849 &self,
833850 primitives: &mut Vec<Primitive<'_>>,
......@@ -836,6 +853,27 @@ impl crate::outline::OutlineLayout {
836853 paper: Paper,
837854 ) {
838855 for (index, paragraph) in self.visible(rows) {
856 if let Some(band) = paragraph.band {
857 let mut rect = parley::BoundingBox::new(
858 0.0,
859 f64::from(paragraph.origin[1]),
860 f64::from(self.size[0]),
861 f64::from(paragraph.origin[1] + paragraph.text.height()),
862 );
863 let cell = self.paragraph_cell(index);
864 if let Some(clipped) = cell.map_or(Some(rect), |cell| cell.clip(rect)) {
865 rect = clipped;
866 primitives.push(Primitive::Rect {
867 rect: [
868 rect.x0 as f32 + origin[0],
869 rect.y0 as f32 + origin[1],
870 rect.x1 as f32 + origin[0],
871 rect.y1 as f32 + origin[1],
872 ],
873 color: paper.tint(colorref(band)),
874 });
875 }
876 }
839877 for (mut rect, color) in paragraph.text.backgrounds() {
840878 rect.x0 += f64::from(paragraph.origin[0]);
841879 rect.x1 += f64::from(paragraph.origin[0]);
......@@ -1046,6 +1084,7 @@ mod tests {
10461084 created: Some(1),
10471085 title: "Header".into(),
10481086 margin_origin: [36.0, 14.4],
1087 color: None,
10491088 definitions: BTreeMap::new(),
10501089 objects: vec![PageObject::Title(Title {
10511090 date: Some(fields[1].id),
......@@ -1062,14 +1101,20 @@ mod tests {
10621101 assert!(editor.active_outline().title);
10631102 assert_eq!(editor.active_outline().origin(), [46.0, 34.4]);
10641103 assert_eq!(editor.active_outline().layout().max_width, None);
1065 let before = scene.content_bounds(&editor).collect::<Vec<_>>();
1104 let bounds = |editor: &CanvasEditor| {
1105 scene
1106 .content_bounds(editor)
1107 .map(|(rect, _)| rect)
1108 .collect::<Vec<_>>()
1109 };
1110 let before = bounds(&editor);
10661111 assert_eq!(before[0][0], 48.0);
10671112 assert!((before[0][1] - 62.6).abs() < 0.00001);
10681113 let text = "A long title with multiple wrapped lines ".repeat(10);
10691114 editor.select_all().unwrap();
10701115 let end = text.encode_utf16().count() as u32;
10711116 editor.compose(&mut engine, text.clone(), end..end).unwrap();
1072 let wrapped = scene.content_bounds(&editor).collect::<Vec<_>>();
1117 let wrapped = bounds(&editor);
10731118 assert!(wrapped[0][1] > before[0][1] + 20.0);
10741119 assert_eq!(
10751120 wrapped[0][1],
......@@ -1077,9 +1122,9 @@ mod tests {
10771122 );
10781123 editor.cancel_composition(&mut engine).unwrap();
10791124 assert_eq!(editor.active_outline().document().nodes(), source);
1080 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), before);
1125 assert_eq!(bounds(&editor), before);
10811126 editor.commit_text(&mut engine, text).unwrap();
1082 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), wrapped);
1127 assert_eq!(bounds(&editor), wrapped);
10831128 assert_eq!(editor.active_outline().id, id);
10841129 assert_eq!(
10851130 editor.active_outline().document().nodes()[0].id,
......@@ -1087,14 +1132,14 @@ mod tests {
10871132 );
10881133 editor.undo(&mut engine).unwrap();
10891134 assert_eq!(editor.active_outline().document().nodes(), source);
1090 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), before);
1135 assert_eq!(bounds(&editor), before);
10911136 editor.redo(&mut engine).unwrap();
10921137 editor.select_all().unwrap();
10931138 editor.insert(&mut engine, "").unwrap();
10941139 assert_eq!(editor.outlines().len(), 1);
10951140 assert!(editor.caret_outline().is_none());
10961141 assert_eq!(editor.active_outline().id, id);
1097 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), before);
1142 assert_eq!(bounds(&editor), before);
10981143 assert!(editor.move_outline(id, [0.0; 2]).is_err());
10991144 assert!(editor.resize(&mut engine, 90.0).is_err());
11001145 editor.undo(&mut engine).unwrap();
......@@ -1111,7 +1156,7 @@ mod tests {
11111156 ["A date label that wraps ".repeat(30), String::new()],
11121157 )
11131158 .unwrap();
1114 let changed = scene.content_bounds(&editor).collect::<Vec<_>>();
1159 let changed = bounds(&editor);
11151160 assert_eq!(changed[0][1], wrapped[0][1]);
11161161 assert!(changed[0][3] > wrapped[0][3]);
11171162 let (field, rect) = scene.date_fields(&editor).next().unwrap();
......@@ -1120,12 +1165,12 @@ mod tests {
11201165 assert_eq!(rect[3], changed[0][3]);
11211166 editor.undo(&mut engine).unwrap();
11221167 assert_eq!(editor.date().unwrap().timestamp(), 1);
1123 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), wrapped);
1168 assert_eq!(bounds(&editor), wrapped);
11241169 assert_eq!(editor.active_outline().document(), &title);
11251170 }
11261171
11271172 #[test]
1128 #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"]
1173 #[ignore = "requires the native baseline section via CANVAS_TEST_SECTION"]
11291174 fn native_title_metrics_and_exit() {
11301175 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
11311176 let store = onestore::Store::parse(&bytes).unwrap();
......@@ -1133,11 +1178,6 @@ mod tests {
11331178 let document = onestore::document::Document::parse(&index).unwrap();
11341179 let page = Page::from_document(&document, "Baseline anchors").unwrap();
11351180 let mut engine = TextEngine::default();
1136 engine
1137 .register_substitute(parley::fontique::Blob::new(Arc::new(
1138 std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(),
1139 )))
1140 .unwrap();
11411181 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
11421182 let title_id = editor
11431183 .outlines()
......@@ -1288,7 +1328,7 @@ mod tests {
12881328 }
12891329
12901330 #[test]
1291 #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"]
1331 #[ignore = "requires the native baseline section via CANVAS_TEST_SECTION"]
12921332 fn native_title_flow() {
12931333 use crate::document::TextDocument;
12941334 use onestore::page::Image;
......@@ -1297,11 +1337,6 @@ mod tests {
12971337 let index = onestore::RevisionIndex::parse(&store).unwrap();
12981338 let document = onestore::document::Document::parse(&index).unwrap();
12991339 let mut engine = TextEngine::default();
1300 engine
1301 .register_substitute(parley::fontique::Blob::new(Arc::new(
1302 std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(),
1303 )))
1304 .unwrap();
13051340 let long = "A page title that is intentionally long enough to wrap across several lines at this window size while retaining its own date and time below the text";
13061341 for (font_size, text, x, y, expected) in [
13071342 (17.0, long, 120.0, 90.0, 116.18992),
......@@ -1448,7 +1483,11 @@ mod tests {
14481483 assert_eq!([old[0], old[2]], [new[0], new[2]]);
14491484 assert!((old[1] + delta - new[1]).abs() < 0.002);
14501485 assert!((old[3] + delta - new[3]).abs() < 0.002);
1451 assert!(scene.content_bounds(&editor).any(|bounds| bounds == *new));
1486 assert!(
1487 scene
1488 .content_bounds(&editor)
1489 .any(|(bounds, _)| bounds == *new)
1490 );
14521491 }
14531492 editor.undo(&mut engine).unwrap();
14541493 assert_eq!(images(&editor), before);
......@@ -1487,6 +1526,7 @@ mod tests {
14871526 created: None,
14881527 title: "Header".into(),
14891528 margin_origin: [36.0, 14.4],
1529 color: None,
14901530 definitions: BTreeMap::new(),
14911531 objects: vec![PageObject::Title(Title {
14921532 date: None,
......@@ -1561,6 +1601,7 @@ mod tests {
15611601 created: None,
15621602 title: String::new(),
15631603 margin_origin: [0.0; 2],
1604 color: None,
15641605 definitions: BTreeMap::new(),
15651606 objects,
15661607 };
......@@ -1640,6 +1681,7 @@ mod tests {
16401681 created: None,
16411682 title: String::new(),
16421683 margin_origin: [36.0, 14.0],
1684 color: None,
16431685 objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))],
16441686 definitions: BTreeMap::from([(
16451687 definition,
......@@ -1711,6 +1753,7 @@ mod tests {
17111753 created: None,
17121754 title: String::new(),
17131755 margin_origin: [0.0; 2],
1756 color: None,
17141757 definitions: BTreeMap::new(),
17151758 objects: vec![
17161759 PageObject::Outline(Outline {
......@@ -1890,6 +1933,7 @@ mod tests {
18901933 created: None,
18911934 title: String::new(),
18921935 margin_origin: [0.0; 2],
1936 color: None,
18931937 definitions: BTreeMap::new(),
18941938 objects: vec![
18951939 outline(2.0, "first"),
......@@ -2063,6 +2107,7 @@ mod tests {
20632107 created: None,
20642108 title: String::new(),
20652109 margin_origin: [0.0; 2],
2110 color: None,
20662111 definitions: BTreeMap::new(),
20672112 objects: vec![PageObject::Ink(ink.clone())],
20682113 };
......@@ -2071,7 +2116,7 @@ mod tests {
20712116 assert!(
20722117 scene
20732118 .content_bounds(&editor)
2074 .any(|bounds| bounds == [9.0, 19.0, 41.0, 61.0])
2119 .any(|(bounds, _)| bounds == [9.0, 19.0, 41.0, 61.0])
20752120 );
20762121 let mut primitives = Vec::new();
20772122 scene
......@@ -2122,6 +2167,7 @@ mod tests {
21222167 created: None,
21232168 title: String::new(),
21242169 margin_origin: [0.0; 2],
2170 color: None,
21252171 definitions: BTreeMap::new(),
21262172 objects: vec![PageObject::Image(Image {
21272173 size: None,
......@@ -2207,6 +2253,7 @@ mod tests {
22072253 created: None,
22082254 title: String::new(),
22092255 margin_origin: [0.0; 2],
2256 color: None,
22102257 definitions: BTreeMap::new(),
22112258 objects: vec![PageObject::Image(background), PageObject::Image(picture)],
22122259 };
......@@ -2274,6 +2321,7 @@ mod tests {
22742321 created: None,
22752322 title: String::new(),
22762323 margin_origin: [0.0; 2],
2324 color: None,
22772325 definitions: BTreeMap::new(),
22782326 objects: pictures.iter().cloned().map(PageObject::Image).collect(),
22792327 };
crates/canvas/src/gpu/page/background.rs+577-304
......@@ -1,5 +1,5 @@
1//! OneNote 2010's page template art, recognised by the SHA-256 of the embedded picture and
2//! painted from vector recreations that suit the paper.
1//! OneNote 2010's page template art, recognised by the SHA-256 of OneNote's picture or the
2//! marker in Snowbound's, and painted from vector recreations that suit the paper.
33
44use super::{SETTLE, Slot, density, queue};
55use crate::gpu::Paper;
......@@ -11,303 +11,393 @@ use std::{
1111 time::{Duration, Instant},
1212};
1313
14/// Each template picture's SHA-256 and its recreation, drawn for white paper in
15/// `#rrggbb` colours over a view box stretched to the picture's rectangle.
16const TEMPLATES: &[(&str, &str)] = &[
17 (
18 "a7fa7f092eff43030a56342c39a765f8d5cc48c7db815ddfc8c1e5ec40117fae",
19 include_str!("../../../assets/backgrounds/bamboo.svg"),
20 ),
21 (
22 "74c131777e7c437fd654427417097bc01b0813ba8e1e50e4b937bd50a1bebcdb",
23 include_str!("../../../assets/backgrounds/binoculars-corner.svg"),
24 ),
25 (
26 "9c552717e8d5079bbb226948641ff13532df3d7be434c6ce545f1692fa57d45a",
27 include_str!("../../../assets/backgrounds/black-and-green-title.svg"),
28 ),
29 (
30 "3c68c7ee798e62a4a99c740153f3980d7df029605c843410942c7f85e794823b",
31 include_str!("../../../assets/backgrounds/blue-bubbles-corner-margin.svg"),
32 ),
33 (
34 "d0a3a1c3cd63c4023fe5716cbe2c211307d0e277e444d9ef76c7fc097a845fd4",
35 include_str!("../../../assets/backgrounds/blue-bubbles-corner-top.svg"),
36 ),
37 (
38 "7966f3d8a2d61ecb49a35e163781858e052c0b122a18a1238afe27b57e2850e8",
39 include_str!("../../../assets/backgrounds/blue-clouds.svg"),
40 ),
41 (
42 "139bb0e79f89c3ddef79b1716a5fbab4c07df5785fb3cdf6b4eeddbf6c078452",
43 include_str!("../../../assets/backgrounds/blue-dots.svg"),
44 ),
45 (
14/// One of OneNote 2010's page templates: its art recreated for white paper in `#rrggbb`
15/// colours over a view box the size in pixels of the picture OneNote stores, that picture's
16/// format, and the SHA-256 of each picture OneNote has shipped for it.
17struct Template {
18 name: &'static str,
19 svg: &'static str,
20 jpeg: bool,
21 hashes: &'static [&'static str],
22}
23
24macro_rules! template {
25 ($name:literal, $format:ident, $($hash:literal),+ $(,)?) => {
26 Template {
27 name: $name,
28 svg: include_str!(concat!("../../../assets/backgrounds/", $name, ".svg")),
29 jpeg: template!(@$format),
30 hashes: &[$($hash),+],
31 }
32 };
33 (@jpeg) => { true };
34 (@png) => { false };
35}
36
37const TEMPLATES: &[Template] = &[
38 template!(
39 "bamboo",
40 jpeg,
41 "a7fa7f092eff43030a56342c39a765f8d5cc48c7db815ddfc8c1e5ec40117fae"
42 ),
43 template!(
44 "binoculars-corner",
45 jpeg,
46 "74c131777e7c437fd654427417097bc01b0813ba8e1e50e4b937bd50a1bebcdb"
47 ),
48 template!(
49 "black-and-green-title",
50 jpeg,
51 "9c552717e8d5079bbb226948641ff13532df3d7be434c6ce545f1692fa57d45a"
52 ),
53 template!(
54 "blue-bubbles-corner-margin",
55 jpeg,
56 "3c68c7ee798e62a4a99c740153f3980d7df029605c843410942c7f85e794823b"
57 ),
58 template!(
59 "blue-bubbles-corner-top",
60 jpeg,
61 "d0a3a1c3cd63c4023fe5716cbe2c211307d0e277e444d9ef76c7fc097a845fd4"
62 ),
63 template!(
64 "blue-clouds",
65 jpeg,
66 "7966f3d8a2d61ecb49a35e163781858e052c0b122a18a1238afe27b57e2850e8"
67 ),
68 template!(
69 "blue-dots",
70 jpeg,
71 "139bb0e79f89c3ddef79b1716a5fbab4c07df5785fb3cdf6b4eeddbf6c078452"
72 ),
73 template!(
74 "blue-flow",
75 png,
4676 "473c90248fa33f8e49b2daf38673d87a9405481eae6a2087cc5384b695b6a4c0",
47 include_str!("../../../assets/backgrounds/blue-flow.svg"),
48 ),
49 (
5077 "51e05999a1c9f17df28cb474e57dd8e64bdab824874a532c20a23766a01f8967",
51 include_str!("../../../assets/backgrounds/blue-flow.svg"),
52 ),
53 (
54 "83245f217deae4a4143b565e13c045dbb32a9063e8c6b2e43bb15cd76c5f9219",
55 include_str!("../../../assets/backgrounds/blue-mist-margin.svg"),
56 ),
57 (
58 "474f9a8c25d5e21192315397ea995b1e11e2c1608157c6e0277688091bfd136a",
59 include_str!("../../../assets/backgrounds/blue-stripe-title.svg"),
60 ),
61 (
62 "06e09de80c3f32254da4fe6b2cbad7c05ef144dd54b8c65745e195bbf7317a2e",
63 include_str!("../../../assets/backgrounds/blue-stripes.svg"),
64 ),
65 (
66 "10c833e47be1c8496f949a6b059c2d79212a4dd66bde62116ea337fa4fe0b654",
67 include_str!("../../../assets/backgrounds/blue-swirls.svg"),
68 ),
69 (
70 "646586cb71742a2369a529876b41af6a472c35cc508d1ae5d8395d55784814f2",
71 include_str!("../../../assets/backgrounds/blue-wave-title.svg"),
72 ),
73 (
74 "fbb4573e3bee1b337077691bebae15d6fac52432405d31396d526d7694a8283d",
75 include_str!("../../../assets/backgrounds/blue-waves.svg"),
76 ),
77 (
78 "1591fd26e7fff5be97431d0ed3d0ade5cfc5fa74e3d7ec282fd242160ce68c1f",
79 include_str!("../../../assets/backgrounds/books.svg"),
80 ),
81 (
82 "bdd997068701ed3a00a224eb694b003c01ac69b857fe7b4147d6c34875b1632b",
83 include_str!("../../../assets/backgrounds/bouquet.svg"),
84 ),
85 (
86 "e64fdcd0b108737d8b8f7b677029f924031d6bbaa50585d9c3def7c7e92ecaf2",
87 include_str!("../../../assets/backgrounds/boxes.svg"),
88 ),
89 (
90 "639eadad468b6b32b9124b1f4395a8da3027ff7258d102173ba070ae2ed541ae",
91 include_str!("../../../assets/backgrounds/boxes-shadow.svg"),
92 ),
93 (
94 "5eebe803e434a845d19bc600df3c75e98bb69bd0de473ceec410d1b3a9154e28",
95 include_str!("../../../assets/backgrounds/bubbles.svg"),
96 ),
97 (
98 "509a6945facfb3ddc7be6ee8b82797ad0c72db5755486ee878125a959cc09b59",
99 include_str!("../../../assets/backgrounds/bubbles-margin-margin.svg"),
100 ),
101 (
102 "e9746b4e9ae9ce7b3b0068779db3e113e2dfc9880f25373d745d0e700e69a906",
103 include_str!("../../../assets/backgrounds/bubbles-margin-top.svg"),
104 ),
105 (
106 "505f731cb7707efab2eb06685b392dc7e59265a40b55aae43e5dc15c0a86cba4",
107 include_str!("../../../assets/backgrounds/buildings.svg"),
108 ),
109 (
110 "edad0f03e6ff99fef9ef8e8b834ce74f26cd23c5f8c067f5cee66f304181e64d",
111 include_str!("../../../assets/backgrounds/chain-title.svg"),
112 ),
113 (
114 "e1cb1a0ec9be62d5445c73aa84df38234002a7e164ee830c9df24997802cb5d2",
115 include_str!("../../../assets/backgrounds/circles.svg"),
116 ),
117 (
118 "adb1ebbe18d6cd8ff08aa9bf5c83cdb83bf9aa179698e34e93dbcdde12f04d32",
119 include_str!("../../../assets/backgrounds/clock.svg"),
120 ),
121 (
122 "70475431cca3c91a4efa3b8f04864371d2d3a45696674a1a0562fe9cd8db287c",
123 include_str!("../../../assets/backgrounds/columns.svg"),
124 ),
125 (
126 "9301db6d2d87282fcee450189aeace16d85f64273bf62713a3044992b6b7a9e9",
127 include_str!("../../../assets/backgrounds/computer.svg"),
128 ),
129 (
130 "20016e5fa1a32dce5af4e92872597e36432185a7bb2e61c91f362bd68484529b",
131 include_str!("../../../assets/backgrounds/cyan-title.svg"),
132 ),
133 (
134 "035b26df61855a3f36dbd30fdab0c157c04c9e8ae2197ea4d4aeb3e82e6a4c2b",
135 include_str!("../../../assets/backgrounds/day-planner.svg"),
136 ),
137 (
138 "b243a24fa13bc8523450e22f408f9eff15301c938f8ca52a57018b58ce6785de",
139 include_str!("../../../assets/backgrounds/fireworks.svg"),
140 ),
141 (
142 "1262dd23ad54e935cfa10feb1be56648e43bef1116696ca71d87e6e033b1ca7d",
143 include_str!("../../../assets/backgrounds/flat-squares.svg"),
144 ),
145 (
146 "423b0dd1a93b391d15b1dc8d8757c3bf5725ff2e7a59e6e3140033e2876b67f6",
147 include_str!("../../../assets/backgrounds/flowers-and-hearts.svg"),
148 ),
149 (
150 "131d637cdc5d0b094fb9fad17f4d2a1ace0d03613588155aacaa2d1cb4e16da9",
151 include_str!("../../../assets/backgrounds/glasses-corner.svg"),
152 ),
153 (
154 "20abe389c885e42b6ebe9e902976229bb6fd63c8c34cb61aa70b8b746209f90a",
155 include_str!("../../../assets/backgrounds/globe.svg"),
156 ),
157 (
158 "95f59a0433050180d4c0e8858b83363d51bea6752a8b7ca516a8677854d8f5b6",
159 include_str!("../../../assets/backgrounds/graph.svg"),
160 ),
161 (
162 "d56cefe9ee2fae72e31bdba7dd2aa4426ea22e3ceb22ef68c8f63f9f24d5a8bc",
163 include_str!("../../../assets/backgrounds/green-stripes.svg"),
164 ),
165 (
166 "2035407a0540e1c4f7934db08ba4add750fcb9a62863ddd9553e7871c81a99e3",
167 include_str!("../../../assets/backgrounds/hearts.svg"),
168 ),
169 (
170 "7ce10d1ea660d2f9cf8b704f3fab2966a4ce2627d9858d32c75d857095012098",
171 include_str!("../../../assets/backgrounds/ivy.svg"),
172 ),
173 (
174 "fb12d59b8be911247bbafdd416852e8b74b028005a141cb4dbbba109b4b6ed2c",
175 include_str!("../../../assets/backgrounds/large-push-pins.svg"),
176 ),
177 (
178 "7d2b01f17354d9237a6ab99d5b9afdf0e1cc43687125848b0c2dedfb44ce3843",
179 include_str!("../../../assets/backgrounds/light-bulb-corner.svg"),
180 ),
181 (
182 "140dd943d0f0cfe6aaa98470b7d1a7cb62ca02cb1d8f522dd2ac77433232ef41",
183 include_str!("../../../assets/backgrounds/lilies.svg"),
184 ),
185 (
186 "1f81dde3b42f23f0666d92ebf14d62893b31b39d72c07aee070eae28c2e6980e",
187 include_str!("../../../assets/backgrounds/math.svg"),
188 ),
189 (
190 "ec8cd7250f3d82e900e99114869777ee859ec73effabed108815f65742078c3a",
191 include_str!("../../../assets/backgrounds/math-science-class-notes.svg"),
192 ),
193 (
194 "0401451e1d1d7dfdc29ad1b2b68a6c8ac0b706e9868bf22fab26a01cd48620ce",
195 include_str!("../../../assets/backgrounds/networking.svg"),
196 ),
197 (
198 "7e6a7714a69688d9ffdf16aa942b66064a0c77fcd9b3e469f89730b4b9290c3e",
199 include_str!("../../../assets/backgrounds/notebook.svg"),
200 ),
201 (
202 "be4f0e6c89fce91b9ebd2623567f7dfc259e0e3c77c9158742b8f64b724df673",
203 include_str!("../../../assets/backgrounds/orange-margin.svg"),
204 ),
205 (
206 "7354001527ab554c44e7d6981b86dd933b7dc2e0d3dc8512ad3eecd843245c52",
207 include_str!("../../../assets/backgrounds/paper.svg"),
208 ),
209 (
210 "e9ead0bfc09d32cb366010cdfede1c432a2d1d550cb7332badac1bee9482bc86",
211 include_str!("../../../assets/backgrounds/paws-cyan.svg"),
212 ),
213 (
214 "28f334b77068f71f5f92a95695433b950610204a0e5580ce567db8fad4993ecb",
215 include_str!("../../../assets/backgrounds/pencil-and-notebook.svg"),
216 ),
217 (
218 "3fbe3c1c238bd7dbc67f8cff5f3bddfd513c96a9851b9616477947d21dff4b2e",
219 include_str!("../../../assets/backgrounds/pens.svg"),
220 ),
221 (
222 "5dc2367a80588a7518db5014122510bf0fd784711015ef83a8718336584f82d0",
223 include_str!("../../../assets/backgrounds/pixel.svg"),
224 ),
225 (
226 "edbb86f160050fbf1f9860276802bae292dbfd0bc98e3ea90d43d981e9f0c54a",
227 include_str!("../../../assets/backgrounds/pixel-blends.svg"),
228 ),
229 (
230 "de35af949d4f83e97ee22f817afe2531cc4b59ff9ee6026dca7ecebc5cf2737f",
231 include_str!("../../../assets/backgrounds/plain-and-simple.svg"),
232 ),
233 (
234 "e111f96490755c7d71e87c88acaea38afe55bb865b1a14a83c5bd239648d5e2c",
235 include_str!("../../../assets/backgrounds/purple-clouds.svg"),
236 ),
237 (
238 "62ad3c277e54f03f1adb44062407346f789e63859b7afabfd64be6af5e9f66ec",
239 include_str!("../../../assets/backgrounds/pushpins-corner.svg"),
24078 ),
241 (
242 "f927c7825851974a2149868146970706523a49165133cee6027a43e8c9abdf27",
243 include_str!("../../../assets/backgrounds/rainbow.svg"),
244 ),
245 (
246 "0e31da4dfcff4a36c64c1ce940362d2309769f36369e4c43c317d5f2fa15658e",
247 include_str!("../../../assets/backgrounds/rainbow-border.svg"),
248 ),
249 (
250 "2e7aaf26bec32148b96442e8fff1bd2cef2d72630969f23b9a2abedb6cfec93b",
251 include_str!("../../../assets/backgrounds/rainbow-mini-border.svg"),
252 ),
253 (
254 "525a857d0eda855a64d3619df58b1c2d013a73e60fa0d49b155ecfcb2c134c7c",
255 include_str!("../../../assets/backgrounds/recipe.svg"),
256 ),
257 (
258 "b4daa90d5a53fcbc85119050b5b76962443c4dd18d7f42cdc6d4e0ad8efad872",
259 include_str!("../../../assets/backgrounds/red-and-black-margin.svg"),
260 ),
261 (
262 "14fa2d16310485aa1ce41f6d774a3d637e8cf8b03c4f72990155df274fdb6bd9",
263 include_str!("../../../assets/backgrounds/side-stripes.svg"),
264 ),
265 (
266 "d74c3973c8d1f7c77274691afb1aa934940674341d7eee563be75e563281bdfd",
267 include_str!("../../../assets/backgrounds/small-push-pins.svg"),
268 ),
269 (
270 "f091ded5e283af6848670a3172e7c43c6099875d39b3fc69c2bdba914f609602",
271 include_str!("../../../assets/backgrounds/sparks.svg"),
272 ),
273 (
274 "7f5b660a1a0bf46c75aaf19b4f77a0e086de003ec03afc1f58d871d55aa5ba9e",
275 include_str!("../../../assets/backgrounds/stars.svg"),
276 ),
277 (
79 template!(
80 "blue-mist-margin",
81 jpeg,
82 "83245f217deae4a4143b565e13c045dbb32a9063e8c6b2e43bb15cd76c5f9219"
83 ),
84 template!(
85 "blue-stripe-title",
86 jpeg,
87 "474f9a8c25d5e21192315397ea995b1e11e2c1608157c6e0277688091bfd136a"
88 ),
89 template!(
90 "blue-stripes",
91 jpeg,
92 "06e09de80c3f32254da4fe6b2cbad7c05ef144dd54b8c65745e195bbf7317a2e"
93 ),
94 template!(
95 "blue-swirls",
96 jpeg,
97 "10c833e47be1c8496f949a6b059c2d79212a4dd66bde62116ea337fa4fe0b654"
98 ),
99 template!(
100 "blue-wave-title",
101 jpeg,
102 "646586cb71742a2369a529876b41af6a472c35cc508d1ae5d8395d55784814f2"
103 ),
104 template!(
105 "blue-waves",
106 jpeg,
107 "fbb4573e3bee1b337077691bebae15d6fac52432405d31396d526d7694a8283d"
108 ),
109 template!(
110 "books",
111 jpeg,
112 "1591fd26e7fff5be97431d0ed3d0ade5cfc5fa74e3d7ec282fd242160ce68c1f"
113 ),
114 template!(
115 "bouquet",
116 jpeg,
117 "bdd997068701ed3a00a224eb694b003c01ac69b857fe7b4147d6c34875b1632b"
118 ),
119 template!(
120 "boxes",
121 png,
122 "e64fdcd0b108737d8b8f7b677029f924031d6bbaa50585d9c3def7c7e92ecaf2"
123 ),
124 template!(
125 "boxes-shadow",
126 png,
127 "639eadad468b6b32b9124b1f4395a8da3027ff7258d102173ba070ae2ed541ae"
128 ),
129 template!(
130 "bubbles",
131 png,
132 "5eebe803e434a845d19bc600df3c75e98bb69bd0de473ceec410d1b3a9154e28"
133 ),
134 template!(
135 "bubbles-margin-margin",
136 jpeg,
137 "509a6945facfb3ddc7be6ee8b82797ad0c72db5755486ee878125a959cc09b59"
138 ),
139 template!(
140 "bubbles-margin-top",
141 jpeg,
142 "e9746b4e9ae9ce7b3b0068779db3e113e2dfc9880f25373d745d0e700e69a906"
143 ),
144 template!(
145 "buildings",
146 jpeg,
147 "505f731cb7707efab2eb06685b392dc7e59265a40b55aae43e5dc15c0a86cba4"
148 ),
149 template!(
150 "chain-title",
151 png,
152 "edad0f03e6ff99fef9ef8e8b834ce74f26cd23c5f8c067f5cee66f304181e64d"
153 ),
154 template!(
155 "circles",
156 png,
157 "e1cb1a0ec9be62d5445c73aa84df38234002a7e164ee830c9df24997802cb5d2"
158 ),
159 template!(
160 "clock",
161 jpeg,
162 "adb1ebbe18d6cd8ff08aa9bf5c83cdb83bf9aa179698e34e93dbcdde12f04d32"
163 ),
164 template!(
165 "columns",
166 jpeg,
167 "70475431cca3c91a4efa3b8f04864371d2d3a45696674a1a0562fe9cd8db287c"
168 ),
169 template!(
170 "computer",
171 jpeg,
172 "9301db6d2d87282fcee450189aeace16d85f64273bf62713a3044992b6b7a9e9"
173 ),
174 template!(
175 "cyan-title",
176 jpeg,
177 "20016e5fa1a32dce5af4e92872597e36432185a7bb2e61c91f362bd68484529b"
178 ),
179 template!(
180 "day-planner",
181 jpeg,
182 "035b26df61855a3f36dbd30fdab0c157c04c9e8ae2197ea4d4aeb3e82e6a4c2b"
183 ),
184 template!(
185 "fireworks",
186 png,
187 "b243a24fa13bc8523450e22f408f9eff15301c938f8ca52a57018b58ce6785de"
188 ),
189 template!(
190 "flat-squares",
191 png,
192 "1262dd23ad54e935cfa10feb1be56648e43bef1116696ca71d87e6e033b1ca7d"
193 ),
194 template!(
195 "flowers-and-hearts",
196 jpeg,
197 "423b0dd1a93b391d15b1dc8d8757c3bf5725ff2e7a59e6e3140033e2876b67f6"
198 ),
199 template!(
200 "glasses-corner",
201 jpeg,
202 "131d637cdc5d0b094fb9fad17f4d2a1ace0d03613588155aacaa2d1cb4e16da9"
203 ),
204 template!(
205 "globe",
206 jpeg,
207 "20abe389c885e42b6ebe9e902976229bb6fd63c8c34cb61aa70b8b746209f90a"
208 ),
209 template!(
210 "graph",
211 png,
212 "95f59a0433050180d4c0e8858b83363d51bea6752a8b7ca516a8677854d8f5b6"
213 ),
214 template!(
215 "green-stripes",
216 jpeg,
217 "d56cefe9ee2fae72e31bdba7dd2aa4426ea22e3ceb22ef68c8f63f9f24d5a8bc"
218 ),
219 template!(
220 "hearts",
221 png,
222 "2035407a0540e1c4f7934db08ba4add750fcb9a62863ddd9553e7871c81a99e3"
223 ),
224 template!(
225 "ivy",
226 jpeg,
227 "7ce10d1ea660d2f9cf8b704f3fab2966a4ce2627d9858d32c75d857095012098"
228 ),
229 template!(
230 "large-push-pins",
231 jpeg,
232 "fb12d59b8be911247bbafdd416852e8b74b028005a141cb4dbbba109b4b6ed2c"
233 ),
234 template!(
235 "light-bulb-corner",
236 jpeg,
237 "7d2b01f17354d9237a6ab99d5b9afdf0e1cc43687125848b0c2dedfb44ce3843"
238 ),
239 template!(
240 "lilies",
241 jpeg,
242 "140dd943d0f0cfe6aaa98470b7d1a7cb62ca02cb1d8f522dd2ac77433232ef41"
243 ),
244 template!(
245 "math",
246 jpeg,
247 "1f81dde3b42f23f0666d92ebf14d62893b31b39d72c07aee070eae28c2e6980e"
248 ),
249 template!(
250 "math-science-class-notes",
251 jpeg,
252 "ec8cd7250f3d82e900e99114869777ee859ec73effabed108815f65742078c3a"
253 ),
254 template!(
255 "networking",
256 png,
257 "0401451e1d1d7dfdc29ad1b2b68a6c8ac0b706e9868bf22fab26a01cd48620ce"
258 ),
259 template!(
260 "notebook",
261 jpeg,
262 "7e6a7714a69688d9ffdf16aa942b66064a0c77fcd9b3e469f89730b4b9290c3e"
263 ),
264 template!(
265 "orange-margin",
266 jpeg,
267 "be4f0e6c89fce91b9ebd2623567f7dfc259e0e3c77c9158742b8f64b724df673"
268 ),
269 template!(
270 "paper",
271 jpeg,
272 "7354001527ab554c44e7d6981b86dd933b7dc2e0d3dc8512ad3eecd843245c52"
273 ),
274 template!(
275 "paws-cyan",
276 png,
277 "e9ead0bfc09d32cb366010cdfede1c432a2d1d550cb7332badac1bee9482bc86"
278 ),
279 template!(
280 "pencil-and-notebook",
281 jpeg,
282 "28f334b77068f71f5f92a95695433b950610204a0e5580ce567db8fad4993ecb"
283 ),
284 template!(
285 "pens",
286 jpeg,
287 "3fbe3c1c238bd7dbc67f8cff5f3bddfd513c96a9851b9616477947d21dff4b2e"
288 ),
289 template!(
290 "pixel",
291 png,
292 "5dc2367a80588a7518db5014122510bf0fd784711015ef83a8718336584f82d0"
293 ),
294 template!(
295 "pixel-blends",
296 png,
297 "edbb86f160050fbf1f9860276802bae292dbfd0bc98e3ea90d43d981e9f0c54a"
298 ),
299 template!(
300 "plain-and-simple",
301 jpeg,
302 "de35af949d4f83e97ee22f817afe2531cc4b59ff9ee6026dca7ecebc5cf2737f"
303 ),
304 template!(
305 "purple-clouds",
306 jpeg,
307 "e111f96490755c7d71e87c88acaea38afe55bb865b1a14a83c5bd239648d5e2c"
308 ),
309 template!(
310 "pushpins-corner",
311 png,
312 "62ad3c277e54f03f1adb44062407346f789e63859b7afabfd64be6af5e9f66ec"
313 ),
314 template!(
315 "rainbow",
316 jpeg,
317 "f927c7825851974a2149868146970706523a49165133cee6027a43e8c9abdf27"
318 ),
319 template!(
320 "rainbow-border",
321 png,
322 "0e31da4dfcff4a36c64c1ce940362d2309769f36369e4c43c317d5f2fa15658e"
323 ),
324 template!(
325 "rainbow-mini-border",
326 png,
327 "2e7aaf26bec32148b96442e8fff1bd2cef2d72630969f23b9a2abedb6cfec93b"
328 ),
329 template!(
330 "recipe",
331 jpeg,
332 "525a857d0eda855a64d3619df58b1c2d013a73e60fa0d49b155ecfcb2c134c7c"
333 ),
334 template!(
335 "red-and-black-margin",
336 jpeg,
337 "b4daa90d5a53fcbc85119050b5b76962443c4dd18d7f42cdc6d4e0ad8efad872"
338 ),
339 template!(
340 "side-stripes",
341 jpeg,
342 "14fa2d16310485aa1ce41f6d774a3d637e8cf8b03c4f72990155df274fdb6bd9"
343 ),
344 template!(
345 "small-push-pins",
346 jpeg,
347 "d74c3973c8d1f7c77274691afb1aa934940674341d7eee563be75e563281bdfd"
348 ),
349 template!(
350 "sparks",
351 jpeg,
352 "f091ded5e283af6848670a3172e7c43c6099875d39b3fc69c2bdba914f609602"
353 ),
354 template!(
355 "stars",
356 png,
357 "7f5b660a1a0bf46c75aaf19b4f77a0e086de003ec03afc1f58d871d55aa5ba9e"
358 ),
359 template!(
360 "swirls",
361 png,
278362 "0908a4cfa23f93011176d47f45843e9ca2973030421996e8e27484781f54b0ec",
279 include_str!("../../../assets/backgrounds/swirls.svg"),
280 ),
281 (
282363 "8b025b80e7d398229ef19384156fe98aeefcf8f69169d69e1eeba73281b24d7e",
283 include_str!("../../../assets/backgrounds/swirls.svg"),
284364 ),
285 (
286 "2a4a3530d652e227ddd5adc096a95f6034718f7c380b07db622022d768815059",
287 include_str!("../../../assets/backgrounds/tiles.svg"),
365 template!(
366 "tiles",
367 jpeg,
368 "2a4a3530d652e227ddd5adc096a95f6034718f7c380b07db622022d768815059"
288369 ),
289 (
290 "d72900e8da72d1a7f3729971aa558e1e9b6e9cf9a0d51e83852e567256dbbfef",
291 include_str!("../../../assets/backgrounds/triangles-title-margin.svg"),
370 template!(
371 "triangles-title-margin",
372 jpeg,
373 "d72900e8da72d1a7f3729971aa558e1e9b6e9cf9a0d51e83852e567256dbbfef"
292374 ),
293 (
294 "2df453410796aec7b9efec00059b6ce64bcf67313a95ae458ba600ea5de14811",
295 include_str!("../../../assets/backgrounds/triangles-title-top.svg"),
375 template!(
376 "triangles-title-top",
377 jpeg,
378 "2df453410796aec7b9efec00059b6ce64bcf67313a95ae458ba600ea5de14811"
296379 ),
297 (
298 "5b321a4d81bd499b289b1755f6450a42047c494dfbc112dbd56da4ced2c15c1a",
299 include_str!("../../../assets/backgrounds/tulips.svg"),
380 template!(
381 "tulips",
382 jpeg,
383 "5b321a4d81bd499b289b1755f6450a42047c494dfbc112dbd56da4ced2c15c1a"
300384 ),
301 (
302 "494547e566fb7a63dd429eb0699fe41aa8998f8ea2f758d813fe3d56c3075719",
303 include_str!("../../../assets/backgrounds/window-reflection.svg"),
385 template!(
386 "window-reflection",
387 jpeg,
388 "494547e566fb7a63dd429eb0699fe41aa8998f8ea2f758d813fe3d56c3075719"
304389 ),
305 (
306 "5a36960df32817e8426bd40a88f88b04fb55b84baef60f1e71e0872217fdb134",
307 include_str!("../../../assets/backgrounds/writing.svg"),
390 template!(
391 "writing",
392 jpeg,
393 "5a36960df32817e8426bd40a88f88b04fb55b84baef60f1e71e0872217fdb134"
308394 ),
309395];
310396
397/// Names the template a picture Snowbound wrote renders: a PNG `tEXt` keyword, and the
398/// prefix of a JPEG comment.
399const MARKER: &str = "Snowbound template";
400
311401/// A raster holds at most this many bytes; page-sized art stops sharpening past about 2.9
312402/// device pixels per point.
313403const MAX_RASTER_BYTES: f32 = 16.0 * 1024.0 * 1024.0;
......@@ -335,12 +425,21 @@ impl Background {
335425 self.svg == other.svg && self.size == other.size
336426 }
337427
428 /// OneNote's template pictures by their hash, and those Snowbound wrote by their marker.
338429 pub fn recognise(picture: &onestore::page::Image) -> Option<Self> {
339 let digest: String = Sha256::digest(picture.bytes.as_deref()?)
430 let bytes = picture.bytes.as_deref()?;
431 let digest: String = Sha256::digest(bytes)
340432 .iter()
341433 .map(|byte| format!("{byte:02x}"))
342434 .collect();
343 let (_, svg) = TEMPLATES.iter().find(|(hash, _)| *hash == digest)?;
435 let template = TEMPLATES
436 .iter()
437 .find(|template| template.hashes.contains(&digest.as_str()))
438 .or_else(|| {
439 let name = marker(bytes)?;
440 TEMPLATES.iter().find(|template| template.name == name)
441 })?;
442 let svg = template.svg;
344443 let size = match (picture.layout.max_width, picture.layout.max_height) {
345444 (Some(width), Some(height)) => [width, height],
346445 _ => {
......@@ -407,11 +506,17 @@ fn dark(paper: Paper) -> bool {
407506 ink > lightness
408507}
409508
410fn rasterize(svg: &str, dark: Option<Paper>, [width, height]: [u32; 2]) -> RasterImage {
411 let tree = match dark {
412 Some(paper) => parse(&onto(svg, paper)),
413 None => parse(svg),
509fn rasterize(svg: &str, dark: Option<Paper>, size: [u32; 2]) -> RasterImage {
510 let pixels = match dark {
511 Some(paper) => render(&onto(svg, paper), size),
512 None => render(svg, size),
414513 };
514 RasterImage::new(size, pixels).expect("Art is within the image budget")
515}
516
517/// Straight-alpha RGBA rows of `svg` stretched to `width` by `height` pixels.
518fn render(svg: &str, [width, height]: [u32; 2]) -> Vec<u8> {
519 let tree = parse(svg);
415520 let mut pixmap = tiny_skia::Pixmap::new(width, height).expect("Art has a size");
416521 let view = tree.size();
417522 resvg::render(
......@@ -422,15 +527,131 @@ fn rasterize(svg: &str, dark: Option<Paper>, [width, height]: [u32; 2]) -> Raste
422527 ),
423528 &mut pixmap.as_mut(),
424529 );
425 let pixels = pixmap
530 pixmap
426531 .pixels()
427532 .iter()
428533 .flat_map(|pixel| {
429534 let color = pixel.demultiply();
430535 [color.red(), color.green(), color.blue(), color.alpha()]
431536 })
537 .collect()
538}
539
540/// Template art `name` for a preview: drawn `size` points big, or at its natural size, at
541/// `density` pixels per point for `paper`, with the size in points it was drawn at.
542pub fn template_art(
543 name: &str,
544 size: Option<[f32; 2]>,
545 density: f32,
546 paper: Paper,
547) -> Option<(RasterImage, [f32; 2])> {
548 let template = TEMPLATES.iter().find(|template| template.name == name)?;
549 let size = size.unwrap_or_else(|| {
550 let view = parse(template.svg).size();
551 [view.width() * 0.75, view.height() * 0.75]
552 });
553 let pixels = size.map(|side| (side * density).round().max(1.0) as u32);
554 Some((rasterize(template.svg, dark(paper).then_some(paper), pixels), size))
555}
556
557/// Template `name`'s art as Snowbound stores it for a page: in the format and pixel size of
558/// OneNote's picture, marked so that recognising it paints the art.
559pub fn template_picture(name: &str) -> Option<Vec<u8>> {
560 let template = TEMPLATES.iter().find(|template| template.name == name)?;
561 let view = parse(template.svg).size();
562 let [width, height] = [view.width(), view.height()].map(|side| side.round() as u32);
563 // Over white paper, as OneNote's pictures are drawn.
564 let rgb: Vec<u8> = render(template.svg, [width, height])
565 .chunks_exact(4)
566 .flat_map(|pixel| {
567 let alpha = u16::from(pixel[3]);
568 [0, 1, 2].map(|channel| {
569 ((u16::from(pixel[channel]) * alpha + 255 * (255 - alpha) + 127) / 255) as u8
570 })
571 })
432572 .collect();
433 RasterImage::new([width, height], pixels).expect("Art is within the image budget")
573 let mut encoded = Vec::new();
574 if template.jpeg {
575 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 90)
576 .encode(&rgb, width, height, image::ExtendedColorType::Rgb8)
577 .expect("Art encodes");
578 // The comment follows the JFIF header, which must lead.
579 let [_, _, _, _, high, low, ..] = encoded[..] else {
580 unreachable!("A JPEG starts with its JFIF header")
581 };
582 let at = 4 + usize::from(u16::from_be_bytes([high, low]));
583 let comment = format!("{MARKER} {name}");
584 let length = u16::try_from(comment.len() + 2).expect("A short comment");
585 let segment = [&[0xff, 0xfe][..], &length.to_be_bytes(), comment.as_bytes()].concat();
586 encoded.splice(at..at, segment);
587 } else {
588 // OneNote's PNG templates hold 256 colours.
589 let rgba: Vec<u8> = rgb
590 .chunks_exact(3)
591 .flat_map(|pixel| [pixel[0], pixel[1], pixel[2], 255])
592 .collect();
593 let quantized = color_quant::NeuQuant::new(10, 256, &rgba);
594 let indices: Vec<u8> = rgba
595 .chunks_exact(4)
596 .map(|pixel| quantized.index_of(pixel) as u8)
597 .collect();
598 let mut encoder = png::Encoder::new(&mut encoded, width, height);
599 encoder.set_color(png::ColorType::Indexed);
600 encoder.set_depth(png::BitDepth::Eight);
601 encoder.set_palette(quantized.color_map_rgb());
602 encoder.set_compression(png::Compression::High);
603 // Palette indices compress best unfiltered.
604 encoder.set_filter(png::Filter::NoFilter);
605 encoder
606 .add_text_chunk(MARKER.into(), name.into())
607 .expect("A Latin-1 keyword");
608 let mut writer = encoder.write_header().expect("Art encodes");
609 writer.write_image_data(&indices).expect("Art encodes");
610 writer.finish().expect("Art encodes");
611 }
612 Some(encoded)
613}
614
615/// The template a picture Snowbound wrote names: a PNG's text chunk before its image data,
616/// or a JPEG's comment before its scan.
617fn marker(picture: &[u8]) -> Option<&str> {
618 fn named(text: &[u8], separator: u8) -> Option<&str> {
619 let name = text
620 .strip_prefix(MARKER.as_bytes())?
621 .strip_prefix(&[separator])?;
622 std::str::from_utf8(name).ok()
623 }
624 if let Some(mut rest) = picture.strip_prefix(b"\x89PNG\r\n\x1a\n") {
625 while let [a, b, c, d, kind @ ..] = rest {
626 let length = u32::from_be_bytes([*a, *b, *c, *d]) as usize;
627 match kind.get(..4)? {
628 b"IDAT" => return None,
629 b"tEXt" => {
630 if let Some(name) = named(kind.get(4..4 + length)?, 0) {
631 return Some(name);
632 }
633 }
634 _ => {}
635 }
636 rest = rest.get(12 + length..)?;
637 }
638 return None;
639 }
640 let mut rest = picture.strip_prefix(&[0xff, 0xd8])?;
641 while let [0xff, kind, high, low, ..] = *rest {
642 let length = usize::from(u16::from_be_bytes([high, low]));
643 match kind {
644 0xda => return None,
645 0xfe => {
646 if let Some(name) = named(rest.get(4..2 + length)?, b' ') {
647 return Some(name);
648 }
649 }
650 _ => {}
651 }
652 rest = rest.get(2 + length..)?;
653 }
654 None
434655}
435656
436657fn parse(svg: &str) -> usvg::Tree {
......@@ -472,7 +693,6 @@ fn onto(svg: &str, paper: Paper) -> String {
472693 recoloured
473694}
474695
475
476696#[cfg(test)]
477697mod tests {
478698 use super::*;
......@@ -520,12 +740,8 @@ mod tests {
520740 }
521741
522742 fn ivy() -> Background {
523 let (_, svg) = TEMPLATES
524 .iter()
525 .find(|(_, svg)| svg.contains("id=\"ivy\""))
526 .unwrap();
527743 Background {
528 svg,
744 svg: TEMPLATES.iter().find(|t| t.name == "ivy").unwrap().svg,
529745 size: [64.0, 48.0],
530746 variants: Default::default(),
531747 }
......@@ -564,19 +780,75 @@ mod tests {
564780 }
565781
566782 #[test]
567 fn every_recreation_parses_for_light_and_dark_paper_under_a_distinct_hash() {
783 fn every_recreation_parses_for_light_and_dark_paper_under_distinct_names_and_hashes() {
784 let mut names = std::collections::BTreeSet::new();
568785 let mut hashes = std::collections::BTreeSet::new();
569 for (hash, svg) in TEMPLATES {
786 for template in TEMPLATES {
570787 assert!(
571 hash.len() == 64 && hash.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
788 names.insert(template.name),
789 "{} is listed twice",
790 template.name
572791 );
573 assert!(hashes.insert(hash), "{hash} is listed twice");
574 for svg in [svg.to_string(), onto(svg, DARK)] {
792 for hash in template.hashes {
793 assert!(
794 hash.len() == 64
795 && hash.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
796 );
797 assert!(hashes.insert(hash), "{hash} is listed twice");
798 }
799 for svg in [template.svg.to_string(), onto(template.svg, DARK)] {
575800 usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap();
576801 }
577802 }
578803 }
579804
805 #[test]
806 fn our_template_pictures_are_recognised_as_the_template() {
807 for (name, magic, size) in [
808 ("ivy", &b"\xff\xd8\xff\xe0"[..], [216, 792]),
809 ("hearts", &b"\x89PNG"[..], [171, 552]),
810 ] {
811 let bytes = template_picture(name).unwrap();
812 assert!(bytes.starts_with(magic), "{name} keeps OneNote's format");
813 assert_eq!(draw::RasterImage::measure(&bytes).unwrap(), size);
814 assert_eq!(marker(&bytes), Some(name));
815 let art = Background::recognise(&picture(&bytes)).unwrap();
816 let template = TEMPLATES.iter().find(|t| t.name == name).unwrap();
817 assert_eq!(art.svg, template.svg);
818 assert!(!template.hashes.iter().any(|hash| {
819 let digest: String = Sha256::digest(&bytes)
820 .iter()
821 .map(|b| format!("{b:02x}"))
822 .collect();
823 digest == *hash
824 }));
825 }
826 assert!(template_picture("not a template").is_none());
827 assert_eq!(marker(&red_png()), None);
828 }
829
830 #[test]
831 fn the_sample_notebook_carries_our_ivy() {
832 let bytes = std::fs::read("../../docs/sample-notebook/Personal/Garden.one").unwrap();
833 let store = onestore::Store::parse(&bytes).unwrap();
834 let index = onestore::RevisionIndex::parse(&store).unwrap();
835 let document = onestore::document::Document::parse(&index).unwrap();
836 let mut backgrounds = 0;
837 for (space, id) in document.pages().unwrap() {
838 let page = Page::from_revision(document.active(space).unwrap(), id).unwrap();
839 for object in &page.objects {
840 if let PageObject::Image(image) = object
841 && image.background
842 {
843 assert_eq!(marker(image.bytes.as_deref().unwrap()), Some("ivy"));
844 assert!(Background::recognise(image).is_some());
845 backgrounds += 1;
846 }
847 }
848 }
849 assert_eq!(backgrounds, 1);
850 }
851
580852 #[test]
581853 fn other_pictures_keep_their_own_bytes() {
582854 assert!(Background::recognise(&picture(&red_png())).is_none());
......@@ -634,6 +906,7 @@ mod tests {
634906 created: None,
635907 title: String::new(),
636908 margin_origin: [0.0; 2],
909 color: None,
637910 definitions: BTreeMap::new(),
638911 objects: vec![PageObject::Image(background)],
639912 };
crates/canvas/src/interaction/accessibility.rs+2
......@@ -736,6 +736,7 @@ pub(super) mod tests {
736736 title: "Header".into(),
737737 created: None,
738738 margin_origin: [36.0, 14.4],
739 color: None,
739740 definitions: Default::default(),
740741 objects: vec![
741742 PageObject::Title(Title {
......@@ -828,6 +829,7 @@ pub(super) mod tests {
828829 created: None,
829830 title: String::new(),
830831 margin_origin: [0.0; 2],
832 color: None,
831833 definitions: Default::default(),
832834 objects: vec![PageObject::Unsupported(Unsupported {
833835 id: Default::default(),
crates/canvas/src/interaction/mod.rs+160-52
......@@ -112,6 +112,8 @@ struct Paint {
112112 /// The document's top and bottom the view shows; paragraphs wholly outside it are
113113 /// not painted.
114114 visible: [f32; 2],
115 /// Whether the active outline shows its frame and grips.
116 chrome: bool,
115117}
116118
117119enum Drag {
......@@ -169,6 +171,14 @@ fn read_only_shortcut(key: &Key, modifiers: Modifiers) -> bool {
169171 && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift && value.eq_ignore_ascii_case("n"))))
170172}
171173
174/// Where a page was left: its scroll and the focused outline's selection.
175#[derive(Clone, Copy, Debug)]
176pub struct Place {
177 origin: [f32; 2],
178 outline: onestore::ExGuid,
179 selection: Selection,
180}
181
172182/// A page on screen: the editor, the drawn scene, the view onto them and the pointer and
173183/// keyboard state between events.
174184pub struct PageView {
......@@ -186,6 +196,9 @@ pub struct PageView {
186196 object_focus: Option<ObjectFocus>,
187197 modifiers: Modifiers,
188198 focused: bool,
199 /// Input is by finger: the focused outline's grips reach further, and outline chrome
200 /// shows only while the view is focused, as there is no hover to reveal it.
201 pub touch: bool,
189202 /// The caret's opacity in its blink.
190203 caret: f32,
191204 /// When the caret last moved, which restarts its blink.
......@@ -242,6 +255,7 @@ impl PageView {
242255 object_focus: None,
243256 modifiers: Modifiers::default(),
244257 focused: true,
258 touch: false,
245259 caret: 1.0,
246260 blink_from: Instant::now(),
247261 };
......@@ -272,28 +286,39 @@ impl PageView {
272286 self.moved()
273287 }
274288
275 /// Shows another page as OneNote opens one, keeping the zoom.
276 pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) {
289 /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was
290 /// left there earlier. OneNote keeps the scroll in device pixels across zoom changes and
291 /// does not reveal the restored selection.
292 pub fn open(
293 &mut self,
294 editor: CanvasEditor,
295 scene: Option<(PageScene, [f32; 2])>,
296 place: Option<Place>,
297 ) {
277298 self.replace(editor, scene);
278 self.place_opened();
299 let Some(place) = place else {
300 return self.place_opened();
301 };
302 self.viewport.origin = place.origin;
303 self.scroll().clamp(&mut self.viewport);
304 if self.editor.focus_outline(place.outline).is_ok() {
305 let _ = self.editor.select(place.selection);
306 }
279307 }
280308
281 /// OneNote 2010 opens a page scrolled fully left and up, then down just far enough to
282 /// show the caret's outline 9 pt and 9 px above the view's bottom.
309 /// Where the page is left, for `open` to return to.
310 pub fn place(&self) -> Place {
311 Place {
312 origin: self.viewport.origin,
313 outline: self.editor.active_outline().id,
314 selection: self.editor.selection(),
315 }
316 }
317
318 /// OneNote 2010 opens a page scrolled fully left and up, however far down its content
319 /// lies.
283320 fn place_opened(&mut self) {
284 let scroll = self.scroll();
285 self.viewport.origin = scroll.min.map(|offset| -offset);
286 let outline = self.editor.active_outline();
287 let offset = self
288 .scene
289 .as_ref()
290 .filter(|_| self.editor.has_page_outline(outline.id))
291 .map_or(0.0, |(_, offset)| offset[1]);
292 let bottom = (outline.bounds().y1 as f32 + offset + 9.0) * self.viewport.scale
293 + 9.0 * self.display_scale
294 + self.viewport.origin[1];
295 self.viewport.origin[1] -= (bottom - self.viewport.size[1] as f32).max(0.0);
296 scroll.clamp(&mut self.viewport);
321 self.viewport.origin = self.scroll().min.map(|offset| -offset);
297322 }
298323
299324 pub fn modifiers(&self) -> Modifiers {
......@@ -382,7 +407,21 @@ impl PageView {
382407 {
383408 return Some(Hit::Image { id, handle });
384409 }
385 page_hit_test(&self.editor, self.scene.as_ref(), point, self.pixel())
410 let touched = (self.touch && self.focused && self.object_focus.is_none())
411 .then(|| self.editor.active_outline().id);
412 page_hit(
413 &self.editor,
414 self.scene.as_ref(),
415 point,
416 self.pixel(),
417 touched,
418 )
419 }
420
421 /// What the view point `position`, in device pixels, lands on, for the host to route a
422 /// gesture before it starts.
423 pub fn hit(&self, position: [f32; 2]) -> Option<Hit> {
424 self.hit_test(self.viewport.document_point(position))
386425 }
387426
388427 fn set_object_focus(&mut self, focus: Option<ObjectFocus>) {
......@@ -503,6 +542,25 @@ impl PageView {
503542 Ok(())
504543 }
505544
545 /// Moves the caret by lines until it has gone a view's height, then scrolls as far so
546 /// it keeps its place on screen.
547 fn move_page(&mut self, up: bool, extend: bool) -> Result<()> {
548 let movement = if up { Movement::Up } else { Movement::Down };
549 let start = self.caret_area()?[1];
550 let mut caret = self.caret_area()?;
551 while (caret[1] - start).abs() < self.viewport.size[1] as f32 {
552 self.editor
553 .move_selection(&mut self.engine, movement, extend)?;
554 let moved = self.caret_area()?;
555 if moved == caret {
556 break;
557 }
558 caret = moved;
559 }
560 self.viewport.origin[1] -= caret[1] - start;
561 Ok(())
562 }
563
506564 /// Reveals the caret or focused object after an edit, then reports the change.
507565 fn edited(&mut self) -> Result<Response> {
508566 self.reveal_focus()?;
......@@ -554,21 +612,23 @@ impl PageView {
554612 rect.y1 as f32 + offset[1],
555613 ]
556614 });
615 let pad = PAD * self.display_scale;
557616 let fixed = self.scene.iter().flat_map(|(scene, offset)| {
558 scene.content_bounds(&self.editor).map(|rect| {
559 [
560 rect[0] + offset[0],
561 rect[1] + offset[1],
562 rect[2] + offset[0],
563 rect[3] + offset[1],
564 ]
565 })
617 scene
618 .content_bounds(&self.editor)
619 .map(move |(rect, padded)| {
620 (
621 [
622 rect[0] + offset[0],
623 rect[1] + offset[1],
624 rect[2] + offset[0],
625 rect[3] + offset[1],
626 ],
627 if padded { pad } else { 0.0 },
628 )
629 })
566630 });
567 scroll::Scroll::new(
568 self.viewport,
569 PAD * self.display_scale,
570 editable.chain(fixed),
571 )
631 scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed))
572632 }
573633
574634 /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`,
......@@ -654,6 +714,7 @@ impl PageView {
654714 colors,
655715 visible: [0.0, self.viewport.size[1] as f32]
656716 .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale),
717 chrome: !self.touch || self.focused,
657718 },
658719 )
659720 }
......@@ -681,7 +742,7 @@ impl PageView {
681742 let (caret, hold) = edit::caret_blink(now.saturating_duration_since(self.blink_from));
682743 let repaint = caret != self.caret;
683744 self.caret = caret;
684 (repaint, Some(now + hold))
745 (repaint, hold.map(|hold| now + hold))
685746 }
686747
687748 /// `size` in device pixels.
......@@ -863,8 +924,12 @@ impl PageView {
863924 }
864925
865926 pub fn pointer_released(&mut self) -> Result<Response> {
866 let preview = self.outline_preview();
867 if let Some((id, origin, size)) = self.image_preview() {
927 // As in OneNote, an outline or picture dropped outside the view stays where it was.
928 let inside =
929 (0..2).all(|axis| (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis]));
930 let moving_image = matches!(self.drag, Some(Drag::Image { handle: [0, 0], .. }));
931 let preview = self.outline_preview().filter(|_| inside);
932 if let Some((id, origin, size)) = self.image_preview().filter(|_| inside || !moving_image) {
868933 self.editor
869934 .place_image(&mut self.engine, id, origin, size)?;
870935 }
......@@ -922,7 +987,8 @@ impl PageView {
922987
923988 /// An input method's marked text; `cursor` is its UTF-8 selection within `text`.
924989 pub fn compose(&mut self, text: String, cursor: Option<(usize, usize)>) -> Result<Response> {
925 if !self.accepts_text() {
990 // Wayland input methods clear an absent composition after each cursor update.
991 if !self.accepts_text() || text.is_empty() && self.editor.marked_range().is_none() {
926992 return Ok(Response::default());
927993 }
928994 if text.is_empty() {
......@@ -962,6 +1028,21 @@ impl PageView {
9621028 self.edited()
9631029 }
9641030
1031 /// Undoes the last edit, or redoes the last undone one.
1032 pub fn undo(&mut self, redo: bool) -> Result<Response> {
1033 if redo {
1034 self.editor.redo(&mut self.engine)?;
1035 } else {
1036 self.editor.undo(&mut self.engine)?;
1037 }
1038 if let Some(ObjectFocus::Image(id)) = self.object_focus
1039 && self.editor.image_placement(id).is_none()
1040 {
1041 self.set_object_focus(None);
1042 }
1043 self.edited()
1044 }
1045
9651046 /// The date or time chosen after `Request::EditDate`.
9661047 pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result<Response> {
9671048 self.editor.change_date(&mut self.engine, timestamp, text)?;
......@@ -1111,17 +1192,9 @@ impl PageView {
11111192 self.set_object_focus(None);
11121193 }
11131194 "a" => self.editor.select_all()?,
1114 "z" => {
1115 if shift {
1116 self.editor.redo(&mut self.engine)?;
1117 } else {
1118 self.editor.undo(&mut self.engine)?;
1119 }
1120 if let Some(ObjectFocus::Image(id)) = self.object_focus
1121 && self.editor.image_placement(id).is_none()
1122 {
1123 self.set_object_focus(None);
1124 }
1195 "z" => return self.undo(shift),
1196 "y" if edit::Platform::CURRENT == edit::Platform::Windows => {
1197 return self.undo(true);
11251198 }
11261199 "c" | "x" => {
11271200 let [anchor, focus] = self.editor.selection().positions;
......@@ -1166,6 +1239,13 @@ impl PageView {
11661239 if let Some(Command::Move(movement)) = chord {
11671240 self.editor
11681241 .move_selection(&mut self.engine, movement, shift)?;
1242 } else if let Some(Command::ScrollPage { up }) = chord {
1243 // AppKit keeps ten points of the last page in view.
1244 let page = self.viewport.size[1] as f32 - 10.0 * self.display_scale;
1245 self.viewport.origin[1] += if up { page } else { -page };
1246 return self.moved();
1247 } else if let Some(Command::MovePage { up }) = chord {
1248 self.move_page(up, shift)?;
11691249 } else if self.editor.marked_range().is_none() {
11701250 match (chord, key) {
11711251 (Some(Command::DeleteTo(movement)), _) => {
......@@ -1249,6 +1329,21 @@ pub fn page_hit_test(
12491329 scene: Option<&(PageScene, [f32; 2])>,
12501330 point: [f32; 2],
12511331 pixel: f32,
1332) -> Option<Hit> {
1333 page_hit(editor, scene, point, pixel, None)
1334}
1335
1336/// Logical pixels a finger's grip targets extend beyond the drawn grips.
1337const TOUCH_REACH: f32 = 16.0;
1338
1339/// `page_hit_test` where the `touched` outline's grips reach `TOUCH_REACH` further: outward,
1340/// and inward along its header.
1341fn page_hit(
1342 editor: &CanvasEditor,
1343 scene: Option<&(PageScene, [f32; 2])>,
1344 point: [f32; 2],
1345 pixel: f32,
1346 touched: Option<onestore::ExGuid>,
12521347) -> Option<Hit> {
12531348 /// Grips and text resolve front to back before any outline's padding, as a native
12541349 /// width handle stays reachable under the next outline's left padding; the typing room
......@@ -1278,19 +1373,31 @@ pub fn page_hit_test(
12781373 let local = [point[0] - offset[0], point[1] - offset[1]];
12791374 let [x, y] = local;
12801375 if layer == Layer::Grips {
1376 let extra = if touched == Some(outline.id) && !outline.title {
1377 TOUCH_REACH * pixel
1378 } else {
1379 0.0
1380 };
12811381 // Width handles: 15 px inside to 6 px outside the header's right end, and 6 px
12821382 // either side of the right border below it.
1283 let inside = if y < body_top { 15.0 } else { 6.0 };
1383 let inside = if y < body_top {
1384 15.0 * pixel + extra
1385 } else {
1386 6.0 * pixel
1387 };
12841388 if !outline.title
1285 && (bounds[2] - inside * pixel..=bounds[2] + 6.0 * pixel).contains(&x)
1286 && (bounds[1]..=bounds[3]).contains(&y)
1389 && (bounds[2] - inside..=bounds[2] + 6.0 * pixel + extra).contains(&x)
1390 && (bounds[1] - extra..=bounds[3] + extra).contains(&y)
12871391 {
12881392 return Some(Hit::Resize {
12891393 id: outline.id,
12901394 grab: point[0] - outline.bounds().x1 as f32,
12911395 });
12921396 }
1293 if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top {
1397 if (bounds[0] - extra..=bounds[2] + extra).contains(&x)
1398 && y >= bounds[1] - extra
1399 && y < body_top
1400 {
12941401 return Some(Hit::Handle {
12951402 id: outline.id,
12961403 grab: [
......@@ -1397,7 +1504,7 @@ fn page_primitives<'a>(
13971504 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,
13981505 _ => outline.origin(),
13991506 };
1400 if (object_focus.is_none() && outline.id == editor.active_outline().id)
1507 if (paint.chrome && object_focus.is_none() && outline.id == editor.active_outline().id)
14011508 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
14021509 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
14031510 {
......@@ -1794,6 +1901,7 @@ fn append_outline<'a>(
17941901 pixel,
17951902 colors,
17961903 visible,
1904 ..
17971905 } = paint;
17981906 let rows = [visible[0] - y, visible[1] - y];
17991907 outline
crates/canvas/src/interaction/scroll.rs+26-8
......@@ -9,19 +9,19 @@ pub struct Scroll {
99}
1010
1111impl Scroll {
12 /// `pad` is the room, in device pixels, OneNote leaves beyond content it scrolls to.
13 pub fn new(viewport: Viewport, pad: f32, bounds: impl Iterator<Item = [f32; 4]>) -> Self {
14 let mut min = [f32::INFINITY; 2];
12 /// Each content rectangle comes with the room, in device pixels, OneNote leaves beyond
13 /// it left and up.
14 pub fn new(viewport: Viewport, bounds: impl Iterator<Item = ([f32; 4], f32)>) -> Self {
15 let mut min = [0.0_f32; 2];
1516 let mut max = [0.0_f32; 2];
16 for rect in bounds {
17 for (rect, pad) in bounds {
1718 for axis in 0..2 {
18 min[axis] = min[axis].min(rect[axis]);
19 min[axis] = min[axis].min(rect[axis] * viewport.scale - pad);
1920 max[axis] = max[axis].max(rect[axis + 2]);
2021 }
2122 }
22 // OneNote stops at the page origin, or `pad` beyond content that reaches past it.
23 // OneNote stops at the page origin unless content reaches past it.
2324 for axis in 0..2 {
24 min[axis] = (min[axis] * viewport.scale - pad).min(0.0);
2525 max[axis] =
2626 ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]);
2727 }
......@@ -46,11 +46,29 @@ mod tests {
4646 scale: 2.0,
4747 origin: [9999.0; 2],
4848 };
49 let scroll = Scroll::new(viewport, 11.0, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter());
49 let scroll = Scroll::new(
50 viewport,
51 [([-80.0, -20.0, 1000.0, 1200.0], 11.0)].into_iter(),
52 );
5053 scroll.clamp(&mut viewport);
5154 assert_eq!(viewport.origin, [171.0, 51.0]);
5255 viewport.origin = [-9999.0; 2];
5356 scroll.clamp(&mut viewport);
5457 assert_eq!(viewport.origin, [-1272.0, -1872.0]);
5558 }
59
60 #[test]
61 fn pictures_reach_no_further_than_their_corner() {
62 let viewport = Viewport {
63 size: [800, 600],
64 scale: 2.0,
65 origin: [0.0; 2],
66 };
67 let bounds = [
68 ([-0.5, -22.35, 613.0, 141.3], 0.0),
69 ([36.0, 86.4, 400.0, 180.0], 11.0),
70 ];
71 let scroll = Scroll::new(viewport, bounds.into_iter());
72 assert_eq!(scroll.min, [-1.0, -44.7]);
73 }
5674}
crates/canvas/src/interaction/tests.rs+149-3
......@@ -20,6 +20,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint {
2020 pixel: display_scale / scale,
2121 colors: COLORS,
2222 visible: [f32::NEG_INFINITY, f32::INFINITY],
23 chrome: true,
2324 }
2425}
2526
......@@ -47,6 +48,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow()
4748 title: "Header".into(),
4849 created: Some(1),
4950 margin_origin: [36.0, 14.4],
51 color: None,
5052 definitions: Default::default(),
5153 objects: vec![onestore::page::PageObject::Title(onestore::page::Title {
5254 id: onestore::ExGuid::default(),
......@@ -530,6 +532,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {
530532 title: String::new(),
531533 created: None,
532534 margin_origin: [0.0; 2],
535 color: None,
533536 definitions: Default::default(),
534537 objects: vec![onestore::page::PageObject::Outline(source)],
535538 };
......@@ -652,6 +655,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() {
652655 identity: None,
653656 created: None,
654657 margin_origin: [36.0, 14.4],
658 color: None,
655659 definitions: Default::default(),
656660 objects: vec![PageObject::Outline(source)],
657661 },
......@@ -828,6 +832,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
828832 pixel: 1.0,
829833 colors: COLORS,
830834 visible: [f32::NEG_INFINITY, f32::INFINITY],
835 chrome: true,
831836 },
832837 &mut primitives,
833838 )
......@@ -899,6 +904,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {
899904 pixel: 1.0,
900905 colors: COLORS,
901906 visible: [f32::NEG_INFINITY, f32::INFINITY],
907 chrome: true,
902908 },
903909 &mut primitives,
904910 )
......@@ -1157,6 +1163,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
11571163 created: None,
11581164 title: String::new(),
11591165 margin_origin: [0.0; 2],
1166 color: None,
11601167 definitions: Default::default(),
11611168 objects,
11621169 };
......@@ -1389,6 +1396,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
13891396 created: None,
13901397 title: String::new(),
13911398 margin_origin: [0.0; 2],
1399 color: None,
13921400 definitions: Default::default(),
13931401 objects: vec![PageObject::Unsupported(Unsupported {
13941402 id: Default::default(),
......@@ -1503,6 +1511,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
15031511 identity: None,
15041512 created: None,
15051513 margin_origin: [36.0, 14.4],
1514 color: None,
15061515 definitions: Default::default(),
15071516 objects: vec![PageObject::Outline(source)],
15081517 },
......@@ -1539,6 +1548,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() {
15391548 identity: None,
15401549 created: None,
15411550 margin_origin: [36.0, 14.4],
1551 color: None,
15421552 definitions: Default::default(),
15431553 objects,
15441554 };
......@@ -1660,6 +1670,16 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
16601670 let origin = view.editor.active_outline().origin();
16611671 assert_eq!(origin, [72.0, 50.4]);
16621672
1673 // Dropped outside the view, as past the window's corner, it stays where it was.
1674 let bounds = view.editor.active_outline().bounds();
1675 let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0];
1676 let _ = view.pointer_moved(header).unwrap();
1677 let _ = view.pointer_pressed(now + Duration::from_secs(4)).unwrap();
1678 let _ = view.pointer_moved([-200.0, -150.0]).unwrap();
1679 assert!(view.outline_preview().is_some());
1680 let _ = view.pointer_released().unwrap();
1681 assert_eq!(view.editor.active_outline().origin(), origin);
1682
16631683 // An empty spot places a new caret there, 7 px above the pointer on the grid.
16641684 click(&mut view, [400.0, 300.0], now + Duration::from_secs(3));
16651685 let caret = view.editor.caret_outline().unwrap().origin();
......@@ -1835,7 +1855,7 @@ fn a_dragged_outline_snaps_to_the_margin_grid_as_onenote_stores_it() {
18351855
18361856/// OneNote 2010 at 75 to 150% opens a page scrolled fully left and up: 11 px beyond 7.5 pt
18371857/// left of text, a list marker, 0.75 pt into a tag's slot, or 6 pt above an outline, never
1838/// right of or below the page origin; then down until the caret's outline clears the bottom.
1858/// right of or below the page origin, even when the only outline lies below the view.
18391859#[test]
18401860fn a_page_opens_where_onenote_places_the_view() {
18411861 use crate::editor::{Formatting, NoteTag};
......@@ -1880,7 +1900,133 @@ fn a_page_opens_where_onenote_places_the_view() {
18801900 "{origin:?} {view:?}"
18811901 );
18821902 }
1883 // Content far down scrolls into view, 9 pt and 9 px above the bottom.
18841903 let (_, view) = open([50.0, 900.0], false);
1885 assert!(view[1] < -600.0);
1904 assert_eq!(view, [0.0, 0.0]);
1905}
1906
1907/// OneNote 2010 returns to a page at the scroll, in device pixels whatever the zoom, and the
1908/// selection it was left with, without revealing the selection.
1909#[test]
1910fn a_page_reopens_where_it_was_left() {
1911 use crate::document::TextPosition;
1912 let mut engine = TextEngine::default();
1913 let outlines: Vec<TextOutline> = [("First", [0.0, 0.0]), ("Far below", [50.0, 900.0])]
1914 .into_iter()
1915 .map(|(text, origin)| {
1916 let paragraph = Paragraph::new(text.into(), Format::default());
1917 let document = TextDocument::new(vec![paragraph]).unwrap();
1918 TextOutline::new(&mut engine, document, 468.0, origin).unwrap()
1919 })
1920 .collect();
1921 let editor = |count: usize| {
1922 CanvasEditor::from_text_outlines(outlines[..count].to_vec(), Default::default(), None)
1923 .unwrap()
1924 };
1925 let mut view = PageView::new(
1926 editor(2),
1927 engine,
1928 None,
1929 [800, 600],
1930 1.0,
1931 Duration::from_millis(500),
1932 );
1933 let far = outlines[1].id;
1934 view.editor.focus_outline(far).unwrap();
1935 let selection = Selection::from([2, 5].map(|offset| TextPosition {
1936 paragraph: 0,
1937 offset,
1938 }));
1939 view.editor.select(selection).unwrap();
1940 let _ = view.scroll_to(1, 300.0).unwrap();
1941 let place = view.place();
1942
1943 view.open(editor(2), None, None);
1944 assert_eq!(view.viewport.origin, [21.0, 19.0]);
1945 let _ = view.set_zoom(2.0).unwrap();
1946 view.open(editor(2), None, Some(place));
1947 assert_eq!(view.viewport.origin, [21.0, -300.0]);
1948 assert_eq!(view.editor.active_outline().id, far);
1949 assert_eq!(view.editor.selection(), selection);
1950
1951 // A place past the page's content is kept within it, here 6 pt above the outline at 200%.
1952 view.open(editor(1), None, Some(place));
1953 assert_eq!(view.viewport.origin, [21.0, 27.0]);
1954 assert_eq!(view.editor.active_outline().id, outlines[0].id);
1955}
1956
1957#[test]
1958fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() {
1959 let mut engine = TextEngine::default();
1960 let lines = (0..100)
1961 .map(|line| Paragraph::new(format!("Line {line}"), Default::default()))
1962 .collect();
1963 let outline = TextOutline::new(
1964 &mut engine,
1965 TextDocument::new(lines).unwrap(),
1966 240.0,
1967 [36.0, 36.0],
1968 )
1969 .unwrap();
1970 let editor = CanvasEditor::from_page(
1971 Page {
1972 title: String::new(),
1973 identity: None,
1974 created: None,
1975 margin_origin: [36.0, 14.4],
1976 color: None,
1977 definitions: Default::default(),
1978 objects: vec![onestore::page::PageObject::Outline(outline.snapshot())],
1979 },
1980 &mut engine,
1981 )
1982 .unwrap();
1983 let mut view = PageView::new(
1984 editor,
1985 engine,
1986 None,
1987 [800, 200],
1988 1.0,
1989 Duration::from_millis(500),
1990 );
1991 view.viewport.scale = 1.0;
1992 let page_down = Key::Named(NamedKey::PageDown);
1993 let option = Modifiers {
1994 option: true,
1995 ..Modifiers::default()
1996 };
1997 for modifiers in [Modifiers::default(), option] {
1998 view.viewport.origin = [0.0; 2];
1999 view.editor
2000 .move_selection(&mut view.engine, Movement::DocumentStart, false)
2001 .unwrap();
2002 let _ = view.modifiers_changed(modifiers).unwrap();
2003 let caret = view.caret_area().unwrap()[1];
2004 let _ = view.key(&page_down, None).unwrap();
2005 let scrolled = -view.viewport.origin[1];
2006 let shown = view.caret_area().unwrap()[1];
2007 match Command::from_key(&page_down, modifiers) {
2008 Some(Command::ScrollPage { up: false }) => {
2009 assert_eq!(scrolled, 190.0, "a page less AppKit's ten points");
2010 assert_eq!(shown, caret - scrolled, "the caret stays in the text");
2011 }
2012 Some(Command::MovePage { up: false }) => {
2013 assert!(scrolled >= 200.0, "{scrolled}");
2014 assert_eq!(shown, caret, "the caret keeps its place on screen");
2015 }
2016 None => assert_eq!((scrolled, shown), (0.0, caret)),
2017 other => panic!("Page Down is {other:?}"),
2018 }
2019 }
2020}
2021
2022#[test]
2023fn an_empty_preedit_without_a_composition_changes_nothing() {
2024 let (mut view, _) = picture_view();
2025 assert_eq!(
2026 view.compose(String::new(), None).unwrap(),
2027 Response::default()
2028 );
2029 assert!(view.compose("k".into(), Some((1, 1))).unwrap().changed);
2030 assert!(view.compose(String::new(), None).unwrap().changed);
2031 assert!(view.editor.marked_range().is_none());
18862032}
crates/canvas/src/layout.rs+150-19
......@@ -4,10 +4,11 @@ use parley::{
44 FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, OverflowWrap,
55 PositionedLayoutItem, StyleProperty,
66 editing::{Cursor, Selection},
7 fontique::{Blob, FontInfo},
78};
89use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId};
910use std::{
10 collections::BTreeSet,
11 collections::{BTreeMap, BTreeSet},
1112 fmt,
1213 ops::Range,
1314 sync::{
......@@ -16,11 +17,20 @@ use std::{
1617 },
1718};
1819
19#[derive(Default)]
2020pub struct TextEngine {
2121 pub fonts: FontContext,
2222 context: LayoutContext<TextBrush>,
2323 arial_substitutes: BTreeSet<u64>,
24 /// The substitute each family lays out in, by family.
25 substitutes: BTreeMap<&'static str, Substitute>,
26}
27
28/// A font laid out in place of a family.
29pub struct Substitute {
30 pub name: &'static str,
31 /// Its faces as registered for the family.
32 pub faces: Vec<(Blob<u8>, FontInfo)>,
33 bundled: bool,
2434}
2535
2636/// Superscripts and subscripts draw at this fraction of their run's size.
......@@ -69,7 +79,9 @@ pub enum LayoutError {
6979impl fmt::Display for LayoutError {
7080 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
7181 match self {
72 Self::UnsupportedSubstituteFont => write!(f, "Choose an Arimo or Carlito font file."),
82 Self::UnsupportedSubstituteFont => {
83 write!(f, "Choose an Arimo, Carlito, Tinos or Cousine font file.")
84 }
7385 _ => write!(f, "Text layout failed: {self:?}"),
7486 }
7587 }
......@@ -77,12 +89,75 @@ impl fmt::Display for LayoutError {
7789
7890impl std::error::Error for LayoutError {}
7991
92/// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font
93/// Licence files beside them, by the family each stands in for.
94const BUNDLED: [(&str, &[&[u8]]); 4] = [
95 (
96 "Calibri",
97 &[
98 include_bytes!("../assets/fonts/Carlito-Regular.ttf"),
99 include_bytes!("../assets/fonts/Carlito-Bold.ttf"),
100 include_bytes!("../assets/fonts/Carlito-Italic.ttf"),
101 include_bytes!("../assets/fonts/Carlito-BoldItalic.ttf"),
102 ],
103 ),
104 (
105 "Arial",
106 &[
107 include_bytes!("../assets/fonts/Arimo.ttf"),
108 include_bytes!("../assets/fonts/Arimo-Italic.ttf"),
109 ],
110 ),
111 (
112 "Times New Roman",
113 &[
114 include_bytes!("../assets/fonts/Tinos-Regular.ttf"),
115 include_bytes!("../assets/fonts/Tinos-Bold.ttf"),
116 include_bytes!("../assets/fonts/Tinos-Italic.ttf"),
117 include_bytes!("../assets/fonts/Tinos-BoldItalic.ttf"),
118 ],
119 ),
120 (
121 "Courier New",
122 &[
123 include_bytes!("../assets/fonts/Cousine-Regular.ttf"),
124 include_bytes!("../assets/fonts/Cousine-Bold.ttf"),
125 include_bytes!("../assets/fonts/Cousine-Italic.ttf"),
126 include_bytes!("../assets/fonts/Cousine-BoldItalic.ttf"),
127 ],
128 ),
129];
130
131impl Default for TextEngine {
132 /// An engine that lays out each bundled family in its substitute where it is missing.
133 fn default() -> Self {
134 let mut engine = Self {
135 fonts: FontContext::default(),
136 context: LayoutContext::default(),
137 arial_substitutes: BTreeSet::new(),
138 substitutes: BTreeMap::new(),
139 };
140 for (family, faces) in BUNDLED {
141 if engine.fonts.collection.family_id(family).is_none() {
142 for face in faces {
143 engine
144 .register(Blob::new(Arc::new(*face)), true)
145 .expect("bundled substitutes register");
146 }
147 }
148 }
149 engine
150 }
151}
152
80153impl TextEngine {
81 /// Register Arimo as Arial or Carlito as Calibri before creating layouts.
82 pub fn register_substitute(
83 &mut self,
84 data: parley::fontique::Blob<u8>,
85 ) -> Result<&'static str, LayoutError> {
154 /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as
155 /// Courier New before creating layouts, in place of the bundled substitute.
156 pub fn register_substitute(&mut self, data: Blob<u8>) -> Result<&'static str, LayoutError> {
157 self.register(data, false)
158 }
159
160 fn register(&mut self, data: Blob<u8>, bundled: bool) -> Result<&'static str, LayoutError> {
86161 let font =
87162 FontRef::new(data.as_ref()).map_err(|_| LayoutError::UnsupportedSubstituteFont)?;
88163 let family = font
......@@ -94,9 +169,11 @@ impl TextEngine {
94169 })
95170 .map(|name| name.to_string())
96171 .ok_or(LayoutError::UnsupportedSubstituteFont)?;
97 let target = match family.as_str() {
98 "Arimo" => "Arial",
99 "Carlito" => "Calibri",
172 let (substitute, target) = match family.as_str() {
173 "Arimo" => ("Arimo", "Arial"),
174 "Carlito" => ("Carlito", "Calibri"),
175 "Tinos" => ("Tinos", "Times New Roman"),
176 "Cousine" => ("Cousine", "Courier New"),
100177 _ => return Err(LayoutError::UnsupportedSubstituteFont),
101178 };
102179 let id = data.id();
......@@ -110,8 +187,23 @@ impl TextEngine {
110187 return Err(LayoutError::InvalidFontMetrics);
111188 }
112189 }
113 let registered = self.fonts.collection.register_fonts(
114 data,
190 let collection = &mut self.fonts.collection;
191 // A substitute of the caller's replaces the bundled one outright.
192 if !bundled
193 && self
194 .substitutes
195 .get(target)
196 .is_some_and(|known| known.bundled)
197 && let Some(replaced) = self.substitutes.remove(target)
198 && let Some(family) = collection.family_id(target)
199 {
200 for (face, info) in replaced.faces {
201 collection.unregister_font(family, info.width(), info.style(), info.weight());
202 self.arial_substitutes.remove(&face.id());
203 }
204 }
205 let registered = collection.register_fonts(
206 data.clone(),
115207 Some(parley::fontique::FontInfoOverride {
116208 family_name: Some(target),
117209 ..Default::default()
......@@ -123,9 +215,25 @@ impl TextEngine {
123215 if target == "Arial" {
124216 self.arial_substitutes.insert(id);
125217 }
218 let entry = self.substitutes.entry(target).or_insert(Substitute {
219 name: substitute,
220 faces: Vec::new(),
221 bundled,
222 });
223 entry.faces.extend(
224 registered
225 .into_iter()
226 .flat_map(|(_, infos)| infos)
227 .map(|info| (data.clone(), info)),
228 );
126229 Ok(target)
127230 }
128231
232 /// The font `family` lays out in when a substitute stands in for it.
233 pub fn substitute(&self, family: &str) -> Option<&Substitute> {
234 self.substitutes.get(family)
235 }
236
129237 fn shape(
130238 &mut self,
131239 paragraph: &Paragraph,
......@@ -466,14 +574,9 @@ mod tests {
466574 use onestore::document::Format;
467575
468576 #[test]
469 #[ignore = "requires Arial and CANVAS_TEST_SUBSTITUTE pointing to Carlito"]
577 #[ignore = "requires Arial"]
470578 fn native_unkerned_advances() {
471579 let mut engine = TextEngine::default();
472 engine
473 .register_substitute(parley::fontique::Blob::new(Arc::new(
474 std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(),
475 )))
476 .unwrap();
477580 for (font, expected) in [("Calibri", 149.63843), ("Arial", 184.58305)] {
478581 let paragraph = Paragraph::new(
479582 "AVATAR AVATAR SECOND OFFICE".into(),
......@@ -489,6 +592,34 @@ mod tests {
489592 }
490593 }
491594
595 #[test]
596 fn a_registered_substitute_replaces_the_bundled_one() {
597 let mut engine = TextEngine::default();
598 if engine.substitute("Calibri").is_none() {
599 return;
600 }
601 let explicit = Blob::new(Arc::new(BUNDLED[0].1[0].to_vec()));
602 assert_eq!(engine.register_substitute(explicit.clone()), Ok("Calibri"));
603 let substitute = engine.substitute("Calibri").unwrap();
604 assert_eq!(substitute.faces.len(), 1);
605 let paragraph = Paragraph::new(
606 "Calibri".into(),
607 Format {
608 font: Some("Calibri".into()),
609 bold: Some(true),
610 ..Format::default()
611 },
612 );
613 let layout = engine.layout(&paragraph, 400.0).unwrap();
614 let (line, _) = layout.lines().next().unwrap();
615 for item in line.items() {
616 let PositionedLayoutItem::GlyphRun(run) = item else {
617 continue;
618 };
619 assert_eq!(run.run().font().font.data.id(), explicit.id());
620 }
621 }
622
492623 #[test]
493624 fn rejected_substitute_leaves_font_selection_unchanged() {
494625 let mut engine = TextEngine::default();
crates/canvas/src/lib.rs+1
......@@ -4,6 +4,7 @@ pub mod language;
44pub mod layout;
55pub mod math;
66pub mod outline;
7pub mod template;
78
89pub mod date;
910#[cfg(feature = "gpu")]
crates/canvas/src/outline.rs+5-6
......@@ -203,6 +203,9 @@ pub struct ParagraphLayout {
203203 pub(crate) parent: Option<ExGuid>,
204204 /// An equation draws in two dimensions in place of its linear text.
205205 pub math: Option<crate::math::MathLayout>,
206 /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its
207 /// outline behind its lines, as OneNote marks a conflict page's conflicting changes.
208 pub band: Option<u32>,
206209}
207210
208211#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
......@@ -626,6 +629,7 @@ impl ParagraphLayout {
626629 tags,
627630 math,
628631 parent: paragraph.parent,
632 band: paragraph.format.highlight,
629633 };
630634 result.place_tags(result.marker_left());
631635 Ok(result)
......@@ -1688,7 +1692,7 @@ mod tests {
16881692 }
16891693
16901694 #[test]
1691 #[ignore = "requires CANVAS_TEST_SECTION native Tab capture and CANVAS_TEST_SUBSTITUTE Carlito font"]
1695 #[ignore = "requires CANVAS_TEST_SECTION native Tab capture"]
16921696 fn native_table_layout() {
16931697 use onestore::page::{Page, PageObject};
16941698 use onestore::{RevisionIndex, Store, document::Document};
......@@ -1697,11 +1701,6 @@ mod tests {
16971701 let index = RevisionIndex::parse(&store).unwrap();
16981702 let document = Document::parse(&index).unwrap();
16991703 let mut engine = TextEngine::default();
1700 engine
1701 .register_substitute(parley::fontique::Blob::new(std::sync::Arc::new(
1702 std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(),
1703 )))
1704 .unwrap();
17051704 let page = Page::from_document(&document, "rows").unwrap();
17061705 let outline = page
17071706 .objects
crates/canvas/src/template.rs created+644
......@@ -0,0 +1,644 @@
1//! The page templates Snowbound offers: OneNote 2010's background art, recreated
2//! (`gpu::page::template_picture`), placed where OneNote's templates place it, and plain
3//! page colours.
4
5/// One background picture of a template: the recreated art's name, and the picture's
6/// position and size in points from the page origin as OneNote's template stores them.
7/// Art without a size draws at its natural size.
8#[derive(Clone, Copy, Debug, PartialEq)]
9pub struct Art {
10 pub art: &'static str,
11 pub position: [f32; 2],
12 pub size: Option<[f32; 2]>,
13}
14
15/// A template by OneNote's name for it, with the art it puts behind the page.
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub struct Template {
18 pub name: &'static str,
19 pub art: &'static [Art],
20}
21
22/// OneNote 2010's Decorative templates and Informal Meeting Notes (resources/onenote-templates.md),
23/// in the order the gallery lists them.
24pub const TEMPLATES: &[Template] = &[
25 Template {
26 name: "Informal Meeting Notes",
27 art: &[Art {
28 art: "small-push-pins",
29 position: [-18.0, -39.6],
30 size: Some([610.3, 789.8]),
31 }],
32 },
33 Template {
34 name: "Bamboo",
35 art: &[Art {
36 art: "bamboo",
37 position: [-36.0, -75.6],
38 size: Some([581.1, 754.3]),
39 }],
40 },
41 Template {
42 name: "Binoculars Corner",
43 art: &[Art {
44 art: "binoculars-corner",
45 position: [-54.0, -12.6],
46 size: Some([353.7, 80.0]),
47 }],
48 },
49 Template {
50 name: "Black and Green Title",
51 art: &[Art {
52 art: "black-and-green-title",
53 position: [0.0, -36.6],
54 size: Some([633.7, 39.6]),
55 }],
56 },
57 Template {
58 name: "Blue Bubbles Corner",
59 art: &[
60 Art {
61 art: "blue-bubbles-corner-margin",
62 position: [-54.0, -3.6],
63 size: Some([59.6, 247.5]),
64 },
65 Art {
66 art: "blue-bubbles-corner-top",
67 position: [443.2, -3.6],
68 size: Some([47.7, 63.7]),
69 },
70 ],
71 },
72 Template {
73 name: "Blue Clouds",
74 art: &[Art {
75 art: "blue-clouds",
76 position: [-2.2, 0.9],
77 size: Some([662.2, 905.0]),
78 }],
79 },
80 Template {
81 name: "Blue Dots",
82 art: &[Art {
83 art: "blue-dots",
84 position: [-1.5, 0.1],
85 size: Some([602.7, 780.0]),
86 }],
87 },
88 Template {
89 name: "Blue Flow",
90 art: &[Art {
91 art: "blue-flow",
92 position: [0.0, -0.6],
93 size: Some([33.1, 498.0]),
94 }],
95 },
96 Template {
97 name: "Blue Mist Margin",
98 art: &[Art {
99 art: "blue-mist-margin",
100 position: [-26.2, -3.6],
101 size: Some([36.0, 431.8]),
102 }],
103 },
104 Template {
105 name: "Blue Stripe Title",
106 art: &[Art {
107 art: "blue-stripe-title",
108 position: [0.0, -12.6],
109 size: Some([662.2, 103.0]),
110 }],
111 },
112 Template {
113 name: "Blue Stripes",
114 art: &[Art {
115 art: "blue-stripes",
116 position: [-63.0, -21.6],
117 size: Some([611.2, 791.0]),
118 }],
119 },
120 Template {
121 name: "Blue Swirls",
122 art: &[Art {
123 art: "blue-swirls",
124 position: [0.0, -3.6],
125 size: Some([595.6, 775.0]),
126 }],
127 },
128 Template {
129 name: "Blue Wave Title",
130 art: &[Art {
131 art: "blue-wave-title",
132 position: [-25.5, -15.6],
133 size: Some([547.2, 42.9]),
134 }],
135 },
136 Template {
137 name: "Blue Waves",
138 art: &[Art {
139 art: "blue-waves",
140 position: [0.0, -12.6],
141 size: Some([634.3, 812.5]),
142 }],
143 },
144 Template {
145 name: "Books",
146 art: &[Art {
147 art: "books",
148 position: [-72.0, -3.6],
149 size: Some([183.9, 658.9]),
150 }],
151 },
152 Template {
153 name: "Bouquet",
154 art: &[Art {
155 art: "bouquet",
156 position: [-45.0, -39.6],
157 size: Some([505.9, 654.8]),
158 }],
159 },
160 Template {
161 name: "Boxes",
162 art: &[Art {
163 art: "boxes",
164 position: [-36.0, -3.6],
165 size: None,
166 }],
167 },
168 Template {
169 name: "Boxes - Shadow",
170 art: &[Art {
171 art: "boxes-shadow",
172 position: [-18.0, 14.4],
173 size: Some([30.0, 456.8]),
174 }],
175 },
176 Template {
177 name: "Bubbles",
178 art: &[Art {
179 art: "bubbles",
180 position: [-18.0, -3.6],
181 size: Some([36.0, 425.3]),
182 }],
183 },
184 Template {
185 name: "Bubbles Margin",
186 art: &[
187 Art {
188 art: "bubbles-margin-margin",
189 position: [-54.0, 68.4],
190 size: Some([54.9, 288.0]),
191 },
192 Art {
193 art: "bubbles-margin-top",
194 position: [-54.0, -21.6],
195 size: Some([432.0, 70.4]),
196 },
197 ],
198 },
199 Template {
200 name: "Buildings",
201 art: &[Art {
202 art: "buildings",
203 position: [-36.0, -21.6],
204 size: Some([597.5, 773.3]),
205 }],
206 },
207 Template {
208 name: "Chain Title",
209 art: &[Art {
210 art: "chain-title",
211 position: [0.0, -11.1],
212 size: Some([533.7, 95.7]),
213 }],
214 },
215 Template {
216 name: "Circles",
217 art: &[Art {
218 art: "circles",
219 position: [-54.0, -3.6],
220 size: Some([68.7, 549.8]),
221 }],
222 },
223 Template {
224 name: "Clock",
225 art: &[Art {
226 art: "clock",
227 position: [-18.0, -3.6],
228 size: Some([607.9, 786.7]),
229 }],
230 },
231 Template {
232 name: "Columns",
233 art: &[Art {
234 art: "columns",
235 position: [-37.5, -8.9],
236 size: Some([602.1, 779.2]),
237 }],
238 },
239 Template {
240 name: "Computer",
241 art: &[Art {
242 art: "computer",
243 position: [-18.0, -21.6],
244 size: Some([462.7, 618.0]),
245 }],
246 },
247 Template {
248 name: "Cyan Title",
249 art: &[Art {
250 art: "cyan-title",
251 position: [0.7, -12.6],
252 size: Some([717.0, 102.3]),
253 }],
254 },
255 Template {
256 name: "Day Planner",
257 art: &[Art {
258 art: "day-planner",
259 position: [-36.0, -3.6],
260 size: Some([482.5, 487.2]),
261 }],
262 },
263 Template {
264 name: "Fireworks",
265 art: &[Art {
266 art: "fireworks",
267 position: [-72.0, -3.6],
268 size: Some([98.8, 288.0]),
269 }],
270 },
271 Template {
272 name: "Flat Squares",
273 art: &[Art {
274 art: "flat-squares",
275 position: [-18.0, -3.6],
276 size: Some([30.0, 492.8]),
277 }],
278 },
279 Template {
280 name: "Flowers and Hearts",
281 art: &[Art {
282 art: "flowers-and-hearts",
283 position: [-54.0, -3.6],
284 size: Some([88.8, 689.8]),
285 }],
286 },
287 Template {
288 name: "Glasses Corner",
289 art: &[Art {
290 art: "glasses-corner",
291 position: [-81.0, -56.1],
292 size: Some([229.5, 120.7]),
293 }],
294 },
295 Template {
296 name: "Globe",
297 art: &[Art {
298 art: "globe",
299 position: [-54.0, -3.6],
300 size: Some([142.7, 557.7]),
301 }],
302 },
303 Template {
304 name: "Graph",
305 art: &[Art {
306 art: "graph",
307 position: [-36.0, -3.6],
308 size: Some([46.5, 495.7]),
309 }],
310 },
311 Template {
312 name: "Green Stripes",
313 art: &[Art {
314 art: "green-stripes",
315 position: [-72.0, -21.6],
316 size: Some([612.0, 792.0]),
317 }],
318 },
319 Template {
320 name: "Hearts",
321 art: &[Art {
322 art: "hearts",
323 position: [-90.0, -3.6],
324 size: Some([89.2, 288.0]),
325 }],
326 },
327 Template {
328 name: "Ivy",
329 art: &[Art {
330 art: "ivy",
331 position: [-27.0, -3.6],
332 size: Some([174.5, 640.0]),
333 }],
334 },
335 Template {
336 name: "Large Push Pins",
337 art: &[Art {
338 art: "large-push-pins",
339 position: [-18.0, -21.6],
340 size: Some([529.7, 685.5]),
341 }],
342 },
343 Template {
344 name: "Light Bulb Corner",
345 art: &[Art {
346 art: "light-bulb-corner",
347 position: [0.0, -5.9],
348 size: Some([155.7, 164.7]),
349 }],
350 },
351 Template {
352 name: "Lilies",
353 art: &[Art {
354 art: "lilies",
355 position: [0.0, -3.6],
356 size: Some([618.5, 811.5]),
357 }],
358 },
359 Template {
360 name: "Math",
361 art: &[Art {
362 art: "math",
363 position: [-36.0, -21.6],
364 size: Some([512.6, 568.1]),
365 }],
366 },
367 Template {
368 name: "Networking",
369 art: &[
370 Art {
371 art: "networking",
372 position: [-36.0, 212.4],
373 size: None,
374 },
375 Art {
376 art: "networking",
377 position: [-36.0, 104.4],
378 size: None,
379 },
380 Art {
381 art: "networking",
382 position: [-36.0, -3.6],
383 size: None,
384 },
385 ],
386 },
387 Template {
388 name: "Notebook",
389 art: &[Art {
390 art: "notebook",
391 position: [-18.0, -21.6],
392 size: Some([483.9, 626.3]),
393 }],
394 },
395 Template {
396 name: "Orange Margin",
397 art: &[Art {
398 art: "orange-margin",
399 position: [-27.0, -3.6],
400 size: Some([37.4, 483.0]),
401 }],
402 },
403 Template {
404 name: "Paper",
405 art: &[Art {
406 art: "paper",
407 position: [-18.0, -57.6],
408 size: Some([612.6, 792.7]),
409 }],
410 },
411 Template {
412 name: "Paws - Cyan",
413 art: &[Art {
414 art: "paws-cyan",
415 position: [-18.0, -3.6],
416 size: Some([36.0, 288.0]),
417 }],
418 },
419 Template {
420 name: "Pencil and Notebook",
421 art: &[Art {
422 art: "pencil-and-notebook",
423 position: [-18.0, -57.6],
424 size: Some([620.8, 469.5]),
425 }],
426 },
427 Template {
428 name: "Pens",
429 art: &[Art {
430 art: "pens",
431 position: [-36.0, -39.6],
432 size: Some([601.0, 777.8]),
433 }],
434 },
435 Template {
436 name: "Pixel",
437 art: &[Art {
438 art: "pixel",
439 position: [-18.0, -21.6],
440 size: Some([432.0, 26.3]),
441 }],
442 },
443 Template {
444 name: "Pixel - Blends",
445 art: &[Art {
446 art: "pixel-blends",
447 position: [-18.0, -3.6],
448 size: Some([29.2, 354.0]),
449 }],
450 },
451 Template {
452 name: "Plain and Simple",
453 art: &[Art {
454 art: "plain-and-simple",
455 position: [-36.0, -12.6],
456 size: Some([606.8, 785.3]),
457 }],
458 },
459 Template {
460 name: "Purple Clouds",
461 art: &[Art {
462 art: "purple-clouds",
463 position: [0.0, -21.6],
464 size: Some([613.5, 163.7]),
465 }],
466 },
467 Template {
468 name: "Pushpins Corner",
469 art: &[Art {
470 art: "pushpins-corner",
471 position: [-36.0, -39.6],
472 size: Some([162.0, 111.9]),
473 }],
474 },
475 Template {
476 name: "Rainbow",
477 art: &[Art {
478 art: "rainbow",
479 position: [-36.0, -3.6],
480 size: Some([612.0, 792.0]),
481 }],
482 },
483 Template {
484 name: "Rainbow Border",
485 art: &[Art {
486 art: "rainbow-border",
487 position: [-18.0, -3.6],
488 size: Some([29.2, 399.0]),
489 }],
490 },
491 Template {
492 name: "Rainbow Mini Border",
493 art: &[Art {
494 art: "rainbow-mini-border",
495 position: [-18.0, -3.6],
496 size: Some([24.0, 368.2]),
497 }],
498 },
499 Template {
500 name: "Recipe",
501 art: &[Art {
502 art: "recipe",
503 position: [-45.0, -3.6],
504 size: Some([533.2, 690.0]),
505 }],
506 },
507 Template {
508 name: "Red and Black Margin",
509 art: &[Art {
510 art: "red-and-black-margin",
511 position: [0.7, 6.9],
512 size: Some([11.8, 484.5]),
513 }],
514 },
515 Template {
516 name: "Side Stripes",
517 art: &[Art {
518 art: "side-stripes",
519 position: [-36.0, -3.6],
520 size: Some([40.2, 848.4]),
521 }],
522 },
523 Template {
524 name: "Small Push Pins",
525 art: &[Art {
526 art: "small-push-pins",
527 position: [-36.0, -39.6],
528 size: Some([610.3, 789.8]),
529 }],
530 },
531 Template {
532 name: "Sparks",
533 art: &[Art {
534 art: "sparks",
535 position: [-18.0, -3.6],
536 size: Some([611.4, 791.3]),
537 }],
538 },
539 Template {
540 name: "Stars",
541 art: &[Art {
542 art: "stars",
543 position: [-36.0, -3.6],
544 size: Some([61.8, 341.9]),
545 }],
546 },
547 Template {
548 name: "Swirls",
549 art: &[Art {
550 art: "swirls",
551 position: [-36.0, -3.6],
552 size: Some([56.2, 366.3]),
553 }],
554 },
555 Template {
556 name: "Tiles",
557 art: &[Art {
558 art: "tiles",
559 position: [-36.0, -3.6],
560 size: Some([606.2, 784.5]),
561 }],
562 },
563 Template {
564 name: "Triangles Title",
565 art: &[
566 Art {
567 art: "triangles-title-top",
568 position: [0.0, -21.6],
569 size: Some([274.2, 53.0]),
570 },
571 Art {
572 art: "triangles-title-margin",
573 position: [5.2, 231.9],
574 size: Some([11.8, 288.0]),
575 },
576 ],
577 },
578 Template {
579 name: "Tulips",
580 art: &[Art {
581 art: "tulips",
582 position: [-72.0, -3.6],
583 size: Some([605.0, 783.0]),
584 }],
585 },
586 Template {
587 name: "Window Reflection",
588 art: &[Art {
589 art: "window-reflection",
590 position: [-18.0, -75.6],
591 size: Some([603.3, 780.7]),
592 }],
593 },
594 Template {
595 name: "Writing",
596 art: &[Art {
597 art: "writing",
598 position: [-54.0, -39.6],
599 size: Some([597.5, 773.2]),
600 }],
601 },
602];
603
604/// OneNote 2010's page colours in its View, Page Color menu order, with the names its
605/// tooltips give them; COLORREF, as the page XML reports each
606/// (evidence/notebook-management/page-colors).
607pub const PAGE_COLORS: &[(&str, u32)] = &[
608 ("Blue", 0xfef5ed),
609 ("Red", 0xefeeff),
610 ("Lemon", 0xddfdfd),
611 ("Apple", 0xe5f9ec),
612 ("Blue Mist", 0xf3ede5),
613 ("Magenta", 0xf6edfb),
614 ("Yellow", 0xdffaff),
615 ("Green", 0xe5f0ec),
616 ("Silver", 0xede9e9),
617 ("Purple", 0xffe9f3),
618 ("Tan", 0xe7f4f9),
619 ("Cyan", 0xebefe5),
620 ("Purple Mist", 0xebe3e8),
621 ("Red Chalk", 0xdedeef),
622 ("Orange", 0xe1f2fb),
623 ("Teal", 0xf2f9d4),
624];
625
626/// The template named `name`.
627pub fn find(name: &str) -> Option<&'static Template> {
628 TEMPLATES.iter().find(|template| template.name == name)
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
636 fn templates_have_unique_names_and_art() {
637 let mut names: Vec<_> = TEMPLATES.iter().map(|template| template.name).collect();
638 names.sort_unstable();
639 names.dedup();
640 assert_eq!(names.len(), TEMPLATES.len());
641 assert!(TEMPLATES.iter().all(|template| !template.art.is_empty()));
642 assert_eq!(find("Ivy").unwrap().art[0].art, "ivy");
643 }
644}
crates/canvas/tests/ops_differential.rs+154-79
......@@ -1,6 +1,6 @@
1//! The ops the editor records for each edit, applied to the section, store what the edited
2//! page lowered whole (`Section::apply_page(editor.page())`) stores: every editor operation
3//! alone and in random sequences with undo and redo, on every page of the corpus sections
1//! The ops the editor records for each edit, applied to the section, store what the model
2//! oracle (`op::predict`) predicts from them, read back so once sealed, and hold the editor's
3//! page or what it lowered whole stores: every editor operation alone and in random sequences with undo and redo, on every page of the corpus sections
44//! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural
55//! probe section, which `structural_roundtrip` sweeps, takes minutes).
66
......@@ -11,8 +11,9 @@ use canvas::{
1111};
1212use draw::edit::Movement;
1313use onestore::{
14 Arena, ExGuid, Section,
15 op::{Edit, Op},
14 Arena, ExGuid, RevisionIndex, Section, Store,
15 document::{Document, Kind as Node},
16 op::{Edit, Op, PageOp},
1617 page::Page,
1718};
1819use std::{collections::BTreeMap, path::Path};
......@@ -372,7 +373,7 @@ fn perform(
372373 }
373374}
374375
375/// How a step's ops compared with its page lowered whole.
376/// How a step's ops compared with the editor's page.
376377#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
377378enum Outcome {
378379 /// The ops store the editor's page, or what that page lowered whole stores.
......@@ -383,7 +384,8 @@ enum Outcome {
383384 /// stored.
384385 OpsOnly,
385386 /// The paths part where the section already stored the page differently from the
386 /// editor's model, as the writers normalize some values.
387 /// editor's model, as the writers normalize some values, or where text keeps an insertion
388 /// style the page model cannot show.
387389 Normalized,
388390 Differs(String),
389391}
......@@ -395,116 +397,190 @@ struct Stored<'a> {
395397 at: u64,
396398}
397399
400/// The text objects of the page in `space` whose empty final run keeps an insertion style,
401/// which inserting at their end takes and the page model cannot show.
402fn hidden_styles(image: &[u8], space: ExGuid) -> Vec<ExGuid> {
403 let store = Store::parse(image).unwrap();
404 let index = RevisionIndex::parse(&store).unwrap();
405 let document = Document::parse(&index).unwrap();
406 let space = &document.spaces[&space];
407 space.revisions[&space.contexts[&ExGuid::default()]]
408 .nodes
409 .iter()
410 .filter(|(_, node)| {
411 matches!(&node.kind, Node::RichText { text, runs, .. }
412 if !text.is_empty() && runs.len() > 1 && runs.last().is_some_and(|r| r.start == r.end))
413 })
414 .map(|(id, _)| *id)
415 .collect()
416}
417
418/// A page as JSON without tags' property-set indices, which the writer assigns.
419fn comparable(page: &Page) -> serde_json::Value {
420 fn strip(value: &mut serde_json::Value) {
421 match value {
422 serde_json::Value::Object(map) => {
423 map.remove("extra_set");
424 map.values_mut().for_each(strip);
425 }
426 serde_json::Value::Array(items) => items.iter_mut().for_each(strip),
427 _ => {}
428 }
429 }
430 let mut value = serde_json::to_value(page).unwrap();
431 strip(&mut value);
432 value
433}
434
398435impl Stored<'_> {
399 /// Stores the editor's step as its ops and, unless they store the editor's page,
400 /// compares with that page lowered whole onto the section as it was before the step.
436 /// Stores the editor's step as its ops, checks the stored page against what the model
437 /// oracle predicts from them and against the sealed image reread, and, unless it is the
438 /// editor's page, compares with that page lowered whole onto the section as it was
439 /// before the step.
401440 fn step(&mut self, editor: &mut CanvasEditor, model: &Page) -> Outcome {
402441 self.at += 10_000_000;
403442 let image = self.section.image();
404 let reads_as = |stored: Page, model: &Page| {
405 Page {
406 title: model.title.clone(),
407 ..stored
408 } == *model
409 };
410 let normalized = !reads_as(self.section.page(self.space).unwrap(), model);
443 let before = self.section.page(self.space).unwrap();
444 let normalized = !reads_as(&before, model);
411445 let ops = editor.take_ops().map_err(|error| error.to_string());
412 let edit = ops.clone().map(|ops| Edit {
413 at: self.at,
414 ops: ops
415 .into_iter()
416 .map(|op| Op::Page {
417 space: self.space,
418 op,
419 })
420 .collect(),
421 });
422 let applied = edit.and_then(|edit| {
423 if edit.ops.is_empty() {
446 let applied = ops.clone().and_then(|ops| {
447 if ops.is_empty() {
424448 return Ok(());
425449 }
450 let edit = Edit {
451 at: self.at,
452 ops: ops
453 .into_iter()
454 .map(|op| Op::Page {
455 space: self.space,
456 op,
457 })
458 .collect(),
459 };
426460 self.section
427461 .apply("Sweep", &edit)
428462 .map_err(|error| error.to_string())
429463 });
464 let stored = self.section.page(self.space).unwrap();
430465 let edited = editor.page().unwrap();
431 if applied.is_ok() && reads_as(self.section.page(self.space).unwrap(), &edited) {
432 self.section.seal().unwrap();
433 return Outcome::Same;
466 let outcome = self.classify(image, &before, &stored, &edited, &ops, applied, normalized);
467 if !matches!(outcome, Outcome::Same | Outcome::Refused)
468 && std::env::var_os("OPS_SWEEP_DEBUG").is_some()
469 {
470 eprintln!("DIFFERS {outcome:?}\n ops:");
471 for op in ops.iter().flatten() {
472 let op = format!("{op:?}");
473 eprintln!(" {}", &op[..op.len().min(600)]);
474 }
475 }
476 self.section.seal().unwrap();
477 let arena = Arena::default();
478 let reread = Section::open(&arena, self.section.image())
479 .unwrap()
480 .page(self.space)
481 .unwrap();
482 if reread != stored {
483 return Outcome::Differs(format!(
484 "reads back otherwise: {}",
485 difference(&stored, &reread)
486 ));
487 }
488 outcome
489 }
490
491 #[allow(clippy::too_many_arguments)]
492 fn classify(
493 &self,
494 image: Vec<u8>,
495 before: &Page,
496 stored: &Page,
497 edited: &Page,
498 ops: &Result<Vec<PageOp>, String>,
499 applied: Result<(), String>,
500 normalized: bool,
501 ) -> Outcome {
502 if let Err(error) = &applied
503 && stored != before
504 {
505 return Outcome::Differs(format!("a refused edit changed the page: {error}"));
506 }
507 if let (Ok(()), Ok(ops)) = (&applied, ops) {
508 let mut predicted = before.clone();
509 if let Err(error) = ops
510 .iter()
511 .try_for_each(|op| onestore::op::predict(&mut predicted, op))
512 {
513 return Outcome::Differs(format!(
514 "the model refuses what the section stores: {error}"
515 ));
516 }
517 if comparable(&predicted) != comparable(stored) {
518 let hidden = hidden_styles(&image, self.space);
519 if ops.iter().any(|op| {
520 matches!(op, PageOp::Text { text, .. } | PageOp::Link { text, .. } | PageOp::Split { text, .. }
521 if hidden.contains(text))
522 }) {
523 return Outcome::Normalized;
524 }
525 return Outcome::Differs(format!(
526 "the model predicts otherwise: {}",
527 difference(stored, &predicted)
528 ));
529 }
530 if reads_as(stored, edited) {
531 return Outcome::Same;
532 }
434533 }
435534 let arena = Arena::default();
436535 let mut whole = Section::open(&arena, image).unwrap();
437 let lowered = onestore::op::lower_page(&whole.page(self.space).unwrap(), &edited)
536 let lowered = onestore::op::lower_page(before, edited)
438537 .map_err(|error| error.to_string())
439538 .and_then(|ops| {
440539 let edit = Edit {
441540 at: self.at,
442541 ops: ops
443 .iter()
542 .into_iter()
444543 .map(|op| Op::Page {
445544 space: self.space,
446 op: op.clone(),
545 op,
447546 })
448547 .collect(),
449548 };
450549 whole
451550 .apply("Sweep", &edit)
452 .map_err(|error| error.to_string())?;
453 Ok(ops)
551 .map_err(|error| error.to_string())
454552 });
455 let outcome = match (applied, &lowered) {
553 match (applied, lowered) {
456554 (Err(error), Err(whole)) => {
457555 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
458556 eprintln!("BOTH {error} | {whole}");
459557 }
460558 Outcome::Refused
461559 }
462 (Ok(()), Ok(_)) => {
463 let (a, b) = (
464 self.section.page(self.space).unwrap(),
465 whole.page(self.space).unwrap(),
466 );
467 if a == b {
560 (Ok(()), Ok(())) => {
561 let whole = whole.page(self.space).unwrap();
562 if *stored == whole {
468563 Outcome::Same
469564 } else if normalized {
470565 Outcome::Normalized
471566 } else {
472 Outcome::Differs(difference(&a, &b))
473 }
474 }
475 (Err(_), Ok(_)) if normalized => Outcome::Normalized,
476 (Err(error), Ok(_)) => Outcome::Differs(format!("ops refused: {error}")),
477 (Ok(()), Err(_)) => {
478 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
479 let stored = self.section.page(self.space).unwrap();
480 eprintln!("OPSONLY-MODEL {}", difference(&stored, &edited));
481 }
482 Outcome::OpsOnly
483 }
484 };
485 if !matches!(outcome, Outcome::Same | Outcome::Refused)
486 && std::env::var_os("OPS_SWEEP_DEBUG").is_some()
487 {
488 let show = |ops: &[onestore::op::PageOp]| {
489 for op in ops {
490 let op = format!("{op:?}");
491 eprintln!(" {}", &op[..op.len().min(600)]);
567 Outcome::Differs(difference(stored, &whole))
492568 }
493 };
494 eprintln!("DIFFERS {outcome:?}\n ops:");
495 if let Ok(ops) = &ops {
496 show(ops);
497 }
498 eprintln!(" whole:");
499 if let Ok(ops) = &lowered {
500 show(ops);
501569 }
570 (Err(_), Ok(())) if normalized => Outcome::Normalized,
571 (Err(error), Ok(())) => Outcome::Differs(format!("ops refused: {error}")),
572 (Ok(()), Err(_)) => Outcome::OpsOnly,
502573 }
503 self.section.seal().unwrap();
504 outcome
505574 }
506575}
507576
577fn reads_as(stored: &Page, model: &Page) -> bool {
578 Page {
579 title: model.title.clone(),
580 ..stored.clone()
581 } == *model
582}
583
508584/// The first place two pages part: an object missing from one, then objects by identity,
509585/// then their order.
510586fn difference(a: &Page, b: &Page) -> String {
......@@ -613,7 +689,7 @@ fn sweep(
613689}
614690
615691#[test]
616fn editor_ops_store_what_the_page_lowered_whole_stores() {
692fn editor_ops_store_the_editor_s_page() {
617693 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
618694 let mut paths: Vec<_> = SECTIONS.iter().map(|path| root.join(path)).collect();
619695 if let Some(extra) = std::env::var_os("OPS_SWEEP_SECTIONS") {
......@@ -645,17 +721,16 @@ fn editor_ops_store_what_the_page_lowered_whole_stores() {
645721 }
646722 let mut totals = [0; 5];
647723 for (kind, counts) in &per_kind {
648 let [same, refused, only, normalized, differs] = counts;
724 let [same, refused, ops_only, normalized, differs] = counts;
649725 report += &format!(
650 "{kind:?}: {same} same, {refused} both refused, {only} only as ops, {normalized} after normalization, {differs} differ\n"
726 "{kind:?}: {same} same, {refused} refused, {ops_only} by ops only, {normalized} after normalization, {differs} differ\n"
651727 );
652728 for (total, count) in totals.iter_mut().zip(counts) {
653729 *total += count;
654730 }
655731 }
656 report += &format!(
657 "all: {totals:?} (same, both refused, only as ops, after normalization, differ)\n"
658 );
732 report +=
733 &format!("all: {totals:?} (same, refused, by ops only, after normalization, differ)\n");
659734 for ((kind, outcome), count) in &tally.outcomes {
660735 if let Outcome::Differs(reason) = outcome {
661736 report += &format!(
crates/canvas/tests/reopen.rs+17-16
......@@ -5,8 +5,9 @@ use canvas::{
55 layout::TextEngine,
66};
77use onestore::{
8 ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store,
8 Arena, ExGuid, PageCreation, RevisionIndex, Section, Store,
99 document::Document,
10 op::{Edit, Op, SectionOp},
1011 page::{Page, PageObject, ParagraphContent},
1112};
1213use std::{fs, path::Path};
......@@ -32,8 +33,8 @@ fn spaces(document: &Document<'_>) -> Vec<ExGuid> {
3233 spaces
3334}
3435
35/// What `notebook::session::Section::import_page` writes: a new titled page, then the
36/// editor's body objects saved onto it.
36/// What `notebook::session::Section::import_page` applies: a new titled page holding a copy of
37/// the editor's body objects.
3738#[test]
3839fn an_imported_editor_page_reopens_with_its_title_editable() {
3940 let section = onestore::create_section("reopen.one", "Body 🦀 é", "Author").unwrap();
......@@ -41,20 +42,20 @@ fn an_imported_editor_page_reopens_with_its_title_editable() {
4142 let (_, editor) = PageScene::from_page(pages(&section).remove(0), &mut engine).unwrap();
4243 let copy = editor.page().unwrap().copy().unwrap();
4344 let creation = PageCreation::new(None, Some("Imported 🦋"), "Author").unwrap();
44 let created = PreparedEdit::create_page(&section, &creation).unwrap();
45 let mut after = pages(created.as_bytes()).pop().unwrap();
46 after
47 .objects
48 .retain(|object| matches!(object, PageObject::Title(_)));
49 after.objects.extend(
50 copy.objects
51 .into_iter()
52 .filter(|object| !matches!(object, PageObject::Title(_))),
53 );
54 after.definitions = copy.definitions;
55 let saved = PreparedEdit::page(created.as_bytes(), creation.space(), &after, "Author").unwrap();
45 let arena = Arena::default();
46 let mut imported = Section::open(&arena, section).unwrap();
47 let import = SectionOp::Import {
48 creation,
49 page: copy,
50 };
51 let edit = Edit {
52 at: 134_000_000_000_000_000,
53 ops: vec![Op::Section(import)],
54 };
55 imported.apply("Author", &edit).unwrap();
56 imported.seal().unwrap();
5657
57 let reopened = pages(saved.as_bytes()).pop().unwrap();
58 let reopened = pages(&imported.image()).pop().unwrap();
5859 let (_, editor) = PageScene::from_page(reopened.clone(), &mut engine).unwrap();
5960 assert_eq!(editor.page().unwrap(), reopened);
6061 let texts = |title: bool| -> Vec<String> {
crates/canvas/tests/structural_roundtrip.rs+19-20
......@@ -1,5 +1,5 @@
1//! Every structural edit the editor makes saves, through the page writer and as the ops the
2//! editor recorded, and rereads as the model it saved: random Enter, Backspace, Delete, Tab,
1//! Every structural edit the editor makes saves as the ops the editor recorded and rereads
2//! as the model it saved: random Enter, Backspace, Delete, Tab,
33//! Shift+Tab, list and paste edits, alone and several to a save, on every outline of the probe
44//! section and of any sections named in `CANVAS_SWEEP_SECTIONS` (`:`-separated paths).
55
......@@ -9,7 +9,7 @@ use canvas::{
99 layout::TextEngine,
1010};
1111use onestore::{
12 Arena, ExGuid, PreparedEdit, RevisionIndex, Section, Store,
12 Arena, ExGuid, RevisionIndex, Section, Store,
1313 document::Document,
1414 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent},
1515};
......@@ -352,25 +352,14 @@ fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut
352352 .map(|op| onestore::op::Op::Page { space, op })
353353 .collect(),
354354 };
355 let ops = match stored.apply("Sweep", &edit) {
355 let failure = match stored.apply("Sweep", &edit) {
356356 Err(error) => Some(format!("ops: {error}")),
357357 Ok(()) => {
358358 let reread = stored.page(space).unwrap();
359359 (!rereads(&reread)).then(|| format!("ops: {}", difference(&after, &reread)))
360360 }
361361 };
362 let failure = match PreparedEdit::page(&section, space, &after, "Sweep") {
363 Err(error) => Some(error.message.to_owned()),
364 Ok(saved) => match pages(saved.as_bytes())
365 .into_iter()
366 .find(|(id, _)| *id == space)
367 {
368 Some((_, reread)) if rereads(&reread) => None,
369 Some((_, reread)) => Some(difference(&after, &reread)),
370 None => Some("the page does not reread".into()),
371 },
372 };
373 for failure in failure.into_iter().chain(ops) {
362 if let Some(failure) = failure {
374363 tally.failures.entry(failure).or_default().push(format!(
375364 "{plan:?} on {:?} in {}",
376365 page.title,
......@@ -483,10 +472,20 @@ fn edit(section: &mut Vec<u8>, title: &str, keys: &[(Caret, Press)], engine: &mu
483472 }
484473 }
485474 let after = editor.page().unwrap();
486 let saved = PreparedEdit::page(section, space, &after, "Snowbound")
487 .unwrap()
488 .as_bytes()
489 .to_vec();
475 let arena = Arena::default();
476 let mut stored = Section::open(&arena, section.to_vec()).unwrap();
477 let edit = onestore::op::Edit {
478 at: 133_000_000_000_000_000,
479 ops: editor
480 .take_ops()
481 .unwrap()
482 .into_iter()
483 .map(|op| onestore::op::Op::Page { space, op })
484 .collect(),
485 };
486 stored.apply("Snowbound", &edit).unwrap();
487 stored.seal().unwrap();
488 let saved = stored.image();
490489 let (_, reread) = pages(&saved)
491490 .into_iter()
492491 .find(|(id, _)| *id == space)
crates/draw/src/edit.rs+472-112
......@@ -1,5 +1,5 @@
1//! Text editing shared by the page and interface text: keys, macOS editing chords, click
2//! counting and caret movement within one parley layout.
1//! Text editing shared by the page and interface text: keys, each platform's editing chords
2//! and caret, click counting and caret movement within one parley layout.
33
44use parley::{
55 Affinity, Brush, Layout,
......@@ -30,6 +30,8 @@ pub enum NamedKey {
3030 ArrowDown,
3131 Home,
3232 End,
33 PageUp,
34 PageDown,
3335 /// Shift, Control, Option or Command pressed alone.
3436 Modifier,
3537 Other,
......@@ -39,7 +41,10 @@ pub enum NamedKey {
3941pub struct Modifiers {
4042 pub shift: bool,
4143 pub control: bool,
44 /// Option, or Alt.
4245 pub option: bool,
46 /// The shortcut modifier: Command on macOS, Control elsewhere, where it comes with
47 /// `control`.
4348 pub command: bool,
4449}
4550
......@@ -67,7 +72,7 @@ pub enum SelectionUnit {
6772 Paragraph,
6873}
6974
70/// What a key does to text under macOS conventions.
75/// What a key does to text under the platform's conventions.
7176#[derive(Clone, Copy, Debug, PartialEq)]
7277pub enum Command {
7378 /// Moves the focus, extending the selection with Shift.
......@@ -78,72 +83,285 @@ pub enum Command {
7883 DeleteTo(Movement),
7984 /// Control-K: deletes to the line's end, or the break after it when already there.
8085 Kill,
86 /// Scrolls the view a page, leaving the caret, as Page Up and Page Down do on macOS.
87 ScrollPage { up: bool },
88 /// Moves the caret a page and the view with it, extending the selection with Shift.
89 MovePage { up: bool },
8190}
8291
8392impl Command {
8493 pub fn from_key(key: &Key, modifiers: Modifiers) -> Option<Self> {
85 let Modifiers {
86 shift,
87 control,
88 option,
89 command,
90 } = modifiers;
91 let pick = |plain, with_option, with_command| {
92 Some(Self::Move(if command {
93 with_command
94 } else if option {
95 with_option
96 } else {
97 plain
98 }))
99 };
100 match key {
101 Key::Named(NamedKey::ArrowLeft) => {
102 pick(Movement::Left, Movement::WordLeft, Movement::LineStart)
94 Platform::CURRENT.command(key, modifiers)
95 }
96}
97
98/// A desktop's text editing conventions: its chords, and how its caret looks and blinks.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum Platform {
101 MacOs,
102 /// GTK, as GNOME and most Linux desktops edit text.
103 Gtk,
104 Windows,
105}
106
107/// A caret's blink, in microseconds: solid for `delay` after it moves, then repeating
108/// `phases` of fading out, off, fading in and on, each fade in `steps` equal steps, until
109/// `timeout` after the move leaves it solid.
110struct Blink {
111 delay: u64,
112 phases: [u64; 4],
113 steps: u64,
114 timeout: Option<u64>,
115}
116
117impl Platform {
118 pub const CURRENT: Platform = if cfg!(target_vendor = "apple") {
119 Platform::MacOs
120 } else if cfg!(windows) {
121 Platform::Windows
122 } else {
123 Platform::Gtk
124 };
125
126 pub fn command(self, key: &Key, modifiers: Modifiers) -> Option<Command> {
127 match self {
128 Platform::MacOs => mac_command(key, modifiers),
129 Platform::Gtk | Platform::Windows => pc_command(key, modifiers),
130 }
131 }
132
133 /// The caret's width in logical pixels: AppKit's insertion indicator, GTK's stem at
134 /// its 0.04 aspect ratio for body text, and Windows' default caret.
135 pub const fn caret_width(self) -> f32 {
136 match self {
137 Platform::MacOs => 2.0,
138 Platform::Gtk | Platform::Windows => 1.0,
139 }
140 }
141
142 fn blink(self) -> Blink {
143 match self {
144 // AppKit's insertion indicator: 350 ms off and 650 ms on, each opening with a
145 // fade in four 37.5 ms steps.
146 Platform::MacOs => Blink {
147 delay: 650_000,
148 phases: [150_000, 200_000, 150_000, 500_000],
149 steps: 4,
150 timeout: None,
151 },
152 // GtkText at the default 1200 ms blink time: half a cycle's pause after a
153 // move, then quarters of on, fading out, off and fading in, stopping after the
154 // default 10 s; its per-frame fade is taken in 60 Hz steps.
155 Platform::Gtk => Blink {
156 delay: 900_000,
157 phases: [300_000; 4],
158 steps: 18,
159 timeout: Some(10_000_000),
160 },
161 // GetCaretBlinkTime's default 530 ms, without fades, until CaretTimeout's 5 s.
162 Platform::Windows => Blink {
163 delay: 530_000,
164 phases: [0, 530_000, 0, 530_000],
165 steps: 1,
166 timeout: Some(5_000_000),
167 },
168 }
169 }
170
171 /// The caret's opacity `since` it last moved, and how long that opacity holds; None
172 /// once it has stopped blinking.
173 pub fn caret_blink(self, since: Duration) -> (f32, Option<Duration>) {
174 let Blink {
175 delay,
176 phases,
177 steps,
178 timeout,
179 } = self.blink();
180 let period: u64 = phases.iter().sum();
181 // The opacity at `time` and when it next may change.
182 let at = |time: u64| -> (f32, u64) {
183 if time < delay {
184 return (1.0, delay);
103185 }
104 Key::Named(NamedKey::ArrowRight) => {
105 pick(Movement::Right, Movement::WordRight, Movement::LineEnd)
186 let mut within = (time - delay) % period;
187 let mut start = time - within;
188 for (phase, length) in phases.into_iter().enumerate() {
189 if within < length {
190 return match phase {
191 1 => (0.0, start + length),
192 3 => (1.0, start + length),
193 _ => {
194 let step = within * steps / length;
195 let shown = step as f32 / steps as f32;
196 (
197 if phase == 0 { 1.0 - shown } else { shown },
198 start + ((step + 1) * length).div_ceil(steps),
199 )
200 }
201 };
202 }
203 within -= length;
204 start += length;
106205 }
107 Key::Named(NamedKey::ArrowUp) => pick(
108 Movement::Up,
109 Movement::ParagraphStart,
110 Movement::DocumentStart,
111 ),
112 Key::Named(NamedKey::ArrowDown) => pick(
113 Movement::Down,
114 Movement::ParagraphEnd,
115 Movement::DocumentEnd,
116 ),
117 Key::Named(NamedKey::Home) if shift => Some(Self::Move(Movement::DocumentStart)),
118 Key::Named(NamedKey::End) if shift => Some(Self::Move(Movement::DocumentEnd)),
119 Key::Named(NamedKey::Backspace) if command || option => {
120 Some(Self::DeleteTo(if command {
121 Movement::LineStart
122 } else {
123 Movement::WordLeft
124 }))
206 unreachable!("a time within the period falls in a phase")
207 };
208 let now = u64::try_from(since.as_micros()).unwrap_or(u64::MAX);
209 if timeout.is_some_and(|timeout| now >= timeout) {
210 return (1.0, None);
211 }
212 let (opacity, mut until) = at(now);
213 // A settled phase holds into the next one's first step, which shows the same.
214 for _ in 0..2 * (steps + 2) {
215 let (next, end) = at(until);
216 if next != opacity {
217 break;
218 }
219 until = end;
220 }
221 match timeout {
222 Some(timeout) if until >= timeout && opacity == 1.0 => (opacity, None),
223 Some(timeout) if until >= timeout => {
224 (opacity, Some(Duration::from_micros(timeout - now)))
125225 }
126 Key::Named(NamedKey::Delete) if command || option => Some(Self::DeleteTo(if command {
127 Movement::LineEnd
226 _ => (opacity, Some(Duration::from_micros(until - now))),
227 }
228 }
229
230 /// The caret's colour and selected text's fill with and without keyboard focus as sRGB
231 /// and alpha, for a light or `dark` appearance: AppKit's system colours, libadwaita's
232 /// text colour and translucent accent, and Windows' Highlight made translucent, as
233 /// selected text keeps its colour here.
234 pub fn text_colors(self, dark: bool) -> [([u8; 3], f32); 3] {
235 match (self, dark) {
236 (Platform::MacOs, false) => [
237 ([0x00, 0x7a, 0xff], 1.0),
238 ([0xb3, 0xd7, 0xff], 1.0),
239 ([0xdc, 0xdc, 0xdc], 1.0),
240 ],
241 (Platform::MacOs, true) => [
242 ([0x00, 0x7a, 0xff], 1.0),
243 ([0x3f, 0x63, 0x8b], 1.0),
244 ([0x46, 0x46, 0x46], 1.0),
245 ],
246 (Platform::Gtk, false) => [
247 ([0x00, 0x00, 0x06], 0.8),
248 ([0x35, 0x84, 0xe4], 0.3),
249 ([0x00, 0x00, 0x06], 0.08),
250 ],
251 (Platform::Gtk, true) => [
252 ([0xff, 0xff, 0xff], 1.0),
253 ([0x35, 0x84, 0xe4], 0.3),
254 ([0xff, 0xff, 0xff], 0.1),
255 ],
256 (Platform::Windows, false) => [
257 ([0x00, 0x00, 0x00], 1.0),
258 ([0x00, 0x78, 0xd7], 0.4),
259 ([0x00, 0x00, 0x00], 0.1),
260 ],
261 (Platform::Windows, true) => [
262 ([0xff, 0xff, 0xff], 1.0),
263 ([0x00, 0x78, 0xd7], 0.5),
264 ([0xff, 0xff, 0xff], 0.15),
265 ],
266 }
267 }
268}
269
270/// AppKit's key bindings, including its Emacs chords on Control.
271fn mac_command(key: &Key, modifiers: Modifiers) -> Option<Command> {
272 let Modifiers {
273 shift,
274 control,
275 option,
276 command,
277 } = modifiers;
278 let pick = |plain, with_option, with_command| {
279 Some(Command::Move(if command {
280 with_command
281 } else if option {
282 with_option
283 } else {
284 plain
285 }))
286 };
287 match key {
288 Key::Named(NamedKey::ArrowLeft) => {
289 pick(Movement::Left, Movement::WordLeft, Movement::LineStart)
290 }
291 Key::Named(NamedKey::ArrowRight) => {
292 pick(Movement::Right, Movement::WordRight, Movement::LineEnd)
293 }
294 Key::Named(NamedKey::ArrowUp) => pick(
295 Movement::Up,
296 Movement::ParagraphStart,
297 Movement::DocumentStart,
298 ),
299 Key::Named(NamedKey::ArrowDown) => pick(
300 Movement::Down,
301 Movement::ParagraphEnd,
302 Movement::DocumentEnd,
303 ),
304 Key::Named(NamedKey::Home) if shift => Some(Command::Move(Movement::DocumentStart)),
305 Key::Named(NamedKey::End) if shift => Some(Command::Move(Movement::DocumentEnd)),
306 Key::Named(NamedKey::PageUp) if option => Some(Command::MovePage { up: true }),
307 Key::Named(NamedKey::PageDown) if option => Some(Command::MovePage { up: false }),
308 Key::Named(NamedKey::PageUp) => Some(Command::ScrollPage { up: true }),
309 Key::Named(NamedKey::PageDown) => Some(Command::ScrollPage { up: false }),
310 Key::Named(NamedKey::Backspace) if command || option => {
311 Some(Command::DeleteTo(if command {
312 Movement::LineStart
128313 } else {
129 Movement::WordRight
130 })),
131 Key::Named(NamedKey::Backspace) => Some(Self::Delete { backward: true }),
132 Key::Named(NamedKey::Delete) => Some(Self::Delete { backward: false }),
133 Key::Character(key) if control && !option => match key.as_str() {
134 "a" => Some(Self::Move(Movement::LineStart)),
135 "e" => Some(Self::Move(Movement::LineEnd)),
136 "b" => Some(Self::Move(Movement::Left)),
137 "f" => Some(Self::Move(Movement::Right)),
138 "p" => Some(Self::Move(Movement::Up)),
139 "n" => Some(Self::Move(Movement::Down)),
140 "h" => Some(Self::Delete { backward: true }),
141 "d" => Some(Self::Delete { backward: false }),
142 "k" => Some(Self::Kill),
143 _ => None,
144 },
145 _ => None,
314 Movement::WordLeft
315 }))
146316 }
317 Key::Named(NamedKey::Delete) if command || option => Some(Command::DeleteTo(if command {
318 Movement::LineEnd
319 } else {
320 Movement::WordRight
321 })),
322 Key::Named(NamedKey::Backspace) => Some(Command::Delete { backward: true }),
323 Key::Named(NamedKey::Delete) => Some(Command::Delete { backward: false }),
324 Key::Character(key) if control && !option => match key.as_str() {
325 "a" => Some(Command::Move(Movement::LineStart)),
326 "e" => Some(Command::Move(Movement::LineEnd)),
327 "b" => Some(Command::Move(Movement::Left)),
328 "f" => Some(Command::Move(Movement::Right)),
329 "p" => Some(Command::Move(Movement::Up)),
330 "n" => Some(Command::Move(Movement::Down)),
331 "h" => Some(Command::Delete { backward: true }),
332 "d" => Some(Command::Delete { backward: false }),
333 "k" => Some(Command::Kill),
334 _ => None,
335 },
336 _ => None,
337 }
338}
339
340/// GTK's and Windows' shared bindings: Control moves and deletes by words, and with Home
341/// and End reaches the document's ends; Control with Up and Down moves by paragraphs.
342fn pc_command(key: &Key, modifiers: Modifiers) -> Option<Command> {
343 let Modifiers {
344 control, option, ..
345 } = modifiers;
346 if option {
347 return None;
348 }
349 let pick =
350 |plain, with_control| Some(Command::Move(if control { with_control } else { plain }));
351 match key {
352 Key::Named(NamedKey::ArrowLeft) => pick(Movement::Left, Movement::WordLeft),
353 Key::Named(NamedKey::ArrowRight) => pick(Movement::Right, Movement::WordRight),
354 Key::Named(NamedKey::ArrowUp) => pick(Movement::Up, Movement::ParagraphStart),
355 Key::Named(NamedKey::ArrowDown) => pick(Movement::Down, Movement::ParagraphEnd),
356 Key::Named(NamedKey::Home) => pick(Movement::LineStart, Movement::DocumentStart),
357 Key::Named(NamedKey::End) => pick(Movement::LineEnd, Movement::DocumentEnd),
358 Key::Named(NamedKey::PageUp) if !control => Some(Command::MovePage { up: true }),
359 Key::Named(NamedKey::PageDown) if !control => Some(Command::MovePage { up: false }),
360 Key::Named(NamedKey::Backspace) if control => Some(Command::DeleteTo(Movement::WordLeft)),
361 Key::Named(NamedKey::Delete) if control => Some(Command::DeleteTo(Movement::WordRight)),
362 Key::Named(NamedKey::Backspace) => Some(Command::Delete { backward: true }),
363 Key::Named(NamedKey::Delete) => Some(Command::Delete { backward: false }),
364 _ => None,
147365 }
148366}
149367
......@@ -183,35 +401,12 @@ impl Clicks {
183401 }
184402}
185403
186/// The caret's width in logical pixels, as AppKit's insertion indicator draws it.
187pub const CARET_WIDTH: f32 = 2.0;
188
189/// The caret's opacity `since` it last moved, and how long that opacity holds. As AppKit's
190/// insertion indicator: solid for 650 ms, then 350 ms off and 650 ms on, each phase
191/// opening with a fade in four 37.5 ms steps.
192pub fn caret_blink(since: Duration) -> (f32, Duration) {
193 const STEP: u64 = 37_500;
194 const ON: u64 = 650_000;
195 const OFF: u64 = 350_000;
196 let micros = u64::try_from(since.as_micros()).unwrap_or(u64::MAX);
197 let Some(cycle) = micros.checked_sub(ON) else {
198 return (1.0, Duration::from_micros(ON + STEP - micros));
199 };
200 let (into, length, fading_in) = match cycle % (ON + OFF) {
201 phase if phase < OFF => (phase, OFF, false),
202 phase => (phase - OFF, ON, true),
203 };
204 let step = into / STEP;
205 // A settled phase holds into the next one's first step, which shows the same.
206 let (faded, hold) = if step < 4 {
207 (step as f32 / 4.0, STEP * (step + 1) - into)
208 } else {
209 (1.0, length - into + STEP)
210 };
211 (
212 if fading_in { faded } else { 1.0 - faded },
213 Duration::from_micros(hold),
214 )
404/// The caret's width in logical pixels on this platform.
405pub const CARET_WIDTH: f32 = Platform::CURRENT.caret_width();
406
407/// The caret's opacity `since` it last moved on this platform; see [`Platform::caret_blink`].
408pub fn caret_blink(since: Duration) -> (f32, Option<Duration>) {
409 Platform::CURRENT.caret_blink(since)
215410}
216411
217412/// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright
......@@ -451,13 +646,109 @@ mod tests {
451646 ];
452647 for (key, modifiers, command) in cases {
453648 assert_eq!(
454 Command::from_key(&key, modifiers),
649 Platform::MacOs.command(&key, modifiers),
455650 command,
456651 "{key:?} {modifiers:?}"
457652 );
458653 }
459654 }
460655
656 #[test]
657 fn keys_follow_gtk_and_windows_editing_chords() {
658 // Control arrives as the shortcut modifier too.
659 let control = Modifiers {
660 control: true,
661 command: true,
662 ..Modifiers::default()
663 };
664 let plain = Modifiers::default();
665 let alt = Modifiers {
666 option: true,
667 ..Modifiers::default()
668 };
669 let named = |named| Key::Named(named);
670 let cases = [
671 (
672 named(NamedKey::ArrowLeft),
673 control,
674 Some(Command::Move(Movement::WordLeft)),
675 ),
676 (
677 named(NamedKey::ArrowRight),
678 control,
679 Some(Command::Move(Movement::WordRight)),
680 ),
681 (
682 named(NamedKey::ArrowUp),
683 control,
684 Some(Command::Move(Movement::ParagraphStart)),
685 ),
686 (
687 named(NamedKey::ArrowDown),
688 plain,
689 Some(Command::Move(Movement::Down)),
690 ),
691 (
692 named(NamedKey::Home),
693 plain,
694 Some(Command::Move(Movement::LineStart)),
695 ),
696 (
697 named(NamedKey::End),
698 plain,
699 Some(Command::Move(Movement::LineEnd)),
700 ),
701 (
702 named(NamedKey::Home),
703 control,
704 Some(Command::Move(Movement::DocumentStart)),
705 ),
706 (
707 named(NamedKey::End),
708 control,
709 Some(Command::Move(Movement::DocumentEnd)),
710 ),
711 (
712 named(NamedKey::PageUp),
713 plain,
714 Some(Command::MovePage { up: true }),
715 ),
716 (
717 named(NamedKey::PageDown),
718 plain,
719 Some(Command::MovePage { up: false }),
720 ),
721 (
722 named(NamedKey::Backspace),
723 control,
724 Some(Command::DeleteTo(Movement::WordLeft)),
725 ),
726 (
727 named(NamedKey::Delete),
728 control,
729 Some(Command::DeleteTo(Movement::WordRight)),
730 ),
731 (
732 named(NamedKey::Backspace),
733 plain,
734 Some(Command::Delete { backward: true }),
735 ),
736 (named(NamedKey::ArrowLeft), alt, None),
737 // No Emacs chords: Control-A and Control-E are shortcuts.
738 (Key::Character("a".into()), control, None),
739 (Key::Character("e".into()), control, None),
740 ];
741 for platform in [Platform::Gtk, Platform::Windows] {
742 for (key, modifiers, command) in &cases {
743 assert_eq!(
744 platform.command(key, *modifiers),
745 *command,
746 "{platform:?} {key:?} {modifiers:?}"
747 );
748 }
749 }
750 }
751
461752 #[test]
462753 fn presses_count_within_the_interval_and_slop_and_cycle() {
463754 let mut clicks = Clicks::new(Duration::from_millis(500));
......@@ -532,30 +823,99 @@ mod tests {
532823 assert!((green - linear(167.5)).abs() < 0.02, "{alpha}");
533824 }
534825
826 /// The opacity and hold in milliseconds `millis` after the caret moved.
827 fn blink_at(platform: Platform, millis: f64) -> (f32, Option<f64>) {
828 let (opacity, hold) = platform.caret_blink(Duration::from_secs_f64(millis / 1000.0));
829 (opacity, hold.map(|hold| hold.as_secs_f64() * 1000.0))
830 }
831
832 fn assert_blink(platform: Platform, millis: f64, expected: (f32, Option<f64>)) {
833 let (opacity, hold) = blink_at(platform, millis);
834 assert_eq!(opacity, expected.0, "{platform:?} at {millis} ms");
835 match (hold, expected.1) {
836 (Some(hold), Some(expected)) => {
837 assert!(
838 (hold - expected).abs() < 0.01,
839 "{platform:?} at {millis} ms holds {hold} ms, not {expected}"
840 )
841 }
842 (hold, expected) => assert_eq!(hold, expected, "{platform:?} at {millis} ms"),
843 }
844 }
845
846 /// How often the opacity changes over `span` milliseconds from `from`.
847 fn blink_changes(platform: Platform, from: u32, span: u32) -> usize {
848 let mut previous = blink_at(platform, f64::from(from)).0;
849 (from + 1..=from + span)
850 .filter(|&millis| {
851 let opacity = blink_at(platform, f64::from(millis)).0;
852 std::mem::replace(&mut previous, opacity) != opacity
853 })
854 .count()
855 }
856
535857 #[test]
536858 fn caret_blinks_on_appkit_s_timing() {
537 let at = |millis: f64| {
538 let (opacity, hold) = caret_blink(Duration::from_secs_f64(millis / 1000.0));
539 (opacity, hold.as_secs_f64() * 1000.0)
540 };
541 let close = |(opacity, hold): (f32, f64), expected: (f32, f64)| {
542 assert_eq!(opacity, expected.0);
543 assert!((hold - expected.1).abs() < 0.01, "{hold} ms, not {}", expected.1);
544 };
545 close(at(0.0), (1.0, 687.5));
546 close(at(687.5), (0.75, 37.5));
547 close(at(760.0), (0.5, 2.5));
548 close(at(800.0), (0.0, 237.5));
549 close(at(1037.5), (0.25, 37.5));
550 close(at(1150.0), (1.0, 537.5));
551 close(at(1687.5), (0.75, 37.5));
552 let mut changes = 0;
553 let mut previous = at(1000.0).0;
554 for millis in 1001..2001 {
555 let opacity = at(f64::from(millis)).0;
556 changes += usize::from(opacity != previous);
557 previous = opacity;
859 let mac = Platform::MacOs;
860 assert_blink(mac, 0.0, (1.0, Some(687.5)));
861 assert_blink(mac, 687.5, (0.75, Some(37.5)));
862 assert_blink(mac, 760.0, (0.5, Some(2.5)));
863 assert_blink(mac, 800.0, (0.0, Some(237.5)));
864 assert_blink(mac, 1037.5, (0.25, Some(37.5)));
865 assert_blink(mac, 1150.0, (1.0, Some(537.5)));
866 assert_blink(mac, 1687.5, (0.75, Some(37.5)));
867 assert_blink(mac, 3_600_200.0, (1.0, Some(487.5)));
868 assert_eq!(
869 blink_changes(mac, 1000, 1000),
870 8,
871 "four fade steps each way per second"
872 );
873 }
874
875 #[test]
876 fn caret_blinks_on_gtk_s_timing() {
877 let gtk = Platform::Gtk;
878 // Solid for half the 1200 ms cycle and its visible quarter, then fades out over 300 ms.
879 assert_blink(gtk, 0.0, (1.0, Some(900.0 + 1000.0 / 60.0)));
880 assert_blink(gtk, 1200.0, (0.0, Some(300.0 + 1000.0 / 60.0)));
881 assert_blink(gtk, 1800.0, (1.0, Some(300.0 + 1000.0 / 60.0)));
882 let (fading, _) = blink_at(gtk, 1050.0);
883 assert!((fading - 0.5).abs() <= 1.0 / 18.0, "{fading}");
884 // A fade each way per 1.2 s cycle, in 60 Hz steps.
885 assert_eq!(blink_changes(gtk, 900, 1200), 2 * 18);
886 // Ten seconds without moving leave it solid, cutting short the fade in.
887 assert_blink(gtk, 9_700.0, (0.0, Some(216.667)));
888 assert_blink(gtk, 9_990.0, (5.0 / 18.0, Some(10.0)));
889 assert_blink(gtk, 10_000.0, (1.0, None));
890 }
891
892 #[test]
893 fn caret_blinks_on_windows_timing() {
894 let windows = Platform::Windows;
895 assert_blink(windows, 0.0, (1.0, Some(530.0)));
896 assert_blink(windows, 530.0, (0.0, Some(530.0)));
897 assert_blink(windows, 1100.0, (1.0, Some(490.0)));
898 assert_eq!(blink_changes(windows, 530, 1060), 2, "no fades");
899 assert_blink(windows, 4800.0, (0.0, Some(200.0)));
900 assert_blink(windows, 5000.0, (1.0, None));
901 }
902
903 #[test]
904 fn carets_and_selections_match_each_platform() {
905 assert_eq!(
906 [Platform::MacOs, Platform::Gtk, Platform::Windows].map(Platform::caret_width),
907 [2.0, 1.0, 1.0]
908 );
909 for platform in [Platform::MacOs, Platform::Gtk, Platform::Windows] {
910 for dark in [false, true] {
911 let [caret, focused, unfocused] = platform.text_colors(dark);
912 assert!(caret.1 >= 0.8, "{platform:?} carets stand out");
913 assert!(focused.1 > unfocused.1 || focused.0 != unfocused.0);
914 }
558915 }
559 assert_eq!(changes, 8, "four fade steps each way per second");
916 // libadwaita tints selections with the accent at 30% and follows the text colour.
917 let [caret, selection, _] = Platform::Gtk.text_colors(false);
918 assert_eq!(selection, ([0x35, 0x84, 0xe4], 0.3));
919 assert_eq!(caret, ([0x00, 0x00, 0x06], 0.8));
560920 }
561921}
crates/draw/src/render.rs+35-7
......@@ -211,7 +211,7 @@ pub struct Layer<'a> {
211211 /// Device bounds `[left, top, right, bottom]` the layer paints within.
212212 pub clip: Option<[f32; 4]>,
213213 /// The colour behind the layer: on a dark one, text in dark colours of its own is
214 /// lifted to stay legible, as OneNote's dark page does.
214 /// lifted to stay legible against what lies behind it, as OneNote's dark page does.
215215 pub backdrop: Option<[f32; 4]>,
216216 pub primitives: &'a [Primitive<'a>],
217217}
......@@ -985,7 +985,8 @@ impl Renderer {
985985 if glyph.color {
986986 [1.0; 4]
987987 } else {
988 run.color.map_or(ink, |color| legible(color, space.backdrop))
988 run.color
989 .map_or(ink, |color| legible(color, space.backdrop, run.backdrop))
989990 },
990991 )?;
991992 }
......@@ -1004,7 +1005,7 @@ impl Renderer {
10041005 [0.5 / ATLAS_SIZE as f32; 4],
10051006 decoration
10061007 .color
1007 .map_or(ink, |color| legible(color, space.backdrop)),
1008 .map_or(ink, |color| legible(color, space.backdrop, run.backdrop)),
10081009 )?;
10091010 }
10101011 Ok(())
......@@ -1467,13 +1468,16 @@ fn srgb_byte(value: f32) -> u8 {
14671468 (srgb * 255.0).round() as u8
14681469}
14691470
1470/// Linear RGBA of an opaque sRGB colour.
14711471/// Least OKLab lightness difference between text and the backdrop it stays legible on.
14721472const LEGIBLE: f32 = 0.4;
14731473
1474/// `color` lifted clear of a dark `backdrop`'s lightness, keeping hue and chroma.
1475fn legible(color: [f32; 4], backdrop: Option<[f32; 4]>) -> [f32; 4] {
1476 let [under, ..] = backdrop.map_or([1.0; 3], oklab);
1474/// On a dark layer `backdrop`, `color` lifted clear of the lightness of what lies `behind`
1475/// it, or of the backdrop, keeping hue and chroma.
1476fn legible(color: [f32; 4], backdrop: Option<[f32; 4]>, behind: Option<[f32; 4]>) -> [f32; 4] {
1477 let Some(backdrop) = backdrop.filter(|backdrop| oklab(*backdrop)[0] < 0.5) else {
1478 return color;
1479 };
1480 let [under, ..] = oklab(behind.unwrap_or(backdrop));
14771481 let [lightness, a, b] = oklab(color);
14781482 if under >= 0.5 || lightness >= under + LEGIBLE {
14791483 return color;
......@@ -1513,6 +1517,7 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] {
15131517 .map(|[x, y, z]| (x * l + y * m + z * s).clamp(0.0, 1.0))
15141518}
15151519
1520/// Linear RGBA of an opaque sRGB colour.
15161521pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {
15171522 let linear = |byte: u8| {
15181523 let value = f32::from(byte) / 255.0;
......@@ -1555,6 +1560,7 @@ mod tests {
15551560 0.0,
15561561 [metrics.block_min_coord, metrics.line_height],
15571562 |_| None,
1563 None,
15581564 paint,
15591565 )?;
15601566 }
......@@ -1582,6 +1588,28 @@ mod tests {
15821588 Text(layout)
15831589 }
15841590
1591 #[test]
1592 fn text_is_lifted_against_what_lies_behind_it_on_a_dark_layer() {
1593 let [black, dark_red] = [[0.0, 0.0, 0.0, 1.0], [0.2, 0.0, 0.0, 1.0]];
1594 let [dark, yellow, navy] = [srgb(0x1f, 0x20, 0x22), srgb(255, 255, 0), srgb(0, 0, 128)];
1595 let lightness = |color| oklab(color)[0];
1596 assert_eq!(legible(black, None, Some(navy)), black, "an unset layer");
1597 assert_eq!(
1598 legible(black, Some([1.0; 4]), Some(navy)),
1599 black,
1600 "a light layer keeps its colours, as OneNote's light page does"
1601 );
1602 assert!(lightness(legible(black, Some(dark), None)) >= lightness(dark) + LEGIBLE - 1e-3);
1603 assert_eq!(
1604 legible(black, Some(dark), Some(yellow)),
1605 black,
1606 "a light highlight on a dark layer"
1607 );
1608 assert_eq!(legible(dark_red, Some(dark), Some(yellow)), dark_red);
1609 let lifted = legible(black, Some(dark), Some(navy));
1610 assert!(lightness(lifted) >= lightness(navy) + LEGIBLE - 1e-3);
1611 }
1612
15851613 #[test]
15861614 fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() {
15871615 let mut encoded = Vec::new();
crates/draw/src/render/text.rs+7-2
......@@ -23,6 +23,9 @@ pub struct GlyphRun<'a> {
2323 pub skew: Option<f32>,
2424 /// `None` paints in the primitive's ink.
2525 pub color: Option<[f32; 4]>,
26 /// The colour painted behind the glyphs, such as a highlight; `None` for the layer's
27 /// backdrop.
28 pub backdrop: Option<[f32; 4]>,
2629 /// Top and height of the run's line: lines outside the target are skipped, and
2730 /// colour glyphs shrink to fit.
2831 pub line: [f32; 2],
......@@ -50,14 +53,15 @@ pub struct Decoration {
5053 pub color: Option<[f32; 4]>,
5154}
5255
53/// Paints one parley glyph run with its line's baseline moved to `baseline` and its
54/// glyphs raised by `rise`.
56/// Paints one parley glyph run with its line's baseline moved to `baseline`, its glyphs
57/// raised by `rise` and `backdrop` behind them.
5558pub fn paint_parley_run<B: parley::Brush>(
5659 run: &parley::GlyphRun<'_, B>,
5760 baseline: f32,
5861 rise: f32,
5962 line: [f32; 2],
6063 color: impl Fn(&B) -> Option<[f32; 4]>,
64 backdrop: Option<[f32; 4]>,
6165 paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>,
6266) -> Result<(), RenderError> {
6367 let shaped = run.run();
......@@ -108,6 +112,7 @@ pub fn paint_parley_run<B: parley::Brush>(
108112 embolden: synthesis.embolden(),
109113 skew: synthesis.skew(),
110114 color: color(&style.brush),
115 backdrop,
111116 line,
112117 glyphs: &mut glyphs,
113118 decorations: &decorations,
crates/snowbound/Cargo.toml+13-2
......@@ -11,6 +11,8 @@ ui = { path = "../ui" }
1111png = "0.18"
1212onestore = { path = "../onestore" }
1313notebook = { path = "../notebook" }
14serde = { version = "1", features = ["derive"] }
15serde_json = "1"
1416pollster = "0.4"
1517wgpu.workspace = true
1618parley.workspace = true
......@@ -18,7 +20,16 @@ winit = "0.30.13"
1820arboard = { version = "3.6.1", default-features = false }
1921accesskit = "0.25.0"
2022accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
23
24[target.'cfg(target_os = "macos")'.dependencies]
2125objc2 = "0.5.2"
22objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell"] }
23objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSTimeZone"] }
26objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell", "NSOpenPanel", "NSPanel", "NSSavePanel"] }
27objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] }
2428
29[target.'cfg(target_os = "linux")'.dependencies]
30accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] }
31smithay-clipboard = "0.7.3"
32# Loads fontconfig at run time, so builds need no fontconfig headers or library.
33fontique = { workspace = true, features = ["fontconfig-dlopen"] }
34libc = "0.2"
35zbus = { version = "5.19", default-features = false, features = ["async-io", "blocking-api"] }
crates/snowbound/assets/icons/chevron-up.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M5 9.25L8 6.25L11 9.25" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/conflict.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 5.5H10.5M8 3L10.5 5.5L8 8M13.5 10.5H5.5M8 8L5.5 10.5L8 13" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/new-page.svg deleted-3
......@@ -1,3 +0,0 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M9 1.5H4.5A1 1 0 0 0 3.5 2.5V13.5A1 1 0 0 0 4.5 14.5H11.5A1 1 0 0 0 12.5 13.5V5L9 1.5V5H12.5M8 7.25V11.75M5.75 9.5H10.25" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/notebook.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4.5 1.5H12A1.5 1.5 0 0 1 13.5 3V13A1.5 1.5 0 0 1 12 14.5H4.5A1 1 0 0 1 3.5 13.5V2.5A1 1 0 0 1 4.5 1.5ZM6 1.5V14.5M2.5 4.5H4.5M2.5 8H4.5M2.5 11.5H4.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/plus.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M8 2.5V13.5M2.5 8H13.5" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/section-group.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M2.5 4A1.5 1.5 0 0 1 4 2.5H6.5L8 4H12A1.5 1.5 0 0 1 13.5 5.5V12A1.5 1.5 0 0 1 12 13.5H4A1.5 1.5 0 0 1 2.5 12Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/window-maximize.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M5.5 4.5H10.5A1 1 0 0 1 11.5 5.5V10.5A1 1 0 0 1 10.5 11.5H5.5A1 1 0 0 1 4.5 10.5V5.5A1 1 0 0 1 5.5 4.5Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round"/>
3</svg>
crates/snowbound/assets/icons/window-minimize.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M4 8H12" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>
3</svg>
crates/snowbound/assets/icons/window-restore.svg created+3
......@@ -0,0 +1,3 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <path d="M6.5 4.5H10.5A1 1 0 0 1 11.5 5.5V9.5M4.5 7.5A1 1 0 0 1 5.5 6.5H8.5A1 1 0 0 1 9.5 7.5V10.5A1 1 0 0 1 8.5 11.5H5.5A1 1 0 0 1 4.5 10.5Z" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
3</svg>
crates/snowbound/linux/README.md created+46
......@@ -0,0 +1,46 @@
1# Snowbound for Linux
2
3An early, lightly tested build of Snowbound, a note-taking app that reads and
4writes OneNote 2010 notebooks. Expect rough edges; please report what breaks.
5
6## Run
7
8```sh
9tar xzf snowbound-linux-x86_64.tar.gz
10cd snowbound-linux-x86_64
11./bin/snowbound
12```
13
14Choose **Open Notebook…** in the notebook list and pick the notebook folder,
15the one holding the `.one` section files and `Open Notebook.onetoc2`.
16Snowbound remembers the notebooks you open. `--notebook FOLDER` opens one
17straight away.
18
19Edits save to the notebook folder as you type. Settings live in
20`~/.config/snowbound` and sync state in `~/.cache/snowbound`; `--cache DIR`
21puts the latter elsewhere.
22
23Run `./install.sh` to add Snowbound to your app launcher; it copies the app
24into `~/.local`. On Wayland the taskbar finds Snowbound's icon only through
25this launcher entry, so without it the taskbar shows a generic icon.
26`./install.sh ~/Notebooks/MyNotebook` makes the launcher open that notebook as
27well.
28
29## Requirements
30
31- x86_64 or aarch64 Linux with glibc 2.31 or newer (Debian 11, Ubuntu 20.04,
32 Fedora 32 and later).
33- A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through
34 EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL.
35- fontconfig, and X11 or Wayland with libxkbcommon.
36- zenity or kdialog for the folder picker, the page date and time dialogs and
37 alerts.
38
39## Known limits
40
41- No New Notebook or menus yet.
42- The dark or light look follows the desktop's setting when the app starts.
43- The character palette (Control-Command-Space on macOS) has no Linux
44 counterpart.
45- The window stops drawing while the folder picker or a date dialog is open.
46- Input methods work through winit and are barely tested.
crates/snowbound/linux/install.sh created+22
......@@ -0,0 +1,22 @@
1#!/bin/sh
2# Installs Snowbound into ~/.local with a launcher entry; with a notebook folder, the
3# launcher opens it.
4set -eu
5here=$(cd "$(dirname "$0")" && pwd)
6data=${XDG_DATA_HOME:-$HOME/.local/share}
7bin=$HOME/.local/bin
8mkdir -p "$bin" "$data/applications"
9cp "$here/bin/snowbound" "$bin/snowbound"
10cp -R "$here/share/icons" "$data/"
11exec_line="$bin/snowbound"
12if [ $# -gt 0 ]; then
13 notebook=$(cd "$1" && pwd)
14 # Desktop entries quote arguments in double quotes, escape \ " ` $ and double %.
15 quoted=$(printf '%s' "$notebook" | sed -e 's/[\\"`$]/\\\\&/g' -e 's/%/%%/g')
16 exec_line="$exec_line --notebook \"$quoted\""
17fi
18sed "s|^Exec=.*|Exec=$exec_line|" "$here/share/applications/snowbound.desktop" \
19 > "$data/applications/snowbound.desktop"
20command -v update-desktop-database >/dev/null && update-desktop-database "$data/applications" || true
21command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q "$data/icons/hicolor" 2>/dev/null || true
22echo "Installed $bin/snowbound and the Snowbound launcher."
crates/snowbound/linux/package.sh created+57
......@@ -0,0 +1,57 @@
1#!/bin/sh
2# Builds target/dist/snowbound-linux-ARCH.tar.gz for each ARCH given (x86_64, aarch64;
3# both by default), cross-linking with zig so it runs from macOS or Linux.
4set -eu
5root=$(cd "$(dirname "$0")/../../.." && pwd)
6here="$root/crates/snowbound/linux"
7icon="$root/crates/snowbound/assets/icon/Snowbound-SnowLeopard.png"
8target="$root/target"
9tools="$target/zig"
10mkdir -p "$tools" "$target/dist"
11command -v zig >/dev/null || { echo "zig is required as the cross linker" >&2; exit 1; }
12
13printf '#!/bin/sh\nexec zig ar "$@"\n' > "$tools/ar"
14chmod +x "$tools/ar"
15
16[ $# -gt 0 ] || set -- x86_64 aarch64
17for arch do
18 triple=$arch-unknown-linux-gnu
19 variable=$(echo "$triple" | tr 'a-z-' 'A-Z_')
20 linker="$tools/cc-$arch"
21 # glibc 2.31 reaches back to Debian 11 and Ubuntu 20.04; zig rejects rustc's
22 # --target, --no-undefined-version and -O1.
23 cat > "$linker" <<EOF
24#!/bin/sh
25for arg do
26 shift
27 case "\$arg" in --target=*|-Wl,--no-undefined-version|-Wl,-O1) ;; *) set -- "\$@" "\$arg" ;; esac
28done
29exec zig cc -target $arch-linux-gnu.2.31 "\$@"
30EOF
31 chmod +x "$linker"
32 rustup target add "$triple" >/dev/null
33 env "CARGO_TARGET_${variable}_LINKER=$linker" \
34 "CC_$(echo "$triple" | tr - _)=$linker" \
35 "AR_$(echo "$triple" | tr - _)=$tools/ar" \
36 CARGO_PROFILE_RELEASE_STRIP=symbols \
37 cargo build --manifest-path "$root/Cargo.toml" --release --target "$triple" -p snowbound
38
39 name=snowbound-linux-$arch
40 stage="$target/dist/$name"
41 rm -rf "$stage"
42 mkdir -p "$stage/bin" "$stage/share/applications"
43 cp "$target/$triple/release/snowbound" "$stage/bin/"
44 cp "$here/snowbound.desktop" "$stage/share/applications/"
45 cp "$here/README.md" "$here/install.sh" "$stage/"
46 for side in 16 24 32 48 64 128 256 512; do
47 folder="$stage/share/icons/hicolor/${side}x$side/apps"
48 mkdir -p "$folder"
49 if command -v sips >/dev/null; then
50 sips -s format png -z "$side" "$side" "$icon" --out "$folder/snowbound.png" >/dev/null
51 else
52 magick "$icon" -resize "${side}x$side" -depth 8 "$folder/snowbound.png"
53 fi
54 done
55 COPYFILE_DISABLE=1 tar --no-xattrs -C "$target/dist" -czf "$target/dist/$name.tar.gz" "$name"
56 echo "$target/dist/$name.tar.gz"
57done
crates/snowbound/linux/snowbound.desktop created+10
......@@ -0,0 +1,10 @@
1[Desktop Entry]
2Type=Application
3Name=Snowbound
4GenericName=Note Taking
5Comment=Type and draw notes in OneNote notebooks
6Exec=snowbound
7Icon=snowbound
8Terminal=false
9Categories=Office;
10StartupWMClass=snowbound
crates/snowbound/src/art.rs+5-1
......@@ -14,20 +14,24 @@ pub const BOLD: &[&str] = art!("icons/bold");
1414pub const BULLETS: &[&str] = art!("icons/bullets");
1515pub const CALENDAR: &[&str] = art!("icons/calendar");
1616pub const CLEAR_FORMATTING: &[&str] = art!("icons/clear-formatting");
17pub const CHEVRON_UP: &[&str] = art!("icons/chevron-up");
1718pub const CLOCK: &[&str] = art!("icons/clock");
1819pub const CLOSE: &[&str] = art!("icons/close");
20pub const CONFLICT: &[&str] = art!("icons/conflict");
1921pub const EQUATION: &[&str] = art!("icons/equation");
2022pub const FONT_COLOR: &[&str] = art!("icons/font-color");
2123pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter");
2224pub const INDENT: &[&str] = art!("icons/indent");
2325pub const ITALIC: &[&str] = art!("icons/italic");
2426pub const LINK: &[&str] = art!("icons/link");
25pub const NEW_PAGE: &[&str] = art!("icons/new-page");
27pub const NOTEBOOK: &[&str] = art!("icons/notebook");
2628pub const NUMBERING: &[&str] = art!("icons/numbering");
2729pub const OUTDENT: &[&str] = art!("icons/outdent");
2830pub const PICTURE: &[&str] = art!("icons/picture");
31pub const PLUS: &[&str] = art!("icons/plus");
2932pub const REDO: &[&str] = art!("icons/redo");
3033pub const SEARCH: &[&str] = art!("icons/search");
34pub const SECTION_GROUP: &[&str] = art!("icons/section-group");
3135pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");
3236pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");
3337pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough");
crates/snowbound/src/conflict_render.rs created+264
......@@ -0,0 +1,264 @@
1//! The conflict page views drawn offscreen, from the conflict-page corpus row's candidate:
2//! the page with its information bar, its versions listed beneath it, and a version open.
3//! `SNOWBOUND_CONFLICT_RENDER` names a directory for the PNGs.
4
5use super::*;
6
7const SIZE: [f32; 2] = [980.0, 520.0];
8const SCALE: f32 = 2.0;
9
10/// One view laid out: the bar and the page on the left, the page list on the right; `menu`
11/// opens the conflict page's menu.
12fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {
13 let theme = ui.theme.clone();
14 let section = theme.section(section_color(None));
15 ui.begin(SIZE, SCALE, Instant::now());
16 if menu {
17 ui.open_popup(Id::ROOT.child("conflict-menu"));
18 }
19 ui.open(
20 "frame",
21 Spec {
22 size: [fill(), fill()],
23 fill: Some(section.frame[0]),
24 gradient: Some(section.frame[1]),
25 pad: [FRAME, FRAME],
26 ..Spec::default()
27 },
28 );
29 ui.open(
30 "column",
31 Spec {
32 axis: Axis::Y,
33 size: [fill(), fill()],
34 ..Spec::default()
35 },
36 );
37 let mut commands: Vec<Command> = session
38 .bar()
39 .and_then(|bar| conflict_bar(ui, bar, &["synthetic", "Songs"], [false, true]))
40 .into_iter()
41 .collect();
42 ui.open_as(
43 page(),
44 Spec {
45 flags: Flags::CUSTOM,
46 size: [fill(), fill()],
47 fill: Some(theme.paper),
48 ..Spec::default()
49 },
50 );
51 ui.close();
52 ui.close();
53 ui.open(
54 "panel",
55 Spec {
56 axis: Axis::Y,
57 size: [px(PAGE_LIST), fill()],
58 ..Spec::default()
59 },
60 );
61 let rows = page_rows(ui, &theme, &section, session, "");
62 commands.extend(rows.clicked.map(Command::OpenPage));
63 ui.close();
64 ui.close();
65 ui.end();
66 commands
67}
68
69/// The view's pixels, RGBA: the interface with `page` drawn in its box.
70fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Device, queue: &wgpu::Queue) -> Vec<u8> {
71 let size = SIZE.map(|side| (side * SCALE) as u32);
72 let corner = ui.rect(super::page()).unwrap();
73 let box_size = [
74 ((corner[2] - corner[0]) * SCALE) as u32,
75 ((corner[3] - corner[1]) * SCALE) as u32,
76 ];
77 let mut engine = TextEngine::default();
78 let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap();
79 let mut view = PageView::new(
80 editor,
81 engine,
82 Some((scene, [0.0; 2])),
83 box_size,
84 SCALE,
85 std::time::Duration::from_millis(500),
86 );
87 let paper = canvas::gpu::Paper {
88 color: ui.theme.paper,
89 ink: ui.theme.paper_ink,
90 };
91 let waker = std::task::Waker::noop();
92 let deadline = Instant::now() + std::time::Duration::from_secs(10);
93 let mut scene = view.scene.take().unwrap();
94 while !view.prepare(&mut scene, &view.editor, paper, waker) && Instant::now() < deadline {
95 std::thread::sleep(std::time::Duration::from_millis(20));
96 }
97 view.scene = Some(scene);
98 view.update_pictures(paper, waker);
99 let primitives = view
100 .primitives(TextColors {
101 caret: [0.0; 4],
102 selection: ui.theme.inactive_selection,
103 paper,
104 })
105 .unwrap();
106 let viewport = view.viewport;
107 let interface = ui.layers();
108 let layers: Vec<_> = interface
109 .iter()
110 .map(|layer| match layer {
111 ui::Layer::Primitives { clip, primitives } => draw::Layer {
112 scale: SCALE,
113 origin: [0.0; 2],
114 clip: clip.map(|clip| clip.map(|value| value * SCALE)),
115 backdrop: None,
116 primitives,
117 },
118 ui::Layer::Custom { rect, .. } => draw::Layer {
119 scale: viewport.scale,
120 origin: [
121 viewport.origin[0] + corner[0] * SCALE,
122 viewport.origin[1] + corner[1] * SCALE,
123 ],
124 clip: Some(rect.map(|value| value * SCALE)),
125 backdrop: Some(ui.theme.paper),
126 primitives: &primitives,
127 },
128 })
129 .collect();
130 let texture = device.create_texture(&wgpu::TextureDescriptor {
131 label: Some("Conflict view"),
132 size: wgpu::Extent3d {
133 width: size[0],
134 height: size[1],
135 depth_or_array_layers: 1,
136 },
137 mip_level_count: 1,
138 sample_count: 1,
139 dimension: wgpu::TextureDimension::D2,
140 format: wgpu::TextureFormat::Rgba8UnormSrgb,
141 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
142 view_formats: &[],
143 });
144 renderer
145 .draw(&texture.create_view(&Default::default()), size, ui.theme.base, &layers)
146 .unwrap();
147 let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
148 let buffer = device.create_buffer(&wgpu::BufferDescriptor {
149 label: Some("Conflict view readback"),
150 size: u64::from(row) * u64::from(size[1]),
151 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
152 mapped_at_creation: false,
153 });
154 let mut encoder = device.create_command_encoder(&Default::default());
155 encoder.copy_texture_to_buffer(
156 texture.as_image_copy(),
157 wgpu::TexelCopyBufferInfo {
158 buffer: &buffer,
159 layout: wgpu::TexelCopyBufferLayout {
160 offset: 0,
161 bytes_per_row: Some(row),
162 rows_per_image: Some(size[1]),
163 },
164 },
165 texture.size(),
166 );
167 queue.submit([encoder.finish()]);
168 buffer.map_async(wgpu::MapMode::Read, .., |result| result.unwrap());
169 device
170 .poll(wgpu::PollType::Wait {
171 submission_index: None,
172 timeout: Some(std::time::Duration::from_secs(5)),
173 })
174 .unwrap();
175 let mapped = buffer.get_mapped_range(..).unwrap();
176 mapped
177 .chunks(row as usize)
178 .flat_map(|line| &line[..size[0] as usize * 4])
179 .copied()
180 .collect()
181}
182
183/// The page shown, a page's versions listed while shown, and the bar's commands: showing
184/// opens the newest version, hiding returns to the page.
185#[test]
186fn conflict_views_offer_the_versions_and_render_offscreen() {
187 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
188 let directory = std::env::temp_dir().join(format!("snowbound-conflict-{}", std::process::id()));
189 let _ = std::fs::remove_dir_all(&directory);
190 std::fs::create_dir_all(directory.join("cache")).unwrap();
191 let file = directory.join("synthetic.one");
192 std::fs::copy(root.join("corpus/conflict-page/candidate/synthetic.one"), &file).unwrap();
193 let library = Arc::new(crate::Library::section(&file, &directory.join("cache")));
194 let section = library.open(&library.location, || {}).unwrap();
195 let path = library.location.clone();
196 let (mut session, _) = read_session(section, library, path, None).unwrap();
197 let page = session.space;
198 let version = session.versions(page)[0].space;
199 let output = std::env::var_os("SNOWBOUND_CONFLICT_RENDER").map(PathBuf::from);
200 let gpu = output.as_ref().map(|output| {
201 std::fs::create_dir_all(output).unwrap();
202 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
203 let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
204 let (device, queue) =
205 pollster::block_on(adapter.request_device(&Default::default())).unwrap();
206 let renderer =
207 draw::Renderer::new(device.clone(), queue.clone(), wgpu::TextureFormat::Rgba8UnormSrgb);
208 (renderer, device, queue)
209 });
210 let mut gpu = gpu;
211 for (name, space, shown, menu, appearance) in [
212 ("page", page, None, false, Theme::light()),
213 ("versions", page, Some(page), false, Theme::light()),
214 ("version", version, Some(page), false, Theme::light()),
215 ("version-menu", version, Some(page), true, Theme::light()),
216 ("page-dark", page, None, false, Theme::dark()),
217 ("version-dark", version, Some(page), false, Theme::dark()),
218 ("version-menu-dark", version, Some(page), true, Theme::dark()),
219 ] {
220 session.space = space;
221 session.shown = shown;
222 let mut ui = Ui::new(appearance, std::time::Duration::from_millis(500));
223 // Later frames lay out with earlier frames' measurements, and a menu fades in.
224 for _ in 0..8 {
225 layout(&mut ui, &session, menu);
226 std::thread::sleep(std::time::Duration::from_millis(30));
227 }
228 let bar = session.bar();
229 match (name, bar) {
230 ("page" | "page-dark", Some(Bar::Page { shown: false, .. }))
231 | ("versions", Some(Bar::Page { shown: true, .. }))
232 | ("version" | "version-menu" | "version-dark" | "version-menu-dark", Some(Bar::Version { .. })) => {}
233 other => panic!("{other:?}", other = other.0),
234 }
235 let row = Id::ROOT.child("frame").child("panel").child(version);
236 let listed = ui.rect(row).is_some();
237 assert_eq!(listed, shown.is_some(), "{name}");
238 if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) {
239 let shown = session.reader(space)().unwrap();
240 let pixels = paint(&ui, shown, renderer, device, queue);
241 let size = SIZE.map(|side| (side * SCALE) as u32);
242 let path = output.join(format!("conflict-{name}.png"));
243 let mut encoder =
244 png::Encoder::new(std::fs::File::create(&path).unwrap(), size[0], size[1]);
245 encoder.set_color(png::ColorType::Rgba);
246 encoder.set_depth(png::BitDepth::Eight);
247 encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
248 }
249 }
250 // A version reads with its conflicting paragraphs banded as OneNote shows them.
251 let kept = session.reader(version)().unwrap();
252 let marked = session.versions(page)[0].objects.clone();
253 assert!(!marked.is_empty());
254 let highlighted = kept.objects.iter().any(|object| match object {
255 onestore::page::PageObject::Outline(outline) => outline.paragraphs.iter().any(|paragraph| {
256 paragraph.text().is_some_and(|text| marked.contains(&text.id))
257 && paragraph.format.highlight == Some(CONFLICTING)
258 }),
259 _ => false,
260 });
261 assert!(highlighted);
262 session.section.close().unwrap();
263 std::fs::remove_dir_all(&directory).unwrap();
264}
crates/snowbound/src/library.rs created+179
......@@ -0,0 +1,179 @@
1//! Open notebooks: where each lives and the sections its tabs offer.
2
3use notebook::discover::{Folder, SectionState};
4use notebook::session::{Notebook, Section};
5use std::{
6 error::Error,
7 path::{Path, PathBuf},
8};
9
10/// A section tab: where the section opens from and how it is labelled.
11pub struct Tab {
12 /// The notebook catalog path, or the file of a section opened on its own.
13 pub path: String,
14 pub name: String,
15 /// COLORREF.
16 pub color: Option<u32>,
17}
18
19/// An open notebook as the sidebar lists it, or a section opened on its own.
20pub struct Library {
21 /// Where it lives, as the settings keep it: the notebook's folder, or the section file.
22 pub location: String,
23 /// The notebook's folder name, or the section file's.
24 pub name: String,
25 /// `Err` holds why a notebook listed as open could not be read this time.
26 pub notebook: Result<Option<Notebook>, String>,
27 cache: PathBuf,
28}
29
30impl Library {
31 /// The notebook in the folder at `location`, or why it cannot be read.
32 pub fn notebook(location: &str, cache: &Path) -> Self {
33 Self {
34 location: location.to_owned(),
35 name: file_name(Path::new(location)),
36 notebook: Notebook::open(location, cache)
37 .map(Some)
38 .map_err(|error| error.to_string()),
39 cache: cache.to_owned(),
40 }
41 }
42
43 /// `notebook`, open at `location`.
44 pub fn open_notebook(location: &str, notebook: Notebook, cache: &Path) -> Self {
45 Self {
46 location: location.to_owned(),
47 name: file_name(Path::new(location)),
48 notebook: Ok(Some(notebook)),
49 cache: cache.to_owned(),
50 }
51 }
52
53 /// The section file at `file` as a notebook of one tab.
54 pub fn section(file: &Path, cache: &Path) -> Self {
55 Self {
56 location: file.to_string_lossy().into_owned(),
57 name: file_name(file.parent().unwrap_or(file)),
58 notebook: Ok(None),
59 cache: cache.to_owned(),
60 }
61 }
62
63 /// Opens the section at catalog `path`.
64 pub fn open(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result<Section, Box<dyn Error>> {
65 Ok(match &self.notebook {
66 Ok(Some(notebook)) => notebook.section(path, notify)?,
67 Ok(None) => Section::open(path, &self.cache, notify)?,
68 Err(error) => return Err(error.clone().into()),
69 })
70 }
71
72 /// Names the section at catalog `path` across every open notebook, for remembering
73 /// its pages.
74 pub fn key(&self, path: &str) -> String {
75 format!("{}\n{path}", self.location)
76 }
77
78 /// The readable sections of the folder at catalog path `folder`, as tabs in its order.
79 pub fn tabs(&self, folder: &str) -> Vec<Tab> {
80 match &self.notebook {
81 Ok(Some(notebook)) => {
82 let mut folders = vec![notebook.catalog()];
83 while let Some(candidate) = folders.pop() {
84 if candidate.path == folder {
85 return tabs(candidate);
86 }
87 folders.extend(&candidate.groups);
88 }
89 Vec::new()
90 }
91 Ok(None) => vec![Tab {
92 path: self.location.clone(),
93 name: section_name(&self.location, &None),
94 color: None,
95 }],
96 Err(_) => Vec::new(),
97 }
98 }
99
100 /// The first readable section, searching groups after sections, as OneNote opens a
101 /// notebook.
102 pub fn first_section(&self) -> Option<String> {
103 let Ok(Some(notebook)) = &self.notebook else {
104 return self.tabs("").pop().map(|tab| tab.path);
105 };
106 let mut folders = std::collections::VecDeque::from([notebook.catalog()]);
107 while let Some(folder) = folders.pop_front() {
108 if let Some(tab) = tabs(folder).into_iter().next() {
109 return Some(tab.path);
110 }
111 folders.extend(folder.groups.iter().filter(|group| !recycle_bin(group)));
112 }
113 None
114 }
115
116 /// Whether the notebook lists a section at catalog path `path`.
117 pub fn contains(&self, path: &str) -> bool {
118 let Some(catalog) = self.catalog() else {
119 return self.location == path;
120 };
121 let mut folders = vec![catalog];
122 while let Some(folder) = folders.pop() {
123 if folder.sections.iter().any(|section| section.path == path) {
124 return true;
125 }
126 folders.extend(&folder.groups);
127 }
128 false
129 }
130
131 /// The notebook's folders of sections, for the sidebar.
132 pub fn catalog(&self) -> Option<&Folder> {
133 match &self.notebook {
134 Ok(Some(notebook)) => Some(notebook.catalog()),
135 _ => None,
136 }
137 }
138}
139
140/// Whether `folder` is the notebook's recycle bin, which OneNote keeps out of its lists.
141pub fn recycle_bin(folder: &Folder) -> bool {
142 folder.path.rsplit('/').next() == Some("OneNote_RecycleBin")
143}
144
145pub fn file_name(path: &Path) -> String {
146 path.canonicalize()
147 .ok()
148 .as_deref()
149 .and_then(Path::file_name)
150 .or_else(|| path.file_name())
151 .map(|name| name.to_string_lossy().into_owned())
152 .unwrap_or_default()
153}
154
155/// A section's name: its display name, or its file's.
156pub fn section_name(path: &str, name: &Option<String>) -> String {
157 name.clone().unwrap_or_else(|| {
158 Path::new(path)
159 .file_stem()
160 .map(|stem| stem.to_string_lossy().into_owned())
161 .unwrap_or_default()
162 })
163}
164
165/// Section tabs for a notebook's readable top-level sections, in its order.
166fn tabs(catalog: &Folder) -> Vec<Tab> {
167 catalog
168 .sections
169 .iter()
170 .filter_map(|section| match &section.state {
171 SectionState::Readable { name, color } => Some(Tab {
172 name: section_name(&section.path, name),
173 path: section.path.clone(),
174 color: *color,
175 }),
176 _ => None,
177 })
178 .collect()
179}
crates/snowbound/src/linux.rs created+610
......@@ -0,0 +1,610 @@
1//! Linux, on X11 and Wayland: window controls drawn in the title bar, dialogs through zenity
2//! or kdialog, the colour scheme from the XDG settings portal, and text conventions from
3//! the C library's locale.
4
5use canvas::date::DateField;
6use std::{ffi::CStr, path::PathBuf, process::Command, sync::OnceLock, time::Duration};
7use ui::{Flags, Spec, Ui, px};
8use winit::{
9 error::EventLoopError,
10 event_loop::{EventLoop, EventLoopProxy},
11 platform::wayland::WindowAttributesExtWayland,
12 window::{Icon, ResizeDirection, Theme, Window, WindowAttributes},
13};
14
15/// The name the desktop entry, the icon theme and window matching know the app by.
16const APP_ID: &str = "snowbound";
17/// The title bar's leading margin; the window controls sit at its trailing end.
18pub const LEADING: f32 = 8.0;
19/// Square, as compositors that round windows don't report by how much.
20pub const CORNER_RADIUS: f32 = 0.0;
21/// How far inside the window's edges a press resizes it, and how far along them a corner
22/// reaches, in logical pixels.
23const EDGE: f32 = 5.0;
24const CORNER: f32 = 16.0;
25const MINIMIZE: &[&str] = &[include_str!("../assets/icons/window-minimize.svg")];
26const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")];
27const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")];
28
29static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
30
31pub fn event_loop(_headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> {
32 // Month and day names and date orders follow the user's locale; other categories stay C.
33 unsafe { libc::setlocale(libc::LC_TIME, c"".as_ptr()) };
34 let event_loop = EventLoop::with_user_event().build()?;
35 QUIT.set(event_loop.create_proxy())
36 .expect("Only one application event loop is created");
37 Ok(event_loop)
38}
39
40/// Decorated by the window manager, or on Wayland by the compositor where it offers
41/// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies
42/// the icon on Wayland, while X11 takes it from the window.
43pub fn window_attributes() -> WindowAttributes {
44 let icon = icon_pixels(64).and_then(|(side, rgba)| Icon::from_rgba(rgba, side, side).ok());
45 Window::default_attributes()
46 .with_name(APP_ID, APP_ID)
47 .with_window_icon(icon)
48}
49
50/// Wayland's clipboard through the window's own connection, as not every compositor offers
51/// a clipboard to clients without a window; X11's otherwise.
52pub enum Clipboard {
53 Wayland(smithay_clipboard::Clipboard),
54 X11(arboard::Clipboard),
55}
56
57impl Clipboard {
58 pub fn new(window: &Window) -> Result<Self, arboard::Error> {
59 use winit::raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
60 match window.display_handle().map(|handle| handle.as_raw()) {
61 // The display outlives the clipboard, which the window's state owns.
62 Ok(RawDisplayHandle::Wayland(handle)) => Ok(Self::Wayland(unsafe {
63 smithay_clipboard::Clipboard::new(handle.display.as_ptr())
64 })),
65 _ => arboard::Clipboard::new().map(Self::X11),
66 }
67 }
68
69 pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {
70 match self {
71 Self::Wayland(clipboard) => Ok(clipboard.store(text)),
72 Self::X11(clipboard) => clipboard.set_text(text),
73 }
74 }
75
76 pub fn get_text(&mut self) -> Result<String, Box<dyn std::error::Error>> {
77 Ok(match self {
78 Self::Wayland(clipboard) => clipboard.load()?,
79 Self::X11(clipboard) => clipboard.get_text()?,
80 })
81 }
82}
83
84/// Whether the system draws the title bar; false only where Wayland's client-side frame
85/// could not be made, leaving the app's own.
86pub fn system_titlebar(window: &Window) -> bool {
87 window.is_decorated()
88}
89
90/// Minimize, maximize and close at the title bar's trailing end.
91pub fn window_controls(ui: &mut Ui, window: &Window) {
92 let theme = ui.theme.clone();
93 let mut button = |part: &str, icon| {
94 ui.leaf(
95 part,
96 Spec {
97 flags: Flags::CLICKABLE,
98 size: [px(crate::TITLE + 12.0), px(crate::TITLE)],
99 icon: Some(icon),
100 color: Some(theme.text),
101 hover_fill: Some(theme.hover()),
102 center: true,
103 ..Spec::default()
104 },
105 )
106 .clicked
107 };
108 if button("minimize", MINIMIZE) {
109 window.set_minimized(true);
110 }
111 if button(
112 "maximize",
113 if window.is_maximized() {
114 RESTORE
115 } else {
116 MAXIMIZE
117 },
118 ) {
119 zoom(window);
120 }
121 if button("close", crate::art::CLOSE)
122 && let Some(proxy) = QUIT.get()
123 {
124 let _ = proxy.send_event(crate::UserEvent::Quit);
125 }
126}
127
128/// The edge or corner a press at `pointer`, in logical pixels, resizes the window from, when
129/// the window has no frame to take it.
130pub fn resize_direction(window: &Window, pointer: [f32; 2]) -> Option<ResizeDirection> {
131 if window.is_decorated() {
132 return None;
133 }
134 let size = window.inner_size().to_logical::<f32>(window.scale_factor());
135 let [x, y] = pointer;
136 let near = |value: f32, extent: f32, reach: f32| (value < reach, value > extent - reach);
137 let (mut west, mut east) = near(x, size.width, EDGE);
138 let (mut north, mut south) = near(y, size.height, EDGE);
139 if north || south {
140 (west, east) = near(x, size.width, CORNER);
141 }
142 if west || east {
143 (north, south) = near(y, size.height, CORNER);
144 }
145 let direction = match (north, south, west, east) {
146 (true, _, true, _) => ResizeDirection::NorthWest,
147 (true, _, _, true) => ResizeDirection::NorthEast,
148 (_, true, true, _) => ResizeDirection::SouthWest,
149 (_, true, _, true) => ResizeDirection::SouthEast,
150 (true, ..) => ResizeDirection::North,
151 (_, true, ..) => ResizeDirection::South,
152 (.., true, _) => ResizeDirection::West,
153 (.., true) => ResizeDirection::East,
154 _ => return None,
155 };
156 (!window.is_maximized() && window.is_resizable()).then_some(direction)
157}
158
159pub fn zoom(window: &Window) {
160 window.set_maximized(!window.is_maximized());
161}
162
163/// Winit delivers typed text with its key events on Linux.
164pub fn install_text_input(_: &Window) {}
165
166pub fn clear_marked_text(_: &Window) {}
167
168pub fn configure_presentation(_: &wgpu::Surface<'_>) {}
169
170pub fn commit_presentation(_: &Window) {}
171
172/// GNOME has no character palette to open from an app.
173pub fn show_character_palette() {}
174
175/// GTK's default double-click time.
176pub fn double_click_interval() -> Duration {
177 Duration::from_millis(400)
178}
179
180/// The desktop's colour scheme from the settings portal, which GNOME, KDE and others serve;
181/// light where it states no preference.
182pub fn appearance(_: &Window) -> Theme {
183 let dark = portal_color_scheme()
184 .unwrap_or_else(|| std::env::var("GTK_THEME").is_ok_and(|theme| theme.ends_with(":dark")));
185 if dark { Theme::Dark } else { Theme::Light }
186}
187
188fn portal_color_scheme() -> Option<bool> {
189 use zbus::zvariant::{OwnedValue, Value};
190 // A missing portal must not hold up the window for D-Bus's 25 s default.
191 let connection = zbus::blocking::connection::Builder::session()
192 .ok()?
193 .method_timeout(Duration::from_millis(500))
194 .build()
195 .ok()?;
196 let proxy = zbus::blocking::Proxy::new(
197 &connection,
198 "org.freedesktop.portal.Desktop",
199 "/org/freedesktop/portal/desktop",
200 "org.freedesktop.portal.Settings",
201 )
202 .ok()?;
203 let key = ("org.freedesktop.appearance", "color-scheme");
204 // Portals before ReadOne answer Read, with the value in a second variant.
205 let reply: OwnedValue = proxy
206 .call("ReadOne", &key)
207 .or_else(|_| proxy.call("Read", &key))
208 .ok()?;
209 let mut value = Value::from(reply);
210 while let Value::Value(inner) = value {
211 value = *inner;
212 }
213 match u32::try_from(value).ok()? {
214 1 => Some(true),
215 2 => Some(false),
216 _ => None,
217 }
218}
219
220pub fn cache_dir() -> Option<PathBuf> {
221 xdg_dir("XDG_CACHE_HOME", ".cache")
222}
223
224pub fn settings_dir() -> Option<PathBuf> {
225 xdg_dir("XDG_CONFIG_HOME", ".config")
226}
227
228/// The app's folder in the XDG base directory `variable` names, or in `fallback` under home.
229fn xdg_dir(variable: &str, fallback: &str) -> Option<PathBuf> {
230 std::env::var_os(variable)
231 .map(PathBuf::from)
232 .filter(|path| path.is_absolute())
233 .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback)))
234 .map(|base| base.join(APP_ID))
235}
236
237/// The application's icon from the icon theme, the smallest at least `pixels` square.
238pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> {
239 let (side, rgba) = icon_pixels(pixels)?;
240 draw::RasterImage::new([side; 2], rgba).ok()
241}
242
243/// Looks beside the executable first, as the release archive installs it, then in the
244/// XDG data directories.
245fn icon_pixels(pixels: u32) -> Option<(u32, Vec<u8>)> {
246 let home = std::env::var_os("XDG_DATA_HOME")
247 .map(PathBuf::from)
248 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")));
249 let system = std::env::var("XDG_DATA_DIRS")
250 .ok()
251 .filter(|dirs| !dirs.is_empty())
252 .unwrap_or_else(|| "/usr/local/share:/usr/share".into());
253 let roots: Vec<PathBuf> = std::env::current_exe()
254 .ok()
255 .and_then(|exe| Some(exe.parent()?.parent()?.join("share")))
256 .into_iter()
257 .chain(home)
258 .chain(std::env::split_paths(&system))
259 .collect();
260 let sizes = [16, 24, 32, 48, 64, 128, 256, 512];
261 let ordered = sizes
262 .iter()
263 .filter(|&&side| side >= pixels)
264 .chain(sizes.iter().rev().filter(|&&side| side < pixels));
265 ordered
266 .flat_map(|side| {
267 roots.iter().map(move |root| {
268 root.join(format!("icons/hicolor/{side}x{side}/apps/{APP_ID}.png"))
269 })
270 })
271 .find_map(|path| decode_rgba(&path))
272}
273
274fn decode_rgba(path: &std::path::Path) -> Option<(u32, Vec<u8>)> {
275 let file = std::io::BufReader::new(std::fs::File::open(path).ok()?);
276 let mut decoder = png::Decoder::new(file);
277 decoder.set_transformations(png::Transformations::normalize_to_color8());
278 let mut reader = decoder.read_info().ok()?;
279 let mut pixels = vec![0; reader.output_buffer_size()?];
280 let frame = reader.next_frame(&mut pixels).ok()?;
281 pixels.truncate(frame.buffer_size());
282 let rgba = match frame.color_type {
283 png::ColorType::Rgba => pixels,
284 png::ColorType::Rgb => pixels
285 .chunks_exact(3)
286 .flat_map(|pixel| [pixel[0], pixel[1], pixel[2], 255])
287 .collect(),
288 _ => return None,
289 };
290 (frame.width == frame.height).then_some((frame.width, rgba))
291}
292
293/// The account's full name from its passwd entry, or its login where that has none.
294pub fn user_name() -> String {
295 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
296 let mut found = std::ptr::null_mut();
297 let mut buffer = vec![0 as libc::c_char; 16 * 1024];
298 unsafe {
299 libc::getpwuid_r(
300 libc::getuid(),
301 &mut entry,
302 buffer.as_mut_ptr(),
303 buffer.len(),
304 &mut found,
305 )
306 };
307 let field = |value: *const libc::c_char| {
308 (!found.is_null() && !value.is_null()).then(|| {
309 unsafe { CStr::from_ptr(value) }
310 .to_string_lossy()
311 .into_owned()
312 })
313 };
314 // GECOS holds the full name before any comma-separated office and phone fields.
315 field(entry.pw_gecos)
316 .and_then(|gecos| gecos.split(',').next().map(str::to_owned))
317 .filter(|name| !name.trim().is_empty())
318 .or_else(|| field(entry.pw_name))
319 .or_else(|| std::env::var("USER").ok())
320 .unwrap_or_default()
321}
322
323/// The keyboard's language is not exposed portably, so pasted text takes the locale's.
324pub fn input_language() -> String {
325 ["LC_ALL", "LC_MESSAGES", "LANG"]
326 .into_iter()
327 .filter_map(|name| std::env::var(name).ok())
328 .find(|value| !value.is_empty())
329 .map(|locale| locale_tag(&locale))
330 .unwrap_or_default()
331}
332
333/// A POSIX locale name, such as `pt_BR.UTF-8@euro`, as a BCP-47 tag.
334fn locale_tag(locale: &str) -> String {
335 let name = locale.split(['.', '@']).next().unwrap_or_default();
336 if matches!(name, "C" | "POSIX") {
337 String::new()
338 } else {
339 name.replace('_', "-")
340 }
341}
342
343const FILETIME_EPOCH: i64 = 11_644_473_600;
344
345fn local_time(filetime: u64) -> libc::tm {
346 let seconds = (filetime / 10_000_000) as i64 - FILETIME_EPOCH;
347 let mut tm: libc::tm = unsafe { std::mem::zeroed() };
348 unsafe { libc::localtime_r(&seconds, &mut tm) };
349 tm
350}
351
352fn format(tm: &libc::tm, pattern: &CStr) -> String {
353 let mut buffer = [0u8; 256];
354 let length = unsafe {
355 libc::strftime(
356 buffer.as_mut_ptr().cast(),
357 buffer.len(),
358 pattern.as_ptr(),
359 tm,
360 )
361 };
362 String::from_utf8_lossy(&buffer[..length]).into_owned()
363}
364
365/// FILETIME as the locale's short date, as OneNote labels a conflict page.
366pub fn short_date(filetime: u64) -> String {
367 format(&local_time(filetime), c"%x")
368}
369
370/// The page's date and time labels, spelled out as OneNote shows them.
371fn date_labels(tm: &libc::tm) -> [String; 2] {
372 let meridiem = unsafe { CStr::from_ptr(libc::nl_langinfo(libc::AM_STR)) };
373 let time: &CStr = if meridiem.is_empty() {
374 c"%H:%M"
375 } else {
376 c"%-I:%M %p"
377 };
378 [format(tm, c"%A, %B %-d, %Y"), format(tm, time)]
379}
380
381/// FILETIME as a new page's title shows it: the long date and the short time.
382pub fn date_text(filetime: u64) -> [String; 2] {
383 date_labels(&local_time(filetime))
384}
385
386/// Asks for the page's date or time with the desktop's dialog tool.
387pub fn edit_date(
388 timestamp: u64,
389 field: DateField,
390 title: &str,
391) -> Result<Option<(u64, [String; 2])>, &'static str> {
392 let tm = local_time(timestamp);
393 let answer = match field {
394 DateField::Date => dialog(
395 [
396 "--calendar",
397 &format!("--title={title}"),
398 "--text=",
399 &format!("--day={}", tm.tm_mday),
400 &format!("--month={}", tm.tm_mon + 1),
401 &format!("--year={}", tm.tm_year + 1900),
402 "--date-format=%Y-%m-%d",
403 ],
404 ["--calendar", title, "--dateformat", "yyyy-MM-dd"],
405 )?,
406 DateField::Time => {
407 let current = format(&tm, c"%H:%M");
408 dialog(
409 [
410 "--entry",
411 &format!("--title={title}"),
412 "--text=Time, as hours and minutes:",
413 &format!("--entry-text={current}"),
414 ],
415 ["--inputbox", "Time, as hours and minutes:", &current],
416 )?
417 }
418 };
419 let Some(answer) = answer else {
420 return Ok(None);
421 };
422 let numbers: Vec<i32> = answer
423 .split(|c: char| !c.is_ascii_digit())
424 .filter(|part| !part.is_empty())
425 .map(|part| part.parse().unwrap_or(i32::MAX))
426 .collect();
427 let afternoon = answer.to_lowercase().contains("pm");
428 merge_date(timestamp, tm, field, &numbers, afternoon).map(Some)
429}
430
431fn merge_date(
432 timestamp: u64,
433 mut tm: libc::tm,
434 field: DateField,
435 numbers: &[i32],
436 afternoon: bool,
437) -> Result<(u64, [String; 2]), &'static str> {
438 match (field, numbers) {
439 (DateField::Date, &[year, month, day])
440 if (1..=12).contains(&month) && (1..=31).contains(&day) =>
441 {
442 tm.tm_year = year - 1900;
443 tm.tm_mon = month - 1;
444 tm.tm_mday = day;
445 }
446 (DateField::Time, &[hour, minute])
447 if (0..24).contains(&hour) && (0..60).contains(&minute) =>
448 {
449 tm.tm_hour = if afternoon && hour < 12 {
450 hour + 12
451 } else {
452 hour
453 };
454 tm.tm_min = minute;
455 }
456 _ => return Err(crate::DATE_UNCHOSEN),
457 }
458 tm.tm_isdst = -1;
459 let seconds = unsafe { libc::mktime(&mut tm) };
460 let outside_range = crate::DATE_OUT_OF_RANGE;
461 let updated = u64::try_from(seconds + FILETIME_EPOCH)
462 .ok()
463 .and_then(|seconds| seconds.checked_mul(10_000_000))
464 .and_then(|ticks| ticks.checked_add(timestamp % 10_000_000))
465 .ok_or(outside_range)?;
466 Ok((updated, date_labels(&tm)))
467}
468
469/// Runs zenity, or kdialog where zenity is missing; the answer is None when cancelled.
470fn dialog<const Z: usize, const K: usize>(
471 zenity: [&str; Z],
472 kdialog: [&str; K],
473) -> Result<Option<String>, &'static str> {
474 let output = Command::new("zenity")
475 .args(zenity)
476 .output()
477 .or_else(|_| Command::new("kdialog").args(kdialog).output())
478 .map_err(|_| "Install zenity or kdialog for Snowbound's dialogs.")?;
479 Ok(output
480 .status
481 .success()
482 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()))
483}
484
485/// Asks whether to go ahead with `action`; false when no tool can ask.
486pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
487 let status = Command::new("zenity")
488 .args([
489 "--question",
490 &format!("--title={message}"),
491 &format!("--text={detail}"),
492 &format!("--ok-label={action}"),
493 &format!("--cancel-label={cancel}"),
494 ])
495 .status()
496 .or_else(|_| {
497 Command::new("kdialog")
498 .args(["--warningcontinuecancel", detail, "--title", message])
499 .args(["--continue-label", action])
500 .status()
501 });
502 match status {
503 Ok(status) => status.success(),
504 Err(_) => {
505 eprintln!("Install zenity or kdialog to answer: {message}");
506 false
507 }
508 }
509}
510
511/// Asks for a notebook's table of contents or a section file, titled `title`; None when
512/// cancelled or when no tool can ask.
513pub fn pick_notebook(title: &str) -> Option<PathBuf> {
514 let asked = dialog(
515 [
516 "--file-selection",
517 &format!("--title={title}"),
518 "--file-filter=OneNote notebooks and sections | *.onetoc2 *.one",
519 ],
520 ["--getopenfilename", ".", "*.onetoc2 *.one", "--title", title],
521 );
522 asked
523 .unwrap_or_else(|error| {
524 eprintln!("{error}");
525 None
526 })
527 .filter(|path| !path.is_empty())
528 .map(PathBuf::from)
529}
530
531/// Asks where to create something named `name` by default.
532pub fn pick_new(title: &str, name: &str) -> Option<PathBuf> {
533 let asked = dialog(
534 [
535 "--file-selection",
536 "--save",
537 &format!("--title={title}"),
538 &format!("--filename={name}"),
539 ],
540 ["--getsavefilename", name, "--title", title],
541 );
542 asked
543 .unwrap_or_else(|error| {
544 eprintln!("{error}");
545 None
546 })
547 .filter(|path| !path.is_empty())
548 .map(PathBuf::from)
549}
550
551/// Tells the user something they asked for could not be done: `message`, then what to do.
552pub fn alert(message: &str, detail: &str) {
553 let (title, text) = (message.to_owned(), detail.to_owned());
554 std::thread::spawn(move || {
555 let shown = Command::new("zenity")
556 .args([
557 "--warning",
558 &format!("--title={title}"),
559 &format!("--text={text}"),
560 ])
561 .status()
562 .or_else(|_| {
563 Command::new("kdialog")
564 .args(["--sorry", &text, "--title", &title])
565 .status()
566 });
567 if shown.is_err() {
568 eprintln!("{title}: {text}");
569 }
570 });
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 fn at(year: i32, month: i32, day: i32, hour: i32, minute: i32) -> libc::tm {
578 let mut tm: libc::tm = unsafe { std::mem::zeroed() };
579 (tm.tm_year, tm.tm_mon, tm.tm_mday, tm.tm_hour, tm.tm_min) =
580 (year - 1900, month - 1, day, hour, minute);
581 tm.tm_isdst = -1;
582 tm
583 }
584
585 fn filetime(mut tm: libc::tm) -> u64 {
586 (unsafe { libc::mktime(&mut tm) } + FILETIME_EPOCH) as u64 * 10_000_000
587 }
588
589 #[test]
590 fn date_changes_keep_the_other_field_and_the_filetime_fraction() {
591 let original = filetime(at(2026, 9, 26, 15, 4)) + 9_123_456;
592 let tm = local_time(original);
593 let (updated, labels) =
594 merge_date(original, tm, DateField::Date, &[2027, 2, 28], false).unwrap();
595 assert_eq!(updated, filetime(at(2027, 2, 28, 15, 4)) + 9_123_456);
596 assert!(labels.iter().all(|label| !label.is_empty()));
597 let (updated, _) = merge_date(original, tm, DateField::Time, &[9, 30], true).unwrap();
598 assert_eq!(updated, filetime(at(2026, 9, 26, 21, 30)) + 9_123_456);
599 assert!(merge_date(original, tm, DateField::Date, &[2026, 13, 1], false).is_err());
600 assert!(merge_date(original, tm, DateField::Time, &[9], false).is_err());
601 assert!(merge_date(original, tm, DateField::Date, &[1500, 1, 1], false).is_err());
602 }
603
604 #[test]
605 fn locales_become_language_tags() {
606 assert_eq!(locale_tag("pt_BR.UTF-8@euro"), "pt-BR");
607 assert_eq!(locale_tag("de_DE"), "de-DE");
608 assert_eq!(locale_tag("C.UTF-8"), "");
609 }
610}
crates/snowbound/src/macos.rs+163-37
......@@ -19,8 +19,9 @@ use std::{cell::Cell, sync::OnceLock};
1919use winit::{
2020 error::EventLoopError,
2121 event_loop::{EventLoop, EventLoopProxy},
22 platform::macos::WindowAttributesExtMacOS,
2223 raw_window_handle::{HasWindowHandle, RawWindowHandle},
23 window::Window,
24 window::{Window, WindowAttributes},
2425};
2526
2627static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
......@@ -70,6 +71,109 @@ fn ns_window(window: &Window) -> Retained<AnyObject> {
7071 }
7172}
7273
74/// Room the traffic lights take at the title bar's leading edge.
75pub const LEADING: f32 = 78.0;
76/// How far AppKit rounds a window's corners.
77pub const CORNER_RADIUS: f32 = 10.0;
78
79/// A transparent title bar over the content, where the window draws its own.
80pub fn window_attributes() -> WindowAttributes {
81 Window::default_attributes()
82 .with_titlebar_transparent(true)
83 .with_title_hidden(true)
84 .with_fullsize_content_view(true)
85}
86
87pub struct Clipboard(arboard::Clipboard);
88
89impl Clipboard {
90 pub fn new(_: &Window) -> Result<Self, arboard::Error> {
91 arboard::Clipboard::new().map(Self)
92 }
93
94 pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> {
95 self.0.set_text(text)
96 }
97
98 pub fn get_text(&mut self) -> Result<String, arboard::Error> {
99 self.0.get_text()
100 }
101}
102
103/// AppKit draws the traffic lights.
104pub fn window_controls(_: &mut ui::Ui, _: &Window) {}
105
106/// The app draws the title bar around the traffic lights.
107pub fn system_titlebar(_: &Window) -> bool {
108 false
109}
110
111/// AppKit's window frame takes resizing presses.
112pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option<winit::window::ResizeDirection> {
113 None
114}
115
116pub fn cache_dir() -> Option<std::path::PathBuf> {
117 Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches/snowbound"))
118}
119
120pub fn settings_dir() -> Option<std::path::PathBuf> {
121 Some(
122 std::path::PathBuf::from(std::env::var_os("HOME")?)
123 .join("Library/Application Support/Snowbound"),
124 )
125}
126
127/// Asks for a notebook folder or a notebook file with the system's open panel, titled
128/// `title`.
129pub fn pick_notebook(title: &str) -> Option<std::path::PathBuf> {
130 let mtm = MainThreadMarker::new().expect("Panels belong to the main thread");
131 unsafe {
132 let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm);
133 panel.setCanChooseDirectories(true);
134 panel.setCanChooseFiles(true);
135 panel.setCanCreateDirectories(true);
136 panel.setTitle(Some(&NSString::from_str(title)));
137 panel.setPrompt(Some(&NSString::from_str("Open")));
138 if panel.runModal() != objc2_app_kit::NSModalResponseOK {
139 return None;
140 }
141 let path = panel.URLs().firstObject()?.path()?;
142 Some(path.to_string().into())
143 }
144}
145
146/// Asks where to create something named `name` by default, with the system's save panel.
147pub fn pick_new(title: &str, name: &str) -> Option<std::path::PathBuf> {
148 let mtm = MainThreadMarker::new().expect("Panels belong to the main thread");
149 unsafe {
150 let panel = objc2_app_kit::NSSavePanel::savePanel(mtm);
151 panel.setCanCreateDirectories(true);
152 panel.setTitle(Some(&NSString::from_str(title)));
153 panel.setPrompt(Some(&NSString::from_str("Create")));
154 panel.setNameFieldStringValue(&NSString::from_str(name));
155 if panel.runModal() != objc2_app_kit::NSModalResponseOK {
156 return None;
157 }
158 Some(panel.URL()?.path()?.to_string().into())
159 }
160}
161
162/// Tells the user something they asked for could not be done: `message`, then what to do.
163pub fn alert(message: &str, detail: &str) {
164 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
165 unsafe {
166 let alert = NSAlert::new(mtm);
167 alert.setMessageText(&NSString::from_str(message));
168 alert.setInformativeText(&NSString::from_str(detail));
169 alert.runModal();
170 }
171}
172
173pub fn appearance(window: &Window) -> winit::window::Theme {
174 window.theme().unwrap_or(winit::window::Theme::Dark)
175}
176
73177/// Zooms the window once the current event is handled: AppKit's zoom animation runs its
74178/// own loop, and started from inside winit's handler it would hold every resize until the
75179/// end, stretching the last frame instead of drawing each step.
......@@ -124,6 +228,7 @@ pub fn show_character_palette() {
124228pub fn edit_date(
125229 timestamp: u64,
126230 field: DateField,
231 title: &str,
127232) -> Result<Option<(u64, [String; 2])>, &'static str> {
128233 let mtm = MainThreadMarker::new().expect("Date controls belong to the main thread");
129234 unsafe {
......@@ -147,10 +252,7 @@ pub fn edit_date(
147252 let _: () = msg_send![&picker, setAccessibilityLabel: &*label];
148253 picker.sizeToFit();
149254 let alert = NSAlert::new(mtm);
150 alert.setMessageText(&NSString::from_str(match field {
151 DateField::Date => "Change Page Date",
152 DateField::Time => "Change Page Time",
153 }));
255 alert.setMessageText(&NSString::from_str(title));
154256 alert.setAccessoryView(Some(&picker));
155257 alert.addButtonWithTitle(&NSString::from_str("Apply"));
156258 alert.addButtonWithTitle(&NSString::from_str("Cancel"));
......@@ -197,9 +299,9 @@ fn merge_date(
197299 }
198300 let date = calendar
199301 .dateFromComponents(&components)
200 .ok_or("Choose another date or time.")?;
302 .ok_or(crate::DATE_UNCHOSEN)?;
201303 let seconds = date.timeIntervalSince1970() + 11_644_473_600.0;
202 let outside_range = "This date is outside the notebook's supported range.";
304 let outside_range = crate::DATE_OUT_OF_RANGE;
203305 if !seconds.is_finite() || seconds < 0.0 || seconds > (u64::MAX / 10_000_000) as f64 {
204306 return Err(outside_range);
205307 }
......@@ -208,17 +310,49 @@ fn merge_date(
208310 .checked_mul(10_000_000)
209311 .and_then(|value| value.checked_add(timestamp % 10_000_000))
210312 .ok_or(outside_range)?;
313 Ok((updated, labels(&date, calendar)))
314 }
315}
316
317/// `date` as a page's title shows it: the long date and the short time.
318fn labels(date: &NSDate, calendar: &NSCalendar) -> [String; 2] {
319 unsafe {
211320 let formatter = NSDateFormatter::new();
212321 formatter.setCalendar(Some(calendar));
213322 formatter.setTimeZone(Some(&calendar.timeZone()));
214323 formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterFullStyle);
215 let date_text = formatter.stringFromDate(&date).to_string();
324 let date_text = formatter.stringFromDate(date).to_string();
216325 formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterNoStyle);
217326 formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterShortStyle);
218 Ok((
219 updated,
220 [date_text, formatter.stringFromDate(&date).to_string()],
221 ))
327 [date_text, formatter.stringFromDate(date).to_string()]
328 }
329}
330
331/// FILETIME as a new page's title shows it: the long date and the short time.
332pub fn date_text(filetime: u64) -> [String; 2] {
333 unsafe {
334 let date = NSDate::dateWithTimeIntervalSince1970(
335 (filetime / 10_000_000) as f64 - 11_644_473_600.0,
336 );
337 labels(&date, &NSCalendar::currentCalendar())
338 }
339}
340
341/// The account's full name, which OneNote's author fields take from Office's user name.
342pub fn user_name() -> String {
343 unsafe { objc2_foundation::NSFullUserName().as_ref() }.to_string()
344}
345
346/// FILETIME as the system's short date, as OneNote labels a conflict page.
347pub fn short_date(filetime: u64) -> String {
348 unsafe {
349 let date = NSDate::dateWithTimeIntervalSince1970(
350 (filetime / 10_000_000) as f64 - 11_644_473_600.0,
351 );
352 let formatter = NSDateFormatter::new();
353 formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterShortStyle);
354 formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterNoStyle);
355 formatter.stringFromDate(&date).to_string()
222356 }
223357}
224358
......@@ -354,8 +488,7 @@ pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> {
354488pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {
355489 let class = AnyClass::get("NSAppearance").expect("AppKit is linked");
356490 unsafe {
357 let appearance: Retained<AnyObject> =
358 msg_send_id![&ns_window(window), effectiveAppearance];
491 let appearance: Retained<AnyObject> = msg_send_id![&ns_window(window), effectiveAppearance];
359492 // System colours resolve in the thread's appearance, which outside drawing does
360493 // not follow the window's.
361494 let previous: Option<Retained<AnyObject>> = msg_send_id![class, currentAppearance];
......@@ -432,46 +565,39 @@ declare_class!(
432565 }
433566);
434567
435pub fn event_loop() -> Result<EventLoop<crate::UserEvent>, EventLoopError> {
568/// A `headless` application takes no Dock icon and never activates.
569pub fn event_loop(headless: bool) -> Result<EventLoop<crate::UserEvent>, EventLoopError> {
570 use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
436571 MainThreadMarker::new().expect("AppKit must start on the main thread");
437572 // Create the application's own subclass before Winit obtains the singleton.
438573 let _: Retained<CanvasApplication> =
439574 unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] };
440 let event_loop = EventLoop::with_user_event().build()?;
575 let mut builder = EventLoop::with_user_event();
576 if headless {
577 builder
578 .with_activation_policy(ActivationPolicy::Prohibited)
579 .with_activate_ignoring_other_apps(false);
580 }
581 let event_loop = builder.build()?;
441582 QUIT.set(event_loop.create_proxy())
442583 .expect("Only one application event loop is created");
443584 Ok(event_loop)
444585}
445586
446pub fn discard_changes() -> bool {
587/// Asks whether to go ahead with `action`, offering `cancel` first.
588pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool {
447589 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
448590 // The alert and its strings stay on AppKit's main thread for the modal call.
449591 unsafe {
450592 let alert = NSAlert::new(mtm);
451 alert.setMessageText(&NSString::from_str("Discard this page?"));
452 alert.setInformativeText(&NSString::from_str(
453 "This temporary page has no saved copy. Closing it will discard your edits.",
454 ));
455 alert.addButtonWithTitle(&NSString::from_str("Keep Editing"));
456 alert.addButtonWithTitle(&NSString::from_str("Discard Changes"));
593 alert.setMessageText(&NSString::from_str(message));
594 alert.setInformativeText(&NSString::from_str(detail));
595 alert.addButtonWithTitle(&NSString::from_str(cancel));
596 alert.addButtonWithTitle(&NSString::from_str(action));
457597 alert.runModal() == NSAlertSecondButtonReturn
458598 }
459599}
460600
461/// Tells the user an edit could not be stored and where their text went.
462pub fn change_not_saved() {
463 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
464 // The alert and its strings stay on AppKit's main thread for the modal call.
465 unsafe {
466 let alert = NSAlert::new(mtm);
467 alert.setMessageText(&NSString::from_str("Change not saved"));
468 alert.setInformativeText(&NSString::from_str(
469 "The page now shows what was last saved. Your text is on the clipboard to paste back.",
470 ));
471 alert.runModal();
472 }
473}
474
475601/// End AppKit preedit after a canvas action commits or cancels the composition.
476602pub fn clear_marked_text(window: &Window) {
477603 MainThreadMarker::new().expect("Text input belongs to the main thread");
crates/snowbound/src/main.rs+1097-399
......@@ -1,8 +1,19 @@
11mod art;
2mod macos;
2#[cfg(test)]
3mod conflict_render;
4mod library;
5mod manage;
6mod menus;
7#[cfg_attr(target_os = "linux", path = "linux.rs")]
8#[cfg_attr(target_os = "macos", path = "macos.rs")]
9mod platform;
10mod screenshot;
11mod settings;
12mod sidebar;
13mod templates;
314
415use canvas::gpu::page::PageScene;
5use canvas::interaction::{Cursor, PageView, Request, Response, TextColors, accessibility};
16use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility};
617use canvas::{
718 date::DateField,
819 document::TextDocument,
......@@ -18,7 +29,9 @@ use onestore::ExGuid;
1829use onestore::document::Format;
1930use onestore::page::Page;
2031use onestore::page::text::Paragraph;
32use library::Library;
2133use std::{
34 collections::{HashMap, HashSet},
2235 error::Error,
2336 path::{Path, PathBuf},
2437 sync::{Arc, mpsc},
......@@ -31,21 +44,23 @@ use winit::{
3144 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
3245 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},
3346 keyboard::{Key, ModifiersState, NamedKey},
34 platform::macos::WindowAttributesExtMacOS,
3547 window::{CursorIcon, Window, WindowId},
3648};
3749
38/// Height of the macOS title bar, which holds the traffic lights.
50/// Height of the title bar, which holds the window's controls.
3951const TITLE: f32 = 28.0;
40/// Room the traffic lights take at the title bar's leading edge.
41const LIGHTS: f32 = 78.0;
4252const TAB_ROW: f32 = 28.0;
4353/// Width of the section colour around the page.
4454const FRAME: f32 = 6.0;
45/// macOS rounds windows' corners by 10 pt; the page's corners share their centres.
46const ROUNDING: f32 = 10.0 - FRAME;
55/// The page's corners share their centres with the window's.
56const ROUNDING: f32 = (platform::CORNER_RADIUS - FRAME).max(0.0);
4757const PAGE_LIST: f32 = 240.0;
4858/// Font sizes the size box offers, OneNote's list in points.
59/// Fonts the font box offers first, OneNote's default leading.
60const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"];
61/// Fonts picked this session that the font box offers after `FONTS`, at most.
62const RECENT_FONTS: usize = 5;
63/// Font sizes the size box offers, OneNote's list in points.
4964const SIZES: [f32; 17] = [
5065 8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0,
5166 72.0,
......@@ -65,14 +80,21 @@ const FONT_COLORS: [u32; 20] = [
6580/// while the gaps still take the pointer.
6681const ROW: f32 = 29.0;
6782const ROW_GAP: f32 = 3.0;
83/// OneNote's highlight for conflicting changes on a conflict page, COLORREF.
84const CONFLICTING: u32 = 0xd6d6ff;
6885/// Space between the page and a page tab that isn't open.
6986const PILL_MARGIN: f32 = 3.0;
7087/// Rounding where the open page tab meets the page, concentric with its neighbours'.
7188const JOIN: f32 = PILL_MARGIN + ROUNDING;
89/// Why the platform's date dialog could not change the page's date.
90const DATE_UNCHOSEN: &str = "Choose another date or time.";
91const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range.";
7292
7393#[derive(Debug)]
7494enum UserEvent {
7595 Quit,
96 /// Text AppKit inserts outside key events, such as the character palette's.
97 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
7698 InsertText(String),
7799 Accessibility(accesskit_winit::Event),
78100 /// The section's synchronization thread reported an event.
......@@ -141,6 +163,11 @@ struct App {
141163 substitutes: Vec<PathBuf>,
142164 /// Font files for the interface in place of the system's.
143165 interface_font: Vec<Vec<u8>>,
166 /// Where `--screenshot` writes its PNGs, drawn from a window never shown.
167 screenshot: Option<PathBuf>,
168 /// Where settings are saved, and what they held at launch, until the window opens.
169 settings: Option<(Option<PathBuf>, settings::Settings)>,
170 cache: PathBuf,
144171 state: Option<State>,
145172 startup_error: Option<Box<dyn Error>>,
146173}
......@@ -155,76 +182,114 @@ enum Input {
155182 Section {
156183 file: PathBuf,
157184 title: String,
158 cache: PathBuf,
159185 },
160 Notebook {
161 root: PathBuf,
162 cache: PathBuf,
186 /// The open notebooks by location, showing `current`; none shows the first run's
187 /// welcome.
188 Notebooks {
189 locations: Vec<String>,
190 current: Option<String>,
163191 },
164192}
165193
166/// A section tab: where the section opens from and how it is labelled.
167struct Tab {
168 /// The notebook catalog path, or the file of a section opened on its own.
169 path: String,
170 name: String,
171 /// COLORREF.
172 color: Option<u32>,
173}
174
175/// The sections the tabs offer.
176struct Library {
177 /// The notebook's folder name, or the section file's.
178 name: String,
179 notebook: Option<notebook::session::Notebook>,
180 tabs: Vec<Tab>,
181 cache: PathBuf,
182}
183
184impl Library {
185 fn open(
186 &self,
187 tab: usize,
188 proxy: EventLoopProxy<UserEvent>,
189 ) -> Result<notebook::session::Section, Box<dyn Error>> {
190 let notify = move || {
191 let _ = proxy.send_event(UserEvent::Sync);
192 };
193 let path = &self.tabs[tab].path;
194 Ok(match &self.notebook {
195 Some(notebook) => notebook.section(path, notify)?,
196 None => notebook::session::Section::open(path, &self.cache, notify)?,
197 })
194/// Asks the event loop to poll sections when their synchronization reports.
195fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static {
196 move || {
197 let _ = proxy.send_event(UserEvent::Sync);
198198 }
199199}
200200
201201/// The open section and the page the editor shows.
202202struct Session {
203203 section: notebook::session::Section,
204 /// The notebook the section belongs to.
205 library: Arc<Library>,
206 /// The sections of the section's folder, and which is this one.
207 tabs: Vec<library::Tab>,
204208 tab: usize,
205209 /// Spaces, titles and outline levels in section order.
206210 pages: Vec<(ExGuid, String, u32)>,
207211 space: ExGuid,
208212 status: &'static str,
209 /// The editor's page has an unreviewed conflict with another machine's change.
210 conflict: bool,
213 /// Each page's conflict pages, the versions a merge could not take.
214 conflicts: Vec<(ExGuid, Vec<onestore::ConflictPage>)>,
215 /// The page whose conflict pages the list shows beneath it.
216 shown: Option<ExGuid>,
217 /// Which of the open conflict page's conflicting changes is selected, in page order.
218 change: Option<usize>,
219}
220
221/// The information bar above a page with conflict pages, or above one of them.
222#[derive(Clone, Copy)]
223enum Bar {
224 Page { page: ExGuid, shown: bool },
225 Version { page: ExGuid, version: ExGuid },
211226}
212227
213228impl Session {
214229 fn title(&self) -> &str {
215 self.pages
230 match self.conflict(self.space) {
231 Some((_, version)) => &version.title,
232 None => self
233 .pages
234 .iter()
235 .find(|(space, ..)| *space == self.space)
236 .map_or("", |(_, title, _)| title),
237 }
238 }
239
240 fn versions(&self, page: ExGuid) -> &[onestore::ConflictPage] {
241 self.conflicts
216242 .iter()
217 .find(|(space, ..)| *space == self.space)
218 .map_or("", |(_, title, _)| title)
243 .find(|(listed, _)| *listed == page)
244 .map_or(&[], |(_, versions)| versions)
245 }
246
247 /// The page conflict page `space` belongs to, with it.
248 fn conflict(&self, space: ExGuid) -> Option<(ExGuid, &onestore::ConflictPage)> {
249 self.conflicts.iter().find_map(|(page, versions)| {
250 Some((*page, versions.iter().find(|version| version.space == space)?))
251 })
252 }
253
254 fn bar(&self) -> Option<Bar> {
255 match self.conflict(self.space) {
256 Some((page, _)) => Some(Bar::Version {
257 page,
258 version: self.space,
259 }),
260 None => (!self.versions(self.space).is_empty()).then_some(Bar::Page {
261 page: self.space,
262 shown: self.shown == Some(self.space),
263 }),
264 }
219265 }
220266
221 fn refresh_conflict(&mut self) -> Result<(), Box<dyn Error>> {
222 self.conflict = self
223 .section
224 .conflict()?
225 .is_some_and(|conflict| conflict.space == self.space);
267 fn refresh_conflicts(&mut self) -> Result<(), Box<dyn Error>> {
268 self.conflicts = self.section.conflicts()?;
269 if self
270 .shown
271 .is_some_and(|page| self.versions(page).is_empty())
272 {
273 self.shown = None;
274 }
226275 Ok(())
227276 }
277
278 /// The page in `space` as the editor shows it: a conflict page read-only, its
279 /// conflicting changes highlighted.
280 fn reader(&self, space: ExGuid) -> impl FnOnce() -> Result<Page, Box<dyn Error>> + Send + 'static {
281 let replica = Arc::clone(self.section.replica());
282 let objects = self
283 .conflict(space)
284 .map(|(_, version)| version.objects.clone());
285 move || {
286 let page = replica.page(space)?;
287 Ok(match objects {
288 Some(objects) => highlighted(page, &objects),
289 None => page,
290 })
291 }
292 }
228293}
229294
230295/// What a loader thread opened: a section, or another page of the open one.
......@@ -250,20 +315,52 @@ const HOLD: std::time::Duration = std::time::Duration::from_millis(200);
250315
251316/// Work the interface asked for, done after the frame is built.
252317enum Command {
253 OpenSection(usize),
318 /// Opens a notebook's section by catalog path, at the page it showed last.
319 OpenSection(Arc<Library>, String),
320 /// Asks for a notebook folder and opens it.
321 OpenNotebook,
322 /// Asks where to keep a new notebook and creates it.
323 NewNotebook,
324 /// Closes a notebook, keeping its files.
325 CloseNotebook(Arc<Library>),
326 /// Changes a notebook's sections and groups.
327 Structure(Arc<Library>, manage::Structure),
328 /// Adds a page at the end of the open section, or a subpage after the open page, and
329 /// edits its title.
330 NewPage { subpage: bool },
331 /// Deletes pages of the open section to the notebook's recycle bin.
332 DeletePages(Vec<ExGuid>),
333 /// Moves or indents pages of the open section.
334 Pages(Vec<onestore::PageEdit>),
335 /// Gives the open page a template's background or colour.
336 Template(templates::Choice),
254337 OpenPage(ExGuid),
255 Resolve { keep_mine: bool },
338 /// Shows a page's conflict pages in the list and opens the newest, or hides them.
339 Versions { page: ExGuid, show: bool },
340 DeleteVersion { page: ExGuid, version: ExGuid },
341 /// Copies a conflict page, as a page of its own, to the end of section tab `tab`.
342 CopyVersion { version: ExGuid, tab: usize },
343 /// Selects the next, or previous, conflicting change on the open conflict page.
344 SelectChange { forward: bool },
256345 Page(Request),
257346}
258347
259348struct State {
260349 window: Arc<Window>,
350 /// Who this machine's edits name, as OneNote names the Office user: the account's
351 /// full name.
352 author: String,
261353 proxy: EventLoopProxy<UserEvent>,
262354 /// Results of reads done off the frame thread, by request number.
263355 loads: (mpsc::Sender<Read>, mpsc::Receiver<Read>),
264356 /// The newest read requested; older ones are dropped when they finish.
265357 loading: u64,
266358 opening: Option<Opening>,
359 /// Where each page was left this run, by `Library::key` and page space, as OneNote
360 /// returns to it until the notebook closes.
361 places: HashMap<(String, ExGuid), Place>,
362 /// The page each section showed last this run, by `Library::key`.
363 last_pages: HashMap<String, ExGuid>,
267364 /// Asks for a frame when a worker thread finishes something the page shows.
268365 redraw: std::task::Waker,
269366 instance: wgpu::Instance,
......@@ -272,14 +369,42 @@ struct State {
272369 renderer: Renderer,
273370 ui: Ui,
274371 view: PageView,
275 library: Option<Arc<Library>>,
372 /// The open notebooks in the sidebar's order.
373 notebooks: Vec<Arc<Library>>,
276374 session: Option<Session>,
375 /// A page not kept in any notebook, from a text file or `--page`.
376 temporary: bool,
377 /// Where the settings are saved; `None` leaves them as they were read.
378 settings: Option<PathBuf>,
379 /// The directory holding the sections' replicas.
380 cache: PathBuf,
381 /// Whether the notebook sidebar is expanded.
382 sidebar: bool,
383 /// Notebooks and section groups whose rows the sidebar folds, by `sidebar::fold_key`.
384 folded: HashSet<String>,
385 /// The open context menu: what it was opened on, and where.
386 menu: Option<(menus::Target, [f32; 2])>,
387 /// A section or group being renamed in the sidebar.
388 renaming: Option<sidebar::Renaming>,
389 /// The page whose tab is being dragged.
390 dragging: Option<ExGuid>,
391 /// The section or group whose sidebar row is being dragged.
392 dragging_entry: Option<sidebar::Entry>,
393 /// Pages whose template strip was dismissed this run.
394 dismissed: HashSet<ExGuid>,
395 /// A page just created, whose title takes the caret once it opens.
396 title_focus: Option<ExGuid>,
397 /// What the template strip shows over a blank page.
398 templates: templates::View,
399 thumbnails: templates::Thumbnails,
277400 /// Text filtering the page list.
278401 filter: String,
279402 /// Whether the page list is shown beside the page.
280403 pages_open: bool,
281404 /// Installed font families, for the font box; listing them is slow.
282405 fonts: Vec<String>,
406 /// Fonts picked from the font box this session, latest first.
407 recent_fonts: Vec<String>,
283408 /// The application's icon at the display's scale, for the title bar.
284409 app_icon: Option<draw::RasterImage>,
285410 commands: Vec<Command>,
......@@ -296,6 +421,8 @@ struct State {
296421 pointer: [f32; 2],
297422 /// When the strip was last pressed, to zoom on a double click.
298423 strip_press: Option<Instant>,
424 /// The strip is held and the window moves once the pointer does.
425 strip_held: bool,
299426 /// Where a replay asked the next frame to be written.
300427 snapshot: Option<PathBuf>,
301428 initial: Vec<(onestore::ExGuid, TextDocument)>,
......@@ -303,7 +430,7 @@ struct State {
303430 initial_date: Option<u64>,
304431 occluded: bool,
305432 ime_allowed: bool,
306 clipboard: arboard::Clipboard,
433 clipboard: platform::Clipboard,
307434 access_adapter: accesskit_winit::Adapter,
308435 accessibility: accessibility::Accessibility,
309436}
......@@ -333,14 +460,14 @@ impl State {
333460 input: Input,
334461 substitutes: &[PathBuf],
335462 interface_font: Vec<Vec<u8>>,
463 visible: bool,
464 (settings, stored): (Option<PathBuf>, settings::Settings),
465 cache: PathBuf,
336466 ) -> Result<Self, Box<dyn Error>> {
337467 let window = Arc::new(
338468 event_loop.create_window(
339 Window::default_attributes()
469 platform::window_attributes()
340470 .with_visible(false)
341 .with_titlebar_transparent(true)
342 .with_title_hidden(true)
343 .with_fullsize_content_view(true)
344471 .with_title(match &input {
345472 Input::Notes {
346473 reference: Some(page),
......@@ -351,20 +478,20 @@ impl State {
351478 reference: None, ..
352479 } => "Untitled · Temporary page".into(),
353480 Input::Section { title, .. } => title.clone(),
354 Input::Notebook { root, .. } => root.display().to_string(),
481 Input::Notebooks { .. } => "Snowbound".into(),
355482 })
356483 .with_inner_size(LogicalSize::new(1180.0, 760.0)),
357484 )?,
358485 );
359 macos::install_text_input(&window);
486 platform::install_text_input(&window);
360487 let access_adapter =
361488 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
362 window.set_visible(true);
363 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
364 Box::new(window.clone()),
365 ));
489 window.set_visible(visible);
490 let instance = wgpu::Instance::new(
491 wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())),
492 );
366493 let surface = instance.create_surface(window.clone())?;
367 macos::configure_presentation(&surface);
494 platform::configure_presentation(&surface);
368495 let adapter = instance
369496 .request_adapter(&wgpu::RequestAdapterOptions {
370497 compatible_surface: Some(&surface),
......@@ -384,7 +511,8 @@ impl State {
384511 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;
385512 eprintln!("Using {} for {target}", path.display());
386513 }
387 let mut library = None;
514 let temporary = matches!(input, Input::Notes { .. } | Input::Page(_));
515 let mut notebooks = Vec::new();
388516 let mut session = None;
389517 let (editor, scene) = match input {
390518 Input::Notes {
......@@ -404,51 +532,54 @@ impl State {
404532 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
405533 (editor, Some((scene, [0.0; 2])))
406534 }
407 Input::Section { file, title, cache } => {
408 let opened = Library {
409 name: file_name(file.parent().unwrap_or(&file)),
410 notebook: None,
411 tabs: vec![Tab {
412 name: file
413 .file_stem()
414 .map(|name| name.to_string_lossy().into_owned())
415 .unwrap_or_default(),
416 path: file.to_string_lossy().into_owned(),
417 color: None,
418 }],
419 cache,
420 };
421 let section = opened.open(0, proxy.clone())?;
535 Input::Section { file, title } => {
536 let library = Arc::new(Library::section(&file, &cache));
537 let section = library.open(&library.location, notify(proxy.clone()))?;
422538 let space = section
423539 .pages()?
424540 .into_iter()
425541 .find(|(_, candidate, _)| *candidate == title)
426542 .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?
427543 .0;
428 library = Some(Arc::new(opened));
429 let (opened, page) = read_session(section, 0, Some(space))?;
544 notebooks.push(Arc::clone(&library));
545 let path = library.location.clone();
546 let (opened, page) = read_session(section, library, path, Some(space))?;
430547 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
431548 session = Some(opened);
432549 (editor, Some((scene, [0.0; 2])))
433550 }
434 Input::Notebook { root, cache } => {
435 let notebook = notebook::session::Notebook::open(&root, &cache)?;
436 let tabs = tabs(&notebook);
437 if tabs.is_empty() {
438 return Err(format!("No readable sections in {}", root.display()).into());
439 }
440 let opened = Library {
441 name: file_name(&root),
442 notebook: Some(notebook),
443 tabs,
444 cache,
445 };
446 let section = opened.open(0, proxy.clone())?;
447 library = Some(Arc::new(opened));
448 let (opened, page) = read_session(section, 0, None)?;
449 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
450 session = Some(opened);
451 (editor, Some((scene, [0.0; 2])))
551 Input::Notebooks { locations, current } => {
552 notebooks = locations
553 .iter()
554 .map(|location| Arc::new(Library::notebook(location, &cache)))
555 .collect();
556 for library in &notebooks {
557 if let Err(error) = &library.notebook {
558 eprintln!("Cannot open the notebook at {}: {error}", library.location);
559 }
560 }
561 let shown = notebooks
562 .iter()
563 .filter(|library| Some(&library.location) == current.as_ref())
564 .chain(&notebooks)
565 .find_map(|library| Some((library, library.first_section()?)));
566 match shown {
567 Some((library, path)) => {
568 let section = library.open(&path, notify(proxy.clone()))?;
569 let (opened, page) =
570 read_session(section, Arc::clone(library), path, None)?;
571 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
572 session = Some(opened);
573 (editor, Some((scene, [0.0; 2])))
574 }
575 None => {
576 let blank = TextDocument::new(vec![Paragraph::new(
577 String::new(),
578 Format::default(),
579 )])?;
580 (CanvasEditor::new(&mut engine, blank, 480.0)?, None)
581 }
582 }
452583 }
453584 };
454585 let initial_date = editor.date().map(|date| date.timestamp());
......@@ -466,8 +597,8 @@ impl State {
466597 window.request_redraw();
467598 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());
468599 let mut ui = Ui::new(
469 theme(window.theme().unwrap_or(winit::window::Theme::Dark)),
470 macos::double_click_interval(),
600 theme(platform::appearance(&window)),
601 platform::double_click_interval(),
471602 );
472603 if !interface_font.is_empty() {
473604 let family = ui
......@@ -476,6 +607,11 @@ impl State {
476607 eprintln!("Using {family} for the interface");
477608 }
478609 ui.set_focus(Some(page()));
610 for family in FONTS {
611 for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) {
612 ui.preview_font(face.clone(), family);
613 }
614 }
479615 let mut fonts: Vec<String> = engine
480616 .fonts
481617 .collection
......@@ -486,7 +622,9 @@ impl State {
486622 .collect();
487623 fonts.sort_unstable_by_key(|name| name.to_lowercase());
488624 fonts.dedup();
625 let clipboard = platform::Clipboard::new(&window)?;
489626 let state = Self {
627 author: platform::user_name(),
490628 window,
491629 redraw: Arc::new(Redraw(proxy.clone())).into(),
492630 proxy,
......@@ -501,14 +639,28 @@ impl State {
501639 scene,
502640 [size.width, size.height],
503641 dpr,
504 macos::double_click_interval(),
642 platform::double_click_interval(),
505643 ),
506 library,
644 notebooks,
507645 session,
646 temporary,
647 settings,
648 cache,
649 sidebar: stored.sidebar,
650 folded: HashSet::new(),
651 dismissed: HashSet::new(),
652 title_focus: None,
653 menu: None,
654 renaming: None,
655 dragging: None,
656 dragging_entry: None,
657 templates: templates::View::Strip,
658 thumbnails: templates::Thumbnails::default(),
508659 filter: String::new(),
509660 pages_open: true,
510661 fonts,
511 app_icon: macos::app_icon((16.0 * dpr).round() as u32),
662 recent_fonts: stored.recent_fonts,
663 app_icon: platform::app_icon((16.0 * dpr).round() as u32),
512664 commands: Vec::new(),
513665 changed: false,
514666 moved: false,
......@@ -516,16 +668,19 @@ impl State {
516668 page_focused: true,
517669 pointer: [0.0; 2],
518670 strip_press: None,
671 strip_held: false,
519672 snapshot: None,
520673 initial,
521674 initial_date,
522675 initial_layouts,
523676 occluded: false,
524677 ime_allowed: true,
525 clipboard: arboard::Clipboard::new()?,
678 clipboard,
526679 loads: mpsc::channel(),
527680 loading: 0,
528681 opening: None,
682 places: HashMap::new(),
683 last_pages: HashMap::new(),
529684 access_adapter,
530685 accessibility: accessibility::Accessibility::default(),
531686 };
......@@ -543,28 +698,15 @@ impl State {
543698 lap("open", start);
544699 let size = self.window.inner_size();
545700 let scale = self.window.scale_factor() as f32;
546 self.ui.begin(
701 self.layout(
547702 [size.width as f32 / scale, size.height as f32 / scale],
548703 scale,
549 Instant::now(),
550 );
551 let (section, open_tab, open_page) = self.build()?;
552 self.ui.end();
553 self.edges(section, open_tab, open_page);
704 )?;
554705 lap("build", start);
555 if let Some(rect) = self.ui.rect(page()) {
556 let size = [
557 ((rect[2] - rect[0]) * scale).round() as u32,
558 ((rect[3] - rect[1]) * scale).round() as u32,
559 ];
560 if size != self.view.viewport.size {
561 let response = self.view.resized(size)?;
562 self.respond(response);
563 }
564 }
565706 self.window.set_cursor(
566 self.ui
567 .cursor()
707 platform::resize_direction(&self.window, self.pointer)
708 .map(CursorIcon::from)
709 .or_else(|| self.ui.cursor())
568710 .unwrap_or_else(|| cursor_icon(self.view.cursor())),
569711 );
570712 let ime = if self.ui.focused() == Some(page()) {
......@@ -600,13 +742,34 @@ impl State {
600742 Ok(())
601743 }
602744
745 /// Lays out the interface in a window `size` logical pixels big and fits the page to
746 /// its box.
747 fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box<dyn Error>> {
748 self.ui.begin(size, scale, Instant::now());
749 let (section, open_tab, open_page) = self.build()?;
750 self.ui.end();
751 self.edges(section, open_tab, open_page);
752 if let Some(rect) = self.ui.rect(page()) {
753 let size = [
754 ((rect[2] - rect[0]) * scale).round() as u32,
755 ((rect[3] - rect[1]) * scale).round() as u32,
756 ];
757 if size != self.view.viewport.size {
758 let response = self.view.resized(size)?;
759 self.respond(response);
760 }
761 }
762 Ok(())
763 }
764
603765 /// Builds the frame's boxes and returns the section's colours as they ease, the open
604766 /// section tab and the open page's tab, for the edges drawn once they are laid out.
605767 fn build(&mut self) -> Result<(ui::Section, Id, Option<Id>), Box<dyn Error>> {
606 let target = self.ui.theme.section(match (&self.library, &self.session) {
607 (Some(library), Some(session)) => section_color(library.tabs[session.tab].color),
608 _ => section_color(None),
609 });
768 let target = self.ui.theme.section(section_color(
769 self.session
770 .as_ref()
771 .and_then(|session| session.tabs[session.tab].color),
772 ));
610773 let mut ease = |part: &str, color: [f32; 4]| -> [f32; 4] {
611774 std::array::from_fn(|channel| {
612775 self.ui
......@@ -623,14 +786,40 @@ impl State {
623786 accent: ease("accent", target.accent),
624787 };
625788 self.ui.theme.accent = section.accent;
626 [
627 self.ui.theme.caret,
628 self.ui.theme.selection,
629 self.ui.theme.inactive_selection,
630 ] = macos::text_colors(&self.window);
789 // Elsewhere the theme holds the platform's colours.
790 #[cfg(target_os = "macos")]
791 {
792 [
793 self.ui.theme.caret,
794 self.ui.theme.selection,
795 self.ui.theme.inactive_selection,
796 ] = platform::text_colors(&self.window);
797 }
631798 let theme = self.ui.theme.clone();
632 self.title_bar(&theme);
799 if !platform::system_titlebar(&self.window) {
800 self.title_bar(&theme);
801 }
802 if self.session.is_none() && !self.temporary {
803 self.welcome(&theme);
804 return Ok((section, Id::ROOT, None));
805 }
633806 self.toolbar(&theme)?;
807 self.ui.open(
808 "body",
809 Spec {
810 size: [fill(), fill()],
811 ..Spec::default()
812 },
813 );
814 self.sidebar(&theme);
815 self.ui.open(
816 "main",
817 Spec {
818 axis: Axis::Y,
819 size: [fill(), fill()],
820 ..Spec::default()
821 },
822 );
634823 self.ui.open(
635824 "tabs",
636825 Spec {
......@@ -647,34 +836,45 @@ impl State {
647836 ..Spec::default()
648837 },
649838 );
650 let (clicked, open_tab) = match (&self.library, &self.session) {
651 (Some(library), Some(session)) => {
652 let tabs: Vec<_> = library
839 let (clicked, open_tab) = match &self.session {
840 Some(session) => {
841 let tabs: Vec<_> = session
653842 .tabs
654843 .iter()
655844 .map(|tab| (tab.name.as_str(), section_color(tab.color)))
656845 .collect();
657 ui::shell::section_tabs(
846 let (clicked, context, open_tab) = ui::shell::section_tabs(
658847 &mut self.ui,
659848 "sections",
660849 &tabs,
661850 session.tab,
662851 &section,
663852 TAB_ROW,
664 )
853 );
854 let open = |tab: usize| {
855 Command::OpenSection(Arc::clone(&session.library), session.tabs[tab].path.clone())
856 };
857 if let Some((tab, point)) = context {
858 self.menu = Some((
859 menus::Target::Section {
860 library: Arc::clone(&session.library),
861 path: session.tabs[tab].path.clone(),
862 },
863 point,
864 ));
865 self.ui.open_popup(menus::id());
866 }
867 (clicked.map(open), open_tab)
868 }
869 None => {
870 let tabs = [("Temporary page", section_color(None))];
871 let shown = if self.temporary { &tabs[..] } else { &[] };
872 let (_, _, open_tab) =
873 ui::shell::section_tabs(&mut self.ui, "sections", shown, 0, &section, TAB_ROW);
874 (None, open_tab)
665875 }
666 _ => ui::shell::section_tabs(
667 &mut self.ui,
668 "sections",
669 &[("Temporary page", section_color(None))],
670 0,
671 &section,
672 TAB_ROW,
673 ),
674876 };
675 if let Some(index) = clicked {
676 self.commands.push(Command::OpenSection(index));
677 }
877 self.commands.extend(clicked);
678878 if self.session.is_some() {
679879 self.page_tools(&theme);
680880 }
......@@ -698,12 +898,21 @@ impl State {
698898 ..Spec::default()
699899 },
700900 );
701 if self
702 .session
703 .as_ref()
704 .is_some_and(|session| session.conflict)
901 if let Some(session) = &self.session
902 && let Some(bar) = session.bar()
705903 {
706 self.conflict_bar(&theme);
904 let changes = self.changes().len();
905 let sections: Vec<&str> = session
906 .tabs
907 .iter()
908 .map(|tab| tab.name.as_str())
909 .collect();
910 let steps = [
911 session.change.is_some_and(|at| at > 0),
912 session.change.map_or(changes > 0, |at| at + 1 < changes),
913 ];
914 self.commands
915 .extend(conflict_bar(&mut self.ui, bar, &sections, steps));
707916 }
708917 self.ui.open_as(
709918 page(),
......@@ -714,6 +923,7 @@ impl State {
714923 ..Spec::default()
715924 },
716925 );
926 self.template_strip(&theme);
717927 let scroll = self.view.scroll();
718928 for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() {
719929 if let Some(offset) = ui::scrollbar(
......@@ -741,6 +951,12 @@ impl State {
741951 self.ui.close();
742952 let open_page = self.page_list(&theme, &section);
743953 self.ui.close();
954 self.ui.close();
955 self.ui.close();
956 self.context_menu();
957 if self.renaming.is_some() && self.ui.focused() != Some(sidebar::rename_field()) {
958 self.ui.set_focus(Some(sidebar::rename_field()));
959 }
744960 Ok((section, open_tab, open_page))
745961 }
746962
......@@ -800,7 +1016,7 @@ impl State {
8001016 // The frame's top corners share the page's centres; its top edge breaks where the
8011017 // open tab stands on it.
8021018 let [start, end] = [frame[0], frame[2]];
803 let outer = FRAME + ROUNDING;
1019 let outer = platform::CORNER_RADIUS;
8041020 let strip = self.ui.theme.strip;
8051021 self.ui.round_corners(
8061022 &[
......@@ -847,7 +1063,7 @@ impl State {
8471063 self.ui.leaf(
8481064 "lights",
8491065 Spec {
850 size: [px(LIGHTS), px(1.0)],
1066 size: [px(platform::LEADING), px(1.0)],
8511067 ..Spec::default()
8521068 },
8531069 );
......@@ -856,11 +1072,11 @@ impl State {
8561072 Spec {
8571073 size: [fit(), px(TITLE)],
8581074 image: self.app_icon.as_ref(),
859 text: Some(
860 self.library
861 .as_ref()
862 .map_or("Temporary page", |library| &library.name),
863 ),
1075 text: Some(match &self.session {
1076 Some(session) => &session.library.name,
1077 None if self.temporary => "Temporary page",
1078 None => "Snowbound",
1079 }),
8641080 ..Spec::default()
8651081 },
8661082 );
......@@ -883,6 +1099,7 @@ impl State {
8831099 },
8841100 );
8851101 }
1102 platform::window_controls(&mut self.ui, &self.window);
8861103 self.ui.close();
8871104 }
8881105
......@@ -905,6 +1122,12 @@ impl State {
9051122 tags: Vec::new(),
9061123 });
9071124 let fonts = &self.fonts;
1125 let recent_fonts = &self.recent_fonts;
1126 let engine = &self.view.engine;
1127 // Under the system's title bar the save status joins the toolbar.
1128 let status = platform::system_titlebar(&self.window)
1129 .then(|| self.session.as_ref().map(|session| session.status))
1130 .flatten();
9081131 let ui = &mut self.ui;
9091132 let text = theme.text;
9101133 let on = |toggle| state.toggles.contains(&toggle);
......@@ -940,19 +1163,47 @@ impl State {
9401163 if ui::shell::combo(ui, "font", &font, 120.0).pressed {
9411164 ui.open_popup(popup("font"));
9421165 }
943 let items: Vec<_> = fonts
1166 // Each family is named with the substitute it shows in, and picked by its
1167 // own name; a family of None heads a group. Typing searches the full list.
1168 let label = |name: &str| {
1169 engine.substitute(name).map_or_else(
1170 || name.to_owned(),
1171 |substitute| format!("{} ({name})", substitute.name),
1172 )
1173 };
1174 let mut choices: Vec<(String, Option<&str>, bool)> = Vec::new();
1175 if ui::popup::query(ui, popup("font")).is_none_or(str::is_empty) {
1176 choices.extend(FONTS.map(|name| (label(name), Some(name), false)));
1177 if !recent_fonts.is_empty() {
1178 choices.push(("Recent".to_owned(), None, true));
1179 choices.extend(
1180 recent_fonts
1181 .iter()
1182 .map(|name| (label(name), Some(name.as_str()), false)),
1183 );
1184 }
1185 }
1186 let listed = choices.len();
1187 choices.extend(fonts.iter().enumerate().map(|(index, name)| {
1188 (label(name), Some(name.as_str()), index == 0 && listed > 0)
1189 }));
1190 let items: Vec<_> = choices
9441191 .iter()
945 .map(|name| ui::popup::Item {
946 text: name,
947 font: Some(name),
948 checked: *name == font,
1192 .map(|(label, name, separated)| ui::popup::Item {
1193 text: label,
1194 font: *name,
1195 current: *name == Some(font.as_str()),
1196 heading: name.is_none(),
1197 separated: *separated,
9491198 ..Default::default()
9501199 })
9511200 .collect();
9521201 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());
953 if let Some(index) = ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font"))
1202 if let Some(index) =
1203 ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font"))
1204 && let Some(name) = choices[index].1
9541205 {
955 command = Some(Formatting::Font(fonts[index].clone()));
1206 command = Some(Formatting::Font(name.to_owned()));
9561207 }
9571208 let combo = ui.id("size");
9581209 if ui::shell::combo(ui, "size", &size, 44.0).pressed {
......@@ -1191,6 +1442,18 @@ impl State {
11911442 ..Spec::default()
11921443 },
11931444 );
1445 if let Some(status) = status {
1446 ui.leaf(
1447 "status",
1448 Spec {
1449 size: [fit(), fill()],
1450 text: Some(status),
1451 color: Some(theme.text_dim),
1452 center: true,
1453 ..Spec::default()
1454 },
1455 );
1456 }
11941457 let zoom = self.view.zoom();
11951458 let label = format!("{:.0}%", zoom * 100.0);
11961459 let mut chosen = None;
......@@ -1225,6 +1488,12 @@ impl State {
12251488 self.respond(response);
12261489 }
12271490 if let Some(command) = command {
1491 if let Formatting::Font(name) = &command {
1492 self.recent_fonts.retain(|recent| recent != name);
1493 self.recent_fonts.insert(0, name.clone());
1494 self.recent_fonts.truncate(RECENT_FONTS);
1495 self.save_settings();
1496 }
12281497 let response = self.view.format(command)?;
12291498 self.respond(response);
12301499 }
......@@ -1309,7 +1578,9 @@ impl State {
13091578 self.ui.set_focus(Some(page()));
13101579 }
13111580 }
1312 ui::shell::tool_button(&mut self.ui, "new", art::NEW_PAGE, theme.text, false);
1581 if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked {
1582 self.commands.push(Command::NewPage { subpage: false });
1583 }
13131584 let toggle = if self.pages_open {
13141585 art::SIDEBAR_COLLAPSE
13151586 } else {
......@@ -1321,41 +1592,6 @@ impl State {
13211592 self.ui.close();
13221593 }
13231594
1324 fn conflict_bar(&mut self, theme: &Theme) {
1325 self.ui.open(
1326 "conflict",
1327 Spec {
1328 size: [fill(), px(40.0)],
1329 fill: Some(theme.panel),
1330 pad: [12.0, 7.0],
1331 gap: 8.0,
1332 ..Spec::default()
1333 },
1334 );
1335 self.ui.leaf(
1336 "message",
1337 Spec {
1338 size: [fit(), px(26.0)],
1339 text: Some("This page also changed on another computer."),
1340 ..Spec::default()
1341 },
1342 );
1343 self.ui.leaf(
1344 "space",
1345 Spec {
1346 size: [fill(), px(1.0)],
1347 ..Spec::default()
1348 },
1349 );
1350 if ui::button(&mut self.ui, "mine", "Keep mine").clicked {
1351 self.commands.push(Command::Resolve { keep_mine: true });
1352 }
1353 if ui::button(&mut self.ui, "theirs", "Keep theirs").clicked {
1354 self.commands.push(Command::Resolve { keep_mine: false });
1355 }
1356 self.ui.close();
1357 }
1358
13591595 /// The section's pages as tabs down the frame's right side, returning the open page's
13601596 /// tab, which is the page's colour and joins it.
13611597 fn page_list(&mut self, theme: &Theme, section: &ui::Section) -> Option<Id> {
......@@ -1373,55 +1609,24 @@ impl State {
13731609 ..Spec::default()
13741610 },
13751611 );
1376 let mut open = None;
1377 for (space, title, level) in matching(&session.pages, &self.filter) {
1378 let selected = *space == session.space;
1379 let spec = Spec {
1380 flags: Flags::CLICKABLE,
1381 size: [px(PAGE_LIST), px(ROW)],
1382 text: Some(if title.is_empty() {
1383 "Untitled page"
1384 } else {
1385 title
1386 }),
1387 pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0],
1388 ..Spec::default()
1389 };
1390 let spec = if selected {
1391 open = Some(self.ui.id(space));
1392 Spec {
1393 color: Some(theme.paper_ink),
1394 fill: Some(theme.paper),
1395 inset: [0.0, 0.0, 0.0, ROW_GAP],
1396 ..spec
1397 }
1398 } else {
1399 // Tabs not open float free of the page as pills; only the open one joins it.
1400 Spec {
1401 color: Some(if title.is_empty() {
1402 ui::mix(theme.ink, section.tab, 0.5)
1403 } else {
1404 theme.ink
1405 }),
1406 fill: Some(section.tab),
1407 hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)),
1408 radius: ROUNDING,
1409 inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP],
1410 // The label keeps its place as the tab opens and closes.
1411 pad: [spec.pad[0] - PILL_MARGIN, 0.0],
1412 ..spec
1413 }
1414 };
1415 if self.ui.leaf(space, spec).clicked && !selected {
1416 self.commands.push(Command::OpenPage(*space));
1417 }
1418 }
1612 let rows = page_rows(&mut self.ui, theme, section, session, &self.filter);
1613 self.commands.extend(rows.clicked.map(Command::OpenPage));
1614 if let Some((space, point)) = rows.context {
1615 self.menu = Some((menus::Target::Page(space), point));
1616 self.ui.open_popup(menus::id());
1617 }
1618 let rect = self.ui.rect(panel).unwrap_or_default();
1619 self.drag_pages(rows.held, section, rect);
14191620 self.ui.close();
1420 open
1621 rows.open
14211622 }
14221623
14231624 /// Hands the page the events routed to its box, in its device pixels.
14241625 fn page_events(&mut self, events: Vec<ui::Event>) -> Result<(), Box<dyn Error>> {
1626 let read_only = self
1627 .session
1628 .as_ref()
1629 .is_some_and(|session| session.conflict(session.space).is_some());
14251630 let scale = self.ui.scale();
14261631 let corner = self.ui.rect(page()).unwrap_or_default();
14271632 let device = |point: [f32; 2]| {
......@@ -1446,6 +1651,27 @@ impl State {
14461651 }
14471652 }
14481653 ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) => continue,
1654 // A conflict page takes no typing; commands and caret moves still apply.
1655 ui::Event::Ime(Ime::Preedit(..) | Ime::Commit(_)) if read_only => continue,
1656 ui::Event::Key { ref key, .. }
1657 if read_only
1658 && !self.view.modifiers().command
1659 && !matches!(
1660 key,
1661 Key::Named(
1662 NamedKey::ArrowLeft
1663 | NamedKey::ArrowRight
1664 | NamedKey::ArrowUp
1665 | NamedKey::ArrowDown
1666 | NamedKey::Home
1667 | NamedKey::End
1668 | NamedKey::PageUp
1669 | NamedKey::PageDown
1670 )
1671 ) =>
1672 {
1673 continue;
1674 }
14491675 ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?,
14501676 ui::Event::Key { key, text } => {
14511677 let modifiers = self.view.modifiers();
......@@ -1467,7 +1693,7 @@ impl State {
14671693 self.respond(response);
14681694 }
14691695 if self.view.editor.marked_range().is_none() {
1470 macos::clear_marked_text(&self.window);
1696 platform::clear_marked_text(&self.window);
14711697 }
14721698 Ok(())
14731699 }
......@@ -1483,30 +1709,79 @@ impl State {
14831709
14841710 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {
14851711 match command {
1486 Command::OpenSection(tab) => {
1487 let library = Arc::clone(self.library.as_ref().ok_or("No sections to open")?);
1488 let proxy = self.proxy.clone();
1712 Command::OpenSection(library, path) => {
1713 let notify = notify(self.proxy.clone());
1714 let last = self.last_pages.get(&library.key(&path)).copied();
14891715 self.load(move || {
1490 let section = library.open(tab, proxy)?;
1491 let (session, page) = read_session(section, tab, None)?;
1716 let section = library.open(&path, notify)?;
1717 let (session, page) = read_session(section, library, path, last)?;
14921718 Ok((Loaded::Section(Box::new(session)), page))
14931719 });
14941720 }
14951721 Command::OpenPage(space) => {
14961722 let session = self.session.as_ref().ok_or("No section is open")?;
1497 let replica = Arc::clone(session.section.replica());
1498 self.load(move || Ok((Loaded::Page(space), replica.page(space)?)));
1723 let read = session.reader(space);
1724 self.load(move || Ok((Loaded::Page(space), read()?)));
1725 }
1726 Command::Versions { page, show } => {
1727 let session = self.session.as_mut().ok_or("No section is open")?;
1728 session.shown = show.then_some(page);
1729 let open = if show {
1730 session.versions(page).first().map(|version| version.space)
1731 } else {
1732 (session.space != page).then_some(page)
1733 };
1734 self.commands.extend(open.map(Command::OpenPage));
14991735 }
1500 Command::Resolve { keep_mine } => self.resolve_conflict(keep_mine)?,
1736 Command::DeleteVersion { page, version } => {
1737 let session = self.session.as_mut().ok_or("No section is open")?;
1738 session.section.delete_pages(&[version])?;
1739 session.status = "Saving";
1740 session.refresh_conflicts()?;
1741 self.commands.push(Command::OpenPage(page));
1742 }
1743 Command::CopyVersion { version, tab } => {
1744 let session = self.session.as_ref().ok_or("No section is open")?;
1745 let page = session.section.page(version)?;
1746 if tab == session.tab {
1747 session.section.import_page(&page, &self.author)?;
1748 } else {
1749 let library = Arc::clone(&session.library);
1750 let path = session.tabs[tab].path.clone();
1751 let (notify, author) = (notify(self.proxy.clone()), self.author.clone());
1752 std::thread::spawn(move || {
1753 let copied = library.open(&path, notify).and_then(|section| {
1754 section.import_page(&page, &author)?;
1755 Ok(section.close()?)
1756 });
1757 if let Err(error) = copied {
1758 eprintln!("Copying the page failed: {error}");
1759 }
1760 });
1761 }
1762 }
1763 Command::SelectChange { forward } => self.select_change(forward)?,
1764 Command::OpenNotebook => {
1765 if let Some(path) = platform::pick_notebook("Open Notebook") {
1766 self.open_path(&path);
1767 }
1768 }
1769 Command::NewNotebook => self.new_notebook()?,
1770 Command::CloseNotebook(library) => self.close_notebook(&library),
1771 Command::Structure(library, change) => self.restructure(library, change),
1772 Command::NewPage { subpage } => self.new_page(subpage)?,
1773 Command::DeletePages(pages) => self.delete_pages(pages)?,
1774 Command::Pages(edits) => self.edit_pages(edits)?,
1775 Command::Template(choice) => self.apply_template(choice)?,
15011776 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
15021777 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
15031778 Command::Page(Request::Paste) => {
15041779 let text = self.clipboard.get_text()?;
1505 let language = canvas::language::lcid(&macos::input_language());
1780 let language = canvas::language::lcid(&platform::input_language());
15061781 let response = self.view.paste(&text, language)?;
15071782 self.respond(response);
15081783 }
1509 Command::Page(Request::CharacterPalette) => macos::show_character_palette(),
1784 Command::Page(Request::CharacterPalette) => platform::show_character_palette(),
15101785 }
15111786 Ok(())
15121787 }
......@@ -1532,7 +1807,13 @@ impl State {
15321807 if id != self.loading {
15331808 continue;
15341809 }
1535 let (loaded, page) = loaded?;
1810 let (loaded, page) = match loaded {
1811 Ok(loaded) => loaded,
1812 Err(error) => {
1813 platform::alert("Couldn't open", &error);
1814 continue;
1815 }
1816 };
15361817 let (scene, editor) = PageScene::from_page(page, &mut self.view.engine)?;
15371818 self.opening = Some(Opening {
15381819 loaded,
......@@ -1558,19 +1839,51 @@ impl State {
15581839 }
15591840 // The page shown so far keeps what was typed while the next one loaded.
15601841 self.persist()?;
1842 if let Some(session) = &self.session {
1843 let key = session.library.key(&session.tabs[session.tab].path);
1844 self.places.insert((key.clone(), session.space), self.view.place());
1845 self.last_pages.insert(key, session.space);
1846 }
15611847 match opening.loaded {
15621848 Loaded::Section(session) => {
1849 // A notebook read again after a change replaces the one it was.
1850 match self
1851 .notebooks
1852 .iter_mut()
1853 .find(|library| library.location == session.library.location)
1854 {
1855 Some(library) => *library = Arc::clone(&session.library),
1856 None => self.notebooks.push(Arc::clone(&session.library)),
1857 }
15631858 self.session = Some(*session);
15641859 self.filter.clear();
1860 self.save_settings();
15651861 }
15661862 Loaded::Page(space) => {
15671863 let session = self.session.as_mut().ok_or("No section is open")?;
15681864 session.space = space;
1569 session.refresh_conflict()?;
1865 session.change = None;
1866 // Pages created, moved or deleted since the list was read.
1867 session.pages = session.section.pages()?;
15701868 }
15711869 }
1572 self.view.open(opening.editor, Some(opening.scene));
1573 self.opened()
1870 let place = self.session.as_ref().and_then(|session| {
1871 let key = session.library.key(&session.tabs[session.tab].path);
1872 self.places.get(&(key, session.space)).copied()
1873 });
1874 self.view.open(opening.editor, Some(opening.scene), place);
1875 self.opened()?;
1876 if self.title_focus.take().is_some_and(|space| {
1877 self.session
1878 .as_ref()
1879 .is_some_and(|session| session.space == space)
1880 }) && let Some(title) = self.view.editor.outlines().iter().find(|outline| outline.title)
1881 {
1882 self.view.editor.focus_outline(title.id)?;
1883 let response = self.view.focus_text()?;
1884 self.respond(response);
1885 }
1886 Ok(())
15741887 }
15751888
15761889 /// Follows a page shown in place of another.
......@@ -1620,6 +1933,7 @@ impl State {
16201933 .unwrap_or_default();
16211934 format!("{} · {file}", session.title())
16221935 }
1936 None if !self.temporary => "Snowbound".to_owned(),
16231937 None => {
16241938 let editor = &self.view.editor;
16251939 if !editor.active_outline().title {
......@@ -1655,8 +1969,12 @@ impl State {
16551969 };
16561970 let timestamp = date.timestamp();
16571971 self.view.editor.finish_composition();
1658 macos::clear_marked_text(&self.window);
1659 if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? {
1972 platform::clear_marked_text(&self.window);
1973 let title = match field {
1974 DateField::Date => "Change Page Date",
1975 DateField::Time => "Change Page Time",
1976 };
1977 if let Some((timestamp, text)) = platform::edit_date(timestamp, field, title)? {
16601978 let response = self.view.change_date(timestamp, text)?;
16611979 self.respond(response);
16621980 self.window.request_redraw();
......@@ -1664,6 +1982,58 @@ impl State {
16641982 Ok(())
16651983 }
16661984
1985 /// The open conflict page's conflicting changes in page order: each text's outline,
1986 /// paragraph position and length.
1987 fn changes(&self) -> Vec<(ExGuid, usize, u32)> {
1988 let Some((_, version)) = self
1989 .session
1990 .as_ref()
1991 .and_then(|session| session.conflict(session.space))
1992 else {
1993 return Vec::new();
1994 };
1995 let marked = |id: &ExGuid| version.objects.contains(id);
1996 let mut changes = Vec::new();
1997 for outline in self.view.editor.outlines() {
1998 for (at, node) in outline.document().text_nodes().enumerate() {
1999 let Some(text) = node.text() else {
2000 continue;
2001 };
2002 if marked(&node.id) || marked(&text.id) {
2003 let length = text.text.text().encode_utf16().count() as u32;
2004 changes.push((outline.id, at, length));
2005 }
2006 }
2007 }
2008 changes
2009 }
2010
2011 /// Selects the next or previous conflicting change, as OneNote's conflict menu does.
2012 fn select_change(&mut self, forward: bool) -> Result<(), Box<dyn Error>> {
2013 let changes = self.changes();
2014 let Some(session) = &mut self.session else {
2015 return Ok(());
2016 };
2017 let next = match (session.change, forward) {
2018 (None, true) => 0,
2019 (Some(at), true) => at + 1,
2020 (Some(at), false) if at > 0 => at - 1,
2021 _ => return Ok(()),
2022 };
2023 let Some(&(outline, paragraph, length)) = changes.get(next) else {
2024 return Ok(());
2025 };
2026 session.change = Some(next);
2027 let at = |offset| canvas::document::TextPosition { paragraph, offset };
2028 self.view.editor.focus_outline(outline)?;
2029 self.view.editor.select([at(0), at(length)].into())?;
2030 self.ui.set_focus(Some(page()));
2031 let response = self.view.focus_text()?;
2032 self.respond(response);
2033 self.window.request_redraw();
2034 Ok(())
2035 }
2036
16672037 /// Hands the section the ops the editor recorded since the last call, as one edit; the
16682038 /// section thread stores it.
16692039 fn persist(&mut self) -> Result<(), Box<dyn Error>> {
......@@ -1677,9 +2047,14 @@ impl State {
16772047 if ops.is_empty() {
16782048 return Ok(());
16792049 }
2050 if session.conflict(session.space).is_some() {
2051 // A conflict page is read-only: it returns to what is stored.
2052 self.stale = true;
2053 return Ok(());
2054 }
16802055 let space = session.space;
16812056 session.section.apply(
1682 "snowbound",
2057 &self.author,
16832058 onestore::op::Edit {
16842059 at: filetime(),
16852060 ops: ops
......@@ -1704,7 +2079,7 @@ impl State {
17042079 let Some(session) = &self.session else {
17052080 return Ok(());
17062081 };
1707 let page = session.section.page(session.space)?;
2082 let page = session.reader(session.space)()?;
17082083 let response = self.view.refresh(page)?;
17092084 self.respond(response);
17102085 self.title();
......@@ -1720,62 +2095,30 @@ impl State {
17202095 // What the editor recorded since builds on the refused edit.
17212096 let _ = self.view.editor.take_ops();
17222097 self.view.editor.cancel_composition(&mut self.view.engine)?;
1723 macos::clear_marked_text(&self.window);
2098 platform::clear_marked_text(&self.window);
17242099 if let Some(session) = &mut self.session {
17252100 session.status = "Not saving";
17262101 }
17272102 self.refresh()?;
1728 macos::change_not_saved();
2103 platform::alert(
2104 "Change not saved",
2105 "The page now shows what was last saved. Your text is on the clipboard to paste back.",
2106 );
17292107 Ok(())
17302108 }
17312109
1732 /// Ends the conflict on this page: `keep_mine` publishes the local page over the remote
1733 /// change, otherwise the remote page replaces it.
1734 fn resolve_conflict(&mut self, keep_mine: bool) -> Result<(), Box<dyn Error>> {
1735 self.persist()?;
1736 let Some(session) = &mut self.session else {
1737 return Ok(());
1738 };
1739 let Some(conflict) = session
1740 .section
1741 .conflict()?
1742 .filter(|conflict| conflict.space == session.space)
1743 else {
1744 return Ok(());
1745 };
1746 let resolution = if keep_mine {
1747 notebook::Resolution::Mine
1748 } else {
1749 notebook::Resolution::Theirs
1750 };
1751 session.section.resolve(conflict.id, resolution)?;
1752 session.status = "Saving";
1753 session.refresh_conflict()?;
1754 if keep_mine {
1755 self.window.request_redraw();
1756 Ok(())
1757 } else {
1758 self.refresh()
1759 }
1760 }
1761
17622110 /// Applies what the section reported since the last poll.
17632111 fn synced(&mut self) -> Result<(), Box<dyn Error>> {
17642112 let Some(session) = &mut self.session else {
17652113 return Ok(());
17662114 };
1767 let shown = (session.status, session.conflict);
2115 let shown = session.status;
17682116 let mut listed = false;
17692117 let mut changed = false;
17702118 let mut rejected = None;
1771 let mut conflicted = session.conflict;
17722119 for event in session.section.events() {
17732120 use notebook::session::Event;
17742121 session.status = match event {
1775 Event::Refreshed => {
1776 listed = true;
1777 continue;
1778 }
17792122 Event::Changed(spaces) => {
17802123 listed = true;
17812124 changed |= spaces.contains(&session.space);
......@@ -1793,13 +2136,6 @@ impl State {
17932136 status: notebook::EditStatus::Published { .. },
17942137 ..
17952138 } => "Saved",
1796 Event::Attempt {
1797 status: notebook::EditStatus::Conflict(_),
1798 ..
1799 } => {
1800 conflicted = true;
1801 "Conflict"
1802 }
18032139 Event::Attempt { .. } => "Saving",
18042140 Event::Unreachable(_) => "Offline",
18052141 Event::Failed(error) => {
......@@ -1808,23 +2144,22 @@ impl State {
18082144 }
18092145 };
18102146 }
1811 // Reading the queue waits on the section thread, so only a conflict shown or
1812 // reported sends the frame thread there.
1813 if conflicted {
1814 session.refresh_conflict()?;
1815 }
1816 if listed || changed || shown != (session.status, session.conflict) {
2147 if listed || changed || shown != session.status {
18172148 self.window.request_redraw();
18182149 }
18192150 if listed {
2151 let back = session.shown;
18202152 session.pages = session.section.pages()?;
1821 if !session
1822 .pages
1823 .iter()
1824 .any(|(space, ..)| *space == session.space)
1825 {
1826 let first = session.pages.first().ok_or("The section has no pages")?.0;
1827 self.commands.push(Command::OpenPage(first));
2153 session.refresh_conflicts()?;
2154 let listed = |space: ExGuid| session.pages.iter().any(|(page, ..)| *page == space);
2155 if !listed(session.space) && session.conflict(session.space).is_none() {
2156 // A page removed elsewhere, or a conflict page deleted elsewhere, which
2157 // returns to its page.
2158 let open = back
2159 .filter(|page| listed(*page))
2160 .or(session.pages.first().map(|(page, ..)| *page))
2161 .ok_or("The section has no pages")?;
2162 self.commands.push(Command::OpenPage(open));
18282163 return Ok(());
18292164 }
18302165 }
......@@ -1950,7 +2285,7 @@ impl State {
19502285 }
19512286 wgpu::CurrentSurfaceTexture::Lost => {
19522287 self.surface = self.instance.create_surface(self.window.clone())?;
1953 macos::configure_presentation(&self.surface);
2288 platform::configure_presentation(&self.surface);
19542289 self.surface.configure(&self.renderer.device, &self.config);
19552290 self.window.request_redraw();
19562291 return Ok(());
......@@ -1972,7 +2307,7 @@ impl State {
19722307 self.window.pre_present_notify();
19732308 let start = Instant::now();
19742309 self.renderer.queue.present(frame);
1975 macos::commit_presentation(&self.window);
2310 platform::commit_presentation(&self.window);
19762311 lap("present", start);
19772312 trace_input(&"Present submitted");
19782313 if reconfigure {
......@@ -2113,11 +2448,28 @@ impl State {
21132448 Ok(())
21142449 }
21152450
2116 /// Queues input for the next frame. A press on the strip moves the window at once,
2117 /// while the platform still holds the press; a double press zooms it.
2451 /// Queues input for the next frame. A press on the strip moves the window: at once on
2452 /// macOS, while AppKit still holds the press, and elsewhere once the pointer moves, as
2453 /// the window manager's move would swallow a second click; a double press zooms it.
21182454 fn input(&mut self, event: ui::Event) {
21192455 if let ui::Event::PointerMoved(point) = event {
21202456 self.pointer = point;
2457 if std::mem::take(&mut self.strip_held) {
2458 let _ = self.window.drag_window();
2459 }
2460 }
2461 if let ui::Event::Button { pressed: false, .. } = event {
2462 self.strip_held = false;
2463 }
2464 if let ui::Event::Button {
2465 button: MouseButton::Left,
2466 pressed: true,
2467 ..
2468 } = event
2469 && let Some(direction) = platform::resize_direction(&self.window, self.pointer)
2470 {
2471 let _ = self.window.drag_resize_window(direction);
2472 return;
21212473 }
21222474 if let ui::Event::Button {
21232475 button: MouseButton::Left,
......@@ -2127,13 +2479,15 @@ impl State {
21272479 && self.ui.box_at(self.pointer) == Some(strip())
21282480 {
21292481 let double = self.strip_press.is_some_and(|last| {
2130 at.saturating_duration_since(last) <= macos::double_click_interval()
2482 at.saturating_duration_since(last) <= platform::double_click_interval()
21312483 });
21322484 self.strip_press = (!double).then_some(at);
21332485 if double {
2134 macos::zoom(&self.window);
2135 } else {
2486 platform::zoom(&self.window);
2487 } else if cfg!(target_os = "macos") {
21362488 let _ = self.window.drag_window();
2489 } else {
2490 self.strip_held = true;
21372491 }
21382492 return;
21392493 }
......@@ -2149,20 +2503,328 @@ fn theme(appearance: winit::window::Theme) -> Theme {
21492503 }
21502504}
21512505
2152fn file_name(path: &Path) -> String {
2153 path.canonicalize()
2154 .ok()
2155 .as_deref()
2156 .and_then(Path::file_name)
2157 .map(|name| name.to_string_lossy().into_owned())
2158 .unwrap_or_default()
2159}
2160
21612506/// A section's colour as linear RGBA; sections without one take OneNote's default blue.
21622507fn section_color(color: Option<u32>) -> [f32; 4] {
21632508 color.map_or(draw::srgb(0x8a, 0xa8, 0xe4), canvas::gpu::colorref)
21642509}
21652510
2511/// OneNote's information bar for conflicting changes, clicked anywhere: above a page with
2512/// conflict pages it shows or hides them; above a conflict page it opens OneNote's menu,
2513/// whose previous and next changes `steps` enables and whose Copy Page To lists `sections`.
2514fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> Option<Command> {
2515 let row = ui.open(
2516 "conflict",
2517 Spec {
2518 flags: Flags::CLICKABLE,
2519 size: [fill(), children()],
2520 fill: Some(draw::srgb(0xff, 0xee, 0xc2)),
2521 hover_fill: Some(draw::srgb(0xff, 0xe4, 0xa6)),
2522 pad: [12.0, 6.0],
2523 gap: 8.0,
2524 ..Spec::default()
2525 },
2526 );
2527 ui.leaf(
2528 "icon",
2529 Spec {
2530 size: [fit(), px(16.0)],
2531 icon: Some(art::CONFLICT),
2532 color: Some(draw::srgb(0xc0, 0x48, 0x20)),
2533 ..Spec::default()
2534 },
2535 );
2536 // OneNote 2010's words (corpus/conflict-page/native), a sentence to a line.
2537 let [said, action] = match bar {
2538 Bar::Page { shown, .. } => [
2539 "This page has changes that could not be merged during synchronization.",
2540 if shown {
2541 "Click here to hide versions of the page with unmerged changes."
2542 } else {
2543 "Click here to show versions of the page with unmerged changes."
2544 },
2545 ],
2546 Bar::Version { .. } => [
2547 "Conflicting changes are highlighted in red. This page cannot be edited, but you can copy changes to the primary page.",
2548 "Click here for more options.",
2549 ],
2550 };
2551 ui.open(
2552 "message",
2553 Spec {
2554 axis: Axis::Y,
2555 size: [fill(), children()],
2556 ..Spec::default()
2557 },
2558 );
2559 for (part, text) in [("said", said), ("action", action)] {
2560 ui.leaf(
2561 part,
2562 Spec {
2563 size: [fit(), px(18.0)],
2564 text: Some(text),
2565 color: Some(draw::srgb(0x20, 0x20, 0x20)),
2566 ..Spec::default()
2567 },
2568 );
2569 }
2570 let action = ui.id("action");
2571 ui.close();
2572 ui.close();
2573 let clicked = ui.signal(row).clicked;
2574 let menu = Id::ROOT.child("conflict-menu");
2575 let copy = Id::ROOT.child("conflict-copy");
2576 // The menu opens under the line the click asks for, as wide as its items.
2577 let [left, top, _, bottom] = ui.rect(action).unwrap_or_default();
2578 let anchor = ui::Anchor::Below([left, top, left, bottom]);
2579 match bar {
2580 Bar::Page { page, shown } => clicked.then_some(Command::Versions { page, show: !shown }),
2581 Bar::Version { page, version } => {
2582 if clicked {
2583 ui.open_popup(menu);
2584 }
2585 let items = [
2586 ui::popup::Item {
2587 text: "Delete Conflict Page",
2588 ..Default::default()
2589 },
2590 ui::popup::Item {
2591 text: "Copy Page To…",
2592 disabled: sections.is_empty(),
2593 ..Default::default()
2594 },
2595 ui::popup::Item {
2596 text: "Select Previous Conflicting Change",
2597 disabled: !steps[0],
2598 separated: true,
2599 ..Default::default()
2600 },
2601 ui::popup::Item {
2602 text: "Select Next Conflicting Change",
2603 disabled: !steps[1],
2604 ..Default::default()
2605 },
2606 ui::popup::Item {
2607 text: "Collapse Conflict Pages",
2608 separated: true,
2609 ..Default::default()
2610 },
2611 ];
2612 let chosen = match ui::popup::menu(ui, menu, anchor, &items, None) {
2613 Some(0) => Some(Command::DeleteVersion { page, version }),
2614 Some(1) => {
2615 ui.open_popup(copy);
2616 None
2617 }
2618 Some(2) => Some(Command::SelectChange { forward: false }),
2619 Some(3) => Some(Command::SelectChange { forward: true }),
2620 Some(_) => Some(Command::Versions { page, show: false }),
2621 None => None,
2622 };
2623 let targets: Vec<_> = sections
2624 .iter()
2625 .map(|name| ui::popup::Item {
2626 text: name,
2627 ..Default::default()
2628 })
2629 .collect();
2630 let tab = ui::popup::menu(ui, copy, anchor, &targets, None);
2631 chosen.or(tab.map(|tab| Command::CopyVersion { version, tab }))
2632 }
2633 }
2634}
2635
2636/// What the page list's rows were asked this frame.
2637#[derive(Default)]
2638struct Rows {
2639 /// The open page's tab.
2640 open: Option<Id>,
2641 clicked: Option<ExGuid>,
2642 /// The page whose context menu was asked for, and where.
2643 context: Option<(ExGuid, [f32; 2])>,
2644 /// The page held down, which a drag moves.
2645 held: Option<ExGuid>,
2646}
2647
2648/// The page list's rows for the pages matching `filter`, a page's conflict pages beneath it
2649/// while shown.
2650fn page_rows(
2651 ui: &mut Ui,
2652 theme: &Theme,
2653 section: &ui::Section,
2654 session: &Session,
2655 filter: &str,
2656) -> Rows {
2657 let mut rows = Rows::default();
2658 for (space, title, level) in matching(&session.pages, filter) {
2659 let versions = session.versions(*space);
2660 let tab = PageTab {
2661 label: if title.is_empty() {
2662 "Untitled page"
2663 } else {
2664 title
2665 },
2666 dim: title.is_empty(),
2667 indent: level.saturating_sub(1),
2668 conflicted: !versions.is_empty(),
2669 };
2670 let selected = *space == session.space;
2671 if selected {
2672 rows.open = Some(ui.id(space));
2673 }
2674 let signal = page_tab(ui, theme, section, space, &tab, selected);
2675 if signal.clicked && !selected {
2676 rows.clicked = Some(*space);
2677 }
2678 if let Some(point) = signal.context {
2679 rows.context = Some((*space, point));
2680 }
2681 if signal.dragging {
2682 rows.held = Some(*space);
2683 }
2684 if session.shown != Some(*space) {
2685 continue;
2686 }
2687 // OneNote lists a page's conflict pages beneath it, each by its date and whose
2688 // version it is.
2689 let muted = ui::Section {
2690 tab: ui::mix(section.tab, theme.chip, 0.6),
2691 ..*section
2692 };
2693 for version in versions {
2694 let label = match version.created {
2695 Some(created) => format!("{} {}", platform::short_date(created), version.user),
2696 None => version.user.clone(),
2697 };
2698 let tab = PageTab {
2699 label: &label,
2700 dim: false,
2701 indent: level.saturating_sub(1),
2702 conflicted: false,
2703 };
2704 let selected = version.space == session.space;
2705 if selected {
2706 rows.open = Some(ui.id(version.space));
2707 }
2708 if page_tab(ui, theme, &muted, &version.space, &tab, selected).clicked && !selected {
2709 rows.clicked = Some(version.space);
2710 }
2711 }
2712 }
2713 rows
2714}
2715
2716/// A row of the page list.
2717struct PageTab<'a> {
2718 label: &'a str,
2719 /// An untitled page's placeholder label.
2720 dim: bool,
2721 indent: u32,
2722 /// The page has conflict pages, which OneNote marks at the tab's end.
2723 conflicted: bool,
2724}
2725
2726/// A page's tab down the frame's right side: the open one is the page's colour and joins
2727/// it, the others float free of it as pills.
2728fn page_tab(
2729 ui: &mut Ui,
2730 theme: &Theme,
2731 section: &ui::Section,
2732 id: &ExGuid,
2733 tab: &PageTab,
2734 selected: bool,
2735) -> ui::Signal {
2736 let pad = 10.0 + 16.0 * tab.indent as f32;
2737 let spec = Spec {
2738 flags: Flags::CLICKABLE,
2739 size: [px(PAGE_LIST), px(ROW)],
2740 pad: [pad, 0.0],
2741 ..Spec::default()
2742 };
2743 let (spec, color) = if selected {
2744 let spec = Spec {
2745 fill: Some(theme.paper),
2746 inset: [0.0, 0.0, 0.0, ROW_GAP],
2747 ..spec
2748 };
2749 (spec, theme.paper_ink)
2750 } else {
2751 let spec = Spec {
2752 fill: Some(section.tab),
2753 hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)),
2754 radius: ROUNDING,
2755 inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP],
2756 // The label keeps its place as the tab opens and closes.
2757 pad: [pad - PILL_MARGIN, 0.0],
2758 ..spec
2759 };
2760 let color = if tab.dim {
2761 ui::mix(theme.ink, section.tab, 0.5)
2762 } else {
2763 theme.ink
2764 };
2765 (spec, color)
2766 };
2767 let row = ui.open(id, spec);
2768 ui.leaf(
2769 "label",
2770 Spec {
2771 size: [fill(), px(ROW - ROW_GAP)],
2772 text: Some(tab.label),
2773 color: Some(color),
2774 ..Spec::default()
2775 },
2776 );
2777 if tab.conflicted {
2778 ui.leaf(
2779 "conflict",
2780 Spec {
2781 size: [fit(), px(ROW - ROW_GAP)],
2782 icon: Some(art::CONFLICT),
2783 color: Some(draw::srgb(0xc0, 0x48, 0x20)),
2784 pad: [8.0, 0.0],
2785 ..Spec::default()
2786 },
2787 );
2788 }
2789 ui.close();
2790 ui.signal(row)
2791}
2792
2793/// `page` with its conflict objects marked as OneNote shows a conflict page's: a band
2794/// across each conflicting paragraph's outline.
2795fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page {
2796 use onestore::page::{PageObject, PageParagraph, ParagraphContent};
2797 fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid]) {
2798 for paragraph in paragraphs {
2799 match &mut paragraph.content {
2800 ParagraphContent::Table(table) => {
2801 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
2802 mark(&mut cell.paragraphs, objects);
2803 }
2804 }
2805 ParagraphContent::Text(text)
2806 if objects.contains(&paragraph.id) || objects.contains(&text.id) =>
2807 {
2808 paragraph.format.highlight = Some(CONFLICTING);
2809 }
2810 _ => {}
2811 }
2812 }
2813 }
2814 for object in &mut page.objects {
2815 match object {
2816 PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects),
2817 PageObject::Title(title) => {
2818 for outline in &mut title.outlines {
2819 mark(&mut outline.paragraphs, objects);
2820 }
2821 }
2822 _ => {}
2823 }
2824 }
2825 page
2826}
2827
21662828/// The pages whose titles contain `filter`, ignoring case.
21672829fn matching<'a>(
21682830 pages: &'a [(ExGuid, String, u32)],
......@@ -2212,50 +2874,42 @@ fn divider(ui: &mut Ui, part: &str, theme: &Theme) {
22122874 );
22132875}
22142876
2215/// Section tabs for a notebook's readable top-level sections, in its order.
2216fn tabs(notebook: &notebook::session::Notebook) -> Vec<Tab> {
2217 notebook
2218 .catalog()
2219 .sections
2220 .iter()
2221 .filter_map(|section| match &section.state {
2222 notebook::discover::SectionState::Readable { name, color } => Some(Tab {
2223 name: name.clone().unwrap_or_else(|| {
2224 Path::new(&section.path)
2225 .file_stem()
2226 .map(|stem| stem.to_string_lossy().into_owned())
2227 .unwrap_or_default()
2228 }),
2229 path: section.path.clone(),
2230 color: *color,
2231 }),
2232 _ => None,
2233 })
2234 .collect()
2235}
2236
2237/// The session for `section` showing `space`, or its first page, and that page.
2877/// The session for `section`, at catalog `path` in `library`, showing `space` or its first
2878/// page, and that page.
22382879fn read_session(
22392880 section: notebook::session::Section,
2240 tab: usize,
2881 library: Arc<Library>,
2882 path: String,
22412883 space: Option<ExGuid>,
22422884) -> Result<(Session, Page), Box<dyn Error>> {
2885 let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder);
2886 let tabs = library.tabs(folder);
2887 let tab = tabs
2888 .iter()
2889 .position(|tab| tab.path == path)
2890 .ok_or("The section is not in its notebook")?;
22432891 let pages = section.pages()?;
2244 let space = match space {
2245 Some(space) => space,
2246 None => pages.first().ok_or("The section has no pages")?.0,
2247 };
2892 let space = space
2893 .filter(|space| pages.iter().any(|(candidate, ..)| candidate == space))
2894 .or(pages.first().map(|(space, ..)| *space))
2895 .ok_or("The section has no pages")?;
22482896 let page = section.page(space)?;
2249 let mut session = Session {
2250 section,
2251 tab,
2252 pages,
2253 space,
2254 status: "",
2255 conflict: false,
2256 };
2257 session.refresh_conflict()?;
2258 Ok((session, page))
2897 let conflicts = section.conflicts()?;
2898 Ok((
2899 Session {
2900 section,
2901 library,
2902 tabs,
2903 tab,
2904 pages,
2905 space,
2906 status: "",
2907 conflicts,
2908 shown: None,
2909 change: None,
2910 },
2911 page,
2912 ))
22592913}
22602914
22612915/// The text the editor shows, outline by outline, without hidden field codes.
......@@ -2315,8 +2969,12 @@ impl App {
23152969 .outlines()
23162970 .iter()
23172971 .map(|outline| (&outline.id, outline.document())))
2318 }) || macos::discard_changes()
2319 {
2972 }) || platform::confirm(
2973 "Discard this page?",
2974 "This temporary page has no saved copy. Closing it will discard your edits.",
2975 "Keep Editing",
2976 "Discard Changes",
2977 ) {
23202978 event_loop.exit();
23212979 }
23222980 }
......@@ -2396,7 +3054,7 @@ impl ApplicationHandler<UserEvent> for App {
23963054 }
23973055 };
23983056 if was_marked && state.view.editor.marked_range().is_none() {
2399 macos::clear_marked_text(&state.window);
3057 platform::clear_marked_text(&state.window);
24003058 }
24013059 if let Err(error) = result {
24023060 eprintln!("{error}");
......@@ -2413,8 +3071,17 @@ impl ApplicationHandler<UserEvent> for App {
24133071 self.input.take().unwrap(),
24143072 &self.substitutes,
24153073 std::mem::take(&mut self.interface_font),
3074 self.screenshot.is_none(),
3075 self.settings.take().unwrap_or_default(),
3076 self.cache.clone(),
24163077 )) {
2417 Ok(state) => self.state = Some(state),
3078 Ok(mut state) => match &self.screenshot {
3079 Some(prefix) => {
3080 self.startup_error = state.screenshot(prefix).err();
3081 event_loop.exit();
3082 }
3083 None => self.state = Some(state),
3084 },
24183085 Err(error) => {
24193086 self.startup_error = Some(error);
24203087 event_loop.exit();
......@@ -2466,7 +3133,7 @@ impl ApplicationHandler<UserEvent> for App {
24663133 }
24673134 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
24683135 state.renderer.clear_glyph_cache();
2469 state.app_icon = macos::app_icon((16.0 * scale_factor).round() as u32);
3136 state.app_icon = platform::app_icon((16.0 * scale_factor).round() as u32);
24703137 let response = state.view.scale_factor_changed(scale_factor as f32)?;
24713138 state.respond(response);
24723139 state.window.request_redraw();
......@@ -2593,7 +3260,10 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
25933260 rest.split_whitespace()
25943261 .map(|name| match name {
25953262 "shift" => Ok(ModifiersState::SHIFT),
2596 "command" => Ok(ModifiersState::SUPER),
3263 "control" => Ok(ModifiersState::CONTROL),
3264 // The shortcut modifier, as ui::edit_modifiers reads it.
3265 "command" if cfg!(target_os = "macos") => Ok(ModifiersState::SUPER),
3266 "command" => Ok(ModifiersState::CONTROL),
25973267 _ => Err(format!("Unknown modifier {name}")),
25983268 })
25993269 .try_fold(ModifiersState::empty(), |all, one| one.map(|one| all | one))?,
......@@ -2640,6 +3310,8 @@ fn main() -> Result<(), Box<dyn Error>> {
26403310 let mut section = None;
26413311 let mut notebook = None;
26423312 let mut cache = None;
3313 let mut settings_file = None;
3314 let mut screenshot = None;
26433315 while let Some(arg) = args.next() {
26443316 if arg == "--substitute-font" {
26453317 substitutes.push(PathBuf::from(
......@@ -2691,6 +3363,15 @@ fn main() -> Result<(), Box<dyn Error>> {
26913363 cache = Some(PathBuf::from(
26923364 args.next().ok_or("Provide a directory after --cache.")?,
26933365 ));
3366 } else if arg == "--settings" {
3367 settings_file = Some(PathBuf::from(
3368 args.next().ok_or("Provide a settings file after --settings.")?,
3369 ));
3370 } else if arg == "--screenshot" {
3371 screenshot = Some(PathBuf::from(
3372 args.next()
3373 .ok_or("Provide a PNG path prefix after --screenshot.")?,
3374 ));
26943375 } else if arg == "--reference" || arg == "--page" {
26953376 if reference.is_some() {
26963377 return Err("Only one page can be opened.".into());
......@@ -2725,7 +3406,7 @@ fn main() -> Result<(), Box<dyn Error>> {
27253406 }
27263407 if positional.len() > 2 {
27273408 return Err(
2728 "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE | --notebook FOLDER] [--cache DIR] [--ui-font FONT_FILE_OR_FOLDER] [--substitute-font FONT_FILE]..."
3409 "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE | --notebook FOLDER] [--cache DIR] [--settings FILE] [--screenshot PNG_PREFIX] [--ui-font FONT_FILE_OR_FOLDER] [--substitute-font FONT_FILE]..."
27293410 .into(),
27303411 );
27313412 }
......@@ -2745,15 +3426,28 @@ fn main() -> Result<(), Box<dyn Error>> {
27453426 });
27463427 let cache = match cache {
27473428 Some(cache) => cache,
2748 None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?)
2749 .join("Library/Caches/snowbound"),
3429 None => platform::cache_dir().ok_or("HOME is not set.")?,
27503430 };
2751 let input = if let Some(root) = notebook {
2752 Input::Notebook { root, cache }
2753 } else if let Some((file, title)) = section {
2754 Input::Section { file, title, cache }
3431 let settings_file = settings_file.or_else(settings::default_path);
3432 let saved = settings_file
3433 .as_deref()
3434 .map(settings::Settings::load)
3435 .unwrap_or_default();
3436 let input = if let Some((file, title)) = section {
3437 Input::Section { file, title }
27553438 } else if editable {
27563439 Input::Page(reference.unwrap())
3440 } else if positional.is_empty() && reference.is_none() {
3441 let mut locations = saved.notebooks.clone();
3442 let mut current = saved.current.clone();
3443 if let Some(root) = notebook {
3444 let location = std::path::absolute(root)?.to_string_lossy().into_owned();
3445 if !locations.contains(&location) {
3446 locations.push(location.clone());
3447 }
3448 current = Some(location);
3449 }
3450 Input::Notebooks { locations, current }
27573451 } else {
27583452 Input::Notes {
27593453 document: TextDocument::new(
......@@ -2765,15 +3459,19 @@ fn main() -> Result<(), Box<dyn Error>> {
27653459 reference,
27663460 }
27673461 };
2768 let event_loop = macos::event_loop()?;
3462 let event_loop = platform::event_loop(screenshot.is_some())?;
27693463 if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") {
27703464 replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?;
27713465 }
27723466 let mut app = App {
27733467 proxy: event_loop.create_proxy(),
27743468 input: Some(input),
3469 // A screenshot leaves the settings as it found them.
3470 settings: Some((settings_file.filter(|_| screenshot.is_none()), saved)),
3471 cache,
27753472 substitutes,
27763473 interface_font,
3474 screenshot,
27773475 state: None,
27783476 startup_error: None,
27793477 };
crates/snowbound/src/manage.rs created+386
......@@ -0,0 +1,386 @@
1//! Changes to notebooks, sections, section groups and pages, as OneNote 2010 makes them:
2//! structure through `notebook::session::Notebook`, pages as ops on the open section.
3
4use crate::{Command, Library, Loaded, State, notify, platform, read_session};
5use notebook::session::Notebook;
6use onestore::{
7 ExGuid, PageCreation, PageEdit,
8 op::{Edit, Op, PageOp, SectionOp},
9 page::{Image, Page, PageObject},
10};
11use std::{error::Error, sync::Arc};
12
13/// A change to a notebook's sections and groups, by catalog path.
14pub enum Structure {
15 NewSection { folder: String },
16 NewGroup { folder: String },
17 Rename { path: String, name: String },
18 /// Moves a section or group to the recycle bin.
19 Delete { path: String },
20 /// Moves a section or group into another folder.
21 Move { path: String, folder: String },
22 /// Orders a folder's sections and groups.
23 Reorder { folder: String, paths: Vec<String> },
24}
25
26/// Notebook colours for new notebooks, in turn: OneNote 2010's section colours.
27const NOTEBOOK_COLORS: [u32; 3] = [0x00e4a88a, 0x0078b0f6, 0x00bba4d5];
28
29/// `current`, the path of a section, after `from` moved to `to`: a section moved itself,
30/// or one inside a moved group.
31fn follow(current: &str, from: &str, to: &str) -> String {
32 match current.strip_prefix(from) {
33 Some("") => to.to_owned(),
34 Some(rest) if rest.starts_with('/') => format!("{to}{rest}"),
35 _ => current.to_owned(),
36 }
37}
38
39/// The first of `base`, `base 2`, `base 3`… that `taken` does not hold, as OneNote names
40/// new sections ("New Section 1", …) and groups.
41fn unused(taken: &[String], base: &str, numbered: bool) -> String {
42 (1..)
43 .map(|n| match (numbered, n) {
44 (false, 1) => base.to_owned(),
45 _ => format!("{base} {n}"),
46 })
47 .find(|name| !taken.iter().any(|taken| taken.eq_ignore_ascii_case(name)))
48 .expect("Some number is free")
49}
50
51impl State {
52 /// A page OneNote 2010 would create now: titled, with its date and time.
53 fn dated_page(&self, before: Option<ExGuid>) -> Result<PageCreation, Box<dyn Error>> {
54 let creation = PageCreation::new(before, Some(""), &self.author)?;
55 let [date, time] = platform::date_text(creation.created());
56 Ok(creation.dated(&date, &time)?)
57 }
58
59 /// Adds a page at the end of the open section, or a subpage after the open page, and
60 /// opens it with its title ready for typing, as OneNote's New Page and New Subpage do.
61 pub(crate) fn new_page(&mut self, subpage: bool) -> Result<(), Box<dyn Error>> {
62 self.persist()?;
63 let session = self.session.as_ref().ok_or("No section is open")?;
64 // A subpage follows the open page and the subpages already under it.
65 let (before, level) = if subpage {
66 let at = session
67 .pages
68 .iter()
69 .position(|(space, ..)| *space == session.space)
70 .ok_or("The open page is not listed")?;
71 let level = session.pages[at].2;
72 let next = session.pages[at + 1..]
73 .iter()
74 .find(|(.., below)| *below <= level)
75 .map(|(space, ..)| *space);
76 (next, (level + 1).min(3))
77 } else {
78 (None, 1)
79 };
80 let creation = self.dated_page(None)?;
81 let space = creation.space();
82 let mut ops = vec![Op::Section(SectionOp::Create(creation))];
83 if subpage {
84 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to(
85 space, before, level,
86 )?])));
87 }
88 let session = self.session.as_mut().ok_or("No section is open")?;
89 session.section.apply(
90 &self.author,
91 Edit {
92 at: crate::filetime(),
93 ops,
94 },
95 )?;
96 session.status = "Saving";
97 self.title_focus = Some(space);
98 self.commands.push(Command::OpenPage(space));
99 Ok(())
100 }
101
102 /// Gives the open page `choice`'s background: a template's art in place of any it
103 /// had, or a page colour instead of art.
104 pub(crate) fn apply_template(
105 &mut self,
106 choice: crate::templates::Choice,
107 ) -> Result<(), Box<dyn Error>> {
108 use crate::templates::Choice;
109 self.persist()?;
110 let session = self.session.as_ref().ok_or("No section is open")?;
111 let space = session.space;
112 let page = session.section.page(space)?;
113 let mut ops: Vec<PageOp> = page
114 .objects
115 .iter()
116 .filter(|object| matches!(object, PageObject::Image(image) if image.background))
117 .map(|object| PageOp::Delete {
118 object: object.id(),
119 })
120 .collect();
121 match choice {
122 Choice::Template(name) => {
123 let template =
124 canvas::template::find(name).ok_or("That template is not available")?;
125 if page.color.is_some() {
126 ops.push(PageOp::Color(None));
127 }
128 // Art lies under everything else on the page, first in its order.
129 let under = page
130 .objects
131 .iter()
132 .find(|object| !matches!(object, PageObject::Image(image) if image.background))
133 .map(PageObject::id);
134 for art in template.art {
135 let bytes = canvas::gpu::page::template_picture(art.art)
136 .ok_or("That template's art is missing")?;
137 ops.push(PageOp::Add {
138 object: PageObject::Image(Image {
139 id: onestore::page::text::new_id()?,
140 layout: onestore::document::Layout {
141 x: Some(art.position[0]),
142 y: Some(art.position[1]),
143 max_width: art.size.map(|size| size[0]),
144 max_height: art.size.map(|size| size[1]),
145 width_set_by_user: art.size.map(|_| true),
146 ..Default::default()
147 },
148 size: art.size,
149 bytes: Some(bytes.into()),
150 alt: None,
151 background: true,
152 }),
153 before: under,
154 });
155 }
156 }
157 Choice::Color(index) => {
158 ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1)))
159 }
160 Choice::More | Choice::Dismiss | Choice::Colors => return Ok(()),
161 }
162 session.section.apply(
163 &self.author,
164 Edit {
165 at: crate::filetime(),
166 ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(),
167 },
168 )?;
169 self.refresh()
170 }
171
172 /// Asks where to create a notebook and creates it, as File, New does in OneNote.
173 pub(crate) fn new_notebook(&mut self) -> Result<(), Box<dyn Error>> {
174 let Some(root) = platform::pick_new("New Notebook", "My Notebook") else {
175 return Ok(());
176 };
177 let page = self.dated_page(None)?;
178 let color = NOTEBOOK_COLORS[self.notebooks.len() % NOTEBOOK_COLORS.len()];
179 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));
180 self.load(move || {
181 let location = std::path::absolute(&root)?.to_string_lossy().into_owned();
182 let notebook = Notebook::create(&location, &cache, color, &page)?;
183 let library = Arc::new(Library::open_notebook(&location, notebook, &cache));
184 let path = library
185 .first_section()
186 .ok_or("The new notebook has no section")?;
187 let section = library.open(&path, notify)?;
188 let (session, page) = read_session(section, library, path, None)?;
189 Ok((Loaded::Section(Box::new(session)), page))
190 });
191 Ok(())
192 }
193
194 /// Closes `library`: its files stay, and the sidebar and the next launch leave it out.
195 pub(crate) fn close_notebook(&mut self, library: &Arc<Library>) {
196 self.notebooks.retain(|open| !Arc::ptr_eq(open, library));
197 if self
198 .session
199 .as_ref()
200 .is_some_and(|session| Arc::ptr_eq(&session.library, library))
201 {
202 self.persist().unwrap_or_else(|error| eprintln!("{error}"));
203 self.session = None;
204 self.templates = crate::templates::View::Strip;
205 match self
206 .notebooks
207 .iter()
208 .find_map(|open| Some((Arc::clone(open), open.first_section()?)))
209 {
210 Some((open, path)) => self.commands.push(Command::OpenSection(open, path)),
211 None => self.title(),
212 }
213 }
214 self.save_settings();
215 }
216
217 /// Changes `library`'s sections and groups on a thread of its own, then shows the
218 /// section the change leaves open: a new or moved one, or the one shown before.
219 pub(crate) fn restructure(&mut self, library: Arc<Library>, change: Structure) {
220 let page = match change {
221 Structure::NewSection { .. } => match self.dated_page(None) {
222 Ok(page) => Some(page),
223 Err(error) => return platform::alert("Couldn't add the section", &error.to_string()),
224 },
225 _ => None,
226 };
227 let current = self
228 .session
229 .as_ref()
230 .filter(|session| Arc::ptr_eq(&session.library, &library))
231 .map(|session| session.tabs[session.tab].path.clone());
232 let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone()));
233 self.load(move || {
234 let mut notebook = Notebook::open(&library.location, &cache)?;
235 let names = |notebook: &Notebook, folder: &str| -> Vec<String> {
236 let mut folders = vec![notebook.catalog()];
237 while let Some(candidate) = folders.pop() {
238 if candidate.path == folder {
239 return candidate
240 .sections
241 .iter()
242 .map(|section| section.path.clone())
243 .chain(candidate.groups.iter().map(|group| group.path.clone()))
244 .map(|path| {
245 let name = path.rsplit('/').next().unwrap_or_default();
246 name.strip_suffix(".one").unwrap_or(name).to_owned()
247 })
248 .collect();
249 }
250 folders.extend(&candidate.groups);
251 }
252 Vec::new()
253 };
254 let open = match change {
255 Structure::NewSection { folder } => {
256 let name = unused(&names(&notebook, &folder), "New Section", true);
257 let page = page.ok_or("The new section has no page")?;
258 Some(notebook.create_section(&folder, &name, &page)?)
259 }
260 Structure::NewGroup { folder } => {
261 let name = unused(&names(&notebook, &folder), "New Section Group", false);
262 notebook.create_group(&folder, &name)?;
263 current
264 }
265 Structure::Rename { path, name } => {
266 let renamed = notebook.rename(&path, &name)?;
267 current.map(|current| follow(&current, &path, &renamed))
268 }
269 Structure::Delete { path } => {
270 notebook.delete(&path)?;
271 current
272 }
273 Structure::Move { path, folder } => {
274 let moved = notebook.move_entry(&path, &folder)?;
275 current.map(|current| follow(&current, &path, &moved))
276 }
277 Structure::Reorder { folder, paths } => {
278 let paths: Vec<&str> = paths.iter().map(String::as_str).collect();
279 notebook.reorder(&folder, &paths)?;
280 current
281 }
282 };
283 let library = Arc::new(Library::open_notebook(&library.location, notebook, &cache));
284 let path = open
285 .filter(|path| library.contains(path))
286 .or_else(|| library.first_section())
287 .ok_or("The notebook has no sections left")?;
288 let section = library.open(&path, notify)?;
289 let (session, page) = read_session(section, library, path, None)?;
290 Ok((Loaded::Section(Box::new(session)), page))
291 });
292 }
293
294 /// Deletes pages of the open section as OneNote 2010 does: each goes to the notebook's
295 /// recycle bin, then leaves the section. A section left without pages gains a new one.
296 pub(crate) fn delete_pages(&mut self, spaces: Vec<ExGuid>) -> Result<(), Box<dyn Error>> {
297 self.persist()?;
298 let session = self.session.as_ref().ok_or("No section is open")?;
299 let pages: Vec<Page> = spaces
300 .iter()
301 .map(|space| session.section.page(*space))
302 .collect::<Result<_, _>>()?;
303 let remaining: Vec<ExGuid> = session
304 .pages
305 .iter()
306 .map(|(space, ..)| *space)
307 .filter(|space| !spaces.contains(space))
308 .collect();
309 // OneNote shows the page after the first deleted one, or the one before it.
310 let at = session
311 .pages
312 .iter()
313 .position(|(space, ..)| spaces.contains(space))
314 .unwrap_or_default();
315 let next = session.pages[at..]
316 .iter()
317 .chain(session.pages[..at].iter().rev())
318 .map(|(space, ..)| *space)
319 .find(|space| remaining.contains(space));
320 let mut ops = vec![Op::Section(SectionOp::Delete(spaces))];
321 let next = match next {
322 Some(next) => next,
323 None => {
324 let creation = self.dated_page(None)?;
325 let space = creation.space();
326 ops.push(Op::Section(SectionOp::Create(creation)));
327 space
328 }
329 };
330 let notebook = match &session.library.notebook {
331 Ok(Some(_)) => Some((session.library.location.clone(), self.cache.clone())),
332 _ => None,
333 };
334 let replica = Arc::clone(session.section.replica());
335 let author = self.author.clone();
336 self.load(move || {
337 if let Some((location, cache)) = notebook {
338 Notebook::open(&location, &cache)?.recycle_pages(&pages, &author)?;
339 }
340 replica.apply(
341 &author,
342 Edit {
343 at: crate::filetime(),
344 ops,
345 },
346 )?;
347 Ok((Loaded::Page(next), replica.page(next)?))
348 });
349 Ok(())
350 }
351
352 /// Moves or indents pages of the open section, in order.
353 pub(crate) fn edit_pages(&mut self, edits: Vec<PageEdit>) -> Result<(), Box<dyn Error>> {
354 self.persist()?;
355 let session = self.session.as_mut().ok_or("No section is open")?;
356 session.section.apply(
357 &self.author,
358 Edit {
359 at: crate::filetime(),
360 ops: vec![Op::Section(SectionOp::Pages(edits))],
361 },
362 )?;
363 session.status = "Saving";
364 session.pages = session.section.pages()?;
365 Ok(())
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn moved_sections_are_followed_and_new_names_take_the_next_number() {
375 assert_eq!(follow("Group/Inner.one", "Group", "Kept"), "Kept/Inner.one");
376 assert_eq!(follow("Group.one", "Group", "Kept"), "Group.one");
377 assert_eq!(follow("A.one", "A.one", "B/A.one"), "B/A.one");
378 let taken = ["New Section 1".to_owned(), "new section 2".to_owned()];
379 assert_eq!(unused(&taken, "New Section", true), "New Section 3");
380 assert_eq!(unused(&[], "New Section Group", false), "New Section Group");
381 assert_eq!(
382 unused(&["New Section Group".to_owned()], "New Section Group", false),
383 "New Section Group 2"
384 );
385 }
386}
crates/snowbound/src/menus.rs created+234
......@@ -0,0 +1,234 @@
1//! Context menus on pages, sections, section groups and notebooks, with OneNote 2010's
2//! commands in its words, and moving pages by dragging their tabs.
3
4use crate::{Command, Library, State, manage::Structure, platform};
5use onestore::{ExGuid, PageEdit};
6use std::sync::Arc;
7use ui::{Anchor, Id, popup::Item};
8
9/// What a context menu was opened on.
10pub enum Target {
11 Page(ExGuid),
12 Section { library: Arc<Library>, path: String },
13 Group { library: Arc<Library>, path: String },
14 Notebook(Arc<Library>),
15}
16
17pub fn id() -> Id {
18 Id::ROOT.child("context-menu")
19}
20
21/// The catalog folder holding `path`.
22fn folder(path: &str) -> String {
23 path.rsplit_once('/')
24 .map_or(String::new(), |(folder, _)| folder.to_owned())
25}
26
27impl State {
28 /// The open context menu, and what its choice does.
29 pub(crate) fn context_menu(&mut self) {
30 let Some((target, point)) = &self.menu else {
31 return;
32 };
33 if !self.ui.popup_open(id()) {
34 self.menu = None;
35 return;
36 }
37 let item = |text| Item {
38 text,
39 ..Default::default()
40 };
41 let items: Vec<Item> = match target {
42 Target::Page(space) => {
43 let pages = self
44 .session
45 .as_ref()
46 .map_or(&[][..], |session| &session.pages[..]);
47 let at = pages.iter().position(|(listed, ..)| listed == space);
48 let level = at.map_or(1, |at| pages[at].2);
49 let above = at
50 .and_then(|at| at.checked_sub(1))
51 .map_or(0, |above| pages[above].2);
52 vec![
53 item("Delete"),
54 Item {
55 separated: true,
56 ..item("New Page")
57 },
58 Item {
59 separated: true,
60 disabled: level > above || level >= 3,
61 ..item("Make Subpage")
62 },
63 Item {
64 disabled: level <= 1,
65 ..item("Promote Subpage")
66 },
67 ]
68 }
69 Target::Section { .. } | Target::Group { .. } => vec![
70 item("Rename"),
71 item("Delete"),
72 Item {
73 separated: true,
74 ..item("New Section")
75 },
76 item("New Section Group"),
77 ],
78 Target::Notebook(_) => vec![
79 item("New Section"),
80 item("New Section Group"),
81 Item {
82 separated: true,
83 ..item("Close This Notebook")
84 },
85 ],
86 };
87 let Some(chosen) = ui::popup::menu(&mut self.ui, id(), Anchor::Point(*point), &items, None)
88 else {
89 return;
90 };
91 let Some((target, _)) = self.menu.take() else {
92 return;
93 };
94 let command = match (target, items[chosen].text) {
95 (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])),
96 (Target::Page(_), "New Page") => Some(Command::NewPage { subpage: false }),
97 (Target::Page(space), text) => self.session.as_ref().and_then(|session| {
98 let level = session
99 .pages
100 .iter()
101 .find(|(listed, ..)| *listed == space)?
102 .2;
103 let level = if text == "Make Subpage" {
104 level + 1
105 } else {
106 level - 1
107 };
108 Some(Command::Pages(vec![PageEdit::set_level(space, level).ok()?]))
109 }),
110 (
111 Target::Section { library, path } | Target::Group { library, path },
112 "Rename",
113 ) => {
114 let name = path.rsplit('/').next().unwrap_or_default();
115 self.renaming = Some(crate::sidebar::Renaming {
116 library,
117 name: name.strip_suffix(".one").unwrap_or(name).to_owned(),
118 path,
119 });
120 self.sidebar = true;
121 None
122 }
123 (Target::Section { library, path }, "Delete") => platform::confirm(
124 "Are you sure you want to move this section to this notebook's Recycle Bin?",
125 &path,
126 "Cancel",
127 "Delete",
128 )
129 .then(|| Command::Structure(library, Structure::Delete { path })),
130 (Target::Group { library, path }, "Delete") => platform::confirm(
131 "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?",
132 path.rsplit('/').next().unwrap_or_default(),
133 "Cancel",
134 "Delete",
135 )
136 .then(|| Command::Structure(library, Structure::Delete { path })),
137 (Target::Section { library, path }, text) => {
138 let folder = folder(&path);
139 Some(Command::Structure(library, new(text, folder)))
140 }
141 (Target::Group { library, path }, text) => {
142 Some(Command::Structure(library, new(text, path)))
143 }
144 (Target::Notebook(library), "Close This Notebook") => {
145 Some(Command::CloseNotebook(library))
146 }
147 (Target::Notebook(library), text) => {
148 Some(Command::Structure(library, new(text, String::new())))
149 }
150 };
151 self.commands.extend(command);
152 }
153
154 /// Moves a page by dragging its tab: while `held`, a line shows where it would go; let
155 /// go, it moves there at its level, or under a page at most one level above it.
156 /// `panel` is the page list's rectangle.
157 pub(crate) fn drag_pages(
158 &mut self,
159 held: Option<ExGuid>,
160 section: &ui::Section,
161 panel: [f32; 4],
162 ) {
163 let Some(session) = &self.session else {
164 return;
165 };
166 if held.is_some() && !self.filter.is_empty() {
167 return;
168 }
169 let dragged = held.or(self.dragging.take());
170 self.dragging = held;
171 let (Some(space), Some([_, y])) = (dragged, self.ui.pointer()) else {
172 return;
173 };
174 // Rows other than the dragged one, with where each lies.
175 let rows: Vec<(ExGuid, u32, [f32; 4])> = session
176 .pages
177 .iter()
178 .filter(|(listed, ..)| *listed != space)
179 .filter_map(|(listed, _, level)| Some((*listed, *level, self.ui.rect(self.ui.id(listed))?)))
180 .collect();
181 let Some(level) = session
182 .pages
183 .iter()
184 .find(|(listed, ..)| *listed == space)
185 .map(|(.., level)| *level)
186 else {
187 return;
188 };
189 let at = rows
190 .iter()
191 .position(|(.., rect)| y < (rect[1] + rect[3]) / 2.0)
192 .unwrap_or(rows.len());
193 let before = rows.get(at).map(|(listed, ..)| *listed);
194 let level = at
195 .checked_sub(1)
196 .map_or(1, |above| level.min(rows[above].1 + 1));
197 let stays = session
198 .pages
199 .iter()
200 .skip_while(|(listed, ..)| *listed != space)
201 .nth(1)
202 .map(|(listed, ..)| *listed)
203 == before;
204 if stays {
205 return;
206 }
207 if held.is_some() {
208 // The line lies between the rows the page would go between.
209 let line = match (at.checked_sub(1).map(|above| rows[above].2), rows.get(at)) {
210 (_, Some((.., below))) => below[1],
211 (Some(above), None) => above[3],
212 (None, None) => return,
213 } - panel[1];
214 let indent = 10.0 + 16.0 * (level - 1) as f32;
215 self.ui.mark(
216 [indent, line - 1.5, crate::PAGE_LIST - 6.0, line + 1.5],
217 section.accent,
218 1.5,
219 );
220 self.dragging = held;
221 } else if let Ok(edit) = PageEdit::move_to(space, before, level) {
222 self.commands.push(Command::Pages(vec![edit]));
223 }
224 }
225}
226
227/// The new section or group a menu's `text` asks for in `folder`.
228fn new(text: &str, folder: String) -> Structure {
229 if text == "New Section Group" {
230 Structure::NewGroup { folder }
231 } else {
232 Structure::NewSection { folder }
233 }
234}
crates/snowbound/src/screenshot.rs created+123
......@@ -0,0 +1,123 @@
1//! `--screenshot`: the window drawn offscreen at a fixed size in each appearance. On macOS
2//! the parts AppKit draws over it, the traffic lights and the rounded corners, are painted
3//! in; elsewhere the app draws its whole window.
4
5use super::*;
6use winit::window::Theme as Appearance;
7
8/// The window in points, drawn at `SCALE` pixels per point.
9const SIZE: [f32; 2] = [1440.0, 900.0];
10const SCALE: f32 = 2.0;
11/// A standard window's close, minimize and zoom buttons as AppKit places them beside a
12/// transparent title bar: centres in points, fill and rim.
13#[cfg(target_os = "macos")]
14const LIGHTS: [(f32, [u8; 3], [u8; 3]); 3] = [
15 (14.0, [0xff, 0x5f, 0x57], [0xe2, 0x46, 0x3f]),
16 (34.0, [0xfe, 0xbc, 0x2e], [0xe1, 0xa1, 0x16]),
17 (54.0, [0x28, 0xc8, 0x40], [0x12, 0xac, 0x28]),
18];
19
20impl State {
21 /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows.
22 pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box<dyn Error>> {
23 [self.config.width, self.config.height] = SIZE.map(|side| (side * SCALE) as u32);
24 self.renderer.clear_glyph_cache();
25 self.app_icon = platform::app_icon((16.0 * SCALE) as u32);
26 let response = self.view.scale_factor_changed(SCALE)?;
27 self.respond(response);
28 self.ui.set_focus(None);
29 if let Some(title) = self
30 .view
31 .editor
32 .outlines()
33 .iter()
34 .find(|outline| outline.title)
35 {
36 self.view.editor.focus_outline(title.id)?;
37 }
38 for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] {
39 self.window.set_theme(Some(appearance));
40 self.ui.theme = theme(appearance);
41 // The page's box sizes the view whose pictures settle.
42 self.layout(SIZE, SCALE)?;
43 let paper = canvas::gpu::Paper {
44 color: self.ui.theme.paper,
45 ink: self.ui.theme.paper_ink,
46 };
47 if let Some((scene, _)) = &mut self.view.scene {
48 scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper);
49 }
50 // Later frames lay out with earlier frames' measurements while colours ease.
51 let deadline = Instant::now() + std::time::Duration::from_secs(2);
52 loop {
53 self.layout(SIZE, SCALE)?;
54 if !self.ui.wants_frame() || Instant::now() > deadline {
55 break;
56 }
57 std::thread::sleep(std::time::Duration::from_millis(16));
58 }
59 let path = PathBuf::from(format!("{}-{name}.png", prefix.display()));
60 self.snapshot(&path)?;
61 #[cfg(target_os = "macos")]
62 window_frame(&path, appearance)?;
63 }
64 Ok(())
65 }
66}
67
68/// Paints the traffic lights into the PNG at `path`, rounds its corners to transparency
69/// and edges them with the window's hairline.
70#[cfg(target_os = "macos")]
71fn window_frame(path: &Path, appearance: Appearance) -> Result<(), Box<dyn Error>> {
72 let mut reader =
73 png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?;
74 let mut pixels = vec![0; reader.output_buffer_size().ok_or("Snapshot too large")?];
75 let info = reader.next_frame(&mut pixels)?;
76 let hairline = match appearance {
77 Appearance::Light => [0, 0, 0, 56],
78 Appearance::Dark => [255, 255, 255, 56],
79 };
80 // Coverage of a pixel whose centre lies `distance` pixels outside a shape's edge.
81 let inside = |distance: f32| (0.5 - distance).clamp(0.0, 1.0);
82 let blend = |pixel: &mut [u8], [r, g, b, a]: [u8; 4], coverage: f32| {
83 let weight = coverage * f32::from(a) / 255.0;
84 for (channel, value) in pixel.iter_mut().zip([r, g, b]) {
85 *channel =
86 (f32::from(*channel) * (1.0 - weight) + f32::from(value) * weight).round() as u8;
87 }
88 };
89 let [width, height] = [info.width as f32, info.height as f32];
90 let radius = platform::CORNER_RADIUS * SCALE;
91 for (index, pixel) in pixels.chunks_exact_mut(4).enumerate() {
92 let x = (index as u32 % info.width) as f32 + 0.5;
93 let y = (index as u32 / info.width) as f32 + 0.5;
94 for (centre, fill, rim) in LIGHTS {
95 let distance = (x - centre * SCALE).hypot(y - TITLE / 2.0 * SCALE);
96 blend(
97 pixel,
98 [rim[0], rim[1], rim[2], 255],
99 inside(distance - 6.0 * SCALE),
100 );
101 blend(
102 pixel,
103 [fill[0], fill[1], fill[2], 255],
104 inside(distance - 5.5 * SCALE),
105 );
106 }
107 // Signed distance to the window's rounded rectangle.
108 let [dx, dy] = [
109 (x - width / 2.0).abs() - (width / 2.0 - radius),
110 (y - height / 2.0).abs() - (height / 2.0 - radius),
111 ];
112 let edge = dx.max(0.0).hypot(dy.max(0.0)) + dx.max(dy).min(0.0) - radius;
113 blend(pixel, hairline, inside((edge + 0.5).abs() - 0.5));
114 pixel[3] = (f32::from(pixel[3]) * inside(edge)).round() as u8;
115 }
116 let partial = path.with_extension("partial");
117 let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, info.width, info.height);
118 encoder.set_color(png::ColorType::Rgba);
119 encoder.set_depth(png::BitDepth::Eight);
120 encoder.write_header()?.write_image_data(&pixels)?;
121 std::fs::rename(partial, path)?;
122 Ok(())
123}
crates/snowbound/src/settings.rs created+109
......@@ -0,0 +1,109 @@
1//! What the app keeps between launches: the open notebooks, the sidebar and recent fonts,
2//! as JSON in the platform's settings directory.
3
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
8#[serde(default)]
9pub struct Settings {
10 /// Open notebooks in the sidebar's order, as `Location::key` names them.
11 pub notebooks: Vec<String>,
12 /// The notebook shown last.
13 pub current: Option<String>,
14 /// Whether the notebook sidebar is expanded.
15 pub sidebar: bool,
16 /// Fonts picked from the font box, latest first.
17 pub recent_fonts: Vec<String>,
18}
19
20impl Settings {
21 /// The settings at `path`; missing or unreadable ones are the defaults, reported.
22 pub fn load(path: &Path) -> Self {
23 match std::fs::read(path) {
24 Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| {
25 eprintln!("Ignoring the settings in {}: {error}", path.display());
26 Self::default()
27 }),
28 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self::default(),
29 Err(error) => {
30 eprintln!("Ignoring the settings in {}: {error}", path.display());
31 Self::default()
32 }
33 }
34 }
35
36 /// Replaces the file at `path` whole, so a crash leaves the old settings or the new.
37 pub fn save(&self, path: &Path) -> std::io::Result<()> {
38 if let Some(folder) = path.parent() {
39 std::fs::create_dir_all(folder)?;
40 }
41 let partial = path.with_extension("partial");
42 std::fs::write(&partial, serde_json::to_vec_pretty(self)?)?;
43 std::fs::rename(partial, path)
44 }
45}
46
47impl crate::State {
48 /// Keeps what the next launch restores: the open notebooks, the one shown, the sidebar
49 /// and recent fonts. A section opened on its own is not kept.
50 pub(crate) fn save_settings(&self) {
51 let Some(path) = &self.settings else {
52 return;
53 };
54 let kept = |library: &&std::sync::Arc<crate::Library>| !matches!(library.notebook, Ok(None));
55 let settings = Settings {
56 notebooks: self
57 .notebooks
58 .iter()
59 .filter(kept)
60 .map(|library| library.location.clone())
61 .collect(),
62 current: self
63 .session
64 .as_ref()
65 .map(|session| &session.library)
66 .filter(kept)
67 .map(|library| library.location.clone()),
68 sidebar: self.sidebar,
69 recent_fonts: self.recent_fonts.clone(),
70 };
71 if let Err(error) = settings.save(path) {
72 eprintln!("Cannot save the settings in {}: {error}", path.display());
73 }
74 }
75}
76
77/// Where the settings live unless `--settings` names a file.
78pub fn default_path() -> Option<PathBuf> {
79 Some(crate::platform::settings_dir()?.join("settings.json"))
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn settings_round_trip_and_tolerate_missing_fields() {
88 let directory = std::env::temp_dir().join(format!("snowbound-settings-{}", std::process::id()));
89 let path = directory.join("nested/settings.json");
90 assert_eq!(Settings::load(&path), Settings::default());
91 let settings = Settings {
92 notebooks: vec!["/notebooks/Personal".into(), "smb://server/share/Work".into()],
93 current: Some("/notebooks/Personal".into()),
94 sidebar: true,
95 recent_fonts: vec!["Georgia".into()],
96 };
97 settings.save(&path).unwrap();
98 assert_eq!(Settings::load(&path), settings);
99 std::fs::write(&path, br#"{"sidebar": true, "unknown": 1}"#).unwrap();
100 assert_eq!(
101 Settings::load(&path),
102 Settings {
103 sidebar: true,
104 ..Settings::default()
105 }
106 );
107 std::fs::remove_dir_all(&directory).unwrap();
108 }
109}
crates/snowbound/src/sidebar.rs created+739
......@@ -0,0 +1,739 @@
1//! OneNote 2010's navigation bar: the open notebooks with their sections and section
2//! groups, down the window's left, beside the section tabs.
3
4use crate::{art, library::Library, menus::Target, section_color};
5use notebook::discover::{Folder, SectionState};
6use std::{collections::HashSet, sync::Arc};
7use ui::{Axis, Event, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px};
8use winit::keyboard::{Key, NamedKey};
9
10pub const WIDTH: f32 = 220.0;
11/// The collapsed sidebar: a rail as wide as its button, which stays where it is as the
12/// sidebar opens.
13pub const RAIL: f32 = crate::FRAME + ui::shell::TOOL + 2.0;
14const ROW: f32 = 24.0;
15/// How far each level of the tree sits inside its parent.
16const INDENT: f32 = 16.0;
17
18/// What the sidebar was asked to do.
19pub enum Action {
20 /// Expands or collapses the sidebar.
21 Toggle,
22 /// Shows a notebook where it was left.
23 Notebook(usize),
24 /// Opens a notebook's section by catalog path.
25 Open { notebook: usize, path: String },
26 /// Folds or unfolds a notebook's or section group's rows, by `fold_key`.
27 Fold(String),
28 NewNotebook,
29 OpenNotebook,
30 /// A row's context menu, opened here.
31 Menu(Target, [f32; 2]),
32 /// Ends renaming, with the name typed or without.
33 Renamed(bool),
34}
35
36/// A section or group being renamed in place, and the name typed so far.
37pub struct Renaming {
38 pub library: Arc<Library>,
39 pub path: String,
40 pub name: String,
41}
42
43/// The field a row being renamed shows.
44pub fn rename_field() -> Id {
45 Id::ROOT.child("rename")
46}
47
48/// Names a notebook's or group's rows for folding them.
49pub fn fold_key(library: &Library, path: &str) -> String {
50 format!("{}\n{path}", library.location)
51}
52
53/// What the tree's rows read and what they were asked.
54struct Tree<'a> {
55 theme: &'a Theme,
56 /// The open section's notebook index and catalog path.
57 open: Option<(usize, &'a str)>,
58 folded: &'a HashSet<String>,
59 renaming: Option<&'a mut Renaming>,
60 action: Option<Action>,
61 /// Each section and group row: where it lies, and what it is.
62 rows: Vec<Entry>,
63 /// The section or group held down, which a drag moves.
64 held: Option<Entry>,
65}
66
67/// A section or group row, for dragging one onto or between the others.
68#[derive(Clone, PartialEq)]
69pub struct Entry {
70 pub notebook: usize,
71 pub path: String,
72 pub group: bool,
73 pub rect: [f32; 4],
74}
75
76impl Entry {
77 fn folder(&self) -> &str {
78 self.path.rsplit_once('/').map_or("", |(folder, _)| folder)
79 }
80}
81
82/// The sidebar under a header row `header` tall, listing `notebooks` with the open section
83/// marked.
84pub fn sidebar(
85 ui: &mut Ui,
86 theme: &Theme,
87 notebooks: &[Arc<Library>],
88 open: Option<(usize, &str)>,
89 folded: &HashSet<String>,
90 renaming: Option<&mut Renaming>,
91 header: f32,
92) -> (Option<Action>, Vec<Entry>, Option<Entry>) {
93 let mut tree = Tree {
94 theme,
95 open,
96 folded,
97 renaming,
98 action: None,
99 rows: Vec::new(),
100 held: None,
101 };
102 ui.open(
103 "header",
104 Spec {
105 size: [fill(), px(header)],
106 pad: [crate::FRAME, (header - ui::shell::TOOL) / 2.0],
107 gap: 6.0,
108 ..Spec::default()
109 },
110 );
111 if ui::shell::tool_button(ui, "toggle", art::NOTEBOOK, theme.text, false).clicked {
112 tree.action = Some(Action::Toggle);
113 }
114 ui.leaf(
115 "title",
116 Spec {
117 size: [fill(), px(ui::shell::TOOL)],
118 text: Some("Notebooks"),
119 color: Some(theme.text_dim),
120 ..Spec::default()
121 },
122 );
123 ui.close();
124 ui.open(
125 "notebooks",
126 Spec {
127 flags: Flags::SCROLL | Flags::CLIP,
128 axis: Axis::Y,
129 size: [fill(), fill()],
130 pad: [4.0, 2.0],
131 ..Spec::default()
132 },
133 );
134 for (index, library) in notebooks.iter().enumerate() {
135 let key = fold_key(library, "");
136 let unfolded = !folded.contains(&key);
137 let (row, fold) = tree_row(
138 ui,
139 &mut tree,
140 ("notebook", index),
141 Row {
142 label: &library.name,
143 icon: Leading::Icon(art::NOTEBOOK),
144 depth: 0,
145 dim: library.notebook.is_err(),
146 fold: Some(unfolded),
147 selected: None,
148 renamed: false,
149 },
150 );
151 if fold {
152 tree.action = Some(Action::Fold(key));
153 } else if row.clicked && open.is_none_or(|(notebook, _)| notebook != index) {
154 tree.action = Some(Action::Notebook(index));
155 }
156 if let Some(point) = row.context {
157 tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point));
158 }
159 if unfolded && let Some(catalog) = library.catalog() {
160 folder(ui, &mut tree, library, index, catalog, 1);
161 }
162 }
163 ui.close();
164 ui.open(
165 "footer",
166 Spec {
167 axis: Axis::Y,
168 size: [fill(), ui::children()],
169 pad: [4.0, 6.0],
170 ..Spec::default()
171 },
172 );
173 for (part, icon, label, chosen) in [
174 ("new", art::PLUS, "New Notebook", Action::NewNotebook),
175 ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook),
176 ] {
177 let (row, _) = tree_row(
178 ui,
179 &mut tree,
180 part,
181 Row {
182 label,
183 icon: Leading::Icon(icon),
184 depth: 0,
185 dim: false,
186 fold: None,
187 selected: None,
188 renamed: false,
189 },
190 );
191 if row.clicked {
192 tree.action = Some(chosen);
193 }
194 }
195 ui.close();
196 (tree.action, tree.rows, tree.held)
197}
198
199/// A notebook's or group's sections, then its groups, as OneNote lists them.
200fn folder(
201 ui: &mut Ui,
202 tree: &mut Tree,
203 library: &Arc<Library>,
204 notebook: usize,
205 folder: &Folder,
206 depth: u32,
207) {
208 let open = tree
209 .open
210 .filter(|(index, _)| *index == notebook)
211 .map(|(_, path)| path);
212 let renamed = |tree: &Tree, path: &str| {
213 tree.renaming
214 .as_ref()
215 .is_some_and(|renaming| Arc::ptr_eq(&renaming.library, library) && renaming.path == path)
216 };
217 for section in &folder.sections {
218 let (name, color, readable) = match &section.state {
219 SectionState::Readable { name, color } => (
220 crate::library::section_name(&section.path, name),
221 *color,
222 true,
223 ),
224 _ => (
225 crate::library::section_name(&section.path, &None),
226 None,
227 false,
228 ),
229 };
230 let renaming = renamed(tree, &section.path);
231 let (row, _) = tree_row(
232 ui,
233 tree,
234 ("section", notebook, &section.path),
235 Row {
236 label: &name,
237 icon: Leading::Swatch(section_color(color)),
238 depth,
239 dim: !readable,
240 fold: None,
241 selected: (open == Some(section.path.as_str())).then_some(section_color(color)),
242 renamed: renaming,
243 },
244 );
245 if row.clicked && readable && open != Some(section.path.as_str()) {
246 tree.action = Some(Action::Open {
247 notebook,
248 path: section.path.clone(),
249 });
250 }
251 entry(ui, tree, notebook, &section.path, false, &row);
252 if let Some(point) = row.context {
253 let target = Target::Section {
254 library: Arc::clone(library),
255 path: section.path.clone(),
256 };
257 tree.action = Some(Action::Menu(target, point));
258 }
259 }
260 for group in &folder.groups {
261 // OneNote lists deleted sections under its own command, not as a group.
262 if crate::library::recycle_bin(group) {
263 continue;
264 }
265 let key = fold_key(library, &group.path);
266 let unfolded = !tree.folded.contains(&key);
267 let name = group.path.rsplit('/').next().unwrap_or_default();
268 let renaming = renamed(tree, &group.path);
269 let (row, fold) = tree_row(
270 ui,
271 tree,
272 ("group", notebook, &group.path),
273 Row {
274 label: name,
275 icon: Leading::Icon(art::SECTION_GROUP),
276 depth,
277 dim: false,
278 fold: Some(unfolded),
279 selected: None,
280 renamed: renaming,
281 },
282 );
283 if row.clicked || fold {
284 tree.action = Some(Action::Fold(key));
285 }
286 entry(ui, tree, notebook, &group.path, true, &row);
287 if let Some(point) = row.context {
288 let target = Target::Group {
289 library: Arc::clone(library),
290 path: group.path.clone(),
291 };
292 tree.action = Some(Action::Menu(target, point));
293 }
294 if unfolded {
295 self::folder(ui, tree, library, notebook, group, depth + 1);
296 }
297 }
298}
299
300/// Records a section or group row for dragging.
301fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row: &Signal) {
302 let Some(rect) = ui.rect(ui.id(("section", notebook, path)))
303 .or_else(|| ui.rect(ui.id(("group", notebook, path))))
304 else {
305 return;
306 };
307 let entry = Entry {
308 notebook,
309 path: path.to_owned(),
310 group,
311 rect,
312 };
313 if row.dragging {
314 tree.held = Some(entry.clone());
315 }
316 tree.rows.push(entry);
317}
318
319enum Leading {
320 Icon(&'static [&'static str]),
321 /// A section's colour, as OneNote's section icon shows it.
322 Swatch([f32; 4]),
323}
324
325struct Row<'a> {
326 label: &'a str,
327 icon: Leading,
328 depth: u32,
329 dim: bool,
330 /// Whether the rows beneath are unfolded, for rows that fold.
331 fold: Option<bool>,
332 /// The open section's colour.
333 selected: Option<[f32; 4]>,
334 /// The row is being renamed: its label is the tree's rename field.
335 renamed: bool,
336}
337
338/// One row of the tree: its signal, and whether its fold arrow was clicked.
339fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row) -> (Signal, bool) {
340 let theme = tree.theme;
341 let lit = row
342 .selected
343 .map(|color| ui::mix(theme.section(color).tab, theme.base, 0.35));
344 let id = ui.open(
345 part,
346 Spec {
347 flags: Flags::CLICKABLE,
348 size: [fill(), px(ROW)],
349 fill: lit,
350 hover_fill: Some(ui::mix(lit.unwrap_or(theme.strip), theme.hover(), 0.6)),
351 radius: 4.0,
352 pad: [6.0 + INDENT * row.depth as f32, 0.0],
353 gap: 6.0,
354 ..Spec::default()
355 },
356 );
357 let color = if row.dim { theme.text_dim } else { theme.text };
358 match row.icon {
359 Leading::Icon(icon) => {
360 ui.leaf(
361 "icon",
362 Spec {
363 size: [px(16.0), px(ROW)],
364 icon: Some(icon),
365 color: Some(color),
366 ..Spec::default()
367 },
368 );
369 }
370 Leading::Swatch(swatch) => {
371 ui.open(
372 "swatch",
373 Spec {
374 size: [px(16.0), px(ROW)],
375 pad: [3.0, 6.0],
376 ..Spec::default()
377 },
378 );
379 ui.leaf(
380 "color",
381 Spec {
382 size: [fill(), fill()],
383 fill: Some(theme.section(swatch).frame[0]),
384 border: Some(theme.section(swatch).edge),
385 radius: 2.0,
386 ..Spec::default()
387 },
388 );
389 ui.close();
390 }
391 }
392 match (row.renamed, tree.renaming.as_deref_mut()) {
393 (true, Some(renaming)) => {
394 let field = ui::text_field(
395 ui,
396 rename_field(),
397 &mut renaming.name,
398 "",
399 Spec {
400 size: [fill(), px(ROW - 4.0)],
401 fill: Some(theme.base),
402 border: Some(theme.accent),
403 radius: 3.0,
404 pad: [4.0, 0.0],
405 ..Spec::default()
406 },
407 );
408 for event in &field.events {
409 if let Event::Key {
410 key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)),
411 ..
412 } = event
413 {
414 tree.action = Some(Action::Renamed(*key == NamedKey::Enter));
415 }
416 }
417 }
418 _ => {
419 ui.leaf(
420 "label",
421 Spec {
422 size: [fill(), px(ROW)],
423 text: Some(row.label),
424 color: Some(color),
425 ..Spec::default()
426 },
427 );
428 }
429 }
430 let folded = row.fold.is_some_and(|unfolded| {
431 ui.leaf(
432 "fold",
433 Spec {
434 flags: Flags::CLICKABLE,
435 size: [fit(), px(ROW)],
436 icon: Some(if unfolded {
437 art::CHEVRON_UP
438 } else {
439 ui::shell::CHEVRON
440 }),
441 color: Some(theme.text_dim),
442 ..Spec::default()
443 },
444 )
445 .clicked
446 });
447 ui.close();
448 (ui.signal(id), folded)
449}
450
451impl crate::State {
452 /// The sidebar left of the section tabs, easing open and shut.
453 pub(crate) fn sidebar(&mut self, theme: &Theme) {
454 if self.temporary {
455 return;
456 }
457 let width = self
458 .ui
459 .animate(self.ui.id("sidebar"), if self.sidebar { WIDTH } else { RAIL });
460 self.ui.open(
461 "sidebar",
462 Spec {
463 flags: Flags::CLIP,
464 size: [px(width), fill()],
465 fill: Some(theme.strip),
466 ..Spec::default()
467 },
468 );
469 // The rows keep their width as the sidebar eases, so their labels never reflow.
470 let rows_id = self.ui.id("rows");
471 self.ui.open(
472 "rows",
473 Spec {
474 axis: Axis::Y,
475 size: [px(WIDTH), fill()],
476 ..Spec::default()
477 },
478 );
479 let open = self.session.as_ref().and_then(|session| {
480 let index = self
481 .notebooks
482 .iter()
483 .position(|library| Arc::ptr_eq(library, &session.library))?;
484 Some((index, session.tabs[session.tab].path.as_str()))
485 });
486 let (action, rows, held) = sidebar(
487 &mut self.ui,
488 theme,
489 &self.notebooks,
490 open,
491 &self.folded,
492 self.renaming.as_mut(),
493 crate::TAB_ROW,
494 );
495 let corner = self.ui.rect(rows_id).unwrap_or_default();
496 self.drag_entries(held, &rows, corner, theme);
497 self.ui.close();
498 self.ui.close();
499 match action {
500 Some(Action::Toggle) => {
501 self.sidebar = !self.sidebar;
502 self.save_settings();
503 }
504 Some(Action::Fold(key)) => {
505 if !self.folded.remove(&key) {
506 self.folded.insert(key);
507 }
508 }
509 Some(Action::Notebook(index)) => {
510 let library = Arc::clone(&self.notebooks[index]);
511 if let Some(path) = library.first_section() {
512 self.commands.push(crate::Command::OpenSection(library, path));
513 }
514 }
515 Some(Action::Open { notebook, path }) => {
516 let library = Arc::clone(&self.notebooks[notebook]);
517 self.commands.push(crate::Command::OpenSection(library, path));
518 }
519 Some(Action::Menu(target, point)) => {
520 self.menu = Some((target, point));
521 self.ui.open_popup(crate::menus::id());
522 }
523 Some(Action::Renamed(keep)) => {
524 if let Some(renaming) = self.renaming.take()
525 && keep
526 {
527 let name = renaming.name.trim().to_owned();
528 let old = renaming.path.rsplit('/').next().unwrap_or_default();
529 if !name.is_empty() && name != old.strip_suffix(".one").unwrap_or(old) {
530 self.commands.push(crate::Command::Structure(
531 renaming.library,
532 crate::manage::Structure::Rename {
533 path: renaming.path,
534 name,
535 },
536 ));
537 }
538 }
539 self.ui.set_focus(Some(crate::page()));
540 }
541 Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook),
542 Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook),
543 None => {}
544 }
545 }
546
547 /// Moves a section or group by dragging its row: onto a group's middle it moves into the
548 /// group, as OneNote moves one dropped on a group's tab; between rows of its folder it
549 /// takes that place; between rows of another folder it moves there, last. `corner` is
550 /// the rows' box.
551 fn drag_entries(&mut self, held: Option<Entry>, rows: &[Entry], corner: [f32; 4], theme: &Theme) {
552 let dragged = held.clone().or(self.dragging_entry.take());
553 self.dragging_entry = held.clone();
554 let (Some(dragged), Some([_, y])) = (dragged, self.ui.pointer()) else {
555 return;
556 };
557 let inside = |entry: &Entry| {
558 entry.path == dragged.path || entry.path.starts_with(&format!("{}/", dragged.path))
559 };
560 let candidates: Vec<&Entry> = rows
561 .iter()
562 .filter(|entry| entry.notebook == dragged.notebook && !inside(entry))
563 .collect();
564 let Some(under) = candidates
565 .iter()
566 .find(|entry| y >= entry.rect[1] && y < entry.rect[3])
567 .or_else(|| candidates.last().filter(|entry| y >= entry.rect[3]))
568 else {
569 return;
570 };
571 let [top, bottom] = [under.rect[1], under.rect[3]];
572 let quarter = (bottom - top) / 4.0;
573 let into = under.group && y > top + quarter && y < bottom - quarter;
574 let (folder, before) = if into {
575 (under.path.as_str(), None)
576 } else if y < (top + bottom) / 2.0 {
577 (under.folder(), Some(under.path.as_str()))
578 } else {
579 let next = candidates
580 .iter()
581 .skip_while(|entry| entry.path != under.path)
582 .nth(1)
583 .filter(|entry| entry.folder() == under.folder());
584 (under.folder(), next.map(|entry| entry.path.as_str()))
585 };
586 let change = if folder == dragged.folder() {
587 if into {
588 return;
589 }
590 // The folder's order with the dragged entry where it was dropped.
591 let mut paths: Vec<String> = candidates
592 .iter()
593 .filter(|entry| entry.folder() == folder)
594 .map(|entry| entry.path.clone())
595 .collect();
596 let at = before
597 .and_then(|before| paths.iter().position(|path| path == before))
598 .unwrap_or(paths.len());
599 paths.insert(at, dragged.path.clone());
600 let current: Vec<&str> = rows
601 .iter()
602 .filter(|entry| entry.notebook == dragged.notebook && entry.folder() == folder)
603 .map(|entry| entry.path.as_str())
604 .collect();
605 if paths.iter().map(String::as_str).eq(current) {
606 return;
607 }
608 crate::manage::Structure::Reorder {
609 folder: folder.to_owned(),
610 paths,
611 }
612 } else {
613 crate::manage::Structure::Move {
614 path: dragged.path.clone(),
615 folder: folder.to_owned(),
616 }
617 };
618 if held.is_some() {
619 let accent = theme.accent;
620 if into {
621 let [left, top, right, bottom] = under.rect;
622 self.ui.mark(
623 [left - corner[0], top - corner[1], right - corner[0], bottom - corner[1]],
624 [accent[0], accent[1], accent[2], 0.3],
625 4.0,
626 );
627 } else {
628 let line = if before == Some(under.path.as_str()) { top } else { bottom } - corner[1];
629 self.ui.mark(
630 [under.rect[0] - corner[0], line - 1.5, under.rect[2] - corner[0], line + 1.5],
631 accent,
632 1.5,
633 );
634 }
635 } else {
636 let library = std::sync::Arc::clone(&self.notebooks[dragged.notebook]);
637 self.commands.push(crate::Command::Structure(library, change));
638 }
639 }
640
641 /// Shows the notebook at `location` at its first section, opening it unless it is open.
642 pub(crate) fn open_notebook(&mut self, location: String) {
643 let open = self
644 .notebooks
645 .iter()
646 .find(|library| library.location == location)
647 .cloned();
648 let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone()));
649 self.load(move || {
650 let library = open.unwrap_or_else(|| Arc::new(Library::notebook(&location, &cache)));
651 if let Err(error) = &library.notebook {
652 return Err(error.clone().into());
653 }
654 let path = library
655 .first_section()
656 .ok_or("This folder holds no notebook sections.")?;
657 let section = library.open(&path, notify)?;
658 let (session, page) = crate::read_session(section, library, path, None)?;
659 Ok((crate::Loaded::Section(Box::new(session)), page))
660 });
661 }
662
663 /// The page's place on the first run, or once every notebook is closed.
664 pub(crate) fn welcome(&mut self, theme: &Theme) {
665 const SIZE: [f32; 2] = [420.0, 120.0];
666 let [left, top, right, bottom] = self.ui.rect(crate::page()).unwrap_or_default();
667 self.ui.open_as(
668 crate::page(),
669 Spec {
670 size: [fill(), fill()],
671 fill: Some(theme.paper),
672 ..Spec::default()
673 },
674 );
675 self.ui.open(
676 "welcome",
677 Spec {
678 flags: Flags::FLOAT,
679 axis: Axis::Y,
680 size: [px(SIZE[0]), px(SIZE[1])],
681 position: [
682 ((right - left - SIZE[0]) / 2.0).max(0.0),
683 ((bottom - top) * 0.4 - SIZE[1] / 2.0).max(0.0),
684 ],
685 gap: 4.0,
686 ..Spec::default()
687 },
688 );
689 let ink = theme.paper_ink;
690 for (part, text, color) in [
691 ("title", "No notebooks open", ink),
692 (
693 "description",
694 "Open a notebook folder, or start a new notebook.",
695 ui::mix(ink, theme.paper, 0.45),
696 ),
697 ] {
698 self.ui.leaf(
699 part,
700 Spec {
701 size: [fill(), px(26.0)],
702 text: Some(text),
703 color: Some(color),
704 center: true,
705 ..Spec::default()
706 },
707 );
708 }
709 let labels = ["Open Notebook…", "New Notebook…"];
710 let pad = 0.75 * theme.font_size;
711 let width: f32 = labels
712 .iter()
713 .map(|label| self.ui.measure(label)[0] + 2.0 * pad)
714 .sum::<f32>()
715 + 8.0;
716 self.ui.open(
717 "actions",
718 Spec {
719 size: [fill(), ui::children()],
720 pad: [((SIZE[0] - width) / 2.0).max(0.0), 12.0],
721 gap: 8.0,
722 ..Spec::default()
723 },
724 );
725 let mut chosen = None;
726 for (label, command) in labels
727 .into_iter()
728 .zip([crate::Command::OpenNotebook, crate::Command::NewNotebook])
729 {
730 if ui::button(&mut self.ui, label, label).clicked {
731 chosen = Some(command);
732 }
733 }
734 self.ui.close();
735 self.commands.extend(chosen);
736 self.ui.close();
737 self.ui.close();
738 }
739}
crates/snowbound/src/templates.rs created+450
......@@ -0,0 +1,450 @@
1//! The template strip a new page shows where its body would start until the body is typed
2//! in, and the gallery of every template it leads to.
3
4use crate::art;
5use canvas::{
6 gpu::Paper,
7 template::{PAGE_COLORS, TEMPLATES, Template},
8};
9use draw::RasterImage;
10use std::collections::HashMap;
11use ui::{Axis, Flags, Spec, Theme, Ui, fill, px};
12
13/// What a thumbnail shows: the top of a letter page, in points from the page origin.
14const REGION: [f32; 4] = [-72.0, -40.0, 540.0, 419.0];
15const TILE: [f32; 2] = [112.0, 84.0];
16const SWATCH: f32 = 24.0;
17const LABEL: f32 = 22.0;
18const GAP: f32 = 12.0;
19const PAD: f32 = 10.0;
20/// Where the strip stands below the body's first line: at its third, so Enter in the title
21/// leaves a line clear above it.
22const LINES_ABOVE: f32 = 2.0 * 18.0;
23/// The strip's picks, in Clover's order, and their labels.
24const STRIP: [(Choice, &str); 5] = [
25 (Choice::Template("Ivy"), "Ivy"),
26 (Choice::Template("Purple Clouds"), "Purple Clouds"),
27 (Choice::Template("Notebook"), "Notebook"),
28 (Choice::Colors, "Solid Color"),
29 (Choice::Template("Informal Meeting Notes"), "Meeting"),
30];
31
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub enum Choice {
34 Template(&'static str),
35 /// An index into `PAGE_COLORS`.
36 Color(usize),
37 /// Shows or hides the page colours.
38 Colors,
39 More,
40 Dismiss,
41}
42
43/// What the template strip shows.
44#[derive(Clone, Copy, Debug, Default, PartialEq)]
45pub enum View {
46 #[default]
47 Strip,
48 /// The strip with the page colours beneath it.
49 Colors,
50 Gallery,
51}
52
53/// Rasters of each template's art for its thumbnail, by template and paper.
54#[derive(Default)]
55pub struct Thumbnails(HashMap<(&'static str, bool), Vec<(RasterImage, [f32; 4])>>);
56
57impl Thumbnails {
58 /// `template`'s art and where it lies in a `TILE`, drawn at `scale` for `paper`.
59 fn art(
60 &mut self,
61 template: &'static Template,
62 paper: Paper,
63 dark: bool,
64 scale: f32,
65 ) -> &[(RasterImage, [f32; 4])] {
66 self.0.entry((template.name, dark)).or_insert_with(|| {
67 let points = TILE[0] / (REGION[2] - REGION[0]);
68 template
69 .art
70 .iter()
71 .filter_map(|art| {
72 let (image, size) = canvas::gpu::page::template_art(
73 art.art,
74 art.size,
75 points * scale,
76 paper,
77 )?;
78 let [x, y] = [
79 (art.position[0] - REGION[0]) * points,
80 (art.position[1] - REGION[1]) * points,
81 ];
82 Some((image, [x, y, size[0] * points, size[1] * points]))
83 })
84 .collect()
85 })
86 }
87}
88
89/// The strip at `position` in the page box, with the page colours beneath while `colors`.
90fn strip(
91 ui: &mut Ui,
92 theme: &Theme,
93 thumbnails: &mut Thumbnails,
94 paper: Paper,
95 dark: bool,
96 position: [f32; 2],
97 colors: bool,
98) -> Option<Choice> {
99 let mut chosen = None;
100 ui.open(
101 "templates",
102 Spec {
103 flags: Flags::FLOAT | Flags::CLICKABLE,
104 axis: Axis::Y,
105 size: [ui::children(), ui::children()],
106 position,
107 fill: Some(theme.popup),
108 shadow: Some(theme.shadow),
109 radius: 8.0,
110 pad: [PAD, PAD],
111 gap: 8.0,
112 ..Spec::default()
113 },
114 );
115 ui.open(
116 "tiles",
117 Spec {
118 size: [ui::children(), ui::children()],
119 gap: GAP,
120 ..Spec::default()
121 },
122 );
123 for (index, (choice, label)) in STRIP.into_iter().chain([(Choice::More, "More templates")]).enumerate() {
124 if tile(ui, theme, thumbnails, paper, dark, index, choice, label) {
125 chosen = Some(choice);
126 }
127 }
128 ui.close();
129 if colors {
130 ui.open(
131 "colors",
132 Spec {
133 size: [ui::children(), px(SWATCH)],
134 gap: 6.0,
135 ..Spec::default()
136 },
137 );
138 for (index, (name, color)) in PAGE_COLORS.iter().enumerate() {
139 let swatch = ui.leaf(
140 (index, *name),
141 Spec {
142 flags: Flags::CLICKABLE,
143 size: [px(SWATCH), px(SWATCH)],
144 fill: Some(page_color(*color, paper, dark)),
145 border: Some(theme.chip),
146 hover_border: Some(theme.accent),
147 radius: 4.0,
148 ..Spec::default()
149 },
150 );
151 if swatch.clicked {
152 chosen = Some(Choice::Color(index));
153 }
154 }
155 ui.close();
156 }
157 let width = ui.rect(ui.id("tiles")).map_or(0.0, |rect| rect[2] - rect[0]);
158 let close = ui.leaf(
159 "dismiss",
160 Spec {
161 flags: Flags::FLOAT | Flags::CLICKABLE,
162 size: [px(22.0), px(22.0)],
163 position: [width + 2.0 * PAD - 14.0, -8.0],
164 icon: Some(art::CLOSE),
165 color: Some(theme.text_dim),
166 fill: Some(theme.popup),
167 border: Some(theme.chip),
168 hover_fill: Some(theme.hover()),
169 radius: 11.0,
170 center: true,
171 ..Spec::default()
172 },
173 );
174 if close.clicked {
175 chosen = Some(Choice::Dismiss);
176 }
177 ui.close();
178 chosen
179}
180
181/// Every template, as a scrolling grid over the page box `size` big.
182fn gallery(
183 ui: &mut Ui,
184 theme: &Theme,
185 thumbnails: &mut Thumbnails,
186 paper: Paper,
187 dark: bool,
188 size: [f32; 2],
189) -> Option<Choice> {
190 let mut chosen = None;
191 let margin = 24.0;
192 ui.open(
193 "gallery",
194 Spec {
195 flags: Flags::FLOAT | Flags::CLICKABLE,
196 axis: Axis::Y,
197 size: [px(size[0] - 2.0 * margin), px(size[1] - 2.0 * margin)],
198 position: [margin, margin],
199 fill: Some(theme.popup),
200 shadow: Some(theme.shadow),
201 radius: 8.0,
202 pad: [PAD, PAD],
203 gap: 8.0,
204 ..Spec::default()
205 },
206 );
207 ui.open(
208 "header",
209 Spec {
210 size: [fill(), px(24.0)],
211 ..Spec::default()
212 },
213 );
214 ui.leaf(
215 "title",
216 Spec {
217 size: [fill(), px(24.0)],
218 text: Some("Page Templates"),
219 ..Spec::default()
220 },
221 );
222 if ui::shell::tool_button(ui, "close", art::CLOSE, theme.text_dim, false).clicked {
223 chosen = Some(Choice::Dismiss);
224 }
225 ui.close();
226 ui.open(
227 "grid",
228 Spec {
229 flags: Flags::SCROLL | Flags::CLIP,
230 axis: Axis::Y,
231 size: [fill(), fill()],
232 gap: GAP,
233 ..Spec::default()
234 },
235 );
236 let column = TILE[0] + 40.0;
237 let columns =
238 (((size[0] - 2.0 * margin - 2.0 * PAD + GAP) / (column + GAP)).floor() as usize).max(1);
239 let choices: Vec<(Choice, &str)> = TEMPLATES
240 .iter()
241 .map(|template| (Choice::Template(template.name), template.name))
242 .chain(
243 PAGE_COLORS
244 .iter()
245 .enumerate()
246 .map(|(index, (name, _))| (Choice::Color(index), *name)),
247 )
248 .collect();
249 for (row, choices) in choices.chunks(columns).enumerate() {
250 ui.open(
251 row,
252 Spec {
253 size: [fill(), ui::children()],
254 gap: GAP,
255 ..Spec::default()
256 },
257 );
258 for (index, (choice, label)) in choices.iter().enumerate() {
259 if tile(ui, theme, thumbnails, paper, dark, index, *choice, label) {
260 chosen = Some(*choice);
261 }
262 }
263 ui.close();
264 }
265 ui.close();
266 ui.close();
267 chosen
268}
269
270/// A page colour as the paper shows it: on dark paper, its hue at the paper's lightness.
271fn page_color(color: u32, paper: Paper, dark: bool) -> [f32; 4] {
272 let color = canvas::gpu::colorref(color);
273 if !dark {
274 return color;
275 }
276 let [lightness, ..] = draw::oklab(paper.color);
277 let [_, a, b] = draw::oklab(color);
278 let [red, green, blue] = draw::from_oklab([lightness + 0.04, 2.0 * a, 2.0 * b]);
279 [red, green, blue, 1.0]
280}
281
282/// One template's thumbnail over its label, as wide as the thumbnail or the label; true
283/// when clicked.
284#[allow(clippy::too_many_arguments)]
285fn tile(
286 ui: &mut Ui,
287 theme: &Theme,
288 thumbnails: &mut Thumbnails,
289 paper: Paper,
290 dark: bool,
291 part: usize,
292 choice: Choice,
293 label: &str,
294) -> bool {
295 let scale = ui.scale();
296 let width = TILE[0].max(ui.measure(label)[0] + 4.0);
297 let fill_color = match choice {
298 Choice::Color(index) => page_color(PAGE_COLORS[index].1, paper, dark),
299 Choice::More | Choice::Dismiss => theme.base,
300 Choice::Template(_) | Choice::Colors => paper.color,
301 };
302 ui.open(
303 (part, label),
304 Spec {
305 axis: Axis::Y,
306 size: [px(width), px(TILE[1] + LABEL)],
307 pad: [(width - TILE[0]) / 2.0, 0.0],
308 ..Spec::default()
309 },
310 );
311 let id = ui.open(
312 "thumbnail",
313 Spec {
314 flags: Flags::CLICKABLE | Flags::CLIP,
315 size: [px(TILE[0]), px(TILE[1])],
316 fill: Some(fill_color),
317 border: Some(theme.chip),
318 hover_border: Some(theme.accent),
319 radius: 4.0,
320 icon: (choice == Choice::More).then_some(art::PLUS),
321 color: Some(theme.text_dim),
322 center: true,
323 ..Spec::default()
324 },
325 );
326 match choice {
327 Choice::Template(name) => {
328 if let Some(template) = canvas::template::find(name) {
329 for (index, (image, [x, y, width, height])) in thumbnails
330 .art(template, paper, dark, scale)
331 .iter()
332 .enumerate()
333 {
334 ui.leaf(
335 ("art", index),
336 Spec {
337 flags: Flags::FLOAT,
338 size: [px(*width), px(*height)],
339 position: [*x, *y],
340 image: Some(image),
341 ..Spec::default()
342 },
343 );
344 }
345 }
346 }
347 // A band of each of the first page colours.
348 Choice::Colors => {
349 let bands = 4;
350 for (index, (_, color)) in PAGE_COLORS.iter().take(bands).enumerate() {
351 let band = TILE[0] / bands as f32;
352 ui.leaf(
353 ("band", index),
354 Spec {
355 flags: Flags::FLOAT,
356 size: [px(band), px(TILE[1])],
357 position: [band * index as f32, 0.0],
358 fill: Some(page_color(*color, paper, dark)),
359 ..Spec::default()
360 },
361 );
362 }
363 }
364 _ => {}
365 }
366 ui.close();
367 ui.leaf(
368 "label",
369 Spec {
370 size: [px(width), px(LABEL)],
371 text: Some(label),
372 color: Some(theme.text_dim),
373 center: true,
374 ..Spec::default()
375 },
376 );
377 ui.close();
378 ui.signal(id).clicked
379}
380
381impl crate::State {
382 /// Over a page with nothing typed in its body: the strip where the body would start,
383 /// or the gallery it opened.
384 pub(crate) fn template_strip(&mut self, theme: &Theme) {
385 let Some(session) = &self.session else {
386 return;
387 };
388 let space = session.space;
389 let editor = &self.view.editor;
390 let blank = editor
391 .visible_outlines()
392 .chain(editor.caret_outline())
393 .filter(|outline| !outline.title)
394 .all(canvas::editor::TextOutline::is_empty);
395 if !blank || session.conflict(space).is_some() || self.dismissed.contains(&space) {
396 self.templates = View::Strip;
397 return;
398 }
399 let (Some(rect), Some(start)) = (self.ui.rect(crate::page()), editor.body_start()) else {
400 return;
401 };
402 let viewport = self.view.viewport;
403 let scale = self.ui.scale();
404 let position = [
405 (viewport.origin[0] + start[0] * viewport.scale) / scale,
406 (viewport.origin[1] + (start[1] + LINES_ABOVE) * viewport.scale) / scale,
407 ];
408 let paper = Paper {
409 color: theme.paper,
410 ink: theme.paper_ink,
411 };
412 let dark = theme.paper_ink[0] > theme.paper[0];
413 let chosen = match self.templates {
414 View::Gallery => {
415 let size = [rect[2] - rect[0], rect[3] - rect[1]];
416 gallery(&mut self.ui, theme, &mut self.thumbnails, paper, dark, size)
417 }
418 view => strip(
419 &mut self.ui,
420 theme,
421 &mut self.thumbnails,
422 paper,
423 dark,
424 position,
425 view == View::Colors,
426 ),
427 };
428 match chosen {
429 Some(Choice::More) => self.templates = View::Gallery,
430 Some(Choice::Colors) => {
431 self.templates = if self.templates == View::Colors {
432 View::Strip
433 } else {
434 View::Colors
435 }
436 }
437 Some(Choice::Dismiss) if self.templates == View::Gallery => {
438 self.templates = View::Strip
439 }
440 Some(Choice::Dismiss) => {
441 self.dismissed.insert(space);
442 }
443 Some(choice) => {
444 self.templates = View::Strip;
445 self.commands.push(crate::Command::Template(choice));
446 }
447 None => {}
448 }
449 }
450}
crates/ui/Cargo.toml+1
......@@ -6,6 +6,7 @@ publish = false
66
77[dependencies]
88draw = { path = "../draw" }
9nucleo-matcher = "0.3.1"
910parley.workspace = true
1011winit = "0.30.13"
1112
crates/ui/examples/popups.rs+4-4
......@@ -1,6 +1,6 @@
11//! Renders each popup control open, in both themes, to `/tmp/ui-popups-{dark,light}.png`,
22//! and frames of lists changing to `/tmp/ui-list-{keystroke,delete}-{dark,light}.png`: a
3//! combo's list after `c` is typed into its filter, and a page list losing rows. Then times
3//! combo's list after `cou` is typed into its filter, and a page list losing rows. Then times
44//! a list of 200 000 rows.
55
66use std::{
......@@ -72,7 +72,7 @@ fn main() {
7272/// Frames a change shows at: before it, then each of the 150 ms it takes.
7373const FRAMES: [u32; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 10];
7474
75/// A font combo's list before `c` is typed into its filter and at frames after.
75/// A font combo's list before `cou` is typed into its filter and at frames after.
7676fn keystroke(
7777 theme: &Theme,
7878 device: &wgpu::Device,
......@@ -92,7 +92,7 @@ fn keystroke(
9292 };
9393 scene.open(id, Vec::new(), build);
9494 let mut panels = vec![paint(device, queue, renderer, &scene.ui)];
95 for event in typed("c") {
95 for event in typed("cou") {
9696 scene.ui.event(event);
9797 }
9898 for frame in 1..=10 {
......@@ -276,7 +276,7 @@ fn measure() {
276276 .map(|_| frame(&mut ui, &rows, &mut selected))
277277 .collect();
278278 println!(
279 "a keystroke filtering 200 000 to {}: {filtering:?} to filter, then {first:?} and {:?} a frame as rows ease",
279 "a keystroke filtering 200 000 to {}: {filtering:?} to filter, then {first:?} and {:?} a frame after",
280280 rows.count(),
281281 average(times)
282282 );
crates/ui/src/lib.rs+45-55
......@@ -213,6 +213,10 @@ pub struct Spec<'a> {
213213 pub axis: Axis,
214214 pub text: Option<&'a str>,
215215 /// The family the label is shaped in, where it has the glyphs; the interface's otherwise.
216 /// The label's size in logical pixels; the theme's otherwise.
217 pub font_size: Option<f32>,
218 /// Shapes the label semibold.
219 pub bold: bool,
216220 pub font: Option<&'a str>,
217221 /// The label's colour; the theme's text colour otherwise.
218222 pub color: Option<[f32; 4]>,
......@@ -232,7 +236,8 @@ pub struct Spec<'a> {
232236 pub shape: Shape,
233237 /// 16 px artwork before the label, tinted with the label's colour.
234238 pub icon: Option<&'static [&'static str]>,
235 /// A 16 px picture in the icon's place.
239 /// A 16 px picture in the icon's place beside a label; without one, the picture fills
240 /// the box inside its padding.
236241 pub image: Option<&'a RasterImage>,
237242 /// Inset of the label and children on each axis.
238243 pub pad: [f32; 2],
......@@ -245,11 +250,6 @@ pub struct Spec<'a> {
245250 /// Floats the box over all others beside a rectangle in the window, as a popup
246251 /// `Ui::open_popup` opened; boxes beneath take no input while one is open.
247252 pub anchor: Option<Anchor>,
248 /// How transparent the box and its contents are, from 0 to 1.
249 pub fade: f32,
250 /// The colour `fade` blends the box and its contents into instead, so boxes fading
251 /// over one another never add up.
252 pub fade_into: Option<[f32; 4]>,
253253}
254254
255255/// Input the host forwards; positions and wheel distances are logical pixels.
......@@ -283,6 +283,8 @@ pub struct Signal {
283283 pub clicked: bool,
284284 /// The primary button is held after pressing the box.
285285 pub dragging: bool,
286 /// The secondary button went down on the box, here: where its context menu opens.
287 pub context: Option<[f32; 2]>,
286288 pub focused: bool,
287289 /// For custom boxes: every event routed to the box, in order. For focused boxes:
288290 /// keys, text and composition. For scrolling boxes: the wheel.
......@@ -329,9 +331,6 @@ struct Built {
329331 position: [f32; 2],
330332 cursor: Option<CursorIcon>,
331333 anchor: Option<Anchor>,
332 /// The opacity of the box and its contents; a popup's rises as it opens.
333 alpha: f32,
334 fade_into: Option<[f32; 4]>,
335334 /// Rectangles relative to the box, painted over its fill.
336335 marks: Vec<([f32; 4], [f32; 4], f32)>,
337336 computed: [f32; 2],
......@@ -368,6 +367,8 @@ struct Popup {
368367 /// Its filter field's text, and the key of the row the keyboard or pointer last chose.
369368 query: String,
370369 highlight: Option<u64>,
370 /// The height its results ease from and to, and the seconds since they set out.
371 height: Option<[f32; 3]>,
371372}
372373
373374struct Hit {
......@@ -422,34 +423,6 @@ enum Display {
422423 },
423424}
424425
425impl Display {
426 /// Multiplies the item's opacity by `alpha`, or blends it that far into `into`.
427 fn fade(&mut self, alpha: f32, into: Option<[f32; 4]>) {
428 let fade = |color: &mut [f32; 4]| match into {
429 Some(into) => {
430 let [red, green, blue, _] = mix(into, *color, alpha);
431 *color = [red, green, blue, color[3]];
432 }
433 None => color[3] *= alpha,
434 };
435 match self {
436 Display::Rect {
437 fill,
438 shade,
439 border,
440 ..
441 } => [Some(fill), shade.as_mut(), border.as_mut()]
442 .into_iter()
443 .flatten()
444 .for_each(fade),
445 Display::Segment { color, .. } | Display::Text { color, .. } => fade(color),
446 Display::Path { colors, .. } => colors.iter_mut().for_each(fade),
447 Display::Icon { tint, .. } => fade(tint),
448 Display::Clip(_) | Display::Image { .. } | Display::Custom { .. } => {}
449 }
450 }
451}
452
453426pub struct Ui {
454427 pub theme: Theme,
455428 /// Whether the window has keyboard focus; without it a field hides its caret and dims
......@@ -484,6 +457,9 @@ pub struct Ui {
484457 /// Where the popups' painting starts in `display`, for edges drawn beneath them.
485458 popups_painted: usize,
486459 animating: bool,
460 /// This frame routed input, whose effects the builder may only have seen after boxes
461 /// built before it read their state; one more frame shows them.
462 routed: bool,
487463 /// The field showing a caret, when its blink started and the frame it last showed.
488464 caret: Option<(Id, Instant, u64)>,
489465 /// When the next timed change is due, such as a caret's blink.
......@@ -520,6 +496,7 @@ impl Ui {
520496 display: Vec::new(),
521497 popups_painted: 0,
522498 animating: false,
499 routed: false,
523500 caret: None,
524501 wake: None,
525502 }
......@@ -532,7 +509,7 @@ impl Ui {
532509
533510 /// Whether queued input or animation needs another frame.
534511 pub fn wants_frame(&self) -> bool {
535 self.animating || !self.queue.is_empty()
512 self.animating || self.routed || !self.queue.is_empty()
536513 }
537514
538515 /// When a timed change, such as a caret's blink, next needs a frame.
......@@ -548,7 +525,8 @@ impl Ui {
548525 self.modifiers
549526 }
550527
551 pub(crate) fn pointer(&self) -> Option<[f32; 2]> {
528 /// Where the pointer is, in logical pixels, while it is over the window.
529 pub fn pointer(&self) -> Option<[f32; 2]> {
552530 self.pointer
553531 }
554532
......@@ -609,6 +587,7 @@ impl Ui {
609587 self.signals.clear();
610588 self.moved = false;
611589 self.wake = None;
590 self.routed = !self.queue.is_empty();
612591 for event in std::mem::take(&mut self.queue) {
613592 self.route(event);
614593 }
......@@ -657,6 +636,11 @@ impl Ui {
657636 self.close_from(under + 1);
658637 }
659638 let target = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM);
639 if let Some(id) = target
640 && button == MouseButton::Right
641 {
642 self.signals.entry(id).or_default().context = Some(point);
643 }
660644 if let Some(id) = target
661645 && button == MouseButton::Left
662646 {
......@@ -782,6 +766,7 @@ impl Ui {
782766 focus: self.focus,
783767 query: String::new(),
784768 highlight: None,
769 height: None,
785770 });
786771 self.focus = Some(id);
787772 self.states.entry(id).or_default().touched = self.frame;
......@@ -855,7 +840,10 @@ impl Ui {
855840 pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id {
856841 let label = spec
857842 .text
858 .map(|text| self.texts.label(text, self.theme.font_size, spec.font, self.frame));
843 .map(|text| {
844 let size = spec.font_size.unwrap_or(self.theme.font_size);
845 self.texts.label(text, size, spec.bold, spec.font, self.frame)
846 });
859847 // Popups hang from the root, outside the clips and flow of where they are built.
860848 let parent = if spec.anchor.is_some() {
861849 0
......@@ -899,10 +887,16 @@ impl Ui {
899887 self.texts.use_fonts(files)
900888 }
901889
890 /// Previews `family` in the font `data` holds, as a font menu shows the substitute a
891 /// missing family lays out in.
892 pub fn preview_font(&mut self, data: parley::fontique::Blob<u8>, family: &str) {
893 self.texts.preview_font(data, family);
894 }
895
902896 /// The size of `text` as a label, in logical pixels.
903897 pub fn measure(&mut self, text: &str) -> [f32; 2] {
904898 self.texts
905 .label(text, self.theme.font_size, None, self.frame)
899 .label(text, self.theme.font_size, false, None, self.frame)
906900 .size
907901 }
908902
......@@ -924,8 +918,10 @@ impl Ui {
924918 };
925919 self.caret = Some((id, start, self.frame));
926920 let (opacity, hold) = draw::edit::caret_blink(self.now.saturating_duration_since(start));
927 let due = self.now + hold;
928 self.wake = Some(self.wake.map_or(due, |wake| wake.min(due)));
921 if let Some(hold) = hold {
922 let due = self.now + hold;
923 self.wake = Some(self.wake.map_or(due, |wake| wake.min(due)));
924 }
929925 opacity
930926 }
931927
......@@ -983,7 +979,6 @@ impl Ui {
983979 }
984980
985981 fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) {
986 let start = self.display.len();
987982 let node = &self.nodes[index];
988983 let state = &self.states[&node.id];
989984 let rect = node.rect;
......@@ -1087,9 +1082,14 @@ impl Ui {
10871082 });
10881083 x += ICON + ICON_GAP;
10891084 } else if let Some(image) = &node.image {
1085 let rect = if node.label.is_some() {
1086 [x, top, x + ICON, top + ICON]
1087 } else {
1088 [inner[0], painted[1] + node.pad[1], inner[2], painted[3] - node.pad[1]]
1089 };
10901090 self.display.push(Display::Image {
10911091 image: image.clone(),
1092 rect: [x, top, x + ICON, top + ICON],
1092 rect,
10931093 });
10941094 x += ICON + ICON_GAP;
10951095 }
......@@ -1136,14 +1136,6 @@ impl Ui {
11361136 if inner_clip != clip {
11371137 self.display.push(Display::Clip(clip));
11381138 }
1139 let Built {
1140 alpha, fade_into, ..
1141 } = self.nodes[index];
1142 if alpha < 1.0 {
1143 for item in &mut self.display[start..] {
1144 item.fade(alpha, fade_into);
1145 }
1146 }
11471139 }
11481140
11491141 /// This frame's painting in order, in logical pixels.
......@@ -1319,8 +1311,6 @@ impl Built {
13191311 position: spec.position,
13201312 cursor: spec.cursor,
13211313 anchor: spec.anchor,
1322 alpha: 1.0 - spec.fade,
1323 fade_into: spec.fade_into,
13241314 marks: Vec::new(),
13251315 computed: [0.0; 2],
13261316 relative: [0.0; 2],
crates/ui/src/list.rs+48-319
......@@ -1,18 +1,13 @@
1//! A virtualized list of equal rows keyed by their items: only rows in view are built,
2//! rows slide to where their items go, rows leaving fold into the seam their gap closes on
3//! and rows arriving unfold out of it, and the view holds its place on the selection while
4//! items arrive and leave above it.
1//! A virtualized list of equal rows keyed by their items: only rows in view are built, and
2//! the view holds its place on the selection while items arrive and leave above it.
53
64use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar};
7use std::collections::{HashMap, HashSet};
85use winit::keyboard::NamedKey;
96
107/// How far rows fade out under a scrolled list's edges.
118const EDGE: f64 = 10.0;
129/// Room beside a scrolling list's rows for its scrollbar.
1310pub(crate) const GUTTER: f32 = 12.0;
14/// Seconds rows take to reach their new places.
15const DURATION: f32 = 0.25;
1611
1712/// Items a list shows, in order.
1813pub trait Rows {
......@@ -44,11 +39,10 @@ pub struct List<'a, R> {
4439 pub hover_selects: bool,
4540}
4641
47/// A row to build: its item's key, its index while listed or None as it folds away after
48/// its item left or moved, and whether it is selected.
42/// A row to build: its item's key, its index, and whether it is selected.
4943pub struct Row {
5044 pub key: u64,
51 pub index: Option<usize>,
45 pub index: usize,
5246 pub selected: bool,
5347}
5448
......@@ -57,72 +51,12 @@ pub struct Row {
5751pub(crate) struct State {
5852 scroll: f64,
5953 target: f64,
60 /// Rows built last frame, from the top of the view down.
61 shown: Vec<Shown>,
54 /// Keys of the rows built last frame and their tops from the top of the view.
55 shown: Vec<(u64, f64)>,
6256 /// The selection last frame, so a new one scrolls into view.
6357 selected: Option<u64>,
6458}
6559
66#[derive(Clone, Copy)]
67struct Shown {
68 key: u64,
69 /// Where its row lies, from the top of the view.
70 place: f64,
71 /// None for a row folding away after its item left or moved.
72 index: Option<usize>,
73 motion: Option<Motion>,
74 /// Where it was drawn from the top of the view, and how strongly.
75 drawn: f64,
76 alpha: f32,
77}
78
79#[derive(Clone, Copy)]
80struct Motion {
81 elapsed: f32,
82 way: Way,
83}
84
85#[derive(Clone, Copy)]
86enum Way {
87 /// Slides to its place from this offset.
88 Slide(f64),
89 Fold(Fold),
90}
91
92/// A row folding into the gap its run leaves between two rows, or unfolding out of the
93/// gap its run opens, squeezed among the run and blending into the background as the gap
94/// narrows.
95#[derive(Clone, Copy)]
96struct Fold {
97 /// The gap's edges as it starts and ends, as offsets from the row's place.
98 start: [f64; 2],
99 end: [f64; 2],
100 /// The row's position in its run, and the run's length.
101 at: usize,
102 of: usize,
103 /// How strongly it was drawn as it set out.
104 alpha: f32,
105}
106
107impl Fold {
108 /// Where the row is drawn and how strongly, `progress` of the way through, never over
109 /// the `solid` rows drawn at these tops, in order. Its text keeps inside the gap, gone
110 /// before the gap is too narrow to hold it clear of the rows either side.
111 fn at(&self, place: f64, progress: f64, height: f64, solid: &[f64]) -> (f64, f32) {
112 let [top, bottom] = [0, 1]
113 .map(|side| place + self.start[side] + (self.end[side] - self.start[side]) * progress);
114 let below = solid.partition_point(|y| y + height / 2.0 <= (top + bottom) / 2.0);
115 let top = below
116 .checked_sub(1)
117 .map_or(top, |above| top.max(solid[above] + height));
118 let bottom = solid.get(below).map_or(bottom, |y| bottom.min(*y));
119 let share = (bottom - top).max(0.0) / self.of as f64;
120 let y = top + (self.at as f64 + 0.5) * share - height / 2.0;
121 let room = (2.0 * share / height - 1.0).clamp(0.0, 1.0) as f32;
122 (y, room * self.alpha)
123 }
124}
125
12660/// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves
12761/// `selected` with the list's keys. Returns the index of the row clicked.
12862pub fn list<R: Rows>(
......@@ -165,15 +99,22 @@ pub fn list<R: Rows>(
16599 let mut state = ui.lists.remove(&id).unwrap_or_default();
166100
167101 // Hold the view on the selection, or the first row showing, wherever it now lies.
168 let listed = || state.shown.iter().filter(|shown| shown.index.is_some());
169 let held = state.selected.and_then(|key| {
170 listed().find(|shown| shown.key == key && shown.place >= 0.0 && shown.place < view)
102 let held = state.selected.and_then(|selected| {
103 state
104 .shown
105 .iter()
106 .find(|(key, place)| *key == selected && (0.0..view).contains(place))
171107 });
172108 // At the top, the view stays there to show what arrives.
173109 let anchor = held
174110 .into_iter()
175 .chain(listed().filter(|shown| state.scroll > 0.0 && shown.place + height > 0.0))
176 .find_map(|shown| Some(top(rows.find(shown.key)?) - shown.place));
111 .chain(
112 state
113 .shown
114 .iter()
115 .filter(|(_, place)| state.scroll > 0.0 && place + height > 0.0),
116 )
117 .find_map(|(key, place)| Some(top(rows.find(*key)?) - place));
177118 if let Some(scroll) = anchor {
178119 state.target += scroll - state.scroll;
179120 state.scroll = scroll;
......@@ -188,17 +129,17 @@ pub fn list<R: Rows>(
188129 }
189130 }
190131 let mut clicked = None;
191 for shown in state.shown.iter().filter(|shown| shown.index.is_some()) {
192 let signal = ui.signal(id.child(shown.key));
193 let Some(index) = rows.find(shown.key).filter(|index| rows.selectable(*index)) else {
132 for (key, _) in &state.shown {
133 let signal = ui.signal(id.child(*key));
134 let Some(index) = rows.find(*key).filter(|index| rows.selectable(*index)) else {
194135 continue;
195136 };
196137 if hover_selects && signal.hovered && ui.moved {
197 *selected = Some(shown.key);
138 *selected = Some(*key);
198139 state.selected = *selected;
199140 }
200141 if signal.clicked {
201 *selected = Some(shown.key);
142 *selected = Some(*key);
202143 clicked = Some(index);
203144 }
204145 }
......@@ -248,218 +189,7 @@ pub fn list<R: Rows>(
248189 state.scroll += (state.target - state.scroll) * rate;
249190 }
250191 let scroll = state.scroll;
251 let step = scroll - anchored;
252
253 let dt = ui.dt;
254 let tick = |motion: Motion| {
255 let elapsed = motion.elapsed + dt;
256 (elapsed < DURATION).then_some(Motion { elapsed, ..motion })
257 };
258 let progress = |motion: &Motion| 1.0 - (1.0 - f64::from(motion.elapsed / DURATION)).powi(3);
259 let listed: Vec<Shown> = state
260 .shown
261 .iter()
262 .filter(|shown| shown.index.is_some())
263 .copied()
264 .collect();
265 let before: HashMap<u64, Shown> = listed.iter().map(|shown| (shown.key, *shown)).collect();
266 // The most rows that stay in their order slide; the rest would pass over them, so fold
267 // away and unfold where they go, as do rows moving while partly folded.
268 let order: Vec<(u64, usize)> = listed
269 .iter()
270 .filter_map(|shown| Some((shown.key, rows.find(shown.key)?)))
271 .collect();
272 let mut tails: Vec<usize> = Vec::new();
273 let mut previous = vec![None; order.len()];
274 for at in 0..order.len() {
275 let length = tails.partition_point(|tail| order[*tail].1 < order[at].1);
276 previous[at] = length.checked_sub(1).map(|length| tails[length]);
277 if length == tails.len() {
278 tails.push(at);
279 } else {
280 tails[length] = at;
281 }
282 }
283 let kept: HashSet<u64> = std::iter::successors(tails.last().copied(), |at| previous[*at])
284 .map(|at| order[at].0)
285 .filter(|key| before[key].alpha >= 1.0)
286 .collect();
287
288 let settled = |index: usize| {
289 let key = rows.key(index);
290 let was = top(index) - anchored;
291 let motion = before.get(&key).and_then(|old| {
292 if (old.place - was).abs() < 0.5 {
293 old.motion.and_then(tick)
294 } else {
295 tick(Motion {
296 elapsed: 0.0,
297 way: Way::Slide(old.drawn - was),
298 })
299 }
300 });
301 Shown {
302 key,
303 place: top(index) - scroll,
304 index: Some(index),
305 motion,
306 drawn: 0.0,
307 alpha: 1.0,
308 }
309 };
310 // Rows arriving where the view already was, rather than scrolled into it, unfold.
311 let arriving = |index: usize| {
312 let key = rows.key(index);
313 let was = top(index) - anchored;
314 match before.get(&key) {
315 Some(old) => (old.place - was).abs() >= 0.5 && !kept.contains(&key),
316 None => !fresh && was + height > 0.0 && was < view,
317 }
318 };
319 let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len);
320 let mut shown = Vec::new();
321 let mut index = in_view.start;
322 while index < in_view.end {
323 if !arriving(index) {
324 shown.push(settled(index));
325 index += 1;
326 continue;
327 }
328 let end = (index..in_view.end)
329 .find(|index| !arriving(*index))
330 .unwrap_or(in_view.end);
331 let [above, below] = [index.checked_sub(1), (end < len).then_some(end)]
332 .map(|side| side.map(|side| rows.key(side)));
333 let [first, last] = [top(index), top(end - 1) + height].map(|y| y - anchored);
334 // The seam opens where the rows either side were drawn, or mid-run with neither.
335 let seam = above
336 .and_then(|key| Some(before.get(&key)?.drawn + height))
337 .or_else(|| below.and_then(|key| Some(before.get(&key)?.drawn)))
338 .unwrap_or((first + last) / 2.0);
339 shown.extend((index..end).map(|at| {
340 let was = top(at) - anchored;
341 Shown {
342 motion: tick(Motion {
343 elapsed: 0.0,
344 way: Way::Fold(Fold {
345 start: [seam - was; 2],
346 end: [first - was, last - was],
347 at: at - index,
348 of: end - index,
349 alpha: 1.0,
350 }),
351 }),
352 ..settled(at)
353 }
354 }));
355 index = end;
356 }
357 for old in &listed {
358 if let Some(index) = rows
359 .find(old.key)
360 .filter(|index| !in_view.contains(index) && kept.contains(&old.key))
361 {
362 shown.push(settled(index));
363 }
364 }
365 // Rows already folding away carry on; runs of rows gone or moving out of order start.
366 let mut folding: Vec<Shown> = state
367 .shown
368 .iter()
369 .filter(|shown| shown.index.is_none())
370 .filter_map(|shown| {
371 Some(Shown {
372 place: shown.place - step,
373 motion: Some(tick(shown.motion?)?),
374 ..*shown
375 })
376 })
377 .collect();
378 let staying = |shown: &Shown| {
379 kept.contains(&shown.key)
380 || rows
381 .find(shown.key)
382 .is_some_and(|index| (top(index) - anchored - shown.place).abs() < 0.5)
383 };
384 let mut at = 0;
385 for run in listed.chunk_by(|a, b| staying(a) == staying(b)) {
386 let start = at;
387 at += run.len();
388 if staying(&run[0]) {
389 continue;
390 }
391 let above = start.checked_sub(1).map(|side| listed[side].key);
392 let below = listed.get(at).map(|side| side.key);
393 let [first, last] = [run[0].drawn, run[run.len() - 1].drawn + height];
394 // The seam the gap closes on: where the row below or above it now lies.
395 let seam = below
396 .and_then(|key| Some(top(rows.find(key)?) - anchored))
397 .or_else(|| above.and_then(|key| Some(top(rows.find(key)?) + height - anchored)))
398 .unwrap_or(first);
399 folding.extend(run.iter().enumerate().map(|(at, old)| Shown {
400 key: old.key,
401 place: old.drawn - step,
402 index: None,
403 motion: tick(Motion {
404 elapsed: 0.0,
405 way: Way::Fold(Fold {
406 start: [first - old.drawn, last - old.drawn],
407 end: [seam - old.drawn; 2],
408 at,
409 of: run.len(),
410 alpha: old.alpha,
411 }),
412 }),
413 drawn: 0.0,
414 alpha: 0.0,
415 }));
416 }
417
418 // Rows sliding or still are drawn first, as folds are bounded by them.
419 for shown in &mut shown {
420 if let Some(Motion {
421 way: Way::Slide(from),
422 ..
423 }) = shown.motion
424 {
425 shown.drawn = shown.place + from * (1.0 - progress(&shown.motion.unwrap()));
426 } else {
427 shown.drawn = shown.place;
428 }
429 }
430 let mut solid: Vec<f64> = shown
431 .iter()
432 .filter(|shown| {
433 !matches!(
434 shown.motion,
435 Some(Motion {
436 way: Way::Fold(_),
437 ..
438 })
439 )
440 })
441 .map(|shown| shown.drawn)
442 .collect();
443 solid.sort_by(f64::total_cmp);
444 for shown in shown.iter_mut().chain(&mut folding) {
445 if let Some(
446 motion @ Motion {
447 way: Way::Fold(fold),
448 ..
449 },
450 ) = shown.motion
451 {
452 (shown.drawn, shown.alpha) = fold.at(shown.place, progress(&motion), height, &solid);
453 }
454 }
455 shown.retain(|shown| {
456 shown.drawn + height > 0.0 && shown.drawn < view
457 || shown.index.is_some_and(|index| in_view.contains(&index))
458 });
459 shown.sort_by(|a, b| a.place.total_cmp(&b.place));
460 ui.animating |= state.scroll != state.target
461 || !folding.is_empty()
462 || shown.iter().any(|shown| shown.motion.is_some());
192 ui.animating |= scroll != state.target;
463193
464194 let theme = ui.theme.clone();
465195 let background = spec.fill.unwrap_or(theme.base);
......@@ -475,34 +205,35 @@ pub fn list<R: Rows>(
475205 Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER),
476206 _ => fill(),
477207 };
478 // Rows folding away paint beneath the rest, and blend into the list rather than fade,
479 // so those squeezed together never add up.
480 for shown in folding.iter().chain(&shown) {
481 let flags = match shown.index {
482 Some(index) if rows.selectable(index) => Flags::FLOAT | Flags::CLICKABLE,
483 _ => Flags::FLOAT,
484 };
485 let row_spec = Spec {
486 flags,
487 size: [width, px(row)],
488 position: [0.0, shown.drawn as f32],
489 fade: 1.0 - shown.alpha,
490 fade_into: Some(background),
491 ..Spec::default()
492 };
493 match shown.index {
494 Some(_) => ui.open(shown.key, row_spec),
495 None => ui.open(("folding", shown.key), row_spec),
208 let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len);
209 let mut shown = Vec::with_capacity(in_view.len());
210 for index in in_view {
211 let key = rows.key(index);
212 let place = top(index) - scroll;
213 let flags = if rows.selectable(index) {
214 Flags::FLOAT | Flags::CLICKABLE
215 } else {
216 Flags::FLOAT
496217 };
218 ui.open(
219 key,
220 Spec {
221 flags,
222 size: [width, px(row)],
223 position: [0.0, place as f32],
224 ..Spec::default()
225 },
226 );
497227 build(
498228 ui,
499229 Row {
500 key: shown.key,
501 index: shown.index,
502 selected: shown.index.is_some() && *selected == Some(shown.key),
230 key,
231 index,
232 selected: *selected == Some(key),
503233 },
504234 );
505235 ui.close();
236 shown.push((key, place));
506237 }
507238 // Rows fade out under the list's edges rather than end at a cut.
508239 let clear = [background[0], background[1], background[2], 0.0];
......@@ -538,15 +269,13 @@ pub fn list<R: Rows>(
538269 // Rows keep their places relative to the new offset, or next frame's hold on the
539270 // first row showing would scroll straight back.
540271 let step = f64::from(offset) - scroll;
541 for shown in shown.iter_mut().chain(&mut folding) {
542 shown.place -= step;
272 for (_, place) in &mut shown {
273 *place -= step;
543274 }
544275 state.scroll = f64::from(offset);
545276 state.target = state.scroll;
546277 }
547278 ui.close();
548 shown.extend(folding);
549 shown.sort_by(|a, b| a.place.total_cmp(&b.place));
550279 state.shown = shown;
551280 ui.lists.insert(id, state);
552281 clicked
crates/ui/src/popup.rs+78-43
......@@ -6,6 +6,11 @@ use crate::{
66 Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Popup, Row, Rows, Spec, Ui, children,
77 fill, fit, list::GUTTER, mix, px, text_field,
88};
9use nucleo_matcher::{
10 Config, Matcher, Utf32Str,
11 pattern::{AtomKind, CaseMatching, Normalization, Pattern},
12};
13use std::cell::RefCell;
914use winit::keyboard::{Key, NamedKey};
1015
1116const CHECK: &[&str] = &[include_str!("../assets/check.svg")];
......@@ -20,6 +25,12 @@ const NARROWEST: f32 = 140.0;
2025const PALETTE: f32 = 560.0;
2126/// A colour grid's cell, around its swatch.
2227const CELL: f32 = 22.0;
28/// Seconds a popup takes to ease to the height of its results.
29const RESIZE: f32 = 0.15;
30thread_local! {
31 /// Scratch space for ranking, a few hundred kilobytes, reused across frames.
32 static MATCHER: RefCell<Matcher> = RefCell::new(Matcher::new(Config::DEFAULT));
33}
2334/// The keys a menu's list takes, and Enter to choose.
2435const KEYS: [NamedKey; 7] = [
2536 NamedKey::ArrowUp,
......@@ -44,9 +55,13 @@ pub struct Item<'a> {
4455 pub shortcut: &'a str,
4556 /// Marked with a check in place of its icon.
4657 pub checked: bool,
58 /// Starts highlighted when the menu opens, as a checked item does.
59 pub current: bool,
4760 pub disabled: bool,
4861 /// Starts a group, ruled off from the one above while unfiltered.
4962 pub separated: bool,
63 /// Names the group it starts: shown only while unfiltered, never chosen.
64 pub heading: bool,
5065}
5166
5267/// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a
......@@ -160,15 +175,39 @@ fn choose(
160175 }
161176 }
162177 let matches = Matches::new(items, &query);
163 // Unfiltered, the checked item starts highlighted; filtered, the best match.
178 // Unfiltered, the current or checked item starts highlighted; filtered, the best match.
164179 highlight = highlight.or_else(|| {
165180 let first = if query.is_empty() {
166 matches.order.iter().position(|index| items[*index].checked)
181 matches.order.iter().position(|index| items[*index].current || items[*index].checked)
167182 } else {
168183 (0..matches.count()).find(|row| matches.selectable(*row))
169184 };
170185 first.map(|row| matches.key(row))
171186 });
187 let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]);
188 let field = if filter.is_some() { ROW + PAD } else { 0.0 };
189 let content = matches.count() as f32 * ROW + matches.space_before(matches.count());
190 let view = content
191 .max(ROW)
192 .min((ROWS * ROW).min(window - 4.0 * PAD - field).max(ROW));
193 // The popup eases to its new height as the results change; the rows do not move.
194 let dt = ui.dt;
195 let [from, to, elapsed] = state(ui, id).height.get_or_insert([view, view, RESIZE]);
196 *elapsed = (*elapsed + dt).min(RESIZE);
197 let height = *to - (*to - *from) * (1.0 - *elapsed / RESIZE).powi(3);
198 if *to != view {
199 [*from, *to, *elapsed] = [height, view, 0.0];
200 }
201 ui.animating |= height != view;
202 ui.open(
203 "results",
204 Spec {
205 flags: Flags::CLIP,
206 axis: Axis::Y,
207 size: [fill(), px(height)],
208 ..Spec::default()
209 },
210 );
172211 let chosen = if matches.count() == 0 {
173212 ui.leaf(
174213 "empty",
......@@ -182,10 +221,6 @@ fn choose(
182221 );
183222 None
184223 } else {
185 let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]);
186 let field = if filter.is_some() { ROW + PAD } else { 0.0 };
187 let content = matches.count() as f32 * ROW + matches.space_before(matches.count());
188 let view = content.min((ROWS * ROW).min(window - 4.0 * PAD - field).max(ROW));
189224 let list = List {
190225 rows: &matches,
191226 row: ROW,
......@@ -212,6 +247,7 @@ fn choose(
212247 })
213248 };
214249 ui.close();
250 ui.close();
215251 if let Some(row) = chosen {
216252 ui.close_popup(id);
217253 return Some(matches.order[row]);
......@@ -226,10 +262,7 @@ fn choose(
226262/// highlight when selected, under a rule when it starts a group.
227263fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
228264 let item = &matches.items[row.key as usize];
229 if row
230 .index
231 .is_some_and(|index| index > 0 && matches.ruled && item.separated)
232 {
265 if row.index > 0 && matches.ruled && item.separated {
233266 ui.leaf(
234267 "rule",
235268 Spec {
......@@ -241,7 +274,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
241274 },
242275 );
243276 }
244 let color = if item.disabled {
277 let color = if item.disabled || item.heading {
245278 theme.text_dim
246279 } else {
247280 theme.text
......@@ -283,6 +316,8 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
283316 size: [fill(), px(ROW)],
284317 text: Some(item.text),
285318 font: item.font,
319 font_size: item.heading.then_some(theme.font_size - 2.0),
320 bold: item.heading,
286321 color: Some(color),
287322 ..Spec::default()
288323 },
......@@ -485,10 +520,11 @@ fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec<NamedKey> {
485520 taken
486521}
487522
488/// The items matching a query, most relevant first, keyed by their index in `items`.
489struct Matches<'a> {
523/// The items fuzzily matching a query as fzf does, best first, keyed by their index in
524/// `items`.
525pub(crate) struct Matches<'a> {
490526 items: &'a [Item<'a>],
491 order: Vec<usize>,
527 pub(crate) order: Vec<usize>,
492528 /// Each item's row, or None when it does not match.
493529 rows: Vec<Option<usize>>,
494530 /// Rules above each row and the end, counted from the top; only while unfiltered.
......@@ -498,14 +534,32 @@ struct Matches<'a> {
498534}
499535
500536impl<'a> Matches<'a> {
501 fn new(items: &'a [Item<'a>], query: &str) -> Self {
502 let mut ranked: Vec<_> = items
503 .iter()
504 .enumerate()
505 .filter_map(|(index, item)| Some((score(query, item.text)?, index)))
506 .collect();
507 ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
508 let order: Vec<usize> = ranked.into_iter().map(|(_, index)| index).collect();
537 pub(crate) fn new(items: &'a [Item<'a>], query: &str) -> Self {
538 let pattern = Pattern::new(
539 query,
540 CaseMatching::Ignore,
541 Normalization::Smart,
542 AtomKind::Fuzzy,
543 );
544 let order: Vec<usize> = if pattern.atoms.is_empty() {
545 (0..items.len()).collect()
546 } else {
547 let mut chars = Vec::new();
548 let mut ranked: Vec<_> = MATCHER.with_borrow_mut(|matcher| {
549 items
550 .iter()
551 .enumerate()
552 .filter(|(_, item)| !item.heading)
553 .filter_map(|(index, item)| {
554 let text = Utf32Str::new(item.text, &mut chars);
555 Some((pattern.score(text, matcher)?, index))
556 })
557 .collect()
558 });
559 // Stable, so equal matches keep the items' order.
560 ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
561 ranked.into_iter().map(|(_, index)| index).collect()
562 };
509563 let mut rows = vec![None; items.len()];
510564 for (row, index) in order.iter().enumerate() {
511565 rows[*index] = Some(row);
......@@ -542,30 +596,11 @@ impl Rows for Matches<'_> {
542596 }
543597
544598 fn selectable(&self, index: usize) -> bool {
545 !self.items[self.order[index]].disabled
599 let item = &self.items[self.order[index]];
600 !item.disabled && !item.heading
546601 }
547602
548603 fn space_before(&self, index: usize) -> f32 {
549604 self.rules[index] as f32 * RULE
550605 }
551606}
552
553/// How well `text` matches `query`, ignoring case: 3 at its start, 2 at a word's, 1
554/// anywhere, 0 with the query's characters in order among others; None otherwise.
555fn score(query: &str, text: &str) -> Option<u8> {
556 let [query, text] = [query, text].map(str::to_lowercase);
557 let at_word = |(at, _): (usize, &str)| text[..at].ends_with(|c: char| !c.is_alphanumeric());
558 if text.starts_with(&query) {
559 Some(3)
560 } else if text.match_indices(&query).any(at_word) {
561 Some(2)
562 } else if text.contains(&query) {
563 Some(1)
564 } else {
565 let mut chars = text.chars();
566 query
567 .chars()
568 .all(|wanted| chars.any(|c| c == wanted))
569 .then_some(0)
570 }
571}
crates/ui/src/shell.rs+8-4
......@@ -18,7 +18,8 @@ pub const TOOL: f32 = 22.0;
1818/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next
1919/// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame
2020/// below as its outline and shadow fade in. `tabs` are names and section colours. Returns
21/// the tab clicked and the open tab's id, for `tab_base`.
21/// the tab clicked, the tab whose context menu was asked for and where, and the open tab's
22/// id, for `tab_base`.
2223pub fn section_tabs(
2324 ui: &mut Ui,
2425 part: impl Hash,
......@@ -26,7 +27,7 @@ pub fn section_tabs(
2627 active: usize,
2728 section: &Section,
2829 height: f32,
29) -> (Option<usize>, Id) {
30) -> (Option<usize>, Option<(usize, [f32; 2])>, Id) {
3031 let theme = ui.theme.clone();
3132 ui.open(
3233 part,
......@@ -46,7 +47,7 @@ pub fn section_tabs(
4647 (left - width, width)
4748 })
4849 .collect();
49 let mut clicked = None;
50 let (mut clicked, mut context) = (None, None);
5051 // Earlier tabs lie over later ones; the open tab over all.
5152 let order = (0..tabs.len())
5253 .rev()
......@@ -83,10 +84,13 @@ pub fn section_tabs(
8384 if signal.clicked && index != active {
8485 clicked = Some(index);
8586 }
87 if let Some(point) = signal.context {
88 context = Some((index, point));
89 }
8690 }
8791 let open_tab = ui.id(("tab", active));
8892 ui.close();
89 (clicked, open_tab)
93 (clicked, context, open_tab)
9094}
9195
9296/// Where the tab laid out at `tab` in a row `height` tall meets the edge below it: from its
crates/ui/src/tests.rs+307-124
......@@ -3,6 +3,12 @@ use std::time::Duration;
33use winit::keyboard::NamedKey;
44
55const DOUBLE_CLICK: Duration = Duration::from_millis(500);
6/// The key that carries shortcuts: Command on macOS, Control elsewhere.
7const SHORTCUT: ModifiersState = if cfg!(target_vendor = "apple") {
8 ModifiersState::SUPER
9} else {
10 ModifiersState::CONTROL
11};
612
713thread_local! {
814 static START: Instant = Instant::now();
......@@ -149,6 +155,42 @@ fn clicks_use_the_previous_layout_and_hover_animates() {
149155 assert!(!ui.wants_frame());
150156}
151157
158/// A click whose press and release arrive together starts an animation that a box built
159/// earlier in the frame reads: the frame that handled the click asks for the next one.
160#[test]
161fn a_click_within_one_frame_starts_an_animation_read_before_it() {
162 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
163 let mut open = false;
164 let mut width = 0.0;
165 let mut build = |ui: &mut Ui, open: &mut bool, width: &mut f32| {
166 *width = ui.animate(Id::ROOT.child("panel"), if *open { 100.0 } else { 0.0 });
167 if button(ui, "toggle", "Toggle").clicked {
168 *open = true;
169 }
170 };
171 frame(&mut ui, |ui| build(ui, &mut open, &mut width));
172 // The pointer rests on the button until its hover has faded in.
173 ui.event(Event::PointerMoved([5.0, 5.0]));
174 for _ in 0..40 {
175 frame(&mut ui, |ui| build(ui, &mut open, &mut width));
176 }
177 assert!(!ui.wants_frame());
178 let at = Instant::now();
179 for pressed in [true, false] {
180 ui.event(Event::Button {
181 button: MouseButton::Left,
182 pressed,
183 at,
184 });
185 }
186 frame(&mut ui, |ui| build(ui, &mut open, &mut width));
187 assert!(open && width == 0.0);
188 assert!(ui.wants_frame(), "the click's frame asks for another");
189 frame(&mut ui, |ui| build(ui, &mut open, &mut width));
190 frame(&mut ui, |ui| build(ui, &mut open, &mut width));
191 assert!(width > 0.0 && ui.wants_frame(), "the panel eases open");
192}
193
152194#[test]
153195fn hover_fades_its_own_colour_in_over_a_box_without_a_fill() {
154196 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
......@@ -341,7 +383,7 @@ fn focused_field(text: &str) -> (Ui, String) {
341383fn point_to(ui: &mut Ui, text: &str, index: usize) {
342384 let size = ui.theme.font_size;
343385 let (texts, frame) = ui.texts();
344 let layout = &texts.label(text, size, None, frame).layout;
386 let layout = &texts.label(text, size, false, None, frame).layout;
345387 let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream)
346388 .geometry(layout, 1.0)
347389 .x0 as f32;
......@@ -372,7 +414,7 @@ fn text_fields_edit_with_keys_and_selection() {
372414 ui.event(Event::Ime(Ime::Commit("é".into())));
373415 field_frame(&mut ui, &mut text);
374416 assert_eq!(text, "caé");
375 ui.event(Event::Modifiers(ModifiersState::SUPER));
417 ui.event(Event::Modifiers(SHORTCUT));
376418 ui.event(typed("a"));
377419 field_frame(&mut ui, &mut text);
378420 ui.event(Event::Modifiers(ModifiersState::empty()));
......@@ -383,6 +425,7 @@ fn text_fields_edit_with_keys_and_selection() {
383425}
384426
385427#[test]
428#[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")]
386429fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() {
387430 let (mut ui, mut text) = focused_field("one");
388431 let start = Instant::now();
......@@ -430,6 +473,7 @@ fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() {
430473}
431474
432475#[test]
476#[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")]
433477fn text_fields_move_and_select_by_word() {
434478 let (mut ui, mut text) = focused_field("one two three");
435479 ui.event(key(NamedKey::End));
......@@ -449,6 +493,48 @@ fn text_fields_move_and_select_by_word() {
449493}
450494
451495#[test]
496#[cfg_attr(
497 target_vendor = "apple",
498 ignore = "GTK and Windows editing conventions"
499)]
500fn text_fields_follow_gtk_and_windows_chords() {
501 let (mut ui, mut text) = focused_field("one two three");
502 ui.event(key(NamedKey::End));
503 ui.event(Event::Modifiers(ModifiersState::CONTROL));
504 ui.event(key(NamedKey::ArrowLeft));
505 ui.event(key(NamedKey::ArrowLeft));
506 field_frame(&mut ui, &mut text);
507 ui.event(Event::Modifiers(
508 ModifiersState::CONTROL | ModifiersState::SHIFT,
509 ));
510 ui.event(key(NamedKey::ArrowRight));
511 field_frame(&mut ui, &mut text);
512 ui.event(Event::Modifiers(ModifiersState::empty()));
513 ui.event(typed("2"));
514 field_frame(&mut ui, &mut text);
515 assert_eq!(text, "one 2 three");
516 ui.event(Event::Modifiers(ModifiersState::CONTROL));
517 ui.event(key(NamedKey::Backspace));
518 field_frame(&mut ui, &mut text);
519 assert_eq!(text, "one three");
520 ui.event(Event::Modifiers(ModifiersState::empty()));
521 ui.event(key(NamedKey::Home));
522 ui.event(typed("<"));
523 ui.event(key(NamedKey::End));
524 ui.event(typed(">"));
525 field_frame(&mut ui, &mut text);
526 assert_eq!(text, "<one three>");
527 ui.event(Event::Modifiers(ModifiersState::CONTROL));
528 ui.event(typed("a"));
529 field_frame(&mut ui, &mut text);
530 ui.event(Event::Modifiers(ModifiersState::empty()));
531 ui.event(key(NamedKey::Delete));
532 field_frame(&mut ui, &mut text);
533 assert_eq!(text, "");
534}
535
536#[test]
537#[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")]
452538fn text_fields_delete_by_word_and_line_chords() {
453539 let (mut ui, mut text) = focused_field("one two three");
454540 ui.event(key(NamedKey::End));
......@@ -674,14 +760,6 @@ fn popups_show_at_once_by_their_anchor() {
674760 ui.open_popup(menu_id());
675761 menu_frame(&mut ui, BELOW, None);
676762 menu_frame(&mut ui, BELOW, None);
677 let alpha = |ui: &Ui| {
678 ui.nodes
679 .iter()
680 .find(|node| node.id == menu_id())
681 .unwrap()
682 .alpha
683 };
684 assert_eq!(alpha(&ui), 1.0);
685763 assert_eq!(
686764 ui.rect(menu_id()).unwrap()[1],
687765 44.0,
......@@ -692,7 +770,6 @@ fn popups_show_at_once_by_their_anchor() {
692770 ui.open_popup(menu_id());
693771 menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None);
694772 menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None);
695 assert_eq!(alpha(&ui), 1.0);
696773 assert_eq!(ui.rect(menu_id()).unwrap()[..2], [50.0, 50.0]);
697774}
698775
......@@ -784,6 +861,47 @@ fn the_pointer_moves_the_highlight_only_when_it_moves() {
784861 );
785862}
786863
864#[test]
865fn headings_are_never_chosen_and_hide_while_filtered() {
866 let mut ui = open_menu(Some("Filter"));
867 let mut items = items();
868 items.insert(
869 3,
870 popup::Item {
871 text: "Selection",
872 heading: true,
873 separated: true,
874 ..popup::Item::default()
875 },
876 );
877 items[4].separated = false;
878 let build = |ui: &mut Ui| {
879 let mut chosen = None;
880 frame(ui, |ui| {
881 chosen = popup::menu(ui, menu_id(), BELOW, &items, Some("Filter"));
882 });
883 chosen
884 };
885 build(&mut ui);
886 ui.event(key(NamedKey::End));
887 build(&mut ui);
888 ui.event(key(NamedKey::ArrowUp));
889 build(&mut ui);
890 assert_eq!(
891 ui.popups[0].highlight,
892 Some(1),
893 "keys skip the heading and Paste"
894 );
895 ui.event(typed("sel"));
896 build(&mut ui);
897 ui.event(key(NamedKey::Enter));
898 assert_eq!(
899 build(&mut ui),
900 Some(4),
901 "the query matches the item, not its heading"
902 );
903}
904
787905#[test]
788906fn typing_filters_to_the_best_matches_first() {
789907 let mut ui = open_menu(Some("Filter"));
......@@ -808,10 +926,14 @@ fn typing_filters_to_the_best_matches_first() {
808926 shown.sort();
809927 shown.into_iter().map(|(_, item)| item).collect::<Vec<_>>()
810928 };
811 assert_eq!(shown(&ui), [3, 0], "letters together before letters apart");
929 assert_eq!(
930 shown(&ui),
931 [0, 3],
932 "a word's start before the middle of one"
933 );
812934 ui.event(key(NamedKey::Backspace));
813935 settle(&mut ui);
814 assert_eq!(shown(&ui), [0, 1, 3], "a prefix before a word's start");
936 assert_eq!(shown(&ui), [0, 1, 3], "equal matches keep their order");
815937 ui.event(key(NamedKey::ArrowDown));
816938 ui.event(key(NamedKey::Enter));
817939 assert_eq!(menu_frame(&mut ui, BELOW, Some("Filter")).1, Some(1));
......@@ -1062,117 +1184,6 @@ fn lists_hold_the_selection_still_as_items_arrive_and_leave_above() {
10621184 assert_eq!(row_top(&ui, 500), Some(at - 60.0));
10631185}
10641186
1065fn alpha(ui: &Ui, key: u64) -> Option<f32> {
1066 let id = list_id().child(key);
1067 ui.nodes
1068 .iter()
1069 .find(|node| node.id == id)
1070 .map(|node| node.alpha)
1071}
1072
1073fn folding(ui: &Ui, key: u64) -> Option<f32> {
1074 let id = list_id().child(("folding", key));
1075 ui.nodes
1076 .iter()
1077 .find(|node| node.id == id)
1078 .map(|node| node.alpha)
1079}
1080
1081#[test]
1082fn rows_slide_while_the_rest_fold_away_and_unfold_at_once() {
1083 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1084 let mut selected = None;
1085 let rows = Keyed::new(0..20);
1086 settle_list(&mut ui, &rows, &mut selected);
1087 let rows = Keyed::new([5, 0, 1, 2, 3, 100, 6, 7, 8, 9, 10, 11]);
1088 list_frame(&mut ui, &rows, &mut selected, &[]);
1089 let sliding = row_top(&ui, 0).unwrap();
1090 assert!(
1091 sliding > 0.0 && sliding < LIST_ROW,
1092 "sliding at once: {sliding}"
1093 );
1094 for key in [4, 5] {
1095 let alpha = folding(&ui, key).unwrap();
1096 assert!(alpha > 0.0 && alpha < 1.0, "{key} folds where it was");
1097 }
1098 assert!(row_top(&ui, 5).unwrap() < 0.0, "out of the seam at the top");
1099 for _ in 0..4 {
1100 list_frame(&mut ui, &rows, &mut selected, &[]);
1101 }
1102 for key in [5, 100] {
1103 let alpha = alpha(&ui, key).unwrap();
1104 assert!(alpha > 0.0 && alpha < 1.0, "{key} unfolds where it goes");
1105 }
1106 for _ in 0..11 {
1107 list_frame(&mut ui, &rows, &mut selected, &[]);
1108 }
1109 assert_eq!(row_top(&ui, 5), Some(0.0), "there in about 250 ms");
1110 assert_eq!(row_top(&ui, 0), Some(LIST_ROW));
1111 assert_eq!(row_top(&ui, 100), Some(5.0 * LIST_ROW));
1112 assert_eq!(alpha(&ui, 100), Some(1.0));
1113 assert_eq!((folding(&ui, 4), folding(&ui, 5)), (None, None));
1114 assert!(!ui.wants_frame());
1115}
1116
1117/// Two rows whose text is drawn over each other: their labels, centred in each row, meet
1118/// within the view while both can be seen and one is fully drawn. Rows folding together,
1119/// both blended into the background, may meet.
1120fn text_overlaps(ui: &mut Ui) -> Option<String> {
1121 let text = ui.measure("Ag")[1];
1122 let list = ui.nodes.iter().position(|node| node.id == list_id())?;
1123 let rows: Vec<_> = ui
1124 .nodes
1125 .iter()
1126 // Below half an 8-bit step, a row changes no pixel.
1127 .filter(|node| {
1128 node.parent == list && node.size[1] == px(LIST_ROW) && node.alpha * 255.0 >= 0.5
1129 })
1130 .map(|node| {
1131 let middle = (node.rect[1] + node.rect[3]) / 2.0;
1132 let [top, bottom] =
1133 [middle - text / 2.0, middle + text / 2.0].map(|y| y.clamp(0.0, VIEW));
1134 (top, bottom, node.alpha)
1135 })
1136 .collect();
1137 rows.iter().enumerate().find_map(|(at, a)| {
1138 rows[at + 1..]
1139 .iter()
1140 .find(|b| a.0 < b.1 && b.0 < a.1 && a.2.max(b.2) >= 1.0)
1141 .map(|b| format!("{a:?} over {b:?}"))
1142 })
1143}
1144
1145#[test]
1146fn rows_never_draw_text_over_each_other() {
1147 let orders: [Vec<u64>; 7] = [
1148 (0..30).collect(),
1149 (4..12).chain([2, 3, 16]).chain(12..16).collect(),
1150 (0..30).rev().collect(),
1151 [7, 3, 12, 0, 1, 25, 26, 2, 4, 5].into(),
1152 (0..30)
1153 .filter(|key| !(3..6).contains(key) && *key != 8)
1154 .collect(),
1155 (0..3).chain(40..44).chain(3..30).collect(),
1156 [1, 0, 3, 2, 5, 4, 7, 6, 9, 8, 50].into(),
1157 ];
1158 for (at, order) in orders.iter().enumerate() {
1159 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1160 let mut selected = None;
1161 settle_list(&mut ui, &Keyed::new(0..30), &mut selected);
1162 // Each change, and another landing half-way through it.
1163 let next = &orders[(at + 1) % orders.len()];
1164 for (frame, rows) in (0..30).map(|frame| {
1165 let order = if frame < 5 { order } else { next };
1166 (frame, Keyed::new(order.iter().copied()))
1167 }) {
1168 list_frame(&mut ui, &rows, &mut selected, &[]);
1169 if let Some(overlap) = text_overlaps(&mut ui) {
1170 panic!("order {at}, frame {frame}: {overlap}");
1171 }
1172 }
1173 }
1174}
1175
11761187#[test]
11771188fn keys_move_the_selection_and_the_view_eases_after_it() {
11781189 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
......@@ -1246,3 +1257,175 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() {
12461257 top
12471258 );
12481259}
1260
1261const FONTS: [&str; 46] = [
1262 "American Typewriter",
1263 "Andale Mono",
1264 "Apple Braille",
1265 "Apple Chancery",
1266 "Arial",
1267 "Arial Black",
1268 "Arial Hebrew",
1269 "Arial Narrow",
1270 "Arial Rounded MT Bold",
1271 "Arial Unicode MS",
1272 "Avenir",
1273 "Baskerville",
1274 "Big Caslon",
1275 "Brush Script MT",
1276 "Chalkboard",
1277 "Charter",
1278 "Cochin",
1279 "Comic Sans MS",
1280 "Courier",
1281 "Courier New",
1282 "Didot",
1283 "Futura",
1284 "Geneva",
1285 "Georgia",
1286 "Gill Sans",
1287 "Helvetica",
1288 "Helvetica Neue",
1289 "Herculanum",
1290 "Hoefler Text",
1291 "Impact",
1292 "Lucida Grande",
1293 "Marker Felt",
1294 "Menlo",
1295 "Monaco",
1296 "Optima",
1297 "Palatino",
1298 "Papyrus",
1299 "Rockwell",
1300 "Savoye LET",
1301 "Skia",
1302 "Tahoma",
1303 "Times",
1304 "Times New Roman",
1305 "Trebuchet MS",
1306 "Verdana",
1307 "Zapfino",
1308];
1309
1310fn fonts() -> Vec<popup::Item<'static>> {
1311 FONTS
1312 .iter()
1313 .map(|text| popup::Item {
1314 text,
1315 ..popup::Item::default()
1316 })
1317 .collect()
1318}
1319
1320fn ranked(items: &[popup::Item], query: &str) -> Vec<&'static str> {
1321 let order = popup::Matches::new(items, query).order;
1322 order.into_iter().map(|index| FONTS[index]).collect()
1323}
1324
1325#[test]
1326fn fuzzy_matches_rank_whole_words_first_and_ties_keep_their_order() {
1327 let fonts = fonts();
1328 let family = [
1329 "Arial",
1330 "Arial Black",
1331 "Arial Hebrew",
1332 "Arial Narrow",
1333 "Arial Rounded MT Bold",
1334 "Arial Unicode MS",
1335 ];
1336 assert_eq!(ranked(&fonts, "Arial"), family);
1337 assert_eq!(ranked(&fonts, "rial"), family, "case aside, mid-word");
1338 let ari = ranked(&fonts, "ari");
1339 assert_eq!(ari[..6], family, "letters together before letters apart");
1340 assert_eq!(
1341 ari[6..],
1342 ["American Typewriter", "Apple Braille", "Baskerville"]
1343 );
1344 assert_eq!(
1345 ranked(&fonts, "tnr"),
1346 ["Times New Roman"],
1347 "words' initials"
1348 );
1349 assert_eq!(
1350 ranked(&fonts, "ar bl")[0],
1351 "Arial Black",
1352 "words in any order"
1353 );
1354 assert_eq!(
1355 ranked(&fonts, "ms"),
1356 [
1357 "Arial Unicode MS",
1358 "Comic Sans MS",
1359 "Trebuchet MS",
1360 "Times",
1361 "Times New Roman"
1362 ]
1363 );
1364 assert!(ranked(&fonts, "Cafe").is_empty());
1365}
1366
1367#[test]
1368fn typing_more_never_reveals_what_a_shorter_query_filtered_out() {
1369 let fonts = fonts();
1370 for query in [
1371 "arial black",
1372 "times new roman",
1373 "helvetica neue",
1374 "comic sans ms",
1375 "tnr",
1376 ] {
1377 let mut shown = ranked(&fonts, "");
1378 for end in 1..=query.len() {
1379 let narrower = ranked(&fonts, &query[..end]);
1380 for font in &narrower {
1381 assert!(shown.contains(font), "{:?} revealed {font}", &query[..end]);
1382 }
1383 shown = narrower;
1384 }
1385 assert!(!shown.is_empty(), "{query}");
1386 }
1387}
1388
1389#[test]
1390fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() {
1391 let fonts = fonts();
1392 let build = |ui: &mut Ui| {
1393 frame(ui, |ui| {
1394 popup::menu(ui, menu_id(), BELOW, &fonts, Some("Font"));
1395 })
1396 };
1397 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
1398 build(&mut ui);
1399 ui.open_popup(menu_id());
1400 build(&mut ui);
1401 build(&mut ui);
1402 let results = menu_id().child("results");
1403 let height = |ui: &Ui| {
1404 let rect = ui.rect(results).unwrap();
1405 rect[3] - rect[1]
1406 };
1407 let tall = height(&ui);
1408 assert!(
1409 !ui.wants_frame() && tall > 5.0 * 26.0,
1410 "open at its full height"
1411 );
1412 ui.event(typed("tnr"));
1413 build(&mut ui);
1414 build(&mut ui);
1415 let row = ui.rect(menu_id().child("rows").child(42_u64)).unwrap();
1416 assert_eq!(
1417 row[1],
1418 ui.rect(results).unwrap()[1],
1419 "Times New Roman at the top"
1420 );
1421 let easing = height(&ui);
1422 assert!(easing > 26.0 && easing < tall, "{easing}");
1423 let mut frames = 0;
1424 while ui.wants_frame() {
1425 build(&mut ui);
1426 frames += 1;
1427 assert_eq!(ui.rect(menu_id().child("rows").child(42_u64)), Some(row));
1428 }
1429 assert_eq!(height(&ui), 26.0);
1430 assert!(frames <= 10, "settles in {frames} frames");
1431}
crates/ui/src/text.rs+30-6
......@@ -11,7 +11,7 @@ pub(crate) struct Label {
1111 pub size: [f32; 2],
1212}
1313
14type LabelKey = (String, u32, Option<String>);
14type LabelKey = (String, u32, bool, Option<String>);
1515
1616/// Labels shaped this frame or the previous one, keyed by their text and size.
1717#[derive(Default)]
......@@ -20,14 +20,21 @@ pub(crate) struct Texts {
2020 context: LayoutContext<()>,
2121 /// A family registered in place of the system's interface font.
2222 family: Option<String>,
23 /// By text, size and preferred family, with the frame each was last used.
23 /// By text, size, weight and preferred family, with the frame each was last used.
2424 cache: HashMap<LabelKey, (Rc<Label>, u64)>,
2525}
2626
2727impl Texts {
2828 /// `size` is in logical pixels; `font` names a family to prefer over the interface's.
29 pub fn label(&mut self, text: &str, size: f32, font: Option<&str>, frame: u64) -> Rc<Label> {
30 let key = (text.to_owned(), size.to_bits(), font.map(str::to_owned));
29 pub fn label(
30 &mut self,
31 text: &str,
32 size: f32,
33 bold: bool,
34 font: Option<&str>,
35 frame: u64,
36 ) -> Rc<Label> {
37 let key = (text.to_owned(), size.to_bits(), bold, font.map(str::to_owned));
3138 if let Some((label, touched)) = self.cache.get_mut(&key) {
3239 *touched = frame;
3340 return label.clone();
......@@ -35,17 +42,22 @@ impl Texts {
3542 let mut builder = self
3643 .context
3744 .ranged_builder(&mut self.fonts, text, 1.0, false);
38 let system = FontFamilyName::Generic(GenericFamily::SystemUi);
45 // Fontconfig configurations without a system-ui alias still have a sans-serif.
46 let system =
47 [GenericFamily::SystemUi, GenericFamily::SansSerif].map(FontFamilyName::Generic);
3948 let families: Vec<_> = font
4049 .into_iter()
4150 .chain(self.family.as_deref())
4251 .map(FontFamilyName::named)
43 .chain([system])
52 .chain(system)
4453 .collect();
4554 builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned(
4655 families,
4756 ))));
4857 builder.push_default(StyleProperty::FontSize(size));
58 if bold {
59 builder.push_default(StyleProperty::FontWeight(parley::FontWeight::SEMI_BOLD));
60 }
4961 let mut layout = builder.build(text);
5062 layout.break_all_lines(None);
5163 let size = [layout.full_width(), layout.height()];
......@@ -68,6 +80,17 @@ impl Texts {
6880 Some(family)
6981 }
7082
83 pub fn preview_font(&mut self, data: Blob<u8>, family: &str) {
84 self.fonts.collection.register_fonts(
85 data,
86 Some(parley::fontique::FontInfoOverride {
87 family_name: Some(family),
88 ..Default::default()
89 }),
90 );
91 self.cache.clear();
92 }
93
7194 pub fn prune(&mut self, frame: u64) {
7295 self.cache.retain(|_, (_, touched)| *touched + 1 >= frame);
7396 }
......@@ -88,6 +111,7 @@ impl Glyphs for Label {
88111 0.0,
89112 [metrics.block_min_coord, metrics.line_height],
90113 |_| None,
114 None,
91115 paint,
92116 )?;
93117 }
crates/ui/src/theme.rs+18-6
......@@ -33,6 +33,16 @@ pub struct Theme {
3333 pub shades: Shades,
3434}
3535
36/// The platform's caret and selection colours in a light or `dark` appearance.
37fn text_colors(dark: bool) -> [[f32; 4]; 3] {
38 draw::edit::Platform::CURRENT
39 .text_colors(dark)
40 .map(|([red, green, blue], alpha)| {
41 let [red, green, blue, _] = srgb(red, green, blue);
42 [red, green, blue, alpha]
43 })
44}
45
3646/// Saturation and lightness pairs, each from 0 to 1.
3747#[derive(Clone, Copy, Debug)]
3848pub struct Shades {
......@@ -58,15 +68,16 @@ pub struct Section {
5868impl Theme {
5969 /// After File Pilot's default dark scheme.
6070 pub fn dark() -> Self {
71 let [caret, selection, inactive_selection] = text_colors(true);
6172 Self {
6273 font_size: 13.0,
6374 base: srgb(0x19, 0x1b, 0x1c),
6475 panel: srgb(0x1f, 0x22, 0x23),
6576 strip: srgb(0x27, 0x2a, 0x2b),
6677 accent: srgb(0x00, 0x79, 0xa6),
67 caret: srgb(0x00, 0x7a, 0xff),
68 selection: srgb(0x3f, 0x63, 0x8b),
69 inactive_selection: srgb(0x46, 0x46, 0x46),
78 caret,
79 selection,
80 inactive_selection,
7081 text: srgb(0xdd, 0xde, 0xe0),
7182 text_dim: srgb(0x6b, 0x70, 0x78),
7283 ink: srgb(0xe8, 0xe9, 0xeb),
......@@ -85,15 +96,16 @@ impl Theme {
8596 }
8697
8798 pub fn light() -> Self {
99 let [caret, selection, inactive_selection] = text_colors(false);
88100 Self {
89101 font_size: 13.0,
90102 base: srgb(0xfc, 0xfc, 0xfd),
91103 panel: srgb(0xf4, 0xf5, 0xf7),
92104 strip: srgb(0xeb, 0xed, 0xf0),
93105 accent: srgb(0x00, 0x79, 0xa6),
94 caret: srgb(0x00, 0x7a, 0xff),
95 selection: srgb(0xb3, 0xd7, 0xff),
96 inactive_selection: srgb(0xdc, 0xdc, 0xdc),
106 caret,
107 selection,
108 inactive_selection,
97109 text: srgb(0x1f, 0x23, 0x28),
98110 text_dim: srgb(0x7b, 0x82, 0x8c),
99111 ink: srgb(0x1d, 0x1e, 0x20),
crates/ui/src/widgets.rs+12-4
......@@ -112,7 +112,7 @@ pub fn text_field(
112112 };
113113 let before = [selection.anchor(), selection.focus()];
114114 let (texts, frame) = ui.texts();
115 let mut label = texts.label(text, size, None, frame);
115 let mut label = texts.label(text, size, false, None, frame);
116116 if let Some(x) = pointer {
117117 let layout = &label.layout;
118118 let unit = if signal.pressed { signal.unit } else { press.1 };
......@@ -143,9 +143,11 @@ pub fn text_field(
143143 Event::Ime(Ime::Commit(committed)) => (selection.text_range(), committed.as_str()),
144144 Event::Key { key, text: typed } => {
145145 let key = edit_key(key);
146 // One line has no break for Control-K to join, nor room for Home and End to scroll.
146 // One line has no break for Control-K to join, nor room for Home, End and the
147 // page keys to scroll.
147148 let command = match (Command::from_key(&key, modifiers), &key) {
148149 (Some(Command::Kill), _) => Some(Command::DeleteTo(Movement::LineEnd)),
150 (Some(Command::ScrollPage { .. } | Command::MovePage { .. }), _) => None,
149151 (None, edit::Key::Named(edit::NamedKey::Home)) => {
150152 Some(Command::Move(Movement::DocumentStart))
151153 }
......@@ -198,7 +200,7 @@ pub fn text_field(
198200 _ => continue,
199201 };
200202 text.replace_range(range.clone(), inserted);
201 label = texts.label(text, size, None, frame);
203 label = texts.label(text, size, false, None, frame);
202204 selection = Selection::from_byte_index(
203205 &label.layout,
204206 range.start + inserted.len(),
......@@ -284,6 +286,8 @@ pub fn edit_key(key: &Key) -> edit::Key {
284286 NamedKey::ArrowDown => Edit::ArrowDown,
285287 NamedKey::Home => Edit::Home,
286288 NamedKey::End => Edit::End,
289 NamedKey::PageUp => Edit::PageUp,
290 NamedKey::PageDown => Edit::PageDown,
287291 NamedKey::Alt
288292 | NamedKey::AltGraph
289293 | NamedKey::Control
......@@ -301,6 +305,10 @@ pub fn edit_modifiers(modifiers: ModifiersState) -> edit::Modifiers {
301305 shift: modifiers.shift_key(),
302306 control: modifiers.control_key(),
303307 option: modifiers.alt_key(),
304 command: modifiers.super_key(),
308 command: if edit::Platform::CURRENT == edit::Platform::MacOs {
309 modifiers.super_key()
310 } else {
311 modifiers.control_key()
312 },
305313 }
306314}
docs/sample-notebook/Personal/Garden.one created
Binary files /dev/null and b/docs/sample-notebook/Personal/Garden.one differ
docs/sample-notebook/Personal/Home.one created
Binary files /dev/null and b/docs/sample-notebook/Personal/Home.one differ
docs/sample-notebook/Personal/Journal.one created
Binary files /dev/null and b/docs/sample-notebook/Personal/Journal.one differ
docs/sample-notebook/Personal/Kitchen.one created
Binary files /dev/null and b/docs/sample-notebook/Personal/Kitchen.one differ
docs/sample-notebook/Personal/Open Notebook.onetoc2 created
Binary files /dev/null and b/docs/sample-notebook/Personal/Open Notebook.onetoc2 differ
docs/sample-notebook/Personal/Reading.one created
Binary files /dev/null and b/docs/sample-notebook/Personal/Reading.one differ
docs/sample-notebook/Personal/Trips.one created
Binary files /dev/null and b/docs/sample-notebook/Personal/Trips.one differ
docs/screenshot-dark.png created
Binary files /dev/null and b/docs/screenshot-dark.png differ
docs/screenshot-light.png created
Binary files /dev/null and b/docs/screenshot-light.png differ
fuzz/fuzz_targets/canvas_editor.rs+1
......@@ -233,6 +233,7 @@ fuzz_target!(|input: &[u8]| {
233233 identity: None,
234234 created: date.map(|date| date.timestamp()),
235235 margin_origin: [0.0; 2],
236 color: None,
236237 definitions: BTreeMap::new(),
237238 objects,
238239 },
fuzz/fuzz_targets/commit.rs+41-35
......@@ -1,58 +1,51 @@
11#![no_main]
22use libfuzzer_sys::fuzz_target;
3use onestore::{CommitState, ExGuid, ObjectData, PropertySets, RevisionIndex, Store, Value};
3use onestore::{
4 CommitState, ExGuid, RevisionIndex, Store,
5 document::{Document, Kind},
6 op::{Op, PageOp},
7};
48use std::sync::LazyLock;
59
610#[path = "../../crates/onestore/tests/support/disk.rs"]
711mod disk;
12#[path = "../../crates/onestore/tests/support/ops.rs"]
13mod ops;
814use disk::Disk;
915
1016const SOURCES: [&[u8]; 2] = [
1117 include_bytes!("../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one"),
1218 include_bytes!("../../corpus/append/round-01/tx-255/notebook/synthetic.one"),
1319];
14static TARGET: LazyLock<(ExGuid, ExGuid, u32)> = LazyLock::new(|| {
20static TARGET: LazyLock<(ExGuid, ExGuid)> = LazyLock::new(|| {
1521 let store = Store::parse(SOURCES[0]).unwrap();
1622 let index = RevisionIndex::parse(&store).unwrap();
17 for (osid, space) in &index.spaces {
18 let revision = index
19 .resolve(*osid, space.labels[&(ExGuid::default(), 1)])
20 .unwrap();
21 for (oid, object) in revision.objects {
22 if let ObjectData::Properties(blob) = object.data {
23 for property in &PropertySets::parse(blob).unwrap().sets[0] {
24 if property.value == Value::Bytes(b"Fictitious plain text.") {
25 return (*osid, oid, property.id);
26 }
27 }
23 let document = Document::parse(&index).unwrap();
24 for (sid, space) in &document.spaces {
25 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
26 for (oid, node) in &revision.nodes {
27 if matches!(&node.kind, Kind::RichText { text, .. } if text == "Fictitious plain text.")
28 {
29 return (*sid, *oid);
2830 }
2931 }
3032 }
31 panic!("Missing native seed property")
33 panic!("Missing native seed text")
3234});
3335
34fn current(bytes: &[u8]) -> Vec<u8> {
36fn current(bytes: &[u8]) -> String {
3537 let store = Store::parse(bytes).unwrap();
3638 assert!(store.checksum_mismatches.is_empty());
3739 let index = RevisionIndex::parse(&store).unwrap();
3840 index.validate_current().unwrap();
39 let (osid, oid, property) = *TARGET;
40 let revision = index
41 .resolve(osid, index.spaces[&osid].labels[&(ExGuid::default(), 1)])
42 .unwrap();
43 let ObjectData::Properties(blob) = revision.objects[&oid].data else {
41 let document = Document::parse(&index).unwrap();
42 let (sid, oid) = *TARGET;
43 let space = &document.spaces[&sid];
44 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
45 let Kind::RichText { text, .. } = &revision.nodes[&oid].kind else {
4446 panic!()
4547 };
46 let properties = PropertySets::parse(blob).unwrap();
47 let Value::Bytes(value) = properties.sets[0]
48 .iter()
49 .find(|p| p.id == property)
50 .unwrap()
51 .value
52 else {
53 panic!()
54 };
55 value.to_vec()
48 text.clone()
5649}
5750
5851fuzz_target!(|input: &[u8]| {
......@@ -60,11 +53,25 @@ fuzz_target!(|input: &[u8]| {
6053 return;
6154 }
6255 let mut source = SOURCES[usize::from(input[0].is_multiple_of(4))].to_vec();
63 let (osid, oid, property) = *TARGET;
56 let (sid, oid) = *TARGET;
6457 for step in 0..=input[1] % 4 {
65 let mut value = input[12..].to_vec();
66 value.push(step);
58 let value: String = input[12..]
59 .iter()
60 .chain([&step])
61 .map(|byte| char::from(b'a' + byte % 26))
62 .collect();
6763 let before = current(&source);
64 let op = PageOp::Text {
65 text: oid,
66 range: 0..before.encode_utf16().count() as u32,
67 with: value.clone(),
68 };
69 let Some(transaction) =
70 ops::transaction(&source, "Fuzz", vec![Op::Page { space: sid, op }]).unwrap()
71 else {
72 assert_eq!(before, value);
73 continue;
74 };
6875 let mut storage = Disk {
6976 visible: source.clone(),
7077 durable: source.clone(),
......@@ -73,8 +80,7 @@ fuzz_target!(|input: &[u8]| {
7380 write_limit: usize::from(input[4]) + 1,
7481 random: u64::from_le_bytes(input[4..12].try_into().unwrap()),
7582 };
76 let result =
77 onestore::commit_property_bytes(&mut storage, &source, osid, oid, property, &value);
83 let result = transaction.commit(&mut storage);
7884 let persisted = current(&storage.durable);
7985 match result {
8086 Ok(()) => assert_eq!(persisted, value),
fuzz/fuzz_targets/edit_text.rs+21-17
......@@ -10,7 +10,10 @@ use std::sync::LazyLock;
1010mod checkpoint;
1111#[path = "../../crates/onestore/tests/support/disk.rs"]
1212mod disk;
13#[path = "../../crates/onestore/tests/support/ops.rs"]
14mod ops;
1315use disk::Disk;
16use onestore::op::{Op, PageOp};
1417
1518const SOURCE: &[u8] = include_bytes!(
1619 "../../corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one"
......@@ -58,7 +61,7 @@ static CASES: LazyLock<[(Vec<u8>, ExGuid, ExGuid); 7]> = LazyLock::new(|| {
5861 panic!("Missing text fixture")
5962 });
6063 let checkpoint = (
61 checkpoint::pending(&ordinary.0, ordinary.1, ordinary.2, 0x14001d7a),
64 checkpoint::pending(&ordinary.0, ordinary.1, ordinary.2),
6265 ordinary.1,
6366 ordinary.2,
6467 );
......@@ -166,29 +169,30 @@ fuzz_target!(|input: &[u8]| {
166169 visible: persisted_source.clone(),
167170 durable: persisted_source.clone(),
168171 operation: 0,
169 fail_at: (step[3] & 1 != 0)
170 .then_some(source.len().div_ceil(193) + usize::from(step[4]) + 1),
172 fail_at: (step[3] & 1 != 0).then_some(usize::from(step[4]) + 1),
171173 write_limit: usize::from(step[5]) + 1,
172174 random: u64::from_le_bytes(step.try_into().unwrap()),
173175 };
174 let result = onestore::commit_text(
175 &mut storage,
176 source,
177 sid,
178 oid,
179 offset..offset + removed,
180 replacement,
181 );
182 let persisted = characters(&storage.durable, sid, oid);
176 let op = PageOp::Text {
177 text: oid,
178 range: offset..offset + removed,
179 with: replacement.to_owned(),
180 };
181 let transaction = ops::transaction(source, "Fuzz", vec![Op::Page { space: sid, op }]);
183182 if !replacement.contains(['\n', '\0']) {
184183 assert!(
185 result
186 .as_ref()
187 .err()
188 .is_none_or(|error| error.error.kind() != std::io::ErrorKind::InvalidData),
189 "Valid ordinary text edit was rejected: {result:?}"
184 transaction.is_ok(),
185 "Valid ordinary text edit was rejected: {transaction:?}"
190186 );
191187 }
188 let result = match transaction {
189 Ok(Some(transaction)) => transaction.commit(&mut storage),
190 Ok(None) | Err(_) => {
191 assert_eq!(characters(&storage.durable, sid, oid), current);
192 continue;
193 }
194 };
195 let persisted = characters(&storage.durable, sid, oid);
192196 match result {
193197 Ok(()) => assert_eq!(persisted, expected),
194198 Err(error) => match error.state {
fuzz/fuzz_targets/insert.rs+40-23
......@@ -1,8 +1,9 @@
11#![no_main]
22use libfuzzer_sys::fuzz_target;
33use onestore::{
4 CommitState, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TextAttribute as A,
4 CommitState, ExGuid, RevisionIndex, Store, TextAttribute as A,
55 document::{Document, Kind},
6 op::{Edit, Op, PageOp},
67};
78use std::sync::LazyLock;
89
......@@ -10,8 +11,10 @@ use std::sync::LazyLock;
1011mod current;
1112#[path = "../../crates/onestore/tests/support/disk.rs"]
1213mod disk;
14#[path = "../../crates/onestore/tests/support/ops.rs"]
15mod ops;
1316
14static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid, ExGuid)> = LazyLock::new(|| {
17static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid)> = LazyLock::new(|| {
1518 let bytes = include_bytes!(
1619 "../../corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one"
1720 )
......@@ -27,15 +30,15 @@ static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid, ExGuid)> = LazyLock::new(|| {
2730 .iter()
2831 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
2932 .unwrap();
30 (bytes, space, page, outline)
33 (bytes, space, outline)
3134});
3235
3336fuzz_target!(|input: &[u8]| {
34 let (source, space, page, outline) = &*SOURCE;
35 if let Ok(intent) = serde_json::from_slice::<Insertion>(input)
36 && let Ok(prepared) = PreparedEdit::insert(source, *space, &intent)
37 let (source, space, outline) = &*SOURCE;
38 if let Ok(edit) = serde_json::from_slice::<Edit>(input)
39 && let Ok(edited) = ops::apply(source, "Insertion fuzz", edit.ops)
3740 {
38 current::current(prepared.as_bytes());
41 current::current(edited.as_bytes());
3942 }
4043 let mut persisted = source.clone();
4144 let mut caches = std::array::from_fn::<_, 12, _>(|_| source.clone());
......@@ -56,32 +59,46 @@ fuzz_target!(|input: &[u8]| {
5659 let first = usize::from(step[3]) % offsets.len();
5760 let second = usize::from(step[4]) % offsets.len();
5861 let (start, end) = (first.min(second), first.max(second));
59 let plain = if step[1] & 1 == 0 {
60 Insertion::paragraph(*outline, None, text, "Insertion fuzz").unwrap()
62 let (insert, text_object) = if step[1] & 1 == 0 {
63 let paragraph = ops::paragraph(text);
64 let id = paragraph.text().unwrap().id;
65 let insert = PageOp::Insert {
66 container: *outline,
67 before: None,
68 paragraphs: vec![paragraph],
69 };
70 (insert, id)
6171 } else {
62 Insertion::outline(*page, 72.0, 144.0, text, "Insertion fuzz").unwrap()
72 let (add, _, id) = ops::new_outline(72.0, 144.0, text);
73 (add, id)
6374 };
6475 let enabled = step[5] & 1 != 0;
65 let intent = if start != end || text.is_empty() {
66 plain
67 .with_formatting(
68 offsets[start]..offsets[end],
69 &[A::Bold(enabled), A::FontSize(18.0)],
70 )
71 .unwrap()
72 } else {
73 plain
76 let mut page_ops = vec![insert];
77 if start != end || text.is_empty() {
78 page_ops.push(PageOp::Format {
79 text: text_object,
80 range: offsets[start]..offsets[end],
81 set: vec![A::Bold(enabled), A::FontSize(18.0)],
82 clear: Vec::new(),
83 });
84 }
85 let edit = Edit {
86 at: ops::now(),
87 ops: page_ops
88 .into_iter()
89 .map(|op| Op::Page { space: *space, op })
90 .collect(),
7491 };
75 let encoded = serde_json::to_vec(&intent).unwrap();
76 let intent: Insertion = serde_json::from_slice(&encoded).unwrap();
77 let edit = PreparedEdit::insert(source, *space, &intent).unwrap();
92 let encoded = serde_json::to_vec(&edit).unwrap();
93 let edit: Edit = serde_json::from_slice(&encoded).unwrap();
94 let edit = ops::apply(source, "Insertion fuzz", edit.ops).unwrap();
7895 let store = Store::parse(edit.as_bytes()).unwrap();
7996 let index = RevisionIndex::parse(&store).unwrap();
8097 let document = Document::parse(&index).unwrap();
8198 let s = &document.spaces[space];
8299 let view = &s.revisions[&s.contexts[&ExGuid::default()]];
83100 let actual: Vec<_> = view
84 .text_runs(intent.text_object())
101 .text_runs(text_object)
85102 .unwrap()
86103 .into_iter()
87104 .flat_map(|run| {
fuzz/fuzz_targets/offline_page.rs+4
......@@ -3,7 +3,11 @@ use libfuzzer_sys::fuzz_target;
33
44#[path = "../../crates/onestore/tests/support/disk.rs"]
55mod disk;
6#[path = "../../crates/notebook/tests/support/model_ops.rs"]
7mod model_ops;
68#[path = "../../crates/notebook/tests/support/page_schedule.rs"]
79mod page_schedule;
10#[path = "../../crates/notebook/tests/support/server.rs"]
11mod server;
812
913fuzz_target!(|input: &[u8]| page_schedule::run(input));
fuzz/fuzz_targets/paragraph.rs+52-41
......@@ -1,9 +1,10 @@
11#![no_main]
22use libfuzzer_sys::fuzz_target;
33use onestore::{
4 CommitState, ExGuid, Insertion, OutlineEdit, ParagraphJoin, ParagraphSplit, PreparedEdit,
5 RevisionIndex, Store, TextAttribute as A,
4 CommitState, ExGuid, OutlineEdit, RevisionIndex, Store, TextAttribute as A,
65 document::{Document, Kind},
6 op::{Edit, PageOp},
7 page::text::new_id,
78};
89use std::sync::LazyLock;
910
......@@ -11,6 +12,8 @@ use std::sync::LazyLock;
1112mod current;
1213#[path = "../../crates/onestore/tests/support/disk.rs"]
1314mod disk;
15#[path = "../../crates/onestore/tests/support/ops.rs"]
16mod ops;
1417
1518static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid)> = LazyLock::new(|| {
1619 let source = onestore::create_section("paragraph.one", "Original", "Author").unwrap();
......@@ -26,34 +29,33 @@ static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid)> = LazyLock::new(|| {
2629 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
2730 .unwrap();
2831 let parent = view.nodes[&outline].children[0];
29 let intent = Insertion::paragraph(parent, None, "a🦀 e\u{301} 東京\rEnd", "Author")
30 .unwrap()
31 .with_formatting(0..4, &[A::Bold(true)])
32 .unwrap()
33 .with_formatting(4..7, &[A::Italic(true), A::Color(Some([12, 34, 56]))])
34 .unwrap();
35 let edited = PreparedEdit::insert(&source, sid, &intent).unwrap();
36 let second = Insertion::outline(page, 144.0, 36.0, "Fixed second", "Author").unwrap();
37 let edited = PreparedEdit::insert(edited.as_bytes(), sid, &second).unwrap();
38 (edited.as_bytes().to_vec(), sid, outline)
32 let mut paragraph = ops::paragraph("a🦀 e\u{301} 東京\rEnd");
33 paragraph.level = 2;
34 let text = paragraph.text().unwrap().id;
35 let format = |range, set| PageOp::Format {
36 text,
37 range,
38 set,
39 clear: Vec::new(),
40 };
41 let insert = PageOp::Insert {
42 container: parent,
43 before: None,
44 paragraphs: vec![paragraph],
45 };
46 let bold = format(0..4, vec![A::Bold(true)]);
47 let italic = format(4..7, vec![A::Italic(true), A::Color(Some([12, 34, 56]))]);
48 let (second, ..) = ops::new_outline(144.0, 36.0, "Fixed second");
49 let edited = ops::page_edited(&source, sid, vec![insert, bold, italic, second]).unwrap();
50 (edited, sid, outline)
3951});
4052
4153fuzz_target!(|input: &[u8]| {
4254 let (source, sid, outline) = &*SOURCE;
43 if let Ok(operation) = serde_json::from_slice::<OutlineEdit>(input)
44 && let Ok(edit) = PreparedEdit::outline(source, *sid, *outline, operation)
45 {
46 current::current(edit.as_bytes());
47 }
48 if let Ok(intent) = serde_json::from_slice::<ParagraphSplit>(input)
49 && let Ok(edit) = PreparedEdit::split(source, *sid, &intent)
55 if let Ok(edit) = serde_json::from_slice::<Edit>(input)
56 && let Ok(edited) = ops::apply(source, "Paragraph fuzz", edit.ops)
5057 {
51 current::current(edit.as_bytes());
52 }
53 if let Ok(intent) = serde_json::from_slice::<ParagraphJoin>(input)
54 && let Ok(edit) = PreparedEdit::join(source, *sid, &intent)
55 {
56 current::current(edit.as_bytes());
58 current::current(edited.as_bytes());
5759 }
5860 let mut persisted = source.clone();
5961 let mut caches = std::array::from_fn::<_, 12, _>(|_| source.clone());
......@@ -134,7 +136,15 @@ fuzz_target!(|input: &[u8]| {
134136 expected_text.push((*id, characters(view, *id)));
135137 }
136138 }
137 PreparedEdit::outline(source, *sid, object, operation).unwrap()
139 ops::page_op(
140 source,
141 *sid,
142 PageOp::Outline {
143 object,
144 edit: operation,
145 },
146 )
147 .unwrap()
138148 } else if step[1] & 8 != 0 && !pairs.is_empty() {
139149 let (parent, left, right) = pairs[usize::from(step[2]) % pairs.len()];
140150 let a = view.nodes[&left].content[0];
......@@ -151,10 +161,7 @@ fuzz_target!(|input: &[u8]| {
151161 .collect();
152162 expected_graph.push((parent, children, view.nodes[&parent].content.clone()));
153163 expected_graph.push((left, view.nodes[&right].children.clone(), vec![survivor]));
154 let intent = ParagraphJoin::new(a, b, "Paragraph fuzz").unwrap();
155 let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap();
156 assert_eq!(intent, restored);
157 PreparedEdit::join(source, *sid, &restored).unwrap()
164 ops::page_op(source, *sid, PageOp::Join { left: a, right: b }).unwrap()
158165 } else {
159166 let paragraph = paragraphs[usize::from(step[2]) % paragraphs.len()];
160167 let text = view.nodes[&paragraph].content[0];
......@@ -166,22 +173,23 @@ fuzz_target!(|input: &[u8]| {
166173 }))
167174 .collect();
168175 let offset = u32::from(step[3]) % (offsets.last().unwrap() + 2);
169 let intent = ParagraphSplit::new(text, offset, "Paragraph fuzz").unwrap();
170 let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap();
171 assert_eq!(intent, restored);
172 let edit = PreparedEdit::split(source, *sid, &restored);
176 let (object, right) = (new_id().unwrap(), new_id().unwrap());
177 let split = PageOp::Split {
178 text,
179 at: offset,
180 paragraph: object,
181 right,
182 lists: Vec::new(),
183 };
184 let edit = ops::page_op(source, *sid, split);
173185 let Some(position) = offsets.iter().position(|n| *n == offset) else {
174186 assert!(edit.is_err());
175187 continue;
176188 };
177189 expected_text.push((text, before[..position].to_vec()));
178 expected_text.push((intent.text_object(), before[position..].to_vec()));
190 expected_text.push((right, before[position..].to_vec()));
179191 expected_graph.push((paragraph, vec![], vec![text]));
180 expected_graph.push((
181 intent.object(),
182 view.nodes[&paragraph].children.clone(),
183 vec![intent.text_object()],
184 ));
192 expected_graph.push((object, view.nodes[&paragraph].children.clone(), vec![right]));
185193 edit.unwrap()
186194 };
187195 let after_store = Store::parse(edit.as_bytes()).unwrap();
......@@ -226,7 +234,10 @@ fuzz_target!(|input: &[u8]| {
226234 write_limit: if step[6] & 1 == 0 { 17 } else { 4096 },
227235 random: u64::from(step[7]) + 1,
228236 };
229 let result = edit.commit(&mut disk);
237 let Some(transaction) = &edit.transaction else {
238 continue;
239 };
240 let result = transaction.commit(&mut disk);
230241 let observed = current::current(&disk.durable);
231242 match result {
232243 Ok(()) => assert_eq!(observed, after),
fuzz/fuzz_targets/protected_write.rs+70-31
......@@ -1,10 +1,14 @@
11#![no_main]
2//! Chains page edits on a protected section: every written image unlocks with the same
3//! password, reads back as the edited model and stores none of the new text in clear.
2//! Chains text edits on a protected section: every written image unlocks with the same
3//! password, reads back as the model predicts and stores none of the new text in clear.
4//! Text typed at the end of a text whose empty final run keeps an insertion style takes
5//! that style, which the page model cannot show, so only its characters are predicted.
46use libfuzzer_sys::fuzz_target;
57use onestore::{
6 ExGuid, PreparedEdit, RevisionIndex, Store,
7 page::{Page, PageObject, Paragraph, text::Edit},
8 ExGuid, RevisionIndex, Store,
9 document::Kind,
10 op::{Edit, Op, PageOp, predict},
11 page::{Page, PageObject},
812 protected::{Limits, UnlockedSection},
913};
1014
......@@ -12,60 +16,95 @@ const SOURCE: &[u8] =
1216 include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one");
1317const PASSWORD: &str = "fictitious-only";
1418
15fn page(bytes: &[u8]) -> (ExGuid, Page) {
19/// The first page, and the texts whose empty final run keeps an insertion style.
20fn page(bytes: &[u8]) -> (ExGuid, Page, Vec<ExGuid>) {
1621 let store = Store::parse(bytes).unwrap();
1722 let index = RevisionIndex::parse(&store).unwrap();
1823 let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap();
1924 let document = unlocked.document().unwrap();
2025 let (space, _) = document.pages().unwrap()[0];
21 (space, Page::from_space(&document, space).unwrap())
26 let view = &document.spaces[&space];
27 let hidden = view.revisions[&view.contexts[&ExGuid::default()]]
28 .nodes
29 .iter()
30 .filter(|(_, node)| {
31 matches!(&node.kind, Kind::RichText { text, runs, .. }
32 if !text.is_empty() && runs.len() > 1 && runs.last().is_some_and(|r| r.start == r.end))
33 })
34 .map(|(id, _)| *id)
35 .collect();
36 (space, Page::from_space(&document, space).unwrap(), hidden)
37}
38
39fn texts(page: &Page) -> Vec<String> {
40 page.objects
41 .iter()
42 .filter_map(|object| match object {
43 PageObject::Outline(outline) => Some(&outline.paragraphs),
44 _ => None,
45 })
46 .flatten()
47 .filter_map(|paragraph| Some(paragraph.text()?.text.text().to_owned()))
48 .collect()
2249}
2350
2451fuzz_target!(|data: &[u8]| {
2552 let mut bytes = SOURCE.to_vec();
26 for step in data.chunks(12).take(4) {
53 for (at, step) in (0..).zip(data.chunks(12).take(4)) {
2754 if step.len() < 4 {
2855 return;
2956 }
30 let (space, mut after) = page(&bytes);
31 let mut texts: Vec<_> = after
57 let (space, mut expected, hidden) = page(&bytes);
58 let candidates: Vec<_> = expected
3259 .objects
33 .iter_mut()
60 .iter()
3461 .filter_map(|object| match object {
35 PageObject::Outline(outline) => Some(&mut outline.paragraphs),
62 PageObject::Outline(outline) => Some(&outline.paragraphs),
3663 _ => None,
3764 })
3865 .flatten()
39 .filter_map(|paragraph| paragraph.text_mut())
66 .filter_map(|paragraph| paragraph.text())
4067 .collect();
41 let count = texts.len();
42 let text = &mut texts[usize::from(step[0]) % count].text;
43 let end = text.utf16_offset(text.text().len()).unwrap();
68 let text = candidates[usize::from(step[0]) % candidates.len()];
69 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
4470 let start = u32::from(step[1]) % (end + 1);
4571 let stop = (start + u32::from(step[2]) % 8).min(end);
4672 // Marked so its absence from the stored bytes is checkable.
47 let inserted = format!("\u{1f512}sealed\u{1f512}{}", String::from_utf8_lossy(&step[3..]).replace('\0', ""));
48 let Ok(format) = text.format_at(start.min(end.saturating_sub(1))).cloned() else {
49 return;
50 };
51 let edit = Edit {
73 let inserted = format!(
74 "\u{1f512}sealed\u{1f512}{}",
75 String::from_utf8_lossy(&step[3..]).replace(['\0', '\n', '\u{fffc}'], "")
76 );
77 let styled = hidden.contains(&text.id) && stop == end;
78 let op = PageOp::Text {
79 text: text.id,
5280 range: start..stop,
53 replacement: Paragraph::new(inserted.clone(), format),
81 with: inserted.clone(),
5482 };
55 let (Ok(_), Ok(_)) = (text.byte_offset(start), text.byte_offset(stop)) else {
56 return;
57 };
58 if text.apply(edit).is_err() {
83 if predict(&mut expected, &op).is_err() {
5984 return;
6085 }
61 let Ok(edit) = PreparedEdit::page_protected(&bytes, PASSWORD, space, &after, "Fuzz") else {
62 return;
86 let edit = Edit {
87 at: 133_000_000_000_000_000 + at * 10_000_000,
88 ops: vec![Op::Page { space, op }],
6389 };
64 let written = edit.as_bytes().to_vec();
90 let store = Store::parse(&bytes).unwrap();
91 let index = RevisionIndex::parse(&store).unwrap();
92 let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap();
93 let transaction = unlocked.apply("Fuzz", &edit).unwrap();
94 let mut written = bytes.clone();
95 transaction.apply(&mut written).unwrap();
6596 let clear: Vec<u8> = inserted.encode_utf16().flat_map(u16::to_le_bytes).collect();
66 assert!(!written[bytes.len()..].windows(clear.len()).any(|w| w == clear));
67 let (_, stored) = page(&written);
68 assert!(stored.objects == after.objects);
97 assert!(
98 !written[bytes.len()..]
99 .windows(clear.len())
100 .any(|w| w == clear)
101 );
102 let (_, stored, _) = page(&written);
103 if styled {
104 assert_eq!(texts(&stored), texts(&expected));
105 } else {
106 assert!(stored.objects == expected.objects);
107 }
69108 bytes = written;
70109 }
71110});
fuzz/fuzz_targets/tree.rs+2
......@@ -5,6 +5,8 @@ use libfuzzer_sys::fuzz_target;
55mod current;
66#[path = "../../crates/onestore/tests/support/disk.rs"]
77mod disk;
8#[path = "../../crates/onestore/tests/support/ops.rs"]
9mod ops;
810#[path = "../../crates/onestore/tests/support/tree_model.rs"]
911mod tree_model;
1012
fuzz/fuzz_targets/write_scalar.rs+46-38
......@@ -1,58 +1,66 @@
11#![no_main]
2//! Replaces a native text with arbitrary text: the edit either refuses it or appends a
3//! revision storing it, leaving the old header's view of the file intact.
24use libfuzzer_sys::fuzz_target;
35use onestore::{
4 ExGuid, ObjectData, PropertySets, RevisionIndex, Store, Value, replace_property_bytes,
6 ExGuid, RevisionIndex, Store,
7 document::{Document, Kind},
8 op::{Op, PageOp},
59};
610use std::sync::LazyLock;
711
12#[path = "../../crates/onestore/tests/support/ops.rs"]
13mod ops;
14
815const SOURCE: &[u8] =
916 include_bytes!("../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one");
10static TARGET: LazyLock<(ExGuid, ExGuid, u32)> = LazyLock::new(|| {
11 let store = Store::parse(SOURCE).unwrap();
17
18fn text(bytes: &[u8], target: Option<(ExGuid, ExGuid)>) -> Vec<(ExGuid, ExGuid, String)> {
19 let store = Store::parse(bytes).unwrap();
20 assert!(store.checksum_mismatches.is_empty());
1221 let index = RevisionIndex::parse(&store).unwrap();
13 for (osid, space) in &index.spaces {
14 let revision = index
15 .resolve(*osid, space.labels[&(ExGuid::default(), 1)])
16 .unwrap();
17 for (oid, object) in revision.objects {
18 if let ObjectData::Properties(data) = object.data {
19 for property in &PropertySets::parse(data).unwrap().sets[0] {
20 if property.value == Value::Bytes(b"Fictitious plain text.") {
21 return (*osid, oid, property.id);
22 }
23 }
22 index.validate_current().unwrap();
23 let document = Document::parse(&index).unwrap();
24 let mut found = Vec::new();
25 for (sid, space) in &document.spaces {
26 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
27 for (oid, node) in &revision.nodes {
28 if let Kind::RichText { text, .. } = &node.kind
29 && target.is_none_or(|target| target == (*sid, *oid))
30 {
31 found.push((*sid, *oid, text.clone()));
2432 }
2533 }
2634 }
27 panic!("Missing native seed property")
35 found
36}
37
38static TARGET: LazyLock<(ExGuid, ExGuid)> = LazyLock::new(|| {
39 text(SOURCE, None)
40 .into_iter()
41 .find_map(|(sid, oid, text)| (text == "Fictitious plain text.").then_some((sid, oid)))
42 .expect("Missing native seed text")
2843});
2944
3045fuzz_target!(|value: &[u8]| {
31 let (osid, oid, property) = *TARGET;
32 let bytes = replace_property_bytes(SOURCE, osid, oid, property, value).unwrap();
46 let (sid, oid) = *TARGET;
47 let value = String::from_utf8_lossy(value);
48 let op = PageOp::Text {
49 text: oid,
50 range: 0.."Fictitious plain text.".len() as u32,
51 with: value.to_string(),
52 };
53 let edited = ops::apply(SOURCE, "Fuzz", vec![Op::Page { space: sid, op }]);
54 if value.contains(['\0', '\n', '\u{fffc}']) {
55 assert!(edited.is_err());
56 return;
57 }
58 let bytes = edited.unwrap().image;
3359 let mut before_commit = bytes.clone();
3460 before_commit[..1024].copy_from_slice(&SOURCE[..1024]);
35 let old = Store::parse(&before_commit).unwrap();
36 assert!(old.checksum_mismatches.is_empty());
37 RevisionIndex::parse(&old)
38 .unwrap()
39 .validate_current()
40 .unwrap();
41 let store = Store::parse(&bytes).unwrap();
42 assert!(store.checksum_mismatches.is_empty());
43 let index = RevisionIndex::parse(&store).unwrap();
44 let rid = index.spaces[&osid].labels[&(ExGuid::default(), 1)];
45 let current = index.resolve(osid, rid).unwrap();
46 let ObjectData::Properties(data) = current.objects[&oid].data else {
47 panic!()
48 };
49 let properties = PropertySets::parse(data).unwrap();
5061 assert_eq!(
51 properties.sets[0]
52 .iter()
53 .find(|candidate| candidate.id == property)
54 .unwrap()
55 .value,
56 Value::Bytes(value)
62 text(&before_commit, Some((sid, oid)))[0].2,
63 "Fictitious plain text."
5764 );
65 assert_eq!(text(&bytes, Some((sid, oid)))[0].2, value);
5866});
readme.md+7-1
......@@ -11,7 +11,13 @@ create and resize as you would expect. In addition, Snowbound (TODO) supports
1111pen and drawing tools, recording audio and video, revision history, multi-machine
1212live collaboration, and much more.
1313
14TODO: insert a screenshot once the UI is polished.
14<!-- Regenerate from the sample notebook (edit it freely in OneNote or Snowbound):
15python3 tools/canvas/build_macos.py --release && d=$(mktemp -d) && cp -R docs/sample-notebook/Personal "$d" && target/Snowbound.app/Contents/MacOS/Snowbound --notebook "$d/Personal" --cache "$d/cache" --screenshot docs/screenshot
16-->
17<picture>
18 <source media="(prefers-color-scheme: dark)" srcset="docs/screenshot-dark.png">
19 <img alt="Snowbound showing a garden notebook page with a to-do list, a table and a sketch on OneNote's Ivy background" src="docs/screenshot-light.png">
20</picture>
1521
1622Snowbound implements the file and sync protocol used in 2010 Microsoft OneNote,
1723so notebooks are fully compatible, including collaboration features. It (TODO)