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