diff --git a/Cargo.lock b/Cargo.lock index 827b573399519f7b60cc2e55d159408af52ad1c4..5919254f084fdbe059e9f8fec367a7f257e2eed4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,8 +400,10 @@ dependencies = [ "parley", "png", "pollster", + "resvg", "serde", "serde_json", + "sha2", "skrifa", "wgpu", ] @@ -642,6 +644,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "dbl" version = "0.5.0" @@ -757,6 +765,15 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -801,6 +818,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "foldhash" version = "0.2.0" @@ -1210,6 +1233,12 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + [[package]] name = "indexmap" version = "2.14.2" @@ -1332,6 +1361,18 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "libc" version = "0.2.189" @@ -1958,6 +1999,7 @@ version = "0.1.0" dependencies = [ "aes", "base64", + "bumpalo", "cbc", "getrandom 0.4.3", "md5", @@ -2081,6 +2123,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -2152,6 +2200,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "polyval" version = "0.7.3" @@ -2329,6 +2386,30 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia 0.12.0", + "usvg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "roxmltree" version = "0.21.1" @@ -2441,7 +2522,7 @@ dependencies = [ "log", "memmap2", "smithay-client-toolkit", - "tiny-skia", + "tiny-skia 0.11.4", ] [[package]] @@ -2543,6 +2624,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "skrifa" version = "0.44.0" @@ -2705,6 +2801,9 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] [[package]] name = "subtle" @@ -2712,6 +2811,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "swash" version = "0.2.10" @@ -2829,7 +2938,22 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "tiny-skia-path", + "tiny-skia-path 0.11.4", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path 0.12.0", ] [[package]] @@ -2843,6 +2967,17 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2969,6 +3104,25 @@ dependencies = [ "ctutils", ] +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "data-url", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path 0.12.0", +] + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 9554ceb1cdac412c0de2821e0cad846ff86fd5c1..9177e380ba9d48672da61240492e8adc3ef731cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,8 @@ resolver = "3" [workspace.dependencies] parley = { git = "https://github.com/linebender/parley", rev = "4f67747d3690bb21d25cc70b674ab0d269595759", features = ["complex-scripts"] } wgpu = "30.0.1" + +# Shaping, rasterization and GPU submission stay optimised in debug builds, where +# unoptimised dependencies alone drop frames. +[profile.dev.package."*"] +opt-level = 3 diff --git a/crates/canvas/Cargo.toml b/crates/canvas/Cargo.toml index a2190ec5b8b4bd21d3053504761a6d5ebe7a305a..520f1f06f4465dfef42d31cecd588b74f66f3c30 100644 --- a/crates/canvas/Cargo.toml +++ b/crates/canvas/Cargo.toml @@ -5,19 +5,21 @@ edition = "2024" publish = false [features] -gpu = ["dep:draw"] +gpu = ["draw/render", "dep:resvg", "dep:sha2"] # How a page responds to input and presents itself to assistive technology. interaction = ["gpu", "dep:accesskit"] [dependencies] +draw = { path = "../draw", default-features = false } onestore = { path = "../onestore" } parley.workspace = true serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" skrifa = "0.44.0" -draw = { path = "../draw", optional = true } accesskit = { version = "0.25.0", optional = true } +resvg = { version = "0.48.1", default-features = false, optional = true } +sha2 = { version = "0.11", optional = true } [[bin]] name = "layout-probe" diff --git a/crates/canvas/assets/backgrounds/bamboo.svg b/crates/canvas/assets/backgrounds/bamboo.svg new file mode 100644 index 0000000000000000000000000000000000000000..a0185c29e6c0b35b8573c0926314e489cd2a693f --- /dev/null +++ b/crates/canvas/assets/backgrounds/bamboo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/binoculars-corner.svg b/crates/canvas/assets/backgrounds/binoculars-corner.svg new file mode 100644 index 0000000000000000000000000000000000000000..7918105398b68a5ef568a38b05ef595024bf24f8 --- /dev/null +++ b/crates/canvas/assets/backgrounds/binoculars-corner.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/black-and-green-title.svg b/crates/canvas/assets/backgrounds/black-and-green-title.svg new file mode 100644 index 0000000000000000000000000000000000000000..471ae6eebc637f87a53ed7f115664a690adc7165 --- /dev/null +++ b/crates/canvas/assets/backgrounds/black-and-green-title.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/blue-bubbles-corner-margin.svg b/crates/canvas/assets/backgrounds/blue-bubbles-corner-margin.svg new file mode 100644 index 0000000000000000000000000000000000000000..5b36dc42a43ca5b7210f04cf21bd5ea0d585488b --- /dev/null +++ b/crates/canvas/assets/backgrounds/blue-bubbles-corner-margin.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/blue-bubbles-corner-top.svg b/crates/canvas/assets/backgrounds/blue-bubbles-corner-top.svg new file mode 100644 index 0000000000000000000000000000000000000000..8a6b00143fccc50a528a266c30c42be135279b70 --- /dev/null +++ b/crates/canvas/assets/backgrounds/blue-bubbles-corner-top.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/canvas/assets/backgrounds/blue-clouds.svg b/crates/canvas/assets/backgrounds/blue-clouds.svg new file mode 100644 index 0000000000000000000000000000000000000000..a70d648476859193b140da2d4518c7dd31a31035 --- /dev/null +++ b/crates/canvas/assets/backgrounds/blue-clouds.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/blue-dots.svg b/crates/canvas/assets/backgrounds/blue-dots.svg new file mode 100644 index 0000000000000000000000000000000000000000..d375954b51c77adc1dc8bd835609afdb97e04074 --- /dev/null +++ b/crates/canvas/assets/backgrounds/blue-dots.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/blue-flow.svg b/crates/canvas/assets/backgrounds/blue-flow.svg new file mode 100644 index 0000000000000000000000000000000000000000..0f78798eeaac0765deb68132f9bb5de7eeeeb73d --- /dev/null +++ b/crates/canvas/assets/backgrounds/blue-flow.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/blue-mist-margin.svg b/crates/canvas/assets/backgrounds/blue-mist-margin.svg new file mode 100644 index 0000000000000000000000000000000000000000..304ef5ff8d0b5849c336d432fdfd9e5070c710a5 --- /dev/null +++ b/crates/canvas/assets/backgrounds/blue-mist-margin.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/blue-stripe-title.svg b/crates/canvas/assets/backgrounds/blue-stripe-title.svg new file mode 100644 index 0000000000000000000000000000000000000000..e34323c3eaf6cfa657112a515d0c372a8f03aa33 --- /dev/null +++ b/crates/canvas/assets/backgrounds/blue-stripe-title.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/blue-stripes.svg b/crates/canvas/assets/backgrounds/blue-stripes.svg new file mode 100644 index 0000000000000000000000000000000000000000..2bf0418e2c9cfecc8f1024c4238f01b6a96a44ee --- /dev/null +++ b/crates/canvas/assets/backgrounds/blue-stripes.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/blue-swirls.svg b/crates/canvas/assets/backgrounds/blue-swirls.svg new file mode 100644 index 0000000000000000000000000000000000000000..d3902ba133e8326d2daa0ccc5147936a6f3411c6 --- /dev/null +++ b/crates/canvas/assets/backgrounds/blue-swirls.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/blue-wave-title.svg b/crates/canvas/assets/backgrounds/blue-wave-title.svg new file mode 100644 index 0000000000000000000000000000000000000000..86090a85c384349c03bbce7e34e0446ea7a7fe12 --- /dev/null +++ b/crates/canvas/assets/backgrounds/blue-wave-title.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/canvas/assets/backgrounds/blue-waves.svg b/crates/canvas/assets/backgrounds/blue-waves.svg new file mode 100644 index 0000000000000000000000000000000000000000..621d4b2d32e62748f5ec934f6539847c0b5805d4 --- /dev/null +++ b/crates/canvas/assets/backgrounds/blue-waves.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/books.svg b/crates/canvas/assets/backgrounds/books.svg new file mode 100644 index 0000000000000000000000000000000000000000..f264958dce281982c0ee9527150441276a06f834 --- /dev/null +++ b/crates/canvas/assets/backgrounds/books.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/bouquet.svg b/crates/canvas/assets/backgrounds/bouquet.svg new file mode 100644 index 0000000000000000000000000000000000000000..3f9f6ddcd8ed8f43dbdaa635f7bf0deb1e6e65e0 --- /dev/null +++ b/crates/canvas/assets/backgrounds/bouquet.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/boxes-shadow.svg b/crates/canvas/assets/backgrounds/boxes-shadow.svg new file mode 100644 index 0000000000000000000000000000000000000000..c86856fd72bdd48c5e1ce3414dd8f2cbddb29bc7 --- /dev/null +++ b/crates/canvas/assets/backgrounds/boxes-shadow.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/boxes.svg b/crates/canvas/assets/backgrounds/boxes.svg new file mode 100644 index 0000000000000000000000000000000000000000..546404fc988964e7d6a3af887fb80acf05a1eb4a --- /dev/null +++ b/crates/canvas/assets/backgrounds/boxes.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/canvas/assets/backgrounds/bubbles-margin-margin.svg b/crates/canvas/assets/backgrounds/bubbles-margin-margin.svg new file mode 100644 index 0000000000000000000000000000000000000000..2899f20a6d5497b18bf2e0ede601129c62b2f279 --- /dev/null +++ b/crates/canvas/assets/backgrounds/bubbles-margin-margin.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/bubbles-margin-top.svg b/crates/canvas/assets/backgrounds/bubbles-margin-top.svg new file mode 100644 index 0000000000000000000000000000000000000000..3db7d13111714687e484a687fd4d36e5d7f44441 --- /dev/null +++ b/crates/canvas/assets/backgrounds/bubbles-margin-top.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/bubbles.svg b/crates/canvas/assets/backgrounds/bubbles.svg new file mode 100644 index 0000000000000000000000000000000000000000..aeaf0c7afa699c539ed8dd3ca700e60a2e5245c6 --- /dev/null +++ b/crates/canvas/assets/backgrounds/bubbles.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/buildings.svg b/crates/canvas/assets/backgrounds/buildings.svg new file mode 100644 index 0000000000000000000000000000000000000000..5173220be59629a3827d3da8120adba86fe1acda --- /dev/null +++ b/crates/canvas/assets/backgrounds/buildings.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/chain-title.svg b/crates/canvas/assets/backgrounds/chain-title.svg new file mode 100644 index 0000000000000000000000000000000000000000..0e8e156ee4d6c9ead7e8333f8e507aecf4d77e38 --- /dev/null +++ b/crates/canvas/assets/backgrounds/chain-title.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/circles.svg b/crates/canvas/assets/backgrounds/circles.svg new file mode 100644 index 0000000000000000000000000000000000000000..9caf23666d67c8fe0f95a37e9adc9c0b1c4f9fca --- /dev/null +++ b/crates/canvas/assets/backgrounds/circles.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/clock.svg b/crates/canvas/assets/backgrounds/clock.svg new file mode 100644 index 0000000000000000000000000000000000000000..b4bcb3fe488973eef430fc2529bf9dc62809cc54 --- /dev/null +++ b/crates/canvas/assets/backgrounds/clock.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/columns.svg b/crates/canvas/assets/backgrounds/columns.svg new file mode 100644 index 0000000000000000000000000000000000000000..3eef61f99881b916aba617604f9e791eff6e44b4 --- /dev/null +++ b/crates/canvas/assets/backgrounds/columns.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/computer.svg b/crates/canvas/assets/backgrounds/computer.svg new file mode 100644 index 0000000000000000000000000000000000000000..57cf93e37be039bb96de30647ddbf429d9abd078 --- /dev/null +++ b/crates/canvas/assets/backgrounds/computer.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/cyan-title.svg b/crates/canvas/assets/backgrounds/cyan-title.svg new file mode 100644 index 0000000000000000000000000000000000000000..f9643c4a671164c2188373128171fb399f6144ae --- /dev/null +++ b/crates/canvas/assets/backgrounds/cyan-title.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/day-planner.svg b/crates/canvas/assets/backgrounds/day-planner.svg new file mode 100644 index 0000000000000000000000000000000000000000..e2819f24814b37cf5b7744bfa54f0a8bf24edffe --- /dev/null +++ b/crates/canvas/assets/backgrounds/day-planner.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/fireworks.svg b/crates/canvas/assets/backgrounds/fireworks.svg new file mode 100644 index 0000000000000000000000000000000000000000..f3bc68941ce45c66d15f2c4cf119a090bedd563c --- /dev/null +++ b/crates/canvas/assets/backgrounds/fireworks.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/flat-squares.svg b/crates/canvas/assets/backgrounds/flat-squares.svg new file mode 100644 index 0000000000000000000000000000000000000000..ed735233e9a8c40e966de0921cfcb24883b69ddc --- /dev/null +++ b/crates/canvas/assets/backgrounds/flat-squares.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/flowers-and-hearts.svg b/crates/canvas/assets/backgrounds/flowers-and-hearts.svg new file mode 100644 index 0000000000000000000000000000000000000000..99d192e76b143e0d16fed5fc95cd84c987987d3a --- /dev/null +++ b/crates/canvas/assets/backgrounds/flowers-and-hearts.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/glasses-corner.svg b/crates/canvas/assets/backgrounds/glasses-corner.svg new file mode 100644 index 0000000000000000000000000000000000000000..7e2083975a639abbade6ee87147f37678a8e1938 --- /dev/null +++ b/crates/canvas/assets/backgrounds/glasses-corner.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/globe.svg b/crates/canvas/assets/backgrounds/globe.svg new file mode 100644 index 0000000000000000000000000000000000000000..d585c383107b88c18b02573b256c19c1fbd57dfc --- /dev/null +++ b/crates/canvas/assets/backgrounds/globe.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/graph.svg b/crates/canvas/assets/backgrounds/graph.svg new file mode 100644 index 0000000000000000000000000000000000000000..48b9b1392241362cefa3af1012941003e68ab2ed --- /dev/null +++ b/crates/canvas/assets/backgrounds/graph.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/green-stripes.svg b/crates/canvas/assets/backgrounds/green-stripes.svg new file mode 100644 index 0000000000000000000000000000000000000000..42bb8e6dfafb9b0fb2b3e808fa01e7b716309aee --- /dev/null +++ b/crates/canvas/assets/backgrounds/green-stripes.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/hearts.svg b/crates/canvas/assets/backgrounds/hearts.svg new file mode 100644 index 0000000000000000000000000000000000000000..c4960189714e0267526dce571bbe4d7314fc660f --- /dev/null +++ b/crates/canvas/assets/backgrounds/hearts.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/ivy.svg b/crates/canvas/assets/backgrounds/ivy.svg new file mode 100644 index 0000000000000000000000000000000000000000..6bdc36176922fe4102ddb0302292ba262909d502 --- /dev/null +++ b/crates/canvas/assets/backgrounds/ivy.svg @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/large-push-pins.svg b/crates/canvas/assets/backgrounds/large-push-pins.svg new file mode 100644 index 0000000000000000000000000000000000000000..ee3f9525dea9741ee80143fe97b34c8db43c05bf --- /dev/null +++ b/crates/canvas/assets/backgrounds/large-push-pins.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/light-bulb-corner.svg b/crates/canvas/assets/backgrounds/light-bulb-corner.svg new file mode 100644 index 0000000000000000000000000000000000000000..56a4bb0aff0e928e671d5ef41b27b19171afbe7d --- /dev/null +++ b/crates/canvas/assets/backgrounds/light-bulb-corner.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/lilies.svg b/crates/canvas/assets/backgrounds/lilies.svg new file mode 100644 index 0000000000000000000000000000000000000000..ad47e4ef4dbc7d3f37c6b582bd68591353214056 --- /dev/null +++ b/crates/canvas/assets/backgrounds/lilies.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/math-science-class-notes.svg b/crates/canvas/assets/backgrounds/math-science-class-notes.svg new file mode 100644 index 0000000000000000000000000000000000000000..599a188f1032102d9795f787e18e4753d6d50fb8 --- /dev/null +++ b/crates/canvas/assets/backgrounds/math-science-class-notes.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/math.svg b/crates/canvas/assets/backgrounds/math.svg new file mode 100644 index 0000000000000000000000000000000000000000..d1e9eb85ecd5c808480663b64208d2e4b7e4e92d --- /dev/null +++ b/crates/canvas/assets/backgrounds/math.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/networking.svg b/crates/canvas/assets/backgrounds/networking.svg new file mode 100644 index 0000000000000000000000000000000000000000..a4c8beae65eb8ca71ca9f226a8596acb239b096e --- /dev/null +++ b/crates/canvas/assets/backgrounds/networking.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/notebook.svg b/crates/canvas/assets/backgrounds/notebook.svg new file mode 100644 index 0000000000000000000000000000000000000000..b6fc2348ffdcc0eed6ea063b1b7a23692fa3df65 --- /dev/null +++ b/crates/canvas/assets/backgrounds/notebook.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/orange-margin.svg b/crates/canvas/assets/backgrounds/orange-margin.svg new file mode 100644 index 0000000000000000000000000000000000000000..fa87629dad9f3fff4da9c4e81f08715cd0bfa45f --- /dev/null +++ b/crates/canvas/assets/backgrounds/orange-margin.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/paper.svg b/crates/canvas/assets/backgrounds/paper.svg new file mode 100644 index 0000000000000000000000000000000000000000..08282bf79c4abf32cf299229efc8e2d70dc41464 --- /dev/null +++ b/crates/canvas/assets/backgrounds/paper.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/paws-cyan.svg b/crates/canvas/assets/backgrounds/paws-cyan.svg new file mode 100644 index 0000000000000000000000000000000000000000..ed86f3c14cb3371a594c716340099e16db97f760 --- /dev/null +++ b/crates/canvas/assets/backgrounds/paws-cyan.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/pencil-and-notebook.svg b/crates/canvas/assets/backgrounds/pencil-and-notebook.svg new file mode 100644 index 0000000000000000000000000000000000000000..1c0a7336515e48cde2a4d942df1a92a4d187b906 --- /dev/null +++ b/crates/canvas/assets/backgrounds/pencil-and-notebook.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/pens.svg b/crates/canvas/assets/backgrounds/pens.svg new file mode 100644 index 0000000000000000000000000000000000000000..00bb627c5a75026de13aa8a54eff46c402faed7e --- /dev/null +++ b/crates/canvas/assets/backgrounds/pens.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/pixel-blends.svg b/crates/canvas/assets/backgrounds/pixel-blends.svg new file mode 100644 index 0000000000000000000000000000000000000000..d1ea597ff3c0c96622aa0c09a3ad42c5b32a7abb --- /dev/null +++ b/crates/canvas/assets/backgrounds/pixel-blends.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/pixel.svg b/crates/canvas/assets/backgrounds/pixel.svg new file mode 100644 index 0000000000000000000000000000000000000000..2e192f63cdeba5134ce8054b5298ea208df33a81 --- /dev/null +++ b/crates/canvas/assets/backgrounds/pixel.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/plain-and-simple.svg b/crates/canvas/assets/backgrounds/plain-and-simple.svg new file mode 100644 index 0000000000000000000000000000000000000000..51b383711d49dc1dc6700021e92702022b5479cd --- /dev/null +++ b/crates/canvas/assets/backgrounds/plain-and-simple.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/purple-clouds.svg b/crates/canvas/assets/backgrounds/purple-clouds.svg new file mode 100644 index 0000000000000000000000000000000000000000..d7998f6a7ae613f6e41e78239ba6c4bdc34b2126 --- /dev/null +++ b/crates/canvas/assets/backgrounds/purple-clouds.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/pushpins-corner.svg b/crates/canvas/assets/backgrounds/pushpins-corner.svg new file mode 100644 index 0000000000000000000000000000000000000000..a1ab5c19641f544c01f29f61541c455f4e267e9a --- /dev/null +++ b/crates/canvas/assets/backgrounds/pushpins-corner.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/rainbow-border.svg b/crates/canvas/assets/backgrounds/rainbow-border.svg new file mode 100644 index 0000000000000000000000000000000000000000..48020a0027f945ff26d95f2113a1c15ed1bad4e9 --- /dev/null +++ b/crates/canvas/assets/backgrounds/rainbow-border.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/rainbow-mini-border.svg b/crates/canvas/assets/backgrounds/rainbow-mini-border.svg new file mode 100644 index 0000000000000000000000000000000000000000..4c7371e97f988c1f1e37c529a9d3e2644503fe48 --- /dev/null +++ b/crates/canvas/assets/backgrounds/rainbow-mini-border.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/rainbow.svg b/crates/canvas/assets/backgrounds/rainbow.svg new file mode 100644 index 0000000000000000000000000000000000000000..de9dc677de9e4a7c0cdb3bf7f27c81e992fff19c --- /dev/null +++ b/crates/canvas/assets/backgrounds/rainbow.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/recipe.svg b/crates/canvas/assets/backgrounds/recipe.svg new file mode 100644 index 0000000000000000000000000000000000000000..01793025b19994eafa69f4baba602db3c3c314cf --- /dev/null +++ b/crates/canvas/assets/backgrounds/recipe.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/red-and-black-margin.svg b/crates/canvas/assets/backgrounds/red-and-black-margin.svg new file mode 100644 index 0000000000000000000000000000000000000000..3340a9d4eee23797da5502df64b425f4decc1657 --- /dev/null +++ b/crates/canvas/assets/backgrounds/red-and-black-margin.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/side-stripes.svg b/crates/canvas/assets/backgrounds/side-stripes.svg new file mode 100644 index 0000000000000000000000000000000000000000..93117cde6e0c729bb9abd2af1f10d05bfa55f150 --- /dev/null +++ b/crates/canvas/assets/backgrounds/side-stripes.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/small-push-pins.svg b/crates/canvas/assets/backgrounds/small-push-pins.svg new file mode 100644 index 0000000000000000000000000000000000000000..612891e1ccbf1412b19f09bd3fbd8bae080be8bd --- /dev/null +++ b/crates/canvas/assets/backgrounds/small-push-pins.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/sparks.svg b/crates/canvas/assets/backgrounds/sparks.svg new file mode 100644 index 0000000000000000000000000000000000000000..875b5c8fd8b4fc9f47cb24d36b7bf97aa96fbcb6 --- /dev/null +++ b/crates/canvas/assets/backgrounds/sparks.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/stars.svg b/crates/canvas/assets/backgrounds/stars.svg new file mode 100644 index 0000000000000000000000000000000000000000..c00933439dacbeb4b43be8112b344a1793a23849 --- /dev/null +++ b/crates/canvas/assets/backgrounds/stars.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/swirls.svg b/crates/canvas/assets/backgrounds/swirls.svg new file mode 100644 index 0000000000000000000000000000000000000000..b9bbaf4107314e42fbb4f4c19d7ff3007cff7fc2 --- /dev/null +++ b/crates/canvas/assets/backgrounds/swirls.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/tiles.svg b/crates/canvas/assets/backgrounds/tiles.svg new file mode 100644 index 0000000000000000000000000000000000000000..0d15498805e2cf07e2fb54fc82ae4985385d7531 --- /dev/null +++ b/crates/canvas/assets/backgrounds/tiles.svg @@ -0,0 +1,389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/triangles-title-margin.svg b/crates/canvas/assets/backgrounds/triangles-title-margin.svg new file mode 100644 index 0000000000000000000000000000000000000000..8cb983d15fbd8506c9d48feeb1bcd883698efc39 --- /dev/null +++ b/crates/canvas/assets/backgrounds/triangles-title-margin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/canvas/assets/backgrounds/triangles-title-top.svg b/crates/canvas/assets/backgrounds/triangles-title-top.svg new file mode 100644 index 0000000000000000000000000000000000000000..f8e90d23d9b8ff4395b511cca88538de26723261 --- /dev/null +++ b/crates/canvas/assets/backgrounds/triangles-title-top.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/canvas/assets/backgrounds/tulips.svg b/crates/canvas/assets/backgrounds/tulips.svg new file mode 100644 index 0000000000000000000000000000000000000000..d79019e4160fb4fbd96c8bb87dfd3656918ef8ad --- /dev/null +++ b/crates/canvas/assets/backgrounds/tulips.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/window-reflection.svg b/crates/canvas/assets/backgrounds/window-reflection.svg new file mode 100644 index 0000000000000000000000000000000000000000..04990aaf7bcac5467215da2b9f4a4576702ad75c --- /dev/null +++ b/crates/canvas/assets/backgrounds/window-reflection.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/canvas/assets/backgrounds/writing.svg b/crates/canvas/assets/backgrounds/writing.svg new file mode 100644 index 0000000000000000000000000000000000000000..d199150185aef8ab4ea88be7ae731bd0039a5035 --- /dev/null +++ b/crates/canvas/assets/backgrounds/writing.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/canvas/examples/render_page.rs b/crates/canvas/examples/render_page.rs index d563bcfc31ee37c779f7ab63fc5ecbd96190e702..ce1e54e584546004c6493815e24a6174b53ec663 100644 --- a/crates/canvas/examples/render_page.rs +++ b/crates/canvas/examples/render_page.rs @@ -1,4 +1,4 @@ -use canvas::gpu::{Viewport, page::PageScene}; +use canvas::gpu::{Paper, Viewport, page::PageScene}; use canvas::layout::TextEngine; use draw::Renderer; use onestore::page::Page; @@ -38,9 +38,12 @@ fn main() -> Result<(), Box> { }; let margin_origin = page.margin_origin; let object_count = page.objects.len(); - let scene = PageScene::new(page, &mut engine)?; + let mut scene = PageScene::new(page, &mut engine)?; + while !scene.update_backgrounds(Paper::WHITE, 96.0 / 72.0, std::task::Waker::noop()) { + std::thread::sleep(Duration::from_millis(1)); + } let mut primitives = Vec::new(); - scene.append_primitives(&mut primitives, [0.0; 2])?; + scene.append_primitives(&mut primitives, [0.0; 2], Paper::WHITE)?; let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); let adapter = pollster::block_on(instance.request_adapter(&Default::default()))?; let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?; diff --git a/crates/canvas/src/document.rs b/crates/canvas/src/document.rs index 417080b2d12d05e91d7a48b039d3e85ebf3135f1..08d18edf13876cd2278b99968f8c3bd5c9b86747 100644 --- a/crates/canvas/src/document.rs +++ b/crates/canvas/src/document.rs @@ -16,6 +16,11 @@ pub struct TextPosition { #[derive(Clone, Debug, PartialEq)] pub struct TextDocument { nodes: Vec, + /// The paragraph position of each root node's first text leaf, then the paragraph count, so + /// a position finds its node by search instead of walking every leaf before it. + starts: Vec, + /// Root indices of the tables, so a cell is found without walking every node. + tables: Vec, } #[derive(Clone, Debug, PartialEq)] @@ -123,85 +128,98 @@ pub(crate) fn swap_columns(nodes: &mut [PageParagraph], widths: &mut BTreeMap impl Iterator + '_ { + let (content, rows) = match &node.content { + ParagraphContent::Text(text) => (text.id, &[][..]), + ParagraphContent::Image(image) => (image.id, &[][..]), + ParagraphContent::Attachment(file) => (file.id, &[][..]), + ParagraphContent::Ink(ink) => (ink.id, &[][..]), + ParagraphContent::Unsupported(unsupported) => (unsupported.id, &[][..]), + ParagraphContent::Table(table) => (table.id, table.rows.as_slice()), + }; + [node.id, content].into_iter().chain( + rows.iter() + .flat_map(|row| std::iter::once(row.id).chain(row.cells.iter().map(|cell| cell.id))), + ) +} + pub(crate) fn validate_nodes( nodes: &[PageParagraph], - edit: Option<&DocumentEdit>, ids: &mut BTreeSet, ) -> Result<(), EditError> { - let mut pending = vec![(None, nodes, 0)]; - while let Some((container, nodes, depth)) = pending.pop() { + if nodes.is_empty() { + return Err(EditError::InvalidRange); + } + validate_run(nodes, &[], 0, ids) +} + +/// Validates `nodes`, which follow `earlier` in a container at `depth`, and every container +/// nested in them; `earlier` is searched only for parents the run lacks. +fn validate_run( + nodes: &[PageParagraph], + earlier: &[PageParagraph], + depth: usize, + ids: &mut BTreeSet, +) -> Result<(), EditError> { + let mut pending = vec![(nodes, earlier, depth)]; + while let Some((nodes, earlier, depth)) = pending.pop() { if depth > 64 { return Err(EditError::InvalidStructure); } - let mut nodes = edited_nodes(nodes, container, edit).peekable(); - if nodes.peek().is_none() { - return Err(EditError::InvalidRange); - } let mut parents = BTreeMap::new(); for node in nodes { - if !ids.insert(node.id) + let parent = |id| { + parents.get(&id).copied().or_else(|| { + earlier + .iter() + .rev() + .find(|node| node.id == id) + .map(|node| node.level) + }) + }; + if owned_ids(node).any(|id| !ids.insert(id)) || node.level == 0 || node .parent - .is_some_and(|id| parents.get(&id).is_none_or(|level| *level >= node.level)) + .is_some_and(|id| parent(id).is_none_or(|level| level >= node.level)) { return Err(EditError::InvalidStructure); } parents.insert(node.id, node.level); match &node.content { - ParagraphContent::Text(text) => { - if !ids.insert(text.id) { - return Err(EditError::InvalidStructure); - } - } ParagraphContent::Image(image) => { - if !ids.insert(image.id) { - return Err(EditError::InvalidStructure); - } crate::outline::image_size(image).ok_or(EditError::UnsupportedContent)?; } - ParagraphContent::Attachment(file) => { - if !ids.insert(file.id) { - return Err(EditError::InvalidStructure); - } - } - ParagraphContent::Ink(ink) => { - if !ids.insert(ink.id) { - return Err(EditError::InvalidStructure); - } - } - ParagraphContent::Unsupported(unsupported) => { - if !ids.insert(unsupported.id) { - return Err(EditError::InvalidStructure); - } - } ParagraphContent::Table(table) => { - if !ids.insert(table.id) - || table.rows.is_empty() + if table.rows.is_empty() || table.columns.is_empty() || table.columns.len() > 255 || table .columns .iter() .any(|column| !column.width.is_finite() || column.width < 36.0) + || table + .rows + .iter() + .any(|row| row.cells.len() != table.columns.len()) { return Err(EditError::InvalidStructure); } - for row in &table.rows { - if !ids.insert(row.id) || row.cells.len() != table.columns.len() { - return Err(EditError::InvalidStructure); + for cell in table.rows.iter().flat_map(|row| &row.cells) { + if !cell.unsupported.is_empty() { + return Err(EditError::UnsupportedContent); } - for cell in &row.cells { - if !ids.insert(cell.id) { - return Err(EditError::InvalidStructure); - } - if !cell.unsupported.is_empty() { - return Err(EditError::UnsupportedContent); - } - pending.push((Some(cell.id), cell.paragraphs.as_slice(), depth + 1)); + if cell.paragraphs.is_empty() { + return Err(EditError::InvalidRange); } + pending.push((cell.paragraphs.as_slice(), &[][..], depth + 1)); } } + ParagraphContent::Text(_) + | ParagraphContent::Attachment(_) + | ParagraphContent::Ink(_) + | ParagraphContent::Unsupported(_) => {} } } } @@ -216,6 +234,15 @@ fn validate_text(nodes: &[PageParagraph]) -> Result<(), EditError> { Ok(()) } +/// Paragraph positions of each node's first text leaf, counting from `first`. +fn starts(nodes: &[PageParagraph], first: usize) -> impl Iterator + '_ { + nodes.iter().scan(first, |next, node| { + let start = *next; + *next += leaves(std::slice::from_ref(node), None).count(); + Some(start) + }) +} + pub(crate) fn validate_flat<'a>( mut nodes: impl Iterator, ) -> Result<(), EditError> { @@ -235,16 +262,20 @@ pub(crate) fn container_mut( nodes: &mut Vec, id: Option, ) -> Option<&mut Vec> { - let Some(id) = id else { - return Some(nodes); - }; + match id { + None => Some(nodes), + Some(id) => cell_mut(nodes, id), + } +} + +fn cell_mut(nodes: &mut [PageParagraph], id: ExGuid) -> Option<&mut Vec> { for node in nodes { if let ParagraphContent::Table(table) = &mut node.content { for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { if cell.id == id { return Some(&mut cell.paragraphs); } - if let Some(nodes) = container_mut(&mut cell.paragraphs, Some(id)) { + if let Some(nodes) = cell_mut(&mut cell.paragraphs, id) { return Some(nodes); } } @@ -253,6 +284,14 @@ pub(crate) fn container_mut( None } +fn tables(nodes: &[PageParagraph], first: usize) -> impl Iterator + '_ { + nodes + .iter() + .enumerate() + .filter(|(_, node)| matches!(node.content, ParagraphContent::Table(_))) + .map(move |(index, _)| first + index) +} + impl TextDocument { pub fn new(paragraphs: Vec) -> Result { Self::from_nodes( @@ -264,9 +303,15 @@ impl TextDocument { } pub fn from_nodes(nodes: Vec) -> Result { - validate_nodes(&nodes, None, &mut BTreeSet::new())?; + validate_nodes(&nodes, &mut BTreeSet::new())?; validate_text(&nodes)?; - Ok(Self { nodes }) + let mut starts = starts(&nodes, 0).collect::>(); + starts.push(leaves(&nodes, None).count()); + Ok(Self { + tables: tables(&nodes, 0).collect(), + nodes, + starts, + }) } pub fn nodes(&self) -> &[PageParagraph] { @@ -285,19 +330,90 @@ impl TextDocument { self.text_nodes().map(|node| &node.text().unwrap().text) } + /// The text leaf at a paragraph position, with its container and index there. + pub(crate) fn leaf(&self, paragraph: usize) -> Option<(Option, usize, &PageParagraph)> { + let root = self + .starts + .partition_point(|start| *start <= paragraph) + .checked_sub(1)?; + let node = self.nodes.get(root)?; + match node.text() { + Some(_) => Some((None, root, node)), + None => leaves(std::slice::from_ref(node), None).nth(paragraph - self.starts[root]), + } + } + + /// The text leaf at a paragraph position once a valid `edit` applies, with the position it + /// has now unless the edit supplies it. + pub(crate) fn edited_leaf<'a>( + &'a self, + edit: &'a DocumentEdit, + paragraph: usize, + ) -> Option<(&'a PageParagraph, Option)> { + let nodes = self.container(edit.container).ok()?; + let first = match edit.container { + None => self.starts[edit.range.start], + Some(cell) => { + let root = self.root(cell).ok()?; + self.starts[root] + + descendants(std::slice::from_ref(&self.nodes[root]), None) + .take_while(|(container, _, _)| *container != edit.container) + .filter(|(_, _, node)| node.text().is_some()) + .count() + + leaves(&nodes[..edit.range.start], None).count() + } + }; + let added = leaves(&edit.replacement, None).count(); + match paragraph.checked_sub(first) { + Some(offset) if offset < added => leaves(&edit.replacement, None) + .nth(offset) + .map(|(_, _, node)| (node, None)), + Some(_) => { + let before = paragraph - added + leaves(&nodes[edit.range.clone()], None).count(); + self.leaf(before).map(|(_, _, node)| (node, Some(before))) + } + None => self + .leaf(paragraph) + .map(|(_, _, node)| (node, Some(paragraph))), + } + } + + /// The root node holding a table cell. + pub(crate) fn root(&self, cell: ExGuid) -> Result { + self.tables + .iter() + .copied() + .find(|&root| { + descendants(std::slice::from_ref(&self.nodes[root]), None) + .any(|(container, _, _)| container == Some(cell)) + }) + .ok_or(EditError::InvalidRange) + } + + pub(crate) fn paragraph(&self, index: usize) -> Option<&Paragraph> { + self.leaf(index) + .map(|(_, _, node)| &node.text().unwrap().text) + } + pub(crate) fn container(&self, id: Option) -> Result<&[PageParagraph], EditError> { + self.nested_container(id).map(|(nodes, _)| nodes) + } + + /// The container's paragraphs and how many tables enclose them. + fn nested_container(&self, id: Option) -> Result<(&[PageParagraph], usize), EditError> { let Some(id) = id else { - return Ok(&self.nodes); + return Ok((&self.nodes, 0)); }; - let mut pending = vec![self.nodes.as_slice()]; - while let Some(nodes) = pending.pop() { + let root = self.root(id)?; + let mut pending = vec![(std::slice::from_ref(&self.nodes[root]), 0)]; + while let Some((nodes, depth)) = pending.pop() { for node in nodes { if let ParagraphContent::Table(table) = &node.content { for cell in table.rows.iter().flat_map(|row| &row.cells) { if cell.id == id { - return Ok(&cell.paragraphs); + return Ok((&cell.paragraphs, depth + 1)); } - pending.push(&cell.paragraphs); + pending.push((&cell.paragraphs, depth + 1)); } } } @@ -344,11 +460,11 @@ impl TextDocument { if range.start > range.end { return Err(EditError::InvalidRange); } - let (container, start, first) = leaves(&self.nodes, None) - .nth(range.start.paragraph) + let (container, start, first) = self + .leaf(range.start.paragraph) .ok_or(EditError::InvalidRange)?; - let (end_container, end, last) = leaves(&self.nodes, None) - .nth(range.end.paragraph) + let (end_container, end, last) = self + .leaf(range.end.paragraph) .ok_or(EditError::InvalidRange)?; if container != end_container { return Err(EditError::UnsupportedContent); @@ -394,12 +510,60 @@ impl TextDocument { }) } + /// Rejects exactly the edits after which [`validate_nodes`] would reject the document, or + /// whose column widths or text are invalid, looking only where the edit can conflict. pub(crate) fn validate_edit(&self, edit: &DocumentEdit) -> Result<(), EditError> { - let nodes = self.container(edit.container)?; - if edit.range.start > edit.range.end || edit.range.end > nodes.len() { + let (nodes, depth) = self.nested_container(edit.container)?; + if edit.range.start > edit.range.end + || edit.range.end > nodes.len() + || nodes.len() - edit.range.len() + edit.replacement.len() == 0 + { return Err(EditError::InvalidRange); } - validate_nodes(&self.nodes, Some(edit), &mut BTreeSet::new())?; + let mut ids = BTreeSet::new(); + validate_run( + &edit.replacement, + &nodes[..edit.range.start], + depth, + &mut ids, + )?; + let removed = &nodes[edit.range.clone()]; + let levels = edit + .replacement + .iter() + .map(|node| (node.id, node.level)) + .collect::>(); + // A later paragraph may name a removed or deepened paragraph as its parent. + let moved = removed + .iter() + .filter_map(|node| { + let level = levels.get(&node.id).copied(); + level + .is_none_or(|level| level > node.level) + .then_some((node.id, level)) + }) + .collect::>(); + if !moved.is_empty() + && nodes[edit.range.end..].iter().any(|node| { + node.parent + .and_then(|id| moved.get(&id)) + .is_some_and(|level| level.is_none_or(|level| level >= node.level)) + }) + { + return Err(EditError::InvalidStructure); + } + // Only identities the removed nodes did not hold can collide elsewhere. + for (_, _, node) in descendants(removed, None) { + for id in owned_ids(node) { + ids.remove(&id); + } + } + if !ids.is_empty() + && descendants(&self.nodes, None) + .any(|(_, _, node)| owned_ids(node).any(|id| ids.contains(&id))) + { + return Err(EditError::InvalidStructure); + } if !edit.columns.is_empty() { let replaced = descendants(&edit.replacement, None) .filter_map(|(_, _, node)| match &node.content { @@ -431,23 +595,59 @@ impl TextDocument { pub(crate) fn apply(&mut self, edit: DocumentEdit) -> Result { self.validate_edit(&edit)?; - let end = edit - .range - .start - .checked_add(edit.replacement.len()) - .ok_or(EditError::TextTooLong)?; - let range = edit.range.start..end; - let nodes = - container_mut(&mut self.nodes, edit.container).ok_or(EditError::InvalidRange)?; - let replacement = nodes.splice(edit.range, edit.replacement).collect(); + Ok(self.splice(edit)) + } + + /// Applies an edit [`Self::validate_edit`] accepted, returning its inverse. + pub(crate) fn splice(&mut self, edit: DocumentEdit) -> DocumentEdit { + let range = edit.range.start..edit.range.start + edit.replacement.len(); + let root = edit.container.map(|cell| { + ( + cell, + self.root(cell).expect("a validated edit's cell exists"), + ) + }); + let nodes = match root { + Some((cell, root)) => cell_mut(std::slice::from_mut(&mut self.nodes[root]), cell) + .expect("a validated edit's cell exists"), + None => &mut self.nodes, + }; + let replacement: Vec<_> = nodes.splice(edit.range.clone(), edit.replacement).collect(); + let delta = leaves(&nodes[range.clone()], None).count() as isize + - leaves(&replacement, None).count() as isize; + let after = match root { + Some((_, root)) => root + 1, + None => { + let first = self.starts[range.start]; + self.starts.splice( + edit.range.clone(), + starts(&self.nodes[range.clone()], first), + ); + let [start, end] = [edit.range.start, edit.range.end] + .map(|index| self.tables.partition_point(|table| *table < index)); + let added = tables(&self.nodes[range.clone()], range.start).collect::>(); + let later = start + added.len(); + self.tables.splice(start..end, added); + for table in &mut self.tables[later..] { + *table = + table.wrapping_add_signed(range.len() as isize - edit.range.len() as isize); + } + range.end + } + }; + if delta != 0 { + for start in &mut self.starts[after..] { + *start = start.wrapping_add_signed(delta); + } + } let mut columns = edit.columns; swap_columns(&mut self.nodes, &mut columns); - Ok(DocumentEdit { + DocumentEdit { container: edit.container, range, replacement, columns, - }) + } } } @@ -1252,6 +1452,239 @@ mod tests { } } + /// Validates the materialized edited document from scratch. + fn full_validation(document: &TextDocument, edit: &DocumentEdit) -> Result<(), EditError> { + let mut nodes = document.nodes.clone(); + let container = container_mut(&mut nodes, edit.container).ok_or(EditError::InvalidRange)?; + if edit.range.start > edit.range.end || edit.range.end > container.len() { + return Err(EditError::InvalidRange); + } + container.splice(edit.range.clone(), edit.replacement.iter().cloned()); + validate_nodes(&nodes, &mut BTreeSet::new())?; + let tables = |nodes| { + descendants(nodes, None) + .filter_map(|(_, _, node)| match &node.content { + ParagraphContent::Table(table) => Some((table.id, table.columns.len())), + _ => None, + }) + .collect::>() + }; + let replaced = tables(&edit.replacement); + let tables = tables(&nodes); + for (id, widths) in &edit.columns { + if replaced.contains_key(id) + || tables.get(id) != Some(&widths.len()) + || widths + .iter() + .any(|width| !width.is_finite() || *width < 36.0) + { + return Err(EditError::InvalidStructure); + } + } + validate_text(&edit.replacement) + } + + #[test] + fn incremental_validation_matches_full_validation_of_random_edits() { + let mut seed = 0x9e37_79b9_u64; + let mut next = |n: usize| { + seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (seed >> 33) as usize % n.max(1) + }; + let fresh = |text: &str| { + node( + Paragraph::new(text.into(), Format::default()), + Format::default(), + ) + .unwrap() + }; + let mut document = table_document(); + let [mut accepted, mut rejected] = [0; 2]; + for step in 0..4000 { + let all = descendants(&document.nodes, None) + .map(|(_, _, node)| node.clone()) + .collect::>(); + let cells = all + .iter() + .filter_map(|node| match &node.content { + ParagraphContent::Table(table) => Some(table), + _ => None, + }) + .flat_map(|table| table.rows.iter().flat_map(|row| &row.cells)) + .map(|cell| Some(cell.id)) + .collect::>(); + let tables = all + .iter() + .filter_map(|node| match &node.content { + ParagraphContent::Table(table) => Some(table.id), + _ => None, + }) + .collect::>(); + let container = match next(8) { + 0..4 => None, + 7 if step % 5 == 0 => Some(new_id().unwrap()), + _ => cells.get(next(cells.len())).copied().flatten(), + }; + let nodes = document.container(container).unwrap_or(&[]).to_vec(); + let start = next(nodes.len() + 2); + let range = if next(16) == 0 { + start..start.saturating_sub(1) + } else { + start..(start + next(3)).min(nodes.len() + usize::from(next(8) == 0)) + }; + let removed = nodes.get(range.clone()).unwrap_or_default(); + let prefix = &nodes[..range.start.min(nodes.len())]; + let suffix = nodes.get(range.end..).unwrap_or_default(); + let ids = + |nodes: &[PageParagraph]| nodes.iter().map(|node| node.id).collect::>(); + let parents = [ids(prefix), ids(removed), ids(suffix), ids(&all)].concat(); + let mut replacement = Vec::new(); + for _ in 0..next(4) { + let mut node = match next(10) { + 0..3 if !removed.is_empty() => removed[next(removed.len())].clone(), + 3 if !all.is_empty() => all[next(all.len())].clone(), + 4 => { + let mut table = table_document().nodes.remove(1); + let ParagraphContent::Table(inner) = &mut table.content else { + unreachable!() + }; + match next(6) { + 0 => inner.rows[0].cells[0].paragraphs.clear(), + 1 => inner.columns[0].width = 35.0, + 2 => { + inner.rows[0].cells.pop(); + } + 3 => { + inner.rows[0].cells[1].paragraphs[0].parent = + Some(inner.rows[0].cells[0].paragraphs[0].id); + } + _ => {} + } + table + } + _ => fresh("new"), + }; + match next(6) { + 0 => node.level = next(4) as u32, + 1 => node.level += 1, + 2 => { + node.level = 1 + next(4) as u32; + node.parent = (!parents.is_empty()).then(|| parents[next(parents.len())]); + } + 3 => node.parent = None, + _ => {} + } + replacement.push(node); + } + let mut columns = BTreeMap::new(); + if next(12) == 0 { + let id = tables + .get(next(tables.len() + 1)) + .copied() + .unwrap_or_else(|| new_id().unwrap()); + columns.insert(id, vec![[72.0, 30.0, f32::NAN][next(3)]; 1 + next(3)]); + } + let edit = if next(4) == 0 { + let positions = document.paragraphs().count(); + let paragraph = next(positions); + let offset = document.paragraphs().nth(paragraph).unwrap().text().len() as u32; + let start = TextPosition { + paragraph, + offset: next(offset as usize + 1) as u32, + }; + let end = TextPosition { + paragraph: paragraph + usize::from(paragraph + 1 < positions && next(2) == 0), + offset: 0, + }; + let texts = vec![Paragraph::new("typed".into(), Format::default()); 1 + next(2)]; + match document.replace(start..end.max(start), texts) { + Ok(edit) => edit, + Err(_) => continue, + } + } else { + DocumentEdit { + container, + range, + replacement, + columns, + } + }; + let expected = full_validation(&document, &edit); + assert_eq!( + document.validate_edit(&edit).is_ok(), + expected.is_ok(), + "step {step}: {expected:?} for {edit:#?}" + ); + if expected.is_ok() { + accepted += 1; + let edited = leaves(&document.nodes, Some(&edit)).collect::>(); + let supplied = descendants(&edit.replacement, None) + .map(|(_, _, node)| node.id) + .collect::>(); + for paragraph in 0..=edited.len() { + let leaf = document.edited_leaf(&edit, paragraph); + assert_eq!( + leaf.map(|(node, _)| node), + edited.get(paragraph).map(|(_, _, node)| *node) + ); + if let Some((node, before)) = leaf { + assert_eq!(before.is_none(), supplied.contains(&node.id)); + assert!( + before.is_none_or(|before| document.leaf(before).unwrap().2 == node) + ); + } + } + document.apply(edit).unwrap(); + assert_eq!( + document, + TextDocument::from_nodes(document.nodes.clone()).unwrap() + ); + let walked = leaves(&document.nodes, None).collect::>(); + for paragraph in 0..=walked.len() { + assert_eq!(document.leaf(paragraph), walked.get(paragraph).copied()); + } + } else { + rejected += 1; + } + } + assert!(accepted > 500 && rejected > 500, "{accepted} {rejected}"); + let nest = |mut node, depth| { + for _ in 0..depth { + let mut table = table_document().nodes.remove(1); + let ParagraphContent::Table(inner) = &mut table.content else { + unreachable!() + }; + inner.rows[0].cells[0].paragraphs = vec![node]; + node = table; + } + node + }; + let document = TextDocument::from_nodes(vec![nest(fresh("leaf"), 64)]).unwrap(); + let (innermost, _, _) = leaves(&document.nodes, None) + .find(|(_, _, node)| node.text().unwrap().text.text() == "leaf") + .unwrap(); + for (container, depth, valid) in [ + (innermost, 0, true), + (innermost, 1, false), + (None, 64, true), + (None, 65, false), + ] { + let edit = DocumentEdit { + container, + range: 0..0, + replacement: vec![nest(fresh("deep"), depth)], + columns: BTreeMap::new(), + }; + assert_eq!( + document.validate_edit(&edit).is_ok(), + full_validation(&document, &edit).is_ok() + ); + assert_eq!(document.validate_edit(&edit).is_ok(), valid); + } + } + #[test] fn invalid_ranges_and_last_paragraph_removal_are_atomic() { let original = diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index fa4ea84eae7bfd6e70764205795ee9b6172ceff3..0d58f77ee5ef2f6caa539b0e637807d008b72363 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -1,12 +1,10 @@ use crate::{ date::PageDate, - document::{DocumentEdit, TextDocument, TextPosition, descendants, edited_nodes, leaves}, + document::{DocumentEdit, TextDocument, TextPosition, descendants, leaves}, layout::{LayoutError, TextEngine}, - outline::{ - Arrange, OutlineLayout, ParagraphLayout, arrange, indentation, outline_layout, - visible_paragraphs, - }, + outline::{Arrange, OutlineLayout, ParagraphLayout, outline_layout, visible_paragraphs}, }; +use draw::edit::{self, Movement, SelectionUnit}; use onestore::ExGuid; use onestore::page::text::{EditError, Paragraph}; use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title}; @@ -26,29 +24,6 @@ pub(crate) mod page; mod table; pub use page::ReadOnlyObject; -#[derive(Clone, Copy, Debug)] -pub enum Movement { - Left, - Right, - Up, - Down, - WordLeft, - WordRight, - LineStart, - LineEnd, - ParagraphStart, - ParagraphEnd, - DocumentStart, - DocumentEnd, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SelectionUnit { - Grapheme, - Word, - Paragraph, -} - #[derive(Debug)] pub enum EditorError { Edit(EditError), @@ -319,19 +294,7 @@ impl TextOutline { let text_width = if self.shaped.tables.is_empty() { Some(self.shaped.size[0]) } else { - let roots = self - .document - .nodes() - .iter() - .filter(|node| node.text().is_some()) - .map(|node| node.id) - .collect::>(); - self.shaped - .paragraphs - .iter() - .filter(|paragraph| roots.contains(¶graph.id)) - .map(|paragraph| paragraph.origin[0] + paragraph.text.shaped.width()) - .reduce(f32::max) + Some(self.shaped.widths[1]).filter(|width| *width > f32::NEG_INFINITY) }; text_width.map_or(self.shaped.size[0], |width| { (width + 36.0) @@ -370,13 +333,23 @@ impl TextOutline { }) } + /// Layouts follow the text leaves minus hidden ones, so an index shared by a leaf and its + /// layout needs no search. fn visible_index(&self, source: usize) -> Result { let id = self .document - .text_nodes() - .nth(source) + .leaf(source) .ok_or(EditError::InvalidRange)? + .2 .id; + if self + .shaped + .paragraphs + .get(source) + .is_some_and(|paragraph| paragraph.id == id) + { + return Ok(source); + } self.shaped .paragraphs .iter() @@ -386,6 +359,13 @@ impl TextOutline { fn source_index(&self, visible: usize) -> usize { let id = self.shaped.paragraphs[visible].id; + if self + .document + .leaf(visible) + .is_some_and(|(_, _, node)| node.id == id) + { + return visible; + } self.document .text_nodes() .position(|paragraph| paragraph.id == id) @@ -989,7 +969,7 @@ impl CanvasEditor { if !ids.insert(outline_id) { return Err(EditError::InvalidStructure.into()); } - crate::document::validate_nodes(paragraphs, None, &mut ids)?; + crate::document::validate_nodes(paragraphs, &mut ids)?; } Ok(Self { outlines, @@ -1574,8 +1554,7 @@ impl CanvasEditor { self.active_outline().visible_index(position.paragraph)?; self.active_outline() .document - .paragraphs() - .nth(position.paragraph) + .paragraph(position.paragraph) .ok_or(EditError::InvalidRange)? .byte_offset(position.offset)?; } @@ -1589,12 +1568,7 @@ impl CanvasEditor { let paragraph = self .active_outline() .source_index(self.active_outline().shaped.paragraphs.len() - 1); - let last = self - .active_outline() - .document - .paragraphs() - .nth(paragraph) - .unwrap(); + let last = self.active_outline().document.paragraph(paragraph).unwrap(); self.select( [ TextPosition { @@ -1632,22 +1606,12 @@ impl CanvasEditor { let paragraph = &self.active_outline().shaped.paragraphs[index]; let layout = ¶graph.text.shaped; let point = [x - paragraph.origin[0], y - paragraph.origin[1]]; - let cursor = paragraph.text.hit_test(point[0], point[1]); - let local = match unit { - SelectionUnit::Grapheme => cursor.into(), - SelectionUnit::Word => { - let rect = cursor.geometry(layout, 1.0); - ParagraphSelection::word_from_point( - layout, - point[0], - ((rect.y0 + rect.y1) * 0.5) as f32, - ) - } - SelectionUnit::Paragraph => ParagraphSelection::new( - Cursor::from_byte_index(layout, 0, Affinity::Downstream), - Cursor::from_byte_index(layout, usize::MAX, Affinity::Upstream), - ), - }; + let local = edit::selection_at( + layout, + paragraph.text.hit_test(point[0], point[1]), + point[0], + unit, + ); let mut selection = self.selection(); for (slot, cursor) in [local.anchor(), local.focus()].into_iter().enumerate() { let visible = paragraph.projection.text().utf16_offset(cursor.index())?; @@ -1665,8 +1629,8 @@ impl CanvasEditor { let document = &self.active_outline().document; let source = selection.positions[0].paragraph; let next = self.active_outline().source_index(index + 1); - let (container, local, _) = leaves(document.nodes(), None).nth(source).unwrap(); - let (next_container, next_local, _) = leaves(document.nodes(), None).nth(next).unwrap(); + let (container, local, _) = document.leaf(source).unwrap(); + let (next_container, next_local, _) = document.leaf(next).unwrap(); if container == next_container && next_local == local + 1 { selection.positions[1] = TextPosition { paragraph: next, @@ -1767,7 +1731,7 @@ impl CanvasEditor { let outline = self.active_outline(); let current = outline.visible_index(focus.paragraph)?; let end = matches!(movement, Movement::ParagraphEnd | Movement::DocumentEnd); - let current_end = outline.document.paragraphs().nth(focus.paragraph).unwrap(); + let current_end = outline.document.paragraph(focus.paragraph).unwrap(); let current_end = current_end.utf16_offset(current_end.text().len())?; let index = match movement { Movement::DocumentStart => 0, @@ -1779,7 +1743,7 @@ impl CanvasEditor { _ => current, }; let source = outline.source_index(index); - let paragraph = outline.document.paragraphs().nth(source).unwrap(); + let paragraph = outline.document.paragraph(source).unwrap(); let position = TextPosition { paragraph: source, offset: if end { @@ -2030,22 +1994,7 @@ impl CanvasEditor { } else { cursor.into() }; - let layout = ¶graph.text.shaped; - next = match movement { - Movement::Left => local.previous_visual(layout, extend), - Movement::Right => local.next_visual(layout, extend), - Movement::WordLeft => paragraph.text.word_cursor(local.focus(), true).into(), - Movement::WordRight => paragraph.text.word_cursor(local.focus(), false).into(), - Movement::LineStart => local.line_start(layout, extend), - Movement::LineEnd => local.line_end(layout, extend), - Movement::Up - | Movement::Down - | Movement::ParagraphStart - | Movement::ParagraphEnd - | Movement::DocumentStart - | Movement::DocumentEnd => unreachable!(), - } - .focus(); + next = edit::step(¶graph.text.shaped, local, movement, extend).focus(); if horizontal && next == cursor && (extend || anchor == focus) { let neighbor = if left { index.checked_sub(1) @@ -2067,7 +2016,7 @@ impl CanvasEditor { line.top + line.height * 0.5, ); if matches!(movement, Movement::WordLeft | Movement::WordRight) { - next = paragraph.text.word_cursor(next, left); + next = edit::word_cursor(¶graph.text.shaped, next, left); } } } @@ -2161,12 +2110,7 @@ impl CanvasEditor { .enumerate() .filter(|(_, (index, _))| *index >= start.paragraph && *index <= end.paragraph) { - let source = self - .active_outline() - .document - .paragraphs() - .nth(index) - .unwrap(); + let source = self.active_outline().document.paragraph(index).unwrap(); let first = if index == start.paragraph { paragraph.cursor(start.offset, affinities[0])? } else { @@ -2249,8 +2193,7 @@ impl CanvasEditor { let format = self .active_outline() .document - .paragraphs() - .nth(start.paragraph) + .paragraph(start.paragraph) .unwrap() .format_at(start.offset)?; let replacement = text @@ -2268,11 +2211,13 @@ impl CanvasEditor { let [anchor, focus] = outline.selection.positions; let start = anchor.min(focus); let end = anchor.max(focus); - let (container, local_start, _) = leaves(outline.document.nodes(), None) - .nth(start.paragraph) + let (container, local_start, _) = outline + .document + .leaf(start.paragraph) .ok_or(EditError::InvalidRange)?; - let (end_container, local_end, _) = leaves(outline.document.nodes(), None) - .nth(end.paragraph) + let (end_container, local_end, _) = outline + .document + .leaf(end.paragraph) .ok_or(EditError::InvalidRange)?; if container != end_container { return Err(EditError::UnsupportedContent.into()); @@ -2343,12 +2288,10 @@ impl CanvasEditor { }) else { return Ok(false); }; - let (container, local, current) = leaves(document.nodes(), None) - .nth(focus.paragraph) + let (container, local, current) = document + .leaf(focus.paragraph) .ok_or(EditError::InvalidRange)?; - let Some((next_container, next_local, next)) = - leaves(document.nodes(), None).nth(neighbor) - else { + let Some((next_container, next_local, next)) = document.leaf(neighbor) else { return Ok(false); }; if container != next_container || local.abs_diff(next_local) != 1 { @@ -2406,8 +2349,7 @@ impl CanvasEditor { let format = self .active_outline() .document - .paragraphs() - .nth(caret.paragraph) + .paragraph(caret.paragraph) .unwrap() .format_at(caret.offset)? .clone(); @@ -2779,8 +2721,7 @@ impl CanvasEditor { let format = self .active_outline() .document - .paragraphs() - .nth(range.start.paragraph) + .paragraph(range.start.paragraph) .unwrap() .format_at(range.start.offset)?; let replacement = text @@ -2922,55 +2863,29 @@ impl CanvasEditor { count -= 1; } if count < outline.document.nodes().len() { - let removed = outline.document.nodes().len() - count; + let edit = DocumentEdit { + columns: BTreeMap::new(), + container: None, + range: count..outline.document.nodes().len(), + replacement: Vec::new(), + }; + let relayout = outline + .shaped + .relayout( + outline.document.nodes(), + &edit, + edit.range.clone(), + &outline.indents, + outline.wrap_width(), + outline.layout.width_set_by_user == Some(true), + &mut |_, _, _| Err(LayoutError::UnsupportedContent), + ) + .expect("removing trailing text lays nothing out anew"); outline .document - .apply(DocumentEdit { - columns: BTreeMap::new(), - container: None, - range: count..outline.document.nodes().len(), - replacement: Vec::new(), - }) + .apply(edit) .expect("removing a provisional suffix preserves a valid document"); - outline - .shaped - .paragraphs - .truncate(outline.shaped.paragraphs.len() - removed); - if outline.shaped.tables.is_empty() && outline.shaped.objects.is_empty() { - let (origins, size) = arrange( - outline.shaped.paragraphs.iter(), - outline.wrap_width(), - outline.layout.width_set_by_user == Some(true), - ) - .expect("a prefix of the validated layout fits"); - for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) { - paragraph.origin[1] = y; - } - outline.shaped.size = size; - } else { - let cached = outline - .shaped - .paragraphs - .iter() - .chain(outline.shaped.objects.iter().filter_map(|o| o.label())) - .map(|paragraph| (paragraph.id, paragraph)) - .collect::>(); - let shaped = OutlineLayout::flow( - outline.document.nodes().iter(), - &outline.indents, - outline.wrap_width(), - outline.layout.width_set_by_user == Some(true), - 0, - None, - &mut |node, width, indents| { - let mut paragraph = cached[&node.id].clone(); - paragraph.reset_origin(indentation(node.level, indents, width)?); - Ok(paragraph) - }, - ) - .expect("removing a provisional suffix preserves table layout"); - outline.shaped = shaped; - } + outline.shaped.commit(relayout); } if outline.document == self.outlines[index].document { self.outlines[index].selection = outline.selection; @@ -3054,117 +2969,47 @@ impl CanvasEditor { ) -> Result { let outline = self.active_outline(); outline.document.validate_edit(&edit)?; - let width = outline.wrap_width(); - let (visible, shaped, origins, size, tables, objects) = if !crate::outline::all_text( - outline.document.nodes().iter().chain(&edit.replacement), - ) { - let sources = outline - .document - .text_nodes() - .map(|node| (node.id, node)) - .collect::>(); - let existing = outline - .shaped - .paragraphs - .iter() - .map(|layout| (layout.id, (sources[&layout.id], layout))) - .collect::>(); - let layout = OutlineLayout::flow( - edited_nodes(outline.document.nodes(), None, Some(&edit)), - &outline.indents, - width, - outline.layout.width_set_by_user == Some(true), - 0, - Some(&edit), - &mut |node, width, indents| { - let indent = indentation(node.level, indents, width)?; - if let Some((source, cached)) = existing.get(&node.id) - && *source == node - && cached.text.shaped.layout_max_advance() == width - indent - && cached - .markers - .iter() - .all(|(marker, _)| marker.shaped.layout_max_advance() == width) - { - let mut result = (*cached).clone(); - result.reset_origin(indent); - return Ok(result); - } - ParagraphLayout::shape(engine, node, width, indents, &self.definitions) - }, - )?; - let origins = layout - .paragraphs - .iter() - .map(|p| p.origin[1]) - .collect::>(); - ( - 0..outline.shaped.paragraphs.len(), - layout.paragraphs, - origins, - layout.size, - layout.tables, - layout.objects, - ) - } else { - let visible_start = outline - .layouts() - .take_while(|(i, _)| *i < edit.range.start) - .count(); - let visible_end = outline - .layouts() - .take_while(|(i, _)| *i < edit.range.end) - .count(); - if visible_end - visible_start != edit.range.len() { - return Err(EditError::UnsupportedContent.into()); + let range = match edit.container { + None => edit.range.clone(), + Some(cell) => { + let root = outline.document.root(cell)?; + root..root + 1 } - let shaped = edit - .replacement - .iter() - .map(|paragraph| { - ParagraphLayout::shape( - engine, - paragraph, - width, - &outline.indents, - &self.definitions, - ) - }) - .collect::, _>>()?; - let (origins, size) = arrange( - outline.shaped.paragraphs[..visible_start] - .iter() - .chain(&shaped) - .chain(&outline.shaped.paragraphs[visible_end..]), - width, - outline.layout.width_set_by_user == Some(true), - )?; - ( - visible_start..visible_end, - shaped, - origins, - size, - Vec::new(), - Vec::new(), - ) }; + let relayout = outline.shaped.relayout( + outline.document.nodes(), + &edit, + range, + &outline.indents, + outline.wrap_width(), + outline.layout.width_set_by_user == Some(true), + &mut |node, width, indents| { + ParagraphLayout::shape(engine, node, width, indents, &self.definitions) + }, + )?; + let [replaced, ..] = outline.shaped.pieces(relayout.range.clone()); for position in selection.positions { - let (_, _, node) = leaves(outline.document.nodes(), Some(&edit)) - .nth(position.paragraph) + let (node, before) = outline + .document + .edited_leaf(&edit, position.paragraph) .ok_or(EditError::InvalidRange)?; node.text().unwrap().text.byte_offset(position.offset)?; - if !outline.shaped.paragraphs[..visible.start] + // A paragraph the edit leaves keeps its layout unless that layout is replaced. + if !relayout + .segment + .paragraphs .iter() - .chain(&shaped) - .chain(&outline.shaped.paragraphs[visible.end..]) .any(|paragraph| paragraph.id == node.id) + && !before + .and_then(|before| outline.visible_index(before).ok()) + .is_some_and(|index| !replaced.contains(&index)) { return Err(EditError::InvalidRange.into()); } } let placements = match positions { Some(positions) => positions.to_vec(), - None => self.title_flow(size[1])?, + None => self.title_flow(relayout.size[1])?, }; let previous_positions = placements .iter() @@ -3193,14 +3038,8 @@ impl CanvasEditor { return self.apply(engine, edit, selection, false, positions); } let outline = self.active_outline_mut(); - let inverse = outline.document.apply(edit)?; - outline.shaped.paragraphs.splice(visible, shaped); - outline.shaped.tables = tables; - outline.shaped.objects = objects; - for (paragraph, y) in outline.shaped.paragraphs.iter_mut().zip(origins) { - paragraph.origin[1] = y; - } - outline.shaped.size = size; + let inverse = outline.document.splice(edit); + outline.shaped.commit(relayout); self.preferred_x = None; let previous_selection = std::mem::replace(&mut self.active_outline_mut().selection, selection); @@ -7414,6 +7253,175 @@ mod tests { assert!(!editor.redo(&mut engine).unwrap()); } + #[test] + fn typing_in_place_matches_a_fresh_layout_for_automatic_and_fixed_widths() { + use onestore::page::{Image, ParagraphContent, text::new_id}; + let mut engine = TextEngine::default(); + let mut seed = 0x51ed_u64; + let mut next = |n: usize| { + seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (seed >> 33) as usize % n + }; + for (text, objects) in [ + (vec!["x"], false), + (vec!["one two", "three", "four five six seven"], false), + (vec!["x"], true), + (vec!["one two", "three", "four five six seven"], true), + ] { + let fixed = text.len() > 1; + let empty = TextDocument::new(vec![Paragraph::new(String::new(), Format::default())]); + let mut editor = CanvasEditor::new(&mut engine, empty.unwrap(), 120.0).unwrap(); + editor.insert(&mut engine, &text.join("\n")).unwrap(); + if fixed { + editor.resize(&mut engine, 120.0).unwrap(); + } + let table = table_editor(&mut engine).active_outline().document.nodes()[0].clone(); + let mut picture = table.clone(); + picture.id = new_id().unwrap(); + picture.content = ParagraphContent::Image(Image { + id: new_id().unwrap(), + layout: onestore::document::Layout { + max_width: Some(90.0), + max_height: Some(40.0), + ..Default::default() + }, + size: None, + bytes: None, + alt: None, + background: false, + }); + if objects { + let selection = [TextPosition { + paragraph: 0, + offset: 0, + }; 2] + .into(); + editor + .commit( + &mut engine, + DocumentEdit { + columns: BTreeMap::new(), + container: None, + range: 1..1, + replacement: vec![picture.clone(), table], + }, + selection, + ) + .unwrap(); + } + for step in 0..300 { + let outline = editor.active_outline(); + let paragraph = next(outline.document.paragraphs().count()); + let text = outline.document.paragraph(paragraph).unwrap(); + let offset = text.utf16_offset(text.text().len()).unwrap(); + let position = TextPosition { + paragraph, + offset: next(offset as usize + 1) as u32, + }; + // Paragraphs under a collapsed one hold no caret. + let _ = editor.select([position; 2].into()); + let nodes = editor.active_outline().document.nodes(); + let root = next(nodes.len()); + let mut node = nodes[root].clone(); + let edit = |range, replacement| DocumentEdit { + columns: BTreeMap::new(), + container: None, + range, + replacement, + }; + let edit = match next(14) { + 10 => { + node.collapsed = !node.collapsed; + Some(edit(root..root + 1, vec![node])) + } + 11 if root > 0 && node.text().is_some() => { + let parent = &nodes[root - 1]; + node.parent = Some(parent.id); + node.level = parent.level + 1; + Some(edit(root..root + 1, vec![node])) + } + 12 if node.text().is_none() || nodes.len() > 3 => { + Some(edit(root..root + 1, Vec::new())) + } + 13 => { + let mut picture = picture.clone(); + picture.id = new_id().unwrap(); + let ParagraphContent::Image(image) = &mut picture.content else { + unreachable!() + }; + image.id = new_id().unwrap(); + Some(edit(root..root, vec![picture])) + } + _ => None, + }; + let _ = match (edit, next(10)) { + (Some(edit), _) => { + let start = [TextPosition { + paragraph: 0, + offset: 0, + }; 2]; + editor.commit(&mut engine, edit, start.into()) + } + (None, 0) => editor.insert(&mut engine, "\n"), + (None, 1 | 2) => editor.delete(&mut engine, next(2) == 0).map(drop), + (None, 3) => editor.undo(&mut engine).map(drop), + (None, _) => editor.insert(&mut engine, ["a", " ", "wide", "W"][next(4)]), + }; + let outline = editor.active_outline(); + let fresh = + TextOutline::from_outline(&mut engine, &outline.snapshot(), &BTreeMap::new()) + .unwrap(); + let layout = |outline: &TextOutline| { + let shaped = &outline.shaped; + let paragraphs = shaped.paragraphs.iter().map(|paragraph| { + ( + paragraph.id, + paragraph.origin, + paragraph + .markers + .iter() + .map(|marker| marker.1) + .collect::>(), + paragraph + .tags + .iter() + .map(|tag| tag.origin) + .collect::>(), + ) + }); + let cells = shaped.tables.iter().flat_map(|table| { + table + .cells + .iter() + .map(|cell| (cell.id, cell.rect, cell.paragraphs.clone())) + }); + let objects = shaped.objects.iter().map(|object| { + let label = object.label().map(|label| label.origin); + (object.id, object.rect, object.bottom, label) + }); + format!( + "{:?}", + ( + shaped.size, + shaped.widths, + outline.bounds(), + paragraphs.collect::>(), + cells.collect::>(), + objects.collect::>(), + ) + ) + }; + assert_eq!(layout(outline), layout(&fresh), "step {step}"); + } + assert_eq!( + editor.active_outline().layout.width_set_by_user, + Some(fixed) + ); + } + } + #[test] fn structural_edits_reflow_only_affected_paragraphs_and_restore_selection() { let mut engine = TextEngine::default(); diff --git a/crates/canvas/src/editor/page.rs b/crates/canvas/src/editor/page.rs index 86abfeff1cc68bb0043c9e7af8cbc31dd95a5f22..ff0d93adc401e79bb472dd0e42953ebc38a25e0f 100644 --- a/crates/canvas/src/editor/page.rs +++ b/crates/canvas/src/editor/page.rs @@ -97,7 +97,6 @@ impl ReadOnlyObject { onestore::document::Format { font: Some("Arial".into()), font_size: Some(11.0), - color: Some(0x005d554e), ..Default::default() }, ), diff --git a/crates/canvas/src/editor/table.rs b/crates/canvas/src/editor/table.rs index b45e3a8db0ecbc5943e7299389195ccbad35d65a..0b1f5ec886d4316a0fe0b5799a6941558e55113a 100644 --- a/crates/canvas/src/editor/table.rs +++ b/crates/canvas/src/editor/table.rs @@ -84,8 +84,9 @@ impl CanvasEditor { } let outline = self.active_outline(); let [anchor, focus] = outline.selection.positions; - let (cell, local, source) = leaves(outline.document.nodes(), None) - .nth(focus.paragraph) + let (cell, local, source) = outline + .document + .leaf(focus.paragraph) .ok_or(EditError::InvalidRange)?; let Some(cell) = cell else { if backward || anchor != focus || focus.offset == 0 { @@ -255,8 +256,9 @@ impl CanvasEditor { } let outline = self.active_outline(); let [anchor, focus] = outline.selection.positions; - let (cell, local, source) = leaves(outline.document.nodes(), None) - .nth(focus.paragraph) + let (cell, local, source) = outline + .document + .leaf(focus.paragraph) .ok_or(EditError::InvalidRange)?; let Some(cell) = cell.filter(|_| anchor == focus) else { return self.insert(engine, "\n"); diff --git a/crates/canvas/src/gpu/mod.rs b/crates/canvas/src/gpu/mod.rs index ee91ab86bbbf5625aefdb3069c580e08faa124f7..8950762c4d5376b2cb8e35c55ab3a5ca9236e4dc 100644 --- a/crates/canvas/src/gpu/mod.rs +++ b/crates/canvas/src/gpu/mod.rs @@ -37,6 +37,32 @@ impl Viewport { } } +/// The page's paper and the ink that content in OneNote's automatic colour draws in, +/// linear RGBA. +#[derive(Clone, Copy, Debug)] +pub struct Paper { + pub color: [f32; 4], + pub ink: [f32; 4], +} + +impl Paper { + pub const WHITE: Self = Self { + color: [1.0; 4], + ink: [0.0, 0.0, 0.0, 1.0], + }; + + /// A near-neutral colour OneNote draws on white paper, moved onto this paper: its + /// darkness becomes ink and its tint tints the paper. + fn shade(&self, light: [f32; 4]) -> [f32; 4] { + let ink = 1.0 - (light[0] + light[1] + light[2]) / 3.0; + let mut shade = light; + for channel in 0..3 { + shade[channel] = self.color[channel] * light[channel] + self.ink[channel] * ink; + } + shade + } +} + impl Glyphs for TextLayout { fn runs( &self, @@ -52,7 +78,8 @@ impl Glyphs for TextLayout { bounds.baseline, run.style().brush.rise, [bounds.top, bounds.height], - |brush: &TextBrush| colorref(brush.color), + // A highlight keeps its stored colour, so automatic text on it stays black. + |brush: &TextBrush| brush.color.or(brush.highlight.map(|_| 0)).map(colorref), paint, )?; } @@ -147,14 +174,24 @@ mod tests { ]; let text = TextEngine::default() .layout( - &Paragraph::new( - "Tagged".into(), - Format { - color: Some(0x000000ff), - underline: Some(true), - ..Format::default() - }, - ), + &Paragraph::from_runs([ + ( + "Tagged".into(), + Format { + color: Some(0x000000ff), + underline: Some(true), + ..Format::default() + }, + ), + (" automatic".into(), Format::default()), + ( + " lit".into(), + Format { + highlight: Some(0x0000ffff), + ..Format::default() + }, + ), + ]), 100.0, ) .unwrap(); @@ -165,13 +202,14 @@ mod tests { sources: tag_sources(*icon), origin: [2.0 + 16.0 * index as f32, 2.0], size: crate::outline::ParagraphTag::SIZE, - opacity: 1.0, + tint: [1.0; 4], }) .collect(); primitives.push(Primitive::Text { text: &text, origin: [2.0, 20.0], clip: None, + ink: [0.0, 1.0, 0.0, 1.0], }); let viewport = Viewport { size, @@ -224,13 +262,28 @@ mod tests { .count(); assert!(painted > 20, "tag {index} was not painted"); } - let red = (40..64) - .flat_map(|y| (4..120).map(move |x| (x, y))) - .filter(|(x, y)| { - let [r, g, b] = pixel(*x, *y); - r > 200 && g < 80 && b < 80 - }) - .count(); - assert!(red > 50, "text colour or underline was not painted"); + let count = |color: [u8; 3]| { + (40..64) + .flat_map(|y| (4..size[0]).map(move |x| (x, y))) + .filter(|(x, y)| { + pixel(*x, *y) + .iter() + .zip(color) + .all(|(v, c)| v.abs_diff(c) < 80) + }) + .count() + }; + assert!( + count([255, 0, 0]) > 50, + "text colour or underline was not painted" + ); + assert!( + count([0, 255, 0]) > 50, + "automatic text did not paint in the primitive's ink" + ); + assert!( + count([0, 0, 0]) > 10, + "automatic text on a highlight did not stay black" + ); } } diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 8d0d5d5b1e61781c9abcca043b9654614c716d1a..e84242c5532483910e0e4e5d6a848cd8de230eb3 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -1,4 +1,6 @@ -use super::colorref; +mod background; + +use super::{Paper, colorref}; use crate::editor::ReadOnlyObject; use crate::editor::page::Content; use crate::{ @@ -6,6 +8,7 @@ use crate::{ editor::{CanvasEditor, EditorError}, layout::{LayoutError, TextEngine}, }; +use background::Background; use draw::{Primitive, RasterImage, RenderError}; use onestore::page::Page; use std::fmt; @@ -14,6 +17,8 @@ use std::fmt; pub struct PageScene { reference: Option>, images: std::collections::BTreeMap, + /// Background pictures from OneNote's page templates, painted from their recreations. + backgrounds: std::collections::BTreeMap, } #[derive(Debug, PartialEq)] @@ -82,6 +87,17 @@ fn append_ink(ink: &onestore::page::Ink, offset: [f32; 2], primitives: &mut Vec< } } +fn append_placeholder(rect: [f32; 4], paper: Paper, primitives: &mut Vec>) { + primitives.push(Primitive::Rect { + rect, + color: paper.shade(colorref(0x00e4ddd6)), + }); + primitives.push(Primitive::Rect { + rect: [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0], + color: paper.shade(colorref(0x00faf7f3)), + }); +} + #[derive(Debug)] pub enum SceneError { Layout(LayoutError), @@ -127,10 +143,10 @@ impl PageScene { let objects = crate::editor::page::build(&mut page, engine, false) .map_err(SceneError::from)? .objects; - let images = Self::decode_images(&objects, None)?; + let pictures = Self::decode_images(&objects, None)?; Ok(Self { reference: Some(objects), - images, + ..pictures }) } @@ -139,21 +155,15 @@ impl PageScene { engine: &mut TextEngine, ) -> Result<(Self, CanvasEditor), SceneError> { let editor = CanvasEditor::from_page(page, engine).map_err(SceneError::from)?; - let images = Self::decode_images(&editor.objects, Some(&editor))?; - Ok(( - Self { - reference: None, - images, - }, - editor, - )) + Ok((Self::decode_images(&editor.objects, Some(&editor))?, editor)) } - /// Decodes every picture the page draws: page-level ones and those inside outlines. + /// Decodes every picture the page draws, page-level ones and those inside outlines, + /// except the template backgrounds it recognises. fn decode_images( objects: &[Content], editor: Option<&CanvasEditor>, - ) -> Result, SceneError> { + ) -> Result { fn nested<'a>( nodes: &'a [onestore::page::PageParagraph], payloads: &mut Vec<(onestore::ExGuid, Option<&'a [u8]>)>, @@ -179,9 +189,18 @@ impl PageScene { } } let mut payloads = Vec::new(); + let mut backgrounds = std::collections::BTreeMap::new(); for object in objects { match object { - Content::Image(source) => payloads.push((source.id, source.bytes.as_deref())), + Content::Image(source) => { + if source.background + && let Some(art) = Background::recognise(source) + { + backgrounds.insert(source.id, art); + } else { + payloads.push((source.id, source.bytes.as_deref())) + } + } Content::Outline { source, .. } => nested(&source.paragraphs, &mut payloads), Content::Editable(id) => { if let Some(outline) = @@ -206,13 +225,34 @@ impl PageScene { return Err(SceneError::InvalidGeometry); } } - Ok(images) + Ok(Self { + reference: None, + images, + backgrounds, + }) } pub fn image(&self, id: onestore::ExGuid) -> Option<&RasterImage> { self.images.get(&id) } + /// Brings template backgrounds to `paper` at `scale` device pixels per point, rasterizing + /// off this thread; `waker` is woken when a raster lands and the page should be drawn + /// again. Call before collecting each frame's primitives. True once every background + /// shows its raster for this paper and scale. + pub fn update_backgrounds( + &mut self, + paper: Paper, + scale: f32, + waker: &std::task::Waker, + ) -> bool { + let mut settled = true; + for art in self.backgrounds.values_mut() { + settled &= art.update(paper, scale, waker); + } + settled + } + /// Pictures, files and handwriting inside an outline whose origin is `origin`. /// `moving` draws one picture at a previewed rectangle, in the same coordinates as `origin`. pub fn append_outline_objects<'a>( @@ -220,6 +260,7 @@ impl PageScene { outline: &'a crate::outline::OutlineLayout, origin: [f32; 2], moving: Option<(onestore::ExGuid, [f32; 4])>, + paper: Paper, primitives: &mut Vec>, ) { for object in &outline.objects { @@ -241,14 +282,7 @@ impl PageScene { append_ink(ink, [rect[0], rect[1]], primitives) } crate::outline::ObjectKind::Unsupported(_) => { - primitives.push(Primitive::Rect { - rect, - color: colorref(0x00e4ddd6), - }); - primitives.push(Primitive::Rect { - rect: [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0], - color: colorref(0x00faf7f3), - }); + append_placeholder(rect, paper, primitives) } crate::outline::ObjectKind::Picture | crate::outline::ObjectKind::File(_) => {} } @@ -257,6 +291,7 @@ impl PageScene { text: &label.text, origin: [origin[0] + label.origin[0], origin[1] + label.origin[1]], clip: None, + ink: paper.ink, }); } } @@ -445,8 +480,9 @@ impl PageScene { &'a self, primitives: &mut Vec>, offset: [f32; 2], + paper: Paper, ) -> Result<(), SceneError> { - self.append_primitives_with(primitives, offset, None, None, |_, _, _| { + self.append_primitives_with(primitives, offset, None, None, paper, |_, _, _| { Err(SceneError::MissingOutline) }) } @@ -458,6 +494,7 @@ impl PageScene { offset: [f32; 2], editor: Option<&'a CanvasEditor>, moving: Option<(onestore::ExGuid, [f32; 4])>, + paper: Paper, mut outline: impl FnMut(onestore::ExGuid, [f32; 2], &mut Vec>) -> Result<(), E>, ) -> Result<(), E> { for content in self.objects(editor)? { @@ -499,18 +536,12 @@ impl PageScene { x1 + offset[0], y1 + offset[1], ]; - primitives.push(Primitive::Rect { - rect, - color: colorref(0x00e4ddd6), - }); - primitives.push(Primitive::Rect { - rect: [rect[0] + 1.0, rect[1] + 1.0, rect[2] - 1.0, rect[3] - 1.0], - color: colorref(0x00faf7f3), - }); + append_placeholder(rect, paper, primitives); primitives.push(Primitive::Text { clip: None, text: &object.label, origin: [rect[0] + 8.0, rect[1] + 8.0], + ink: paper.shade(colorref(0x005d554e)), }); continue; } @@ -525,28 +556,41 @@ impl PageScene { }; let object_origin = [origin[0] + offset[0], origin[1] + offset[1]]; match content { - Content::Image(source) => primitives.push(Primitive::Image { - image: self - .images - .get(&source.id) - .ok_or(SceneError::MissingImage)?, - rect: match moving { - Some((id, [x0, y0, x1, y1])) if id == source.id => [ - x0 + offset[0], - y0 + offset[1], - x1 + offset[0], - y1 + offset[1], - ], - _ => [ - object_origin[0], - object_origin[1], - object_origin[0] - + source.layout.max_width.ok_or(SceneError::MissingImage)?, - object_origin[1] - + source.layout.max_height.ok_or(SceneError::MissingImage)?, - ], - }, - }), + Content::Image(source) => { + let (image, [width, height]) = match self.backgrounds.get(&source.id) { + // The paper shows until the worker's raster lands. + Some(art) => match art.image(paper) { + Some(image) => (image, art.size), + None => continue, + }, + None => ( + self.images + .get(&source.id) + .ok_or(SceneError::MissingImage)?, + [ + source.layout.max_width.ok_or(SceneError::MissingImage)?, + source.layout.max_height.ok_or(SceneError::MissingImage)?, + ], + ), + }; + primitives.push(Primitive::Image { + image, + rect: match moving { + Some((id, [x0, y0, x1, y1])) if id == source.id => [ + x0 + offset[0], + y0 + offset[1], + x1 + offset[0], + y1 + offset[1], + ], + _ => [ + object_origin[0], + object_origin[1], + object_origin[0] + width, + object_origin[1] + height, + ], + }, + }) + } Content::Outline { .. } | Content::Date { .. } => { let outline = match content { Content::Outline { layout, .. } => layout, @@ -555,14 +599,16 @@ impl PageScene { .ok_or(SceneError::MissingOutline)? .layout(), }; - outline.append_table_primitives(primitives, object_origin); - outline.append_background_primitives(primitives, object_origin); - self.append_outline_objects(outline, object_origin, None, primitives); - for (index, paragraph) in outline.paragraphs.iter().enumerate() { + outline.append_table_primitives(primitives, object_origin, paper); + let everything = [f32::NEG_INFINITY, f32::INFINITY]; + outline.append_background_primitives(primitives, object_origin, everything); + self.append_outline_objects(outline, object_origin, None, paper, primitives); + for (index, paragraph) in outline.visible(everything) { outline.append_paragraph_primitives( index, paragraph, object_origin, + paper.ink, primitives, ); } @@ -575,12 +621,33 @@ impl PageScene { } impl crate::outline::OutlineLayout { + /// Paragraphs that may paint between outline-local `rows`, with their indices. + pub fn visible( + &self, + rows: [f32; 2], + ) -> impl Iterator { + // Markers, tags and overhanging ink stay within this of a paragraph's lines. + const OVERHANG: f32 = 72.0; + self.paragraphs + .iter() + .enumerate() + .filter(move |(_, paragraph)| { + let [start, end] = [ + paragraph.origin[1] - OVERHANG, + paragraph.origin[1] + paragraph.text.height() + OVERHANG, + ]; + paragraph.math.is_some() || (end >= rows[0] && start <= rows[1]) + }) + } + + /// Highlights behind the paragraphs `visible` finds between outline-local `rows`. pub fn append_background_primitives( &self, primitives: &mut Vec>, origin: [f32; 2], + rows: [f32; 2], ) { - for (index, paragraph) in self.paragraphs.iter().enumerate() { + for (index, paragraph) in self.visible(rows) { for (mut rect, color) in paragraph.text.backgrounds() { rect.x0 += f64::from(paragraph.origin[0]); rect.x1 += f64::from(paragraph.origin[0]); @@ -612,6 +679,7 @@ impl crate::outline::OutlineLayout { index: usize, paragraph: &'a crate::outline::ParagraphLayout, origin: [f32; 2], + ink: [f32; 4], primitives: &mut Vec>, ) { let [x, y] = [ @@ -635,6 +703,7 @@ impl crate::outline::OutlineLayout { clip, text: layout, origin: [x + origin[0], y + origin[1]], + ink, }, // As pen strokes, so hairline rules keep a device pixel. crate::math::MathItem::Rule([x0, y0, x1, y1]) => { @@ -651,7 +720,7 @@ impl crate::outline::OutlineLayout { to: [x + to[0], y + to[1]], width, round: false, - color: colorref(math.color), + color: math.color.map_or(ink, colorref), } } crate::math::MathItem::Stroke { from, to, width } => Primitive::Segment { @@ -659,7 +728,7 @@ impl crate::outline::OutlineLayout { to: [x + to[0], y + to[1]], width: *width, round: true, - color: colorref(math.color), + color: math.color.map_or(ink, colorref), }, }); } @@ -668,6 +737,7 @@ impl crate::outline::OutlineLayout { clip, text: ¶graph.text, origin: [x, y], + ink, }), } for (layout, marker) in ¶graph.markers { @@ -675,6 +745,7 @@ impl crate::outline::OutlineLayout { clip, text: layout, origin: [origin[0] + marker[0], y + marker[1]], + ink, }); } for tag in ¶graph.tags { @@ -685,12 +756,17 @@ impl crate::outline::OutlineLayout { y + tag.origin[1], ], size: crate::outline::ParagraphTag::SIZE, - opacity: if tag.disabled { 0.45 } else { 1.0 }, + tint: [1.0, 1.0, 1.0, if tag.disabled { 0.45 } else { 1.0 }], }); } } - pub fn append_table_primitives(&self, primitives: &mut Vec>, origin: [f32; 2]) { + pub fn append_table_primitives( + &self, + primitives: &mut Vec>, + origin: [f32; 2], + paper: Paper, + ) { for table in &self.tables { let (Some(first), Some(last)) = (table.cells.first(), table.cells.last()) else { continue; @@ -699,7 +775,7 @@ impl crate::outline::OutlineLayout { continue; } let bounds = [first.rect[0], first.rect[1], last.rect[2], last.rect[3]]; - let color = colorref(0x00a3a3a3); + let color = paper.shade(colorref(0x00a3a3a3)); primitives.push(Primitive::RoundedRect { rect: [ bounds[0] + origin[0] - 0.375, @@ -1155,6 +1231,7 @@ mod tests { [0.0; 2], Some(editor), None, + Paper::WHITE, |_, _, _| Ok(()), ) .unwrap(); @@ -1553,6 +1630,7 @@ mod tests { [0.0; 2], Some(editor), None, + Paper::WHITE, |id, offset, primitives| { let outline = editor .outlines() @@ -1563,6 +1641,7 @@ mod tests { primitives.push(Primitive::Text { clip: None, text: ¶graph.text, + ink: Paper::WHITE.ink, origin: [ outline.origin()[0] + paragraph.origin[0] + offset[0], outline.origin()[1] + paragraph.origin[1] + offset[1], @@ -1643,7 +1722,7 @@ mod tests { let (scene, mut editor) = PageScene::from_page(page(), &mut engine).unwrap(); let mut reference_primitives = Vec::new(); reference - .append_primitives(&mut reference_primitives, [0.0; 2]) + .append_primitives(&mut reference_primitives, [0.0; 2], Paper::WHITE) .unwrap(); let image_id; { @@ -1656,11 +1735,13 @@ mod tests { clip: None, text: a, origin: x, + .. }, Primitive::Text { clip: None, text: b, origin: y, + .. }, ) => { assert_eq!(x, y); @@ -1753,7 +1834,7 @@ mod tests { .advance ); assert!(matches!( - scene.append_primitives(&mut Vec::new(), [0.0; 2]), + scene.append_primitives(&mut Vec::new(), [0.0; 2], Paper::WHITE), Err(SceneError::MissingOutline) )); } @@ -1804,6 +1885,7 @@ mod tests { [5.0, 0.0], Some(&editor), None, + Paper::WHITE, |_, _, _| Ok(()), ) .unwrap(); @@ -1870,9 +1952,11 @@ mod tests { )); } let mut primitives = Vec::new(); - scene.append_primitives(&mut primitives, [0.0; 2]).unwrap(); scene - .append_primitives(&mut primitives, [10.0, -20.0]) + .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE) + .unwrap(); + scene + .append_primitives(&mut primitives, [10.0, -20.0], Paper::WHITE) .unwrap(); let [ Primitive::Image { @@ -1942,6 +2026,7 @@ mod tests { [0.0; 2], Some(&editor), Some((id, [70.0, 5.0, 120.0, 55.0])), + Paper::WHITE, |_, _, _| Ok(()), ) .unwrap(); diff --git a/crates/canvas/src/gpu/page/background.rs b/crates/canvas/src/gpu/page/background.rs new file mode 100644 index 0000000000000000000000000000000000000000..18f2236ba94cda808fd113d280e259bb9d833506 --- /dev/null +++ b/crates/canvas/src/gpu/page/background.rs @@ -0,0 +1,807 @@ +//! OneNote 2010's page template art, recognised by the SHA-256 of the embedded picture and +//! painted from vector recreations that suit the paper. + +use crate::gpu::Paper; +use draw::RasterImage; +use resvg::{tiny_skia, usvg}; +use sha2::{Digest, Sha256}; +use std::{ + sync::{Arc, Mutex, OnceLock, Weak, mpsc}, + task::Waker, + 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"), + ), + ( + "74c131777e7c437fd654427417097bc01b0813ba8e1e50e4b937bd50a1bebcdb", + include_str!("../../../assets/backgrounds/binoculars-corner.svg"), + ), + ( + "9c552717e8d5079bbb226948641ff13532df3d7be434c6ce545f1692fa57d45a", + include_str!("../../../assets/backgrounds/black-and-green-title.svg"), + ), + ( + "3c68c7ee798e62a4a99c740153f3980d7df029605c843410942c7f85e794823b", + include_str!("../../../assets/backgrounds/blue-bubbles-corner-margin.svg"), + ), + ( + "d0a3a1c3cd63c4023fe5716cbe2c211307d0e277e444d9ef76c7fc097a845fd4", + include_str!("../../../assets/backgrounds/blue-bubbles-corner-top.svg"), + ), + ( + "7966f3d8a2d61ecb49a35e163781858e052c0b122a18a1238afe27b57e2850e8", + include_str!("../../../assets/backgrounds/blue-clouds.svg"), + ), + ( + "139bb0e79f89c3ddef79b1716a5fbab4c07df5785fb3cdf6b4eeddbf6c078452", + include_str!("../../../assets/backgrounds/blue-dots.svg"), + ), + ( + "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"), + ), + ( + "0908a4cfa23f93011176d47f45843e9ca2973030421996e8e27484781f54b0ec", + include_str!("../../../assets/backgrounds/swirls.svg"), + ), + ( + "8b025b80e7d398229ef19384156fe98aeefcf8f69169d69e1eeba73281b24d7e", + include_str!("../../../assets/backgrounds/swirls.svg"), + ), + ( + "2a4a3530d652e227ddd5adc096a95f6034718f7c380b07db622022d768815059", + include_str!("../../../assets/backgrounds/tiles.svg"), + ), + ( + "d72900e8da72d1a7f3729971aa558e1e9b6e9cf9a0d51e83852e567256dbbfef", + include_str!("../../../assets/backgrounds/triangles-title-margin.svg"), + ), + ( + "2df453410796aec7b9efec00059b6ce64bcf67313a95ae458ba600ea5de14811", + include_str!("../../../assets/backgrounds/triangles-title-top.svg"), + ), + ( + "5b321a4d81bd499b289b1755f6450a42047c494dfbc112dbd56da4ced2c15c1a", + include_str!("../../../assets/backgrounds/tulips.svg"), + ), + ( + "494547e566fb7a63dd429eb0699fe41aa8998f8ea2f758d813fe3d56c3075719", + include_str!("../../../assets/backgrounds/window-reflection.svg"), + ), + ( + "5a36960df32817e8426bd40a88f88b04fb55b84baef60f1e71e0872217fdb134", + include_str!("../../../assets/backgrounds/writing.svg"), + ), +]; + +/// 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; +/// How long the zoom must hold before art is rasterized again for it. +const SETTLE: Duration = Duration::from_millis(150); + +/// Template art standing in for a page's background picture. +pub(super) struct Background { + svg: &'static str, + /// Points; OneNote draws a picture without a stored size at its natural size. + pub size: [f32; 2], + /// For light and for dark paper. + variants: [Variant; 2], +} + +#[derive(Default)] +struct Variant { + /// The raster painted and its device pixels per point. + shown: Option<(RasterImage, f32)>, + /// The one raster being made and its device pixels per point; replacing it abandons it. + pending: Option<(Arc>>, f32)>, +} + +/// Art rasterized on the worker thread, into `slot` unless its scene has let it go. +struct Job { + svg: &'static str, + /// The dark paper to recolour for; light paper takes the art as drawn. + dark: Option, + size: [u32; 2], + start: Instant, + slot: Weak>>, + waker: Waker, +} + +impl Background { + pub fn recognise(picture: &onestore::page::Image) -> Option { + let digest: String = Sha256::digest(picture.bytes.as_deref()?) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let (_, svg) = TEMPLATES.iter().find(|(hash, _)| *hash == digest)?; + let size = match (picture.layout.max_width, picture.layout.max_height) { + (Some(width), Some(height)) => [width, height], + _ => { + let size = parse(svg).size(); + [size.width() * 0.75, size.height() * 0.75] + } + }; + Some(Self { + svg, + size, + variants: Default::default(), + }) + } + + /// The latest raster for this paper, if one has landed. + pub fn image(&self, paper: Paper) -> Option<&RasterImage> { + let (image, _) = self.variants[usize::from(dark(paper))].shown.as_ref()?; + Some(image) + } + + /// Takes a finished raster and asks the worker for one at `scale` device pixels per + /// point when the shown one differs; `waker` hears when it lands. True once the raster + /// shown matches. + pub fn update(&mut self, paper: Paper, scale: f32, waker: &Waker) -> bool { + let dark = dark(paper); + let [width, height] = self.size.map(|side| side.max(1.0)); + let density = 2_f32 + .powf((scale.max(0.25).log2() * 2.0).ceil() / 2.0) + .min((MAX_RASTER_BYTES / (4.0 * width * height)).sqrt()) + .min(4096.0 / width.max(height)); + let variant = &mut self.variants[usize::from(dark)]; + let landed = variant + .pending + .as_ref() + .and_then(|(slot, made)| Some((slot.lock().unwrap().take()?, *made))); + if let Some((image, made)) = landed { + if made == density || variant.shown.is_none() { + variant.shown = Some((image, made)); + } + variant.pending = None; + } + let shown = variant.shown.as_ref().map(|(_, made)| *made); + if shown != Some(density) + && variant.pending.as_ref().map(|(_, made)| *made) != Some(density) + { + let slot = Arc::default(); + worker() + .send(Job { + svg: self.svg, + dark: dark.then_some(paper), + size: [width, height].map(|side| (side * density).round().max(1.0) as u32), + start: Instant::now() + + if shown.is_some() { + SETTLE + } else { + Duration::ZERO + }, + slot: Arc::downgrade(&slot), + waker: waker.clone(), + }) + .expect("The background worker outlives its jobs"); + variant.pending = Some((slot, density)); + } + shown == Some(density) + } +} + +fn dark(paper: Paper) -> bool { + let [lightness, ..] = oklab(paper.color); + let [ink, ..] = oklab(paper.ink); + ink > lightness +} + +/// The process's one background rasterizer, started on first use. +fn worker() -> &'static mpsc::Sender { + static WORKER: OnceLock> = OnceLock::new(); + WORKER.get_or_init(|| { + let (sender, jobs) = mpsc::channel::(); + std::thread::Builder::new() + .name("template backgrounds".into()) + .spawn(move || { + for job in jobs { + std::thread::sleep(job.start.saturating_duration_since(Instant::now())); + let Some(slot) = job.slot.upgrade() else { + continue; + }; + let image = rasterize(job.svg, job.dark, job.size); + *slot.lock().unwrap() = Some(image); + job.waker.wake(); + } + }) + .expect("The background worker starts"); + sender + }) +} + +fn rasterize(svg: &str, dark: Option, [width, height]: [u32; 2]) -> RasterImage { + let tree = match dark { + Some(paper) => parse(&onto(svg, paper)), + None => parse(svg), + }; + let mut pixmap = tiny_skia::Pixmap::new(width, height).expect("Art has a size"); + let view = tree.size(); + resvg::render( + &tree, + tiny_skia::Transform::from_scale( + width as f32 / view.width(), + height as f32 / view.height(), + ), + &mut pixmap.as_mut(), + ); + let pixels = pixmap + .pixels() + .iter() + .flat_map(|pixel| { + let color = pixel.demultiply(); + [color.red(), color.green(), color.blue(), color.alpha()] + }) + .collect(); + RasterImage::new([width, height], pixels).expect("Art is within the image budget") +} + +fn parse(svg: &str) -> usvg::Tree { + usvg::Tree::from_str(svg, &usvg::Options::default()).expect("Bundled template art is valid") +} + +/// Recolours `#rrggbb` art drawn for white paper onto dark `paper`: lightness turns about +/// the paper towards its ink, easing off so the art stays behind the text, and hue and +/// chroma carry over. Keywords, such as a mask's `white`, stay as they are. +fn onto(svg: &str, paper: Paper) -> String { + let [lightness, a, b] = oklab(paper.color); + let [ink, ..] = oklab(paper.ink); + let mut pieces = svg.split('#'); + let mut recoloured = pieces.next().unwrap_or_default().to_owned(); + for piece in pieces { + let color = piece + .get(..6) + .filter(|_| !piece[6..].starts_with(|c: char| c.is_ascii_alphanumeric())) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()); + let Some(color) = color else { + recoloured.push('#'); + recoloured.push_str(piece); + continue; + }; + let [_, red, green, blue] = color.to_be_bytes(); + let [l, ca, cb] = oklab(draw::srgb(red, green, blue)); + let turned = lightness + 0.7 * (1.0 - l).max(0.0).sqrt() * (ink - lightness); + let [red, green, blue] = linear([turned, a + ca, b + cb]).map(|value| { + let encoded = if value <= 0.003_130_8 { + value * 12.92 + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + }; + (encoded * 255.0).round() as u8 + }); + recoloured.push_str(&format!("#{red:02x}{green:02x}{blue:02x}")); + recoloured.push_str(&piece[6..]); + } + recoloured +} + +/// OKLab lightness and opponent axes of linear RGB. +fn oklab([red, green, blue, _]: [f32; 4]) -> [f32; 3] { + let [l, m, s] = [ + [0.412_221_46, 0.536_332_55, 0.051_445_995], + [0.211_903_5, 0.680_699_5, 0.107_396_96], + [0.088_302_46, 0.281_718_85, 0.629_978_7], + ] + .map(|[r, g, b]| (r * red + g * green + b * blue).cbrt()); + [ + 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s, + 1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s, + 0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s, + ] +} + +/// Linear RGB of OKLab, clipped to the sRGB gamut. +fn linear([lightness, a, b]: [f32; 3]) -> [f32; 3] { + let [l, m, s] = [ + lightness + 0.396_337_78 * a + 0.215_803_76 * b, + lightness - 0.105_561_346 * a - 0.063_854_17 * b, + lightness - 0.089_484_18 * a - 1.291_485_5 * b, + ] + .map(|value| value.powi(3)); + [ + [4.076_741_7, -3.307_711_6, 0.230_969_94], + [-1.268_438, 2.609_757_4, -0.341_319_38], + [-0.004_196_086_3, -0.703_418_6, 1.707_614_7], + ] + .map(|[x, y, z]| (x * l + y * m + z * s).clamp(0.0, 1.0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{gpu::page::PageScene, layout::TextEngine}; + use onestore::{ + ExGuid, + document::Layout, + page::{Image, Page, PageObject}, + }; + use std::collections::BTreeMap; + + const DARK: Paper = Paper { + color: [0.0137, 0.0144, 0.0159, 1.0], + ink: [0.791, 0.791, 0.791, 1.0], + }; + + fn picture(bytes: &[u8]) -> Image { + Image { + id: ExGuid::default(), + layout: Layout { + x: Some(0.0), + y: Some(0.0), + max_width: Some(64.0), + max_height: Some(48.0), + ..Default::default() + }, + size: None, + bytes: Some(Arc::from(bytes)), + alt: None, + background: true, + } + } + + fn red_png() -> Vec { + let mut encoded = Vec::new(); + let mut encoder = png::Encoder::new(&mut encoded, 1, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&[255, 0, 0, 255]) + .unwrap(); + encoded + } + + fn ivy() -> Background { + let (_, svg) = TEMPLATES + .iter() + .find(|(_, svg)| svg.contains("id=\"ivy\"")) + .unwrap(); + Background { + svg, + size: [64.0, 48.0], + variants: Default::default(), + } + } + + fn settle(mut update: impl FnMut() -> bool) { + let give_up = Instant::now() + Duration::from_secs(10); + while !update() { + assert!(Instant::now() < give_up, "the worker never delivered"); + std::thread::sleep(Duration::from_millis(2)); + } + } + + #[test] + fn rasters_land_from_the_worker_and_follow_the_zoom() { + let mut art = ivy(); + assert!(!art.update(DARK, 1.0, Waker::noop())); + assert!(art.image(DARK).is_none()); + settle(|| art.update(DARK, 1.0, Waker::noop())); + let size = |art: &Background| art.image(DARK).map(|image| image.pixels().len() / 4); + assert_eq!(size(&art), Some(64 * 48)); + assert!( + art.image(Paper::WHITE).is_none(), + "each paper has its own raster" + ); + // Zooming keeps the last raster up while one for the new scale is made, and a + // newer scale abandons the older request. + assert!(!art.update(DARK, 4.0, Waker::noop())); + let abandoned = Arc::downgrade(&art.variants[1].pending.as_ref().unwrap().0); + assert!(!art.update(DARK, 2.0, Waker::noop())); + assert!(abandoned.upgrade().is_none()); + assert_eq!(size(&art), Some(64 * 48)); + settle(|| art.update(DARK, 2.0, Waker::noop())); + assert_eq!(size(&art), Some(128 * 96)); + assert!(art.variants[1].pending.is_none()); + } + + #[test] + fn every_recreation_parses_for_light_and_dark_paper_under_a_distinct_hash() { + let mut hashes = std::collections::BTreeSet::new(); + for (hash, svg) in TEMPLATES { + 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 [svg.to_string(), onto(svg, DARK)] { + usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap(); + } + } + } + + #[test] + fn other_pictures_keep_their_own_bytes() { + assert!(Background::recognise(&picture(&red_png())).is_none()); + assert!( + Background::recognise(&Image { + bytes: None, + ..picture(&[]) + }) + .is_none() + ); + } + + #[test] + fn dark_paper_takes_white_to_the_paper_and_keeps_hue() { + assert_eq!(onto(r##"fill="#ffffff""##, DARK), r##"fill="#1f2022""##); + assert_eq!( + onto("href=\"#beads\" stop-color=\"white\"", DARK), + "href=\"#beads\" stop-color=\"white\"" + ); + let leaf = onto("#8fce73", DARK); + let [red, green, blue] = + [1, 3, 5].map(|at| u8::from_str_radix(&leaf[at..at + 2], 16).unwrap()); + assert!(green > red && green > blue && green < 0xce, "{leaf}"); + } + + #[test] + #[ignore = "requires CANVAS_TEST_SECTION pointing to a section whose pages use OneNote's page templates"] + fn recognises_template_backgrounds_in_a_section() { + let bytes = std::fs::read(std::env::var("CANVAS_TEST_SECTION").unwrap()).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 recognised = 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!(Background::recognise(image).is_some(), "{:?}", image.id); + recognised += 1; + } + } + } + assert!(recognised > 0); + } + + #[test] + #[ignore = "requires a native GPU adapter"] + fn dark_paper_paints_the_dark_recreation_instead_of_the_embedded_picture() { + let background = picture(&red_png()); + let id = background.id; + let page = Page { + identity: None, + created: None, + title: String::new(), + margin_origin: [0.0; 2], + definitions: BTreeMap::new(), + objects: vec![PageObject::Image(background)], + }; + let mut scene = PageScene::new(page, &mut TextEngine::default()).unwrap(); + scene.backgrounds.insert(id, ivy()); + let mut before = Vec::new(); + scene + .append_primitives(&mut before, [0.0; 2], DARK) + .unwrap(); + assert!(before.is_empty(), "the paper shows until the raster lands"); + drop(before); + settle(|| scene.update_backgrounds(DARK, 1.0, Waker::noop())); + let mut primitives = Vec::new(); + scene + .append_primitives(&mut primitives, [0.0; 2], DARK) + .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 size = [64_u32, 48]; + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Background readback test"), + 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: &[], + }); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Background readback"), + size: u64::from(size[0] * size[1] * 4), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut renderer = draw::Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); + let viewport = crate::gpu::Viewport { + size, + scale: 1.0, + origin: [0.0; 2], + }; + renderer + .draw( + &target.create_view(&Default::default()), + size, + DARK.color, + &[viewport.layer(&primitives)], + ) + .unwrap(); + let mut encoder = renderer.device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + target.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(size[0] * 4), + rows_per_image: Some(size[1]), + }, + }, + wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + ); + renderer.queue.submit([encoder.finish()]); + readback.map_async(wgpu::MapMode::Read, .., |result| result.unwrap()); + renderer + .device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(5)), + }) + .unwrap(); + let pixels = readback.get_mapped_range(..).unwrap().to_vec(); + let pixels: Vec<[u8; 3]> = pixels.chunks_exact(4).map(|p| [p[0], p[1], p[2]]).collect(); + assert!( + pixels + .iter() + .all(|[r, g, b]| !(*r > 200 && *g < 80 && *b < 80)), + "the embedded picture was painted" + ); + // Leaves on dark paper stay green, darker than they are on white. + assert!( + pixels + .iter() + .any(|[r, g, b]| g > r && g > b && *g > 0x40 && *g < 0xce), + "the dark recreation was not painted" + ); + assert!( + pixels.iter().all(|[_, g, _]| *g < 0xe0), + "the art kept white paper" + ); + } +} diff --git a/crates/canvas/src/gpu/profile.rs b/crates/canvas/src/gpu/profile.rs index b16456e5b1c1764b5bea418ca1516b49dbdae8da..7e789d5b189854ff894bf0255ce40f2572d2cb10 100644 --- a/crates/canvas/src/gpu/profile.rs +++ b/crates/canvas/src/gpu/profile.rs @@ -101,12 +101,15 @@ fn renderer_cost() { } let mut primitives = Vec::new(); if let Some(scene) = &scene { - scene.append_primitives(&mut primitives, [0.0; 2]).unwrap(); + scene + .append_primitives(&mut primitives, [0.0; 2], Paper::WHITE) + .unwrap(); } else { primitives.push(draw::Primitive::Text { clip: None, text: layout.as_ref().unwrap(), origin: [36.0, 90.0], + ink: Paper::WHITE.ink, }); } let start = Instant::now(); @@ -150,3 +153,202 @@ fn load_scene(bytes: &[u8], title: &str, engine: &mut TextEngine) -> page::PageS ) .unwrap() } + +/// Frame cost of a large page as the app draws it: the view's primitives and the renderer's +/// submission, resting, scrolling, typing and pressing Enter. `CANVAS_TEST_SECTION` and +/// `CANVAS_TEST_PAGE` choose a stored page; otherwise one outline of 4000 paragraphs, which then +/// gains a picture and a table in its middle to type beside and in. +#[cfg(feature = "interaction")] +#[test] +#[ignore = "manual release frame timing probe"] +fn frame_cost() { + use crate::document::{TextDocument, TextPosition}; + use crate::editor::CanvasEditor; + use crate::interaction::{PageView, TextColors}; + use draw::edit::{Key, NamedKey}; + use onestore::page::{ + Image, ParagraphContent, Table, TableCell, TableColumn, TableRow, text::new_id, + }; + const LINES: usize = 4000; + let lines = || { + (0..LINES).map(|line| { + Paragraph::new( + format!("Line {line}: a paragraph of notes with café and a few more words to wrap across the outline's width."), + Default::default(), + ) + }) + }; + let synthetic = std::env::var_os("CANVAS_TEST_SECTION").is_none(); + let mut engine = TextEngine::default(); + let (editor, scene) = match std::env::var_os("CANVAS_TEST_SECTION") { + Some(path) => { + let bytes = std::fs::read(path).unwrap(); + let store = onestore::Store::parse(&bytes).unwrap(); + let index = onestore::RevisionIndex::parse(&store).unwrap(); + let document = onestore::document::Document::parse(&index).unwrap(); + let title = std::env::var("CANVAS_TEST_PAGE").unwrap_or_default(); + let page = Page::from_document(&document, &title).unwrap(); + let (scene, editor) = page::PageScene::from_page(page, &mut engine).unwrap(); + (editor, Some((scene, [0.0; 2]))) + } + None => { + let document = TextDocument::new(lines().collect()).unwrap(); + let editor = CanvasEditor::new(&mut engine, document, 480.0).unwrap(); + (editor, None) + } + }; + let size = [2360, 1300]; + let mut view = PageView::new(editor, engine, scene, size, 2.0, Duration::from_millis(500)); + 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 format = wgpu::TextureFormat::Rgba8UnormSrgb; + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Frame profile target"), + 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, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = target.create_view(&Default::default()); + let mut renderer = Renderer::new(device, queue, format); + let colors = TextColors { + caret: [0.0, 0.0, 0.0, 1.0], + selection: [0.7, 0.8, 1.0, 1.0], + paper: Paper::WHITE, + }; + let _ = view.pointer_moved([400.0, 400.0]).unwrap(); + for phase in ["rest", "scroll", "type", "enter", "picture", "cell"] { + match phase { + "picture" if synthetic => { + let mut nodes = TextDocument::new(lines().collect()) + .unwrap() + .nodes() + .to_vec(); + let mut picture = nodes[0].clone(); + picture.id = new_id().unwrap(); + picture.content = ParagraphContent::Image(Image { + id: new_id().unwrap(), + layout: onestore::document::Layout { + max_width: Some(120.0), + max_height: Some(80.0), + ..Default::default() + }, + size: None, + bytes: None, + alt: None, + background: false, + }); + let cell = || TableCell { + id: new_id().unwrap(), + layout: Default::default(), + indents: vec![18.0, 0.0, 27.0], + shading: None, + paragraphs: vec![ + crate::document::node( + Paragraph::new("Cell".into(), Default::default()), + Default::default(), + ) + .unwrap(), + ], + unsupported: Vec::new(), + }; + let mut table = nodes[0].clone(); + table.id = new_id().unwrap(); + table.content = ParagraphContent::Table(Table { + id: new_id().unwrap(), + columns: vec![ + TableColumn { + width: 72.0, + locked: true, + }; + 2 + ], + rows: (0..2) + .map(|_| TableRow { + id: new_id().unwrap(), + cells: vec![cell(), cell()], + }) + .collect(), + borders: Some(true), + layout: Default::default(), + tags: Vec::new(), + }); + // The picture precedes paragraph LINES / 2, which the table follows. + nodes.insert(LINES / 2, picture); + nodes.insert(LINES / 2 + 2, table); + let document = TextDocument::from_nodes(nodes).unwrap(); + view.editor = CanvasEditor::new(&mut view.engine, document, 480.0).unwrap(); + } + "picture" | "cell" if !synthetic => continue, + _ => {} + } + let caret = match phase { + "picture" => Some(LINES / 2), + "cell" => Some(LINES / 2 + 1), + _ => None, + }; + if let Some(paragraph) = caret { + view.editor + .select( + [TextPosition { + paragraph, + offset: 0, + }; 2] + .into(), + ) + .unwrap(); + } + let mut times = [Vec::new(), Vec::new(), Vec::new()]; + for _ in 0..64 { + let start = Instant::now(); + match phase { + "scroll" => { + let _ = view.wheel([0.0, -120.0]).unwrap(); + } + "enter" => { + let _ = view.key(&Key::Named(NamedKey::Enter), None).unwrap(); + } + "type" | "picture" | "cell" => { + let _ = view.key(&Key::Named(NamedKey::Other), Some("a")).unwrap(); + } + _ => {} + } + let input = start.elapsed(); + let start = Instant::now(); + let primitives = view.primitives(colors).unwrap(); + let built = start.elapsed(); + let start = Instant::now(); + renderer + .draw(&target, size, [1.0; 4], &[view.viewport.layer(&primitives)]) + .unwrap(); + let drawn = start.elapsed(); + drop(primitives); + renderer + .device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(10)), + }) + .unwrap(); + for (list, time) in times.iter_mut().zip([input, built, drawn]) { + list.push(time); + } + } + for (name, mut list) in ["input", "primitives", "draw"].into_iter().zip(times) { + list.sort(); + eprintln!( + "frame_cost\t{phase}\t{name}\tp50 {:.2} ms\tp99 {:.2} ms", + list[list.len() / 2].as_secs_f64() * 1e3, + list[list.len() * 99 / 100].as_secs_f64() * 1e3 + ); + } + } +} diff --git a/crates/canvas/src/interaction/accessibility.rs b/crates/canvas/src/interaction/accessibility.rs index b52c9a314d7e42df320265326460bbe21c8638b2..3e0f96458c32d063b2ea7c03d18ac012fdeb2d55 100644 --- a/crates/canvas/src/interaction/accessibility.rs +++ b/crates/canvas/src/interaction/accessibility.rs @@ -1,141 +1,89 @@ -use crate::gpu::Viewport; +use crate::gpu::{Viewport, page::PageScene}; use crate::{ date::DateField, document::TextPosition, - editor::{CanvasEditor, Selection, TextOutline}, + editor::{CanvasEditor, Selection}, + outline::ParagraphLayout, }; use accesskit::{ Action, Affine, Node, NodeId, Rect, Role, TextDirection, TextSelection, TreeId, TreeInfo, TreeUpdate, }; -use onestore::page::text::EditError; +use onestore::{ExGuid, page::text::EditError}; use parley::Affinity; -use std::{collections::HashMap, ops::Range}; +use std::collections::HashMap; pub const ROOT: NodeId = NodeId(0); +/// The page in document coordinates; its transform is the viewport, so scrolling and zooming +/// change only this node. +const PAGE: NodeId = NodeId(1); struct Run { - outline: onestore::ExGuid, id: NodeId, node: Node, - positions: Vec, + /// Paragraph-relative source offset of each character boundary, except the end of a + /// paragraph break, which is the next paragraph's start. + offsets: Vec, } -#[derive(Hash, PartialEq, Eq)] -struct ParagraphKey { +/// A paragraph's runs, in paragraph coordinates under a node at its origin, so reflow above +/// it moves one node. +struct Paragraph { + id: NodeId, layout: u64, source: usize, - next: Option, - origin: [u32; 2], -} - -pub struct Accessibility { - outlines: Vec<(onestore::ExGuid, NodeId)>, + origin: [f32; 2], + /// Its text's byte length in the field's value. + length: usize, runs: Vec, - paragraphs: HashMap>, - read_only: Vec, - dates: Vec<(DateField, NodeId)>, +} + +impl Paragraph { + fn node(&self) -> Node { + let mut node = Node::new(Role::GenericContainer); + node.set_transform(Affine::translate(( + f64::from(self.origin[0]), + f64::from(self.origin[1]), + ))); + node.set_children(self.runs.iter().map(|run| run.id).collect::>()); + node + } +} + +struct Field { + outline: ExGuid, + id: NodeId, + node: Node, + paragraphs: Vec, +} + +/// The nodes last sent to assistive technology, so each update sends only what changed. +pub struct Accessibility { + fields: Vec, + read_only: Vec<(NodeId, Node)>, + dates: Vec<(DateField, NodeId, Node)>, next_id: u64, } impl Default for Accessibility { fn default() -> Self { Self { - outlines: Vec::new(), - runs: Vec::new(), - paragraphs: HashMap::new(), + fields: Vec::new(), read_only: Vec::new(), dates: Vec::new(), - next_id: 1, + next_id: 2, } } } impl Accessibility { - /// Retire published runs without recycling their IDs. + /// Retire published nodes without recycling their IDs; the next update sends the whole tree. pub fn deactivate(&mut self) { - self.runs.clear(); - self.paragraphs.clear(); - self.outlines.clear(); + self.fields.clear(); self.read_only.clear(); self.dates.clear(); } - pub fn append_page_fields( - &mut self, - update: &mut TreeUpdate, - scene: Option<&(crate::gpu::page::PageScene, [f32; 2])>, - editor: &CanvasEditor, - viewport: Viewport, - focus: Option, - ) { - let mut count = 0; - if let Some((scene, offset)) = scene { - for (index, object) in scene.read_only(Some(editor)).enumerate() { - if index == self.read_only.len() { - let id = self.allocate(); - self.read_only.push(id); - } - let id = self.read_only[index]; - let mut node = Node::new(Role::Label); - node.set_value(object.message); - node.set_read_only(); - node.add_action(Action::Focus); - if focus == Some(index) { - update.focus = id; - } - let [x0, y0, x1, y1] = object.rect(); - let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]); - let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]); - node.set_bounds(Rect::new(x(x0), y(y0), x(x1), y(y1))); - update - .nodes - .iter_mut() - .find(|(id, _)| *id == ROOT) - .unwrap() - .1 - .push_child(id); - update.nodes.push((id, node)); - count += 1; - } - } - self.read_only.truncate(count); - let mut date_count = 0; - if let Some((scene, offset)) = scene { - for (index, (field, rect)) in scene.date_fields(editor).enumerate() { - if index == self.dates.len() { - let id = self.allocate(); - self.dates.push((field, id)); - } - self.dates[index].0 = field; - let id = self.dates[index].1; - let mut node = Node::new(Role::Button); - node.set_label(super::DATE_LABELS[field as usize]); - node.set_value( - editor.date().unwrap().source().paragraphs[index] - .text() - .unwrap() - .text - .text(), - ); - node.add_action(Action::Click); - let x = |v| f64::from((v + offset[0]) * viewport.scale + viewport.origin[0]); - let y = |v| f64::from((v + offset[1]) * viewport.scale + viewport.origin[1]); - node.set_bounds(Rect::new(x(rect[0]), y(rect[1]), x(rect[2]), y(rect[3]))); - update - .nodes - .iter_mut() - .find(|(id, _)| *id == ROOT) - .unwrap() - .1 - .push_child(id); - update.nodes.push((id, node)); - date_count += 1; - } - } - self.dates.truncate(date_count); - } - fn allocate(&mut self) -> NodeId { let id = NodeId(self.next_id); self.next_id = self @@ -146,309 +94,132 @@ impl Accessibility { } pub fn read_only_for_node(&self, node: NodeId) -> Option { - self.read_only.iter().position(|id| *id == node) + self.read_only.iter().position(|(id, _)| *id == node) } pub fn date_for_node(&self, node: NodeId) -> Option { self.dates .iter() - .find(|(_, id)| *id == node) - .map(|(field, _)| *field) + .find(|(_, id, _)| *id == node) + .map(|(field, ..)| *field) } - pub fn outline_for_node(&self, node: NodeId) -> Option { - self.outlines + pub fn outline_for_node(&self, node: NodeId) -> Option { + self.fields .iter() - .find(|(_, id)| *id == node) - .map(|(outline, _)| *outline) + .find(|field| field.id == node) + .map(|field| field.outline) } + /// The nodes that changed since the last update. After an error, `deactivate` before the + /// next update. pub fn update( &mut self, editor: &CanvasEditor, + scene: Option<&(PageScene, [f32; 2])>, viewport: Viewport, title: &str, - preview: Option<(onestore::ExGuid, [f32; 2])>, + preview: Option<(ExGuid, [f32; 2])>, + read_only_focus: Option, ) -> Result { - let mut runs = Vec::new(); - let mut outlines = Vec::new(); - let mut paragraph_ranges = HashMap::new(); - for outline in editor.visible_outlines().chain(editor.caret_outline()) { - let field = self - .outlines - .iter() - .find(|(id, _)| *id == outline.id) - .map(|(_, node)| *node) - .unwrap_or_else(|| self.allocate()); - outlines.push((outline.id, field)); - let mut paragraphs = outline.layouts().peekable(); - while let Some((paragraph, shaped)) = paragraphs.next() { - let layout = &shaped.text; - let origin = shaped.origin; - let key = ParagraphKey { - layout: layout.id(), - source: paragraph, - next: paragraphs.peek().map(|(next, _)| *next), - origin: origin.map(f32::to_bits), - }; - let start = runs.len(); - if let Some(range) = self.paragraphs.get(&key) { - runs.extend( - self.runs[range.clone()] - .iter() - .map(|run| (run.outline, run.node.clone(), run.positions.clone())), - ); - paragraph_ranges.insert(key, start..runs.len()); - continue; - } - let projection = &shaped.projection; - let text = projection.text().text(); - let boundaries: Vec<_> = projection.source_boundaries().collect(); - let source_position = |byte| -> Result { - let index = boundaries - .binary_search_by_key(&byte, |(byte, _)| *byte) - .map_err(|_| EditError::InvalidRange)?; - Ok(TextPosition { - paragraph, - offset: boundaries[index].1, - }) - }; - let line_count = layout.lines().count(); - for (line_index, (line, bounds)) in layout.lines().enumerate() { - let line_start = runs.len(); - let mut x = line.metrics().offset; - let mut logical_runs = Vec::new(); - for run in line.runs() { - let advance = run.advance(); - logical_runs.push((run, x)); - x += advance; - } - logical_runs.sort_by_key(|(run, _)| run.text_range().start); - for (run, x) in logical_runs { - // AccessKit stores each character's byte length in a u8. - let mut characters = Vec::new(); - for cluster in run.clusters() { - let range = cluster.text_range(); - if range.is_empty() || range.start == text.len() { - continue; - } - let cluster_text = - text.get(range.clone()).ok_or(EditError::InvalidRange)?; - if range.len() <= u8::MAX as usize { - characters.push(( - range, - cluster.advance(), - cluster.is_word_boundary(), - )); - } else { - for (index, (offset, ch)) in cluster_text.char_indices().enumerate() - { - characters.push(( - range.start + offset..range.start + offset + ch.len_utf8(), - if index == 0 { cluster.advance() } else { 0.0 }, - index == 0 && cluster.is_word_boundary(), - )); - } - } - } - let mut advance = 0.0; - for chunk in characters.chunks(256) { - let width: f32 = chunk.iter().map(|(_, width, _)| width).sum(); - let left = x + if run.is_rtl() { - run.advance() - advance - width - } else { - advance - }; - let mut node = Node::new(Role::TextRun); - node.set_bounds(Rect::new( - f64::from(left + origin[0]), - f64::from(bounds.top + origin[1]), - f64::from(left + width + origin[0]), - f64::from(bounds.top + bounds.height + origin[1]), - )); - node.set_text_direction(if run.is_rtl() { - TextDirection::RightToLeft - } else { - TextDirection::LeftToRight - }); - node.set_font_size(run.font_size()); - let start = chunk[0].0.start; - let end = chunk.last().unwrap().0.end; - node.set_value(text.get(start..end).ok_or(EditError::InvalidRange)?); - node.set_character_lengths( - chunk - .iter() - .map(|(range, _, _)| u8::try_from(range.len()).unwrap()) - .collect::>(), - ); - let mut offset = 0.0; - node.set_character_positions( - chunk - .iter() - .map(|(_, width, _)| { - let position = offset; - offset += width; - position - }) - .collect::>(), - ); - node.set_character_widths( - chunk.iter().map(|(_, width, _)| *width).collect::>(), - ); - node.set_word_starts( - chunk - .iter() - .enumerate() - .filter_map(|(index, (_, _, word))| word.then_some(index as u8)) - .collect::>(), - ); - let positions = chunk - .iter() - .map(|(range, _, _)| source_position(range.start)) - .chain(std::iter::once(source_position(end))) - .collect::, _>>()?; - runs.push((outline.id, node, positions)); - advance += width; - } - } - // Empty lines still need a text position for selection and insertion. - if runs.len() == line_start { - let mut node = Node::new(Role::TextRun); - node.set_value(""); - node.set_character_lengths(Vec::::new()); - node.set_character_positions(Vec::::new()); - node.set_character_widths(Vec::::new()); - node.set_text_direction(TextDirection::LeftToRight); - node.set_bounds(Rect::new( - f64::from(origin[0]), - f64::from(bounds.top + origin[1]), - f64::from(origin[0]), - f64::from(bounds.top + bounds.height + origin[1]), - )); - runs.push(( - outline.id, - node, - vec![source_position(bounds.source.start)?], - )); - } - if line_index + 1 == line_count - && let Some((next, _)) = paragraphs.peek() - { - let (_, node, source_positions) = runs.last_mut().unwrap(); - let mut value = node.value().unwrap().to_owned(); - value.push('\n'); - node.set_value(value); - let mut lengths = node.character_lengths().to_vec(); - lengths.push(1); - node.set_character_lengths(lengths); - if let Some(rect) = node.bounds() { - let mut positions = - node.character_positions().unwrap_or_default().to_vec(); - positions.push(rect.width() as f32); - node.set_character_positions(positions); - let mut widths = node.character_widths().unwrap_or_default().to_vec(); - widths.push(4.0); - node.set_character_widths(widths); - } - source_positions.push(TextPosition { - paragraph: *next, - offset: 0, - }); - } - } - if !shaped.tags.is_empty() { - let descriptions = shaped - .tags - .iter() - .map(|tag| { - use crate::outline::TagIcon; - let fallback = match tag.icon { - TagIcon::CheckBox { .. } => "To do", - TagIcon::Question => "Question", - TagIcon::Music => "Music", - TagIcon::Exclamation => "Critical", - TagIcon::RedSquare => "Project A", - TagIcon::YellowSquare => "Project B", - TagIcon::BlueSquare => "Project C", - }; - let label = if tag.label.is_empty() { - fallback - } else { - &tag.label - }; - let state = match tag.icon { - TagIcon::CheckBox { checked: true } => ", completed", - TagIcon::CheckBox { checked: false } => ", incomplete", - _ => "", - }; - format!( - "{label}{state}{}", - if tag.disabled { ", disabled" } else { "" } - ) - }) - .collect::>() - .join("; "); - runs[start].1.set_description(descriptions); - } - paragraph_ranges.insert(key, start..runs.len()); - } - } - let mut runs: Vec<_> = runs - .into_iter() - .enumerate() - .map(|(index, (outline, mut node, positions))| { - node.clear_next_on_line(); - node.clear_previous_on_line(); - let id = match self.runs.get(index) { - Some(old) - if old.outline == outline - && old.positions == positions - && old.node.value() == node.value() => - { - old.id - } - _ => self.allocate(), - }; - Run { - outline, - id, - node, - positions, - } - }) - .collect(); - for index in 1..runs.len() { - if runs[index - 1].outline == runs[index].outline - && runs[index - 1].node.bounds().map(|r| r.y0) - == runs[index].node.bounds().map(|r| r.y0) - { - let (before, after) = runs.split_at_mut(index); - before[index - 1].node.set_next_on_line(after[0].id); - after[0].node.set_previous_on_line(before[index - 1].id); - } - } - self.runs = runs; - self.paragraphs = paragraph_ranges; - self.outlines = outlines; - let mut root = Node::new(Role::Window); - root.set_label(title); - root.set_children(self.outlines.iter().map(|(_, id)| *id).collect::>()); - root.set_bounds(Rect::new( - 0.0, - 0.0, - viewport.size[0] as f64, - viewport.size[1] as f64, - )); - let mut nodes = vec![(ROOT, root)]; + let mut nodes = Vec::new(); let mut focus = ROOT; - for (index, (outline, (_, id))) in editor + let mut previous: HashMap<_, _> = self + .fields + .drain(..) + .map(|field| (field.outline, field)) + .collect(); + for (ordinal, outline) in editor .visible_outlines() .chain(editor.caret_outline()) - .zip(&self.outlines) .enumerate() { - let mut field = Node::new(Role::MultilineTextInput); - field.set_label(if outline.title { + let (id, sent, old) = match previous.remove(&outline.id) { + Some(field) => (field.id, Some(field.node), field.paragraphs), + None => (self.allocate(), None, Vec::new()), + }; + let layouts: Vec<_> = outline.layouts().collect(); + // Only a paragraph with a successor ends its last run with the paragraph break. + let same = |old_index: usize, index: usize| { + old[old_index].layout == layouts[index].1.text.id() + && (old_index + 1 == old.len()) == (index + 1 == layouts.len()) + }; + let shorter = old.len().min(layouts.len()); + let prefix = (0..shorter).take_while(|&i| same(i, i)).count(); + let suffix = (1..=shorter - prefix) + .take_while(|&i| same(old.len() - i, layouts.len() - i)) + .count(); + let text = sent.as_ref().and_then(Node::value).unwrap_or_default(); + let length = |paragraphs: &[Paragraph]| paragraphs.iter().map(|p| p.length).sum(); + let mut value = text[..length(&old[..prefix])].to_owned(); + let tail = &text[text.len() - length(&old[old.len() - suffix..])..]; + let mut paragraphs = old; + let kept = paragraphs.split_off(paragraphs.len() - suffix); + let mut replaced = paragraphs.split_off(prefix).into_iter(); + for (at, (source, shaped)) in layouts + .iter() + .enumerate() + .take(layouts.len() - suffix) + .skip(prefix) + { + let (id, old) = match replaced.next() { + Some(paragraph) => (paragraph.id, paragraph.runs), + None => (self.allocate(), Vec::new()), + }; + let mut runs = Vec::new(); + for (index, (node, offsets)) in runs_of(shaped, at + 1 < layouts.len())? + .into_iter() + .enumerate() + { + let id = match old.get(index) { + Some(old) if old.offsets == offsets && old.node.value() == node.value() => { + old.id + } + _ => self.allocate(), + }; + runs.push(Run { id, node, offsets }); + } + for index in 1..runs.len() { + if runs[index - 1].node.bounds().map(|r| r.y0) + == runs[index].node.bounds().map(|r| r.y0) + { + let (before, after) = runs.split_at_mut(index); + before[index - 1].node.set_next_on_line(after[0].id); + after[0].node.set_previous_on_line(before[index - 1].id); + } + } + for (index, run) in runs.iter().enumerate() { + if old + .get(index) + .is_none_or(|old| old.id != run.id || old.node != run.node) + { + nodes.push((run.id, run.node.clone())); + } + } + let start = value.len(); + value.extend(runs.iter().filter_map(|run| run.node.value())); + let paragraph = Paragraph { + id, + layout: shaped.text.id(), + source: *source, + origin: shaped.origin, + length: value.len() - start, + runs, + }; + nodes.push((id, paragraph.node())); + paragraphs.push(paragraph); + } + paragraphs.extend(kept); + value.push_str(tail); + for (paragraph, (source, shaped)) in paragraphs.iter_mut().zip(&layouts) { + paragraph.source = *source; + if paragraph.origin != shaped.origin { + paragraph.origin = shaped.origin; + nodes.push((paragraph.id, paragraph.node())); + } + } + let mut node = Node::new(Role::MultilineTextInput); + node.set_label(if outline.title { "Page title".into() } else if editor .caret_outline() @@ -456,47 +227,121 @@ impl Accessibility { { "Text input".into() } else { - format!("Text outline {}", index + 1) + format!("Text outline {}", ordinal + 1) }); - field.add_action(Action::Focus); - field.add_action(Action::SetTextSelection); - field.add_action(Action::ReplaceSelectedText); - field.add_action(Action::SetValue); - let runs = self.runs.iter().filter(|run| run.outline == outline.id); - field.set_value( - runs.clone() - .filter_map(|run| run.node.value()) - .collect::(), + node.add_action(Action::Focus); + node.add_action(Action::SetTextSelection); + node.add_action(Action::ReplaceSelectedText); + node.add_action(Action::SetValue); + node.set_value(value); + node.set_children( + paragraphs + .iter() + .map(|paragraph| paragraph.id) + .collect::>(), ); - field.set_children(runs.map(|run| run.id).collect::>()); let origin = preview .filter(|(id, _)| *id == outline.id) .map(|(_, origin)| origin) .unwrap_or_else(|| outline.origin()); - field.set_transform(Affine::new([ - f64::from(viewport.scale), - 0.0, - 0.0, - f64::from(viewport.scale), - f64::from(viewport.origin[0]) + f64::from(origin[0]) * f64::from(viewport.scale), - f64::from(viewport.origin[1]) + f64::from(origin[1]) * f64::from(viewport.scale), - ])); + node.set_transform(Affine::translate(( + f64::from(origin[0]), + f64::from(origin[1]), + ))); let bounds = outline.bounds(); - field.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height())); + node.set_bounds(Rect::new(0.0, 0.0, bounds.width(), bounds.height())); if outline.id == editor.active_outline().id { let Selection { positions: [anchor, caret], affinities, } = editor.selection(); - field.set_text_selection(TextSelection { - anchor: self.position(outline, anchor, affinities[0])?, - focus: self.position(outline, caret, affinities[1])?, + node.set_text_selection(TextSelection { + anchor: position(&layouts, ¶graphs, anchor, affinities[0])?, + focus: position(&layouts, ¶graphs, caret, affinities[1])?, }); - focus = *id; + focus = id; + } + if sent.as_ref() != Some(&node) { + nodes.push((id, node.clone())); + } + self.fields.push(Field { + outline: outline.id, + id, + node, + paragraphs, + }); + } + let mut children: Vec<_> = self.fields.iter().map(|field| field.id).collect(); + let sent_read_only = std::mem::take(&mut self.read_only); + let sent_dates = std::mem::take(&mut self.dates); + if let Some((scene, offset)) = scene { + let rect = |[x0, y0, x1, y1]: [f32; 4]| { + Rect::new( + f64::from(x0 + offset[0]), + f64::from(y0 + offset[1]), + f64::from(x1 + offset[0]), + f64::from(y1 + offset[1]), + ) + }; + for (index, object) in scene.read_only(Some(editor)).enumerate() { + let mut node = Node::new(Role::Label); + node.set_value(object.message); + node.set_read_only(); + node.add_action(Action::Focus); + node.set_bounds(rect(object.rect())); + let sent = sent_read_only.get(index); + let id = sent.map_or_else(|| self.allocate(), |(id, _)| *id); + if sent.is_none_or(|(_, sent)| *sent != node) { + nodes.push((id, node.clone())); + } + if read_only_focus == Some(index) { + focus = id; + } + children.push(id); + self.read_only.push((id, node)); + } + for (index, (field, bounds)) in scene.date_fields(editor).enumerate() { + let mut node = Node::new(Role::Button); + node.set_label(super::DATE_LABELS[field as usize]); + node.set_value( + editor.date().unwrap().source().paragraphs[index] + .text() + .unwrap() + .text + .text(), + ); + node.add_action(Action::Click); + node.set_bounds(rect(bounds)); + let sent = sent_dates.get(index); + let id = sent.map_or_else(|| self.allocate(), |(_, id, _)| *id); + if sent.is_none_or(|(_, _, sent)| *sent != node) { + nodes.push((id, node.clone())); + } + children.push(id); + self.dates.push((field, id, node)); } - nodes.push((*id, field)); } - nodes.extend(self.runs.iter().map(|run| (run.id, run.node.clone()))); + let mut page = Node::new(Role::GenericContainer); + page.set_transform(Affine::new([ + f64::from(viewport.scale), + 0.0, + 0.0, + f64::from(viewport.scale), + f64::from(viewport.origin[0]), + f64::from(viewport.origin[1]), + ])); + page.set_children(children); + let mut root = Node::new(Role::Window); + root.set_label(title); + root.set_children(vec![PAGE]); + root.set_bounds(Rect::new( + 0.0, + 0.0, + viewport.size[0] as f64, + viewport.size[1] as f64, + )); + nodes.push((ROOT, root)); + nodes.push((PAGE, page)); Ok(TreeUpdate { nodes, tree: Some(TreeInfo::new(ROOT)), @@ -505,71 +350,47 @@ impl Accessibility { }) } - fn position( - &self, - outline: &TextOutline, - mut position: TextPosition, - affinity: Affinity, - ) -> Result { - let shaped = outline.paragraph_layout(position.paragraph)?; - let projection = &shaped.projection; - let layout = &shaped.text; - let origin = shaped.origin; - let cursor = layout.cursor( - projection - .text() - .byte_offset(projection.visible_offset(position.offset)?)?, - affinity, - ); - position.offset = projection.source_offset( - projection.text().utf16_offset(cursor.index())?, - onestore::page::text::Affinity::Downstream, - )?; - let caret = layout.caret(cursor, 0.0); - let mut positions = self - .runs - .iter() - .filter(|run| { - run.outline == outline.id - && run - .node - .bounds() - .is_some_and(|rect| rect.y0 == f64::from(caret.y0 as f32 + origin[1])) - }) - .filter_map(|run| { - run.positions - .iter() - .position(|p| *p == position) - .map(|character_index| accesskit::TextPosition { - node: run.id, - character_index, - }) - }); - match affinity { - Affinity::Upstream => positions.next(), - Affinity::Downstream => positions.next_back(), - } - .ok_or(EditError::InvalidRange) - } - pub fn selection( &self, - outline: onestore::ExGuid, + outline: ExGuid, selection: TextSelection, ) -> Result { + let field = self + .fields + .iter() + .find(|field| field.outline == outline) + .ok_or(EditError::InvalidRange)?; let position = |position: accesskit::TextPosition| { - let run = self - .runs + let (index, paragraph, run) = field + .paragraphs .iter() - .find(|run| run.outline == outline && run.id == position.node) + .enumerate() + .find_map(|(index, paragraph)| { + let run = paragraph + .runs + .iter() + .position(|run| run.id == position.node)?; + Some((index, paragraph, run)) + }) .ok_or(EditError::InvalidRange)?; - let source = *run - .positions - .get(position.character_index) - .ok_or(EditError::InvalidRange)?; - let affinity = if position.character_index > 0 - && position.character_index + 1 == run.positions.len() - { + let offsets = ¶graph.runs[run].offsets; + let breaks = run + 1 == paragraph.runs.len() && index + 1 < field.paragraphs.len(); + let count = offsets.len() + usize::from(breaks); + let character = position.character_index; + if character >= count { + return Err(EditError::InvalidRange); + } + let source = match offsets.get(character) { + Some(offset) => TextPosition { + paragraph: paragraph.source, + offset: *offset, + }, + None => TextPosition { + paragraph: field.paragraphs[index + 1].source, + offset: 0, + }, + }; + let affinity = if character > 0 && character + 1 == count { Affinity::Upstream } else { Affinity::Downstream @@ -585,13 +406,304 @@ impl Accessibility { } } +/// The accessible position of a source position in `layouts`, which `paragraphs` mirrors. +fn position( + layouts: &[(usize, &ParagraphLayout)], + paragraphs: &[Paragraph], + mut position: TextPosition, + affinity: Affinity, +) -> Result { + let index = layouts + .binary_search_by_key(&position.paragraph, |(source, _)| *source) + .map_err(|_| EditError::InvalidRange)?; + let shaped = layouts[index].1; + let projection = &shaped.projection; + let layout = &shaped.text; + let cursor = layout.cursor( + projection + .text() + .byte_offset(projection.visible_offset(position.offset)?)?, + affinity, + ); + position.offset = projection.source_offset( + projection.text().utf16_offset(cursor.index())?, + onestore::page::text::Affinity::Downstream, + )?; + let caret = layout.caret(cursor, 0.0); + let mut positions = paragraphs[index] + .runs + .iter() + .filter(|run| run.node.bounds().is_some_and(|rect| rect.y0 == caret.y0)) + .filter_map(|run| { + run.offsets + .iter() + .position(|offset| *offset == position.offset) + .map(|character_index| accesskit::TextPosition { + node: run.id, + character_index, + }) + }); + match affinity { + Affinity::Upstream => positions.next(), + Affinity::Downstream => positions.next_back(), + } + .ok_or(EditError::InvalidRange) +} + +/// A paragraph's text runs in paragraph coordinates, each with its character boundaries' +/// source offsets; `breaks` ends the last run with the paragraph break. +fn runs_of(shaped: &ParagraphLayout, breaks: bool) -> Result)>, EditError> { + let mut runs = Vec::new(); + let layout = &shaped.text; + let projection = &shaped.projection; + let text = projection.text().text(); + let boundaries: Vec<_> = projection.source_boundaries().collect(); + let source_offset = |byte| -> Result { + let index = boundaries + .binary_search_by_key(&byte, |(byte, _)| *byte) + .map_err(|_| EditError::InvalidRange)?; + Ok(boundaries[index].1) + }; + for (line, bounds) in layout.lines() { + let line_start = runs.len(); + let mut x = line.metrics().offset; + let mut logical_runs = Vec::new(); + for run in line.runs() { + let advance = run.advance(); + logical_runs.push((run, x)); + x += advance; + } + logical_runs.sort_by_key(|(run, _)| run.text_range().start); + for (run, x) in logical_runs { + // AccessKit stores each character's byte length in a u8. + let mut characters = Vec::new(); + for cluster in run.clusters() { + let range = cluster.text_range(); + if range.is_empty() || range.start == text.len() { + continue; + } + let cluster_text = text.get(range.clone()).ok_or(EditError::InvalidRange)?; + if range.len() <= u8::MAX as usize { + characters.push((range, cluster.advance(), cluster.is_word_boundary())); + } else { + for (index, (offset, ch)) in cluster_text.char_indices().enumerate() { + characters.push(( + range.start + offset..range.start + offset + ch.len_utf8(), + if index == 0 { cluster.advance() } else { 0.0 }, + index == 0 && cluster.is_word_boundary(), + )); + } + } + } + let mut advance = 0.0; + for chunk in characters.chunks(256) { + let width: f32 = chunk.iter().map(|(_, width, _)| width).sum(); + let left = x + if run.is_rtl() { + run.advance() - advance - width + } else { + advance + }; + let mut node = Node::new(Role::TextRun); + node.set_bounds(Rect::new( + f64::from(left), + f64::from(bounds.top), + f64::from(left + width), + f64::from(bounds.top + bounds.height), + )); + node.set_text_direction(if run.is_rtl() { + TextDirection::RightToLeft + } else { + TextDirection::LeftToRight + }); + node.set_font_size(run.font_size()); + let start = chunk[0].0.start; + let end = chunk.last().unwrap().0.end; + node.set_value(text.get(start..end).ok_or(EditError::InvalidRange)?); + node.set_character_lengths( + chunk + .iter() + .map(|(range, _, _)| u8::try_from(range.len()).unwrap()) + .collect::>(), + ); + let mut offset = 0.0; + node.set_character_positions( + chunk + .iter() + .map(|(_, width, _)| { + let position = offset; + offset += width; + position + }) + .collect::>(), + ); + node.set_character_widths( + chunk.iter().map(|(_, width, _)| *width).collect::>(), + ); + node.set_word_starts( + chunk + .iter() + .enumerate() + .filter_map(|(index, (_, _, word))| word.then_some(index as u8)) + .collect::>(), + ); + let offsets = chunk + .iter() + .map(|(range, _, _)| source_offset(range.start)) + .chain(std::iter::once(source_offset(end))) + .collect::, _>>()?; + runs.push((node, offsets)); + advance += width; + } + } + // Empty lines still need a text position for selection and insertion. + if runs.len() == line_start { + let mut node = Node::new(Role::TextRun); + node.set_value(""); + node.set_character_lengths(Vec::::new()); + node.set_character_positions(Vec::::new()); + node.set_character_widths(Vec::::new()); + node.set_text_direction(TextDirection::LeftToRight); + node.set_bounds(Rect::new( + 0.0, + f64::from(bounds.top), + 0.0, + f64::from(bounds.top + bounds.height), + )); + runs.push((node, vec![source_offset(bounds.source.start)?])); + } + } + if breaks { + let (node, _) = runs.last_mut().unwrap(); + let mut value = node.value().unwrap().to_owned(); + value.push('\n'); + node.set_value(value); + let mut lengths = node.character_lengths().to_vec(); + lengths.push(1); + node.set_character_lengths(lengths); + if let Some(rect) = node.bounds() { + let mut positions = node.character_positions().unwrap_or_default().to_vec(); + positions.push(rect.width() as f32); + node.set_character_positions(positions); + let mut widths = node.character_widths().unwrap_or_default().to_vec(); + widths.push(4.0); + node.set_character_widths(widths); + } + } + if !shaped.tags.is_empty() { + let descriptions = shaped + .tags + .iter() + .map(|tag| { + use crate::outline::TagIcon; + let fallback = match tag.icon { + TagIcon::CheckBox { .. } => "To do", + TagIcon::Question => "Question", + TagIcon::Music => "Music", + TagIcon::Exclamation => "Critical", + TagIcon::RedSquare => "Project A", + TagIcon::YellowSquare => "Project B", + TagIcon::BlueSquare => "Project C", + }; + let label = if tag.label.is_empty() { + fallback + } else { + &tag.label + }; + let state = match tag.icon { + TagIcon::CheckBox { checked: true } => ", completed", + TagIcon::CheckBox { checked: false } => ", incomplete", + _ => "", + }; + format!( + "{label}{state}{}", + if tag.disabled { ", disabled" } else { "" } + ) + }) + .collect::>() + .join("; "); + runs[0].0.set_description(descriptions); + } + Ok(runs) +} + #[cfg(test)] -mod tests { +pub(super) mod tests { use super::*; - use crate::{document::TextDocument, layout::TextEngine}; + use crate::{document::TextDocument, editor::TextOutline, layout::TextEngine}; + use accesskit_consumer::{NodeRef, Tree, TreeState}; use onestore::document::Format; use onestore::page::text::Paragraph; + /// Applies `update` to the platform's copy of the tree, as an adapter does. + pub(in crate::interaction) fn apply(tree: &mut Option, update: TreeUpdate) -> &TreeState { + struct Ignore; + impl accesskit_consumer::TreeChangeHandler for Ignore { + fn node_added(&mut self, _: &NodeRef) {} + fn node_updated(&mut self, _: &NodeRef, _: &NodeRef) {} + fn focus_moved(&mut self, _: Option<&NodeRef>, _: Option<&NodeRef>) {} + fn node_removed(&mut self, _: &NodeRef) {} + } + match tree { + Some(tree) => tree.update_and_process_changes(update, &mut Ignore), + None => *tree = Some(Tree::new(update, true)), + } + tree.as_ref().unwrap().state() + } + + /// Every node of `state`, depth first. + pub(in crate::interaction) fn nodes(state: &TreeState) -> Vec> { + let mut nodes = Vec::new(); + let mut pending = vec![state.root()]; + while let Some(node) = pending.pop() { + pending.extend(node.children().rev()); + nodes.push(node); + } + nodes + } + + /// `state`'s nodes and focus with IDs renumbered depth first, to compare trees whose IDs differ. + fn canonical(state: &TreeState) -> (Vec, NodeId) { + let nodes = nodes(state); + let order: HashMap<_, _> = nodes + .iter() + .enumerate() + .map(|(index, node)| (node.locate().0, NodeId(index as u64))) + .collect(); + let id = |id: NodeId| order[&id]; + let canonical = nodes + .iter() + .map(|node| { + let mut data = node.data().clone(); + data.set_children( + data.children() + .iter() + .map(|child| id(*child)) + .collect::>(), + ); + if let Some(next) = data.next_on_line() { + data.set_next_on_line(id(next)); + } + if let Some(previous) = data.previous_on_line() { + data.set_previous_on_line(id(previous)); + } + if let Some(&TextSelection { anchor, focus }) = data.text_selection() { + data.set_text_selection(TextSelection { + anchor: accesskit::TextPosition { + node: id(anchor.node), + ..anchor + }, + focus: accesskit::TextPosition { + node: id(focus.node), + ..focus + }, + }); + } + data + }) + .collect(); + (canonical, id(state.focus_in_tree().locate().0)) + } + #[test] fn title_flow_updates_read_only_hit_and_accessibility_bounds() { use onestore::page::{Page, PageObject, Title, Unsupported}; @@ -649,6 +761,7 @@ mod tests { scale: 2.0, }; let mut access = Accessibility::default(); + let mut tree = None; let mut identity = None; for phase in 0..4 { match phase { @@ -664,17 +777,18 @@ mod tests { } _ => {} } - let mut update = access.update(&editor, viewport, "Test", None).unwrap(); - access.append_page_fields(&mut update, Some(&scene), &editor, viewport, Some(0)); - let (id, node) = update - .nodes - .iter() - .find(|(_, node)| node.role() == Role::Label) + let update = access + .update(&editor, Some(&scene), viewport, "Test", None, Some(0)) .unwrap(); + let node = nodes(apply(&mut tree, update)) + .into_iter() + .find(|node| node.role() == Role::Label) + .unwrap(); + let id = node.locate().0; if let Some(identity) = identity { - assert_eq!(*id, identity); + assert_eq!(id, identity); } - identity = Some(*id); + identity = Some(id); let y = if phase % 2 == 0 { 80.0 } else { 145.92 }; let expected = Rect::new( 310.0, @@ -682,7 +796,7 @@ mod tests { 710.0, f64::from((y + 100.0) * 2.0 - 20.0), ); - let bounds = node.bounds().unwrap(); + let bounds = node.bounding_box().unwrap(); assert_eq!(bounds.x0, expected.x0); assert_eq!(bounds.x1, expected.x1); assert!((bounds.y0 - expected.y0).abs() < 0.001); @@ -726,6 +840,7 @@ mod tests { let (scene, mut editor) = PageScene::from_page(page, &mut engine).unwrap(); let scene = (scene, [30.0, 40.0]); let mut access = Accessibility::default(); + let mut tree = None; let mut identity = None; for scale in [1.0, 2.0, 0.5] { editor.insert(&mut engine, "annotation ").unwrap(); @@ -734,29 +849,31 @@ mod tests { origin: [-200.0, 10.0], scale, }; - let mut update = access.update(&editor, viewport, "Test", None).unwrap(); - access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None); - let (id, node) = update - .nodes - .iter() - .find(|(_, node)| node.role() == Role::Label) + let update = access + .update(&editor, Some(&scene), viewport, "Test", None, None) .unwrap(); + let state = apply(&mut tree, update); + let node = nodes(state) + .into_iter() + .find(|node| node.role() == Role::Label) + .unwrap(); + let id = node.locate().0; if let Some(identity) = identity { - assert_eq!(*id, identity); + assert_eq!(id, identity); } - identity = Some(*id); - assert_eq!(node.value(), Some("Unsupported content\nRead-only")); + identity = Some(id); + assert_eq!(node.data().value(), Some("Unsupported content\nRead-only")); assert!(node.is_read_only()); - assert!(node.supports_action(Action::Focus)); + assert!(node.data().supports_action(Action::Focus)); for action in [ Action::SetValue, Action::ReplaceSelectedText, Action::SetTextSelection, ] { - assert!(!node.supports_action(action)); + assert!(!node.data().supports_action(action)); } assert_eq!( - node.bounds(), + node.bounding_box(), Some(Rect::new( f64::from(40.0 * scale - 200.0), f64::from(60.0 * scale + 10.0), @@ -764,19 +881,22 @@ mod tests { f64::from(120.0 * scale + 10.0) )) ); - assert_eq!(access.outline_for_node(*id), None); - let tree = accesskit_consumer::Tree::new(update, true); + assert_eq!(access.outline_for_node(id), None); assert_eq!( - tree.state().focus().unwrap().document_range().text(), + state.focus().unwrap().document_range().text(), "annotation " ); - let mut focused = access.update(&editor, viewport, "Test", None).unwrap(); - access.append_page_fields(&mut focused, Some(&scene), &editor, viewport, Some(0)); + let focused = access + .update(&editor, Some(&scene), viewport, "Test", None, Some(0)) + .unwrap(); assert_eq!(focused.focus, identity.unwrap()); assert_eq!(access.read_only_for_node(focused.focus), Some(0)); - let tree = accesskit_consumer::Tree::new(focused, true); assert_eq!( - tree.state().focus().unwrap().value().as_deref(), + apply(&mut tree, focused) + .focus() + .unwrap() + .value() + .as_deref(), Some("Unsupported content\nRead-only") ); editor.undo(&mut engine).unwrap(); @@ -800,7 +920,7 @@ mod tests { .collect(), ) .unwrap(); - let mut nodes = text.nodes().to_vec(); + let mut paragraphs = text.nodes().to_vec(); let mut definitions = BTreeMap::new(); for (index, (shape, label, status)) in [ (3, Some("Rehearsal"), 0), @@ -828,7 +948,7 @@ mod tests { format: Format::default(), }, ); - nodes[index].text_mut().unwrap().tags.push(Tag { + paragraphs[index].text_mut().unwrap().tags.push(Tag { definition: Some(id), action_type: None, status, @@ -855,13 +975,14 @@ mod tests { ..Layout::default() }, indents: vec![18.0, 0.0], - paragraphs: nodes, + paragraphs, unsupported: Vec::new(), }], definitions, ) .unwrap(); let mut access = Accessibility::default(); + let mut tree = None; let viewport = Viewport { size: [800, 600], origin: [48.0; 2], @@ -874,12 +995,14 @@ mod tests { if step == 3 { editor.undo(&mut engine).unwrap(); } - let update = access.update(&editor, viewport, "Test", None).unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + let state = apply(&mut tree, update); assert_eq!( - update - .nodes + nodes(state) .iter() - .filter_map(|(_, n)| n.description()) + .filter_map(|node| node.data().description()) .collect::>(), [ "Rehearsal, incomplete", @@ -888,8 +1011,7 @@ mod tests { "Music, disabled" ] ); - let tree = accesskit_consumer::Tree::new(update, true); - let text = tree.state().focus().unwrap().document_range().text(); + let text = state.focus().unwrap().document_range().text(); assert_eq!( text, if step == 2 { @@ -929,24 +1051,27 @@ mod tests { CanvasEditor::new(&mut engine, TextDocument::from_nodes(nodes).unwrap(), 180.0) .unwrap(); let mut access = Accessibility::default(); + let mut tree = None; let viewport = Viewport { size: [900, 700], scale: 2.0, origin: [13.0, 19.0], }; - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); - let field = tree.state().focus().unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + let field = apply(&mut tree, update).focus().unwrap(); assert_eq!( field.document_range().text(), "root\nfolded\nlast\nfolded tail" ); - assert!( - access - .runs + assert_eq!( + access.fields[0] + .paragraphs .iter() - .flat_map(|run| &run.positions) - .all(|position| ![2, 5].contains(&position.paragraph)) + .map(|paragraph| paragraph.source) + .collect::>(), + [0, 1, 3, 4] ); let source = access .selection( @@ -969,10 +1094,11 @@ mod tests { } ); editor.select(source).unwrap(); - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); assert_eq!( - tree.state() + apply(&mut tree, update) .focus() .unwrap() .text_selection() @@ -989,23 +1115,20 @@ mod tests { .into(), ) .unwrap(); - access.update(&editor, viewport, "Test", None).unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); let caret = editor.caret(1.0).unwrap(); assert_eq!(caret.x0, 27.0); - let position = access - .position( - editor.active_outline(), - editor.selection().positions[1], - Affinity::Downstream, - ) - .unwrap(); - let run = access - .runs - .iter() - .find(|run| run.id == position.node) - .unwrap(); - assert_eq!(run.node.bounds().unwrap().x0, 27.0); - assert_eq!(run.node.bounds().unwrap().y0, caret.y0); + let rects = apply(&mut tree, update) + .focus() + .unwrap() + .text_selection() + .unwrap() + .bounding_boxes(); + assert_eq!(rects.len(), 1); + assert_eq!(rects[0].x0, 27.0 * 2.0 + 13.0); + assert_eq!(rects[0].y0, caret.y0 * 2.0 + 19.0); } #[test] @@ -1043,12 +1166,15 @@ mod tests { .unwrap(); let mut editor = CanvasEditor::new(&mut engine, document, width).unwrap(); let mut access = Accessibility::default(); + let mut tree = None; let viewport = Viewport { size: [800, 600], origin: [48.0; 2], scale: 2.0, }; - let update = access.update(&editor, viewport, "Test", None).unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); for (_, node) in &update.nodes { if node.role() == Role::TextRun { assert_eq!( @@ -1066,18 +1192,18 @@ mod tests { } } } - let tree = accesskit_consumer::Tree::new(update, true); - let field = tree.state().focus().unwrap(); + let field = apply(&mut tree, update).focus().unwrap(); assert_eq!(field.document_range().text(), text, "width {width}"); let selection = field.document_range().to_text_selection(); let source_selection = access .selection(editor.active_outline().id, selection) .unwrap(); editor.select(source_selection).unwrap(); - let selected = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(selected, true); + let selected = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); assert_eq!( - tree.state() + apply(&mut tree, selected) .focus() .unwrap() .text_selection() @@ -1085,48 +1211,57 @@ mod tests { .text(), text ); - let stable_ids: Vec<_> = access.runs.iter().map(|run| run.id).collect(); - access + let zoomed = access .update( &editor, + None, Viewport { scale: 3.0, ..viewport }, "Test", None, + None, ) .unwrap(); assert_eq!( - stable_ids, - access.runs.iter().map(|run| run.id).collect::>() + zoomed.nodes.iter().map(|(id, _)| *id).collect::>(), + [ROOT, PAGE] ); + apply(&mut tree, zoomed); access.deactivate(); - access.update(&editor, viewport, "Test", None).unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + let state = apply(&mut tree, update); assert!( access .selection(editor.active_outline().id, selection) .is_err() ); - let selection = access - .update(&editor, viewport, "Test", None) - .unwrap() - .nodes[1] - .1 - .text_selection() - .copied() - .unwrap(); + let selection = *state.focus().unwrap().data().text_selection().unwrap(); editor.insert(&mut engine, "replacement").unwrap(); - access.update(&editor, viewport, "Test", None).unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + apply(&mut tree, update); assert!( access .selection(editor.active_outline().id, selection) .is_err() ); editor.undo(&mut engine).unwrap(); - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); - assert_eq!(tree.state().focus().unwrap().document_range().text(), text); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + assert_eq!( + apply(&mut tree, update) + .focus() + .unwrap() + .document_range() + .text(), + text + ); } } } @@ -1166,6 +1301,7 @@ mod tests { .collect(); let mut editor = CanvasEditor::new(&mut engine, document, 48.0).unwrap(); let mut access = Accessibility::default(); + let mut tree = None; let viewport = Viewport { size: [800, 600], origin: [31.0, -57.0], @@ -1181,9 +1317,10 @@ mod tests { }) .unwrap(); let caret = editor.caret(0.0).unwrap(); - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); - let field = tree.state().focus().unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + let field = apply(&mut tree, update).focus().unwrap(); assert_eq!( field.document_range().text(), "abc e\u{301} 🌳 words wrap here\n\nabc שלום def عالم end\nlast\nline\n" @@ -1217,7 +1354,7 @@ mod tests { } #[test] fn keyboard_and_accessibility_preserve_visual_caret_at_wraps() { - use crate::editor::Movement; + use draw::edit::Movement; let mut engine = TextEngine::default(); let document = TextDocument::new(vec![ Paragraph::new( @@ -1230,6 +1367,7 @@ mod tests { .unwrap(); let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); let mut access = Accessibility::default(); + let mut tree = None; let viewport = Viewport { size: [800, 600], origin: [0.0; 2], @@ -1256,11 +1394,10 @@ mod tests { ] { editor.move_selection(&mut engine, movement, false).unwrap(); let caret = editor.caret(0.0).unwrap(); - let tree = accesskit_consumer::Tree::new( - access.update(&editor, viewport, "Test", None).unwrap(), - true, - ); - let field = tree.state().focus().unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + let field = apply(&mut tree, update).focus().unwrap(); let selection = field.text_selection().unwrap(); let rects = selection.bounding_boxes(); assert_eq!(rects.len(), 1); @@ -1303,7 +1440,7 @@ mod tests { .unwrap(); let mut editor = CanvasEditor::new(&mut engine, document, 72.0).unwrap(); let mut cached = Accessibility::default(); - let mut rebuilt = Accessibility::default(); + let mut tree = None; let mut compare = |editor: &CanvasEditor, step: usize| { let viewport = Viewport { size: [800, 600], @@ -1314,28 +1451,44 @@ mod tests { .is_multiple_of(2) .then_some((editor.active_outline().id, [13.5, -7.25])); for repeat in 0..2 { - rebuilt.paragraphs.clear(); - let actual = cached.update(editor, viewport, "Test", preview).unwrap(); - let expected = rebuilt.update(editor, viewport, "Test", preview).unwrap(); - assert_eq!(actual.nodes, expected.nodes, "step {step}, repeat {repeat}"); - assert_eq!(actual.focus, expected.focus); - let tree = accesskit_consumer::Tree::new(actual, true); - let field = tree.state().focus().unwrap(); - let selection = field.document_range().to_text_selection(); - assert_eq!( - cached - .selection(editor.active_outline().id, selection) - .unwrap(), + let update = cached + .update(editor, None, viewport, "Test", preview, None) + .unwrap(); + let actual = apply(&mut tree, update); + let mut rebuilt = Accessibility::default(); + let expected = Tree::new( rebuilt - .selection(editor.active_outline().id, selection) - .unwrap() + .update(editor, None, viewport, "Test", preview, None) + .unwrap(), + true, ); + assert_eq!( + canonical(actual), + canonical(expected.state()), + "step {step}, repeat {repeat}" + ); + let [actual, expected] = + [(&cached, actual), (&rebuilt, expected.state())].map(|(access, state)| { + let field = state.focus().unwrap(); + let selection = field.document_range().to_text_selection(); + access + .selection(editor.active_outline().id, selection) + .unwrap() + }); + assert_eq!(actual, expected); let count = editor .outlines() .iter() .map(|o| o.layouts().count()) .sum::(); - assert_eq!(cached.paragraphs.len(), count); + assert_eq!( + cached + .fields + .iter() + .map(|field| field.paragraphs.len()) + .sum::(), + count + ); } }; compare(&editor, 0); @@ -1405,6 +1558,72 @@ mod tests { compare(&editor, 162); } } + #[test] + fn scrolling_sends_the_page_and_typing_sends_the_edited_paragraph() { + let mut engine = TextEngine::default(); + let document = TextDocument::new( + (0..200) + .map(|index| Paragraph::new(format!("paragraph {index}"), Format::default())) + .collect(), + ) + .unwrap(); + let mut editor = CanvasEditor::new(&mut engine, document, 480.0).unwrap(); + let mut access = Accessibility::default(); + let mut tree = None; + let mut viewport = Viewport { + size: [800, 600], + origin: [48.0; 2], + scale: 2.0, + }; + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + apply(&mut tree, update); + let ids = |update: &TreeUpdate| update.nodes.iter().map(|(id, _)| *id).collect::>(); + for (scroll, zoom) in [(-300.0, 1.0), (150.0, 1.25)] { + viewport.origin[1] += scroll; + viewport.scale *= zoom; + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + assert_eq!(ids(&update), [ROOT, PAGE]); + apply(&mut tree, update); + } + let field = access.fields[0].id; + editor + .select( + [TextPosition { + paragraph: 150, + offset: 3, + }; 2] + .into(), + ) + .unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + assert_eq!(ids(&update), [field, ROOT, PAGE]); + apply(&mut tree, update); + editor.insert(&mut engine, "x").unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + let paragraph = &access.fields[0].paragraphs[150]; + assert_eq!( + ids(&update), + [paragraph.runs[0].id, paragraph.id, field, ROOT, PAGE] + ); + assert_eq!(update.nodes[0].1.value(), Some("parxagraph 150\n")); + let state = apply(&mut tree, update); + let expected = Tree::new( + Accessibility::default() + .update(&editor, None, viewport, "Test", None, None) + .unwrap(), + true, + ); + assert_eq!(canonical(state), canonical(expected.state())); + } + #[test] fn outlines_have_independent_accessibility_identity_and_selection() { let mut engine = TextEngine::default(); @@ -1422,7 +1641,9 @@ mod tests { scale: 2.0, origin: [48.0; 2], }; - let update = access.update(&editor, viewport, "Test", None).unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); let fields: Vec<_> = update .nodes .iter() @@ -1439,35 +1660,45 @@ mod tests { assert_ne!(fields[0].0, fields[1].0); assert_eq!(access.outline_for_node(fields[0].0), Some(first)); assert_eq!(access.outline_for_node(fields[1].0), Some(second)); - let tree = accesskit_consumer::Tree::new(update, true); - let field = tree.state().focus().unwrap(); + let mut tree = None; + let field = apply(&mut tree, update).focus().unwrap(); assert_eq!(field.document_range().text(), "annotation 🌳"); let selection = field.document_range().to_text_selection(); assert!(access.selection(first, selection).is_err()); let source_selection = access.selection(second, selection).unwrap(); editor.select(source_selection).unwrap(); let preview = access - .update(&editor, viewport, "Test", Some((second, [400.0, 50.0]))) + .update( + &editor, + None, + viewport, + "Test", + Some((second, [400.0, 50.0])), + None, + ) .unwrap(); - let preview_tree = accesskit_consumer::Tree::new(preview, true); - let preview_field = preview_tree.state().focus().unwrap(); + let preview_field = apply(&mut tree, preview).focus().unwrap(); let rect = preview_field.document_range().bounding_boxes()[0]; assert_eq!(rect.x0, 848.0); assert_eq!(rect.y0, 148.0); assert_eq!(editor.active_outline().origin(), [300.0, 40.0]); editor.move_outline(second, [400.0, 50.0]).unwrap(); - access.update(&editor, viewport, "Test", None).unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + apply(&mut tree, update); assert_eq!(access.outline_for_node(fields[1].0), Some(second)); assert!(access.selection(second, selection).is_ok()); editor.undo(&mut engine).unwrap(); editor.undo(&mut engine).unwrap(); editor.undo(&mut engine).unwrap(); - let update = access.update(&editor, viewport, "Test", None).unwrap(); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); assert_eq!( - update - .nodes + nodes(apply(&mut tree, update)) .iter() - .filter(|(_, node)| node.role() == Role::MultilineTextInput) + .filter(|node| node.role() == Role::MultilineTextInput) .count(), 1 ); diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index 6919657a660e0c215843d9eb5d9f205d082e9611..dbebc44d498bbda9097f4de1847fa24e7742f4e7 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -11,15 +11,16 @@ pub use scroll::Scroll; #[cfg(test)] mod tests; -use crate::gpu::{Viewport, page::PageScene}; +use crate::gpu::{Paper, Viewport, page::PageScene}; use crate::{ date::DateField, - editor::{ - CanvasEditor, DEFAULT_OUTLINE_WIDTH, Movement, Selection, SelectionUnit, TextOutline, - }, + editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, Selection, TextOutline}, layout::TextEngine, }; -use draw::{Primitive, Stroke}; +use draw::{ + Primitive, Stroke, + edit::{self, Clicks, Command, Key, Modifiers, Movement, NamedKey, SelectionUnit}, +}; use std::{ error::Error, time::{Duration, Instant}, @@ -31,39 +32,6 @@ pub const DATE_LABELS: [&str; 2] = ["Page date", "Page time"]; pub type Result = std::result::Result>; -#[derive(Clone, Debug, PartialEq)] -pub enum Key { - Named(NamedKey), - Character(String), -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum NamedKey { - Escape, - Tab, - Space, - Enter, - Backspace, - Delete, - ArrowLeft, - ArrowRight, - ArrowUp, - ArrowDown, - Home, - End, - /// Shift, Control, Option or Command pressed alone. - Modifier, - Other, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct Modifiers { - pub shift: bool, - pub control: bool, - pub option: bool, - pub command: bool, -} - #[derive(Clone, Copy, Debug, PartialEq)] pub enum Cursor { Default, @@ -89,12 +57,14 @@ pub enum Request { CharacterPalette, } -/// What an event did: `changed` means the page, selection or view changed (the host saves, -/// updates accessibility and redraws); `redraw` alone repaints hover feedback. +/// What an event did: `changed` means the page or selection changed (the host saves, +/// updates accessibility and redraws); `moved` that only the view scrolled or zoomed (the +/// host updates accessibility and redraws); `redraw` alone repaints hover feedback. #[must_use] #[derive(Debug, Default, PartialEq)] pub struct Response { pub changed: bool, + pub moved: bool, pub redraw: bool, pub request: Option, } @@ -104,7 +74,7 @@ impl Response { Self { changed: true, redraw: true, - request: None, + ..Self::default() } } @@ -123,11 +93,12 @@ impl Response { } } -/// The platform's caret and text selection colours, linear RGBA. +/// The platform's caret and text selection colours, linear RGBA, and the page's paper. #[derive(Clone, Copy, Debug)] pub struct TextColors { pub caret: [f32; 4], pub selection: [f32; 4], + pub paper: Paper, } /// How to paint an outline: whether the caret is in its blink-on phase, the view scale, @@ -138,6 +109,9 @@ struct Paint { scale: f32, pixel: f32, colors: TextColors, + /// The document's top and bottom the view shows; paragraphs wholly outside it are + /// not painted. + visible: [f32; 2], } enum Drag { @@ -205,10 +179,9 @@ pub struct PageView { pub viewport: Viewport, /// Device pixels per logical pixel. display_scale: f32, - double_click: Duration, + clicks: Clicks, pointer: [f32; 2], pointer_inside: bool, - last_click: Option<(Instant, [f32; 2], u8)>, drag: Option, object_focus: Option, modifiers: Modifiers, @@ -242,10 +215,9 @@ impl PageView { origin: home(display_scale), }, display_scale, - double_click, + clicks: Clicks::new(double_click), pointer: [0.0; 2], pointer_inside: false, - last_click: None, drag: None, object_focus: None, modifiers: Modifiers::default(), @@ -397,6 +369,15 @@ impl PageView { Ok(Response::changed()) } + fn moved(&mut self) -> Result { + self.scroll().clamp(&mut self.viewport); + Ok(Response { + moved: true, + redraw: true, + ..Response::default() + }) + } + /// The caret's rectangle in device pixels, for placing the platform's input method. pub fn caret_area(&self) -> Result<[f32; 4]> { let mut rect = self.editor.caret(1.0)?; @@ -474,13 +455,26 @@ impl PageView { self.changed() } - fn zoom(&mut self, factor: f32) { - let point = self.viewport.document_point(self.pointer); + /// The page's zoom, where 1 is OneNote's 100%: 96 pixels per inch. + pub fn zoom(&self) -> f32 { + self.viewport.scale / (self.display_scale * 96.0 / 72.0) + } + + /// Zooms to `zoom` about the middle of the view, within 25% to 400%. + pub fn set_zoom(&mut self, zoom: f32) -> Result { + let middle = self.viewport.size.map(|size| size as f32 / 2.0); + self.zoom_about(zoom / self.zoom(), middle); + self.moved() + } + + /// Scales the view by `factor`, keeping the document point under `anchor` in place. + fn zoom_about(&mut self, factor: f32, anchor: [f32; 2]) { + let point = self.viewport.document_point(anchor); let dpr = self.display_scale; self.viewport.scale = (self.viewport.scale * factor).clamp(dpr / 3.0, dpr * 16.0 / 3.0); self.viewport.origin = [ - self.pointer[0] - point[0] * self.viewport.scale, - self.pointer[1] - point[1] * self.viewport.scale, + anchor[0] - point[0] * self.viewport.scale, + anchor[1] - point[1] * self.viewport.scale, ]; } @@ -522,7 +516,16 @@ impl PageView { /// within the page's bounds. pub fn scroll_to(&mut self, axis: usize, offset: f32) -> Result { self.viewport.origin[axis] = -offset; - self.changed() + self.moved() + } + + /// Brings the page's template backgrounds to `paper` and the current zoom before a + /// frame's primitives; `waker` is woken off the main thread when the view should be + /// drawn again because a background raster landed. + pub fn update_backgrounds(&mut self, paper: Paper, waker: &std::task::Waker) { + if let Some((scene, _)) = &mut self.scene { + scene.update_backgrounds(paper, self.viewport.scale, waker); + } } /// Everything to draw this frame. @@ -571,6 +574,8 @@ impl PageView { scale: self.viewport.scale, pixel: self.pixel(), colors, + visible: [0.0, self.viewport.size[1] as f32] + .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale), }, ) } @@ -609,7 +614,7 @@ impl PageView { if size.contains(&0) { return Ok(Response::default()); } - self.changed() + self.moved() } pub fn scale_factor_changed(&mut self, scale: f32) -> Result { @@ -618,7 +623,7 @@ impl PageView { self.viewport.origin[0] *= ratio; self.viewport.origin[1] *= ratio; self.display_scale = scale; - self.changed() + self.moved() } pub fn focus_changed(&mut self, focused: bool) -> Result { @@ -654,7 +659,14 @@ impl PageView { let target = self.editor .selection_at(point[0] - origin[0], point[1] - origin[1], unit)?; - self.editor.select(drag_selection(anchor, target, unit))?; + let pair = |selection: Selection| { + [0, 1].map(|end| (selection.positions[end], selection.affinities[end])) + }; + let ends = edit::drag(pair(anchor), pair(target), unit, |(position, _)| position); + self.editor.select(Selection { + positions: ends.map(|(position, _)| position), + affinities: ends.map(|(_, affinity)| affinity), + })?; self.changed() } Some(Drag::Outline { pending_press, .. } | Drag::Image { pending_press, .. }) => { @@ -688,16 +700,9 @@ impl PageView { /// A primary-button press at the pointer's position, at `now` for click counting. pub fn pointer_pressed(&mut self, now: Instant) -> Result { let point = self.viewport.document_point(self.pointer); - let count = self - .last_click - .filter(|(time, point, _)| { - now.duration_since(*time) <= self.double_click - && (0..2).all(|axis| { - (point[axis] - self.pointer[axis]).abs() <= 4.0 * self.display_scale - }) - }) - .map_or(1, |(_, _, count)| (count % 3) + 1); - self.last_click = Some((now, self.pointer, count)); + let unit = self + .clicks + .press(now, self.pointer, 4.0 * self.display_scale); match self.hit_test(point) { Some(Hit::Date(field)) => { self.editor.finish_composition(); @@ -739,11 +744,6 @@ impl PageView { self.editor.focus_outline(id)?; let previous = self.editor.selection(); self.editor.select_below(&mut self.engine, id, point)?; - let unit = match count { - 2 => SelectionUnit::Word, - 3 => SelectionUnit::Paragraph, - _ => SelectionUnit::Grapheme, - }; let selection = self.editor.selection_at(point[0], point[1], unit)?; let selection = if extend { Selection { @@ -802,12 +802,12 @@ impl PageView { /// `delta` in device pixels; Command zooms about the pointer instead of scrolling. pub fn wheel(&mut self, delta: [f32; 2]) -> Result { if self.modifiers.command { - self.zoom((delta[1] * 0.005).exp()); + self.zoom_about((delta[1] * 0.005).exp(), self.pointer); } else { self.viewport.origin[0] += delta[0]; self.viewport.origin[1] += delta[1]; } - self.changed() + self.moved() } /// Text the platform inserts outside key events, such as the character picker's; @@ -1046,118 +1046,50 @@ impl PageView { } "v" => request = Some(Request::Paste), "+" | "=" => { - self.zoom(1.1); - return self.changed(); + self.zoom_about(1.1, self.pointer); + return self.moved(); } "-" => { - self.zoom(1.0 / 1.1); - return self.changed(); + self.zoom_about(1.0 / 1.1, self.pointer); + return self.moved(); } "0" => { - self.zoom((self.display_scale * 96.0 / 72.0) / self.viewport.scale); - return self.changed(); + self.zoom_about(1.0 / self.zoom(), self.pointer); + return self.moved(); } _ => return Ok(Response::default()), } } else { - let movement = match key { - Key::Named(NamedKey::ArrowLeft) => Some(if command { - Movement::LineStart - } else if option { - Movement::WordLeft - } else { - Movement::Left - }), - Key::Named(NamedKey::ArrowRight) => Some(if command { - Movement::LineEnd - } else if option { - Movement::WordRight - } else { - Movement::Right - }), - Key::Named(NamedKey::ArrowUp) => Some(if command { - Movement::DocumentStart - } else if option { - Movement::ParagraphStart - } else { - Movement::Up - }), - Key::Named(NamedKey::ArrowDown) => Some(if command { - Movement::DocumentEnd - } else if option { - Movement::ParagraphEnd - } else { - Movement::Down - }), - Key::Named(NamedKey::Home) if shift => Some(Movement::DocumentStart), - Key::Named(NamedKey::End) if shift => Some(Movement::DocumentEnd), - Key::Character(key) if control && !option => match key.as_str() { - "a" => Some(Movement::LineStart), - "e" => Some(Movement::LineEnd), - "b" => Some(Movement::Left), - "f" => Some(Movement::Right), - "p" => Some(Movement::Up), - "n" => Some(Movement::Down), - _ => None, - }, - _ => None, - }; - if let Some(movement) = movement { + let chord = Command::from_key(key, self.modifiers); + if let Some(Command::Move(movement)) = chord { self.editor .move_selection(&mut self.engine, movement, shift)?; } else if self.editor.marked_range().is_none() { - match key { - Key::Named(NamedKey::Backspace) if command || option => { - self.editor.delete_to( - &mut self.engine, - if command { - Movement::LineStart - } else { - Movement::WordLeft - }, - )?; + match (chord, key) { + (Some(Command::DeleteTo(movement)), _) => { + self.editor.delete_to(&mut self.engine, movement)?; } - Key::Named(NamedKey::Delete) if command || option => { - self.editor.delete_to( - &mut self.engine, - if command { - Movement::LineEnd - } else { - Movement::WordRight - }, - )?; + (Some(Command::Delete { backward }), _) => { + self.editor.delete(&mut self.engine, backward)?; } - Key::Named(NamedKey::Backspace) => { - self.editor.delete(&mut self.engine, true)?; + (Some(Command::Kill), _) => { + if !self.editor.delete_to(&mut self.engine, Movement::LineEnd)? { + self.editor.delete(&mut self.engine, false)?; + } } - Key::Named(NamedKey::Delete) => { - self.editor.delete(&mut self.engine, false)?; - } - Key::Named(end @ (NamedKey::Home | NamedKey::End)) => { + (_, Key::Named(end @ (NamedKey::Home | NamedKey::End))) => { let limits = self.scroll(); self.viewport.origin[1] = -if *end == NamedKey::Home { limits.min[1] } else { limits.max[1] }; - return self.changed(); + return self.moved(); } - Key::Character(key) if control && !option => match key.as_str() { - "h" => { - self.editor.delete(&mut self.engine, true)?; - } - "d" => { - self.editor.delete(&mut self.engine, false)?; - } - "k" if !self.editor.delete_to(&mut self.engine, Movement::LineEnd)? => { - self.editor.delete(&mut self.engine, false)?; - } - _ => {} - }, - Key::Named(NamedKey::Enter) => { + (_, Key::Named(NamedKey::Enter)) => { self.editor.enter(&mut self.engine, shift)?; } - Key::Named(NamedKey::Tab) => { + (_, Key::Named(NamedKey::Tab)) => { self.editor.tab(&mut self.engine, shift)?; } _ if !command && !control => { @@ -1392,6 +1324,7 @@ fn page_primitives<'a>( outline.shaped(), [origin[0] + offset[0], origin[1] + offset[1]], moving, + paint.colors.paper, primitives, ); } @@ -1407,6 +1340,7 @@ fn page_primitives<'a>( *origin, Some(editor), moving, + paint.colors.paper, &draw_outline, )?; } @@ -1578,22 +1512,6 @@ fn append_image_chrome(rect: [f32; 4], pixel: f32, primitives: &mut Vec Selection { - if unit == SelectionUnit::Grapheme { - return Selection { - positions: [anchor.positions[0], target.positions[1]], - affinities: [anchor.affinities[0], target.affinities[1]], - }; - } - let backwards = target.positions[0] < anchor.positions[0].min(anchor.positions[1]); - let start = usize::from((anchor.positions[0] > anchor.positions[1]) != backwards); - let end = usize::from((target.positions[0] > target.positions[1]) == backwards); - Selection { - positions: [anchor.positions[start], target.positions[end]], - affinities: [anchor.affinities[start], target.affinities[end]], - } -} - fn snap_to_grid(point: [f32; 2], margin: [f32; 2]) -> [f32; 2] { std::array::from_fn(|axis| { let offset = margin[axis]; @@ -1722,11 +1640,15 @@ fn append_outline<'a>( scale, pixel, colors, + visible, } = paint; - outline.shaped().append_table_primitives(primitives, origin); + let rows = [visible[0] - y, visible[1] - y]; outline .shaped() - .append_background_primitives(primitives, origin); + .append_table_primitives(primitives, origin, colors.paper); + outline + .shaped() + .append_background_primitives(primitives, origin, rows); if let Some(editor) = editor { for rect in editor.selection_rects()? { primitives.push(Primitive::Rect { @@ -1740,10 +1662,14 @@ fn append_outline<'a>( }); } } - for (index, (_, paragraph)) in outline.layouts().enumerate() { - outline - .shaped() - .append_paragraph_primitives(index, paragraph, origin, primitives); + for (index, paragraph) in outline.shaped().visible(rows) { + outline.shaped().append_paragraph_primitives( + index, + paragraph, + origin, + colors.paper.ink, + primitives, + ); } if let Some(editor) = editor { for rect in editor.marked_rects()? { @@ -1754,7 +1680,7 @@ fn append_outline<'a>( rect.x1 as f32 + x, rect.y1 as f32 + y, ], - color: [0.0, 0.0, 0.0, 1.0], + color: colors.paper.ink, }); } let [anchor, focus] = editor.selection().positions; diff --git a/crates/canvas/src/interaction/profile.rs b/crates/canvas/src/interaction/profile.rs index ff7c084c0d56ce34d49a99160f44c7142244d56d..4fc5122099783164c0dbe7b5ed1364ec29143ccd 100644 --- a/crates/canvas/src/interaction/profile.rs +++ b/crates/canvas/src/interaction/profile.rs @@ -45,7 +45,7 @@ fn editing_cost( editor: &mut CanvasEditor, scene: Option<&(PageScene, [f32; 2])>, ) { - let viewport = Viewport { + let mut viewport = Viewport { size: [1000, 720], scale: 96.0 / 72.0, origin: [48.0; 2], @@ -59,7 +59,9 @@ fn editing_cost( }); let mut access = accessibility::Accessibility::default(); let update = timed(case, "ax_first", 0, || { - access.update(editor, viewport, "Profile", None).unwrap() + access + .update(editor, scene, viewport, "Profile", None, None) + .unwrap() }); eprintln!("canvas_count\t{case}\tax_nodes\t0\t{}", update.nodes.len()); drop(update); @@ -113,14 +115,30 @@ fn editing_cost( }); eprintln!("canvas_count\t{case}\tprimitives\t{sample}\t{primitives}"); timed(case, "ax_insert", sample, || { - black_box(access.update(editor, viewport, "Profile", None).unwrap()); + black_box( + access + .update(editor, scene, viewport, "Profile", None, None) + .unwrap(), + ); + }); + viewport.origin[1] -= 40.0; + timed(case, "ax_scroll", sample, || { + black_box( + access + .update(editor, scene, viewport, "Profile", None, None) + .unwrap(), + ); }); assert_eq!(edited_layouts, layout_ids(editor)); timed(case, "undo", sample, || { assert!(editor.undo(engine).unwrap()) }); timed(case, "ax_undo", sample, || { - black_box(access.update(editor, viewport, "Profile", None).unwrap()); + black_box( + access + .update(editor, scene, viewport, "Profile", None, None) + .unwrap(), + ); }); assert_eq!(editor.active_outline().document(), &initial[index]); } diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 8d051299a5f486f942be54c62ea919cf5331345c..dd2c07c181f8c357da7616c9063bd6fd89cf3b43 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -9,6 +9,7 @@ use std::sync::Arc; const COLORS: TextColors = TextColors { caret: [0.1, 0.3, 0.9, 1.0], selection: [0.7, 0.8, 1.0, 1.0], + paper: crate::gpu::Paper::WHITE, }; /// Paint for a view at `scale` device pixels per point on a display of `display_scale`. @@ -18,6 +19,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint { scale, pixel: display_scale / scale, colors: COLORS, + visible: [f32::NEG_INFINITY, f32::INFINITY], } } @@ -61,6 +63,7 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() origin: [-30.0, -50.0], }; let mut access = accessibility::Accessibility::default(); + let mut tree = None; let mut identities = Vec::new(); for phase in 0..3 { if phase == 1 { @@ -73,19 +76,21 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() .change_date(&mut engine, 2, ["Wednesday".into(), "8:40 AM".into()]) .unwrap(); } - let mut update = access.update(&editor, viewport, "Header", None).unwrap(); - access.append_page_fields(&mut update, Some(&scene), &editor, viewport, None); - let buttons: Vec<_> = update - .nodes - .iter() - .filter(|(_, node)| node.role() == accesskit::Role::Button) - .collect(); + let update = access + .update(&editor, Some(&scene), viewport, "Header", None, None) + .unwrap(); + let buttons: Vec<_> = + accessibility::tests::nodes(accessibility::tests::apply(&mut tree, update)) + .into_iter() + .filter(|node| node.role() == accesskit::Role::Button) + .collect(); assert_eq!(buttons.len(), 2); - for (index, (id, node)) in buttons.iter().enumerate() { + for (index, node) in buttons.iter().enumerate() { + let id = node.locate().0; let field = [DateField::Date, DateField::Time][index]; - assert_eq!(access.date_for_node(*id), Some(field)); - assert!(node.supports_action(accesskit::Action::Click)); - let rect = node.bounds().unwrap(); + assert_eq!(access.date_for_node(id), Some(field)); + assert!(node.data().supports_action(accesskit::Action::Click)); + let rect = node.bounding_box().unwrap(); let point = viewport.document_point([ ((rect.x0 + rect.x1) * 0.5) as f32, ((rect.y0 + rect.y1) * 0.5) as f32, @@ -95,15 +100,14 @@ fn date_buttons_keep_accessibility_identity_and_match_mouse_hits_after_reflow() Some(Hit::Date(field)) ); if phase == 0 { - identities.push(*id); + identities.push(id); } else { - assert_eq!(*id, identities[index]); + assert_eq!(id, identities[index]); } } if phase == 2 { - assert_eq!(buttons[1].1.value(), Some("8:40 AM")); + assert_eq!(buttons[1].data().value(), Some("8:40 AM")); } - accesskit_consumer::Tree::new(update, true); } } @@ -169,6 +173,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() { let update = access .update( &editor, + None, Viewport { size: [800, 600], scale: 1.0, @@ -176,6 +181,7 @@ fn title_chrome_selects_text_and_exposes_a_named_editable_field() { }, "Header", None, + None, ) .unwrap(); assert!(update.nodes.iter().any(|(_, node)| node.role() @@ -214,6 +220,7 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() { let update = access .update( &editor, + None, Viewport { size: [800, 600], scale: 1.0, @@ -221,6 +228,7 @@ fn provisional_lines_share_one_drawn_hit_tested_and_accessible_outline() { }, "Test", None, + None, ) .unwrap(); let fields = update @@ -264,7 +272,9 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { scale: 96.0 / 72.0, origin: [48.0; 2], }; - let initial = access.update(&editor, viewport, "Page", None).unwrap(); + let initial = access + .update(&editor, None, viewport, "Page", None, None) + .unwrap(); let id = editor.active_outline().id; assert_eq!(access.outline_for_node(initial.focus), Some(id)); let rectangles = |editor: &CanvasEditor| { @@ -291,16 +301,22 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { .iter() .all(|(_, color)| *color == [0.0, 0.0, 0.0, 1.0] || *color == COLORS.caret) ); - let preedit = access.update(&editor, viewport, "Page", None).unwrap(); + let preedit = access + .update(&editor, None, viewport, "Page", None, None) + .unwrap(); assert_eq!(preedit.focus, initial.focus); editor.commit_text(&mut engine, "日本".into()).unwrap(); - let committed = access.update(&editor, viewport, "Page", None).unwrap(); + let committed = access + .update(&editor, None, viewport, "Page", None, None) + .unwrap(); assert_eq!(committed.focus, initial.focus); assert!(rectangles(&editor).len() > 1); editor.undo(&mut engine).unwrap(); assert!(editor.outlines().is_empty()); assert_eq!(rectangles(&editor).len(), 1); - let empty = access.update(&editor, viewport, "Page", None).unwrap(); + let empty = access + .update(&editor, None, viewport, "Page", None, None) + .unwrap(); assert_eq!(empty.focus, committed.focus); assert_eq!(access.outline_for_node(committed.focus), Some(id)); editor.redo(&mut engine).unwrap(); @@ -308,12 +324,16 @@ fn blank_caret_and_composition_have_accessible_text_without_outline_chrome() { editor.delete(&mut engine, true).unwrap(); assert!(editor.outlines().is_empty()); assert_eq!(rectangles(&editor).len(), 1); - let retired = access.update(&editor, viewport, "Page", None).unwrap(); + let retired = access + .update(&editor, None, viewport, "Page", None, None) + .unwrap(); assert_eq!(retired.focus, committed.focus); assert_eq!(access.outline_for_node(retired.focus), Some(id)); editor.undo(&mut engine).unwrap(); assert!(rectangles(&editor).len() > 1); - let restored = access.update(&editor, viewport, "Page", None).unwrap(); + let restored = access + .update(&editor, None, viewport, "Page", None, None) + .unwrap(); assert_eq!(restored.focus, committed.focus); editor.redo(&mut engine).unwrap(); assert_eq!(rectangles(&editor).len(), 1); @@ -375,6 +395,7 @@ fn imported_page_widgets_and_accessibility_follow_edits() { } eprintln!("Verified {tag_hits} imported tag-gutter targets"); let mut access = accessibility::Accessibility::default(); + let mut tree = None; let tags = |editor: &CanvasEditor| { page_primitives( editor, @@ -389,9 +410,9 @@ fn imported_page_widgets_and_accessibility_follow_edits() { Primitive::Icon { sources, origin, - opacity, + tint, .. - } => Some(((sources, opacity), origin)), + } => Some(((sources, tint[3]), origin)), _ => None, }) .collect::>() @@ -410,15 +431,25 @@ fn imported_page_widgets_and_accessibility_follow_edits() { .into(), ) .unwrap(); - let before = access.update(&editor, viewport, "Test", None).unwrap(); - let before = accesskit_consumer::Tree::new(before, true); - let before = before.state().focus().unwrap().document_range().text(); + let before = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); + let before = accessibility::tests::apply(&mut tree, before) + .focus() + .unwrap() + .document_range() + .text(); editor.insert(&mut engine, "CANVAS CHECK ").unwrap(); { - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); assert_eq!( - tree.state().focus().unwrap().document_range().text(), + accessibility::tests::apply(&mut tree, update) + .focus() + .unwrap() + .document_range() + .text(), format!("CANVAS CHECK {before}") ); let primitives = page_primitives( @@ -439,10 +470,15 @@ fn imported_page_widgets_and_accessibility_follow_edits() { editor.undo(&mut engine).unwrap(); assert_eq!(editor.active_outline().document(), &original); assert_eq!(tags(&editor), original_tags); - let update = access.update(&editor, viewport, "Test", None).unwrap(); - let tree = accesskit_consumer::Tree::new(update, true); + let update = access + .update(&editor, None, viewport, "Test", None, None) + .unwrap(); assert_eq!( - tree.state().focus().unwrap().document_range().text(), + accessibility::tests::apply(&mut tree, update) + .focus() + .unwrap() + .document_range() + .text(), before ); } @@ -661,34 +697,6 @@ fn picture_handles_resize_as_onenote_does() { assert_eq!(image_handle_at(rect, pixel, [600.0, 150.0]), None); } -#[test] -fn grouped_drag_keeps_the_initial_word_when_reversing_direction() { - let selection = |start, end| { - Selection::from([start, end].map(|offset| crate::document::TextPosition { - paragraph: 0, - offset, - })) - }; - let anchor = selection(6, 10); - assert_eq!( - drag_selection(anchor, selection(11, 16), SelectionUnit::Word).positions, - selection(6, 16).positions - ); - assert_eq!( - drag_selection(anchor, selection(0, 5), SelectionUnit::Word).positions, - selection(10, 0).positions - ); - assert_eq!( - drag_selection(anchor, anchor, SelectionUnit::Word).positions, - anchor.positions - ); - let anchor = selection(10, 3); - assert_eq!( - drag_selection(anchor, selection(7, 7), SelectionUnit::Grapheme).positions, - selection(10, 7).positions - ); -} - const DEFAULT_MARGIN: [f32; 2] = [36.0, 14.4]; #[test] @@ -819,6 +827,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { scale: 1.0, pixel: 1.0, colors: COLORS, + visible: [f32::NEG_INFINITY, f32::INFINITY], }, &mut primitives, ) @@ -889,6 +898,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { scale: 1.0, pixel: 1.0, colors: COLORS, + visible: [f32::NEG_INFINITY, f32::INFINITY], }, &mut primitives, ) diff --git a/crates/canvas/src/layout.rs b/crates/canvas/src/layout.rs index c9b8615806ea6ae55474b13a14ae471da821a284..0c17ea74574dc21c5d79b751ce5a4f6697b019b2 100644 --- a/crates/canvas/src/layout.rs +++ b/crates/canvas/src/layout.rs @@ -30,7 +30,8 @@ const SUBSCRIPT_DROP: f32 = 0.08; #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct TextBrush { - pub color: u32, + /// COLORREF, or `None` for OneNote's automatic colour, which follows the paper. + pub color: Option, pub highlight: Option, /// Points the run draws above the line's baseline: positive for a superscript, /// negative for a subscript. Line metrics and hit-testing keep the baseline. @@ -191,9 +192,9 @@ impl TextEngine { StyleProperty::Brush(TextBrush { // Links take OneNote's blue unless given a colour of their own. color: match format.color { - Some(color) if color != 0xff000000 => color, - _ if link => 0x00ff0000, - _ => 0, + Some(color) if color != 0xff000000 => Some(color), + _ if link => Some(0x00ff0000), + _ => None, }, highlight: format.highlight.filter(|color| *color != 0xff000000), rise, @@ -398,59 +399,6 @@ impl TextLayout { Cursor::from_byte_index(&self.shaped, byte, affinity) } - pub(crate) fn word_cursor(&self, cursor: Cursor, backward: bool) -> Cursor { - let mut current = cursor; - let mut visited = BTreeSet::new(); - loop { - if !visited.insert((current.index(), current.affinity() == Affinity::Upstream)) { - // Parley visual cursors can cycle at soft-wrapped bidi boundaries. - let [left, right] = cursor.visual_clusters(&self.shaped); - let rtl = if backward { - left.or(right) - } else { - right.or(left) - } - .is_some_and(|cluster| cluster.is_rtl()); - return if backward != rtl { - cursor.previous_logical_word(&self.shaped) - } else { - cursor.next_logical_word(&self.shaped) - }; - } - let next = if backward { - current.previous_visual(&self.shaped) - } else { - current.next_visual(&self.shaped) - }; - if next == current { - return current; - } - current = next; - let [Some(left), Some(right)] = current.visual_clusters(&self.shaped) else { - return current; - }; - let boundary = if left.is_rtl() { - left.is_word_boundary() - && if backward { - left.is_space_or_nbsp() - || (right.is_word_boundary() && !right.is_space_or_nbsp()) - } else { - !left.is_space_or_nbsp() - } - } else { - right.is_word_boundary() - && if backward { - !right.is_space_or_nbsp() - } else { - !left.is_space_or_nbsp() - } - }; - if boundary { - return current; - } - } - } - pub fn hit_test(&self, x: f32, y: f32) -> Cursor { let index = self .lines @@ -664,7 +612,14 @@ mod tests { _ => None, }) .collect(); - assert_eq!(runs, [(0, false), (0x00ff_0000, true), (0x0000_00ff, true)]); + assert_eq!( + runs, + [ + (None, false), + (Some(0x00ff_0000), true), + (Some(0x0000_00ff), true) + ] + ); } #[test] diff --git a/crates/canvas/src/math.rs b/crates/canvas/src/math.rs index 829d41ade045ce08c9f3cc085f5090e4a49e4ceb..5fc062808aa5b7319cdf74c520c6c2a23198691d 100644 --- a/crates/canvas/src/math.rs +++ b/crates/canvas/src/math.rs @@ -10,8 +10,8 @@ use onestore::page::text::Paragraph; #[derive(Clone)] pub struct MathLayout { pub size: [f32; 2], - /// COLORREF for rules and strokes. - pub color: u32, + /// COLORREF for rules and strokes, or `None` for the paper's ink. + pub color: Option, /// Distance from the top to the baseline of the equation's main row. pub baseline: f32, pub items: Vec, @@ -616,7 +616,7 @@ pub fn layout(engine: &mut TextEngine, paragraph: &Paragraph) -> Result, pub tables: Vec, /// Pictures, files and handwriting that occupy a paragraph of their own. pub objects: Vec, pub size: [f32; 2], + /// One per root node, so an edit places anew only what it changes and moves what follows. + blocks: Vec, + /// The widest root node, at least 36 points, and the widest root text's line boxes; kept so + /// an edit sizes an automatic width without measuring every node. + pub(crate) widths: [f32; 2], +} + +/// A root node's place in its outline's flow. +#[derive(Clone)] +struct Block { + /// Where the node's paragraphs, tables and objects start. + first: [usize; 3], + /// Spacing before and after the node and its own height, or `None` while hidden. + metrics: Option<[f32; 3]>, + /// The node's right edge and, for text, its line boxes' right edge. + extent: [f32; 2], + /// The flow's bottom and pending spacing after the node. + state: (f64, Option), + /// A table's vertical coordinates before its offset. + rel: Vec, +} + +/// A layout of root nodes `range` once an edit applies, and how the nodes after them move. +pub(crate) struct Relayout { + pub range: Range, + pub segment: OutlineLayout, + /// Each following node's new top and flow state, up to the first that stays. + moved: Vec<(f32, (f64, Option))>, + pub size: [f32; 2], + widths: [f32; 2], } #[derive(Clone)] @@ -50,6 +83,61 @@ impl ObjectLayout { ObjectKind::Picture | ObjectKind::Ink(_) => None, } } + + /// The rect's top and bottom, the object's bottom and its label's top once the object + /// starts at `y`, given its own height, and the height it takes in the flow. + fn at(&self, y: f32, height: f32) -> ([f32; 4], f32) { + match &self.kind { + ObjectKind::File(label) => { + let top = y + 6.0; + let label_top = top + height + 10.5; + let bottom = label_top + label.text.height() + 9.0; + ([top, top + height, bottom, label_top], bottom - y) + } + ObjectKind::Unsupported(_) => ([y, y + height, y + height, y + 8.0], height), + ObjectKind::Picture | ObjectKind::Ink(_) => ([y, y + height, y + height, 0.0], height), + } + } + + fn place(&mut self, y: f32, height: f32) -> f32 { + let ([top, end, bottom, label_top], flow) = self.at(y, height); + [self.rect[1], self.rect[3], self.bottom] = [top, end, bottom]; + if let ObjectKind::File(label) | ObjectKind::Unsupported(label) = &mut self.kind { + label.origin[1] = label_top; + } + flow + } +} + +/// The vertical coordinates of these pieces, in a fixed order. +fn verticals<'a>( + paragraphs: &'a mut [ParagraphLayout], + tables: &'a mut [TableLayout], + objects: &'a mut [ObjectLayout], +) -> impl Iterator { + let cells = tables.iter_mut().flat_map(|table| &mut table.cells); + paragraphs + .iter_mut() + .map(|paragraph| &mut paragraph.origin[1]) + .chain(cells.flat_map(|cell| { + let [_, top, _, bottom] = &mut cell.rect; + [top, bottom] + })) + .chain(objects.iter_mut().flat_map(|object| { + let ObjectLayout { + rect: [_, top, _, end], + bottom, + kind, + .. + } = object; + let label = match kind { + ObjectKind::File(label) | ObjectKind::Unsupported(label) => { + Some(&mut label.origin[1]) + } + ObjectKind::Picture | ObjectKind::Ink(_) => None, + }; + [top, end, bottom].into_iter().chain(label) + })) } /// OneNote centers a file's icon and name in a column this wide. @@ -181,21 +269,21 @@ pub(crate) fn indentation(level: u32, indents: &[f32], width: f32) -> Result, - format: &Format, -) -> Result { - let before = format.space_before.unwrap_or(0.0); - let after = format.space_after.unwrap_or(0.0); - if [before, after].iter().any(|v| !v.is_finite() || *v < 0.0) { +fn spacing(format: &Format) -> Result<[f32; 2], LayoutError> { + let spacing = [format.space_before, format.space_after].map(|space| space.unwrap_or(0.0)); + if spacing.iter().any(|v| !v.is_finite() || *v < 0.0) { return Err(LayoutError::InvalidSpacing); } - if let Some(previous) = previous { - *bottom += f64::from(previous.max(before)); + Ok(spacing) +} + +/// Where a node with `spacing` starts after the flow `state` of a bottom and pending spacing. +fn top(state: &mut (f64, Option), [before, after]: [f32; 2]) -> f32 { + if let Some(previous) = state.1 { + state.0 += f64::from(previous.max(before)); } - *previous = Some(after); - Ok(*bottom as f32) + state.1 = Some(after); + state.0 as f32 } impl ParagraphLayout { @@ -386,42 +474,6 @@ pub(crate) fn visible_paragraphs<'a>( }) } -pub(crate) fn arrange<'a>( - paragraphs: impl Iterator, - width: f32, - width_set_by_user: bool, -) -> Result<(Vec, [f32; 2]), LayoutError> { - let mut origins = Vec::new(); - let mut bottom = 0.0_f64; - let mut previous_after = None; - let mut content_width = 0.0_f32; - for paragraph in paragraphs { - let format = ¶graph.projection.text().spans()[0].format; - origins.push(spacing(&mut bottom, &mut previous_after, format)?); - let size = paragraph.size(); - bottom += f64::from(size[1]); - if !(bottom as f32).is_finite() { - return Err(LayoutError::InvalidSpacing); - } - content_width = content_width.max(size[0]); - } - let height = bottom as f32; - if !height.is_finite() || !content_width.is_finite() { - return Err(LayoutError::InvalidSpacing); - } - Ok(( - origins, - [ - if width_set_by_user { - width - } else { - content_width.max(36.0) - }, - height, - ], - )) -} - impl OutlineLayout { /// OneNote gives an outline one tag column, as wide as its most-tagged paragraph needs; each /// paragraph's tags start at the column's left edge. Tag origins assume a one-tag column. @@ -491,6 +543,33 @@ impl OutlineLayout { depth: usize, edit: Option<&DocumentEdit>, shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result, + ) -> Result { + let mut result = Self::stack( + nodes, + indents, + width, + depth, + edit, + BTreeSet::new(), + (0.0, None), + shape, + )?; + result.size[0] = if fixed_width { width } else { result.size[0] }.max(result.table_width()); + Ok(result) + } + + /// Lays out `nodes` after the flow `state`, hiding the children of `hiding`; at the root, + /// keeps a block for each node. + #[allow(clippy::too_many_arguments)] + fn stack<'a>( + nodes: impl Iterator, + indents: &[f32], + width: f32, + depth: usize, + edit: Option<&DocumentEdit>, + mut hiding: BTreeSet, + mut state: (f64, Option), + shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result, ) -> Result { if !width.is_finite() || width <= 0.0 { return Err(LayoutError::InvalidWidth); @@ -499,145 +578,424 @@ impl OutlineLayout { return Err(LayoutError::UnsupportedContent); } let mut result = Self { - paragraphs: Vec::new(), - tables: Vec::new(), - objects: Vec::new(), size: [36.0, 0.0], + widths: [36.0, f32::NEG_INFINITY], + ..Self::default() }; - let mut bottom = 0.0; - let mut previous = None; - for node in visible_paragraphs(nodes) { - match &node.content { - ParagraphContent::Text(_) => { - let mut paragraph = shape(node, width, indents)?; - let y = spacing( - &mut bottom, - &mut previous, - ¶graph.projection.text().spans()[0].format, - )?; - let size = paragraph.size(); - paragraph.origin[1] = y; - result.paragraphs.push(paragraph); - result.size[0] = result.size[0].max(size[0]); - bottom += f64::from(size[1]); + for node in nodes { + let hidden = node.parent.is_some_and(|parent| hiding.contains(&parent)); + if hidden || node.collapsed { + hiding.insert(node.id); + } + let mut block = Block { + first: [ + result.paragraphs.len(), + result.tables.len(), + result.objects.len(), + ], + metrics: None, + extent: [f32::NEG_INFINITY; 2], + state, + rel: Vec::new(), + }; + if !hidden { + let (space, height, flow, extent) = match &node.content { + ParagraphContent::Text(_) => { + let mut paragraph = shape(node, width, indents)?; + let space = spacing(¶graph.projection.text().spans()[0].format)?; + paragraph.origin[1] = top(&mut state, space); + let size = paragraph.size(); + block.extent[1] = paragraph.origin[0] + paragraph.text.shaped.width(); + result.paragraphs.push(paragraph); + (space, size[1], size[1], size[0]) + } + ParagraphContent::Table(table) => { + let x = indentation(node.level, indents, width)?; + let space = spacing(&node.format)?; + let y = top(&mut state, space); + let mut child = Self::table(table, depth + 1, edit, shape)?; + if depth == 0 { + block.rel = verticals( + &mut child.paragraphs, + &mut child.tables, + &mut child.objects, + ) + .map(|value| *value) + .collect(); + } + let size = child.size; + result.append(child, [x, y]); + (space, size[1], size[1], x + size[0]) + } + content => { + let x = indentation(node.level, indents, width)?; + let space = spacing(&node.format)?; + let y = top(&mut state, space); + let (mut object, height, extent) = match content { + ParagraphContent::Image(image) => { + let [w, h] = + image_size(image).ok_or(LayoutError::UnsupportedContent)?; + let object = ObjectLayout { + id: image.id, + rect: [x, 0.0, x + w, 0.0], + kind: ObjectKind::Picture, + bottom: 0.0, + }; + (object, h, x + w) + } + ParagraphContent::Attachment(file) => { + let [w, h] = file.size.unwrap_or([24.0, 24.0]); + let left = x + (ATTACHMENT_WIDTH - w) / 2.0; + // OneNote shows the name without its extension. + let name = std::path::Path::new(&file.filename) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or(&file.filename); + let format = Format { + alignment: Some(1), + ..node.format.clone() + }; + let mut label = shape( + &caption(node.id, file.id, name, format), + ATTACHMENT_WIDTH, + &[0.0], + )?; + label.reset_origin(x); + let object = ObjectLayout { + id: file.id, + rect: [left, 0.0, left + w, 0.0], + kind: ObjectKind::File(label), + bottom: 0.0, + }; + (object, h, x + ATTACHMENT_WIDTH) + } + ParagraphContent::Ink(ink) => { + // The paragraph reaches from its origin to the farthest stroke point. + let [w, h] = ink + .bounds() + .map(|[x, y, w, h]| [x + w, y + h]) + .filter(|size| size.iter().all(|v| v.is_finite() && *v >= 0.0)) + .ok_or(LayoutError::UnsupportedContent)?; + let object = ObjectLayout { + id: ink.id, + rect: [x, 0.0, x + w, 0.0], + kind: ObjectKind::Ink(ink.clone()), + bottom: 0.0, + }; + (object, h, x + w) + } + ParagraphContent::Unsupported(unsupported) => { + let w = unsupported.layout.max_width.unwrap_or(160.0).max(160.0); + let mut label = shape( + &caption( + node.id, + unsupported.id, + "Unsupported content", + Format::default(), + ), + w - 16.0, + &[0.0], + )?; + label.reset_origin(x + 8.0); + let h = unsupported + .layout + .max_height + .unwrap_or(0.0) + .max(label.text.height() + 16.0); + let object = ObjectLayout { + id: unsupported.id, + rect: [x, 0.0, x + w, 0.0], + kind: ObjectKind::Unsupported(label), + bottom: 0.0, + }; + (object, h, x + w) + } + ParagraphContent::Text(_) | ParagraphContent::Table(_) => { + unreachable!("text and tables are not objects") + } + }; + let flow = object.place(y, height); + result.objects.push(object); + (space, height, flow, extent) + } + }; + state.0 += f64::from(flow); + result.size[0] = result.size[0].max(extent); + result.widths[1] = result.widths[1].max(block.extent[1]); + block.extent[0] = extent; + block.metrics = Some([space[0], space[1], height]); + if !(state.0 as f32).is_finite() || !result.size[0].is_finite() { + return Err(LayoutError::InvalidSpacing); } - ParagraphContent::Table(table) => { - let x = indentation(node.level, indents, width)?; - let y = spacing(&mut bottom, &mut previous, &node.format)?; - let child = Self::table(table, depth + 1, edit, shape)?; - result.size[0] = result.size[0].max(x + child.size[0]); - bottom += f64::from(child.size[1]); - result.append(child, [x, y]); + } + block.state = state; + if depth == 0 { + result.blocks.push(block); + } + } + result.size[1] = state.0 as f32; + result.widths[0] = result.size[0]; + Ok(result) + } + + /// The paragraphs, tables and objects of root nodes `range`. + pub(crate) fn pieces(&self, range: Range) -> [Range; 3] { + let first = |root: usize| { + self.blocks.get(root).map_or( + [self.paragraphs.len(), self.tables.len(), self.objects.len()], + |block| block.first, + ) + }; + let [start, end] = [first(range.start), first(range.end)]; + [0, 1, 2].map(|kind| start[kind]..end[kind]) + } + + /// Lays out root nodes `range` of `nodes` once `edit` applies, the edit's own or the table + /// holding its cell, and works out where the nodes after them move. Resizing columns or + /// showing or hiding other nodes lays out every node. + #[allow(clippy::too_many_arguments)] + pub(crate) fn relayout( + &self, + nodes: &[PageParagraph], + edit: &DocumentEdit, + range: Range, + indents: &[f32], + width: f32, + fixed_width: bool, + shape: &mut impl FnMut(&PageParagraph, f32, &[f32]) -> Result, + ) -> Result { + let mut range = if edit.columns.is_empty() { + range + } else { + 0..nodes.len() + }; + loop { + let old = self.pieces(range.clone()); + let sources = leaves(&nodes[range.clone()], None) + .map(|(_, _, node)| (node.id, node)) + .collect::>(); + let cached = self.paragraphs[old[0].clone()] + .iter() + .filter_map(|layout| Some((layout.id, (*sources.get(&layout.id)?, layout)))) + .collect::>(); + let count = match edit.container { + None => range.len() - edit.range.len() + edit.replacement.len(), + Some(_) => range.len(), + }; + let replacement = edited_nodes(nodes, None, Some(edit)) + .skip(range.start) + .take(count); + let supplied = replacement + .clone() + .map(|node| node.id) + .collect::>(); + // Children of a hidden or collapsed earlier node stay hidden. + let hiding = replacement + .clone() + .filter_map(|node| { + let parent = node.parent.filter(|parent| !supplied.contains(parent))?; + let root = nodes[..range.start] + .iter() + .rposition(|node| node.id == parent)?; + (self.blocks[root].metrics.is_none() || nodes[root].collapsed).then_some(parent) + }) + .collect(); + let start = range + .start + .checked_sub(1) + .map_or((0.0, None), |root| self.blocks[root].state); + let segment = Self::stack( + replacement.clone(), + indents, + width, + 0, + Some(edit), + hiding, + start, + &mut |node, width, indents| { + let indent = indentation(node.level, indents, width)?; + if let Some((source, cached)) = cached.get(&node.id) + && *source == node + && cached.text.shaped.layout_max_advance() == width - indent + && cached + .markers + .iter() + .all(|(marker, _)| marker.shaped.layout_max_advance() == width) + { + let mut result = (*cached).clone(); + result.reset_origin(indent); + return Ok(result); + } + shape(node, width, indents) + }, + )?; + let hides = + |block: &Block, node: &PageParagraph| block.metrics.is_none() || node.collapsed; + if range.end < nodes.len() { + let now = replacement + .zip(&segment.blocks) + .map(|(node, block)| (node.id, hides(block, node))) + .collect::>(); + if nodes[range.clone()] + .iter() + .zip(&self.blocks[range.clone()]) + .any(|(node, block)| { + now.get(&node.id) + .is_some_and(|now| *now != hides(block, node)) + }) + { + range = 0..nodes.len(); + continue; } - ParagraphContent::Image(image) => { - let x = indentation(node.level, indents, width)?; - let y = spacing(&mut bottom, &mut previous, &node.format)?; - let [w, h] = image_size(image).ok_or(LayoutError::UnsupportedContent)?; - result.objects.push(ObjectLayout { - id: image.id, - rect: [x, y, x + w, y + h], - kind: ObjectKind::Picture, - bottom: y + h, - }); - result.size[0] = result.size[0].max(x + w); - bottom += f64::from(h); + } + let mut state = segment.blocks.last().map_or(start, |block| block.state); + let mut incoming = range + .end + .checked_sub(1) + .map_or((0.0, None), |root| self.blocks[root].state); + let mut moved = Vec::new(); + for root in range.end..self.blocks.len() { + if state == incoming { + break; } - ParagraphContent::Attachment(file) => { - let x = indentation(node.level, indents, width)?; - let y = spacing(&mut bottom, &mut previous, &node.format)?; - let [w, h] = file.size.unwrap_or([24.0, 24.0]); - let icon = [x + (ATTACHMENT_WIDTH - w) / 2.0, y + 6.0]; - // OneNote shows the name without its extension. - let name = std::path::Path::new(&file.filename) - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or(&file.filename); - let format = Format { - alignment: Some(1), - ..node.format.clone() + let block = &self.blocks[root]; + incoming = block.state; + let mut y = 0.0; + if let Some([before, after, height]) = block.metrics { + y = top(&mut state, [before, after]); + let [_, tables, objects] = self.pieces(root..root + 1); + let flow = match self.objects[objects].first() { + Some(object) if tables.is_empty() => object.at(y, height).1, + _ => height, }; - let mut label = shape( - &caption(node.id, file.id, name, format), - ATTACHMENT_WIDTH, - &[0.0], - )?; - label.reset_origin(x); - label.origin[1] = icon[1] + h + 10.5; - let end = label.origin[1] + label.text.height() + 9.0; - result.objects.push(ObjectLayout { - id: file.id, - rect: [icon[0], icon[1], icon[0] + w, icon[1] + h], - kind: ObjectKind::File(label), - bottom: end, - }); - result.size[0] = result.size[0].max(x + ATTACHMENT_WIDTH); - bottom += f64::from(end - y); + state.0 += f64::from(flow); + if !(state.0 as f32).is_finite() { + return Err(LayoutError::InvalidSpacing); + } } - ParagraphContent::Ink(ink) => { - let x = indentation(node.level, indents, width)?; - let y = spacing(&mut bottom, &mut previous, &node.format)?; - // The paragraph reaches from its origin to the farthest stroke point. - let [w, h] = ink - .bounds() - .map(|[x, y, w, h]| [x + w, y + h]) - .filter(|size| size.iter().all(|v| v.is_finite() && *v >= 0.0)) - .ok_or(LayoutError::UnsupportedContent)?; - result.objects.push(ObjectLayout { - id: ink.id, - rect: [x, y, x + w, y + h], - kind: ObjectKind::Ink(ink.clone()), - bottom: y + h, - }); - result.size[0] = result.size[0].max(x + w); - bottom += f64::from(h); + moved.push((y, state)); + } + let bottom = if moved.len() == self.blocks.len() - range.end { + state.0 as f32 + } else { + self.size[1] + }; + let widest = |blocks: &[Block], side: usize| { + blocks + .iter() + .map(|block| block.extent[side]) + .fold(f32::NEG_INFINITY, f32::max) + }; + let widths = [0, 1].map(|side| { + let [before, after] = [ + widest(&self.blocks[range.clone()], side), + widest(&segment.blocks, side), + ]; + if after >= before || before < self.widths[side] { + self.widths[side].max(after) + } else { + [ + &self.blocks[..range.start], + &segment.blocks, + &self.blocks[range.end..], + ] + .into_iter() + .map(|blocks| widest(blocks, side)) + .fold([36.0, f32::NEG_INFINITY][side], f32::max) } - ParagraphContent::Unsupported(unsupported) => { - let x = indentation(node.level, indents, width)?; - let y = spacing(&mut bottom, &mut previous, &node.format)?; - let w = unsupported.layout.max_width.unwrap_or(160.0).max(160.0); - let mut label = shape( - &caption( - node.id, - unsupported.id, - "Unsupported content", - Format::default(), - ), - w - 16.0, - &[0.0], - )?; - label.reset_origin(x + 8.0); - label.origin[1] = y + 8.0; - let h = unsupported - .layout - .max_height - .unwrap_or(0.0) - .max(label.text.height() + 16.0); - result.objects.push(ObjectLayout { - id: unsupported.id, - rect: [x, y, x + w, y + h], - kind: ObjectKind::Unsupported(label), - bottom: y + h, - }); - result.size[0] = result.size[0].max(x + w); - bottom += f64::from(h); + }); + let tables = &old[1]; + let tables = self.tables[..tables.start] + .iter() + .chain(&segment.tables) + .chain(&self.tables[tables.end..]); + let size = [ + if fixed_width { width } else { widths[0] }.max(table_width(tables)), + bottom, + ]; + return Ok(Relayout { + range, + segment, + moved, + size, + widths, + }); + } + } + + /// Puts a [`Relayout`] of this layout in place. + pub(crate) fn commit(&mut self, relayout: Relayout) { + let Relayout { + range, + mut segment, + moved, + size, + widths, + } = relayout; + let [paragraphs, tables, objects] = self.pieces(range.clone()); + let first = [paragraphs.start, tables.start, objects.start]; + let delta = [ + segment.paragraphs.len() as isize - paragraphs.len() as isize, + segment.tables.len() as isize - tables.len() as isize, + segment.objects.len() as isize - objects.len() as isize, + ]; + for cell in segment.tables.iter_mut().flat_map(|table| &mut table.cells) { + cell.paragraphs = cell.paragraphs.start + first[0]..cell.paragraphs.end + first[0]; + } + for block in &mut segment.blocks { + for (value, first) in block.first.iter_mut().zip(first) { + *value += first; + } + } + let after = range.start + segment.blocks.len(); + let later_tables = tables.start + segment.tables.len(); + self.paragraphs.splice(paragraphs, segment.paragraphs); + self.tables.splice(tables, segment.tables); + self.objects.splice(objects, segment.objects); + self.blocks.splice(range, segment.blocks); + if delta != [0; 3] { + for block in &mut self.blocks[after..] { + for (value, delta) in block.first.iter_mut().zip(delta) { + *value = value.wrapping_add_signed(delta); } } - if !(bottom as f32).is_finite() || !result.size[0].is_finite() { - return Err(LayoutError::InvalidSpacing); + for cell in self.tables[later_tables..] + .iter_mut() + .flat_map(|table| &mut table.cells) + { + cell.paragraphs = cell.paragraphs.start.wrapping_add_signed(delta[0]) + ..cell.paragraphs.end.wrapping_add_signed(delta[0]); + } + } + for (root, (y, state)) in (after..).zip(moved) { + let [paragraphs, tables, objects] = self.pieces(root..root + 1); + let block = &mut self.blocks[root]; + block.state = state; + let Some([.., height]) = block.metrics else { + continue; + }; + if !tables.is_empty() { + for (value, rel) in verticals( + &mut self.paragraphs[paragraphs], + &mut self.tables[tables], + &mut self.objects[objects], + ) + .zip(&block.rel) + { + *value = rel + y; + } + } else if let Some(object) = self.objects[objects].first_mut() { + object.place(y, height); + } else { + self.paragraphs[paragraphs.start].origin[1] = y; } } - result.size[1] = bottom as f32; - result.size[0] = if fixed_width { width } else { result.size[0] }.max(result.table_width()); - Ok(result) + self.size = size; + self.widths = widths; } pub(crate) fn table_width(&self) -> f32 { - // Stored column widths extend 1.77pt past the final cell border. - self.tables - .iter() - .filter_map(|table| table.cells.last()) - .map(|cell| cell.rect[2] + 1.77) - .fold(0.0, f32::max) + table_width(&self.tables) } fn table( @@ -666,13 +1024,11 @@ impl OutlineLayout { return Err(LayoutError::UnsupportedContent); } let mut result = Self { - paragraphs: Vec::new(), tables: vec![TableLayout { id: table.id, cells: Vec::new(), borders: table.borders.unwrap_or(true), }], - objects: Vec::new(), size: [ (0..table.columns.len()) .map(|index| width(index) + 4.98) @@ -680,6 +1036,7 @@ impl OutlineLayout { - 1.83, 3.54, ], + ..Self::default() }; let mut y = 0.0; for row in &table.rows { @@ -724,26 +1081,15 @@ impl OutlineLayout { } Ok(result) } +} - pub(crate) fn new( - mut paragraphs: Vec, - width: f32, - width_set_by_user: bool, - ) -> Result { - if !width.is_finite() || width <= 0.0 { - return Err(LayoutError::InvalidWidth); - } - let (origins, size) = arrange(paragraphs.iter(), width, width_set_by_user)?; - for (paragraph, y) in paragraphs.iter_mut().zip(origins) { - paragraph.origin[1] = y; - } - Ok(Self { - paragraphs, - tables: Vec::new(), - objects: Vec::new(), - size, - }) - } +/// Stored column widths extend 1.77pt past the final cell border. +fn table_width<'a>(tables: impl IntoIterator) -> f32 { + tables + .into_iter() + .filter_map(|table| table.cells.last()) + .map(|cell| cell.rect[2] + 1.77) + .fold(0.0, f32::max) } /// Shapes a page object into positioned paragraph layouts. @@ -773,13 +1119,6 @@ impl Arrange for Outline { } } -/// Outlines of plain paragraphs stack them; tables and pictures, files or ink need the full flow. -pub(crate) fn all_text<'a>(nodes: impl IntoIterator) -> bool { - nodes - .into_iter() - .all(|node| matches!(node.content, ParagraphContent::Text(_))) -} - pub(crate) fn outline_layout( outline: &Outline, engine: &mut TextEngine, @@ -789,26 +1128,16 @@ pub(crate) fn outline_layout( if !outline.unsupported.is_empty() { return Err(LayoutError::UnsupportedContent); } - if !all_text(&outline.paragraphs) { - return OutlineLayout::flow( - outline.paragraphs.iter(), - &outline.indents, - width, - outline.layout.width_set_by_user == Some(true), - 0, - None, - &mut |node, width, indents| { - ParagraphLayout::shape(engine, node, width, indents, definitions) - }, - ); - } - let paragraphs = visible_paragraphs(outline.paragraphs.iter()) - .map(|p| ParagraphLayout::shape(engine, p, width, &outline.indents, definitions)) - .collect::>()?; - OutlineLayout::new( - paragraphs, + OutlineLayout::flow( + outline.paragraphs.iter(), + &outline.indents, width, outline.layout.width_set_by_user == Some(true), + 0, + None, + &mut |node, width, indents| { + ParagraphLayout::shape(engine, node, width, indents, definitions) + }, ) } @@ -1377,9 +1706,12 @@ mod tests { _ => None, }) .collect(); - assert_eq!(colors, [0x0080_8000]); + assert_eq!(colors, [Some(0x0080_8000)]); assert_eq!(shaped.projection.text().spans()[0].format.color, None); - let outline = OutlineLayout::new(vec![shaped], 200.0, false).unwrap(); + let outline = OutlineLayout { + paragraphs: vec![shaped], + ..OutlineLayout::default() + }; assert_eq!(outline.tag_column_offset(), -12.0); } diff --git a/crates/draw/Cargo.toml b/crates/draw/Cargo.toml index ca40daaa10f05183248e549908e0f2708cf47b10..dfa4dd091cce7759589636e24938938f2ff4ce83 100644 --- a/crates/draw/Cargo.toml +++ b/crates/draw/Cargo.toml @@ -4,13 +4,19 @@ version = "0.1.0" edition = "2024" publish = false +[features] +default = ["render"] +# The wgpu renderer; `edit` needs only parley. +render = ["dep:bytemuck", "dep:image", "dep:roxmltree", "dep:swash", "dep:wgpu"] + [dependencies] -bytemuck = { version = "1.25", features = ["derive"] } -image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"] } parley.workspace = true -roxmltree = "0.21" -swash = "0.2.10" -wgpu.workspace = true + +bytemuck = { version = "1.25", features = ["derive"], optional = true } +image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true } +roxmltree = { version = "0.21", optional = true } +swash = { version = "0.2.10", optional = true } +wgpu = { workspace = true, optional = true } [dev-dependencies] pollster = "0.4" diff --git a/crates/draw/src/edit.rs b/crates/draw/src/edit.rs new file mode 100644 index 0000000000000000000000000000000000000000..3952e5e794b0aab5b99f92be2f43eb12744c49f5 --- /dev/null +++ b/crates/draw/src/edit.rs @@ -0,0 +1,457 @@ +//! Text editing shared by the page and interface text: keys, macOS editing chords, click +//! counting and caret movement within one parley layout. + +use parley::{ + Affinity, Brush, Layout, + editing::{Cursor, Selection}, +}; +use std::{ + collections::BTreeSet, + time::{Duration, Instant}, +}; + +#[derive(Clone, Debug, PartialEq)] +pub enum Key { + Named(NamedKey), + Character(String), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum NamedKey { + Escape, + Tab, + Space, + Enter, + Backspace, + Delete, + ArrowLeft, + ArrowRight, + ArrowUp, + ArrowDown, + Home, + End, + /// Shift, Control, Option or Command pressed alone. + Modifier, + Other, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Modifiers { + pub shift: bool, + pub control: bool, + pub option: bool, + pub command: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Movement { + Left, + Right, + Up, + Down, + WordLeft, + WordRight, + LineStart, + LineEnd, + ParagraphStart, + ParagraphEnd, + DocumentStart, + DocumentEnd, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SelectionUnit { + #[default] + Grapheme, + Word, + Paragraph, +} + +/// What a key does to text under macOS conventions. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Command { + /// Moves the focus, extending the selection with Shift. + Move(Movement), + /// Deletes the selection, or the grapheme before or after the caret. + Delete { backward: bool }, + /// Deletes the selection, or from the caret to where the movement would take it. + DeleteTo(Movement), + /// Control-K: deletes to the line's end, or the break after it when already there. + Kill, +} + +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 + } 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(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, + }, + _ => None, + } + } +} + +/// Counts presses into single, double and triple clicks. +#[derive(Debug)] +pub struct Clicks { + interval: Duration, + last: Option<(Instant, [f32; 2], SelectionUnit)>, +} + +impl Clicks { + /// `interval` is the platform's double-click interval. + pub fn new(interval: Duration) -> Self { + Self { + interval, + last: None, + } + } + + /// Records a press at `point` and returns the unit it selects by. A press within the + /// interval and `slop` of the previous one on each axis continues its count, cycling + /// after three. + pub fn press(&mut self, at: Instant, point: [f32; 2], slop: f32) -> SelectionUnit { + let unit = self + .last + .filter(|(time, last, _)| { + at.saturating_duration_since(*time) <= self.interval + && (0..2).all(|axis| (last[axis] - point[axis]).abs() <= slop) + }) + .map_or(SelectionUnit::Grapheme, |(_, _, unit)| match unit { + SelectionUnit::Grapheme => SelectionUnit::Word, + SelectionUnit::Word => SelectionUnit::Paragraph, + SelectionUnit::Paragraph => SelectionUnit::Grapheme, + }); + self.last = Some((at, point, unit)); + unit + } +} + +/// The selection a drag makes from the selection its press made to the one under the +/// pointer, both by `unit`, ordered by `position`. Words and paragraphs stay whole, so +/// the press's selection stays covered when the drag reverses past it. +pub fn drag( + anchor: [T; 2], + target: [T; 2], + unit: SelectionUnit, + position: impl Fn(T) -> P, +) -> [T; 2] { + if unit == SelectionUnit::Grapheme { + return [anchor[0], target[1]]; + } + let [anchor_start, anchor_end] = anchor.map(&position); + let [target_start, target_end] = target.map(&position); + let backwards = target_start < anchor_start && target_start < anchor_end; + let start = usize::from((anchor_start > anchor_end) != backwards); + let end = usize::from((target_start > target_end) == backwards); + [anchor[start], target[end]] +} + +/// The selection a press makes by `unit`, where `hit` is the cursor at the press's `x`. +pub fn selection_at( + layout: &Layout, + hit: Cursor, + x: f32, + unit: SelectionUnit, +) -> Selection { + match unit { + SelectionUnit::Grapheme => hit.into(), + SelectionUnit::Word => { + let rect = hit.geometry(layout, 1.0); + Selection::word_from_point(layout, x, ((rect.y0 + rect.y1) * 0.5) as f32) + } + SelectionUnit::Paragraph => Selection::new( + Cursor::from_byte_index(layout, 0, Affinity::Downstream), + Cursor::from_byte_index(layout, usize::MAX, Affinity::Upstream), + ), + } +} + +/// Where `movement` takes `selection` within `layout`, keeping its anchor when `extend`. +/// Up, Down and the paragraph and document movements stop at the layout's ends. +pub fn step( + layout: &Layout, + selection: Selection, + movement: Movement, + extend: bool, +) -> Selection { + let to = |focus: Cursor| { + if extend { + selection.extend(focus) + } else { + focus.into() + } + }; + match movement { + Movement::Left => selection.previous_visual(layout, extend), + Movement::Right => selection.next_visual(layout, extend), + Movement::WordLeft => to(word_cursor(layout, selection.focus(), true)), + Movement::WordRight => to(word_cursor(layout, selection.focus(), false)), + Movement::LineStart => selection.line_start(layout, extend), + Movement::LineEnd => selection.line_end(layout, extend), + Movement::Up | Movement::ParagraphStart | Movement::DocumentStart => { + to(Cursor::from_byte_index(layout, 0, Affinity::Downstream)) + } + Movement::Down | Movement::ParagraphEnd | Movement::DocumentEnd => to( + Cursor::from_byte_index(layout, usize::MAX, Affinity::Upstream), + ), + } +} + +/// The next word boundary in visual order, stopping before a word going left and after +/// one going right, as macOS does. +pub fn word_cursor(layout: &Layout, cursor: Cursor, backward: bool) -> Cursor { + let mut current = cursor; + let mut visited = BTreeSet::new(); + loop { + if !visited.insert((current.index(), current.affinity() == Affinity::Upstream)) { + // Parley visual cursors can cycle at soft-wrapped bidi boundaries. + let [left, right] = cursor.visual_clusters(layout); + let rtl = if backward { + left.or(right) + } else { + right.or(left) + } + .is_some_and(|cluster| cluster.is_rtl()); + return if backward != rtl { + cursor.previous_logical_word(layout) + } else { + cursor.next_logical_word(layout) + }; + } + let next = if backward { + current.previous_visual(layout) + } else { + current.next_visual(layout) + }; + if next == current { + return current; + } + current = next; + let [Some(left), Some(right)] = current.visual_clusters(layout) else { + return current; + }; + let boundary = if left.is_rtl() { + left.is_word_boundary() + && if backward { + left.is_space_or_nbsp() + || (right.is_word_boundary() && !right.is_space_or_nbsp()) + } else { + !left.is_space_or_nbsp() + } + } else { + right.is_word_boundary() + && if backward { + !right.is_space_or_nbsp() + } else { + !left.is_space_or_nbsp() + } + }; + if boundary { + return current; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use parley::{FontContext, LayoutContext}; + + fn layout(text: &str) -> Layout<()> { + let mut fonts = FontContext::new(); + let mut context = LayoutContext::new(); + let mut layout = context + .ranged_builder(&mut fonts, text, 1.0, false) + .build(text); + layout.break_all_lines(None); + layout + } + + #[test] + fn keys_follow_macos_editing_chords() { + let with = |modifiers: fn(&mut Modifiers)| { + let mut value = Modifiers::default(); + modifiers(&mut value); + value + }; + let named = |named| Key::Named(named); + let cases = [ + ( + named(NamedKey::ArrowLeft), + with(|m| m.option = true), + Some(Command::Move(Movement::WordLeft)), + ), + ( + named(NamedKey::ArrowRight), + with(|m| (m.command, m.option) = (true, true)), + Some(Command::Move(Movement::LineEnd)), + ), + ( + named(NamedKey::ArrowUp), + with(|m| m.option = true), + Some(Command::Move(Movement::ParagraphStart)), + ), + (named(NamedKey::Home), Modifiers::default(), None), + ( + named(NamedKey::End), + with(|m| m.shift = true), + Some(Command::Move(Movement::DocumentEnd)), + ), + ( + named(NamedKey::Backspace), + with(|m| m.option = true), + Some(Command::DeleteTo(Movement::WordLeft)), + ), + ( + named(NamedKey::Delete), + with(|m| m.command = true), + Some(Command::DeleteTo(Movement::LineEnd)), + ), + ( + named(NamedKey::Backspace), + with(|m| m.control = true), + Some(Command::Delete { backward: true }), + ), + ( + Key::Character("e".into()), + with(|m| m.control = true), + Some(Command::Move(Movement::LineEnd)), + ), + ( + Key::Character("k".into()), + with(|m| m.control = true), + Some(Command::Kill), + ), + ( + Key::Character("k".into()), + with(|m| (m.control, m.option) = (true, true)), + None, + ), + (Key::Character("a".into()), Modifiers::default(), None), + ]; + for (key, modifiers, command) in cases { + assert_eq!( + Command::from_key(&key, modifiers), + command, + "{key:?} {modifiers:?}" + ); + } + } + + #[test] + fn presses_count_within_the_interval_and_slop_and_cycle() { + let mut clicks = Clicks::new(Duration::from_millis(500)); + let start = Instant::now(); + let at = |millis| start + Duration::from_millis(millis); + let units: Vec<_> = [(0, 0.0), (100, 3.0), (200, 3.0), (300, 0.0), (1000, 0.0)] + .map(|(millis, x)| clicks.press(at(millis), [x, 0.0], 4.0)) + .into(); + use SelectionUnit::*; + assert_eq!(units, [Grapheme, Word, Paragraph, Grapheme, Grapheme]); + assert_eq!(clicks.press(at(1100), [10.0, 0.0], 4.0), Grapheme); + } + + #[test] + fn grouped_drag_keeps_the_initial_word_when_reversing_direction() { + let drag = |anchor, target, unit| drag(anchor, target, unit, |offset: usize| offset); + let word = [6, 10]; + assert_eq!(drag(word, [11, 16], SelectionUnit::Word), [6, 16]); + assert_eq!(drag(word, [0, 5], SelectionUnit::Word), [10, 0]); + assert_eq!(drag(word, word, SelectionUnit::Word), word); + assert_eq!(drag([10, 3], [7, 7], SelectionUnit::Grapheme), [10, 7]); + } + + #[test] + fn words_stop_at_their_edges_in_both_directions() { + let layout = layout("one two three"); + let at = |index| Cursor::from_byte_index(&layout, index, Affinity::Downstream); + let words = |start, backward| { + std::iter::successors(Some(at(start)), |cursor| { + let next = word_cursor(&layout, *cursor, backward); + (next != *cursor).then_some(next) + }) + .skip(1) + .map(|cursor| cursor.index()) + .collect::>() + }; + assert_eq!(words(0, false), [3, 7, 14]); + assert_eq!(words(14, true), [9, 4, 0]); + } + + #[test] + fn steps_collapse_extend_and_reach_the_ends() { + let layout = layout("one two"); + let all = selection_at(&layout, Cursor::default(), 0.0, SelectionUnit::Paragraph); + assert_eq!(all.text_range(), 0..7); + let left = step(&layout, all, Movement::Left, false); + assert_eq!(left.text_range(), 0..0); + let word = step(&layout, left, Movement::WordRight, true); + assert_eq!(word.text_range(), 0..3); + assert_eq!( + step(&layout, word, Movement::Down, false).focus().index(), + 7 + ); + assert_eq!( + step(&layout, word, Movement::Up, true).text_range(), + 0..0, + "extending keeps the anchor" + ); + } +} diff --git a/crates/draw/src/icon.rs b/crates/draw/src/icon.rs deleted file mode 100644 index f6d9fd43332c8894b5c4f3a92336ee5c12e24019..0000000000000000000000000000000000000000 --- a/crates/draw/src/icon.rs +++ /dev/null @@ -1,122 +0,0 @@ -use swash::{ - scale::image::{Content, Image}, - zeno::{Mask, Placement, Transform, Vector}, -}; - -/// Rasterizes 16×16 SVG sources over each other at `size` device pixels, offset by -/// `phase` quarter pixels. Paths are filled with two-stop vertical gradients. -pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { - let width = (size + 1.0).ceil() as u32; - let mut pixels = vec![[0.0_f32; 4]; (width * width) as usize]; - for source in sources { - let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid"); - assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); - for path in svg.descendants().filter(|node| node.has_tag_name("path")) { - let gradient = path - .attribute("fill") - .unwrap() - .strip_prefix("url(#") - .unwrap() - .strip_suffix(')') - .unwrap(); - let gradient = svg - .descendants() - .find(|node| node.attribute("id") == Some(gradient)) - .unwrap(); - let mut stops = gradient - .children() - .filter(|node| node.has_tag_name("stop")) - .map(|node| { - let color = u32::from_str_radix( - node.attribute("stop-color") - .unwrap() - .strip_prefix('#') - .unwrap(), - 16, - ) - .unwrap(); - let [_, r, g, b] = color.to_be_bytes(); - super::srgb(r, g, b) - }); - let top = stops.next().unwrap(); - let bottom = stops.next().unwrap(); - assert!(stops.next().is_none()); - let (mask, _) = Mask::new(path.attribute("d").unwrap()) - .transform(Some(Transform::scale(size / 16.0, size / 16.0))) - .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25)) - .size(width, width) - .render(); - for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { - let alpha = f32::from(coverage) / 255.0; - let y = ((index / width as usize) as f32 / size).min(1.0); - for channel in 0..3 { - let color = top[channel] + (bottom[channel] - top[channel]) * y; - pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha); - } - pixel[3] = alpha + pixel[3] * (1.0 - alpha); - } - } - } - let data = pixels - .into_iter() - .flat_map(|pixel| { - let alpha = pixel[3]; - let channel = |value: f32| { - let value = if alpha > 0.0 { value / alpha } else { 0.0 }; - super::srgb_byte(value) - }; - [ - channel(pixel[0]), - channel(pixel[1]), - channel(pixel[2]), - (alpha * 255.0).round() as u8, - ] - }) - .collect(); - Image { - content: Content::Color, - placement: Placement { - left: 0, - top: 0, - width, - height: width, - }, - data, - ..Image::default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const BOX: &str = r##""##; - const MARK: &str = r##""##; - - #[test] - fn icons_keep_transparency_and_overlays_across_scales() { - for size in [8.0, 16.0, 32.0, 57.5] { - for phase in [[0, 0], [1, 2], [3, 3]] { - let plain = rasterize(&[BOX], size, phase); - let marked = rasterize(&[BOX, MARK], size, phase); - for image in [&plain, &marked] { - assert_eq!( - image.data.len(), - (image.placement.width * image.placement.height * 4) as usize - ); - assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); - assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); - assert!( - image - .data - .chunks_exact(4) - .filter(|p| p[3] == 0) - .all(|p| p[..3] == [0; 3]) - ); - } - assert_ne!(plain.data, marked.data); - assert!(marked.data.chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); - } - } - } -} diff --git a/crates/draw/src/lib.rs b/crates/draw/src/lib.rs index 3a15b65c3b6ad1d105b6ef0bdbe9a52dc60b0e62..411394fa5389a9bc45b1bfb5344de85504f3ca6a 100644 --- a/crates/draw/src/lib.rs +++ b/crates/draw/src/lib.rs @@ -1,1828 +1,10 @@ -//! Paints layers of primitives with wgpu: glyphs and icons through one atlas, images, -//! rounded rectangles and pen strokes. Each layer has its own transform and clip. +//! What the page and the interface share: painting layers of glyphs, icons, paths, +//! images, rounded rectangles and pen strokes with wgpu, each layer with its own transform +//! and clip, and editing text within a laid-out line. -mod icon; -mod text; +pub mod edit; +#[cfg(feature = "render")] +mod render; -pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; - -use bytemuck::{Pod, Zeroable}; -use image::ImageDecoder; -use parley::fontique::Blob; -use std::{ - collections::{HashMap, HashSet}, - ops::Range, -}; -use swash::{ - FontRef, - scale::{Render, ScaleContext, Source, StrikeWith, image::Content}, - zeno::{Angle, Format, Transform, Vector}, -}; - -const ATLAS_SIZE: u32 = 2048; -const MAX_GLYPHS: usize = 8192; -const MAX_VERTICES: usize = 65_536; -/// Decoded bytes of all images one frame may paint. -pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; -const MAX_IMAGES: usize = 256; - -#[derive(Clone)] -pub struct RasterImage { - size: [u32; 2], - pixels: Blob, -} - -impl RasterImage { - pub fn decode(encoded: &[u8]) -> Result { - if encoded.len() as u64 > MAX_IMAGE_BYTES { - return Err(RenderError::ImageBudget); - } - let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; - let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); - let mut limits = image::Limits::default(); - limits.max_image_width = Some(16_384); - limits.max_image_height = Some(16_384); - limits.max_alloc = Some(MAX_IMAGE_BYTES); - reader.limits(limits.clone()); - let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; - let (width, height) = decoder.dimensions(); - if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES - || decoder.total_bytes() > MAX_IMAGE_BYTES - { - return Err(RenderError::ImageBudget); - } - limits - .reserve(decoder.total_bytes()) - .map_err(RenderError::ImageDecode)?; - decoder - .set_limits(limits) - .map_err(RenderError::ImageDecode)?; - let pixels = image::DynamicImage::from_decoder(decoder) - .map_err(RenderError::ImageDecode)? - .into_rgba8(); - Self::new([width, height], pixels.into_raw()) - } - - /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. - pub fn new(size: [u32; 2], mut pixels: Vec) -> Result { - let bytes = u64::from(size[0]) - .checked_mul(u64::from(size[1])) - .and_then(|v| v.checked_mul(4)) - .ok_or(RenderError::InvalidImage)?; - if size.contains(&0) || bytes != pixels.len() as u64 { - return Err(RenderError::InvalidImage); - } - if bytes > MAX_IMAGE_BYTES { - return Err(RenderError::ImageBudget); - } - // Premultiply in linear light before filtering; transparent RGB must not bleed at edges. - for pixel in pixels.chunks_exact_mut(4) { - let alpha = pixel[3]; - if alpha == 255 { - continue; - } - let linear = srgb(pixel[0], pixel[1], pixel[2]); - for (byte, value) in pixel[..3].iter_mut().zip(linear) { - let value = value * f32::from(alpha) / 255.0; - *byte = srgb_byte(value); - } - } - Ok(Self { - size, - pixels: pixels.into(), - }) - } - - /// The identity caches and uploads share across clones. - pub fn id(&self) -> u64 { - self.pixels.id() - } - - /// Premultiplied sRGB RGBA rows. - pub fn pixels(&self) -> &[u8] { - self.pixels.as_ref() - } -} - -struct CachedImage { - binding: wgpu::BindGroup, - bytes: u64, -} - -struct Batch { - vertices: Range, - image: Option, - scissor: [u32; 4], -} - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct Vertex { - position: [f32; 2], - uv: [f32; 2], - color: [f32; 4], - local: [f32; 2], - shape: [f32; 4], - stroke: f32, -} - -#[derive(Hash, PartialEq, Eq)] -enum AtlasKey { - Text { - font: u64, - index: u32, - glyph: u16, - size: u32, - coords: Vec, - phase: [u8; 2], - embolden: bool, - skew: u32, - }, - Icon { - sources: &'static [&'static str], - size: u32, - phase: [u8; 2], - }, -} - -#[derive(Clone, Copy)] -struct AtlasGlyph { - x: u32, - y: u32, - width: u32, - height: u32, - left: i32, - top: i32, - color: bool, -} - -/// Primitives sharing one transform and clip. -pub struct Layer<'a> { - /// Device pixels per layer unit. - pub scale: f32, - /// Device position of the layer origin. - pub origin: [f32; 2], - /// Device bounds `[left, top, right, bottom]` the layer paints within. - pub clip: Option<[f32; 4]>, - pub primitives: &'a [Primitive<'a>], -} - -/// A layer's transform onto the target. -#[derive(Clone, Copy)] -struct Space { - size: [u32; 2], - scale: f32, - origin: [f32; 2], -} - -impl Space { - fn visible_rect(&self, rect: [f32; 4]) -> Result, RenderError> { - let rect = [ - rect[0] * self.scale + self.origin[0], - rect[1] * self.scale + self.origin[1], - rect[2] * self.scale + self.origin[0], - rect[3] * self.scale + self.origin[1], - ]; - if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { - return Err(RenderError::InvalidPrimitive); - } - Ok((rect[0] < self.size[0] as f32 - && rect[1] < self.size[1] as f32 - && rect[2] > 0.0 - && rect[3] > 0.0 - && rect[0] < rect[2] - && rect[1] < rect[3]) - .then_some(rect)) - } - - /// Whole device pixels covering `rect` within the target, as a scissor rectangle. - fn scissor(&self, rect: [f32; 4]) -> [u32; 4] { - let left = rect[0].max(0.0).floor() as u32; - let top = rect[1].max(0.0).floor() as u32; - let right = (rect[2].min(self.size[0] as f32).ceil() as u32).max(left); - let bottom = (rect[3].min(self.size[1] as f32).ceil() as u32).max(top); - [left, top, right - left, bottom - top] - } -} - -fn intersect(a: [u32; 4], b: [u32; 4]) -> [u32; 4] { - let left = a[0].max(b[0]); - let top = a[1].max(b[1]); - let right = (a[0] + a[2]).min(b[0] + b[2]).max(left); - let bottom = (a[1] + a[3]).min(b[1] + b[3]).max(top); - [left, top, right - left, bottom - top] -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Stroke { - Solid(f32), - /// Dash and gap lengths are twice the stroke width. - Dashed(f32), -} - -/// Coordinates are layer units; colors are linear RGBA. -pub enum Primitive<'a> { - Text { - text: &'a dyn Glyphs, - origin: [f32; 2], - /// Layer bounds of the painted glyphs, which keep their full shaping and advances. - clip: Option<[f32; 4]>, - }, - /// 16×16 SVG artwork painted `size` units square, later sources over earlier ones. - Icon { - sources: &'static [&'static str], - origin: [f32; 2], - size: f32, - opacity: f32, - }, - Rect { - rect: [f32; 4], - color: [f32; 4], - }, - RoundedRect { - rect: [f32; 4], - radius: [f32; 2], - /// None fills the shape; stroke widths are positive layer units. - stroke: Option, - color: [f32; 4], - }, - Image { - image: &'a RasterImage, - rect: [f32; 4], - }, - /// A pen stroke between two points, `width` units across. - Segment { - from: [f32; 2], - to: [f32; 2], - width: f32, - /// A round pen tip caps the ends; otherwise they are square. - round: bool, - color: [f32; 4], - }, -} - -#[derive(Debug)] -pub enum RenderError { - AtlasFull, - FrameTooLarge, - UnreadableFont, - UnsupportedGlyph, - InvalidLayer, - InvalidPrimitive, - InvalidImage, - ImageBudget, - ImageTooLarge, - ImageDecode(image::ImageError), -} - -/// Cache and buffer use after the last frame, for resource probes. Texture and buffer -/// sizes are as requested, without driver overhead. -#[derive(Debug)] -pub struct Occupancy { - /// Glyph and icon atlas entries, including glyphs with no pixels. - pub glyphs: usize, - pub atlas_texels: u64, - pub images: usize, - pub image_bytes: u64, - pub vertices: usize, - pub batches: usize, - pub vertex_capacity_bytes: usize, - pub batch_capacity_bytes: usize, - pub glyph_capacity: usize, - pub image_capacity: usize, - pub atlas_bytes: u64, - pub vertex_buffer_bytes: u64, - pub within_budget: bool, -} - -pub struct Renderer { - pub device: wgpu::Device, - pub queue: wgpu::Queue, - pipeline: wgpu::RenderPipeline, - image_pipeline: wgpu::RenderPipeline, - atlas: wgpu::Texture, - bind_group: wgpu::BindGroup, - image_sampler: wgpu::Sampler, - vertex_buffer: wgpu::Buffer, - vertices: Vec, - glyphs: HashMap>, - images: HashMap, - batches: Vec, - scaler: ScaleContext, - pen: [u32; 2], - row_height: u32, -} - -impl Renderer { - pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { - let shader = device.create_shader_module(wgpu::include_wgsl!("draw.wgsl")); - let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Draw texture"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Draw"), - bind_group_layouts: &[Some(&binding_layout)], - ..Default::default() - }); - let make_pipeline = |blend| { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("Draw"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vertex"), - compilation_options: Default::default(), - buffers: &[Some(wgpu::VertexBufferLayout { - array_stride: size_of::() as u64, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32], - })], - }, - fragment: Some(wgpu::FragmentState { - module: &shader, - entry_point: Some("fragment"), - compilation_options: Default::default(), - targets: &[Some(wgpu::ColorTargetState { - format, - blend: Some(blend), - write_mask: wgpu::ColorWrites::ALL, - })], - }), - primitive: Default::default(), - depth_stencil: None, - multisample: Default::default(), - multiview_mask: None, - cache: None, - }) - }; - let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING); - let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); - let atlas = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Glyph atlas"), - size: wgpu::Extent3d { - width: ATLAS_SIZE, - height: ATLAS_SIZE, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - let view = atlas.create_view(&Default::default()); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - mag_filter: wgpu::FilterMode::Nearest, - min_filter: wgpu::FilterMode::Nearest, - ..Default::default() - }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Glyph atlas"), - layout: &pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&sampler), - }, - ], - }); - let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Draw vertices"), - size: (MAX_VERTICES * size_of::()) as u64, - usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("Draw image filtering"), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - ..Default::default() - }); - queue.write_texture( - atlas.as_image_copy(), - &[255; 4], - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(4), - rows_per_image: Some(1), - }, - wgpu::Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - ); - Self { - device, - queue, - pipeline, - image_pipeline, - atlas, - bind_group, - image_sampler, - vertex_buffer, - vertices: Vec::new(), - glyphs: HashMap::new(), - images: HashMap::new(), - batches: Vec::new(), - scaler: ScaleContext::with_max_entries(32), - pen: [1, 0], - row_height: 1, - } - } - - pub fn clear_glyph_cache(&mut self) { - self.glyphs.clear(); - self.pen = [1, 0]; - self.row_height = 1; - } - - /// Forgets uploaded images; they upload again when next painted. - pub fn clear_images(&mut self) { - self.images.clear(); - } - - pub fn occupancy(&self) -> Occupancy { - let image_bytes = self.images.values().map(|image| image.bytes).sum(); - Occupancy { - glyphs: self.glyphs.len(), - atlas_texels: self - .glyphs - .values() - .flatten() - .map(|glyph| u64::from(glyph.width) * u64::from(glyph.height)) - .sum(), - images: self.images.len(), - image_bytes, - vertices: self.vertices.len(), - batches: self.batches.len(), - vertex_capacity_bytes: self.vertices.capacity() * size_of::(), - batch_capacity_bytes: self.batches.capacity() * size_of::(), - glyph_capacity: self.glyphs.capacity(), - image_capacity: self.images.capacity(), - atlas_bytes: u64::from(self.atlas.width()) * u64::from(self.atlas.height()) * 4, - vertex_buffer_bytes: self.vertex_buffer.size(), - within_budget: self.glyphs.len() <= MAX_GLYPHS - && self.images.len() <= MAX_IMAGES - && image_bytes <= MAX_IMAGE_BYTES - && self.vertices.len() <= MAX_VERTICES, - } - } - - /// Clears `target`, `size` device pixels, to linear `clear` and paints the layers in order. - pub fn draw( - &mut self, - target: &wgpu::TextureView, - size: [u32; 2], - clear: [f32; 4], - layers: &[Layer<'_>], - ) -> Result<(), RenderError> { - if size.contains(&0) - || clear.iter().any(|v| !v.is_finite()) - || layers.iter().any(|layer| { - !layer.scale.is_finite() - || layer.scale <= 0.0 - || layer.origin.iter().any(|v| !v.is_finite()) - || layer - .clip - .is_some_and(|clip| clip.iter().any(|v| !v.is_finite())) - }) - { - return Err(RenderError::InvalidLayer); - } - let mut active_images = HashSet::new(); - let mut image_bytes = 0; - for layer in layers { - let space = Space { - size, - scale: layer.scale, - origin: layer.origin, - }; - for primitive in layer.primitives { - if let Primitive::Image { image, rect } = primitive - && space.visible_rect(*rect)?.is_some() - && active_images.insert(image.id()) - { - image_bytes += image.pixels().len() as u64; - if image_bytes > MAX_IMAGE_BYTES || active_images.len() > MAX_IMAGES { - return Err(RenderError::ImageBudget); - } - } - } - } - for attempt in 0..2 { - self.vertices.clear(); - self.batches.clear(); - match self.prepare(size, layers, &active_images) { - Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), - result => { - result?; - break; - } - } - } - self.queue - .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); - let mut encoder = self.device.create_command_encoder(&Default::default()); - { - let [r, g, b, a] = clear.map(f64::from); - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Draw"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: target, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }), - store: wgpu::StoreOp::Store, - }, - })], - ..Default::default() - }); - pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); - for batch in &self.batches { - let [x, y, width, height] = batch.scissor; - pass.set_scissor_rect(x, y, width, height); - pass.set_pipeline(if batch.image.is_some() { - &self.image_pipeline - } else { - &self.pipeline - }); - let binding = batch - .image - .map(|id| &self.images[&id].binding) - .unwrap_or(&self.bind_group); - pass.set_bind_group(0, binding, &[]); - pass.draw(batch.vertices.clone(), 0..1); - } - } - self.queue.submit([encoder.finish()]); - Ok(()) - } - - fn prepare( - &mut self, - size: [u32; 2], - layers: &[Layer<'_>], - active_images: &HashSet, - ) -> Result<(), RenderError> { - for layer in layers { - let space = Space { - size, - scale: layer.scale, - origin: layer.origin, - }; - let bounds = match layer.clip { - Some(clip) => space.scissor(clip), - None => [0, 0, size[0], size[1]], - }; - if bounds[2] == 0 || bounds[3] == 0 { - continue; - } - for primitive in layer.primitives { - self.primitive(space, bounds, primitive, active_images)?; - } - } - Ok(()) - } - - fn primitive( - &mut self, - space: Space, - bounds: [u32; 4], - primitive: &Primitive<'_>, - active_images: &HashSet, - ) -> Result<(), RenderError> { - let start = self.vertices.len() as u32; - let mut image_id = None; - let mut scissor = bounds; - match primitive { - Primitive::Icon { - sources, - origin, - size, - opacity, - } => { - let origin = [ - space.origin[0] + origin[0] * space.scale, - space.origin[1] + origin[1] * space.scale, - ]; - self.icon(Space { origin, ..space }, sources, *size, *opacity)?; - } - Primitive::Text { text, origin, clip } => { - let origin = [ - space.origin[0] + origin[0] * space.scale, - space.origin[1] + origin[1] * space.scale, - ]; - if origin.iter().any(|v| !v.is_finite()) { - return Err(RenderError::InvalidPrimitive); - } - if let Some(clip) = clip { - let Some(rect) = space.visible_rect(*clip)? else { - return Ok(()); - }; - scissor = intersect(bounds, space.scissor(rect)); - if scissor[2] == 0 || scissor[3] == 0 { - return Ok(()); - } - } - let space = Space { origin, ..space }; - text.runs(&mut |run| self.glyph_run(space, run))?; - } - Primitive::Rect { rect, color } => { - self.quad( - space, - [ - rect[0] * space.scale + space.origin[0], - rect[1] * space.scale + space.origin[1], - rect[2] * space.scale + space.origin[0], - rect[3] * space.scale + space.origin[1], - ], - [0.5 / ATLAS_SIZE as f32; 4], - *color, - )?; - } - Primitive::RoundedRect { - rect, - radius, - stroke, - color, - } => { - if radius - .iter() - .any(|value| !value.is_finite() || *value < 0.0) - { - return Err(RenderError::InvalidPrimitive); - } - let width = match stroke { - None => 0.0, - Some(Stroke::Solid(width) | Stroke::Dashed(width)) => { - if !width.is_finite() || *width <= 0.0 || width * space.scale == 0.0 { - return Err(RenderError::InvalidPrimitive); - } - *width - } - }; - let rect = [ - rect[0] * space.scale + space.origin[0], - rect[1] * space.scale + space.origin[1], - rect[2] * space.scale + space.origin[0], - rect[3] * space.scale + space.origin[1], - ]; - self.quad(space, rect, [0.5 / ATLAS_SIZE as f32; 4], *color)?; - let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; - if !(4.0 * (half[0] + half[1])).is_finite() { - return Err(RenderError::InvalidPrimitive); - } - let radius = if radius.contains(&0.0) { - [0.0; 2] - } else { - std::array::from_fn(|axis| (radius[axis] * space.scale).min(half[axis])) - }; - let width = (width * space.scale).min(half[0].min(half[1])); - // The stroke sign selects its dash pattern at the shader boundary. - let width = if matches!(stroke, Some(Stroke::Dashed(_))) { - -width - } else { - width - }; - for vertex in &mut self.vertices[start as usize..] { - vertex.shape = [half[0], half[1], radius[0], radius[1]]; - vertex.stroke = width; - } - } - Primitive::Segment { - from, - to, - width, - round, - color, - } => self.segment(space, *from, *to, *width, *round, *color)?, - Primitive::Image { image, rect } => { - if let Some(rect) = space.visible_rect(*rect)? { - self.image(image, active_images)?; - image_id = Some(image.id()); - self.quad(space, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?; - } - } - } - let end = self.vertices.len() as u32; - if start != end { - if let Some(last) = self.batches.last_mut() - && last.image == image_id - && last.scissor == scissor - { - last.vertices.end = end; - } else { - self.batches.push(Batch { - vertices: start..end, - image: image_id, - scissor, - }); - } - } - Ok(()) - } - - fn image(&mut self, image: &RasterImage, active: &HashSet) -> Result<(), RenderError> { - if self.images.contains_key(&image.id()) { - return Ok(()); - } - if image - .size - .iter() - .any(|v| *v > self.device.limits().max_texture_dimension_2d) - { - return Err(RenderError::ImageTooLarge); - } - let bytes = image.pixels().len() as u64; - while self.images.values().map(|i| i.bytes).sum::() + bytes > MAX_IMAGE_BYTES - || self.images.len() >= MAX_IMAGES - { - let id = self - .images - .keys() - .find(|id| !active.contains(id)) - .copied() - .ok_or(RenderError::ImageBudget)?; - self.images.remove(&id); - } - let size = wgpu::Extent3d { - width: image.size[0], - height: image.size[1], - depth_or_array_layers: 1, - }; - let texture = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("Draw image"), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - self.queue.write_texture( - texture.as_image_copy(), - image.pixels(), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(image.size[0] * 4), - rows_per_image: Some(image.size[1]), - }, - size, - ); - let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Draw image"), - layout: &self.pipeline.get_bind_group_layout(0), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView( - &texture.create_view(&Default::default()), - ), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&self.image_sampler), - }, - ], - }); - self.images - .insert(image.id(), CachedImage { binding, bytes }); - Ok(()) - } - - fn glyph_run(&mut self, space: Space, run: GlyphRun<'_>) -> Result<(), RenderError> { - let scale = space.scale; - let origin = space.origin; - let line_top = run.line[0] * scale + origin[1]; - let line_height = run.line[1] * scale; - if line_top + line_height < 0.0 || line_top > space.size[1] as f32 { - return Ok(()); - } - let size = run.size * scale; - if !size.is_finite() || size > ATLAS_SIZE as f32 { - return Err(RenderError::AtlasFull); - } - for glyph in run.glyphs { - let x = glyph.x * scale + origin[0]; - let y = glyph.y * scale + origin[1]; - // Quantization affects raster coverage only, never advances or line breaks. - let x = (x * 4.0).round() * 0.25; - let y = (y * 4.0).round() * 0.25; - let glyph_id = glyph - .id - .try_into() - .map_err(|_| RenderError::UnsupportedGlyph)?; - let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; - let key = AtlasKey::Text { - font: run.font.id(), - index: run.index, - glyph: glyph_id, - size: size.to_bits(), - coords: run.coords.to_vec(), - phase, - embolden: run.embolden, - skew: run.skew.unwrap_or(0.0).to_bits(), - }; - let cached = if let Some(cached) = self.glyphs.get(&key) { - *cached - } else { - if self.glyphs.len() >= MAX_GLYPHS { - return Err(RenderError::AtlasFull); - } - let font = FontRef::from_index(run.font.as_ref(), run.index as usize) - .ok_or(RenderError::UnreadableFont)?; - let mut scaler = self - .scaler - .builder_with_id(font, [run.font.id(), u64::from(run.index)]) - .size(size) - .hint(false) - .normalized_coords(run.coords.iter().copied()) - .build(); - let mut render = Render::new(&[ - Source::ColorOutline(0), - Source::ColorBitmap(StrikeWith::BestFit), - Source::Outline, - ]); - // Swash rasterizes outlines in Y-up coordinates. - render.format(Format::Alpha).offset(Vector::new( - f32::from(phase[0]) * 0.25, - -f32::from(phase[1]) * 0.25, - )); - if run.embolden { - render.embolden(size / 24.0); - } - if let Some(skew) = run.skew { - render.transform(Some(Transform::skew( - Angle::from_degrees(skew), - Angle::from_degrees(0.0), - ))); - } - let image = render.render(&mut scaler, glyph_id); - let cached = if let Some(image) = - image.filter(|i| i.placement.width > 0 && i.placement.height > 0) - { - Some(self.upload(image)?) - } else { - None - }; - self.glyphs.insert(key, cached); - cached - }; - if let Some(glyph) = cached { - let mut left = x.floor() + glyph.left as f32; - let mut top = y.floor() - glyph.top as f32; - let mut width = glyph.width as f32; - let mut height = glyph.height as f32; - if glyph.color { - let ratio = (line_height / height).min(1.0); - left += width * (1.0 - ratio) * 0.5; - width *= ratio; - height *= ratio; - top = top.clamp(line_top, (line_top + line_height - height).max(line_top)); - } - let atlas_size = ATLAS_SIZE as f32; - self.quad( - space, - [left, top, left + width, top + height], - [ - glyph.x as f32 / atlas_size, - glyph.y as f32 / atlas_size, - (glyph.x + glyph.width) as f32 / atlas_size, - (glyph.y + glyph.height) as f32 / atlas_size, - ], - if glyph.color { [1.0; 4] } else { run.color }, - )?; - } - } - for decoration in run.decorations { - let x = decoration.x * scale + origin[0]; - let y = decoration.y * scale + origin[1]; - self.quad( - space, - [ - x, - y, - x + decoration.width * scale, - y + (decoration.thickness * scale).max(1.0), - ], - [0.5 / ATLAS_SIZE as f32; 4], - decoration.color, - )?; - } - Ok(()) - } - - fn upload(&mut self, image: swash::scale::image::Image) -> Result { - let p = image.placement; - if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE { - return Err(RenderError::AtlasFull); - } - if self.pen[0] + p.width + 1 > ATLAS_SIZE { - self.pen[0] = 0; - self.pen[1] += self.row_height + 1; - self.row_height = 0; - } - if self.pen[1] + p.height + 1 > ATLAS_SIZE { - return Err(RenderError::AtlasFull); - } - let cached = AtlasGlyph { - x: self.pen[0], - y: self.pen[1], - width: p.width, - height: p.height, - left: p.left, - top: p.top, - color: image.content == Content::Color, - }; - let rgba = match image.content { - Content::Mask => image - .data - .iter() - .flat_map(|a| [255, 255, 255, *a]) - .collect(), - _ => image.data, - }; - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - origin: wgpu::Origin3d { - x: cached.x, - y: cached.y, - z: 0, - }, - ..self.atlas.as_image_copy() - }, - &rgba, - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(p.width * 4), - rows_per_image: Some(p.height), - }, - wgpu::Extent3d { - width: p.width, - height: p.height, - depth_or_array_layers: 1, - }, - ); - self.pen[0] += p.width + 1; - self.row_height = self.row_height.max(p.height); - Ok(cached) - } - - fn icon( - &mut self, - space: Space, - sources: &'static [&'static str], - size: f32, - opacity: f32, - ) -> Result<(), RenderError> { - let size = size * space.scale; - let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25); - if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { - return Err(RenderError::InvalidPrimitive); - } - if x + size + 1.0 < 0.0 - || y + size + 1.0 < 0.0 - || x - 1.0 > space.size[0] as f32 - || y - 1.0 > space.size[1] as f32 - { - return Ok(()); - } - if size + 3.0 > ATLAS_SIZE as f32 { - return Err(RenderError::AtlasFull); - } - let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; - let key = AtlasKey::Icon { - sources, - size: size.to_bits(), - phase, - }; - let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { - *glyph - } else { - if self.glyphs.len() >= MAX_GLYPHS { - return Err(RenderError::AtlasFull); - } - let glyph = self.upload(icon::rasterize(sources, size, phase))?; - self.glyphs.insert(key, Some(glyph)); - glyph - }; - let left = x.floor(); - let top = y.floor(); - let atlas = ATLAS_SIZE as f32; - self.quad( - space, - [ - left, - top, - left + glyph.width as f32, - top + glyph.height as f32, - ], - [ - glyph.x as f32 / atlas, - glyph.y as f32 / atlas, - (glyph.x + glyph.width) as f32 / atlas, - (glyph.y + glyph.height) as f32 / atlas, - ], - [1.0, 1.0, 1.0, opacity], - ) - } - - /// Draws the segment as a capsule in its own frame, reusing the rounded-rectangle distance. - fn segment( - &mut self, - space: Space, - from: [f32; 2], - to: [f32; 2], - width: f32, - round: bool, - color: [f32; 4], - ) -> Result<(), RenderError> { - let pixel = |p: [f32; 2]| { - [ - p[0] * space.scale + space.origin[0], - p[1] * space.scale + space.origin[1], - ] - }; - let [from, to] = [pixel(from), pixel(to)]; - // Hairlines stay one device pixel wide. - let radius = (width * space.scale).max(1.0) * 0.5; - if from.iter().chain(&to).chain(&color).any(|v| !v.is_finite()) || !radius.is_finite() { - return Err(RenderError::InvalidPrimitive); - } - let pad = radius + 1.0; - if from[0].max(to[0]) + pad < 0.0 - || from[1].max(to[1]) + pad < 0.0 - || from[0].min(to[0]) - pad > space.size[0] as f32 - || from[1].min(to[1]) - pad > space.size[1] as f32 - { - return Ok(()); - } - if self.vertices.len() + 6 > MAX_VERTICES { - return Err(RenderError::FrameTooLarge); - } - let delta = [to[0] - from[0], to[1] - from[1]]; - let length = delta[0].hypot(delta[1]); - let along = if length > 0.0 { - [delta[0] / length, delta[1] / length] - } else { - [1.0, 0.0] - }; - let across = [-along[1], along[0]]; - let center = [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]; - let half = [length * 0.5 + radius, radius]; - let corner = if round { radius } else { 0.0 }; - let [hx, hy] = [half[0] + 1.0, half[1] + 1.0]; - for local in [ - [-hx, -hy], - [-hx, hy], - [hx, hy], - [-hx, -hy], - [hx, hy], - [hx, -hy], - ] { - let x = center[0] + along[0] * local[0] + across[0] * local[1]; - let y = center[1] + along[1] * local[0] + across[1] * local[1]; - self.vertices.push(Vertex { - position: [ - x * 2.0 / space.size[0] as f32 - 1.0, - 1.0 - y * 2.0 / space.size[1] as f32, - ], - uv: [0.5 / ATLAS_SIZE as f32; 2], - color, - local, - shape: [half[0], half[1], corner, corner], - stroke: 0.0, - }); - } - Ok(()) - } - - fn quad( - &mut self, - space: Space, - rect: [f32; 4], - uv: [f32; 4], - color: [f32; 4], - ) -> Result<(), RenderError> { - if rect.iter().chain(&color).any(|v| !v.is_finite()) - || rect[2] < rect[0] - || rect[3] < rect[1] - { - return Err(RenderError::InvalidPrimitive); - } - if rect[2] < 0.0 - || rect[3] < 0.0 - || rect[0] > space.size[0] as f32 - || rect[1] > space.size[1] as f32 - { - return Ok(()); - } - if self.vertices.len() + 6 > MAX_VERTICES { - return Err(RenderError::FrameTooLarge); - } - let x0 = rect[0] * 2.0 / space.size[0] as f32 - 1.0; - let x1 = rect[2] * 2.0 / space.size[0] as f32 - 1.0; - let y0 = 1.0 - rect[1] * 2.0 / space.size[1] as f32; - let y1 = 1.0 - rect[3] * 2.0 / space.size[1] as f32; - if [x0, x1, y0, y1].iter().any(|v| !v.is_finite()) { - return Err(RenderError::InvalidPrimitive); - } - let [hx, hy] = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; - for (position, uv, local) in [ - ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), - ([x0, y1], [uv[0], uv[3]], [-hx, hy]), - ([x1, y1], [uv[2], uv[3]], [hx, hy]), - ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), - ([x1, y1], [uv[2], uv[3]], [hx, hy]), - ([x1, y0], [uv[2], uv[1]], [hx, -hy]), - ] { - self.vertices.push(Vertex { - position, - uv, - color, - local, - shape: [0.0; 4], - stroke: 0.0, - }); - } - Ok(()) - } -} - -fn srgb_byte(value: f32) -> u8 { - let srgb = if value <= 0.0031308 { - value * 12.92 - } else { - 1.055 * value.powf(1.0 / 2.4) - 0.055 - }; - (srgb * 255.0).round() as u8 -} - -/// 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; - if value <= 0.04045 { - value / 12.92 - } else { - ((value + 0.055) / 1.055).powf(2.4) - } - }; - [linear(red), linear(green), linear(blue), 1.0] -} - -#[cfg(test)] -mod tests { - use super::*; - use parley::{ - FontContext, FontFamily, FontFamilyName, GenericFamily, Layout, LayoutContext, - PositionedLayoutItem, StyleProperty, - }; - use std::time::Duration; - - const CHECKBOX: &str = r##""##; - const MARK: &str = r##""##; - const DOT: &str = r##""##; - - struct Text(Layout<[u8; 4]>); - - impl Glyphs for Text { - fn runs( - &self, - paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, - ) -> Result<(), RenderError> { - for line in self.0.lines() { - let metrics = line.metrics(); - for item in line.items() { - if let PositionedLayoutItem::GlyphRun(run) = item { - paint_parley_run( - &run, - metrics.baseline, - 0.0, - [metrics.block_min_coord, metrics.line_height], - |_| [0.0, 0.0, 0.0, 1.0], - paint, - )?; - } - } - } - Ok(()) - } - } - - fn shape(text: &str, family: &str, size: f32, underline: bool, width: f32) -> Text { - let mut fonts = FontContext::default(); - let mut context = LayoutContext::default(); - let mut builder = context.ranged_builder(&mut fonts, text, 1.0, false); - builder.push_default(StyleProperty::FontFamily(FontFamily::List( - vec![ - FontFamilyName::named(family), - GenericFamily::SansSerif.into(), - ] - .into(), - ))); - builder.push_default(StyleProperty::FontSize(size)); - builder.push_default(StyleProperty::Underline(underline)); - let mut layout = builder.build(text); - layout.break_all_lines(Some(width)); - Text(layout) - } - - #[test] - fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() { - let mut encoded = Vec::new(); - let mut encoder = png::Encoder::new(&mut encoded, 2, 1); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - let rgba = [255, 64, 0, 255, 0, 128, 255, 128]; - encoder - .write_header() - .unwrap() - .write_image_data(&rgba) - .unwrap(); - let decoded = RasterImage::decode(&encoded).unwrap(); - assert_eq!(decoded.size, [2, 1]); - assert_eq!(decoded.pixels(), [255, 64, 0, 255, 0, 93, 188, 128]); - for end in 0..encoded.len() - 12 { - assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}"); - } - - let mut jpeg = Vec::new(); - image::codecs::jpeg::JpegEncoder::new(&mut jpeg) - .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) - .unwrap(); - let decoded = RasterImage::decode(&jpeg).unwrap(); - assert_eq!(decoded.size, [4, 4]); - assert_eq!(decoded.pixels(), [255; 4 * 4 * 4]); - - let mut gif = Vec::new(); - image::codecs::gif::GifEncoder::new(&mut gif) - .encode(&[255; 2 * 2 * 4], 2, 2, image::ExtendedColorType::Rgba8) - .unwrap(); - let decoded = RasterImage::decode(&gif).unwrap(); - assert_eq!(decoded.size, [2, 2]); - assert_eq!(decoded.pixels(), [255; 2 * 2 * 4]); - - for size in [[8192, 8192], [16_385, 1]] { - let mut header = Vec::new(); - let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - let mut writer = encoder.write_header().unwrap(); - writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); - drop(writer); - let error = RasterImage::decode(&header).err().unwrap(); - if size[0] == 8192 { - assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); - } else { - assert!( - matches!( - error, - RenderError::ImageDecode(image::ImageError::Limits(_)) - ), - "{error:?}" - ); - } - } - } - - #[test] - fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { - assert!(matches!( - RasterImage::new([0, 1], Vec::new()), - Err(RenderError::InvalidImage) - )); - assert!(matches!( - RasterImage::new([2, 2], vec![255; 15]), - Err(RenderError::InvalidImage) - )); - assert!(matches!( - RasterImage::new([u32::MAX; 2], Vec::new()), - Err(RenderError::InvalidImage) - )); - let image = RasterImage::new([1, 1], vec![1, 2, 3, 255]).unwrap(); - assert_eq!(image.id(), image.clone().id()); - } - - struct Target { - texture: wgpu::Texture, - readback: wgpu::Buffer, - } - - impl Target { - fn new(device: &wgpu::Device) -> Self { - Self { - texture: device.create_texture(&wgpu::TextureDescriptor { - label: Some("Draw readback test"), - size: wgpu::Extent3d { - width: 512, - height: 256, - 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: &[], - }), - readback: device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Draw readback"), - size: 512 * 256 * 4, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }), - } - } - - fn draw(&self, renderer: &mut Renderer, layers: &[Layer<'_>]) -> Result<(), RenderError> { - renderer.draw( - &self.texture.create_view(&Default::default()), - [512, 256], - [1.0; 4], - layers, - ) - } - - fn capture(&self, renderer: &Renderer) -> Vec { - let mut encoder = renderer.device.create_command_encoder(&Default::default()); - encoder.copy_texture_to_buffer( - self.texture.as_image_copy(), - wgpu::TexelCopyBufferInfo { - buffer: &self.readback, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(512 * 4), - rows_per_image: Some(256), - }, - }, - wgpu::Extent3d { - width: 512, - height: 256, - depth_or_array_layers: 1, - }, - ); - renderer.queue.submit([encoder.finish()]); - let (sender, receiver) = std::sync::mpsc::channel(); - self.readback - .map_async(wgpu::MapMode::Read, .., move |result| { - sender.send(result).unwrap(); - }); - renderer - .device - .poll(wgpu::PollType::Wait { - submission_index: None, - timeout: Some(Duration::from_secs(5)), - }) - .unwrap(); - receiver - .recv_timeout(Duration::from_secs(5)) - .unwrap() - .unwrap(); - let capture = self.readback.get_mapped_range(..).unwrap().to_vec(); - self.readback.unmap(); - capture - } - } - - fn page<'a>(primitives: &'a [Primitive<'a>]) -> [Layer<'a>; 1] { - [Layer { - scale: 2.0, - origin: [24.0; 2], - clip: None, - primitives, - }] - } - - #[test] - #[ignore = "requires a native GPU adapter"] - fn eviction_and_repaint_reproduce_pixels() { - 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 format = wgpu::TextureFormat::Rgba8UnormSrgb; - let target = Target::new(&device); - let mut renderer = Renderer::new(device, queue, format); - let layout = shape("Hello 🌳\nCafé e\u{301} שלום", "Arial", 11.0, true, 220.0); - let mut captures = Vec::new(); - let image = RasterImage::new( - [2, 2], - vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0], - ) - .unwrap(); - let transparent_edge = - RasterImage::new([2, 1], vec![255, 0, 0, 255, 0, 0, 255, 0]).unwrap(); - let icons: [&'static [&'static str]; 4] = [&[CHECKBOX], &[CHECKBOX, MARK], &[DOT], &[MARK]]; - let phase_layout = shape("H", "Arial", 11.0, false, 20.0); - let mut primitives = vec![ - Primitive::Rect { - rect: [0.0, 0.0, 70.0, 14.0], - color: [0.55, 0.73, 1.0, 0.5], - }, - Primitive::Text { - clip: None, - text: &layout, - origin: [0.0; 2], - }, - Primitive::Text { - clip: None, - text: &layout, - origin: [8.0, 64.0], - }, - Primitive::Image { - image: &image, - rect: [128.0, 8.0, 160.0, 40.0], - }, - Primitive::Rect { - rect: [128.0, 8.0, 132.0, 12.0], - color: [0.0, 0.0, 1.0, 1.0], - }, - Primitive::Image { - image: &transparent_edge, - rect: [176.0, 8.0, 208.0, 40.0], - }, - Primitive::RoundedRect { - rect: [128.0, 48.0, 148.0, 68.0], - radius: [5.0, 10.0], - stroke: None, - color: [1.0, 0.0, 0.0, 1.0], - }, - Primitive::RoundedRect { - rect: [164.0, 48.0, 184.0, 68.0], - radius: [5.0; 2], - stroke: Some(Stroke::Solid(1.0)), - color: [0.0, 0.0, 1.0, 1.0], - }, - Primitive::RoundedRect { - rect: [200.0, 48.0, 240.0, 68.0], - radius: [3.0, 10.0], - stroke: Some(Stroke::Dashed(1.0)), - color: [0.0, 0.0, 1.0, 1.0], - }, - Primitive::Segment { - from: [226.0, 104.0], - to: [240.0, 104.0], - width: 4.0, - round: true, - color: [1.0, 0.0, 0.0, 1.0], - }, - ]; - for (index, sources) in icons.iter().enumerate() { - primitives.push(Primitive::Icon { - sources, - origin: [128.125 + 20.0 * index as f32, 80.25], - size: 12.0, - opacity: 1.0, - }); - } - primitives.extend((0..9).map(|step| Primitive::Text { - clip: None, - text: &phase_layout, - origin: [8.0 + 24.0 * step as f32, 96.0 + 0.125 * step as f32], - })); - for pass in 0..6 { - if pass == 2 { - renderer.clear_glyph_cache(); - renderer.images.clear(); - } else if pass == 3 { - renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format); - } - let clipping = [ - Primitive::Text { - text: &layout, - origin: [0.0; 2], - clip: (pass == 5).then_some([2.5, 1.25, 30.75, 20.5]), - }, - Primitive::Rect { - rect: [40.0, 40.0, 70.0, 50.0], - color: [0.0, 0.0, 1.0, 1.0], - }, - ]; - target - .draw( - &mut renderer, - &page(if pass < 4 { &primitives } else { &clipping }), - ) - .unwrap(); - captures.push(target.capture(&renderer)); - } - let centroids: Vec<_> = (0..9) - .map(|step| { - let mut weight = 0.0_f64; - let mut moment = 0.0_f64; - for y in 212..252 { - for x in (40 + 48 * step)..(72 + 48 * step) { - let value = captures[0][(y * 512 + x) * 4]; - let coverage = 1.0 - f64::from(srgb(value, 0, 0)[0]); - weight += coverage; - moment += coverage * y as f64; - } - } - assert!(weight > 10.0, "phase sample {step} was not painted"); - moment / weight - }) - .collect(); - let pixel = |x: usize, y: usize| &captures[0][(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; - assert_eq!(pixel(490, 232), [255, 0, 0, 255]); - assert_eq!(pixel(473, 232), [255, 0, 0, 255]); - assert_eq!(pixel(490, 238), [255; 4]); - assert_eq!(pixel(472, 228), [255; 4]); - assert_eq!(pixel(300, 140), [255, 0, 0, 255]); - assert_eq!(pixel(280, 120), [255; 4]); - assert_eq!(pixel(282, 124), [255; 4]); - assert_eq!(pixel(281, 140), [255, 0, 0, 255]); - assert_eq!(pixel(372, 140), [255; 4]); - assert_eq!(pixel(372, 120), [0, 0, 255, 255]); - assert_eq!(pixel(352, 120), [255; 4]); - assert_eq!(pixel(424, 120), [255; 4]); - assert_eq!(pixel(464, 140), [255; 4]); - assert!((440..480).any(|x| pixel(x, 120)[0] < 40)); - assert!((440..480).any(|x| pixel(x, 120)[0] > 250)); - for x in 440..472 { - assert!(pixel(x, 120)[0].abs_diff(pixel(x + 8, 120)[0]) <= 1); - } - for (step, centroid) in centroids.iter().enumerate() { - let movement = centroid - centroids[0]; - assert!( - (movement - step as f64 * 0.25).abs() < 0.06, - "quarter-pixel step {step} moved the glyph by {movement} px" - ); - } - assert!(!renderer.glyphs.is_empty()); - assert!(renderer.glyphs.len() <= MAX_GLYPHS); - assert_eq!( - renderer - .glyphs - .keys() - .filter(|key| matches!(key, AtlasKey::Icon { .. })) - .count(), - 4 - ); - for x in [280, 320, 360, 400] { - let colored = (184..210) - .flat_map(|y| (x..x + 26).map(move |x| (y * 512 + x) * 4)) - .filter(|offset| captures[0][*offset..*offset + 3].iter().any(|v| *v < 180)) - .count(); - assert!(colored > 10, "icon at {x} was not painted"); - } - let count = renderer.glyphs.len(); - target - .draw( - &mut renderer, - &page(&[Primitive::Icon { - sources: icons[0], - origin: [10000.0; 2], - size: 12.0, - opacity: 1.0, - }]), - ) - .unwrap(); - assert_eq!(renderer.glyphs.len(), count); - assert!( - captures[0] - .chunks_exact(4) - .filter(|pixel| pixel[0] < 200) - .count() - > 100 - ); - assert_eq!(captures[0], captures[1]); - assert_eq!(captures[0], captures[2]); - assert_eq!(captures[0], captures[3]); - assert_ne!(captures[4], captures[5]); - for y in 0..256 { - for x in 0..512 { - let offset = (y * 512 + x) * 4; - let expected = if ((29..86).contains(&x) && (26..65).contains(&y)) - || ((104..164).contains(&x) && (104..124).contains(&y)) - { - &captures[4][offset..offset + 4] - } else { - &[255; 4] - }; - assert_eq!( - &captures[5][offset..offset + 4], - expected, - "clipping at {x},{y}" - ); - } - } - for (x, y, color) in [ - (288, 48, [255, 0, 0, 255]), - (336, 96, [255; 4]), - (284, 44, [0, 0, 255, 255]), - ] { - let offset = (y * 512 + x) * 4; - assert_eq!(captures[0][offset..offset + 4], color); - } - let edge = &captures[0][(48 * 512 + 408) * 4..(48 * 512 + 408) * 4 + 4]; - assert_eq!(edge[0], 255, "{edge:?}"); - assert_eq!(edge[1], edge[2], "{edge:?}"); - assert!((180..=200).contains(&edge[1]), "{edge:?}"); - assert_eq!(renderer.images.len(), 2); - for primitive in [ - Primitive::RoundedRect { - rect: [-1e38, 0.0, 1e38, 1e38], - radius: [3.0; 2], - stroke: Some(Stroke::Dashed(1.0)), - color: [1.0; 4], - }, - Primitive::RoundedRect { - rect: [0.0, 0.0, 10.0, 10.0], - radius: [f32::NAN, 2.0], - stroke: None, - color: [1.0; 4], - }, - Primitive::RoundedRect { - rect: [0.0, 0.0, 10.0, 10.0], - radius: [2.0; 2], - stroke: Some(Stroke::Solid(0.0)), - color: [1.0; 4], - }, - Primitive::RoundedRect { - rect: [0.0, 0.0, 10.0, 10.0], - radius: [2.0; 2], - stroke: Some(Stroke::Dashed(f32::NAN)), - color: [1.0; 4], - }, - Primitive::Rect { - rect: [0.0, 0.0, f32::NAN, 1.0], - color: [1.0; 4], - }, - Primitive::Rect { - rect: [2.0, 0.0, 1.0, 1.0], - color: [1.0; 4], - }, - Primitive::Text { - clip: None, - text: &layout, - origin: [f32::MAX; 2], - }, - Primitive::Text { - clip: Some([0.0, 0.0, f32::NAN, 1.0]), - text: &layout, - origin: [0.0; 2], - }, - Primitive::Text { - clip: Some([2.0, 0.0, 1.0, 1.0]), - text: &layout, - origin: [0.0; 2], - }, - ] { - assert!(matches!( - target.draw(&mut renderer, &page(&[primitive])), - Err(RenderError::InvalidPrimitive) - )); - } - let too_many: Vec<_> = (0..=MAX_IMAGES) - .map(|_| RasterImage::new([1, 1], vec![255; 4]).unwrap()) - .collect(); - let primitives: Vec<_> = too_many - .iter() - .map(|image| Primitive::Image { - image, - rect: [0.0, 0.0, 1.0, 1.0], - }) - .collect(); - assert!(matches!( - target.draw(&mut renderer, &page(&primitives)), - Err(RenderError::ImageBudget) - )); - let primitives: Vec<_> = too_many - .iter() - .map(|image| Primitive::Image { - image, - rect: [-20.0, -20.0, -19.0, -19.0], - }) - .collect(); - target.draw(&mut renderer, &page(&primitives)).unwrap(); - assert_eq!(renderer.images.len(), 2); - let mut seen = HashSet::from([image.id(), transparent_edge.id()]); - let mut previous = None; - for _ in 0..5 { - let large = RasterImage::new([2048; 2], vec![255; 2048 * 2048 * 4]).unwrap(); - seen.insert(large.id()); - let mut primitives = vec![Primitive::Image { - image: &large, - rect: [0.0, 0.0, 16.0, 16.0], - }]; - if let Some(image) = &previous { - primitives.push(Primitive::Image { - image, - rect: [16.0, 0.0, 32.0, 16.0], - }); - } - target.draw(&mut renderer, &page(&primitives)).unwrap(); - assert!(renderer.images.contains_key(&large.id())); - if let Some(image) = &previous { - assert!(renderer.images.contains_key(&image.id())); - } - assert!(renderer.occupancy().within_budget); - previous = Some(large); - } - assert!(renderer.images.len() <= MAX_IMAGES); - assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); - renderer.images.clear(); - target - .draw( - &mut renderer, - &page(&[Primitive::Image { - image: &image, - rect: [128.0, 8.0, 160.0, 40.0], - }]), - ) - .unwrap(); - assert_eq!(renderer.images.len(), 1); - assert!(renderer.images.contains_key(&image.id())); - } - - #[test] - #[ignore = "requires a native GPU adapter"] - fn layers_transform_and_clip_independently() { - 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 target = Target::new(&device); - let mut renderer = Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); - let fill = [Primitive::Rect { - rect: [0.0, 0.0, 300.0, 100.0], - color: [1.0, 0.0, 0.0, 1.0], - }]; - let chrome = [Primitive::Rect { - rect: [0.0, 0.0, 10.0, 10.0], - color: [0.0, 0.0, 1.0, 1.0], - }]; - target - .draw( - &mut renderer, - &[ - Layer { - scale: 1.0, - origin: [0.0; 2], - clip: Some([100.0, 50.0, 200.5, 80.0]), - primitives: &fill, - }, - Layer { - scale: 2.0, - origin: [300.0, 10.0], - clip: None, - primitives: &chrome, - }, - ], - ) - .unwrap(); - let capture = target.capture(&renderer); - let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; - assert_eq!(pixel(10, 10), [255; 4], "clipped away"); - assert_eq!(pixel(100, 50), [255, 0, 0, 255]); - assert_eq!( - pixel(200, 79), - [255, 0, 0, 255], - "partial pixels round outward" - ); - assert_eq!(pixel(99, 60), [255; 4]); - assert_eq!(pixel(201, 60), [255; 4]); - assert_eq!(pixel(150, 80), [255; 4]); - assert_eq!(pixel(319, 29), [0, 0, 255, 255]); - assert_eq!(pixel(320, 30), [255; 4]); - assert!(matches!( - target.draw( - &mut renderer, - &[Layer { - scale: 0.0, - origin: [0.0; 2], - clip: None, - primitives: &fill, - }] - ), - Err(RenderError::InvalidLayer) - )); - } -} +#[cfg(feature = "render")] +pub use render::*; diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs new file mode 100644 index 0000000000000000000000000000000000000000..c4d023177bb4c0d9145e39743948b58e7bc04c72 --- /dev/null +++ b/crates/draw/src/render.rs @@ -0,0 +1,2135 @@ +mod icon; +mod text; + +pub use text::{Decoration, Glyph, GlyphRun, Glyphs, paint_parley_run}; + +use bytemuck::{Pod, Zeroable}; +use image::ImageDecoder; +use parley::fontique::Blob; +use std::{ + collections::{HashMap, HashSet}, + ops::Range, +}; +use swash::{ + FontRef, + scale::{Render, ScaleContext, Source, StrikeWith, image::Content}, + zeno::{Angle, Cap, Format, Join, Mask, Transform, Vector}, +}; + +const ATLAS_SIZE: u32 = 2048; +const MAX_GLYPHS: usize = 8192; +const MAX_VERTICES: usize = 65_536; +/// Decoded bytes of all images one frame may paint. +pub const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_IMAGES: usize = 256; + +#[derive(Clone)] +pub struct RasterImage { + size: [u32; 2], + pixels: Blob, +} + +impl RasterImage { + pub fn decode(encoded: &[u8]) -> Result { + if encoded.len() as u64 > MAX_IMAGE_BYTES { + return Err(RenderError::ImageBudget); + } + let format = image::guess_format(encoded).map_err(RenderError::ImageDecode)?; + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(encoded), format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16_384); + limits.max_image_height = Some(16_384); + limits.max_alloc = Some(MAX_IMAGE_BYTES); + reader.limits(limits.clone()); + let mut decoder = reader.into_decoder().map_err(RenderError::ImageDecode)?; + let (width, height) = decoder.dimensions(); + if u64::from(width) * u64::from(height) * 4 > MAX_IMAGE_BYTES + || decoder.total_bytes() > MAX_IMAGE_BYTES + { + return Err(RenderError::ImageBudget); + } + limits + .reserve(decoder.total_bytes()) + .map_err(RenderError::ImageDecode)?; + decoder + .set_limits(limits) + .map_err(RenderError::ImageDecode)?; + let pixels = image::DynamicImage::from_decoder(decoder) + .map_err(RenderError::ImageDecode)? + .into_rgba8(); + Self::new([width, height], pixels.into_raw()) + } + + /// Immutable, straight-alpha sRGB RGBA pixels retain one cache identity across clones. + pub fn new(size: [u32; 2], mut pixels: Vec) -> Result { + let bytes = u64::from(size[0]) + .checked_mul(u64::from(size[1])) + .and_then(|v| v.checked_mul(4)) + .ok_or(RenderError::InvalidImage)?; + if size.contains(&0) || bytes != pixels.len() as u64 { + return Err(RenderError::InvalidImage); + } + if bytes > MAX_IMAGE_BYTES { + return Err(RenderError::ImageBudget); + } + // Premultiply in linear light before filtering; transparent RGB must not bleed at edges. + for pixel in pixels.chunks_exact_mut(4) { + let alpha = pixel[3]; + if alpha == 255 { + continue; + } + let linear = srgb(pixel[0], pixel[1], pixel[2]); + for (byte, value) in pixel[..3].iter_mut().zip(linear) { + let value = value * f32::from(alpha) / 255.0; + *byte = srgb_byte(value); + } + } + Ok(Self { + size, + pixels: pixels.into(), + }) + } + + /// The identity caches and uploads share across clones. + pub fn id(&self) -> u64 { + self.pixels.id() + } + + /// Premultiplied sRGB RGBA rows. + pub fn pixels(&self) -> &[u8] { + self.pixels.as_ref() + } +} + +struct CachedImage { + binding: wgpu::BindGroup, + bytes: u64, +} + +struct Batch { + vertices: Range, + image: Option, + scissor: [u32; 4], +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct Vertex { + position: [f32; 2], + uv: [f32; 2], + color: [f32; 4], + local: [f32; 2], + shape: [f32; 4], + stroke: f32, +} + +#[derive(Hash, PartialEq, Eq)] +enum AtlasKey { + Text { + font: u64, + index: u32, + glyph: u16, + size: u32, + coords: Vec, + phase: [u8; 2], + embolden: bool, + skew: u32, + }, + Icon { + sources: &'static [&'static str], + size: u32, + phase: [u8; 2], + }, + Path { + data: String, + scale: u32, + /// The style's kind and width bits. + style: (u8, u32), + phase: [u8; 2], + }, +} + +#[derive(Clone, Copy)] +struct AtlasGlyph { + x: u32, + y: u32, + width: u32, + height: u32, + left: i32, + top: i32, + color: bool, +} + +/// Primitives sharing one transform and clip. +pub struct Layer<'a> { + /// Device pixels per layer unit. + pub scale: f32, + /// Device position of the layer origin. + pub origin: [f32; 2], + /// Device bounds `[left, top, right, bottom]` the layer paints within. + pub clip: Option<[f32; 4]>, + pub primitives: &'a [Primitive<'a>], +} + +/// A layer's transform onto the target. +#[derive(Clone, Copy)] +struct Space { + size: [u32; 2], + scale: f32, + origin: [f32; 2], +} + +impl Space { + fn visible_rect(&self, rect: [f32; 4]) -> Result, RenderError> { + let rect = [ + rect[0] * self.scale + self.origin[0], + rect[1] * self.scale + self.origin[1], + rect[2] * self.scale + self.origin[0], + rect[3] * self.scale + self.origin[1], + ]; + if rect.iter().any(|v| !v.is_finite()) || rect[2] < rect[0] || rect[3] < rect[1] { + return Err(RenderError::InvalidPrimitive); + } + Ok((rect[0] < self.size[0] as f32 + && rect[1] < self.size[1] as f32 + && rect[2] > 0.0 + && rect[3] > 0.0 + && rect[0] < rect[2] + && rect[1] < rect[3]) + .then_some(rect)) + } + + /// Whole device pixels covering `rect` within the target, as a scissor rectangle. + fn scissor(&self, rect: [f32; 4]) -> [u32; 4] { + let left = rect[0].max(0.0).floor() as u32; + let top = rect[1].max(0.0).floor() as u32; + let right = (rect[2].min(self.size[0] as f32).ceil() as u32).max(left); + let bottom = (rect[3].min(self.size[1] as f32).ceil() as u32).max(top); + [left, top, right - left, bottom - top] + } +} + +fn intersect(a: [u32; 4], b: [u32; 4]) -> [u32; 4] { + let left = a[0].max(b[0]); + let top = a[1].max(b[1]); + let right = (a[0] + a[2]).min(b[0] + b[2]).max(left); + let bottom = (a[1] + a[3]).min(b[1] + b[3]).max(top); + [left, top, right - left, bottom - top] +} + +/// How a path paints; widths are layer units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PathStyle { + Fill, + /// Round caps and joins. + Stroke(f32), + /// Filled and blurred this far, as a soft shadow. + Shadow(f32), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Stroke { + Solid(f32), + /// Dash and gap lengths are twice the stroke width. + Dashed(f32), +} + +/// Coordinates are layer units; colors are linear RGBA. +pub enum Primitive<'a> { + Text { + text: &'a dyn Glyphs, + origin: [f32; 2], + /// Layer bounds of the painted glyphs, which keep their full shaping and advances. + clip: Option<[f32; 4]>, + /// The colour of runs and decorations without their own. + ink: [f32; 4], + }, + /// 16×16 SVG artwork painted `size` units square, later sources over earlier ones. + /// `tint` multiplies it; `currentColor` paints white, so it takes the tint's colour. + Icon { + sources: &'static [&'static str], + origin: [f32; 2], + size: f32, + tint: [f32; 4], + }, + /// SVG path data in layer units from `origin`, shaded from `colors[0]` at the top of + /// what it paints to `colors[1]` at the bottom. Each distinct path is rasterized once + /// per scale and style. + Path { + data: &'a str, + origin: [f32; 2], + style: PathStyle, + colors: [[f32; 4]; 2], + }, + Rect { + rect: [f32; 4], + color: [f32; 4], + }, + RoundedRect { + rect: [f32; 4], + radius: [f32; 2], + /// None fills the shape; stroke widths are positive layer units. + stroke: Option, + color: [f32; 4], + }, + /// A rounded rectangle filled from `colors[0]` at its top to `colors[1]` at its bottom. + Gradient { + rect: [f32; 4], + radius: [f32; 2], + colors: [[f32; 4]; 2], + }, + Image { + image: &'a RasterImage, + rect: [f32; 4], + }, + /// A pen stroke between two points, `width` units across. + Segment { + from: [f32; 2], + to: [f32; 2], + width: f32, + /// A round pen tip caps the ends; otherwise they are square. + round: bool, + color: [f32; 4], + }, +} + +#[derive(Debug)] +pub enum RenderError { + AtlasFull, + FrameTooLarge, + UnreadableFont, + UnsupportedGlyph, + InvalidLayer, + InvalidPrimitive, + InvalidImage, + ImageBudget, + ImageTooLarge, + ImageDecode(image::ImageError), +} + +/// Cache and buffer use after the last frame, for resource probes. Texture and buffer +/// sizes are as requested, without driver overhead. +#[derive(Debug)] +pub struct Occupancy { + /// Glyph and icon atlas entries, including glyphs with no pixels. + pub glyphs: usize, + pub atlas_texels: u64, + pub images: usize, + pub image_bytes: u64, + pub vertices: usize, + pub batches: usize, + pub vertex_capacity_bytes: usize, + pub batch_capacity_bytes: usize, + pub glyph_capacity: usize, + pub image_capacity: usize, + pub atlas_bytes: u64, + pub vertex_buffer_bytes: u64, + pub within_budget: bool, +} + +pub struct Renderer { + pub device: wgpu::Device, + pub queue: wgpu::Queue, + pipeline: wgpu::RenderPipeline, + image_pipeline: wgpu::RenderPipeline, + atlas: wgpu::Texture, + bind_group: wgpu::BindGroup, + image_sampler: wgpu::Sampler, + vertex_buffer: wgpu::Buffer, + vertices: Vec, + glyphs: HashMap>, + images: HashMap, + batches: Vec, + scaler: ScaleContext, + pen: [u32; 2], + row_height: u32, +} + +impl Renderer { + pub fn new(device: wgpu::Device, queue: wgpu::Queue, format: wgpu::TextureFormat) -> Self { + let shader = device.create_shader_module(wgpu::include_wgsl!("draw.wgsl")); + let binding_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Draw texture"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Draw"), + bind_group_layouts: &[Some(&binding_layout)], + ..Default::default() + }); + let make_pipeline = |blend| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Draw"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vertex"), + compilation_options: Default::default(), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4, 3 => Float32x2, 4 => Float32x4, 5 => Float32], + })], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fragment"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) + }; + let pipeline = make_pipeline(wgpu::BlendState::ALPHA_BLENDING); + let image_pipeline = make_pipeline(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING); + let atlas = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Glyph atlas"), + size: wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = atlas.create_view(&Default::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Glyph atlas"), + layout: &pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Draw vertices"), + size: (MAX_VERTICES * size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Draw image filtering"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + queue.write_texture( + atlas.as_image_copy(), + &[255; 4], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + Self { + device, + queue, + pipeline, + image_pipeline, + atlas, + bind_group, + image_sampler, + vertex_buffer, + vertices: Vec::new(), + glyphs: HashMap::new(), + images: HashMap::new(), + batches: Vec::new(), + scaler: ScaleContext::with_max_entries(32), + pen: [1, 0], + row_height: 1, + } + } + + pub fn clear_glyph_cache(&mut self) { + self.glyphs.clear(); + self.pen = [1, 0]; + self.row_height = 1; + } + + /// Forgets uploaded images; they upload again when next painted. + pub fn clear_images(&mut self) { + self.images.clear(); + } + + pub fn occupancy(&self) -> Occupancy { + let image_bytes = self.images.values().map(|image| image.bytes).sum(); + Occupancy { + glyphs: self.glyphs.len(), + atlas_texels: self + .glyphs + .values() + .flatten() + .map(|glyph| u64::from(glyph.width) * u64::from(glyph.height)) + .sum(), + images: self.images.len(), + image_bytes, + vertices: self.vertices.len(), + batches: self.batches.len(), + vertex_capacity_bytes: self.vertices.capacity() * size_of::(), + batch_capacity_bytes: self.batches.capacity() * size_of::(), + glyph_capacity: self.glyphs.capacity(), + image_capacity: self.images.capacity(), + atlas_bytes: u64::from(self.atlas.width()) * u64::from(self.atlas.height()) * 4, + vertex_buffer_bytes: self.vertex_buffer.size(), + within_budget: self.glyphs.len() <= MAX_GLYPHS + && self.images.len() <= MAX_IMAGES + && image_bytes <= MAX_IMAGE_BYTES + && self.vertices.len() <= MAX_VERTICES, + } + } + + /// Clears `target`, `size` device pixels, to linear `clear` and paints the layers in order. + pub fn draw( + &mut self, + target: &wgpu::TextureView, + size: [u32; 2], + clear: [f32; 4], + layers: &[Layer<'_>], + ) -> Result<(), RenderError> { + if size.contains(&0) + || clear.iter().any(|v| !v.is_finite()) + || layers.iter().any(|layer| { + !layer.scale.is_finite() + || layer.scale <= 0.0 + || layer.origin.iter().any(|v| !v.is_finite()) + || layer + .clip + .is_some_and(|clip| clip.iter().any(|v| !v.is_finite())) + }) + { + return Err(RenderError::InvalidLayer); + } + let mut active_images = HashSet::new(); + let mut image_bytes = 0; + for layer in layers { + let space = Space { + size, + scale: layer.scale, + origin: layer.origin, + }; + for primitive in layer.primitives { + if let Primitive::Image { image, rect } = primitive + && space.visible_rect(*rect)?.is_some() + && active_images.insert(image.id()) + { + image_bytes += image.pixels().len() as u64; + if image_bytes > MAX_IMAGE_BYTES || active_images.len() > MAX_IMAGES { + return Err(RenderError::ImageBudget); + } + } + } + } + for attempt in 0..2 { + self.vertices.clear(); + self.batches.clear(); + match self.prepare(size, layers, &active_images) { + Err(RenderError::AtlasFull) if attempt == 0 => self.clear_glyph_cache(), + result => { + result?; + break; + } + } + } + self.queue + .write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.vertices)); + let mut encoder = self.device.create_command_encoder(&Default::default()); + { + let [r, g, b, a] = clear.map(f64::from); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Draw"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); + for batch in &self.batches { + let [x, y, width, height] = batch.scissor; + pass.set_scissor_rect(x, y, width, height); + pass.set_pipeline(if batch.image.is_some() { + &self.image_pipeline + } else { + &self.pipeline + }); + let binding = batch + .image + .map(|id| &self.images[&id].binding) + .unwrap_or(&self.bind_group); + pass.set_bind_group(0, binding, &[]); + pass.draw(batch.vertices.clone(), 0..1); + } + } + self.queue.submit([encoder.finish()]); + Ok(()) + } + + fn prepare( + &mut self, + size: [u32; 2], + layers: &[Layer<'_>], + active_images: &HashSet, + ) -> Result<(), RenderError> { + for layer in layers { + let space = Space { + size, + scale: layer.scale, + origin: layer.origin, + }; + let bounds = match layer.clip { + Some(clip) => space.scissor(clip), + None => [0, 0, size[0], size[1]], + }; + if bounds[2] == 0 || bounds[3] == 0 { + continue; + } + for primitive in layer.primitives { + self.primitive(space, bounds, primitive, active_images)?; + } + } + Ok(()) + } + + fn primitive( + &mut self, + space: Space, + bounds: [u32; 4], + primitive: &Primitive<'_>, + active_images: &HashSet, + ) -> Result<(), RenderError> { + let start = self.vertices.len() as u32; + let mut image_id = None; + let mut scissor = bounds; + match primitive { + Primitive::Icon { + sources, + origin, + size, + tint, + } => { + let origin = [ + space.origin[0] + origin[0] * space.scale, + space.origin[1] + origin[1] * space.scale, + ]; + self.icon(Space { origin, ..space }, sources, *size, *tint)?; + } + Primitive::Path { + data, + origin, + style, + colors, + } => self.path(space, data, *origin, *style, *colors)?, + Primitive::Text { + text, + origin, + clip, + ink, + } => { + let origin = [ + space.origin[0] + origin[0] * space.scale, + space.origin[1] + origin[1] * space.scale, + ]; + if origin.iter().any(|v| !v.is_finite()) { + return Err(RenderError::InvalidPrimitive); + } + if let Some(clip) = clip { + let Some(rect) = space.visible_rect(*clip)? else { + return Ok(()); + }; + scissor = intersect(bounds, space.scissor(rect)); + if scissor[2] == 0 || scissor[3] == 0 { + return Ok(()); + } + } + let space = Space { origin, ..space }; + text.runs(&mut |run| self.glyph_run(space, run, *ink))?; + } + Primitive::Rect { rect, color } => { + self.quad( + space, + [ + rect[0] * space.scale + space.origin[0], + rect[1] * space.scale + space.origin[1], + rect[2] * space.scale + space.origin[0], + rect[3] * space.scale + space.origin[1], + ], + [0.5 / ATLAS_SIZE as f32; 4], + *color, + )?; + } + Primitive::RoundedRect { + rect, + radius, + stroke, + color, + } => self.rounded_rect(space, *rect, *radius, *stroke, [*color; 2])?, + Primitive::Gradient { + rect, + radius, + colors, + } => self.rounded_rect(space, *rect, *radius, None, *colors)?, + Primitive::Segment { + from, + to, + width, + round, + color, + } => self.segment(space, *from, *to, *width, *round, *color)?, + Primitive::Image { image, rect } => { + if let Some(rect) = space.visible_rect(*rect)? { + self.image(image, active_images)?; + image_id = Some(image.id()); + self.quad(space, rect, [0.0, 0.0, 1.0, 1.0], [1.0; 4])?; + } + } + } + let end = self.vertices.len() as u32; + if start != end { + if let Some(last) = self.batches.last_mut() + && last.image == image_id + && last.scissor == scissor + { + last.vertices.end = end; + } else { + self.batches.push(Batch { + vertices: start..end, + image: image_id, + scissor, + }); + } + } + Ok(()) + } + + fn image(&mut self, image: &RasterImage, active: &HashSet) -> Result<(), RenderError> { + if self.images.contains_key(&image.id()) { + return Ok(()); + } + if image + .size + .iter() + .any(|v| *v > self.device.limits().max_texture_dimension_2d) + { + return Err(RenderError::ImageTooLarge); + } + let bytes = image.pixels().len() as u64; + while self.images.values().map(|i| i.bytes).sum::() + bytes > MAX_IMAGE_BYTES + || self.images.len() >= MAX_IMAGES + { + let id = self + .images + .keys() + .find(|id| !active.contains(id)) + .copied() + .ok_or(RenderError::ImageBudget)?; + self.images.remove(&id); + } + let size = wgpu::Extent3d { + width: image.size[0], + height: image.size[1], + depth_or_array_layers: 1, + }; + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("Draw image"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.queue.write_texture( + texture.as_image_copy(), + image.pixels(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(image.size[0] * 4), + rows_per_image: Some(image.size[1]), + }, + size, + ); + let binding = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Draw image"), + layout: &self.pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + &texture.create_view(&Default::default()), + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.image_sampler), + }, + ], + }); + self.images + .insert(image.id(), CachedImage { binding, bytes }); + Ok(()) + } + + fn glyph_run( + &mut self, + space: Space, + run: GlyphRun<'_>, + ink: [f32; 4], + ) -> Result<(), RenderError> { + let scale = space.scale; + let origin = space.origin; + let line_top = run.line[0] * scale + origin[1]; + let line_height = run.line[1] * scale; + if line_top + line_height < 0.0 || line_top > space.size[1] as f32 { + return Ok(()); + } + let size = run.size * scale; + if !size.is_finite() || size > ATLAS_SIZE as f32 { + return Err(RenderError::AtlasFull); + } + for glyph in run.glyphs { + let x = glyph.x * scale + origin[0]; + let y = glyph.y * scale + origin[1]; + // Quantization affects raster coverage only, never advances or line breaks. + let x = (x * 4.0).round() * 0.25; + let y = (y * 4.0).round() * 0.25; + let glyph_id = glyph + .id + .try_into() + .map_err(|_| RenderError::UnsupportedGlyph)?; + let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; + let key = AtlasKey::Text { + font: run.font.id(), + index: run.index, + glyph: glyph_id, + size: size.to_bits(), + coords: run.coords.to_vec(), + phase, + embolden: run.embolden, + skew: run.skew.unwrap_or(0.0).to_bits(), + }; + let cached = if let Some(cached) = self.glyphs.get(&key) { + *cached + } else { + if self.glyphs.len() >= MAX_GLYPHS { + return Err(RenderError::AtlasFull); + } + let font = FontRef::from_index(run.font.as_ref(), run.index as usize) + .ok_or(RenderError::UnreadableFont)?; + let mut scaler = self + .scaler + .builder_with_id(font, [run.font.id(), u64::from(run.index)]) + .size(size) + .hint(false) + .normalized_coords(run.coords.iter().copied()) + .build(); + let mut render = Render::new(&[ + Source::ColorOutline(0), + Source::ColorBitmap(StrikeWith::BestFit), + Source::Outline, + ]); + // Swash rasterizes outlines in Y-up coordinates. + render.format(Format::Alpha).offset(Vector::new( + f32::from(phase[0]) * 0.25, + -f32::from(phase[1]) * 0.25, + )); + if run.embolden { + render.embolden(size / 24.0); + } + if let Some(skew) = run.skew { + render.transform(Some(Transform::skew( + Angle::from_degrees(skew), + Angle::from_degrees(0.0), + ))); + } + let image = render.render(&mut scaler, glyph_id); + let cached = if let Some(image) = + image.filter(|i| i.placement.width > 0 && i.placement.height > 0) + { + Some(self.upload(image)?) + } else { + None + }; + self.glyphs.insert(key, cached); + cached + }; + if let Some(glyph) = cached { + let mut left = x.floor() + glyph.left as f32; + let mut top = y.floor() - glyph.top as f32; + let mut width = glyph.width as f32; + let mut height = glyph.height as f32; + if glyph.color { + let ratio = (line_height / height).min(1.0); + left += width * (1.0 - ratio) * 0.5; + width *= ratio; + height *= ratio; + top = top.clamp(line_top, (line_top + line_height - height).max(line_top)); + } + let atlas_size = ATLAS_SIZE as f32; + self.quad( + space, + [left, top, left + width, top + height], + [ + glyph.x as f32 / atlas_size, + glyph.y as f32 / atlas_size, + (glyph.x + glyph.width) as f32 / atlas_size, + (glyph.y + glyph.height) as f32 / atlas_size, + ], + if glyph.color { + [1.0; 4] + } else { + run.color.unwrap_or(ink) + }, + )?; + } + } + for decoration in run.decorations { + let x = decoration.x * scale + origin[0]; + let y = decoration.y * scale + origin[1]; + self.quad( + space, + [ + x, + y, + x + decoration.width * scale, + y + (decoration.thickness * scale).max(1.0), + ], + [0.5 / ATLAS_SIZE as f32; 4], + decoration.color.unwrap_or(ink), + )?; + } + Ok(()) + } + + fn upload(&mut self, image: swash::scale::image::Image) -> Result { + let p = image.placement; + if p.width + 2 > ATLAS_SIZE || p.height + 2 > ATLAS_SIZE { + return Err(RenderError::AtlasFull); + } + if self.pen[0] + p.width + 1 > ATLAS_SIZE { + self.pen[0] = 0; + self.pen[1] += self.row_height + 1; + self.row_height = 0; + } + if self.pen[1] + p.height + 1 > ATLAS_SIZE { + return Err(RenderError::AtlasFull); + } + let cached = AtlasGlyph { + x: self.pen[0], + y: self.pen[1], + width: p.width, + height: p.height, + left: p.left, + top: p.top, + color: image.content == Content::Color, + }; + let rgba = match image.content { + Content::Mask => image + .data + .iter() + .flat_map(|a| [255, 255, 255, *a]) + .collect(), + _ => image.data, + }; + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + origin: wgpu::Origin3d { + x: cached.x, + y: cached.y, + z: 0, + }, + ..self.atlas.as_image_copy() + }, + &rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(p.width * 4), + rows_per_image: Some(p.height), + }, + wgpu::Extent3d { + width: p.width, + height: p.height, + depth_or_array_layers: 1, + }, + ); + self.pen[0] += p.width + 1; + self.row_height = self.row_height.max(p.height); + Ok(cached) + } + + fn icon( + &mut self, + space: Space, + sources: &'static [&'static str], + size: f32, + tint: [f32; 4], + ) -> Result<(), RenderError> { + let size = size * space.scale; + let [x, y] = space.origin.map(|v| (v * 4.0).round() * 0.25); + if !size.is_finite() || [x, y].iter().any(|v| !v.is_finite()) { + return Err(RenderError::InvalidPrimitive); + } + if x + size + 1.0 < 0.0 + || y + size + 1.0 < 0.0 + || x - 1.0 > space.size[0] as f32 + || y - 1.0 > space.size[1] as f32 + { + return Ok(()); + } + if size + 3.0 > ATLAS_SIZE as f32 { + return Err(RenderError::AtlasFull); + } + let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; + let key = AtlasKey::Icon { + sources, + size: size.to_bits(), + phase, + }; + let glyph = if let Some(Some(glyph)) = self.glyphs.get(&key) { + *glyph + } else { + if self.glyphs.len() >= MAX_GLYPHS { + return Err(RenderError::AtlasFull); + } + let glyph = self.upload(icon::rasterize(sources, size, phase))?; + self.glyphs.insert(key, Some(glyph)); + glyph + }; + let left = x.floor(); + let top = y.floor(); + let atlas = ATLAS_SIZE as f32; + self.quad( + space, + [ + left, + top, + left + glyph.width as f32, + top + glyph.height as f32, + ], + [ + glyph.x as f32 / atlas, + glyph.y as f32 / atlas, + (glyph.x + glyph.width) as f32 / atlas, + (glyph.y + glyph.height) as f32 / atlas, + ], + tint, + ) + } + + /// `colors` are the top and bottom edges' colours. + fn rounded_rect( + &mut self, + space: Space, + rect: [f32; 4], + radius: [f32; 2], + stroke: Option, + colors: [[f32; 4]; 2], + ) -> Result<(), RenderError> { + if radius + .iter() + .any(|value| !value.is_finite() || *value < 0.0) + { + return Err(RenderError::InvalidPrimitive); + } + let width = match stroke { + None => 0.0, + Some(Stroke::Solid(width) | Stroke::Dashed(width)) => { + if !width.is_finite() || width <= 0.0 || width * space.scale == 0.0 { + return Err(RenderError::InvalidPrimitive); + } + width + } + }; + let rect = [ + rect[0] * space.scale + space.origin[0], + rect[1] * space.scale + space.origin[1], + rect[2] * space.scale + space.origin[0], + rect[3] * space.scale + space.origin[1], + ]; + let start = self.vertices.len(); + self.quad(space, rect, [0.5 / ATLAS_SIZE as f32; 4], colors[0])?; + let half = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; + if !(4.0 * (half[0] + half[1])).is_finite() || !colors[1].iter().all(|v| v.is_finite()) { + return Err(RenderError::InvalidPrimitive); + } + let radius = if radius.contains(&0.0) { + [0.0; 2] + } else { + std::array::from_fn(|axis| (radius[axis] * space.scale).min(half[axis])) + }; + let width = (width * space.scale).min(half[0].min(half[1])); + // The stroke sign selects its dash pattern at the shader boundary. + let width = if matches!(stroke, Some(Stroke::Dashed(_))) { + -width + } else { + width + }; + for vertex in &mut self.vertices[start..] { + vertex.shape = [half[0], half[1], radius[0], radius[1]]; + vertex.stroke = width; + if vertex.local[1] > 0.0 { + vertex.color = colors[1]; + } + } + Ok(()) + } + + fn path( + &mut self, + space: Space, + data: &str, + origin: [f32; 2], + style: PathStyle, + colors: [[f32; 4]; 2], + ) -> Result<(), RenderError> { + let [x, y] = [0, 1] + .map(|axis| ((space.origin[axis] + origin[axis] * space.scale) * 4.0).round() * 0.25); + let (kind, width) = match style { + PathStyle::Fill => (0, 0.0), + PathStyle::Stroke(width) => (1, width), + PathStyle::Shadow(width) => (2, width), + }; + if [x, y] + .iter() + .chain(colors.as_flattened()) + .any(|v| !v.is_finite()) + || !width.is_finite() + || width < 0.0 + || (width == 0.0 && kind != 0) + { + return Err(RenderError::InvalidPrimitive); + } + let phase = [((x - x.floor()) * 4.0) as u8, ((y - y.floor()) * 4.0) as u8]; + let key = AtlasKey::Path { + data: data.to_owned(), + scale: space.scale.to_bits(), + style: (kind, width.to_bits()), + phase, + }; + let glyph = match self.glyphs.get(&key) { + Some(glyph) => *glyph, + None => { + if self.glyphs.len() >= MAX_GLYPHS { + return Err(RenderError::AtlasFull); + } + let mut mask = Mask::new(data); + mask.transform(Some(Transform::scale(space.scale, space.scale))) + .offset(phase.map(|quarter| f32::from(quarter) * 0.25)); + if let PathStyle::Stroke(width) = style { + mask.style(swash::zeno::Stroke { + start_cap: Cap::Round, + end_cap: Cap::Round, + join: Join::Round, + ..swash::zeno::Stroke::new(width) + }); + } + let (mut pixels, mut placement) = mask.render(); + if let PathStyle::Shadow(blur) = style { + (pixels, placement) = soften(&pixels, placement, blur * space.scale); + } + let glyph = (placement.width > 0 && placement.height > 0) + .then(|| { + self.upload(swash::scale::image::Image { + content: Content::Mask, + placement, + data: pixels, + ..Default::default() + }) + }) + .transpose()?; + self.glyphs.insert(key, glyph); + glyph + } + }; + let Some(glyph) = glyph else { + return Ok(()); + }; + let left = x.floor() + glyph.left as f32; + let top = y.floor() + glyph.top as f32; + let atlas = ATLAS_SIZE as f32; + let start = self.vertices.len(); + self.quad( + space, + [ + left, + top, + left + glyph.width as f32, + top + glyph.height as f32, + ], + [ + glyph.x as f32 / atlas, + glyph.y as f32 / atlas, + (glyph.x + glyph.width) as f32 / atlas, + (glyph.y + glyph.height) as f32 / atlas, + ], + colors[0], + )?; + for vertex in &mut self.vertices[start..] { + if vertex.local[1] > 0.0 { + vertex.color = colors[1]; + } + } + Ok(()) + } + + /// Draws the segment as a capsule in its own frame, reusing the rounded-rectangle distance. + fn segment( + &mut self, + space: Space, + from: [f32; 2], + to: [f32; 2], + width: f32, + round: bool, + color: [f32; 4], + ) -> Result<(), RenderError> { + let pixel = |p: [f32; 2]| { + [ + p[0] * space.scale + space.origin[0], + p[1] * space.scale + space.origin[1], + ] + }; + let [from, to] = [pixel(from), pixel(to)]; + // Hairlines stay one device pixel wide. + let radius = (width * space.scale).max(1.0) * 0.5; + if from.iter().chain(&to).chain(&color).any(|v| !v.is_finite()) || !radius.is_finite() { + return Err(RenderError::InvalidPrimitive); + } + let pad = radius + 1.0; + if from[0].max(to[0]) + pad < 0.0 + || from[1].max(to[1]) + pad < 0.0 + || from[0].min(to[0]) - pad > space.size[0] as f32 + || from[1].min(to[1]) - pad > space.size[1] as f32 + { + return Ok(()); + } + if self.vertices.len() + 6 > MAX_VERTICES { + return Err(RenderError::FrameTooLarge); + } + let delta = [to[0] - from[0], to[1] - from[1]]; + let length = delta[0].hypot(delta[1]); + let along = if length > 0.0 { + [delta[0] / length, delta[1] / length] + } else { + [1.0, 0.0] + }; + let across = [-along[1], along[0]]; + let center = [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]; + let half = [length * 0.5 + radius, radius]; + let corner = if round { radius } else { 0.0 }; + let [hx, hy] = [half[0] + 1.0, half[1] + 1.0]; + for local in [ + [-hx, -hy], + [-hx, hy], + [hx, hy], + [-hx, -hy], + [hx, hy], + [hx, -hy], + ] { + let x = center[0] + along[0] * local[0] + across[0] * local[1]; + let y = center[1] + along[1] * local[0] + across[1] * local[1]; + self.vertices.push(Vertex { + position: [ + x * 2.0 / space.size[0] as f32 - 1.0, + 1.0 - y * 2.0 / space.size[1] as f32, + ], + uv: [0.5 / ATLAS_SIZE as f32; 2], + color, + local, + shape: [half[0], half[1], corner, corner], + stroke: 0.0, + }); + } + Ok(()) + } + + fn quad( + &mut self, + space: Space, + rect: [f32; 4], + uv: [f32; 4], + color: [f32; 4], + ) -> Result<(), RenderError> { + if rect.iter().chain(&color).any(|v| !v.is_finite()) + || rect[2] < rect[0] + || rect[3] < rect[1] + { + return Err(RenderError::InvalidPrimitive); + } + if rect[2] < 0.0 + || rect[3] < 0.0 + || rect[0] > space.size[0] as f32 + || rect[1] > space.size[1] as f32 + { + return Ok(()); + } + if self.vertices.len() + 6 > MAX_VERTICES { + return Err(RenderError::FrameTooLarge); + } + let x0 = rect[0] * 2.0 / space.size[0] as f32 - 1.0; + let x1 = rect[2] * 2.0 / space.size[0] as f32 - 1.0; + let y0 = 1.0 - rect[1] * 2.0 / space.size[1] as f32; + let y1 = 1.0 - rect[3] * 2.0 / space.size[1] as f32; + if [x0, x1, y0, y1].iter().any(|v| !v.is_finite()) { + return Err(RenderError::InvalidPrimitive); + } + let [hx, hy] = [(rect[2] - rect[0]) * 0.5, (rect[3] - rect[1]) * 0.5]; + for (position, uv, local) in [ + ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), + ([x0, y1], [uv[0], uv[3]], [-hx, hy]), + ([x1, y1], [uv[2], uv[3]], [hx, hy]), + ([x0, y0], [uv[0], uv[1]], [-hx, -hy]), + ([x1, y1], [uv[2], uv[3]], [hx, hy]), + ([x1, y0], [uv[2], uv[1]], [hx, -hy]), + ] { + self.vertices.push(Vertex { + position, + uv, + color, + local, + shape: [0.0; 4], + stroke: 0.0, + }); + } + Ok(()) + } +} + +/// Blurs a coverage mask about `radius` device pixels with three box blurs on each axis, +/// growing it to hold the spread. +fn soften( + pixels: &[u8], + placement: swash::zeno::Placement, + radius: f32, +) -> (Vec, swash::zeno::Placement) { + let reach = (radius / 2.0).ceil().max(1.0) as usize; + let pad = 3 * reach; + let [width, height] = [placement.width as usize, placement.height as usize]; + let [wide, tall] = [width + 2 * pad, height + 2 * pad]; + let mut values = vec![0.0_f32; wide * tall]; + for row in 0..height { + for column in 0..width { + values[(row + pad) * wide + column + pad] = f32::from(pixels[row * width + column]); + } + } + let window = (2 * reach + 1) as f32; + let mut line = Vec::new(); + for _ in 0..3 { + // Rows, then columns. + for (count, step, lines, stride) in [(wide, 1, tall, wide), (tall, wide, wide, 1)] { + for index in 0..lines { + let at = |position: usize| index * stride + position * step; + line.clear(); + line.extend((0..count).map(|position| values[at(position)])); + let mut sum: f32 = line[..reach.min(count)].iter().sum(); + for position in 0..count { + if position + reach < count { + sum += line[position + reach]; + } + values[at(position)] = sum / window; + if position >= reach { + sum -= line[position - reach]; + } + } + } + } + } + ( + values + .into_iter() + .map(|value| value.round().clamp(0.0, 255.0) as u8) + .collect(), + swash::zeno::Placement { + left: placement.left - pad as i32, + top: placement.top - pad as i32, + width: wide as u32, + height: tall as u32, + }, + ) +} + +fn srgb_byte(value: f32) -> u8 { + let srgb = if value <= 0.0031308 { + value * 12.92 + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + }; + (srgb * 255.0).round() as u8 +} + +/// 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; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + [linear(red), linear(green), linear(blue), 1.0] +} + +#[cfg(test)] +mod tests { + use super::*; + use parley::{ + FontContext, FontFamily, FontFamilyName, GenericFamily, Layout, LayoutContext, + PositionedLayoutItem, StyleProperty, + }; + use std::time::Duration; + + const CHECKBOX: &str = r##""##; + const MARK: &str = r##""##; + const DOT: &str = r##""##; + + struct Text(Layout<[u8; 4]>); + + impl Glyphs for Text { + fn runs( + &self, + paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, + ) -> Result<(), RenderError> { + for line in self.0.lines() { + let metrics = line.metrics(); + for item in line.items() { + if let PositionedLayoutItem::GlyphRun(run) = item { + paint_parley_run( + &run, + metrics.baseline, + 0.0, + [metrics.block_min_coord, metrics.line_height], + |_| None, + paint, + )?; + } + } + } + Ok(()) + } + } + + fn shape(text: &str, family: &str, size: f32, underline: bool, width: f32) -> Text { + let mut fonts = FontContext::default(); + let mut context = LayoutContext::default(); + let mut builder = context.ranged_builder(&mut fonts, text, 1.0, false); + builder.push_default(StyleProperty::FontFamily(FontFamily::List( + vec![ + FontFamilyName::named(family), + GenericFamily::SansSerif.into(), + ] + .into(), + ))); + builder.push_default(StyleProperty::FontSize(size)); + builder.push_default(StyleProperty::Underline(underline)); + let mut layout = builder.build(text); + layout.break_all_lines(Some(width)); + Text(layout) + } + + #[test] + fn decoding_preserves_pixels_and_rejects_large_or_truncated_images() { + let mut encoded = Vec::new(); + let mut encoder = png::Encoder::new(&mut encoded, 2, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let rgba = [255, 64, 0, 255, 0, 128, 255, 128]; + encoder + .write_header() + .unwrap() + .write_image_data(&rgba) + .unwrap(); + let decoded = RasterImage::decode(&encoded).unwrap(); + assert_eq!(decoded.size, [2, 1]); + assert_eq!(decoded.pixels(), [255, 64, 0, 255, 0, 93, 188, 128]); + for end in 0..encoded.len() - 12 { + assert!(RasterImage::decode(&encoded[..end]).is_err(), "{end}"); + } + + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new(&mut jpeg) + .encode(&[255; 4 * 4 * 3], 4, 4, image::ExtendedColorType::Rgb8) + .unwrap(); + let decoded = RasterImage::decode(&jpeg).unwrap(); + assert_eq!(decoded.size, [4, 4]); + assert_eq!(decoded.pixels(), [255; 4 * 4 * 4]); + + let mut gif = Vec::new(); + image::codecs::gif::GifEncoder::new(&mut gif) + .encode(&[255; 2 * 2 * 4], 2, 2, image::ExtendedColorType::Rgba8) + .unwrap(); + let decoded = RasterImage::decode(&gif).unwrap(); + assert_eq!(decoded.size, [2, 2]); + assert_eq!(decoded.pixels(), [255; 2 * 2 * 4]); + + for size in [[8192, 8192], [16_385, 1]] { + let mut header = Vec::new(); + let mut encoder = png::Encoder::new(&mut header, size[0], size[1]); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer.write_chunk(png::chunk::IDAT, &[]).unwrap(); + drop(writer); + let error = RasterImage::decode(&header).err().unwrap(); + if size[0] == 8192 { + assert!(matches!(error, RenderError::ImageBudget), "{error:?}"); + } else { + assert!( + matches!( + error, + RenderError::ImageDecode(image::ImageError::Limits(_)) + ), + "{error:?}" + ); + } + } + } + + #[test] + fn image_pixels_have_valid_dimensions_and_immutable_clone_identity() { + assert!(matches!( + RasterImage::new([0, 1], Vec::new()), + Err(RenderError::InvalidImage) + )); + assert!(matches!( + RasterImage::new([2, 2], vec![255; 15]), + Err(RenderError::InvalidImage) + )); + assert!(matches!( + RasterImage::new([u32::MAX; 2], Vec::new()), + Err(RenderError::InvalidImage) + )); + let image = RasterImage::new([1, 1], vec![1, 2, 3, 255]).unwrap(); + assert_eq!(image.id(), image.clone().id()); + } + + struct Target { + texture: wgpu::Texture, + readback: wgpu::Buffer, + } + + impl Target { + fn new(device: &wgpu::Device) -> Self { + Self { + texture: device.create_texture(&wgpu::TextureDescriptor { + label: Some("Draw readback test"), + size: wgpu::Extent3d { + width: 512, + height: 256, + 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: &[], + }), + readback: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Draw readback"), + size: 512 * 256 * 4, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + } + } + + fn draw(&self, renderer: &mut Renderer, layers: &[Layer<'_>]) -> Result<(), RenderError> { + renderer.draw( + &self.texture.create_view(&Default::default()), + [512, 256], + [1.0; 4], + layers, + ) + } + + fn capture(&self, renderer: &Renderer) -> Vec { + let mut encoder = renderer.device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + self.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &self.readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(512 * 4), + rows_per_image: Some(256), + }, + }, + wgpu::Extent3d { + width: 512, + height: 256, + depth_or_array_layers: 1, + }, + ); + renderer.queue.submit([encoder.finish()]); + let (sender, receiver) = std::sync::mpsc::channel(); + self.readback + .map_async(wgpu::MapMode::Read, .., move |result| { + sender.send(result).unwrap(); + }); + renderer + .device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(5)), + }) + .unwrap(); + receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .unwrap(); + let capture = self.readback.get_mapped_range(..).unwrap().to_vec(); + self.readback.unmap(); + capture + } + } + + fn page<'a>(primitives: &'a [Primitive<'a>]) -> [Layer<'a>; 1] { + [Layer { + scale: 2.0, + origin: [24.0; 2], + clip: None, + primitives, + }] + } + + #[test] + #[ignore = "requires a native GPU adapter"] + fn eviction_and_repaint_reproduce_pixels() { + 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 format = wgpu::TextureFormat::Rgba8UnormSrgb; + let target = Target::new(&device); + let mut renderer = Renderer::new(device, queue, format); + let layout = shape("Hello 🌳\nCafé e\u{301} שלום", "Arial", 11.0, true, 220.0); + let mut captures = Vec::new(); + let image = RasterImage::new( + [2, 2], + vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 0], + ) + .unwrap(); + let transparent_edge = + RasterImage::new([2, 1], vec![255, 0, 0, 255, 0, 0, 255, 0]).unwrap(); + let icons: [&'static [&'static str]; 4] = [&[CHECKBOX], &[CHECKBOX, MARK], &[DOT], &[MARK]]; + let phase_layout = shape("H", "Arial", 11.0, false, 20.0); + let mut primitives = vec![ + Primitive::Rect { + rect: [0.0, 0.0, 70.0, 14.0], + color: [0.55, 0.73, 1.0, 0.5], + }, + Primitive::Text { + clip: None, + text: &layout, + ink: [0.0, 0.0, 0.0, 1.0], + origin: [0.0; 2], + }, + Primitive::Text { + clip: None, + text: &layout, + ink: [0.0, 0.0, 0.0, 1.0], + origin: [8.0, 64.0], + }, + Primitive::Image { + image: &image, + rect: [128.0, 8.0, 160.0, 40.0], + }, + Primitive::Rect { + rect: [128.0, 8.0, 132.0, 12.0], + color: [0.0, 0.0, 1.0, 1.0], + }, + Primitive::Image { + image: &transparent_edge, + rect: [176.0, 8.0, 208.0, 40.0], + }, + Primitive::RoundedRect { + rect: [128.0, 48.0, 148.0, 68.0], + radius: [5.0, 10.0], + stroke: None, + color: [1.0, 0.0, 0.0, 1.0], + }, + Primitive::RoundedRect { + rect: [164.0, 48.0, 184.0, 68.0], + radius: [5.0; 2], + stroke: Some(Stroke::Solid(1.0)), + color: [0.0, 0.0, 1.0, 1.0], + }, + Primitive::RoundedRect { + rect: [200.0, 48.0, 240.0, 68.0], + radius: [3.0, 10.0], + stroke: Some(Stroke::Dashed(1.0)), + color: [0.0, 0.0, 1.0, 1.0], + }, + Primitive::Segment { + from: [226.0, 104.0], + to: [240.0, 104.0], + width: 4.0, + round: true, + color: [1.0, 0.0, 0.0, 1.0], + }, + ]; + for (index, sources) in icons.iter().enumerate() { + primitives.push(Primitive::Icon { + sources, + origin: [128.125 + 20.0 * index as f32, 80.25], + size: 12.0, + tint: [1.0; 4], + }); + } + primitives.extend((0..9).map(|step| Primitive::Text { + clip: None, + text: &phase_layout, + ink: [0.0, 0.0, 0.0, 1.0], + origin: [8.0 + 24.0 * step as f32, 96.0 + 0.125 * step as f32], + })); + for pass in 0..6 { + if pass == 2 { + renderer.clear_glyph_cache(); + renderer.images.clear(); + } else if pass == 3 { + renderer = Renderer::new(renderer.device.clone(), renderer.queue.clone(), format); + } + let clipping = [ + Primitive::Text { + text: &layout, + ink: [0.0, 0.0, 0.0, 1.0], + origin: [0.0; 2], + clip: (pass == 5).then_some([2.5, 1.25, 30.75, 20.5]), + }, + Primitive::Rect { + rect: [40.0, 40.0, 70.0, 50.0], + color: [0.0, 0.0, 1.0, 1.0], + }, + ]; + target + .draw( + &mut renderer, + &page(if pass < 4 { &primitives } else { &clipping }), + ) + .unwrap(); + captures.push(target.capture(&renderer)); + } + let centroids: Vec<_> = (0..9) + .map(|step| { + let mut weight = 0.0_f64; + let mut moment = 0.0_f64; + for y in 212..252 { + for x in (40 + 48 * step)..(72 + 48 * step) { + let value = captures[0][(y * 512 + x) * 4]; + let coverage = 1.0 - f64::from(srgb(value, 0, 0)[0]); + weight += coverage; + moment += coverage * y as f64; + } + } + assert!(weight > 10.0, "phase sample {step} was not painted"); + moment / weight + }) + .collect(); + let pixel = |x: usize, y: usize| &captures[0][(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; + assert_eq!(pixel(490, 232), [255, 0, 0, 255]); + assert_eq!(pixel(473, 232), [255, 0, 0, 255]); + assert_eq!(pixel(490, 238), [255; 4]); + assert_eq!(pixel(472, 228), [255; 4]); + assert_eq!(pixel(300, 140), [255, 0, 0, 255]); + assert_eq!(pixel(280, 120), [255; 4]); + assert_eq!(pixel(282, 124), [255; 4]); + assert_eq!(pixel(281, 140), [255, 0, 0, 255]); + assert_eq!(pixel(372, 140), [255; 4]); + assert_eq!(pixel(372, 120), [0, 0, 255, 255]); + assert_eq!(pixel(352, 120), [255; 4]); + assert_eq!(pixel(424, 120), [255; 4]); + assert_eq!(pixel(464, 140), [255; 4]); + assert!((440..480).any(|x| pixel(x, 120)[0] < 40)); + assert!((440..480).any(|x| pixel(x, 120)[0] > 250)); + for x in 440..472 { + assert!(pixel(x, 120)[0].abs_diff(pixel(x + 8, 120)[0]) <= 1); + } + for (step, centroid) in centroids.iter().enumerate() { + let movement = centroid - centroids[0]; + assert!( + (movement - step as f64 * 0.25).abs() < 0.06, + "quarter-pixel step {step} moved the glyph by {movement} px" + ); + } + assert!(!renderer.glyphs.is_empty()); + assert!(renderer.glyphs.len() <= MAX_GLYPHS); + assert_eq!( + renderer + .glyphs + .keys() + .filter(|key| matches!(key, AtlasKey::Icon { .. })) + .count(), + 4 + ); + for x in [280, 320, 360, 400] { + let colored = (184..210) + .flat_map(|y| (x..x + 26).map(move |x| (y * 512 + x) * 4)) + .filter(|offset| captures[0][*offset..*offset + 3].iter().any(|v| *v < 180)) + .count(); + assert!(colored > 10, "icon at {x} was not painted"); + } + let count = renderer.glyphs.len(); + target + .draw( + &mut renderer, + &page(&[Primitive::Icon { + sources: icons[0], + origin: [10000.0; 2], + size: 12.0, + tint: [1.0; 4], + }]), + ) + .unwrap(); + assert_eq!(renderer.glyphs.len(), count); + assert!( + captures[0] + .chunks_exact(4) + .filter(|pixel| pixel[0] < 200) + .count() + > 100 + ); + assert_eq!(captures[0], captures[1]); + assert_eq!(captures[0], captures[2]); + assert_eq!(captures[0], captures[3]); + assert_ne!(captures[4], captures[5]); + for y in 0..256 { + for x in 0..512 { + let offset = (y * 512 + x) * 4; + let expected = if ((29..86).contains(&x) && (26..65).contains(&y)) + || ((104..164).contains(&x) && (104..124).contains(&y)) + { + &captures[4][offset..offset + 4] + } else { + &[255; 4] + }; + assert_eq!( + &captures[5][offset..offset + 4], + expected, + "clipping at {x},{y}" + ); + } + } + for (x, y, color) in [ + (288, 48, [255, 0, 0, 255]), + (336, 96, [255; 4]), + (284, 44, [0, 0, 255, 255]), + ] { + let offset = (y * 512 + x) * 4; + assert_eq!(captures[0][offset..offset + 4], color); + } + let edge = &captures[0][(48 * 512 + 408) * 4..(48 * 512 + 408) * 4 + 4]; + assert_eq!(edge[0], 255, "{edge:?}"); + assert_eq!(edge[1], edge[2], "{edge:?}"); + assert!((180..=200).contains(&edge[1]), "{edge:?}"); + assert_eq!(renderer.images.len(), 2); + for primitive in [ + Primitive::RoundedRect { + rect: [-1e38, 0.0, 1e38, 1e38], + radius: [3.0; 2], + stroke: Some(Stroke::Dashed(1.0)), + color: [1.0; 4], + }, + Primitive::RoundedRect { + rect: [0.0, 0.0, 10.0, 10.0], + radius: [f32::NAN, 2.0], + stroke: None, + color: [1.0; 4], + }, + Primitive::RoundedRect { + rect: [0.0, 0.0, 10.0, 10.0], + radius: [2.0; 2], + stroke: Some(Stroke::Solid(0.0)), + color: [1.0; 4], + }, + Primitive::RoundedRect { + rect: [0.0, 0.0, 10.0, 10.0], + radius: [2.0; 2], + stroke: Some(Stroke::Dashed(f32::NAN)), + color: [1.0; 4], + }, + Primitive::Rect { + rect: [0.0, 0.0, f32::NAN, 1.0], + color: [1.0; 4], + }, + Primitive::Rect { + rect: [2.0, 0.0, 1.0, 1.0], + color: [1.0; 4], + }, + Primitive::Text { + clip: None, + text: &layout, + ink: [0.0, 0.0, 0.0, 1.0], + origin: [f32::MAX; 2], + }, + Primitive::Text { + clip: Some([0.0, 0.0, f32::NAN, 1.0]), + text: &layout, + ink: [0.0, 0.0, 0.0, 1.0], + origin: [0.0; 2], + }, + Primitive::Text { + clip: Some([2.0, 0.0, 1.0, 1.0]), + text: &layout, + ink: [0.0, 0.0, 0.0, 1.0], + origin: [0.0; 2], + }, + ] { + assert!(matches!( + target.draw(&mut renderer, &page(&[primitive])), + Err(RenderError::InvalidPrimitive) + )); + } + let too_many: Vec<_> = (0..=MAX_IMAGES) + .map(|_| RasterImage::new([1, 1], vec![255; 4]).unwrap()) + .collect(); + let primitives: Vec<_> = too_many + .iter() + .map(|image| Primitive::Image { + image, + rect: [0.0, 0.0, 1.0, 1.0], + }) + .collect(); + assert!(matches!( + target.draw(&mut renderer, &page(&primitives)), + Err(RenderError::ImageBudget) + )); + let primitives: Vec<_> = too_many + .iter() + .map(|image| Primitive::Image { + image, + rect: [-20.0, -20.0, -19.0, -19.0], + }) + .collect(); + target.draw(&mut renderer, &page(&primitives)).unwrap(); + assert_eq!(renderer.images.len(), 2); + let mut seen = HashSet::from([image.id(), transparent_edge.id()]); + let mut previous = None; + for _ in 0..5 { + let large = RasterImage::new([2048; 2], vec![255; 2048 * 2048 * 4]).unwrap(); + seen.insert(large.id()); + let mut primitives = vec![Primitive::Image { + image: &large, + rect: [0.0, 0.0, 16.0, 16.0], + }]; + if let Some(image) = &previous { + primitives.push(Primitive::Image { + image, + rect: [16.0, 0.0, 32.0, 16.0], + }); + } + target.draw(&mut renderer, &page(&primitives)).unwrap(); + assert!(renderer.images.contains_key(&large.id())); + if let Some(image) = &previous { + assert!(renderer.images.contains_key(&image.id())); + } + assert!(renderer.occupancy().within_budget); + previous = Some(large); + } + assert!(renderer.images.len() <= MAX_IMAGES); + assert!(seen.iter().any(|id| !renderer.images.contains_key(id))); + renderer.images.clear(); + target + .draw( + &mut renderer, + &page(&[Primitive::Image { + image: &image, + rect: [128.0, 8.0, 160.0, 40.0], + }]), + ) + .unwrap(); + assert_eq!(renderer.images.len(), 1); + assert!(renderer.images.contains_key(&image.id())); + } + + #[test] + #[ignore = "requires a native GPU adapter"] + fn paths_fill_and_stroke_in_layer_units() { + 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 target = Target::new(&device); + let mut renderer = Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); + let primitives = [ + Primitive::Path { + data: "M0 0H20L30 20H0Z", + origin: [10.0, 10.0], + style: PathStyle::Fill, + colors: [[0.0, 0.0, 1.0, 1.0]; 2], + }, + Primitive::Path { + data: "M0 0H40", + origin: [10.0, 60.0], + style: PathStyle::Stroke(4.0), + colors: [[1.0, 0.0, 0.0, 1.0]; 2], + }, + Primitive::Path { + data: "M0 0H20V20H0Z", + origin: [150.0, 20.0], + style: PathStyle::Shadow(6.0), + colors: [[0.0, 0.0, 0.0, 1.0]; 2], + }, + Primitive::Gradient { + rect: [100.0, 10.0, 120.0, 110.0], + radius: [0.0; 2], + colors: [[0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0]], + }, + ]; + target + .draw( + &mut renderer, + &[Layer { + scale: 2.0, + origin: [0.0; 2], + clip: None, + primitives: &primitives, + }], + ) + .unwrap(); + let capture = target.capture(&renderer); + let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; + assert_eq!(pixel(40, 40), [0, 0, 255, 255]); + assert_eq!(pixel(70, 55), [0, 0, 255, 255], "inside the slanted edge"); + assert_eq!(pixel(75, 30), [255; 4], "outside the slanted edge"); + let edge = pixel(65, 30); + assert!( + edge[0] > 0 && edge[0] < 255, + "the slant is antialiased: {edge:?}" + ); + assert_eq!(pixel(60, 120), [255, 0, 0, 255]); + assert_eq!(pixel(60, 112), [255; 4], "strokes are their width across"); + assert_eq!(pixel(17, 120)[1], 0, "round caps reach past the ends"); + assert_eq!(pixel(12, 120), [255; 4]); + let [top, bottom] = [pixel(220, 21), pixel(220, 218)]; + assert!( + top[2] > 250 && top[1] < 60, + "gradients start at the top colour: {top:?}" + ); + assert!( + bottom[1] > 250 && bottom[2] < 60, + "and end at the bottom's: {bottom:?}" + ); + let [inside, fading, beyond] = [pixel(320, 60), pixel(298, 60), pixel(270, 60)]; + assert!(inside[0] < 40, "a shadow is dark inside: {inside:?}"); + assert!( + fading[0] > 40 && fading[0] < 230, + "and fades at its edge: {fading:?}" + ); + assert_eq!(beyond, [255; 4]); + } + + #[test] + #[ignore = "requires a native GPU adapter"] + fn layers_transform_and_clip_independently() { + 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 target = Target::new(&device); + let mut renderer = Renderer::new(device, queue, wgpu::TextureFormat::Rgba8UnormSrgb); + let fill = [Primitive::Rect { + rect: [0.0, 0.0, 300.0, 100.0], + color: [1.0, 0.0, 0.0, 1.0], + }]; + let chrome = [Primitive::Rect { + rect: [0.0, 0.0, 10.0, 10.0], + color: [0.0, 0.0, 1.0, 1.0], + }]; + target + .draw( + &mut renderer, + &[ + Layer { + scale: 1.0, + origin: [0.0; 2], + clip: Some([100.0, 50.0, 200.5, 80.0]), + primitives: &fill, + }, + Layer { + scale: 2.0, + origin: [300.0, 10.0], + clip: None, + primitives: &chrome, + }, + ], + ) + .unwrap(); + let capture = target.capture(&renderer); + let pixel = |x: usize, y: usize| &capture[(y * 512 + x) * 4..(y * 512 + x) * 4 + 4]; + assert_eq!(pixel(10, 10), [255; 4], "clipped away"); + assert_eq!(pixel(100, 50), [255, 0, 0, 255]); + assert_eq!( + pixel(200, 79), + [255, 0, 0, 255], + "partial pixels round outward" + ); + assert_eq!(pixel(99, 60), [255; 4]); + assert_eq!(pixel(201, 60), [255; 4]); + assert_eq!(pixel(150, 80), [255; 4]); + assert_eq!(pixel(319, 29), [0, 0, 255, 255]); + assert_eq!(pixel(320, 30), [255; 4]); + assert!(matches!( + target.draw( + &mut renderer, + &[Layer { + scale: 0.0, + origin: [0.0; 2], + clip: None, + primitives: &fill, + }] + ), + Err(RenderError::InvalidLayer) + )); + } +} diff --git a/crates/draw/src/render/icon.rs b/crates/draw/src/render/icon.rs new file mode 100644 index 0000000000000000000000000000000000000000..45bc71b605b85db5a06dc37ec1d5a8e92166b635 --- /dev/null +++ b/crates/draw/src/render/icon.rs @@ -0,0 +1,161 @@ +use swash::{ + scale::image::{Content, Image}, + zeno::{Cap, Fill, Join, Mask, Placement, Stroke, Style, Transform, Vector}, +}; + +/// Rasterizes 16×16 SVG sources over each other at `size` device pixels, offset by +/// `phase` quarter pixels. Paths are filled and stroked with `#rrggbb`, two-stop vertical +/// gradients or `currentColor`, which paints white; strokes have round caps and joins. +pub(crate) fn rasterize(sources: &[&str], size: f32, phase: [u8; 2]) -> Image { + let side = (size + 1.0).ceil() as u32; + let mut pixels = vec![[0.0_f32; 4]; (side * side) as usize]; + for source in sources { + let svg = roxmltree::Document::parse(source).expect("Bundled icon SVG must be valid"); + assert_eq!(svg.root_element().attribute("viewBox"), Some("0 0 16 16")); + let paint = |value: &str| -> Option<[[f32; 4]; 2]> { + let hex = |value: &str| { + let [_, r, g, b] = u32::from_str_radix(value.strip_prefix('#').unwrap(), 16) + .unwrap() + .to_be_bytes(); + super::srgb(r, g, b) + }; + match value { + "none" => None, + "currentColor" => Some([[1.0; 4]; 2]), + _ => Some(match value.strip_prefix("url(#") { + Some(id) => { + let id = id.strip_suffix(')').unwrap(); + let gradient = svg + .descendants() + .find(|node| node.attribute("id") == Some(id)) + .unwrap(); + let stops: Vec<_> = gradient + .children() + .filter(|node| node.has_tag_name("stop")) + .map(|node| hex(node.attribute("stop-color").unwrap())) + .collect(); + stops.try_into().expect("Icon gradients have two stops") + } + None => [hex(value); 2], + }), + } + }; + for path in svg.descendants().filter(|node| node.has_tag_name("path")) { + let fill = match path.attribute("fill-rule") { + Some("evenodd") => Fill::EvenOdd, + _ => Fill::NonZero, + }; + let pen = path + .attribute("stroke-width") + .map_or(1.0, |width| width.parse().unwrap()); + let layers = [ + ( + path.attribute("fill").unwrap_or("#000000"), + Style::from(fill), + ), + ( + path.attribute("stroke").unwrap_or("none"), + Style::from(Stroke { + start_cap: Cap::Round, + end_cap: Cap::Round, + join: Join::Round, + ..Stroke::new(pen) + }), + ), + ]; + for (value, style) in layers { + let Some([top, bottom]) = paint(value) else { + continue; + }; + let (mask, _) = Mask::new(path.attribute("d").unwrap()) + .style(style) + .transform(Some(Transform::scale(size / 16.0, size / 16.0))) + .render_offset(Vector::new(phase[0] as f32 * 0.25, phase[1] as f32 * 0.25)) + .size(side, side) + .render(); + for (index, (pixel, coverage)) in pixels.iter_mut().zip(mask).enumerate() { + let alpha = f32::from(coverage) / 255.0; + let y = ((index / side as usize) as f32 / size).min(1.0); + for channel in 0..3 { + let color = top[channel] + (bottom[channel] - top[channel]) * y; + pixel[channel] = color * alpha + pixel[channel] * (1.0 - alpha); + } + pixel[3] = alpha + pixel[3] * (1.0 - alpha); + } + } + } + } + let data = pixels + .into_iter() + .flat_map(|pixel| { + let alpha = pixel[3]; + let channel = |value: f32| { + let value = if alpha > 0.0 { value / alpha } else { 0.0 }; + super::srgb_byte(value) + }; + [ + channel(pixel[0]), + channel(pixel[1]), + channel(pixel[2]), + (alpha * 255.0).round() as u8, + ] + }) + .collect(); + Image { + content: Content::Color, + placement: Placement { + left: 0, + top: 0, + width: side, + height: side, + }, + data, + ..Image::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BOX: &str = r##""##; + const MARK: &str = r##""##; + + #[test] + fn current_color_strokes_paint_white_coverage() { + const LINE: &str = r##""##; + let image = rasterize(&[LINE], 16.0, [0, 0]); + let pixel = |x: usize, y: usize| &image.data[(y * 17 + x) * 4..][..4]; + assert_eq!(pixel(8, 7), [255, 255, 255, 255]); + assert_eq!(pixel(8, 2)[3], 0); + // Round caps reach past the path's ends. + assert!(pixel(1, 7)[3] > 0); + } + + #[test] + fn icons_keep_transparency_and_overlays_across_scales() { + for size in [8.0, 16.0, 32.0, 57.5] { + for phase in [[0, 0], [1, 2], [3, 3]] { + let plain = rasterize(&[BOX], size, phase); + let marked = rasterize(&[BOX, MARK], size, phase); + for image in [&plain, &marked] { + assert_eq!( + image.data.len(), + (image.placement.width * image.placement.height * 4) as usize + ); + assert!(image.data.chunks_exact(4).any(|p| p[3] > 0)); + assert!(image.data.chunks_exact(4).any(|p| p[3] < 255)); + assert!( + image + .data + .chunks_exact(4) + .filter(|p| p[3] == 0) + .all(|p| p[..3] == [0; 3]) + ); + } + assert_ne!(plain.data, marked.data); + assert!(marked.data.chunks_exact(4).any(|p| p[0] > p[2] && p[3] > 0)); + } + } + } +} diff --git a/crates/draw/src/render/text.rs b/crates/draw/src/render/text.rs new file mode 100644 index 0000000000000000000000000000000000000000..1fd8b0165c34c21122e1a0f99324decd8393c406 --- /dev/null +++ b/crates/draw/src/render/text.rs @@ -0,0 +1,115 @@ +use crate::RenderError; +use parley::fontique::Blob; + +/// Text the renderer can paint, as glyph runs in layer units. Owners recover their +/// concrete text from a primitive through `Any`. +pub trait Glyphs: std::any::Any { + fn runs( + &self, + paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, + ) -> Result<(), RenderError>; +} + +/// Glyphs sharing one font, size and colour. +pub struct GlyphRun<'a> { + pub font: &'a Blob, + /// The face within a font collection. + pub index: u32, + pub size: f32, + /// Variation coordinates in F2Dot14 bits. + pub coords: &'a [i16], + pub embolden: bool, + /// Synthesized oblique angle in degrees. + pub skew: Option, + /// `None` paints in the primitive's ink. + pub color: 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], + pub glyphs: &'a mut dyn Iterator, + /// Underline and strikethrough, painted after the glyphs. + pub decorations: &'a [Decoration], +} + +/// A glyph's baseline position. +#[derive(Clone, Copy)] +pub struct Glyph { + pub id: u32, + pub x: f32, + pub y: f32, +} + +/// A decoration line whose top edge is `y`; it stays at least one device pixel thick. +#[derive(Clone, Copy)] +pub struct Decoration { + pub x: f32, + pub y: f32, + pub width: f32, + pub thickness: f32, + /// `None` paints in the primitive's ink. + pub color: Option<[f32; 4]>, +} + +/// Paints one parley glyph run with its line's baseline moved to `baseline` and its +/// glyphs raised by `rise`. +pub fn paint_parley_run( + run: &parley::GlyphRun<'_, B>, + baseline: f32, + rise: f32, + line: [f32; 2], + color: impl Fn(&B) -> Option<[f32; 4]>, + paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, +) -> Result<(), RenderError> { + let shaped = run.run(); + let font = &shaped.font().font; + let coords: Vec = shaped + .normalized_coords() + .iter() + .map(|c| c.to_bits()) + .collect(); + let synthesis = shaped.synthesis(); + let style = run.style(); + let metrics = shaped.font_metrics(); + let decorations: Vec<_> = [ + ( + &style.underline, + metrics.underline_offset, + metrics.underline_size, + ), + ( + &style.strikethrough, + metrics.strikethrough_offset, + metrics.strikethrough_size, + ), + ] + .into_iter() + .filter_map(|(decoration, offset, thickness)| { + let decoration = decoration.as_ref()?; + Some(Decoration { + x: run.offset(), + y: baseline - rise - decoration.offset.unwrap_or(offset), + width: run.advance(), + thickness: decoration.size.unwrap_or(thickness), + color: color(&decoration.brush), + }) + }) + .collect(); + let line_baseline = run.baseline(); + let mut glyphs = run.positioned_glyphs().map(|glyph| Glyph { + id: glyph.id, + x: glyph.x, + y: glyph.y + baseline - line_baseline - rise, + }); + paint(GlyphRun { + font: &font.data, + index: font.index, + size: shaped.font_size(), + coords: &coords, + embolden: synthesis.embolden(), + skew: synthesis.skew(), + color: color(&style.brush), + line, + glyphs: &mut glyphs, + decorations: &decorations, + }) +} diff --git a/crates/draw/src/text.rs b/crates/draw/src/text.rs deleted file mode 100644 index c17d43d67bf0ac01c9e88c38495c895c9633ad18..0000000000000000000000000000000000000000 --- a/crates/draw/src/text.rs +++ /dev/null @@ -1,113 +0,0 @@ -use crate::RenderError; -use parley::fontique::Blob; - -/// Text the renderer can paint, as glyph runs in layer units. Owners recover their -/// concrete text from a primitive through `Any`. -pub trait Glyphs: std::any::Any { - fn runs( - &self, - paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, - ) -> Result<(), RenderError>; -} - -/// Glyphs sharing one font, size and colour. -pub struct GlyphRun<'a> { - pub font: &'a Blob, - /// The face within a font collection. - pub index: u32, - pub size: f32, - /// Variation coordinates in F2Dot14 bits. - pub coords: &'a [i16], - pub embolden: bool, - /// Synthesized oblique angle in degrees. - pub skew: Option, - pub color: [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], - pub glyphs: &'a mut dyn Iterator, - /// Underline and strikethrough, painted after the glyphs. - pub decorations: &'a [Decoration], -} - -/// A glyph's baseline position. -#[derive(Clone, Copy)] -pub struct Glyph { - pub id: u32, - pub x: f32, - pub y: f32, -} - -/// A decoration line whose top edge is `y`; it stays at least one device pixel thick. -#[derive(Clone, Copy)] -pub struct Decoration { - pub x: f32, - pub y: f32, - pub width: f32, - pub thickness: f32, - pub color: [f32; 4], -} - -/// Paints one parley glyph run with its line's baseline moved to `baseline` and its -/// glyphs raised by `rise`. -pub fn paint_parley_run( - run: &parley::GlyphRun<'_, B>, - baseline: f32, - rise: f32, - line: [f32; 2], - color: impl Fn(&B) -> [f32; 4], - paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, -) -> Result<(), RenderError> { - let shaped = run.run(); - let font = &shaped.font().font; - let coords: Vec = shaped - .normalized_coords() - .iter() - .map(|c| c.to_bits()) - .collect(); - let synthesis = shaped.synthesis(); - let style = run.style(); - let metrics = shaped.font_metrics(); - let decorations: Vec<_> = [ - ( - &style.underline, - metrics.underline_offset, - metrics.underline_size, - ), - ( - &style.strikethrough, - metrics.strikethrough_offset, - metrics.strikethrough_size, - ), - ] - .into_iter() - .filter_map(|(decoration, offset, thickness)| { - let decoration = decoration.as_ref()?; - Some(Decoration { - x: run.offset(), - y: baseline - rise - decoration.offset.unwrap_or(offset), - width: run.advance(), - thickness: decoration.size.unwrap_or(thickness), - color: color(&decoration.brush), - }) - }) - .collect(); - let line_baseline = run.baseline(); - let mut glyphs = run.positioned_glyphs().map(|glyph| Glyph { - id: glyph.id, - x: glyph.x, - y: glyph.y + baseline - line_baseline - rise, - }); - paint(GlyphRun { - font: &font.data, - index: font.index, - size: shaped.font_size(), - coords: &coords, - embolden: synthesis.embolden(), - skew: synthesis.skew(), - color: color(&style.brush), - line, - glyphs: &mut glyphs, - decorations: &decorations, - }) -} diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 6c68424adecd213826c9cab60bf1743a279a1439..a261641596c11a473a78372c6f0105e882d10027 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -20,5 +20,5 @@ accesskit = "0.25.0" accesskit_winit = { version = "0.34.0", default-features = false, features = ["rwh_06"] } 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", "NSLocale", "NSTimeZone"] } +objc2-foundation = { version = "0.2.2", default-features = false, features = ["NSString", "NSThread", "NSAttributedString", "NSDate", "NSCalendar", "NSDateFormatter", "NSGeometry", "NSLocale", "NSTimeZone"] } diff --git a/crates/snowbound/assets/icons/align-left.svg b/crates/snowbound/assets/icons/align-left.svg new file mode 100644 index 0000000000000000000000000000000000000000..f02128e0867546786369e3f3819cc252bc09d30a --- /dev/null +++ b/crates/snowbound/assets/icons/align-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/attachment.svg b/crates/snowbound/assets/icons/attachment.svg new file mode 100644 index 0000000000000000000000000000000000000000..cb67ea008a3100f872375fc74a334a8fc1e263c7 --- /dev/null +++ b/crates/snowbound/assets/icons/attachment.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/bold.svg b/crates/snowbound/assets/icons/bold.svg new file mode 100644 index 0000000000000000000000000000000000000000..321e8c2dfcfcd9449f9f278aa4582e64e6d4dc7f --- /dev/null +++ b/crates/snowbound/assets/icons/bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/bullets.svg b/crates/snowbound/assets/icons/bullets.svg new file mode 100644 index 0000000000000000000000000000000000000000..25d07438bac8c766d32572624a98a4ad15b24663 --- /dev/null +++ b/crates/snowbound/assets/icons/bullets.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/snowbound/assets/icons/calendar.svg b/crates/snowbound/assets/icons/calendar.svg new file mode 100644 index 0000000000000000000000000000000000000000..62a7a1fd9eca825a49980acd7bbf4af79892b12e --- /dev/null +++ b/crates/snowbound/assets/icons/calendar.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/assets/icons/clear-formatting.svg b/crates/snowbound/assets/icons/clear-formatting.svg new file mode 100644 index 0000000000000000000000000000000000000000..89f884319184eeca99ed2a5d728d618e4e936410 --- /dev/null +++ b/crates/snowbound/assets/icons/clear-formatting.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/clock.svg b/crates/snowbound/assets/icons/clock.svg new file mode 100644 index 0000000000000000000000000000000000000000..b15d8232353737eb6beffd61d536875c1929e101 --- /dev/null +++ b/crates/snowbound/assets/icons/clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/close.svg b/crates/snowbound/assets/icons/close.svg new file mode 100644 index 0000000000000000000000000000000000000000..ea3207268855e80053ea8380fb89f48ea7774222 --- /dev/null +++ b/crates/snowbound/assets/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/equation.svg b/crates/snowbound/assets/icons/equation.svg new file mode 100644 index 0000000000000000000000000000000000000000..a78d4234834ccdf59aaafe6d4ca6a0b37255e8b3 --- /dev/null +++ b/crates/snowbound/assets/icons/equation.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/font-color.svg b/crates/snowbound/assets/icons/font-color.svg new file mode 100644 index 0000000000000000000000000000000000000000..31b07897c1307af18155712412ea8e59293023c7 --- /dev/null +++ b/crates/snowbound/assets/icons/font-color.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/highlighter.svg b/crates/snowbound/assets/icons/highlighter.svg new file mode 100644 index 0000000000000000000000000000000000000000..2138765913a812a77f89cb7df4ecd07fc0cfbfac --- /dev/null +++ b/crates/snowbound/assets/icons/highlighter.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/indent.svg b/crates/snowbound/assets/icons/indent.svg new file mode 100644 index 0000000000000000000000000000000000000000..9652f7c45fd22cea621273ae2029026b6391f560 --- /dev/null +++ b/crates/snowbound/assets/icons/indent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/assets/icons/italic.svg b/crates/snowbound/assets/icons/italic.svg new file mode 100644 index 0000000000000000000000000000000000000000..e31f1c48bb272f930a8c086f51ac357193d33924 --- /dev/null +++ b/crates/snowbound/assets/icons/italic.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/link.svg b/crates/snowbound/assets/icons/link.svg new file mode 100644 index 0000000000000000000000000000000000000000..4a7a6cb2bf418869fd8acbdcab78256ac2980cae --- /dev/null +++ b/crates/snowbound/assets/icons/link.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/new-page.svg b/crates/snowbound/assets/icons/new-page.svg new file mode 100644 index 0000000000000000000000000000000000000000..3a0005f4b0b92c39542b23c3bace34f58ad851d5 --- /dev/null +++ b/crates/snowbound/assets/icons/new-page.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/numbering.svg b/crates/snowbound/assets/icons/numbering.svg new file mode 100644 index 0000000000000000000000000000000000000000..a14b600f3818ddf0786c55296debbd59fbef8c62 --- /dev/null +++ b/crates/snowbound/assets/icons/numbering.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/snowbound/assets/icons/outdent.svg b/crates/snowbound/assets/icons/outdent.svg new file mode 100644 index 0000000000000000000000000000000000000000..edc6e9ee13bd8a4f352d3b2cfc761e7044202790 --- /dev/null +++ b/crates/snowbound/assets/icons/outdent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/assets/icons/picture.svg b/crates/snowbound/assets/icons/picture.svg new file mode 100644 index 0000000000000000000000000000000000000000..a771122d81f8d7bc1abfc90a799b55889c55bd92 --- /dev/null +++ b/crates/snowbound/assets/icons/picture.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/assets/icons/redo.svg b/crates/snowbound/assets/icons/redo.svg new file mode 100644 index 0000000000000000000000000000000000000000..50a070148f8a00836122f8bcd58bf44c0daf5d6b --- /dev/null +++ b/crates/snowbound/assets/icons/redo.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/search.svg b/crates/snowbound/assets/icons/search.svg new file mode 100644 index 0000000000000000000000000000000000000000..ab43123841a73ded3bfc6f178ca6e54a8319c56c --- /dev/null +++ b/crates/snowbound/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/sidebar-collapse.svg b/crates/snowbound/assets/icons/sidebar-collapse.svg new file mode 100644 index 0000000000000000000000000000000000000000..bcc07d5bfacbad17b5baecc9ede9007c5343ff54 --- /dev/null +++ b/crates/snowbound/assets/icons/sidebar-collapse.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/sidebar-expand.svg b/crates/snowbound/assets/icons/sidebar-expand.svg new file mode 100644 index 0000000000000000000000000000000000000000..9feeb894e79125260832bcdfdb8d3a767f311881 --- /dev/null +++ b/crates/snowbound/assets/icons/sidebar-expand.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/strikethrough.svg b/crates/snowbound/assets/icons/strikethrough.svg new file mode 100644 index 0000000000000000000000000000000000000000..c8af3109a10ef42d3d5898e7e70ab860650eb507 --- /dev/null +++ b/crates/snowbound/assets/icons/strikethrough.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/subscript.svg b/crates/snowbound/assets/icons/subscript.svg new file mode 100644 index 0000000000000000000000000000000000000000..9442665331d4dbf266ebd090414460233454e5ba --- /dev/null +++ b/crates/snowbound/assets/icons/subscript.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/snowbound/assets/icons/table.svg b/crates/snowbound/assets/icons/table.svg new file mode 100644 index 0000000000000000000000000000000000000000..015d37c616acf4b648c1c2517bf61d7a6b903a7a --- /dev/null +++ b/crates/snowbound/assets/icons/table.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/underline.svg b/crates/snowbound/assets/icons/underline.svg new file mode 100644 index 0000000000000000000000000000000000000000..c5f1fa2ec76574f5cb3a99f30e57d04ca5767b05 --- /dev/null +++ b/crates/snowbound/assets/icons/underline.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/undo.svg b/crates/snowbound/assets/icons/undo.svg new file mode 100644 index 0000000000000000000000000000000000000000..0c8ceeeb3159e0cd611135f7b36ae2d2cc29fc92 --- /dev/null +++ b/crates/snowbound/assets/icons/undo.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/zoom-in.svg b/crates/snowbound/assets/icons/zoom-in.svg new file mode 100644 index 0000000000000000000000000000000000000000..f12d03e1f3263839f84bc066844e179c233156d6 --- /dev/null +++ b/crates/snowbound/assets/icons/zoom-in.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/icons/zoom-out.svg b/crates/snowbound/assets/icons/zoom-out.svg new file mode 100644 index 0000000000000000000000000000000000000000..91c37f13bb72063f725451294457003f2d61a72a --- /dev/null +++ b/crates/snowbound/assets/icons/zoom-out.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/snowbound/assets/tags/address.svg b/crates/snowbound/assets/tags/address.svg new file mode 100644 index 0000000000000000000000000000000000000000..428250519b22d61e99daa3f5a71634f66d0043f1 --- /dev/null +++ b/crates/snowbound/assets/tags/address.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/tags/contact.svg b/crates/snowbound/assets/tags/contact.svg new file mode 100644 index 0000000000000000000000000000000000000000..33d2f91d9b16edbf8434235264e8e4c1b158749c --- /dev/null +++ b/crates/snowbound/assets/tags/contact.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/tags/definition.svg b/crates/snowbound/assets/tags/definition.svg new file mode 100644 index 0000000000000000000000000000000000000000..65fd94a73e7d1866925d755642c7dafdd5524979 --- /dev/null +++ b/crates/snowbound/assets/tags/definition.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/tags/highlight.svg b/crates/snowbound/assets/tags/highlight.svg new file mode 100644 index 0000000000000000000000000000000000000000..9922e577f6a695a0c1b4597805fc654a293811c4 --- /dev/null +++ b/crates/snowbound/assets/tags/highlight.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/tags/phone.svg b/crates/snowbound/assets/tags/phone.svg new file mode 100644 index 0000000000000000000000000000000000000000..07d6349b3f96797e1cba895eff3d0b7ca84b16d0 --- /dev/null +++ b/crates/snowbound/assets/tags/phone.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/tags/remember.svg b/crates/snowbound/assets/tags/remember.svg new file mode 100644 index 0000000000000000000000000000000000000000..e63ca630863ac8492e116e50e44469c28b92841b --- /dev/null +++ b/crates/snowbound/assets/tags/remember.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/crates/snowbound/assets/tags/star.svg b/crates/snowbound/assets/tags/star.svg new file mode 100644 index 0000000000000000000000000000000000000000..62f340191717c95b0a5fbc21a2522a3241ed68ad --- /dev/null +++ b/crates/snowbound/assets/tags/star.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/crates/snowbound/src/art.rs b/crates/snowbound/src/art.rs new file mode 100644 index 0000000000000000000000000000000000000000..c30f6e2f0e24df28234f6838e34fb0313e20b241 --- /dev/null +++ b/crates/snowbound/src/art.rs @@ -0,0 +1,45 @@ +//! Artwork for the window's chrome: tinted interface icons and the toolbar's tag buttons. + +macro_rules! art { + ($path:literal) => { + &[include_str!(concat!("../assets/", $path, ".svg"))] + }; +} + +pub const ALIGN_LEFT: &[&str] = art!("icons/align-left"); +pub const ATTACHMENT: &[&str] = art!("icons/attachment"); +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 CLOCK: &[&str] = art!("icons/clock"); +pub const CLOSE: &[&str] = art!("icons/close"); +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 NUMBERING: &[&str] = art!("icons/numbering"); +pub const OUTDENT: &[&str] = art!("icons/outdent"); +pub const PICTURE: &[&str] = art!("icons/picture"); +pub const REDO: &[&str] = art!("icons/redo"); +pub const SEARCH: &[&str] = art!("icons/search"); +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"); +pub const SUBSCRIPT: &[&str] = art!("icons/subscript"); +pub const TABLE: &[&str] = art!("icons/table"); +pub const UNDERLINE: &[&str] = art!("icons/underline"); +pub const UNDO: &[&str] = art!("icons/undo"); +pub const ZOOM_IN: &[&str] = art!("icons/zoom-in"); +pub const ZOOM_OUT: &[&str] = art!("icons/zoom-out"); + +pub const TAG_STAR: &[&str] = art!("tags/star"); +pub const TAG_REMEMBER: &[&str] = art!("tags/remember"); +pub const TAG_DEFINITION: &[&str] = art!("tags/definition"); +pub const TAG_HIGHLIGHT: &[&str] = art!("tags/highlight"); +pub const TAG_CONTACT: &[&str] = art!("tags/contact"); +pub const TAG_ADDRESS: &[&str] = art!("tags/address"); +pub const TAG_PHONE: &[&str] = art!("tags/phone"); diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index ad838f6a372e1eceb6d475574b360c74c102b7f6..e5e2e93595bcf68e0059dcd977adbb48e730cf33 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -13,7 +13,7 @@ use objc2_app_kit::{ }; use objc2_foundation::{ MainThreadMarker, NSAttributedString, NSCalendar, NSCalendarUnit, NSDate, NSDateFormatter, - NSDateFormatterStyle, NSRange, NSString, + NSDateFormatterStyle, NSPoint, NSRange, NSRect, NSSize, NSString, }; use std::{cell::Cell, sync::OnceLock}; use winit::{ @@ -261,6 +261,56 @@ pub fn configure_presentation(surface: &wgpu::Surface<'_>) { } } +/// The application's icon as the Dock shows it, `pixels` square. +pub fn app_icon(pixels: u32) -> Option { + let side = pixels as usize; + let mut rgba = unsafe { + let mtm = MainThreadMarker::new()?; + let icon: Retained = + msg_send_id![&NSApplication::sharedApplication(mtm), applicationIconImage]; + let bitmap: Allocated = msg_send_id![AnyClass::get("NSBitmapImageRep")?, alloc]; + let planes: *mut *mut u8 = std::ptr::null_mut(); + let bitmap: Option> = msg_send_id![ + bitmap, + initWithBitmapDataPlanes: planes, + pixelsWide: side as isize, + pixelsHigh: side as isize, + bitsPerSample: 8isize, + samplesPerPixel: 4isize, + hasAlpha: true, + isPlanar: false, + colorSpaceName: &*NSString::from_str("NSCalibratedRGBColorSpace"), + bytesPerRow: 4 * side as isize, + bitsPerPixel: 32isize + ]; + let bitmap = bitmap?; + let contexts = AnyClass::get("NSGraphicsContext")?; + let context: Option> = + msg_send_id![contexts, graphicsContextWithBitmapImageRep: &*bitmap]; + let _: () = msg_send![contexts, saveGraphicsState]; + let _: () = msg_send![contexts, setCurrentContext: &*context?]; + let bounds = NSRect::new( + NSPoint::new(0.0, 0.0), + NSSize::new(side as f64, side as f64), + ); + // NSCompositingOperationSourceOver + let _: () = msg_send![&icon, drawInRect: bounds, fromRect: NSRect::ZERO, operation: 2usize, fraction: 1.0f64]; + let _: () = msg_send![contexts, restoreGraphicsState]; + let data: *const u8 = msg_send![&bitmap, bitmapData]; + std::slice::from_raw_parts(data, 4 * side * side).to_vec() + }; + // AppKit's bitmap is premultiplied. + for pixel in rgba.chunks_exact_mut(4) { + let alpha = u32::from(pixel[3]); + for channel in &mut pixel[..3] { + *channel = (u32::from(*channel) * 255) + .checked_div(alpha) + .map_or(0, |value| value.min(255) as u8); + } + } + draw::RasterImage::new([pixels; 2], rgba).ok() +} + pub fn text_colors() -> [[f32; 4]; 2] { unsafe { let space = NSColorSpace::sRGBColorSpace(); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 5285a57c6f26b10397b8962d6b23b6e095767e1d..a99bda00c032d80a4e466e05401298201bb1bd92 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -1,16 +1,15 @@ +mod art; mod macos; use canvas::gpu::page::PageScene; -use canvas::interaction::{ - Cursor, Key as PageKey, Modifiers, NamedKey as PageNamedKey, PageView, Request, Response, - TextColors, accessibility, -}; +use canvas::interaction::{Cursor, PageView, Request, Response, TextColors, accessibility}; use canvas::{ date::DateField, document::TextDocument, editor::{CanvasEditor, DEFAULT_OUTLINE_WIDTH, TextOutline}, layout::TextEngine, }; +use canvas::{gpu::tag_sources, outline::TagIcon}; use draw::Renderer; use onestore::ExGuid; use onestore::document::Format; @@ -22,7 +21,7 @@ use std::{ sync::Arc, time::Instant, }; -use ui::{Axis, Flags, Id, Spec, Theme, Ui, fill, fit, px}; +use ui::{Axis, Flags, Id, Shape, Spec, Theme, Ui, children, fill, fit, px}; use winit::{ application::ApplicationHandler, dpi::{LogicalSize, PhysicalPosition, PhysicalSize}, @@ -33,10 +32,15 @@ use winit::{ window::{CursorIcon, Window, WindowId}, }; -/// Height of the window's top strip, which holds the traffic lights and section tabs. -const STRIP: f32 = 38.0; -/// Room the traffic lights take at the strip's leading edge. +/// Height of the macOS title bar, which holds the traffic lights. +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; const PAGE_LIST: f32 = 240.0; #[derive(Debug)] @@ -48,6 +52,17 @@ enum UserEvent { Sync, /// Scripted input from `SNOWBOUND_REPLAY`. Replay(Replay), + /// A page background finished rasterizing on its worker thread. + Redraw, +} + +/// Asks the event loop for a frame from any thread. +struct Redraw(EventLoopProxy); + +impl std::task::Wake for Redraw { + fn wake(self: Arc) { + let _ = self.0.send_event(UserEvent::Redraw); + } } #[derive(Debug)] @@ -55,6 +70,9 @@ enum Replay { Input(ui::Event), /// Paints the next frame into a PNG as well as the window. Snapshot(PathBuf), + /// A frame during a wait, as a visible window's display would ask for. + Tick, + Appearance(winit::window::Theme), } impl From for UserEvent { @@ -63,49 +81,20 @@ impl From for UserEvent { } } +/// With `SNOWBOUND_PROFILE` set, prints how long a phase of the frame took since `start`. +fn lap(phase: &str, start: Instant) { + static PROFILE: std::sync::OnceLock = std::sync::OnceLock::new(); + if *PROFILE.get_or_init(|| std::env::var_os("SNOWBOUND_PROFILE").is_some()) { + eprintln!("profile\t{phase}\t{}", start.elapsed().as_micros()); + } +} + fn trace_input(event: &impl std::fmt::Debug) { if std::env::var_os("SNOWBOUND_TRACE_INPUT").is_some() { eprintln!("Input {:?}: {event:?}", std::time::SystemTime::now()); } } -fn page_key(key: &Key) -> PageKey { - match key { - Key::Character(text) => PageKey::Character(text.to_string()), - Key::Named(named) => PageKey::Named(match named { - NamedKey::Escape => PageNamedKey::Escape, - NamedKey::Tab => PageNamedKey::Tab, - NamedKey::Space => PageNamedKey::Space, - NamedKey::Enter => PageNamedKey::Enter, - NamedKey::Backspace => PageNamedKey::Backspace, - NamedKey::Delete => PageNamedKey::Delete, - NamedKey::ArrowLeft => PageNamedKey::ArrowLeft, - NamedKey::ArrowRight => PageNamedKey::ArrowRight, - NamedKey::ArrowUp => PageNamedKey::ArrowUp, - NamedKey::ArrowDown => PageNamedKey::ArrowDown, - NamedKey::Home => PageNamedKey::Home, - NamedKey::End => PageNamedKey::End, - NamedKey::Alt - | NamedKey::AltGraph - | NamedKey::Control - | NamedKey::Shift - | NamedKey::Super - | NamedKey::Meta => PageNamedKey::Modifier, - _ => PageNamedKey::Other, - }), - _ => PageKey::Named(PageNamedKey::Other), - } -} - -fn page_modifiers(modifiers: ModifiersState) -> Modifiers { - Modifiers { - shift: modifiers.shift_key(), - control: modifiers.control_key(), - option: modifiers.alt_key(), - command: modifiers.super_key(), - } -} - fn cursor_icon(cursor: Cursor) -> CursorIcon { match cursor { Cursor::Default => CursorIcon::Default, @@ -123,6 +112,8 @@ struct App { proxy: EventLoopProxy, input: Option, substitutes: Vec, + /// Font files for the interface in place of the system's. + interface_font: Vec>, state: Option, startup_error: Option>, } @@ -156,6 +147,8 @@ struct Tab { /// 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, @@ -220,6 +213,8 @@ enum Command { struct State { window: Arc, proxy: EventLoopProxy, + /// Asks for a frame when a worker thread finishes something the page shows. + redraw: std::task::Waker, instance: wgpu::Instance, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, @@ -230,9 +225,15 @@ struct State { session: Option, /// Text filtering the page list. filter: String, + /// Whether the page list is shown beside the page. + pages_open: bool, + /// The application's icon at the display's scale, for the title bar. + app_icon: Option, commands: Vec, /// The page changed during this frame, to be saved and announced after it. changed: bool, + /// Only the view scrolled or zoomed during this frame, to be announced after it. + moved: bool, /// Whether the page last heard it had keyboard focus. page_focused: bool, window_focused: bool, @@ -260,6 +261,11 @@ fn page() -> Id { Id::ROOT.child("page") } +/// The section's colour around the page and its tabs. +fn frame() -> Id { + Id::ROOT.child("frame") +} + /// The field filtering the page list. fn filter() -> Id { Id::ROOT.child("filter") @@ -271,6 +277,7 @@ impl State { proxy: EventLoopProxy, input: Input, substitutes: &[PathBuf], + interface_font: Vec>, ) -> Result> { let window = Arc::new( event_loop.create_window( @@ -344,6 +351,7 @@ impl State { } Input::Section { file, title, cache } => { let opened = Library { + name: file_name(file.parent().unwrap_or(&file)), notebook: None, tabs: vec![Tab { name: file @@ -374,6 +382,7 @@ impl State { return Err(format!("No readable sections in {}", root.display()).into()); } let opened = Library { + name: file_name(&root), notebook: Some(notebook), tabs, cache, @@ -398,10 +407,20 @@ impl State { window.set_ime_allowed(true); window.request_redraw(); eprintln!("Canvas GPU: {:?}; scale factor {dpr}", adapter.get_info()); - let mut ui = Ui::new(Theme::dark()); + let mut ui = Ui::new( + theme(window.theme().unwrap_or(winit::window::Theme::Dark)), + macos::double_click_interval(), + ); + if !interface_font.is_empty() { + let family = ui + .use_fonts(interface_font) + .ok_or("The interface font files hold no font")?; + eprintln!("Using {family} for the interface"); + } ui.set_focus(Some(page())); let state = Self { window, + redraw: Arc::new(Redraw(proxy.clone())).into(), proxy, instance, surface, @@ -419,8 +438,11 @@ impl State { library, session, filter: String::new(), + pages_open: true, + app_icon: macos::app_icon((16.0 * dpr).round() as u32), commands: Vec::new(), changed: false, + moved: false, page_focused: true, window_focused: true, pointer: [0.0; 2], @@ -442,6 +464,7 @@ impl State { /// Builds, lays out and paints one frame, then does what it asked for and asks for the /// frame that shows the result. fn frame(&mut self) -> Result<(), Box> { + let start = Instant::now(); let size = self.window.inner_size(); let scale = self.window.scale_factor() as f32; self.ui.begin( @@ -449,8 +472,10 @@ impl State { scale, Instant::now(), ); - self.build()?; + 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, @@ -476,6 +501,7 @@ impl State { self.window.set_ime_allowed(ime); } self.draw()?; + lap("drawn", start); let commands = std::mem::take(&mut self.commands); let follow = !commands.is_empty() || self.ui.wants_frame(); for command in commands { @@ -483,101 +509,100 @@ impl State { } if std::mem::take(&mut self.changed) { self.after_edit()?; + } else if self.moved { + self.after_move()?; } - if follow { + self.moved = false; + // A covered window shows nothing, so animations wait for it to be uncovered. + if follow && !self.occluded { self.window.request_redraw(); } + lap("frame", start); Ok(()) } - fn build(&mut self) -> Result<(), Box> { + /// 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 mut ease = |part: &str, color: [f32; 4]| -> [f32; 4] { + std::array::from_fn(|channel| { + self.ui + .animate(Id::ROOT.child((part, channel)), color[channel]) + }) + }; + let section = ui::Section { + frame: [ + ease("top", target.frame[0]), + ease("bottom", target.frame[1]), + ], + tab: ease("tab", target.tab), + edge: ease("edge", target.edge), + accent: ease("accent", target.accent), + }; + self.ui.theme.accent = section.accent; let theme = self.ui.theme.clone(); - self.ui.open_as( - strip(), + self.title_bar(&theme); + self.toolbar(&theme)?; + self.ui.open( + "tabs", Spec { - flags: Flags::CLICKABLE, - size: [fill(), px(STRIP)], + size: [fill(), px(TAB_ROW)], fill: Some(theme.strip), - pad: [0.0, 8.0], - gap: 2.0, + pad: [FRAME, 0.0], ..Spec::default() }, ); self.ui.leaf( - "lights", + "corner", Spec { - size: [px(LIGHTS), px(1.0)], + size: [px(ROUNDING), px(1.0)], ..Spec::default() }, ); - match (&self.library, &self.session) { + let (clicked, open_tab) = match (&self.library, &self.session) { (Some(library), Some(session)) => { - for (index, tab) in library.tabs.iter().enumerate() { - let active = index == session.tab; - let id = self.ui.open( - ("tab", index), - Spec { - flags: Flags::CLICKABLE, - size: [fit(), px(STRIP - 8.0)], - text: Some(&tab.name), - color: Some(if active { theme.text } else { theme.text_dim }), - fill: Some(if active { theme.base } else { theme.strip }), - hover_fill: (!active).then_some(theme.chip), - radius: 6.0, - pad: [14.0 + 14.0, 0.0], - center: true, - ..Spec::default() - }, - ); - let swatch = tab.color.map_or(theme.text_dim, canvas::gpu::colorref); - let middle = (STRIP - 8.0) / 2.0; - self.ui - .mark([12.0, middle - 4.0, 20.0, middle + 4.0], swatch); - self.ui.close(); - if self.ui.signal(id).clicked && !active { - self.commands.push(Command::OpenSection(index)); - } - } - } - _ => { - self.ui.leaf( - "tab", - Spec { - size: [fit(), px(STRIP - 8.0)], - text: Some("Temporary page"), - fill: Some(theme.base), - radius: 6.0, - pad: [16.0, 0.0], - ..Spec::default() - }, - ); + let tabs: Vec<_> = library + .tabs + .iter() + .map(|tab| (tab.name.as_str(), section_color(tab.color))) + .collect(); + ui::shell::section_tabs( + &mut self.ui, + "sections", + &tabs, + session.tab, + §ion, + TAB_ROW, + ) } + _ => 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.ui.leaf( - "space", - Spec { - size: [fill(), px(1.0)], - ..Spec::default() - }, - ); - if let Some(session) = &self.session { - self.ui.leaf( - "status", - Spec { - size: [fit(), px(STRIP - 8.0)], - text: Some(session.status), - color: Some(theme.text_dim), - pad: [14.0, 0.0], - ..Spec::default() - }, - ); + if self.session.is_some() { + self.page_tools(&theme); } self.ui.close(); - self.ui.open( - "body", + self.ui.open_as( + frame(), Spec { size: [fill(), fill()], + fill: Some(section.frame[0]), + gradient: Some(section.frame[1]), + pad: [FRAME, FRAME], ..Spec::default() }, ); @@ -601,7 +626,7 @@ impl State { Spec { flags: Flags::CUSTOM | Flags::FOCUSABLE, size: [fill(), fill()], - fill: Some([1.0; 4]), + fill: Some(theme.paper), ..Spec::default() }, ); @@ -630,21 +655,373 @@ impl State { } self.page_events(signal.events)?; self.ui.close(); - if self.session.is_some() { + let open_page = self.page_list(&theme, §ion); + self.ui.close(); + Ok((section, open_tab, open_page)) + } + + /// Borders the open section tab and the frame's top, and rounds and borders the page + /// together with the open page's tab, joined where they meet. + fn edges(&mut self, section: ui::Section, open_tab: Id, open_page: Option) { + let panel = self.ui.rect(frame().child("panel")); + let (Some(frame), Some(page)) = (self.ui.rect(frame()), self.ui.rect(page())) else { + return; + }; + let [left, top, right, bottom] = page; + let tab = open_page + .and_then(|id| self.ui.rect(id)) + .zip(panel) + .and_then(|(row, panel)| { + let [start, end] = [ + row[1].max(panel[1]).max(top), + row[3].min(panel[3]).min(bottom), + ]; + let reach = row[2].min(panel[2]); + (reach - right > 2.0 * ROUNDING && end - start > 2.0 * ROUNDING).then(|| { + // A tab too near the page's corner joins it along the edge. + let start = if start < top + 2.0 * ROUNDING { + top + } else { + start + }; + let end = if end > bottom - 2.0 * ROUNDING { + bottom + } else { + end + }; + [start, end, reach] + }) + }); + let mut outline = vec![([left, top], ROUNDING)]; + match tab { + Some([start, end, reach]) => { + if start > top { + outline.extend([([right, top], ROUNDING), ([right, start], ROUNDING)]); + } + outline.extend([([reach, start], ROUNDING), ([reach, end], ROUNDING)]); + if end < bottom { + outline.extend([([right, end], ROUNDING), ([right, bottom], ROUNDING)]); + } + } + None => outline.extend([([right, top], ROUNDING), ([right, bottom], ROUNDING)]), + } + outline.push(([left, bottom], ROUNDING)); + let height = (frame[3] - frame[1]).max(1.0); + let paper = self.ui.theme.paper; + self.ui.round_corners(&outline, paper, |y| { + ui::mix(section.frame[0], section.frame[1], (y - frame[1]) / height) + }); + self.ui.border(&outline, true, section.edge); + // 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 strip = self.ui.theme.strip; + self.ui.round_corners( + &[ + ([start, frame[1]], outer), + ([end, frame[1]], outer), + ([end, frame[3]], 0.0), + ([start, frame[3]], 0.0), + ], + paper, + |_| strip, + ); + let [foot, toe] = self.ui.rect(open_tab).map_or([end; 2], ui::shell::tab_base); + for edge in [ + [ + ([start, frame[1] + outer], 0.0), + ([start, frame[1]], outer), + ([foot, frame[1]], 0.0), + ], + [ + ([toe, frame[1]], 0.0), + ([end, frame[1]], outer), + ([end, frame[1] + outer], 0.0), + ], + ] { + self.ui.border(&edge, false, section.edge); + } + } + + /// The window's title bar beside the traffic lights: the application's icon, the + /// notebook's name and the saving status. + fn title_bar(&mut self, theme: &Theme) { + self.ui.open_as( + strip(), + Spec { + flags: Flags::CLICKABLE, + size: [fill(), px(TITLE)], + fill: Some(theme.strip), + ..Spec::default() + }, + ); + self.ui.leaf( + "lights", + Spec { + size: [px(LIGHTS), px(1.0)], + ..Spec::default() + }, + ); + self.ui.leaf( + "notebook", + Spec { + size: [fit(), px(TITLE)], + image: self.app_icon.as_ref(), + text: Some( + self.library + .as_ref() + .map_or("Temporary page", |library| &library.name), + ), + ..Spec::default() + }, + ); + self.ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + if let Some(session) = &self.session { self.ui.leaf( - "separator", + "status", Spec { - size: [px(1.0), fill()], - fill: Some(theme.separator), + size: [fit(), px(TITLE)], + text: Some(session.status), + color: Some(theme.text_dim), + pad: [12.0, 0.0], ..Spec::default() }, ); - self.page_list(&theme); } self.ui.close(); + } + + /// Two rows of formatting, tag, insert and zoom buttons, grouped as OneNote's Home + /// ribbon groups them. + fn toolbar(&mut self, theme: &Theme) -> Result<(), Box> { + let ui = &mut self.ui; + let text = theme.text; + ui.open( + "toolbar", + Spec { + size: [fill(), children()], + fill: Some(theme.strip), + pad: [8.0, 3.0], + gap: 6.0, + ..Spec::default() + }, + ); + group(ui, "history", |ui| { + ui::shell::tool_button(ui, "undo", art::UNDO, text); + ui::shell::tool_button(ui, "redo", art::REDO, text); + }); + divider(ui, "history", theme); + group(ui, "text", |ui| { + row(ui, 0, |ui| { + ui::shell::combo(ui, "font", "Calibri", 120.0); + ui::shell::combo(ui, "size", "11", 44.0); + ui::shell::split_button(ui, "bullets", art::BULLETS, None); + ui::shell::split_button(ui, "numbering", art::NUMBERING, None); + ui::shell::tool_button(ui, "clear", art::CLEAR_FORMATTING, text); + }); + row(ui, 1, |ui| { + for (part, icon) in [ + ("bold", art::BOLD), + ("italic", art::ITALIC), + ("underline", art::UNDERLINE), + ("strikethrough", art::STRIKETHROUGH), + ] { + ui::shell::tool_button(ui, part, icon, text); + } + ui::shell::split_button(ui, "script", art::SUBSCRIPT, None); + ui::shell::split_button( + ui, + "highlight", + art::HIGHLIGHTER, + Some(draw::srgb(0xff, 0xff, 0x00)), + ); + ui::shell::split_button( + ui, + "color", + art::FONT_COLOR, + Some(draw::srgb(0xe8, 0x3a, 0x30)), + ); + ui::shell::tool_button(ui, "outdent", art::OUTDENT, text); + ui::shell::tool_button(ui, "indent", art::INDENT, text); + ui::shell::split_button(ui, "align", art::ALIGN_LEFT, None); + }); + }); + divider(ui, "text", theme); + let tags = [ + tag_sources(TagIcon::CheckBox { checked: false }), + art::TAG_STAR, + tag_sources(TagIcon::Question), + art::TAG_REMEMBER, + art::TAG_DEFINITION, + art::TAG_HIGHLIGHT, + art::TAG_CONTACT, + art::TAG_ADDRESS, + art::TAG_PHONE, + ]; + group(ui, "tags", |ui| { + for (index, tags) in tags.chunks(5).enumerate() { + row(ui, index, |ui| { + for (column, icon) in tags.iter().enumerate() { + ui::shell::tool_button(ui, column, icon, [1.0; 4]); + } + if index == 1 { + ui::shell::tool_button(ui, "more", ui::shell::CHEVRON, text); + } + }); + } + }); + divider(ui, "tags", theme); + group(ui, "insert", |ui| { + row(ui, 0, |ui| { + for (part, icon) in [ + ("table", art::TABLE), + ("picture", art::PICTURE), + ("file", art::ATTACHMENT), + ("link", art::LINK), + ] { + ui::shell::tool_button(ui, part, icon, text); + } + }); + row(ui, 1, |ui| { + for (part, icon) in [ + ("date", art::CALENDAR), + ("time", art::CLOCK), + ("equation", art::EQUATION), + ] { + ui::shell::tool_button(ui, part, icon, text); + } + }); + }); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); + let zoom = self.view.zoom(); + let label = format!("{:.0}%", zoom * 100.0); + let mut chosen = None; + group(ui, "zoom", |ui| { + row(ui, 0, |ui| { + if ui::shell::tool_button(ui, "out", art::ZOOM_OUT, text).clicked { + chosen = Some(zoom / 1.1); + } + let level = ui.leaf( + "level", + Spec { + flags: Flags::CLICKABLE, + size: [px(44.0), px(ui::shell::TOOL)], + text: Some(&label), + hover_fill: Some(theme.hover()), + radius: 4.0, + center: true, + ..Spec::default() + }, + ); + if level.clicked { + chosen = Some(1.0); + } + if ui::shell::tool_button(ui, "in", art::ZOOM_IN, text).clicked { + chosen = Some(zoom * 1.1); + } + }); + }); + ui.close(); + if let Some(zoom) = chosen { + let response = self.view.set_zoom(zoom)?; + self.respond(response); + } Ok(()) } + /// The search box and the page list's buttons, above the list. + fn page_tools(&mut self, theme: &Theme) { + let Some(session) = &self.session else { + return; + }; + self.ui.open( + "tools", + Spec { + size: [px(PAGE_LIST), px(TAB_ROW)], + pad: [0.0, (TAB_ROW - ui::shell::TOOL) / 2.0], + gap: 3.0, + ..Spec::default() + }, + ); + let focused = self.ui.focused() == Some(filter()); + self.ui.open( + "search", + Spec { + size: [fill(), px(ui::shell::TOOL)], + fill: Some(theme.base), + border: Some(if focused { theme.accent } else { theme.chip }), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + self.ui.leaf( + "icon", + Spec { + size: [fit(), px(ui::shell::TOOL)], + icon: Some(art::SEARCH), + color: Some(theme.text_dim), + ..Spec::default() + }, + ); + let signal = ui::text_field( + &mut self.ui, + filter(), + &mut self.filter, + "Search pages", + Spec { + size: [fill(), px(ui::shell::TOOL)], + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + if !self.filter.is_empty() + && ui::shell::tool_button(&mut self.ui, "clear", art::CLOSE, theme.text_dim).clicked + { + self.filter.clear(); + } + self.ui.close(); + for event in &signal.events { + if let ui::Event::Key { + key: Key::Named(key @ (NamedKey::Escape | NamedKey::Enter)), + .. + } = event + { + let first = matching(&session.pages, &self.filter).next(); + if *key == NamedKey::Enter + && let Some((space, ..)) = first.filter(|(space, ..)| *space != session.space) + { + self.commands.push(Command::OpenPage(*space)); + } + self.filter.clear(); + self.ui.set_focus(Some(page())); + } + } + ui::shell::tool_button(&mut self.ui, "new", art::NEW_PAGE, theme.text); + let toggle = if self.pages_open { + art::SIDEBAR_COLLAPSE + } else { + art::SIDEBAR_EXPAND + }; + if ui::shell::tool_button(&mut self.ui, "toggle", toggle, theme.text).clicked { + self.pages_open = !self.pages_open; + } + self.ui.close(); + } + fn conflict_bar(&mut self, theme: &Theme) { self.ui.open( "conflict", @@ -680,111 +1057,65 @@ impl State { self.ui.close(); } - fn page_list(&mut self, theme: &Theme) { - let Some(session) = &self.session else { - return; - }; + /// 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 { + let session = self.session.as_ref()?; + let panel = self.ui.id("panel"); + let width = self + .ui + .animate(panel, if self.pages_open { PAGE_LIST } else { 0.0 }); self.ui.open( "panel", - Spec { - axis: Axis::Y, - size: [px(PAGE_LIST), fill()], - fill: Some(theme.panel), - ..Spec::default() - }, - ); - self.ui.open( - "pages", Spec { flags: Flags::SCROLL | Flags::CLIP, axis: Axis::Y, - size: [fill(), fill()], - pad: [6.0, 6.0], + size: [px(width), fill()], gap: 1.0, ..Spec::default() }, ); - let query = self.filter.to_lowercase(); - let mut first = None; - for (space, title, level) in &session.pages { - if !title.to_lowercase().contains(&query) { - continue; - } - first.get_or_insert(*space); + let mut open = None; + for (space, title, level) in matching(&session.pages, &self.filter) { let selected = *space == session.space; - let signal = self.ui.leaf( - space, + let spec = Spec { + flags: Flags::CLICKABLE, + size: [px(PAGE_LIST), px(26.0)], + 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 { - flags: Flags::CLICKABLE, - size: [fill(), px(26.0)], - text: Some(if title.is_empty() { - "Untitled page" - } else { - title - }), - color: Some(if selected || !title.is_empty() { - theme.text + color: Some(theme.paper_ink), + fill: Some(theme.paper), + ..spec + } + } else { + Spec { + color: Some(if title.is_empty() { + ui::mix(theme.ink, section.tab, 0.5) } else { - theme.text_dim + theme.ink }), - fill: selected.then_some(theme.accent), - hover_fill: (!selected).then_some(theme.hover), - hover_border: (!selected).then_some(theme.accent), - radius: 4.0, - pad: [10.0 + 16.0 * level.saturating_sub(1) as f32, 0.0], - ..Spec::default() - }, - ); - if signal.clicked && !selected { + fill: Some(section.tab), + hover_fill: Some(section.frame[0]), + radius: ROUNDING, + shape: Shape::Trailing, + ..spec + } + }; + if self.ui.leaf(space, spec).clicked && !selected { self.commands.push(Command::OpenPage(*space)); } } self.ui.close(); - let placeholder = format!("Filter {} pages…", session.pages.len()); - self.ui.open( - "footer", - Spec { - size: [fill(), px(38.0)], - pad: [6.0, 6.0], - ..Spec::default() - }, - ); - let before = self.filter.clone(); - let signal = ui::text_field( - &mut self.ui, - filter(), - &mut self.filter, - &placeholder, - Spec { - size: [fill(), px(26.0)], - fill: Some(theme.base), - border: Some(theme.separator), - radius: 4.0, - pad: [8.0, 0.0], - ..Spec::default() - }, - ); - // The list above was built with the filter as it stood. - if self.filter != before { - self.window.request_redraw(); - } - for event in &signal.events { - if let ui::Event::Key { - key: Key::Named(key @ (NamedKey::Escape | NamedKey::Enter)), - .. - } = event - { - if *key == NamedKey::Enter - && let Some(space) = first.filter(|space| *space != session.space) - { - self.commands.push(Command::OpenPage(space)); - } - self.filter.clear(); - self.ui.set_focus(Some(page())); - } - } - self.ui.close(); - self.ui.close(); + open } /// Hands the page the events routed to its box, in its device pixels. @@ -822,13 +1153,13 @@ impl State { self.ui.set_focus(Some(filter())); continue; } - self.view.key(&page_key(&key), text.as_deref())? + self.view.key(&ui::edit_key(&key), text.as_deref())? } ui::Event::Ime(Ime::Preedit(text, cursor)) => self.view.compose(text, cursor)?, ui::Event::Ime(Ime::Commit(text)) => self.view.commit_text(text)?, ui::Event::Ime(Ime::Disabled) => self.view.cancel_composition()?, ui::Event::Modifiers(modifiers) => { - self.view.modifiers_changed(page_modifiers(modifiers))? + self.view.modifiers_changed(ui::edit_modifiers(modifiers))? } }; self.respond(response); @@ -842,6 +1173,7 @@ impl State { /// Notes a page change for after the frame and queues what the page asked of the host. fn respond(&mut self, response: Response) { self.changed |= response.changed; + self.moved |= response.moved; if let Some(request) = response.request { self.commands.push(Command::Page(request)); } @@ -892,6 +1224,16 @@ impl State { /// Follows an edit: the input method's position, accessibility, saving and the title. fn after_edit(&mut self) -> Result<(), Box> { + self.after_move()?; + let start = Instant::now(); + self.persist()?; + lap("save", start); + self.title(); + Ok(()) + } + + /// Follows the view moving: the input method's position and accessibility. + fn after_move(&mut self) -> Result<(), Box> { let scale = self.ui.scale(); let corner = self.ui.rect(page()).unwrap_or_default(); let [x0, y0, x1, y1] = self.view.caret_area()?; @@ -899,9 +1241,9 @@ impl State { PhysicalPosition::new(x0 + corner[0] * scale, y0 + corner[1] * scale), PhysicalSize::new(x1 - x0, y1 - y0), ); + let start = Instant::now(); self.update_accessibility()?; - self.persist()?; - self.title(); + lap("accessibility", start); Ok(()) } @@ -1095,20 +1437,13 @@ impl State { self.access_adapter.update_if_active(|| { match self.accessibility.update( &view.editor, + view.scene.as_ref(), viewport, &self.window.title(), view.outline_preview(), + view.object_focus().and_then(|focus| focus.read_only()), ) { - Ok(mut update) => { - self.accessibility.append_page_fields( - &mut update, - view.scene.as_ref(), - &view.editor, - viewport, - view.object_focus().and_then(|focus| focus.read_only()), - ); - update - } + Ok(update) => update, Err(failure) => { error = Some(failure); self.accessibility.deactivate(); @@ -1185,6 +1520,7 @@ impl State { } fn draw(&mut self) -> Result<(), Box> { + let start = Instant::now(); if let Some(path) = self.snapshot.take() { self.snapshot(&path)?; } @@ -1222,9 +1558,12 @@ impl State { return Err("Canvas surface validation failed".into()); } }; + lap("acquire", start); self.paint(&frame.texture.create_view(&Default::default()))?; self.window.pre_present_notify(); + let start = Instant::now(); self.renderer.queue.present(frame); + lap("present", start); trace_input(&"Present submitted"); if reconfigure { self.surface.configure(&self.renderer.device, &self.config); @@ -1234,8 +1573,19 @@ impl State { /// Paints the interface with the page in its box. fn paint(&mut self, target: &wgpu::TextureView) -> Result<(), Box> { + let start = Instant::now(); let [caret, selection] = macos::text_colors(); - let page_primitives = self.view.primitives(TextColors { caret, selection })?; + let paper = canvas::gpu::Paper { + color: self.ui.theme.paper, + ink: self.ui.theme.paper_ink, + }; + self.view.update_backgrounds(paper, &self.redraw); + let page_primitives = self.view.primitives(TextColors { + caret, + selection, + paper, + })?; + lap("page primitives", start); let scale = self.ui.scale(); let corner = self.ui.rect(page()).unwrap_or_default(); let viewport = self.view.viewport; @@ -1261,6 +1611,7 @@ impl State { }) .collect(); trace_input(&("Draw", viewport.origin, viewport.scale)); + let start = Instant::now(); self.renderer .draw( target, @@ -1268,7 +1619,9 @@ impl State { self.ui.theme.base, &layers, ) - .map_err(|error| format!("Canvas drawing failed: {error:?}").into()) + .map_err(|error| format!("Canvas drawing failed: {error:?}"))?; + lap("render", start); + Ok(()) } /// Writes the frame to a PNG, so a covered window can be reviewed. @@ -1373,6 +1726,76 @@ impl State { } } +fn theme(appearance: winit::window::Theme) -> Theme { + match appearance { + winit::window::Theme::Dark => Theme::dark(), + winit::window::Theme::Light => Theme::light(), + } +} + +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) +} + +/// The pages whose titles contain `filter`, ignoring case. +fn matching<'a>( + pages: &'a [(ExGuid, String, u32)], + filter: &str, +) -> impl Iterator { + let filter = filter.to_lowercase(); + pages + .iter() + .filter(move |(_, title, _)| title.to_lowercase().contains(&filter)) +} + +/// A toolbar group's rows, stacked. +fn group(ui: &mut Ui, part: &str, rows: impl FnOnce(&mut Ui)) { + ui.open( + part, + Spec { + axis: Axis::Y, + gap: 2.0, + ..Spec::default() + }, + ); + rows(ui); + ui.close(); +} + +fn row(ui: &mut Ui, part: usize, buttons: impl FnOnce(&mut Ui)) { + ui.open( + part, + Spec { + gap: 1.0, + ..Spec::default() + }, + ); + buttons(ui); + ui.close(); +} + +/// The line between two toolbar groups. +fn divider(ui: &mut Ui, part: &str, theme: &Theme) { + ui.leaf( + ("divider", part), + Spec { + size: [px(1.0), px(2.0 * ui::shell::TOOL + 2.0)], + fill: Some(theme.chip), + ..Spec::default() + }, + ); +} + /// Section tabs for a notebook's readable top-level sections, in its order. fn tabs(notebook: ¬ebook::session::Notebook) -> Vec { notebook @@ -1481,11 +1904,19 @@ impl ApplicationHandler for App { } return; } + UserEvent::Redraw => { + if let Some(state) = &self.state { + state.window.request_redraw(); + } + return; + } UserEvent::Replay(replay) => { if let Some(state) = &mut self.state { match replay { Replay::Input(event) => state.input(event), Replay::Snapshot(path) => state.snapshot = Some(path), + Replay::Tick => {} + Replay::Appearance(appearance) => state.ui.theme = theme(appearance), } // A covered window gets no redraws, so each step draws its own frame. if let Err(error) = state.frame() { @@ -1504,7 +1935,10 @@ impl ApplicationHandler for App { } let was_marked = state.view.editor.marked_range().is_some(); let result = match event.window_event { - accesskit_winit::WindowEvent::InitialTreeRequested => state.update_accessibility(), + accesskit_winit::WindowEvent::InitialTreeRequested => { + state.accessibility.deactivate(); + state.update_accessibility() + } accesskit_winit::WindowEvent::ActionRequested(request) => state.access_action(request), accesskit_winit::WindowEvent::AccessibilityDeactivated => { state.accessibility.deactivate(); @@ -1528,6 +1962,7 @@ impl ApplicationHandler for App { self.proxy.clone(), self.input.take().unwrap(), &self.substitutes, + std::mem::take(&mut self.interface_font), )) { Ok(state) => self.state = Some(state), Err(error) => { @@ -1575,8 +2010,13 @@ impl ApplicationHandler for App { // lets the window show the previous frame at the new size. return state.frame(); } + WindowEvent::ThemeChanged(appearance) => { + state.ui.theme = theme(appearance); + state.window.request_redraw(); + } WindowEvent::ScaleFactorChanged { scale_factor, .. } => { state.renderer.clear_glyph_cache(); + state.app_icon = macos::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(); @@ -1589,7 +2029,7 @@ impl ApplicationHandler for App { trace_input(&("Window occluded", occluded)); state.occluded = occluded; if !occluded { - state.changed = true; + state.moved = true; state.window.request_redraw(); } } @@ -1654,7 +2094,8 @@ impl ApplicationHandler for App { /// Feeds a development script to the window from another thread, one command per line /// in logical pixels: `move X Y`, `press`, `release`, `wheel DX DY`, `key NAME`, `type -/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS` and `snapshot PNG_PATH`. +/// TEXT`, `modifiers [shift] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH` and +/// `appearance light|dark`. fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box> { let mut steps = Vec::new(); for line in script.lines().filter(|line| !line.trim().is_empty()) { @@ -1702,6 +2143,11 @@ fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box Err(std::time::Duration::from_millis(rest.parse()?)), "snapshot" => Ok(Replay::Snapshot(rest.into())), + "appearance" => Ok(Replay::Appearance(match rest { + "light" => winit::window::Theme::Light, + "dark" => winit::window::Theme::Dark, + _ => return Err(format!("Unknown appearance {rest}").into()), + })), _ => return Err(format!("Unknown replay command: {line}").into()), }); } @@ -1714,7 +2160,13 @@ fn replay(script: String, proxy: EventLoopProxy) -> Result<(), Box std::thread::sleep(duration), + Err(duration) => { + let start = Instant::now(); + while start.elapsed() < duration { + std::thread::sleep(std::time::Duration::from_millis(16)); + let _ = proxy.send_event(UserEvent::Replay(Replay::Tick)); + } + } } } }); @@ -1725,6 +2177,7 @@ fn main() -> Result<(), Box> { let mut args = std::env::args_os().skip(1); let mut positional = Vec::new(); let mut substitutes = Vec::new(); + let mut interface_font = Vec::new(); let mut reference = None; let mut editable = false; let mut section = None; @@ -1736,6 +2189,27 @@ fn main() -> Result<(), Box> { args.next() .ok_or("Provide a font file after --substitute-font.")?, )); + } else if arg == "--ui-font" { + let path = PathBuf::from( + args.next() + .ok_or("Provide a font file or folder after --ui-font.")?, + ); + let mut files = if path.is_dir() { + std::fs::read_dir(&path)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>()? + } else { + vec![path] + }; + files.retain(|file| { + file.extension().is_some_and(|extension| { + matches!(extension.to_str(), Some("otf" | "ttf" | "ttc")) + }) + }); + files.sort(); + for file in files { + interface_font.push(std::fs::read(file)?); + } } else if arg == "--notebook" { notebook = Some(PathBuf::from( args.next() @@ -1794,7 +2268,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] [--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] [--ui-font FONT_FILE_OR_FOLDER] [--substitute-font FONT_FILE]..." .into(), ); } @@ -1842,6 +2316,7 @@ fn main() -> Result<(), Box> { proxy: event_loop.create_proxy(), input: Some(input), substitutes, + interface_font, state: None, startup_error: None, }; diff --git a/crates/ui/assets/chevron-down.svg b/crates/ui/assets/chevron-down.svg new file mode 100644 index 0000000000000000000000000000000000000000..0ac146ea6adfd1948953f7fd22c242001fa7c547 --- /dev/null +++ b/crates/ui/assets/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs index 106c6810766f2099d55cd24f29c8c70e32480ea5..affbac0084f784b134c6677508c7656152e39260 100644 --- a/crates/ui/src/layout.rs +++ b/crates/ui/src/layout.rs @@ -11,7 +11,17 @@ pub(crate) fn solve(nodes: &mut [Built], states: &HashMap, scale: f32 node.computed[axis] = match node.size[axis].size { Size::Pixels(pixels) => pixels, Size::Text => { - node.label.as_ref().map_or(0.0, |label| label.size[axis]) + 2.0 * node.pad[axis] + let content = if axis == 0 { + node.content_width() + } else { + let label = node.label.as_ref().map_or(0.0, |label| label.size[1]); + if node.icon.is_some() || node.image.is_some() { + label.max(crate::ICON) + } else { + label + } + }; + content + 2.0 * node.pad[axis] } Size::Fraction(_) | Size::Children => 0.0, }; diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 0cee2fbbf18cc6e94b62c7674eea08a794ed87ca..32816b763ce83cd7856a2170812f833371b0c88a 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -4,22 +4,27 @@ //! events are routed before building and its layout is solved after. mod layout; +pub mod shell; mod text; mod theme; mod widgets; -pub use theme::Theme; -pub use widgets::{button, scrollbar, text_field}; +pub use theme::{Section, Shades, Theme}; +pub use widgets::{button, edit_key, edit_modifiers, scrollbar, text_field}; -use draw::{Primitive, Stroke}; +use draw::{ + PathStyle, Primitive, RasterImage, Stroke, + edit::{Clicks, SelectionUnit}, +}; +use parley::editing::Selection; use std::{ collections::HashMap, hash::{DefaultHasher, Hash, Hasher}, ops::BitOr, rc::Rc, - time::Instant, + time::{Duration, Instant}, }; -use text::{Label, Painted, Texts}; +use text::{Label, Texts}; use winit::{ event::{Ime, MouseButton}, keyboard::{Key, ModifiersState}, @@ -28,6 +33,11 @@ use winit::{ /// Seconds for an animated value to close half of its remaining distance. const HALF_LIFE: f32 = 0.03; +/// How far a box's shadow spreads, in logical pixels. +const SHADOW: f32 = 3.0; +/// Logical size of a box's icon, and its distance from the label. +const ICON: f32 = 16.0; +const ICON_GAP: f32 = 6.0; /// A box's identity across frames: its parent's id combined with a builder-chosen part. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -139,6 +149,18 @@ impl BitOr for Flags { } } +/// How a box's fill and border are outlined; `radius` rounds the named corners. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum Shape { + #[default] + Rounded, + /// A section tab: the top leading corner rounded and the trailing edge leaning out + /// `slant` pixels from top to bottom, centred on the box's edge so neighbours overlap. + Tab { slant: f32 }, + /// Only the trailing corners rounded, so the leading edge joins what it sits against. + Trailing, +} + /// What a box is this frame. Colours are linear RGBA. #[derive(Clone, Default)] pub struct Spec<'a> { @@ -150,11 +172,20 @@ pub struct Spec<'a> { /// The label's colour; the theme's text colour otherwise. pub color: Option<[f32; 4]>, pub fill: Option<[f32; 4]>, + /// The fill's colour at the bottom, fading from `fill` at the top. + pub gradient: Option<[f32; 4]>, + /// A soft shadow of the box's outline, painted beneath it. + pub shadow: Option<[f32; 4]>, /// The fill under the pointer, blended in as hover animates. pub hover_fill: Option<[f32; 4]>, pub border: Option<[f32; 4]>, pub hover_border: Option<[f32; 4]>, pub radius: f32, + 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. + pub image: Option<&'a RasterImage>, /// Inset of the label and children on each axis. pub pad: [f32; 2], /// Space between children along the flow. @@ -190,6 +221,8 @@ pub struct Signal { pub hovered: bool, /// The primary button went down on the box. pub pressed: bool, + /// What the press selects text by: a single, double or triple click. + pub unit: SelectionUnit, /// The primary button came up over the box it went down on. pub clicked: bool, /// The primary button is held after pressing the box. @@ -224,10 +257,15 @@ struct Built { label: Option>, color: [f32; 4], fill: Option<[f32; 4]>, + gradient: Option<[f32; 4]>, + shadow: Option<[f32; 4]>, hover_fill: Option<[f32; 4]>, border: Option<[f32; 4]>, hover_border: Option<[f32; 4]>, radius: f32, + shape: Shape, + icon: Option<&'static [&'static str]>, + image: Option, pad: [f32; 2], gap: f32, center: bool, @@ -245,17 +283,20 @@ struct Built { struct State { touched: u64, hot: f32, - active: f32, + /// The box paints differently under the pointer, so its hover is worth easing. + hovers: bool, scroll: f32, scroll_target: f32, /// Height of a scrolling box's children last frame. content: f32, rect: [f32; 4], - /// A text field's caret and selection anchor, in bytes. - caret: usize, - mark: usize, + /// A text field's selection, and the selection and unit of the press a drag extends. + selection: Selection, + press: (Selection, SelectionUnit), /// Where a dragged scrollbar thumb was taken, from its start. grab: f32, + /// An animated value and its target, for `Ui::animate`. + tween: Option<[f32; 2]>, } struct Hit { @@ -270,13 +311,39 @@ enum Display { Rect { rect: [f32; 4], fill: [f32; 4], + /// The fill's colour at the bottom. + shade: Option<[f32; 4]>, border: Option<[f32; 4]>, radius: f32, }, + /// A hairline 1 px wide. + Segment { + from: [f32; 2], + to: [f32; 2], + color: [f32; 4], + }, + Image { + image: RasterImage, + rect: [f32; 4], + }, + /// A shaped outline, filled or stroked. + Path { + data: String, + origin: [f32; 2], + style: PathStyle, + /// Top and bottom. + colors: [[f32; 4]; 2], + }, + Icon { + sources: &'static [&'static str], + origin: [f32; 2], + tint: [f32; 4], + }, Text { - painted: usize, + label: Rc