From 0d005581890507e46939e22ee0d991887bb3c1b7 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 25 Sep 2026 17:00:54 -0700 Subject: [PATCH] feat: OneNote-shaped shell, themes, dark pages, template backgrounds and large-page speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window follows OneNote's layout in light and dark themes that track the system appearance: title bar with the app icon and notebook name, two compact toolbar rows after the Home ribbon, section tabs that lean at 45° and rise when opened, a section-coloured frame shading down the window, and page tabs whose open tab joins the page. Sections keep their stored hue at per-theme saturation and lightness; --ui-font sets the interface typeface without bundling one. In the dark theme the paper darkens and automatic text turns light, resolved at paint time. The 71 background pictures of OneNote 2010's page templates are recognised by hash and painted from authored SVG recreations in light and dark, rasterized off the frame thread at the current zoom. Large pages stay within a 165 Hz frame: painting skips paragraphs and highlights outside the view, accessibility updates only what changed and moves the page by one transform on scroll, keystrokes validate and lay out only what they touch, scrolling no longer saves, and hover no longer animates boxes that don't show it. draw gains filled, stroked and blurred SVG paths, gradients and tinted icons, and draw::edit holds the text editing the page and ui share; the renderer sits behind draw's default render feature. Assisted-by: claude-opus-5.5 Assisted-by: claude-opus-5 --- Cargo.lock | 158 +- Cargo.toml | 5 + crates/canvas/Cargo.toml | 6 +- crates/canvas/assets/backgrounds/bamboo.svg | 22 + .../assets/backgrounds/binoculars-corner.svg | 20 + .../backgrounds/black-and-green-title.svg | 12 + .../blue-bubbles-corner-margin.svg | 10 + .../backgrounds/blue-bubbles-corner-top.svg | 5 + .../canvas/assets/backgrounds/blue-clouds.svg | 10 + .../canvas/assets/backgrounds/blue-dots.svg | 58 + .../canvas/assets/backgrounds/blue-flow.svg | 18 + .../assets/backgrounds/blue-mist-margin.svg | 14 + .../assets/backgrounds/blue-stripe-title.svg | 15 + .../assets/backgrounds/blue-stripes.svg | 12 + .../canvas/assets/backgrounds/blue-swirls.svg | 102 + .../assets/backgrounds/blue-wave-title.svg | 5 + .../canvas/assets/backgrounds/blue-waves.svg | 33 + crates/canvas/assets/backgrounds/books.svg | 9 + crates/canvas/assets/backgrounds/bouquet.svg | 21 + .../assets/backgrounds/boxes-shadow.svg | 16 + crates/canvas/assets/backgrounds/boxes.svg | 3 + .../backgrounds/bubbles-margin-margin.svg | 9 + .../assets/backgrounds/bubbles-margin-top.svg | 8 + crates/canvas/assets/backgrounds/bubbles.svg | 25 + .../canvas/assets/backgrounds/buildings.svg | 16 + .../canvas/assets/backgrounds/chain-title.svg | 20 + crates/canvas/assets/backgrounds/circles.svg | 9 + crates/canvas/assets/backgrounds/clock.svg | 16 + crates/canvas/assets/backgrounds/columns.svg | 33 + crates/canvas/assets/backgrounds/computer.svg | 23 + .../canvas/assets/backgrounds/cyan-title.svg | 25 + .../canvas/assets/backgrounds/day-planner.svg | 21 + .../canvas/assets/backgrounds/fireworks.svg | 25 + .../assets/backgrounds/flat-squares.svg | 17 + .../assets/backgrounds/flowers-and-hearts.svg | 36 + .../assets/backgrounds/glasses-corner.svg | 15 + crates/canvas/assets/backgrounds/globe.svg | 18 + crates/canvas/assets/backgrounds/graph.svg | 18 + .../assets/backgrounds/green-stripes.svg | 12 + crates/canvas/assets/backgrounds/hearts.svg | 11 + crates/canvas/assets/backgrounds/ivy.svg | 158 ++ .../assets/backgrounds/large-push-pins.svg | 24 + .../assets/backgrounds/light-bulb-corner.svg | 15 + crates/canvas/assets/backgrounds/lilies.svg | 15 + .../backgrounds/math-science-class-notes.svg | 17 + crates/canvas/assets/backgrounds/math.svg | 15 + .../canvas/assets/backgrounds/networking.svg | 33 + crates/canvas/assets/backgrounds/notebook.svg | 19 + .../assets/backgrounds/orange-margin.svg | 16 + crates/canvas/assets/backgrounds/paper.svg | 15 + .../canvas/assets/backgrounds/paws-cyan.svg | 18 + .../backgrounds/pencil-and-notebook.svg | 17 + crates/canvas/assets/backgrounds/pens.svg | 27 + .../assets/backgrounds/pixel-blends.svg | 39 + crates/canvas/assets/backgrounds/pixel.svg | 11 + .../assets/backgrounds/plain-and-simple.svg | 12 + .../assets/backgrounds/purple-clouds.svg | 61 + .../assets/backgrounds/pushpins-corner.svg | 16 + .../assets/backgrounds/rainbow-border.svg | 18 + .../backgrounds/rainbow-mini-border.svg | 44 + crates/canvas/assets/backgrounds/rainbow.svg | 40 + crates/canvas/assets/backgrounds/recipe.svg | 21 + .../backgrounds/red-and-black-margin.svg | 16 + .../assets/backgrounds/side-stripes.svg | 11 + .../assets/backgrounds/small-push-pins.svg | 22 + crates/canvas/assets/backgrounds/sparks.svg | 43 + crates/canvas/assets/backgrounds/stars.svg | 26 + crates/canvas/assets/backgrounds/swirls.svg | 45 + crates/canvas/assets/backgrounds/tiles.svg | 389 +++ .../backgrounds/triangles-title-margin.svg | 6 + .../backgrounds/triangles-title-top.svg | 6 + crates/canvas/assets/backgrounds/tulips.svg | 131 + .../assets/backgrounds/window-reflection.svg | 14 + crates/canvas/assets/backgrounds/writing.svg | 25 + crates/canvas/examples/render_page.rs | 9 +- crates/canvas/src/document.rs | 581 ++++- crates/canvas/src/editor.rs | 532 ++-- crates/canvas/src/editor/page.rs | 1 - crates/canvas/src/editor/table.rs | 10 +- crates/canvas/src/gpu/mod.rs | 89 +- crates/canvas/src/gpu/page.rs | 221 +- crates/canvas/src/gpu/page/background.rs | 807 +++++++ crates/canvas/src/gpu/profile.rs | 204 +- .../canvas/src/interaction/accessibility.rs | 1411 ++++++----- crates/canvas/src/interaction/mod.rs | 292 +-- crates/canvas/src/interaction/profile.rs | 26 +- crates/canvas/src/interaction/tests.rs | 130 +- crates/canvas/src/layout.rs | 71 +- crates/canvas/src/math.rs | 6 +- crates/canvas/src/outline.rs | 778 ++++-- crates/draw/Cargo.toml | 16 +- crates/draw/src/edit.rs | 457 ++++ crates/draw/src/icon.rs | 122 - crates/draw/src/lib.rs | 1834 +------------- crates/draw/src/render.rs | 2135 +++++++++++++++++ crates/draw/src/render/icon.rs | 161 ++ crates/draw/src/{ => render}/text.rs | 8 +- crates/snowbound/Cargo.toml | 2 +- crates/snowbound/assets/icons/align-left.svg | 3 + crates/snowbound/assets/icons/attachment.svg | 3 + crates/snowbound/assets/icons/bold.svg | 3 + crates/snowbound/assets/icons/bullets.svg | 6 + crates/snowbound/assets/icons/calendar.svg | 4 + .../assets/icons/clear-formatting.svg | 3 + crates/snowbound/assets/icons/clock.svg | 3 + crates/snowbound/assets/icons/close.svg | 3 + crates/snowbound/assets/icons/equation.svg | 3 + crates/snowbound/assets/icons/font-color.svg | 3 + crates/snowbound/assets/icons/highlighter.svg | 3 + crates/snowbound/assets/icons/indent.svg | 4 + crates/snowbound/assets/icons/italic.svg | 3 + crates/snowbound/assets/icons/link.svg | 3 + crates/snowbound/assets/icons/new-page.svg | 3 + crates/snowbound/assets/icons/numbering.svg | 5 + crates/snowbound/assets/icons/outdent.svg | 4 + crates/snowbound/assets/icons/picture.svg | 4 + crates/snowbound/assets/icons/redo.svg | 3 + crates/snowbound/assets/icons/search.svg | 3 + .../assets/icons/sidebar-collapse.svg | 3 + .../snowbound/assets/icons/sidebar-expand.svg | 3 + .../snowbound/assets/icons/strikethrough.svg | 3 + crates/snowbound/assets/icons/subscript.svg | 4 + crates/snowbound/assets/icons/table.svg | 3 + crates/snowbound/assets/icons/underline.svg | 3 + crates/snowbound/assets/icons/undo.svg | 3 + crates/snowbound/assets/icons/zoom-in.svg | 3 + crates/snowbound/assets/icons/zoom-out.svg | 3 + crates/snowbound/assets/tags/address.svg | 34 + crates/snowbound/assets/tags/contact.svg | 25 + crates/snowbound/assets/tags/definition.svg | 15 + crates/snowbound/assets/tags/highlight.svg | 29 + crates/snowbound/assets/tags/phone.svg | 14 + crates/snowbound/assets/tags/remember.svg | 15 + crates/snowbound/assets/tags/star.svg | 14 + crates/snowbound/src/art.rs | 45 + crates/snowbound/src/macos.rs | 52 +- crates/snowbound/src/main.rs | 939 ++++++-- crates/ui/assets/chevron-down.svg | 3 + crates/ui/src/layout.rs | 12 +- crates/ui/src/lib.rs | 400 ++- crates/ui/src/shell.rs | 282 +++ crates/ui/src/tests.rs | 240 +- crates/ui/src/text.rs | 60 +- crates/ui/src/theme.rs | 146 +- crates/ui/src/widgets.rs | 253 +- fuzz/Cargo.lock | 10 + fuzz/Cargo.toml | 1 + fuzz/fuzz_targets/canvas_editor.rs | 3 +- tools/canvas/README.md | 14 +- 149 files changed, 10903 insertions(+), 3999 deletions(-) create mode 100644 crates/canvas/assets/backgrounds/bamboo.svg create mode 100644 crates/canvas/assets/backgrounds/binoculars-corner.svg create mode 100644 crates/canvas/assets/backgrounds/black-and-green-title.svg create mode 100644 crates/canvas/assets/backgrounds/blue-bubbles-corner-margin.svg create mode 100644 crates/canvas/assets/backgrounds/blue-bubbles-corner-top.svg create mode 100644 crates/canvas/assets/backgrounds/blue-clouds.svg create mode 100644 crates/canvas/assets/backgrounds/blue-dots.svg create mode 100644 crates/canvas/assets/backgrounds/blue-flow.svg create mode 100644 crates/canvas/assets/backgrounds/blue-mist-margin.svg create mode 100644 crates/canvas/assets/backgrounds/blue-stripe-title.svg create mode 100644 crates/canvas/assets/backgrounds/blue-stripes.svg create mode 100644 crates/canvas/assets/backgrounds/blue-swirls.svg create mode 100644 crates/canvas/assets/backgrounds/blue-wave-title.svg create mode 100644 crates/canvas/assets/backgrounds/blue-waves.svg create mode 100644 crates/canvas/assets/backgrounds/books.svg create mode 100644 crates/canvas/assets/backgrounds/bouquet.svg create mode 100644 crates/canvas/assets/backgrounds/boxes-shadow.svg create mode 100644 crates/canvas/assets/backgrounds/boxes.svg create mode 100644 crates/canvas/assets/backgrounds/bubbles-margin-margin.svg create mode 100644 crates/canvas/assets/backgrounds/bubbles-margin-top.svg create mode 100644 crates/canvas/assets/backgrounds/bubbles.svg create mode 100644 crates/canvas/assets/backgrounds/buildings.svg create mode 100644 crates/canvas/assets/backgrounds/chain-title.svg create mode 100644 crates/canvas/assets/backgrounds/circles.svg create mode 100644 crates/canvas/assets/backgrounds/clock.svg create mode 100644 crates/canvas/assets/backgrounds/columns.svg create mode 100644 crates/canvas/assets/backgrounds/computer.svg create mode 100644 crates/canvas/assets/backgrounds/cyan-title.svg create mode 100644 crates/canvas/assets/backgrounds/day-planner.svg create mode 100644 crates/canvas/assets/backgrounds/fireworks.svg create mode 100644 crates/canvas/assets/backgrounds/flat-squares.svg create mode 100644 crates/canvas/assets/backgrounds/flowers-and-hearts.svg create mode 100644 crates/canvas/assets/backgrounds/glasses-corner.svg create mode 100644 crates/canvas/assets/backgrounds/globe.svg create mode 100644 crates/canvas/assets/backgrounds/graph.svg create mode 100644 crates/canvas/assets/backgrounds/green-stripes.svg create mode 100644 crates/canvas/assets/backgrounds/hearts.svg create mode 100644 crates/canvas/assets/backgrounds/ivy.svg create mode 100644 crates/canvas/assets/backgrounds/large-push-pins.svg create mode 100644 crates/canvas/assets/backgrounds/light-bulb-corner.svg create mode 100644 crates/canvas/assets/backgrounds/lilies.svg create mode 100644 crates/canvas/assets/backgrounds/math-science-class-notes.svg create mode 100644 crates/canvas/assets/backgrounds/math.svg create mode 100644 crates/canvas/assets/backgrounds/networking.svg create mode 100644 crates/canvas/assets/backgrounds/notebook.svg create mode 100644 crates/canvas/assets/backgrounds/orange-margin.svg create mode 100644 crates/canvas/assets/backgrounds/paper.svg create mode 100644 crates/canvas/assets/backgrounds/paws-cyan.svg create mode 100644 crates/canvas/assets/backgrounds/pencil-and-notebook.svg create mode 100644 crates/canvas/assets/backgrounds/pens.svg create mode 100644 crates/canvas/assets/backgrounds/pixel-blends.svg create mode 100644 crates/canvas/assets/backgrounds/pixel.svg create mode 100644 crates/canvas/assets/backgrounds/plain-and-simple.svg create mode 100644 crates/canvas/assets/backgrounds/purple-clouds.svg create mode 100644 crates/canvas/assets/backgrounds/pushpins-corner.svg create mode 100644 crates/canvas/assets/backgrounds/rainbow-border.svg create mode 100644 crates/canvas/assets/backgrounds/rainbow-mini-border.svg create mode 100644 crates/canvas/assets/backgrounds/rainbow.svg create mode 100644 crates/canvas/assets/backgrounds/recipe.svg create mode 100644 crates/canvas/assets/backgrounds/red-and-black-margin.svg create mode 100644 crates/canvas/assets/backgrounds/side-stripes.svg create mode 100644 crates/canvas/assets/backgrounds/small-push-pins.svg create mode 100644 crates/canvas/assets/backgrounds/sparks.svg create mode 100644 crates/canvas/assets/backgrounds/stars.svg create mode 100644 crates/canvas/assets/backgrounds/swirls.svg create mode 100644 crates/canvas/assets/backgrounds/tiles.svg create mode 100644 crates/canvas/assets/backgrounds/triangles-title-margin.svg create mode 100644 crates/canvas/assets/backgrounds/triangles-title-top.svg create mode 100644 crates/canvas/assets/backgrounds/tulips.svg create mode 100644 crates/canvas/assets/backgrounds/window-reflection.svg create mode 100644 crates/canvas/assets/backgrounds/writing.svg create mode 100644 crates/canvas/src/gpu/page/background.rs create mode 100644 crates/draw/src/edit.rs delete mode 100644 crates/draw/src/icon.rs create mode 100644 crates/draw/src/render.rs create mode 100644 crates/draw/src/render/icon.rs rename crates/draw/src/{ => render}/text.rs (94%) create mode 100644 crates/snowbound/assets/icons/align-left.svg create mode 100644 crates/snowbound/assets/icons/attachment.svg create mode 100644 crates/snowbound/assets/icons/bold.svg create mode 100644 crates/snowbound/assets/icons/bullets.svg create mode 100644 crates/snowbound/assets/icons/calendar.svg create mode 100644 crates/snowbound/assets/icons/clear-formatting.svg create mode 100644 crates/snowbound/assets/icons/clock.svg create mode 100644 crates/snowbound/assets/icons/close.svg create mode 100644 crates/snowbound/assets/icons/equation.svg create mode 100644 crates/snowbound/assets/icons/font-color.svg create mode 100644 crates/snowbound/assets/icons/highlighter.svg create mode 100644 crates/snowbound/assets/icons/indent.svg create mode 100644 crates/snowbound/assets/icons/italic.svg create mode 100644 crates/snowbound/assets/icons/link.svg create mode 100644 crates/snowbound/assets/icons/new-page.svg create mode 100644 crates/snowbound/assets/icons/numbering.svg create mode 100644 crates/snowbound/assets/icons/outdent.svg create mode 100644 crates/snowbound/assets/icons/picture.svg create mode 100644 crates/snowbound/assets/icons/redo.svg create mode 100644 crates/snowbound/assets/icons/search.svg create mode 100644 crates/snowbound/assets/icons/sidebar-collapse.svg create mode 100644 crates/snowbound/assets/icons/sidebar-expand.svg create mode 100644 crates/snowbound/assets/icons/strikethrough.svg create mode 100644 crates/snowbound/assets/icons/subscript.svg create mode 100644 crates/snowbound/assets/icons/table.svg create mode 100644 crates/snowbound/assets/icons/underline.svg create mode 100644 crates/snowbound/assets/icons/undo.svg create mode 100644 crates/snowbound/assets/icons/zoom-in.svg create mode 100644 crates/snowbound/assets/icons/zoom-out.svg create mode 100644 crates/snowbound/assets/tags/address.svg create mode 100644 crates/snowbound/assets/tags/contact.svg create mode 100644 crates/snowbound/assets/tags/definition.svg create mode 100644 crates/snowbound/assets/tags/highlight.svg create mode 100644 crates/snowbound/assets/tags/phone.svg create mode 100644 crates/snowbound/assets/tags/remember.svg create mode 100644 crates/snowbound/assets/tags/star.svg create mode 100644 crates/snowbound/src/art.rs create mode 100644 crates/ui/assets/chevron-down.svg create mode 100644 crates/ui/src/shell.rs 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/text.rs b/crates/draw/src/render/text.rs similarity index 94% rename from crates/draw/src/text.rs rename to crates/draw/src/render/text.rs index c17d43d67bf0ac01c9e88c38495c895c9633ad18..1fd8b0165c34c21122e1a0f99324decd8393c406 100644 --- a/crates/draw/src/text.rs +++ b/crates/draw/src/render/text.rs @@ -21,7 +21,8 @@ pub struct GlyphRun<'a> { pub embolden: bool, /// Synthesized oblique angle in degrees. pub skew: Option, - pub color: [f32; 4], + /// `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], @@ -45,7 +46,8 @@ pub struct Decoration { pub y: f32, pub width: f32, pub thickness: f32, - pub color: [f32; 4], + /// `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 @@ -55,7 +57,7 @@ pub fn paint_parley_run( baseline: f32, rise: f32, line: [f32; 2], - color: impl Fn(&B) -> [f32; 4], + color: impl Fn(&B) -> Option<[f32; 4]>, paint: &mut dyn FnMut(GlyphRun<'_>) -> Result<(), RenderError>, ) -> Result<(), RenderError> { let shaped = run.run(); 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