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 = [...@@ -27,6 +27,20 @@ dependencies = [
27 "uuid",27 "uuid",
28]28]
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
30[[package]]44[[package]]
31name = "accesskit_consumer"45name = "accesskit_consumer"
32version = "0.39.0"46version = "0.39.0"
...@@ -65,6 +79,24 @@ dependencies = [...@@ -65,6 +79,24 @@ dependencies = [
65 "objc2-foundation 0.2.2",79 "objc2-foundation 0.2.2",
66]80]
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
68[[package]]100[[package]]
69name = "accesskit_windows"101name = "accesskit_windows"
70version = "0.35.0"102version = "0.35.0"
...@@ -88,6 +120,7 @@ dependencies = [...@@ -88,6 +120,7 @@ dependencies = [
88 "accesskit",120 "accesskit",
89 "accesskit_ios",121 "accesskit_ios",
90 "accesskit_macos",122 "accesskit_macos",
123 "accesskit_unix",
91 "accesskit_windows",124 "accesskit_windows",
92 "raw-window-handle",125 "raw-window-handle",
93 "winit",126 "winit",
...@@ -232,6 +265,126 @@ dependencies = [...@@ -232,6 +265,126 @@ dependencies = [
232 "libloading",265 "libloading",
233]266]
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
235[[package]]388[[package]]
236name = "async-trait"389name = "async-trait"
237version = "0.1.92"390version = "0.1.92"
...@@ -249,6 +402,43 @@ version = "1.1.2"...@@ -249,6 +402,43 @@ version = "1.1.2"
249source = "registry+https://github.com/rust-lang/crates.io-index"402source = "registry+https://github.com/rust-lang/crates.io-index"
250checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"403checksum = "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
252[[package]]442[[package]]
253name = "autocfg"443name = "autocfg"
254version = "1.5.1"444version = "1.5.1"
...@@ -325,6 +515,19 @@ dependencies = [...@@ -325,6 +515,19 @@ dependencies = [
325 "objc2 0.6.4",515 "objc2 0.6.4",
326]516]
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
328[[package]]531[[package]]
329name = "bumpalo"532name = "bumpalo"
330version = "3.20.3"533version = "3.20.3"
...@@ -377,25 +580,52 @@ dependencies = [...@@ -377,25 +580,52 @@ dependencies = [
377 "thiserror 1.0.69",580 "thiserror 1.0.69",
378]581]
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
380[[package]]596[[package]]
381name = "calloop-wayland-source"597name = "calloop-wayland-source"
382version = "0.3.0"598version = "0.3.0"
383source = "registry+https://github.com/rust-lang/crates.io-index"599source = "registry+https://github.com/rust-lang/crates.io-index"
384checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"600checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
385dependencies = [601dependencies = [
386 "calloop",602 "calloop 0.13.0",
387 "rustix 0.38.44",603 "rustix 0.38.44",
388 "wayland-backend",604 "wayland-backend",
389 "wayland-client",605 "wayland-client",
390]606]
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
392[[package]]620[[package]]
393name = "canvas"621name = "canvas"
394version = "0.1.0"622version = "0.1.0"
395dependencies = [623dependencies = [
396 "accesskit",624 "accesskit",
397 "accesskit_consumer",625 "accesskit_consumer",
626 "color_quant",
398 "draw",627 "draw",
628 "image",
399 "onestore",629 "onestore",
400 "parley",630 "parley",
401 "png",631 "png",
...@@ -744,6 +974,33 @@ dependencies = [...@@ -744,6 +974,33 @@ dependencies = [
744 "wgpu",974 "wgpu",
745]975]
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
747[[package]]1004[[package]]
748name = "equivalent"1005name = "equivalent"
749version = "1.0.2"1006version = "1.0.2"
...@@ -775,6 +1032,26 @@ dependencies = [...@@ -775,6 +1032,26 @@ dependencies = [
775 "num-traits",1032 "num-traits",
776]1033]
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
778[[package]]1055[[package]]
779name = "fallible-iterator"1056name = "fallible-iterator"
780version = "0.3.0"1057version = "0.3.0"
...@@ -900,6 +1177,36 @@ version = "0.3.34"...@@ -900,6 +1177,36 @@ version = "0.3.34"
900source = "registry+https://github.com/rust-lang/crates.io-index"1177source = "registry+https://github.com/rust-lang/crates.io-index"
901checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"1178checksum = "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
903[[package]]1210[[package]]
904name = "futures-task"1211name = "futures-task"
905version = "0.3.34"1212version = "0.3.34"
...@@ -913,6 +1220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -913,6 +1220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
913checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"1220checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
914dependencies = [1221dependencies = [
915 "futures-core",1222 "futures-core",
1223 "futures-macro",
916 "futures-task",1224 "futures-task",
917 "pin-project-lite",1225 "pin-project-lite",
918 "slab",1226 "slab",
...@@ -1076,6 +1384,12 @@ version = "0.5.3"...@@ -1076,6 +1384,12 @@ version = "0.5.3"
1076source = "registry+https://github.com/rust-lang/crates.io-index"1384source = "registry+https://github.com/rust-lang/crates.io-index"
1077checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"1385checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
10781386
1387[[package]]
1388name = "hex"
1389version = "0.4.3"
1390source = "registry+https://github.com/rust-lang/crates.io-index"
1391checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1392
1079[[package]]1393[[package]]
1080name = "hmac"1394name = "hmac"
1081version = "0.13.0"1395version = "0.13.0"
...@@ -1520,6 +1834,15 @@ dependencies = [...@@ -1520,6 +1834,15 @@ dependencies = [
1520 "libc",1834 "libc",
1521]1835]
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
1523[[package]]1846[[package]]
1524name = "miniz_oxide"1847name = "miniz_oxide"
1525version = "0.8.9"1848version = "0.8.9"
...@@ -1557,9 +1880,11 @@ version = "0.1.0"...@@ -1557,9 +1880,11 @@ version = "0.1.0"
1557dependencies = [1880dependencies = [
1558 "canvas",1881 "canvas",
1559 "draw",1882 "draw",
1883 "notebook",
1560 "onestore",1884 "onestore",
1561 "parley",1885 "parley",
1562 "pollster",1886 "pollster",
1887 "serde",
1563 "serde_json",1888 "serde_json",
1564 "wgpu",1889 "wgpu",
1565]1890]
...@@ -1671,6 +1996,16 @@ dependencies = [...@@ -1671,6 +1996,16 @@ dependencies = [
1671 "zeroize",1996 "zeroize",
1672]1997]
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
1674[[package]]2009[[package]]
1675name = "num-traits"2010name = "num-traits"
1676version = "0.2.19"2011version = "0.2.19"
...@@ -2053,6 +2388,16 @@ dependencies = [...@@ -2053,6 +2388,16 @@ dependencies = [
2053 "num-traits",2388 "num-traits",
2054]2389]
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
2056[[package]]2401[[package]]
2057name = "owned_ttf_parser"2402name = "owned_ttf_parser"
2058version = "0.25.1"2403version = "0.25.1"
...@@ -2062,6 +2407,12 @@ dependencies = [...@@ -2062,6 +2407,12 @@ dependencies = [
2062 "ttf-parser",2407 "ttf-parser",
2063]2408]
20642409
2410[[package]]
2411name = "parking"
2412version = "2.2.1"
2413source = "registry+https://github.com/rust-lang/crates.io-index"
2414checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
2415
2065[[package]]2416[[package]]
2066name = "parking_lot"2417name = "parking_lot"
2067version = "0.12.5"2418version = "0.12.5"
...@@ -2145,6 +2496,49 @@ version = "2.3.2"...@@ -2145,6 +2496,49 @@ version = "2.3.2"
2145source = "registry+https://github.com/rust-lang/crates.io-index"2496source = "registry+https://github.com/rust-lang/crates.io-index"
2146checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"2497checksum = "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
2148[[package]]2542[[package]]
2149name = "pico-args"2543name = "pico-args"
2150version = "0.5.0"2544version = "0.5.0"
...@@ -2177,6 +2571,17 @@ version = "0.2.17"...@@ -2177,6 +2571,17 @@ version = "0.2.17"
2177source = "registry+https://github.com/rust-lang/crates.io-index"2571source = "registry+https://github.com/rust-lang/crates.io-index"
2178checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"2572checksum = "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
2180[[package]]2585[[package]]
2181name = "pkg-config"2586name = "pkg-config"
2182version = "0.3.34"2587version = "0.3.34"
...@@ -2549,7 +2954,7 @@ dependencies = [...@@ -2549,7 +2954,7 @@ dependencies = [
2549 "ab_glyph",2954 "ab_glyph",
2550 "log",2955 "log",
2551 "memmap2",2956 "memmap2",
2552 "smithay-client-toolkit",2957 "smithay-client-toolkit 0.19.2",
2553 "tiny-skia 0.11.4",2958 "tiny-skia 0.11.4",
2554]2959]
25552960
...@@ -2602,6 +3007,17 @@ dependencies = [...@@ -2602,6 +3007,17 @@ dependencies = [
2602 "zmij",3007 "zmij",
2603]3008]
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
2605[[package]]3021[[package]]
2606name = "sha1"3022name = "sha1"
2607version = "0.11.0"3023version = "0.11.0"
...@@ -2630,6 +3046,16 @@ version = "2.0.1"...@@ -2630,6 +3046,16 @@ version = "2.0.1"
2630source = "registry+https://github.com/rust-lang/crates.io-index"3046source = "registry+https://github.com/rust-lang/crates.io-index"
2631checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"3047checksum = "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
2633[[package]]3059[[package]]
2634name = "simd-adler32"3060name = "simd-adler32"
2635version = "0.3.10"3061version = "0.3.10"
...@@ -2732,8 +3158,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -2732,8 +3158,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2732checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"3158checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
2733dependencies = [3159dependencies = [
2734 "bitflags 2.13.1",3160 "bitflags 2.13.1",
2735 "calloop",3161 "calloop 0.13.0",
2736 "calloop-wayland-source",3162 "calloop-wayland-source 0.3.0",
2737 "cursor-icon",3163 "cursor-icon",
2738 "libc",3164 "libc",
2739 "log",3165 "log",
...@@ -2750,6 +3176,44 @@ dependencies = [...@@ -2750,6 +3176,44 @@ dependencies = [
2750 "xkeysym",3176 "xkeysym",
2751]3177]
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
2753[[package]]3217[[package]]
2754name = "smol_str"3218name = "smol_str"
2755version = "0.2.2"3219version = "0.2.2"
...@@ -2768,6 +3232,8 @@ dependencies = [...@@ -2768,6 +3232,8 @@ dependencies = [
2768 "arboard",3232 "arboard",
2769 "canvas",3233 "canvas",
2770 "draw",3234 "draw",
3235 "fontique",
3236 "libc",
2771 "notebook",3237 "notebook",
2772 "objc2 0.5.2",3238 "objc2 0.5.2",
2773 "objc2-app-kit 0.2.2",3239 "objc2-app-kit 0.2.2",
...@@ -2776,9 +3242,13 @@ dependencies = [...@@ -2776,9 +3242,13 @@ dependencies = [
2776 "parley",3242 "parley",
2777 "png",3243 "png",
2778 "pollster",3244 "pollster",
3245 "serde",
3246 "serde_json",
3247 "smithay-clipboard",
2779 "ui",3248 "ui",
2780 "wgpu",3249 "wgpu",
2781 "winit",3250 "winit",
3251 "zbus",
2782]3252]
27833253
2784[[package]]3254[[package]]
...@@ -3081,15 +3551,31 @@ version = "0.1.44"...@@ -3081,15 +3551,31 @@ version = "0.1.44"
3081source = "registry+https://github.com/rust-lang/crates.io-index"3551source = "registry+https://github.com/rust-lang/crates.io-index"
3082checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"3552checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
3083dependencies = [3553dependencies = [
3554 "log",
3084 "pin-project-lite",3555 "pin-project-lite",
3556 "tracing-attributes",
3085 "tracing-core",3557 "tracing-core",
3086]3558]
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
3088[[package]]3571[[package]]
3089name = "tracing-core"3572name = "tracing-core"
3090version = "0.1.36"3573version = "0.1.36"
3091source = "registry+https://github.com/rust-lang/crates.io-index"3574source = "registry+https://github.com/rust-lang/crates.io-index"
3092checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"3575checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
3576dependencies = [
3577 "once_cell",
3578]
30933579
3094[[package]]3580[[package]]
3095name = "ttf-parser"3581name = "ttf-parser"
...@@ -3109,11 +3595,23 @@ version = "1.20.1"...@@ -3109,11 +3595,23 @@ version = "1.20.1"
3109source = "registry+https://github.com/rust-lang/crates.io-index"3595source = "registry+https://github.com/rust-lang/crates.io-index"
3110checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"3596checksum = "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
3112[[package]]3609[[package]]
3113name = "ui"3610name = "ui"
3114version = "0.1.0"3611version = "0.1.0"
3115dependencies = [3612dependencies = [
3116 "draw",3613 "draw",
3614 "nucleo-matcher",
3117 "parley",3615 "parley",
3118 "png",3616 "png",
3119 "pollster",3617 "pollster",
...@@ -3179,6 +3677,11 @@ name = "uuid"...@@ -3179,6 +3677,11 @@ name = "uuid"
3179version = "1.26.1"3677version = "1.26.1"
3180source = "registry+https://github.com/rust-lang/crates.io-index"3678source = "registry+https://github.com/rust-lang/crates.io-index"
3181checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"3679checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
3680dependencies = [
3681 "js-sys",
3682 "serde_core",
3683 "wasm-bindgen",
3684]
31823685
3183[[package]]3686[[package]]
3184name = "vcpkg"3687name = "vcpkg"
...@@ -3332,6 +3835,32 @@ dependencies = [...@@ -3332,6 +3835,32 @@ dependencies = [
3332 "wayland-scanner",3835 "wayland-scanner",
3333]3836]
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
3335[[package]]3864[[package]]
3336name = "wayland-protocols-plasma"3865name = "wayland-protocols-plasma"
3337version = "0.3.12"3866version = "0.3.12"
...@@ -3876,7 +4405,7 @@ dependencies = [...@@ -3876,7 +4405,7 @@ dependencies = [
3876 "bitflags 2.13.1",4405 "bitflags 2.13.1",
3877 "block2 0.5.1",4406 "block2 0.5.1",
3878 "bytemuck",4407 "bytemuck",
3879 "calloop",4408 "calloop 0.13.0",
3880 "cfg_aliases",4409 "cfg_aliases",
3881 "concurrent-queue",4410 "concurrent-queue",
3882 "core-foundation",4411 "core-foundation",
...@@ -3898,7 +4427,7 @@ dependencies = [...@@ -3898,7 +4427,7 @@ dependencies = [
3898 "redox_syscall 0.4.1",4427 "redox_syscall 0.4.1",
3899 "rustix 0.38.44",4428 "rustix 0.38.44",
3900 "sctk-adwaita",4429 "sctk-adwaita",
3901 "smithay-client-toolkit",4430 "smithay-client-toolkit 0.19.2",
3902 "smol_str",4431 "smol_str",
3903 "tracing",4432 "tracing",
3904 "unicode-segmentation",4433 "unicode-segmentation",
...@@ -4040,6 +4569,112 @@ dependencies = [...@@ -4040,6 +4569,112 @@ dependencies = [
4040 "synstructure",4569 "synstructure",
4041]4570]
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
4043[[package]]4678[[package]]
4044name = "zeno"4679name = "zeno"
4045version = "0.3.3"4680version = "0.3.3"
...@@ -4154,3 +4789,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"...@@ -4154,3 +4789,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
4154dependencies = [4789dependencies = [
4155 "zune-core",4790 "zune-core",
4156]4791]
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"...@@ -5,6 +5,9 @@ resolver = "3"
55
6[workspace.dependencies]6[workspace.dependencies]
7parley = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759", features = ["complex-scripts"] }7parley = { 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" }
8wgpu = "30.0.1"11wgpu = "30.0.1"
912
10# Shaping, rasterization and GPU submission stay optimised in debug builds, where13# Shaping, rasterization and GPU submission stay optimised in debug builds, where
crates/canvas/Cargo.toml+4-2
...@@ -5,7 +5,7 @@ edition = "2024"...@@ -5,7 +5,7 @@ edition = "2024"
5publish = false5publish = false
66
7[features]7[features]
8gpu = ["draw/render", "dep:resvg", "dep:sha2"]8gpu = ["draw/render", "dep:color_quant", "dep:image", "dep:png", "dep:resvg", "dep:sha2"]
9# How a page responds to input and presents itself to assistive technology.9# How a page responds to input and presents itself to assistive technology.
10interaction = ["gpu", "dep:accesskit"]10interaction = ["gpu", "dep:accesskit"]
1111
...@@ -18,6 +18,9 @@ serde_json = "1.0.151"...@@ -18,6 +18,9 @@ serde_json = "1.0.151"
18skrifa = "0.44.0"18skrifa = "0.44.0"
1919
20accesskit = { version = "0.25.0", optional = true }20accesskit = { 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 }
21resvg = { version = "0.48.1", default-features = false, optional = true }24resvg = { version = "0.48.1", default-features = false, optional = true }
22sha2 = { version = "0.11", optional = true }25sha2 = { version = "0.11", optional = true }
2326
...@@ -32,7 +35,6 @@ path = "src/bin/page_probe.rs"...@@ -32,7 +35,6 @@ path = "src/bin/page_probe.rs"
32[dev-dependencies]35[dev-dependencies]
33accesskit_consumer = "0.39.0"36accesskit_consumer = "0.39.0"
34pollster = "0.4"37pollster = "0.4"
35png = "0.18"
36wgpu.workspace = true38wgpu.workspace = true
3739
38[[example]]40[[example]]
crates/canvas/assets/backgrounds/hearts.svg+8-8
...@@ -1,11 +1,11 @@...@@ -1,11 +1,11 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 171 552" preserveAspectRatio="none">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 171 552" preserveAspectRatio="none">
2<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>2<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>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(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>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(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>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(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>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(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>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(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>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(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>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 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>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>
11</svg>11</svg>
crates/canvas/assets/backgrounds/pushpins-corner.svg+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 177 123" preserveAspectRatio="none">1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 177 123" preserveAspectRatio="none">
2<defs>2<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>
4<linearGradient id="navy" x2="1"><stop offset="0" stop-color="#1f2d4a"/><stop offset=".45" stop-color="#6d7f9f"/><stop offset="1" stop-color="#1f2d4a"/></linearGradient>4<linearGradient id="navy" x2="1"><stop offset="0" stop-color="#1f2d4a"/><stop offset=".45" stop-color="#6d7f9f"/><stop offset="1" stop-color="#1f2d4a"/></linearGradient>
5<g id="pin">5<g id="pin">
6<path d="M-11-30C-11-36 11-36 11-30L8-12C16-8 18 0 15 4H-15C-18 0-16-8-8-12Z"/>6<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> {...@@ -250,7 +250,12 @@ fn divides_link(text: &Paragraph, offset: u32) -> Result<bool, EditError> {
250/// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets250/// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets
251/// an equation or embedded object at the seam or carries one to another paragraph: their run251/// an equation or embedded object at the seam or carries one to another paragraph: their run
252/// data belongs to the whole paragraph, and OneNote has not been seen dividing them.252/// 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> {
254 let object = |text: &Paragraph, span: usize| {259 let object = |text: &Paragraph, span: usize| {
255 let format = &text.spans()[span].format;260 let format = &text.spans()[span].format;
256 [format.math, format.embedded_object].contains(&Some(true))261 [format.math, format.embedded_object].contains(&Some(true))
...@@ -759,19 +764,20 @@ impl TextDocument {...@@ -759,19 +764,20 @@ impl TextDocument {
759 // The joined text lies in the upper paragraph, whose spacing and alignment it takes.764 // The joined text lies in the upper paragraph, whose spacing and alignment it takes.
760 let paragraph = &above.spans()[0].format;765 let paragraph = &above.spans()[0].format;
761 let mut start = 0;766 let mut start = 0;
762 text.text.append(Paragraph::from_runs(below.spans().iter().map(|span| {767 text.text
763 let run = below.text()[start..span.end].to_owned();768 .append(Paragraph::from_runs(below.spans().iter().map(|span| {
764 start = span.end;769 let run = below.text()[start..span.end].to_owned();
765 let format = Format {770 start = span.end;
766 alignment: paragraph.alignment,771 let format = Format {
767 space_before: paragraph.space_before,772 alignment: paragraph.alignment,
768 space_after: paragraph.space_after,773 space_before: paragraph.space_before,
769 line_spacing: paragraph.line_spacing,774 space_after: paragraph.space_after,
770 list_spacing: paragraph.list_spacing,775 line_spacing: paragraph.line_spacing,
771 ..span.format.inherit(&reset)776 list_spacing: paragraph.list_spacing,
772 };777 ..span.format.inherit(&reset)
773 (run, format)778 };
774 })))?;779 (run, format)
780 })))?;
775 }781 }
776 let moves = BTreeMap::from([(bottom.id, &head)]);782 let moves = BTreeMap::from([(bottom.id, &head)]);
777 let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?;783 let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?;
crates/canvas/src/editor.rs+158-20
...@@ -109,6 +109,7 @@ struct PageHeader {...@@ -109,6 +109,7 @@ struct PageHeader {
109 identity: Option<[u8; 16]>,109 identity: Option<[u8; 16]>,
110 created: Option<u64>,110 created: Option<u64>,
111 margin_origin: [f32; 2],111 margin_origin: [f32; 2],
112 color: Option<u32>,
112 areas: Vec<page::TitleArea>,113 areas: Vec<page::TitleArea>,
113}114}
114115
...@@ -398,6 +399,59 @@ impl TextOutline {...@@ -398,6 +399,59 @@ impl TextOutline {
398 Ok(&self.shaped.paragraphs[self.visible_index(source)?])399 Ok(&self.shaped.paragraphs[self.visible_index(source)?])
399 }400 }
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
401 fn column(&self, x: f32, current: ExGuid) -> Result<Vec<usize>, EditError> {455 fn column(&self, x: f32, current: ExGuid) -> Result<Vec<usize>, EditError> {
402 let paragraphs = self456 let paragraphs = self
403 .shaped457 .shaped
...@@ -711,6 +765,7 @@ impl CanvasEditor {...@@ -711,6 +765,7 @@ impl CanvasEditor {
711 identity: page.identity,765 identity: page.identity,
712 created: page.created,766 created: page.created,
713 margin_origin: page.margin_origin,767 margin_origin: page.margin_origin,
768 color: page.color,
714 areas,769 areas,
715 };770 };
716 let mut ids = BTreeSet::new();771 let mut ids = BTreeSet::new();
...@@ -972,6 +1027,7 @@ impl CanvasEditor {...@@ -972,6 +1027,7 @@ impl CanvasEditor {
972 .map(PageDate::timestamp)1027 .map(PageDate::timestamp)
973 .or(self.header.created),1028 .or(self.header.created),
974 margin_origin: self.header.margin_origin,1029 margin_origin: self.header.margin_origin,
1030 color: self.header.color,
975 objects,1031 objects,
976 definitions,1032 definitions,
977 })1033 })
...@@ -1029,16 +1085,31 @@ impl CanvasEditor {...@@ -1029,16 +1085,31 @@ impl CanvasEditor {
1029 .fold(text_bottom, f32::max)1085 .fold(text_bottom, f32::max)
1030 }1086 }
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
1032 pub fn leave_title(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {1107 pub fn leave_title(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
1033 let title = self.active_outline();1108 let title = self.active_outline();
1034 if !title.title {1109 if !title.title {
1035 return Ok(());1110 return Ok(());
1036 }1111 }
1037 let bottom = self.header_bottom(title.id, title.bounds().y1 as f32);1112 let position = self.body_start_of(title);
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 ];
1042 if let Some((id, origin)) = self1113 if let Some((id, origin)) = self
1043 .outlines()1114 .outlines()
1044 .iter()1115 .iter()
...@@ -1896,10 +1967,19 @@ impl CanvasEditor {...@@ -1896,10 +1967,19 @@ impl CanvasEditor {
1896 }1967 }
18971968
1898 pub fn caret(&self, width: f32) -> Result<BoundingBox, EditError> {1969 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> {
1900 let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?;1981 let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?;
1901 let cursor =1982 let cursor = paragraph.cursor(focus.offset, affinity)?;
1902 paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?;
1903 let mut rect = paragraph.text.caret(cursor, width);1983 let mut rect = paragraph.text.caret(cursor, width);
1904 rect.x0 += f64::from(paragraph.origin[0]);1984 rect.x0 += f64::from(paragraph.origin[0]);
1905 rect.x1 += f64::from(paragraph.origin[0]);1985 rect.x1 += f64::from(paragraph.origin[0]);
...@@ -2325,12 +2405,12 @@ impl CanvasEditor {...@@ -2325,12 +2405,12 @@ impl CanvasEditor {
2325 }2405 }
23262406
2327 pub fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> {2407 pub fn selection_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
2328 self.rectangles(self.active_outline().selection)2408 self.range_rects(self.active_outline().selection)
2329 }2409 }
23302410
2331 pub fn marked_rects(&self) -> Result<Vec<BoundingBox>, EditError> {2411 pub fn marked_rects(&self) -> Result<Vec<BoundingBox>, EditError> {
2332 match &self.composition {2412 match &self.composition {
2333 Some(composition) => self.rectangles(Selection {2413 Some(composition) => self.range_rects(Selection {
2334 positions: [composition.range.start, composition.range.end],2414 positions: [composition.range.start, composition.range.end],
2335 affinities: [Affinity::Downstream, Affinity::Upstream],2415 affinities: [Affinity::Downstream, Affinity::Upstream],
2336 }),2416 }),
...@@ -2338,7 +2418,8 @@ impl CanvasEditor {...@@ -2338,7 +2418,8 @@ impl CanvasEditor {
2338 }2418 }
2339 }2419 }
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> {
2342 let [anchor, focus] = selection.positions;2423 let [anchor, focus] = selection.positions;
2343 if anchor == focus {2424 if anchor == focus {
2344 return Ok(Vec::new());2425 return Ok(Vec::new());
...@@ -2515,8 +2596,10 @@ impl CanvasEditor {...@@ -2515,8 +2596,10 @@ impl CanvasEditor {
2515 let (start, end) = (anchor.min(focus), anchor.max(focus));2596 let (start, end) = (anchor.min(focus), anchor.max(focus));
2516 let format = self.typing_format(start)?;2597 let format = self.typing_format(start)?;
2517 let document = &self.active_outline().document;2598 let document = &self.active_outline().document;
2518 let mut edit =2599 let mut edit = document.replace(
2519 document.replace(start..end, vec![Paragraph::new(String::new(), format.clone()); 2])?;2600 start..end,
2601 vec![Paragraph::new(String::new(), format.clone()); 2],
2602 )?;
2520 let nodes = document.container(edit.container)?;2603 let nodes = document.container(edit.container)?;
2521 let next = nodes2604 let next = nodes
2522 .get(crate::document::subtree_end(nodes, edit.range.start))2605 .get(crate::document::subtree_end(nodes, edit.range.start))
...@@ -2554,10 +2637,8 @@ impl CanvasEditor {...@@ -2554,10 +2637,8 @@ impl CanvasEditor {
2554 .find(|id| style(Some(*id)) == Some("p"));2637 .find(|id| style(Some(*id)) == Some("p"));
2555 // The heading's run formatting carries over under the body style (`c9-h1-*`).2638 // The heading's run formatting carries over under the body style (`c9-h1-*`).
2556 let run = format.over(&self.style_format(head.style)?);2639 let run = format.over(&self.style_format(head.style)?);
2557 tail.text_mut().unwrap().text = Paragraph::new(2640 tail.text_mut().unwrap().text =
2558 String::new(),2641 Paragraph::new(String::new(), run.inherit(&self.style_format(tail.style)?));
2559 run.inherit(&self.style_format(tail.style)?),
2560 );
2561 }2642 }
2562 let caret = TextPosition {2643 let caret = TextPosition {
2563 paragraph: start.paragraph + 1,2644 paragraph: start.paragraph + 1,
...@@ -2765,6 +2846,14 @@ impl CanvasEditor {...@@ -2765,6 +2846,14 @@ impl CanvasEditor {
2765 Ok(true)2846 Ok(true)
2766 }2847 }
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
2768 pub fn undo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {2857 pub fn undo(&mut self, engine: &mut TextEngine) -> Result<bool, EditorError> {
2769 if self.composition.is_some() {2858 if self.composition.is_some() {
2770 return self.cancel_composition(engine);2859 return self.cancel_composition(engine);
...@@ -4984,6 +5073,7 @@ mod tests {...@@ -4984,6 +5073,7 @@ mod tests {
4984 identity: None,5073 identity: None,
4985 created: None,5074 created: None,
4986 margin_origin: [36.0, 14.4],5075 margin_origin: [36.0, 14.4],
5076 color: None,
4987 definitions: BTreeMap::new(),5077 definitions: BTreeMap::new(),
4988 objects: vec![PageObject::Image(background), PageObject::Image(picture)],5078 objects: vec![PageObject::Image(background), PageObject::Image(picture)],
4989 },5079 },
...@@ -5107,6 +5197,7 @@ mod tests {...@@ -5107,6 +5197,7 @@ mod tests {
5107 identity: None,5197 identity: None,
5108 created: None,5198 created: None,
5109 margin_origin: [36.0, 14.4],5199 margin_origin: [36.0, 14.4],
5200 color: None,
5110 definitions: BTreeMap::new(),5201 definitions: BTreeMap::new(),
5111 objects,5202 objects,
5112 },5203 },
...@@ -5164,6 +5255,7 @@ mod tests {...@@ -5164,6 +5255,7 @@ mod tests {
5164 identity: None,5255 identity: None,
5165 created: None,5256 created: None,
5166 margin_origin: [36.0, 14.4],5257 margin_origin: [36.0, 14.4],
5258 color: None,
5167 definitions: BTreeMap::new(),5259 definitions: BTreeMap::new(),
5168 objects: vec![PageObject::Outline(source)],5260 objects: vec![PageObject::Outline(source)],
5169 },5261 },
...@@ -5303,6 +5395,7 @@ mod tests {...@@ -5303,6 +5395,7 @@ mod tests {
5303 identity: None,5395 identity: None,
5304 created: None,5396 created: None,
5305 margin_origin: [36.0, 14.4],5397 margin_origin: [36.0, 14.4],
5398 color: None,
5306 definitions: BTreeMap::new(),5399 definitions: BTreeMap::new(),
5307 objects: vec![5400 objects: vec![
5308 PageObject::Outline(mixed),5401 PageObject::Outline(mixed),
...@@ -5393,6 +5486,7 @@ mod tests {...@@ -5393,6 +5486,7 @@ mod tests {
5393 identity: None,5486 identity: None,
5394 created: None,5487 created: None,
5395 margin_origin: [36.0, 14.4],5488 margin_origin: [36.0, 14.4],
5489 color: None,
5396 definitions: BTreeMap::new(),5490 definitions: BTreeMap::new(),
5397 objects,5491 objects,
5398 },5492 },
...@@ -6900,6 +6994,50 @@ mod tests {...@@ -6900,6 +6994,50 @@ mod tests {
6900 );6994 );
6901 }6995 }
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
6903 #[test]7041 #[test]
6904 fn imported_indentation_markers_and_collapsed_children_share_edit_geometry() {7042 fn imported_indentation_markers_and_collapsed_children_share_edit_geometry() {
6905 let document = TextDocument::new(7043 let document = TextDocument::new(
...@@ -8610,7 +8748,7 @@ mod tests {...@@ -8610,7 +8748,7 @@ mod tests {
8610 }8748 }
86118749
8612 #[test]8750 #[test]
8613 fn an_edited_page_writes_back_through_the_page_writer() {8751 fn an_edited_page_writes_back_as_its_ops() {
8614 let mut engine = TextEngine::default();8752 let mut engine = TextEngine::default();
8615 let (space, source) = corpus_page(CORPUS[2], "Split middle");8753 let (space, source) = corpus_page(CORPUS[2], "Split middle");
8616 let mut editor = CanvasEditor::from_page(source.clone(), &mut engine).unwrap();8754 let mut editor = CanvasEditor::from_page(source.clone(), &mut engine).unwrap();
...@@ -8623,8 +8761,8 @@ mod tests {...@@ -8623,8 +8761,8 @@ mod tests {
8623 editor.focus_outline(body).unwrap();8761 editor.focus_outline(body).unwrap();
8624 editor.insert(&mut engine, "Edited ").unwrap();8762 editor.insert(&mut engine, "Edited ").unwrap();
8625 let page = editor.page().unwrap();8763 let page = editor.page().unwrap();
8626 let written = onestore::PreparedEdit::page(CORPUS[2], space, &page, "Author").unwrap();8764 let written = ops::saved(CORPUS[2], space, &mut editor);
8627 let reread = corpus_pages(written.as_bytes())8765 let reread = corpus_pages(&written)
8628 .into_iter()8766 .into_iter()
8629 .find_map(|(candidate, page)| (candidate == space).then_some(page))8767 .find_map(|(candidate, page)| (candidate == space).then_some(page))
8630 .unwrap();8768 .unwrap();
crates/canvas/src/editor/evidence.rs+2-3
...@@ -784,9 +784,8 @@ fn structural_edits_on_native_pages_survive_the_page_writer() {...@@ -784,9 +784,8 @@ fn structural_edits_on_native_pages_survive_the_page_writer() {
784 press(&mut editor, &mut engine, *key);784 press(&mut editor, &mut engine, *key);
785 }785 }
786 let edited = settled(editor.page().unwrap());786 let edited = settled(editor.page().unwrap());
787 let written = onestore::PreparedEdit::page(&section, space, &edited, "Author")787 let written = super::ops::saved(&section, space, &mut editor);
788 .unwrap_or_else(|error| panic!("{title}: {error}"));788 let (_, reread) = page(&written, title);
789 let (_, reread) = page(written.as_bytes(), title);
790 assert_eq!(settled(reread).objects, edited.objects, "{title}");789 assert_eq!(settled(reread).objects, edited.objects, "{title}");
791 }790 }
792}791}
crates/canvas/src/editor/format.rs+2-2
...@@ -1544,8 +1544,8 @@ mod tests {...@@ -1544,8 +1544,8 @@ mod tests {
1544 }1544 }
1545 let state = editor.format_state().unwrap();1545 let state = editor.format_state().unwrap();
1546 let edited = settled(editor.page().unwrap());1546 let edited = settled(editor.page().unwrap());
1547 let written = onestore::PreparedEdit::page(SECTION, space, &edited, "Author").unwrap();1547 let written = super::ops::saved(SECTION, space, &mut editor);
1548 let (_, reread) = page(written.as_bytes());1548 let (_, reread) = page(&written);
1549 let reread = settled(reread);1549 let reread = settled(reread);
1550 assert_eq!(reread.objects, edited.objects);1550 assert_eq!(reread.objects, edited.objects);
1551 let mut editor = CanvasEditor::from_page(reread, &mut engine).unwrap();1551 let mut editor = CanvasEditor::from_page(reread, &mut engine).unwrap();
crates/canvas/src/editor/ops.rs+21
...@@ -359,6 +359,7 @@ impl CanvasEditor {...@@ -359,6 +359,7 @@ impl CanvasEditor {
359 identity: None,359 identity: None,
360 created: None,360 created: None,
361 margin_origin: [0.0; 2],361 margin_origin: [0.0; 2],
362 color: None,
362 objects,363 objects,
363 definitions: named(&self.definitions, &[&outline.paragraphs]),364 definitions: named(&self.definitions, &[&outline.paragraphs]),
364 };365 };
...@@ -456,6 +457,26 @@ impl CanvasEditor {...@@ -456,6 +457,26 @@ impl CanvasEditor {
456 }457 }
457}458}
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
459#[cfg(test)]480#[cfg(test)]
460mod tests {481mod tests {
461 use super::*;482 use super::*;
crates/canvas/src/gpu/mod.rs+63
...@@ -52,6 +52,22 @@ impl Paper {...@@ -52,6 +52,22 @@ impl Paper {
52 ink: [0.0, 0.0, 0.0, 1.0],52 ink: [0.0, 0.0, 0.0, 1.0],
53 };53 };
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
55 /// A near-neutral colour OneNote draws on white paper, moved onto this paper: its71 /// A near-neutral colour OneNote draws on white paper, moved onto this paper: its
56 /// darkness becomes ink and its tint tints the paper.72 /// darkness becomes ink and its tint tints the paper.
57 pub(crate) fn shade(&self, light: [f32; 4]) -> [f32; 4] {73 pub(crate) fn shade(&self, light: [f32; 4]) -> [f32; 4] {
...@@ -87,6 +103,11 @@ impl Glyphs for TextLayout {...@@ -87,6 +103,11 @@ impl Glyphs for TextLayout {
87 .or(brush.highlight.filter(|&color| color != 0).map(|_| 0))103 .or(brush.highlight.filter(|&color| color != 0).map(|_| 0))
88 .map(colorref)104 .map(colorref)
89 },105 },
106 run.style()
107 .brush
108 .highlight
109 .filter(|&color| color != 0)
110 .map(colorref),
90 paint,111 paint,
91 )?;112 )?;
92 }113 }
...@@ -150,6 +171,48 @@ mod tests {...@@ -150,6 +171,48 @@ mod tests {
150 use onestore::page::text::Paragraph;171 use onestore::page::text::Paragraph;
151 use std::time::Duration;172 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
153 #[test]216 #[test]
154 #[ignore = "requires a native GPU adapter"]217 #[ignore = "requires a native GPU adapter"]
155 fn tags_and_page_text_paint_through_the_renderer() {218 fn tags_and_page_text_paint_through_the_renderer() {
crates/canvas/src/gpu/page.rs+93-45
...@@ -10,6 +10,7 @@ use crate::{...@@ -10,6 +10,7 @@ use crate::{
10 layout::{LayoutError, TextEngine},10 layout::{LayoutError, TextEngine},
11};11};
12use background::Background;12use background::Background;
13pub use background::{template_art, template_picture};
13use draw::Primitive;14use draw::Primitive;
14use onestore::page::Page;15use onestore::page::Page;
15use picture::Picture;16use picture::Picture;
...@@ -492,11 +493,12 @@ impl PageScene {...@@ -492,11 +493,12 @@ impl PageScene {
492 })493 })
493 }494 }
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.
496 pub fn content_bounds<'a>(498 pub fn content_bounds<'a>(
497 &'a self,499 &'a self,
498 editor: &'a CanvasEditor,500 editor: &'a CanvasEditor,
499 ) -> impl Iterator<Item = [f32; 4]> + 'a {501 ) -> impl Iterator<Item = ([f32; 4], bool)> + 'a {
500 self.objects(Some(editor))502 self.objects(Some(editor))
501 .into_iter()503 .into_iter()
502 .flatten()504 .flatten()
...@@ -515,12 +517,15 @@ impl PageScene {...@@ -515,12 +517,15 @@ impl PageScene {
515 Some(editor),517 Some(editor),
516 )518 )
517 .ok()?;519 .ok()?;
518 Some([520 Some((
519 origin[0],521 [
520 origin[1],522 origin[0],
521 origin[0] + layout.size[0],523 origin[1],
522 origin[1] + layout.size[1],524 origin[0] + layout.size[0],
523 ])525 origin[1] + layout.size[1],
526 ],
527 true,
528 ))
524 }529 }
525 Content::Date { below_title } => {530 Content::Date { below_title } => {
526 let date = editor.date()?;531 let date = editor.date()?;
...@@ -534,21 +539,27 @@ impl PageScene {...@@ -534,21 +539,27 @@ impl PageScene {
534 )539 )
535 .ok()?;540 .ok()?;
536 let size = date.layout().size;541 let size = date.layout().size;
537 Some([542 Some((
538 origin[0],543 [
539 origin[1],544 origin[0],
540 origin[0] + size[0],545 origin[1],
541 origin[1] + size[1],546 origin[0] + size[0],
542 ])547 origin[1] + size[1],
548 ],
549 true,
550 ))
543 }551 }
544 Content::Image(source) => Some([552 Content::Image(source) => Some((
545 source.layout.x.unwrap_or(0.0),553 [
546 source.layout.y.unwrap_or(0.0),554 source.layout.x.unwrap_or(0.0),
547 source.layout.x.unwrap_or(0.0) + source.layout.max_width?,555 source.layout.y.unwrap_or(0.0),
548 source.layout.y.unwrap_or(0.0) + source.layout.max_height?,556 source.layout.x.unwrap_or(0.0) + source.layout.max_width?,
549 ]),557 source.layout.y.unwrap_or(0.0) + source.layout.max_height?,
550 Content::Ink(ink) => crate::editor::page::ink_bounds(ink),558 ],
551 Content::ReadOnly(object) => Some(object.rect()),559 false,
560 )),
561 Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)),
562 Content::ReadOnly(object) => Some((object.rect(), true)),
552 Content::Editable(_) => None,563 Content::Editable(_) => None,
553 })564 })
554 }565 }
...@@ -787,7 +798,12 @@ impl PageScene {...@@ -787,7 +798,12 @@ impl PageScene {
787 };798 };
788 outline.append_table_primitives(primitives, object_origin, paper);799 outline.append_table_primitives(primitives, object_origin, paper);
789 let everything = [f32::NEG_INFINITY, f32::INFINITY];800 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 );
791 self.append_outline_objects(outline, object_origin, None, paper, primitives);807 self.append_outline_objects(outline, object_origin, None, paper, primitives);
792 for (index, paragraph) in outline.visible(everything) {808 for (index, paragraph) in outline.visible(everything) {
793 outline.append_paragraph_primitives(809 outline.append_paragraph_primitives(
...@@ -828,6 +844,7 @@ impl crate::outline::OutlineLayout {...@@ -828,6 +844,7 @@ impl crate::outline::OutlineLayout {
828844
829 /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black845 /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black
830 /// highlight paints in the paper's ink, censoring the automatic text on it in any theme.846 /// 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.
831 pub fn append_background_primitives(848 pub fn append_background_primitives(
832 &self,849 &self,
833 primitives: &mut Vec<Primitive<'_>>,850 primitives: &mut Vec<Primitive<'_>>,
...@@ -836,6 +853,27 @@ impl crate::outline::OutlineLayout {...@@ -836,6 +853,27 @@ impl crate::outline::OutlineLayout {
836 paper: Paper,853 paper: Paper,
837 ) {854 ) {
838 for (index, paragraph) in self.visible(rows) {855 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 }
839 for (mut rect, color) in paragraph.text.backgrounds() {877 for (mut rect, color) in paragraph.text.backgrounds() {
840 rect.x0 += f64::from(paragraph.origin[0]);878 rect.x0 += f64::from(paragraph.origin[0]);
841 rect.x1 += f64::from(paragraph.origin[0]);879 rect.x1 += f64::from(paragraph.origin[0]);
...@@ -1046,6 +1084,7 @@ mod tests {...@@ -1046,6 +1084,7 @@ mod tests {
1046 created: Some(1),1084 created: Some(1),
1047 title: "Header".into(),1085 title: "Header".into(),
1048 margin_origin: [36.0, 14.4],1086 margin_origin: [36.0, 14.4],
1087 color: None,
1049 definitions: BTreeMap::new(),1088 definitions: BTreeMap::new(),
1050 objects: vec![PageObject::Title(Title {1089 objects: vec![PageObject::Title(Title {
1051 date: Some(fields[1].id),1090 date: Some(fields[1].id),
...@@ -1062,14 +1101,20 @@ mod tests {...@@ -1062,14 +1101,20 @@ mod tests {
1062 assert!(editor.active_outline().title);1101 assert!(editor.active_outline().title);
1063 assert_eq!(editor.active_outline().origin(), [46.0, 34.4]);1102 assert_eq!(editor.active_outline().origin(), [46.0, 34.4]);
1064 assert_eq!(editor.active_outline().layout().max_width, None);1103 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);
1066 assert_eq!(before[0][0], 48.0);1111 assert_eq!(before[0][0], 48.0);
1067 assert!((before[0][1] - 62.6).abs() < 0.00001);1112 assert!((before[0][1] - 62.6).abs() < 0.00001);
1068 let text = "A long title with multiple wrapped lines ".repeat(10);1113 let text = "A long title with multiple wrapped lines ".repeat(10);
1069 editor.select_all().unwrap();1114 editor.select_all().unwrap();
1070 let end = text.encode_utf16().count() as u32;1115 let end = text.encode_utf16().count() as u32;
1071 editor.compose(&mut engine, text.clone(), end..end).unwrap();1116 editor.compose(&mut engine, text.clone(), end..end).unwrap();
1072 let wrapped = scene.content_bounds(&editor).collect::<Vec<_>>();1117 let wrapped = bounds(&editor);
1073 assert!(wrapped[0][1] > before[0][1] + 20.0);1118 assert!(wrapped[0][1] > before[0][1] + 20.0);
1074 assert_eq!(1119 assert_eq!(
1075 wrapped[0][1],1120 wrapped[0][1],
...@@ -1077,9 +1122,9 @@ mod tests {...@@ -1077,9 +1122,9 @@ mod tests {
1077 );1122 );
1078 editor.cancel_composition(&mut engine).unwrap();1123 editor.cancel_composition(&mut engine).unwrap();
1079 assert_eq!(editor.active_outline().document().nodes(), source);1124 assert_eq!(editor.active_outline().document().nodes(), source);
1080 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), before);1125 assert_eq!(bounds(&editor), before);
1081 editor.commit_text(&mut engine, text).unwrap();1126 editor.commit_text(&mut engine, text).unwrap();
1082 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), wrapped);1127 assert_eq!(bounds(&editor), wrapped);
1083 assert_eq!(editor.active_outline().id, id);1128 assert_eq!(editor.active_outline().id, id);
1084 assert_eq!(1129 assert_eq!(
1085 editor.active_outline().document().nodes()[0].id,1130 editor.active_outline().document().nodes()[0].id,
...@@ -1087,14 +1132,14 @@ mod tests {...@@ -1087,14 +1132,14 @@ mod tests {
1087 );1132 );
1088 editor.undo(&mut engine).unwrap();1133 editor.undo(&mut engine).unwrap();
1089 assert_eq!(editor.active_outline().document().nodes(), source);1134 assert_eq!(editor.active_outline().document().nodes(), source);
1090 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), before);1135 assert_eq!(bounds(&editor), before);
1091 editor.redo(&mut engine).unwrap();1136 editor.redo(&mut engine).unwrap();
1092 editor.select_all().unwrap();1137 editor.select_all().unwrap();
1093 editor.insert(&mut engine, "").unwrap();1138 editor.insert(&mut engine, "").unwrap();
1094 assert_eq!(editor.outlines().len(), 1);1139 assert_eq!(editor.outlines().len(), 1);
1095 assert!(editor.caret_outline().is_none());1140 assert!(editor.caret_outline().is_none());
1096 assert_eq!(editor.active_outline().id, id);1141 assert_eq!(editor.active_outline().id, id);
1097 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), before);1142 assert_eq!(bounds(&editor), before);
1098 assert!(editor.move_outline(id, [0.0; 2]).is_err());1143 assert!(editor.move_outline(id, [0.0; 2]).is_err());
1099 assert!(editor.resize(&mut engine, 90.0).is_err());1144 assert!(editor.resize(&mut engine, 90.0).is_err());
1100 editor.undo(&mut engine).unwrap();1145 editor.undo(&mut engine).unwrap();
...@@ -1111,7 +1156,7 @@ mod tests {...@@ -1111,7 +1156,7 @@ mod tests {
1111 ["A date label that wraps ".repeat(30), String::new()],1156 ["A date label that wraps ".repeat(30), String::new()],
1112 )1157 )
1113 .unwrap();1158 .unwrap();
1114 let changed = scene.content_bounds(&editor).collect::<Vec<_>>();1159 let changed = bounds(&editor);
1115 assert_eq!(changed[0][1], wrapped[0][1]);1160 assert_eq!(changed[0][1], wrapped[0][1]);
1116 assert!(changed[0][3] > wrapped[0][3]);1161 assert!(changed[0][3] > wrapped[0][3]);
1117 let (field, rect) = scene.date_fields(&editor).next().unwrap();1162 let (field, rect) = scene.date_fields(&editor).next().unwrap();
...@@ -1120,12 +1165,12 @@ mod tests {...@@ -1120,12 +1165,12 @@ mod tests {
1120 assert_eq!(rect[3], changed[0][3]);1165 assert_eq!(rect[3], changed[0][3]);
1121 editor.undo(&mut engine).unwrap();1166 editor.undo(&mut engine).unwrap();
1122 assert_eq!(editor.date().unwrap().timestamp(), 1);1167 assert_eq!(editor.date().unwrap().timestamp(), 1);
1123 assert_eq!(scene.content_bounds(&editor).collect::<Vec<_>>(), wrapped);1168 assert_eq!(bounds(&editor), wrapped);
1124 assert_eq!(editor.active_outline().document(), &title);1169 assert_eq!(editor.active_outline().document(), &title);
1125 }1170 }
11261171
1127 #[test]1172 #[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"]
1129 fn native_title_metrics_and_exit() {1174 fn native_title_metrics_and_exit() {
1130 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();1175 let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap();
1131 let store = onestore::Store::parse(&bytes).unwrap();1176 let store = onestore::Store::parse(&bytes).unwrap();
...@@ -1133,11 +1178,6 @@ mod tests {...@@ -1133,11 +1178,6 @@ mod tests {
1133 let document = onestore::document::Document::parse(&index).unwrap();1178 let document = onestore::document::Document::parse(&index).unwrap();
1134 let page = Page::from_document(&document, "Baseline anchors").unwrap();1179 let page = Page::from_document(&document, "Baseline anchors").unwrap();
1135 let mut engine = TextEngine::default();1180 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();
1141 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();1181 let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap();
1142 let title_id = editor1182 let title_id = editor
1143 .outlines()1183 .outlines()
...@@ -1288,7 +1328,7 @@ mod tests {...@@ -1288,7 +1328,7 @@ mod tests {
1288 }1328 }
12891329
1290 #[test]1330 #[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"]
1292 fn native_title_flow() {1332 fn native_title_flow() {
1293 use crate::document::TextDocument;1333 use crate::document::TextDocument;
1294 use onestore::page::Image;1334 use onestore::page::Image;
...@@ -1297,11 +1337,6 @@ mod tests {...@@ -1297,11 +1337,6 @@ mod tests {
1297 let index = onestore::RevisionIndex::parse(&store).unwrap();1337 let index = onestore::RevisionIndex::parse(&store).unwrap();
1298 let document = onestore::document::Document::parse(&index).unwrap();1338 let document = onestore::document::Document::parse(&index).unwrap();
1299 let mut engine = TextEngine::default();1339 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();
1305 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";1340 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";
1306 for (font_size, text, x, y, expected) in [1341 for (font_size, text, x, y, expected) in [
1307 (17.0, long, 120.0, 90.0, 116.18992),1342 (17.0, long, 120.0, 90.0, 116.18992),
...@@ -1448,7 +1483,11 @@ mod tests {...@@ -1448,7 +1483,11 @@ mod tests {
1448 assert_eq!([old[0], old[2]], [new[0], new[2]]);1483 assert_eq!([old[0], old[2]], [new[0], new[2]]);
1449 assert!((old[1] + delta - new[1]).abs() < 0.002);1484 assert!((old[1] + delta - new[1]).abs() < 0.002);
1450 assert!((old[3] + delta - new[3]).abs() < 0.002);1485 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 );
1452 }1491 }
1453 editor.undo(&mut engine).unwrap();1492 editor.undo(&mut engine).unwrap();
1454 assert_eq!(images(&editor), before);1493 assert_eq!(images(&editor), before);
...@@ -1487,6 +1526,7 @@ mod tests {...@@ -1487,6 +1526,7 @@ mod tests {
1487 created: None,1526 created: None,
1488 title: "Header".into(),1527 title: "Header".into(),
1489 margin_origin: [36.0, 14.4],1528 margin_origin: [36.0, 14.4],
1529 color: None,
1490 definitions: BTreeMap::new(),1530 definitions: BTreeMap::new(),
1491 objects: vec![PageObject::Title(Title {1531 objects: vec![PageObject::Title(Title {
1492 date: None,1532 date: None,
...@@ -1561,6 +1601,7 @@ mod tests {...@@ -1561,6 +1601,7 @@ mod tests {
1561 created: None,1601 created: None,
1562 title: String::new(),1602 title: String::new(),
1563 margin_origin: [0.0; 2],1603 margin_origin: [0.0; 2],
1604 color: None,
1564 definitions: BTreeMap::new(),1605 definitions: BTreeMap::new(),
1565 objects,1606 objects,
1566 };1607 };
...@@ -1640,6 +1681,7 @@ mod tests {...@@ -1640,6 +1681,7 @@ mod tests {
1640 created: None,1681 created: None,
1641 title: String::new(),1682 title: String::new(),
1642 margin_origin: [36.0, 14.0],1683 margin_origin: [36.0, 14.0],
1684 color: None,
1643 objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))],1685 objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))],
1644 definitions: BTreeMap::from([(1686 definitions: BTreeMap::from([(
1645 definition,1687 definition,
...@@ -1711,6 +1753,7 @@ mod tests {...@@ -1711,6 +1753,7 @@ mod tests {
1711 created: None,1753 created: None,
1712 title: String::new(),1754 title: String::new(),
1713 margin_origin: [0.0; 2],1755 margin_origin: [0.0; 2],
1756 color: None,
1714 definitions: BTreeMap::new(),1757 definitions: BTreeMap::new(),
1715 objects: vec![1758 objects: vec![
1716 PageObject::Outline(Outline {1759 PageObject::Outline(Outline {
...@@ -1890,6 +1933,7 @@ mod tests {...@@ -1890,6 +1933,7 @@ mod tests {
1890 created: None,1933 created: None,
1891 title: String::new(),1934 title: String::new(),
1892 margin_origin: [0.0; 2],1935 margin_origin: [0.0; 2],
1936 color: None,
1893 definitions: BTreeMap::new(),1937 definitions: BTreeMap::new(),
1894 objects: vec![1938 objects: vec![
1895 outline(2.0, "first"),1939 outline(2.0, "first"),
...@@ -2063,6 +2107,7 @@ mod tests {...@@ -2063,6 +2107,7 @@ mod tests {
2063 created: None,2107 created: None,
2064 title: String::new(),2108 title: String::new(),
2065 margin_origin: [0.0; 2],2109 margin_origin: [0.0; 2],
2110 color: None,
2066 definitions: BTreeMap::new(),2111 definitions: BTreeMap::new(),
2067 objects: vec![PageObject::Ink(ink.clone())],2112 objects: vec![PageObject::Ink(ink.clone())],
2068 };2113 };
...@@ -2071,7 +2116,7 @@ mod tests {...@@ -2071,7 +2116,7 @@ mod tests {
2071 assert!(2116 assert!(
2072 scene2117 scene
2073 .content_bounds(&editor)2118 .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])
2075 );2120 );
2076 let mut primitives = Vec::new();2121 let mut primitives = Vec::new();
2077 scene2122 scene
...@@ -2122,6 +2167,7 @@ mod tests {...@@ -2122,6 +2167,7 @@ mod tests {
2122 created: None,2167 created: None,
2123 title: String::new(),2168 title: String::new(),
2124 margin_origin: [0.0; 2],2169 margin_origin: [0.0; 2],
2170 color: None,
2125 definitions: BTreeMap::new(),2171 definitions: BTreeMap::new(),
2126 objects: vec![PageObject::Image(Image {2172 objects: vec![PageObject::Image(Image {
2127 size: None,2173 size: None,
...@@ -2207,6 +2253,7 @@ mod tests {...@@ -2207,6 +2253,7 @@ mod tests {
2207 created: None,2253 created: None,
2208 title: String::new(),2254 title: String::new(),
2209 margin_origin: [0.0; 2],2255 margin_origin: [0.0; 2],
2256 color: None,
2210 definitions: BTreeMap::new(),2257 definitions: BTreeMap::new(),
2211 objects: vec![PageObject::Image(background), PageObject::Image(picture)],2258 objects: vec![PageObject::Image(background), PageObject::Image(picture)],
2212 };2259 };
...@@ -2274,6 +2321,7 @@ mod tests {...@@ -2274,6 +2321,7 @@ mod tests {
2274 created: None,2321 created: None,
2275 title: String::new(),2322 title: String::new(),
2276 margin_origin: [0.0; 2],2323 margin_origin: [0.0; 2],
2324 color: None,
2277 definitions: BTreeMap::new(),2325 definitions: BTreeMap::new(),
2278 objects: pictures.iter().cloned().map(PageObject::Image).collect(),2326 objects: pictures.iter().cloned().map(PageObject::Image).collect(),
2279 };2327 };
crates/canvas/src/gpu/page/background.rs+577-304
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1//! OneNote 2010's page template art, recognised by the SHA-256 of the embedded picture and1//! OneNote 2010's page template art, recognised by the SHA-256 of OneNote's picture or the
2//! painted from vector recreations that suit the paper.2//! marker in Snowbound's, and painted from vector recreations that suit the paper.
33
4use super::{SETTLE, Slot, density, queue};4use super::{SETTLE, Slot, density, queue};
5use crate::gpu::Paper;5use crate::gpu::Paper;
...@@ -11,303 +11,393 @@ use std::{...@@ -11,303 +11,393 @@ use std::{
11 time::{Duration, Instant},11 time::{Duration, Instant},
12};12};
1313
14/// Each template picture's SHA-256 and its recreation, drawn for white paper in14/// One of OneNote 2010's page templates: its art recreated for white paper in `#rrggbb`
15/// `#rrggbb` colours over a view box stretched to the picture's rectangle.15/// colours over a view box the size in pixels of the picture OneNote stores, that picture's
16const TEMPLATES: &[(&str, &str)] = &[16/// format, and the SHA-256 of each picture OneNote has shipped for it.
17 (17struct Template {
18 "a7fa7f092eff43030a56342c39a765f8d5cc48c7db815ddfc8c1e5ec40117fae",18 name: &'static str,
19 include_str!("../../../assets/backgrounds/bamboo.svg"),19 svg: &'static str,
20 ),20 jpeg: bool,
21 (21 hashes: &'static [&'static str],
22 "74c131777e7c437fd654427417097bc01b0813ba8e1e50e4b937bd50a1bebcdb",22}
23 include_str!("../../../assets/backgrounds/binoculars-corner.svg"),23
24 ),24macro_rules! template {
25 (25 ($name:literal, $format:ident, $($hash:literal),+ $(,)?) => {
26 "9c552717e8d5079bbb226948641ff13532df3d7be434c6ce545f1692fa57d45a",26 Template {
27 include_str!("../../../assets/backgrounds/black-and-green-title.svg"),27 name: $name,
28 ),28 svg: include_str!(concat!("../../../assets/backgrounds/", $name, ".svg")),
29 (29 jpeg: template!(@$format),
30 "3c68c7ee798e62a4a99c740153f3980d7df029605c843410942c7f85e794823b",30 hashes: &[$($hash),+],
31 include_str!("../../../assets/backgrounds/blue-bubbles-corner-margin.svg"),31 }
32 ),32 };
33 (33 (@jpeg) => { true };
34 "d0a3a1c3cd63c4023fe5716cbe2c211307d0e277e444d9ef76c7fc097a845fd4",34 (@png) => { false };
35 include_str!("../../../assets/backgrounds/blue-bubbles-corner-top.svg"),35}
36 ),36
37 (37const TEMPLATES: &[Template] = &[
38 "7966f3d8a2d61ecb49a35e163781858e052c0b122a18a1238afe27b57e2850e8",38 template!(
39 include_str!("../../../assets/backgrounds/blue-clouds.svg"),39 "bamboo",
40 ),40 jpeg,
41 (41 "a7fa7f092eff43030a56342c39a765f8d5cc48c7db815ddfc8c1e5ec40117fae"
42 "139bb0e79f89c3ddef79b1716a5fbab4c07df5785fb3cdf6b4eeddbf6c078452",42 ),
43 include_str!("../../../assets/backgrounds/blue-dots.svg"),43 template!(
44 ),44 "binoculars-corner",
45 (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,
46 "473c90248fa33f8e49b2daf38673d87a9405481eae6a2087cc5384b695b6a4c0",76 "473c90248fa33f8e49b2daf38673d87a9405481eae6a2087cc5384b695b6a4c0",
47 include_str!("../../../assets/backgrounds/blue-flow.svg"),
48 ),
49 (
50 "51e05999a1c9f17df28cb474e57dd8e64bdab824874a532c20a23766a01f8967",77 "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"),
240 ),78 ),
241 (79 template!(
242 "f927c7825851974a2149868146970706523a49165133cee6027a43e8c9abdf27",80 "blue-mist-margin",
243 include_str!("../../../assets/backgrounds/rainbow.svg"),81 jpeg,
244 ),82 "83245f217deae4a4143b565e13c045dbb32a9063e8c6b2e43bb15cd76c5f9219"
245 (83 ),
246 "0e31da4dfcff4a36c64c1ce940362d2309769f36369e4c43c317d5f2fa15658e",84 template!(
247 include_str!("../../../assets/backgrounds/rainbow-border.svg"),85 "blue-stripe-title",
248 ),86 jpeg,
249 (87 "474f9a8c25d5e21192315397ea995b1e11e2c1608157c6e0277688091bfd136a"
250 "2e7aaf26bec32148b96442e8fff1bd2cef2d72630969f23b9a2abedb6cfec93b",88 ),
251 include_str!("../../../assets/backgrounds/rainbow-mini-border.svg"),89 template!(
252 ),90 "blue-stripes",
253 (91 jpeg,
254 "525a857d0eda855a64d3619df58b1c2d013a73e60fa0d49b155ecfcb2c134c7c",92 "06e09de80c3f32254da4fe6b2cbad7c05ef144dd54b8c65745e195bbf7317a2e"
255 include_str!("../../../assets/backgrounds/recipe.svg"),93 ),
256 ),94 template!(
257 (95 "blue-swirls",
258 "b4daa90d5a53fcbc85119050b5b76962443c4dd18d7f42cdc6d4e0ad8efad872",96 jpeg,
259 include_str!("../../../assets/backgrounds/red-and-black-margin.svg"),97 "10c833e47be1c8496f949a6b059c2d79212a4dd66bde62116ea337fa4fe0b654"
260 ),98 ),
261 (99 template!(
262 "14fa2d16310485aa1ce41f6d774a3d637e8cf8b03c4f72990155df274fdb6bd9",100 "blue-wave-title",
263 include_str!("../../../assets/backgrounds/side-stripes.svg"),101 jpeg,
264 ),102 "646586cb71742a2369a529876b41af6a472c35cc508d1ae5d8395d55784814f2"
265 (103 ),
266 "d74c3973c8d1f7c77274691afb1aa934940674341d7eee563be75e563281bdfd",104 template!(
267 include_str!("../../../assets/backgrounds/small-push-pins.svg"),105 "blue-waves",
268 ),106 jpeg,
269 (107 "fbb4573e3bee1b337077691bebae15d6fac52432405d31396d526d7694a8283d"
270 "f091ded5e283af6848670a3172e7c43c6099875d39b3fc69c2bdba914f609602",108 ),
271 include_str!("../../../assets/backgrounds/sparks.svg"),109 template!(
272 ),110 "books",
273 (111 jpeg,
274 "7f5b660a1a0bf46c75aaf19b4f77a0e086de003ec03afc1f58d871d55aa5ba9e",112 "1591fd26e7fff5be97431d0ed3d0ade5cfc5fa74e3d7ec282fd242160ce68c1f"
275 include_str!("../../../assets/backgrounds/stars.svg"),113 ),
276 ),114 template!(
277 (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,
278 "0908a4cfa23f93011176d47f45843e9ca2973030421996e8e27484781f54b0ec",362 "0908a4cfa23f93011176d47f45843e9ca2973030421996e8e27484781f54b0ec",
279 include_str!("../../../assets/backgrounds/swirls.svg"),
280 ),
281 (
282 "8b025b80e7d398229ef19384156fe98aeefcf8f69169d69e1eeba73281b24d7e",363 "8b025b80e7d398229ef19384156fe98aeefcf8f69169d69e1eeba73281b24d7e",
283 include_str!("../../../assets/backgrounds/swirls.svg"),
284 ),364 ),
285 (365 template!(
286 "2a4a3530d652e227ddd5adc096a95f6034718f7c380b07db622022d768815059",366 "tiles",
287 include_str!("../../../assets/backgrounds/tiles.svg"),367 jpeg,
368 "2a4a3530d652e227ddd5adc096a95f6034718f7c380b07db622022d768815059"
288 ),369 ),
289 (370 template!(
290 "d72900e8da72d1a7f3729971aa558e1e9b6e9cf9a0d51e83852e567256dbbfef",371 "triangles-title-margin",
291 include_str!("../../../assets/backgrounds/triangles-title-margin.svg"),372 jpeg,
373 "d72900e8da72d1a7f3729971aa558e1e9b6e9cf9a0d51e83852e567256dbbfef"
292 ),374 ),
293 (375 template!(
294 "2df453410796aec7b9efec00059b6ce64bcf67313a95ae458ba600ea5de14811",376 "triangles-title-top",
295 include_str!("../../../assets/backgrounds/triangles-title-top.svg"),377 jpeg,
378 "2df453410796aec7b9efec00059b6ce64bcf67313a95ae458ba600ea5de14811"
296 ),379 ),
297 (380 template!(
298 "5b321a4d81bd499b289b1755f6450a42047c494dfbc112dbd56da4ced2c15c1a",381 "tulips",
299 include_str!("../../../assets/backgrounds/tulips.svg"),382 jpeg,
383 "5b321a4d81bd499b289b1755f6450a42047c494dfbc112dbd56da4ced2c15c1a"
300 ),384 ),
301 (385 template!(
302 "494547e566fb7a63dd429eb0699fe41aa8998f8ea2f758d813fe3d56c3075719",386 "window-reflection",
303 include_str!("../../../assets/backgrounds/window-reflection.svg"),387 jpeg,
388 "494547e566fb7a63dd429eb0699fe41aa8998f8ea2f758d813fe3d56c3075719"
304 ),389 ),
305 (390 template!(
306 "5a36960df32817e8426bd40a88f88b04fb55b84baef60f1e71e0872217fdb134",391 "writing",
307 include_str!("../../../assets/backgrounds/writing.svg"),392 jpeg,
393 "5a36960df32817e8426bd40a88f88b04fb55b84baef60f1e71e0872217fdb134"
308 ),394 ),
309];395];
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
311/// A raster holds at most this many bytes; page-sized art stops sharpening past about 2.9401/// A raster holds at most this many bytes; page-sized art stops sharpening past about 2.9
312/// device pixels per point.402/// device pixels per point.
313const MAX_RASTER_BYTES: f32 = 16.0 * 1024.0 * 1024.0;403const MAX_RASTER_BYTES: f32 = 16.0 * 1024.0 * 1024.0;
...@@ -335,12 +425,21 @@ impl Background {...@@ -335,12 +425,21 @@ impl Background {
335 self.svg == other.svg && self.size == other.size425 self.svg == other.svg && self.size == other.size
336 }426 }
337427
428 /// OneNote's template pictures by their hash, and those Snowbound wrote by their marker.
338 pub fn recognise(picture: &onestore::page::Image) -> Option<Self> {429 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)
340 .iter()432 .iter()
341 .map(|byte| format!("{byte:02x}"))433 .map(|byte| format!("{byte:02x}"))
342 .collect();434 .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;
344 let size = match (picture.layout.max_width, picture.layout.max_height) {443 let size = match (picture.layout.max_width, picture.layout.max_height) {
345 (Some(width), Some(height)) => [width, height],444 (Some(width), Some(height)) => [width, height],
346 _ => {445 _ => {
...@@ -407,11 +506,17 @@ fn dark(paper: Paper) -> bool {...@@ -407,11 +506,17 @@ fn dark(paper: Paper) -> bool {
407 ink > lightness506 ink > lightness
408}507}
409508
410fn rasterize(svg: &str, dark: Option<Paper>, [width, height]: [u32; 2]) -> RasterImage {509fn rasterize(svg: &str, dark: Option<Paper>, size: [u32; 2]) -> RasterImage {
411 let tree = match dark {510 let pixels = match dark {
412 Some(paper) => parse(&onto(svg, paper)),511 Some(paper) => render(&onto(svg, paper), size),
413 None => parse(svg),512 None => render(svg, size),
414 };513 };
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);
415 let mut pixmap = tiny_skia::Pixmap::new(width, height).expect("Art has a size");520 let mut pixmap = tiny_skia::Pixmap::new(width, height).expect("Art has a size");
416 let view = tree.size();521 let view = tree.size();
417 resvg::render(522 resvg::render(
...@@ -422,15 +527,131 @@ fn rasterize(svg: &str, dark: Option<Paper>, [width, height]: [u32; 2]) -> Raste...@@ -422,15 +527,131 @@ fn rasterize(svg: &str, dark: Option<Paper>, [width, height]: [u32; 2]) -> Raste
422 ),527 ),
423 &mut pixmap.as_mut(),528 &mut pixmap.as_mut(),
424 );529 );
425 let pixels = pixmap530 pixmap
426 .pixels()531 .pixels()
427 .iter()532 .iter()
428 .flat_map(|pixel| {533 .flat_map(|pixel| {
429 let color = pixel.demultiply();534 let color = pixel.demultiply();
430 [color.red(), color.green(), color.blue(), color.alpha()]535 [color.red(), color.green(), color.blue(), color.alpha()]
431 })536 })
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 })
432 .collect();572 .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
434}655}
435656
436fn parse(svg: &str) -> usvg::Tree {657fn parse(svg: &str) -> usvg::Tree {
...@@ -472,7 +693,6 @@ fn onto(svg: &str, paper: Paper) -> String {...@@ -472,7 +693,6 @@ fn onto(svg: &str, paper: Paper) -> String {
472 recoloured693 recoloured
473}694}
474695
475
476#[cfg(test)]696#[cfg(test)]
477mod tests {697mod tests {
478 use super::*;698 use super::*;
...@@ -520,12 +740,8 @@ mod tests {...@@ -520,12 +740,8 @@ mod tests {
520 }740 }
521741
522 fn ivy() -> Background {742 fn ivy() -> Background {
523 let (_, svg) = TEMPLATES
524 .iter()
525 .find(|(_, svg)| svg.contains("id=\"ivy\""))
526 .unwrap();
527 Background {743 Background {
528 svg,744 svg: TEMPLATES.iter().find(|t| t.name == "ivy").unwrap().svg,
529 size: [64.0, 48.0],745 size: [64.0, 48.0],
530 variants: Default::default(),746 variants: Default::default(),
531 }747 }
...@@ -564,19 +780,75 @@ mod tests {...@@ -564,19 +780,75 @@ mod tests {
564 }780 }
565781
566 #[test]782 #[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();
568 let mut hashes = std::collections::BTreeSet::new();785 let mut hashes = std::collections::BTreeSet::new();
569 for (hash, svg) in TEMPLATES {786 for template in TEMPLATES {
570 assert!(787 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
572 );791 );
573 assert!(hashes.insert(hash), "{hash} is listed twice");792 for hash in template.hashes {
574 for svg in [svg.to_string(), onto(svg, DARK)] {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)] {
575 usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap();800 usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap();
576 }801 }
577 }802 }
578 }803 }
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
580 #[test]852 #[test]
581 fn other_pictures_keep_their_own_bytes() {853 fn other_pictures_keep_their_own_bytes() {
582 assert!(Background::recognise(&picture(&red_png())).is_none());854 assert!(Background::recognise(&picture(&red_png())).is_none());
...@@ -634,6 +906,7 @@ mod tests {...@@ -634,6 +906,7 @@ mod tests {
634 created: None,906 created: None,
635 title: String::new(),907 title: String::new(),
636 margin_origin: [0.0; 2],908 margin_origin: [0.0; 2],
909 color: None,
637 definitions: BTreeMap::new(),910 definitions: BTreeMap::new(),
638 objects: vec![PageObject::Image(background)],911 objects: vec![PageObject::Image(background)],
639 };912 };
crates/canvas/src/interaction/accessibility.rs+2
...@@ -736,6 +736,7 @@ pub(super) mod tests {...@@ -736,6 +736,7 @@ pub(super) mod tests {
736 title: "Header".into(),736 title: "Header".into(),
737 created: None,737 created: None,
738 margin_origin: [36.0, 14.4],738 margin_origin: [36.0, 14.4],
739 color: None,
739 definitions: Default::default(),740 definitions: Default::default(),
740 objects: vec![741 objects: vec![
741 PageObject::Title(Title {742 PageObject::Title(Title {
...@@ -828,6 +829,7 @@ pub(super) mod tests {...@@ -828,6 +829,7 @@ pub(super) mod tests {
828 created: None,829 created: None,
829 title: String::new(),830 title: String::new(),
830 margin_origin: [0.0; 2],831 margin_origin: [0.0; 2],
832 color: None,
831 definitions: Default::default(),833 definitions: Default::default(),
832 objects: vec![PageObject::Unsupported(Unsupported {834 objects: vec![PageObject::Unsupported(Unsupported {
833 id: Default::default(),835 id: Default::default(),
crates/canvas/src/interaction/mod.rs+160-52
...@@ -112,6 +112,8 @@ struct Paint {...@@ -112,6 +112,8 @@ struct Paint {
112 /// The document's top and bottom the view shows; paragraphs wholly outside it are112 /// The document's top and bottom the view shows; paragraphs wholly outside it are
113 /// not painted.113 /// not painted.
114 visible: [f32; 2],114 visible: [f32; 2],
115 /// Whether the active outline shows its frame and grips.
116 chrome: bool,
115}117}
116118
117enum Drag {119enum Drag {
...@@ -169,6 +171,14 @@ fn read_only_shortcut(key: &Key, modifiers: Modifiers) -> bool {...@@ -169,6 +171,14 @@ fn read_only_shortcut(key: &Key, modifiers: Modifiers) -> bool {
169 && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift && value.eq_ignore_ascii_case("n"))))171 && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift && value.eq_ignore_ascii_case("n"))))
170}172}
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
172/// A page on screen: the editor, the drawn scene, the view onto them and the pointer and182/// A page on screen: the editor, the drawn scene, the view onto them and the pointer and
173/// keyboard state between events.183/// keyboard state between events.
174pub struct PageView {184pub struct PageView {
...@@ -186,6 +196,9 @@ pub struct PageView {...@@ -186,6 +196,9 @@ pub struct PageView {
186 object_focus: Option<ObjectFocus>,196 object_focus: Option<ObjectFocus>,
187 modifiers: Modifiers,197 modifiers: Modifiers,
188 focused: bool,198 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,
189 /// The caret's opacity in its blink.202 /// The caret's opacity in its blink.
190 caret: f32,203 caret: f32,
191 /// When the caret last moved, which restarts its blink.204 /// When the caret last moved, which restarts its blink.
...@@ -242,6 +255,7 @@ impl PageView {...@@ -242,6 +255,7 @@ impl PageView {
242 object_focus: None,255 object_focus: None,
243 modifiers: Modifiers::default(),256 modifiers: Modifiers::default(),
244 focused: true,257 focused: true,
258 touch: false,
245 caret: 1.0,259 caret: 1.0,
246 blink_from: Instant::now(),260 blink_from: Instant::now(),
247 };261 };
...@@ -272,28 +286,39 @@ impl PageView {...@@ -272,28 +286,39 @@ impl PageView {
272 self.moved()286 self.moved()
273 }287 }
274288
275 /// Shows another page as OneNote opens one, keeping the zoom.289 /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was
276 pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) {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 ) {
277 self.replace(editor, scene);298 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 }
279 }307 }
280308
281 /// OneNote 2010 opens a page scrolled fully left and up, then down just far enough to309 /// Where the page is left, for `open` to return to.
282 /// show the caret's outline 9 pt and 9 px above the view's bottom.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.
283 fn place_opened(&mut self) {320 fn place_opened(&mut self) {
284 let scroll = self.scroll();321 self.viewport.origin = self.scroll().min.map(|offset| -offset);
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);
297 }322 }
298323
299 pub fn modifiers(&self) -> Modifiers {324 pub fn modifiers(&self) -> Modifiers {
...@@ -382,7 +407,21 @@ impl PageView {...@@ -382,7 +407,21 @@ impl PageView {
382 {407 {
383 return Some(Hit::Image { id, handle });408 return Some(Hit::Image { id, handle });
384 }409 }
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))
386 }425 }
387426
388 fn set_object_focus(&mut self, focus: Option<ObjectFocus>) {427 fn set_object_focus(&mut self, focus: Option<ObjectFocus>) {
...@@ -503,6 +542,25 @@ impl PageView {...@@ -503,6 +542,25 @@ impl PageView {
503 Ok(())542 Ok(())
504 }543 }
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
506 /// Reveals the caret or focused object after an edit, then reports the change.564 /// Reveals the caret or focused object after an edit, then reports the change.
507 fn edited(&mut self) -> Result<Response> {565 fn edited(&mut self) -> Result<Response> {
508 self.reveal_focus()?;566 self.reveal_focus()?;
...@@ -554,21 +612,23 @@ impl PageView {...@@ -554,21 +612,23 @@ impl PageView {
554 rect.y1 as f32 + offset[1],612 rect.y1 as f32 + offset[1],
555 ]613 ]
556 });614 });
615 let pad = PAD * self.display_scale;
557 let fixed = self.scene.iter().flat_map(|(scene, offset)| {616 let fixed = self.scene.iter().flat_map(|(scene, offset)| {
558 scene.content_bounds(&self.editor).map(|rect| {617 scene
559 [618 .content_bounds(&self.editor)
560 rect[0] + offset[0],619 .map(move |(rect, padded)| {
561 rect[1] + offset[1],620 (
562 rect[2] + offset[0],621 [
563 rect[3] + offset[1],622 rect[0] + offset[0],
564 ]623 rect[1] + offset[1],
565 })624 rect[2] + offset[0],
625 rect[3] + offset[1],
626 ],
627 if padded { pad } else { 0.0 },
628 )
629 })
566 });630 });
567 scroll::Scroll::new(631 scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed))
568 self.viewport,
569 PAD * self.display_scale,
570 editable.chain(fixed),
571 )
572 }632 }
573633
574 /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`,634 /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`,
...@@ -654,6 +714,7 @@ impl PageView {...@@ -654,6 +714,7 @@ impl PageView {
654 colors,714 colors,
655 visible: [0.0, self.viewport.size[1] as f32]715 visible: [0.0, self.viewport.size[1] as f32]
656 .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale),716 .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale),
717 chrome: !self.touch || self.focused,
657 },718 },
658 )719 )
659 }720 }
...@@ -681,7 +742,7 @@ impl PageView {...@@ -681,7 +742,7 @@ impl PageView {
681 let (caret, hold) = edit::caret_blink(now.saturating_duration_since(self.blink_from));742 let (caret, hold) = edit::caret_blink(now.saturating_duration_since(self.blink_from));
682 let repaint = caret != self.caret;743 let repaint = caret != self.caret;
683 self.caret = caret;744 self.caret = caret;
684 (repaint, Some(now + hold))745 (repaint, hold.map(|hold| now + hold))
685 }746 }
686747
687 /// `size` in device pixels.748 /// `size` in device pixels.
...@@ -863,8 +924,12 @@ impl PageView {...@@ -863,8 +924,12 @@ impl PageView {
863 }924 }
864925
865 pub fn pointer_released(&mut self) -> Result<Response> {926 pub fn pointer_released(&mut self) -> Result<Response> {
866 let preview = self.outline_preview();927 // As in OneNote, an outline or picture dropped outside the view stays where it was.
867 if let Some((id, origin, size)) = self.image_preview() {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) {
868 self.editor933 self.editor
869 .place_image(&mut self.engine, id, origin, size)?;934 .place_image(&mut self.engine, id, origin, size)?;
870 }935 }
...@@ -922,7 +987,8 @@ impl PageView {...@@ -922,7 +987,8 @@ impl PageView {
922987
923 /// An input method's marked text; `cursor` is its UTF-8 selection within `text`.988 /// An input method's marked text; `cursor` is its UTF-8 selection within `text`.
924 pub fn compose(&mut self, text: String, cursor: Option<(usize, usize)>) -> Result<Response> {989 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() {
926 return Ok(Response::default());992 return Ok(Response::default());
927 }993 }
928 if text.is_empty() {994 if text.is_empty() {
...@@ -962,6 +1028,21 @@ impl PageView {...@@ -962,6 +1028,21 @@ impl PageView {
962 self.edited()1028 self.edited()
963 }1029 }
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
965 /// The date or time chosen after `Request::EditDate`.1046 /// The date or time chosen after `Request::EditDate`.
966 pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result<Response> {1047 pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result<Response> {
967 self.editor.change_date(&mut self.engine, timestamp, text)?;1048 self.editor.change_date(&mut self.engine, timestamp, text)?;
...@@ -1111,17 +1192,9 @@ impl PageView {...@@ -1111,17 +1192,9 @@ impl PageView {
1111 self.set_object_focus(None);1192 self.set_object_focus(None);
1112 }1193 }
1113 "a" => self.editor.select_all()?,1194 "a" => self.editor.select_all()?,
1114 "z" => {1195 "z" => return self.undo(shift),
1115 if shift {1196 "y" if edit::Platform::CURRENT == edit::Platform::Windows => {
1116 self.editor.redo(&mut self.engine)?;1197 return self.undo(true);
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 }
1125 }1198 }
1126 "c" | "x" => {1199 "c" | "x" => {
1127 let [anchor, focus] = self.editor.selection().positions;1200 let [anchor, focus] = self.editor.selection().positions;
...@@ -1166,6 +1239,13 @@ impl PageView {...@@ -1166,6 +1239,13 @@ impl PageView {
1166 if let Some(Command::Move(movement)) = chord {1239 if let Some(Command::Move(movement)) = chord {
1167 self.editor1240 self.editor
1168 .move_selection(&mut self.engine, movement, shift)?;1241 .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)?;
1169 } else if self.editor.marked_range().is_none() {1249 } else if self.editor.marked_range().is_none() {
1170 match (chord, key) {1250 match (chord, key) {
1171 (Some(Command::DeleteTo(movement)), _) => {1251 (Some(Command::DeleteTo(movement)), _) => {
...@@ -1249,6 +1329,21 @@ pub fn page_hit_test(...@@ -1249,6 +1329,21 @@ pub fn page_hit_test(
1249 scene: Option<&(PageScene, [f32; 2])>,1329 scene: Option<&(PageScene, [f32; 2])>,
1250 point: [f32; 2],1330 point: [f32; 2],
1251 pixel: f32,1331 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>,
1252) -> Option<Hit> {1347) -> Option<Hit> {
1253 /// Grips and text resolve front to back before any outline's padding, as a native1348 /// Grips and text resolve front to back before any outline's padding, as a native
1254 /// width handle stays reachable under the next outline's left padding; the typing room1349 /// width handle stays reachable under the next outline's left padding; the typing room
...@@ -1278,19 +1373,31 @@ pub fn page_hit_test(...@@ -1278,19 +1373,31 @@ pub fn page_hit_test(
1278 let local = [point[0] - offset[0], point[1] - offset[1]];1373 let local = [point[0] - offset[0], point[1] - offset[1]];
1279 let [x, y] = local;1374 let [x, y] = local;
1280 if layer == Layer::Grips {1375 if layer == Layer::Grips {
1376 let extra = if touched == Some(outline.id) && !outline.title {
1377 TOUCH_REACH * pixel
1378 } else {
1379 0.0
1380 };
1281 // Width handles: 15 px inside to 6 px outside the header's right end, and 6 px1381 // Width handles: 15 px inside to 6 px outside the header's right end, and 6 px
1282 // either side of the right border below it.1382 // 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 };
1284 if !outline.title1388 if !outline.title
1285 && (bounds[2] - inside * pixel..=bounds[2] + 6.0 * pixel).contains(&x)1389 && (bounds[2] - inside..=bounds[2] + 6.0 * pixel + extra).contains(&x)
1286 && (bounds[1]..=bounds[3]).contains(&y)1390 && (bounds[1] - extra..=bounds[3] + extra).contains(&y)
1287 {1391 {
1288 return Some(Hit::Resize {1392 return Some(Hit::Resize {
1289 id: outline.id,1393 id: outline.id,
1290 grab: point[0] - outline.bounds().x1 as f32,1394 grab: point[0] - outline.bounds().x1 as f32,
1291 });1395 });
1292 }1396 }
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 {
1294 return Some(Hit::Handle {1401 return Some(Hit::Handle {
1295 id: outline.id,1402 id: outline.id,
1296 grab: [1403 grab: [
...@@ -1397,7 +1504,7 @@ fn page_primitives<'a>(...@@ -1397,7 +1504,7 @@ fn page_primitives<'a>(
1397 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,1504 Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin,
1398 _ => outline.origin(),1505 _ => outline.origin(),
1399 };1506 };
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)
1401 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)1508 || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id)
1402 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)1509 || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id)
1403 {1510 {
...@@ -1794,6 +1901,7 @@ fn append_outline<'a>(...@@ -1794,6 +1901,7 @@ fn append_outline<'a>(
1794 pixel,1901 pixel,
1795 colors,1902 colors,
1796 visible,1903 visible,
1904 ..
1797 } = paint;1905 } = paint;
1798 let rows = [visible[0] - y, visible[1] - y];1906 let rows = [visible[0] - y, visible[1] - y];
1799 outline1907 outline
crates/canvas/src/interaction/scroll.rs+26-8
...@@ -9,19 +9,19 @@ pub struct Scroll {...@@ -9,19 +9,19 @@ pub struct Scroll {
9}9}
1010
11impl Scroll {11impl Scroll {
12 /// `pad` is the room, in device pixels, OneNote leaves beyond content it scrolls to.12 /// Each content rectangle comes with the room, in device pixels, OneNote leaves beyond
13 pub fn new(viewport: Viewport, pad: f32, bounds: impl Iterator<Item = [f32; 4]>) -> Self {13 /// it left and up.
14 let mut min = [f32::INFINITY; 2];14 pub fn new(viewport: Viewport, bounds: impl Iterator<Item = ([f32; 4], f32)>) -> Self {
15 let mut min = [0.0_f32; 2];
15 let mut max = [0.0_f32; 2];16 let mut max = [0.0_f32; 2];
16 for rect in bounds {17 for (rect, pad) in bounds {
17 for axis in 0..2 {18 for axis in 0..2 {
18 min[axis] = min[axis].min(rect[axis]);19 min[axis] = min[axis].min(rect[axis] * viewport.scale - pad);
19 max[axis] = max[axis].max(rect[axis + 2]);20 max[axis] = max[axis].max(rect[axis + 2]);
20 }21 }
21 }22 }
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.
23 for axis in 0..2 {24 for axis in 0..2 {
24 min[axis] = (min[axis] * viewport.scale - pad).min(0.0);
25 max[axis] =25 max[axis] =
26 ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]);26 ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]);
27 }27 }
...@@ -46,11 +46,29 @@ mod tests {...@@ -46,11 +46,29 @@ mod tests {
46 scale: 2.0,46 scale: 2.0,
47 origin: [9999.0; 2],47 origin: [9999.0; 2],
48 };48 };
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 );
50 scroll.clamp(&mut viewport);53 scroll.clamp(&mut viewport);
51 assert_eq!(viewport.origin, [171.0, 51.0]);54 assert_eq!(viewport.origin, [171.0, 51.0]);
52 viewport.origin = [-9999.0; 2];55 viewport.origin = [-9999.0; 2];
53 scroll.clamp(&mut viewport);56 scroll.clamp(&mut viewport);
54 assert_eq!(viewport.origin, [-1272.0, -1872.0]);57 assert_eq!(viewport.origin, [-1272.0, -1872.0]);
55 }58 }
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 }
56}74}
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 {...@@ -20,6 +20,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint {
20 pixel: display_scale / scale,20 pixel: display_scale / scale,
21 colors: COLORS,21 colors: COLORS,
22 visible: [f32::NEG_INFINITY, f32::INFINITY],22 visible: [f32::NEG_INFINITY, f32::INFINITY],
23 chrome: true,
23 }24 }
24}25}
2526
...@@ -47,6 +48,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow()...@@ -47,6 +48,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow()
47 title: "Header".into(),48 title: "Header".into(),
48 created: Some(1),49 created: Some(1),
49 margin_origin: [36.0, 14.4],50 margin_origin: [36.0, 14.4],
51 color: None,
50 definitions: Default::default(),52 definitions: Default::default(),
51 objects: vec![onestore::page::PageObject::Title(onestore::page::Title {53 objects: vec![onestore::page::PageObject::Title(onestore::page::Title {
52 id: onestore::ExGuid::default(),54 id: onestore::ExGuid::default(),
...@@ -530,6 +532,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {...@@ -530,6 +532,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() {
530 title: String::new(),532 title: String::new(),
531 created: None,533 created: None,
532 margin_origin: [0.0; 2],534 margin_origin: [0.0; 2],
535 color: None,
533 definitions: Default::default(),536 definitions: Default::default(),
534 objects: vec![onestore::page::PageObject::Outline(source)],537 objects: vec![onestore::page::PageObject::Outline(source)],
535 };538 };
...@@ -652,6 +655,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() {...@@ -652,6 +655,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() {
652 identity: None,655 identity: None,
653 created: None,656 created: None,
654 margin_origin: [36.0, 14.4],657 margin_origin: [36.0, 14.4],
658 color: None,
655 definitions: Default::default(),659 definitions: Default::default(),
656 objects: vec![PageObject::Outline(source)],660 objects: vec![PageObject::Outline(source)],
657 },661 },
...@@ -828,6 +832,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {...@@ -828,6 +832,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
828 pixel: 1.0,832 pixel: 1.0,
829 colors: COLORS,833 colors: COLORS,
830 visible: [f32::NEG_INFINITY, f32::INFINITY],834 visible: [f32::NEG_INFINITY, f32::INFINITY],
835 chrome: true,
831 },836 },
832 &mut primitives,837 &mut primitives,
833 )838 )
...@@ -899,6 +904,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {...@@ -899,6 +904,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {
899 pixel: 1.0,904 pixel: 1.0,
900 colors: COLORS,905 colors: COLORS,
901 visible: [f32::NEG_INFINITY, f32::INFINITY],906 visible: [f32::NEG_INFINITY, f32::INFINITY],
907 chrome: true,
902 },908 },
903 &mut primitives,909 &mut primitives,
904 )910 )
...@@ -1157,6 +1163,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {...@@ -1157,6 +1163,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() {
1157 created: None,1163 created: None,
1158 title: String::new(),1164 title: String::new(),
1159 margin_origin: [0.0; 2],1165 margin_origin: [0.0; 2],
1166 color: None,
1160 definitions: Default::default(),1167 definitions: Default::default(),
1161 objects,1168 objects,
1162 };1169 };
...@@ -1389,6 +1396,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {...@@ -1389,6 +1396,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() {
1389 created: None,1396 created: None,
1390 title: String::new(),1397 title: String::new(),
1391 margin_origin: [0.0; 2],1398 margin_origin: [0.0; 2],
1399 color: None,
1392 definitions: Default::default(),1400 definitions: Default::default(),
1393 objects: vec![PageObject::Unsupported(Unsupported {1401 objects: vec![PageObject::Unsupported(Unsupported {
1394 id: Default::default(),1402 id: Default::default(),
...@@ -1503,6 +1511,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) {...@@ -1503,6 +1511,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) {
1503 identity: None,1511 identity: None,
1504 created: None,1512 created: None,
1505 margin_origin: [36.0, 14.4],1513 margin_origin: [36.0, 14.4],
1514 color: None,
1506 definitions: Default::default(),1515 definitions: Default::default(),
1507 objects: vec![PageObject::Outline(source)],1516 objects: vec![PageObject::Outline(source)],
1508 },1517 },
...@@ -1539,6 +1548,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() {...@@ -1539,6 +1548,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() {
1539 identity: None,1548 identity: None,
1540 created: None,1549 created: None,
1541 margin_origin: [36.0, 14.4],1550 margin_origin: [36.0, 14.4],
1551 color: None,
1542 definitions: Default::default(),1552 definitions: Default::default(),
1543 objects,1553 objects,
1544 };1554 };
...@@ -1660,6 +1670,16 @@ fn events_route_through_the_view_as_the_host_delivers_them() {...@@ -1660,6 +1670,16 @@ fn events_route_through_the_view_as_the_host_delivers_them() {
1660 let origin = view.editor.active_outline().origin();1670 let origin = view.editor.active_outline().origin();
1661 assert_eq!(origin, [72.0, 50.4]);1671 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
1663 // An empty spot places a new caret there, 7 px above the pointer on the grid.1683 // An empty spot places a new caret there, 7 px above the pointer on the grid.
1664 click(&mut view, [400.0, 300.0], now + Duration::from_secs(3));1684 click(&mut view, [400.0, 300.0], now + Duration::from_secs(3));
1665 let caret = view.editor.caret_outline().unwrap().origin();1685 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() {...@@ -1835,7 +1855,7 @@ fn a_dragged_outline_snaps_to_the_margin_grid_as_onenote_stores_it() {
18351855
1836/// OneNote 2010 at 75 to 150% opens a page scrolled fully left and up: 11 px beyond 7.5 pt1856/// OneNote 2010 at 75 to 150% opens a page scrolled fully left and up: 11 px beyond 7.5 pt
1837/// left of text, a list marker, 0.75 pt into a tag's slot, or 6 pt above an outline, never1857/// 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.
1839#[test]1859#[test]
1840fn a_page_opens_where_onenote_places_the_view() {1860fn a_page_opens_where_onenote_places_the_view() {
1841 use crate::editor::{Formatting, NoteTag};1861 use crate::editor::{Formatting, NoteTag};
...@@ -1880,7 +1900,133 @@ fn a_page_opens_where_onenote_places_the_view() {...@@ -1880,7 +1900,133 @@ fn a_page_opens_where_onenote_places_the_view() {
1880 "{origin:?} {view:?}"1900 "{origin:?} {view:?}"
1881 );1901 );
1882 }1902 }
1883 // Content far down scrolls into view, 9 pt and 9 px above the bottom.
1884 let (_, view) = open([50.0, 900.0], false);1903 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());
1886}2032}
crates/canvas/src/layout.rs+150-19
...@@ -4,10 +4,11 @@ use parley::{...@@ -4,10 +4,11 @@ use parley::{
4 FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, OverflowWrap,4 FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, OverflowWrap,
5 PositionedLayoutItem, StyleProperty,5 PositionedLayoutItem, StyleProperty,
6 editing::{Cursor, Selection},6 editing::{Cursor, Selection},
7 fontique::{Blob, FontInfo},
7};8};
8use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId};9use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId};
9use std::{10use std::{
10 collections::BTreeSet,11 collections::{BTreeMap, BTreeSet},
11 fmt,12 fmt,
12 ops::Range,13 ops::Range,
13 sync::{14 sync::{
...@@ -16,11 +17,20 @@ use std::{...@@ -16,11 +17,20 @@ use std::{
16 },17 },
17};18};
1819
19#[derive(Default)]
20pub struct TextEngine {20pub struct TextEngine {
21 pub fonts: FontContext,21 pub fonts: FontContext,
22 context: LayoutContext<TextBrush>,22 context: LayoutContext<TextBrush>,
23 arial_substitutes: BTreeSet<u64>,23 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,
24}34}
2535
26/// Superscripts and subscripts draw at this fraction of their run's size.36/// Superscripts and subscripts draw at this fraction of their run's size.
...@@ -69,7 +79,9 @@ pub enum LayoutError {...@@ -69,7 +79,9 @@ pub enum LayoutError {
69impl fmt::Display for LayoutError {79impl fmt::Display for LayoutError {
70 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71 match self {81 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 }
73 _ => write!(f, "Text layout failed: {self:?}"),85 _ => write!(f, "Text layout failed: {self:?}"),
74 }86 }
75 }87 }
...@@ -77,12 +89,75 @@ impl fmt::Display for LayoutError {...@@ -77,12 +89,75 @@ impl fmt::Display for LayoutError {
7789
78impl std::error::Error for LayoutError {}90impl 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
80impl TextEngine {153impl TextEngine {
81 /// Register Arimo as Arial or Carlito as Calibri before creating layouts.154 /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as
82 pub fn register_substitute(155 /// Courier New before creating layouts, in place of the bundled substitute.
83 &mut self,156 pub fn register_substitute(&mut self, data: Blob<u8>) -> Result<&'static str, LayoutError> {
84 data: parley::fontique::Blob<u8>,157 self.register(data, false)
85 ) -> Result<&'static str, LayoutError> {158 }
159
160 fn register(&mut self, data: Blob<u8>, bundled: bool) -> Result<&'static str, LayoutError> {
86 let font =161 let font =
87 FontRef::new(data.as_ref()).map_err(|_| LayoutError::UnsupportedSubstituteFont)?;162 FontRef::new(data.as_ref()).map_err(|_| LayoutError::UnsupportedSubstituteFont)?;
88 let family = font163 let family = font
...@@ -94,9 +169,11 @@ impl TextEngine {...@@ -94,9 +169,11 @@ impl TextEngine {
94 })169 })
95 .map(|name| name.to_string())170 .map(|name| name.to_string())
96 .ok_or(LayoutError::UnsupportedSubstituteFont)?;171 .ok_or(LayoutError::UnsupportedSubstituteFont)?;
97 let target = match family.as_str() {172 let (substitute, target) = match family.as_str() {
98 "Arimo" => "Arial",173 "Arimo" => ("Arimo", "Arial"),
99 "Carlito" => "Calibri",174 "Carlito" => ("Carlito", "Calibri"),
175 "Tinos" => ("Tinos", "Times New Roman"),
176 "Cousine" => ("Cousine", "Courier New"),
100 _ => return Err(LayoutError::UnsupportedSubstituteFont),177 _ => return Err(LayoutError::UnsupportedSubstituteFont),
101 };178 };
102 let id = data.id();179 let id = data.id();
...@@ -110,8 +187,23 @@ impl TextEngine {...@@ -110,8 +187,23 @@ impl TextEngine {
110 return Err(LayoutError::InvalidFontMetrics);187 return Err(LayoutError::InvalidFontMetrics);
111 }188 }
112 }189 }
113 let registered = self.fonts.collection.register_fonts(190 let collection = &mut self.fonts.collection;
114 data,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(),
115 Some(parley::fontique::FontInfoOverride {207 Some(parley::fontique::FontInfoOverride {
116 family_name: Some(target),208 family_name: Some(target),
117 ..Default::default()209 ..Default::default()
...@@ -123,9 +215,25 @@ impl TextEngine {...@@ -123,9 +215,25 @@ impl TextEngine {
123 if target == "Arial" {215 if target == "Arial" {
124 self.arial_substitutes.insert(id);216 self.arial_substitutes.insert(id);
125 }217 }
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 );
126 Ok(target)229 Ok(target)
127 }230 }
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
129 fn shape(237 fn shape(
130 &mut self,238 &mut self,
131 paragraph: &Paragraph,239 paragraph: &Paragraph,
...@@ -466,14 +574,9 @@ mod tests {...@@ -466,14 +574,9 @@ mod tests {
466 use onestore::document::Format;574 use onestore::document::Format;
467575
468 #[test]576 #[test]
469 #[ignore = "requires Arial and CANVAS_TEST_SUBSTITUTE pointing to Carlito"]577 #[ignore = "requires Arial"]
470 fn native_unkerned_advances() {578 fn native_unkerned_advances() {
471 let mut engine = TextEngine::default();579 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();
477 for (font, expected) in [("Calibri", 149.63843), ("Arial", 184.58305)] {580 for (font, expected) in [("Calibri", 149.63843), ("Arial", 184.58305)] {
478 let paragraph = Paragraph::new(581 let paragraph = Paragraph::new(
479 "AVATAR AVATAR SECOND OFFICE".into(),582 "AVATAR AVATAR SECOND OFFICE".into(),
...@@ -489,6 +592,34 @@ mod tests {...@@ -489,6 +592,34 @@ mod tests {
489 }592 }
490 }593 }
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
492 #[test]623 #[test]
493 fn rejected_substitute_leaves_font_selection_unchanged() {624 fn rejected_substitute_leaves_font_selection_unchanged() {
494 let mut engine = TextEngine::default();625 let mut engine = TextEngine::default();
crates/canvas/src/lib.rs+1
...@@ -4,6 +4,7 @@ pub mod language;...@@ -4,6 +4,7 @@ pub mod language;
4pub mod layout;4pub mod layout;
5pub mod math;5pub mod math;
6pub mod outline;6pub mod outline;
7pub mod template;
78
8pub mod date;9pub mod date;
9#[cfg(feature = "gpu")]10#[cfg(feature = "gpu")]
crates/canvas/src/outline.rs+5-6
...@@ -203,6 +203,9 @@ pub struct ParagraphLayout {...@@ -203,6 +203,9 @@ pub struct ParagraphLayout {
203 pub(crate) parent: Option<ExGuid>,203 pub(crate) parent: Option<ExGuid>,
204 /// An equation draws in two dimensions in place of its linear text.204 /// An equation draws in two dimensions in place of its linear text.
205 pub math: Option<crate::math::MathLayout>,205 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>,
206}209}
207210
208#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]211#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
...@@ -626,6 +629,7 @@ impl ParagraphLayout {...@@ -626,6 +629,7 @@ impl ParagraphLayout {
626 tags,629 tags,
627 math,630 math,
628 parent: paragraph.parent,631 parent: paragraph.parent,
632 band: paragraph.format.highlight,
629 };633 };
630 result.place_tags(result.marker_left());634 result.place_tags(result.marker_left());
631 Ok(result)635 Ok(result)
...@@ -1688,7 +1692,7 @@ mod tests {...@@ -1688,7 +1692,7 @@ mod tests {
1688 }1692 }
16891693
1690 #[test]1694 #[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"]
1692 fn native_table_layout() {1696 fn native_table_layout() {
1693 use onestore::page::{Page, PageObject};1697 use onestore::page::{Page, PageObject};
1694 use onestore::{RevisionIndex, Store, document::Document};1698 use onestore::{RevisionIndex, Store, document::Document};
...@@ -1697,11 +1701,6 @@ mod tests {...@@ -1697,11 +1701,6 @@ mod tests {
1697 let index = RevisionIndex::parse(&store).unwrap();1701 let index = RevisionIndex::parse(&store).unwrap();
1698 let document = Document::parse(&index).unwrap();1702 let document = Document::parse(&index).unwrap();
1699 let mut engine = TextEngine::default();1703 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();
1705 let page = Page::from_document(&document, "rows").unwrap();1704 let page = Page::from_document(&document, "rows").unwrap();
1706 let outline = page1705 let outline = page
1707 .objects1706 .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,6 +1,6 @@
1//! The ops the editor records for each edit, applied to the section, store what the edited1//! The ops the editor records for each edit, applied to the section, store what the model
2//! page lowered whole (`Section::apply_page(editor.page())`) stores: every editor operation2//! oracle (`op::predict`) predicts from them, read back so once sealed, and hold the editor's
3//! alone and in random sequences with undo and redo, on every page of the corpus sections3//! 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
4//! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural4//! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural
5//! probe section, which `structural_roundtrip` sweeps, takes minutes).5//! probe section, which `structural_roundtrip` sweeps, takes minutes).
66
...@@ -11,8 +11,9 @@ use canvas::{...@@ -11,8 +11,9 @@ use canvas::{
11};11};
12use draw::edit::Movement;12use draw::edit::Movement;
13use onestore::{13use onestore::{
14 Arena, ExGuid, Section,14 Arena, ExGuid, RevisionIndex, Section, Store,
15 op::{Edit, Op},15 document::{Document, Kind as Node},
16 op::{Edit, Op, PageOp},
16 page::Page,17 page::Page,
17};18};
18use std::{collections::BTreeMap, path::Path};19use std::{collections::BTreeMap, path::Path};
...@@ -372,7 +373,7 @@ fn perform(...@@ -372,7 +373,7 @@ fn perform(
372 }373 }
373}374}
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.
376#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]377#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
377enum Outcome {378enum Outcome {
378 /// The ops store the editor's page, or what that page lowered whole stores.379 /// The ops store the editor's page, or what that page lowered whole stores.
...@@ -383,7 +384,8 @@ enum Outcome {...@@ -383,7 +384,8 @@ enum Outcome {
383 /// stored.384 /// stored.
384 OpsOnly,385 OpsOnly,
385 /// The paths part where the section already stored the page differently from the386 /// 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.
387 Normalized,389 Normalized,
388 Differs(String),390 Differs(String),
389}391}
...@@ -395,116 +397,190 @@ struct Stored<'a> {...@@ -395,116 +397,190 @@ struct Stored<'a> {
395 at: u64,397 at: u64,
396}398}
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
398impl Stored<'_> {435impl Stored<'_> {
399 /// Stores the editor's step as its ops and, unless they store the editor's page,436 /// Stores the editor's step as its ops, checks the stored page against what the model
400 /// compares with that page lowered whole onto the section as it was before the step.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.
401 fn step(&mut self, editor: &mut CanvasEditor, model: &Page) -> Outcome {440 fn step(&mut self, editor: &mut CanvasEditor, model: &Page) -> Outcome {
402 self.at += 10_000_000;441 self.at += 10_000_000;
403 let image = self.section.image();442 let image = self.section.image();
404 let reads_as = |stored: Page, model: &Page| {443 let before = self.section.page(self.space).unwrap();
405 Page {444 let normalized = !reads_as(&before, model);
406 title: model.title.clone(),
407 ..stored
408 } == *model
409 };
410 let normalized = !reads_as(self.section.page(self.space).unwrap(), model);
411 let ops = editor.take_ops().map_err(|error| error.to_string());445 let ops = editor.take_ops().map_err(|error| error.to_string());
412 let edit = ops.clone().map(|ops| Edit {446 let applied = ops.clone().and_then(|ops| {
413 at: self.at,447 if ops.is_empty() {
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() {
424 return Ok(());448 return Ok(());
425 }449 }
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 };
426 self.section460 self.section
427 .apply("Sweep", &edit)461 .apply("Sweep", &edit)
428 .map_err(|error| error.to_string())462 .map_err(|error| error.to_string())
429 });463 });
464 let stored = self.section.page(self.space).unwrap();
430 let edited = editor.page().unwrap();465 let edited = editor.page().unwrap();
431 if applied.is_ok() && reads_as(self.section.page(self.space).unwrap(), &edited) {466 let outcome = self.classify(image, &before, &stored, &edited, &ops, applied, normalized);
432 self.section.seal().unwrap();467 if !matches!(outcome, Outcome::Same | Outcome::Refused)
433 return Outcome::Same;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 }
434 }533 }
435 let arena = Arena::default();534 let arena = Arena::default();
436 let mut whole = Section::open(&arena, image).unwrap();535 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)
438 .map_err(|error| error.to_string())537 .map_err(|error| error.to_string())
439 .and_then(|ops| {538 .and_then(|ops| {
440 let edit = Edit {539 let edit = Edit {
441 at: self.at,540 at: self.at,
442 ops: ops541 ops: ops
443 .iter()542 .into_iter()
444 .map(|op| Op::Page {543 .map(|op| Op::Page {
445 space: self.space,544 space: self.space,
446 op: op.clone(),545 op,
447 })546 })
448 .collect(),547 .collect(),
449 };548 };
450 whole549 whole
451 .apply("Sweep", &edit)550 .apply("Sweep", &edit)
452 .map_err(|error| error.to_string())?;551 .map_err(|error| error.to_string())
453 Ok(ops)
454 });552 });
455 let outcome = match (applied, &lowered) {553 match (applied, lowered) {
456 (Err(error), Err(whole)) => {554 (Err(error), Err(whole)) => {
457 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {555 if std::env::var_os("OPS_SWEEP_DEBUG").is_some() {
458 eprintln!("BOTH {error} | {whole}");556 eprintln!("BOTH {error} | {whole}");
459 }557 }
460 Outcome::Refused558 Outcome::Refused
461 }559 }
462 (Ok(()), Ok(_)) => {560 (Ok(()), Ok(())) => {
463 let (a, b) = (561 let whole = whole.page(self.space).unwrap();
464 self.section.page(self.space).unwrap(),562 if *stored == whole {
465 whole.page(self.space).unwrap(),
466 );
467 if a == b {
468 Outcome::Same563 Outcome::Same
469 } else if normalized {564 } else if normalized {
470 Outcome::Normalized565 Outcome::Normalized
471 } else {566 } else {
472 Outcome::Differs(difference(&a, &b))567 Outcome::Differs(difference(stored, &whole))
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)]);
492 }568 }
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);
501 }569 }
570 (Err(_), Ok(())) if normalized => Outcome::Normalized,
571 (Err(error), Ok(())) => Outcome::Differs(format!("ops refused: {error}")),
572 (Ok(()), Err(_)) => Outcome::OpsOnly,
502 }573 }
503 self.section.seal().unwrap();
504 outcome
505 }574 }
506}575}
507576
577fn reads_as(stored: &Page, model: &Page) -> bool {
578 Page {
579 title: model.title.clone(),
580 ..stored.clone()
581 } == *model
582}
583
508/// The first place two pages part: an object missing from one, then objects by identity,584/// The first place two pages part: an object missing from one, then objects by identity,
509/// then their order.585/// then their order.
510fn difference(a: &Page, b: &Page) -> String {586fn difference(a: &Page, b: &Page) -> String {
...@@ -613,7 +689,7 @@ fn sweep(...@@ -613,7 +689,7 @@ fn sweep(
613}689}
614690
615#[test]691#[test]
616fn editor_ops_store_what_the_page_lowered_whole_stores() {692fn editor_ops_store_the_editor_s_page() {
617 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");693 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
618 let mut paths: Vec<_> = SECTIONS.iter().map(|path| root.join(path)).collect();694 let mut paths: Vec<_> = SECTIONS.iter().map(|path| root.join(path)).collect();
619 if let Some(extra) = std::env::var_os("OPS_SWEEP_SECTIONS") {695 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() {...@@ -645,17 +721,16 @@ fn editor_ops_store_what_the_page_lowered_whole_stores() {
645 }721 }
646 let mut totals = [0; 5];722 let mut totals = [0; 5];
647 for (kind, counts) in &per_kind {723 for (kind, counts) in &per_kind {
648 let [same, refused, only, normalized, differs] = counts;724 let [same, refused, ops_only, normalized, differs] = counts;
649 report += &format!(725 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"
651 );727 );
652 for (total, count) in totals.iter_mut().zip(counts) {728 for (total, count) in totals.iter_mut().zip(counts) {
653 *total += count;729 *total += count;
654 }730 }
655 }731 }
656 report += &format!(732 report +=
657 "all: {totals:?} (same, both refused, only as ops, after normalization, differ)\n"733 &format!("all: {totals:?} (same, refused, by ops only, after normalization, differ)\n");
658 );
659 for ((kind, outcome), count) in &tally.outcomes {734 for ((kind, outcome), count) in &tally.outcomes {
660 if let Outcome::Differs(reason) = outcome {735 if let Outcome::Differs(reason) = outcome {
661 report += &format!(736 report += &format!(
crates/canvas/tests/reopen.rs+17-16
...@@ -5,8 +5,9 @@ use canvas::{...@@ -5,8 +5,9 @@ use canvas::{
5 layout::TextEngine,5 layout::TextEngine,
6};6};
7use onestore::{7use onestore::{
8 ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store,8 Arena, ExGuid, PageCreation, RevisionIndex, Section, Store,
9 document::Document,9 document::Document,
10 op::{Edit, Op, SectionOp},
10 page::{Page, PageObject, ParagraphContent},11 page::{Page, PageObject, ParagraphContent},
11};12};
12use std::{fs, path::Path};13use std::{fs, path::Path};
...@@ -32,8 +33,8 @@ fn spaces(document: &Document<'_>) -> Vec<ExGuid> {...@@ -32,8 +33,8 @@ fn spaces(document: &Document<'_>) -> Vec<ExGuid> {
32 spaces33 spaces
33}34}
3435
35/// What `notebook::session::Section::import_page` writes: a new titled page, then the36/// What `notebook::session::Section::import_page` applies: a new titled page holding a copy of
36/// editor's body objects saved onto it.37/// the editor's body objects.
37#[test]38#[test]
38fn an_imported_editor_page_reopens_with_its_title_editable() {39fn an_imported_editor_page_reopens_with_its_title_editable() {
39 let section = onestore::create_section("reopen.one", "Body 🦀 é", "Author").unwrap();40 let section = onestore::create_section("reopen.one", "Body 🦀 é", "Author").unwrap();
...@@ -41,20 +42,20 @@ fn an_imported_editor_page_reopens_with_its_title_editable() {...@@ -41,20 +42,20 @@ fn an_imported_editor_page_reopens_with_its_title_editable() {
41 let (_, editor) = PageScene::from_page(pages(&section).remove(0), &mut engine).unwrap();42 let (_, editor) = PageScene::from_page(pages(&section).remove(0), &mut engine).unwrap();
42 let copy = editor.page().unwrap().copy().unwrap();43 let copy = editor.page().unwrap().copy().unwrap();
43 let creation = PageCreation::new(None, Some("Imported 🦋"), "Author").unwrap();44 let creation = PageCreation::new(None, Some("Imported 🦋"), "Author").unwrap();
44 let created = PreparedEdit::create_page(&section, &creation).unwrap();45 let arena = Arena::default();
45 let mut after = pages(created.as_bytes()).pop().unwrap();46 let mut imported = Section::open(&arena, section).unwrap();
46 after47 let import = SectionOp::Import {
47 .objects48 creation,
48 .retain(|object| matches!(object, PageObject::Title(_)));49 page: copy,
49 after.objects.extend(50 };
50 copy.objects51 let edit = Edit {
51 .into_iter()52 at: 134_000_000_000_000_000,
52 .filter(|object| !matches!(object, PageObject::Title(_))),53 ops: vec![Op::Section(import)],
53 );54 };
54 after.definitions = copy.definitions;55 imported.apply("Author", &edit).unwrap();
55 let saved = PreparedEdit::page(created.as_bytes(), creation.space(), &after, "Author").unwrap();56 imported.seal().unwrap();
5657
57 let reopened = pages(saved.as_bytes()).pop().unwrap();58 let reopened = pages(&imported.image()).pop().unwrap();
58 let (_, editor) = PageScene::from_page(reopened.clone(), &mut engine).unwrap();59 let (_, editor) = PageScene::from_page(reopened.clone(), &mut engine).unwrap();
59 assert_eq!(editor.page().unwrap(), reopened);60 assert_eq!(editor.page().unwrap(), reopened);
60 let texts = |title: bool| -> Vec<String> {61 let texts = |title: bool| -> Vec<String> {
crates/canvas/tests/structural_roundtrip.rs+19-20
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1//! Every structural edit the editor makes saves, through the page writer and as the ops the1//! Every structural edit the editor makes saves as the ops the editor recorded and rereads
2//! editor recorded, and rereads as the model it saved: random Enter, Backspace, Delete, Tab,2//! as the model it saved: random Enter, Backspace, Delete, Tab,
3//! Shift+Tab, list and paste edits, alone and several to a save, on every outline of the probe3//! Shift+Tab, list and paste edits, alone and several to a save, on every outline of the probe
4//! section and of any sections named in `CANVAS_SWEEP_SECTIONS` (`:`-separated paths).4//! section and of any sections named in `CANVAS_SWEEP_SECTIONS` (`:`-separated paths).
55
...@@ -9,7 +9,7 @@ use canvas::{...@@ -9,7 +9,7 @@ use canvas::{
9 layout::TextEngine,9 layout::TextEngine,
10};10};
11use onestore::{11use onestore::{
12 Arena, ExGuid, PreparedEdit, RevisionIndex, Section, Store,12 Arena, ExGuid, RevisionIndex, Section, Store,
13 document::Document,13 document::Document,
14 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent},14 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent},
15};15};
...@@ -352,25 +352,14 @@ fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut...@@ -352,25 +352,14 @@ fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut
352 .map(|op| onestore::op::Op::Page { space, op })352 .map(|op| onestore::op::Op::Page { space, op })
353 .collect(),353 .collect(),
354 };354 };
355 let ops = match stored.apply("Sweep", &edit) {355 let failure = match stored.apply("Sweep", &edit) {
356 Err(error) => Some(format!("ops: {error}")),356 Err(error) => Some(format!("ops: {error}")),
357 Ok(()) => {357 Ok(()) => {
358 let reread = stored.page(space).unwrap();358 let reread = stored.page(space).unwrap();
359 (!rereads(&reread)).then(|| format!("ops: {}", difference(&after, &reread)))359 (!rereads(&reread)).then(|| format!("ops: {}", difference(&after, &reread)))
360 }360 }
361 };361 };
362 let failure = match PreparedEdit::page(&section, space, &after, "Sweep") {362 if let Some(failure) = failure {
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) {
374 tally.failures.entry(failure).or_default().push(format!(363 tally.failures.entry(failure).or_default().push(format!(
375 "{plan:?} on {:?} in {}",364 "{plan:?} on {:?} in {}",
376 page.title,365 page.title,
...@@ -483,10 +472,20 @@ fn edit(section: &mut Vec<u8>, title: &str, keys: &[(Caret, Press)], engine: &mu...@@ -483,10 +472,20 @@ fn edit(section: &mut Vec<u8>, title: &str, keys: &[(Caret, Press)], engine: &mu
483 }472 }
484 }473 }
485 let after = editor.page().unwrap();474 let after = editor.page().unwrap();
486 let saved = PreparedEdit::page(section, space, &after, "Snowbound")475 let arena = Arena::default();
487 .unwrap()476 let mut stored = Section::open(&arena, section.to_vec()).unwrap();
488 .as_bytes()477 let edit = onestore::op::Edit {
489 .to_vec();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();
490 let (_, reread) = pages(&saved)489 let (_, reread) = pages(&saved)
491 .into_iter()490 .into_iter()
492 .find(|(id, _)| *id == space)491 .find(|(id, _)| *id == space)
crates/draw/src/edit.rs+472-112
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1//! Text editing shared by the page and interface text: keys, macOS editing chords, click1//! Text editing shared by the page and interface text: keys, each platform's editing chords
2//! counting and caret movement within one parley layout.2//! and caret, click counting and caret movement within one parley layout.
33
4use parley::{4use parley::{
5 Affinity, Brush, Layout,5 Affinity, Brush, Layout,
...@@ -30,6 +30,8 @@ pub enum NamedKey {...@@ -30,6 +30,8 @@ pub enum NamedKey {
30 ArrowDown,30 ArrowDown,
31 Home,31 Home,
32 End,32 End,
33 PageUp,
34 PageDown,
33 /// Shift, Control, Option or Command pressed alone.35 /// Shift, Control, Option or Command pressed alone.
34 Modifier,36 Modifier,
35 Other,37 Other,
...@@ -39,7 +41,10 @@ pub enum NamedKey {...@@ -39,7 +41,10 @@ pub enum NamedKey {
39pub struct Modifiers {41pub struct Modifiers {
40 pub shift: bool,42 pub shift: bool,
41 pub control: bool,43 pub control: bool,
44 /// Option, or Alt.
42 pub option: bool,45 pub option: bool,
46 /// The shortcut modifier: Command on macOS, Control elsewhere, where it comes with
47 /// `control`.
43 pub command: bool,48 pub command: bool,
44}49}
4550
...@@ -67,7 +72,7 @@ pub enum SelectionUnit {...@@ -67,7 +72,7 @@ pub enum SelectionUnit {
67 Paragraph,72 Paragraph,
68}73}
6974
70/// What a key does to text under macOS conventions.75/// What a key does to text under the platform's conventions.
71#[derive(Clone, Copy, Debug, PartialEq)]76#[derive(Clone, Copy, Debug, PartialEq)]
72pub enum Command {77pub enum Command {
73 /// Moves the focus, extending the selection with Shift.78 /// Moves the focus, extending the selection with Shift.
...@@ -78,72 +83,285 @@ pub enum Command {...@@ -78,72 +83,285 @@ pub enum Command {
78 DeleteTo(Movement),83 DeleteTo(Movement),
79 /// Control-K: deletes to the line's end, or the break after it when already there.84 /// Control-K: deletes to the line's end, or the break after it when already there.
80 Kill,85 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 },
81}90}
8291
83impl Command {92impl Command {
84 pub fn from_key(key: &Key, modifiers: Modifiers) -> Option<Self> {93 pub fn from_key(key: &Key, modifiers: Modifiers) -> Option<Self> {
85 let Modifiers {94 Platform::CURRENT.command(key, modifiers)
86 shift,95 }
87 control,96}
88 option,97
89 command,98/// A desktop's text editing conventions: its chords, and how its caret looks and blinks.
90 } = modifiers;99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91 let pick = |plain, with_option, with_command| {100pub enum Platform {
92 Some(Self::Move(if command {101 MacOs,
93 with_command102 /// GTK, as GNOME and most Linux desktops edit text.
94 } else if option {103 Gtk,
95 with_option104 Windows,
96 } else {105}
97 plain106
98 }))107/// A caret's blink, in microseconds: solid for `delay` after it moves, then repeating
99 };108/// `phases` of fading out, off, fading in and on, each fade in `steps` equal steps, until
100 match key {109/// `timeout` after the move leaves it solid.
101 Key::Named(NamedKey::ArrowLeft) => {110struct Blink {
102 pick(Movement::Left, Movement::WordLeft, Movement::LineStart)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);
103 }185 }
104 Key::Named(NamedKey::ArrowRight) => {186 let mut within = (time - delay) % period;
105 pick(Movement::Right, Movement::WordRight, Movement::LineEnd)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;
106 }205 }
107 Key::Named(NamedKey::ArrowUp) => pick(206 unreachable!("a time within the period falls in a phase")
108 Movement::Up,207 };
109 Movement::ParagraphStart,208 let now = u64::try_from(since.as_micros()).unwrap_or(u64::MAX);
110 Movement::DocumentStart,209 if timeout.is_some_and(|timeout| now >= timeout) {
111 ),210 return (1.0, None);
112 Key::Named(NamedKey::ArrowDown) => pick(211 }
113 Movement::Down,212 let (opacity, mut until) = at(now);
114 Movement::ParagraphEnd,213 // A settled phase holds into the next one's first step, which shows the same.
115 Movement::DocumentEnd,214 for _ in 0..2 * (steps + 2) {
116 ),215 let (next, end) = at(until);
117 Key::Named(NamedKey::Home) if shift => Some(Self::Move(Movement::DocumentStart)),216 if next != opacity {
118 Key::Named(NamedKey::End) if shift => Some(Self::Move(Movement::DocumentEnd)),217 break;
119 Key::Named(NamedKey::Backspace) if command || option => {218 }
120 Some(Self::DeleteTo(if command {219 until = end;
121 Movement::LineStart220 }
122 } else {221 match timeout {
123 Movement::WordLeft222 Some(timeout) if until >= timeout && opacity == 1.0 => (opacity, None),
124 }))223 Some(timeout) if until >= timeout => {
224 (opacity, Some(Duration::from_micros(timeout - now)))
125 }225 }
126 Key::Named(NamedKey::Delete) if command || option => Some(Self::DeleteTo(if command {226 _ => (opacity, Some(Duration::from_micros(until - now))),
127 Movement::LineEnd227 }
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
128 } else {313 } else {
129 Movement::WordRight314 Movement::WordLeft
130 })),315 }))
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,
146 }316 }
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,
147 }365 }
148}366}
149367
...@@ -183,35 +401,12 @@ impl Clicks {...@@ -183,35 +401,12 @@ impl Clicks {
183 }401 }
184}402}
185403
186/// The caret's width in logical pixels, as AppKit's insertion indicator draws it.404/// The caret's width in logical pixels on this platform.
187pub const CARET_WIDTH: f32 = 2.0;405pub const CARET_WIDTH: f32 = Platform::CURRENT.caret_width();
188406
189/// The caret's opacity `since` it last moved, and how long that opacity holds. As AppKit's407/// The caret's opacity `since` it last moved on this platform; see [`Platform::caret_blink`].
190/// insertion indicator: solid for 650 ms, then 350 ms off and 650 ms on, each phase408pub fn caret_blink(since: Duration) -> (f32, Option<Duration>) {
191/// opening with a fade in four 37.5 ms steps.409 Platform::CURRENT.caret_blink(since)
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 )
215}410}
216411
217/// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright412/// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright
...@@ -451,13 +646,109 @@ mod tests {...@@ -451,13 +646,109 @@ mod tests {
451 ];646 ];
452 for (key, modifiers, command) in cases {647 for (key, modifiers, command) in cases {
453 assert_eq!(648 assert_eq!(
454 Command::from_key(&key, modifiers),649 Platform::MacOs.command(&key, modifiers),
455 command,650 command,
456 "{key:?} {modifiers:?}"651 "{key:?} {modifiers:?}"
457 );652 );
458 }653 }
459 }654 }
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
461 #[test]752 #[test]
462 fn presses_count_within_the_interval_and_slop_and_cycle() {753 fn presses_count_within_the_interval_and_slop_and_cycle() {
463 let mut clicks = Clicks::new(Duration::from_millis(500));754 let mut clicks = Clicks::new(Duration::from_millis(500));
...@@ -532,30 +823,99 @@ mod tests {...@@ -532,30 +823,99 @@ mod tests {
532 assert!((green - linear(167.5)).abs() < 0.02, "{alpha}");823 assert!((green - linear(167.5)).abs() < 0.02, "{alpha}");
533 }824 }
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
535 #[test]857 #[test]
536 fn caret_blinks_on_appkit_s_timing() {858 fn caret_blinks_on_appkit_s_timing() {
537 let at = |millis: f64| {859 let mac = Platform::MacOs;
538 let (opacity, hold) = caret_blink(Duration::from_secs_f64(millis / 1000.0));860 assert_blink(mac, 0.0, (1.0, Some(687.5)));
539 (opacity, hold.as_secs_f64() * 1000.0)861 assert_blink(mac, 687.5, (0.75, Some(37.5)));
540 };862 assert_blink(mac, 760.0, (0.5, Some(2.5)));
541 let close = |(opacity, hold): (f32, f64), expected: (f32, f64)| {863 assert_blink(mac, 800.0, (0.0, Some(237.5)));
542 assert_eq!(opacity, expected.0);864 assert_blink(mac, 1037.5, (0.25, Some(37.5)));
543 assert!((hold - expected.1).abs() < 0.01, "{hold} ms, not {}", expected.1);865 assert_blink(mac, 1150.0, (1.0, Some(537.5)));
544 };866 assert_blink(mac, 1687.5, (0.75, Some(37.5)));
545 close(at(0.0), (1.0, 687.5));867 assert_blink(mac, 3_600_200.0, (1.0, Some(487.5)));
546 close(at(687.5), (0.75, 37.5));868 assert_eq!(
547 close(at(760.0), (0.5, 2.5));869 blink_changes(mac, 1000, 1000),
548 close(at(800.0), (0.0, 237.5));870 8,
549 close(at(1037.5), (0.25, 37.5));871 "four fade steps each way per second"
550 close(at(1150.0), (1.0, 537.5));872 );
551 close(at(1687.5), (0.75, 37.5));873 }
552 let mut changes = 0;874
553 let mut previous = at(1000.0).0;875 #[test]
554 for millis in 1001..2001 {876 fn caret_blinks_on_gtk_s_timing() {
555 let opacity = at(f64::from(millis)).0;877 let gtk = Platform::Gtk;
556 changes += usize::from(opacity != previous);878 // Solid for half the 1200 ms cycle and its visible quarter, then fades out over 300 ms.
557 previous = opacity;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 }
558 }915 }
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));
560 }920 }
561}921}
crates/draw/src/render.rs+35-7
...@@ -211,7 +211,7 @@ pub struct Layer<'a> {...@@ -211,7 +211,7 @@ pub struct Layer<'a> {
211 /// Device bounds `[left, top, right, bottom]` the layer paints within.211 /// Device bounds `[left, top, right, bottom]` the layer paints within.
212 pub clip: Option<[f32; 4]>,212 pub clip: Option<[f32; 4]>,
213 /// The colour behind the layer: on a dark one, text in dark colours of its own is213 /// 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.
215 pub backdrop: Option<[f32; 4]>,215 pub backdrop: Option<[f32; 4]>,
216 pub primitives: &'a [Primitive<'a>],216 pub primitives: &'a [Primitive<'a>],
217}217}
...@@ -985,7 +985,8 @@ impl Renderer {...@@ -985,7 +985,8 @@ impl Renderer {
985 if glyph.color {985 if glyph.color {
986 [1.0; 4]986 [1.0; 4]
987 } else {987 } 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))
989 },990 },
990 )?;991 )?;
991 }992 }
...@@ -1004,7 +1005,7 @@ impl Renderer {...@@ -1004,7 +1005,7 @@ impl Renderer {
1004 [0.5 / ATLAS_SIZE as f32; 4],1005 [0.5 / ATLAS_SIZE as f32; 4],
1005 decoration1006 decoration
1006 .color1007 .color
1007 .map_or(ink, |color| legible(color, space.backdrop)),1008 .map_or(ink, |color| legible(color, space.backdrop, run.backdrop)),
1008 )?;1009 )?;
1009 }1010 }
1010 Ok(())1011 Ok(())
...@@ -1467,13 +1468,16 @@ fn srgb_byte(value: f32) -> u8 {...@@ -1467,13 +1468,16 @@ fn srgb_byte(value: f32) -> u8 {
1467 (srgb * 255.0).round() as u81468 (srgb * 255.0).round() as u8
1468}1469}
14691470
1470/// Linear RGBA of an opaque sRGB colour.
1471/// Least OKLab lightness difference between text and the backdrop it stays legible on.1471/// Least OKLab lightness difference between text and the backdrop it stays legible on.
1472const LEGIBLE: f32 = 0.4;1472const LEGIBLE: f32 = 0.4;
14731473
1474/// `color` lifted clear of a dark `backdrop`'s lightness, keeping hue and chroma.1474/// On a dark layer `backdrop`, `color` lifted clear of the lightness of what lies `behind`
1475fn legible(color: [f32; 4], backdrop: Option<[f32; 4]>) -> [f32; 4] {1475/// it, or of the backdrop, keeping hue and chroma.
1476 let [under, ..] = backdrop.map_or([1.0; 3], oklab);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));
1477 let [lightness, a, b] = oklab(color);1481 let [lightness, a, b] = oklab(color);
1478 if under >= 0.5 || lightness >= under + LEGIBLE {1482 if under >= 0.5 || lightness >= under + LEGIBLE {
1479 return color;1483 return color;
...@@ -1513,6 +1517,7 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] {...@@ -1513,6 +1517,7 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] {
1513 .map(|[x, y, z]| (x * l + y * m + z * s).clamp(0.0, 1.0))1517 .map(|[x, y, z]| (x * l + y * m + z * s).clamp(0.0, 1.0))
1514}1518}
15151519
1520/// Linear RGBA of an opaque sRGB colour.
1516pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {1521pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] {
1517 let linear = |byte: u8| {1522 let linear = |byte: u8| {
1518 let value = f32::from(byte) / 255.0;1523 let value = f32::from(byte) / 255.0;
...@@ -1555,6 +1560,7 @@ mod tests {...@@ -1555,6 +1560,7 @@ mod tests {
1555 0.0,1560 0.0,
1556 [metrics.block_min_coord, metrics.line_height],1561 [metrics.block_min_coord, metrics.line_height],
1557 |_| None,1562 |_| None,
1563 None,
1558 paint,1564 paint,
1559 )?;1565 )?;
1560 }1566 }
...@@ -1582,6 +1588,28 @@ mod tests {...@@ -1582,6 +1588,28 @@ mod tests {
1582 Text(layout)1588 Text(layout)
1583 }1589 }
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
1585 #[test]1613 #[test]
1586 fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() {1614 fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() {
1587 let mut encoded = Vec::new();1615 let mut encoded = Vec::new();
crates/draw/src/render/text.rs+7-2
...@@ -23,6 +23,9 @@ pub struct GlyphRun<'a> {...@@ -23,6 +23,9 @@ pub struct GlyphRun<'a> {
23 pub skew: Option<f32>,23 pub skew: Option<f32>,
24 /// `None` paints in the primitive's ink.24 /// `None` paints in the primitive's ink.
25 pub color: Option<[f32; 4]>,25 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]>,
26 /// Top and height of the run's line: lines outside the target are skipped, and29 /// Top and height of the run's line: lines outside the target are skipped, and
27 /// colour glyphs shrink to fit.30 /// colour glyphs shrink to fit.
28 pub line: [f32; 2],31 pub line: [f32; 2],
...@@ -50,14 +53,15 @@ pub struct Decoration {...@@ -50,14 +53,15 @@ pub struct Decoration {
50 pub color: Option<[f32; 4]>,53 pub color: Option<[f32; 4]>,
51}54}
5255
53/// Paints one parley glyph run with its line's baseline moved to `baseline` and its56/// Paints one parley glyph run with its line's baseline moved to `baseline`, its glyphs
54/// glyphs raised by `rise`.57/// raised by `rise` and `backdrop` behind them.
55pub fn paint_parley_run<B: parley::Brush>(58pub fn paint_parley_run<B: parley::Brush>(
56 run: &parley::GlyphRun<'_, B>,59 run: &parley::GlyphRun<'_, B>,
57 baseline: f32,60 baseline: f32,
58 rise: f32,61 rise: f32,
59 line: [f32; 2],62 line: [f32; 2],
60 color: impl Fn(&B) -> Option<[f32; 4]>,63 color: impl Fn(&B) -> Option<[f32; 4]>,
64 backdrop: Option<[f32; 4]>,
61 paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>,65 paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>,
62) -> Result<(), RenderError> {66) -> Result<(), RenderError> {
63 let shaped = run.run();67 let shaped = run.run();
...@@ -108,6 +112,7 @@ pub fn paint_parley_run<B: parley::Brush>(...@@ -108,6 +112,7 @@ pub fn paint_parley_run<B: parley::Brush>(
108 embolden: synthesis.embolden(),112 embolden: synthesis.embolden(),
109 skew: synthesis.skew(),113 skew: synthesis.skew(),
110 color: color(&style.brush),114 color: color(&style.brush),
115 backdrop,
111 line,116 line,
112 glyphs: &mut glyphs,117 glyphs: &mut glyphs,
113 decorations: &decorations,118 decorations: &decorations,
crates/snowbound/Cargo.toml+13-2
...@@ -11,6 +11,8 @@ ui = { path = "../ui" }...@@ -11,6 +11,8 @@ ui = { path = "../ui" }
11png = "0.18"11png = "0.18"
12onestore = { path = "../onestore" }12onestore = { path = "../onestore" }
13notebook = { path = "../notebook" }13notebook = { path = "../notebook" }
14serde = { version = "1", features = ["derive"] }
15serde_json = "1"
14pollster = "0.4"16pollster = "0.4"
15wgpu.workspace = true17wgpu.workspace = true
16parley.workspace = true18parley.workspace = true
...@@ -18,7 +20,16 @@ winit = "0.30.13"...@@ -18,7 +20,16 @@ winit = "0.30.13"
18arboard = { version = "3.6.1", default-features = false }20arboard = { version = "3.6.1", default-features = false }
19accesskit = "0.25.0"21accesskit = "0.25.0"
20accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }22accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] }
23
24[target.'cfg(target_os = "macos")'.dependencies]
21objc2 = "0.5.2"25objc2 = "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"] }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"] }
23objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSTimeZone"] }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");...@@ -14,20 +14,24 @@ pub const BOLD: &[&str] = art!("icons/bold");
14pub const BULLETS: &[&str] = art!("icons/bullets");14pub const BULLETS: &[&str] = art!("icons/bullets");
15pub const CALENDAR: &[&str] = art!("icons/calendar");15pub const CALENDAR: &[&str] = art!("icons/calendar");
16pub const CLEAR_FORMATTING: &[&str] = art!("icons/clear-formatting");16pub const CLEAR_FORMATTING: &[&str] = art!("icons/clear-formatting");
17pub const CHEVRON_UP: &[&str] = art!("icons/chevron-up");
17pub const CLOCK: &[&str] = art!("icons/clock");18pub const CLOCK: &[&str] = art!("icons/clock");
18pub const CLOSE: &[&str] = art!("icons/close");19pub const CLOSE: &[&str] = art!("icons/close");
20pub const CONFLICT: &[&str] = art!("icons/conflict");
19pub const EQUATION: &[&str] = art!("icons/equation");21pub const EQUATION: &[&str] = art!("icons/equation");
20pub const FONT_COLOR: &[&str] = art!("icons/font-color");22pub const FONT_COLOR: &[&str] = art!("icons/font-color");
21pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter");23pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter");
22pub const INDENT: &[&str] = art!("icons/indent");24pub const INDENT: &[&str] = art!("icons/indent");
23pub const ITALIC: &[&str] = art!("icons/italic");25pub const ITALIC: &[&str] = art!("icons/italic");
24pub const LINK: &[&str] = art!("icons/link");26pub const LINK: &[&str] = art!("icons/link");
25pub const NEW_PAGE: &[&str] = art!("icons/new-page");27pub const NOTEBOOK: &[&str] = art!("icons/notebook");
26pub const NUMBERING: &[&str] = art!("icons/numbering");28pub const NUMBERING: &[&str] = art!("icons/numbering");
27pub const OUTDENT: &[&str] = art!("icons/outdent");29pub const OUTDENT: &[&str] = art!("icons/outdent");
28pub const PICTURE: &[&str] = art!("icons/picture");30pub const PICTURE: &[&str] = art!("icons/picture");
31pub const PLUS: &[&str] = art!("icons/plus");
29pub const REDO: &[&str] = art!("icons/redo");32pub const REDO: &[&str] = art!("icons/redo");
30pub const SEARCH: &[&str] = art!("icons/search");33pub const SEARCH: &[&str] = art!("icons/search");
34pub const SECTION_GROUP: &[&str] = art!("icons/section-group");
31pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");35pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse");
32pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");36pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand");
33pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough");37pub 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};...@@ -19,8 +19,9 @@ use std::{cell::Cell, sync::OnceLock};
19use winit::{19use winit::{
20 error::EventLoopError,20 error::EventLoopError,
21 event_loop::{EventLoop, EventLoopProxy},21 event_loop::{EventLoop, EventLoopProxy},
22 platform::macos::WindowAttributesExtMacOS,
22 raw_window_handle::{HasWindowHandle, RawWindowHandle},23 raw_window_handle::{HasWindowHandle, RawWindowHandle},
23 window::Window,24 window::{Window, WindowAttributes},
24};25};
2526
26static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();27static QUIT: OnceLock<EventLoopProxy<crate::UserEvent>> = OnceLock::new();
...@@ -70,6 +71,109 @@ fn ns_window(window: &Window) -> Retained<AnyObject> {...@@ -70,6 +71,109 @@ fn ns_window(window: &Window) -> Retained<AnyObject> {
70 }71 }
71}72}
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
73/// Zooms the window once the current event is handled: AppKit's zoom animation runs its177/// Zooms the window once the current event is handled: AppKit's zoom animation runs its
74/// own loop, and started from inside winit's handler it would hold every resize until the178/// own loop, and started from inside winit's handler it would hold every resize until the
75/// end, stretching the last frame instead of drawing each step.179/// end, stretching the last frame instead of drawing each step.
...@@ -124,6 +228,7 @@ pub fn show_character_palette() {...@@ -124,6 +228,7 @@ pub fn show_character_palette() {
124pub fn edit_date(228pub fn edit_date(
125 timestamp: u64,229 timestamp: u64,
126 field: DateField,230 field: DateField,
231 title: &str,
127) -> Result<Option<(u64, [String; 2])>, &'static str> {232) -> Result<Option<(u64, [String; 2])>, &'static str> {
128 let mtm = MainThreadMarker::new().expect("Date controls belong to the main thread");233 let mtm = MainThreadMarker::new().expect("Date controls belong to the main thread");
129 unsafe {234 unsafe {
...@@ -147,10 +252,7 @@ pub fn edit_date(...@@ -147,10 +252,7 @@ pub fn edit_date(
147 let _: () = msg_send![&picker, setAccessibilityLabel: &*label];252 let _: () = msg_send![&picker, setAccessibilityLabel: &*label];
148 picker.sizeToFit();253 picker.sizeToFit();
149 let alert = NSAlert::new(mtm);254 let alert = NSAlert::new(mtm);
150 alert.setMessageText(&NSString::from_str(match field {255 alert.setMessageText(&NSString::from_str(title));
151 DateField::Date => "Change Page Date",
152 DateField::Time => "Change Page Time",
153 }));
154 alert.setAccessoryView(Some(&picker));256 alert.setAccessoryView(Some(&picker));
155 alert.addButtonWithTitle(&NSString::from_str("Apply"));257 alert.addButtonWithTitle(&NSString::from_str("Apply"));
156 alert.addButtonWithTitle(&NSString::from_str("Cancel"));258 alert.addButtonWithTitle(&NSString::from_str("Cancel"));
...@@ -197,9 +299,9 @@ fn merge_date(...@@ -197,9 +299,9 @@ fn merge_date(
197 }299 }
198 let date = calendar300 let date = calendar
199 .dateFromComponents(&components)301 .dateFromComponents(&components)
200 .ok_or("Choose another date or time.")?;302 .ok_or(crate::DATE_UNCHOSEN)?;
201 let seconds = date.timeIntervalSince1970() + 11_644_473_600.0;303 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;
203 if !seconds.is_finite() || seconds < 0.0 || seconds > (u64::MAX / 10_000_000) as f64 {305 if !seconds.is_finite() || seconds < 0.0 || seconds > (u64::MAX / 10_000_000) as f64 {
204 return Err(outside_range);306 return Err(outside_range);
205 }307 }
...@@ -208,17 +310,49 @@ fn merge_date(...@@ -208,17 +310,49 @@ fn merge_date(
208 .checked_mul(10_000_000)310 .checked_mul(10_000_000)
209 .and_then(|value| value.checked_add(timestamp % 10_000_000))311 .and_then(|value| value.checked_add(timestamp % 10_000_000))
210 .ok_or(outside_range)?;312 .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 {
211 let formatter = NSDateFormatter::new();320 let formatter = NSDateFormatter::new();
212 formatter.setCalendar(Some(calendar));321 formatter.setCalendar(Some(calendar));
213 formatter.setTimeZone(Some(&calendar.timeZone()));322 formatter.setTimeZone(Some(&calendar.timeZone()));
214 formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterFullStyle);323 formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterFullStyle);
215 let date_text = formatter.stringFromDate(&date).to_string();324 let date_text = formatter.stringFromDate(date).to_string();
216 formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterNoStyle);325 formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterNoStyle);
217 formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterShortStyle);326 formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterShortStyle);
218 Ok((327 [date_text, formatter.stringFromDate(date).to_string()]
219 updated,328 }
220 [date_text, formatter.stringFromDate(&date).to_string()],329}
221 ))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()
222 }356 }
223}357}
224358
...@@ -354,8 +488,7 @@ pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> {...@@ -354,8 +488,7 @@ pub fn app_icon(pixels: u32) -> Option<draw::RasterImage> {
354pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {488pub fn text_colors(window: &Window) -> [[f32; 4]; 3] {
355 let class = AnyClass::get("NSAppearance").expect("AppKit is linked");489 let class = AnyClass::get("NSAppearance").expect("AppKit is linked");
356 unsafe {490 unsafe {
357 let appearance: Retained<AnyObject> =491 let appearance: Retained<AnyObject> = msg_send_id![&ns_window(window), effectiveAppearance];
358 msg_send_id![&ns_window(window), effectiveAppearance];
359 // System colours resolve in the thread's appearance, which outside drawing does492 // System colours resolve in the thread's appearance, which outside drawing does
360 // not follow the window's.493 // not follow the window's.
361 let previous: Option<Retained<AnyObject>> = msg_send_id![class, currentAppearance];494 let previous: Option<Retained<AnyObject>> = msg_send_id![class, currentAppearance];
...@@ -432,46 +565,39 @@ declare_class!(...@@ -432,46 +565,39 @@ declare_class!(
432 }565 }
433);566);
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};
436 MainThreadMarker::new().expect("AppKit must start on the main thread");571 MainThreadMarker::new().expect("AppKit must start on the main thread");
437 // Create the application's own subclass before Winit obtains the singleton.572 // Create the application's own subclass before Winit obtains the singleton.
438 let _: Retained<CanvasApplication> =573 let _: Retained<CanvasApplication> =
439 unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] };574 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()?;
441 QUIT.set(event_loop.create_proxy())582 QUIT.set(event_loop.create_proxy())
442 .expect("Only one application event loop is created");583 .expect("Only one application event loop is created");
443 Ok(event_loop)584 Ok(event_loop)
444}585}
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 {
447 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");589 let mtm = MainThreadMarker::new().expect("Window events run on the main thread");
448 // The alert and its strings stay on AppKit's main thread for the modal call.590 // The alert and its strings stay on AppKit's main thread for the modal call.
449 unsafe {591 unsafe {
450 let alert = NSAlert::new(mtm);592 let alert = NSAlert::new(mtm);
451 alert.setMessageText(&NSString::from_str("Discard this page?"));593 alert.setMessageText(&NSString::from_str(message));
452 alert.setInformativeText(&NSString::from_str(594 alert.setInformativeText(&NSString::from_str(detail));
453 "This temporary page has no saved copy. Closing it will discard your edits.",595 alert.addButtonWithTitle(&NSString::from_str(cancel));
454 ));596 alert.addButtonWithTitle(&NSString::from_str(action));
455 alert.addButtonWithTitle(&NSString::from_str("Keep Editing"));
456 alert.addButtonWithTitle(&NSString::from_str("Discard Changes"));
457 alert.runModal() == NSAlertSecondButtonReturn597 alert.runModal() == NSAlertSecondButtonReturn
458 }598 }
459}599}
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
475/// End AppKit preedit after a canvas action commits or cancels the composition.601/// End AppKit preedit after a canvas action commits or cancels the composition.
476pub fn clear_marked_text(window: &Window) {602pub fn clear_marked_text(window: &Window) {
477 MainThreadMarker::new().expect("Text input belongs to the main thread");603 MainThreadMarker::new().expect("Text input belongs to the main thread");
crates/snowbound/src/main.rs+1097-399
...@@ -1,8 +1,19 @@...@@ -1,8 +1,19 @@
1mod art;1mod 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
4use canvas::gpu::page::PageScene;15use canvas::gpu::page::PageScene;
5use canvas::interaction::{Cursor, PageView, Request, Response, TextColors, accessibility};16use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility};
6use canvas::{17use canvas::{
7 date::DateField,18 date::DateField,
8 document::TextDocument,19 document::TextDocument,
...@@ -18,7 +29,9 @@ use onestore::ExGuid;...@@ -18,7 +29,9 @@ use onestore::ExGuid;
18use onestore::document::Format;29use onestore::document::Format;
19use onestore::page::Page;30use onestore::page::Page;
20use onestore::page::text::Paragraph;31use onestore::page::text::Paragraph;
32use library::Library;
21use std::{33use std::{
34 collections::{HashMap, HashSet},
22 error::Error,35 error::Error,
23 path::{Path, PathBuf},36 path::{Path, PathBuf},
24 sync::{Arc, mpsc},37 sync::{Arc, mpsc},
...@@ -31,21 +44,23 @@ use winit::{...@@ -31,21 +44,23 @@ use winit::{
31 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},44 event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent},
32 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},45 event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy},
33 keyboard::{Key, ModifiersState, NamedKey},46 keyboard::{Key, ModifiersState, NamedKey},
34 platform::macos::WindowAttributesExtMacOS,
35 window::{CursorIcon, Window, WindowId},47 window::{CursorIcon, Window, WindowId},
36};48};
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.
39const TITLE: f32 = 28.0;51const TITLE: f32 = 28.0;
40/// Room the traffic lights take at the title bar's leading edge.
41const LIGHTS: f32 = 78.0;
42const TAB_ROW: f32 = 28.0;52const TAB_ROW: f32 = 28.0;
43/// Width of the section colour around the page.53/// Width of the section colour around the page.
44const FRAME: f32 = 6.0;54const FRAME: f32 = 6.0;
45/// macOS rounds windows' corners by 10 pt; the page's corners share their centres.55/// The page's corners share their centres with the window's.
46const ROUNDING: f32 = 10.0 - FRAME;56const ROUNDING: f32 = (platform::CORNER_RADIUS - FRAME).max(0.0);
47const PAGE_LIST: f32 = 240.0;57const PAGE_LIST: f32 = 240.0;
48/// Font sizes the size box offers, OneNote's list in points.58/// 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.
49const SIZES: [f32; 17] = [64const SIZES: [f32; 17] = [
50 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,65 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,
51 72.0,66 72.0,
...@@ -65,14 +80,21 @@ const FONT_COLORS: [u32; 20] = [...@@ -65,14 +80,21 @@ const FONT_COLORS: [u32; 20] = [
65/// while the gaps still take the pointer.80/// while the gaps still take the pointer.
66const ROW: f32 = 29.0;81const ROW: f32 = 29.0;
67const ROW_GAP: f32 = 3.0;82const ROW_GAP: f32 = 3.0;
83/// OneNote's highlight for conflicting changes on a conflict page, COLORREF.
84const CONFLICTING: u32 = 0xd6d6ff;
68/// Space between the page and a page tab that isn't open.85/// Space between the page and a page tab that isn't open.
69const PILL_MARGIN: f32 = 3.0;86const PILL_MARGIN: f32 = 3.0;
70/// Rounding where the open page tab meets the page, concentric with its neighbours'.87/// Rounding where the open page tab meets the page, concentric with its neighbours'.
71const JOIN: f32 = PILL_MARGIN + ROUNDING;88const 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
73#[derive(Debug)]93#[derive(Debug)]
74enum UserEvent {94enum UserEvent {
75 Quit,95 Quit,
96 /// Text AppKit inserts outside key events, such as the character palette's.
97 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
76 InsertText(String),98 InsertText(String),
77 Accessibility(accesskit_winit::Event),99 Accessibility(accesskit_winit::Event),
78 /// The section's synchronization thread reported an event.100 /// The section's synchronization thread reported an event.
...@@ -141,6 +163,11 @@ struct App {...@@ -141,6 +163,11 @@ struct App {
141 substitutes: Vec<PathBuf>,163 substitutes: Vec<PathBuf>,
142 /// Font files for the interface in place of the system's.164 /// Font files for the interface in place of the system's.
143 interface_font: Vec<Vec<u8>>,165 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,
144 state: Option<State>,171 state: Option<State>,
145 startup_error: Option<Box<dyn Error>>,172 startup_error: Option<Box<dyn Error>>,
146}173}
...@@ -155,76 +182,114 @@ enum Input {...@@ -155,76 +182,114 @@ enum Input {
155 Section {182 Section {
156 file: PathBuf,183 file: PathBuf,
157 title: String,184 title: String,
158 cache: PathBuf,
159 },185 },
160 Notebook {186 /// The open notebooks by location, showing `current`; none shows the first run's
161 root: PathBuf,187 /// welcome.
162 cache: PathBuf,188 Notebooks {
189 locations: Vec<String>,
190 current: Option<String>,
163 },191 },
164}192}
165193
166/// A section tab: where the section opens from and how it is labelled.194/// Asks the event loop to poll sections when their synchronization reports.
167struct Tab {195fn notify(proxy: EventLoopProxy<UserEvent>) -> impl Fn() + Send + 'static {
168 /// The notebook catalog path, or the file of a section opened on its own.196 move || {
169 path: String,197 let _ = proxy.send_event(UserEvent::Sync);
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 })
198 }198 }
199}199}
200200
201/// The open section and the page the editor shows.201/// The open section and the page the editor shows.
202struct Session {202struct Session {
203 section: notebook::session::Section,203 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>,
204 tab: usize,208 tab: usize,
205 /// Spaces, titles and outline levels in section order.209 /// Spaces, titles and outline levels in section order.
206 pages: Vec<(ExGuid, String, u32)>,210 pages: Vec<(ExGuid, String, u32)>,
207 space: ExGuid,211 space: ExGuid,
208 status: &'static str,212 status: &'static str,
209 /// The editor's page has an unreviewed conflict with another machine's change.213 /// Each page's conflict pages, the versions a merge could not take.
210 conflict: bool,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 },
211}226}
212227
213impl Session {228impl Session {
214 fn title(&self) -> &str {229 fn title(&self) -> &str {
215 self.pages230 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
216 .iter()242 .iter()
217 .find(|(space, ..)| *space == self.space)243 .find(|(listed, _)| *listed == page)
218 .map_or("", |(_, title, _)| title)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 }
219 }265 }
220266
221 fn refresh_conflict(&mut self) -> Result<(), Box<dyn Error>> {267 fn refresh_conflicts(&mut self) -> Result<(), Box<dyn Error>> {
222 self.conflict = self268 self.conflicts = self.section.conflicts()?;
223 .section269 if self
224 .conflict()?270 .shown
225 .is_some_and(|conflict| conflict.space == self.space);271 .is_some_and(|page| self.versions(page).is_empty())
272 {
273 self.shown = None;
274 }
226 Ok(())275 Ok(())
227 }276 }
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 }
228}293}
229294
230/// What a loader thread opened: a section, or another page of the open one.295/// 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);...@@ -250,20 +315,52 @@ const HOLD: std::time::Duration = std::time::Duration::from_millis(200);
250315
251/// Work the interface asked for, done after the frame is built.316/// Work the interface asked for, done after the frame is built.
252enum Command {317enum 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),
254 OpenPage(ExGuid),337 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 },
256 Page(Request),345 Page(Request),
257}346}
258347
259struct State {348struct State {
260 window: Arc<Window>,349 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,
261 proxy: EventLoopProxy<UserEvent>,353 proxy: EventLoopProxy<UserEvent>,
262 /// Results of reads done off the frame thread, by request number.354 /// Results of reads done off the frame thread, by request number.
263 loads: (mpsc::Sender<Read>, mpsc::Receiver<Read>),355 loads: (mpsc::Sender<Read>, mpsc::Receiver<Read>),
264 /// The newest read requested; older ones are dropped when they finish.356 /// The newest read requested; older ones are dropped when they finish.
265 loading: u64,357 loading: u64,
266 opening: Option<Opening>,358 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>,
267 /// Asks for a frame when a worker thread finishes something the page shows.364 /// Asks for a frame when a worker thread finishes something the page shows.
268 redraw: std::task::Waker,365 redraw: std::task::Waker,
269 instance: wgpu::Instance,366 instance: wgpu::Instance,
...@@ -272,14 +369,42 @@ struct State {...@@ -272,14 +369,42 @@ struct State {
272 renderer: Renderer,369 renderer: Renderer,
273 ui: Ui,370 ui: Ui,
274 view: PageView,371 view: PageView,
275 library: Option<Arc<Library>>,372 /// The open notebooks in the sidebar's order.
373 notebooks: Vec<Arc<Library>>,
276 session: Option<Session>,374 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,
277 /// Text filtering the page list.400 /// Text filtering the page list.
278 filter: String,401 filter: String,
279 /// Whether the page list is shown beside the page.402 /// Whether the page list is shown beside the page.
280 pages_open: bool,403 pages_open: bool,
281 /// Installed font families, for the font box; listing them is slow.404 /// Installed font families, for the font box; listing them is slow.
282 fonts: Vec<String>,405 fonts: Vec<String>,
406 /// Fonts picked from the font box this session, latest first.
407 recent_fonts: Vec<String>,
283 /// The application's icon at the display's scale, for the title bar.408 /// The application's icon at the display's scale, for the title bar.
284 app_icon: Option<draw::RasterImage>,409 app_icon: Option<draw::RasterImage>,
285 commands: Vec<Command>,410 commands: Vec<Command>,
...@@ -296,6 +421,8 @@ struct State {...@@ -296,6 +421,8 @@ struct State {
296 pointer: [f32; 2],421 pointer: [f32; 2],
297 /// When the strip was last pressed, to zoom on a double click.422 /// When the strip was last pressed, to zoom on a double click.
298 strip_press: Option<Instant>,423 strip_press: Option<Instant>,
424 /// The strip is held and the window moves once the pointer does.
425 strip_held: bool,
299 /// Where a replay asked the next frame to be written.426 /// Where a replay asked the next frame to be written.
300 snapshot: Option<PathBuf>,427 snapshot: Option<PathBuf>,
301 initial: Vec<(onestore::ExGuid, TextDocument)>,428 initial: Vec<(onestore::ExGuid, TextDocument)>,
...@@ -303,7 +430,7 @@ struct State {...@@ -303,7 +430,7 @@ struct State {
303 initial_date: Option<u64>,430 initial_date: Option<u64>,
304 occluded: bool,431 occluded: bool,
305 ime_allowed: bool,432 ime_allowed: bool,
306 clipboard: arboard::Clipboard,433 clipboard: platform::Clipboard,
307 access_adapter: accesskit_winit::Adapter,434 access_adapter: accesskit_winit::Adapter,
308 accessibility: accessibility::Accessibility,435 accessibility: accessibility::Accessibility,
309}436}
...@@ -333,14 +460,14 @@ impl State {...@@ -333,14 +460,14 @@ impl State {
333 input: Input,460 input: Input,
334 substitutes: &[PathBuf],461 substitutes: &[PathBuf],
335 interface_font: Vec<Vec<u8>>,462 interface_font: Vec<Vec<u8>>,
463 visible: bool,
464 (settings, stored): (Option<PathBuf>, settings::Settings),
465 cache: PathBuf,
336 ) -> Result<Self, Box<dyn Error>> {466 ) -> Result<Self, Box<dyn Error>> {
337 let window = Arc::new(467 let window = Arc::new(
338 event_loop.create_window(468 event_loop.create_window(
339 Window::default_attributes()469 platform::window_attributes()
340 .with_visible(false)470 .with_visible(false)
341 .with_titlebar_transparent(true)
342 .with_title_hidden(true)
343 .with_fullsize_content_view(true)
344 .with_title(match &input {471 .with_title(match &input {
345 Input::Notes {472 Input::Notes {
346 reference: Some(page),473 reference: Some(page),
...@@ -351,20 +478,20 @@ impl State {...@@ -351,20 +478,20 @@ impl State {
351 reference: None, ..478 reference: None, ..
352 } => "Untitled · Temporary page".into(),479 } => "Untitled · Temporary page".into(),
353 Input::Section { title, .. } => title.clone(),480 Input::Section { title, .. } => title.clone(),
354 Input::Notebook { root, .. } => root.display().to_string(),481 Input::Notebooks { .. } => "Snowbound".into(),
355 })482 })
356 .with_inner_size(LogicalSize::new(1180.0, 760.0)),483 .with_inner_size(LogicalSize::new(1180.0, 760.0)),
357 )?,484 )?,
358 );485 );
359 macos::install_text_input(&window);486 platform::install_text_input(&window);
360 let access_adapter =487 let access_adapter =
361 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());488 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
362 window.set_visible(true);489 window.set_visible(visible);
363 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(490 let instance = wgpu::Instance::new(
364 Box::new(window.clone()),491 wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())),
365 ));492 );
366 let surface = instance.create_surface(window.clone())?;493 let surface = instance.create_surface(window.clone())?;
367 macos::configure_presentation(&surface);494 platform::configure_presentation(&surface);
368 let adapter = instance495 let adapter = instance
369 .request_adapter(&wgpu::RequestAdapterOptions {496 .request_adapter(&wgpu::RequestAdapterOptions {
370 compatible_surface: Some(&surface),497 compatible_surface: Some(&surface),
...@@ -384,7 +511,8 @@ impl State {...@@ -384,7 +511,8 @@ impl State {
384 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;511 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;
385 eprintln!("Using {} for {target}", path.display());512 eprintln!("Using {} for {target}", path.display());
386 }513 }
387 let mut library = None;514 let temporary = matches!(input, Input::Notes { .. } | Input::Page(_));
515 let mut notebooks = Vec::new();
388 let mut session = None;516 let mut session = None;
389 let (editor, scene) = match input {517 let (editor, scene) = match input {
390 Input::Notes {518 Input::Notes {
...@@ -404,51 +532,54 @@ impl State {...@@ -404,51 +532,54 @@ impl State {
404 let (scene, editor) = PageScene::from_page(page, &mut engine)?;532 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
405 (editor, Some((scene, [0.0; 2])))533 (editor, Some((scene, [0.0; 2])))
406 }534 }
407 Input::Section { file, title, cache } => {535 Input::Section { file, title } => {
408 let opened = Library {536 let library = Arc::new(Library::section(&file, &cache));
409 name: file_name(file.parent().unwrap_or(&file)),537 let section = library.open(&library.location, notify(proxy.clone()))?;
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())?;
422 let space = section538 let space = section
423 .pages()?539 .pages()?
424 .into_iter()540 .into_iter()
425 .find(|(_, candidate, _)| *candidate == title)541 .find(|(_, candidate, _)| *candidate == title)
426 .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?542 .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?
427 .0;543 .0;
428 library = Some(Arc::new(opened));544 notebooks.push(Arc::clone(&library));
429 let (opened, page) = read_session(section, 0, Some(space))?;545 let path = library.location.clone();
546 let (opened, page) = read_session(section, library, path, Some(space))?;
430 let (scene, editor) = PageScene::from_page(page, &mut engine)?;547 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
431 session = Some(opened);548 session = Some(opened);
432 (editor, Some((scene, [0.0; 2])))549 (editor, Some((scene, [0.0; 2])))
433 }550 }
434 Input::Notebook { root, cache } => {551 Input::Notebooks { locations, current } => {
435 let notebook = notebook::session::Notebook::open(&root, &cache)?;552 notebooks = locations
436 let tabs = tabs(&notebook);553 .iter()
437 if tabs.is_empty() {554 .map(|location| Arc::new(Library::notebook(location, &cache)))
438 return Err(format!("No readable sections in {}", root.display()).into());555 .collect();
439 }556 for library in &notebooks {
440 let opened = Library {557 if let Err(error) = &library.notebook {
441 name: file_name(&root),558 eprintln!("Cannot open the notebook at {}: {error}", library.location);
442 notebook: Some(notebook),559 }
443 tabs,560 }
444 cache,561 let shown = notebooks
445 };562 .iter()
446 let section = opened.open(0, proxy.clone())?;563 .filter(|library| Some(&library.location) == current.as_ref())
447 library = Some(Arc::new(opened));564 .chain(&notebooks)
448 let (opened, page) = read_session(section, 0, None)?;565 .find_map(|library| Some((library, library.first_section()?)));
449 let (scene, editor) = PageScene::from_page(page, &mut engine)?;566 match shown {
450 session = Some(opened);567 Some((library, path)) => {
451 (editor, Some((scene, [0.0; 2])))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 }
452 }583 }
453 };584 };
454 let initial_date = editor.date().map(|date| date.timestamp());585 let initial_date = editor.date().map(|date| date.timestamp());
...@@ -466,8 +597,8 @@ impl State {...@@ -466,8 +597,8 @@ impl State {
466 window.request_redraw();597 window.request_redraw();
467 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());598 eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info());
468 let mut ui = Ui::new(599 let mut ui = Ui::new(
469 theme(window.theme().unwrap_or(winit::window::Theme::Dark)),600 theme(platform::appearance(&window)),
470 macos::double_click_interval(),601 platform::double_click_interval(),
471 );602 );
472 if !interface_font.is_empty() {603 if !interface_font.is_empty() {
473 let family = ui604 let family = ui
...@@ -476,6 +607,11 @@ impl State {...@@ -476,6 +607,11 @@ impl State {
476 eprintln!("Using {family} for the interface");607 eprintln!("Using {family} for the interface");
477 }608 }
478 ui.set_focus(Some(page()));609 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 }
479 let mut fonts: Vec<String> = engine615 let mut fonts: Vec<String> = engine
480 .fonts616 .fonts
481 .collection617 .collection
...@@ -486,7 +622,9 @@ impl State {...@@ -486,7 +622,9 @@ impl State {
486 .collect();622 .collect();
487 fonts.sort_unstable_by_key(|name| name.to_lowercase());623 fonts.sort_unstable_by_key(|name| name.to_lowercase());
488 fonts.dedup();624 fonts.dedup();
625 let clipboard = platform::Clipboard::new(&window)?;
489 let state = Self {626 let state = Self {
627 author: platform::user_name(),
490 window,628 window,
491 redraw: Arc::new(Redraw(proxy.clone())).into(),629 redraw: Arc::new(Redraw(proxy.clone())).into(),
492 proxy,630 proxy,
...@@ -501,14 +639,28 @@ impl State {...@@ -501,14 +639,28 @@ impl State {
501 scene,639 scene,
502 [size.width, size.height],640 [size.width, size.height],
503 dpr,641 dpr,
504 macos::double_click_interval(),642 platform::double_click_interval(),
505 ),643 ),
506 library,644 notebooks,
507 session,645 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(),
508 filter: String::new(),659 filter: String::new(),
509 pages_open: true,660 pages_open: true,
510 fonts,661 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),
512 commands: Vec::new(),664 commands: Vec::new(),
513 changed: false,665 changed: false,
514 moved: false,666 moved: false,
...@@ -516,16 +668,19 @@ impl State {...@@ -516,16 +668,19 @@ impl State {
516 page_focused: true,668 page_focused: true,
517 pointer: [0.0; 2],669 pointer: [0.0; 2],
518 strip_press: None,670 strip_press: None,
671 strip_held: false,
519 snapshot: None,672 snapshot: None,
520 initial,673 initial,
521 initial_date,674 initial_date,
522 initial_layouts,675 initial_layouts,
523 occluded: false,676 occluded: false,
524 ime_allowed: true,677 ime_allowed: true,
525 clipboard: arboard::Clipboard::new()?,678 clipboard,
526 loads: mpsc::channel(),679 loads: mpsc::channel(),
527 loading: 0,680 loading: 0,
528 opening: None,681 opening: None,
682 places: HashMap::new(),
683 last_pages: HashMap::new(),
529 access_adapter,684 access_adapter,
530 accessibility: accessibility::Accessibility::default(),685 accessibility: accessibility::Accessibility::default(),
531 };686 };
...@@ -543,28 +698,15 @@ impl State {...@@ -543,28 +698,15 @@ impl State {
543 lap("open", start);698 lap("open", start);
544 let size = self.window.inner_size();699 let size = self.window.inner_size();
545 let scale = self.window.scale_factor() as f32;700 let scale = self.window.scale_factor() as f32;
546 self.ui.begin(701 self.layout(
547 [size.width as f32 / scale, size.height as f32 / scale],702 [size.width as f32 / scale, size.height as f32 / scale],
548 scale,703 scale,
549 Instant::now(),704 )?;
550 );
551 let (section, open_tab, open_page) = self.build()?;
552 self.ui.end();
553 self.edges(section, open_tab, open_page);
554 lap("build", start);705 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 }
565 self.window.set_cursor(706 self.window.set_cursor(
566 self.ui707 platform::resize_direction(&self.window, self.pointer)
567 .cursor()708 .map(CursorIcon::from)
709 .or_else(|| self.ui.cursor())
568 .unwrap_or_else(|| cursor_icon(self.view.cursor())),710 .unwrap_or_else(|| cursor_icon(self.view.cursor())),
569 );711 );
570 let ime = if self.ui.focused() == Some(page()) {712 let ime = if self.ui.focused() == Some(page()) {
...@@ -600,13 +742,34 @@ impl State {...@@ -600,13 +742,34 @@ impl State {
600 Ok(())742 Ok(())
601 }743 }
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
603 /// Builds the frame's boxes and returns the section's colours as they ease, the open765 /// Builds the frame's boxes and returns the section's colours as they ease, the open
604 /// section tab and the open page's tab, for the edges drawn once they are laid out.766 /// section tab and the open page's tab, for the edges drawn once they are laid out.
605 fn build(&mut self) -> Result<(ui::Section, Id, Option<Id>), Box<dyn Error>> {767 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) {768 let target = self.ui.theme.section(section_color(
607 (Some(library), Some(session)) => section_color(library.tabs[session.tab].color),769 self.session
608 _ => section_color(None),770 .as_ref()
609 });771 .and_then(|session| session.tabs[session.tab].color),
772 ));
610 let mut ease = |part: &str, color: [f32; 4]| -> [f32; 4] {773 let mut ease = |part: &str, color: [f32; 4]| -> [f32; 4] {
611 std::array::from_fn(|channel| {774 std::array::from_fn(|channel| {
612 self.ui775 self.ui
...@@ -623,14 +786,40 @@ impl State {...@@ -623,14 +786,40 @@ impl State {
623 accent: ease("accent", target.accent),786 accent: ease("accent", target.accent),
624 };787 };
625 self.ui.theme.accent = section.accent;788 self.ui.theme.accent = section.accent;
626 [789 // Elsewhere the theme holds the platform's colours.
627 self.ui.theme.caret,790 #[cfg(target_os = "macos")]
628 self.ui.theme.selection,791 {
629 self.ui.theme.inactive_selection,792 [
630 ] = macos::text_colors(&self.window);793 self.ui.theme.caret,
794 self.ui.theme.selection,
795 self.ui.theme.inactive_selection,
796 ] = platform::text_colors(&self.window);
797 }
631 let theme = self.ui.theme.clone();798 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 }
633 self.toolbar(&theme)?;806 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 );
634 self.ui.open(823 self.ui.open(
635 "tabs",824 "tabs",
636 Spec {825 Spec {
...@@ -647,34 +836,45 @@ impl State {...@@ -647,34 +836,45 @@ impl State {
647 ..Spec::default()836 ..Spec::default()
648 },837 },
649 );838 );
650 let (clicked, open_tab) = match (&self.library, &self.session) {839 let (clicked, open_tab) = match &self.session {
651 (Some(library), Some(session)) => {840 Some(session) => {
652 let tabs: Vec<_> = library841 let tabs: Vec<_> = session
653 .tabs842 .tabs
654 .iter()843 .iter()
655 .map(|tab| (tab.name.as_str(), section_color(tab.color)))844 .map(|tab| (tab.name.as_str(), section_color(tab.color)))
656 .collect();845 .collect();
657 ui::shell::section_tabs(846 let (clicked, context, open_tab) = ui::shell::section_tabs(
658 &mut self.ui,847 &mut self.ui,
659 "sections",848 "sections",
660 &tabs,849 &tabs,
661 session.tab,850 session.tab,
662 &section,851 &section,
663 TAB_ROW,852 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)
665 }875 }
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 ),
674 };876 };
675 if let Some(index) = clicked {877 self.commands.extend(clicked);
676 self.commands.push(Command::OpenSection(index));
677 }
678 if self.session.is_some() {878 if self.session.is_some() {
679 self.page_tools(&theme);879 self.page_tools(&theme);
680 }880 }
...@@ -698,12 +898,21 @@ impl State {...@@ -698,12 +898,21 @@ impl State {
698 ..Spec::default()898 ..Spec::default()
699 },899 },
700 );900 );
701 if self901 if let Some(session) = &self.session
702 .session902 && let Some(bar) = session.bar()
703 .as_ref()
704 .is_some_and(|session| session.conflict)
705 {903 {
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));
707 }916 }
708 self.ui.open_as(917 self.ui.open_as(
709 page(),918 page(),
...@@ -714,6 +923,7 @@ impl State {...@@ -714,6 +923,7 @@ impl State {
714 ..Spec::default()923 ..Spec::default()
715 },924 },
716 );925 );
926 self.template_strip(&theme);
717 let scroll = self.view.scroll();927 let scroll = self.view.scroll();
718 for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() {928 for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() {
719 if let Some(offset) = ui::scrollbar(929 if let Some(offset) = ui::scrollbar(
...@@ -741,6 +951,12 @@ impl State {...@@ -741,6 +951,12 @@ impl State {
741 self.ui.close();951 self.ui.close();
742 let open_page = self.page_list(&theme, &section);952 let open_page = self.page_list(&theme, &section);
743 self.ui.close();953 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 }
744 Ok((section, open_tab, open_page))960 Ok((section, open_tab, open_page))
745 }961 }
746962
...@@ -800,7 +1016,7 @@ impl State {...@@ -800,7 +1016,7 @@ impl State {
800 // The frame's top corners share the page's centres; its top edge breaks where the1016 // The frame's top corners share the page's centres; its top edge breaks where the
801 // open tab stands on it.1017 // open tab stands on it.
802 let [start, end] = [frame[0], frame[2]];1018 let [start, end] = [frame[0], frame[2]];
803 let outer = FRAME + ROUNDING;1019 let outer = platform::CORNER_RADIUS;
804 let strip = self.ui.theme.strip;1020 let strip = self.ui.theme.strip;
805 self.ui.round_corners(1021 self.ui.round_corners(
806 &[1022 &[
...@@ -847,7 +1063,7 @@ impl State {...@@ -847,7 +1063,7 @@ impl State {
847 self.ui.leaf(1063 self.ui.leaf(
848 "lights",1064 "lights",
849 Spec {1065 Spec {
850 size: [px(LIGHTS), px(1.0)],1066 size: [px(platform::LEADING), px(1.0)],
851 ..Spec::default()1067 ..Spec::default()
852 },1068 },
853 );1069 );
...@@ -856,11 +1072,11 @@ impl State {...@@ -856,11 +1072,11 @@ impl State {
856 Spec {1072 Spec {
857 size: [fit(), px(TITLE)],1073 size: [fit(), px(TITLE)],
858 image: self.app_icon.as_ref(),1074 image: self.app_icon.as_ref(),
859 text: Some(1075 text: Some(match &self.session {
860 self.library1076 Some(session) => &session.library.name,
861 .as_ref()1077 None if self.temporary => "Temporary page",
862 .map_or("Temporary page", |library| &library.name),1078 None => "Snowbound",
863 ),1079 }),
864 ..Spec::default()1080 ..Spec::default()
865 },1081 },
866 );1082 );
...@@ -883,6 +1099,7 @@ impl State {...@@ -883,6 +1099,7 @@ impl State {
883 },1099 },
884 );1100 );
885 }1101 }
1102 platform::window_controls(&mut self.ui, &self.window);
886 self.ui.close();1103 self.ui.close();
887 }1104 }
8881105
...@@ -905,6 +1122,12 @@ impl State {...@@ -905,6 +1122,12 @@ impl State {
905 tags: Vec::new(),1122 tags: Vec::new(),
906 });1123 });
907 let fonts = &self.fonts;1124 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();
908 let ui = &mut self.ui;1131 let ui = &mut self.ui;
909 let text = theme.text;1132 let text = theme.text;
910 let on = |toggle| state.toggles.contains(&toggle);1133 let on = |toggle| state.toggles.contains(&toggle);
...@@ -940,19 +1163,47 @@ impl State {...@@ -940,19 +1163,47 @@ impl State {
940 if ui::shell::combo(ui, "font", &font, 120.0).pressed {1163 if ui::shell::combo(ui, "font", &font, 120.0).pressed {
941 ui.open_popup(popup("font"));1164 ui.open_popup(popup("font"));
942 }1165 }
943 let items: Vec<_> = fonts1166 // 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
944 .iter()1191 .iter()
945 .map(|name| ui::popup::Item {1192 .map(|(label, name, separated)| ui::popup::Item {
946 text: name,1193 text: label,
947 font: Some(name),1194 font: *name,
948 checked: *name == font,1195 current: *name == Some(font.as_str()),
1196 heading: name.is_none(),
1197 separated: *separated,
949 ..Default::default()1198 ..Default::default()
950 })1199 })
951 .collect();1200 .collect();
952 let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default());1201 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
954 {1205 {
955 command = Some(Formatting::Font(fonts[index].clone()));1206 command = Some(Formatting::Font(name.to_owned()));
956 }1207 }
957 let combo = ui.id("size");1208 let combo = ui.id("size");
958 if ui::shell::combo(ui, "size", &size, 44.0).pressed {1209 if ui::shell::combo(ui, "size", &size, 44.0).pressed {
...@@ -1191,6 +1442,18 @@ impl State {...@@ -1191,6 +1442,18 @@ impl State {
1191 ..Spec::default()1442 ..Spec::default()
1192 },1443 },
1193 );1444 );
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 }
1194 let zoom = self.view.zoom();1457 let zoom = self.view.zoom();
1195 let label = format!("{:.0}%", zoom * 100.0);1458 let label = format!("{:.0}%", zoom * 100.0);
1196 let mut chosen = None;1459 let mut chosen = None;
...@@ -1225,6 +1488,12 @@ impl State {...@@ -1225,6 +1488,12 @@ impl State {
1225 self.respond(response);1488 self.respond(response);
1226 }1489 }
1227 if let Some(command) = command {1490 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 }
1228 let response = self.view.format(command)?;1497 let response = self.view.format(command)?;
1229 self.respond(response);1498 self.respond(response);
1230 }1499 }
...@@ -1309,7 +1578,9 @@ impl State {...@@ -1309,7 +1578,9 @@ impl State {
1309 self.ui.set_focus(Some(page()));1578 self.ui.set_focus(Some(page()));
1310 }1579 }
1311 }1580 }
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 }
1313 let toggle = if self.pages_open {1584 let toggle = if self.pages_open {
1314 art::SIDEBAR_COLLAPSE1585 art::SIDEBAR_COLLAPSE
1315 } else {1586 } else {
...@@ -1321,41 +1592,6 @@ impl State {...@@ -1321,41 +1592,6 @@ impl State {
1321 self.ui.close();1592 self.ui.close();
1322 }1593 }
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
1359 /// The section's pages as tabs down the frame's right side, returning the open page's1595 /// The section's pages as tabs down the frame's right side, returning the open page's
1360 /// tab, which is the page's colour and joins it.1596 /// tab, which is the page's colour and joins it.
1361 fn page_list(&mut self, theme: &Theme, section: &ui::Section) -> Option<Id> {1597 fn page_list(&mut self, theme: &Theme, section: &ui::Section) -> Option<Id> {
...@@ -1373,55 +1609,24 @@ impl State {...@@ -1373,55 +1609,24 @@ impl State {
1373 ..Spec::default()1609 ..Spec::default()
1374 },1610 },
1375 );1611 );
1376 let mut open = None;1612 let rows = page_rows(&mut self.ui, theme, section, session, &self.filter);
1377 for (space, title, level) in matching(&session.pages, &self.filter) {1613 self.commands.extend(rows.clicked.map(Command::OpenPage));
1378 let selected = *space == session.space;1614 if let Some((space, point)) = rows.context {
1379 let spec = Spec {1615 self.menu = Some((menus::Target::Page(space), point));
1380 flags: Flags::CLICKABLE,1616 self.ui.open_popup(menus::id());
1381 size: [px(PAGE_LIST), px(ROW)],1617 }
1382 text: Some(if title.is_empty() {1618 let rect = self.ui.rect(panel).unwrap_or_default();
1383 "Untitled page"1619 self.drag_pages(rows.held, section, rect);
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 }
1419 self.ui.close();1620 self.ui.close();
1420 open1621 rows.open
1421 }1622 }
14221623
1423 /// Hands the page the events routed to its box, in its device pixels.1624 /// Hands the page the events routed to its box, in its device pixels.
1424 fn page_events(&mut self, events: Vec<ui::Event>) -> Result<(), Box<dyn Error>> {1625 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());
1425 let scale = self.ui.scale();1630 let scale = self.ui.scale();
1426 let corner = self.ui.rect(page()).unwrap_or_default();1631 let corner = self.ui.rect(page()).unwrap_or_default();
1427 let device = |point: [f32; 2]| {1632 let device = |point: [f32; 2]| {
...@@ -1446,6 +1651,27 @@ impl State {...@@ -1446,6 +1651,27 @@ impl State {
1446 }1651 }
1447 }1652 }
1448 ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) => continue,1653 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 }
1449 ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?,1675 ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?,
1450 ui::Event::Key { key, text } => {1676 ui::Event::Key { key, text } => {
1451 let modifiers = self.view.modifiers();1677 let modifiers = self.view.modifiers();
...@@ -1467,7 +1693,7 @@ impl State {...@@ -1467,7 +1693,7 @@ impl State {
1467 self.respond(response);1693 self.respond(response);
1468 }1694 }
1469 if self.view.editor.marked_range().is_none() {1695 if self.view.editor.marked_range().is_none() {
1470 macos::clear_marked_text(&self.window);1696 platform::clear_marked_text(&self.window);
1471 }1697 }
1472 Ok(())1698 Ok(())
1473 }1699 }
...@@ -1483,30 +1709,79 @@ impl State {...@@ -1483,30 +1709,79 @@ impl State {
14831709
1484 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {1710 fn apply(&mut self, command: Command) -> Result<(), Box<dyn Error>> {
1485 match command {1711 match command {
1486 Command::OpenSection(tab) => {1712 Command::OpenSection(library, path) => {
1487 let library = Arc::clone(self.library.as_ref().ok_or("No sections to open")?);1713 let notify = notify(self.proxy.clone());
1488 let proxy = self.proxy.clone();1714 let last = self.last_pages.get(&library.key(&path)).copied();
1489 self.load(move || {1715 self.load(move || {
1490 let section = library.open(tab, proxy)?;1716 let section = library.open(&path, notify)?;
1491 let (session, page) = read_session(section, tab, None)?;1717 let (session, page) = read_session(section, library, path, last)?;
1492 Ok((Loaded::Section(Box::new(session)), page))1718 Ok((Loaded::Section(Box::new(session)), page))
1493 });1719 });
1494 }1720 }
1495 Command::OpenPage(space) => {1721 Command::OpenPage(space) => {
1496 let session = self.session.as_ref().ok_or("No section is open")?;1722 let session = self.session.as_ref().ok_or("No section is open")?;
1497 let replica = Arc::clone(session.section.replica());1723 let read = session.reader(space);
1498 self.load(move || Ok((Loaded::Page(space), replica.page(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));
1499 }1735 }
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)?,
1501 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,1776 Command::Page(Request::EditDate(field)) => self.edit_date(field)?,
1502 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,1777 Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?,
1503 Command::Page(Request::Paste) => {1778 Command::Page(Request::Paste) => {
1504 let text = self.clipboard.get_text()?;1779 let text = self.clipboard.get_text()?;
1505 let language = canvas::language::lcid(&macos::input_language());1780 let language = canvas::language::lcid(&platform::input_language());
1506 let response = self.view.paste(&text, language)?;1781 let response = self.view.paste(&text, language)?;
1507 self.respond(response);1782 self.respond(response);
1508 }1783 }
1509 Command::Page(Request::CharacterPalette) => macos::show_character_palette(),1784 Command::Page(Request::CharacterPalette) => platform::show_character_palette(),
1510 }1785 }
1511 Ok(())1786 Ok(())
1512 }1787 }
...@@ -1532,7 +1807,13 @@ impl State {...@@ -1532,7 +1807,13 @@ impl State {
1532 if id != self.loading {1807 if id != self.loading {
1533 continue;1808 continue;
1534 }1809 }
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 };
1536 let (scene, editor) = PageScene::from_page(page, &mut self.view.engine)?;1817 let (scene, editor) = PageScene::from_page(page, &mut self.view.engine)?;
1537 self.opening = Some(Opening {1818 self.opening = Some(Opening {
1538 loaded,1819 loaded,
...@@ -1558,19 +1839,51 @@ impl State {...@@ -1558,19 +1839,51 @@ impl State {
1558 }1839 }
1559 // The page shown so far keeps what was typed while the next one loaded.1840 // The page shown so far keeps what was typed while the next one loaded.
1560 self.persist()?;1841 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 }
1561 match opening.loaded {1847 match opening.loaded {
1562 Loaded::Section(session) => {1848 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 }
1563 self.session = Some(*session);1858 self.session = Some(*session);
1564 self.filter.clear();1859 self.filter.clear();
1860 self.save_settings();
1565 }1861 }
1566 Loaded::Page(space) => {1862 Loaded::Page(space) => {
1567 let session = self.session.as_mut().ok_or("No section is open")?;1863 let session = self.session.as_mut().ok_or("No section is open")?;
1568 session.space = space;1864 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()?;
1570 }1868 }
1571 }1869 }
1572 self.view.open(opening.editor, Some(opening.scene));1870 let place = self.session.as_ref().and_then(|session| {
1573 self.opened()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(())
1574 }1887 }
15751888
1576 /// Follows a page shown in place of another.1889 /// Follows a page shown in place of another.
...@@ -1620,6 +1933,7 @@ impl State {...@@ -1620,6 +1933,7 @@ impl State {
1620 .unwrap_or_default();1933 .unwrap_or_default();
1621 format!("{} · {file}", session.title())1934 format!("{} · {file}", session.title())
1622 }1935 }
1936 None if !self.temporary => "Snowbound".to_owned(),
1623 None => {1937 None => {
1624 let editor = &self.view.editor;1938 let editor = &self.view.editor;
1625 if !editor.active_outline().title {1939 if !editor.active_outline().title {
...@@ -1655,8 +1969,12 @@ impl State {...@@ -1655,8 +1969,12 @@ impl State {
1655 };1969 };
1656 let timestamp = date.timestamp();1970 let timestamp = date.timestamp();
1657 self.view.editor.finish_composition();1971 self.view.editor.finish_composition();
1658 macos::clear_marked_text(&self.window);1972 platform::clear_marked_text(&self.window);
1659 if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? {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)? {
1660 let response = self.view.change_date(timestamp, text)?;1978 let response = self.view.change_date(timestamp, text)?;
1661 self.respond(response);1979 self.respond(response);
1662 self.window.request_redraw();1980 self.window.request_redraw();
...@@ -1664,6 +1982,58 @@ impl State {...@@ -1664,6 +1982,58 @@ impl State {
1664 Ok(())1982 Ok(())
1665 }1983 }
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
1667 /// Hands the section the ops the editor recorded since the last call, as one edit; the2037 /// Hands the section the ops the editor recorded since the last call, as one edit; the
1668 /// section thread stores it.2038 /// section thread stores it.
1669 fn persist(&mut self) -> Result<(), Box<dyn Error>> {2039 fn persist(&mut self) -> Result<(), Box<dyn Error>> {
...@@ -1677,9 +2047,14 @@ impl State {...@@ -1677,9 +2047,14 @@ impl State {
1677 if ops.is_empty() {2047 if ops.is_empty() {
1678 return Ok(());2048 return Ok(());
1679 }2049 }
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 }
1680 let space = session.space;2055 let space = session.space;
1681 session.section.apply(2056 session.section.apply(
1682 "snowbound",2057 &self.author,
1683 onestore::op::Edit {2058 onestore::op::Edit {
1684 at: filetime(),2059 at: filetime(),
1685 ops: ops2060 ops: ops
...@@ -1704,7 +2079,7 @@ impl State {...@@ -1704,7 +2079,7 @@ impl State {
1704 let Some(session) = &self.session else {2079 let Some(session) = &self.session else {
1705 return Ok(());2080 return Ok(());
1706 };2081 };
1707 let page = session.section.page(session.space)?;2082 let page = session.reader(session.space)()?;
1708 let response = self.view.refresh(page)?;2083 let response = self.view.refresh(page)?;
1709 self.respond(response);2084 self.respond(response);
1710 self.title();2085 self.title();
...@@ -1720,62 +2095,30 @@ impl State {...@@ -1720,62 +2095,30 @@ impl State {
1720 // What the editor recorded since builds on the refused edit.2095 // What the editor recorded since builds on the refused edit.
1721 let _ = self.view.editor.take_ops();2096 let _ = self.view.editor.take_ops();
1722 self.view.editor.cancel_composition(&mut self.view.engine)?;2097 self.view.editor.cancel_composition(&mut self.view.engine)?;
1723 macos::clear_marked_text(&self.window);2098 platform::clear_marked_text(&self.window);
1724 if let Some(session) = &mut self.session {2099 if let Some(session) = &mut self.session {
1725 session.status = "Not saving";2100 session.status = "Not saving";
1726 }2101 }
1727 self.refresh()?;2102 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 );
1729 Ok(())2107 Ok(())
1730 }2108 }
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
1762 /// Applies what the section reported since the last poll.2110 /// Applies what the section reported since the last poll.
1763 fn synced(&mut self) -> Result<(), Box<dyn Error>> {2111 fn synced(&mut self) -> Result<(), Box<dyn Error>> {
1764 let Some(session) = &mut self.session else {2112 let Some(session) = &mut self.session else {
1765 return Ok(());2113 return Ok(());
1766 };2114 };
1767 let shown = (session.status, session.conflict);2115 let shown = session.status;
1768 let mut listed = false;2116 let mut listed = false;
1769 let mut changed = false;2117 let mut changed = false;
1770 let mut rejected = None;2118 let mut rejected = None;
1771 let mut conflicted = session.conflict;
1772 for event in session.section.events() {2119 for event in session.section.events() {
1773 use notebook::session::Event;2120 use notebook::session::Event;
1774 session.status = match event {2121 session.status = match event {
1775 Event::Refreshed => {
1776 listed = true;
1777 continue;
1778 }
1779 Event::Changed(spaces) => {2122 Event::Changed(spaces) => {
1780 listed = true;2123 listed = true;
1781 changed |= spaces.contains(&session.space);2124 changed |= spaces.contains(&session.space);
...@@ -1793,13 +2136,6 @@ impl State {...@@ -1793,13 +2136,6 @@ impl State {
1793 status: notebook::EditStatus::Published { .. },2136 status: notebook::EditStatus::Published { .. },
1794 ..2137 ..
1795 } => "Saved",2138 } => "Saved",
1796 Event::Attempt {
1797 status: notebook::EditStatus::Conflict(_),
1798 ..
1799 } => {
1800 conflicted = true;
1801 "Conflict"
1802 }
1803 Event::Attempt { .. } => "Saving",2139 Event::Attempt { .. } => "Saving",
1804 Event::Unreachable(_) => "Offline",2140 Event::Unreachable(_) => "Offline",
1805 Event::Failed(error) => {2141 Event::Failed(error) => {
...@@ -1808,23 +2144,22 @@ impl State {...@@ -1808,23 +2144,22 @@ impl State {
1808 }2144 }
1809 };2145 };
1810 }2146 }
1811 // Reading the queue waits on the section thread, so only a conflict shown or2147 if listed || changed || shown != session.status {
1812 // reported sends the frame thread there.
1813 if conflicted {
1814 session.refresh_conflict()?;
1815 }
1816 if listed || changed || shown != (session.status, session.conflict) {
1817 self.window.request_redraw();2148 self.window.request_redraw();
1818 }2149 }
1819 if listed {2150 if listed {
2151 let back = session.shown;
1820 session.pages = session.section.pages()?;2152 session.pages = session.section.pages()?;
1821 if !session2153 session.refresh_conflicts()?;
1822 .pages2154 let listed = |space: ExGuid| session.pages.iter().any(|(page, ..)| *page == space);
1823 .iter()2155 if !listed(session.space) && session.conflict(session.space).is_none() {
1824 .any(|(space, ..)| *space == session.space)2156 // A page removed elsewhere, or a conflict page deleted elsewhere, which
1825 {2157 // returns to its page.
1826 let first = session.pages.first().ok_or("The section has no pages")?.0;2158 let open = back
1827 self.commands.push(Command::OpenPage(first));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));
1828 return Ok(());2163 return Ok(());
1829 }2164 }
1830 }2165 }
...@@ -1950,7 +2285,7 @@ impl State {...@@ -1950,7 +2285,7 @@ impl State {
1950 }2285 }
1951 wgpu::CurrentSurfaceTexture::Lost => {2286 wgpu::CurrentSurfaceTexture::Lost => {
1952 self.surface = self.instance.create_surface(self.window.clone())?;2287 self.surface = self.instance.create_surface(self.window.clone())?;
1953 macos::configure_presentation(&self.surface);2288 platform::configure_presentation(&self.surface);
1954 self.surface.configure(&self.renderer.device, &self.config);2289 self.surface.configure(&self.renderer.device, &self.config);
1955 self.window.request_redraw();2290 self.window.request_redraw();
1956 return Ok(());2291 return Ok(());
...@@ -1972,7 +2307,7 @@ impl State {...@@ -1972,7 +2307,7 @@ impl State {
1972 self.window.pre_present_notify();2307 self.window.pre_present_notify();
1973 let start = Instant::now();2308 let start = Instant::now();
1974 self.renderer.queue.present(frame);2309 self.renderer.queue.present(frame);
1975 macos::commit_presentation(&self.window);2310 platform::commit_presentation(&self.window);
1976 lap("present", start);2311 lap("present", start);
1977 trace_input(&"Present submitted");2312 trace_input(&"Present submitted");
1978 if reconfigure {2313 if reconfigure {
...@@ -2113,11 +2448,28 @@ impl State {...@@ -2113,11 +2448,28 @@ impl State {
2113 Ok(())2448 Ok(())
2114 }2449 }
21152450
2116 /// Queues input for the next frame. A press on the strip moves the window at once,2451 /// Queues input for the next frame. A press on the strip moves the window: at once on
2117 /// while the platform still holds the press; a double press zooms it.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.
2118 fn input(&mut self, event: ui::Event) {2454 fn input(&mut self, event: ui::Event) {
2119 if let ui::Event::PointerMoved(point) = event {2455 if let ui::Event::PointerMoved(point) = event {
2120 self.pointer = point;2456 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;
2121 }2473 }
2122 if let ui::Event::Button {2474 if let ui::Event::Button {
2123 button: MouseButton::Left,2475 button: MouseButton::Left,
...@@ -2127,13 +2479,15 @@ impl State {...@@ -2127,13 +2479,15 @@ impl State {
2127 && self.ui.box_at(self.pointer) == Some(strip())2479 && self.ui.box_at(self.pointer) == Some(strip())
2128 {2480 {
2129 let double = self.strip_press.is_some_and(|last| {2481 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()
2131 });2483 });
2132 self.strip_press = (!double).then_some(at);2484 self.strip_press = (!double).then_some(at);
2133 if double {2485 if double {
2134 macos::zoom(&self.window);2486 platform::zoom(&self.window);
2135 } else {2487 } else if cfg!(target_os = "macos") {
2136 let _ = self.window.drag_window();2488 let _ = self.window.drag_window();
2489 } else {
2490 self.strip_held = true;
2137 }2491 }
2138 return;2492 return;
2139 }2493 }
...@@ -2149,20 +2503,328 @@ fn theme(appearance: winit::window::Theme) -> Theme {...@@ -2149,20 +2503,328 @@ fn theme(appearance: winit::window::Theme) -> Theme {
2149 }2503 }
2150}2504}
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
2161/// A section's colour as linear RGBA; sections without one take OneNote's default blue.2506/// A section's colour as linear RGBA; sections without one take OneNote's default blue.
2162fn section_color(color: Option<u32>) -> [f32; 4] {2507fn section_color(color: Option<u32>) -> [f32; 4] {
2163 color.map_or(draw::srgb(0x8a, 0xa8, 0xe4), canvas::gpu::colorref)2508 color.map_or(draw::srgb(0x8a, 0xa8, 0xe4), canvas::gpu::colorref)
2164}2509}
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
2166/// The pages whose titles contain `filter`, ignoring case.2828/// The pages whose titles contain `filter`, ignoring case.
2167fn matching<'a>(2829fn matching<'a>(
2168 pages: &'a [(ExGuid, String, u32)],2830 pages: &'a [(ExGuid, String, u32)],
...@@ -2212,50 +2874,42 @@ fn divider(ui: &mut Ui, part: &str, theme: &Theme) {...@@ -2212,50 +2874,42 @@ fn divider(ui: &mut Ui, part: &str, theme: &Theme) {
2212 );2874 );
2213}2875}
22142876
2215/// Section tabs for a notebook's readable top-level sections, in its order.2877/// The session for `section`, at catalog `path` in `library`, showing `space` or its first
2216fn tabs(notebook: &notebook::session::Notebook) -> Vec<Tab> {2878/// page, and that page.
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.
2238fn read_session(2879fn read_session(
2239 section: notebook::session::Section,2880 section: notebook::session::Section,
2240 tab: usize,2881 library: Arc<Library>,
2882 path: String,
2241 space: Option<ExGuid>,2883 space: Option<ExGuid>,
2242) -> Result<(Session, Page), Box<dyn Error>> {2884) -> 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")?;
2243 let pages = section.pages()?;2891 let pages = section.pages()?;
2244 let space = match space {2892 let space = space
2245 Some(space) => space,2893 .filter(|space| pages.iter().any(|(candidate, ..)| candidate == space))
2246 None => pages.first().ok_or("The section has no pages")?.0,2894 .or(pages.first().map(|(space, ..)| *space))
2247 };2895 .ok_or("The section has no pages")?;
2248 let page = section.page(space)?;2896 let page = section.page(space)?;
2249 let mut session = Session {2897 let conflicts = section.conflicts()?;
2250 section,2898 Ok((
2251 tab,2899 Session {
2252 pages,2900 section,
2253 space,2901 library,
2254 status: "",2902 tabs,
2255 conflict: false,2903 tab,
2256 };2904 pages,
2257 session.refresh_conflict()?;2905 space,
2258 Ok((session, page))2906 status: "",
2907 conflicts,
2908 shown: None,
2909 change: None,
2910 },
2911 page,
2912 ))
2259}2913}
22602914
2261/// The text the editor shows, outline by outline, without hidden field codes.2915/// The text the editor shows, outline by outline, without hidden field codes.
...@@ -2315,8 +2969,12 @@ impl App {...@@ -2315,8 +2969,12 @@ impl App {
2315 .outlines()2969 .outlines()
2316 .iter()2970 .iter()
2317 .map(|outline| (&outline.id, outline.document())))2971 .map(|outline| (&outline.id, outline.document())))
2318 }) || macos::discard_changes()2972 }) || platform::confirm(
2319 {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 ) {
2320 event_loop.exit();2978 event_loop.exit();
2321 }2979 }
2322 }2980 }
...@@ -2396,7 +3054,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -2396,7 +3054,7 @@ impl ApplicationHandler<UserEvent> for App {
2396 }3054 }
2397 };3055 };
2398 if was_marked && state.view.editor.marked_range().is_none() {3056 if was_marked && state.view.editor.marked_range().is_none() {
2399 macos::clear_marked_text(&state.window);3057 platform::clear_marked_text(&state.window);
2400 }3058 }
2401 if let Err(error) = result {3059 if let Err(error) = result {
2402 eprintln!("{error}");3060 eprintln!("{error}");
...@@ -2413,8 +3071,17 @@ impl ApplicationHandler<UserEvent> for App {...@@ -2413,8 +3071,17 @@ impl ApplicationHandler<UserEvent> for App {
2413 self.input.take().unwrap(),3071 self.input.take().unwrap(),
2414 &self.substitutes,3072 &self.substitutes,
2415 std::mem::take(&mut self.interface_font),3073 std::mem::take(&mut self.interface_font),
3074 self.screenshot.is_none(),
3075 self.settings.take().unwrap_or_default(),
3076 self.cache.clone(),
2416 )) {3077 )) {
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 },
2418 Err(error) => {3085 Err(error) => {
2419 self.startup_error = Some(error);3086 self.startup_error = Some(error);
2420 event_loop.exit();3087 event_loop.exit();
...@@ -2466,7 +3133,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -2466,7 +3133,7 @@ impl ApplicationHandler<UserEvent> for App {
2466 }3133 }
2467 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {3134 WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
2468 state.renderer.clear_glyph_cache();3135 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);
2470 let response = state.view.scale_factor_changed(scale_factor as f32)?;3137 let response = state.view.scale_factor_changed(scale_factor as f32)?;
2471 state.respond(response);3138 state.respond(response);
2472 state.window.request_redraw();3139 state.window.request_redraw();
...@@ -2593,7 +3260,10 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy...@@ -2593,7 +3260,10 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
2593 rest.split_whitespace()3260 rest.split_whitespace()
2594 .map(|name| match name {3261 .map(|name| match name {
2595 "shift" => Ok(ModifiersState::SHIFT),3262 "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),
2597 _ => Err(format!("Unknown modifier {name}")),3267 _ => Err(format!("Unknown modifier {name}")),
2598 })3268 })
2599 .try_fold(ModifiersState::empty(), |all, one| one.map(|one| all | one))?,3269 .try_fold(ModifiersState::empty(), |all, one| one.map(|one| all | one))?,
...@@ -2640,6 +3310,8 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -2640,6 +3310,8 @@ fn main() -> Result<(), Box<dyn Error>> {
2640 let mut section = None;3310 let mut section = None;
2641 let mut notebook = None;3311 let mut notebook = None;
2642 let mut cache = None;3312 let mut cache = None;
3313 let mut settings_file = None;
3314 let mut screenshot = None;
2643 while let Some(arg) = args.next() {3315 while let Some(arg) = args.next() {
2644 if arg == "--substitute-font" {3316 if arg == "--substitute-font" {
2645 substitutes.push(PathBuf::from(3317 substitutes.push(PathBuf::from(
...@@ -2691,6 +3363,15 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -2691,6 +3363,15 @@ fn main() -> Result<(), Box<dyn Error>> {
2691 cache = Some(PathBuf::from(3363 cache = Some(PathBuf::from(
2692 args.next().ok_or("Provide a directory after --cache.")?,3364 args.next().ok_or("Provide a directory after --cache.")?,
2693 ));3365 ));
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 ));
2694 } else if arg == "--reference" || arg == "--page" {3375 } else if arg == "--reference" || arg == "--page" {
2695 if reference.is_some() {3376 if reference.is_some() {
2696 return Err("Only one page can be opened.".into());3377 return Err("Only one page can be opened.".into());
...@@ -2725,7 +3406,7 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -2725,7 +3406,7 @@ fn main() -> Result<(), Box<dyn Error>> {
2725 }3406 }
2726 if positional.len() > 2 {3407 if positional.len() > 2 {
2727 return Err(3408 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]..."
2729 .into(),3410 .into(),
2730 );3411 );
2731 }3412 }
...@@ -2745,15 +3426,28 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -2745,15 +3426,28 @@ fn main() -> Result<(), Box<dyn Error>> {
2745 });3426 });
2746 let cache = match cache {3427 let cache = match cache {
2747 Some(cache) => cache,3428 Some(cache) => cache,
2748 None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?)3429 None => platform::cache_dir().ok_or("HOME is not set.")?,
2749 .join("Library/Caches/snowbound"),
2750 };3430 };
2751 let input = if let Some(root) = notebook {3431 let settings_file = settings_file.or_else(settings::default_path);
2752 Input::Notebook { root, cache }3432 let saved = settings_file
2753 } else if let Some((file, title)) = section {3433 .as_deref()
2754 Input::Section { file, title, cache }3434 .map(settings::Settings::load)
3435 .unwrap_or_default();
3436 let input = if let Some((file, title)) = section {
3437 Input::Section { file, title }
2755 } else if editable {3438 } else if editable {
2756 Input::Page(reference.unwrap())3439 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 }
2757 } else {3451 } else {
2758 Input::Notes {3452 Input::Notes {
2759 document: TextDocument::new(3453 document: TextDocument::new(
...@@ -2765,15 +3459,19 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -2765,15 +3459,19 @@ fn main() -> Result<(), Box<dyn Error>> {
2765 reference,3459 reference,
2766 }3460 }
2767 };3461 };
2768 let event_loop = macos::event_loop()?;3462 let event_loop = platform::event_loop(screenshot.is_some())?;
2769 if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") {3463 if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") {
2770 replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?;3464 replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?;
2771 }3465 }
2772 let mut app = App {3466 let mut app = App {
2773 proxy: event_loop.create_proxy(),3467 proxy: event_loop.create_proxy(),
2774 input: Some(input),3468 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,
2775 substitutes,3472 substitutes,
2776 interface_font,3473 interface_font,
3474 screenshot,
2777 state: None,3475 state: None,
2778 startup_error: None,3476 startup_error: None,
2779 };3477 };
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...@@ -6,6 +6,7 @@ publish = false
66
7[dependencies]7[dependencies]
8draw = { path = "../draw" }8draw = { path = "../draw" }
9nucleo-matcher = "0.3.1"
9parley.workspace = true10parley.workspace = true
10winit = "0.30.13"11winit = "0.30.13"
1112
crates/ui/examples/popups.rs+4-4
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1//! Renders each popup control open, in both themes, to `/tmp/ui-popups-{dark,light}.png`,1//! Renders each popup control open, in both themes, to `/tmp/ui-popups-{dark,light}.png`,
2//! and frames of lists changing to `/tmp/ui-list-{keystroke,delete}-{dark,light}.png`: a2//! 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 times3//! combo's list after `cou` is typed into its filter, and a page list losing rows. Then times
4//! a list of 200 000 rows.4//! a list of 200 000 rows.
55
6use std::{6use std::{
...@@ -72,7 +72,7 @@ fn main() {...@@ -72,7 +72,7 @@ fn main() {
72/// Frames a change shows at: before it, then each of the 150 ms it takes.72/// Frames a change shows at: before it, then each of the 150 ms it takes.
73const FRAMES: [u32; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 10];73const 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.
76fn keystroke(76fn keystroke(
77 theme: &Theme,77 theme: &Theme,
78 device: &wgpu::Device,78 device: &wgpu::Device,
...@@ -92,7 +92,7 @@ fn keystroke(...@@ -92,7 +92,7 @@ fn keystroke(
92 };92 };
93 scene.open(id, Vec::new(), build);93 scene.open(id, Vec::new(), build);
94 let mut panels = vec![paint(device, queue, renderer, &scene.ui)];94 let mut panels = vec![paint(device, queue, renderer, &scene.ui)];
95 for event in typed("c") {95 for event in typed("cou") {
96 scene.ui.event(event);96 scene.ui.event(event);
97 }97 }
98 for frame in 1..=10 {98 for frame in 1..=10 {
...@@ -276,7 +276,7 @@ fn measure() {...@@ -276,7 +276,7 @@ fn measure() {
276 .map(|_| frame(&mut ui, &rows, &mut selected))276 .map(|_| frame(&mut ui, &rows, &mut selected))
277 .collect();277 .collect();
278 println!(278 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",
280 rows.count(),280 rows.count(),
281 average(times)281 average(times)
282 );282 );
crates/ui/src/lib.rs+45-55
...@@ -213,6 +213,10 @@ pub struct Spec<'a> {...@@ -213,6 +213,10 @@ pub struct Spec<'a> {
213 pub axis: Axis,213 pub axis: Axis,
214 pub text: Option<&'a str>,214 pub text: Option<&'a str>,
215 /// The family the label is shaped in, where it has the glyphs; the interface's otherwise.215 /// 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,
216 pub font: Option<&'a str>,220 pub font: Option<&'a str>,
217 /// The label's colour; the theme's text colour otherwise.221 /// The label's colour; the theme's text colour otherwise.
218 pub color: Option<[f32; 4]>,222 pub color: Option<[f32; 4]>,
...@@ -232,7 +236,8 @@ pub struct Spec<'a> {...@@ -232,7 +236,8 @@ pub struct Spec<'a> {
232 pub shape: Shape,236 pub shape: Shape,
233 /// 16 px artwork before the label, tinted with the label's colour.237 /// 16 px artwork before the label, tinted with the label's colour.
234 pub icon: Option<&'static [&'static str]>,238 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.
236 pub image: Option<&'a RasterImage>,241 pub image: Option<&'a RasterImage>,
237 /// Inset of the label and children on each axis.242 /// Inset of the label and children on each axis.
238 pub pad: [f32; 2],243 pub pad: [f32; 2],
...@@ -245,11 +250,6 @@ pub struct Spec<'a> {...@@ -245,11 +250,6 @@ pub struct Spec<'a> {
245 /// Floats the box over all others beside a rectangle in the window, as a popup250 /// Floats the box over all others beside a rectangle in the window, as a popup
246 /// `Ui::open_popup` opened; boxes beneath take no input while one is open.251 /// `Ui::open_popup` opened; boxes beneath take no input while one is open.
247 pub anchor: Option<Anchor>,252 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]>,
253}253}
254254
255/// Input the host forwards; positions and wheel distances are logical pixels.255/// Input the host forwards; positions and wheel distances are logical pixels.
...@@ -283,6 +283,8 @@ pub struct Signal {...@@ -283,6 +283,8 @@ pub struct Signal {
283 pub clicked: bool,283 pub clicked: bool,
284 /// The primary button is held after pressing the box.284 /// The primary button is held after pressing the box.
285 pub dragging: bool,285 pub dragging: bool,
286 /// The secondary button went down on the box, here: where its context menu opens.
287 pub context: Option<[f32; 2]>,
286 pub focused: bool,288 pub focused: bool,
287 /// For custom boxes: every event routed to the box, in order. For focused boxes:289 /// For custom boxes: every event routed to the box, in order. For focused boxes:
288 /// keys, text and composition. For scrolling boxes: the wheel.290 /// keys, text and composition. For scrolling boxes: the wheel.
...@@ -329,9 +331,6 @@ struct Built {...@@ -329,9 +331,6 @@ struct Built {
329 position: [f32; 2],331 position: [f32; 2],
330 cursor: Option<CursorIcon>,332 cursor: Option<CursorIcon>,
331 anchor: Option<Anchor>,333 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]>,
335 /// Rectangles relative to the box, painted over its fill.334 /// Rectangles relative to the box, painted over its fill.
336 marks: Vec<([f32; 4], [f32; 4], f32)>,335 marks: Vec<([f32; 4], [f32; 4], f32)>,
337 computed: [f32; 2],336 computed: [f32; 2],
...@@ -368,6 +367,8 @@ struct Popup {...@@ -368,6 +367,8 @@ struct Popup {
368 /// Its filter field's text, and the key of the row the keyboard or pointer last chose.367 /// Its filter field's text, and the key of the row the keyboard or pointer last chose.
369 query: String,368 query: String,
370 highlight: Option<u64>,369 highlight: Option<u64>,
370 /// The height its results ease from and to, and the seconds since they set out.
371 height: Option<[f32; 3]>,
371}372}
372373
373struct Hit {374struct Hit {
...@@ -422,34 +423,6 @@ enum Display {...@@ -422,34 +423,6 @@ enum Display {
422 },423 },
423}424}
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
453pub struct Ui {426pub struct Ui {
454 pub theme: Theme,427 pub theme: Theme,
455 /// Whether the window has keyboard focus; without it a field hides its caret and dims428 /// Whether the window has keyboard focus; without it a field hides its caret and dims
...@@ -484,6 +457,9 @@ pub struct Ui {...@@ -484,6 +457,9 @@ pub struct Ui {
484 /// Where the popups' painting starts in `display`, for edges drawn beneath them.457 /// Where the popups' painting starts in `display`, for edges drawn beneath them.
485 popups_painted: usize,458 popups_painted: usize,
486 animating: bool,459 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,
487 /// The field showing a caret, when its blink started and the frame it last showed.463 /// The field showing a caret, when its blink started and the frame it last showed.
488 caret: Option<(Id, Instant, u64)>,464 caret: Option<(Id, Instant, u64)>,
489 /// When the next timed change is due, such as a caret's blink.465 /// When the next timed change is due, such as a caret's blink.
...@@ -520,6 +496,7 @@ impl Ui {...@@ -520,6 +496,7 @@ impl Ui {
520 display: Vec::new(),496 display: Vec::new(),
521 popups_painted: 0,497 popups_painted: 0,
522 animating: false,498 animating: false,
499 routed: false,
523 caret: None,500 caret: None,
524 wake: None,501 wake: None,
525 }502 }
...@@ -532,7 +509,7 @@ impl Ui {...@@ -532,7 +509,7 @@ impl Ui {
532509
533 /// Whether queued input or animation needs another frame.510 /// Whether queued input or animation needs another frame.
534 pub fn wants_frame(&self) -> bool {511 pub fn wants_frame(&self) -> bool {
535 self.animating || !self.queue.is_empty()512 self.animating || self.routed || !self.queue.is_empty()
536 }513 }
537514
538 /// When a timed change, such as a caret's blink, next needs a frame.515 /// When a timed change, such as a caret's blink, next needs a frame.
...@@ -548,7 +525,8 @@ impl Ui {...@@ -548,7 +525,8 @@ impl Ui {
548 self.modifiers525 self.modifiers
549 }526 }
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]> {
552 self.pointer530 self.pointer
553 }531 }
554532
...@@ -609,6 +587,7 @@ impl Ui {...@@ -609,6 +587,7 @@ impl Ui {
609 self.signals.clear();587 self.signals.clear();
610 self.moved = false;588 self.moved = false;
611 self.wake = None;589 self.wake = None;
590 self.routed = !self.queue.is_empty();
612 for event in std::mem::take(&mut self.queue) {591 for event in std::mem::take(&mut self.queue) {
613 self.route(event);592 self.route(event);
614 }593 }
...@@ -657,6 +636,11 @@ impl Ui {...@@ -657,6 +636,11 @@ impl Ui {
657 self.close_from(under + 1);636 self.close_from(under + 1);
658 }637 }
659 let target = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM);638 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 }
660 if let Some(id) = target644 if let Some(id) = target
661 && button == MouseButton::Left645 && button == MouseButton::Left
662 {646 {
...@@ -782,6 +766,7 @@ impl Ui {...@@ -782,6 +766,7 @@ impl Ui {
782 focus: self.focus,766 focus: self.focus,
783 query: String::new(),767 query: String::new(),
784 highlight: None,768 highlight: None,
769 height: None,
785 });770 });
786 self.focus = Some(id);771 self.focus = Some(id);
787 self.states.entry(id).or_default().touched = self.frame;772 self.states.entry(id).or_default().touched = self.frame;
...@@ -855,7 +840,10 @@ impl Ui {...@@ -855,7 +840,10 @@ impl Ui {
855 pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id {840 pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id {
856 let label = spec841 let label = spec
857 .text842 .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 });
859 // Popups hang from the root, outside the clips and flow of where they are built.847 // Popups hang from the root, outside the clips and flow of where they are built.
860 let parent = if spec.anchor.is_some() {848 let parent = if spec.anchor.is_some() {
861 0849 0
...@@ -899,10 +887,16 @@ impl Ui {...@@ -899,10 +887,16 @@ impl Ui {
899 self.texts.use_fonts(files)887 self.texts.use_fonts(files)
900 }888 }
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
902 /// The size of `text` as a label, in logical pixels.896 /// The size of `text` as a label, in logical pixels.
903 pub fn measure(&mut self, text: &str) -> [f32; 2] {897 pub fn measure(&mut self, text: &str) -> [f32; 2] {
904 self.texts898 self.texts
905 .label(text, self.theme.font_size, None, self.frame)899 .label(text, self.theme.font_size, false, None, self.frame)
906 .size900 .size
907 }901 }
908902
...@@ -924,8 +918,10 @@ impl Ui {...@@ -924,8 +918,10 @@ impl Ui {
924 };918 };
925 self.caret = Some((id, start, self.frame));919 self.caret = Some((id, start, self.frame));
926 let (opacity, hold) = draw::edit::caret_blink(self.now.saturating_duration_since(start));920 let (opacity, hold) = draw::edit::caret_blink(self.now.saturating_duration_since(start));
927 let due = self.now + hold;921 if let Some(hold) = hold {
928 self.wake = Some(self.wake.map_or(due, |wake| wake.min(due)));922 let due = self.now + hold;
923 self.wake = Some(self.wake.map_or(due, |wake| wake.min(due)));
924 }
929 opacity925 opacity
930 }926 }
931927
...@@ -983,7 +979,6 @@ impl Ui {...@@ -983,7 +979,6 @@ impl Ui {
983 }979 }
984980
985 fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) {981 fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) {
986 let start = self.display.len();
987 let node = &self.nodes[index];982 let node = &self.nodes[index];
988 let state = &self.states[&node.id];983 let state = &self.states[&node.id];
989 let rect = node.rect;984 let rect = node.rect;
...@@ -1087,9 +1082,14 @@ impl Ui {...@@ -1087,9 +1082,14 @@ impl Ui {
1087 });1082 });
1088 x += ICON + ICON_GAP;1083 x += ICON + ICON_GAP;
1089 } else if let Some(image) = &node.image {1084 } 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 };
1090 self.display.push(Display::Image {1090 self.display.push(Display::Image {
1091 image: image.clone(),1091 image: image.clone(),
1092 rect: [x, top, x + ICON, top + ICON],1092 rect,
1093 });1093 });
1094 x += ICON + ICON_GAP;1094 x += ICON + ICON_GAP;
1095 }1095 }
...@@ -1136,14 +1136,6 @@ impl Ui {...@@ -1136,14 +1136,6 @@ impl Ui {
1136 if inner_clip != clip {1136 if inner_clip != clip {
1137 self.display.push(Display::Clip(clip));1137 self.display.push(Display::Clip(clip));
1138 }1138 }
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 }
1147 }1139 }
11481140
1149 /// This frame's painting in order, in logical pixels.1141 /// This frame's painting in order, in logical pixels.
...@@ -1319,8 +1311,6 @@ impl Built {...@@ -1319,8 +1311,6 @@ impl Built {
1319 position: spec.position,1311 position: spec.position,
1320 cursor: spec.cursor,1312 cursor: spec.cursor,
1321 anchor: spec.anchor,1313 anchor: spec.anchor,
1322 alpha: 1.0 - spec.fade,
1323 fade_into: spec.fade_into,
1324 marks: Vec::new(),1314 marks: Vec::new(),
1325 computed: [0.0; 2],1315 computed: [0.0; 2],
1326 relative: [0.0; 2],1316 relative: [0.0; 2],
crates/ui/src/list.rs+48-319
...@@ -1,18 +1,13 @@...@@ -1,18 +1,13 @@
1//! A virtualized list of equal rows keyed by their items: only rows in view are built,1//! A virtualized list of equal rows keyed by their items: only rows in view are built, and
2//! rows slide to where their items go, rows leaving fold into the seam their gap closes on2//! the view holds its place on the selection while items arrive and leave above it.
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.
53
6use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar};4use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar};
7use std::collections::{HashMap, HashSet};
8use winit::keyboard::NamedKey;5use winit::keyboard::NamedKey;
96
10/// How far rows fade out under a scrolled list's edges.7/// How far rows fade out under a scrolled list's edges.
11const EDGE: f64 = 10.0;8const EDGE: f64 = 10.0;
12/// Room beside a scrolling list's rows for its scrollbar.9/// Room beside a scrolling list's rows for its scrollbar.
13pub(crate) const GUTTER: f32 = 12.0;10pub(crate) const GUTTER: f32 = 12.0;
14/// Seconds rows take to reach their new places.
15const DURATION: f32 = 0.25;
1611
17/// Items a list shows, in order.12/// Items a list shows, in order.
18pub trait Rows {13pub trait Rows {
...@@ -44,11 +39,10 @@ pub struct List<'a, R> {...@@ -44,11 +39,10 @@ pub struct List<'a, R> {
44 pub hover_selects: bool,39 pub hover_selects: bool,
45}40}
4641
47/// A row to build: its item's key, its index while listed or None as it folds away after42/// A row to build: its item's key, its index, and whether it is selected.
48/// its item left or moved, and whether it is selected.
49pub struct Row {43pub struct Row {
50 pub key: u64,44 pub key: u64,
51 pub index: Option<usize>,45 pub index: usize,
52 pub selected: bool,46 pub selected: bool,
53}47}
5448
...@@ -57,72 +51,12 @@ pub struct Row {...@@ -57,72 +51,12 @@ pub struct Row {
57pub(crate) struct State {51pub(crate) struct State {
58 scroll: f64,52 scroll: f64,
59 target: f64,53 target: f64,
60 /// Rows built last frame, from the top of the view down.54 /// Keys of the rows built last frame and their tops from the top of the view.
61 shown: Vec<Shown>,55 shown: Vec<(u64, f64)>,
62 /// The selection last frame, so a new one scrolls into view.56 /// The selection last frame, so a new one scrolls into view.
63 selected: Option<u64>,57 selected: Option<u64>,
64}58}
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
126/// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves60/// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves
127/// `selected` with the list's keys. Returns the index of the row clicked.61/// `selected` with the list's keys. Returns the index of the row clicked.
128pub fn list<R: Rows>(62pub fn list<R: Rows>(
...@@ -165,15 +99,22 @@ pub fn list<R: Rows>(...@@ -165,15 +99,22 @@ pub fn list<R: Rows>(
165 let mut state = ui.lists.remove(&id).unwrap_or_default();99 let mut state = ui.lists.remove(&id).unwrap_or_default();
166100
167 // Hold the view on the selection, or the first row showing, wherever it now lies.101 // 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());102 let held = state.selected.and_then(|selected| {
169 let held = state.selected.and_then(|key| {103 state
170 listed().find(|shown| shown.key == key && shown.place >= 0.0 && shown.place < view)104 .shown
105 .iter()
106 .find(|(key, place)| *key == selected && (0.0..view).contains(place))
171 });107 });
172 // At the top, the view stays there to show what arrives.108 // At the top, the view stays there to show what arrives.
173 let anchor = held109 let anchor = held
174 .into_iter()110 .into_iter()
175 .chain(listed().filter(|shown| state.scroll > 0.0 && shown.place + height > 0.0))111 .chain(
176 .find_map(|shown| Some(top(rows.find(shown.key)?) - shown.place));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));
177 if let Some(scroll) = anchor {118 if let Some(scroll) = anchor {
178 state.target += scroll - state.scroll;119 state.target += scroll - state.scroll;
179 state.scroll = scroll;120 state.scroll = scroll;
...@@ -188,17 +129,17 @@ pub fn list<R: Rows>(...@@ -188,17 +129,17 @@ pub fn list<R: Rows>(
188 }129 }
189 }130 }
190 let mut clicked = None;131 let mut clicked = None;
191 for shown in state.shown.iter().filter(|shown| shown.index.is_some()) {132 for (key, _) in &state.shown {
192 let signal = ui.signal(id.child(shown.key));133 let signal = ui.signal(id.child(*key));
193 let Some(index) = rows.find(shown.key).filter(|index| rows.selectable(*index)) else {134 let Some(index) = rows.find(*key).filter(|index| rows.selectable(*index)) else {
194 continue;135 continue;
195 };136 };
196 if hover_selects && signal.hovered && ui.moved {137 if hover_selects && signal.hovered && ui.moved {
197 *selected = Some(shown.key);138 *selected = Some(*key);
198 state.selected = *selected;139 state.selected = *selected;
199 }140 }
200 if signal.clicked {141 if signal.clicked {
201 *selected = Some(shown.key);142 *selected = Some(*key);
202 clicked = Some(index);143 clicked = Some(index);
203 }144 }
204 }145 }
...@@ -248,218 +189,7 @@ pub fn list<R: Rows>(...@@ -248,218 +189,7 @@ pub fn list<R: Rows>(
248 state.scroll += (state.target - state.scroll) * rate;189 state.scroll += (state.target - state.scroll) * rate;
249 }190 }
250 let scroll = state.scroll;191 let scroll = state.scroll;
251 let step = scroll - anchored;192 ui.animating |= scroll != state.target;
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());
463193
464 let theme = ui.theme.clone();194 let theme = ui.theme.clone();
465 let background = spec.fill.unwrap_or(theme.base);195 let background = spec.fill.unwrap_or(theme.base);
...@@ -475,34 +205,35 @@ pub fn list<R: Rows>(...@@ -475,34 +205,35 @@ pub fn list<R: Rows>(
475 Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER),205 Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER),
476 _ => fill(),206 _ => fill(),
477 };207 };
478 // Rows folding away paint beneath the rest, and blend into the list rather than fade,208 let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len);
479 // so those squeezed together never add up.209 let mut shown = Vec::with_capacity(in_view.len());
480 for shown in folding.iter().chain(&shown) {210 for index in in_view {
481 let flags = match shown.index {211 let key = rows.key(index);
482 Some(index) if rows.selectable(index) => Flags::FLOAT | Flags::CLICKABLE,212 let place = top(index) - scroll;
483 _ => Flags::FLOAT,213 let flags = if rows.selectable(index) {
484 };214 Flags::FLOAT | Flags::CLICKABLE
485 let row_spec = Spec {215 } else {
486 flags,216 Flags::FLOAT
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),
496 };217 };
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 );
497 build(227 build(
498 ui,228 ui,
499 Row {229 Row {
500 key: shown.key,230 key,
501 index: shown.index,231 index,
502 selected: shown.index.is_some() && *selected == Some(shown.key),232 selected: *selected == Some(key),
503 },233 },
504 );234 );
505 ui.close();235 ui.close();
236 shown.push((key, place));
506 }237 }
507 // Rows fade out under the list's edges rather than end at a cut.238 // Rows fade out under the list's edges rather than end at a cut.
508 let clear = [background[0], background[1], background[2], 0.0];239 let clear = [background[0], background[1], background[2], 0.0];
...@@ -538,15 +269,13 @@ pub fn list<R: Rows>(...@@ -538,15 +269,13 @@ pub fn list<R: Rows>(
538 // Rows keep their places relative to the new offset, or next frame's hold on the269 // Rows keep their places relative to the new offset, or next frame's hold on the
539 // first row showing would scroll straight back.270 // first row showing would scroll straight back.
540 let step = f64::from(offset) - scroll;271 let step = f64::from(offset) - scroll;
541 for shown in shown.iter_mut().chain(&mut folding) {272 for (_, place) in &mut shown {
542 shown.place -= step;273 *place -= step;
543 }274 }
544 state.scroll = f64::from(offset);275 state.scroll = f64::from(offset);
545 state.target = state.scroll;276 state.target = state.scroll;
546 }277 }
547 ui.close();278 ui.close();
548 shown.extend(folding);
549 shown.sort_by(|a, b| a.place.total_cmp(&b.place));
550 state.shown = shown;279 state.shown = shown;
551 ui.lists.insert(id, state);280 ui.lists.insert(id, state);
552 clicked281 clicked
crates/ui/src/popup.rs+78-43
...@@ -6,6 +6,11 @@ use crate::{...@@ -6,6 +6,11 @@ use crate::{
6 Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Popup, Row, Rows, Spec, Ui, children,6 Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Popup, Row, Rows, Spec, Ui, children,
7 fill, fit, list::GUTTER, mix, px, text_field,7 fill, fit, list::GUTTER, mix, px, text_field,
8};8};
9use nucleo_matcher::{
10 Config, Matcher, Utf32Str,
11 pattern::{AtomKind, CaseMatching, Normalization, Pattern},
12};
13use std::cell::RefCell;
9use winit::keyboard::{Key, NamedKey};14use winit::keyboard::{Key, NamedKey};
1015
11const CHECK: &[&str] = &[include_str!("../assets/check.svg")];16const CHECK: &[&str] = &[include_str!("../assets/check.svg")];
...@@ -20,6 +25,12 @@ const NARROWEST: f32 = 140.0;...@@ -20,6 +25,12 @@ const NARROWEST: f32 = 140.0;
20const PALETTE: f32 = 560.0;25const PALETTE: f32 = 560.0;
21/// A colour grid's cell, around its swatch.26/// A colour grid's cell, around its swatch.
22const CELL: f32 = 22.0;27const 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}
23/// The keys a menu's list takes, and Enter to choose.34/// The keys a menu's list takes, and Enter to choose.
24const KEYS: [NamedKey; 7] = [35const KEYS: [NamedKey; 7] = [
25 NamedKey::ArrowUp,36 NamedKey::ArrowUp,
...@@ -44,9 +55,13 @@ pub struct Item<'a> {...@@ -44,9 +55,13 @@ pub struct Item<'a> {
44 pub shortcut: &'a str,55 pub shortcut: &'a str,
45 /// Marked with a check in place of its icon.56 /// Marked with a check in place of its icon.
46 pub checked: bool,57 pub checked: bool,
58 /// Starts highlighted when the menu opens, as a checked item does.
59 pub current: bool,
47 pub disabled: bool,60 pub disabled: bool,
48 /// Starts a group, ruled off from the one above while unfiltered.61 /// Starts a group, ruled off from the one above while unfiltered.
49 pub separated: bool,62 pub separated: bool,
63 /// Names the group it starts: shown only while unfiltered, never chosen.
64 pub heading: bool,
50}65}
5166
52/// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a67/// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a
...@@ -160,15 +175,39 @@ fn choose(...@@ -160,15 +175,39 @@ fn choose(
160 }175 }
161 }176 }
162 let matches = Matches::new(items, &query);177 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.
164 highlight = highlight.or_else(|| {179 highlight = highlight.or_else(|| {
165 let first = if query.is_empty() {180 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)
167 } else {182 } else {
168 (0..matches.count()).find(|row| matches.selectable(*row))183 (0..matches.count()).find(|row| matches.selectable(*row))
169 };184 };
170 first.map(|row| matches.key(row))185 first.map(|row| matches.key(row))
171 });186 });
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 );
172 let chosen = if matches.count() == 0 {211 let chosen = if matches.count() == 0 {
173 ui.leaf(212 ui.leaf(
174 "empty",213 "empty",
...@@ -182,10 +221,6 @@ fn choose(...@@ -182,10 +221,6 @@ fn choose(
182 );221 );
183 None222 None
184 } else {223 } 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));
189 let list = List {224 let list = List {
190 rows: &matches,225 rows: &matches,
191 row: ROW,226 row: ROW,
...@@ -212,6 +247,7 @@ fn choose(...@@ -212,6 +247,7 @@ fn choose(
212 })247 })
213 };248 };
214 ui.close();249 ui.close();
250 ui.close();
215 if let Some(row) = chosen {251 if let Some(row) = chosen {
216 ui.close_popup(id);252 ui.close_popup(id);
217 return Some(matches.order[row]);253 return Some(matches.order[row]);
...@@ -226,10 +262,7 @@ fn choose(...@@ -226,10 +262,7 @@ fn choose(
226/// highlight when selected, under a rule when it starts a group.262/// highlight when selected, under a rule when it starts a group.
227fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {263fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
228 let item = &matches.items[row.key as usize];264 let item = &matches.items[row.key as usize];
229 if row265 if row.index > 0 && matches.ruled && item.separated {
230 .index
231 .is_some_and(|index| index > 0 && matches.ruled && item.separated)
232 {
233 ui.leaf(266 ui.leaf(
234 "rule",267 "rule",
235 Spec {268 Spec {
...@@ -241,7 +274,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {...@@ -241,7 +274,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
241 },274 },
242 );275 );
243 }276 }
244 let color = if item.disabled {277 let color = if item.disabled || item.heading {
245 theme.text_dim278 theme.text_dim
246 } else {279 } else {
247 theme.text280 theme.text
...@@ -283,6 +316,8 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {...@@ -283,6 +316,8 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) {
283 size: [fill(), px(ROW)],316 size: [fill(), px(ROW)],
284 text: Some(item.text),317 text: Some(item.text),
285 font: item.font,318 font: item.font,
319 font_size: item.heading.then_some(theme.font_size - 2.0),
320 bold: item.heading,
286 color: Some(color),321 color: Some(color),
287 ..Spec::default()322 ..Spec::default()
288 },323 },
...@@ -485,10 +520,11 @@ fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec<NamedKey> {...@@ -485,10 +520,11 @@ fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec<NamedKey> {
485 taken520 taken
486}521}
487522
488/// The items matching a query, most relevant first, keyed by their index in `items`.523/// The items fuzzily matching a query as fzf does, best first, keyed by their index in
489struct Matches<'a> {524/// `items`.
525pub(crate) struct Matches<'a> {
490 items: &'a [Item<'a>],526 items: &'a [Item<'a>],
491 order: Vec<usize>,527 pub(crate) order: Vec<usize>,
492 /// Each item's row, or None when it does not match.528 /// Each item's row, or None when it does not match.
493 rows: Vec<Option<usize>>,529 rows: Vec<Option<usize>>,
494 /// Rules above each row and the end, counted from the top; only while unfiltered.530 /// Rules above each row and the end, counted from the top; only while unfiltered.
...@@ -498,14 +534,32 @@ struct Matches<'a> {...@@ -498,14 +534,32 @@ struct Matches<'a> {
498}534}
499535
500impl<'a> Matches<'a> {536impl<'a> Matches<'a> {
501 fn new(items: &'a [Item<'a>], query: &str) -> Self {537 pub(crate) fn new(items: &'a [Item<'a>], query: &str) -> Self {
502 let mut ranked: Vec<_> = items538 let pattern = Pattern::new(
503 .iter()539 query,
504 .enumerate()540 CaseMatching::Ignore,
505 .filter_map(|(index, item)| Some((score(query, item.text)?, index)))541 Normalization::Smart,
506 .collect();542 AtomKind::Fuzzy,
507 ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score));543 );
508 let order: Vec<usize> = ranked.into_iter().map(|(_, index)| index).collect();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 };
509 let mut rows = vec![None; items.len()];563 let mut rows = vec![None; items.len()];
510 for (row, index) in order.iter().enumerate() {564 for (row, index) in order.iter().enumerate() {
511 rows[*index] = Some(row);565 rows[*index] = Some(row);
...@@ -542,30 +596,11 @@ impl Rows for Matches<'_> {...@@ -542,30 +596,11 @@ impl Rows for Matches<'_> {
542 }596 }
543597
544 fn selectable(&self, index: usize) -> bool {598 fn selectable(&self, index: usize) -> bool {
545 !self.items[self.order[index]].disabled599 let item = &self.items[self.order[index]];
600 !item.disabled && !item.heading
546 }601 }
547602
548 fn space_before(&self, index: usize) -> f32 {603 fn space_before(&self, index: usize) -> f32 {
549 self.rules[index] as f32 * RULE604 self.rules[index] as f32 * RULE
550 }605 }
551}606}
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;...@@ -18,7 +18,8 @@ pub const TOOL: f32 = 22.0;
18/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next18/// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next
19/// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame19/// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame
20/// below as its outline and shadow fade in. `tabs` are names and section colours. Returns20/// 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`.
22pub fn section_tabs(23pub fn section_tabs(
23 ui: &mut Ui,24 ui: &mut Ui,
24 part: impl Hash,25 part: impl Hash,
...@@ -26,7 +27,7 @@ pub fn section_tabs(...@@ -26,7 +27,7 @@ pub fn section_tabs(
26 active: usize,27 active: usize,
27 section: &Section,28 section: &Section,
28 height: f32,29 height: f32,
29) -> (Option<usize>, Id) {30) -> (Option<usize>, Option<(usize, [f32; 2])>, Id) {
30 let theme = ui.theme.clone();31 let theme = ui.theme.clone();
31 ui.open(32 ui.open(
32 part,33 part,
...@@ -46,7 +47,7 @@ pub fn section_tabs(...@@ -46,7 +47,7 @@ pub fn section_tabs(
46 (left - width, width)47 (left - width, width)
47 })48 })
48 .collect();49 .collect();
49 let mut clicked = None;50 let (mut clicked, mut context) = (None, None);
50 // Earlier tabs lie over later ones; the open tab over all.51 // Earlier tabs lie over later ones; the open tab over all.
51 let order = (0..tabs.len())52 let order = (0..tabs.len())
52 .rev()53 .rev()
...@@ -83,10 +84,13 @@ pub fn section_tabs(...@@ -83,10 +84,13 @@ pub fn section_tabs(
83 if signal.clicked && index != active {84 if signal.clicked && index != active {
84 clicked = Some(index);85 clicked = Some(index);
85 }86 }
87 if let Some(point) = signal.context {
88 context = Some((index, point));
89 }
86 }90 }
87 let open_tab = ui.id(("tab", active));91 let open_tab = ui.id(("tab", active));
88 ui.close();92 ui.close();
89 (clicked, open_tab)93 (clicked, context, open_tab)
90}94}
9195
92/// Where the tab laid out at `tab` in a row `height` tall meets the edge below it: from its96/// 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;...@@ -3,6 +3,12 @@ use std::time::Duration;
3use winit::keyboard::NamedKey;3use winit::keyboard::NamedKey;
44
5const DOUBLE_CLICK: Duration = Duration::from_millis(500);5const 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
7thread_local! {13thread_local! {
8 static START: Instant = Instant::now();14 static START: Instant = Instant::now();
...@@ -149,6 +155,42 @@ fn clicks_use_the_previous_layout_and_hover_animates() {...@@ -149,6 +155,42 @@ fn clicks_use_the_previous_layout_and_hover_animates() {
149 assert!(!ui.wants_frame());155 assert!(!ui.wants_frame());
150}156}
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
152#[test]194#[test]
153fn hover_fades_its_own_colour_in_over_a_box_without_a_fill() {195fn hover_fades_its_own_colour_in_over_a_box_without_a_fill() {
154 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);196 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
...@@ -341,7 +383,7 @@ fn focused_field(text: &str) -> (Ui, String) {...@@ -341,7 +383,7 @@ fn focused_field(text: &str) -> (Ui, String) {
341fn point_to(ui: &mut Ui, text: &str, index: usize) {383fn point_to(ui: &mut Ui, text: &str, index: usize) {
342 let size = ui.theme.font_size;384 let size = ui.theme.font_size;
343 let (texts, frame) = ui.texts();385 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;
345 let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream)387 let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream)
346 .geometry(layout, 1.0)388 .geometry(layout, 1.0)
347 .x0 as f32;389 .x0 as f32;
...@@ -372,7 +414,7 @@ fn text_fields_edit_with_keys_and_selection() {...@@ -372,7 +414,7 @@ fn text_fields_edit_with_keys_and_selection() {
372 ui.event(Event::Ime(Ime::Commit("é".into())));414 ui.event(Event::Ime(Ime::Commit("é".into())));
373 field_frame(&mut ui, &mut text);415 field_frame(&mut ui, &mut text);
374 assert_eq!(text, "caé");416 assert_eq!(text, "caé");
375 ui.event(Event::Modifiers(ModifiersState::SUPER));417 ui.event(Event::Modifiers(SHORTCUT));
376 ui.event(typed("a"));418 ui.event(typed("a"));
377 field_frame(&mut ui, &mut text);419 field_frame(&mut ui, &mut text);
378 ui.event(Event::Modifiers(ModifiersState::empty()));420 ui.event(Event::Modifiers(ModifiersState::empty()));
...@@ -383,6 +425,7 @@ fn text_fields_edit_with_keys_and_selection() {...@@ -383,6 +425,7 @@ fn text_fields_edit_with_keys_and_selection() {
383}425}
384426
385#[test]427#[test]
428#[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")]
386fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() {429fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() {
387 let (mut ui, mut text) = focused_field("one");430 let (mut ui, mut text) = focused_field("one");
388 let start = Instant::now();431 let start = Instant::now();
...@@ -430,6 +473,7 @@ fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() {...@@ -430,6 +473,7 @@ fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() {
430}473}
431474
432#[test]475#[test]
476#[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")]
433fn text_fields_move_and_select_by_word() {477fn text_fields_move_and_select_by_word() {
434 let (mut ui, mut text) = focused_field("one two three");478 let (mut ui, mut text) = focused_field("one two three");
435 ui.event(key(NamedKey::End));479 ui.event(key(NamedKey::End));
...@@ -449,6 +493,48 @@ fn text_fields_move_and_select_by_word() {...@@ -449,6 +493,48 @@ fn text_fields_move_and_select_by_word() {
449}493}
450494
451#[test]495#[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")]
452fn text_fields_delete_by_word_and_line_chords() {538fn text_fields_delete_by_word_and_line_chords() {
453 let (mut ui, mut text) = focused_field("one two three");539 let (mut ui, mut text) = focused_field("one two three");
454 ui.event(key(NamedKey::End));540 ui.event(key(NamedKey::End));
...@@ -674,14 +760,6 @@ fn popups_show_at_once_by_their_anchor() {...@@ -674,14 +760,6 @@ fn popups_show_at_once_by_their_anchor() {
674 ui.open_popup(menu_id());760 ui.open_popup(menu_id());
675 menu_frame(&mut ui, BELOW, None);761 menu_frame(&mut ui, BELOW, None);
676 menu_frame(&mut ui, BELOW, None);762 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);
685 assert_eq!(763 assert_eq!(
686 ui.rect(menu_id()).unwrap()[1],764 ui.rect(menu_id()).unwrap()[1],
687 44.0,765 44.0,
...@@ -692,7 +770,6 @@ fn popups_show_at_once_by_their_anchor() {...@@ -692,7 +770,6 @@ fn popups_show_at_once_by_their_anchor() {
692 ui.open_popup(menu_id());770 ui.open_popup(menu_id());
693 menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None);771 menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None);
694 menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None);772 menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None);
695 assert_eq!(alpha(&ui), 1.0);
696 assert_eq!(ui.rect(menu_id()).unwrap()[..2], [50.0, 50.0]);773 assert_eq!(ui.rect(menu_id()).unwrap()[..2], [50.0, 50.0]);
697}774}
698775
...@@ -784,6 +861,47 @@ fn the_pointer_moves_the_highlight_only_when_it_moves() {...@@ -784,6 +861,47 @@ fn the_pointer_moves_the_highlight_only_when_it_moves() {
784 );861 );
785}862}
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
787#[test]905#[test]
788fn typing_filters_to_the_best_matches_first() {906fn typing_filters_to_the_best_matches_first() {
789 let mut ui = open_menu(Some("Filter"));907 let mut ui = open_menu(Some("Filter"));
...@@ -808,10 +926,14 @@ fn typing_filters_to_the_best_matches_first() {...@@ -808,10 +926,14 @@ fn typing_filters_to_the_best_matches_first() {
808 shown.sort();926 shown.sort();
809 shown.into_iter().map(|(_, item)| item).collect::<Vec<_>>()927 shown.into_iter().map(|(_, item)| item).collect::<Vec<_>>()
810 };928 };
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 );
812 ui.event(key(NamedKey::Backspace));934 ui.event(key(NamedKey::Backspace));
813 settle(&mut ui);935 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");
815 ui.event(key(NamedKey::ArrowDown));937 ui.event(key(NamedKey::ArrowDown));
816 ui.event(key(NamedKey::Enter));938 ui.event(key(NamedKey::Enter));
817 assert_eq!(menu_frame(&mut ui, BELOW, Some("Filter")).1, Some(1));939 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() {...@@ -1062,117 +1184,6 @@ fn lists_hold_the_selection_still_as_items_arrive_and_leave_above() {
1062 assert_eq!(row_top(&ui, 500), Some(at - 60.0));1184 assert_eq!(row_top(&ui, 500), Some(at - 60.0));
1063}1185}
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
1176#[test]1187#[test]
1177fn keys_move_the_selection_and_the_view_eases_after_it() {1188fn keys_move_the_selection_and_the_view_eases_after_it() {
1178 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);1189 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
...@@ -1246,3 +1257,175 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() {...@@ -1246,3 +1257,175 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() {
1246 top1257 top
1247 );1258 );
1248}1259}
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 {...@@ -11,7 +11,7 @@ pub(crate) struct Label {
11 pub size: [f32; 2],11 pub size: [f32; 2],
12}12}
1313
14type LabelKey = (String, u32, Option<String>);14type LabelKey = (String, u32, bool, Option<String>);
1515
16/// Labels shaped this frame or the previous one, keyed by their text and size.16/// Labels shaped this frame or the previous one, keyed by their text and size.
17#[derive(Default)]17#[derive(Default)]
...@@ -20,14 +20,21 @@ pub(crate) struct Texts {...@@ -20,14 +20,21 @@ pub(crate) struct Texts {
20 context: LayoutContext<()>,20 context: LayoutContext<()>,
21 /// A family registered in place of the system's interface font.21 /// A family registered in place of the system's interface font.
22 family: Option<String>,22 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.
24 cache: HashMap<LabelKey, (Rc<Label>, u64)>,24 cache: HashMap<LabelKey, (Rc<Label>, u64)>,
25}25}
2626
27impl Texts {27impl Texts {
28 /// `size` is in logical pixels; `font` names a family to prefer over the interface's.28 /// `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> {29 pub fn label(
30 let key = (text.to_owned(), size.to_bits(), font.map(str::to_owned));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));
31 if let Some((label, touched)) = self.cache.get_mut(&key) {38 if let Some((label, touched)) = self.cache.get_mut(&key) {
32 *touched = frame;39 *touched = frame;
33 return label.clone();40 return label.clone();
...@@ -35,17 +42,22 @@ impl Texts {...@@ -35,17 +42,22 @@ impl Texts {
35 let mut builder = self42 let mut builder = self
36 .context43 .context
37 .ranged_builder(&mut self.fonts, text, 1.0, false);44 .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);
39 let families: Vec<_> = font48 let families: Vec<_> = font
40 .into_iter()49 .into_iter()
41 .chain(self.family.as_deref())50 .chain(self.family.as_deref())
42 .map(FontFamilyName::named)51 .map(FontFamilyName::named)
43 .chain([system])52 .chain(system)
44 .collect();53 .collect();
45 builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned(54 builder.push_default(StyleProperty::FontFamily(FontFamily::List(Cow::Owned(
46 families,55 families,
47 ))));56 ))));
48 builder.push_default(StyleProperty::FontSize(size));57 builder.push_default(StyleProperty::FontSize(size));
58 if bold {
59 builder.push_default(StyleProperty::FontWeight(parley::FontWeight::SEMI_BOLD));
60 }
49 let mut layout = builder.build(text);61 let mut layout = builder.build(text);
50 layout.break_all_lines(None);62 layout.break_all_lines(None);
51 let size = [layout.full_width(), layout.height()];63 let size = [layout.full_width(), layout.height()];
...@@ -68,6 +80,17 @@ impl Texts {...@@ -68,6 +80,17 @@ impl Texts {
68 Some(family)80 Some(family)
69 }81 }
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
71 pub fn prune(&mut self, frame: u64) {94 pub fn prune(&mut self, frame: u64) {
72 self.cache.retain(|_, (_, touched)| *touched + 1 >= frame);95 self.cache.retain(|_, (_, touched)| *touched + 1 >= frame);
73 }96 }
...@@ -88,6 +111,7 @@ impl Glyphs for Label {...@@ -88,6 +111,7 @@ impl Glyphs for Label {
88 0.0,111 0.0,
89 [metrics.block_min_coord, metrics.line_height],112 [metrics.block_min_coord, metrics.line_height],
90 |_| None,113 |_| None,
114 None,
91 paint,115 paint,
92 )?;116 )?;
93 }117 }
crates/ui/src/theme.rs+18-6
...@@ -33,6 +33,16 @@ pub struct Theme {...@@ -33,6 +33,16 @@ pub struct Theme {
33 pub shades: Shades,33 pub shades: Shades,
34}34}
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
36/// Saturation and lightness pairs, each from 0 to 1.46/// Saturation and lightness pairs, each from 0 to 1.
37#[derive(Clone, Copy, Debug)]47#[derive(Clone, Copy, Debug)]
38pub struct Shades {48pub struct Shades {
...@@ -58,15 +68,16 @@ pub struct Section {...@@ -58,15 +68,16 @@ pub struct Section {
58impl Theme {68impl Theme {
59 /// After File Pilot's default dark scheme.69 /// After File Pilot's default dark scheme.
60 pub fn dark() -> Self {70 pub fn dark() -> Self {
71 let [caret, selection, inactive_selection] = text_colors(true);
61 Self {72 Self {
62 font_size: 13.0,73 font_size: 13.0,
63 base: srgb(0x19, 0x1b, 0x1c),74 base: srgb(0x19, 0x1b, 0x1c),
64 panel: srgb(0x1f, 0x22, 0x23),75 panel: srgb(0x1f, 0x22, 0x23),
65 strip: srgb(0x27, 0x2a, 0x2b),76 strip: srgb(0x27, 0x2a, 0x2b),
66 accent: srgb(0x00, 0x79, 0xa6),77 accent: srgb(0x00, 0x79, 0xa6),
67 caret: srgb(0x00, 0x7a, 0xff),78 caret,
68 selection: srgb(0x3f, 0x63, 0x8b),79 selection,
69 inactive_selection: srgb(0x46, 0x46, 0x46),80 inactive_selection,
70 text: srgb(0xdd, 0xde, 0xe0),81 text: srgb(0xdd, 0xde, 0xe0),
71 text_dim: srgb(0x6b, 0x70, 0x78),82 text_dim: srgb(0x6b, 0x70, 0x78),
72 ink: srgb(0xe8, 0xe9, 0xeb),83 ink: srgb(0xe8, 0xe9, 0xeb),
...@@ -85,15 +96,16 @@ impl Theme {...@@ -85,15 +96,16 @@ impl Theme {
85 }96 }
8697
87 pub fn light() -> Self {98 pub fn light() -> Self {
99 let [caret, selection, inactive_selection] = text_colors(false);
88 Self {100 Self {
89 font_size: 13.0,101 font_size: 13.0,
90 base: srgb(0xfc, 0xfc, 0xfd),102 base: srgb(0xfc, 0xfc, 0xfd),
91 panel: srgb(0xf4, 0xf5, 0xf7),103 panel: srgb(0xf4, 0xf5, 0xf7),
92 strip: srgb(0xeb, 0xed, 0xf0),104 strip: srgb(0xeb, 0xed, 0xf0),
93 accent: srgb(0x00, 0x79, 0xa6),105 accent: srgb(0x00, 0x79, 0xa6),
94 caret: srgb(0x00, 0x7a, 0xff),106 caret,
95 selection: srgb(0xb3, 0xd7, 0xff),107 selection,
96 inactive_selection: srgb(0xdc, 0xdc, 0xdc),108 inactive_selection,
97 text: srgb(0x1f, 0x23, 0x28),109 text: srgb(0x1f, 0x23, 0x28),
98 text_dim: srgb(0x7b, 0x82, 0x8c),110 text_dim: srgb(0x7b, 0x82, 0x8c),
99 ink: srgb(0x1d, 0x1e, 0x20),111 ink: srgb(0x1d, 0x1e, 0x20),
crates/ui/src/widgets.rs+12-4
...@@ -112,7 +112,7 @@ pub fn text_field(...@@ -112,7 +112,7 @@ pub fn text_field(
112 };112 };
113 let before = [selection.anchor(), selection.focus()];113 let before = [selection.anchor(), selection.focus()];
114 let (texts, frame) = ui.texts();114 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);
116 if let Some(x) = pointer {116 if let Some(x) = pointer {
117 let layout = &label.layout;117 let layout = &label.layout;
118 let unit = if signal.pressed { signal.unit } else { press.1 };118 let unit = if signal.pressed { signal.unit } else { press.1 };
...@@ -143,9 +143,11 @@ pub fn text_field(...@@ -143,9 +143,11 @@ pub fn text_field(
143 Event::Ime(Ime::Commit(committed)) => (selection.text_range(), committed.as_str()),143 Event::Ime(Ime::Commit(committed)) => (selection.text_range(), committed.as_str()),
144 Event::Key { key, text: typed } => {144 Event::Key { key, text: typed } => {
145 let key = edit_key(key);145 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.
147 let command = match (Command::from_key(&key, modifiers), &key) {148 let command = match (Command::from_key(&key, modifiers), &key) {
148 (Some(Command::Kill), _) => Some(Command::DeleteTo(Movement::LineEnd)),149 (Some(Command::Kill), _) => Some(Command::DeleteTo(Movement::LineEnd)),
150 (Some(Command::ScrollPage { .. } | Command::MovePage { .. }), _) => None,
149 (None, edit::Key::Named(edit::NamedKey::Home)) => {151 (None, edit::Key::Named(edit::NamedKey::Home)) => {
150 Some(Command::Move(Movement::DocumentStart))152 Some(Command::Move(Movement::DocumentStart))
151 }153 }
...@@ -198,7 +200,7 @@ pub fn text_field(...@@ -198,7 +200,7 @@ pub fn text_field(
198 _ => continue,200 _ => continue,
199 };201 };
200 text.replace_range(range.clone(), inserted);202 text.replace_range(range.clone(), inserted);
201 label = texts.label(text, size, None, frame);203 label = texts.label(text, size, false, None, frame);
202 selection = Selection::from_byte_index(204 selection = Selection::from_byte_index(
203 &label.layout,205 &label.layout,
204 range.start + inserted.len(),206 range.start + inserted.len(),
...@@ -284,6 +286,8 @@ pub fn edit_key(key: &Key) -> edit::Key {...@@ -284,6 +286,8 @@ pub fn edit_key(key: &Key) -> edit::Key {
284 NamedKey::ArrowDown => Edit::ArrowDown,286 NamedKey::ArrowDown => Edit::ArrowDown,
285 NamedKey::Home => Edit::Home,287 NamedKey::Home => Edit::Home,
286 NamedKey::End => Edit::End,288 NamedKey::End => Edit::End,
289 NamedKey::PageUp => Edit::PageUp,
290 NamedKey::PageDown => Edit::PageDown,
287 NamedKey::Alt291 NamedKey::Alt
288 | NamedKey::AltGraph292 | NamedKey::AltGraph
289 | NamedKey::Control293 | NamedKey::Control
...@@ -301,6 +305,10 @@ pub fn edit_modifiers(modifiers: ModifiersState) -> edit::Modifiers {...@@ -301,6 +305,10 @@ pub fn edit_modifiers(modifiers: ModifiersState) -> edit::Modifiers {
301 shift: modifiers.shift_key(),305 shift: modifiers.shift_key(),
302 control: modifiers.control_key(),306 control: modifiers.control_key(),
303 option: modifiers.alt_key(),307 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 },
305 }313 }
306}314}
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]| {...@@ -233,6 +233,7 @@ fuzz_target!(|input: &[u8]| {
233 identity: None,233 identity: None,
234 created: date.map(|date| date.timestamp()),234 created: date.map(|date| date.timestamp()),
235 margin_origin: [0.0; 2],235 margin_origin: [0.0; 2],
236 color: None,
236 definitions: BTreeMap::new(),237 definitions: BTreeMap::new(),
237 objects,238 objects,
238 },239 },
fuzz/fuzz_targets/commit.rs+41-35
...@@ -1,58 +1,51 @@...@@ -1,58 +1,51 @@
1#![no_main]1#![no_main]
2use libfuzzer_sys::fuzz_target;2use 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};
4use std::sync::LazyLock;8use std::sync::LazyLock;
59
6#[path = "../../crates/onestore/tests/support/disk.rs"]10#[path = "../../crates/onestore/tests/support/disk.rs"]
7mod disk;11mod disk;
12#[path = "../../crates/onestore/tests/support/ops.rs"]
13mod ops;
8use disk::Disk;14use disk::Disk;
915
10const SOURCES: [&[u8]; 2] = [16const SOURCES: [&[u8]; 2] = [
11 include_bytes!("../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one"),17 include_bytes!("../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one"),
12 include_bytes!("../../corpus/append/round-01/tx-255/notebook/synthetic.one"),18 include_bytes!("../../corpus/append/round-01/tx-255/notebook/synthetic.one"),
13];19];
14static TARGET: LazyLock<(ExGuid, ExGuid, u32)> = LazyLock::new(|| {20static TARGET: LazyLock<(ExGuid, ExGuid)> = LazyLock::new(|| {
15 let store = Store::parse(SOURCES[0]).unwrap();21 let store = Store::parse(SOURCES[0]).unwrap();
16 let index = RevisionIndex::parse(&store).unwrap();22 let index = RevisionIndex::parse(&store).unwrap();
17 for (osid, space) in &index.spaces {23 let document = Document::parse(&index).unwrap();
18 let revision = index24 for (sid, space) in &document.spaces {
19 .resolve(*osid, space.labels[&(ExGuid::default(), 1)])25 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
20 .unwrap();26 for (oid, node) in &revision.nodes {
21 for (oid, object) in revision.objects {27 if matches!(&node.kind, Kind::RichText { text, .. } if text == "Fictitious plain text.")
22 if let ObjectData::Properties(blob) = object.data {28 {
23 for property in &PropertySets::parse(blob).unwrap().sets[0] {29 return (*sid, *oid);
24 if property.value == Value::Bytes(b"Fictitious plain text.") {
25 return (*osid, oid, property.id);
26 }
27 }
28 }30 }
29 }31 }
30 }32 }
31 panic!("Missing native seed property")33 panic!("Missing native seed text")
32});34});
3335
34fn current(bytes: &[u8]) -> Vec<u8> {36fn current(bytes: &[u8]) -> String {
35 let store = Store::parse(bytes).unwrap();37 let store = Store::parse(bytes).unwrap();
36 assert!(store.checksum_mismatches.is_empty());38 assert!(store.checksum_mismatches.is_empty());
37 let index = RevisionIndex::parse(&store).unwrap();39 let index = RevisionIndex::parse(&store).unwrap();
38 index.validate_current().unwrap();40 index.validate_current().unwrap();
39 let (osid, oid, property) = *TARGET;41 let document = Document::parse(&index).unwrap();
40 let revision = index42 let (sid, oid) = *TARGET;
41 .resolve(osid, index.spaces[&osid].labels[&(ExGuid::default(), 1)])43 let space = &document.spaces[&sid];
42 .unwrap();44 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
43 let ObjectData::Properties(blob) = revision.objects[&oid].data else {45 let Kind::RichText { text, .. } = &revision.nodes[&oid].kind else {
44 panic!()46 panic!()
45 };47 };
46 let properties = PropertySets::parse(blob).unwrap();48 text.clone()
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()
56}49}
5750
58fuzz_target!(|input: &[u8]| {51fuzz_target!(|input: &[u8]| {
...@@ -60,11 +53,25 @@ fuzz_target!(|input: &[u8]| {...@@ -60,11 +53,25 @@ fuzz_target!(|input: &[u8]| {
60 return;53 return;
61 }54 }
62 let mut source = SOURCES[usize::from(input[0].is_multiple_of(4))].to_vec();55 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;
64 for step in 0..=input[1] % 4 {57 for step in 0..=input[1] % 4 {
65 let mut value = input[12..].to_vec();58 let value: String = input[12..]
66 value.push(step);59 .iter()
60 .chain([&step])
61 .map(|byte| char::from(b'a' + byte % 26))
62 .collect();
67 let before = current(&source);63 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 };
68 let mut storage = Disk {75 let mut storage = Disk {
69 visible: source.clone(),76 visible: source.clone(),
70 durable: source.clone(),77 durable: source.clone(),
...@@ -73,8 +80,7 @@ fuzz_target!(|input: &[u8]| {...@@ -73,8 +80,7 @@ fuzz_target!(|input: &[u8]| {
73 write_limit: usize::from(input[4]) + 1,80 write_limit: usize::from(input[4]) + 1,
74 random: u64::from_le_bytes(input[4..12].try_into().unwrap()),81 random: u64::from_le_bytes(input[4..12].try_into().unwrap()),
75 };82 };
76 let result =83 let result = transaction.commit(&mut storage);
77 onestore::commit_property_bytes(&mut storage, &source, osid, oid, property, &value);
78 let persisted = current(&storage.durable);84 let persisted = current(&storage.durable);
79 match result {85 match result {
80 Ok(()) => assert_eq!(persisted, value),86 Ok(()) => assert_eq!(persisted, value),
fuzz/fuzz_targets/edit_text.rs+21-17
...@@ -10,7 +10,10 @@ use std::sync::LazyLock;...@@ -10,7 +10,10 @@ use std::sync::LazyLock;
10mod checkpoint;10mod checkpoint;
11#[path = "../../crates/onestore/tests/support/disk.rs"]11#[path = "../../crates/onestore/tests/support/disk.rs"]
12mod disk;12mod disk;
13#[path = "../../crates/onestore/tests/support/ops.rs"]
14mod ops;
13use disk::Disk;15use disk::Disk;
16use onestore::op::{Op, PageOp};
1417
15const SOURCE: &[u8] = include_bytes!(18const SOURCE: &[u8] = include_bytes!(
16 "../../corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one"19 "../../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(|| {...@@ -58,7 +61,7 @@ static CASES: LazyLock<[(Vec<u8>, ExGuid, ExGuid); 7]> = LazyLock::new(|| {
58 panic!("Missing text fixture")61 panic!("Missing text fixture")
59 });62 });
60 let checkpoint = (63 let checkpoint = (
61 checkpoint::pending(&ordinary.0, ordinary.1, ordinary.2, 0x14001d7a),64 checkpoint::pending(&ordinary.0, ordinary.1, ordinary.2),
62 ordinary.1,65 ordinary.1,
63 ordinary.2,66 ordinary.2,
64 );67 );
...@@ -166,29 +169,30 @@ fuzz_target!(|input: &[u8]| {...@@ -166,29 +169,30 @@ fuzz_target!(|input: &[u8]| {
166 visible: persisted_source.clone(),169 visible: persisted_source.clone(),
167 durable: persisted_source.clone(),170 durable: persisted_source.clone(),
168 operation: 0,171 operation: 0,
169 fail_at: (step[3] & 1 != 0)172 fail_at: (step[3] & 1 != 0).then_some(usize::from(step[4]) + 1),
170 .then_some(source.len().div_ceil(193) + usize::from(step[4]) + 1),
171 write_limit: usize::from(step[5]) + 1,173 write_limit: usize::from(step[5]) + 1,
172 random: u64::from_le_bytes(step.try_into().unwrap()),174 random: u64::from_le_bytes(step.try_into().unwrap()),
173 };175 };
174 let result = onestore::commit_text(176 let op = PageOp::Text {
175 &mut storage,177 text: oid,
176 source,178 range: offset..offset + removed,
177 sid,179 with: replacement.to_owned(),
178 oid,180 };
179 offset..offset + removed,181 let transaction = ops::transaction(source, "Fuzz", vec![Op::Page { space: sid, op }]);
180 replacement,
181 );
182 let persisted = characters(&storage.durable, sid, oid);
183 if !replacement.contains(['\n', '\0']) {182 if !replacement.contains(['\n', '\0']) {
184 assert!(183 assert!(
185 result184 transaction.is_ok(),
186 .as_ref()185 "Valid ordinary text edit was rejected: {transaction:?}"
187 .err()
188 .is_none_or(|error| error.error.kind() != std::io::ErrorKind::InvalidData),
189 "Valid ordinary text edit was rejected: {result:?}"
190 );186 );
191 }187 }
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);
192 match result {196 match result {
193 Ok(()) => assert_eq!(persisted, expected),197 Ok(()) => assert_eq!(persisted, expected),
194 Err(error) => match error.state {198 Err(error) => match error.state {
fuzz/fuzz_targets/insert.rs+40-23
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1#![no_main]1#![no_main]
2use libfuzzer_sys::fuzz_target;2use libfuzzer_sys::fuzz_target;
3use onestore::{3use onestore::{
4 CommitState, ExGuid, Insertion, PreparedEdit, RevisionIndex, Store, TextAttribute as A,4 CommitState, ExGuid, RevisionIndex, Store, TextAttribute as A,
5 document::{Document, Kind},5 document::{Document, Kind},
6 op::{Edit, Op, PageOp},
6};7};
7use std::sync::LazyLock;8use std::sync::LazyLock;
89
...@@ -10,8 +11,10 @@ use std::sync::LazyLock;...@@ -10,8 +11,10 @@ use std::sync::LazyLock;
10mod current;11mod current;
11#[path = "../../crates/onestore/tests/support/disk.rs"]12#[path = "../../crates/onestore/tests/support/disk.rs"]
12mod disk;13mod 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(|| {
15 let bytes = include_bytes!(18 let bytes = include_bytes!(
16 "../../corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one"19 "../../corpus/native/20260905-05/snapshots/03-format-unicode/notebook/synthetic.one"
17 )20 )
...@@ -27,15 +30,15 @@ static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid, ExGuid)> = LazyLock::new(|| {...@@ -27,15 +30,15 @@ static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid, ExGuid)> = LazyLock::new(|| {
27 .iter()30 .iter()
28 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))31 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
29 .unwrap();32 .unwrap();
30 (bytes, space, page, outline)33 (bytes, space, outline)
31});34});
3235
33fuzz_target!(|input: &[u8]| {36fuzz_target!(|input: &[u8]| {
34 let (source, space, page, outline) = &*SOURCE;37 let (source, space, outline) = &*SOURCE;
35 if let Ok(intent) = serde_json::from_slice::<Insertion>(input)38 if let Ok(edit) = serde_json::from_slice::<Edit>(input)
36 && let Ok(prepared) = PreparedEdit::insert(source, *space, &intent)39 && let Ok(edited) = ops::apply(source, "Insertion fuzz", edit.ops)
37 {40 {
38 current::current(prepared.as_bytes());41 current::current(edited.as_bytes());
39 }42 }
40 let mut persisted = source.clone();43 let mut persisted = source.clone();
41 let mut caches = std::array::from_fn::<_, 12, _>(|_| source.clone());44 let mut caches = std::array::from_fn::<_, 12, _>(|_| source.clone());
...@@ -56,32 +59,46 @@ fuzz_target!(|input: &[u8]| {...@@ -56,32 +59,46 @@ fuzz_target!(|input: &[u8]| {
56 let first = usize::from(step[3]) % offsets.len();59 let first = usize::from(step[3]) % offsets.len();
57 let second = usize::from(step[4]) % offsets.len();60 let second = usize::from(step[4]) % offsets.len();
58 let (start, end) = (first.min(second), first.max(second));61 let (start, end) = (first.min(second), first.max(second));
59 let plain = if step[1] & 1 == 0 {62 let (insert, text_object) = if step[1] & 1 == 0 {
60 Insertion::paragraph(*outline, None, text, "Insertion fuzz").unwrap()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)
61 } else {71 } 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)
63 };74 };
64 let enabled = step[5] & 1 != 0;75 let enabled = step[5] & 1 != 0;
65 let intent = if start != end || text.is_empty() {76 let mut page_ops = vec![insert];
66 plain77 if start != end || text.is_empty() {
67 .with_formatting(78 page_ops.push(PageOp::Format {
68 offsets[start]..offsets[end],79 text: text_object,
69 &[A::Bold(enabled), A::FontSize(18.0)],80 range: offsets[start]..offsets[end],
70 )81 set: vec![A::Bold(enabled), A::FontSize(18.0)],
71 .unwrap()82 clear: Vec::new(),
72 } else {83 });
73 plain84 }
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(),
74 };91 };
75 let encoded = serde_json::to_vec(&intent).unwrap();92 let encoded = serde_json::to_vec(&edit).unwrap();
76 let intent: Insertion = serde_json::from_slice(&encoded).unwrap();93 let edit: Edit = serde_json::from_slice(&encoded).unwrap();
77 let edit = PreparedEdit::insert(source, *space, &intent).unwrap();94 let edit = ops::apply(source, "Insertion fuzz", edit.ops).unwrap();
78 let store = Store::parse(edit.as_bytes()).unwrap();95 let store = Store::parse(edit.as_bytes()).unwrap();
79 let index = RevisionIndex::parse(&store).unwrap();96 let index = RevisionIndex::parse(&store).unwrap();
80 let document = Document::parse(&index).unwrap();97 let document = Document::parse(&index).unwrap();
81 let s = &document.spaces[space];98 let s = &document.spaces[space];
82 let view = &s.revisions[&s.contexts[&ExGuid::default()]];99 let view = &s.revisions[&s.contexts[&ExGuid::default()]];
83 let actual: Vec<_> = view100 let actual: Vec<_> = view
84 .text_runs(intent.text_object())101 .text_runs(text_object)
85 .unwrap()102 .unwrap()
86 .into_iter()103 .into_iter()
87 .flat_map(|run| {104 .flat_map(|run| {
fuzz/fuzz_targets/offline_page.rs+4
...@@ -3,7 +3,11 @@ use libfuzzer_sys::fuzz_target;...@@ -3,7 +3,11 @@ use libfuzzer_sys::fuzz_target;
33
4#[path = "../../crates/onestore/tests/support/disk.rs"]4#[path = "../../crates/onestore/tests/support/disk.rs"]
5mod disk;5mod disk;
6#[path = "../../crates/notebook/tests/support/model_ops.rs"]
7mod model_ops;
6#[path = "../../crates/notebook/tests/support/page_schedule.rs"]8#[path = "../../crates/notebook/tests/support/page_schedule.rs"]
7mod page_schedule;9mod page_schedule;
10#[path = "../../crates/notebook/tests/support/server.rs"]
11mod server;
812
9fuzz_target!(|input: &[u8]| page_schedule::run(input));13fuzz_target!(|input: &[u8]| page_schedule::run(input));
fuzz/fuzz_targets/paragraph.rs+52-41
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1#![no_main]1#![no_main]
2use libfuzzer_sys::fuzz_target;2use libfuzzer_sys::fuzz_target;
3use onestore::{3use onestore::{
4 CommitState, ExGuid, Insertion, OutlineEdit, ParagraphJoin, ParagraphSplit, PreparedEdit,4 CommitState, ExGuid, OutlineEdit, RevisionIndex, Store, TextAttribute as A,
5 RevisionIndex, Store, TextAttribute as A,
6 document::{Document, Kind},5 document::{Document, Kind},
6 op::{Edit, PageOp},
7 page::text::new_id,
7};8};
8use std::sync::LazyLock;9use std::sync::LazyLock;
910
...@@ -11,6 +12,8 @@ use std::sync::LazyLock;...@@ -11,6 +12,8 @@ use std::sync::LazyLock;
11mod current;12mod current;
12#[path = "../../crates/onestore/tests/support/disk.rs"]13#[path = "../../crates/onestore/tests/support/disk.rs"]
13mod disk;14mod disk;
15#[path = "../../crates/onestore/tests/support/ops.rs"]
16mod ops;
1417
15static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid)> = LazyLock::new(|| {18static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid)> = LazyLock::new(|| {
16 let source = onestore::create_section("paragraph.one", "Original", "Author").unwrap();19 let source = onestore::create_section("paragraph.one", "Original", "Author").unwrap();
...@@ -26,34 +29,33 @@ static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid)> = LazyLock::new(|| {...@@ -26,34 +29,33 @@ static SOURCE: LazyLock<(Vec<u8>, ExGuid, ExGuid)> = LazyLock::new(|| {
26 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))29 .find(|id| matches!(view.nodes[id].kind, Kind::Outline { .. }))
27 .unwrap();30 .unwrap();
28 let parent = view.nodes[&outline].children[0];31 let parent = view.nodes[&outline].children[0];
29 let intent = Insertion::paragraph(parent, None, "a🦀 e\u{301} 東京\rEnd", "Author")32 let mut paragraph = ops::paragraph("a🦀 e\u{301} 東京\rEnd");
30 .unwrap()33 paragraph.level = 2;
31 .with_formatting(0..4, &[A::Bold(true)])34 let text = paragraph.text().unwrap().id;
32 .unwrap()35 let format = |range, set| PageOp::Format {
33 .with_formatting(4..7, &[A::Italic(true), A::Color(Some([12, 34, 56]))])36 text,
34 .unwrap();37 range,
35 let edited = PreparedEdit::insert(&source, sid, &intent).unwrap();38 set,
36 let second = Insertion::outline(page, 144.0, 36.0, "Fixed second", "Author").unwrap();39 clear: Vec::new(),
37 let edited = PreparedEdit::insert(edited.as_bytes(), sid, &second).unwrap();40 };
38 (edited.as_bytes().to_vec(), sid, outline)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)
39});51});
4052
41fuzz_target!(|input: &[u8]| {53fuzz_target!(|input: &[u8]| {
42 let (source, sid, outline) = &*SOURCE;54 let (source, sid, outline) = &*SOURCE;
43 if let Ok(operation) = serde_json::from_slice::<OutlineEdit>(input)55 if let Ok(edit) = serde_json::from_slice::<Edit>(input)
44 && let Ok(edit) = PreparedEdit::outline(source, *sid, *outline, operation)56 && let Ok(edited) = ops::apply(source, "Paragraph fuzz", edit.ops)
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)
50 {57 {
51 current::current(edit.as_bytes());58 current::current(edited.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());
57 }59 }
58 let mut persisted = source.clone();60 let mut persisted = source.clone();
59 let mut caches = std::array::from_fn::<_, 12, _>(|_| source.clone());61 let mut caches = std::array::from_fn::<_, 12, _>(|_| source.clone());
...@@ -134,7 +136,15 @@ fuzz_target!(|input: &[u8]| {...@@ -134,7 +136,15 @@ fuzz_target!(|input: &[u8]| {
134 expected_text.push((*id, characters(view, *id)));136 expected_text.push((*id, characters(view, *id)));
135 }137 }
136 }138 }
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()
138 } else if step[1] & 8 != 0 && !pairs.is_empty() {148 } else if step[1] & 8 != 0 && !pairs.is_empty() {
139 let (parent, left, right) = pairs[usize::from(step[2]) % pairs.len()];149 let (parent, left, right) = pairs[usize::from(step[2]) % pairs.len()];
140 let a = view.nodes[&left].content[0];150 let a = view.nodes[&left].content[0];
...@@ -151,10 +161,7 @@ fuzz_target!(|input: &[u8]| {...@@ -151,10 +161,7 @@ fuzz_target!(|input: &[u8]| {
151 .collect();161 .collect();
152 expected_graph.push((parent, children, view.nodes[&parent].content.clone()));162 expected_graph.push((parent, children, view.nodes[&parent].content.clone()));
153 expected_graph.push((left, view.nodes[&right].children.clone(), vec![survivor]));163 expected_graph.push((left, view.nodes[&right].children.clone(), vec![survivor]));
154 let intent = ParagraphJoin::new(a, b, "Paragraph fuzz").unwrap();164 ops::page_op(source, *sid, PageOp::Join { left: a, right: b }).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()
158 } else {165 } else {
159 let paragraph = paragraphs[usize::from(step[2]) % paragraphs.len()];166 let paragraph = paragraphs[usize::from(step[2]) % paragraphs.len()];
160 let text = view.nodes[&paragraph].content[0];167 let text = view.nodes[&paragraph].content[0];
...@@ -166,22 +173,23 @@ fuzz_target!(|input: &[u8]| {...@@ -166,22 +173,23 @@ fuzz_target!(|input: &[u8]| {
166 }))173 }))
167 .collect();174 .collect();
168 let offset = u32::from(step[3]) % (offsets.last().unwrap() + 2);175 let offset = u32::from(step[3]) % (offsets.last().unwrap() + 2);
169 let intent = ParagraphSplit::new(text, offset, "Paragraph fuzz").unwrap();176 let (object, right) = (new_id().unwrap(), new_id().unwrap());
170 let restored = serde_json::from_value(serde_json::to_value(&intent).unwrap()).unwrap();177 let split = PageOp::Split {
171 assert_eq!(intent, restored);178 text,
172 let edit = PreparedEdit::split(source, *sid, &restored);179 at: offset,
180 paragraph: object,
181 right,
182 lists: Vec::new(),
183 };
184 let edit = ops::page_op(source, *sid, split);
173 let Some(position) = offsets.iter().position(|n| *n == offset) else {185 let Some(position) = offsets.iter().position(|n| *n == offset) else {
174 assert!(edit.is_err());186 assert!(edit.is_err());
175 continue;187 continue;
176 };188 };
177 expected_text.push((text, before[..position].to_vec()));189 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()));
179 expected_graph.push((paragraph, vec![], vec![text]));191 expected_graph.push((paragraph, vec![], vec![text]));
180 expected_graph.push((192 expected_graph.push((object, view.nodes[&paragraph].children.clone(), vec![right]));
181 intent.object(),
182 view.nodes[&paragraph].children.clone(),
183 vec![intent.text_object()],
184 ));
185 edit.unwrap()193 edit.unwrap()
186 };194 };
187 let after_store = Store::parse(edit.as_bytes()).unwrap();195 let after_store = Store::parse(edit.as_bytes()).unwrap();
...@@ -226,7 +234,10 @@ fuzz_target!(|input: &[u8]| {...@@ -226,7 +234,10 @@ fuzz_target!(|input: &[u8]| {
226 write_limit: if step[6] & 1 == 0 { 17 } else { 4096 },234 write_limit: if step[6] & 1 == 0 { 17 } else { 4096 },
227 random: u64::from(step[7]) + 1,235 random: u64::from(step[7]) + 1,
228 };236 };
229 let result = edit.commit(&mut disk);237 let Some(transaction) = &edit.transaction else {
238 continue;
239 };
240 let result = transaction.commit(&mut disk);
230 let observed = current::current(&disk.durable);241 let observed = current::current(&disk.durable);
231 match result {242 match result {
232 Ok(()) => assert_eq!(observed, after),243 Ok(()) => assert_eq!(observed, after),
fuzz/fuzz_targets/protected_write.rs+70-31
...@@ -1,10 +1,14 @@...@@ -1,10 +1,14 @@
1#![no_main]1#![no_main]
2//! Chains page edits on a protected section: every written image unlocks with the same2//! Chains text 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.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.
4use libfuzzer_sys::fuzz_target;6use libfuzzer_sys::fuzz_target;
5use onestore::{7use onestore::{
6 ExGuid, PreparedEdit, RevisionIndex, Store,8 ExGuid, RevisionIndex, Store,
7 page::{Page, PageObject, Paragraph, text::Edit},9 document::Kind,
10 op::{Edit, Op, PageOp, predict},
11 page::{Page, PageObject},
8 protected::{Limits, UnlockedSection},12 protected::{Limits, UnlockedSection},
9};13};
1014
...@@ -12,60 +16,95 @@ const SOURCE: &[u8] =...@@ -12,60 +16,95 @@ const SOURCE: &[u8] =
12 include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one");16 include_bytes!("../../corpus/native-encrypted/encrypted-01/notebook/synthetic.one");
13const PASSWORD: &str = "fictitious-only";17const 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>) {
16 let store = Store::parse(bytes).unwrap();21 let store = Store::parse(bytes).unwrap();
17 let index = RevisionIndex::parse(&store).unwrap();22 let index = RevisionIndex::parse(&store).unwrap();
18 let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap();23 let unlocked = UnlockedSection::open(&index, PASSWORD, Limits::default()).unwrap();
19 let document = unlocked.document().unwrap();24 let document = unlocked.document().unwrap();
20 let (space, _) = document.pages().unwrap()[0];25 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()
22}49}
2350
24fuzz_target!(|data: &[u8]| {51fuzz_target!(|data: &[u8]| {
25 let mut bytes = SOURCE.to_vec();52 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)) {
27 if step.len() < 4 {54 if step.len() < 4 {
28 return;55 return;
29 }56 }
30 let (space, mut after) = page(&bytes);57 let (space, mut expected, hidden) = page(&bytes);
31 let mut texts: Vec<_> = after58 let candidates: Vec<_> = expected
32 .objects59 .objects
33 .iter_mut()60 .iter()
34 .filter_map(|object| match object {61 .filter_map(|object| match object {
35 PageObject::Outline(outline) => Some(&mut outline.paragraphs),62 PageObject::Outline(outline) => Some(&outline.paragraphs),
36 _ => None,63 _ => None,
37 })64 })
38 .flatten()65 .flatten()
39 .filter_map(|paragraph| paragraph.text_mut())66 .filter_map(|paragraph| paragraph.text())
40 .collect();67 .collect();
41 let count = texts.len();68 let text = candidates[usize::from(step[0]) % candidates.len()];
42 let text = &mut texts[usize::from(step[0]) % count].text;69 let end = text.text.utf16_offset(text.text.text().len()).unwrap();
43 let end = text.utf16_offset(text.text().len()).unwrap();
44 let start = u32::from(step[1]) % (end + 1);70 let start = u32::from(step[1]) % (end + 1);
45 let stop = (start + u32::from(step[2]) % 8).min(end);71 let stop = (start + u32::from(step[2]) % 8).min(end);
46 // Marked so its absence from the stored bytes is checkable.72 // 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', ""));73 let inserted = format!(
48 let Ok(format) = text.format_at(start.min(end.saturating_sub(1))).cloned() else {74 "\u{1f512}sealed\u{1f512}{}",
49 return;75 String::from_utf8_lossy(&step[3..]).replace(['\0', '\n', '\u{fffc}'], "")
50 };76 );
51 let edit = Edit {77 let styled = hidden.contains(&text.id) && stop == end;
78 let op = PageOp::Text {
79 text: text.id,
52 range: start..stop,80 range: start..stop,
53 replacement: Paragraph::new(inserted.clone(), format),81 with: inserted.clone(),
54 };82 };
55 let (Ok(_), Ok(_)) = (text.byte_offset(start), text.byte_offset(stop)) else {83 if predict(&mut expected, &op).is_err() {
56 return;
57 };
58 if text.apply(edit).is_err() {
59 return;84 return;
60 }85 }
61 let Ok(edit) = PreparedEdit::page_protected(&bytes, PASSWORD, space, &after, "Fuzz") else {86 let edit = Edit {
62 return;87 at: 133_000_000_000_000_000 + at * 10_000_000,
88 ops: vec![Op::Page { space, op }],
63 };89 };
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();
65 let clear: Vec<u8> = inserted.encode_utf16().flat_map(u16::to_le_bytes).collect();96 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));97 assert!(
67 let (_, stored) = page(&written);98 !written[bytes.len()..]
68 assert!(stored.objects == after.objects);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 }
69 bytes = written;108 bytes = written;
70 }109 }
71});110});
fuzz/fuzz_targets/tree.rs+2
...@@ -5,6 +5,8 @@ use libfuzzer_sys::fuzz_target;...@@ -5,6 +5,8 @@ use libfuzzer_sys::fuzz_target;
5mod current;5mod current;
6#[path = "../../crates/onestore/tests/support/disk.rs"]6#[path = "../../crates/onestore/tests/support/disk.rs"]
7mod disk;7mod disk;
8#[path = "../../crates/onestore/tests/support/ops.rs"]
9mod ops;
8#[path = "../../crates/onestore/tests/support/tree_model.rs"]10#[path = "../../crates/onestore/tests/support/tree_model.rs"]
9mod tree_model;11mod tree_model;
1012
fuzz/fuzz_targets/write_scalar.rs+46-38
...@@ -1,58 +1,66 @@...@@ -1,58 +1,66 @@
1#![no_main]1#![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.
2use libfuzzer_sys::fuzz_target;4use libfuzzer_sys::fuzz_target;
3use onestore::{5use onestore::{
4 ExGuid, ObjectData, PropertySets, RevisionIndex, Store, Value, replace_property_bytes,6 ExGuid, RevisionIndex, Store,
7 document::{Document, Kind},
8 op::{Op, PageOp},
5};9};
6use std::sync::LazyLock;10use std::sync::LazyLock;
711
12#[path = "../../crates/onestore/tests/support/ops.rs"]
13mod ops;
14
8const SOURCE: &[u8] =15const SOURCE: &[u8] =
9 include_bytes!("../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one");16 include_bytes!("../../corpus/native/20260905-05/snapshots/02-text/notebook/synthetic.one");
10static TARGET: LazyLock<(ExGuid, ExGuid, u32)> = LazyLock::new(|| {17
11 let store = Store::parse(SOURCE).unwrap();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());
12 let index = RevisionIndex::parse(&store).unwrap();21 let index = RevisionIndex::parse(&store).unwrap();
13 for (osid, space) in &index.spaces {22 index.validate_current().unwrap();
14 let revision = index23 let document = Document::parse(&index).unwrap();
15 .resolve(*osid, space.labels[&(ExGuid::default(), 1)])24 let mut found = Vec::new();
16 .unwrap();25 for (sid, space) in &document.spaces {
17 for (oid, object) in revision.objects {26 let revision = &space.revisions[&space.contexts[&ExGuid::default()]];
18 if let ObjectData::Properties(data) = object.data {27 for (oid, node) in &revision.nodes {
19 for property in &PropertySets::parse(data).unwrap().sets[0] {28 if let Kind::RichText { text, .. } = &node.kind
20 if property.value == Value::Bytes(b"Fictitious plain text.") {29 && target.is_none_or(|target| target == (*sid, *oid))
21 return (*osid, oid, property.id);30 {
22 }31 found.push((*sid, *oid, text.clone()));
23 }
24 }32 }
25 }33 }
26 }34 }
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")
28});43});
2944
30fuzz_target!(|value: &[u8]| {45fuzz_target!(|value: &[u8]| {
31 let (osid, oid, property) = *TARGET;46 let (sid, oid) = *TARGET;
32 let bytes = replace_property_bytes(SOURCE, osid, oid, property, value).unwrap();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;
33 let mut before_commit = bytes.clone();59 let mut before_commit = bytes.clone();
34 before_commit[..1024].copy_from_slice(&SOURCE[..1024]);60 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();
50 assert_eq!(61 assert_eq!(
51 properties.sets[0]62 text(&before_commit, Some((sid, oid)))[0].2,
52 .iter()63 "Fictitious plain text."
53 .find(|candidate| candidate.id == property)
54 .unwrap()
55 .value,
56 Value::Bytes(value)
57 );64 );
65 assert_eq!(text(&bytes, Some((sid, oid)))[0].2, value);
58});66});
readme.md+7-1
...@@ -11,7 +11,13 @@ create and resize as you would expect. In addition, Snowbound (TODO) supports...@@ -11,7 +11,13 @@ create and resize as you would expect. In addition, Snowbound (TODO) supports
11pen and drawing tools, recording audio and video, revision history, multi-machine11pen and drawing tools, recording audio and video, revision history, multi-machine
12live collaboration, and much more.12live 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
16Snowbound implements the file and sync protocol used in 2010 Microsoft OneNote,22Snowbound implements the file and sync protocol used in 2010 Microsoft OneNote,
17so notebooks are fully compatible, including collaboration features. It (TODO)23so notebooks are fully compatible, including collaboration features. It (TODO)