diff --git a/Cargo.lock b/Cargo.lock index 6e5c6d204ad123c550aef66d2de29e52bfbdb520..e2a39ca4388b18081a0b0b634566336a96ea27be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "accesskit_atspi_common" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d47ad644916f6cb7e432a5ca0dbd7cc78281a9772881057bb72d4aadb93257" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant", +] + [[package]] name = "accesskit_consumer" version = "0.39.0" @@ -65,6 +79,24 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "accesskit_unix" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665c9079b7325a8168a6793437a172dda44b19a05af8ed52d7e32abaada996fe" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + [[package]] name = "accesskit_windows" version = "0.35.0" @@ -88,6 +120,7 @@ dependencies = [ "accesskit", "accesskit_ios", "accesskit_macos", + "accesskit_unix", "accesskit_windows", "raw-window-handle", "winit", @@ -232,6 +265,126 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -249,6 +402,43 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -325,6 +515,19 @@ dependencies = [ "objc2 0.6.4", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -377,25 +580,52 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop", + "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", ] +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + [[package]] name = "canvas" version = "0.1.0" dependencies = [ "accesskit", "accesskit_consumer", + "color_quant", "draw", + "image", "onestore", "parley", "png", @@ -744,6 +974,33 @@ dependencies = [ "wgpu", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -775,6 +1032,26 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -900,6 +1177,36 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "futures-task" version = "0.3.34" @@ -913,6 +1220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", @@ -1076,6 +1384,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.13.0" @@ -1520,6 +1834,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1557,9 +1880,11 @@ version = "0.1.0" dependencies = [ "canvas", "draw", + "notebook", "onestore", "parley", "pollster", + "serde", "serde_json", "wgpu", ] @@ -1671,6 +1996,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2053,6 +2388,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -2062,6 +2407,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2145,6 +2496,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -2177,6 +2571,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2549,7 +2954,7 @@ dependencies = [ "ab_glyph", "log", "memmap2", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "tiny-skia 0.11.4", ] @@ -2602,6 +3007,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2630,6 +3046,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2732,8 +3158,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.1", - "calloop", - "calloop-wayland-source", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", "cursor-icon", "libc", "log", @@ -2750,6 +3176,44 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + [[package]] name = "smol_str" version = "0.2.2" @@ -2768,6 +3232,8 @@ dependencies = [ "arboard", "canvas", "draw", + "fontique", + "libc", "notebook", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -2776,9 +3242,13 @@ dependencies = [ "parley", "png", "pollster", + "serde", + "serde_json", + "smithay-clipboard", "ui", "wgpu", "winit", + "zbus", ] [[package]] @@ -3081,15 +3551,31 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] [[package]] name = "ttf-parser" @@ -3109,11 +3595,23 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "ui" version = "0.1.0" dependencies = [ "draw", + "nucleo-matcher", "parley", "png", "pollster", @@ -3179,6 +3677,11 @@ name = "uuid" version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] [[package]] name = "vcpkg" @@ -3332,6 +3835,32 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -3876,7 +4405,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "bytemuck", - "calloop", + "calloop 0.13.0", "cfg_aliases", "concurrent-queue", "core-foundation", @@ -3898,7 +4427,7 @@ dependencies = [ "redox_syscall 0.4.1", "rustix 0.38.44", "sctk-adwaita", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", @@ -4040,6 +4569,112 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zeno" version = "0.3.3" @@ -4154,3 +4789,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 9177e380ba9d48672da61240492e8adc3ef731cc..7c253db8cb534ca7b5d4dfbbadf0ae15d6c72d3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,9 @@ resolver = "3" [workspace.dependencies] parley = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759", features = ["complex-scripts"] } +# Parley's own fontique, for its features: Cargo shares a git dependency only between +# entries naming the same revision. +fontique = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759" } wgpu = "30.0.1" # Shaping, rasterization and GPU submission stay optimised in debug builds, where diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index 520f1f06f4465dfef42d31cecd588b74f66f3c30..cba1ec195c3878c773e22b5bb1df73e319925fbf 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" publish = false [features] -gpu = ["draw/render", "dep:resvg", "dep:sha2"] +gpu = ["draw/render", "dep:color_quant", "dep:image", "dep:png", "dep:resvg", "dep:sha2"] # How a page responds to input and presents itself to assistive technology. interaction = ["gpu", "dep:accesskit"] @@ -18,6 +18,9 @@ serde_json = "1.0.151" skrifa = "0.44.0" accesskit = { version = "0.25.0", optional = true } +color_quant = { version = "1.1", optional = true } +image = { version = "0.25", default-features = false, features = ["jpeg"], optional = true } +png = { version = "0.18", optional = true } resvg = { version = "0.48.1", default-features = false, optional = true } sha2 = { version = "0.11", optional = true } @@ -32,7 +35,6 @@ path = "src/bin/page_probe.rs" [dev-dependencies] accesskit_consumer = "0.39.0" pollster = "0.4" -png = "0.18" wgpu.workspace = true [[example]] diff --git a/crates/canvas/assets/backgrounds/hearts.svg b/crates/canvas/assets/backgrounds/hearts.svg index c4960189714e0267526dce571bbe4d7314fc660f..3862f8f60209f1ff3c061fb31d4d96893a9af72b 100644 --- a/crates/canvas/assets/backgrounds/hearts.svg +++ b/crates/canvas/assets/backgrounds/hearts.svg @@ -1,11 +1,11 @@ - - - - - - - - + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/pushpins-corner.svg b/crates/canvas/assets/backgrounds/pushpins-corner.svg index a1ab5c19641f544c01f29f61541c455f4e267e9a..7fcf6715d351a56609bd564fbe2f3ee303a05528 100644 --- a/crates/canvas/assets/backgrounds/pushpins-corner.svg +++ b/crates/canvas/assets/backgrounds/pushpins-corner.svg @@ -1,6 +1,6 @@ - + diff --git a/crates/canvas/assets/fonts/Arimo-Italic.ttf b/crates/canvas/assets/fonts/Arimo-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2d2519e651227ab7c6bb6c9d1722529291a1f626 Binary files /dev/null and b/crates/canvas/assets/fonts/Arimo-Italic.ttf differ diff --git a/crates/canvas/assets/fonts/Arimo-OFL.txt b/crates/canvas/assets/fonts/Arimo-OFL.txt new file mode 100644 index 0000000000000000000000000000000000000000..d610cd5937b2dad697afc36c3853626b034dc62d --- /dev/null +++ b/crates/canvas/assets/fonts/Arimo-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2026 The Arimo Project Authors (https://github.com/googlefonts/arimo) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/canvas/assets/fonts/Arimo.ttf b/crates/canvas/assets/fonts/Arimo.ttf new file mode 100644 index 0000000000000000000000000000000000000000..428b8cb4539e4910832f64384b53ae73de91e423 Binary files /dev/null and b/crates/canvas/assets/fonts/Arimo.ttf differ diff --git a/crates/canvas/assets/fonts/Carlito-Bold.ttf b/crates/canvas/assets/fonts/Carlito-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..67543b5a1efe4b910317116b02e8e9e01659692e Binary files /dev/null and b/crates/canvas/assets/fonts/Carlito-Bold.ttf differ diff --git a/crates/canvas/assets/fonts/Carlito-BoldItalic.ttf b/crates/canvas/assets/fonts/Carlito-BoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e2c5d8803828d9a3373459d71cf18ec06753cef5 Binary files /dev/null and b/crates/canvas/assets/fonts/Carlito-BoldItalic.ttf differ diff --git a/crates/canvas/assets/fonts/Carlito-Italic.ttf b/crates/canvas/assets/fonts/Carlito-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8dc3c84edfc8f0b6c6acdbd112abfda827b484ac Binary files /dev/null and b/crates/canvas/assets/fonts/Carlito-Italic.ttf differ diff --git a/crates/canvas/assets/fonts/Carlito-OFL.txt b/crates/canvas/assets/fonts/Carlito-OFL.txt new file mode 100644 index 0000000000000000000000000000000000000000..8d6b170e4d9a5580a34471dc4ac3e8fc5fbbd712 --- /dev/null +++ b/crates/canvas/assets/fonts/Carlito-OFL.txt @@ -0,0 +1,94 @@ +Copyright 2013 The Carlito Project Authors (https://github.com/googlefonts/carlito), with Reserved Font Name "Carlito" + + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/canvas/assets/fonts/Carlito-Regular.ttf b/crates/canvas/assets/fonts/Carlito-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..427b95989fee609ab683d800ad00e22a4b14ecad Binary files /dev/null and b/crates/canvas/assets/fonts/Carlito-Regular.ttf differ diff --git a/crates/canvas/assets/fonts/Cousine-Bold.ttf b/crates/canvas/assets/fonts/Cousine-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..966b5bdb8b94770baa7bb72ac269095946418f80 Binary files /dev/null and b/crates/canvas/assets/fonts/Cousine-Bold.ttf differ diff --git a/crates/canvas/assets/fonts/Cousine-BoldItalic.ttf b/crates/canvas/assets/fonts/Cousine-BoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2c8731f8a5ab7aa32b966b162d8dc60993dca8e6 Binary files /dev/null and b/crates/canvas/assets/fonts/Cousine-BoldItalic.ttf differ diff --git a/crates/canvas/assets/fonts/Cousine-Italic.ttf b/crates/canvas/assets/fonts/Cousine-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e40f400320afdf8ae0d34181e8ac5e98ca51fc14 Binary files /dev/null and b/crates/canvas/assets/fonts/Cousine-Italic.ttf differ diff --git a/crates/canvas/assets/fonts/Cousine-OFL.txt b/crates/canvas/assets/fonts/Cousine-OFL.txt new file mode 100644 index 0000000000000000000000000000000000000000..08e54ce12f944a1c1f785855fbfd0a98d0eaf5ca --- /dev/null +++ b/crates/canvas/assets/fonts/Cousine-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2026 The Cousine Project Authors (https://github.com/googlefonts/cousine) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/crates/canvas/assets/fonts/Cousine-Regular.ttf b/crates/canvas/assets/fonts/Cousine-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e01ff6f05338a180c65ebadb5617f732d941e961 Binary files /dev/null and b/crates/canvas/assets/fonts/Cousine-Regular.ttf differ diff --git a/crates/canvas/assets/fonts/Tinos-Bold.ttf b/crates/canvas/assets/fonts/Tinos-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..887fc97a95d30a08190217b6075a5e0a0f92bec7 Binary files /dev/null and b/crates/canvas/assets/fonts/Tinos-Bold.ttf differ diff --git a/crates/canvas/assets/fonts/Tinos-BoldItalic.ttf b/crates/canvas/assets/fonts/Tinos-BoldItalic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a9abd086816f68d6eb3672f1d0babd1db1807d4a Binary files /dev/null and b/crates/canvas/assets/fonts/Tinos-BoldItalic.ttf differ diff --git a/crates/canvas/assets/fonts/Tinos-Italic.ttf b/crates/canvas/assets/fonts/Tinos-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5aed4f3757537bf334ca89dd6893063a3ef9ecc9 Binary files /dev/null and b/crates/canvas/assets/fonts/Tinos-Italic.ttf differ diff --git a/crates/canvas/assets/fonts/Tinos-OFL.txt b/crates/canvas/assets/fonts/Tinos-OFL.txt new file mode 100644 index 0000000000000000000000000000000000000000..e91137c77e543b84502bc37822aadff61b2de4e8 --- /dev/null +++ b/crates/canvas/assets/fonts/Tinos-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2026 The Tinos Project Authors (https://github.com/googlefonts/tinos) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/crates/canvas/assets/fonts/Tinos-Regular.ttf b/crates/canvas/assets/fonts/Tinos-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..893a1b525962984481aee5ce275516d384eb4865 Binary files /dev/null and b/crates/canvas/assets/fonts/Tinos-Regular.ttf differ diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs index 5f8e9b3db5797ca8ea5408c5c1b8d2e52d8c8e60..db94e28d9a1bda652521b5b6401adf03a2e4a364 100644 --- a/crates/canvas/src/document.rs +++ b/crates/canvas/src/document.rs @@ -250,7 +250,12 @@ fn divides_link(text: &Paragraph, offset: u32) -> Result { /// Whether a split or join keeping `first` up to UTF-16 `start` and `last` from `end` on meets /// an equation or embedded object at the seam or carries one to another paragraph: their run /// data belongs to the whole paragraph, and OneNote has not been seen dividing them. -fn moves_object(first: &Paragraph, start: u32, last: &Paragraph, end: u32) -> Result { +fn moves_object( + first: &Paragraph, + start: u32, + last: &Paragraph, + end: u32, +) -> Result { let object = |text: &Paragraph, span: usize| { let format = &text.spans()[span].format; [format.math, format.embedded_object].contains(&Some(true)) @@ -759,19 +764,20 @@ impl TextDocument { // The joined text lies in the upper paragraph, whose spacing and alignment it takes. let paragraph = &above.spans()[0].format; let mut start = 0; - text.text.append(Paragraph::from_runs(below.spans().iter().map(|span| { - let run = below.text()[start..span.end].to_owned(); - start = span.end; - let format = Format { - alignment: paragraph.alignment, - space_before: paragraph.space_before, - space_after: paragraph.space_after, - line_spacing: paragraph.line_spacing, - list_spacing: paragraph.list_spacing, - ..span.format.inherit(&reset) - }; - (run, format) - })))?; + text.text + .append(Paragraph::from_runs(below.spans().iter().map(|span| { + let run = below.text()[start..span.end].to_owned(); + start = span.end; + let format = Format { + alignment: paragraph.alignment, + space_before: paragraph.space_before, + space_after: paragraph.space_after, + line_spacing: paragraph.line_spacing, + list_spacing: paragraph.list_spacing, + ..span.format.inherit(&reset) + }; + (run, format) + })))?; } let moves = BTreeMap::from([(bottom.id, &head)]); let adopted = adopt(nodes, last + 1, &moves, BTreeMap::new())?; diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 46fbe3fbfbfca8c9a725832dffe20881fa980c71..83c8cba1059efbcc83ac1e6823c6c702d363af5c 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -109,6 +109,7 @@ struct PageHeader { identity: Option<[u8; 16]>, created: Option, margin_origin: [f32; 2], + color: Option, areas: Vec, } @@ -398,6 +399,59 @@ impl TextOutline { Ok(&self.shaped.paragraphs[self.visible_index(source)?]) } + /// The shown paragraphs with their source text: those a collapsed parent hides have no + /// layout and are left out. + fn shown(&self) -> impl Iterator { + self.layouts() + .filter_map(|(index, _)| Some((index, self.document.paragraph(index)?))) + } + + /// The outline's text as platform text input sees it: the shown paragraphs joined by + /// `\n`, addressed in UTF-16 units by `utf16_offset` and `utf16_position`. + pub fn shown_text(&self) -> String { + self.shown() + .map(|(_, paragraph)| paragraph.text()) + .collect::>() + .join("\n") + } + + /// Where `position` falls in `shown_text`; a hidden paragraph falls at the start of the + /// next shown one. + pub fn utf16_offset(&self, position: TextPosition) -> Result { + let mut offset = 0; + for (index, paragraph) in self.shown() { + if index == position.paragraph { + return Ok(offset + position.offset); + } + if index > position.paragraph { + return Ok(offset); + } + offset += paragraph.utf16_offset(paragraph.text().len())? + 1; + } + Ok(offset.saturating_sub(1)) + } + + /// The source position at `offset` in `shown_text`, clamped to its end. + pub fn utf16_position(&self, offset: u32) -> Result { + let mut rest = offset; + let mut end = None; + for (index, paragraph) in self.shown() { + let length = paragraph.utf16_offset(paragraph.text().len())?; + if rest <= length { + return Ok(TextPosition { + paragraph: index, + offset: rest, + }); + } + rest -= length + 1; + end = Some(TextPosition { + paragraph: index, + offset: length, + }); + } + end.ok_or(EditError::InvalidRange) + } + fn column(&self, x: f32, current: ExGuid) -> Result, EditError> { let paragraphs = self .shaped @@ -711,6 +765,7 @@ impl CanvasEditor { identity: page.identity, created: page.created, margin_origin: page.margin_origin, + color: page.color, areas, }; let mut ids = BTreeSet::new(); @@ -972,6 +1027,7 @@ impl CanvasEditor { .map(PageDate::timestamp) .or(self.header.created), margin_origin: self.header.margin_origin, + color: self.header.color, objects, definitions, }) @@ -1029,16 +1085,31 @@ impl CanvasEditor { .fold(text_bottom, f32::max) } - pub fn leave_title(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> { - let title = self.active_outline(); - if !title.title { - return Ok(()); - } + /// Where the caret goes on leaving `title`: the first body line under its header, on + /// OneNote's 18-point grid. + fn body_start_of(&self, title: &TextOutline) -> [f32; 2] { let bottom = self.header_bottom(title.id, title.bounds().y1 as f32); - let position = [ + [ title.origin()[0], ((((f64::from(bottom) - 14.4) / 18.0).ceil() + 1.0) * 18.0 + 14.4) as f32, - ]; + ] + } + + /// Where the page's first body line starts, in points, under its title. + pub fn body_start(&self) -> Option<[f32; 2]> { + let title = self + .visible_outlines() + .chain(self.caret_outline()) + .find(|outline| outline.title)?; + Some(self.body_start_of(title)) + } + + pub fn leave_title(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> { + let title = self.active_outline(); + if !title.title { + return Ok(()); + } + let position = self.body_start_of(title); if let Some((id, origin)) = self .outlines() .iter() @@ -1896,10 +1967,19 @@ impl CanvasEditor { } pub fn caret(&self, width: f32) -> Result { - let focus = self.active_outline().selection.positions[1]; + let selection = self.active_outline().selection; + self.caret_at(selection.positions[1], selection.affinities[1], width) + } + + /// The caret at `focus` in the active outline, in its coordinates. + pub fn caret_at( + &self, + focus: TextPosition, + affinity: Affinity, + width: f32, + ) -> Result { let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?; - let cursor = - paragraph.cursor(focus.offset, self.active_outline().selection.affinities[1])?; + let cursor = paragraph.cursor(focus.offset, affinity)?; let mut rect = paragraph.text.caret(cursor, width); rect.x0 += f64::from(paragraph.origin[0]); rect.x1 += f64::from(paragraph.origin[0]); @@ -2325,12 +2405,12 @@ impl CanvasEditor { } pub fn selection_rects(&self) -> Result, EditError> { - self.rectangles(self.active_outline().selection) + self.range_rects(self.active_outline().selection) } pub fn marked_rects(&self) -> Result, EditError> { match &self.composition { - Some(composition) => self.rectangles(Selection { + Some(composition) => self.range_rects(Selection { positions: [composition.range.start, composition.range.end], affinities: [Affinity::Downstream, Affinity::Upstream], }), @@ -2338,7 +2418,8 @@ impl CanvasEditor { } } - fn rectangles(&self, selection: Selection) -> Result, EditError> { + /// Highlight rectangles of `selection` in the active outline, in its coordinates. + pub fn range_rects(&self, selection: Selection) -> Result, EditError> { let [anchor, focus] = selection.positions; if anchor == focus { return Ok(Vec::new()); @@ -2515,8 +2596,10 @@ impl CanvasEditor { let (start, end) = (anchor.min(focus), anchor.max(focus)); let format = self.typing_format(start)?; let document = &self.active_outline().document; - let mut edit = - document.replace(start..end, vec![Paragraph::new(String::new(), format.clone()); 2])?; + let mut edit = document.replace( + start..end, + vec![Paragraph::new(String::new(), format.clone()); 2], + )?; let nodes = document.container(edit.container)?; let next = nodes .get(crate::document::subtree_end(nodes, edit.range.start)) @@ -2554,10 +2637,8 @@ impl CanvasEditor { .find(|id| style(Some(*id)) == Some("p")); // The heading's run formatting carries over under the body style (`c9-h1-*`). let run = format.over(&self.style_format(head.style)?); - tail.text_mut().unwrap().text = Paragraph::new( - String::new(), - run.inherit(&self.style_format(tail.style)?), - ); + tail.text_mut().unwrap().text = + Paragraph::new(String::new(), run.inherit(&self.style_format(tail.style)?)); } let caret = TextPosition { paragraph: start.paragraph + 1, @@ -2765,6 +2846,14 @@ impl CanvasEditor { Ok(true) } + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + pub fn undo(&mut self, engine: &mut TextEngine) -> Result { if self.composition.is_some() { return self.cancel_composition(engine); @@ -4984,6 +5073,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + color: None, definitions: BTreeMap::new(), objects: vec![PageObject::Image(background), PageObject::Image(picture)], }, @@ -5107,6 +5197,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + color: None, definitions: BTreeMap::new(), objects, }, @@ -5164,6 +5255,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + color: None, definitions: BTreeMap::new(), objects: vec![PageObject::Outline(source)], }, @@ -5303,6 +5395,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + color: None, definitions: BTreeMap::new(), objects: vec![ PageObject::Outline(mixed), @@ -5393,6 +5486,7 @@ mod tests { identity: None, created: None, margin_origin: [36.0, 14.4], + color: None, definitions: BTreeMap::new(), objects, }, @@ -6900,6 +6994,50 @@ mod tests { ); } + #[test] + fn platform_text_offsets_skip_collapsed_children() { + let document = TextDocument::new( + ["Collapsed", "Hidden", "Last"] + .into_iter() + .map(|s| Paragraph::new(s.into(), Format::default())) + .collect(), + ) + .unwrap(); + let mut nodes = document.nodes().to_vec(); + nodes[0].collapsed = true; + nodes[1].parent = Some(nodes[0].id); + nodes[1].level = 2; + let outline = Outline { + title: false, + min_width: None, + id: onestore::page::text::new_id().unwrap(), + layout: onestore::document::Layout { + max_width: Some(150.0), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: nodes, + unsupported: Vec::new(), + }; + let mut engine = TextEngine::default(); + let editor = + CanvasEditor::from_outlines(&mut engine, vec![outline], BTreeMap::new()).unwrap(); + let outline = editor.active_outline(); + assert_eq!(outline.shown_text(), "Collapsed\nLast"); + let at = |paragraph, offset| TextPosition { paragraph, offset }; + for (position, offset) in [ + (at(0, 3), 3), + (at(1, 2), 10), + (at(2, 0), 10), + (at(2, 4), 14), + ] { + assert_eq!(outline.utf16_offset(position).unwrap(), offset); + } + for (offset, position) in [(9, at(0, 9)), (10, at(2, 0)), (99, at(2, 4))] { + assert_eq!(outline.utf16_position(offset).unwrap(), position); + } + } + #[test] fn imported_indentation_markers_and_collapsed_children_share_edit_geometry() { let document = TextDocument::new( @@ -8610,7 +8748,7 @@ mod tests { } #[test] - fn an_edited_page_writes_back_through_the_page_writer() { + fn an_edited_page_writes_back_as_its_ops() { let mut engine = TextEngine::default(); let (space, source) = corpus_page(CORPUS[2], "Split middle"); let mut editor = CanvasEditor::from_page(source.clone(), &mut engine).unwrap(); @@ -8623,8 +8761,8 @@ mod tests { editor.focus_outline(body).unwrap(); editor.insert(&mut engine, "Edited ").unwrap(); let page = editor.page().unwrap(); - let written = onestore::PreparedEdit::page(CORPUS[2], space, &page, "Author").unwrap(); - let reread = corpus_pages(written.as_bytes()) + let written = ops::saved(CORPUS[2], space, &mut editor); + let reread = corpus_pages(&written) .into_iter() .find_map(|(candidate, page)| (candidate == space).then_some(page)) .unwrap(); diff --git a/crates/canvas/src/editor/evidence.rs b/crates/canvas/src/editor/evidence.rs index 6601402a87b96a28c6113398de37d14e6d3ed92f..50651b2940fba25352e8e42285d44e5b1a2981ff 100644 --- a/crates/canvas/src/editor/evidence.rs +++ b/crates/canvas/src/editor/evidence.rs @@ -784,9 +784,8 @@ fn structural_edits_on_native_pages_survive_the_page_writer() { press(&mut editor, &mut engine, *key); } let edited = settled(editor.page().unwrap()); - let written = onestore::PreparedEdit::page(§ion, space, &edited, "Author") - .unwrap_or_else(|error| panic!("{title}: {error}")); - let (_, reread) = page(written.as_bytes(), title); + let written = super::ops::saved(§ion, space, &mut editor); + let (_, reread) = page(&written, title); assert_eq!(settled(reread).objects, edited.objects, "{title}"); } } diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index 0df0a6b6640b55b5ec3f1daf16100717b97b4483..74d983cbfd1920fd40d9ad7cf49cd4de6a489fc9 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -1544,8 +1544,8 @@ mod tests { } let state = editor.format_state().unwrap(); let edited = settled(editor.page().unwrap()); - let written = onestore::PreparedEdit::page(SECTION, space, &edited, "Author").unwrap(); - let (_, reread) = page(written.as_bytes()); + let written = super::ops::saved(SECTION, space, &mut editor); + let (_, reread) = page(&written); let reread = settled(reread); assert_eq!(reread.objects, edited.objects); let mut editor = CanvasEditor::from_page(reread, &mut engine).unwrap(); diff --git a/crates/canvas/src/editor/ops.rs b/crates/canvas/src/editor/ops.rs index f28952bc4503632331c0ba5666d9cc542095b4fd..398a2ac57ff2d5583131a5714d57f11d8bb9b4bc 100644 --- a/crates/canvas/src/editor/ops.rs +++ b/crates/canvas/src/editor/ops.rs @@ -359,6 +359,7 @@ impl CanvasEditor { identity: None, created: None, margin_origin: [0.0; 2], + color: None, objects, definitions: named(&self.definitions, &[&outline.paragraphs]), }; @@ -456,6 +457,26 @@ impl CanvasEditor { } } +/// `image` with the ops `editor` recorded since they were last taken applied to the page in +/// `space` and sealed, as the application saves an edit. +#[cfg(test)] +pub(crate) fn saved(image: &[u8], space: ExGuid, editor: &mut CanvasEditor) -> Vec { + let arena = onestore::Arena::default(); + let mut section = onestore::Section::open(&arena, image.to_vec()).unwrap(); + let ops = editor.take_ops().unwrap(); + let ops = ops + .into_iter() + .map(|op| op::Op::Page { space, op }) + .collect(); + let edit = op::Edit { + at: 134_000_000_000_000_000, + ops, + }; + section.apply("Author", &edit).unwrap(); + section.seal().unwrap(); + section.image() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index b96c3a6920f3b5f5bc6b98c36f5dc0c0c8687878..5d01688070d92858d827cff791835453729a4851 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -52,6 +52,22 @@ impl Paper { ink: [0.0, 0.0, 0.0, 1.0], }; + /// A fill OneNote draws on white paper, moved onto this paper: as far from it in + /// OKLab lightness as from white, in the same hue and chroma, so text keeps its + /// contrast on it. + pub(crate) fn tint(&self, light: [f32; 4]) -> [f32; 4] { + let [lightness, a, b] = draw::oklab(light); + let [paper, ..] = draw::oklab(self.color); + let distance = 1.0 - lightness; + let lightness = if paper < 0.5 { + paper + distance + } else { + paper - distance + }; + let [red, green, blue] = draw::from_oklab([lightness, a, b]); + [red, green, blue, light[3]] + } + /// A near-neutral colour OneNote draws on white paper, moved onto this paper: its /// darkness becomes ink and its tint tints the paper. pub(crate) fn shade(&self, light: [f32; 4]) -> [f32; 4] { @@ -87,6 +103,11 @@ impl Glyphs for TextLayout { .or(brush.highlight.filter(|&color| color != 0).map(|_| 0)) .map(colorref) }, + run.style() + .brush + .highlight + .filter(|&color| color != 0) + .map(colorref), paint, )?; } @@ -150,6 +171,48 @@ mod tests { use onestore::page::text::Paragraph; use std::time::Duration; + #[test] + fn runs_carry_their_highlight_as_what_lies_behind_them() { + let run = |highlight, color| { + ( + "text".into(), + Format { + highlight, + color, + ..Format::default() + }, + ) + }; + let text = TextEngine::default() + .layout( + &Paragraph::from_runs([ + run(None, None), + run(Some(0x0000ffff), None), + run(Some(0x0000ffff), Some(0x000000ff)), + run(Some(0), None), + ]), + 400.0, + ) + .unwrap(); + let mut painted = Vec::new(); + text.runs(&mut |run| { + painted.push((run.color, run.backdrop)); + Ok(()) + }) + .unwrap(); + let [black, red, yellow] = [0, 0x000000ff, 0x0000ffff].map(colorref); + assert_eq!( + painted, + [ + (None, None), + (Some(black), Some(yellow)), + (Some(red), Some(yellow)), + // A black highlight paints in the ink, as its automatic text does. + (None, None), + ] + ); + } + #[test] #[ignore = "requires a native GPU adapter"] fn tags_and_page_text_paint_through_the_renderer() { diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 49af7234797d07cb248cb404c35f6b31f6085a4e..54a130ac749e65c5ff21e46bf3880b85ae0d415d 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -10,6 +10,7 @@ use crate::{ layout::{LayoutError, TextEngine}, }; use background::Background; +pub use background::{template_art, template_picture}; use draw::Primitive; use onestore::page::Page; use picture::Picture; @@ -492,11 +493,12 @@ impl PageScene { }) } - /// Bounds of noneditable content in page coordinates. + /// Bounds of noneditable content in page coordinates, each with whether OneNote's view + /// leaves room beyond it: a picture's corner is as far as the view reaches. pub fn content_bounds<'a>( &'a self, editor: &'a CanvasEditor, - ) -> impl Iterator + 'a { + ) -> impl Iterator + 'a { self.objects(Some(editor)) .into_iter() .flatten() @@ -515,12 +517,15 @@ impl PageScene { Some(editor), ) .ok()?; - Some([ - origin[0], - origin[1], - origin[0] + layout.size[0], - origin[1] + layout.size[1], - ]) + Some(( + [ + origin[0], + origin[1], + origin[0] + layout.size[0], + origin[1] + layout.size[1], + ], + true, + )) } Content::Date { below_title } => { let date = editor.date()?; @@ -534,21 +539,27 @@ impl PageScene { ) .ok()?; let size = date.layout().size; - Some([ - origin[0], - origin[1], - origin[0] + size[0], - origin[1] + size[1], - ]) + Some(( + [ + origin[0], + origin[1], + origin[0] + size[0], + origin[1] + size[1], + ], + true, + )) } - Content::Image(source) => Some([ - source.layout.x.unwrap_or(0.0), - source.layout.y.unwrap_or(0.0), - source.layout.x.unwrap_or(0.0) + source.layout.max_width?, - source.layout.y.unwrap_or(0.0) + source.layout.max_height?, - ]), - Content::Ink(ink) => crate::editor::page::ink_bounds(ink), - Content::ReadOnly(object) => Some(object.rect()), + Content::Image(source) => Some(( + [ + source.layout.x.unwrap_or(0.0), + source.layout.y.unwrap_or(0.0), + source.layout.x.unwrap_or(0.0) + source.layout.max_width?, + source.layout.y.unwrap_or(0.0) + source.layout.max_height?, + ], + false, + )), + Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)), + Content::ReadOnly(object) => Some((object.rect(), true)), Content::Editable(_) => None, }) } @@ -787,7 +798,12 @@ impl PageScene { }; outline.append_table_primitives(primitives, object_origin, paper); let everything = [f32::NEG_INFINITY, f32::INFINITY]; - outline.append_background_primitives(primitives, object_origin, everything, paper); + outline.append_background_primitives( + primitives, + object_origin, + everything, + paper, + ); self.append_outline_objects(outline, object_origin, None, paper, primitives); for (index, paragraph) in outline.visible(everything) { outline.append_paragraph_primitives( @@ -828,6 +844,7 @@ impl crate::outline::OutlineLayout { /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. A black /// highlight paints in the paper's ink, censoring the automatic text on it in any theme. + /// A paragraph's band spans its outline, or its cell, tinted for the paper. pub fn append_background_primitives( &self, primitives: &mut Vec>, @@ -836,6 +853,27 @@ impl crate::outline::OutlineLayout { paper: Paper, ) { for (index, paragraph) in self.visible(rows) { + if let Some(band) = paragraph.band { + let mut rect = parley::BoundingBox::new( + 0.0, + f64::from(paragraph.origin[1]), + f64::from(self.size[0]), + f64::from(paragraph.origin[1] + paragraph.text.height()), + ); + let cell = self.paragraph_cell(index); + if let Some(clipped) = cell.map_or(Some(rect), |cell| cell.clip(rect)) { + rect = clipped; + primitives.push(Primitive::Rect { + rect: [ + rect.x0 as f32 + origin[0], + rect.y0 as f32 + origin[1], + rect.x1 as f32 + origin[0], + rect.y1 as f32 + origin[1], + ], + color: paper.tint(colorref(band)), + }); + } + } for (mut rect, color) in paragraph.text.backgrounds() { rect.x0 += f64::from(paragraph.origin[0]); rect.x1 += f64::from(paragraph.origin[0]); @@ -1046,6 +1084,7 @@ mod tests { created: Some(1), title: "Header".into(), margin_origin: [36.0, 14.4], + color: None, definitions: BTreeMap::new(), objects: vec![PageObject::Title(Title { date: Some(fields[1].id), @@ -1062,14 +1101,20 @@ mod tests { assert!(editor.active_outline().title); assert_eq!(editor.active_outline().origin(), [46.0, 34.4]); assert_eq!(editor.active_outline().layout().max_width, None); - let before = scene.content_bounds(&editor).collect::>(); + let bounds = |editor: &CanvasEditor| { + scene + .content_bounds(editor) + .map(|(rect, _)| rect) + .collect::>() + }; + let before = bounds(&editor); assert_eq!(before[0][0], 48.0); assert!((before[0][1] - 62.6).abs() < 0.00001); let text = "A long title with multiple wrapped lines ".repeat(10); editor.select_all().unwrap(); let end = text.encode_utf16().count() as u32; editor.compose(&mut engine, text.clone(), end..end).unwrap(); - let wrapped = scene.content_bounds(&editor).collect::>(); + let wrapped = bounds(&editor); assert!(wrapped[0][1] > before[0][1] + 20.0); assert_eq!( wrapped[0][1], @@ -1077,9 +1122,9 @@ mod tests { ); editor.cancel_composition(&mut engine).unwrap(); assert_eq!(editor.active_outline().document().nodes(), source); - assert_eq!(scene.content_bounds(&editor).collect::>(), before); + assert_eq!(bounds(&editor), before); editor.commit_text(&mut engine, text).unwrap(); - assert_eq!(scene.content_bounds(&editor).collect::>(), wrapped); + assert_eq!(bounds(&editor), wrapped); assert_eq!(editor.active_outline().id, id); assert_eq!( editor.active_outline().document().nodes()[0].id, @@ -1087,14 +1132,14 @@ mod tests { ); editor.undo(&mut engine).unwrap(); assert_eq!(editor.active_outline().document().nodes(), source); - assert_eq!(scene.content_bounds(&editor).collect::>(), before); + assert_eq!(bounds(&editor), before); editor.redo(&mut engine).unwrap(); editor.select_all().unwrap(); editor.insert(&mut engine, "").unwrap(); assert_eq!(editor.outlines().len(), 1); assert!(editor.caret_outline().is_none()); assert_eq!(editor.active_outline().id, id); - assert_eq!(scene.content_bounds(&editor).collect::>(), before); + assert_eq!(bounds(&editor), before); assert!(editor.move_outline(id, [0.0; 2]).is_err()); assert!(editor.resize(&mut engine, 90.0).is_err()); editor.undo(&mut engine).unwrap(); @@ -1111,7 +1156,7 @@ mod tests { ["A date label that wraps ".repeat(30), String::new()], ) .unwrap(); - let changed = scene.content_bounds(&editor).collect::>(); + let changed = bounds(&editor); assert_eq!(changed[0][1], wrapped[0][1]); assert!(changed[0][3] > wrapped[0][3]); let (field, rect) = scene.date_fields(&editor).next().unwrap(); @@ -1120,12 +1165,12 @@ mod tests { assert_eq!(rect[3], changed[0][3]); editor.undo(&mut engine).unwrap(); assert_eq!(editor.date().unwrap().timestamp(), 1); - assert_eq!(scene.content_bounds(&editor).collect::>(), wrapped); + assert_eq!(bounds(&editor), wrapped); assert_eq!(editor.active_outline().document(), &title); } #[test] - #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"] + #[ignore = "requires the native baseline section via CANVAS_TEST_SECTION"] fn native_title_metrics_and_exit() { let bytes = std::fs::read(std::env::var_os("CANVAS_TEST_SECTION").unwrap()).unwrap(); let store = onestore::Store::parse(&bytes).unwrap(); @@ -1133,11 +1178,6 @@ mod tests { let document = onestore::document::Document::parse(&index).unwrap(); let page = Page::from_document(&document, "Baseline anchors").unwrap(); let mut engine = TextEngine::default(); - engine - .register_substitute(parley::fontique::Blob::new(Arc::new( - std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(), - ))) - .unwrap(); let mut editor = CanvasEditor::from_page(page, &mut engine).unwrap(); let title_id = editor .outlines() @@ -1288,7 +1328,7 @@ mod tests { } #[test] - #[ignore = "requires the native baseline section and Carlito via CANVAS_TEST_SECTION/CANVAS_TEST_SUBSTITUTE"] + #[ignore = "requires the native baseline section via CANVAS_TEST_SECTION"] fn native_title_flow() { use crate::document::TextDocument; use onestore::page::Image; @@ -1297,11 +1337,6 @@ mod tests { let index = onestore::RevisionIndex::parse(&store).unwrap(); let document = onestore::document::Document::parse(&index).unwrap(); let mut engine = TextEngine::default(); - engine - .register_substitute(parley::fontique::Blob::new(Arc::new( - std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(), - ))) - .unwrap(); 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"; for (font_size, text, x, y, expected) in [ (17.0, long, 120.0, 90.0, 116.18992), @@ -1448,7 +1483,11 @@ mod tests { assert_eq!([old[0], old[2]], [new[0], new[2]]); assert!((old[1] + delta - new[1]).abs() < 0.002); assert!((old[3] + delta - new[3]).abs() < 0.002); - assert!(scene.content_bounds(&editor).any(|bounds| bounds == *new)); + assert!( + scene + .content_bounds(&editor) + .any(|(bounds, _)| bounds == *new) + ); } editor.undo(&mut engine).unwrap(); assert_eq!(images(&editor), before); @@ -1487,6 +1526,7 @@ mod tests { created: None, title: "Header".into(), margin_origin: [36.0, 14.4], + color: None, definitions: BTreeMap::new(), objects: vec![PageObject::Title(Title { date: None, @@ -1561,6 +1601,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + color: None, definitions: BTreeMap::new(), objects, }; @@ -1640,6 +1681,7 @@ mod tests { created: None, title: String::new(), margin_origin: [36.0, 14.0], + color: None, objects: vec![unsupported, PageObject::Outline(make_outline("Editable"))], definitions: BTreeMap::from([( definition, @@ -1711,6 +1753,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + color: None, definitions: BTreeMap::new(), objects: vec![ PageObject::Outline(Outline { @@ -1890,6 +1933,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + color: None, definitions: BTreeMap::new(), objects: vec![ outline(2.0, "first"), @@ -2063,6 +2107,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + color: None, definitions: BTreeMap::new(), objects: vec![PageObject::Ink(ink.clone())], }; @@ -2071,7 +2116,7 @@ mod tests { assert!( scene .content_bounds(&editor) - .any(|bounds| bounds == [9.0, 19.0, 41.0, 61.0]) + .any(|(bounds, _)| bounds == [9.0, 19.0, 41.0, 61.0]) ); let mut primitives = Vec::new(); scene @@ -2122,6 +2167,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + color: None, definitions: BTreeMap::new(), objects: vec![PageObject::Image(Image { size: None, @@ -2207,6 +2253,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + color: None, definitions: BTreeMap::new(), objects: vec![PageObject::Image(background), PageObject::Image(picture)], }; @@ -2274,6 +2321,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + color: None, definitions: BTreeMap::new(), objects: pictures.iter().cloned().map(PageObject::Image).collect(), }; diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs index 1cd2c1445aaafc81e1da53fd8359ae3aa57698ec..be6f94b53ae1f91ef08800cd53d1fa54632d864a 100644 --- a/crates/canvas/src/gpu/page/background.rs +++ b/crates/canvas/src/gpu/page/background.rs @@ -1,5 +1,5 @@ -//! OneNote 2010's page template art, recognised by the SHA-256 of the embedded picture and -//! painted from vector recreations that suit the paper. +//! OneNote 2010's page template art, recognised by the SHA-256 of OneNote's picture or the +//! marker in Snowbound's, and painted from vector recreations that suit the paper. use super::{SETTLE, Slot, density, queue}; use crate::gpu::Paper; @@ -11,303 +11,393 @@ use std::{ time::{Duration, Instant}, }; -/// Each template picture's SHA-256 and its recreation, drawn for white paper in -/// `#rrggbb` colours over a view box stretched to the picture's rectangle. -const TEMPLATES: &[(&str, &str)] = &[ - ( - "a7fa7f092eff43030a56342c39a765f8d5cc48c7db815ddfc8c1e5ec40117fae", - include_str!("../../../assets/backgrounds/bamboo.svg"), +/// One of OneNote 2010's page templates: its art recreated for white paper in `#rrggbb` +/// colours over a view box the size in pixels of the picture OneNote stores, that picture's +/// format, and the SHA-256 of each picture OneNote has shipped for it. +struct Template { + name: &'static str, + svg: &'static str, + jpeg: bool, + hashes: &'static [&'static str], +} + +macro_rules! template { + ($name:literal, $format:ident, $($hash:literal),+ $(,)?) => { + Template { + name: $name, + svg: include_str!(concat!("../../../assets/backgrounds/", $name, ".svg")), + jpeg: template!(@$format), + hashes: &[$($hash),+], + } + }; + (@jpeg) => { true }; + (@png) => { false }; +} + +const TEMPLATES: &[Template] = &[ + template!( + "bamboo", + jpeg, + "a7fa7f092eff43030a56342c39a765f8d5cc48c7db815ddfc8c1e5ec40117fae" ), - ( - "74c131777e7c437fd654427417097bc01b0813ba8e1e50e4b937bd50a1bebcdb", - include_str!("../../../assets/backgrounds/binoculars-corner.svg"), + template!( + "binoculars-corner", + jpeg, + "74c131777e7c437fd654427417097bc01b0813ba8e1e50e4b937bd50a1bebcdb" ), - ( - "9c552717e8d5079bbb226948641ff13532df3d7be434c6ce545f1692fa57d45a", - include_str!("../../../assets/backgrounds/black-and-green-title.svg"), + template!( + "black-and-green-title", + jpeg, + "9c552717e8d5079bbb226948641ff13532df3d7be434c6ce545f1692fa57d45a" ), - ( - "3c68c7ee798e62a4a99c740153f3980d7df029605c843410942c7f85e794823b", - include_str!("../../../assets/backgrounds/blue-bubbles-corner-margin.svg"), + template!( + "blue-bubbles-corner-margin", + jpeg, + "3c68c7ee798e62a4a99c740153f3980d7df029605c843410942c7f85e794823b" ), - ( - "d0a3a1c3cd63c4023fe5716cbe2c211307d0e277e444d9ef76c7fc097a845fd4", - include_str!("../../../assets/backgrounds/blue-bubbles-corner-top.svg"), + template!( + "blue-bubbles-corner-top", + jpeg, + "d0a3a1c3cd63c4023fe5716cbe2c211307d0e277e444d9ef76c7fc097a845fd4" ), - ( - "7966f3d8a2d61ecb49a35e163781858e052c0b122a18a1238afe27b57e2850e8", - include_str!("../../../assets/backgrounds/blue-clouds.svg"), + template!( + "blue-clouds", + jpeg, + "7966f3d8a2d61ecb49a35e163781858e052c0b122a18a1238afe27b57e2850e8" ), - ( - "139bb0e79f89c3ddef79b1716a5fbab4c07df5785fb3cdf6b4eeddbf6c078452", - include_str!("../../../assets/backgrounds/blue-dots.svg"), + template!( + "blue-dots", + jpeg, + "139bb0e79f89c3ddef79b1716a5fbab4c07df5785fb3cdf6b4eeddbf6c078452" ), - ( + template!( + "blue-flow", + png, "473c90248fa33f8e49b2daf38673d87a9405481eae6a2087cc5384b695b6a4c0", - include_str!("../../../assets/backgrounds/blue-flow.svg"), - ), - ( "51e05999a1c9f17df28cb474e57dd8e64bdab824874a532c20a23766a01f8967", - include_str!("../../../assets/backgrounds/blue-flow.svg"), ), - ( - "83245f217deae4a4143b565e13c045dbb32a9063e8c6b2e43bb15cd76c5f9219", - include_str!("../../../assets/backgrounds/blue-mist-margin.svg"), - ), - ( - "474f9a8c25d5e21192315397ea995b1e11e2c1608157c6e0277688091bfd136a", - include_str!("../../../assets/backgrounds/blue-stripe-title.svg"), - ), - ( - "06e09de80c3f32254da4fe6b2cbad7c05ef144dd54b8c65745e195bbf7317a2e", - include_str!("../../../assets/backgrounds/blue-stripes.svg"), - ), - ( - "10c833e47be1c8496f949a6b059c2d79212a4dd66bde62116ea337fa4fe0b654", - include_str!("../../../assets/backgrounds/blue-swirls.svg"), - ), - ( - "646586cb71742a2369a529876b41af6a472c35cc508d1ae5d8395d55784814f2", - include_str!("../../../assets/backgrounds/blue-wave-title.svg"), - ), - ( - "fbb4573e3bee1b337077691bebae15d6fac52432405d31396d526d7694a8283d", - include_str!("../../../assets/backgrounds/blue-waves.svg"), - ), - ( - "1591fd26e7fff5be97431d0ed3d0ade5cfc5fa74e3d7ec282fd242160ce68c1f", - include_str!("../../../assets/backgrounds/books.svg"), - ), - ( - "bdd997068701ed3a00a224eb694b003c01ac69b857fe7b4147d6c34875b1632b", - include_str!("../../../assets/backgrounds/bouquet.svg"), - ), - ( - "e64fdcd0b108737d8b8f7b677029f924031d6bbaa50585d9c3def7c7e92ecaf2", - include_str!("../../../assets/backgrounds/boxes.svg"), - ), - ( - "639eadad468b6b32b9124b1f4395a8da3027ff7258d102173ba070ae2ed541ae", - include_str!("../../../assets/backgrounds/boxes-shadow.svg"), - ), - ( - "5eebe803e434a845d19bc600df3c75e98bb69bd0de473ceec410d1b3a9154e28", - include_str!("../../../assets/backgrounds/bubbles.svg"), - ), - ( - "509a6945facfb3ddc7be6ee8b82797ad0c72db5755486ee878125a959cc09b59", - include_str!("../../../assets/backgrounds/bubbles-margin-margin.svg"), - ), - ( - "e9746b4e9ae9ce7b3b0068779db3e113e2dfc9880f25373d745d0e700e69a906", - include_str!("../../../assets/backgrounds/bubbles-margin-top.svg"), - ), - ( - "505f731cb7707efab2eb06685b392dc7e59265a40b55aae43e5dc15c0a86cba4", - include_str!("../../../assets/backgrounds/buildings.svg"), - ), - ( - "edad0f03e6ff99fef9ef8e8b834ce74f26cd23c5f8c067f5cee66f304181e64d", - include_str!("../../../assets/backgrounds/chain-title.svg"), - ), - ( - "e1cb1a0ec9be62d5445c73aa84df38234002a7e164ee830c9df24997802cb5d2", - include_str!("../../../assets/backgrounds/circles.svg"), - ), - ( - "adb1ebbe18d6cd8ff08aa9bf5c83cdb83bf9aa179698e34e93dbcdde12f04d32", - include_str!("../../../assets/backgrounds/clock.svg"), - ), - ( - "70475431cca3c91a4efa3b8f04864371d2d3a45696674a1a0562fe9cd8db287c", - include_str!("../../../assets/backgrounds/columns.svg"), - ), - ( - "9301db6d2d87282fcee450189aeace16d85f64273bf62713a3044992b6b7a9e9", - include_str!("../../../assets/backgrounds/computer.svg"), - ), - ( - "20016e5fa1a32dce5af4e92872597e36432185a7bb2e61c91f362bd68484529b", - include_str!("../../../assets/backgrounds/cyan-title.svg"), - ), - ( - "035b26df61855a3f36dbd30fdab0c157c04c9e8ae2197ea4d4aeb3e82e6a4c2b", - include_str!("../../../assets/backgrounds/day-planner.svg"), - ), - ( - "b243a24fa13bc8523450e22f408f9eff15301c938f8ca52a57018b58ce6785de", - include_str!("../../../assets/backgrounds/fireworks.svg"), - ), - ( - "1262dd23ad54e935cfa10feb1be56648e43bef1116696ca71d87e6e033b1ca7d", - include_str!("../../../assets/backgrounds/flat-squares.svg"), - ), - ( - "423b0dd1a93b391d15b1dc8d8757c3bf5725ff2e7a59e6e3140033e2876b67f6", - include_str!("../../../assets/backgrounds/flowers-and-hearts.svg"), - ), - ( - "131d637cdc5d0b094fb9fad17f4d2a1ace0d03613588155aacaa2d1cb4e16da9", - include_str!("../../../assets/backgrounds/glasses-corner.svg"), - ), - ( - "20abe389c885e42b6ebe9e902976229bb6fd63c8c34cb61aa70b8b746209f90a", - include_str!("../../../assets/backgrounds/globe.svg"), - ), - ( - "95f59a0433050180d4c0e8858b83363d51bea6752a8b7ca516a8677854d8f5b6", - include_str!("../../../assets/backgrounds/graph.svg"), - ), - ( - "d56cefe9ee2fae72e31bdba7dd2aa4426ea22e3ceb22ef68c8f63f9f24d5a8bc", - include_str!("../../../assets/backgrounds/green-stripes.svg"), - ), - ( - "2035407a0540e1c4f7934db08ba4add750fcb9a62863ddd9553e7871c81a99e3", - include_str!("../../../assets/backgrounds/hearts.svg"), - ), - ( - "7ce10d1ea660d2f9cf8b704f3fab2966a4ce2627d9858d32c75d857095012098", - include_str!("../../../assets/backgrounds/ivy.svg"), - ), - ( - "fb12d59b8be911247bbafdd416852e8b74b028005a141cb4dbbba109b4b6ed2c", - include_str!("../../../assets/backgrounds/large-push-pins.svg"), - ), - ( - "7d2b01f17354d9237a6ab99d5b9afdf0e1cc43687125848b0c2dedfb44ce3843", - include_str!("../../../assets/backgrounds/light-bulb-corner.svg"), - ), - ( - "140dd943d0f0cfe6aaa98470b7d1a7cb62ca02cb1d8f522dd2ac77433232ef41", - include_str!("../../../assets/backgrounds/lilies.svg"), - ), - ( - "1f81dde3b42f23f0666d92ebf14d62893b31b39d72c07aee070eae28c2e6980e", - include_str!("../../../assets/backgrounds/math.svg"), - ), - ( - "ec8cd7250f3d82e900e99114869777ee859ec73effabed108815f65742078c3a", - include_str!("../../../assets/backgrounds/math-science-class-notes.svg"), - ), - ( - "0401451e1d1d7dfdc29ad1b2b68a6c8ac0b706e9868bf22fab26a01cd48620ce", - include_str!("../../../assets/backgrounds/networking.svg"), - ), - ( - "7e6a7714a69688d9ffdf16aa942b66064a0c77fcd9b3e469f89730b4b9290c3e", - include_str!("../../../assets/backgrounds/notebook.svg"), - ), - ( - "be4f0e6c89fce91b9ebd2623567f7dfc259e0e3c77c9158742b8f64b724df673", - include_str!("../../../assets/backgrounds/orange-margin.svg"), - ), - ( - "7354001527ab554c44e7d6981b86dd933b7dc2e0d3dc8512ad3eecd843245c52", - include_str!("../../../assets/backgrounds/paper.svg"), - ), - ( - "e9ead0bfc09d32cb366010cdfede1c432a2d1d550cb7332badac1bee9482bc86", - include_str!("../../../assets/backgrounds/paws-cyan.svg"), - ), - ( - "28f334b77068f71f5f92a95695433b950610204a0e5580ce567db8fad4993ecb", - include_str!("../../../assets/backgrounds/pencil-and-notebook.svg"), - ), - ( - "3fbe3c1c238bd7dbc67f8cff5f3bddfd513c96a9851b9616477947d21dff4b2e", - include_str!("../../../assets/backgrounds/pens.svg"), - ), - ( - "5dc2367a80588a7518db5014122510bf0fd784711015ef83a8718336584f82d0", - include_str!("../../../assets/backgrounds/pixel.svg"), - ), - ( - "edbb86f160050fbf1f9860276802bae292dbfd0bc98e3ea90d43d981e9f0c54a", - include_str!("../../../assets/backgrounds/pixel-blends.svg"), - ), - ( - "de35af949d4f83e97ee22f817afe2531cc4b59ff9ee6026dca7ecebc5cf2737f", - include_str!("../../../assets/backgrounds/plain-and-simple.svg"), - ), - ( - "e111f96490755c7d71e87c88acaea38afe55bb865b1a14a83c5bd239648d5e2c", - include_str!("../../../assets/backgrounds/purple-clouds.svg"), - ), - ( - "62ad3c277e54f03f1adb44062407346f789e63859b7afabfd64be6af5e9f66ec", - include_str!("../../../assets/backgrounds/pushpins-corner.svg"), - ), - ( - "f927c7825851974a2149868146970706523a49165133cee6027a43e8c9abdf27", - include_str!("../../../assets/backgrounds/rainbow.svg"), - ), - ( - "0e31da4dfcff4a36c64c1ce940362d2309769f36369e4c43c317d5f2fa15658e", - include_str!("../../../assets/backgrounds/rainbow-border.svg"), - ), - ( - "2e7aaf26bec32148b96442e8fff1bd2cef2d72630969f23b9a2abedb6cfec93b", - include_str!("../../../assets/backgrounds/rainbow-mini-border.svg"), - ), - ( - "525a857d0eda855a64d3619df58b1c2d013a73e60fa0d49b155ecfcb2c134c7c", - include_str!("../../../assets/backgrounds/recipe.svg"), - ), - ( - "b4daa90d5a53fcbc85119050b5b76962443c4dd18d7f42cdc6d4e0ad8efad872", - include_str!("../../../assets/backgrounds/red-and-black-margin.svg"), - ), - ( - "14fa2d16310485aa1ce41f6d774a3d637e8cf8b03c4f72990155df274fdb6bd9", - include_str!("../../../assets/backgrounds/side-stripes.svg"), - ), - ( - "d74c3973c8d1f7c77274691afb1aa934940674341d7eee563be75e563281bdfd", - include_str!("../../../assets/backgrounds/small-push-pins.svg"), - ), - ( - "f091ded5e283af6848670a3172e7c43c6099875d39b3fc69c2bdba914f609602", - include_str!("../../../assets/backgrounds/sparks.svg"), - ), - ( - "7f5b660a1a0bf46c75aaf19b4f77a0e086de003ec03afc1f58d871d55aa5ba9e", - include_str!("../../../assets/backgrounds/stars.svg"), - ), - ( + template!( + "blue-mist-margin", + jpeg, + "83245f217deae4a4143b565e13c045dbb32a9063e8c6b2e43bb15cd76c5f9219" + ), + template!( + "blue-stripe-title", + jpeg, + "474f9a8c25d5e21192315397ea995b1e11e2c1608157c6e0277688091bfd136a" + ), + template!( + "blue-stripes", + jpeg, + "06e09de80c3f32254da4fe6b2cbad7c05ef144dd54b8c65745e195bbf7317a2e" + ), + template!( + "blue-swirls", + jpeg, + "10c833e47be1c8496f949a6b059c2d79212a4dd66bde62116ea337fa4fe0b654" + ), + template!( + "blue-wave-title", + jpeg, + "646586cb71742a2369a529876b41af6a472c35cc508d1ae5d8395d55784814f2" + ), + template!( + "blue-waves", + jpeg, + "fbb4573e3bee1b337077691bebae15d6fac52432405d31396d526d7694a8283d" + ), + template!( + "books", + jpeg, + "1591fd26e7fff5be97431d0ed3d0ade5cfc5fa74e3d7ec282fd242160ce68c1f" + ), + template!( + "bouquet", + jpeg, + "bdd997068701ed3a00a224eb694b003c01ac69b857fe7b4147d6c34875b1632b" + ), + template!( + "boxes", + png, + "e64fdcd0b108737d8b8f7b677029f924031d6bbaa50585d9c3def7c7e92ecaf2" + ), + template!( + "boxes-shadow", + png, + "639eadad468b6b32b9124b1f4395a8da3027ff7258d102173ba070ae2ed541ae" + ), + template!( + "bubbles", + png, + "5eebe803e434a845d19bc600df3c75e98bb69bd0de473ceec410d1b3a9154e28" + ), + template!( + "bubbles-margin-margin", + jpeg, + "509a6945facfb3ddc7be6ee8b82797ad0c72db5755486ee878125a959cc09b59" + ), + template!( + "bubbles-margin-top", + jpeg, + "e9746b4e9ae9ce7b3b0068779db3e113e2dfc9880f25373d745d0e700e69a906" + ), + template!( + "buildings", + jpeg, + "505f731cb7707efab2eb06685b392dc7e59265a40b55aae43e5dc15c0a86cba4" + ), + template!( + "chain-title", + png, + "edad0f03e6ff99fef9ef8e8b834ce74f26cd23c5f8c067f5cee66f304181e64d" + ), + template!( + "circles", + png, + "e1cb1a0ec9be62d5445c73aa84df38234002a7e164ee830c9df24997802cb5d2" + ), + template!( + "clock", + jpeg, + "adb1ebbe18d6cd8ff08aa9bf5c83cdb83bf9aa179698e34e93dbcdde12f04d32" + ), + template!( + "columns", + jpeg, + "70475431cca3c91a4efa3b8f04864371d2d3a45696674a1a0562fe9cd8db287c" + ), + template!( + "computer", + jpeg, + "9301db6d2d87282fcee450189aeace16d85f64273bf62713a3044992b6b7a9e9" + ), + template!( + "cyan-title", + jpeg, + "20016e5fa1a32dce5af4e92872597e36432185a7bb2e61c91f362bd68484529b" + ), + template!( + "day-planner", + jpeg, + "035b26df61855a3f36dbd30fdab0c157c04c9e8ae2197ea4d4aeb3e82e6a4c2b" + ), + template!( + "fireworks", + png, + "b243a24fa13bc8523450e22f408f9eff15301c938f8ca52a57018b58ce6785de" + ), + template!( + "flat-squares", + png, + "1262dd23ad54e935cfa10feb1be56648e43bef1116696ca71d87e6e033b1ca7d" + ), + template!( + "flowers-and-hearts", + jpeg, + "423b0dd1a93b391d15b1dc8d8757c3bf5725ff2e7a59e6e3140033e2876b67f6" + ), + template!( + "glasses-corner", + jpeg, + "131d637cdc5d0b094fb9fad17f4d2a1ace0d03613588155aacaa2d1cb4e16da9" + ), + template!( + "globe", + jpeg, + "20abe389c885e42b6ebe9e902976229bb6fd63c8c34cb61aa70b8b746209f90a" + ), + template!( + "graph", + png, + "95f59a0433050180d4c0e8858b83363d51bea6752a8b7ca516a8677854d8f5b6" + ), + template!( + "green-stripes", + jpeg, + "d56cefe9ee2fae72e31bdba7dd2aa4426ea22e3ceb22ef68c8f63f9f24d5a8bc" + ), + template!( + "hearts", + png, + "2035407a0540e1c4f7934db08ba4add750fcb9a62863ddd9553e7871c81a99e3" + ), + template!( + "ivy", + jpeg, + "7ce10d1ea660d2f9cf8b704f3fab2966a4ce2627d9858d32c75d857095012098" + ), + template!( + "large-push-pins", + jpeg, + "fb12d59b8be911247bbafdd416852e8b74b028005a141cb4dbbba109b4b6ed2c" + ), + template!( + "light-bulb-corner", + jpeg, + "7d2b01f17354d9237a6ab99d5b9afdf0e1cc43687125848b0c2dedfb44ce3843" + ), + template!( + "lilies", + jpeg, + "140dd943d0f0cfe6aaa98470b7d1a7cb62ca02cb1d8f522dd2ac77433232ef41" + ), + template!( + "math", + jpeg, + "1f81dde3b42f23f0666d92ebf14d62893b31b39d72c07aee070eae28c2e6980e" + ), + template!( + "math-science-class-notes", + jpeg, + "ec8cd7250f3d82e900e99114869777ee859ec73effabed108815f65742078c3a" + ), + template!( + "networking", + png, + "0401451e1d1d7dfdc29ad1b2b68a6c8ac0b706e9868bf22fab26a01cd48620ce" + ), + template!( + "notebook", + jpeg, + "7e6a7714a69688d9ffdf16aa942b66064a0c77fcd9b3e469f89730b4b9290c3e" + ), + template!( + "orange-margin", + jpeg, + "be4f0e6c89fce91b9ebd2623567f7dfc259e0e3c77c9158742b8f64b724df673" + ), + template!( + "paper", + jpeg, + "7354001527ab554c44e7d6981b86dd933b7dc2e0d3dc8512ad3eecd843245c52" + ), + template!( + "paws-cyan", + png, + "e9ead0bfc09d32cb366010cdfede1c432a2d1d550cb7332badac1bee9482bc86" + ), + template!( + "pencil-and-notebook", + jpeg, + "28f334b77068f71f5f92a95695433b950610204a0e5580ce567db8fad4993ecb" + ), + template!( + "pens", + jpeg, + "3fbe3c1c238bd7dbc67f8cff5f3bddfd513c96a9851b9616477947d21dff4b2e" + ), + template!( + "pixel", + png, + "5dc2367a80588a7518db5014122510bf0fd784711015ef83a8718336584f82d0" + ), + template!( + "pixel-blends", + png, + "edbb86f160050fbf1f9860276802bae292dbfd0bc98e3ea90d43d981e9f0c54a" + ), + template!( + "plain-and-simple", + jpeg, + "de35af949d4f83e97ee22f817afe2531cc4b59ff9ee6026dca7ecebc5cf2737f" + ), + template!( + "purple-clouds", + jpeg, + "e111f96490755c7d71e87c88acaea38afe55bb865b1a14a83c5bd239648d5e2c" + ), + template!( + "pushpins-corner", + png, + "62ad3c277e54f03f1adb44062407346f789e63859b7afabfd64be6af5e9f66ec" + ), + template!( + "rainbow", + jpeg, + "f927c7825851974a2149868146970706523a49165133cee6027a43e8c9abdf27" + ), + template!( + "rainbow-border", + png, + "0e31da4dfcff4a36c64c1ce940362d2309769f36369e4c43c317d5f2fa15658e" + ), + template!( + "rainbow-mini-border", + png, + "2e7aaf26bec32148b96442e8fff1bd2cef2d72630969f23b9a2abedb6cfec93b" + ), + template!( + "recipe", + jpeg, + "525a857d0eda855a64d3619df58b1c2d013a73e60fa0d49b155ecfcb2c134c7c" + ), + template!( + "red-and-black-margin", + jpeg, + "b4daa90d5a53fcbc85119050b5b76962443c4dd18d7f42cdc6d4e0ad8efad872" + ), + template!( + "side-stripes", + jpeg, + "14fa2d16310485aa1ce41f6d774a3d637e8cf8b03c4f72990155df274fdb6bd9" + ), + template!( + "small-push-pins", + jpeg, + "d74c3973c8d1f7c77274691afb1aa934940674341d7eee563be75e563281bdfd" + ), + template!( + "sparks", + jpeg, + "f091ded5e283af6848670a3172e7c43c6099875d39b3fc69c2bdba914f609602" + ), + template!( + "stars", + png, + "7f5b660a1a0bf46c75aaf19b4f77a0e086de003ec03afc1f58d871d55aa5ba9e" + ), + template!( + "swirls", + png, "0908a4cfa23f93011176d47f45843e9ca2973030421996e8e27484781f54b0ec", - include_str!("../../../assets/backgrounds/swirls.svg"), - ), - ( "8b025b80e7d398229ef19384156fe98aeefcf8f69169d69e1eeba73281b24d7e", - include_str!("../../../assets/backgrounds/swirls.svg"), ), - ( - "2a4a3530d652e227ddd5adc096a95f6034718f7c380b07db622022d768815059", - include_str!("../../../assets/backgrounds/tiles.svg"), + template!( + "tiles", + jpeg, + "2a4a3530d652e227ddd5adc096a95f6034718f7c380b07db622022d768815059" ), - ( - "d72900e8da72d1a7f3729971aa558e1e9b6e9cf9a0d51e83852e567256dbbfef", - include_str!("../../../assets/backgrounds/triangles-title-margin.svg"), + template!( + "triangles-title-margin", + jpeg, + "d72900e8da72d1a7f3729971aa558e1e9b6e9cf9a0d51e83852e567256dbbfef" ), - ( - "2df453410796aec7b9efec00059b6ce64bcf67313a95ae458ba600ea5de14811", - include_str!("../../../assets/backgrounds/triangles-title-top.svg"), + template!( + "triangles-title-top", + jpeg, + "2df453410796aec7b9efec00059b6ce64bcf67313a95ae458ba600ea5de14811" ), - ( - "5b321a4d81bd499b289b1755f6450a42047c494dfbc112dbd56da4ced2c15c1a", - include_str!("../../../assets/backgrounds/tulips.svg"), + template!( + "tulips", + jpeg, + "5b321a4d81bd499b289b1755f6450a42047c494dfbc112dbd56da4ced2c15c1a" ), - ( - "494547e566fb7a63dd429eb0699fe41aa8998f8ea2f758d813fe3d56c3075719", - include_str!("../../../assets/backgrounds/window-reflection.svg"), + template!( + "window-reflection", + jpeg, + "494547e566fb7a63dd429eb0699fe41aa8998f8ea2f758d813fe3d56c3075719" ), - ( - "5a36960df32817e8426bd40a88f88b04fb55b84baef60f1e71e0872217fdb134", - include_str!("../../../assets/backgrounds/writing.svg"), + template!( + "writing", + jpeg, + "5a36960df32817e8426bd40a88f88b04fb55b84baef60f1e71e0872217fdb134" ), ]; +/// Names the template a picture Snowbound wrote renders: a PNG `tEXt` keyword, and the +/// prefix of a JPEG comment. +const MARKER: &str = "Snowbound template"; + /// A raster holds at most this many bytes; page-sized art stops sharpening past about 2.9 /// device pixels per point. const MAX_RASTER_BYTES: f32 = 16.0 * 1024.0 * 1024.0; @@ -335,12 +425,21 @@ impl Background { self.svg == other.svg && self.size == other.size } + /// OneNote's template pictures by their hash, and those Snowbound wrote by their marker. pub fn recognise(picture: &onestore::page::Image) -> Option { - let digest: String = Sha256::digest(picture.bytes.as_deref()?) + let bytes = picture.bytes.as_deref()?; + let digest: String = Sha256::digest(bytes) .iter() .map(|byte| format!("{byte:02x}")) .collect(); - let (_, svg) = TEMPLATES.iter().find(|(hash, _)| *hash == digest)?; + let template = TEMPLATES + .iter() + .find(|template| template.hashes.contains(&digest.as_str())) + .or_else(|| { + let name = marker(bytes)?; + TEMPLATES.iter().find(|template| template.name == name) + })?; + let svg = template.svg; let size = match (picture.layout.max_width, picture.layout.max_height) { (Some(width), Some(height)) => [width, height], _ => { @@ -407,11 +506,17 @@ fn dark(paper: Paper) -> bool { ink > lightness } -fn rasterize(svg: &str, dark: Option, [width, height]: [u32; 2]) -> RasterImage { - let tree = match dark { - Some(paper) => parse(&onto(svg, paper)), - None => parse(svg), +fn rasterize(svg: &str, dark: Option, size: [u32; 2]) -> RasterImage { + let pixels = match dark { + Some(paper) => render(&onto(svg, paper), size), + None => render(svg, size), }; + RasterImage::new(size, pixels).expect("Art is within the image budget") +} + +/// Straight-alpha RGBA rows of `svg` stretched to `width` by `height` pixels. +fn render(svg: &str, [width, height]: [u32; 2]) -> Vec { + let tree = parse(svg); let mut pixmap = tiny_skia::Pixmap::new(width, height).expect("Art has a size"); let view = tree.size(); resvg::render( @@ -422,15 +527,131 @@ fn rasterize(svg: &str, dark: Option, [width, height]: [u32; 2]) -> Raste ), &mut pixmap.as_mut(), ); - let pixels = pixmap + pixmap .pixels() .iter() .flat_map(|pixel| { let color = pixel.demultiply(); [color.red(), color.green(), color.blue(), color.alpha()] }) + .collect() +} + +/// Template art `name` for a preview: drawn `size` points big, or at its natural size, at +/// `density` pixels per point for `paper`, with the size in points it was drawn at. +pub fn template_art( + name: &str, + size: Option<[f32; 2]>, + density: f32, + paper: Paper, +) -> Option<(RasterImage, [f32; 2])> { + let template = TEMPLATES.iter().find(|template| template.name == name)?; + let size = size.unwrap_or_else(|| { + let view = parse(template.svg).size(); + [view.width() * 0.75, view.height() * 0.75] + }); + let pixels = size.map(|side| (side * density).round().max(1.0) as u32); + Some((rasterize(template.svg, dark(paper).then_some(paper), pixels), size)) +} + +/// Template `name`'s art as Snowbound stores it for a page: in the format and pixel size of +/// OneNote's picture, marked so that recognising it paints the art. +pub fn template_picture(name: &str) -> Option> { + let template = TEMPLATES.iter().find(|template| template.name == name)?; + let view = parse(template.svg).size(); + let [width, height] = [view.width(), view.height()].map(|side| side.round() as u32); + // Over white paper, as OneNote's pictures are drawn. + let rgb: Vec = render(template.svg, [width, height]) + .chunks_exact(4) + .flat_map(|pixel| { + let alpha = u16::from(pixel[3]); + [0, 1, 2].map(|channel| { + ((u16::from(pixel[channel]) * alpha + 255 * (255 - alpha) + 127) / 255) as u8 + }) + }) .collect(); - RasterImage::new([width, height], pixels).expect("Art is within the image budget") + let mut encoded = Vec::new(); + if template.jpeg { + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 90) + .encode(&rgb, width, height, image::ExtendedColorType::Rgb8) + .expect("Art encodes"); + // The comment follows the JFIF header, which must lead. + let [_, _, _, _, high, low, ..] = encoded[..] else { + unreachable!("A JPEG starts with its JFIF header") + }; + let at = 4 + usize::from(u16::from_be_bytes([high, low])); + let comment = format!("{MARKER} {name}"); + let length = u16::try_from(comment.len() + 2).expect("A short comment"); + let segment = [&[0xff, 0xfe][..], &length.to_be_bytes(), comment.as_bytes()].concat(); + encoded.splice(at..at, segment); + } else { + // OneNote's PNG templates hold 256 colours. + let rgba: Vec = rgb + .chunks_exact(3) + .flat_map(|pixel| [pixel[0], pixel[1], pixel[2], 255]) + .collect(); + let quantized = color_quant::NeuQuant::new(10, 256, &rgba); + let indices: Vec = rgba + .chunks_exact(4) + .map(|pixel| quantized.index_of(pixel) as u8) + .collect(); + let mut encoder = png::Encoder::new(&mut encoded, width, height); + encoder.set_color(png::ColorType::Indexed); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_palette(quantized.color_map_rgb()); + encoder.set_compression(png::Compression::High); + // Palette indices compress best unfiltered. + encoder.set_filter(png::Filter::NoFilter); + encoder + .add_text_chunk(MARKER.into(), name.into()) + .expect("A Latin-1 keyword"); + let mut writer = encoder.write_header().expect("Art encodes"); + writer.write_image_data(&indices).expect("Art encodes"); + writer.finish().expect("Art encodes"); + } + Some(encoded) +} + +/// The template a picture Snowbound wrote names: a PNG's text chunk before its image data, +/// or a JPEG's comment before its scan. +fn marker(picture: &[u8]) -> Option<&str> { + fn named(text: &[u8], separator: u8) -> Option<&str> { + let name = text + .strip_prefix(MARKER.as_bytes())? + .strip_prefix(&[separator])?; + std::str::from_utf8(name).ok() + } + if let Some(mut rest) = picture.strip_prefix(b"\x89PNG\r\n\x1a\n") { + while let [a, b, c, d, kind @ ..] = rest { + let length = u32::from_be_bytes([*a, *b, *c, *d]) as usize; + match kind.get(..4)? { + b"IDAT" => return None, + b"tEXt" => { + if let Some(name) = named(kind.get(4..4 + length)?, 0) { + return Some(name); + } + } + _ => {} + } + rest = rest.get(12 + length..)?; + } + return None; + } + let mut rest = picture.strip_prefix(&[0xff, 0xd8])?; + while let [0xff, kind, high, low, ..] = *rest { + let length = usize::from(u16::from_be_bytes([high, low])); + match kind { + 0xda => return None, + 0xfe => { + if let Some(name) = named(rest.get(4..2 + length)?, b' ') { + return Some(name); + } + } + _ => {} + } + rest = rest.get(2 + length..)?; + } + None } fn parse(svg: &str) -> usvg::Tree { @@ -472,7 +693,6 @@ fn onto(svg: &str, paper: Paper) -> String { recoloured } - #[cfg(test)] mod tests { use super::*; @@ -520,12 +740,8 @@ mod tests { } fn ivy() -> Background { - let (_, svg) = TEMPLATES - .iter() - .find(|(_, svg)| svg.contains("id=\"ivy\"")) - .unwrap(); Background { - svg, + svg: TEMPLATES.iter().find(|t| t.name == "ivy").unwrap().svg, size: [64.0, 48.0], variants: Default::default(), } @@ -564,19 +780,75 @@ mod tests { } #[test] - fn every_recreation_parses_for_light_and_dark_paper_under_a_distinct_hash() { + fn every_recreation_parses_for_light_and_dark_paper_under_distinct_names_and_hashes() { + let mut names = std::collections::BTreeSet::new(); let mut hashes = std::collections::BTreeSet::new(); - for (hash, svg) in TEMPLATES { + for template in TEMPLATES { assert!( - hash.len() == 64 && hash.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) + names.insert(template.name), + "{} is listed twice", + template.name ); - assert!(hashes.insert(hash), "{hash} is listed twice"); - for svg in [svg.to_string(), onto(svg, DARK)] { + for hash in template.hashes { + assert!( + hash.len() == 64 + && hash.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) + ); + assert!(hashes.insert(hash), "{hash} is listed twice"); + } + for svg in [template.svg.to_string(), onto(template.svg, DARK)] { usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap(); } } } + #[test] + fn our_template_pictures_are_recognised_as_the_template() { + for (name, magic, size) in [ + ("ivy", &b"\xff\xd8\xff\xe0"[..], [216, 792]), + ("hearts", &b"\x89PNG"[..], [171, 552]), + ] { + let bytes = template_picture(name).unwrap(); + assert!(bytes.starts_with(magic), "{name} keeps OneNote's format"); + assert_eq!(draw::RasterImage::measure(&bytes).unwrap(), size); + assert_eq!(marker(&bytes), Some(name)); + let art = Background::recognise(&picture(&bytes)).unwrap(); + let template = TEMPLATES.iter().find(|t| t.name == name).unwrap(); + assert_eq!(art.svg, template.svg); + assert!(!template.hashes.iter().any(|hash| { + let digest: String = Sha256::digest(&bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + digest == *hash + })); + } + assert!(template_picture("not a template").is_none()); + assert_eq!(marker(&red_png()), None); + } + + #[test] + fn the_sample_notebook_carries_our_ivy() { + let bytes = std::fs::read("../../docs/sample-notebook/Personal/Garden.one").unwrap(); + let store = onestore::Store::parse(&bytes).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + let document = onestore::document::Document::parse(&index).unwrap(); + let mut backgrounds = 0; + for (space, id) in document.pages().unwrap() { + let page = Page::from_revision(document.active(space).unwrap(), id).unwrap(); + for object in &page.objects { + if let PageObject::Image(image) = object + && image.background + { + assert_eq!(marker(image.bytes.as_deref().unwrap()), Some("ivy")); + assert!(Background::recognise(image).is_some()); + backgrounds += 1; + } + } + } + assert_eq!(backgrounds, 1); + } + #[test] fn other_pictures_keep_their_own_bytes() { assert!(Background::recognise(&picture(&red_png())).is_none()); @@ -634,6 +906,7 @@ mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + color: None, definitions: BTreeMap::new(), objects: vec![PageObject::Image(background)], }; diff --git a/crates/canvas/src/interaction/accessibility.rs b/crates/canvas/src/interaction/accessibility.rs index ff0a27a5aea4a292802c7fb8a6aaad51d4a3b1c1..376e86aea00b31151277c27a905f9d2fc35b317a 100644 --- a/crates/canvas/src/interaction/accessibility.rs +++ b/crates/canvas/src/interaction/accessibility.rs @@ -736,6 +736,7 @@ pub(super) mod tests { title: "Header".into(), created: None, margin_origin: [36.0, 14.4], + color: None, definitions: Default::default(), objects: vec![ PageObject::Title(Title { @@ -828,6 +829,7 @@ pub(super) mod tests { created: None, title: String::new(), margin_origin: [0.0; 2], + color: None, definitions: Default::default(), objects: vec![PageObject::Unsupported(Unsupported { id: Default::default(), diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 8d5f66f9874d43c8c6ed406f8c210f7088a139dd..a16ce9755be8591bd40d1695aeeead6ccb3b0f79 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -112,6 +112,8 @@ struct Paint { /// The document's top and bottom the view shows; paragraphs wholly outside it are /// not painted. visible: [f32; 2], + /// Whether the active outline shows its frame and grips. + chrome: bool, } enum Drag { @@ -169,6 +171,14 @@ fn read_only_shortcut(key: &Key, modifiers: Modifiers) -> bool { && matches!(key, Key::Character(value) if matches!(value.as_str(), "+" | "=" | "-" | "0") || (modifiers.shift && value.eq_ignore_ascii_case("n")))) } +/// Where a page was left: its scroll and the focused outline's selection. +#[derive(Clone, Copy, Debug)] +pub struct Place { + origin: [f32; 2], + outline: onestore::ExGuid, + selection: Selection, +} + /// A page on screen: the editor, the drawn scene, the view onto them and the pointer and /// keyboard state between events. pub struct PageView { @@ -186,6 +196,9 @@ pub struct PageView { object_focus: Option, modifiers: Modifiers, focused: bool, + /// Input is by finger: the focused outline's grips reach further, and outline chrome + /// shows only while the view is focused, as there is no hover to reveal it. + pub touch: bool, /// The caret's opacity in its blink. caret: f32, /// When the caret last moved, which restarts its blink. @@ -242,6 +255,7 @@ impl PageView { object_focus: None, modifiers: Modifiers::default(), focused: true, + touch: false, caret: 1.0, blink_from: Instant::now(), }; @@ -272,28 +286,39 @@ impl PageView { self.moved() } - /// Shows another page as OneNote opens one, keeping the zoom. - pub fn open(&mut self, editor: CanvasEditor, scene: Option<(PageScene, [f32; 2])>) { + /// Shows another page as OneNote opens one, keeping the zoom: at `place` if the page was + /// left there earlier. OneNote keeps the scroll in device pixels across zoom changes and + /// does not reveal the restored selection. + pub fn open( + &mut self, + editor: CanvasEditor, + scene: Option<(PageScene, [f32; 2])>, + place: Option, + ) { self.replace(editor, scene); - self.place_opened(); + let Some(place) = place else { + return self.place_opened(); + }; + self.viewport.origin = place.origin; + self.scroll().clamp(&mut self.viewport); + if self.editor.focus_outline(place.outline).is_ok() { + let _ = self.editor.select(place.selection); + } } - /// OneNote 2010 opens a page scrolled fully left and up, then down just far enough to - /// show the caret's outline 9 pt and 9 px above the view's bottom. + /// Where the page is left, for `open` to return to. + pub fn place(&self) -> Place { + Place { + origin: self.viewport.origin, + outline: self.editor.active_outline().id, + selection: self.editor.selection(), + } + } + + /// OneNote 2010 opens a page scrolled fully left and up, however far down its content + /// lies. fn place_opened(&mut self) { - let scroll = self.scroll(); - self.viewport.origin = scroll.min.map(|offset| -offset); - let outline = self.editor.active_outline(); - let offset = self - .scene - .as_ref() - .filter(|_| self.editor.has_page_outline(outline.id)) - .map_or(0.0, |(_, offset)| offset[1]); - let bottom = (outline.bounds().y1 as f32 + offset + 9.0) * self.viewport.scale - + 9.0 * self.display_scale - + self.viewport.origin[1]; - self.viewport.origin[1] -= (bottom - self.viewport.size[1] as f32).max(0.0); - scroll.clamp(&mut self.viewport); + self.viewport.origin = self.scroll().min.map(|offset| -offset); } pub fn modifiers(&self) -> Modifiers { @@ -382,7 +407,21 @@ impl PageView { { return Some(Hit::Image { id, handle }); } - page_hit_test(&self.editor, self.scene.as_ref(), point, self.pixel()) + let touched = (self.touch && self.focused && self.object_focus.is_none()) + .then(|| self.editor.active_outline().id); + page_hit( + &self.editor, + self.scene.as_ref(), + point, + self.pixel(), + touched, + ) + } + + /// What the view point `position`, in device pixels, lands on, for the host to route a + /// gesture before it starts. + pub fn hit(&self, position: [f32; 2]) -> Option { + self.hit_test(self.viewport.document_point(position)) } fn set_object_focus(&mut self, focus: Option) { @@ -503,6 +542,25 @@ impl PageView { Ok(()) } + /// Moves the caret by lines until it has gone a view's height, then scrolls as far so + /// it keeps its place on screen. + fn move_page(&mut self, up: bool, extend: bool) -> Result<()> { + let movement = if up { Movement::Up } else { Movement::Down }; + let start = self.caret_area()?[1]; + let mut caret = self.caret_area()?; + while (caret[1] - start).abs() < self.viewport.size[1] as f32 { + self.editor + .move_selection(&mut self.engine, movement, extend)?; + let moved = self.caret_area()?; + if moved == caret { + break; + } + caret = moved; + } + self.viewport.origin[1] -= caret[1] - start; + Ok(()) + } + /// Reveals the caret or focused object after an edit, then reports the change. fn edited(&mut self) -> Result { self.reveal_focus()?; @@ -554,21 +612,23 @@ impl PageView { rect.y1 as f32 + offset[1], ] }); + let pad = PAD * self.display_scale; let fixed = self.scene.iter().flat_map(|(scene, offset)| { - scene.content_bounds(&self.editor).map(|rect| { - [ - rect[0] + offset[0], - rect[1] + offset[1], - rect[2] + offset[0], - rect[3] + offset[1], - ] - }) + scene + .content_bounds(&self.editor) + .map(move |(rect, padded)| { + ( + [ + rect[0] + offset[0], + rect[1] + offset[1], + rect[2] + offset[0], + rect[3] + offset[1], + ], + if padded { pad } else { 0.0 }, + ) + }) }); - scroll::Scroll::new( - self.viewport, - PAD * self.display_scale, - editable.chain(fixed), - ) + scroll::Scroll::new(self.viewport, editable.map(|rect| (rect, pad)).chain(fixed)) } /// Scrolls the view's corner `offset` device pixels from the page origin along `axis`, @@ -654,6 +714,7 @@ impl PageView { colors, visible: [0.0, self.viewport.size[1] as f32] .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale), + chrome: !self.touch || self.focused, }, ) } @@ -681,7 +742,7 @@ impl PageView { let (caret, hold) = edit::caret_blink(now.saturating_duration_since(self.blink_from)); let repaint = caret != self.caret; self.caret = caret; - (repaint, Some(now + hold)) + (repaint, hold.map(|hold| now + hold)) } /// `size` in device pixels. @@ -863,8 +924,12 @@ impl PageView { } pub fn pointer_released(&mut self) -> Result { - let preview = self.outline_preview(); - if let Some((id, origin, size)) = self.image_preview() { + // As in OneNote, an outline or picture dropped outside the view stays where it was. + let inside = + (0..2).all(|axis| (0.0..self.viewport.size[axis] as f32).contains(&self.pointer[axis])); + let moving_image = matches!(self.drag, Some(Drag::Image { handle: [0, 0], .. })); + let preview = self.outline_preview().filter(|_| inside); + if let Some((id, origin, size)) = self.image_preview().filter(|_| inside || !moving_image) { self.editor .place_image(&mut self.engine, id, origin, size)?; } @@ -922,7 +987,8 @@ impl PageView { /// An input method's marked text; `cursor` is its UTF-8 selection within `text`. pub fn compose(&mut self, text: String, cursor: Option<(usize, usize)>) -> Result { - if !self.accepts_text() { + // Wayland input methods clear an absent composition after each cursor update. + if !self.accepts_text() || text.is_empty() && self.editor.marked_range().is_none() { return Ok(Response::default()); } if text.is_empty() { @@ -962,6 +1028,21 @@ impl PageView { self.edited() } + /// Undoes the last edit, or redoes the last undone one. + pub fn undo(&mut self, redo: bool) -> Result { + if redo { + self.editor.redo(&mut self.engine)?; + } else { + self.editor.undo(&mut self.engine)?; + } + if let Some(ObjectFocus::Image(id)) = self.object_focus + && self.editor.image_placement(id).is_none() + { + self.set_object_focus(None); + } + self.edited() + } + /// The date or time chosen after `Request::EditDate`. pub fn change_date(&mut self, timestamp: u64, text: [String; 2]) -> Result { self.editor.change_date(&mut self.engine, timestamp, text)?; @@ -1111,17 +1192,9 @@ impl PageView { self.set_object_focus(None); } "a" => self.editor.select_all()?, - "z" => { - if shift { - self.editor.redo(&mut self.engine)?; - } else { - self.editor.undo(&mut self.engine)?; - } - if let Some(ObjectFocus::Image(id)) = self.object_focus - && self.editor.image_placement(id).is_none() - { - self.set_object_focus(None); - } + "z" => return self.undo(shift), + "y" if edit::Platform::CURRENT == edit::Platform::Windows => { + return self.undo(true); } "c" | "x" => { let [anchor, focus] = self.editor.selection().positions; @@ -1166,6 +1239,13 @@ impl PageView { if let Some(Command::Move(movement)) = chord { self.editor .move_selection(&mut self.engine, movement, shift)?; + } else if let Some(Command::ScrollPage { up }) = chord { + // AppKit keeps ten points of the last page in view. + let page = self.viewport.size[1] as f32 - 10.0 * self.display_scale; + self.viewport.origin[1] += if up { page } else { -page }; + return self.moved(); + } else if let Some(Command::MovePage { up }) = chord { + self.move_page(up, shift)?; } else if self.editor.marked_range().is_none() { match (chord, key) { (Some(Command::DeleteTo(movement)), _) => { @@ -1249,6 +1329,21 @@ pub fn page_hit_test( scene: Option<&(PageScene, [f32; 2])>, point: [f32; 2], pixel: f32, +) -> Option { + page_hit(editor, scene, point, pixel, None) +} + +/// Logical pixels a finger's grip targets extend beyond the drawn grips. +const TOUCH_REACH: f32 = 16.0; + +/// `page_hit_test` where the `touched` outline's grips reach `TOUCH_REACH` further: outward, +/// and inward along its header. +fn page_hit( + editor: &CanvasEditor, + scene: Option<&(PageScene, [f32; 2])>, + point: [f32; 2], + pixel: f32, + touched: Option, ) -> Option { /// Grips and text resolve front to back before any outline's padding, as a native /// width handle stays reachable under the next outline's left padding; the typing room @@ -1278,19 +1373,31 @@ pub fn page_hit_test( let local = [point[0] - offset[0], point[1] - offset[1]]; let [x, y] = local; if layer == Layer::Grips { + let extra = if touched == Some(outline.id) && !outline.title { + TOUCH_REACH * pixel + } else { + 0.0 + }; // Width handles: 15 px inside to 6 px outside the header's right end, and 6 px // either side of the right border below it. - let inside = if y < body_top { 15.0 } else { 6.0 }; + let inside = if y < body_top { + 15.0 * pixel + extra + } else { + 6.0 * pixel + }; if !outline.title - && (bounds[2] - inside * pixel..=bounds[2] + 6.0 * pixel).contains(&x) - && (bounds[1]..=bounds[3]).contains(&y) + && (bounds[2] - inside..=bounds[2] + 6.0 * pixel + extra).contains(&x) + && (bounds[1] - extra..=bounds[3] + extra).contains(&y) { return Some(Hit::Resize { id: outline.id, grab: point[0] - outline.bounds().x1 as f32, }); } - if x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y < body_top { + if (bounds[0] - extra..=bounds[2] + extra).contains(&x) + && y >= bounds[1] - extra + && y < body_top + { return Some(Hit::Handle { id: outline.id, grab: [ @@ -1397,7 +1504,7 @@ fn page_primitives<'a>( Some(PointerFeedback::Move(id, origin)) if id == outline.id => origin, _ => outline.origin(), }; - if (object_focus.is_none() && outline.id == editor.active_outline().id) + if (paint.chrome && object_focus.is_none() && outline.id == editor.active_outline().id) || matches!(preview, Some(PointerFeedback::Hover(id) | PointerFeedback::Move(id, _)) if id == outline.id) || matches!(preview, Some(PointerFeedback::Resize(resized)) if resized.id == outline.id) { @@ -1794,6 +1901,7 @@ fn append_outline<'a>( pixel, colors, visible, + .. } = paint; let rows = [visible[0] - y, visible[1] - y]; outline diff --git a/crates/canvas/src/interaction/scroll.rs b/crates/canvas/src/interaction/scroll.rs index 0b27efd8bbeb17be36691bf75e501412d20a7d6f..fd2068e16505a04e89ef15c347468eec802c4421 100644 --- a/crates/canvas/src/interaction/scroll.rs +++ b/crates/canvas/src/interaction/scroll.rs @@ -9,19 +9,19 @@ pub struct Scroll { } impl Scroll { - /// `pad` is the room, in device pixels, OneNote leaves beyond content it scrolls to. - pub fn new(viewport: Viewport, pad: f32, bounds: impl Iterator) -> Self { - let mut min = [f32::INFINITY; 2]; + /// Each content rectangle comes with the room, in device pixels, OneNote leaves beyond + /// it left and up. + pub fn new(viewport: Viewport, bounds: impl Iterator) -> Self { + let mut min = [0.0_f32; 2]; let mut max = [0.0_f32; 2]; - for rect in bounds { + for (rect, pad) in bounds { for axis in 0..2 { - min[axis] = min[axis].min(rect[axis]); + min[axis] = min[axis].min(rect[axis] * viewport.scale - pad); max[axis] = max[axis].max(rect[axis + 2]); } } - // OneNote stops at the page origin, or `pad` beyond content that reaches past it. + // OneNote stops at the page origin unless content reaches past it. for axis in 0..2 { - min[axis] = (min[axis] * viewport.scale - pad).min(0.0); max[axis] = ((max[axis] + 36.0) * viewport.scale - viewport.size[axis] as f32).max(min[axis]); } @@ -46,11 +46,29 @@ mod tests { scale: 2.0, origin: [9999.0; 2], }; - let scroll = Scroll::new(viewport, 11.0, [[-80.0, -20.0, 1000.0, 1200.0]].into_iter()); + let scroll = Scroll::new( + viewport, + [([-80.0, -20.0, 1000.0, 1200.0], 11.0)].into_iter(), + ); scroll.clamp(&mut viewport); assert_eq!(viewport.origin, [171.0, 51.0]); viewport.origin = [-9999.0; 2]; scroll.clamp(&mut viewport); assert_eq!(viewport.origin, [-1272.0, -1872.0]); } + + #[test] + fn pictures_reach_no_further_than_their_corner() { + let viewport = Viewport { + size: [800, 600], + scale: 2.0, + origin: [0.0; 2], + }; + let bounds = [ + ([-0.5, -22.35, 613.0, 141.3], 0.0), + ([36.0, 86.4, 400.0, 180.0], 11.0), + ]; + let scroll = Scroll::new(viewport, bounds.into_iter()); + assert_eq!(scroll.min, [-1.0, -44.7]); + } } diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 66aed76d4669eeb37d5ca5e5fbff9af7cf2aee49..92b322633d88db7e0c5c69583a081c739d2f82dc 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -20,6 +20,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint { pixel: display_scale / scale, colors: COLORS, visible: [f32::NEG_INFINITY, f32::INFINITY], + chrome: true, } } @@ -47,6 +48,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() title: "Header".into(), created: Some(1), margin_origin: [36.0, 14.4], + color: None, definitions: Default::default(), objects: vec![onestore::page::PageObject::Title(onestore::page::Title { id: onestore::ExGuid::default(), @@ -530,6 +532,7 @@ fn extension_hits_yield_to_objects_and_keep_their_source_coordinate_frame() { title: String::new(), created: None, margin_origin: [0.0; 2], + color: None, definitions: Default::default(), objects: vec![onestore::page::PageObject::Outline(source)], }; @@ -652,6 +655,7 @@ fn a_picture_in_an_outline_takes_the_click_over_its_text() { identity: None, created: None, margin_origin: [36.0, 14.4], + color: None, definitions: Default::default(), objects: vec![PageObject::Outline(source)], }, @@ -828,6 +832,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { pixel: 1.0, colors: COLORS, visible: [f32::NEG_INFINITY, f32::INFINITY], + chrome: true, }, &mut primitives, ) @@ -899,6 +904,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { pixel: 1.0, colors: COLORS, visible: [f32::NEG_INFINITY, f32::INFINITY], + chrome: true, }, &mut primitives, ) @@ -1157,6 +1163,7 @@ fn overlapping_objects_follow_paint_order_through_creation_movement_and_undo() { created: None, title: String::new(), margin_origin: [0.0; 2], + color: None, definitions: Default::default(), objects, }; @@ -1389,6 +1396,7 @@ fn read_only_focus_retires_text_overlays_and_draws_a_scaled_focus_border() { created: None, title: String::new(), margin_origin: [0.0; 2], + color: None, definitions: Default::default(), objects: vec![PageObject::Unsupported(Unsupported { id: Default::default(), @@ -1503,6 +1511,7 @@ fn picture_view() -> (PageView, onestore::ExGuid) { identity: None, created: None, margin_origin: [36.0, 14.4], + color: None, definitions: Default::default(), objects: vec![PageObject::Outline(source)], }, @@ -1539,6 +1548,7 @@ fn a_page_outline_emptied_by_backspace_still_draws() { identity: None, created: None, margin_origin: [36.0, 14.4], + color: None, definitions: Default::default(), objects, }; @@ -1660,6 +1670,16 @@ fn events_route_through_the_view_as_the_host_delivers_them() { let origin = view.editor.active_outline().origin(); assert_eq!(origin, [72.0, 50.4]); + // Dropped outside the view, as past the window's corner, it stays where it was. + let bounds = view.editor.active_outline().bounds(); + let header = [(bounds.x0 + bounds.x1) as f32 / 2.0, bounds.y0 as f32 - 8.0]; + let _ = view.pointer_moved(header).unwrap(); + let _ = view.pointer_pressed(now + Duration::from_secs(4)).unwrap(); + let _ = view.pointer_moved([-200.0, -150.0]).unwrap(); + assert!(view.outline_preview().is_some()); + let _ = view.pointer_released().unwrap(); + assert_eq!(view.editor.active_outline().origin(), origin); + // An empty spot places a new caret there, 7 px above the pointer on the grid. click(&mut view, [400.0, 300.0], now + Duration::from_secs(3)); 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() { /// OneNote 2010 at 75 to 150% opens a page scrolled fully left and up: 11 px beyond 7.5 pt /// left of text, a list marker, 0.75 pt into a tag's slot, or 6 pt above an outline, never -/// right of or below the page origin; then down until the caret's outline clears the bottom. +/// right of or below the page origin, even when the only outline lies below the view. #[test] fn a_page_opens_where_onenote_places_the_view() { use crate::editor::{Formatting, NoteTag}; @@ -1880,7 +1900,133 @@ fn a_page_opens_where_onenote_places_the_view() { "{origin:?} {view:?}" ); } - // Content far down scrolls into view, 9 pt and 9 px above the bottom. let (_, view) = open([50.0, 900.0], false); - assert!(view[1] < -600.0); + assert_eq!(view, [0.0, 0.0]); +} + +/// OneNote 2010 returns to a page at the scroll, in device pixels whatever the zoom, and the +/// selection it was left with, without revealing the selection. +#[test] +fn a_page_reopens_where_it_was_left() { + use crate::document::TextPosition; + let mut engine = TextEngine::default(); + let outlines: Vec = [("First", [0.0, 0.0]), ("Far below", [50.0, 900.0])] + .into_iter() + .map(|(text, origin)| { + let paragraph = Paragraph::new(text.into(), Format::default()); + let document = TextDocument::new(vec![paragraph]).unwrap(); + TextOutline::new(&mut engine, document, 468.0, origin).unwrap() + }) + .collect(); + let editor = |count: usize| { + CanvasEditor::from_text_outlines(outlines[..count].to_vec(), Default::default(), None) + .unwrap() + }; + let mut view = PageView::new( + editor(2), + engine, + None, + [800, 600], + 1.0, + Duration::from_millis(500), + ); + let far = outlines[1].id; + view.editor.focus_outline(far).unwrap(); + let selection = Selection::from([2, 5].map(|offset| TextPosition { + paragraph: 0, + offset, + })); + view.editor.select(selection).unwrap(); + let _ = view.scroll_to(1, 300.0).unwrap(); + let place = view.place(); + + view.open(editor(2), None, None); + assert_eq!(view.viewport.origin, [21.0, 19.0]); + let _ = view.set_zoom(2.0).unwrap(); + view.open(editor(2), None, Some(place)); + assert_eq!(view.viewport.origin, [21.0, -300.0]); + assert_eq!(view.editor.active_outline().id, far); + assert_eq!(view.editor.selection(), selection); + + // A place past the page's content is kept within it, here 6 pt above the outline at 200%. + view.open(editor(1), None, Some(place)); + assert_eq!(view.viewport.origin, [21.0, 27.0]); + assert_eq!(view.editor.active_outline().id, outlines[0].id); +} + +#[test] +fn page_keys_scroll_or_carry_the_caret_as_the_platform_does() { + let mut engine = TextEngine::default(); + let lines = (0..100) + .map(|line| Paragraph::new(format!("Line {line}"), Default::default())) + .collect(); + let outline = TextOutline::new( + &mut engine, + TextDocument::new(lines).unwrap(), + 240.0, + [36.0, 36.0], + ) + .unwrap(); + let editor = CanvasEditor::from_page( + Page { + title: String::new(), + identity: None, + created: None, + margin_origin: [36.0, 14.4], + color: None, + definitions: Default::default(), + objects: vec![onestore::page::PageObject::Outline(outline.snapshot())], + }, + &mut engine, + ) + .unwrap(); + let mut view = PageView::new( + editor, + engine, + None, + [800, 200], + 1.0, + Duration::from_millis(500), + ); + view.viewport.scale = 1.0; + let page_down = Key::Named(NamedKey::PageDown); + let option = Modifiers { + option: true, + ..Modifiers::default() + }; + for modifiers in [Modifiers::default(), option] { + view.viewport.origin = [0.0; 2]; + view.editor + .move_selection(&mut view.engine, Movement::DocumentStart, false) + .unwrap(); + let _ = view.modifiers_changed(modifiers).unwrap(); + let caret = view.caret_area().unwrap()[1]; + let _ = view.key(&page_down, None).unwrap(); + let scrolled = -view.viewport.origin[1]; + let shown = view.caret_area().unwrap()[1]; + match Command::from_key(&page_down, modifiers) { + Some(Command::ScrollPage { up: false }) => { + assert_eq!(scrolled, 190.0, "a page less AppKit's ten points"); + assert_eq!(shown, caret - scrolled, "the caret stays in the text"); + } + Some(Command::MovePage { up: false }) => { + assert!(scrolled >= 200.0, "{scrolled}"); + assert_eq!(shown, caret, "the caret keeps its place on screen"); + } + None => assert_eq!((scrolled, shown), (0.0, caret)), + other => panic!("Page Down is {other:?}"), + } + } +} + +#[test] +fn an_empty_preedit_without_a_composition_changes_nothing() { + let (mut view, _) = picture_view(); + assert_eq!( + view.compose(String::new(), None).unwrap(), + Response::default() + ); + assert!(view.compose("k".into(), Some((1, 1))).unwrap().changed); + assert!(view.compose(String::new(), None).unwrap().changed); + assert!(view.editor.marked_range().is_none()); } diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index 585c18738b827a4a5a2c44232136266bb6938947..025b4d581c67b54796e33fbcfe47ea3e3fa8eac6 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -4,10 +4,11 @@ use parley::{ FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, OverflowWrap, PositionedLayoutItem, StyleProperty, editing::{Cursor, Selection}, + fontique::{Blob, FontInfo}, }; use skrifa::{FontRef, MetadataProvider, raw::TableProvider, string::StringId}; use std::{ - collections::BTreeSet, + collections::{BTreeMap, BTreeSet}, fmt, ops::Range, sync::{ @@ -16,11 +17,20 @@ use std::{ }, }; -#[derive(Default)] pub struct TextEngine { pub fonts: FontContext, context: LayoutContext, arial_substitutes: BTreeSet, + /// The substitute each family lays out in, by family. + substitutes: BTreeMap<&'static str, Substitute>, +} + +/// A font laid out in place of a family. +pub struct Substitute { + pub name: &'static str, + /// Its faces as registered for the family. + pub faces: Vec<(Blob, FontInfo)>, + bundled: bool, } /// Superscripts and subscripts draw at this fraction of their run's size. @@ -69,7 +79,9 @@ pub enum LayoutError { impl fmt::Display for LayoutError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::UnsupportedSubstituteFont => write!(f, "Choose an Arimo or Carlito font file."), + Self::UnsupportedSubstituteFont => { + write!(f, "Choose an Arimo, Carlito, Tinos or Cousine font file.") + } _ => write!(f, "Text layout failed: {self:?}"), } } @@ -77,12 +89,75 @@ impl fmt::Display for LayoutError { impl std::error::Error for LayoutError {} +/// Metric-compatible faces for the fonts OneNote pages use most, under the SIL Open Font +/// Licence files beside them, by the family each stands in for. +const BUNDLED: [(&str, &[&[u8]]); 4] = [ + ( + "Calibri", + &[ + include_bytes!("../assets/fonts/Carlito-Regular.ttf"), + include_bytes!("../assets/fonts/Carlito-Bold.ttf"), + include_bytes!("../assets/fonts/Carlito-Italic.ttf"), + include_bytes!("../assets/fonts/Carlito-BoldItalic.ttf"), + ], + ), + ( + "Arial", + &[ + include_bytes!("../assets/fonts/Arimo.ttf"), + include_bytes!("../assets/fonts/Arimo-Italic.ttf"), + ], + ), + ( + "Times New Roman", + &[ + include_bytes!("../assets/fonts/Tinos-Regular.ttf"), + include_bytes!("../assets/fonts/Tinos-Bold.ttf"), + include_bytes!("../assets/fonts/Tinos-Italic.ttf"), + include_bytes!("../assets/fonts/Tinos-BoldItalic.ttf"), + ], + ), + ( + "Courier New", + &[ + include_bytes!("../assets/fonts/Cousine-Regular.ttf"), + include_bytes!("../assets/fonts/Cousine-Bold.ttf"), + include_bytes!("../assets/fonts/Cousine-Italic.ttf"), + include_bytes!("../assets/fonts/Cousine-BoldItalic.ttf"), + ], + ), +]; + +impl Default for TextEngine { + /// An engine that lays out each bundled family in its substitute where it is missing. + fn default() -> Self { + let mut engine = Self { + fonts: FontContext::default(), + context: LayoutContext::default(), + arial_substitutes: BTreeSet::new(), + substitutes: BTreeMap::new(), + }; + for (family, faces) in BUNDLED { + if engine.fonts.collection.family_id(family).is_none() { + for face in faces { + engine + .register(Blob::new(Arc::new(*face)), true) + .expect("bundled substitutes register"); + } + } + } + engine + } +} + impl TextEngine { - /// Register Arimo as Arial or Carlito as Calibri before creating layouts. - pub fn register_substitute( - &mut self, - data: parley::fontique::Blob, - ) -> Result<&'static str, LayoutError> { + /// Register Arimo as Arial, Carlito as Calibri, Tinos as Times New Roman or Cousine as + /// Courier New before creating layouts, in place of the bundled substitute. + pub fn register_substitute(&mut self, data: Blob) -> Result<&'static str, LayoutError> { + self.register(data, false) + } + + fn register(&mut self, data: Blob, bundled: bool) -> Result<&'static str, LayoutError> { let font = FontRef::new(data.as_ref()).map_err(|_| LayoutError::UnsupportedSubstituteFont)?; let family = font @@ -94,9 +169,11 @@ impl TextEngine { }) .map(|name| name.to_string()) .ok_or(LayoutError::UnsupportedSubstituteFont)?; - let target = match family.as_str() { - "Arimo" => "Arial", - "Carlito" => "Calibri", + let (substitute, target) = match family.as_str() { + "Arimo" => ("Arimo", "Arial"), + "Carlito" => ("Carlito", "Calibri"), + "Tinos" => ("Tinos", "Times New Roman"), + "Cousine" => ("Cousine", "Courier New"), _ => return Err(LayoutError::UnsupportedSubstituteFont), }; let id = data.id(); @@ -110,8 +187,23 @@ impl TextEngine { return Err(LayoutError::InvalidFontMetrics); } } - let registered = self.fonts.collection.register_fonts( - data, + let collection = &mut self.fonts.collection; + // A substitute of the caller's replaces the bundled one outright. + if !bundled + && self + .substitutes + .get(target) + .is_some_and(|known| known.bundled) + && let Some(replaced) = self.substitutes.remove(target) + && let Some(family) = collection.family_id(target) + { + for (face, info) in replaced.faces { + collection.unregister_font(family, info.width(), info.style(), info.weight()); + self.arial_substitutes.remove(&face.id()); + } + } + let registered = collection.register_fonts( + data.clone(), Some(parley::fontique::FontInfoOverride { family_name: Some(target), ..Default::default() @@ -123,9 +215,25 @@ impl TextEngine { if target == "Arial" { self.arial_substitutes.insert(id); } + let entry = self.substitutes.entry(target).or_insert(Substitute { + name: substitute, + faces: Vec::new(), + bundled, + }); + entry.faces.extend( + registered + .into_iter() + .flat_map(|(_, infos)| infos) + .map(|info| (data.clone(), info)), + ); Ok(target) } + /// The font `family` lays out in when a substitute stands in for it. + pub fn substitute(&self, family: &str) -> Option<&Substitute> { + self.substitutes.get(family) + } + fn shape( &mut self, paragraph: &Paragraph, @@ -466,14 +574,9 @@ mod tests { use onestore::document::Format; #[test] - #[ignore = "requires Arial and CANVAS_TEST_SUBSTITUTE pointing to Carlito"] + #[ignore = "requires Arial"] fn native_unkerned_advances() { let mut engine = TextEngine::default(); - engine - .register_substitute(parley::fontique::Blob::new(Arc::new( - std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(), - ))) - .unwrap(); for (font, expected) in [("Calibri", 149.63843), ("Arial", 184.58305)] { let paragraph = Paragraph::new( "AVATAR AVATAR SECOND OFFICE".into(), @@ -489,6 +592,34 @@ mod tests { } } + #[test] + fn a_registered_substitute_replaces_the_bundled_one() { + let mut engine = TextEngine::default(); + if engine.substitute("Calibri").is_none() { + return; + } + let explicit = Blob::new(Arc::new(BUNDLED[0].1[0].to_vec())); + assert_eq!(engine.register_substitute(explicit.clone()), Ok("Calibri")); + let substitute = engine.substitute("Calibri").unwrap(); + assert_eq!(substitute.faces.len(), 1); + let paragraph = Paragraph::new( + "Calibri".into(), + Format { + font: Some("Calibri".into()), + bold: Some(true), + ..Format::default() + }, + ); + let layout = engine.layout(¶graph, 400.0).unwrap(); + let (line, _) = layout.lines().next().unwrap(); + for item in line.items() { + let PositionedLayoutItem::GlyphRun(run) = item else { + continue; + }; + assert_eq!(run.run().font().font.data.id(), explicit.id()); + } + } + #[test] fn rejected_substitute_leaves_font_selection_unchanged() { let mut engine = TextEngine::default(); diff --git a/crates/canvas/src/lib.rs b/crates/canvas/src/lib.rs index 9d73865ffbae3620e2ff271a93cb257cef3fb2c7..d47ac7e1766288ae0a210fbf4f665606568dfe20 100644 --- a/crates/canvas/src/lib.rs +++ b/crates/canvas/src/lib.rs @@ -4,6 +4,7 @@ pub mod language; pub mod layout; pub mod math; pub mod outline; +pub mod template; pub mod date; #[cfg(feature = "gpu")] diff --git a/crates/canvas/src/outline.rs b/crates/canvas/src/outline.rs index 4bf76d33f527349d205c0bc51a5d087c49c90d34..fdc44bbe4b025a08f1f89b749f52e848a94261c0 100644 --- a/crates/canvas/src/outline.rs +++ b/crates/canvas/src/outline.rs @@ -203,6 +203,9 @@ pub struct ParagraphLayout { pub(crate) parent: Option, /// An equation draws in two dimensions in place of its linear text. pub math: Option, + /// A highlight of the whole paragraph (its own `highlight`), COLORREF: a band across its + /// outline behind its lines, as OneNote marks a conflict page's conflicting changes. + pub band: Option, } #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] @@ -626,6 +629,7 @@ impl ParagraphLayout { tags, math, parent: paragraph.parent, + band: paragraph.format.highlight, }; result.place_tags(result.marker_left()); Ok(result) @@ -1688,7 +1692,7 @@ mod tests { } #[test] - #[ignore = "requires CANVAS_TEST_SECTION native Tab capture and CANVAS_TEST_SUBSTITUTE Carlito font"] + #[ignore = "requires CANVAS_TEST_SECTION native Tab capture"] fn native_table_layout() { use onestore::page::{Page, PageObject}; use onestore::{RevisionIndex, Store, document::Document}; @@ -1697,11 +1701,6 @@ mod tests { let index = RevisionIndex::parse(&store).unwrap(); let document = Document::parse(&index).unwrap(); let mut engine = TextEngine::default(); - engine - .register_substitute(parley::fontique::Blob::new(std::sync::Arc::new( - std::fs::read(std::env::var_os("CANVAS_TEST_SUBSTITUTE").unwrap()).unwrap(), - ))) - .unwrap(); let page = Page::from_document(&document, "rows").unwrap(); let outline = page .objects diff --git a/crates/canvas/src/template.rs b/crates/canvas/src/template.rs new file mode 100644 index 0000000000000000000000000000000000000000..ac1936500fe2d3511ef00a1fd7d6714ef016179e --- /dev/null +++ b/crates/canvas/src/template.rs @@ -0,0 +1,644 @@ +//! The page templates Snowbound offers: OneNote 2010's background art, recreated +//! (`gpu::page::template_picture`), placed where OneNote's templates place it, and plain +//! page colours. + +/// One background picture of a template: the recreated art's name, and the picture's +/// position and size in points from the page origin as OneNote's template stores them. +/// Art without a size draws at its natural size. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Art { + pub art: &'static str, + pub position: [f32; 2], + pub size: Option<[f32; 2]>, +} + +/// A template by OneNote's name for it, with the art it puts behind the page. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Template { + pub name: &'static str, + pub art: &'static [Art], +} + +/// OneNote 2010's Decorative templates and Informal Meeting Notes (resources/onenote-templates.md), +/// in the order the gallery lists them. +pub const TEMPLATES: &[Template] = &[ + Template { + name: "Informal Meeting Notes", + art: &[Art { + art: "small-push-pins", + position: [-18.0, -39.6], + size: Some([610.3, 789.8]), + }], + }, + Template { + name: "Bamboo", + art: &[Art { + art: "bamboo", + position: [-36.0, -75.6], + size: Some([581.1, 754.3]), + }], + }, + Template { + name: "Binoculars Corner", + art: &[Art { + art: "binoculars-corner", + position: [-54.0, -12.6], + size: Some([353.7, 80.0]), + }], + }, + Template { + name: "Black and Green Title", + art: &[Art { + art: "black-and-green-title", + position: [0.0, -36.6], + size: Some([633.7, 39.6]), + }], + }, + Template { + name: "Blue Bubbles Corner", + art: &[ + Art { + art: "blue-bubbles-corner-margin", + position: [-54.0, -3.6], + size: Some([59.6, 247.5]), + }, + Art { + art: "blue-bubbles-corner-top", + position: [443.2, -3.6], + size: Some([47.7, 63.7]), + }, + ], + }, + Template { + name: "Blue Clouds", + art: &[Art { + art: "blue-clouds", + position: [-2.2, 0.9], + size: Some([662.2, 905.0]), + }], + }, + Template { + name: "Blue Dots", + art: &[Art { + art: "blue-dots", + position: [-1.5, 0.1], + size: Some([602.7, 780.0]), + }], + }, + Template { + name: "Blue Flow", + art: &[Art { + art: "blue-flow", + position: [0.0, -0.6], + size: Some([33.1, 498.0]), + }], + }, + Template { + name: "Blue Mist Margin", + art: &[Art { + art: "blue-mist-margin", + position: [-26.2, -3.6], + size: Some([36.0, 431.8]), + }], + }, + Template { + name: "Blue Stripe Title", + art: &[Art { + art: "blue-stripe-title", + position: [0.0, -12.6], + size: Some([662.2, 103.0]), + }], + }, + Template { + name: "Blue Stripes", + art: &[Art { + art: "blue-stripes", + position: [-63.0, -21.6], + size: Some([611.2, 791.0]), + }], + }, + Template { + name: "Blue Swirls", + art: &[Art { + art: "blue-swirls", + position: [0.0, -3.6], + size: Some([595.6, 775.0]), + }], + }, + Template { + name: "Blue Wave Title", + art: &[Art { + art: "blue-wave-title", + position: [-25.5, -15.6], + size: Some([547.2, 42.9]), + }], + }, + Template { + name: "Blue Waves", + art: &[Art { + art: "blue-waves", + position: [0.0, -12.6], + size: Some([634.3, 812.5]), + }], + }, + Template { + name: "Books", + art: &[Art { + art: "books", + position: [-72.0, -3.6], + size: Some([183.9, 658.9]), + }], + }, + Template { + name: "Bouquet", + art: &[Art { + art: "bouquet", + position: [-45.0, -39.6], + size: Some([505.9, 654.8]), + }], + }, + Template { + name: "Boxes", + art: &[Art { + art: "boxes", + position: [-36.0, -3.6], + size: None, + }], + }, + Template { + name: "Boxes - Shadow", + art: &[Art { + art: "boxes-shadow", + position: [-18.0, 14.4], + size: Some([30.0, 456.8]), + }], + }, + Template { + name: "Bubbles", + art: &[Art { + art: "bubbles", + position: [-18.0, -3.6], + size: Some([36.0, 425.3]), + }], + }, + Template { + name: "Bubbles Margin", + art: &[ + Art { + art: "bubbles-margin-margin", + position: [-54.0, 68.4], + size: Some([54.9, 288.0]), + }, + Art { + art: "bubbles-margin-top", + position: [-54.0, -21.6], + size: Some([432.0, 70.4]), + }, + ], + }, + Template { + name: "Buildings", + art: &[Art { + art: "buildings", + position: [-36.0, -21.6], + size: Some([597.5, 773.3]), + }], + }, + Template { + name: "Chain Title", + art: &[Art { + art: "chain-title", + position: [0.0, -11.1], + size: Some([533.7, 95.7]), + }], + }, + Template { + name: "Circles", + art: &[Art { + art: "circles", + position: [-54.0, -3.6], + size: Some([68.7, 549.8]), + }], + }, + Template { + name: "Clock", + art: &[Art { + art: "clock", + position: [-18.0, -3.6], + size: Some([607.9, 786.7]), + }], + }, + Template { + name: "Columns", + art: &[Art { + art: "columns", + position: [-37.5, -8.9], + size: Some([602.1, 779.2]), + }], + }, + Template { + name: "Computer", + art: &[Art { + art: "computer", + position: [-18.0, -21.6], + size: Some([462.7, 618.0]), + }], + }, + Template { + name: "Cyan Title", + art: &[Art { + art: "cyan-title", + position: [0.7, -12.6], + size: Some([717.0, 102.3]), + }], + }, + Template { + name: "Day Planner", + art: &[Art { + art: "day-planner", + position: [-36.0, -3.6], + size: Some([482.5, 487.2]), + }], + }, + Template { + name: "Fireworks", + art: &[Art { + art: "fireworks", + position: [-72.0, -3.6], + size: Some([98.8, 288.0]), + }], + }, + Template { + name: "Flat Squares", + art: &[Art { + art: "flat-squares", + position: [-18.0, -3.6], + size: Some([30.0, 492.8]), + }], + }, + Template { + name: "Flowers and Hearts", + art: &[Art { + art: "flowers-and-hearts", + position: [-54.0, -3.6], + size: Some([88.8, 689.8]), + }], + }, + Template { + name: "Glasses Corner", + art: &[Art { + art: "glasses-corner", + position: [-81.0, -56.1], + size: Some([229.5, 120.7]), + }], + }, + Template { + name: "Globe", + art: &[Art { + art: "globe", + position: [-54.0, -3.6], + size: Some([142.7, 557.7]), + }], + }, + Template { + name: "Graph", + art: &[Art { + art: "graph", + position: [-36.0, -3.6], + size: Some([46.5, 495.7]), + }], + }, + Template { + name: "Green Stripes", + art: &[Art { + art: "green-stripes", + position: [-72.0, -21.6], + size: Some([612.0, 792.0]), + }], + }, + Template { + name: "Hearts", + art: &[Art { + art: "hearts", + position: [-90.0, -3.6], + size: Some([89.2, 288.0]), + }], + }, + Template { + name: "Ivy", + art: &[Art { + art: "ivy", + position: [-27.0, -3.6], + size: Some([174.5, 640.0]), + }], + }, + Template { + name: "Large Push Pins", + art: &[Art { + art: "large-push-pins", + position: [-18.0, -21.6], + size: Some([529.7, 685.5]), + }], + }, + Template { + name: "Light Bulb Corner", + art: &[Art { + art: "light-bulb-corner", + position: [0.0, -5.9], + size: Some([155.7, 164.7]), + }], + }, + Template { + name: "Lilies", + art: &[Art { + art: "lilies", + position: [0.0, -3.6], + size: Some([618.5, 811.5]), + }], + }, + Template { + name: "Math", + art: &[Art { + art: "math", + position: [-36.0, -21.6], + size: Some([512.6, 568.1]), + }], + }, + Template { + name: "Networking", + art: &[ + Art { + art: "networking", + position: [-36.0, 212.4], + size: None, + }, + Art { + art: "networking", + position: [-36.0, 104.4], + size: None, + }, + Art { + art: "networking", + position: [-36.0, -3.6], + size: None, + }, + ], + }, + Template { + name: "Notebook", + art: &[Art { + art: "notebook", + position: [-18.0, -21.6], + size: Some([483.9, 626.3]), + }], + }, + Template { + name: "Orange Margin", + art: &[Art { + art: "orange-margin", + position: [-27.0, -3.6], + size: Some([37.4, 483.0]), + }], + }, + Template { + name: "Paper", + art: &[Art { + art: "paper", + position: [-18.0, -57.6], + size: Some([612.6, 792.7]), + }], + }, + Template { + name: "Paws - Cyan", + art: &[Art { + art: "paws-cyan", + position: [-18.0, -3.6], + size: Some([36.0, 288.0]), + }], + }, + Template { + name: "Pencil and Notebook", + art: &[Art { + art: "pencil-and-notebook", + position: [-18.0, -57.6], + size: Some([620.8, 469.5]), + }], + }, + Template { + name: "Pens", + art: &[Art { + art: "pens", + position: [-36.0, -39.6], + size: Some([601.0, 777.8]), + }], + }, + Template { + name: "Pixel", + art: &[Art { + art: "pixel", + position: [-18.0, -21.6], + size: Some([432.0, 26.3]), + }], + }, + Template { + name: "Pixel - Blends", + art: &[Art { + art: "pixel-blends", + position: [-18.0, -3.6], + size: Some([29.2, 354.0]), + }], + }, + Template { + name: "Plain and Simple", + art: &[Art { + art: "plain-and-simple", + position: [-36.0, -12.6], + size: Some([606.8, 785.3]), + }], + }, + Template { + name: "Purple Clouds", + art: &[Art { + art: "purple-clouds", + position: [0.0, -21.6], + size: Some([613.5, 163.7]), + }], + }, + Template { + name: "Pushpins Corner", + art: &[Art { + art: "pushpins-corner", + position: [-36.0, -39.6], + size: Some([162.0, 111.9]), + }], + }, + Template { + name: "Rainbow", + art: &[Art { + art: "rainbow", + position: [-36.0, -3.6], + size: Some([612.0, 792.0]), + }], + }, + Template { + name: "Rainbow Border", + art: &[Art { + art: "rainbow-border", + position: [-18.0, -3.6], + size: Some([29.2, 399.0]), + }], + }, + Template { + name: "Rainbow Mini Border", + art: &[Art { + art: "rainbow-mini-border", + position: [-18.0, -3.6], + size: Some([24.0, 368.2]), + }], + }, + Template { + name: "Recipe", + art: &[Art { + art: "recipe", + position: [-45.0, -3.6], + size: Some([533.2, 690.0]), + }], + }, + Template { + name: "Red and Black Margin", + art: &[Art { + art: "red-and-black-margin", + position: [0.7, 6.9], + size: Some([11.8, 484.5]), + }], + }, + Template { + name: "Side Stripes", + art: &[Art { + art: "side-stripes", + position: [-36.0, -3.6], + size: Some([40.2, 848.4]), + }], + }, + Template { + name: "Small Push Pins", + art: &[Art { + art: "small-push-pins", + position: [-36.0, -39.6], + size: Some([610.3, 789.8]), + }], + }, + Template { + name: "Sparks", + art: &[Art { + art: "sparks", + position: [-18.0, -3.6], + size: Some([611.4, 791.3]), + }], + }, + Template { + name: "Stars", + art: &[Art { + art: "stars", + position: [-36.0, -3.6], + size: Some([61.8, 341.9]), + }], + }, + Template { + name: "Swirls", + art: &[Art { + art: "swirls", + position: [-36.0, -3.6], + size: Some([56.2, 366.3]), + }], + }, + Template { + name: "Tiles", + art: &[Art { + art: "tiles", + position: [-36.0, -3.6], + size: Some([606.2, 784.5]), + }], + }, + Template { + name: "Triangles Title", + art: &[ + Art { + art: "triangles-title-top", + position: [0.0, -21.6], + size: Some([274.2, 53.0]), + }, + Art { + art: "triangles-title-margin", + position: [5.2, 231.9], + size: Some([11.8, 288.0]), + }, + ], + }, + Template { + name: "Tulips", + art: &[Art { + art: "tulips", + position: [-72.0, -3.6], + size: Some([605.0, 783.0]), + }], + }, + Template { + name: "Window Reflection", + art: &[Art { + art: "window-reflection", + position: [-18.0, -75.6], + size: Some([603.3, 780.7]), + }], + }, + Template { + name: "Writing", + art: &[Art { + art: "writing", + position: [-54.0, -39.6], + size: Some([597.5, 773.2]), + }], + }, +]; + +/// OneNote 2010's page colours in its View, Page Color menu order, with the names its +/// tooltips give them; COLORREF, as the page XML reports each +/// (evidence/notebook-management/page-colors). +pub const PAGE_COLORS: &[(&str, u32)] = &[ + ("Blue", 0xfef5ed), + ("Red", 0xefeeff), + ("Lemon", 0xddfdfd), + ("Apple", 0xe5f9ec), + ("Blue Mist", 0xf3ede5), + ("Magenta", 0xf6edfb), + ("Yellow", 0xdffaff), + ("Green", 0xe5f0ec), + ("Silver", 0xede9e9), + ("Purple", 0xffe9f3), + ("Tan", 0xe7f4f9), + ("Cyan", 0xebefe5), + ("Purple Mist", 0xebe3e8), + ("Red Chalk", 0xdedeef), + ("Orange", 0xe1f2fb), + ("Teal", 0xf2f9d4), +]; + +/// The template named `name`. +pub fn find(name: &str) -> Option<&'static Template> { + TEMPLATES.iter().find(|template| template.name == name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn templates_have_unique_names_and_art() { + let mut names: Vec<_> = TEMPLATES.iter().map(|template| template.name).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), TEMPLATES.len()); + assert!(TEMPLATES.iter().all(|template| !template.art.is_empty())); + assert_eq!(find("Ivy").unwrap().art[0].art, "ivy"); + } +} diff --git a/crates/canvas/tests/ops_differential.rs b/crates/canvas/tests/ops_differential.rs index 5b2654946e68b12f9e7ea5205d9f2a0d16b43f4a..836885a452deb566fa673902388bde69adc9dcd8 100644 --- a/crates/canvas/tests/ops_differential.rs +++ b/crates/canvas/tests/ops_differential.rs @@ -1,6 +1,6 @@ -//! The ops the editor records for each edit, applied to the section, store what the edited -//! page lowered whole (`Section::apply_page(editor.page())`) stores: every editor operation -//! alone and in random sequences with undo and redo, on every page of the corpus sections +//! The ops the editor records for each edit, applied to the section, store what the model +//! oracle (`op::predict`) predicts from them, read back so once sealed, and hold the editor's +//! 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 //! below and of any named in `OPS_SWEEP_SECTIONS` (`:`-separated paths; the structural //! probe section, which `structural_roundtrip` sweeps, takes minutes). @@ -11,8 +11,9 @@ use canvas::{ }; use draw::edit::Movement; use onestore::{ - Arena, ExGuid, Section, - op::{Edit, Op}, + Arena, ExGuid, RevisionIndex, Section, Store, + document::{Document, Kind as Node}, + op::{Edit, Op, PageOp}, page::Page, }; use std::{collections::BTreeMap, path::Path}; @@ -372,7 +373,7 @@ fn perform( } } -/// How a step's ops compared with its page lowered whole. +/// How a step's ops compared with the editor's page. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] enum Outcome { /// The ops store the editor's page, or what that page lowered whole stores. @@ -383,7 +384,8 @@ enum Outcome { /// stored. OpsOnly, /// The paths part where the section already stored the page differently from the - /// editor's model, as the writers normalize some values. + /// editor's model, as the writers normalize some values, or where text keeps an insertion + /// style the page model cannot show. Normalized, Differs(String), } @@ -395,116 +397,190 @@ struct Stored<'a> { at: u64, } +/// The text objects of the page in `space` whose empty final run keeps an insertion style, +/// which inserting at their end takes and the page model cannot show. +fn hidden_styles(image: &[u8], space: ExGuid) -> Vec { + let store = Store::parse(image).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let space = &document.spaces[&space]; + space.revisions[&space.contexts[&ExGuid::default()]] + .nodes + .iter() + .filter(|(_, node)| { + matches!(&node.kind, Node::RichText { text, runs, .. } + if !text.is_empty() && runs.len() > 1 && runs.last().is_some_and(|r| r.start == r.end)) + }) + .map(|(id, _)| *id) + .collect() +} + +/// A page as JSON without tags' property-set indices, which the writer assigns. +fn comparable(page: &Page) -> serde_json::Value { + fn strip(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + map.remove("extra_set"); + map.values_mut().for_each(strip); + } + serde_json::Value::Array(items) => items.iter_mut().for_each(strip), + _ => {} + } + } + let mut value = serde_json::to_value(page).unwrap(); + strip(&mut value); + value +} + impl Stored<'_> { - /// Stores the editor's step as its ops and, unless they store the editor's page, - /// compares with that page lowered whole onto the section as it was before the step. + /// Stores the editor's step as its ops, checks the stored page against what the model + /// oracle predicts from them and against the sealed image reread, and, unless it is the + /// editor's page, compares with that page lowered whole onto the section as it was + /// before the step. fn step(&mut self, editor: &mut CanvasEditor, model: &Page) -> Outcome { self.at += 10_000_000; let image = self.section.image(); - let reads_as = |stored: Page, model: &Page| { - Page { - title: model.title.clone(), - ..stored - } == *model - }; - let normalized = !reads_as(self.section.page(self.space).unwrap(), model); + let before = self.section.page(self.space).unwrap(); + let normalized = !reads_as(&before, model); let ops = editor.take_ops().map_err(|error| error.to_string()); - let edit = ops.clone().map(|ops| Edit { - at: self.at, - ops: ops - .into_iter() - .map(|op| Op::Page { - space: self.space, - op, - }) - .collect(), - }); - let applied = edit.and_then(|edit| { - if edit.ops.is_empty() { + let applied = ops.clone().and_then(|ops| { + if ops.is_empty() { return Ok(()); } + let edit = Edit { + at: self.at, + ops: ops + .into_iter() + .map(|op| Op::Page { + space: self.space, + op, + }) + .collect(), + }; self.section .apply("Sweep", &edit) .map_err(|error| error.to_string()) }); + let stored = self.section.page(self.space).unwrap(); let edited = editor.page().unwrap(); - if applied.is_ok() && reads_as(self.section.page(self.space).unwrap(), &edited) { - self.section.seal().unwrap(); - return Outcome::Same; + let outcome = self.classify(image, &before, &stored, &edited, &ops, applied, normalized); + if !matches!(outcome, Outcome::Same | Outcome::Refused) + && std::env::var_os("OPS_SWEEP_DEBUG").is_some() + { + eprintln!("DIFFERS {outcome:?}\n ops:"); + for op in ops.iter().flatten() { + let op = format!("{op:?}"); + eprintln!(" {}", &op[..op.len().min(600)]); + } + } + self.section.seal().unwrap(); + let arena = Arena::default(); + let reread = Section::open(&arena, self.section.image()) + .unwrap() + .page(self.space) + .unwrap(); + if reread != stored { + return Outcome::Differs(format!( + "reads back otherwise: {}", + difference(&stored, &reread) + )); + } + outcome + } + + #[allow(clippy::too_many_arguments)] + fn classify( + &self, + image: Vec, + before: &Page, + stored: &Page, + edited: &Page, + ops: &Result, String>, + applied: Result<(), String>, + normalized: bool, + ) -> Outcome { + if let Err(error) = &applied + && stored != before + { + return Outcome::Differs(format!("a refused edit changed the page: {error}")); + } + if let (Ok(()), Ok(ops)) = (&applied, ops) { + let mut predicted = before.clone(); + if let Err(error) = ops + .iter() + .try_for_each(|op| onestore::op::predict(&mut predicted, op)) + { + return Outcome::Differs(format!( + "the model refuses what the section stores: {error}" + )); + } + if comparable(&predicted) != comparable(stored) { + let hidden = hidden_styles(&image, self.space); + if ops.iter().any(|op| { + matches!(op, PageOp::Text { text, .. } | PageOp::Link { text, .. } | PageOp::Split { text, .. } + if hidden.contains(text)) + }) { + return Outcome::Normalized; + } + return Outcome::Differs(format!( + "the model predicts otherwise: {}", + difference(stored, &predicted) + )); + } + if reads_as(stored, edited) { + return Outcome::Same; + } } let arena = Arena::default(); let mut whole = Section::open(&arena, image).unwrap(); - let lowered = onestore::op::lower_page(&whole.page(self.space).unwrap(), &edited) + let lowered = onestore::op::lower_page(before, edited) .map_err(|error| error.to_string()) .and_then(|ops| { let edit = Edit { at: self.at, ops: ops - .iter() + .into_iter() .map(|op| Op::Page { space: self.space, - op: op.clone(), + op, }) .collect(), }; whole .apply("Sweep", &edit) - .map_err(|error| error.to_string())?; - Ok(ops) + .map_err(|error| error.to_string()) }); - let outcome = match (applied, &lowered) { + match (applied, lowered) { (Err(error), Err(whole)) => { if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { eprintln!("BOTH {error} | {whole}"); } Outcome::Refused } - (Ok(()), Ok(_)) => { - let (a, b) = ( - self.section.page(self.space).unwrap(), - whole.page(self.space).unwrap(), - ); - if a == b { + (Ok(()), Ok(())) => { + let whole = whole.page(self.space).unwrap(); + if *stored == whole { Outcome::Same } else if normalized { Outcome::Normalized } else { - Outcome::Differs(difference(&a, &b)) + Outcome::Differs(difference(stored, &whole)) } } - (Err(_), Ok(_)) if normalized => Outcome::Normalized, - (Err(error), Ok(_)) => Outcome::Differs(format!("ops refused: {error}")), - (Ok(()), Err(_)) => { - if std::env::var_os("OPS_SWEEP_DEBUG").is_some() { - let stored = self.section.page(self.space).unwrap(); - eprintln!("OPSONLY-MODEL {}", difference(&stored, &edited)); - } - Outcome::OpsOnly - } - }; - if !matches!(outcome, Outcome::Same | Outcome::Refused) - && std::env::var_os("OPS_SWEEP_DEBUG").is_some() - { - let show = |ops: &[onestore::op::PageOp]| { - for op in ops { - let op = format!("{op:?}"); - eprintln!(" {}", &op[..op.len().min(600)]); - } - }; - eprintln!("DIFFERS {outcome:?}\n ops:"); - if let Ok(ops) = &ops { - show(ops); - } - eprintln!(" whole:"); - if let Ok(ops) = &lowered { - show(ops); - } + (Err(_), Ok(())) if normalized => Outcome::Normalized, + (Err(error), Ok(())) => Outcome::Differs(format!("ops refused: {error}")), + (Ok(()), Err(_)) => Outcome::OpsOnly, } - self.section.seal().unwrap(); - outcome } } +fn reads_as(stored: &Page, model: &Page) -> bool { + Page { + title: model.title.clone(), + ..stored.clone() + } == *model +} + /// The first place two pages part: an object missing from one, then objects by identity, /// then their order. fn difference(a: &Page, b: &Page) -> String { @@ -613,7 +689,7 @@ fn sweep( } #[test] -fn editor_ops_store_what_the_page_lowered_whole_stores() { +fn editor_ops_store_the_editor_s_page() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let mut paths: Vec<_> = SECTIONS.iter().map(|path| root.join(path)).collect(); 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() { } let mut totals = [0; 5]; for (kind, counts) in &per_kind { - let [same, refused, only, normalized, differs] = counts; + let [same, refused, ops_only, normalized, differs] = counts; report += &format!( - "{kind:?}: {same} same, {refused} both refused, {only} only as ops, {normalized} after normalization, {differs} differ\n" + "{kind:?}: {same} same, {refused} refused, {ops_only} by ops only, {normalized} after normalization, {differs} differ\n" ); for (total, count) in totals.iter_mut().zip(counts) { *total += count; } } - report += &format!( - "all: {totals:?} (same, both refused, only as ops, after normalization, differ)\n" - ); + report += + &format!("all: {totals:?} (same, refused, by ops only, after normalization, differ)\n"); for ((kind, outcome), count) in &tally.outcomes { if let Outcome::Differs(reason) = outcome { report += &format!( diff --git a/crates/canvas/tests/reopen.rs b/crates/canvas/tests/reopen.rs index b2424a9fd99d73bca4d1a7c2c64ab47deb4dbb50..f203545c3e3124b6b010146b240b30fc36b0137f 100644 --- a/crates/canvas/tests/reopen.rs +++ b/crates/canvas/tests/reopen.rs @@ -5,8 +5,9 @@ use canvas::{ layout::TextEngine, }; use onestore::{ - ExGuid, PageCreation, PreparedEdit, RevisionIndex, Store, + Arena, ExGuid, PageCreation, RevisionIndex, Section, Store, document::Document, + op::{Edit, Op, SectionOp}, page::{Page, PageObject, ParagraphContent}, }; use std::{fs, path::Path}; @@ -32,8 +33,8 @@ fn spaces(document: &Document<'_>) -> Vec { spaces } -/// What `notebook::session::Section::import_page` writes: a new titled page, then the -/// editor's body objects saved onto it. +/// What `notebook::session::Section::import_page` applies: a new titled page holding a copy of +/// the editor's body objects. #[test] fn an_imported_editor_page_reopens_with_its_title_editable() { let section = onestore::create_section("reopen.one", "Body 🦀 é", "Author").unwrap(); @@ -41,20 +42,20 @@ fn an_imported_editor_page_reopens_with_its_title_editable() { let (_, editor) = PageScene::from_page(pages(§ion).remove(0), &mut engine).unwrap(); let copy = editor.page().unwrap().copy().unwrap(); let creation = PageCreation::new(None, Some("Imported 🦋"), "Author").unwrap(); - let created = PreparedEdit::create_page(§ion, &creation).unwrap(); - let mut after = pages(created.as_bytes()).pop().unwrap(); - after - .objects - .retain(|object| matches!(object, PageObject::Title(_))); - after.objects.extend( - copy.objects - .into_iter() - .filter(|object| !matches!(object, PageObject::Title(_))), - ); - after.definitions = copy.definitions; - let saved = PreparedEdit::page(created.as_bytes(), creation.space(), &after, "Author").unwrap(); + let arena = Arena::default(); + let mut imported = Section::open(&arena, section).unwrap(); + let import = SectionOp::Import { + creation, + page: copy, + }; + let edit = Edit { + at: 134_000_000_000_000_000, + ops: vec![Op::Section(import)], + }; + imported.apply("Author", &edit).unwrap(); + imported.seal().unwrap(); - let reopened = pages(saved.as_bytes()).pop().unwrap(); + let reopened = pages(&imported.image()).pop().unwrap(); let (_, editor) = PageScene::from_page(reopened.clone(), &mut engine).unwrap(); assert_eq!(editor.page().unwrap(), reopened); let texts = |title: bool| -> Vec { diff --git a/crates/canvas/tests/structural_roundtrip.rs b/crates/canvas/tests/structural_roundtrip.rs index 78c330f6aa39ac99ba2c9aa088c32204d206be2f..cd541b34b5c7e5d1e3ee1aa0fa04b993a4d99987 100644 --- a/crates/canvas/tests/structural_roundtrip.rs +++ b/crates/canvas/tests/structural_roundtrip.rs @@ -1,5 +1,5 @@ -//! Every structural edit the editor makes saves, through the page writer and as the ops the -//! editor recorded, and rereads as the model it saved: random Enter, Backspace, Delete, Tab, +//! Every structural edit the editor makes saves as the ops the editor recorded and rereads +//! as the model it saved: random Enter, Backspace, Delete, Tab, //! Shift+Tab, list and paste edits, alone and several to a save, on every outline of the probe //! section and of any sections named in `CANVAS_SWEEP_SECTIONS` (`:`-separated paths). @@ -9,7 +9,7 @@ use canvas::{ layout::TextEngine, }; use onestore::{ - Arena, ExGuid, PreparedEdit, RevisionIndex, Section, Store, + Arena, ExGuid, RevisionIndex, Section, Store, document::Document, page::{Outline, Page, PageObject, PageParagraph, ParagraphContent}, }; @@ -352,25 +352,14 @@ fn sweep(path: &Path, random: &mut Random, engine: &mut TextEngine, tally: &mut .map(|op| onestore::op::Op::Page { space, op }) .collect(), }; - let ops = match stored.apply("Sweep", &edit) { + let failure = match stored.apply("Sweep", &edit) { Err(error) => Some(format!("ops: {error}")), Ok(()) => { let reread = stored.page(space).unwrap(); (!rereads(&reread)).then(|| format!("ops: {}", difference(&after, &reread))) } }; - let failure = match PreparedEdit::page(§ion, space, &after, "Sweep") { - Err(error) => Some(error.message.to_owned()), - Ok(saved) => match pages(saved.as_bytes()) - .into_iter() - .find(|(id, _)| *id == space) - { - Some((_, reread)) if rereads(&reread) => None, - Some((_, reread)) => Some(difference(&after, &reread)), - None => Some("the page does not reread".into()), - }, - }; - for failure in failure.into_iter().chain(ops) { + if let Some(failure) = failure { tally.failures.entry(failure).or_default().push(format!( "{plan:?} on {:?} in {}", page.title, @@ -483,10 +472,20 @@ fn edit(section: &mut Vec, title: &str, keys: &[(Caret, Press)], engine: &mu } } let after = editor.page().unwrap(); - let saved = PreparedEdit::page(section, space, &after, "Snowbound") - .unwrap() - .as_bytes() - .to_vec(); + let arena = Arena::default(); + let mut stored = Section::open(&arena, section.to_vec()).unwrap(); + let edit = onestore::op::Edit { + at: 133_000_000_000_000_000, + ops: editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| onestore::op::Op::Page { space, op }) + .collect(), + }; + stored.apply("Snowbound", &edit).unwrap(); + stored.seal().unwrap(); + let saved = stored.image(); let (_, reread) = pages(&saved) .into_iter() .find(|(id, _)| *id == space) diff --git a/crates/draw/src/edit.rs b/crates/draw/src/edit.rs index e9d1d2fade22ef59cd673425f61ce54b2cc5c8e7..cba3c6dc681dcd45635d7bdf243721bfc135be47 100644 --- a/crates/draw/src/edit.rs +++ b/crates/draw/src/edit.rs @@ -1,5 +1,5 @@ -//! Text editing shared by the page and interface text: keys, macOS editing chords, click -//! counting and caret movement within one parley layout. +//! Text editing shared by the page and interface text: keys, each platform's editing chords +//! and caret, click counting and caret movement within one parley layout. use parley::{ Affinity, Brush, Layout, @@ -30,6 +30,8 @@ pub enum NamedKey { ArrowDown, Home, End, + PageUp, + PageDown, /// Shift, Control, Option or Command pressed alone. Modifier, Other, @@ -39,7 +41,10 @@ pub enum NamedKey { pub struct Modifiers { pub shift: bool, pub control: bool, + /// Option, or Alt. pub option: bool, + /// The shortcut modifier: Command on macOS, Control elsewhere, where it comes with + /// `control`. pub command: bool, } @@ -67,7 +72,7 @@ pub enum SelectionUnit { Paragraph, } -/// What a key does to text under macOS conventions. +/// What a key does to text under the platform's conventions. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Command { /// Moves the focus, extending the selection with Shift. @@ -78,72 +83,285 @@ pub enum Command { DeleteTo(Movement), /// Control-K: deletes to the line's end, or the break after it when already there. Kill, + /// Scrolls the view a page, leaving the caret, as Page Up and Page Down do on macOS. + ScrollPage { up: bool }, + /// Moves the caret a page and the view with it, extending the selection with Shift. + MovePage { up: bool }, } impl Command { pub fn from_key(key: &Key, modifiers: Modifiers) -> Option { - let Modifiers { - shift, - control, - option, - command, - } = modifiers; - let pick = |plain, with_option, with_command| { - Some(Self::Move(if command { - with_command - } else if option { - with_option + Platform::CURRENT.command(key, modifiers) + } +} + +/// A desktop's text editing conventions: its chords, and how its caret looks and blinks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Platform { + MacOs, + /// GTK, as GNOME and most Linux desktops edit text. + Gtk, + Windows, +} + +/// A caret's blink, in microseconds: solid for `delay` after it moves, then repeating +/// `phases` of fading out, off, fading in and on, each fade in `steps` equal steps, until +/// `timeout` after the move leaves it solid. +struct Blink { + delay: u64, + phases: [u64; 4], + steps: u64, + timeout: Option, +} + +impl Platform { + pub const CURRENT: Platform = if cfg!(target_vendor = "apple") { + Platform::MacOs + } else if cfg!(windows) { + Platform::Windows + } else { + Platform::Gtk + }; + + pub fn command(self, key: &Key, modifiers: Modifiers) -> Option { + match self { + Platform::MacOs => mac_command(key, modifiers), + Platform::Gtk | Platform::Windows => pc_command(key, modifiers), + } + } + + /// The caret's width in logical pixels: AppKit's insertion indicator, GTK's stem at + /// its 0.04 aspect ratio for body text, and Windows' default caret. + pub const fn caret_width(self) -> f32 { + match self { + Platform::MacOs => 2.0, + Platform::Gtk | Platform::Windows => 1.0, + } + } + + fn blink(self) -> Blink { + match self { + // AppKit's insertion indicator: 350 ms off and 650 ms on, each opening with a + // fade in four 37.5 ms steps. + Platform::MacOs => Blink { + delay: 650_000, + phases: [150_000, 200_000, 150_000, 500_000], + steps: 4, + timeout: None, + }, + // GtkText at the default 1200 ms blink time: half a cycle's pause after a + // move, then quarters of on, fading out, off and fading in, stopping after the + // default 10 s; its per-frame fade is taken in 60 Hz steps. + Platform::Gtk => Blink { + delay: 900_000, + phases: [300_000; 4], + steps: 18, + timeout: Some(10_000_000), + }, + // GetCaretBlinkTime's default 530 ms, without fades, until CaretTimeout's 5 s. + Platform::Windows => Blink { + delay: 530_000, + phases: [0, 530_000, 0, 530_000], + steps: 1, + timeout: Some(5_000_000), + }, + } + } + + /// The caret's opacity `since` it last moved, and how long that opacity holds; None + /// once it has stopped blinking. + pub fn caret_blink(self, since: Duration) -> (f32, Option) { + let Blink { + delay, + phases, + steps, + timeout, + } = self.blink(); + let period: u64 = phases.iter().sum(); + // The opacity at `time` and when it next may change. + let at = |time: u64| -> (f32, u64) { + if time < delay { + return (1.0, delay); + } + let mut within = (time - delay) % period; + let mut start = time - within; + for (phase, length) in phases.into_iter().enumerate() { + if within < length { + return match phase { + 1 => (0.0, start + length), + 3 => (1.0, start + length), + _ => { + let step = within * steps / length; + let shown = step as f32 / steps as f32; + ( + if phase == 0 { 1.0 - shown } else { shown }, + start + ((step + 1) * length).div_ceil(steps), + ) + } + }; + } + within -= length; + start += length; + } + unreachable!("a time within the period falls in a phase") + }; + let now = u64::try_from(since.as_micros()).unwrap_or(u64::MAX); + if timeout.is_some_and(|timeout| now >= timeout) { + return (1.0, None); + } + let (opacity, mut until) = at(now); + // A settled phase holds into the next one's first step, which shows the same. + for _ in 0..2 * (steps + 2) { + let (next, end) = at(until); + if next != opacity { + break; + } + until = end; + } + match timeout { + Some(timeout) if until >= timeout && opacity == 1.0 => (opacity, None), + Some(timeout) if until >= timeout => { + (opacity, Some(Duration::from_micros(timeout - now))) + } + _ => (opacity, Some(Duration::from_micros(until - now))), + } + } + + /// The caret's colour and selected text's fill with and without keyboard focus as sRGB + /// and alpha, for a light or `dark` appearance: AppKit's system colours, libadwaita's + /// text colour and translucent accent, and Windows' Highlight made translucent, as + /// selected text keeps its colour here. + pub fn text_colors(self, dark: bool) -> [([u8; 3], f32); 3] { + match (self, dark) { + (Platform::MacOs, false) => [ + ([0x00, 0x7a, 0xff], 1.0), + ([0xb3, 0xd7, 0xff], 1.0), + ([0xdc, 0xdc, 0xdc], 1.0), + ], + (Platform::MacOs, true) => [ + ([0x00, 0x7a, 0xff], 1.0), + ([0x3f, 0x63, 0x8b], 1.0), + ([0x46, 0x46, 0x46], 1.0), + ], + (Platform::Gtk, false) => [ + ([0x00, 0x00, 0x06], 0.8), + ([0x35, 0x84, 0xe4], 0.3), + ([0x00, 0x00, 0x06], 0.08), + ], + (Platform::Gtk, true) => [ + ([0xff, 0xff, 0xff], 1.0), + ([0x35, 0x84, 0xe4], 0.3), + ([0xff, 0xff, 0xff], 0.1), + ], + (Platform::Windows, false) => [ + ([0x00, 0x00, 0x00], 1.0), + ([0x00, 0x78, 0xd7], 0.4), + ([0x00, 0x00, 0x00], 0.1), + ], + (Platform::Windows, true) => [ + ([0xff, 0xff, 0xff], 1.0), + ([0x00, 0x78, 0xd7], 0.5), + ([0xff, 0xff, 0xff], 0.15), + ], + } + } +} + +/// AppKit's key bindings, including its Emacs chords on Control. +fn mac_command(key: &Key, modifiers: Modifiers) -> Option { + let Modifiers { + shift, + control, + option, + command, + } = modifiers; + let pick = |plain, with_option, with_command| { + Some(Command::Move(if command { + with_command + } else if option { + with_option + } else { + plain + })) + }; + match key { + Key::Named(NamedKey::ArrowLeft) => { + pick(Movement::Left, Movement::WordLeft, Movement::LineStart) + } + Key::Named(NamedKey::ArrowRight) => { + pick(Movement::Right, Movement::WordRight, Movement::LineEnd) + } + Key::Named(NamedKey::ArrowUp) => pick( + Movement::Up, + Movement::ParagraphStart, + Movement::DocumentStart, + ), + Key::Named(NamedKey::ArrowDown) => pick( + Movement::Down, + Movement::ParagraphEnd, + Movement::DocumentEnd, + ), + Key::Named(NamedKey::Home) if shift => Some(Command::Move(Movement::DocumentStart)), + Key::Named(NamedKey::End) if shift => Some(Command::Move(Movement::DocumentEnd)), + Key::Named(NamedKey::PageUp) if option => Some(Command::MovePage { up: true }), + Key::Named(NamedKey::PageDown) if option => Some(Command::MovePage { up: false }), + Key::Named(NamedKey::PageUp) => Some(Command::ScrollPage { up: true }), + Key::Named(NamedKey::PageDown) => Some(Command::ScrollPage { up: false }), + Key::Named(NamedKey::Backspace) if command || option => { + Some(Command::DeleteTo(if command { + Movement::LineStart } else { - plain + Movement::WordLeft })) - }; - match key { - Key::Named(NamedKey::ArrowLeft) => { - pick(Movement::Left, Movement::WordLeft, Movement::LineStart) - } - Key::Named(NamedKey::ArrowRight) => { - pick(Movement::Right, Movement::WordRight, Movement::LineEnd) - } - Key::Named(NamedKey::ArrowUp) => pick( - Movement::Up, - Movement::ParagraphStart, - Movement::DocumentStart, - ), - Key::Named(NamedKey::ArrowDown) => pick( - Movement::Down, - Movement::ParagraphEnd, - Movement::DocumentEnd, - ), - Key::Named(NamedKey::Home) if shift => Some(Self::Move(Movement::DocumentStart)), - Key::Named(NamedKey::End) if shift => Some(Self::Move(Movement::DocumentEnd)), - Key::Named(NamedKey::Backspace) if command || option => { - Some(Self::DeleteTo(if command { - Movement::LineStart - } else { - Movement::WordLeft - })) - } - Key::Named(NamedKey::Delete) if command || option => Some(Self::DeleteTo(if command { - Movement::LineEnd - } else { - Movement::WordRight - })), - Key::Named(NamedKey::Backspace) => Some(Self::Delete { backward: true }), - Key::Named(NamedKey::Delete) => Some(Self::Delete { backward: false }), - Key::Character(key) if control && !option => match key.as_str() { - "a" => Some(Self::Move(Movement::LineStart)), - "e" => Some(Self::Move(Movement::LineEnd)), - "b" => Some(Self::Move(Movement::Left)), - "f" => Some(Self::Move(Movement::Right)), - "p" => Some(Self::Move(Movement::Up)), - "n" => Some(Self::Move(Movement::Down)), - "h" => Some(Self::Delete { backward: true }), - "d" => Some(Self::Delete { backward: false }), - "k" => Some(Self::Kill), - _ => None, - }, + } + Key::Named(NamedKey::Delete) if command || option => Some(Command::DeleteTo(if command { + Movement::LineEnd + } else { + Movement::WordRight + })), + Key::Named(NamedKey::Backspace) => Some(Command::Delete { backward: true }), + Key::Named(NamedKey::Delete) => Some(Command::Delete { backward: false }), + Key::Character(key) if control && !option => match key.as_str() { + "a" => Some(Command::Move(Movement::LineStart)), + "e" => Some(Command::Move(Movement::LineEnd)), + "b" => Some(Command::Move(Movement::Left)), + "f" => Some(Command::Move(Movement::Right)), + "p" => Some(Command::Move(Movement::Up)), + "n" => Some(Command::Move(Movement::Down)), + "h" => Some(Command::Delete { backward: true }), + "d" => Some(Command::Delete { backward: false }), + "k" => Some(Command::Kill), _ => None, - } + }, + _ => None, + } +} + +/// GTK's and Windows' shared bindings: Control moves and deletes by words, and with Home +/// and End reaches the document's ends; Control with Up and Down moves by paragraphs. +fn pc_command(key: &Key, modifiers: Modifiers) -> Option { + let Modifiers { + control, option, .. + } = modifiers; + if option { + return None; + } + let pick = + |plain, with_control| Some(Command::Move(if control { with_control } else { plain })); + match key { + Key::Named(NamedKey::ArrowLeft) => pick(Movement::Left, Movement::WordLeft), + Key::Named(NamedKey::ArrowRight) => pick(Movement::Right, Movement::WordRight), + Key::Named(NamedKey::ArrowUp) => pick(Movement::Up, Movement::ParagraphStart), + Key::Named(NamedKey::ArrowDown) => pick(Movement::Down, Movement::ParagraphEnd), + Key::Named(NamedKey::Home) => pick(Movement::LineStart, Movement::DocumentStart), + Key::Named(NamedKey::End) => pick(Movement::LineEnd, Movement::DocumentEnd), + Key::Named(NamedKey::PageUp) if !control => Some(Command::MovePage { up: true }), + Key::Named(NamedKey::PageDown) if !control => Some(Command::MovePage { up: false }), + Key::Named(NamedKey::Backspace) if control => Some(Command::DeleteTo(Movement::WordLeft)), + Key::Named(NamedKey::Delete) if control => Some(Command::DeleteTo(Movement::WordRight)), + Key::Named(NamedKey::Backspace) => Some(Command::Delete { backward: true }), + Key::Named(NamedKey::Delete) => Some(Command::Delete { backward: false }), + _ => None, } } @@ -183,35 +401,12 @@ impl Clicks { } } -/// The caret's width in logical pixels, as AppKit's insertion indicator draws it. -pub const CARET_WIDTH: f32 = 2.0; +/// The caret's width in logical pixels on this platform. +pub const CARET_WIDTH: f32 = Platform::CURRENT.caret_width(); -/// The caret's opacity `since` it last moved, and how long that opacity holds. As AppKit's -/// insertion indicator: solid for 650 ms, then 350 ms off and 650 ms on, each phase -/// opening with a fade in four 37.5 ms steps. -pub fn caret_blink(since: Duration) -> (f32, Duration) { - const STEP: u64 = 37_500; - const ON: u64 = 650_000; - const OFF: u64 = 350_000; - let micros = u64::try_from(since.as_micros()).unwrap_or(u64::MAX); - let Some(cycle) = micros.checked_sub(ON) else { - return (1.0, Duration::from_micros(ON + STEP - micros)); - }; - let (into, length, fading_in) = match cycle % (ON + OFF) { - phase if phase < OFF => (phase, OFF, false), - phase => (phase - OFF, ON, true), - }; - let step = into / STEP; - // A settled phase holds into the next one's first step, which shows the same. - let (faded, hold) = if step < 4 { - (step as f32 / 4.0, STEP * (step + 1) - into) - } else { - (1.0, length - into + STEP) - }; - ( - if fading_in { faded } else { 1.0 - faded }, - Duration::from_micros(hold), - ) +/// The caret's opacity `since` it last moved on this platform; see [`Platform::caret_blink`]. +pub fn caret_blink(since: Duration) -> (f32, Option) { + Platform::CURRENT.caret_blink(since) } /// `caret`, linear RGBA, with the alpha that shows it at `opacity` over `backdrop` as bright @@ -451,13 +646,109 @@ mod tests { ]; for (key, modifiers, command) in cases { assert_eq!( - Command::from_key(&key, modifiers), + Platform::MacOs.command(&key, modifiers), command, "{key:?} {modifiers:?}" ); } } + #[test] + fn keys_follow_gtk_and_windows_editing_chords() { + // Control arrives as the shortcut modifier too. + let control = Modifiers { + control: true, + command: true, + ..Modifiers::default() + }; + let plain = Modifiers::default(); + let alt = Modifiers { + option: true, + ..Modifiers::default() + }; + let named = |named| Key::Named(named); + let cases = [ + ( + named(NamedKey::ArrowLeft), + control, + Some(Command::Move(Movement::WordLeft)), + ), + ( + named(NamedKey::ArrowRight), + control, + Some(Command::Move(Movement::WordRight)), + ), + ( + named(NamedKey::ArrowUp), + control, + Some(Command::Move(Movement::ParagraphStart)), + ), + ( + named(NamedKey::ArrowDown), + plain, + Some(Command::Move(Movement::Down)), + ), + ( + named(NamedKey::Home), + plain, + Some(Command::Move(Movement::LineStart)), + ), + ( + named(NamedKey::End), + plain, + Some(Command::Move(Movement::LineEnd)), + ), + ( + named(NamedKey::Home), + control, + Some(Command::Move(Movement::DocumentStart)), + ), + ( + named(NamedKey::End), + control, + Some(Command::Move(Movement::DocumentEnd)), + ), + ( + named(NamedKey::PageUp), + plain, + Some(Command::MovePage { up: true }), + ), + ( + named(NamedKey::PageDown), + plain, + Some(Command::MovePage { up: false }), + ), + ( + named(NamedKey::Backspace), + control, + Some(Command::DeleteTo(Movement::WordLeft)), + ), + ( + named(NamedKey::Delete), + control, + Some(Command::DeleteTo(Movement::WordRight)), + ), + ( + named(NamedKey::Backspace), + plain, + Some(Command::Delete { backward: true }), + ), + (named(NamedKey::ArrowLeft), alt, None), + // No Emacs chords: Control-A and Control-E are shortcuts. + (Key::Character("a".into()), control, None), + (Key::Character("e".into()), control, None), + ]; + for platform in [Platform::Gtk, Platform::Windows] { + for (key, modifiers, command) in &cases { + assert_eq!( + platform.command(key, *modifiers), + *command, + "{platform:?} {key:?} {modifiers:?}" + ); + } + } + } + #[test] fn presses_count_within_the_interval_and_slop_and_cycle() { let mut clicks = Clicks::new(Duration::from_millis(500)); @@ -532,30 +823,99 @@ mod tests { assert!((green - linear(167.5)).abs() < 0.02, "{alpha}"); } + /// The opacity and hold in milliseconds `millis` after the caret moved. + fn blink_at(platform: Platform, millis: f64) -> (f32, Option) { + let (opacity, hold) = platform.caret_blink(Duration::from_secs_f64(millis / 1000.0)); + (opacity, hold.map(|hold| hold.as_secs_f64() * 1000.0)) + } + + fn assert_blink(platform: Platform, millis: f64, expected: (f32, Option)) { + let (opacity, hold) = blink_at(platform, millis); + assert_eq!(opacity, expected.0, "{platform:?} at {millis} ms"); + match (hold, expected.1) { + (Some(hold), Some(expected)) => { + assert!( + (hold - expected).abs() < 0.01, + "{platform:?} at {millis} ms holds {hold} ms, not {expected}" + ) + } + (hold, expected) => assert_eq!(hold, expected, "{platform:?} at {millis} ms"), + } + } + + /// How often the opacity changes over `span` milliseconds from `from`. + fn blink_changes(platform: Platform, from: u32, span: u32) -> usize { + let mut previous = blink_at(platform, f64::from(from)).0; + (from + 1..=from + span) + .filter(|&millis| { + let opacity = blink_at(platform, f64::from(millis)).0; + std::mem::replace(&mut previous, opacity) != opacity + }) + .count() + } + #[test] fn caret_blinks_on_appkit_s_timing() { - let at = |millis: f64| { - let (opacity, hold) = caret_blink(Duration::from_secs_f64(millis / 1000.0)); - (opacity, hold.as_secs_f64() * 1000.0) - }; - let close = |(opacity, hold): (f32, f64), expected: (f32, f64)| { - assert_eq!(opacity, expected.0); - assert!((hold - expected.1).abs() < 0.01, "{hold} ms, not {}", expected.1); - }; - close(at(0.0), (1.0, 687.5)); - close(at(687.5), (0.75, 37.5)); - close(at(760.0), (0.5, 2.5)); - close(at(800.0), (0.0, 237.5)); - close(at(1037.5), (0.25, 37.5)); - close(at(1150.0), (1.0, 537.5)); - close(at(1687.5), (0.75, 37.5)); - let mut changes = 0; - let mut previous = at(1000.0).0; - for millis in 1001..2001 { - let opacity = at(f64::from(millis)).0; - changes += usize::from(opacity != previous); - previous = opacity; + let mac = Platform::MacOs; + assert_blink(mac, 0.0, (1.0, Some(687.5))); + assert_blink(mac, 687.5, (0.75, Some(37.5))); + assert_blink(mac, 760.0, (0.5, Some(2.5))); + assert_blink(mac, 800.0, (0.0, Some(237.5))); + assert_blink(mac, 1037.5, (0.25, Some(37.5))); + assert_blink(mac, 1150.0, (1.0, Some(537.5))); + assert_blink(mac, 1687.5, (0.75, Some(37.5))); + assert_blink(mac, 3_600_200.0, (1.0, Some(487.5))); + assert_eq!( + blink_changes(mac, 1000, 1000), + 8, + "four fade steps each way per second" + ); + } + + #[test] + fn caret_blinks_on_gtk_s_timing() { + let gtk = Platform::Gtk; + // Solid for half the 1200 ms cycle and its visible quarter, then fades out over 300 ms. + assert_blink(gtk, 0.0, (1.0, Some(900.0 + 1000.0 / 60.0))); + assert_blink(gtk, 1200.0, (0.0, Some(300.0 + 1000.0 / 60.0))); + assert_blink(gtk, 1800.0, (1.0, Some(300.0 + 1000.0 / 60.0))); + let (fading, _) = blink_at(gtk, 1050.0); + assert!((fading - 0.5).abs() <= 1.0 / 18.0, "{fading}"); + // A fade each way per 1.2 s cycle, in 60 Hz steps. + assert_eq!(blink_changes(gtk, 900, 1200), 2 * 18); + // Ten seconds without moving leave it solid, cutting short the fade in. + assert_blink(gtk, 9_700.0, (0.0, Some(216.667))); + assert_blink(gtk, 9_990.0, (5.0 / 18.0, Some(10.0))); + assert_blink(gtk, 10_000.0, (1.0, None)); + } + + #[test] + fn caret_blinks_on_windows_timing() { + let windows = Platform::Windows; + assert_blink(windows, 0.0, (1.0, Some(530.0))); + assert_blink(windows, 530.0, (0.0, Some(530.0))); + assert_blink(windows, 1100.0, (1.0, Some(490.0))); + assert_eq!(blink_changes(windows, 530, 1060), 2, "no fades"); + assert_blink(windows, 4800.0, (0.0, Some(200.0))); + assert_blink(windows, 5000.0, (1.0, None)); + } + + #[test] + fn carets_and_selections_match_each_platform() { + assert_eq!( + [Platform::MacOs, Platform::Gtk, Platform::Windows].map(Platform::caret_width), + [2.0, 1.0, 1.0] + ); + for platform in [Platform::MacOs, Platform::Gtk, Platform::Windows] { + for dark in [false, true] { + let [caret, focused, unfocused] = platform.text_colors(dark); + assert!(caret.1 >= 0.8, "{platform:?} carets stand out"); + assert!(focused.1 > unfocused.1 || focused.0 != unfocused.0); + } } - assert_eq!(changes, 8, "four fade steps each way per second"); + // libadwaita tints selections with the accent at 30% and follows the text colour. + let [caret, selection, _] = Platform::Gtk.text_colors(false); + assert_eq!(selection, ([0x35, 0x84, 0xe4], 0.3)); + assert_eq!(caret, ([0x00, 0x00, 0x06], 0.8)); } } diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index 35af48927462ee1dae4f4355bb1ee37bf6cf58f1..b620eecb870eb92a38016c94b72464c4748b89ed 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -211,7 +211,7 @@ pub struct Layer<'a> { /// Device bounds `[left, top, right, bottom]` the layer paints within. pub clip: Option<[f32; 4]>, /// The colour behind the layer: on a dark one, text in dark colours of its own is - /// lifted to stay legible, as OneNote's dark page does. + /// lifted to stay legible against what lies behind it, as OneNote's dark page does. pub backdrop: Option<[f32; 4]>, pub primitives: &'a [Primitive<'a>], } @@ -985,7 +985,8 @@ impl Renderer { if glyph.color { [1.0; 4] } else { - run.color.map_or(ink, |color| legible(color, space.backdrop)) + run.color + .map_or(ink, |color| legible(color, space.backdrop, run.backdrop)) }, )?; } @@ -1004,7 +1005,7 @@ impl Renderer { [0.5 / ATLAS_SIZE as f32; 4], decoration .color - .map_or(ink, |color| legible(color, space.backdrop)), + .map_or(ink, |color| legible(color, space.backdrop, run.backdrop)), )?; } Ok(()) @@ -1467,13 +1468,16 @@ fn srgb_byte(value: f32) -> u8 { (srgb * 255.0).round() as u8 } -/// Linear RGBA of an opaque sRGB colour. /// Least OKLab lightness difference between text and the backdrop it stays legible on. const LEGIBLE: f32 = 0.4; -/// `color` lifted clear of a dark `backdrop`'s lightness, keeping hue and chroma. -fn legible(color: [f32; 4], backdrop: Option<[f32; 4]>) -> [f32; 4] { - let [under, ..] = backdrop.map_or([1.0; 3], oklab); +/// On a dark layer `backdrop`, `color` lifted clear of the lightness of what lies `behind` +/// it, or of the backdrop, keeping hue and chroma. +fn legible(color: [f32; 4], backdrop: Option<[f32; 4]>, behind: Option<[f32; 4]>) -> [f32; 4] { + let Some(backdrop) = backdrop.filter(|backdrop| oklab(*backdrop)[0] < 0.5) else { + return color; + }; + let [under, ..] = oklab(behind.unwrap_or(backdrop)); let [lightness, a, b] = oklab(color); if under >= 0.5 || lightness >= under + LEGIBLE { return color; @@ -1513,6 +1517,7 @@ pub fn from_oklab([lightness, a, b]: [f32; 3]) -> [f32; 3] { .map(|[x, y, z]| (x * l + y * m + z * s).clamp(0.0, 1.0)) } +/// Linear RGBA of an opaque sRGB colour. pub fn srgb(red: u8, green: u8, blue: u8) -> [f32; 4] { let linear = |byte: u8| { let value = f32::from(byte) / 255.0; @@ -1555,6 +1560,7 @@ mod tests { 0.0, [metrics.block_min_coord, metrics.line_height], |_| None, + None, paint, )?; } @@ -1582,6 +1588,28 @@ mod tests { Text(layout) } + #[test] + fn text_is_lifted_against_what_lies_behind_it_on_a_dark_layer() { + let [black, dark_red] = [[0.0, 0.0, 0.0, 1.0], [0.2, 0.0, 0.0, 1.0]]; + let [dark, yellow, navy] = [srgb(0x1f, 0x20, 0x22), srgb(255, 255, 0), srgb(0, 0, 128)]; + let lightness = |color| oklab(color)[0]; + assert_eq!(legible(black, None, Some(navy)), black, "an unset layer"); + assert_eq!( + legible(black, Some([1.0; 4]), Some(navy)), + black, + "a light layer keeps its colours, as OneNote's light page does" + ); + assert!(lightness(legible(black, Some(dark), None)) >= lightness(dark) + LEGIBLE - 1e-3); + assert_eq!( + legible(black, Some(dark), Some(yellow)), + black, + "a light highlight on a dark layer" + ); + assert_eq!(legible(dark_red, Some(dark), Some(yellow)), dark_red); + let lifted = legible(black, Some(dark), Some(navy)); + assert!(lightness(lifted) >= lightness(navy) + LEGIBLE - 1e-3); + } + #[test] fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() { let mut encoded = Vec::new(); diff --git a/crates/draw/src/render/text.rs b/crates/draw/src/render/text.rs index 1fd8b0165c34c21122e1a0f99324decd8393c406..04841e9e772c8ad3b960e41a8e9c5c9980785b78 100644 --- a/crates/draw/src/render/text.rs +++ b/crates/draw/src/render/text.rs @@ -23,6 +23,9 @@ pub struct GlyphRun<'a> { pub skew: Option, /// `None` paints in the primitive's ink. pub color: Option<[f32; 4]>, + /// The colour painted behind the glyphs, such as a highlight; `None` for the layer's + /// backdrop. + pub backdrop: Option<[f32; 4]>, /// Top and height of the run's line: lines outside the target are skipped, and /// colour glyphs shrink to fit. pub line: [f32; 2], @@ -50,14 +53,15 @@ pub struct Decoration { pub color: Option<[f32; 4]>, } -/// Paints one parley glyph run with its line's baseline moved to `baseline` and its -/// glyphs raised by `rise`. +/// Paints one parley glyph run with its line's baseline moved to `baseline`, its glyphs +/// raised by `rise` and `backdrop` behind them. pub fn paint_parley_run( run: &parley::GlyphRun<'_, B>, baseline: f32, rise: f32, line: [f32; 2], color: impl Fn(&B) -> Option<[f32; 4]>, + backdrop: Option<[f32; 4]>, paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, ) -> Result<(), RenderError> { let shaped = run.run(); @@ -108,6 +112,7 @@ pub fn paint_parley_run( embolden: synthesis.embolden(), skew: synthesis.skew(), color: color(&style.brush), + backdrop, line, glyphs: &mut glyphs, decorations: &decorations, diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index a261641596c11a473a78372c6f0105e882d10027..d68498d9e347c88f618ee5973bb43e6c4dc24f24 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -11,6 +11,8 @@ ui = { path = "../ui" } png = "0.18" onestore = { path = "../onestore" } notebook = { path = "../notebook" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" pollster = "0.4" wgpu.workspace = true parley.workspace = true @@ -18,7 +20,16 @@ winit = "0.30.13" arboard = { version = "3.6.1", default-features = false } accesskit = "0.25.0" accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } + +[target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.5.2" -objc2-app-kit = { version = "0.2.2", default-features = false, features = ["NSAlert", "NSButton", "NSControl", "NSView", "NSResponder", "NSWindow", "NSApplication", "NSEvent", "NSColor", "NSColorSpace", "NSDatePicker", "NSDatePickerCell"] } -objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSTimeZone"] } +objc2-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"] } +objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSPathUtilities", "NSTimeZone", "NSURL", "NSArray"] } +[target.'cfg(target_os = "linux")'.dependencies] +accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] } +smithay-clipboard = "0.7.3" +# Loads fontconfig at run time, so builds need no fontconfig headers or library. +fontique = { workspace = true, features = ["fontconfig-dlopen"] } +libc = "0.2" +zbus = { version = "5.19", default-features = false, features = ["async-io", "blocking-api"] } diff --git a/crates/snowbound/assets/icons/chevron-up.svg b/crates/snowbound/assets/icons/chevron-up.svg new file mode 100644 index 0000000000000000000000000000000000000000..5d78f2d483a6a3bfc3bc3a02dfc3467df484fac9 --- /dev/null +++ b/crates/snowbound/assets/icons/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/conflict.svg b/crates/snowbound/assets/icons/conflict.svg new file mode 100644 index 0000000000000000000000000000000000000000..56afd82583ad393a5362cea34aea20108236e8ab --- /dev/null +++ b/crates/snowbound/assets/icons/conflict.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/new-page.svg b/crates/snowbound/assets/icons/new-page.svg deleted file mode 100644 index 3a0005f4b0b92c39542b23c3bace34f58ad851d5..0000000000000000000000000000000000000000 --- a/crates/snowbound/assets/icons/new-page.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/crates/snowbound/assets/icons/notebook.svg b/crates/snowbound/assets/icons/notebook.svg new file mode 100644 index 0000000000000000000000000000000000000000..4506a4ff25c873acc0c70162eb6b5dc347fa5b8e --- /dev/null +++ b/crates/snowbound/assets/icons/notebook.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/plus.svg b/crates/snowbound/assets/icons/plus.svg new file mode 100644 index 0000000000000000000000000000000000000000..cdf878f05e7833b337ca5b40f5efb28475561c7e --- /dev/null +++ b/crates/snowbound/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/section-group.svg b/crates/snowbound/assets/icons/section-group.svg new file mode 100644 index 0000000000000000000000000000000000000000..6171b29bf0c4802e8a69e118107e3e14337f5d3f --- /dev/null +++ b/crates/snowbound/assets/icons/section-group.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/window-maximize.svg b/crates/snowbound/assets/icons/window-maximize.svg new file mode 100644 index 0000000000000000000000000000000000000000..cfd4dfc0238132c0f697ad7974e2820a44894a09 --- /dev/null +++ b/crates/snowbound/assets/icons/window-maximize.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/window-minimize.svg b/crates/snowbound/assets/icons/window-minimize.svg new file mode 100644 index 0000000000000000000000000000000000000000..59f33dcde71295ba68dc49d981495ed34f7ce22e --- /dev/null +++ b/crates/snowbound/assets/icons/window-minimize.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/window-restore.svg b/crates/snowbound/assets/icons/window-restore.svg new file mode 100644 index 0000000000000000000000000000000000000000..6ff60daa47504ffd234788ed5bdc9d7ed9487f70 --- /dev/null +++ b/crates/snowbound/assets/icons/window-restore.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/linux/README.md b/crates/snowbound/linux/README.md new file mode 100644 index 0000000000000000000000000000000000000000..56364374ad23c04d78bf1dedafc09332b85f54a0 --- /dev/null +++ b/crates/snowbound/linux/README.md @@ -0,0 +1,46 @@ +# Snowbound for Linux + +An early, lightly tested build of Snowbound, a note-taking app that reads and +writes OneNote 2010 notebooks. Expect rough edges; please report what breaks. + +## Run + +```sh +tar xzf snowbound-linux-x86_64.tar.gz +cd snowbound-linux-x86_64 +./bin/snowbound +``` + +Choose **Open Notebook…** in the notebook list and pick the notebook folder, +the one holding the `.one` section files and `Open Notebook.onetoc2`. +Snowbound remembers the notebooks you open. `--notebook FOLDER` opens one +straight away. + +Edits save to the notebook folder as you type. Settings live in +`~/.config/snowbound` and sync state in `~/.cache/snowbound`; `--cache DIR` +puts the latter elsewhere. + +Run `./install.sh` to add Snowbound to your app launcher; it copies the app +into `~/.local`. On Wayland the taskbar finds Snowbound's icon only through +this launcher entry, so without it the taskbar shows a generic icon. +`./install.sh ~/Notebooks/MyNotebook` makes the launcher open that notebook as +well. + +## Requirements + +- x86_64 or aarch64 Linux with glibc 2.31 or newer (Debian 11, Ubuntu 20.04, + Fedora 32 and later). +- A Vulkan driver (Mesa's are standard) or, failing that, OpenGL ES 3 through + EGL. `WGPU_BACKEND=gl ./bin/snowbound ...` forces OpenGL. +- fontconfig, and X11 or Wayland with libxkbcommon. +- zenity or kdialog for the folder picker, the page date and time dialogs and + alerts. + +## Known limits + +- No New Notebook or menus yet. +- The dark or light look follows the desktop's setting when the app starts. +- The character palette (Control-Command-Space on macOS) has no Linux + counterpart. +- The window stops drawing while the folder picker or a date dialog is open. +- Input methods work through winit and are barely tested. diff --git a/crates/snowbound/linux/install.sh b/crates/snowbound/linux/install.sh new file mode 100755 index 0000000000000000000000000000000000000000..e58ec69b009e6b7f176129a60f014ca12af456d5 --- /dev/null +++ b/crates/snowbound/linux/install.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Installs Snowbound into ~/.local with a launcher entry; with a notebook folder, the +# launcher opens it. +set -eu +here=$(cd "$(dirname "$0")" && pwd) +data=${XDG_DATA_HOME:-$HOME/.local/share} +bin=$HOME/.local/bin +mkdir -p "$bin" "$data/applications" +cp "$here/bin/snowbound" "$bin/snowbound" +cp -R "$here/share/icons" "$data/" +exec_line="$bin/snowbound" +if [ $# -gt 0 ]; then + notebook=$(cd "$1" && pwd) + # Desktop entries quote arguments in double quotes, escape \ " ` $ and double %. + quoted=$(printf '%s' "$notebook" | sed -e 's/[\\"`$]/\\\\&/g' -e 's/%/%%/g') + exec_line="$exec_line --notebook \"$quoted\"" +fi +sed "s|^Exec=.*|Exec=$exec_line|" "$here/share/applications/snowbound.desktop" \ + > "$data/applications/snowbound.desktop" +command -v update-desktop-database >/dev/null && update-desktop-database "$data/applications" || true +command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q "$data/icons/hicolor" 2>/dev/null || true +echo "Installed $bin/snowbound and the Snowbound launcher." diff --git a/crates/snowbound/linux/package.sh b/crates/snowbound/linux/package.sh new file mode 100755 index 0000000000000000000000000000000000000000..5986bf4c4116afee045c6de11a26bca85405ba27 --- /dev/null +++ b/crates/snowbound/linux/package.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Builds target/dist/snowbound-linux-ARCH.tar.gz for each ARCH given (x86_64, aarch64; +# both by default), cross-linking with zig so it runs from macOS or Linux. +set -eu +root=$(cd "$(dirname "$0")/../../.." && pwd) +here="$root/crates/snowbound/linux" +icon="$root/crates/snowbound/assets/icon/Snowbound-SnowLeopard.png" +target="$root/target" +tools="$target/zig" +mkdir -p "$tools" "$target/dist" +command -v zig >/dev/null || { echo "zig is required as the cross linker" >&2; exit 1; } + +printf '#!/bin/sh\nexec zig ar "$@"\n' > "$tools/ar" +chmod +x "$tools/ar" + +[ $# -gt 0 ] || set -- x86_64 aarch64 +for arch do + triple=$arch-unknown-linux-gnu + variable=$(echo "$triple" | tr 'a-z-' 'A-Z_') + linker="$tools/cc-$arch" + # glibc 2.31 reaches back to Debian 11 and Ubuntu 20.04; zig rejects rustc's + # --target, --no-undefined-version and -O1. + cat > "$linker" </dev/null + env "CARGO_TARGET_${variable}_LINKER=$linker" \ + "CC_$(echo "$triple" | tr - _)=$linker" \ + "AR_$(echo "$triple" | tr - _)=$tools/ar" \ + CARGO_PROFILE_RELEASE_STRIP=symbols \ + cargo build --manifest-path "$root/Cargo.toml" --release --target "$triple" -p snowbound + + name=snowbound-linux-$arch + stage="$target/dist/$name" + rm -rf "$stage" + mkdir -p "$stage/bin" "$stage/share/applications" + cp "$target/$triple/release/snowbound" "$stage/bin/" + cp "$here/snowbound.desktop" "$stage/share/applications/" + cp "$here/README.md" "$here/install.sh" "$stage/" + for side in 16 24 32 48 64 128 256 512; do + folder="$stage/share/icons/hicolor/${side}x$side/apps" + mkdir -p "$folder" + if command -v sips >/dev/null; then + sips -s format png -z "$side" "$side" "$icon" --out "$folder/snowbound.png" >/dev/null + else + magick "$icon" -resize "${side}x$side" -depth 8 "$folder/snowbound.png" + fi + done + COPYFILE_DISABLE=1 tar --no-xattrs -C "$target/dist" -czf "$target/dist/$name.tar.gz" "$name" + echo "$target/dist/$name.tar.gz" +done diff --git a/crates/snowbound/linux/snowbound.desktop b/crates/snowbound/linux/snowbound.desktop new file mode 100644 index 0000000000000000000000000000000000000000..d5477267e398bb6e11211a5318a56f022f85215d --- /dev/null +++ b/crates/snowbound/linux/snowbound.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Snowbound +GenericName=Note Taking +Comment=Type and draw notes in OneNote notebooks +Exec=snowbound +Icon=snowbound +Terminal=false +Categories=Office; +StartupWMClass=snowbound diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs index 751f1ccac8658b8b33834ac22137e461d131c91a..7a467f129394d5dbad86fb2ea9150eed1b6a13e8 100644 --- a/crates/snowbound/src/art.rs +++ b/crates/snowbound/src/art.rs @@ -14,20 +14,24 @@ pub const BOLD: &[&str] = art!("icons/bold"); pub const BULLETS: &[&str] = art!("icons/bullets"); pub const CALENDAR: &[&str] = art!("icons/calendar"); pub const CLEAR_FORMATTING: &[&str] = art!("icons/clear-formatting"); +pub const CHEVRON_UP: &[&str] = art!("icons/chevron-up"); pub const CLOCK: &[&str] = art!("icons/clock"); pub const CLOSE: &[&str] = art!("icons/close"); +pub const CONFLICT: &[&str] = art!("icons/conflict"); pub const EQUATION: &[&str] = art!("icons/equation"); pub const FONT_COLOR: &[&str] = art!("icons/font-color"); pub const HIGHLIGHTER: &[&str] = art!("icons/highlighter"); pub const INDENT: &[&str] = art!("icons/indent"); pub const ITALIC: &[&str] = art!("icons/italic"); pub const LINK: &[&str] = art!("icons/link"); -pub const NEW_PAGE: &[&str] = art!("icons/new-page"); +pub const NOTEBOOK: &[&str] = art!("icons/notebook"); pub const NUMBERING: &[&str] = art!("icons/numbering"); pub const OUTDENT: &[&str] = art!("icons/outdent"); pub const PICTURE: &[&str] = art!("icons/picture"); +pub const PLUS: &[&str] = art!("icons/plus"); pub const REDO: &[&str] = art!("icons/redo"); pub const SEARCH: &[&str] = art!("icons/search"); +pub const SECTION_GROUP: &[&str] = art!("icons/section-group"); pub const SIDEBAR_COLLAPSE: &[&str] = art!("icons/sidebar-collapse"); pub const SIDEBAR_EXPAND: &[&str] = art!("icons/sidebar-expand"); pub const STRIKETHROUGH: &[&str] = art!("icons/strikethrough"); diff --git a/crates/snowbound/src/conflict_render.rs b/crates/snowbound/src/conflict_render.rs new file mode 100644 index 0000000000000000000000000000000000000000..951d6d1804b1214c1f105a58308cba9df82a2da0 --- /dev/null +++ b/crates/snowbound/src/conflict_render.rs @@ -0,0 +1,264 @@ +//! The conflict page views drawn offscreen, from the conflict-page corpus row's candidate: +//! the page with its information bar, its versions listed beneath it, and a version open. +//! `SNOWBOUND_CONFLICT_RENDER` names a directory for the PNGs. + +use super::*; + +const SIZE: [f32; 2] = [980.0, 520.0]; +const SCALE: f32 = 2.0; + +/// One view laid out: the bar and the page on the left, the page list on the right; `menu` +/// opens the conflict page's menu. +fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec { + let theme = ui.theme.clone(); + let section = theme.section(section_color(None)); + ui.begin(SIZE, SCALE, Instant::now()); + if menu { + ui.open_popup(Id::ROOT.child("conflict-menu")); + } + ui.open( + "frame", + Spec { + size: [fill(), fill()], + fill: Some(section.frame[0]), + gradient: Some(section.frame[1]), + pad: [FRAME, FRAME], + ..Spec::default() + }, + ); + ui.open( + "column", + Spec { + axis: Axis::Y, + size: [fill(), fill()], + ..Spec::default() + }, + ); + let mut commands: Vec = session + .bar() + .and_then(|bar| conflict_bar(ui, bar, &["synthetic", "Songs"], [false, true])) + .into_iter() + .collect(); + ui.open_as( + page(), + Spec { + flags: Flags::CUSTOM, + size: [fill(), fill()], + fill: Some(theme.paper), + ..Spec::default() + }, + ); + ui.close(); + ui.close(); + ui.open( + "panel", + Spec { + axis: Axis::Y, + size: [px(PAGE_LIST), fill()], + ..Spec::default() + }, + ); + let rows = page_rows(ui, &theme, §ion, session, ""); + commands.extend(rows.clicked.map(Command::OpenPage)); + ui.close(); + ui.close(); + ui.end(); + commands +} + +/// The view's pixels, RGBA: the interface with `page` drawn in its box. +fn paint(ui: &Ui, page: Page, renderer: &mut draw::Renderer, device: &wgpu::Device, queue: &wgpu::Queue) -> Vec { + let size = SIZE.map(|side| (side * SCALE) as u32); + let corner = ui.rect(super::page()).unwrap(); + let box_size = [ + ((corner[2] - corner[0]) * SCALE) as u32, + ((corner[3] - corner[1]) * SCALE) as u32, + ]; + let mut engine = TextEngine::default(); + let (scene, editor) = PageScene::from_page(page, &mut engine).unwrap(); + let mut view = PageView::new( + editor, + engine, + Some((scene, [0.0; 2])), + box_size, + SCALE, + std::time::Duration::from_millis(500), + ); + let paper = canvas::gpu::Paper { + color: ui.theme.paper, + ink: ui.theme.paper_ink, + }; + let waker = std::task::Waker::noop(); + let deadline = Instant::now() + std::time::Duration::from_secs(10); + let mut scene = view.scene.take().unwrap(); + while !view.prepare(&mut scene, &view.editor, paper, waker) && Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + view.scene = Some(scene); + view.update_pictures(paper, waker); + let primitives = view + .primitives(TextColors { + caret: [0.0; 4], + selection: ui.theme.inactive_selection, + paper, + }) + .unwrap(); + let viewport = view.viewport; + let interface = ui.layers(); + let layers: Vec<_> = interface + .iter() + .map(|layer| match layer { + ui::Layer::Primitives { clip, primitives } => draw::Layer { + scale: SCALE, + origin: [0.0; 2], + clip: clip.map(|clip| clip.map(|value| value * SCALE)), + backdrop: None, + primitives, + }, + ui::Layer::Custom { rect, .. } => draw::Layer { + scale: viewport.scale, + origin: [ + viewport.origin[0] + corner[0] * SCALE, + viewport.origin[1] + corner[1] * SCALE, + ], + clip: Some(rect.map(|value| value * SCALE)), + backdrop: Some(ui.theme.paper), + primitives: &primitives, + }, + }) + .collect(); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Conflict view"), + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + renderer + .draw(&texture.create_view(&Default::default()), size, ui.theme.base, &layers) + .unwrap(); + let row = (size[0] * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Conflict view readback"), + size: u64::from(row) * u64::from(size[1]), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row), + rows_per_image: Some(size[1]), + }, + }, + texture.size(), + ); + queue.submit([encoder.finish()]); + buffer.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); + device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(std::time::Duration::from_secs(5)), + }) + .unwrap(); + let mapped = buffer.get_mapped_range(..).unwrap(); + mapped + .chunks(row as usize) + .flat_map(|line| &line[..size[0] as usize * 4]) + .copied() + .collect() +} + +/// The page shown, a page's versions listed while shown, and the bar's commands: showing +/// opens the newest version, hiding returns to the page. +#[test] +fn conflict_views_offer_the_versions_and_render_offscreen() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let directory = std::env::temp_dir().join(format!("snowbound-conflict-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + std::fs::create_dir_all(directory.join("cache")).unwrap(); + let file = directory.join("synthetic.one"); + std::fs::copy(root.join("corpus/conflict-page/candidate/synthetic.one"), &file).unwrap(); + let library = Arc::new(crate::Library::section(&file, &directory.join("cache"))); + let section = library.open(&library.location, || {}).unwrap(); + let path = library.location.clone(); + let (mut session, _) = read_session(section, library, path, None).unwrap(); + let page = session.space; + let version = session.versions(page)[0].space; + let output = std::env::var_os("SNOWBOUND_CONFLICT_RENDER").map(PathBuf::from); + let gpu = output.as_ref().map(|output| { + std::fs::create_dir_all(output).unwrap(); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let renderer = + draw::Renderer::new(device.clone(), queue.clone(), wgpu::TextureFormat::Rgba8UnormSrgb); + (renderer, device, queue) + }); + let mut gpu = gpu; + for (name, space, shown, menu, appearance) in [ + ("page", page, None, false, Theme::light()), + ("versions", page, Some(page), false, Theme::light()), + ("version", version, Some(page), false, Theme::light()), + ("version-menu", version, Some(page), true, Theme::light()), + ("page-dark", page, None, false, Theme::dark()), + ("version-dark", version, Some(page), false, Theme::dark()), + ("version-menu-dark", version, Some(page), true, Theme::dark()), + ] { + session.space = space; + session.shown = shown; + let mut ui = Ui::new(appearance, std::time::Duration::from_millis(500)); + // Later frames lay out with earlier frames' measurements, and a menu fades in. + for _ in 0..8 { + layout(&mut ui, &session, menu); + std::thread::sleep(std::time::Duration::from_millis(30)); + } + let bar = session.bar(); + match (name, bar) { + ("page" | "page-dark", Some(Bar::Page { shown: false, .. })) + | ("versions", Some(Bar::Page { shown: true, .. })) + | ("version" | "version-menu" | "version-dark" | "version-menu-dark", Some(Bar::Version { .. })) => {} + other => panic!("{other:?}", other = other.0), + } + let row = Id::ROOT.child("frame").child("panel").child(version); + let listed = ui.rect(row).is_some(); + assert_eq!(listed, shown.is_some(), "{name}"); + if let (Some(output), Some((renderer, device, queue))) = (&output, &mut gpu) { + let shown = session.reader(space)().unwrap(); + let pixels = paint(&ui, shown, renderer, device, queue); + let size = SIZE.map(|side| (side * SCALE) as u32); + let path = output.join(format!("conflict-{name}.png")); + let mut encoder = + png::Encoder::new(std::fs::File::create(&path).unwrap(), size[0], size[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header().unwrap().write_image_data(&pixels).unwrap(); + } + } + // A version reads with its conflicting paragraphs banded as OneNote shows them. + let kept = session.reader(version)().unwrap(); + let marked = session.versions(page)[0].objects.clone(); + assert!(!marked.is_empty()); + let highlighted = kept.objects.iter().any(|object| match object { + onestore::page::PageObject::Outline(outline) => outline.paragraphs.iter().any(|paragraph| { + paragraph.text().is_some_and(|text| marked.contains(&text.id)) + && paragraph.format.highlight == Some(CONFLICTING) + }), + _ => false, + }); + assert!(highlighted); + session.section.close().unwrap(); + std::fs::remove_dir_all(&directory).unwrap(); +} diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs new file mode 100644 index 0000000000000000000000000000000000000000..d840d55360a3828c40eb1418337366d616f38638 --- /dev/null +++ b/crates/snowbound/src/library.rs @@ -0,0 +1,179 @@ +//! Open notebooks: where each lives and the sections its tabs offer. + +use notebook::discover::{Folder, SectionState}; +use notebook::session::{Notebook, Section}; +use std::{ + error::Error, + path::{Path, PathBuf}, +}; + +/// A section tab: where the section opens from and how it is labelled. +pub struct Tab { + /// The notebook catalog path, or the file of a section opened on its own. + pub path: String, + pub name: String, + /// COLORREF. + pub color: Option, +} + +/// An open notebook as the sidebar lists it, or a section opened on its own. +pub struct Library { + /// Where it lives, as the settings keep it: the notebook's folder, or the section file. + pub location: String, + /// The notebook's folder name, or the section file's. + pub name: String, + /// `Err` holds why a notebook listed as open could not be read this time. + pub notebook: Result, String>, + cache: PathBuf, +} + +impl Library { + /// The notebook in the folder at `location`, or why it cannot be read. + pub fn notebook(location: &str, cache: &Path) -> Self { + Self { + location: location.to_owned(), + name: file_name(Path::new(location)), + notebook: Notebook::open(location, cache) + .map(Some) + .map_err(|error| error.to_string()), + cache: cache.to_owned(), + } + } + + /// `notebook`, open at `location`. + pub fn open_notebook(location: &str, notebook: Notebook, cache: &Path) -> Self { + Self { + location: location.to_owned(), + name: file_name(Path::new(location)), + notebook: Ok(Some(notebook)), + cache: cache.to_owned(), + } + } + + /// The section file at `file` as a notebook of one tab. + pub fn section(file: &Path, cache: &Path) -> Self { + Self { + location: file.to_string_lossy().into_owned(), + name: file_name(file.parent().unwrap_or(file)), + notebook: Ok(None), + cache: cache.to_owned(), + } + } + + /// Opens the section at catalog `path`. + pub fn open(&self, path: &str, notify: impl Fn() + Send + 'static) -> Result> { + Ok(match &self.notebook { + Ok(Some(notebook)) => notebook.section(path, notify)?, + Ok(None) => Section::open(path, &self.cache, notify)?, + Err(error) => return Err(error.clone().into()), + }) + } + + /// Names the section at catalog `path` across every open notebook, for remembering + /// its pages. + pub fn key(&self, path: &str) -> String { + format!("{}\n{path}", self.location) + } + + /// The readable sections of the folder at catalog path `folder`, as tabs in its order. + pub fn tabs(&self, folder: &str) -> Vec { + match &self.notebook { + Ok(Some(notebook)) => { + let mut folders = vec![notebook.catalog()]; + while let Some(candidate) = folders.pop() { + if candidate.path == folder { + return tabs(candidate); + } + folders.extend(&candidate.groups); + } + Vec::new() + } + Ok(None) => vec![Tab { + path: self.location.clone(), + name: section_name(&self.location, &None), + color: None, + }], + Err(_) => Vec::new(), + } + } + + /// The first readable section, searching groups after sections, as OneNote opens a + /// notebook. + pub fn first_section(&self) -> Option { + let Ok(Some(notebook)) = &self.notebook else { + return self.tabs("").pop().map(|tab| tab.path); + }; + let mut folders = std::collections::VecDeque::from([notebook.catalog()]); + while let Some(folder) = folders.pop_front() { + if let Some(tab) = tabs(folder).into_iter().next() { + return Some(tab.path); + } + folders.extend(folder.groups.iter().filter(|group| !recycle_bin(group))); + } + None + } + + /// Whether the notebook lists a section at catalog path `path`. + pub fn contains(&self, path: &str) -> bool { + let Some(catalog) = self.catalog() else { + return self.location == path; + }; + let mut folders = vec![catalog]; + while let Some(folder) = folders.pop() { + if folder.sections.iter().any(|section| section.path == path) { + return true; + } + folders.extend(&folder.groups); + } + false + } + + /// The notebook's folders of sections, for the sidebar. + pub fn catalog(&self) -> Option<&Folder> { + match &self.notebook { + Ok(Some(notebook)) => Some(notebook.catalog()), + _ => None, + } + } +} + +/// Whether `folder` is the notebook's recycle bin, which OneNote keeps out of its lists. +pub fn recycle_bin(folder: &Folder) -> bool { + folder.path.rsplit('/').next() == Some("OneNote_RecycleBin") +} + +pub fn file_name(path: &Path) -> String { + path.canonicalize() + .ok() + .as_deref() + .and_then(Path::file_name) + .or_else(|| path.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// A section's name: its display name, or its file's. +pub fn section_name(path: &str, name: &Option) -> String { + name.clone().unwrap_or_else(|| { + Path::new(path) + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_default() + }) +} + +/// Section tabs for a notebook's readable top-level sections, in its order. +fn tabs(catalog: &Folder) -> Vec { + catalog + .sections + .iter() + .filter_map(|section| match §ion.state { + SectionState::Readable { name, color } => Some(Tab { + name: section_name(§ion.path, name), + path: section.path.clone(), + color: *color, + }), + _ => None, + }) + .collect() +} diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs new file mode 100644 index 0000000000000000000000000000000000000000..d08a923e5a1ac4735a5c91e5ba0d79ba6b06fc07 --- /dev/null +++ b/crates/snowbound/src/linux.rs @@ -0,0 +1,610 @@ +//! Linux, on X11 and Wayland: window controls drawn in the title bar, dialogs through zenity +//! or kdialog, the colour scheme from the XDG settings portal, and text conventions from +//! the C library's locale. + +use canvas::date::DateField; +use std::{ffi::CStr, path::PathBuf, process::Command, sync::OnceLock, time::Duration}; +use ui::{Flags, Spec, Ui, px}; +use winit::{ + error::EventLoopError, + event_loop::{EventLoop, EventLoopProxy}, + platform::wayland::WindowAttributesExtWayland, + window::{Icon, ResizeDirection, Theme, Window, WindowAttributes}, +}; + +/// The name the desktop entry, the icon theme and window matching know the app by. +const APP_ID: &str = "snowbound"; +/// The title bar's leading margin; the window controls sit at its trailing end. +pub const LEADING: f32 = 8.0; +/// Square, as compositors that round windows don't report by how much. +pub const CORNER_RADIUS: f32 = 0.0; +/// How far inside the window's edges a press resizes it, and how far along them a corner +/// reaches, in logical pixels. +const EDGE: f32 = 5.0; +const CORNER: f32 = 16.0; +const MINIMIZE: &[&str] = &[include_str!("../assets/icons/window-minimize.svg")]; +const MAXIMIZE: &[&str] = &[include_str!("../assets/icons/window-maximize.svg")]; +const RESTORE: &[&str] = &[include_str!("../assets/icons/window-restore.svg")]; + +static QUIT: OnceLock> = OnceLock::new(); + +pub fn event_loop(_headless: bool) -> Result, EventLoopError> { + // Month and day names and date orders follow the user's locale; other categories stay C. + unsafe { libc::setlocale(libc::LC_TIME, c"".as_ptr()) }; + let event_loop = EventLoop::with_user_event().build()?; + QUIT.set(event_loop.create_proxy()) + .expect("Only one application event loop is created"); + Ok(event_loop) +} + +/// Decorated by the window manager, or on Wayland by the compositor where it offers +/// xdg-decoration and otherwise by winit's Adwaita frame; named so the desktop entry supplies +/// the icon on Wayland, while X11 takes it from the window. +pub fn window_attributes() -> WindowAttributes { + let icon = icon_pixels(64).and_then(|(side, rgba)| Icon::from_rgba(rgba, side, side).ok()); + Window::default_attributes() + .with_name(APP_ID, APP_ID) + .with_window_icon(icon) +} + +/// Wayland's clipboard through the window's own connection, as not every compositor offers +/// a clipboard to clients without a window; X11's otherwise. +pub enum Clipboard { + Wayland(smithay_clipboard::Clipboard), + X11(arboard::Clipboard), +} + +impl Clipboard { + pub fn new(window: &Window) -> Result { + use winit::raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; + match window.display_handle().map(|handle| handle.as_raw()) { + // The display outlives the clipboard, which the window's state owns. + Ok(RawDisplayHandle::Wayland(handle)) => Ok(Self::Wayland(unsafe { + smithay_clipboard::Clipboard::new(handle.display.as_ptr()) + })), + _ => arboard::Clipboard::new().map(Self::X11), + } + } + + pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> { + match self { + Self::Wayland(clipboard) => Ok(clipboard.store(text)), + Self::X11(clipboard) => clipboard.set_text(text), + } + } + + pub fn get_text(&mut self) -> Result> { + Ok(match self { + Self::Wayland(clipboard) => clipboard.load()?, + Self::X11(clipboard) => clipboard.get_text()?, + }) + } +} + +/// Whether the system draws the title bar; false only where Wayland's client-side frame +/// could not be made, leaving the app's own. +pub fn system_titlebar(window: &Window) -> bool { + window.is_decorated() +} + +/// Minimize, maximize and close at the title bar's trailing end. +pub fn window_controls(ui: &mut Ui, window: &Window) { + let theme = ui.theme.clone(); + let mut button = |part: &str, icon| { + ui.leaf( + part, + Spec { + flags: Flags::CLICKABLE, + size: [px(crate::TITLE + 12.0), px(crate::TITLE)], + icon: Some(icon), + color: Some(theme.text), + hover_fill: Some(theme.hover()), + center: true, + ..Spec::default() + }, + ) + .clicked + }; + if button("minimize", MINIMIZE) { + window.set_minimized(true); + } + if button( + "maximize", + if window.is_maximized() { + RESTORE + } else { + MAXIMIZE + }, + ) { + zoom(window); + } + if button("close", crate::art::CLOSE) + && let Some(proxy) = QUIT.get() + { + let _ = proxy.send_event(crate::UserEvent::Quit); + } +} + +/// The edge or corner a press at `pointer`, in logical pixels, resizes the window from, when +/// the window has no frame to take it. +pub fn resize_direction(window: &Window, pointer: [f32; 2]) -> Option { + if window.is_decorated() { + return None; + } + let size = window.inner_size().to_logical::(window.scale_factor()); + let [x, y] = pointer; + let near = |value: f32, extent: f32, reach: f32| (value < reach, value > extent - reach); + let (mut west, mut east) = near(x, size.width, EDGE); + let (mut north, mut south) = near(y, size.height, EDGE); + if north || south { + (west, east) = near(x, size.width, CORNER); + } + if west || east { + (north, south) = near(y, size.height, CORNER); + } + let direction = match (north, south, west, east) { + (true, _, true, _) => ResizeDirection::NorthWest, + (true, _, _, true) => ResizeDirection::NorthEast, + (_, true, true, _) => ResizeDirection::SouthWest, + (_, true, _, true) => ResizeDirection::SouthEast, + (true, ..) => ResizeDirection::North, + (_, true, ..) => ResizeDirection::South, + (.., true, _) => ResizeDirection::West, + (.., true) => ResizeDirection::East, + _ => return None, + }; + (!window.is_maximized() && window.is_resizable()).then_some(direction) +} + +pub fn zoom(window: &Window) { + window.set_maximized(!window.is_maximized()); +} + +/// Winit delivers typed text with its key events on Linux. +pub fn install_text_input(_: &Window) {} + +pub fn clear_marked_text(_: &Window) {} + +pub fn configure_presentation(_: &wgpu::Surface<'_>) {} + +pub fn commit_presentation(_: &Window) {} + +/// GNOME has no character palette to open from an app. +pub fn show_character_palette() {} + +/// GTK's default double-click time. +pub fn double_click_interval() -> Duration { + Duration::from_millis(400) +} + +/// The desktop's colour scheme from the settings portal, which GNOME, KDE and others serve; +/// light where it states no preference. +pub fn appearance(_: &Window) -> Theme { + let dark = portal_color_scheme() + .unwrap_or_else(|| std::env::var("GTK_THEME").is_ok_and(|theme| theme.ends_with(":dark"))); + if dark { Theme::Dark } else { Theme::Light } +} + +fn portal_color_scheme() -> Option { + use zbus::zvariant::{OwnedValue, Value}; + // A missing portal must not hold up the window for D-Bus's 25 s default. + let connection = zbus::blocking::connection::Builder::session() + .ok()? + .method_timeout(Duration::from_millis(500)) + .build() + .ok()?; + let proxy = zbus::blocking::Proxy::new( + &connection, + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", + ) + .ok()?; + let key = ("org.freedesktop.appearance", "color-scheme"); + // Portals before ReadOne answer Read, with the value in a second variant. + let reply: OwnedValue = proxy + .call("ReadOne", &key) + .or_else(|_| proxy.call("Read", &key)) + .ok()?; + let mut value = Value::from(reply); + while let Value::Value(inner) = value { + value = *inner; + } + match u32::try_from(value).ok()? { + 1 => Some(true), + 2 => Some(false), + _ => None, + } +} + +pub fn cache_dir() -> Option { + xdg_dir("XDG_CACHE_HOME", ".cache") +} + +pub fn settings_dir() -> Option { + xdg_dir("XDG_CONFIG_HOME", ".config") +} + +/// The app's folder in the XDG base directory `variable` names, or in `fallback` under home. +fn xdg_dir(variable: &str, fallback: &str) -> Option { + std::env::var_os(variable) + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .or_else(|| Some(PathBuf::from(std::env::var_os("HOME")?).join(fallback))) + .map(|base| base.join(APP_ID)) +} + +/// The application's icon from the icon theme, the smallest at least `pixels` square. +pub fn app_icon(pixels: u32) -> Option { + let (side, rgba) = icon_pixels(pixels)?; + draw::RasterImage::new([side; 2], rgba).ok() +} + +/// Looks beside the executable first, as the release archive installs it, then in the +/// XDG data directories. +fn icon_pixels(pixels: u32) -> Option<(u32, Vec)> { + let home = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))); + let system = std::env::var("XDG_DATA_DIRS") + .ok() + .filter(|dirs| !dirs.is_empty()) + .unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + let roots: Vec = std::env::current_exe() + .ok() + .and_then(|exe| Some(exe.parent()?.parent()?.join("share"))) + .into_iter() + .chain(home) + .chain(std::env::split_paths(&system)) + .collect(); + let sizes = [16, 24, 32, 48, 64, 128, 256, 512]; + let ordered = sizes + .iter() + .filter(|&&side| side >= pixels) + .chain(sizes.iter().rev().filter(|&&side| side < pixels)); + ordered + .flat_map(|side| { + roots.iter().map(move |root| { + root.join(format!("icons/hicolor/{side}x{side}/apps/{APP_ID}.png")) + }) + }) + .find_map(|path| decode_rgba(&path)) +} + +fn decode_rgba(path: &std::path::Path) -> Option<(u32, Vec)> { + let file = std::io::BufReader::new(std::fs::File::open(path).ok()?); + let mut decoder = png::Decoder::new(file); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder.read_info().ok()?; + let mut pixels = vec![0; reader.output_buffer_size()?]; + let frame = reader.next_frame(&mut pixels).ok()?; + pixels.truncate(frame.buffer_size()); + let rgba = match frame.color_type { + png::ColorType::Rgba => pixels, + png::ColorType::Rgb => pixels + .chunks_exact(3) + .flat_map(|pixel| [pixel[0], pixel[1], pixel[2], 255]) + .collect(), + _ => return None, + }; + (frame.width == frame.height).then_some((frame.width, rgba)) +} + +/// The account's full name from its passwd entry, or its login where that has none. +pub fn user_name() -> String { + let mut entry: libc::passwd = unsafe { std::mem::zeroed() }; + let mut found = std::ptr::null_mut(); + let mut buffer = vec![0 as libc::c_char; 16 * 1024]; + unsafe { + libc::getpwuid_r( + libc::getuid(), + &mut entry, + buffer.as_mut_ptr(), + buffer.len(), + &mut found, + ) + }; + let field = |value: *const libc::c_char| { + (!found.is_null() && !value.is_null()).then(|| { + unsafe { CStr::from_ptr(value) } + .to_string_lossy() + .into_owned() + }) + }; + // GECOS holds the full name before any comma-separated office and phone fields. + field(entry.pw_gecos) + .and_then(|gecos| gecos.split(',').next().map(str::to_owned)) + .filter(|name| !name.trim().is_empty()) + .or_else(|| field(entry.pw_name)) + .or_else(|| std::env::var("USER").ok()) + .unwrap_or_default() +} + +/// The keyboard's language is not exposed portably, so pasted text takes the locale's. +pub fn input_language() -> String { + ["LC_ALL", "LC_MESSAGES", "LANG"] + .into_iter() + .filter_map(|name| std::env::var(name).ok()) + .find(|value| !value.is_empty()) + .map(|locale| locale_tag(&locale)) + .unwrap_or_default() +} + +/// A POSIX locale name, such as `pt_BR.UTF-8@euro`, as a BCP-47 tag. +fn locale_tag(locale: &str) -> String { + let name = locale.split(['.', '@']).next().unwrap_or_default(); + if matches!(name, "C" | "POSIX") { + String::new() + } else { + name.replace('_', "-") + } +} + +const FILETIME_EPOCH: i64 = 11_644_473_600; + +fn local_time(filetime: u64) -> libc::tm { + let seconds = (filetime / 10_000_000) as i64 - FILETIME_EPOCH; + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + unsafe { libc::localtime_r(&seconds, &mut tm) }; + tm +} + +fn format(tm: &libc::tm, pattern: &CStr) -> String { + let mut buffer = [0u8; 256]; + let length = unsafe { + libc::strftime( + buffer.as_mut_ptr().cast(), + buffer.len(), + pattern.as_ptr(), + tm, + ) + }; + String::from_utf8_lossy(&buffer[..length]).into_owned() +} + +/// FILETIME as the locale's short date, as OneNote labels a conflict page. +pub fn short_date(filetime: u64) -> String { + format(&local_time(filetime), c"%x") +} + +/// The page's date and time labels, spelled out as OneNote shows them. +fn date_labels(tm: &libc::tm) -> [String; 2] { + let meridiem = unsafe { CStr::from_ptr(libc::nl_langinfo(libc::AM_STR)) }; + let time: &CStr = if meridiem.is_empty() { + c"%H:%M" + } else { + c"%-I:%M %p" + }; + [format(tm, c"%A, %B %-d, %Y"), format(tm, time)] +} + +/// FILETIME as a new page's title shows it: the long date and the short time. +pub fn date_text(filetime: u64) -> [String; 2] { + date_labels(&local_time(filetime)) +} + +/// Asks for the page's date or time with the desktop's dialog tool. +pub fn edit_date( + timestamp: u64, + field: DateField, + title: &str, +) -> Result, &'static str> { + let tm = local_time(timestamp); + let answer = match field { + DateField::Date => dialog( + [ + "--calendar", + &format!("--title={title}"), + "--text=", + &format!("--day={}", tm.tm_mday), + &format!("--month={}", tm.tm_mon + 1), + &format!("--year={}", tm.tm_year + 1900), + "--date-format=%Y-%m-%d", + ], + ["--calendar", title, "--dateformat", "yyyy-MM-dd"], + )?, + DateField::Time => { + let current = format(&tm, c"%H:%M"); + dialog( + [ + "--entry", + &format!("--title={title}"), + "--text=Time, as hours and minutes:", + &format!("--entry-text={current}"), + ], + ["--inputbox", "Time, as hours and minutes:", ¤t], + )? + } + }; + let Some(answer) = answer else { + return Ok(None); + }; + let numbers: Vec = answer + .split(|c: char| !c.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .map(|part| part.parse().unwrap_or(i32::MAX)) + .collect(); + let afternoon = answer.to_lowercase().contains("pm"); + merge_date(timestamp, tm, field, &numbers, afternoon).map(Some) +} + +fn merge_date( + timestamp: u64, + mut tm: libc::tm, + field: DateField, + numbers: &[i32], + afternoon: bool, +) -> Result<(u64, [String; 2]), &'static str> { + match (field, numbers) { + (DateField::Date, &[year, month, day]) + if (1..=12).contains(&month) && (1..=31).contains(&day) => + { + tm.tm_year = year - 1900; + tm.tm_mon = month - 1; + tm.tm_mday = day; + } + (DateField::Time, &[hour, minute]) + if (0..24).contains(&hour) && (0..60).contains(&minute) => + { + tm.tm_hour = if afternoon && hour < 12 { + hour + 12 + } else { + hour + }; + tm.tm_min = minute; + } + _ => return Err(crate::DATE_UNCHOSEN), + } + tm.tm_isdst = -1; + let seconds = unsafe { libc::mktime(&mut tm) }; + let outside_range = crate::DATE_OUT_OF_RANGE; + let updated = u64::try_from(seconds + FILETIME_EPOCH) + .ok() + .and_then(|seconds| seconds.checked_mul(10_000_000)) + .and_then(|ticks| ticks.checked_add(timestamp % 10_000_000)) + .ok_or(outside_range)?; + Ok((updated, date_labels(&tm))) +} + +/// Runs zenity, or kdialog where zenity is missing; the answer is None when cancelled. +fn dialog( + zenity: [&str; Z], + kdialog: [&str; K], +) -> Result, &'static str> { + let output = Command::new("zenity") + .args(zenity) + .output() + .or_else(|_| Command::new("kdialog").args(kdialog).output()) + .map_err(|_| "Install zenity or kdialog for Snowbound's dialogs.")?; + Ok(output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())) +} + +/// Asks whether to go ahead with `action`; false when no tool can ask. +pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { + let status = Command::new("zenity") + .args([ + "--question", + &format!("--title={message}"), + &format!("--text={detail}"), + &format!("--ok-label={action}"), + &format!("--cancel-label={cancel}"), + ]) + .status() + .or_else(|_| { + Command::new("kdialog") + .args(["--warningcontinuecancel", detail, "--title", message]) + .args(["--continue-label", action]) + .status() + }); + match status { + Ok(status) => status.success(), + Err(_) => { + eprintln!("Install zenity or kdialog to answer: {message}"); + false + } + } +} + +/// Asks for a notebook's table of contents or a section file, titled `title`; None when +/// cancelled or when no tool can ask. +pub fn pick_notebook(title: &str) -> Option { + let asked = dialog( + [ + "--file-selection", + &format!("--title={title}"), + "--file-filter=OneNote notebooks and sections | *.onetoc2 *.one", + ], + ["--getopenfilename", ".", "*.onetoc2 *.one", "--title", title], + ); + asked + .unwrap_or_else(|error| { + eprintln!("{error}"); + None + }) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) +} + +/// Asks where to create something named `name` by default. +pub fn pick_new(title: &str, name: &str) -> Option { + let asked = dialog( + [ + "--file-selection", + "--save", + &format!("--title={title}"), + &format!("--filename={name}"), + ], + ["--getsavefilename", name, "--title", title], + ); + asked + .unwrap_or_else(|error| { + eprintln!("{error}"); + None + }) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) +} + +/// Tells the user something they asked for could not be done: `message`, then what to do. +pub fn alert(message: &str, detail: &str) { + let (title, text) = (message.to_owned(), detail.to_owned()); + std::thread::spawn(move || { + let shown = Command::new("zenity") + .args([ + "--warning", + &format!("--title={title}"), + &format!("--text={text}"), + ]) + .status() + .or_else(|_| { + Command::new("kdialog") + .args(["--sorry", &text, "--title", &title]) + .status() + }); + if shown.is_err() { + eprintln!("{title}: {text}"); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(year: i32, month: i32, day: i32, hour: i32, minute: i32) -> libc::tm { + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + (tm.tm_year, tm.tm_mon, tm.tm_mday, tm.tm_hour, tm.tm_min) = + (year - 1900, month - 1, day, hour, minute); + tm.tm_isdst = -1; + tm + } + + fn filetime(mut tm: libc::tm) -> u64 { + (unsafe { libc::mktime(&mut tm) } + FILETIME_EPOCH) as u64 * 10_000_000 + } + + #[test] + fn date_changes_keep_the_other_field_and_the_filetime_fraction() { + let original = filetime(at(2026, 9, 26, 15, 4)) + 9_123_456; + let tm = local_time(original); + let (updated, labels) = + merge_date(original, tm, DateField::Date, &[2027, 2, 28], false).unwrap(); + assert_eq!(updated, filetime(at(2027, 2, 28, 15, 4)) + 9_123_456); + assert!(labels.iter().all(|label| !label.is_empty())); + let (updated, _) = merge_date(original, tm, DateField::Time, &[9, 30], true).unwrap(); + assert_eq!(updated, filetime(at(2026, 9, 26, 21, 30)) + 9_123_456); + assert!(merge_date(original, tm, DateField::Date, &[2026, 13, 1], false).is_err()); + assert!(merge_date(original, tm, DateField::Time, &[9], false).is_err()); + assert!(merge_date(original, tm, DateField::Date, &[1500, 1, 1], false).is_err()); + } + + #[test] + fn locales_become_language_tags() { + assert_eq!(locale_tag("pt_BR.UTF-8@euro"), "pt-BR"); + assert_eq!(locale_tag("de_DE"), "de-DE"); + assert_eq!(locale_tag("C.UTF-8"), ""); + } +} diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index f35307d3c5254383e11aae8099e1b51856e0b0b8..58a669dc60e8f061cad1d2beee20746ec2870e09 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -19,8 +19,9 @@ use std::{cell::Cell, sync::OnceLock}; use winit::{ error::EventLoopError, event_loop::{EventLoop, EventLoopProxy}, + platform::macos::WindowAttributesExtMacOS, raw_window_handle::{HasWindowHandle, RawWindowHandle}, - window::Window, + window::{Window, WindowAttributes}, }; static QUIT: OnceLock> = OnceLock::new(); @@ -70,6 +71,109 @@ fn ns_window(window: &Window) -> Retained { } } +/// Room the traffic lights take at the title bar's leading edge. +pub const LEADING: f32 = 78.0; +/// How far AppKit rounds a window's corners. +pub const CORNER_RADIUS: f32 = 10.0; + +/// A transparent title bar over the content, where the window draws its own. +pub fn window_attributes() -> WindowAttributes { + Window::default_attributes() + .with_titlebar_transparent(true) + .with_title_hidden(true) + .with_fullsize_content_view(true) +} + +pub struct Clipboard(arboard::Clipboard); + +impl Clipboard { + pub fn new(_: &Window) -> Result { + arboard::Clipboard::new().map(Self) + } + + pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> { + self.0.set_text(text) + } + + pub fn get_text(&mut self) -> Result { + self.0.get_text() + } +} + +/// AppKit draws the traffic lights. +pub fn window_controls(_: &mut ui::Ui, _: &Window) {} + +/// The app draws the title bar around the traffic lights. +pub fn system_titlebar(_: &Window) -> bool { + false +} + +/// AppKit's window frame takes resizing presses. +pub fn resize_direction(_: &Window, _: [f32; 2]) -> Option { + None +} + +pub fn cache_dir() -> Option { + Some(std::path::PathBuf::from(std::env::var_os("HOME")?).join("Library/Caches/snowbound")) +} + +pub fn settings_dir() -> Option { + Some( + std::path::PathBuf::from(std::env::var_os("HOME")?) + .join("Library/Application Support/Snowbound"), + ) +} + +/// Asks for a notebook folder or a notebook file with the system's open panel, titled +/// `title`. +pub fn pick_notebook(title: &str) -> Option { + let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); + unsafe { + let panel = objc2_app_kit::NSOpenPanel::openPanel(mtm); + panel.setCanChooseDirectories(true); + panel.setCanChooseFiles(true); + panel.setCanCreateDirectories(true); + panel.setTitle(Some(&NSString::from_str(title))); + panel.setPrompt(Some(&NSString::from_str("Open"))); + if panel.runModal() != objc2_app_kit::NSModalResponseOK { + return None; + } + let path = panel.URLs().firstObject()?.path()?; + Some(path.to_string().into()) + } +} + +/// Asks where to create something named `name` by default, with the system's save panel. +pub fn pick_new(title: &str, name: &str) -> Option { + let mtm = MainThreadMarker::new().expect("Panels belong to the main thread"); + unsafe { + let panel = objc2_app_kit::NSSavePanel::savePanel(mtm); + panel.setCanCreateDirectories(true); + panel.setTitle(Some(&NSString::from_str(title))); + panel.setPrompt(Some(&NSString::from_str("Create"))); + panel.setNameFieldStringValue(&NSString::from_str(name)); + if panel.runModal() != objc2_app_kit::NSModalResponseOK { + return None; + } + Some(panel.URL()?.path()?.to_string().into()) + } +} + +/// Tells the user something they asked for could not be done: `message`, then what to do. +pub fn alert(message: &str, detail: &str) { + let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); + unsafe { + let alert = NSAlert::new(mtm); + alert.setMessageText(&NSString::from_str(message)); + alert.setInformativeText(&NSString::from_str(detail)); + alert.runModal(); + } +} + +pub fn appearance(window: &Window) -> winit::window::Theme { + window.theme().unwrap_or(winit::window::Theme::Dark) +} + /// Zooms the window once the current event is handled: AppKit's zoom animation runs its /// own loop, and started from inside winit's handler it would hold every resize until the /// end, stretching the last frame instead of drawing each step. @@ -124,6 +228,7 @@ pub fn show_character_palette() { pub fn edit_date( timestamp: u64, field: DateField, + title: &str, ) -> Result, &'static str> { let mtm = MainThreadMarker::new().expect("Date controls belong to the main thread"); unsafe { @@ -147,10 +252,7 @@ pub fn edit_date( let _: () = msg_send![&picker, setAccessibilityLabel: &*label]; picker.sizeToFit(); let alert = NSAlert::new(mtm); - alert.setMessageText(&NSString::from_str(match field { - DateField::Date => "Change Page Date", - DateField::Time => "Change Page Time", - })); + alert.setMessageText(&NSString::from_str(title)); alert.setAccessoryView(Some(&picker)); alert.addButtonWithTitle(&NSString::from_str("Apply")); alert.addButtonWithTitle(&NSString::from_str("Cancel")); @@ -197,9 +299,9 @@ fn merge_date( } let date = calendar .dateFromComponents(&components) - .ok_or("Choose another date or time.")?; + .ok_or(crate::DATE_UNCHOSEN)?; let seconds = date.timeIntervalSince1970() + 11_644_473_600.0; - let outside_range = "This date is outside the notebook's supported range."; + let outside_range = crate::DATE_OUT_OF_RANGE; if !seconds.is_finite() || seconds < 0.0 || seconds > (u64::MAX / 10_000_000) as f64 { return Err(outside_range); } @@ -208,17 +310,49 @@ fn merge_date( .checked_mul(10_000_000) .and_then(|value| value.checked_add(timestamp % 10_000_000)) .ok_or(outside_range)?; + Ok((updated, labels(&date, calendar))) + } +} + +/// `date` as a page's title shows it: the long date and the short time. +fn labels(date: &NSDate, calendar: &NSCalendar) -> [String; 2] { + unsafe { let formatter = NSDateFormatter::new(); formatter.setCalendar(Some(calendar)); formatter.setTimeZone(Some(&calendar.timeZone())); formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterFullStyle); - let date_text = formatter.stringFromDate(&date).to_string(); + let date_text = formatter.stringFromDate(date).to_string(); formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterNoStyle); formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterShortStyle); - Ok(( - updated, - [date_text, formatter.stringFromDate(&date).to_string()], - )) + [date_text, formatter.stringFromDate(date).to_string()] + } +} + +/// FILETIME as a new page's title shows it: the long date and the short time. +pub fn date_text(filetime: u64) -> [String; 2] { + unsafe { + let date = NSDate::dateWithTimeIntervalSince1970( + (filetime / 10_000_000) as f64 - 11_644_473_600.0, + ); + labels(&date, &NSCalendar::currentCalendar()) + } +} + +/// The account's full name, which OneNote's author fields take from Office's user name. +pub fn user_name() -> String { + unsafe { objc2_foundation::NSFullUserName().as_ref() }.to_string() +} + +/// FILETIME as the system's short date, as OneNote labels a conflict page. +pub fn short_date(filetime: u64) -> String { + unsafe { + let date = NSDate::dateWithTimeIntervalSince1970( + (filetime / 10_000_000) as f64 - 11_644_473_600.0, + ); + let formatter = NSDateFormatter::new(); + formatter.setDateStyle(NSDateFormatterStyle::NSDateFormatterShortStyle); + formatter.setTimeStyle(NSDateFormatterStyle::NSDateFormatterNoStyle); + formatter.stringFromDate(&date).to_string() } } @@ -354,8 +488,7 @@ pub fn app_icon(pixels: u32) -> Option { pub fn text_colors(window: &Window) -> [[f32; 4]; 3] { let class = AnyClass::get("NSAppearance").expect("AppKit is linked"); unsafe { - let appearance: Retained = - msg_send_id![&ns_window(window), effectiveAppearance]; + let appearance: Retained = msg_send_id![&ns_window(window), effectiveAppearance]; // System colours resolve in the thread's appearance, which outside drawing does // not follow the window's. let previous: Option> = msg_send_id![class, currentAppearance]; @@ -432,46 +565,39 @@ declare_class!( } ); -pub fn event_loop() -> Result, EventLoopError> { +/// A `headless` application takes no Dock icon and never activates. +pub fn event_loop(headless: bool) -> Result, EventLoopError> { + use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; MainThreadMarker::new().expect("AppKit must start on the main thread"); // Create the application's own subclass before Winit obtains the singleton. let _: Retained = unsafe { msg_send_id![CanvasApplication::class(), sharedApplication] }; - let event_loop = EventLoop::with_user_event().build()?; + let mut builder = EventLoop::with_user_event(); + if headless { + builder + .with_activation_policy(ActivationPolicy::Prohibited) + .with_activate_ignoring_other_apps(false); + } + let event_loop = builder.build()?; QUIT.set(event_loop.create_proxy()) .expect("Only one application event loop is created"); Ok(event_loop) } -pub fn discard_changes() -> bool { +/// Asks whether to go ahead with `action`, offering `cancel` first. +pub fn confirm(message: &str, detail: &str, cancel: &str, action: &str) -> bool { let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); // The alert and its strings stay on AppKit's main thread for the modal call. unsafe { let alert = NSAlert::new(mtm); - alert.setMessageText(&NSString::from_str("Discard this page?")); - alert.setInformativeText(&NSString::from_str( - "This temporary page has no saved copy. Closing it will discard your edits.", - )); - alert.addButtonWithTitle(&NSString::from_str("Keep Editing")); - alert.addButtonWithTitle(&NSString::from_str("Discard Changes")); + alert.setMessageText(&NSString::from_str(message)); + alert.setInformativeText(&NSString::from_str(detail)); + alert.addButtonWithTitle(&NSString::from_str(cancel)); + alert.addButtonWithTitle(&NSString::from_str(action)); alert.runModal() == NSAlertSecondButtonReturn } } -/// Tells the user an edit could not be stored and where their text went. -pub fn change_not_saved() { - let mtm = MainThreadMarker::new().expect("Window events run on the main thread"); - // The alert and its strings stay on AppKit's main thread for the modal call. - unsafe { - let alert = NSAlert::new(mtm); - alert.setMessageText(&NSString::from_str("Change not saved")); - alert.setInformativeText(&NSString::from_str( - "The page now shows what was last saved. Your text is on the clipboard to paste back.", - )); - alert.runModal(); - } -} - /// End AppKit preedit after a canvas action commits or cancels the composition. pub fn clear_marked_text(window: &Window) { MainThreadMarker::new().expect("Text input belongs to the main thread"); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 161ee6900abc621c0cca0d6cf641c6f9dc2c0239..238462277399624dfdeee8e93a732dfc5a24b911 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1,8 +1,19 @@ mod art; -mod macos; +#[cfg(test)] +mod conflict_render; +mod library; +mod manage; +mod menus; +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] +mod platform; +mod screenshot; +mod settings; +mod sidebar; +mod templates; use canvas::gpu::page::PageScene; -use canvas::interaction::{Cursor, PageView, Request, Response, TextColors, accessibility}; +use canvas::interaction::{Cursor, PageView, Place, Request, Response, TextColors, accessibility}; use canvas::{ date::DateField, document::TextDocument, @@ -18,7 +29,9 @@ use onestore::ExGuid; use onestore::document::Format; use onestore::page::Page; use onestore::page::text::Paragraph; +use library::Library; use std::{ + collections::{HashMap, HashSet}, error::Error, path::{Path, PathBuf}, sync::{Arc, mpsc}, @@ -31,21 +44,23 @@ use winit::{ event::{ElementState, Ime, MouseButton, MouseScrollDelta, WindowEvent}, event_loop::{ActiveEventLoop, ControlFlow, EventLoopProxy}, keyboard::{Key, ModifiersState, NamedKey}, - platform::macos::WindowAttributesExtMacOS, window::{CursorIcon, Window, WindowId}, }; -/// Height of the macOS title bar, which holds the traffic lights. +/// Height of the title bar, which holds the window's controls. const TITLE: f32 = 28.0; -/// Room the traffic lights take at the title bar's leading edge. -const LIGHTS: f32 = 78.0; const TAB_ROW: f32 = 28.0; /// Width of the section colour around the page. const FRAME: f32 = 6.0; -/// macOS rounds windows' corners by 10 pt; the page's corners share their centres. -const ROUNDING: f32 = 10.0 - FRAME; +/// The page's corners share their centres with the window's. +const ROUNDING: f32 = (platform::CORNER_RADIUS - FRAME).max(0.0); const PAGE_LIST: f32 = 240.0; /// Font sizes the size box offers, OneNote's list in points. +/// Fonts the font box offers first, OneNote's default leading. +const FONTS: [&str; 4] = ["Calibri", "Arial", "Times New Roman", "Courier New"]; +/// Fonts picked this session that the font box offers after `FONTS`, at most. +const RECENT_FONTS: usize = 5; +/// Font sizes the size box offers, OneNote's list in points. const SIZES: [f32; 17] = [ 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, 72.0, @@ -65,14 +80,21 @@ const FONT_COLORS: [u32; 20] = [ /// while the gaps still take the pointer. const ROW: f32 = 29.0; const ROW_GAP: f32 = 3.0; +/// OneNote's highlight for conflicting changes on a conflict page, COLORREF. +const CONFLICTING: u32 = 0xd6d6ff; /// Space between the page and a page tab that isn't open. const PILL_MARGIN: f32 = 3.0; /// Rounding where the open page tab meets the page, concentric with its neighbours'. const JOIN: f32 = PILL_MARGIN + ROUNDING; +/// Why the platform's date dialog could not change the page's date. +const DATE_UNCHOSEN: &str = "Choose another date or time."; +const DATE_OUT_OF_RANGE: &str = "This date is outside the notebook's supported range."; #[derive(Debug)] enum UserEvent { Quit, + /// Text AppKit inserts outside key events, such as the character palette's. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] InsertText(String), Accessibility(accesskit_winit::Event), /// The section's synchronization thread reported an event. @@ -141,6 +163,11 @@ struct App { substitutes: Vec, /// Font files for the interface in place of the system's. interface_font: Vec>, + /// Where `--screenshot` writes its PNGs, drawn from a window never shown. + screenshot: Option, + /// Where settings are saved, and what they held at launch, until the window opens. + settings: Option<(Option, settings::Settings)>, + cache: PathBuf, state: Option, startup_error: Option>, } @@ -155,76 +182,114 @@ enum Input { Section { file: PathBuf, title: String, - cache: PathBuf, }, - Notebook { - root: PathBuf, - cache: PathBuf, + /// The open notebooks by location, showing `current`; none shows the first run's + /// welcome. + Notebooks { + locations: Vec, + current: Option, }, } -/// A section tab: where the section opens from and how it is labelled. -struct Tab { - /// The notebook catalog path, or the file of a section opened on its own. - path: String, - name: String, - /// COLORREF. - color: Option, -} - -/// The sections the tabs offer. -struct Library { - /// The notebook's folder name, or the section file's. - name: String, - notebook: Option, - tabs: Vec, - cache: PathBuf, -} - -impl Library { - fn open( - &self, - tab: usize, - proxy: EventLoopProxy, - ) -> Result> { - let notify = move || { - let _ = proxy.send_event(UserEvent::Sync); - }; - let path = &self.tabs[tab].path; - Ok(match &self.notebook { - Some(notebook) => notebook.section(path, notify)?, - None => notebook::session::Section::open(path, &self.cache, notify)?, - }) +/// Asks the event loop to poll sections when their synchronization reports. +fn notify(proxy: EventLoopProxy) -> impl Fn() + Send + 'static { + move || { + let _ = proxy.send_event(UserEvent::Sync); } } /// The open section and the page the editor shows. struct Session { section: notebook::session::Section, + /// The notebook the section belongs to. + library: Arc, + /// The sections of the section's folder, and which is this one. + tabs: Vec, tab: usize, /// Spaces, titles and outline levels in section order. pages: Vec<(ExGuid, String, u32)>, space: ExGuid, status: &'static str, - /// The editor's page has an unreviewed conflict with another machine's change. - conflict: bool, + /// Each page's conflict pages, the versions a merge could not take. + conflicts: Vec<(ExGuid, Vec)>, + /// The page whose conflict pages the list shows beneath it. + shown: Option, + /// Which of the open conflict page's conflicting changes is selected, in page order. + change: Option, +} + +/// The information bar above a page with conflict pages, or above one of them. +#[derive(Clone, Copy)] +enum Bar { + Page { page: ExGuid, shown: bool }, + Version { page: ExGuid, version: ExGuid }, } impl Session { fn title(&self) -> &str { - self.pages + match self.conflict(self.space) { + Some((_, version)) => &version.title, + None => self + .pages + .iter() + .find(|(space, ..)| *space == self.space) + .map_or("", |(_, title, _)| title), + } + } + + fn versions(&self, page: ExGuid) -> &[onestore::ConflictPage] { + self.conflicts .iter() - .find(|(space, ..)| *space == self.space) - .map_or("", |(_, title, _)| title) + .find(|(listed, _)| *listed == page) + .map_or(&[], |(_, versions)| versions) } - fn refresh_conflict(&mut self) -> Result<(), Box> { - self.conflict = self - .section - .conflict()? - .is_some_and(|conflict| conflict.space == self.space); + /// The page conflict page `space` belongs to, with it. + fn conflict(&self, space: ExGuid) -> Option<(ExGuid, &onestore::ConflictPage)> { + self.conflicts.iter().find_map(|(page, versions)| { + Some((*page, versions.iter().find(|version| version.space == space)?)) + }) + } + + fn bar(&self) -> Option { + match self.conflict(self.space) { + Some((page, _)) => Some(Bar::Version { + page, + version: self.space, + }), + None => (!self.versions(self.space).is_empty()).then_some(Bar::Page { + page: self.space, + shown: self.shown == Some(self.space), + }), + } + } + + fn refresh_conflicts(&mut self) -> Result<(), Box> { + self.conflicts = self.section.conflicts()?; + if self + .shown + .is_some_and(|page| self.versions(page).is_empty()) + { + self.shown = None; + } Ok(()) } + + /// The page in `space` as the editor shows it: a conflict page read-only, its + /// conflicting changes highlighted. + fn reader(&self, space: ExGuid) -> impl FnOnce() -> Result> + Send + 'static { + let replica = Arc::clone(self.section.replica()); + let objects = self + .conflict(space) + .map(|(_, version)| version.objects.clone()); + move || { + let page = replica.page(space)?; + Ok(match objects { + Some(objects) => highlighted(page, &objects), + None => page, + }) + } + } } /// 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); /// Work the interface asked for, done after the frame is built. enum Command { - OpenSection(usize), + /// Opens a notebook's section by catalog path, at the page it showed last. + OpenSection(Arc, String), + /// Asks for a notebook folder and opens it. + OpenNotebook, + /// Asks where to keep a new notebook and creates it. + NewNotebook, + /// Closes a notebook, keeping its files. + CloseNotebook(Arc), + /// Changes a notebook's sections and groups. + Structure(Arc, manage::Structure), + /// Adds a page at the end of the open section, or a subpage after the open page, and + /// edits its title. + NewPage { subpage: bool }, + /// Deletes pages of the open section to the notebook's recycle bin. + DeletePages(Vec), + /// Moves or indents pages of the open section. + Pages(Vec), + /// Gives the open page a template's background or colour. + Template(templates::Choice), OpenPage(ExGuid), - Resolve { keep_mine: bool }, + /// Shows a page's conflict pages in the list and opens the newest, or hides them. + Versions { page: ExGuid, show: bool }, + DeleteVersion { page: ExGuid, version: ExGuid }, + /// Copies a conflict page, as a page of its own, to the end of section tab `tab`. + CopyVersion { version: ExGuid, tab: usize }, + /// Selects the next, or previous, conflicting change on the open conflict page. + SelectChange { forward: bool }, Page(Request), } struct State { window: Arc, + /// Who this machine's edits name, as OneNote names the Office user: the account's + /// full name. + author: String, proxy: EventLoopProxy, /// Results of reads done off the frame thread, by request number. loads: (mpsc::Sender, mpsc::Receiver), /// The newest read requested; older ones are dropped when they finish. loading: u64, opening: Option, + /// Where each page was left this run, by `Library::key` and page space, as OneNote + /// returns to it until the notebook closes. + places: HashMap<(String, ExGuid), Place>, + /// The page each section showed last this run, by `Library::key`. + last_pages: HashMap, /// Asks for a frame when a worker thread finishes something the page shows. redraw: std::task::Waker, instance: wgpu::Instance, @@ -272,14 +369,42 @@ struct State { renderer: Renderer, ui: Ui, view: PageView, - library: Option>, + /// The open notebooks in the sidebar's order. + notebooks: Vec>, session: Option, + /// A page not kept in any notebook, from a text file or `--page`. + temporary: bool, + /// Where the settings are saved; `None` leaves them as they were read. + settings: Option, + /// The directory holding the sections' replicas. + cache: PathBuf, + /// Whether the notebook sidebar is expanded. + sidebar: bool, + /// Notebooks and section groups whose rows the sidebar folds, by `sidebar::fold_key`. + folded: HashSet, + /// The open context menu: what it was opened on, and where. + menu: Option<(menus::Target, [f32; 2])>, + /// A section or group being renamed in the sidebar. + renaming: Option, + /// The page whose tab is being dragged. + dragging: Option, + /// The section or group whose sidebar row is being dragged. + dragging_entry: Option, + /// Pages whose template strip was dismissed this run. + dismissed: HashSet, + /// A page just created, whose title takes the caret once it opens. + title_focus: Option, + /// What the template strip shows over a blank page. + templates: templates::View, + thumbnails: templates::Thumbnails, /// Text filtering the page list. filter: String, /// Whether the page list is shown beside the page. pages_open: bool, /// Installed font families, for the font box; listing them is slow. fonts: Vec, + /// Fonts picked from the font box this session, latest first. + recent_fonts: Vec, /// The application's icon at the display's scale, for the title bar. app_icon: Option, commands: Vec, @@ -296,6 +421,8 @@ struct State { pointer: [f32; 2], /// When the strip was last pressed, to zoom on a double click. strip_press: Option, + /// The strip is held and the window moves once the pointer does. + strip_held: bool, /// Where a replay asked the next frame to be written. snapshot: Option, initial: Vec<(onestore::ExGuid, TextDocument)>, @@ -303,7 +430,7 @@ struct State { initial_date: Option, occluded: bool, ime_allowed: bool, - clipboard: arboard::Clipboard, + clipboard: platform::Clipboard, access_adapter: accesskit_winit::Adapter, accessibility: accessibility::Accessibility, } @@ -333,14 +460,14 @@ impl State { input: Input, substitutes: &[PathBuf], interface_font: Vec>, + visible: bool, + (settings, stored): (Option, settings::Settings), + cache: PathBuf, ) -> Result> { let window = Arc::new( event_loop.create_window( - Window::default_attributes() + platform::window_attributes() .with_visible(false) - .with_titlebar_transparent(true) - .with_title_hidden(true) - .with_fullsize_content_view(true) .with_title(match &input { Input::Notes { reference: Some(page), @@ -351,20 +478,20 @@ impl State { reference: None, .. } => "Untitled · Temporary page".into(), Input::Section { title, .. } => title.clone(), - Input::Notebook { root, .. } => root.display().to_string(), + Input::Notebooks { .. } => "Snowbound".into(), }) .with_inner_size(LogicalSize::new(1180.0, 760.0)), )?, ); - macos::install_text_input(&window); + platform::install_text_input(&window); let access_adapter = accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); - window.set_visible(true); - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle( - Box::new(window.clone()), - )); + window.set_visible(visible); + let instance = wgpu::Instance::new( + wgpu::InstanceDescriptor::new_with_display_handle_from_env(Box::new(window.clone())), + ); let surface = instance.create_surface(window.clone())?; - macos::configure_presentation(&surface); + platform::configure_presentation(&surface); let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { compatible_surface: Some(&surface), @@ -384,7 +511,8 @@ impl State { .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; eprintln!("Using {} for {target}", path.display()); } - let mut library = None; + let temporary = matches!(input, Input::Notes { .. } | Input::Page(_)); + let mut notebooks = Vec::new(); let mut session = None; let (editor, scene) = match input { Input::Notes { @@ -404,51 +532,54 @@ impl State { let (scene, editor) = PageScene::from_page(page, &mut engine)?; (editor, Some((scene, [0.0; 2]))) } - Input::Section { file, title, cache } => { - let opened = Library { - name: file_name(file.parent().unwrap_or(&file)), - notebook: None, - tabs: vec![Tab { - name: file - .file_stem() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_default(), - path: file.to_string_lossy().into_owned(), - color: None, - }], - cache, - }; - let section = opened.open(0, proxy.clone())?; + Input::Section { file, title } => { + let library = Arc::new(Library::section(&file, &cache)); + let section = library.open(&library.location, notify(proxy.clone()))?; let space = section .pages()? .into_iter() .find(|(_, candidate, _)| *candidate == title) .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))? .0; - library = Some(Arc::new(opened)); - let (opened, page) = read_session(section, 0, Some(space))?; + notebooks.push(Arc::clone(&library)); + let path = library.location.clone(); + let (opened, page) = read_session(section, library, path, Some(space))?; let (scene, editor) = PageScene::from_page(page, &mut engine)?; session = Some(opened); (editor, Some((scene, [0.0; 2]))) } - Input::Notebook { root, cache } => { - let notebook = notebook::session::Notebook::open(&root, &cache)?; - let tabs = tabs(¬ebook); - if tabs.is_empty() { - return Err(format!("No readable sections in {}", root.display()).into()); + Input::Notebooks { locations, current } => { + notebooks = locations + .iter() + .map(|location| Arc::new(Library::notebook(location, &cache))) + .collect(); + for library in ¬ebooks { + if let Err(error) = &library.notebook { + eprintln!("Cannot open the notebook at {}: {error}", library.location); + } + } + let shown = notebooks + .iter() + .filter(|library| Some(&library.location) == current.as_ref()) + .chain(¬ebooks) + .find_map(|library| Some((library, library.first_section()?))); + match shown { + Some((library, path)) => { + let section = library.open(&path, notify(proxy.clone()))?; + let (opened, page) = + read_session(section, Arc::clone(library), path, None)?; + let (scene, editor) = PageScene::from_page(page, &mut engine)?; + session = Some(opened); + (editor, Some((scene, [0.0; 2]))) + } + None => { + let blank = TextDocument::new(vec![Paragraph::new( + String::new(), + Format::default(), + )])?; + (CanvasEditor::new(&mut engine, blank, 480.0)?, None) + } } - let opened = Library { - name: file_name(&root), - notebook: Some(notebook), - tabs, - cache, - }; - let section = opened.open(0, proxy.clone())?; - library = Some(Arc::new(opened)); - let (opened, page) = read_session(section, 0, None)?; - let (scene, editor) = PageScene::from_page(page, &mut engine)?; - session = Some(opened); - (editor, Some((scene, [0.0; 2]))) } }; let initial_date = editor.date().map(|date| date.timestamp()); @@ -466,8 +597,8 @@ impl State { window.request_redraw(); eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); let mut ui = Ui::new( - theme(window.theme().unwrap_or(winit::window::Theme::Dark)), - macos::double_click_interval(), + theme(platform::appearance(&window)), + platform::double_click_interval(), ); if !interface_font.is_empty() { let family = ui @@ -476,6 +607,11 @@ impl State { eprintln!("Using {family} for the interface"); } ui.set_focus(Some(page())); + for family in FONTS { + for (face, _) in engine.substitute(family).map_or(&[][..], |s| &s.faces) { + ui.preview_font(face.clone(), family); + } + } let mut fonts: Vec = engine .fonts .collection @@ -486,7 +622,9 @@ impl State { .collect(); fonts.sort_unstable_by_key(|name| name.to_lowercase()); fonts.dedup(); + let clipboard = platform::Clipboard::new(&window)?; let state = Self { + author: platform::user_name(), window, redraw: Arc::new(Redraw(proxy.clone())).into(), proxy, @@ -501,14 +639,28 @@ impl State { scene, [size.width, size.height], dpr, - macos::double_click_interval(), + platform::double_click_interval(), ), - library, + notebooks, session, + temporary, + settings, + cache, + sidebar: stored.sidebar, + folded: HashSet::new(), + dismissed: HashSet::new(), + title_focus: None, + menu: None, + renaming: None, + dragging: None, + dragging_entry: None, + templates: templates::View::Strip, + thumbnails: templates::Thumbnails::default(), filter: String::new(), pages_open: true, fonts, - app_icon: macos::app_icon((16.0 * dpr).round() as u32), + recent_fonts: stored.recent_fonts, + app_icon: platform::app_icon((16.0 * dpr).round() as u32), commands: Vec::new(), changed: false, moved: false, @@ -516,16 +668,19 @@ impl State { page_focused: true, pointer: [0.0; 2], strip_press: None, + strip_held: false, snapshot: None, initial, initial_date, initial_layouts, occluded: false, ime_allowed: true, - clipboard: arboard::Clipboard::new()?, + clipboard, loads: mpsc::channel(), loading: 0, opening: None, + places: HashMap::new(), + last_pages: HashMap::new(), access_adapter, accessibility: accessibility::Accessibility::default(), }; @@ -543,28 +698,15 @@ impl State { lap("open", start); let size = self.window.inner_size(); let scale = self.window.scale_factor() as f32; - self.ui.begin( + self.layout( [size.width as f32 / scale, size.height as f32 / scale], scale, - Instant::now(), - ); - let (section, open_tab, open_page) = self.build()?; - self.ui.end(); - self.edges(section, open_tab, open_page); + )?; lap("build", start); - if let Some(rect) = self.ui.rect(page()) { - let size = [ - ((rect[2] - rect[0]) * scale).round() as u32, - ((rect[3] - rect[1]) * scale).round() as u32, - ]; - if size != self.view.viewport.size { - let response = self.view.resized(size)?; - self.respond(response); - } - } self.window.set_cursor( - self.ui - .cursor() + platform::resize_direction(&self.window, self.pointer) + .map(CursorIcon::from) + .or_else(|| self.ui.cursor()) .unwrap_or_else(|| cursor_icon(self.view.cursor())), ); let ime = if self.ui.focused() == Some(page()) { @@ -600,13 +742,34 @@ impl State { Ok(()) } + /// Lays out the interface in a window `size` logical pixels big and fits the page to + /// its box. + fn layout(&mut self, size: [f32; 2], scale: f32) -> Result<(), Box> { + self.ui.begin(size, scale, Instant::now()); + let (section, open_tab, open_page) = self.build()?; + self.ui.end(); + self.edges(section, open_tab, open_page); + if let Some(rect) = self.ui.rect(page()) { + let size = [ + ((rect[2] - rect[0]) * scale).round() as u32, + ((rect[3] - rect[1]) * scale).round() as u32, + ]; + if size != self.view.viewport.size { + let response = self.view.resized(size)?; + self.respond(response); + } + } + Ok(()) + } + /// Builds the frame's boxes and returns the section's colours as they ease, the open /// section tab and the open page's tab, for the edges drawn once they are laid out. fn build(&mut self) -> Result<(ui::Section, Id, Option), Box> { - let target = self.ui.theme.section(match (&self.library, &self.session) { - (Some(library), Some(session)) => section_color(library.tabs[session.tab].color), - _ => section_color(None), - }); + let target = self.ui.theme.section(section_color( + self.session + .as_ref() + .and_then(|session| session.tabs[session.tab].color), + )); let mut ease = |part: &str, color: [f32; 4]| -> [f32; 4] { std::array::from_fn(|channel| { self.ui @@ -623,14 +786,40 @@ impl State { accent: ease("accent", target.accent), }; self.ui.theme.accent = section.accent; - [ - self.ui.theme.caret, - self.ui.theme.selection, - self.ui.theme.inactive_selection, - ] = macos::text_colors(&self.window); + // Elsewhere the theme holds the platform's colours. + #[cfg(target_os = "macos")] + { + [ + self.ui.theme.caret, + self.ui.theme.selection, + self.ui.theme.inactive_selection, + ] = platform::text_colors(&self.window); + } let theme = self.ui.theme.clone(); - self.title_bar(&theme); + if !platform::system_titlebar(&self.window) { + self.title_bar(&theme); + } + if self.session.is_none() && !self.temporary { + self.welcome(&theme); + return Ok((section, Id::ROOT, None)); + } self.toolbar(&theme)?; + self.ui.open( + "body", + Spec { + size: [fill(), fill()], + ..Spec::default() + }, + ); + self.sidebar(&theme); + self.ui.open( + "main", + Spec { + axis: Axis::Y, + size: [fill(), fill()], + ..Spec::default() + }, + ); self.ui.open( "tabs", Spec { @@ -647,34 +836,45 @@ impl State { ..Spec::default() }, ); - let (clicked, open_tab) = match (&self.library, &self.session) { - (Some(library), Some(session)) => { - let tabs: Vec<_> = library + let (clicked, open_tab) = match &self.session { + Some(session) => { + let tabs: Vec<_> = session .tabs .iter() .map(|tab| (tab.name.as_str(), section_color(tab.color))) .collect(); - ui::shell::section_tabs( + let (clicked, context, open_tab) = ui::shell::section_tabs( &mut self.ui, "sections", &tabs, session.tab, §ion, TAB_ROW, - ) + ); + let open = |tab: usize| { + Command::OpenSection(Arc::clone(&session.library), session.tabs[tab].path.clone()) + }; + if let Some((tab, point)) = context { + self.menu = Some(( + menus::Target::Section { + library: Arc::clone(&session.library), + path: session.tabs[tab].path.clone(), + }, + point, + )); + self.ui.open_popup(menus::id()); + } + (clicked.map(open), open_tab) + } + None => { + let tabs = [("Temporary page", section_color(None))]; + let shown = if self.temporary { &tabs[..] } else { &[] }; + let (_, _, open_tab) = + ui::shell::section_tabs(&mut self.ui, "sections", shown, 0, §ion, TAB_ROW); + (None, open_tab) } - _ => ui::shell::section_tabs( - &mut self.ui, - "sections", - &[("Temporary page", section_color(None))], - 0, - §ion, - TAB_ROW, - ), }; - if let Some(index) = clicked { - self.commands.push(Command::OpenSection(index)); - } + self.commands.extend(clicked); if self.session.is_some() { self.page_tools(&theme); } @@ -698,12 +898,21 @@ impl State { ..Spec::default() }, ); - if self - .session - .as_ref() - .is_some_and(|session| session.conflict) + if let Some(session) = &self.session + && let Some(bar) = session.bar() { - self.conflict_bar(&theme); + let changes = self.changes().len(); + let sections: Vec<&str> = session + .tabs + .iter() + .map(|tab| tab.name.as_str()) + .collect(); + let steps = [ + session.change.is_some_and(|at| at > 0), + session.change.map_or(changes > 0, |at| at + 1 < changes), + ]; + self.commands + .extend(conflict_bar(&mut self.ui, bar, §ions, steps)); } self.ui.open_as( page(), @@ -714,6 +923,7 @@ impl State { ..Spec::default() }, ); + self.template_strip(&theme); let scroll = self.view.scroll(); for (index, axis) in [Axis::X, Axis::Y].into_iter().enumerate() { if let Some(offset) = ui::scrollbar( @@ -741,6 +951,12 @@ impl State { self.ui.close(); let open_page = self.page_list(&theme, §ion); self.ui.close(); + self.ui.close(); + self.ui.close(); + self.context_menu(); + if self.renaming.is_some() && self.ui.focused() != Some(sidebar::rename_field()) { + self.ui.set_focus(Some(sidebar::rename_field())); + } Ok((section, open_tab, open_page)) } @@ -800,7 +1016,7 @@ impl State { // The frame's top corners share the page's centres; its top edge breaks where the // open tab stands on it. let [start, end] = [frame[0], frame[2]]; - let outer = FRAME + ROUNDING; + let outer = platform::CORNER_RADIUS; let strip = self.ui.theme.strip; self.ui.round_corners( &[ @@ -847,7 +1063,7 @@ impl State { self.ui.leaf( "lights", Spec { - size: [px(LIGHTS), px(1.0)], + size: [px(platform::LEADING), px(1.0)], ..Spec::default() }, ); @@ -856,11 +1072,11 @@ impl State { Spec { size: [fit(), px(TITLE)], image: self.app_icon.as_ref(), - text: Some( - self.library - .as_ref() - .map_or("Temporary page", |library| &library.name), - ), + text: Some(match &self.session { + Some(session) => &session.library.name, + None if self.temporary => "Temporary page", + None => "Snowbound", + }), ..Spec::default() }, ); @@ -883,6 +1099,7 @@ impl State { }, ); } + platform::window_controls(&mut self.ui, &self.window); self.ui.close(); } @@ -905,6 +1122,12 @@ impl State { tags: Vec::new(), }); let fonts = &self.fonts; + let recent_fonts = &self.recent_fonts; + let engine = &self.view.engine; + // Under the system's title bar the save status joins the toolbar. + let status = platform::system_titlebar(&self.window) + .then(|| self.session.as_ref().map(|session| session.status)) + .flatten(); let ui = &mut self.ui; let text = theme.text; let on = |toggle| state.toggles.contains(&toggle); @@ -940,19 +1163,47 @@ impl State { if ui::shell::combo(ui, "font", &font, 120.0).pressed { ui.open_popup(popup("font")); } - let items: Vec<_> = fonts + // Each family is named with the substitute it shows in, and picked by its + // own name; a family of None heads a group. Typing searches the full list. + let label = |name: &str| { + engine.substitute(name).map_or_else( + || name.to_owned(), + |substitute| format!("{} ({name})", substitute.name), + ) + }; + let mut choices: Vec<(String, Option<&str>, bool)> = Vec::new(); + if ui::popup::query(ui, popup("font")).is_none_or(str::is_empty) { + choices.extend(FONTS.map(|name| (label(name), Some(name), false))); + if !recent_fonts.is_empty() { + choices.push(("Recent".to_owned(), None, true)); + choices.extend( + recent_fonts + .iter() + .map(|name| (label(name), Some(name.as_str()), false)), + ); + } + } + let listed = choices.len(); + choices.extend(fonts.iter().enumerate().map(|(index, name)| { + (label(name), Some(name.as_str()), index == 0 && listed > 0) + })); + let items: Vec<_> = choices .iter() - .map(|name| ui::popup::Item { - text: name, - font: Some(name), - checked: *name == font, + .map(|(label, name, separated)| ui::popup::Item { + text: label, + font: *name, + current: *name == Some(font.as_str()), + heading: name.is_none(), + separated: *separated, ..Default::default() }) .collect(); let anchor = ui::Anchor::Over(ui.rect(combo).unwrap_or_default()); - if let Some(index) = ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font")) + if let Some(index) = + ui::popup::menu(ui, popup("font"), anchor, &items, Some("Font")) + && let Some(name) = choices[index].1 { - command = Some(Formatting::Font(fonts[index].clone())); + command = Some(Formatting::Font(name.to_owned())); } let combo = ui.id("size"); if ui::shell::combo(ui, "size", &size, 44.0).pressed { @@ -1191,6 +1442,18 @@ impl State { ..Spec::default() }, ); + if let Some(status) = status { + ui.leaf( + "status", + Spec { + size: [fit(), fill()], + text: Some(status), + color: Some(theme.text_dim), + center: true, + ..Spec::default() + }, + ); + } let zoom = self.view.zoom(); let label = format!("{:.0}%", zoom * 100.0); let mut chosen = None; @@ -1225,6 +1488,12 @@ impl State { self.respond(response); } if let Some(command) = command { + if let Formatting::Font(name) = &command { + self.recent_fonts.retain(|recent| recent != name); + self.recent_fonts.insert(0, name.clone()); + self.recent_fonts.truncate(RECENT_FONTS); + self.save_settings(); + } let response = self.view.format(command)?; self.respond(response); } @@ -1309,7 +1578,9 @@ impl State { self.ui.set_focus(Some(page())); } } - ui::shell::tool_button(&mut self.ui, "new", art::NEW_PAGE, theme.text, false); + if ui::shell::tool_button(&mut self.ui, "new", art::PLUS, theme.text, false).clicked { + self.commands.push(Command::NewPage { subpage: false }); + } let toggle = if self.pages_open { art::SIDEBAR_COLLAPSE } else { @@ -1321,41 +1592,6 @@ impl State { self.ui.close(); } - fn conflict_bar(&mut self, theme: &Theme) { - self.ui.open( - "conflict", - Spec { - size: [fill(), px(40.0)], - fill: Some(theme.panel), - pad: [12.0, 7.0], - gap: 8.0, - ..Spec::default() - }, - ); - self.ui.leaf( - "message", - Spec { - size: [fit(), px(26.0)], - text: Some("This page also changed on another computer."), - ..Spec::default() - }, - ); - self.ui.leaf( - "space", - Spec { - size: [fill(), px(1.0)], - ..Spec::default() - }, - ); - if ui::button(&mut self.ui, "mine", "Keep mine").clicked { - self.commands.push(Command::Resolve { keep_mine: true }); - } - if ui::button(&mut self.ui, "theirs", "Keep theirs").clicked { - self.commands.push(Command::Resolve { keep_mine: false }); - } - self.ui.close(); - } - /// The section's pages as tabs down the frame's right side, returning the open page's /// tab, which is the page's colour and joins it. fn page_list(&mut self, theme: &Theme, section: &ui::Section) -> Option { @@ -1373,55 +1609,24 @@ impl State { ..Spec::default() }, ); - let mut open = None; - for (space, title, level) in matching(&session.pages, &self.filter) { - let selected = *space == session.space; - let spec = Spec { - flags: Flags::CLICKABLE, - size: [px(PAGE_LIST), px(ROW)], - text: Some(if title.is_empty() { - "Untitled page" - } else { - title - }), - pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0], - ..Spec::default() - }; - let spec = if selected { - open = Some(self.ui.id(space)); - Spec { - color: Some(theme.paper_ink), - fill: Some(theme.paper), - inset: [0.0, 0.0, 0.0, ROW_GAP], - ..spec - } - } else { - // Tabs not open float free of the page as pills; only the open one joins it. - Spec { - color: Some(if title.is_empty() { - ui::mix(theme.ink, section.tab, 0.5) - } else { - theme.ink - }), - fill: Some(section.tab), - hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)), - radius: ROUNDING, - inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP], - // The label keeps its place as the tab opens and closes. - pad: [spec.pad[0] - PILL_MARGIN, 0.0], - ..spec - } - }; - if self.ui.leaf(space, spec).clicked && !selected { - self.commands.push(Command::OpenPage(*space)); - } + let rows = page_rows(&mut self.ui, theme, section, session, &self.filter); + self.commands.extend(rows.clicked.map(Command::OpenPage)); + if let Some((space, point)) = rows.context { + self.menu = Some((menus::Target::Page(space), point)); + self.ui.open_popup(menus::id()); } + let rect = self.ui.rect(panel).unwrap_or_default(); + self.drag_pages(rows.held, section, rect); self.ui.close(); - open + rows.open } /// Hands the page the events routed to its box, in its device pixels. fn page_events(&mut self, events: Vec) -> Result<(), Box> { + let read_only = self + .session + .as_ref() + .is_some_and(|session| session.conflict(session.space).is_some()); let scale = self.ui.scale(); let corner = self.ui.rect(page()).unwrap_or_default(); let device = |point: [f32; 2]| { @@ -1446,6 +1651,27 @@ impl State { } } ui::Event::Button { .. } | ui::Event::Ime(Ime::Enabled) => continue, + // A conflict page takes no typing; commands and caret moves still apply. + ui::Event::Ime(Ime::Preedit(..) | Ime::Commit(_)) if read_only => continue, + ui::Event::Key { ref key, .. } + if read_only + && !self.view.modifiers().command + && !matches!( + key, + Key::Named( + NamedKey::ArrowLeft + | NamedKey::ArrowRight + | NamedKey::ArrowUp + | NamedKey::ArrowDown + | NamedKey::Home + | NamedKey::End + | NamedKey::PageUp + | NamedKey::PageDown + ) + ) => + { + continue; + } ui::Event::Wheel(delta) => self.view.wheel([delta[0] * scale, delta[1] * scale])?, ui::Event::Key { key, text } => { let modifiers = self.view.modifiers(); @@ -1467,7 +1693,7 @@ impl State { self.respond(response); } if self.view.editor.marked_range().is_none() { - macos::clear_marked_text(&self.window); + platform::clear_marked_text(&self.window); } Ok(()) } @@ -1483,30 +1709,79 @@ impl State { fn apply(&mut self, command: Command) -> Result<(), Box> { match command { - Command::OpenSection(tab) => { - let library = Arc::clone(self.library.as_ref().ok_or("No sections to open")?); - let proxy = self.proxy.clone(); + Command::OpenSection(library, path) => { + let notify = notify(self.proxy.clone()); + let last = self.last_pages.get(&library.key(&path)).copied(); self.load(move || { - let section = library.open(tab, proxy)?; - let (session, page) = read_session(section, tab, None)?; + let section = library.open(&path, notify)?; + let (session, page) = read_session(section, library, path, last)?; Ok((Loaded::Section(Box::new(session)), page)) }); } Command::OpenPage(space) => { let session = self.session.as_ref().ok_or("No section is open")?; - let replica = Arc::clone(session.section.replica()); - self.load(move || Ok((Loaded::Page(space), replica.page(space)?))); + let read = session.reader(space); + self.load(move || Ok((Loaded::Page(space), read()?))); } - Command::Resolve { keep_mine } => self.resolve_conflict(keep_mine)?, + Command::Versions { page, show } => { + let session = self.session.as_mut().ok_or("No section is open")?; + session.shown = show.then_some(page); + let open = if show { + session.versions(page).first().map(|version| version.space) + } else { + (session.space != page).then_some(page) + }; + self.commands.extend(open.map(Command::OpenPage)); + } + Command::DeleteVersion { page, version } => { + let session = self.session.as_mut().ok_or("No section is open")?; + session.section.delete_pages(&[version])?; + session.status = "Saving"; + session.refresh_conflicts()?; + self.commands.push(Command::OpenPage(page)); + } + Command::CopyVersion { version, tab } => { + let session = self.session.as_ref().ok_or("No section is open")?; + let page = session.section.page(version)?; + if tab == session.tab { + session.section.import_page(&page, &self.author)?; + } else { + let library = Arc::clone(&session.library); + let path = session.tabs[tab].path.clone(); + let (notify, author) = (notify(self.proxy.clone()), self.author.clone()); + std::thread::spawn(move || { + let copied = library.open(&path, notify).and_then(|section| { + section.import_page(&page, &author)?; + Ok(section.close()?) + }); + if let Err(error) = copied { + eprintln!("Copying the page failed: {error}"); + } + }); + } + } + Command::SelectChange { forward } => self.select_change(forward)?, + Command::OpenNotebook => { + if let Some(path) = platform::pick_notebook("Open Notebook") { + self.open_path(&path); + } + } + Command::NewNotebook => self.new_notebook()?, + Command::CloseNotebook(library) => self.close_notebook(&library), + Command::Structure(library, change) => self.restructure(library, change), + Command::NewPage { subpage } => self.new_page(subpage)?, + Command::DeletePages(pages) => self.delete_pages(pages)?, + Command::Pages(edits) => self.edit_pages(edits)?, + Command::Template(choice) => self.apply_template(choice)?, Command::Page(Request::EditDate(field)) => self.edit_date(field)?, Command::Page(Request::Copy(text)) => self.clipboard.set_text(text)?, Command::Page(Request::Paste) => { let text = self.clipboard.get_text()?; - let language = canvas::language::lcid(&macos::input_language()); + let language = canvas::language::lcid(&platform::input_language()); let response = self.view.paste(&text, language)?; self.respond(response); } - Command::Page(Request::CharacterPalette) => macos::show_character_palette(), + Command::Page(Request::CharacterPalette) => platform::show_character_palette(), } Ok(()) } @@ -1532,7 +1807,13 @@ impl State { if id != self.loading { continue; } - let (loaded, page) = loaded?; + let (loaded, page) = match loaded { + Ok(loaded) => loaded, + Err(error) => { + platform::alert("Couldn't open", &error); + continue; + } + }; let (scene, editor) = PageScene::from_page(page, &mut self.view.engine)?; self.opening = Some(Opening { loaded, @@ -1558,19 +1839,51 @@ impl State { } // The page shown so far keeps what was typed while the next one loaded. self.persist()?; + if let Some(session) = &self.session { + let key = session.library.key(&session.tabs[session.tab].path); + self.places.insert((key.clone(), session.space), self.view.place()); + self.last_pages.insert(key, session.space); + } match opening.loaded { Loaded::Section(session) => { + // A notebook read again after a change replaces the one it was. + match self + .notebooks + .iter_mut() + .find(|library| library.location == session.library.location) + { + Some(library) => *library = Arc::clone(&session.library), + None => self.notebooks.push(Arc::clone(&session.library)), + } self.session = Some(*session); self.filter.clear(); + self.save_settings(); } Loaded::Page(space) => { let session = self.session.as_mut().ok_or("No section is open")?; session.space = space; - session.refresh_conflict()?; + session.change = None; + // Pages created, moved or deleted since the list was read. + session.pages = session.section.pages()?; } } - self.view.open(opening.editor, Some(opening.scene)); - self.opened() + let place = self.session.as_ref().and_then(|session| { + let key = session.library.key(&session.tabs[session.tab].path); + self.places.get(&(key, session.space)).copied() + }); + self.view.open(opening.editor, Some(opening.scene), place); + self.opened()?; + if self.title_focus.take().is_some_and(|space| { + self.session + .as_ref() + .is_some_and(|session| session.space == space) + }) && let Some(title) = self.view.editor.outlines().iter().find(|outline| outline.title) + { + self.view.editor.focus_outline(title.id)?; + let response = self.view.focus_text()?; + self.respond(response); + } + Ok(()) } /// Follows a page shown in place of another. @@ -1620,6 +1933,7 @@ impl State { .unwrap_or_default(); format!("{} · {file}", session.title()) } + None if !self.temporary => "Snowbound".to_owned(), None => { let editor = &self.view.editor; if !editor.active_outline().title { @@ -1655,8 +1969,12 @@ impl State { }; let timestamp = date.timestamp(); self.view.editor.finish_composition(); - macos::clear_marked_text(&self.window); - if let Some((timestamp, text)) = macos::edit_date(timestamp, field)? { + platform::clear_marked_text(&self.window); + let title = match field { + DateField::Date => "Change Page Date", + DateField::Time => "Change Page Time", + }; + if let Some((timestamp, text)) = platform::edit_date(timestamp, field, title)? { let response = self.view.change_date(timestamp, text)?; self.respond(response); self.window.request_redraw(); @@ -1664,6 +1982,58 @@ impl State { Ok(()) } + /// The open conflict page's conflicting changes in page order: each text's outline, + /// paragraph position and length. + fn changes(&self) -> Vec<(ExGuid, usize, u32)> { + let Some((_, version)) = self + .session + .as_ref() + .and_then(|session| session.conflict(session.space)) + else { + return Vec::new(); + }; + let marked = |id: &ExGuid| version.objects.contains(id); + let mut changes = Vec::new(); + for outline in self.view.editor.outlines() { + for (at, node) in outline.document().text_nodes().enumerate() { + let Some(text) = node.text() else { + continue; + }; + if marked(&node.id) || marked(&text.id) { + let length = text.text.text().encode_utf16().count() as u32; + changes.push((outline.id, at, length)); + } + } + } + changes + } + + /// Selects the next or previous conflicting change, as OneNote's conflict menu does. + fn select_change(&mut self, forward: bool) -> Result<(), Box> { + let changes = self.changes(); + let Some(session) = &mut self.session else { + return Ok(()); + }; + let next = match (session.change, forward) { + (None, true) => 0, + (Some(at), true) => at + 1, + (Some(at), false) if at > 0 => at - 1, + _ => return Ok(()), + }; + let Some(&(outline, paragraph, length)) = changes.get(next) else { + return Ok(()); + }; + session.change = Some(next); + let at = |offset| canvas::document::TextPosition { paragraph, offset }; + self.view.editor.focus_outline(outline)?; + self.view.editor.select([at(0), at(length)].into())?; + self.ui.set_focus(Some(page())); + let response = self.view.focus_text()?; + self.respond(response); + self.window.request_redraw(); + Ok(()) + } + /// Hands the section the ops the editor recorded since the last call, as one edit; the /// section thread stores it. fn persist(&mut self) -> Result<(), Box> { @@ -1677,9 +2047,14 @@ impl State { if ops.is_empty() { return Ok(()); } + if session.conflict(session.space).is_some() { + // A conflict page is read-only: it returns to what is stored. + self.stale = true; + return Ok(()); + } let space = session.space; session.section.apply( - "snowbound", + &self.author, onestore::op::Edit { at: filetime(), ops: ops @@ -1704,7 +2079,7 @@ impl State { let Some(session) = &self.session else { return Ok(()); }; - let page = session.section.page(session.space)?; + let page = session.reader(session.space)()?; let response = self.view.refresh(page)?; self.respond(response); self.title(); @@ -1720,62 +2095,30 @@ impl State { // What the editor recorded since builds on the refused edit. let _ = self.view.editor.take_ops(); self.view.editor.cancel_composition(&mut self.view.engine)?; - macos::clear_marked_text(&self.window); + platform::clear_marked_text(&self.window); if let Some(session) = &mut self.session { session.status = "Not saving"; } self.refresh()?; - macos::change_not_saved(); + platform::alert( + "Change not saved", + "The page now shows what was last saved. Your text is on the clipboard to paste back.", + ); Ok(()) } - /// Ends the conflict on this page: `keep_mine` publishes the local page over the remote - /// change, otherwise the remote page replaces it. - fn resolve_conflict(&mut self, keep_mine: bool) -> Result<(), Box> { - self.persist()?; - let Some(session) = &mut self.session else { - return Ok(()); - }; - let Some(conflict) = session - .section - .conflict()? - .filter(|conflict| conflict.space == session.space) - else { - return Ok(()); - }; - let resolution = if keep_mine { - notebook::Resolution::Mine - } else { - notebook::Resolution::Theirs - }; - session.section.resolve(conflict.id, resolution)?; - session.status = "Saving"; - session.refresh_conflict()?; - if keep_mine { - self.window.request_redraw(); - Ok(()) - } else { - self.refresh() - } - } - /// Applies what the section reported since the last poll. fn synced(&mut self) -> Result<(), Box> { let Some(session) = &mut self.session else { return Ok(()); }; - let shown = (session.status, session.conflict); + let shown = session.status; let mut listed = false; let mut changed = false; let mut rejected = None; - let mut conflicted = session.conflict; for event in session.section.events() { use notebook::session::Event; session.status = match event { - Event::Refreshed => { - listed = true; - continue; - } Event::Changed(spaces) => { listed = true; changed |= spaces.contains(&session.space); @@ -1793,13 +2136,6 @@ impl State { status: notebook::EditStatus::Published { .. }, .. } => "Saved", - Event::Attempt { - status: notebook::EditStatus::Conflict(_), - .. - } => { - conflicted = true; - "Conflict" - } Event::Attempt { .. } => "Saving", Event::Unreachable(_) => "Offline", Event::Failed(error) => { @@ -1808,23 +2144,22 @@ impl State { } }; } - // Reading the queue waits on the section thread, so only a conflict shown or - // reported sends the frame thread there. - if conflicted { - session.refresh_conflict()?; - } - if listed || changed || shown != (session.status, session.conflict) { + if listed || changed || shown != session.status { self.window.request_redraw(); } if listed { + let back = session.shown; session.pages = session.section.pages()?; - if !session - .pages - .iter() - .any(|(space, ..)| *space == session.space) - { - let first = session.pages.first().ok_or("The section has no pages")?.0; - self.commands.push(Command::OpenPage(first)); + session.refresh_conflicts()?; + let listed = |space: ExGuid| session.pages.iter().any(|(page, ..)| *page == space); + if !listed(session.space) && session.conflict(session.space).is_none() { + // A page removed elsewhere, or a conflict page deleted elsewhere, which + // returns to its page. + let open = back + .filter(|page| listed(*page)) + .or(session.pages.first().map(|(page, ..)| *page)) + .ok_or("The section has no pages")?; + self.commands.push(Command::OpenPage(open)); return Ok(()); } } @@ -1950,7 +2285,7 @@ impl State { } wgpu::CurrentSurfaceTexture::Lost => { self.surface = self.instance.create_surface(self.window.clone())?; - macos::configure_presentation(&self.surface); + platform::configure_presentation(&self.surface); self.surface.configure(&self.renderer.device, &self.config); self.window.request_redraw(); return Ok(()); @@ -1972,7 +2307,7 @@ impl State { self.window.pre_present_notify(); let start = Instant::now(); self.renderer.queue.present(frame); - macos::commit_presentation(&self.window); + platform::commit_presentation(&self.window); lap("present", start); trace_input(&"Present submitted"); if reconfigure { @@ -2113,11 +2448,28 @@ impl State { Ok(()) } - /// Queues input for the next frame. A press on the strip moves the window at once, - /// while the platform still holds the press; a double press zooms it. + /// Queues input for the next frame. A press on the strip moves the window: at once on + /// macOS, while AppKit still holds the press, and elsewhere once the pointer moves, as + /// the window manager's move would swallow a second click; a double press zooms it. fn input(&mut self, event: ui::Event) { if let ui::Event::PointerMoved(point) = event { self.pointer = point; + if std::mem::take(&mut self.strip_held) { + let _ = self.window.drag_window(); + } + } + if let ui::Event::Button { pressed: false, .. } = event { + self.strip_held = false; + } + if let ui::Event::Button { + button: MouseButton::Left, + pressed: true, + .. + } = event + && let Some(direction) = platform::resize_direction(&self.window, self.pointer) + { + let _ = self.window.drag_resize_window(direction); + return; } if let ui::Event::Button { button: MouseButton::Left, @@ -2127,13 +2479,15 @@ impl State { && self.ui.box_at(self.pointer) == Some(strip()) { let double = self.strip_press.is_some_and(|last| { - at.saturating_duration_since(last) <= macos::double_click_interval() + at.saturating_duration_since(last) <= platform::double_click_interval() }); self.strip_press = (!double).then_some(at); if double { - macos::zoom(&self.window); - } else { + platform::zoom(&self.window); + } else if cfg!(target_os = "macos") { let _ = self.window.drag_window(); + } else { + self.strip_held = true; } return; } @@ -2149,20 +2503,328 @@ fn theme(appearance: winit::window::Theme) -> Theme { } } -fn file_name(path: &Path) -> String { - path.canonicalize() - .ok() - .as_deref() - .and_then(Path::file_name) - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_default() -} - /// A section's colour as linear RGBA; sections without one take OneNote's default blue. fn section_color(color: Option) -> [f32; 4] { color.map_or(draw::srgb(0x8a, 0xa8, 0xe4), canvas::gpu::colorref) } +/// OneNote's information bar for conflicting changes, clicked anywhere: above a page with +/// conflict pages it shows or hides them; above a conflict page it opens OneNote's menu, +/// whose previous and next changes `steps` enables and whose Copy Page To lists `sections`. +fn conflict_bar(ui: &mut Ui, bar: Bar, sections: &[&str], steps: [bool; 2]) -> Option { + let row = ui.open( + "conflict", + Spec { + flags: Flags::CLICKABLE, + size: [fill(), children()], + fill: Some(draw::srgb(0xff, 0xee, 0xc2)), + hover_fill: Some(draw::srgb(0xff, 0xe4, 0xa6)), + pad: [12.0, 6.0], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "icon", + Spec { + size: [fit(), px(16.0)], + icon: Some(art::CONFLICT), + color: Some(draw::srgb(0xc0, 0x48, 0x20)), + ..Spec::default() + }, + ); + // OneNote 2010's words (corpus/conflict-page/native), a sentence to a line. + let [said, action] = match bar { + Bar::Page { shown, .. } => [ + "This page has changes that could not be merged during synchronization.", + if shown { + "Click here to hide versions of the page with unmerged changes." + } else { + "Click here to show versions of the page with unmerged changes." + }, + ], + Bar::Version { .. } => [ + "Conflicting changes are highlighted in red. This page cannot be edited, but you can copy changes to the primary page.", + "Click here for more options.", + ], + }; + ui.open( + "message", + Spec { + axis: Axis::Y, + size: [fill(), children()], + ..Spec::default() + }, + ); + for (part, text) in [("said", said), ("action", action)] { + ui.leaf( + part, + Spec { + size: [fit(), px(18.0)], + text: Some(text), + color: Some(draw::srgb(0x20, 0x20, 0x20)), + ..Spec::default() + }, + ); + } + let action = ui.id("action"); + ui.close(); + ui.close(); + let clicked = ui.signal(row).clicked; + let menu = Id::ROOT.child("conflict-menu"); + let copy = Id::ROOT.child("conflict-copy"); + // The menu opens under the line the click asks for, as wide as its items. + let [left, top, _, bottom] = ui.rect(action).unwrap_or_default(); + let anchor = ui::Anchor::Below([left, top, left, bottom]); + match bar { + Bar::Page { page, shown } => clicked.then_some(Command::Versions { page, show: !shown }), + Bar::Version { page, version } => { + if clicked { + ui.open_popup(menu); + } + let items = [ + ui::popup::Item { + text: "Delete Conflict Page", + ..Default::default() + }, + ui::popup::Item { + text: "Copy Page To…", + disabled: sections.is_empty(), + ..Default::default() + }, + ui::popup::Item { + text: "Select Previous Conflicting Change", + disabled: !steps[0], + separated: true, + ..Default::default() + }, + ui::popup::Item { + text: "Select Next Conflicting Change", + disabled: !steps[1], + ..Default::default() + }, + ui::popup::Item { + text: "Collapse Conflict Pages", + separated: true, + ..Default::default() + }, + ]; + let chosen = match ui::popup::menu(ui, menu, anchor, &items, None) { + Some(0) => Some(Command::DeleteVersion { page, version }), + Some(1) => { + ui.open_popup(copy); + None + } + Some(2) => Some(Command::SelectChange { forward: false }), + Some(3) => Some(Command::SelectChange { forward: true }), + Some(_) => Some(Command::Versions { page, show: false }), + None => None, + }; + let targets: Vec<_> = sections + .iter() + .map(|name| ui::popup::Item { + text: name, + ..Default::default() + }) + .collect(); + let tab = ui::popup::menu(ui, copy, anchor, &targets, None); + chosen.or(tab.map(|tab| Command::CopyVersion { version, tab })) + } + } +} + +/// What the page list's rows were asked this frame. +#[derive(Default)] +struct Rows { + /// The open page's tab. + open: Option, + clicked: Option, + /// The page whose context menu was asked for, and where. + context: Option<(ExGuid, [f32; 2])>, + /// The page held down, which a drag moves. + held: Option, +} + +/// The page list's rows for the pages matching `filter`, a page's conflict pages beneath it +/// while shown. +fn page_rows( + ui: &mut Ui, + theme: &Theme, + section: &ui::Section, + session: &Session, + filter: &str, +) -> Rows { + let mut rows = Rows::default(); + for (space, title, level) in matching(&session.pages, filter) { + let versions = session.versions(*space); + let tab = PageTab { + label: if title.is_empty() { + "Untitled page" + } else { + title + }, + dim: title.is_empty(), + indent: level.saturating_sub(1), + conflicted: !versions.is_empty(), + }; + let selected = *space == session.space; + if selected { + rows.open = Some(ui.id(space)); + } + let signal = page_tab(ui, theme, section, space, &tab, selected); + if signal.clicked && !selected { + rows.clicked = Some(*space); + } + if let Some(point) = signal.context { + rows.context = Some((*space, point)); + } + if signal.dragging { + rows.held = Some(*space); + } + if session.shown != Some(*space) { + continue; + } + // OneNote lists a page's conflict pages beneath it, each by its date and whose + // version it is. + let muted = ui::Section { + tab: ui::mix(section.tab, theme.chip, 0.6), + ..*section + }; + for version in versions { + let label = match version.created { + Some(created) => format!("{} {}", platform::short_date(created), version.user), + None => version.user.clone(), + }; + let tab = PageTab { + label: &label, + dim: false, + indent: level.saturating_sub(1), + conflicted: false, + }; + let selected = version.space == session.space; + if selected { + rows.open = Some(ui.id(version.space)); + } + if page_tab(ui, theme, &muted, &version.space, &tab, selected).clicked && !selected { + rows.clicked = Some(version.space); + } + } + } + rows +} + +/// A row of the page list. +struct PageTab<'a> { + label: &'a str, + /// An untitled page's placeholder label. + dim: bool, + indent: u32, + /// The page has conflict pages, which OneNote marks at the tab's end. + conflicted: bool, +} + +/// A page's tab down the frame's right side: the open one is the page's colour and joins +/// it, the others float free of it as pills. +fn page_tab( + ui: &mut Ui, + theme: &Theme, + section: &ui::Section, + id: &ExGuid, + tab: &PageTab, + selected: bool, +) -> ui::Signal { + let pad = 10.0 + 16.0 * tab.indent as f32; + let spec = Spec { + flags: Flags::CLICKABLE, + size: [px(PAGE_LIST), px(ROW)], + pad: [pad, 0.0], + ..Spec::default() + }; + let (spec, color) = if selected { + let spec = Spec { + fill: Some(theme.paper), + inset: [0.0, 0.0, 0.0, ROW_GAP], + ..spec + }; + (spec, theme.paper_ink) + } else { + let spec = Spec { + fill: Some(section.tab), + hover_fill: Some(ui::mix(section.tab, section.accent, 0.3)), + radius: ROUNDING, + inset: [PILL_MARGIN, 0.0, 6.0, ROW_GAP], + // The label keeps its place as the tab opens and closes. + pad: [pad - PILL_MARGIN, 0.0], + ..spec + }; + let color = if tab.dim { + ui::mix(theme.ink, section.tab, 0.5) + } else { + theme.ink + }; + (spec, color) + }; + let row = ui.open(id, spec); + ui.leaf( + "label", + Spec { + size: [fill(), px(ROW - ROW_GAP)], + text: Some(tab.label), + color: Some(color), + ..Spec::default() + }, + ); + if tab.conflicted { + ui.leaf( + "conflict", + Spec { + size: [fit(), px(ROW - ROW_GAP)], + icon: Some(art::CONFLICT), + color: Some(draw::srgb(0xc0, 0x48, 0x20)), + pad: [8.0, 0.0], + ..Spec::default() + }, + ); + } + ui.close(); + ui.signal(row) +} + +/// `page` with its conflict objects marked as OneNote shows a conflict page's: a band +/// across each conflicting paragraph's outline. +fn highlighted(mut page: Page, objects: &[ExGuid]) -> Page { + use onestore::page::{PageObject, PageParagraph, ParagraphContent}; + fn mark(paragraphs: &mut [PageParagraph], objects: &[ExGuid]) { + for paragraph in paragraphs { + match &mut paragraph.content { + ParagraphContent::Table(table) => { + for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { + mark(&mut cell.paragraphs, objects); + } + } + ParagraphContent::Text(text) + if objects.contains(¶graph.id) || objects.contains(&text.id) => + { + paragraph.format.highlight = Some(CONFLICTING); + } + _ => {} + } + } + } + for object in &mut page.objects { + match object { + PageObject::Outline(outline) => mark(&mut outline.paragraphs, objects), + PageObject::Title(title) => { + for outline in &mut title.outlines { + mark(&mut outline.paragraphs, objects); + } + } + _ => {} + } + } + page +} + /// The pages whose titles contain `filter`, ignoring case. fn matching<'a>( pages: &'a [(ExGuid, String, u32)], @@ -2212,50 +2874,42 @@ fn divider(ui: &mut Ui, part: &str, theme: &Theme) { ); } -/// Section tabs for a notebook's readable top-level sections, in its order. -fn tabs(notebook: ¬ebook::session::Notebook) -> Vec { - notebook - .catalog() - .sections - .iter() - .filter_map(|section| match §ion.state { - notebook::discover::SectionState::Readable { name, color } => Some(Tab { - name: name.clone().unwrap_or_else(|| { - Path::new(§ion.path) - .file_stem() - .map(|stem| stem.to_string_lossy().into_owned()) - .unwrap_or_default() - }), - path: section.path.clone(), - color: *color, - }), - _ => None, - }) - .collect() -} - -/// The session for `section` showing `space`, or its first page, and that page. +/// The session for `section`, at catalog `path` in `library`, showing `space` or its first +/// page, and that page. fn read_session( section: notebook::session::Section, - tab: usize, + library: Arc, + path: String, space: Option, ) -> Result<(Session, Page), Box> { + let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder); + let tabs = library.tabs(folder); + let tab = tabs + .iter() + .position(|tab| tab.path == path) + .ok_or("The section is not in its notebook")?; let pages = section.pages()?; - let space = match space { - Some(space) => space, - None => pages.first().ok_or("The section has no pages")?.0, - }; + let space = space + .filter(|space| pages.iter().any(|(candidate, ..)| candidate == space)) + .or(pages.first().map(|(space, ..)| *space)) + .ok_or("The section has no pages")?; let page = section.page(space)?; - let mut session = Session { - section, - tab, - pages, - space, - status: "", - conflict: false, - }; - session.refresh_conflict()?; - Ok((session, page)) + let conflicts = section.conflicts()?; + Ok(( + Session { + section, + library, + tabs, + tab, + pages, + space, + status: "", + conflicts, + shown: None, + change: None, + }, + page, + )) } /// The text the editor shows, outline by outline, without hidden field codes. @@ -2315,8 +2969,12 @@ impl App { .outlines() .iter() .map(|outline| (&outline.id, outline.document()))) - }) || macos::discard_changes() - { + }) || platform::confirm( + "Discard this page?", + "This temporary page has no saved copy. Closing it will discard your edits.", + "Keep Editing", + "Discard Changes", + ) { event_loop.exit(); } } @@ -2396,7 +3054,7 @@ impl ApplicationHandler for App { } }; if was_marked && state.view.editor.marked_range().is_none() { - macos::clear_marked_text(&state.window); + platform::clear_marked_text(&state.window); } if let Err(error) = result { eprintln!("{error}"); @@ -2413,8 +3071,17 @@ impl ApplicationHandler for App { self.input.take().unwrap(), &self.substitutes, std::mem::take(&mut self.interface_font), + self.screenshot.is_none(), + self.settings.take().unwrap_or_default(), + self.cache.clone(), )) { - Ok(state) => self.state = Some(state), + Ok(mut state) => match &self.screenshot { + Some(prefix) => { + self.startup_error = state.screenshot(prefix).err(); + event_loop.exit(); + } + None => self.state = Some(state), + }, Err(error) => { self.startup_error = Some(error); event_loop.exit(); @@ -2466,7 +3133,7 @@ impl ApplicationHandler for App { } WindowEvent::ScaleFactorChanged { scale_factor, .. } => { state.renderer.clear_glyph_cache(); - state.app_icon = macos::app_icon((16.0 * scale_factor).round() as u32); + state.app_icon = platform::app_icon((16.0 * scale_factor).round() as u32); let response = state.view.scale_factor_changed(scale_factor as f32)?; state.respond(response); state.window.request_redraw(); @@ -2593,7 +3260,10 @@ fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box Ok(ModifiersState::SHIFT), - "command" => Ok(ModifiersState::SUPER), + "control" => Ok(ModifiersState::CONTROL), + // The shortcut modifier, as ui::edit_modifiers reads it. + "command" if cfg!(target_os = "macos") => Ok(ModifiersState::SUPER), + "command" => Ok(ModifiersState::CONTROL), _ => Err(format!("Unknown modifier {name}")), }) .try_fold(ModifiersState::empty(), |all, one| one.map(|one| all | one))?, @@ -2640,6 +3310,8 @@ fn main() -> Result<(), Box> { let mut section = None; let mut notebook = None; let mut cache = None; + let mut settings_file = None; + let mut screenshot = None; while let Some(arg) = args.next() { if arg == "--substitute-font" { substitutes.push(PathBuf::from( @@ -2691,6 +3363,15 @@ fn main() -> Result<(), Box> { cache = Some(PathBuf::from( args.next().ok_or("Provide a directory after --cache.")?, )); + } else if arg == "--settings" { + settings_file = Some(PathBuf::from( + args.next().ok_or("Provide a settings file after --settings.")?, + )); + } else if arg == "--screenshot" { + screenshot = Some(PathBuf::from( + args.next() + .ok_or("Provide a PNG path prefix after --screenshot.")?, + )); } else if arg == "--reference" || arg == "--page" { if reference.is_some() { return Err("Only one page can be opened.".into()); @@ -2725,7 +3406,7 @@ fn main() -> Result<(), Box> { } if positional.len() > 2 { return Err( - "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]..." + "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]..." .into(), ); } @@ -2745,15 +3426,28 @@ fn main() -> Result<(), Box> { }); let cache = match cache { Some(cache) => cache, - None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?) - .join("Library/Caches/snowbound"), + None => platform::cache_dir().ok_or("HOME is not set.")?, }; - let input = if let Some(root) = notebook { - Input::Notebook { root, cache } - } else if let Some((file, title)) = section { - Input::Section { file, title, cache } + let settings_file = settings_file.or_else(settings::default_path); + let saved = settings_file + .as_deref() + .map(settings::Settings::load) + .unwrap_or_default(); + let input = if let Some((file, title)) = section { + Input::Section { file, title } } else if editable { Input::Page(reference.unwrap()) + } else if positional.is_empty() && reference.is_none() { + let mut locations = saved.notebooks.clone(); + let mut current = saved.current.clone(); + if let Some(root) = notebook { + let location = std::path::absolute(root)?.to_string_lossy().into_owned(); + if !locations.contains(&location) { + locations.push(location.clone()); + } + current = Some(location); + } + Input::Notebooks { locations, current } } else { Input::Notes { document: TextDocument::new( @@ -2765,15 +3459,19 @@ fn main() -> Result<(), Box> { reference, } }; - let event_loop = macos::event_loop()?; + let event_loop = platform::event_loop(screenshot.is_some())?; if let Some(script) = std::env::var_os("SNOWBOUND_REPLAY") { replay(std::fs::read_to_string(script)?, event_loop.create_proxy())?; } let mut app = App { proxy: event_loop.create_proxy(), input: Some(input), + // A screenshot leaves the settings as it found them. + settings: Some((settings_file.filter(|_| screenshot.is_none()), saved)), + cache, substitutes, interface_font, + screenshot, state: None, startup_error: None, }; diff --git a/crates/snowbound/src/manage.rs b/crates/snowbound/src/manage.rs new file mode 100644 index 0000000000000000000000000000000000000000..cb621da20455d3d616eb309338696503bcedd89b --- /dev/null +++ b/crates/snowbound/src/manage.rs @@ -0,0 +1,386 @@ +//! Changes to notebooks, sections, section groups and pages, as OneNote 2010 makes them: +//! structure through `notebook::session::Notebook`, pages as ops on the open section. + +use crate::{Command, Library, Loaded, State, notify, platform, read_session}; +use notebook::session::Notebook; +use onestore::{ + ExGuid, PageCreation, PageEdit, + op::{Edit, Op, PageOp, SectionOp}, + page::{Image, Page, PageObject}, +}; +use std::{error::Error, sync::Arc}; + +/// A change to a notebook's sections and groups, by catalog path. +pub enum Structure { + NewSection { folder: String }, + NewGroup { folder: String }, + Rename { path: String, name: String }, + /// Moves a section or group to the recycle bin. + Delete { path: String }, + /// Moves a section or group into another folder. + Move { path: String, folder: String }, + /// Orders a folder's sections and groups. + Reorder { folder: String, paths: Vec }, +} + +/// Notebook colours for new notebooks, in turn: OneNote 2010's section colours. +const NOTEBOOK_COLORS: [u32; 3] = [0x00e4a88a, 0x0078b0f6, 0x00bba4d5]; + +/// `current`, the path of a section, after `from` moved to `to`: a section moved itself, +/// or one inside a moved group. +fn follow(current: &str, from: &str, to: &str) -> String { + match current.strip_prefix(from) { + Some("") => to.to_owned(), + Some(rest) if rest.starts_with('/') => format!("{to}{rest}"), + _ => current.to_owned(), + } +} + +/// The first of `base`, `base 2`, `base 3`… that `taken` does not hold, as OneNote names +/// new sections ("New Section 1", …) and groups. +fn unused(taken: &[String], base: &str, numbered: bool) -> String { + (1..) + .map(|n| match (numbered, n) { + (false, 1) => base.to_owned(), + _ => format!("{base} {n}"), + }) + .find(|name| !taken.iter().any(|taken| taken.eq_ignore_ascii_case(name))) + .expect("Some number is free") +} + +impl State { + /// A page OneNote 2010 would create now: titled, with its date and time. + fn dated_page(&self, before: Option) -> Result> { + let creation = PageCreation::new(before, Some(""), &self.author)?; + let [date, time] = platform::date_text(creation.created()); + Ok(creation.dated(&date, &time)?) + } + + /// Adds a page at the end of the open section, or a subpage after the open page, and + /// opens it with its title ready for typing, as OneNote's New Page and New Subpage do. + pub(crate) fn new_page(&mut self, subpage: bool) -> Result<(), Box> { + self.persist()?; + let session = self.session.as_ref().ok_or("No section is open")?; + // A subpage follows the open page and the subpages already under it. + let (before, level) = if subpage { + let at = session + .pages + .iter() + .position(|(space, ..)| *space == session.space) + .ok_or("The open page is not listed")?; + let level = session.pages[at].2; + let next = session.pages[at + 1..] + .iter() + .find(|(.., below)| *below <= level) + .map(|(space, ..)| *space); + (next, (level + 1).min(3)) + } else { + (None, 1) + }; + let creation = self.dated_page(None)?; + let space = creation.space(); + let mut ops = vec![Op::Section(SectionOp::Create(creation))]; + if subpage { + ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to( + space, before, level, + )?]))); + } + let session = self.session.as_mut().ok_or("No section is open")?; + session.section.apply( + &self.author, + Edit { + at: crate::filetime(), + ops, + }, + )?; + session.status = "Saving"; + self.title_focus = Some(space); + self.commands.push(Command::OpenPage(space)); + Ok(()) + } + + /// Gives the open page `choice`'s background: a template's art in place of any it + /// had, or a page colour instead of art. + pub(crate) fn apply_template( + &mut self, + choice: crate::templates::Choice, + ) -> Result<(), Box> { + use crate::templates::Choice; + self.persist()?; + let session = self.session.as_ref().ok_or("No section is open")?; + let space = session.space; + let page = session.section.page(space)?; + let mut ops: Vec = page + .objects + .iter() + .filter(|object| matches!(object, PageObject::Image(image) if image.background)) + .map(|object| PageOp::Delete { + object: object.id(), + }) + .collect(); + match choice { + Choice::Template(name) => { + let template = + canvas::template::find(name).ok_or("That template is not available")?; + if page.color.is_some() { + ops.push(PageOp::Color(None)); + } + // Art lies under everything else on the page, first in its order. + let under = page + .objects + .iter() + .find(|object| !matches!(object, PageObject::Image(image) if image.background)) + .map(PageObject::id); + for art in template.art { + let bytes = canvas::gpu::page::template_picture(art.art) + .ok_or("That template's art is missing")?; + ops.push(PageOp::Add { + object: PageObject::Image(Image { + id: onestore::page::text::new_id()?, + layout: onestore::document::Layout { + x: Some(art.position[0]), + y: Some(art.position[1]), + max_width: art.size.map(|size| size[0]), + max_height: art.size.map(|size| size[1]), + width_set_by_user: art.size.map(|_| true), + ..Default::default() + }, + size: art.size, + bytes: Some(bytes.into()), + alt: None, + background: true, + }), + before: under, + }); + } + } + Choice::Color(index) => { + ops.push(PageOp::Color(Some(canvas::template::PAGE_COLORS[index].1))) + } + Choice::More | Choice::Dismiss | Choice::Colors => return Ok(()), + } + session.section.apply( + &self.author, + Edit { + at: crate::filetime(), + ops: ops.into_iter().map(|op| Op::Page { space, op }).collect(), + }, + )?; + self.refresh() + } + + /// Asks where to create a notebook and creates it, as File, New does in OneNote. + pub(crate) fn new_notebook(&mut self) -> Result<(), Box> { + let Some(root) = platform::pick_new("New Notebook", "My Notebook") else { + return Ok(()); + }; + let page = self.dated_page(None)?; + let color = NOTEBOOK_COLORS[self.notebooks.len() % NOTEBOOK_COLORS.len()]; + let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); + self.load(move || { + let location = std::path::absolute(&root)?.to_string_lossy().into_owned(); + let notebook = Notebook::create(&location, &cache, color, &page)?; + let library = Arc::new(Library::open_notebook(&location, notebook, &cache)); + let path = library + .first_section() + .ok_or("The new notebook has no section")?; + let section = library.open(&path, notify)?; + let (session, page) = read_session(section, library, path, None)?; + Ok((Loaded::Section(Box::new(session)), page)) + }); + Ok(()) + } + + /// Closes `library`: its files stay, and the sidebar and the next launch leave it out. + pub(crate) fn close_notebook(&mut self, library: &Arc) { + self.notebooks.retain(|open| !Arc::ptr_eq(open, library)); + if self + .session + .as_ref() + .is_some_and(|session| Arc::ptr_eq(&session.library, library)) + { + self.persist().unwrap_or_else(|error| eprintln!("{error}")); + self.session = None; + self.templates = crate::templates::View::Strip; + match self + .notebooks + .iter() + .find_map(|open| Some((Arc::clone(open), open.first_section()?))) + { + Some((open, path)) => self.commands.push(Command::OpenSection(open, path)), + None => self.title(), + } + } + self.save_settings(); + } + + /// Changes `library`'s sections and groups on a thread of its own, then shows the + /// section the change leaves open: a new or moved one, or the one shown before. + pub(crate) fn restructure(&mut self, library: Arc, change: Structure) { + let page = match change { + Structure::NewSection { .. } => match self.dated_page(None) { + Ok(page) => Some(page), + Err(error) => return platform::alert("Couldn't add the section", &error.to_string()), + }, + _ => None, + }; + let current = self + .session + .as_ref() + .filter(|session| Arc::ptr_eq(&session.library, &library)) + .map(|session| session.tabs[session.tab].path.clone()); + let (cache, notify) = (self.cache.clone(), notify(self.proxy.clone())); + self.load(move || { + let mut notebook = Notebook::open(&library.location, &cache)?; + let names = |notebook: &Notebook, folder: &str| -> Vec { + let mut folders = vec![notebook.catalog()]; + while let Some(candidate) = folders.pop() { + if candidate.path == folder { + return candidate + .sections + .iter() + .map(|section| section.path.clone()) + .chain(candidate.groups.iter().map(|group| group.path.clone())) + .map(|path| { + let name = path.rsplit('/').next().unwrap_or_default(); + name.strip_suffix(".one").unwrap_or(name).to_owned() + }) + .collect(); + } + folders.extend(&candidate.groups); + } + Vec::new() + }; + let open = match change { + Structure::NewSection { folder } => { + let name = unused(&names(¬ebook, &folder), "New Section", true); + let page = page.ok_or("The new section has no page")?; + Some(notebook.create_section(&folder, &name, &page)?) + } + Structure::NewGroup { folder } => { + let name = unused(&names(¬ebook, &folder), "New Section Group", false); + notebook.create_group(&folder, &name)?; + current + } + Structure::Rename { path, name } => { + let renamed = notebook.rename(&path, &name)?; + current.map(|current| follow(¤t, &path, &renamed)) + } + Structure::Delete { path } => { + notebook.delete(&path)?; + current + } + Structure::Move { path, folder } => { + let moved = notebook.move_entry(&path, &folder)?; + current.map(|current| follow(¤t, &path, &moved)) + } + Structure::Reorder { folder, paths } => { + let paths: Vec<&str> = paths.iter().map(String::as_str).collect(); + notebook.reorder(&folder, &paths)?; + current + } + }; + let library = Arc::new(Library::open_notebook(&library.location, notebook, &cache)); + let path = open + .filter(|path| library.contains(path)) + .or_else(|| library.first_section()) + .ok_or("The notebook has no sections left")?; + let section = library.open(&path, notify)?; + let (session, page) = read_session(section, library, path, None)?; + Ok((Loaded::Section(Box::new(session)), page)) + }); + } + + /// Deletes pages of the open section as OneNote 2010 does: each goes to the notebook's + /// recycle bin, then leaves the section. A section left without pages gains a new one. + pub(crate) fn delete_pages(&mut self, spaces: Vec) -> Result<(), Box> { + self.persist()?; + let session = self.session.as_ref().ok_or("No section is open")?; + let pages: Vec = spaces + .iter() + .map(|space| session.section.page(*space)) + .collect::>()?; + let remaining: Vec = session + .pages + .iter() + .map(|(space, ..)| *space) + .filter(|space| !spaces.contains(space)) + .collect(); + // OneNote shows the page after the first deleted one, or the one before it. + let at = session + .pages + .iter() + .position(|(space, ..)| spaces.contains(space)) + .unwrap_or_default(); + let next = session.pages[at..] + .iter() + .chain(session.pages[..at].iter().rev()) + .map(|(space, ..)| *space) + .find(|space| remaining.contains(space)); + let mut ops = vec![Op::Section(SectionOp::Delete(spaces))]; + let next = match next { + Some(next) => next, + None => { + let creation = self.dated_page(None)?; + let space = creation.space(); + ops.push(Op::Section(SectionOp::Create(creation))); + space + } + }; + let notebook = match &session.library.notebook { + Ok(Some(_)) => Some((session.library.location.clone(), self.cache.clone())), + _ => None, + }; + let replica = Arc::clone(session.section.replica()); + let author = self.author.clone(); + self.load(move || { + if let Some((location, cache)) = notebook { + Notebook::open(&location, &cache)?.recycle_pages(&pages, &author)?; + } + replica.apply( + &author, + Edit { + at: crate::filetime(), + ops, + }, + )?; + Ok((Loaded::Page(next), replica.page(next)?)) + }); + Ok(()) + } + + /// Moves or indents pages of the open section, in order. + pub(crate) fn edit_pages(&mut self, edits: Vec) -> Result<(), Box> { + self.persist()?; + let session = self.session.as_mut().ok_or("No section is open")?; + session.section.apply( + &self.author, + Edit { + at: crate::filetime(), + ops: vec![Op::Section(SectionOp::Pages(edits))], + }, + )?; + session.status = "Saving"; + session.pages = session.section.pages()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn moved_sections_are_followed_and_new_names_take_the_next_number() { + assert_eq!(follow("Group/Inner.one", "Group", "Kept"), "Kept/Inner.one"); + assert_eq!(follow("Group.one", "Group", "Kept"), "Group.one"); + assert_eq!(follow("A.one", "A.one", "B/A.one"), "B/A.one"); + let taken = ["New Section 1".to_owned(), "new section 2".to_owned()]; + assert_eq!(unused(&taken, "New Section", true), "New Section 3"); + assert_eq!(unused(&[], "New Section Group", false), "New Section Group"); + assert_eq!( + unused(&["New Section Group".to_owned()], "New Section Group", false), + "New Section Group 2" + ); + } +} diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs new file mode 100644 index 0000000000000000000000000000000000000000..eb4f769f507e643677dc26110be2425acd67f08c --- /dev/null +++ b/crates/snowbound/src/menus.rs @@ -0,0 +1,234 @@ +//! Context menus on pages, sections, section groups and notebooks, with OneNote 2010's +//! commands in its words, and moving pages by dragging their tabs. + +use crate::{Command, Library, State, manage::Structure, platform}; +use onestore::{ExGuid, PageEdit}; +use std::sync::Arc; +use ui::{Anchor, Id, popup::Item}; + +/// What a context menu was opened on. +pub enum Target { + Page(ExGuid), + Section { library: Arc, path: String }, + Group { library: Arc, path: String }, + Notebook(Arc), +} + +pub fn id() -> Id { + Id::ROOT.child("context-menu") +} + +/// The catalog folder holding `path`. +fn folder(path: &str) -> String { + path.rsplit_once('/') + .map_or(String::new(), |(folder, _)| folder.to_owned()) +} + +impl State { + /// The open context menu, and what its choice does. + pub(crate) fn context_menu(&mut self) { + let Some((target, point)) = &self.menu else { + return; + }; + if !self.ui.popup_open(id()) { + self.menu = None; + return; + } + let item = |text| Item { + text, + ..Default::default() + }; + let items: Vec = match target { + Target::Page(space) => { + let pages = self + .session + .as_ref() + .map_or(&[][..], |session| &session.pages[..]); + let at = pages.iter().position(|(listed, ..)| listed == space); + let level = at.map_or(1, |at| pages[at].2); + let above = at + .and_then(|at| at.checked_sub(1)) + .map_or(0, |above| pages[above].2); + vec![ + item("Delete"), + Item { + separated: true, + ..item("New Page") + }, + Item { + separated: true, + disabled: level > above || level >= 3, + ..item("Make Subpage") + }, + Item { + disabled: level <= 1, + ..item("Promote Subpage") + }, + ] + } + Target::Section { .. } | Target::Group { .. } => vec![ + item("Rename"), + item("Delete"), + Item { + separated: true, + ..item("New Section") + }, + item("New Section Group"), + ], + Target::Notebook(_) => vec![ + item("New Section"), + item("New Section Group"), + Item { + separated: true, + ..item("Close This Notebook") + }, + ], + }; + let Some(chosen) = ui::popup::menu(&mut self.ui, id(), Anchor::Point(*point), &items, None) + else { + return; + }; + let Some((target, _)) = self.menu.take() else { + return; + }; + let command = match (target, items[chosen].text) { + (Target::Page(space), "Delete") => Some(Command::DeletePages(vec![space])), + (Target::Page(_), "New Page") => Some(Command::NewPage { subpage: false }), + (Target::Page(space), text) => self.session.as_ref().and_then(|session| { + let level = session + .pages + .iter() + .find(|(listed, ..)| *listed == space)? + .2; + let level = if text == "Make Subpage" { + level + 1 + } else { + level - 1 + }; + Some(Command::Pages(vec![PageEdit::set_level(space, level).ok()?])) + }), + ( + Target::Section { library, path } | Target::Group { library, path }, + "Rename", + ) => { + let name = path.rsplit('/').next().unwrap_or_default(); + self.renaming = Some(crate::sidebar::Renaming { + library, + name: name.strip_suffix(".one").unwrap_or(name).to_owned(), + path, + }); + self.sidebar = true; + None + } + (Target::Section { library, path }, "Delete") => platform::confirm( + "Are you sure you want to move this section to this notebook's Recycle Bin?", + &path, + "Cancel", + "Delete", + ) + .then(|| Command::Structure(library, Structure::Delete { path })), + (Target::Group { library, path }, "Delete") => platform::confirm( + "Are you sure you want to move the sections in this section group to this notebook's Recycle Bin?", + path.rsplit('/').next().unwrap_or_default(), + "Cancel", + "Delete", + ) + .then(|| Command::Structure(library, Structure::Delete { path })), + (Target::Section { library, path }, text) => { + let folder = folder(&path); + Some(Command::Structure(library, new(text, folder))) + } + (Target::Group { library, path }, text) => { + Some(Command::Structure(library, new(text, path))) + } + (Target::Notebook(library), "Close This Notebook") => { + Some(Command::CloseNotebook(library)) + } + (Target::Notebook(library), text) => { + Some(Command::Structure(library, new(text, String::new()))) + } + }; + self.commands.extend(command); + } + + /// Moves a page by dragging its tab: while `held`, a line shows where it would go; let + /// go, it moves there at its level, or under a page at most one level above it. + /// `panel` is the page list's rectangle. + pub(crate) fn drag_pages( + &mut self, + held: Option, + section: &ui::Section, + panel: [f32; 4], + ) { + let Some(session) = &self.session else { + return; + }; + if held.is_some() && !self.filter.is_empty() { + return; + } + let dragged = held.or(self.dragging.take()); + self.dragging = held; + let (Some(space), Some([_, y])) = (dragged, self.ui.pointer()) else { + return; + }; + // Rows other than the dragged one, with where each lies. + let rows: Vec<(ExGuid, u32, [f32; 4])> = session + .pages + .iter() + .filter(|(listed, ..)| *listed != space) + .filter_map(|(listed, _, level)| Some((*listed, *level, self.ui.rect(self.ui.id(listed))?))) + .collect(); + let Some(level) = session + .pages + .iter() + .find(|(listed, ..)| *listed == space) + .map(|(.., level)| *level) + else { + return; + }; + let at = rows + .iter() + .position(|(.., rect)| y < (rect[1] + rect[3]) / 2.0) + .unwrap_or(rows.len()); + let before = rows.get(at).map(|(listed, ..)| *listed); + let level = at + .checked_sub(1) + .map_or(1, |above| level.min(rows[above].1 + 1)); + let stays = session + .pages + .iter() + .skip_while(|(listed, ..)| *listed != space) + .nth(1) + .map(|(listed, ..)| *listed) + == before; + if stays { + return; + } + if held.is_some() { + // The line lies between the rows the page would go between. + let line = match (at.checked_sub(1).map(|above| rows[above].2), rows.get(at)) { + (_, Some((.., below))) => below[1], + (Some(above), None) => above[3], + (None, None) => return, + } - panel[1]; + let indent = 10.0 + 16.0 * (level - 1) as f32; + self.ui.mark( + [indent, line - 1.5, crate::PAGE_LIST - 6.0, line + 1.5], + section.accent, + 1.5, + ); + self.dragging = held; + } else if let Ok(edit) = PageEdit::move_to(space, before, level) { + self.commands.push(Command::Pages(vec![edit])); + } + } +} + +/// The new section or group a menu's `text` asks for in `folder`. +fn new(text: &str, folder: String) -> Structure { + if text == "New Section Group" { + Structure::NewGroup { folder } + } else { + Structure::NewSection { folder } + } +} diff --git a/crates/snowbound/src/screenshot.rs b/crates/snowbound/src/screenshot.rs new file mode 100644 index 0000000000000000000000000000000000000000..55a620143597bfe0e2aecad13f8e32d465ad8eeb --- /dev/null +++ b/crates/snowbound/src/screenshot.rs @@ -0,0 +1,123 @@ +//! `--screenshot`: the window drawn offscreen at a fixed size in each appearance. On macOS +//! the parts AppKit draws over it, the traffic lights and the rounded corners, are painted +//! in; elsewhere the app draws its whole window. + +use super::*; +use winit::window::Theme as Appearance; + +/// The window in points, drawn at `SCALE` pixels per point. +const SIZE: [f32; 2] = [1440.0, 900.0]; +const SCALE: f32 = 2.0; +/// A standard window's close, minimize and zoom buttons as AppKit places them beside a +/// transparent title bar: centres in points, fill and rim. +#[cfg(target_os = "macos")] +const LIGHTS: [(f32, [u8; 3], [u8; 3]); 3] = [ + (14.0, [0xff, 0x5f, 0x57], [0xe2, 0x46, 0x3f]), + (34.0, [0xfe, 0xbc, 0x2e], [0xe1, 0xa1, 0x16]), + (54.0, [0x28, 0xc8, 0x40], [0x12, 0xac, 0x28]), +]; + +impl State { + /// Writes `PREFIX-light.png` and `PREFIX-dark.png`, the page unfocused so no caret shows. + pub(crate) fn screenshot(&mut self, prefix: &Path) -> Result<(), Box> { + [self.config.width, self.config.height] = SIZE.map(|side| (side * SCALE) as u32); + self.renderer.clear_glyph_cache(); + self.app_icon = platform::app_icon((16.0 * SCALE) as u32); + let response = self.view.scale_factor_changed(SCALE)?; + self.respond(response); + self.ui.set_focus(None); + if let Some(title) = self + .view + .editor + .outlines() + .iter() + .find(|outline| outline.title) + { + self.view.editor.focus_outline(title.id)?; + } + for (name, appearance) in [("light", Appearance::Light), ("dark", Appearance::Dark)] { + self.window.set_theme(Some(appearance)); + self.ui.theme = theme(appearance); + // The page's box sizes the view whose pictures settle. + self.layout(SIZE, SCALE)?; + let paper = canvas::gpu::Paper { + color: self.ui.theme.paper, + ink: self.ui.theme.paper_ink, + }; + if let Some((scene, _)) = &mut self.view.scene { + scene.settle(Some(&self.view.editor), self.view.viewport.scale, paper); + } + // Later frames lay out with earlier frames' measurements while colours ease. + let deadline = Instant::now() + std::time::Duration::from_secs(2); + loop { + self.layout(SIZE, SCALE)?; + if !self.ui.wants_frame() || Instant::now() > deadline { + break; + } + std::thread::sleep(std::time::Duration::from_millis(16)); + } + let path = PathBuf::from(format!("{}-{name}.png", prefix.display())); + self.snapshot(&path)?; + #[cfg(target_os = "macos")] + window_frame(&path, appearance)?; + } + Ok(()) + } +} + +/// Paints the traffic lights into the PNG at `path`, rounds its corners to transparency +/// and edges them with the window's hairline. +#[cfg(target_os = "macos")] +fn window_frame(path: &Path, appearance: Appearance) -> Result<(), Box> { + let mut reader = + png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path)?)).read_info()?; + let mut pixels = vec![0; reader.output_buffer_size().ok_or("Snapshot too large")?]; + let info = reader.next_frame(&mut pixels)?; + let hairline = match appearance { + Appearance::Light => [0, 0, 0, 56], + Appearance::Dark => [255, 255, 255, 56], + }; + // Coverage of a pixel whose centre lies `distance` pixels outside a shape's edge. + let inside = |distance: f32| (0.5 - distance).clamp(0.0, 1.0); + let blend = |pixel: &mut [u8], [r, g, b, a]: [u8; 4], coverage: f32| { + let weight = coverage * f32::from(a) / 255.0; + for (channel, value) in pixel.iter_mut().zip([r, g, b]) { + *channel = + (f32::from(*channel) * (1.0 - weight) + f32::from(value) * weight).round() as u8; + } + }; + let [width, height] = [info.width as f32, info.height as f32]; + let radius = platform::CORNER_RADIUS * SCALE; + for (index, pixel) in pixels.chunks_exact_mut(4).enumerate() { + let x = (index as u32 % info.width) as f32 + 0.5; + let y = (index as u32 / info.width) as f32 + 0.5; + for (centre, fill, rim) in LIGHTS { + let distance = (x - centre * SCALE).hypot(y - TITLE / 2.0 * SCALE); + blend( + pixel, + [rim[0], rim[1], rim[2], 255], + inside(distance - 6.0 * SCALE), + ); + blend( + pixel, + [fill[0], fill[1], fill[2], 255], + inside(distance - 5.5 * SCALE), + ); + } + // Signed distance to the window's rounded rectangle. + let [dx, dy] = [ + (x - width / 2.0).abs() - (width / 2.0 - radius), + (y - height / 2.0).abs() - (height / 2.0 - radius), + ]; + let edge = dx.max(0.0).hypot(dy.max(0.0)) + dx.max(dy).min(0.0) - radius; + blend(pixel, hairline, inside((edge + 0.5).abs() - 0.5)); + pixel[3] = (f32::from(pixel[3]) * inside(edge)).round() as u8; + } + let partial = path.with_extension("partial"); + let mut encoder = png::Encoder::new(std::fs::File::create(&partial)?, info.width, info.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header()?.write_image_data(&pixels)?; + std::fs::rename(partial, path)?; + Ok(()) +} diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..2c950b35f9748f57f61c97f4a61c6b42407559bd --- /dev/null +++ b/crates/snowbound/src/settings.rs @@ -0,0 +1,109 @@ +//! What the app keeps between launches: the open notebooks, the sidebar and recent fonts, +//! as JSON in the platform's settings directory. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Settings { + /// Open notebooks in the sidebar's order, as `Location::key` names them. + pub notebooks: Vec, + /// The notebook shown last. + pub current: Option, + /// Whether the notebook sidebar is expanded. + pub sidebar: bool, + /// Fonts picked from the font box, latest first. + pub recent_fonts: Vec, +} + +impl Settings { + /// The settings at `path`; missing or unreadable ones are the defaults, reported. + pub fn load(path: &Path) -> Self { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|error| { + eprintln!("Ignoring the settings in {}: {error}", path.display()); + Self::default() + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self::default(), + Err(error) => { + eprintln!("Ignoring the settings in {}: {error}", path.display()); + Self::default() + } + } + } + + /// Replaces the file at `path` whole, so a crash leaves the old settings or the new. + pub fn save(&self, path: &Path) -> std::io::Result<()> { + if let Some(folder) = path.parent() { + std::fs::create_dir_all(folder)?; + } + let partial = path.with_extension("partial"); + std::fs::write(&partial, serde_json::to_vec_pretty(self)?)?; + std::fs::rename(partial, path) + } +} + +impl crate::State { + /// Keeps what the next launch restores: the open notebooks, the one shown, the sidebar + /// and recent fonts. A section opened on its own is not kept. + pub(crate) fn save_settings(&self) { + let Some(path) = &self.settings else { + return; + }; + let kept = |library: &&std::sync::Arc| !matches!(library.notebook, Ok(None)); + let settings = Settings { + notebooks: self + .notebooks + .iter() + .filter(kept) + .map(|library| library.location.clone()) + .collect(), + current: self + .session + .as_ref() + .map(|session| &session.library) + .filter(kept) + .map(|library| library.location.clone()), + sidebar: self.sidebar, + recent_fonts: self.recent_fonts.clone(), + }; + if let Err(error) = settings.save(path) { + eprintln!("Cannot save the settings in {}: {error}", path.display()); + } + } +} + +/// Where the settings live unless `--settings` names a file. +pub fn default_path() -> Option { + Some(crate::platform::settings_dir()?.join("settings.json")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_round_trip_and_tolerate_missing_fields() { + let directory = std::env::temp_dir().join(format!("snowbound-settings-{}", std::process::id())); + let path = directory.join("nested/settings.json"); + assert_eq!(Settings::load(&path), Settings::default()); + let settings = Settings { + notebooks: vec!["/notebooks/Personal".into(), "smb://server/share/Work".into()], + current: Some("/notebooks/Personal".into()), + sidebar: true, + recent_fonts: vec!["Georgia".into()], + }; + settings.save(&path).unwrap(); + assert_eq!(Settings::load(&path), settings); + std::fs::write(&path, br#"{"sidebar": true, "unknown": 1}"#).unwrap(); + assert_eq!( + Settings::load(&path), + Settings { + sidebar: true, + ..Settings::default() + } + ); + std::fs::remove_dir_all(&directory).unwrap(); + } +} diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs new file mode 100644 index 0000000000000000000000000000000000000000..43462e36d6a10d923b0eef0ed824fd439df41d32 --- /dev/null +++ b/crates/snowbound/src/sidebar.rs @@ -0,0 +1,739 @@ +//! OneNote 2010's navigation bar: the open notebooks with their sections and section +//! groups, down the window's left, beside the section tabs. + +use crate::{art, library::Library, menus::Target, section_color}; +use notebook::discover::{Folder, SectionState}; +use std::{collections::HashSet, sync::Arc}; +use ui::{Axis, Event, Flags, Id, Signal, Spec, Theme, Ui, fill, fit, px}; +use winit::keyboard::{Key, NamedKey}; + +pub const WIDTH: f32 = 220.0; +/// The collapsed sidebar: a rail as wide as its button, which stays where it is as the +/// sidebar opens. +pub const RAIL: f32 = crate::FRAME + ui::shell::TOOL + 2.0; +const ROW: f32 = 24.0; +/// How far each level of the tree sits inside its parent. +const INDENT: f32 = 16.0; + +/// What the sidebar was asked to do. +pub enum Action { + /// Expands or collapses the sidebar. + Toggle, + /// Shows a notebook where it was left. + Notebook(usize), + /// Opens a notebook's section by catalog path. + Open { notebook: usize, path: String }, + /// Folds or unfolds a notebook's or section group's rows, by `fold_key`. + Fold(String), + NewNotebook, + OpenNotebook, + /// A row's context menu, opened here. + Menu(Target, [f32; 2]), + /// Ends renaming, with the name typed or without. + Renamed(bool), +} + +/// A section or group being renamed in place, and the name typed so far. +pub struct Renaming { + pub library: Arc, + pub path: String, + pub name: String, +} + +/// The field a row being renamed shows. +pub fn rename_field() -> Id { + Id::ROOT.child("rename") +} + +/// Names a notebook's or group's rows for folding them. +pub fn fold_key(library: &Library, path: &str) -> String { + format!("{}\n{path}", library.location) +} + +/// What the tree's rows read and what they were asked. +struct Tree<'a> { + theme: &'a Theme, + /// The open section's notebook index and catalog path. + open: Option<(usize, &'a str)>, + folded: &'a HashSet, + renaming: Option<&'a mut Renaming>, + action: Option, + /// Each section and group row: where it lies, and what it is. + rows: Vec, + /// The section or group held down, which a drag moves. + held: Option, +} + +/// A section or group row, for dragging one onto or between the others. +#[derive(Clone, PartialEq)] +pub struct Entry { + pub notebook: usize, + pub path: String, + pub group: bool, + pub rect: [f32; 4], +} + +impl Entry { + fn folder(&self) -> &str { + self.path.rsplit_once('/').map_or("", |(folder, _)| folder) + } +} + +/// The sidebar under a header row `header` tall, listing `notebooks` with the open section +/// marked. +pub fn sidebar( + ui: &mut Ui, + theme: &Theme, + notebooks: &[Arc], + open: Option<(usize, &str)>, + folded: &HashSet, + renaming: Option<&mut Renaming>, + header: f32, +) -> (Option, Vec, Option) { + let mut tree = Tree { + theme, + open, + folded, + renaming, + action: None, + rows: Vec::new(), + held: None, + }; + ui.open( + "header", + Spec { + size: [fill(), px(header)], + pad: [crate::FRAME, (header - ui::shell::TOOL) / 2.0], + gap: 6.0, + ..Spec::default() + }, + ); + if ui::shell::tool_button(ui, "toggle", art::NOTEBOOK, theme.text, false).clicked { + tree.action = Some(Action::Toggle); + } + ui.leaf( + "title", + Spec { + size: [fill(), px(ui::shell::TOOL)], + text: Some("Notebooks"), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + ui.close(); + ui.open( + "notebooks", + Spec { + flags: Flags::SCROLL | Flags::CLIP, + axis: Axis::Y, + size: [fill(), fill()], + pad: [4.0, 2.0], + ..Spec::default() + }, + ); + for (index, library) in notebooks.iter().enumerate() { + let key = fold_key(library, ""); + let unfolded = !folded.contains(&key); + let (row, fold) = tree_row( + ui, + &mut tree, + ("notebook", index), + Row { + label: &library.name, + icon: Leading::Icon(art::NOTEBOOK), + depth: 0, + dim: library.notebook.is_err(), + fold: Some(unfolded), + selected: None, + renamed: false, + }, + ); + if fold { + tree.action = Some(Action::Fold(key)); + } else if row.clicked && open.is_none_or(|(notebook, _)| notebook != index) { + tree.action = Some(Action::Notebook(index)); + } + if let Some(point) = row.context { + tree.action = Some(Action::Menu(Target::Notebook(Arc::clone(library)), point)); + } + if unfolded && let Some(catalog) = library.catalog() { + folder(ui, &mut tree, library, index, catalog, 1); + } + } + ui.close(); + ui.open( + "footer", + Spec { + axis: Axis::Y, + size: [fill(), ui::children()], + pad: [4.0, 6.0], + ..Spec::default() + }, + ); + for (part, icon, label, chosen) in [ + ("new", art::PLUS, "New Notebook", Action::NewNotebook), + ("open", art::NOTEBOOK, "Open Existing", Action::OpenNotebook), + ] { + let (row, _) = tree_row( + ui, + &mut tree, + part, + Row { + label, + icon: Leading::Icon(icon), + depth: 0, + dim: false, + fold: None, + selected: None, + renamed: false, + }, + ); + if row.clicked { + tree.action = Some(chosen); + } + } + ui.close(); + (tree.action, tree.rows, tree.held) +} + +/// A notebook's or group's sections, then its groups, as OneNote lists them. +fn folder( + ui: &mut Ui, + tree: &mut Tree, + library: &Arc, + notebook: usize, + folder: &Folder, + depth: u32, +) { + let open = tree + .open + .filter(|(index, _)| *index == notebook) + .map(|(_, path)| path); + let renamed = |tree: &Tree, path: &str| { + tree.renaming + .as_ref() + .is_some_and(|renaming| Arc::ptr_eq(&renaming.library, library) && renaming.path == path) + }; + for section in &folder.sections { + let (name, color, readable) = match §ion.state { + SectionState::Readable { name, color } => ( + crate::library::section_name(§ion.path, name), + *color, + true, + ), + _ => ( + crate::library::section_name(§ion.path, &None), + None, + false, + ), + }; + let renaming = renamed(tree, §ion.path); + let (row, _) = tree_row( + ui, + tree, + ("section", notebook, §ion.path), + Row { + label: &name, + icon: Leading::Swatch(section_color(color)), + depth, + dim: !readable, + fold: None, + selected: (open == Some(section.path.as_str())).then_some(section_color(color)), + renamed: renaming, + }, + ); + if row.clicked && readable && open != Some(section.path.as_str()) { + tree.action = Some(Action::Open { + notebook, + path: section.path.clone(), + }); + } + entry(ui, tree, notebook, §ion.path, false, &row); + if let Some(point) = row.context { + let target = Target::Section { + library: Arc::clone(library), + path: section.path.clone(), + }; + tree.action = Some(Action::Menu(target, point)); + } + } + for group in &folder.groups { + // OneNote lists deleted sections under its own command, not as a group. + if crate::library::recycle_bin(group) { + continue; + } + let key = fold_key(library, &group.path); + let unfolded = !tree.folded.contains(&key); + let name = group.path.rsplit('/').next().unwrap_or_default(); + let renaming = renamed(tree, &group.path); + let (row, fold) = tree_row( + ui, + tree, + ("group", notebook, &group.path), + Row { + label: name, + icon: Leading::Icon(art::SECTION_GROUP), + depth, + dim: false, + fold: Some(unfolded), + selected: None, + renamed: renaming, + }, + ); + if row.clicked || fold { + tree.action = Some(Action::Fold(key)); + } + entry(ui, tree, notebook, &group.path, true, &row); + if let Some(point) = row.context { + let target = Target::Group { + library: Arc::clone(library), + path: group.path.clone(), + }; + tree.action = Some(Action::Menu(target, point)); + } + if unfolded { + self::folder(ui, tree, library, notebook, group, depth + 1); + } + } +} + +/// Records a section or group row for dragging. +fn entry(ui: &Ui, tree: &mut Tree, notebook: usize, path: &str, group: bool, row: &Signal) { + let Some(rect) = ui.rect(ui.id(("section", notebook, path))) + .or_else(|| ui.rect(ui.id(("group", notebook, path)))) + else { + return; + }; + let entry = Entry { + notebook, + path: path.to_owned(), + group, + rect, + }; + if row.dragging { + tree.held = Some(entry.clone()); + } + tree.rows.push(entry); +} + +enum Leading { + Icon(&'static [&'static str]), + /// A section's colour, as OneNote's section icon shows it. + Swatch([f32; 4]), +} + +struct Row<'a> { + label: &'a str, + icon: Leading, + depth: u32, + dim: bool, + /// Whether the rows beneath are unfolded, for rows that fold. + fold: Option, + /// The open section's colour. + selected: Option<[f32; 4]>, + /// The row is being renamed: its label is the tree's rename field. + renamed: bool, +} + +/// One row of the tree: its signal, and whether its fold arrow was clicked. +fn tree_row(ui: &mut Ui, tree: &mut Tree, part: impl std::hash::Hash, row: Row) -> (Signal, bool) { + let theme = tree.theme; + let lit = row + .selected + .map(|color| ui::mix(theme.section(color).tab, theme.base, 0.35)); + let id = ui.open( + part, + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(ROW)], + fill: lit, + hover_fill: Some(ui::mix(lit.unwrap_or(theme.strip), theme.hover(), 0.6)), + radius: 4.0, + pad: [6.0 + INDENT * row.depth as f32, 0.0], + gap: 6.0, + ..Spec::default() + }, + ); + let color = if row.dim { theme.text_dim } else { theme.text }; + match row.icon { + Leading::Icon(icon) => { + ui.leaf( + "icon", + Spec { + size: [px(16.0), px(ROW)], + icon: Some(icon), + color: Some(color), + ..Spec::default() + }, + ); + } + Leading::Swatch(swatch) => { + ui.open( + "swatch", + Spec { + size: [px(16.0), px(ROW)], + pad: [3.0, 6.0], + ..Spec::default() + }, + ); + ui.leaf( + "color", + Spec { + size: [fill(), fill()], + fill: Some(theme.section(swatch).frame[0]), + border: Some(theme.section(swatch).edge), + radius: 2.0, + ..Spec::default() + }, + ); + ui.close(); + } + } + match (row.renamed, tree.renaming.as_deref_mut()) { + (true, Some(renaming)) => { + let field = ui::text_field( + ui, + rename_field(), + &mut renaming.name, + "", + Spec { + size: [fill(), px(ROW - 4.0)], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 3.0, + pad: [4.0, 0.0], + ..Spec::default() + }, + ); + for event in &field.events { + if let Event::Key { + key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)), + .. + } = event + { + tree.action = Some(Action::Renamed(*key == NamedKey::Enter)); + } + } + } + _ => { + ui.leaf( + "label", + Spec { + size: [fill(), px(ROW)], + text: Some(row.label), + color: Some(color), + ..Spec::default() + }, + ); + } + } + let folded = row.fold.is_some_and(|unfolded| { + ui.leaf( + "fold", + Spec { + flags: Flags::CLICKABLE, + size: [fit(), px(ROW)], + icon: Some(if unfolded { + art::CHEVRON_UP + } else { + ui::shell::CHEVRON + }), + color: Some(theme.text_dim), + ..Spec::default() + }, + ) + .clicked + }); + ui.close(); + (ui.signal(id), folded) +} + +impl crate::State { + /// The sidebar left of the section tabs, easing open and shut. + pub(crate) fn sidebar(&mut self, theme: &Theme) { + if self.temporary { + return; + } + let width = self + .ui + .animate(self.ui.id("sidebar"), if self.sidebar { WIDTH } else { RAIL }); + self.ui.open( + "sidebar", + Spec { + flags: Flags::CLIP, + size: [px(width), fill()], + fill: Some(theme.strip), + ..Spec::default() + }, + ); + // The rows keep their width as the sidebar eases, so their labels never reflow. + let rows_id = self.ui.id("rows"); + self.ui.open( + "rows", + Spec { + axis: Axis::Y, + size: [px(WIDTH), fill()], + ..Spec::default() + }, + ); + let open = self.session.as_ref().and_then(|session| { + let index = self + .notebooks + .iter() + .position(|library| Arc::ptr_eq(library, &session.library))?; + Some((index, session.tabs[session.tab].path.as_str())) + }); + let (action, rows, held) = sidebar( + &mut self.ui, + theme, + &self.notebooks, + open, + &self.folded, + self.renaming.as_mut(), + crate::TAB_ROW, + ); + let corner = self.ui.rect(rows_id).unwrap_or_default(); + self.drag_entries(held, &rows, corner, theme); + self.ui.close(); + self.ui.close(); + match action { + Some(Action::Toggle) => { + self.sidebar = !self.sidebar; + self.save_settings(); + } + Some(Action::Fold(key)) => { + if !self.folded.remove(&key) { + self.folded.insert(key); + } + } + Some(Action::Notebook(index)) => { + let library = Arc::clone(&self.notebooks[index]); + if let Some(path) = library.first_section() { + self.commands.push(crate::Command::OpenSection(library, path)); + } + } + Some(Action::Open { notebook, path }) => { + let library = Arc::clone(&self.notebooks[notebook]); + self.commands.push(crate::Command::OpenSection(library, path)); + } + Some(Action::Menu(target, point)) => { + self.menu = Some((target, point)); + self.ui.open_popup(crate::menus::id()); + } + Some(Action::Renamed(keep)) => { + if let Some(renaming) = self.renaming.take() + && keep + { + let name = renaming.name.trim().to_owned(); + let old = renaming.path.rsplit('/').next().unwrap_or_default(); + if !name.is_empty() && name != old.strip_suffix(".one").unwrap_or(old) { + self.commands.push(crate::Command::Structure( + renaming.library, + crate::manage::Structure::Rename { + path: renaming.path, + name, + }, + )); + } + } + self.ui.set_focus(Some(crate::page())); + } + Some(Action::NewNotebook) => self.commands.push(crate::Command::NewNotebook), + Some(Action::OpenNotebook) => self.commands.push(crate::Command::OpenNotebook), + None => {} + } + } + + /// Moves a section or group by dragging its row: onto a group's middle it moves into the + /// group, as OneNote moves one dropped on a group's tab; between rows of its folder it + /// takes that place; between rows of another folder it moves there, last. `corner` is + /// the rows' box. + fn drag_entries(&mut self, held: Option, rows: &[Entry], corner: [f32; 4], theme: &Theme) { + let dragged = held.clone().or(self.dragging_entry.take()); + self.dragging_entry = held.clone(); + let (Some(dragged), Some([_, y])) = (dragged, self.ui.pointer()) else { + return; + }; + let inside = |entry: &Entry| { + entry.path == dragged.path || entry.path.starts_with(&format!("{}/", dragged.path)) + }; + let candidates: Vec<&Entry> = rows + .iter() + .filter(|entry| entry.notebook == dragged.notebook && !inside(entry)) + .collect(); + let Some(under) = candidates + .iter() + .find(|entry| y >= entry.rect[1] && y < entry.rect[3]) + .or_else(|| candidates.last().filter(|entry| y >= entry.rect[3])) + else { + return; + }; + let [top, bottom] = [under.rect[1], under.rect[3]]; + let quarter = (bottom - top) / 4.0; + let into = under.group && y > top + quarter && y < bottom - quarter; + let (folder, before) = if into { + (under.path.as_str(), None) + } else if y < (top + bottom) / 2.0 { + (under.folder(), Some(under.path.as_str())) + } else { + let next = candidates + .iter() + .skip_while(|entry| entry.path != under.path) + .nth(1) + .filter(|entry| entry.folder() == under.folder()); + (under.folder(), next.map(|entry| entry.path.as_str())) + }; + let change = if folder == dragged.folder() { + if into { + return; + } + // The folder's order with the dragged entry where it was dropped. + let mut paths: Vec = candidates + .iter() + .filter(|entry| entry.folder() == folder) + .map(|entry| entry.path.clone()) + .collect(); + let at = before + .and_then(|before| paths.iter().position(|path| path == before)) + .unwrap_or(paths.len()); + paths.insert(at, dragged.path.clone()); + let current: Vec<&str> = rows + .iter() + .filter(|entry| entry.notebook == dragged.notebook && entry.folder() == folder) + .map(|entry| entry.path.as_str()) + .collect(); + if paths.iter().map(String::as_str).eq(current) { + return; + } + crate::manage::Structure::Reorder { + folder: folder.to_owned(), + paths, + } + } else { + crate::manage::Structure::Move { + path: dragged.path.clone(), + folder: folder.to_owned(), + } + }; + if held.is_some() { + let accent = theme.accent; + if into { + let [left, top, right, bottom] = under.rect; + self.ui.mark( + [left - corner[0], top - corner[1], right - corner[0], bottom - corner[1]], + [accent[0], accent[1], accent[2], 0.3], + 4.0, + ); + } else { + let line = if before == Some(under.path.as_str()) { top } else { bottom } - corner[1]; + self.ui.mark( + [under.rect[0] - corner[0], line - 1.5, under.rect[2] - corner[0], line + 1.5], + accent, + 1.5, + ); + } + } else { + let library = std::sync::Arc::clone(&self.notebooks[dragged.notebook]); + self.commands.push(crate::Command::Structure(library, change)); + } + } + + /// Shows the notebook at `location` at its first section, opening it unless it is open. + pub(crate) fn open_notebook(&mut self, location: String) { + let open = self + .notebooks + .iter() + .find(|library| library.location == location) + .cloned(); + let (cache, notify) = (self.cache.clone(), crate::notify(self.proxy.clone())); + self.load(move || { + let library = open.unwrap_or_else(|| Arc::new(Library::notebook(&location, &cache))); + if let Err(error) = &library.notebook { + return Err(error.clone().into()); + } + let path = library + .first_section() + .ok_or("This folder holds no notebook sections.")?; + let section = library.open(&path, notify)?; + let (session, page) = crate::read_session(section, library, path, None)?; + Ok((crate::Loaded::Section(Box::new(session)), page)) + }); + } + + /// The page's place on the first run, or once every notebook is closed. + pub(crate) fn welcome(&mut self, theme: &Theme) { + const SIZE: [f32; 2] = [420.0, 120.0]; + let [left, top, right, bottom] = self.ui.rect(crate::page()).unwrap_or_default(); + self.ui.open_as( + crate::page(), + Spec { + size: [fill(), fill()], + fill: Some(theme.paper), + ..Spec::default() + }, + ); + self.ui.open( + "welcome", + Spec { + flags: Flags::FLOAT, + axis: Axis::Y, + size: [px(SIZE[0]), px(SIZE[1])], + position: [ + ((right - left - SIZE[0]) / 2.0).max(0.0), + ((bottom - top) * 0.4 - SIZE[1] / 2.0).max(0.0), + ], + gap: 4.0, + ..Spec::default() + }, + ); + let ink = theme.paper_ink; + for (part, text, color) in [ + ("title", "No notebooks open", ink), + ( + "description", + "Open a notebook folder, or start a new notebook.", + ui::mix(ink, theme.paper, 0.45), + ), + ] { + self.ui.leaf( + part, + Spec { + size: [fill(), px(26.0)], + text: Some(text), + color: Some(color), + center: true, + ..Spec::default() + }, + ); + } + let labels = ["Open Notebook…", "New Notebook…"]; + let pad = 0.75 * theme.font_size; + let width: f32 = labels + .iter() + .map(|label| self.ui.measure(label)[0] + 2.0 * pad) + .sum::() + + 8.0; + self.ui.open( + "actions", + Spec { + size: [fill(), ui::children()], + pad: [((SIZE[0] - width) / 2.0).max(0.0), 12.0], + gap: 8.0, + ..Spec::default() + }, + ); + let mut chosen = None; + for (label, command) in labels + .into_iter() + .zip([crate::Command::OpenNotebook, crate::Command::NewNotebook]) + { + if ui::button(&mut self.ui, label, label).clicked { + chosen = Some(command); + } + } + self.ui.close(); + self.commands.extend(chosen); + self.ui.close(); + self.ui.close(); + } +} diff --git a/crates/snowbound/src/templates.rs b/crates/snowbound/src/templates.rs new file mode 100644 index 0000000000000000000000000000000000000000..fffa4f7c3c1925fb625ea5f465b00d1ba0b83202 --- /dev/null +++ b/crates/snowbound/src/templates.rs @@ -0,0 +1,450 @@ +//! The template strip a new page shows where its body would start until the body is typed +//! in, and the gallery of every template it leads to. + +use crate::art; +use canvas::{ + gpu::Paper, + template::{PAGE_COLORS, TEMPLATES, Template}, +}; +use draw::RasterImage; +use std::collections::HashMap; +use ui::{Axis, Flags, Spec, Theme, Ui, fill, px}; + +/// What a thumbnail shows: the top of a letter page, in points from the page origin. +const REGION: [f32; 4] = [-72.0, -40.0, 540.0, 419.0]; +const TILE: [f32; 2] = [112.0, 84.0]; +const SWATCH: f32 = 24.0; +const LABEL: f32 = 22.0; +const GAP: f32 = 12.0; +const PAD: f32 = 10.0; +/// Where the strip stands below the body's first line: at its third, so Enter in the title +/// leaves a line clear above it. +const LINES_ABOVE: f32 = 2.0 * 18.0; +/// The strip's picks, in Clover's order, and their labels. +const STRIP: [(Choice, &str); 5] = [ + (Choice::Template("Ivy"), "Ivy"), + (Choice::Template("Purple Clouds"), "Purple Clouds"), + (Choice::Template("Notebook"), "Notebook"), + (Choice::Colors, "Solid Color"), + (Choice::Template("Informal Meeting Notes"), "Meeting"), +]; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Choice { + Template(&'static str), + /// An index into `PAGE_COLORS`. + Color(usize), + /// Shows or hides the page colours. + Colors, + More, + Dismiss, +} + +/// What the template strip shows. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum View { + #[default] + Strip, + /// The strip with the page colours beneath it. + Colors, + Gallery, +} + +/// Rasters of each template's art for its thumbnail, by template and paper. +#[derive(Default)] +pub struct Thumbnails(HashMap<(&'static str, bool), Vec<(RasterImage, [f32; 4])>>); + +impl Thumbnails { + /// `template`'s art and where it lies in a `TILE`, drawn at `scale` for `paper`. + fn art( + &mut self, + template: &'static Template, + paper: Paper, + dark: bool, + scale: f32, + ) -> &[(RasterImage, [f32; 4])] { + self.0.entry((template.name, dark)).or_insert_with(|| { + let points = TILE[0] / (REGION[2] - REGION[0]); + template + .art + .iter() + .filter_map(|art| { + let (image, size) = canvas::gpu::page::template_art( + art.art, + art.size, + points * scale, + paper, + )?; + let [x, y] = [ + (art.position[0] - REGION[0]) * points, + (art.position[1] - REGION[1]) * points, + ]; + Some((image, [x, y, size[0] * points, size[1] * points])) + }) + .collect() + }) + } +} + +/// The strip at `position` in the page box, with the page colours beneath while `colors`. +fn strip( + ui: &mut Ui, + theme: &Theme, + thumbnails: &mut Thumbnails, + paper: Paper, + dark: bool, + position: [f32; 2], + colors: bool, +) -> Option { + let mut chosen = None; + ui.open( + "templates", + Spec { + flags: Flags::FLOAT | Flags::CLICKABLE, + axis: Axis::Y, + size: [ui::children(), ui::children()], + position, + fill: Some(theme.popup), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [PAD, PAD], + gap: 8.0, + ..Spec::default() + }, + ); + ui.open( + "tiles", + Spec { + size: [ui::children(), ui::children()], + gap: GAP, + ..Spec::default() + }, + ); + for (index, (choice, label)) in STRIP.into_iter().chain([(Choice::More, "More templates")]).enumerate() { + if tile(ui, theme, thumbnails, paper, dark, index, choice, label) { + chosen = Some(choice); + } + } + ui.close(); + if colors { + ui.open( + "colors", + Spec { + size: [ui::children(), px(SWATCH)], + gap: 6.0, + ..Spec::default() + }, + ); + for (index, (name, color)) in PAGE_COLORS.iter().enumerate() { + let swatch = ui.leaf( + (index, *name), + Spec { + flags: Flags::CLICKABLE, + size: [px(SWATCH), px(SWATCH)], + fill: Some(page_color(*color, paper, dark)), + border: Some(theme.chip), + hover_border: Some(theme.accent), + radius: 4.0, + ..Spec::default() + }, + ); + if swatch.clicked { + chosen = Some(Choice::Color(index)); + } + } + ui.close(); + } + let width = ui.rect(ui.id("tiles")).map_or(0.0, |rect| rect[2] - rect[0]); + let close = ui.leaf( + "dismiss", + Spec { + flags: Flags::FLOAT | Flags::CLICKABLE, + size: [px(22.0), px(22.0)], + position: [width + 2.0 * PAD - 14.0, -8.0], + icon: Some(art::CLOSE), + color: Some(theme.text_dim), + fill: Some(theme.popup), + border: Some(theme.chip), + hover_fill: Some(theme.hover()), + radius: 11.0, + center: true, + ..Spec::default() + }, + ); + if close.clicked { + chosen = Some(Choice::Dismiss); + } + ui.close(); + chosen +} + +/// Every template, as a scrolling grid over the page box `size` big. +fn gallery( + ui: &mut Ui, + theme: &Theme, + thumbnails: &mut Thumbnails, + paper: Paper, + dark: bool, + size: [f32; 2], +) -> Option { + let mut chosen = None; + let margin = 24.0; + ui.open( + "gallery", + Spec { + flags: Flags::FLOAT | Flags::CLICKABLE, + axis: Axis::Y, + size: [px(size[0] - 2.0 * margin), px(size[1] - 2.0 * margin)], + position: [margin, margin], + fill: Some(theme.popup), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [PAD, PAD], + gap: 8.0, + ..Spec::default() + }, + ); + ui.open( + "header", + Spec { + size: [fill(), px(24.0)], + ..Spec::default() + }, + ); + ui.leaf( + "title", + Spec { + size: [fill(), px(24.0)], + text: Some("Page Templates"), + ..Spec::default() + }, + ); + if ui::shell::tool_button(ui, "close", art::CLOSE, theme.text_dim, false).clicked { + chosen = Some(Choice::Dismiss); + } + ui.close(); + ui.open( + "grid", + Spec { + flags: Flags::SCROLL | Flags::CLIP, + axis: Axis::Y, + size: [fill(), fill()], + gap: GAP, + ..Spec::default() + }, + ); + let column = TILE[0] + 40.0; + let columns = + (((size[0] - 2.0 * margin - 2.0 * PAD + GAP) / (column + GAP)).floor() as usize).max(1); + let choices: Vec<(Choice, &str)> = TEMPLATES + .iter() + .map(|template| (Choice::Template(template.name), template.name)) + .chain( + PAGE_COLORS + .iter() + .enumerate() + .map(|(index, (name, _))| (Choice::Color(index), *name)), + ) + .collect(); + for (row, choices) in choices.chunks(columns).enumerate() { + ui.open( + row, + Spec { + size: [fill(), ui::children()], + gap: GAP, + ..Spec::default() + }, + ); + for (index, (choice, label)) in choices.iter().enumerate() { + if tile(ui, theme, thumbnails, paper, dark, index, *choice, label) { + chosen = Some(*choice); + } + } + ui.close(); + } + ui.close(); + ui.close(); + chosen +} + +/// A page colour as the paper shows it: on dark paper, its hue at the paper's lightness. +fn page_color(color: u32, paper: Paper, dark: bool) -> [f32; 4] { + let color = canvas::gpu::colorref(color); + if !dark { + return color; + } + let [lightness, ..] = draw::oklab(paper.color); + let [_, a, b] = draw::oklab(color); + let [red, green, blue] = draw::from_oklab([lightness + 0.04, 2.0 * a, 2.0 * b]); + [red, green, blue, 1.0] +} + +/// One template's thumbnail over its label, as wide as the thumbnail or the label; true +/// when clicked. +#[allow(clippy::too_many_arguments)] +fn tile( + ui: &mut Ui, + theme: &Theme, + thumbnails: &mut Thumbnails, + paper: Paper, + dark: bool, + part: usize, + choice: Choice, + label: &str, +) -> bool { + let scale = ui.scale(); + let width = TILE[0].max(ui.measure(label)[0] + 4.0); + let fill_color = match choice { + Choice::Color(index) => page_color(PAGE_COLORS[index].1, paper, dark), + Choice::More | Choice::Dismiss => theme.base, + Choice::Template(_) | Choice::Colors => paper.color, + }; + ui.open( + (part, label), + Spec { + axis: Axis::Y, + size: [px(width), px(TILE[1] + LABEL)], + pad: [(width - TILE[0]) / 2.0, 0.0], + ..Spec::default() + }, + ); + let id = ui.open( + "thumbnail", + Spec { + flags: Flags::CLICKABLE | Flags::CLIP, + size: [px(TILE[0]), px(TILE[1])], + fill: Some(fill_color), + border: Some(theme.chip), + hover_border: Some(theme.accent), + radius: 4.0, + icon: (choice == Choice::More).then_some(art::PLUS), + color: Some(theme.text_dim), + center: true, + ..Spec::default() + }, + ); + match choice { + Choice::Template(name) => { + if let Some(template) = canvas::template::find(name) { + for (index, (image, [x, y, width, height])) in thumbnails + .art(template, paper, dark, scale) + .iter() + .enumerate() + { + ui.leaf( + ("art", index), + Spec { + flags: Flags::FLOAT, + size: [px(*width), px(*height)], + position: [*x, *y], + image: Some(image), + ..Spec::default() + }, + ); + } + } + } + // A band of each of the first page colours. + Choice::Colors => { + let bands = 4; + for (index, (_, color)) in PAGE_COLORS.iter().take(bands).enumerate() { + let band = TILE[0] / bands as f32; + ui.leaf( + ("band", index), + Spec { + flags: Flags::FLOAT, + size: [px(band), px(TILE[1])], + position: [band * index as f32, 0.0], + fill: Some(page_color(*color, paper, dark)), + ..Spec::default() + }, + ); + } + } + _ => {} + } + ui.close(); + ui.leaf( + "label", + Spec { + size: [px(width), px(LABEL)], + text: Some(label), + color: Some(theme.text_dim), + center: true, + ..Spec::default() + }, + ); + ui.close(); + ui.signal(id).clicked +} + +impl crate::State { + /// Over a page with nothing typed in its body: the strip where the body would start, + /// or the gallery it opened. + pub(crate) fn template_strip(&mut self, theme: &Theme) { + let Some(session) = &self.session else { + return; + }; + let space = session.space; + let editor = &self.view.editor; + let blank = editor + .visible_outlines() + .chain(editor.caret_outline()) + .filter(|outline| !outline.title) + .all(canvas::editor::TextOutline::is_empty); + if !blank || session.conflict(space).is_some() || self.dismissed.contains(&space) { + self.templates = View::Strip; + return; + } + let (Some(rect), Some(start)) = (self.ui.rect(crate::page()), editor.body_start()) else { + return; + }; + let viewport = self.view.viewport; + let scale = self.ui.scale(); + let position = [ + (viewport.origin[0] + start[0] * viewport.scale) / scale, + (viewport.origin[1] + (start[1] + LINES_ABOVE) * viewport.scale) / scale, + ]; + let paper = Paper { + color: theme.paper, + ink: theme.paper_ink, + }; + let dark = theme.paper_ink[0] > theme.paper[0]; + let chosen = match self.templates { + View::Gallery => { + let size = [rect[2] - rect[0], rect[3] - rect[1]]; + gallery(&mut self.ui, theme, &mut self.thumbnails, paper, dark, size) + } + view => strip( + &mut self.ui, + theme, + &mut self.thumbnails, + paper, + dark, + position, + view == View::Colors, + ), + }; + match chosen { + Some(Choice::More) => self.templates = View::Gallery, + Some(Choice::Colors) => { + self.templates = if self.templates == View::Colors { + View::Strip + } else { + View::Colors + } + } + Some(Choice::Dismiss) if self.templates == View::Gallery => { + self.templates = View::Strip + } + Some(Choice::Dismiss) => { + self.dismissed.insert(space); + } + Some(choice) => { + self.templates = View::Strip; + self.commands.push(crate::Command::Template(choice)); + } + None => {} + } + } +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index d5d829440da7f6d6da08d3391b228ba04b413acf..2f5c320d99baa8f47856e2d7ac541575a84248f6 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -6,6 +6,7 @@ publish = false [dependencies] draw = { path = "../draw" } +nucleo-matcher = "0.3.1" parley.workspace = true winit = "0.30.13" diff --git a/crates/ui/examples/popups.rs b/crates/ui/examples/popups.rs index fe1bae19d913942d1fa1f6b43ec46477a055f8a1..b0cbb85a062fb850b9aa5fe501b4c32514b7b6d0 100644 --- a/crates/ui/examples/popups.rs +++ b/crates/ui/examples/popups.rs @@ -1,6 +1,6 @@ //! Renders each popup control open, in both themes, to `/tmp/ui-popups-{dark,light}.png`, //! and frames of lists changing to `/tmp/ui-list-{keystroke,delete}-{dark,light}.png`: a -//! combo's list after `c` is typed into its filter, and a page list losing rows. Then times +//! combo's list after `cou` is typed into its filter, and a page list losing rows. Then times //! a list of 200 000 rows. use std::{ @@ -72,7 +72,7 @@ fn main() { /// Frames a change shows at: before it, then each of the 150 ms it takes. const FRAMES: [u32; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 10]; -/// A font combo's list before `c` is typed into its filter and at frames after. +/// A font combo's list before `cou` is typed into its filter and at frames after. fn keystroke( theme: &Theme, device: &wgpu::Device, @@ -92,7 +92,7 @@ fn keystroke( }; scene.open(id, Vec::new(), build); let mut panels = vec![paint(device, queue, renderer, &scene.ui)]; - for event in typed("c") { + for event in typed("cou") { scene.ui.event(event); } for frame in 1..=10 { @@ -276,7 +276,7 @@ fn measure() { .map(|_| frame(&mut ui, &rows, &mut selected)) .collect(); println!( - "a keystroke filtering 200 000 to {}: {filtering:?} to filter, then {first:?} and {:?} a frame as rows ease", + "a keystroke filtering 200 000 to {}: {filtering:?} to filter, then {first:?} and {:?} a frame after", rows.count(), average(times) ); diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 2b176690247eefd4b180d1898d242c88d13892cf..cf1bda3ce88990d842a2d166f8b5fd8a585a2115 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -213,6 +213,10 @@ pub struct Spec<'a> { pub axis: Axis, pub text: Option<&'a str>, /// The family the label is shaped in, where it has the glyphs; the interface's otherwise. + /// The label's size in logical pixels; the theme's otherwise. + pub font_size: Option, + /// Shapes the label semibold. + pub bold: bool, pub font: Option<&'a str>, /// The label's colour; the theme's text colour otherwise. pub color: Option<[f32; 4]>, @@ -232,7 +236,8 @@ pub struct Spec<'a> { pub shape: Shape, /// 16 px artwork before the label, tinted with the label's colour. pub icon: Option<&'static [&'static str]>, - /// A 16 px picture in the icon's place. + /// A 16 px picture in the icon's place beside a label; without one, the picture fills + /// the box inside its padding. pub image: Option<&'a RasterImage>, /// Inset of the label and children on each axis. pub pad: [f32; 2], @@ -245,11 +250,6 @@ pub struct Spec<'a> { /// Floats the box over all others beside a rectangle in the window, as a popup /// `Ui::open_popup` opened; boxes beneath take no input while one is open. pub anchor: Option, - /// How transparent the box and its contents are, from 0 to 1. - pub fade: f32, - /// The colour `fade` blends the box and its contents into instead, so boxes fading - /// over one another never add up. - pub fade_into: Option<[f32; 4]>, } /// Input the host forwards; positions and wheel distances are logical pixels. @@ -283,6 +283,8 @@ pub struct Signal { pub clicked: bool, /// The primary button is held after pressing the box. pub dragging: bool, + /// The secondary button went down on the box, here: where its context menu opens. + pub context: Option<[f32; 2]>, pub focused: bool, /// For custom boxes: every event routed to the box, in order. For focused boxes: /// keys, text and composition. For scrolling boxes: the wheel. @@ -329,9 +331,6 @@ struct Built { position: [f32; 2], cursor: Option, anchor: Option, - /// The opacity of the box and its contents; a popup's rises as it opens. - alpha: f32, - fade_into: Option<[f32; 4]>, /// Rectangles relative to the box, painted over its fill. marks: Vec<([f32; 4], [f32; 4], f32)>, computed: [f32; 2], @@ -368,6 +367,8 @@ struct Popup { /// Its filter field's text, and the key of the row the keyboard or pointer last chose. query: String, highlight: Option, + /// The height its results ease from and to, and the seconds since they set out. + height: Option<[f32; 3]>, } struct Hit { @@ -422,34 +423,6 @@ enum Display { }, } -impl Display { - /// Multiplies the item's opacity by `alpha`, or blends it that far into `into`. - fn fade(&mut self, alpha: f32, into: Option<[f32; 4]>) { - let fade = |color: &mut [f32; 4]| match into { - Some(into) => { - let [red, green, blue, _] = mix(into, *color, alpha); - *color = [red, green, blue, color[3]]; - } - None => color[3] *= alpha, - }; - match self { - Display::Rect { - fill, - shade, - border, - .. - } => [Some(fill), shade.as_mut(), border.as_mut()] - .into_iter() - .flatten() - .for_each(fade), - Display::Segment { color, .. } | Display::Text { color, .. } => fade(color), - Display::Path { colors, .. } => colors.iter_mut().for_each(fade), - Display::Icon { tint, .. } => fade(tint), - Display::Clip(_) | Display::Image { .. } | Display::Custom { .. } => {} - } - } -} - pub struct Ui { pub theme: Theme, /// Whether the window has keyboard focus; without it a field hides its caret and dims @@ -484,6 +457,9 @@ pub struct Ui { /// Where the popups' painting starts in `display`, for edges drawn beneath them. popups_painted: usize, animating: bool, + /// This frame routed input, whose effects the builder may only have seen after boxes + /// built before it read their state; one more frame shows them. + routed: bool, /// The field showing a caret, when its blink started and the frame it last showed. caret: Option<(Id, Instant, u64)>, /// When the next timed change is due, such as a caret's blink. @@ -520,6 +496,7 @@ impl Ui { display: Vec::new(), popups_painted: 0, animating: false, + routed: false, caret: None, wake: None, } @@ -532,7 +509,7 @@ impl Ui { /// Whether queued input or animation needs another frame. pub fn wants_frame(&self) -> bool { - self.animating || !self.queue.is_empty() + self.animating || self.routed || !self.queue.is_empty() } /// When a timed change, such as a caret's blink, next needs a frame. @@ -548,7 +525,8 @@ impl Ui { self.modifiers } - pub(crate) fn pointer(&self) -> Option<[f32; 2]> { + /// Where the pointer is, in logical pixels, while it is over the window. + pub fn pointer(&self) -> Option<[f32; 2]> { self.pointer } @@ -609,6 +587,7 @@ impl Ui { self.signals.clear(); self.moved = false; self.wake = None; + self.routed = !self.queue.is_empty(); for event in std::mem::take(&mut self.queue) { self.route(event); } @@ -657,6 +636,11 @@ impl Ui { self.close_from(under + 1); } let target = self.hit(point, Flags::CLICKABLE | Flags::CUSTOM); + if let Some(id) = target + && button == MouseButton::Right + { + self.signals.entry(id).or_default().context = Some(point); + } if let Some(id) = target && button == MouseButton::Left { @@ -782,6 +766,7 @@ impl Ui { focus: self.focus, query: String::new(), highlight: None, + height: None, }); self.focus = Some(id); self.states.entry(id).or_default().touched = self.frame; @@ -855,7 +840,10 @@ impl Ui { pub fn open_as(&mut self, id: Id, spec: Spec<'_>) -> Id { let label = spec .text - .map(|text| self.texts.label(text, self.theme.font_size, spec.font, self.frame)); + .map(|text| { + let size = spec.font_size.unwrap_or(self.theme.font_size); + self.texts.label(text, size, spec.bold, spec.font, self.frame) + }); // Popups hang from the root, outside the clips and flow of where they are built. let parent = if spec.anchor.is_some() { 0 @@ -899,10 +887,16 @@ impl Ui { self.texts.use_fonts(files) } + /// Previews `family` in the font `data` holds, as a font menu shows the substitute a + /// missing family lays out in. + pub fn preview_font(&mut self, data: parley::fontique::Blob, family: &str) { + self.texts.preview_font(data, family); + } + /// The size of `text` as a label, in logical pixels. pub fn measure(&mut self, text: &str) -> [f32; 2] { self.texts - .label(text, self.theme.font_size, None, self.frame) + .label(text, self.theme.font_size, false, None, self.frame) .size } @@ -924,8 +918,10 @@ impl Ui { }; self.caret = Some((id, start, self.frame)); let (opacity, hold) = draw::edit::caret_blink(self.now.saturating_duration_since(start)); - let due = self.now + hold; - self.wake = Some(self.wake.map_or(due, |wake| wake.min(due))); + if let Some(hold) = hold { + let due = self.now + hold; + self.wake = Some(self.wake.map_or(due, |wake| wake.min(due))); + } opacity } @@ -983,7 +979,6 @@ impl Ui { } fn paint(&mut self, index: usize, clip: Option<[f32; 4]>) { - let start = self.display.len(); let node = &self.nodes[index]; let state = &self.states[&node.id]; let rect = node.rect; @@ -1087,9 +1082,14 @@ impl Ui { }); x += ICON + ICON_GAP; } else if let Some(image) = &node.image { + let rect = if node.label.is_some() { + [x, top, x + ICON, top + ICON] + } else { + [inner[0], painted[1] + node.pad[1], inner[2], painted[3] - node.pad[1]] + }; self.display.push(Display::Image { image: image.clone(), - rect: [x, top, x + ICON, top + ICON], + rect, }); x += ICON + ICON_GAP; } @@ -1136,14 +1136,6 @@ impl Ui { if inner_clip != clip { self.display.push(Display::Clip(clip)); } - let Built { - alpha, fade_into, .. - } = self.nodes[index]; - if alpha < 1.0 { - for item in &mut self.display[start..] { - item.fade(alpha, fade_into); - } - } } /// This frame's painting in order, in logical pixels. @@ -1319,8 +1311,6 @@ impl Built { position: spec.position, cursor: spec.cursor, anchor: spec.anchor, - alpha: 1.0 - spec.fade, - fade_into: spec.fade_into, marks: Vec::new(), computed: [0.0; 2], relative: [0.0; 2], diff --git a/crates/ui/src/list.rs b/crates/ui/src/list.rs index df112f3d4ffd2ec1fd588ed3bf2fea983221f548..69906deca6798e0d18175c802ea7c21dc2caee48 100644 --- a/crates/ui/src/list.rs +++ b/crates/ui/src/list.rs @@ -1,18 +1,13 @@ -//! A virtualized list of equal rows keyed by their items: only rows in view are built, -//! rows slide to where their items go, rows leaving fold into the seam their gap closes on -//! and rows arriving unfold out of it, and the view holds its place on the selection while -//! items arrive and leave above it. +//! A virtualized list of equal rows keyed by their items: only rows in view are built, and +//! the view holds its place on the selection while items arrive and leave above it. use crate::{Axis, Event, Flags, HALF_LIFE, Id, Size, Spec, Ui, fill, mix, px, scrollbar}; -use std::collections::{HashMap, HashSet}; use winit::keyboard::NamedKey; /// How far rows fade out under a scrolled list's edges. const EDGE: f64 = 10.0; /// Room beside a scrolling list's rows for its scrollbar. pub(crate) const GUTTER: f32 = 12.0; -/// Seconds rows take to reach their new places. -const DURATION: f32 = 0.25; /// Items a list shows, in order. pub trait Rows { @@ -44,11 +39,10 @@ pub struct List<'a, R> { pub hover_selects: bool, } -/// A row to build: its item's key, its index while listed or None as it folds away after -/// its item left or moved, and whether it is selected. +/// A row to build: its item's key, its index, and whether it is selected. pub struct Row { pub key: u64, - pub index: Option, + pub index: usize, pub selected: bool, } @@ -57,72 +51,12 @@ pub struct Row { pub(crate) struct State { scroll: f64, target: f64, - /// Rows built last frame, from the top of the view down. - shown: Vec, + /// Keys of the rows built last frame and their tops from the top of the view. + shown: Vec<(u64, f64)>, /// The selection last frame, so a new one scrolls into view. selected: Option, } -#[derive(Clone, Copy)] -struct Shown { - key: u64, - /// Where its row lies, from the top of the view. - place: f64, - /// None for a row folding away after its item left or moved. - index: Option, - motion: Option, - /// Where it was drawn from the top of the view, and how strongly. - drawn: f64, - alpha: f32, -} - -#[derive(Clone, Copy)] -struct Motion { - elapsed: f32, - way: Way, -} - -#[derive(Clone, Copy)] -enum Way { - /// Slides to its place from this offset. - Slide(f64), - Fold(Fold), -} - -/// A row folding into the gap its run leaves between two rows, or unfolding out of the -/// gap its run opens, squeezed among the run and blending into the background as the gap -/// narrows. -#[derive(Clone, Copy)] -struct Fold { - /// The gap's edges as it starts and ends, as offsets from the row's place. - start: [f64; 2], - end: [f64; 2], - /// The row's position in its run, and the run's length. - at: usize, - of: usize, - /// How strongly it was drawn as it set out. - alpha: f32, -} - -impl Fold { - /// Where the row is drawn and how strongly, `progress` of the way through, never over - /// the `solid` rows drawn at these tops, in order. Its text keeps inside the gap, gone - /// before the gap is too narrow to hold it clear of the rows either side. - fn at(&self, place: f64, progress: f64, height: f64, solid: &[f64]) -> (f64, f32) { - let [top, bottom] = [0, 1] - .map(|side| place + self.start[side] + (self.end[side] - self.start[side]) * progress); - let below = solid.partition_point(|y| y + height / 2.0 <= (top + bottom) / 2.0); - let top = below - .checked_sub(1) - .map_or(top, |above| top.max(solid[above] + height)); - let bottom = solid.get(below).map_or(bottom, |y| bottom.min(*y)); - let share = (bottom - top).max(0.0) / self.of as f64; - let y = top + (self.at as f64 + 0.5) * share - height / 2.0; - let room = (2.0 * share / height - 1.0).clamp(0.0, 1.0) as f32; - (y, room * self.alpha) - } -} - /// Builds `list` into a box of `spec`, calling `build` inside each row in view, and moves /// `selected` with the list's keys. Returns the index of the row clicked. pub fn list( @@ -165,15 +99,22 @@ pub fn list( let mut state = ui.lists.remove(&id).unwrap_or_default(); // Hold the view on the selection, or the first row showing, wherever it now lies. - let listed = || state.shown.iter().filter(|shown| shown.index.is_some()); - let held = state.selected.and_then(|key| { - listed().find(|shown| shown.key == key && shown.place >= 0.0 && shown.place < view) + let held = state.selected.and_then(|selected| { + state + .shown + .iter() + .find(|(key, place)| *key == selected && (0.0..view).contains(place)) }); // At the top, the view stays there to show what arrives. let anchor = held .into_iter() - .chain(listed().filter(|shown| state.scroll > 0.0 && shown.place + height > 0.0)) - .find_map(|shown| Some(top(rows.find(shown.key)?) - shown.place)); + .chain( + state + .shown + .iter() + .filter(|(_, place)| state.scroll > 0.0 && place + height > 0.0), + ) + .find_map(|(key, place)| Some(top(rows.find(*key)?) - place)); if let Some(scroll) = anchor { state.target += scroll - state.scroll; state.scroll = scroll; @@ -188,17 +129,17 @@ pub fn list( } } let mut clicked = None; - for shown in state.shown.iter().filter(|shown| shown.index.is_some()) { - let signal = ui.signal(id.child(shown.key)); - let Some(index) = rows.find(shown.key).filter(|index| rows.selectable(*index)) else { + for (key, _) in &state.shown { + let signal = ui.signal(id.child(*key)); + let Some(index) = rows.find(*key).filter(|index| rows.selectable(*index)) else { continue; }; if hover_selects && signal.hovered && ui.moved { - *selected = Some(shown.key); + *selected = Some(*key); state.selected = *selected; } if signal.clicked { - *selected = Some(shown.key); + *selected = Some(*key); clicked = Some(index); } } @@ -248,218 +189,7 @@ pub fn list( state.scroll += (state.target - state.scroll) * rate; } let scroll = state.scroll; - let step = scroll - anchored; - - let dt = ui.dt; - let tick = |motion: Motion| { - let elapsed = motion.elapsed + dt; - (elapsed < DURATION).then_some(Motion { elapsed, ..motion }) - }; - let progress = |motion: &Motion| 1.0 - (1.0 - f64::from(motion.elapsed / DURATION)).powi(3); - let listed: Vec = state - .shown - .iter() - .filter(|shown| shown.index.is_some()) - .copied() - .collect(); - let before: HashMap = listed.iter().map(|shown| (shown.key, *shown)).collect(); - // The most rows that stay in their order slide; the rest would pass over them, so fold - // away and unfold where they go, as do rows moving while partly folded. - let order: Vec<(u64, usize)> = listed - .iter() - .filter_map(|shown| Some((shown.key, rows.find(shown.key)?))) - .collect(); - let mut tails: Vec = Vec::new(); - let mut previous = vec![None; order.len()]; - for at in 0..order.len() { - let length = tails.partition_point(|tail| order[*tail].1 < order[at].1); - previous[at] = length.checked_sub(1).map(|length| tails[length]); - if length == tails.len() { - tails.push(at); - } else { - tails[length] = at; - } - } - let kept: HashSet = std::iter::successors(tails.last().copied(), |at| previous[*at]) - .map(|at| order[at].0) - .filter(|key| before[key].alpha >= 1.0) - .collect(); - - let settled = |index: usize| { - let key = rows.key(index); - let was = top(index) - anchored; - let motion = before.get(&key).and_then(|old| { - if (old.place - was).abs() < 0.5 { - old.motion.and_then(tick) - } else { - tick(Motion { - elapsed: 0.0, - way: Way::Slide(old.drawn - was), - }) - } - }); - Shown { - key, - place: top(index) - scroll, - index: Some(index), - motion, - drawn: 0.0, - alpha: 1.0, - } - }; - // Rows arriving where the view already was, rather than scrolled into it, unfold. - let arriving = |index: usize| { - let key = rows.key(index); - let was = top(index) - anchored; - match before.get(&key) { - Some(old) => (old.place - was).abs() >= 0.5 && !kept.contains(&key), - None => !fresh && was + height > 0.0 && was < view, - } - }; - let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len); - let mut shown = Vec::new(); - let mut index = in_view.start; - while index < in_view.end { - if !arriving(index) { - shown.push(settled(index)); - index += 1; - continue; - } - let end = (index..in_view.end) - .find(|index| !arriving(*index)) - .unwrap_or(in_view.end); - let [above, below] = [index.checked_sub(1), (end < len).then_some(end)] - .map(|side| side.map(|side| rows.key(side))); - let [first, last] = [top(index), top(end - 1) + height].map(|y| y - anchored); - // The seam opens where the rows either side were drawn, or mid-run with neither. - let seam = above - .and_then(|key| Some(before.get(&key)?.drawn + height)) - .or_else(|| below.and_then(|key| Some(before.get(&key)?.drawn))) - .unwrap_or((first + last) / 2.0); - shown.extend((index..end).map(|at| { - let was = top(at) - anchored; - Shown { - motion: tick(Motion { - elapsed: 0.0, - way: Way::Fold(Fold { - start: [seam - was; 2], - end: [first - was, last - was], - at: at - index, - of: end - index, - alpha: 1.0, - }), - }), - ..settled(at) - } - })); - index = end; - } - for old in &listed { - if let Some(index) = rows - .find(old.key) - .filter(|index| !in_view.contains(index) && kept.contains(&old.key)) - { - shown.push(settled(index)); - } - } - // Rows already folding away carry on; runs of rows gone or moving out of order start. - let mut folding: Vec = state - .shown - .iter() - .filter(|shown| shown.index.is_none()) - .filter_map(|shown| { - Some(Shown { - place: shown.place - step, - motion: Some(tick(shown.motion?)?), - ..*shown - }) - }) - .collect(); - let staying = |shown: &Shown| { - kept.contains(&shown.key) - || rows - .find(shown.key) - .is_some_and(|index| (top(index) - anchored - shown.place).abs() < 0.5) - }; - let mut at = 0; - for run in listed.chunk_by(|a, b| staying(a) == staying(b)) { - let start = at; - at += run.len(); - if staying(&run[0]) { - continue; - } - let above = start.checked_sub(1).map(|side| listed[side].key); - let below = listed.get(at).map(|side| side.key); - let [first, last] = [run[0].drawn, run[run.len() - 1].drawn + height]; - // The seam the gap closes on: where the row below or above it now lies. - let seam = below - .and_then(|key| Some(top(rows.find(key)?) - anchored)) - .or_else(|| above.and_then(|key| Some(top(rows.find(key)?) + height - anchored))) - .unwrap_or(first); - folding.extend(run.iter().enumerate().map(|(at, old)| Shown { - key: old.key, - place: old.drawn - step, - index: None, - motion: tick(Motion { - elapsed: 0.0, - way: Way::Fold(Fold { - start: [first - old.drawn, last - old.drawn], - end: [seam - old.drawn; 2], - at, - of: run.len(), - alpha: old.alpha, - }), - }), - drawn: 0.0, - alpha: 0.0, - })); - } - - // Rows sliding or still are drawn first, as folds are bounded by them. - for shown in &mut shown { - if let Some(Motion { - way: Way::Slide(from), - .. - }) = shown.motion - { - shown.drawn = shown.place + from * (1.0 - progress(&shown.motion.unwrap())); - } else { - shown.drawn = shown.place; - } - } - let mut solid: Vec = shown - .iter() - .filter(|shown| { - !matches!( - shown.motion, - Some(Motion { - way: Way::Fold(_), - .. - }) - ) - }) - .map(|shown| shown.drawn) - .collect(); - solid.sort_by(f64::total_cmp); - for shown in shown.iter_mut().chain(&mut folding) { - if let Some( - motion @ Motion { - way: Way::Fold(fold), - .. - }, - ) = shown.motion - { - (shown.drawn, shown.alpha) = fold.at(shown.place, progress(&motion), height, &solid); - } - } - shown.retain(|shown| { - shown.drawn + height > 0.0 && shown.drawn < view - || shown.index.is_some_and(|index| in_view.contains(&index)) - }); - shown.sort_by(|a, b| a.place.total_cmp(&b.place)); - ui.animating |= state.scroll != state.target - || !folding.is_empty() - || shown.iter().any(|shown| shown.motion.is_some()); + ui.animating |= scroll != state.target; let theme = ui.theme.clone(); let background = spec.fill.unwrap_or(theme.base); @@ -475,34 +205,35 @@ pub fn list( Some(rect) if most > 0.0 => px(rect[2] - rect[0] - GUTTER), _ => fill(), }; - // Rows folding away paint beneath the rest, and blend into the list rather than fade, - // so those squeezed together never add up. - for shown in folding.iter().chain(&shown) { - let flags = match shown.index { - Some(index) if rows.selectable(index) => Flags::FLOAT | Flags::CLICKABLE, - _ => Flags::FLOAT, - }; - let row_spec = Spec { - flags, - size: [width, px(row)], - position: [0.0, shown.drawn as f32], - fade: 1.0 - shown.alpha, - fade_into: Some(background), - ..Spec::default() - }; - match shown.index { - Some(_) => ui.open(shown.key, row_spec), - None => ui.open(("folding", shown.key), row_spec), + let in_view = row_at(scroll)..(row_at(scroll + view) + 1).min(len); + let mut shown = Vec::with_capacity(in_view.len()); + for index in in_view { + let key = rows.key(index); + let place = top(index) - scroll; + let flags = if rows.selectable(index) { + Flags::FLOAT | Flags::CLICKABLE + } else { + Flags::FLOAT }; + ui.open( + key, + Spec { + flags, + size: [width, px(row)], + position: [0.0, place as f32], + ..Spec::default() + }, + ); build( ui, Row { - key: shown.key, - index: shown.index, - selected: shown.index.is_some() && *selected == Some(shown.key), + key, + index, + selected: *selected == Some(key), }, ); ui.close(); + shown.push((key, place)); } // Rows fade out under the list's edges rather than end at a cut. let clear = [background[0], background[1], background[2], 0.0]; @@ -538,15 +269,13 @@ pub fn list( // Rows keep their places relative to the new offset, or next frame's hold on the // first row showing would scroll straight back. let step = f64::from(offset) - scroll; - for shown in shown.iter_mut().chain(&mut folding) { - shown.place -= step; + for (_, place) in &mut shown { + *place -= step; } state.scroll = f64::from(offset); state.target = state.scroll; } ui.close(); - shown.extend(folding); - shown.sort_by(|a, b| a.place.total_cmp(&b.place)); state.shown = shown; ui.lists.insert(id, state); clicked diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index e4f0a9e09ac180c6cc6c86d4690419edacbcb628..5f4133e03c60a62348519bf3a06e4f883c410af8 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -6,6 +6,11 @@ use crate::{ Anchor, Axis, Event, Flags, ICON, ICON_GAP, Id, List, Popup, Row, Rows, Spec, Ui, children, fill, fit, list::GUTTER, mix, px, text_field, }; +use nucleo_matcher::{ + Config, Matcher, Utf32Str, + pattern::{AtomKind, CaseMatching, Normalization, Pattern}, +}; +use std::cell::RefCell; use winit::keyboard::{Key, NamedKey}; const CHECK: &[&str] = &[include_str!("../assets/check.svg")]; @@ -20,6 +25,12 @@ const NARROWEST: f32 = 140.0; const PALETTE: f32 = 560.0; /// A colour grid's cell, around its swatch. const CELL: f32 = 22.0; +/// Seconds a popup takes to ease to the height of its results. +const RESIZE: f32 = 0.15; +thread_local! { + /// Scratch space for ranking, a few hundred kilobytes, reused across frames. + static MATCHER: RefCell = RefCell::new(Matcher::new(Config::DEFAULT)); +} /// The keys a menu's list takes, and Enter to choose. const KEYS: [NamedKey; 7] = [ NamedKey::ArrowUp, @@ -44,9 +55,13 @@ pub struct Item<'a> { pub shortcut: &'a str, /// Marked with a check in place of its icon. pub checked: bool, + /// Starts highlighted when the menu opens, as a checked item does. + pub current: bool, pub disabled: bool, /// Starts a group, ruled off from the one above while unfiltered. pub separated: bool, + /// Names the group it starts: shown only while unfiltered, never chosen. + pub heading: bool, } /// Builds popup `id` as a menu of `items` beside `anchor` while it is open, under a @@ -160,15 +175,39 @@ fn choose( } } let matches = Matches::new(items, &query); - // Unfiltered, the checked item starts highlighted; filtered, the best match. + // Unfiltered, the current or checked item starts highlighted; filtered, the best match. highlight = highlight.or_else(|| { let first = if query.is_empty() { - matches.order.iter().position(|index| items[*index].checked) + matches.order.iter().position(|index| items[*index].current || items[*index].checked) } else { (0..matches.count()).find(|row| matches.selectable(*row)) }; first.map(|row| matches.key(row)) }); + let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]); + let field = if filter.is_some() { ROW + PAD } else { 0.0 }; + let content = matches.count() as f32 * ROW + matches.space_before(matches.count()); + let view = content + .max(ROW) + .min((ROWS * ROW).min(window - 4.0 * PAD - field).max(ROW)); + // The popup eases to its new height as the results change; the rows do not move. + let dt = ui.dt; + let [from, to, elapsed] = state(ui, id).height.get_or_insert([view, view, RESIZE]); + *elapsed = (*elapsed + dt).min(RESIZE); + let height = *to - (*to - *from) * (1.0 - *elapsed / RESIZE).powi(3); + if *to != view { + [*from, *to, *elapsed] = [height, view, 0.0]; + } + ui.animating |= height != view; + ui.open( + "results", + Spec { + flags: Flags::CLIP, + axis: Axis::Y, + size: [fill(), px(height)], + ..Spec::default() + }, + ); let chosen = if matches.count() == 0 { ui.leaf( "empty", @@ -182,10 +221,6 @@ fn choose( ); None } else { - let window = ui.rect(Id::ROOT).map_or(0.0, |window| window[3]); - let field = if filter.is_some() { ROW + PAD } else { 0.0 }; - let content = matches.count() as f32 * ROW + matches.space_before(matches.count()); - let view = content.min((ROWS * ROW).min(window - 4.0 * PAD - field).max(ROW)); let list = List { rows: &matches, row: ROW, @@ -212,6 +247,7 @@ fn choose( }) }; ui.close(); + ui.close(); if let Some(row) = chosen { ui.close_popup(id); return Some(matches.order[row]); @@ -226,10 +262,7 @@ fn choose( /// highlight when selected, under a rule when it starts a group. fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { let item = &matches.items[row.key as usize]; - if row - .index - .is_some_and(|index| index > 0 && matches.ruled && item.separated) - { + if row.index > 0 && matches.ruled && item.separated { ui.leaf( "rule", Spec { @@ -241,7 +274,7 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { }, ); } - let color = if item.disabled { + let color = if item.disabled || item.heading { theme.text_dim } else { theme.text @@ -283,6 +316,8 @@ fn menu_row(ui: &mut Ui, theme: &crate::Theme, matches: &Matches, row: Row) { size: [fill(), px(ROW)], text: Some(item.text), font: item.font, + font_size: item.heading.then_some(theme.font_size - 2.0), + bold: item.heading, color: Some(color), ..Spec::default() }, @@ -485,10 +520,11 @@ fn navigation(ui: &mut Ui, owners: &[Id], keys: &[NamedKey]) -> Vec { taken } -/// The items matching a query, most relevant first, keyed by their index in `items`. -struct Matches<'a> { +/// The items fuzzily matching a query as fzf does, best first, keyed by their index in +/// `items`. +pub(crate) struct Matches<'a> { items: &'a [Item<'a>], - order: Vec, + pub(crate) order: Vec, /// Each item's row, or None when it does not match. rows: Vec>, /// Rules above each row and the end, counted from the top; only while unfiltered. @@ -498,14 +534,32 @@ struct Matches<'a> { } impl<'a> Matches<'a> { - fn new(items: &'a [Item<'a>], query: &str) -> Self { - let mut ranked: Vec<_> = items - .iter() - .enumerate() - .filter_map(|(index, item)| Some((score(query, item.text)?, index))) - .collect(); - ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); - let order: Vec = ranked.into_iter().map(|(_, index)| index).collect(); + pub(crate) fn new(items: &'a [Item<'a>], query: &str) -> Self { + let pattern = Pattern::new( + query, + CaseMatching::Ignore, + Normalization::Smart, + AtomKind::Fuzzy, + ); + let order: Vec = if pattern.atoms.is_empty() { + (0..items.len()).collect() + } else { + let mut chars = Vec::new(); + let mut ranked: Vec<_> = MATCHER.with_borrow_mut(|matcher| { + items + .iter() + .enumerate() + .filter(|(_, item)| !item.heading) + .filter_map(|(index, item)| { + let text = Utf32Str::new(item.text, &mut chars); + Some((pattern.score(text, matcher)?, index)) + }) + .collect() + }); + // Stable, so equal matches keep the items' order. + ranked.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); + ranked.into_iter().map(|(_, index)| index).collect() + }; let mut rows = vec![None; items.len()]; for (row, index) in order.iter().enumerate() { rows[*index] = Some(row); @@ -542,30 +596,11 @@ impl Rows for Matches<'_> { } fn selectable(&self, index: usize) -> bool { - !self.items[self.order[index]].disabled + let item = &self.items[self.order[index]]; + !item.disabled && !item.heading } fn space_before(&self, index: usize) -> f32 { self.rules[index] as f32 * RULE } } - -/// How well `text` matches `query`, ignoring case: 3 at its start, 2 at a word's, 1 -/// anywhere, 0 with the query's characters in order among others; None otherwise. -fn score(query: &str, text: &str) -> Option { - let [query, text] = [query, text].map(str::to_lowercase); - let at_word = |(at, _): (usize, &str)| text[..at].ends_with(|c: char| !c.is_alphanumeric()); - if text.starts_with(&query) { - Some(3) - } else if text.match_indices(&query).any(at_word) { - Some(2) - } else if text.contains(&query) { - Some(1) - } else { - let mut chars = text.chars(); - query - .chars() - .all(|wanted| chars.any(|c| c == wanted)) - .then_some(0) - } -} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 5863664d91c8a72696e7c8eb815b09d8158f6347..7228b920324cc9bf303635da29596da92203377c 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -18,7 +18,8 @@ pub const TOOL: f32 = 22.0; /// Section tabs in a row `height` tall, as OneNote draws them: each leans over the next /// at 45°, and the open one lies on top in `section`'s colours, rising to meet the frame /// below as its outline and shadow fade in. `tabs` are names and section colours. Returns -/// the tab clicked and the open tab's id, for `tab_base`. +/// the tab clicked, the tab whose context menu was asked for and where, and the open tab's +/// id, for `tab_base`. pub fn section_tabs( ui: &mut Ui, part: impl Hash, @@ -26,7 +27,7 @@ pub fn section_tabs( active: usize, section: &Section, height: f32, -) -> (Option, Id) { +) -> (Option, Option<(usize, [f32; 2])>, Id) { let theme = ui.theme.clone(); ui.open( part, @@ -46,7 +47,7 @@ pub fn section_tabs( (left - width, width) }) .collect(); - let mut clicked = None; + let (mut clicked, mut context) = (None, None); // Earlier tabs lie over later ones; the open tab over all. let order = (0..tabs.len()) .rev() @@ -83,10 +84,13 @@ pub fn section_tabs( if signal.clicked && index != active { clicked = Some(index); } + if let Some(point) = signal.context { + context = Some((index, point)); + } } let open_tab = ui.id(("tab", active)); ui.close(); - (clicked, open_tab) + (clicked, context, open_tab) } /// Where the tab laid out at `tab` in a row `height` tall meets the edge below it: from its diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs index 7841863cc2641400cc2fced124dd1f6bdad7e765..20132daf56f7d95b0e3fb2f2c51f6fbd7511a442 100644 --- a/crates/ui/src/tests.rs +++ b/crates/ui/src/tests.rs @@ -3,6 +3,12 @@ use std::time::Duration; use winit::keyboard::NamedKey; const DOUBLE_CLICK: Duration = Duration::from_millis(500); +/// The key that carries shortcuts: Command on macOS, Control elsewhere. +const SHORTCUT: ModifiersState = if cfg!(target_vendor = "apple") { + ModifiersState::SUPER +} else { + ModifiersState::CONTROL +}; thread_local! { static START: Instant = Instant::now(); @@ -149,6 +155,42 @@ fn clicks_use_the_previous_layout_and_hover_animates() { assert!(!ui.wants_frame()); } +/// A click whose press and release arrive together starts an animation that a box built +/// earlier in the frame reads: the frame that handled the click asks for the next one. +#[test] +fn a_click_within_one_frame_starts_an_animation_read_before_it() { + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + let mut open = false; + let mut width = 0.0; + let mut build = |ui: &mut Ui, open: &mut bool, width: &mut f32| { + *width = ui.animate(Id::ROOT.child("panel"), if *open { 100.0 } else { 0.0 }); + if button(ui, "toggle", "Toggle").clicked { + *open = true; + } + }; + frame(&mut ui, |ui| build(ui, &mut open, &mut width)); + // The pointer rests on the button until its hover has faded in. + ui.event(Event::PointerMoved([5.0, 5.0])); + for _ in 0..40 { + frame(&mut ui, |ui| build(ui, &mut open, &mut width)); + } + assert!(!ui.wants_frame()); + let at = Instant::now(); + for pressed in [true, false] { + ui.event(Event::Button { + button: MouseButton::Left, + pressed, + at, + }); + } + frame(&mut ui, |ui| build(ui, &mut open, &mut width)); + assert!(open && width == 0.0); + assert!(ui.wants_frame(), "the click's frame asks for another"); + frame(&mut ui, |ui| build(ui, &mut open, &mut width)); + frame(&mut ui, |ui| build(ui, &mut open, &mut width)); + assert!(width > 0.0 && ui.wants_frame(), "the panel eases open"); +} + #[test] fn hover_fades_its_own_colour_in_over_a_box_without_a_fill() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); @@ -341,7 +383,7 @@ fn focused_field(text: &str) -> (Ui, String) { fn point_to(ui: &mut Ui, text: &str, index: usize) { let size = ui.theme.font_size; let (texts, frame) = ui.texts(); - let layout = &texts.label(text, size, None, frame).layout; + let layout = &texts.label(text, size, false, None, frame).layout; let x = parley::editing::Cursor::from_byte_index(layout, index, parley::Affinity::Downstream) .geometry(layout, 1.0) .x0 as f32; @@ -372,7 +414,7 @@ fn text_fields_edit_with_keys_and_selection() { ui.event(Event::Ime(Ime::Commit("é".into()))); field_frame(&mut ui, &mut text); assert_eq!(text, "caé"); - ui.event(Event::Modifiers(ModifiersState::SUPER)); + ui.event(Event::Modifiers(SHORTCUT)); ui.event(typed("a")); field_frame(&mut ui, &mut text); ui.event(Event::Modifiers(ModifiersState::empty())); @@ -383,6 +425,7 @@ fn text_fields_edit_with_keys_and_selection() { } #[test] +#[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")] fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() { let (mut ui, mut text) = focused_field("one"); let start = Instant::now(); @@ -430,6 +473,7 @@ fn text_field_carets_fade_on_appkit_s_blink_and_restart_when_moved() { } #[test] +#[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")] fn text_fields_move_and_select_by_word() { let (mut ui, mut text) = focused_field("one two three"); ui.event(key(NamedKey::End)); @@ -449,6 +493,48 @@ fn text_fields_move_and_select_by_word() { } #[test] +#[cfg_attr( + target_vendor = "apple", + ignore = "GTK and Windows editing conventions" +)] +fn text_fields_follow_gtk_and_windows_chords() { + let (mut ui, mut text) = focused_field("one two three"); + ui.event(key(NamedKey::End)); + ui.event(Event::Modifiers(ModifiersState::CONTROL)); + ui.event(key(NamedKey::ArrowLeft)); + ui.event(key(NamedKey::ArrowLeft)); + field_frame(&mut ui, &mut text); + ui.event(Event::Modifiers( + ModifiersState::CONTROL | ModifiersState::SHIFT, + )); + ui.event(key(NamedKey::ArrowRight)); + field_frame(&mut ui, &mut text); + ui.event(Event::Modifiers(ModifiersState::empty())); + ui.event(typed("2")); + field_frame(&mut ui, &mut text); + assert_eq!(text, "one 2 three"); + ui.event(Event::Modifiers(ModifiersState::CONTROL)); + ui.event(key(NamedKey::Backspace)); + field_frame(&mut ui, &mut text); + assert_eq!(text, "one three"); + ui.event(Event::Modifiers(ModifiersState::empty())); + ui.event(key(NamedKey::Home)); + ui.event(typed("<")); + ui.event(key(NamedKey::End)); + ui.event(typed(">")); + field_frame(&mut ui, &mut text); + assert_eq!(text, ""); + ui.event(Event::Modifiers(ModifiersState::CONTROL)); + ui.event(typed("a")); + field_frame(&mut ui, &mut text); + ui.event(Event::Modifiers(ModifiersState::empty())); + ui.event(key(NamedKey::Delete)); + field_frame(&mut ui, &mut text); + assert_eq!(text, ""); +} + +#[test] +#[cfg_attr(not(target_vendor = "apple"), ignore = "macOS editing conventions")] fn text_fields_delete_by_word_and_line_chords() { let (mut ui, mut text) = focused_field("one two three"); ui.event(key(NamedKey::End)); @@ -674,14 +760,6 @@ fn popups_show_at_once_by_their_anchor() { ui.open_popup(menu_id()); menu_frame(&mut ui, BELOW, None); menu_frame(&mut ui, BELOW, None); - let alpha = |ui: &Ui| { - ui.nodes - .iter() - .find(|node| node.id == menu_id()) - .unwrap() - .alpha - }; - assert_eq!(alpha(&ui), 1.0); assert_eq!( ui.rect(menu_id()).unwrap()[1], 44.0, @@ -692,7 +770,6 @@ fn popups_show_at_once_by_their_anchor() { ui.open_popup(menu_id()); menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); menu_frame(&mut ui, Anchor::Point([50.0, 50.0]), None); - assert_eq!(alpha(&ui), 1.0); assert_eq!(ui.rect(menu_id()).unwrap()[..2], [50.0, 50.0]); } @@ -784,6 +861,47 @@ fn the_pointer_moves_the_highlight_only_when_it_moves() { ); } +#[test] +fn headings_are_never_chosen_and_hide_while_filtered() { + let mut ui = open_menu(Some("Filter")); + let mut items = items(); + items.insert( + 3, + popup::Item { + text: "Selection", + heading: true, + separated: true, + ..popup::Item::default() + }, + ); + items[4].separated = false; + let build = |ui: &mut Ui| { + let mut chosen = None; + frame(ui, |ui| { + chosen = popup::menu(ui, menu_id(), BELOW, &items, Some("Filter")); + }); + chosen + }; + build(&mut ui); + ui.event(key(NamedKey::End)); + build(&mut ui); + ui.event(key(NamedKey::ArrowUp)); + build(&mut ui); + assert_eq!( + ui.popups[0].highlight, + Some(1), + "keys skip the heading and Paste" + ); + ui.event(typed("sel")); + build(&mut ui); + ui.event(key(NamedKey::Enter)); + assert_eq!( + build(&mut ui), + Some(4), + "the query matches the item, not its heading" + ); +} + #[test] fn typing_filters_to_the_best_matches_first() { let mut ui = open_menu(Some("Filter")); @@ -808,10 +926,14 @@ fn typing_filters_to_the_best_matches_first() { shown.sort(); shown.into_iter().map(|(_, item)| item).collect::>() }; - assert_eq!(shown(&ui), [3, 0], "letters together before letters apart"); + assert_eq!( + shown(&ui), + [0, 3], + "a word's start before the middle of one" + ); ui.event(key(NamedKey::Backspace)); settle(&mut ui); - assert_eq!(shown(&ui), [0, 1, 3], "a prefix before a word's start"); + assert_eq!(shown(&ui), [0, 1, 3], "equal matches keep their order"); ui.event(key(NamedKey::ArrowDown)); ui.event(key(NamedKey::Enter)); 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() { assert_eq!(row_top(&ui, 500), Some(at - 60.0)); } -fn alpha(ui: &Ui, key: u64) -> Option { - let id = list_id().child(key); - ui.nodes - .iter() - .find(|node| node.id == id) - .map(|node| node.alpha) -} - -fn folding(ui: &Ui, key: u64) -> Option { - let id = list_id().child(("folding", key)); - ui.nodes - .iter() - .find(|node| node.id == id) - .map(|node| node.alpha) -} - -#[test] -fn rows_slide_while_the_rest_fold_away_and_unfold_at_once() { - let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); - let mut selected = None; - let rows = Keyed::new(0..20); - settle_list(&mut ui, &rows, &mut selected); - let rows = Keyed::new([5, 0, 1, 2, 3, 100, 6, 7, 8, 9, 10, 11]); - list_frame(&mut ui, &rows, &mut selected, &[]); - let sliding = row_top(&ui, 0).unwrap(); - assert!( - sliding > 0.0 && sliding < LIST_ROW, - "sliding at once: {sliding}" - ); - for key in [4, 5] { - let alpha = folding(&ui, key).unwrap(); - assert!(alpha > 0.0 && alpha < 1.0, "{key} folds where it was"); - } - assert!(row_top(&ui, 5).unwrap() < 0.0, "out of the seam at the top"); - for _ in 0..4 { - list_frame(&mut ui, &rows, &mut selected, &[]); - } - for key in [5, 100] { - let alpha = alpha(&ui, key).unwrap(); - assert!(alpha > 0.0 && alpha < 1.0, "{key} unfolds where it goes"); - } - for _ in 0..11 { - list_frame(&mut ui, &rows, &mut selected, &[]); - } - assert_eq!(row_top(&ui, 5), Some(0.0), "there in about 250 ms"); - assert_eq!(row_top(&ui, 0), Some(LIST_ROW)); - assert_eq!(row_top(&ui, 100), Some(5.0 * LIST_ROW)); - assert_eq!(alpha(&ui, 100), Some(1.0)); - assert_eq!((folding(&ui, 4), folding(&ui, 5)), (None, None)); - assert!(!ui.wants_frame()); -} - -/// Two rows whose text is drawn over each other: their labels, centred in each row, meet -/// within the view while both can be seen and one is fully drawn. Rows folding together, -/// both blended into the background, may meet. -fn text_overlaps(ui: &mut Ui) -> Option { - let text = ui.measure("Ag")[1]; - let list = ui.nodes.iter().position(|node| node.id == list_id())?; - let rows: Vec<_> = ui - .nodes - .iter() - // Below half an 8-bit step, a row changes no pixel. - .filter(|node| { - node.parent == list && node.size[1] == px(LIST_ROW) && node.alpha * 255.0 >= 0.5 - }) - .map(|node| { - let middle = (node.rect[1] + node.rect[3]) / 2.0; - let [top, bottom] = - [middle - text / 2.0, middle + text / 2.0].map(|y| y.clamp(0.0, VIEW)); - (top, bottom, node.alpha) - }) - .collect(); - rows.iter().enumerate().find_map(|(at, a)| { - rows[at + 1..] - .iter() - .find(|b| a.0 < b.1 && b.0 < a.1 && a.2.max(b.2) >= 1.0) - .map(|b| format!("{a:?} over {b:?}")) - }) -} - -#[test] -fn rows_never_draw_text_over_each_other() { - let orders: [Vec; 7] = [ - (0..30).collect(), - (4..12).chain([2, 3, 16]).chain(12..16).collect(), - (0..30).rev().collect(), - [7, 3, 12, 0, 1, 25, 26, 2, 4, 5].into(), - (0..30) - .filter(|key| !(3..6).contains(key) && *key != 8) - .collect(), - (0..3).chain(40..44).chain(3..30).collect(), - [1, 0, 3, 2, 5, 4, 7, 6, 9, 8, 50].into(), - ]; - for (at, order) in orders.iter().enumerate() { - let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); - let mut selected = None; - settle_list(&mut ui, &Keyed::new(0..30), &mut selected); - // Each change, and another landing half-way through it. - let next = &orders[(at + 1) % orders.len()]; - for (frame, rows) in (0..30).map(|frame| { - let order = if frame < 5 { order } else { next }; - (frame, Keyed::new(order.iter().copied())) - }) { - list_frame(&mut ui, &rows, &mut selected, &[]); - if let Some(overlap) = text_overlaps(&mut ui) { - panic!("order {at}, frame {frame}: {overlap}"); - } - } - } -} - #[test] fn keys_move_the_selection_and_the_view_eases_after_it() { let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); @@ -1246,3 +1257,175 @@ fn a_long_menu_scrolls_by_dragging_its_thumb() { top ); } + +const FONTS: [&str; 46] = [ + "American Typewriter", + "Andale Mono", + "Apple Braille", + "Apple Chancery", + "Arial", + "Arial Black", + "Arial Hebrew", + "Arial Narrow", + "Arial Rounded MT Bold", + "Arial Unicode MS", + "Avenir", + "Baskerville", + "Big Caslon", + "Brush Script MT", + "Chalkboard", + "Charter", + "Cochin", + "Comic Sans MS", + "Courier", + "Courier New", + "Didot", + "Futura", + "Geneva", + "Georgia", + "Gill Sans", + "Helvetica", + "Helvetica Neue", + "Herculanum", + "Hoefler Text", + "Impact", + "Lucida Grande", + "Marker Felt", + "Menlo", + "Monaco", + "Optima", + "Palatino", + "Papyrus", + "Rockwell", + "Savoye LET", + "Skia", + "Tahoma", + "Times", + "Times New Roman", + "Trebuchet MS", + "Verdana", + "Zapfino", +]; + +fn fonts() -> Vec> { + FONTS + .iter() + .map(|text| popup::Item { + text, + ..popup::Item::default() + }) + .collect() +} + +fn ranked(items: &[popup::Item], query: &str) -> Vec<&'static str> { + let order = popup::Matches::new(items, query).order; + order.into_iter().map(|index| FONTS[index]).collect() +} + +#[test] +fn fuzzy_matches_rank_whole_words_first_and_ties_keep_their_order() { + let fonts = fonts(); + let family = [ + "Arial", + "Arial Black", + "Arial Hebrew", + "Arial Narrow", + "Arial Rounded MT Bold", + "Arial Unicode MS", + ]; + assert_eq!(ranked(&fonts, "Arial"), family); + assert_eq!(ranked(&fonts, "rial"), family, "case aside, mid-word"); + let ari = ranked(&fonts, "ari"); + assert_eq!(ari[..6], family, "letters together before letters apart"); + assert_eq!( + ari[6..], + ["American Typewriter", "Apple Braille", "Baskerville"] + ); + assert_eq!( + ranked(&fonts, "tnr"), + ["Times New Roman"], + "words' initials" + ); + assert_eq!( + ranked(&fonts, "ar bl")[0], + "Arial Black", + "words in any order" + ); + assert_eq!( + ranked(&fonts, "ms"), + [ + "Arial Unicode MS", + "Comic Sans MS", + "Trebuchet MS", + "Times", + "Times New Roman" + ] + ); + assert!(ranked(&fonts, "Cafe").is_empty()); +} + +#[test] +fn typing_more_never_reveals_what_a_shorter_query_filtered_out() { + let fonts = fonts(); + for query in [ + "arial black", + "times new roman", + "helvetica neue", + "comic sans ms", + "tnr", + ] { + let mut shown = ranked(&fonts, ""); + for end in 1..=query.len() { + let narrower = ranked(&fonts, &query[..end]); + for font in &narrower { + assert!(shown.contains(font), "{:?} revealed {font}", &query[..end]); + } + shown = narrower; + } + assert!(!shown.is_empty(), "{query}"); + } +} + +#[test] +fn a_filtered_menu_eases_to_its_new_height_while_its_rows_appear_in_place() { + let fonts = fonts(); + let build = |ui: &mut Ui| { + frame(ui, |ui| { + popup::menu(ui, menu_id(), BELOW, &fonts, Some("Font")); + }) + }; + let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK); + build(&mut ui); + ui.open_popup(menu_id()); + build(&mut ui); + build(&mut ui); + let results = menu_id().child("results"); + let height = |ui: &Ui| { + let rect = ui.rect(results).unwrap(); + rect[3] - rect[1] + }; + let tall = height(&ui); + assert!( + !ui.wants_frame() && tall > 5.0 * 26.0, + "open at its full height" + ); + ui.event(typed("tnr")); + build(&mut ui); + build(&mut ui); + let row = ui.rect(menu_id().child("rows").child(42_u64)).unwrap(); + assert_eq!( + row[1], + ui.rect(results).unwrap()[1], + "Times New Roman at the top" + ); + let easing = height(&ui); + assert!(easing > 26.0 && easing < tall, "{easing}"); + let mut frames = 0; + while ui.wants_frame() { + build(&mut ui); + frames += 1; + assert_eq!(ui.rect(menu_id().child("rows").child(42_u64)), Some(row)); + } + assert_eq!(height(&ui), 26.0); + assert!(frames <= 10, "settles in {frames} frames"); +} diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index a8982c991d95aa33d9a6b17eb845418285fdd188..26df62274073b02ff76970467322084b6e837307 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -11,7 +11,7 @@ pub(crate) struct Label { pub size: [f32; 2], } -type LabelKey = (String, u32, Option); +type LabelKey = (String, u32, bool, Option); /// Labels shaped this frame or the previous one, keyed by their text and size. #[derive(Default)] @@ -20,14 +20,21 @@ pub(crate) struct Texts { context: LayoutContext<()>, /// A family registered in place of the system's interface font. family: Option, - /// By text, size and preferred family, with the frame each was last used. + /// By text, size, weight and preferred family, with the frame each was last used. cache: HashMap, u64)>, } impl Texts { /// `size` is in logical pixels; `font` names a family to prefer over the interface's. - pub fn label(&mut self, text: &str, size: f32, font: Option<&str>, frame: u64) -> RcSnowbound showing a garden notebook page with a to-do list, a table and a sketch on OneNote's Ivy background + Snowbound implements the file and sync protocol used in 2010 Microsoft OneNote, so notebooks are fully compatible, including collaboration features. It (TODO)