Merge branch 'master' into path_copy_paste

This commit is contained in:
Adesh Gupta 2025-07-01 15:59:50 +05:30 committed by GitHub
commit 4a5f8c7d3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1281 additions and 569 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ perf.data*
profile.json
flamegraph.svg
.idea/
.direnv

View file

@ -51,7 +51,7 @@
libraw
# Tauri dependencies: keep in sync with https://v2.tauri.app/start/prerequisites/
# Tauri dependencies: keep in sync with https://v2.tauri.app/start/prerequisites/#system-dependencies (under the NixOS tab)
at-spi2-atk
atkmm
cairo

340
Cargo.lock generated
View file

@ -82,6 +82,12 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android-activity"
version = "0.5.2"
@ -486,11 +492,11 @@ dependencies = [
[[package]]
name = "block2"
version = "0.6.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d59b4c170e16f0405a2e95aff44432a0d41aa97675f3d52623effe95792a037"
checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2"
dependencies = [
"objc2 0.6.0",
"objc2 0.6.1",
]
[[package]]
@ -528,9 +534,9 @@ checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]]
name = "bytemuck"
version = "1.22.0"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540"
checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422"
dependencies = [
"bytemuck_derive",
]
@ -822,6 +828,12 @@ dependencies = [
"serde",
]
[[package]]
name = "color"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ae467d04a8a8aea5d9a49018a6ade2e4221d92968e8ce55a48c0b1164e5f698"
[[package]]
name = "color_quant"
version = "1.1.0"
@ -1255,6 +1267,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]]
name = "dispatch2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.1",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@ -1569,6 +1591,25 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "font-types"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7"
dependencies = [
"bytemuck",
]
[[package]]
name = "fontconfig-cache-parser"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18"
dependencies = [
"bytemuck",
"thiserror 1.0.69",
]
[[package]]
name = "fontconfig-parser"
version = "0.5.7"
@ -1592,6 +1633,29 @@ dependencies = [
"ttf-parser 0.24.1",
]
[[package]]
name = "fontique"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39f97079e1293b8c1e9fb03a2875d328bd2ee8f3b95ce62959c0acc04049c708"
dependencies = [
"bytemuck",
"fontconfig-cache-parser",
"hashbrown 0.15.4",
"icu_locid",
"memmap2",
"objc2 0.6.1",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.1",
"peniko 0.4.0",
"read-fonts 0.29.3",
"roxmltree",
"smallvec",
"windows 0.58.0",
"windows-core 0.58.0",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
@ -2109,7 +2173,7 @@ checksum = "dcf29e94d6d243368b7a56caa16bc213e4f9f8ed38c4d9557069527b5d5281ca"
dependencies = [
"bitflags 2.9.0",
"gpu-descriptor-types",
"hashbrown 0.15.2",
"hashbrown 0.15.4",
]
[[package]]
@ -2200,13 +2264,14 @@ dependencies = [
"node-macro",
"num-derive",
"num-traits",
"parley",
"petgraph 0.7.1",
"rand 0.9.0",
"rand_chacha 0.9.0",
"rustc-hash 2.1.1",
"rustybuzz 0.20.1",
"serde",
"serde_json",
"skrifa 0.32.0",
"specta",
"tinyvec",
"tokio",
@ -2470,10 +2535,12 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.2"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
@ -2949,7 +3016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652"
dependencies = [
"equivalent",
"hashbrown 0.15.2",
"hashbrown 0.15.4",
"serde",
]
@ -3204,9 +3271,9 @@ dependencies = [
[[package]]
name = "kurbo"
version = "0.11.1"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89234b2cc610a7dd927ebde6b41dd1a5d4214cffaef4cf1fb2195d592f92518f"
checksum = "1077d333efea6170d9ccb96d3c3026f300ca0773da4938cc4c811daa6df68b0c"
dependencies = [
"arrayvec",
"serde",
@ -3282,7 +3349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [
"cfg-if",
"windows-targets 0.48.5",
"windows-targets 0.52.6",
]
[[package]]
@ -3506,10 +3573,10 @@ dependencies = [
"dpi",
"gtk",
"keyboard-types",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.1",
"once_cell",
"png",
"serde",
@ -3790,9 +3857,9 @@ dependencies = [
[[package]]
name = "objc2"
version = "0.6.0"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3531f65190d9cff863b77a99857e74c314dd16bf56c538c4b57c7cbc3f3a6e59"
checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551"
dependencies = [
"objc2-encode 4.1.0",
"objc2-exception-helper",
@ -3805,15 +3872,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb"
dependencies = [
"bitflags 2.9.0",
"block2 0.6.0",
"block2 0.6.1",
"libc",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.1",
"objc2-quartz-core 0.3.0",
]
@ -3824,8 +3891,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c1948a9be5f469deadbd6bcb86ad7ff9e47b4f632380139722f7d9840c0d42c"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2-foundation 0.3.0",
"objc2 0.6.1",
"objc2-foundation 0.3.1",
]
[[package]]
@ -3835,18 +3902,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f860f8e841f6d32f754836f51e6bc7777cd7e7053cf18528233f6811d3eceb4"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2-foundation 0.3.0",
"objc2 0.6.1",
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925"
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"dispatch2",
"objc2 0.6.1",
]
[[package]]
@ -3856,7 +3924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dca602628b65356b6513290a21a6405b4d4027b8b250f0b98dddbb28b7de02"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-core-foundation",
"objc2-io-surface",
]
@ -3867,8 +3935,18 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ffa6bea72bf42c78b0b34e89c0bafac877d5f80bf91e159a5d96ea7f693ca56"
dependencies = [
"objc2 0.6.0",
"objc2-foundation 0.3.0",
"objc2 0.6.1",
"objc2-foundation 0.3.1",
]
[[package]]
name = "objc2-core-text"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ba833d4a1cb1aac330f8c973fd92b6ff1858e4aef5cdd00a255eefb28022fb5"
dependencies = [
"bitflags 2.9.0",
"objc2-core-foundation",
]
[[package]]
@ -3906,14 +3984,14 @@ dependencies = [
[[package]]
name = "objc2-foundation"
version = "0.3.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a21c6c9014b82c39515db5b396f91645182611c97d24637cf56ac01e5f8d998"
checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
dependencies = [
"bitflags 2.9.0",
"block2 0.6.0",
"block2 0.6.1",
"libc",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-core-foundation",
]
@ -3924,7 +4002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "161a8b87e32610086e1a7a9e9ec39f84459db7b3a0881c1f16ca5a2605581c19"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-core-foundation",
]
@ -3960,8 +4038,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fb3794501bb1bee12f08dcad8c61f2a5875791ad1c6f47faa71a0f033f20071"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2-foundation 0.3.0",
"objc2 0.6.1",
"objc2-foundation 0.3.1",
]
[[package]]
@ -3971,9 +4049,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "777a571be14a42a3990d4ebedaeb8b54cd17377ec21b92e8200ac03797b3bee1"
dependencies = [
"bitflags 2.9.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.1",
]
[[package]]
@ -3983,11 +4061,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b717127e4014b0f9f3e8bba3d3f2acec81f1bde01f656823036e823ed2c94dce"
dependencies = [
"bitflags 2.9.0",
"block2 0.6.0",
"objc2 0.6.0",
"block2 0.6.1",
"objc2 0.6.1",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.1",
]
[[package]]
@ -4149,6 +4227,19 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "parley"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13e57638545cf2ba4c3e72cc5715e53b1880b829cc3dbefda3d1700c58efe723"
dependencies = [
"fontique",
"hashbrown 0.15.4",
"peniko 0.4.0",
"skrifa 0.31.3",
"swash",
]
[[package]]
name = "paste"
version = "1.0.15"
@ -4180,7 +4271,18 @@ name = "peniko"
version = "0.2.0"
source = "git+https://github.com/linebender/peniko.git?rev=d114c62#d114c6292dbcfb03e7360692198be423168a0edd"
dependencies = [
"color",
"color 0.1.0",
"kurbo",
"smallvec",
]
[[package]]
name = "peniko"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f9529efd019889b2a205193c14ffb6e2839b54ed9d2720674f10f4b04d87ac9"
dependencies = [
"color 0.3.1",
"kurbo",
"smallvec",
]
@ -4968,7 +5070,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f9e8a4f503e5c8750e4cd3b32a4e090035c46374b305a15c70bad833dca05f"
dependencies = [
"bytemuck",
"font-types",
"font-types 0.8.3",
]
[[package]]
name = "read-fonts"
version = "0.29.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d"
dependencies = [
"bytemuck",
"font-types 0.9.0",
]
[[package]]
name = "read-fonts"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "192735ef611aac958468e670cb98432c925426f3cb71521fda202130f7388d91"
dependencies = [
"bytemuck",
"font-types 0.9.0",
]
[[package]]
@ -5261,26 +5383,8 @@ dependencies = [
"log",
"smallvec",
"ttf-parser 0.24.1",
"unicode-bidi-mirroring 0.3.0",
"unicode-ccc 0.3.0",
"unicode-properties",
"unicode-script",
]
[[package]]
name = "rustybuzz"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
dependencies = [
"bitflags 2.9.0",
"bytemuck",
"core_maths",
"log",
"smallvec",
"ttf-parser 0.25.1",
"unicode-bidi-mirroring 0.4.0",
"unicode-ccc 0.4.0",
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-properties",
"unicode-script",
]
@ -5415,9 +5519,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.218"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
@ -5446,9 +5550,9 @@ dependencies = [
[[package]]
name = "serde_derive"
version = "1.0.218"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
@ -5652,7 +5756,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cc1aa86c26dbb1b63875a7180aa0819709b33348eb5b1491e4321fae388179d"
dependencies = [
"bytemuck",
"read-fonts",
"read-fonts 0.25.3",
]
[[package]]
name = "skrifa"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607"
dependencies = [
"bytemuck",
"read-fonts 0.29.3",
]
[[package]]
name = "skrifa"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d632b5a73f566303dbeabd344dc3e716fd4ddc9a70d6fc8ea8e6f06617da97"
dependencies = [
"bytemuck",
"read-fonts 0.30.1",
]
[[package]]
@ -5675,9 +5799,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.14.0"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
dependencies = [
"serde",
]
@ -5895,6 +6019,17 @@ dependencies = [
"siphasher 1.0.1",
]
[[package]]
name = "swash"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f745de914febc7c9ab4388dfaf94bbc87e69f57bb41133a9b0c84d4be49856f3"
dependencies = [
"skrifa 0.31.3",
"yazi",
"zeno",
]
[[package]]
name = "swift-rs"
version = "1.0.7"
@ -6005,9 +6140,9 @@ dependencies = [
"ndk 0.9.0",
"ndk-context",
"ndk-sys 0.6.0+11769913",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.1",
"once_cell",
"parking_lot",
"raw-window-handle",
@ -6060,9 +6195,9 @@ dependencies = [
"log",
"mime",
"muda",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.1",
"percent-encoding",
"plist",
"raw-window-handle",
@ -6266,9 +6401,9 @@ dependencies = [
"http",
"jni",
"log",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.1",
"once_cell",
"percent-encoding",
"raw-window-handle",
@ -6688,11 +6823,11 @@ dependencies = [
"dirs",
"libappindicator",
"muda",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.1",
"once_cell",
"png",
"serde",
@ -6720,9 +6855,6 @@ name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
dependencies = [
"core_maths",
]
[[package]]
name = "typeid"
@ -6795,24 +6927,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64af057ad7466495ca113126be61838d8af947f41d93a949980b2389a118082f"
[[package]]
name = "unicode-bidi-mirroring"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
[[package]]
name = "unicode-ccc"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "260bc6647b3893a9a90668360803a15f96b85a5257b1c3a0c3daf6ae2496de42"
[[package]]
name = "unicode-ccc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
[[package]]
name = "unicode-ident"
version = "1.0.18"
@ -6900,7 +7020,7 @@ dependencies = [
"log",
"pico-args",
"roxmltree",
"rustybuzz 0.18.0",
"rustybuzz",
"simplecss",
"siphasher 1.0.1",
"strict-num",
@ -6971,9 +7091,9 @@ dependencies = [
"bytemuck",
"futures-intrusive",
"log",
"peniko",
"peniko 0.2.0",
"png",
"skrifa",
"skrifa 0.26.6",
"static_assertions",
"thiserror 2.0.12",
"vello_encoding",
@ -6988,8 +7108,8 @@ source = "git+https://github.com/linebender/vello.git?rev=3275ec8#3275ec85d83118
dependencies = [
"bytemuck",
"guillotiere",
"peniko",
"skrifa",
"peniko 0.2.0",
"skrifa 0.26.6",
"smallvec",
]
@ -7556,10 +7676,10 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
dependencies = [
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.1",
"raw-window-handle",
"windows-sys 0.59.0",
"windows-version",
@ -8077,7 +8197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b19b78efae8b853c6c817e8752fc1dbf9cab8a8ffe9c30f399bd750ccf0f0730"
dependencies = [
"base64 0.22.1",
"block2 0.6.0",
"block2 0.6.1",
"cookie",
"crossbeam-channel",
"dpi",
@ -8091,10 +8211,10 @@ dependencies = [
"kuchikiki",
"libc",
"ndk 0.9.0",
"objc2 0.6.0",
"objc2 0.6.1",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.0",
"objc2-foundation 0.3.1",
"objc2-ui-kit",
"objc2-web-kit",
"once_cell",
@ -8199,6 +8319,12 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yazi"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
[[package]]
name = "yoke"
version = "0.7.5"
@ -8223,6 +8349,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeno"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
[[package]]
name = "zerocopy"
version = "0.7.35"

View file

@ -116,7 +116,8 @@ rand_chacha = "0.9"
glam = { version = "0.29", default-features = false, features = ["serde", "scalar-math", "debug-glam-assert"] }
base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp"] }
rustybuzz = "0.20"
parley = "0.5.0"
skrifa = "0.32.0"
pretty_assertions = "1.4.1"
fern = { version = "0.7", features = ["colored"] }
num_enum = "0.7"

View file

@ -12,7 +12,7 @@
**Graphite is a free, open source vector and raster graphics engine, [available now](https://editor.graphite.rs) in alpha. Get creative with a fully nondestructive editing workflow that combines layer-based compositing with node-based generative design.**
Having begun life as a vector editor, Graphite continues evolving into a generalized, all-in-one graphics toolbox that's built more like a game engine than a conventional creative app. The editor's tools wrap its node graph core, providing user-friendly workflows for vector, raster, and beyond. Photo editing, motion graphics, digital painting, desktop publishing, and VFX compositing are additional competencies from the [roadmap](https://graphite.rs/features/#roadmap) making Graphite into a highly versatile content creation tool.
Having begun life as a vector editor, Graphite continues evolving into a generalized, all-in-one graphics toolbox that's built more like a game engine than a conventional creative app. The editor's tools wrap its node graph core, providing user-friendly workflows for vector, raster, and beyond. Photo editing, motion graphics, digital painting, desktop publishing, and VFX compositing are additional competencies on the planned [roadmap](https://graphite.rs/features/#roadmap) making Graphite into a highly versatile content creation tool.
Learn more from the [website](https://graphite.rs/), subscribe to the [newsletter](https://graphite.rs/#newsletter), consider [volunteering](https://graphite.rs/volunteer/) or [donating](https://graphite.rs/donate/), and remember to give this repository a ⭐!
@ -58,22 +58,26 @@ Learn more from the [website](https://graphite.rs/), subscribe to the [newslette
</a>
<br /><br />
https://github.com/user-attachments/assets/f4604aea-e8f1-45ce-9218-46ddc666f11d
## Support our mission ❤️
Graphite is 100% community built and funded. Please become a part of keeping the project alive and thriving with a [donation](https://graphite.rs/donate/) if you share a belief in our **mission**:
> Graphite strives to unshackle the creativity of every budding artist and seasoned professional by building the best comprehensive art and design tool that's accessible to all.
>
> Mission success will come when Graphite is an industry standard. A cohesive product vision and focus on innovation over imitation is the strategy that will make that possible.
## Screenshots
!["Isometric Fountain" vector artwork](https://static.graphite.rs/content/index/gui-demo-node-graph-isometric-fountain.png)
![Made using nondestructive boolean operations and procedural polka dot patterns](https://github.com/user-attachments/assets/decb7011-18c2-4c68-82af-d1fa5064244a)
!["Marbled Mandelbrot" fractal raster artwork](https://static.graphite.rs/content/index/gui-demo-fractal__3.png)
![Mandelbrot fractal filled with a noise pattern, procedurally generated and infinitely scalable](https://github.com/user-attachments/assets/9e023997-185b-4f43-a724-797d308d9e7b)
![Design for a magazine spread, a preview of the upcoming focus on desktop publishing](https://github.com/user-attachments/assets/90eca551-5868-4f8d-9016-33958bf96345)
## Contributing/building the code
Are you a graphics programmer or Rust developer? Graphite aims to be one of the most approachable projects for putting your engineering skills to use in the world of open source. See [instructions here](https://graphite.rs/volunteer/guide/) for setting up the project and getting started.
*By submitting code for inclusion in the project, you are agreeing to license your changes under the Apache 2.0 license, and that you have the authority to do so. Some directories may have other licenses, like dual-licensed MIT/Apache 2.0, and code submissions to those directories mean you agree to the applicable license(s).*
## Support our mission ❤️
Graphite is 100% community built and funded. Please become a part of keeping our project alive and thriving with a [donation](https://graphite.rs/donate/) if you share a belief in our mission:
> Graphite strives to unshackle the creativity of every budding artist and seasoned professional by building the best comprehensive art and design tool that's accessible to all.
>
> Mission success will come when Graphite is an industry standard. A cohesive product vision and focus on innovation over imitation is the strategy that will make that possible.

View file

@ -36,9 +36,9 @@ thiserror = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
bezier-rs = { workspace = true }
kurbo = { workspace = true }
futures = { workspace = true }
glam = { workspace = true }
kurbo = { workspace = true }
derivative = { workspace = true }
specta = { workspace = true }
dyn-any = { workspace = true }

View file

@ -102,7 +102,7 @@ pub const MANIPULATOR_GROUP_MARKER_SIZE: f64 = 6.;
pub const SELECTION_THRESHOLD: f64 = 10.;
pub const HIDE_HANDLE_DISTANCE: f64 = 3.;
pub const HANDLE_ROTATE_SNAP_ANGLE: f64 = 15.;
pub const SEGMENT_INSERTION_DISTANCE: f64 = 8.;
pub const SEGMENT_INSERTION_DISTANCE: f64 = 5.;
pub const SEGMENT_OVERLAY_SIZE: f64 = 10.;
pub const HANDLE_LENGTH_FACTOR: f64 = 0.5;
@ -133,6 +133,7 @@ pub const SCALE_EFFECT: f64 = 0.5;
// COLORS
pub const COLOR_OVERLAY_BLUE: &str = "#00a8ff";
pub const COLOR_OVERLAY_BLUE_50: &str = "rgba(0, 168, 255, 0.5)";
pub const COLOR_OVERLAY_YELLOW: &str = "#ffc848";
pub const COLOR_OVERLAY_GREEN: &str = "#63ce63";
pub const COLOR_OVERLAY_RED: &str = "#ef5454";

View file

@ -214,7 +214,7 @@ pub fn input_mappings() -> Mapping {
entry!(KeyDown(KeyX); modifiers=[Accel], action_dispatch=PathToolMessage::Cut { clipboard: Clipboard::Device }),
entry!(KeyDown(KeyC); modifiers=[Accel], action_dispatch=PathToolMessage::Copy { clipboard: Clipboard::Device }),
entry!(KeyDownNoRepeat(Tab); action_dispatch=PathToolMessage::SwapSelectedHandles),
entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { extend_selection: Shift, lasso_select: Control, handle_drag_from_anchor: Alt, drag_restore_handle: Control }),
entry!(KeyDown(MouseLeft); action_dispatch=PathToolMessage::MouseDown { extend_selection: Shift, lasso_select: Control, handle_drag_from_anchor: Alt, drag_restore_handle: Control, molding_in_segment_edit: KeyA }),
entry!(KeyDown(MouseRight); action_dispatch=PathToolMessage::RightClick),
entry!(KeyDown(Escape); action_dispatch=PathToolMessage::Escape),
entry!(KeyDown(KeyG); action_dispatch=PathToolMessage::GRS { key: KeyG }),

View file

@ -190,6 +190,7 @@ impl<'a> ModifyInputsContext<'a> {
Some(NodeInput::value(TaggedValue::F64(typesetting.character_spacing), false)),
Some(NodeInput::value(TaggedValue::OptionalF64(typesetting.max_width), false)),
Some(NodeInput::value(TaggedValue::OptionalF64(typesetting.max_height), false)),
Some(NodeInput::value(TaggedValue::F64(typesetting.tilt), false)),
]);
let text_id = NodeId::new();

View file

@ -960,13 +960,13 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
nodes: [
DocumentNode {
inputs: vec![NodeInput::network(concrete!(RasterDataTable<CPU>), 0), NodeInput::value(TaggedValue::XY(XY::X), false)],
implementation: DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_core::ops::ExtractXyNode")),
implementation: DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_core::extract_xy::ExtractXyNode")),
manual_composition: Some(generic!(T)),
..Default::default()
},
DocumentNode {
inputs: vec![NodeInput::network(concrete!(RasterDataTable<CPU>), 0), NodeInput::value(TaggedValue::XY(XY::Y), false)],
implementation: DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_core::ops::ExtractXyNode")),
implementation: DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("graphene_core::extract_xy::ExtractXyNode")),
manual_composition: Some(generic!(T)),
..Default::default()
},
@ -1556,6 +1556,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
NodeInput::value(TaggedValue::F64(TypesettingConfig::default().character_spacing), false),
NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_width), false),
NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_height), false),
NodeInput::value(TaggedValue::F64(TypesettingConfig::default().tilt), false),
],
..Default::default()
},
@ -1577,6 +1578,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
"Line Height",
"TODO",
WidgetOverride::Number(NumberInputSettings {
unit: Some("x".to_string()),
min: Some(0.),
step: Some(0.1),
..Default::default()
@ -1586,6 +1588,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
"Character Spacing",
"TODO",
WidgetOverride::Number(NumberInputSettings {
unit: Some(" px".to_string()),
min: Some(0.),
step: Some(0.1),
..Default::default()
@ -1595,6 +1598,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
"Max Width",
"TODO",
WidgetOverride::Number(NumberInputSettings {
unit: Some(" px".to_string()),
min: Some(1.),
blank_assist: false,
..Default::default()
@ -1604,11 +1608,22 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
"Max Height",
"TODO",
WidgetOverride::Number(NumberInputSettings {
unit: Some(" px".to_string()),
min: Some(1.),
blank_assist: false,
..Default::default()
}),
),
PropertiesRow::with_override(
"Tilt",
"Faux italic",
WidgetOverride::Number(NumberInputSettings {
min: Some(-85.),
max: Some(85.),
unit: Some("°".to_string()),
..Default::default()
}),
),
],
output_names: vec!["Vector".to_string()],
..Default::default()

View file

@ -124,8 +124,19 @@ pub fn path_overlays(document: &DocumentMessageHandler, draw_handles: DrawHandle
overlay_context.outline_vector(&vector_data, transform);
}
// Get the selected segments and then add a bold line overlay on them
for (segment_id, bezier, _, _) in vector_data.segment_bezier_iter() {
let Some(selected_shape_state) = shape_editor.selected_shape_state.get_mut(&layer) else {
continue;
};
if selected_shape_state.is_segment_selected(segment_id) {
overlay_context.outline_select_bezier(bezier, transform);
}
}
let selected = shape_editor.selected_shape_state.get(&layer);
let is_selected = |point: ManipulatorPointId| selected.is_some_and(|selected| selected.is_selected(point));
let is_selected = |point: ManipulatorPointId| selected.is_some_and(|selected| selected.is_point_selected(point));
if display_handles {
let opposite_handles_data: Vec<(PointId, SegmentId)> = shape_editor.selected_points().filter_map(|point_id| vector_data.adjacent_segment(point_id)).collect();
@ -187,7 +198,7 @@ pub fn path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &
//let document_to_viewport = document.navigation_handler.calculate_offset_transform(overlay_context.size / 2., &document.document_ptz);
let transform = document.metadata().transform_to_viewport(layer);
let selected = shape_editor.selected_shape_state.get(&layer);
let is_selected = |selected: Option<&SelectedLayerState>, point: ManipulatorPointId| selected.is_some_and(|selected| selected.is_selected(point));
let is_selected = |selected: Option<&SelectedLayerState>, point: ManipulatorPointId| selected.is_some_and(|selected| selected.is_point_selected(point));
for point in vector_data.extendable_points(preferences.vector_meshes) {
let Some(position) = vector_data.point_domain.position_from_id(point) else { continue };

View file

@ -1,7 +1,7 @@
use super::utility_functions::overlay_canvas_context;
use crate::consts::{
COLOR_OVERLAY_BLUE, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, COLOR_OVERLAY_WHITE, COLOR_OVERLAY_YELLOW, COMPASS_ROSE_ARROW_SIZE, COMPASS_ROSE_HOVER_RING_DIAMETER, COMPASS_ROSE_MAIN_RING_DIAMETER,
COMPASS_ROSE_RING_INNER_DIAMETER, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER,
COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_50, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, COLOR_OVERLAY_WHITE, COLOR_OVERLAY_YELLOW, COMPASS_ROSE_ARROW_SIZE, COMPASS_ROSE_HOVER_RING_DIAMETER,
COMPASS_ROSE_MAIN_RING_DIAMETER, COMPASS_ROSE_RING_INNER_DIAMETER, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER,
};
use crate::messages::prelude::Message;
use bezier_rs::{Bezier, Subpath};
@ -581,6 +581,35 @@ impl OverlayContext {
self.end_dpi_aware_transform();
}
/// Used by the path tool segment mode in order to show the selected segments.
pub fn outline_select_bezier(&mut self, bezier: Bezier, transform: DAffine2) {
self.start_dpi_aware_transform();
self.render_context.begin_path();
self.bezier_command(bezier, transform, true);
self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
self.render_context.set_line_width(4.);
self.render_context.stroke();
self.render_context.set_line_width(1.);
self.end_dpi_aware_transform();
}
pub fn outline_overlay_bezier(&mut self, bezier: Bezier, transform: DAffine2) {
self.start_dpi_aware_transform();
self.render_context.begin_path();
self.bezier_command(bezier, transform, true);
self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE_50);
self.render_context.set_line_width(4.);
self.render_context.stroke();
self.render_context.set_line_width(1.);
self.end_dpi_aware_transform();
}
fn bezier_command(&self, bezier: Bezier, transform: DAffine2, move_to: bool) {
self.start_dpi_aware_transform();

View file

@ -1161,8 +1161,7 @@ impl NodeNetworkInterface {
.and_then(|node_metadata| node_metadata.persistent_metadata.input_properties.get(index))
}
pub fn insert_input_properties_row(&mut self, node_id: &NodeId, index: usize, network_path: &[NodeId]) {
let row = ("", "TODO").into();
pub fn insert_input_properties_row(&mut self, node_id: &NodeId, index: usize, network_path: &[NodeId], row: PropertiesRow) {
let _ = self
.node_metadata_mut(node_id, network_path)
.map(|node_metadata| node_metadata.persistent_metadata.input_properties.insert(index - 1, row));

View file

@ -9,9 +9,7 @@ use crate::messages::tool::common_functionality::shape_editor::ShapeState;
use crate::messages::tool::utility_types::ToolType;
use glam::{DAffine2, DMat2, DVec2};
use graphene_std::renderer::Quad;
use graphene_std::vector::VectorModificationType;
use graphene_std::vector::{HandleExt, ManipulatorPointId};
use graphene_std::vector::{HandleId, PointId};
use graphene_std::vector::{HandleExt, HandleId, ManipulatorPointId, PointId, VectorModificationType};
use std::collections::{HashMap, VecDeque};
use std::f64::consts::PI;
@ -88,6 +86,18 @@ impl OriginalTransforms {
let Some(selected_points) = shape_editor.selected_points_in_layer(layer) else {
continue;
};
let Some(selected_segments) = shape_editor.selected_segments_in_layer(layer) else {
continue;
};
let mut selected_points = selected_points.clone();
for (segment_id, _, start, end) in vector_data.segment_bezier_iter() {
if selected_segments.contains(&segment_id) {
selected_points.insert(ManipulatorPointId::Anchor(start));
selected_points.insert(ManipulatorPointId::Anchor(end));
}
}
// Anchors also move their handles
let anchor_ids = selected_points.iter().filter_map(|point| point.as_anchor());
@ -604,7 +614,7 @@ impl<'a> Selected<'a> {
responses.add(GraphOperationMessage::Vector { layer, modification_type });
}
if transform_operation.is_some_and(|transform_operation| matches!(transform_operation, TransformOperation::Scaling(_))) && initial_points.anchors.len() > 1 {
if transform_operation.is_some_and(|transform_operation| matches!(transform_operation, TransformOperation::Scaling(_))) && (initial_points.anchors.len() == 2) {
return;
}

View file

@ -1,6 +1,7 @@
// TODO: Eventually remove this document upgrade code
// This file contains lots of hacky code for upgrading old documents to the new format
use super::document::utility_types::network_interface::{NumberInputSettings, PropertiesRow, WidgetOverride};
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, OutputConnector};
@ -308,8 +309,8 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
let node_definition = resolve_document_node_type(reference).unwrap();
let document_node = node_definition.default_node_template().document_node;
document.network_interface.replace_implementation(node_id, network_path, document_node.implementation.clone());
document.network_interface.insert_input_properties_row(node_id, 8, network_path);
document.network_interface.insert_input_properties_row(node_id, 9, network_path);
document.network_interface.insert_input_properties_row(node_id, 8, network_path, ("", "TODO").into());
document.network_interface.insert_input_properties_row(node_id, 9, network_path, ("", "TODO").into());
let old_inputs = document.network_interface.replace_inputs(node_id, document_node.inputs.clone(), network_path);
let align_input = NodeInput::value(TaggedValue::StrokeAlign(StrokeAlign::Center), false);
@ -402,7 +403,7 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
}
// Upgrade Text node to include line height and character spacing, which were previously hardcoded to 1, from https://github.com/GraphiteEditor/Graphite/pull/2016
if reference == "Text" && inputs_count != 8 {
if reference == "Text" && inputs_count != 9 {
let node_definition = resolve_document_node_type(reference).unwrap();
let document_node = node_definition.default_node_template().document_node;
document.network_interface.replace_implementation(node_id, network_path, document_node.implementation.clone());
@ -433,12 +434,44 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
);
document.network_interface.set_input(
&InputConnector::node(*node_id, 6),
NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_width), false),
if inputs_count >= 7 {
old_inputs[6].clone()
} else {
NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_width), false)
},
network_path,
);
document.network_interface.set_input(
&InputConnector::node(*node_id, 7),
NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_height), false),
if inputs_count >= 8 {
old_inputs[7].clone()
} else {
NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_height), false)
},
network_path,
);
document.network_interface.insert_input_properties_row(
node_id,
9,
network_path,
PropertiesRow::with_override(
"Tilt",
"Faux italic",
WidgetOverride::Number(NumberInputSettings {
min: Some(-85.),
max: Some(85.),
unit: Some("°".to_string()),
..Default::default()
}),
),
);
document.network_interface.set_input(
&InputConnector::node(*node_id, 8),
if inputs_count >= 9 {
old_inputs[8].clone()
} else {
NodeInput::value(TaggedValue::F64(TypesettingConfig::default().tilt), false)
},
network_path,
);
}

View file

@ -361,6 +361,7 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter
let Some(&TaggedValue::F64(character_spacing)) = inputs[5].as_value() else { return None };
let Some(&TaggedValue::OptionalF64(max_width)) = inputs[6].as_value() else { return None };
let Some(&TaggedValue::OptionalF64(max_height)) = inputs[7].as_value() else { return None };
let Some(&TaggedValue::F64(tilt)) = inputs[8].as_value() else { return None };
let typesetting = TypesettingConfig {
font_size,
@ -368,6 +369,7 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter
max_width,
character_spacing,
max_height,
tilt,
};
Some((text, font, typesetting))
}

View file

@ -1,14 +1,15 @@
use super::graph_modification_utils::merge_layers;
use super::snapping::{SnapCache, SnapCandidatePoint, SnapData, SnapManager, SnappedPoint};
use super::utility_functions::{adjust_handle_colinearity, calculate_segment_angle, restore_g1_continuity, restore_previous_handle_position};
use super::utility_functions::{adjust_handle_colinearity, calculate_bezier_bbox, calculate_segment_angle, restore_g1_continuity, restore_previous_handle_position};
use crate::consts::HANDLE_LENGTH_FACTOR;
use crate::messages::portfolio::document::overlays::utility_functions::selected_segments;
use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier};
use crate::messages::portfolio::document::utility_types::misc::{PathSnapSource, SnapSource};
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::preferences::SelectionMode;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::snapping::SnapTypeConfiguration;
use crate::messages::tool::common_functionality::utility_functions::is_visible_point;
use crate::messages::tool::common_functionality::utility_functions::{is_intersecting, is_visible_point};
use crate::messages::tool::tool_messages::path_tool::{PathOverlayMode, PointSelectState};
use bezier_rs::{Bezier, BezierHandles, Subpath, TValue};
use glam::{DAffine2, DVec2};
@ -45,6 +46,7 @@ pub enum ManipulatorAngle {
#[derive(Clone, Debug, Default)]
pub struct SelectedLayerState {
selected_points: HashSet<ManipulatorPointId>,
selected_segments: HashSet<SegmentId>,
/// Keeps track of the current state; helps avoid unnecessary computation when called by [`ShapeState`].
ignore_handles: bool,
ignore_anchors: bool,
@ -57,11 +59,27 @@ impl SelectedLayerState {
pub fn is_empty(&self) -> bool {
self.selected_points.is_empty()
}
pub fn selected(&self) -> impl Iterator<Item = ManipulatorPointId> + '_ {
pub fn selected_points(&self) -> impl Iterator<Item = ManipulatorPointId> + '_ {
self.selected_points.iter().copied()
}
pub fn is_selected(&self, point: ManipulatorPointId) -> bool {
pub fn selected_segments(&self) -> impl Iterator<Item = SegmentId> + '_ {
self.selected_segments.iter().copied()
}
pub fn selected_points_count(&self) -> usize {
self.selected_points.len()
}
pub fn selected_segments_count(&self) -> usize {
self.selected_segments.len()
}
pub fn is_segment_selected(&self, segment: SegmentId) -> bool {
self.selected_segments.contains(&segment)
}
pub fn is_point_selected(&self, point: ManipulatorPointId) -> bool {
self.selected_points.contains(&point)
}
@ -69,10 +87,26 @@ impl SelectedLayerState {
self.selected_points.insert(point);
}
pub fn select_segment(&mut self, segment: SegmentId) {
self.selected_segments.insert(segment);
}
pub fn deselect_point(&mut self, point: ManipulatorPointId) {
self.selected_points.remove(&point);
}
pub fn deselect_segment(&mut self, segment: SegmentId) {
self.selected_segments.remove(&segment);
}
pub fn clear_points(&mut self) {
self.selected_points.clear();
}
pub fn clear_segments(&mut self) {
self.selected_segments.clear();
}
pub fn ignore_handles(&mut self, status: bool) {
if self.ignore_handles != status {
return;
@ -104,14 +138,6 @@ impl SelectedLayerState {
self.ignored_anchor_points.clear();
}
}
pub fn clear_points(&mut self) {
self.selected_points.clear();
}
pub fn selected_points_count(&self) -> usize {
self.selected_points.len()
}
}
pub type SelectedShapeState = HashMap<LayerNodeIdentifier, SelectedLayerState>;
@ -131,6 +157,12 @@ pub struct SelectedPointsInfo {
pub vector_data: VectorData,
}
#[derive(Debug)]
pub struct SelectedSegmentsInfo {
pub segments: Vec<SegmentId>,
pub vector_data: VectorData,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ManipulatorPointInfo {
pub layer: LayerNodeIdentifier,
@ -139,6 +171,7 @@ pub struct ManipulatorPointInfo {
pub type OpposingHandleLengths = HashMap<LayerNodeIdentifier, HashMap<HandleId, f64>>;
#[derive(Clone)]
pub struct ClosestSegment {
layer: LayerNodeIdentifier,
segment: SegmentId,
@ -162,6 +195,10 @@ impl ClosestSegment {
self.points
}
pub fn bezier(&self) -> Bezier {
self.bezier
}
pub fn closest_point_document(&self) -> DVec2 {
self.bezier.evaluate(TValue::Parametric(self.t))
}
@ -476,7 +513,7 @@ impl ShapeState {
if let Some(id) = selected.as_anchor() {
for neighbor in vector_data.connected_points(id) {
if state.is_selected(ManipulatorPointId::Anchor(neighbor)) {
if state.is_point_selected(ManipulatorPointId::Anchor(neighbor)) {
continue;
}
let Some(position) = vector_data.point_domain.position_from_id(neighbor) else { continue };
@ -515,38 +552,30 @@ impl ShapeState {
let point_position = manipulator_point_id.get_position(&vector_data)?;
let selected_shape_state = self.selected_shape_state.get(&layer)?;
let already_selected = selected_shape_state.is_selected(manipulator_point_id);
// Should we select or deselect the point?
let new_selected = if already_selected { !extend_selection } else { true };
let already_selected = selected_shape_state.is_point_selected(manipulator_point_id);
// Offset to snap the selected point to the cursor
let offset = mouse_position - network_interface.document_metadata().transform_to_viewport(layer).transform_point2(point_position);
// This is selecting the manipulator only for now, next to generalize to points
if new_selected {
let retain_existing_selection = extend_selection || already_selected;
if !retain_existing_selection {
self.deselect_all_points();
}
// Add to the selected points
let selected_shape_state = self.selected_shape_state.get_mut(&layer)?;
selected_shape_state.select_point(manipulator_point_id);
let points = self
.selected_shape_state
.iter()
.flat_map(|(layer, state)| state.selected_points.iter().map(|&point_id| ManipulatorPointInfo { layer: *layer, point_id }))
.collect();
return Some(Some(SelectedPointsInfo { points, offset, vector_data }));
} else {
let selected_shape_state = self.selected_shape_state.get_mut(&layer)?;
selected_shape_state.deselect_point(manipulator_point_id);
return Some(None);
let retain_existing_selection = extend_selection || already_selected;
if !retain_existing_selection {
self.deselect_all_points();
self.deselect_all_segments();
}
// Add to the selected points (deselect is managed in DraggingState, DragStop)
let selected_shape_state = self.selected_shape_state.get_mut(&layer)?;
selected_shape_state.select_point(manipulator_point_id);
let points = self
.selected_shape_state
.iter()
.flat_map(|(layer, state)| state.selected_points.iter().map(|&point_id| ManipulatorPointInfo { layer: *layer, point_id }))
.collect();
return Some(Some(SelectedPointsInfo { points, offset, vector_data }));
}
None
}
@ -558,11 +587,16 @@ impl ShapeState {
select_threshold: f64,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
point_editing_mode: bool,
) -> Option<(bool, Option<SelectedPointsInfo>)> {
if self.selected_shape_state.is_empty() {
return None;
}
if !point_editing_mode {
return None;
}
if let Some((layer, manipulator_point_id)) = self.find_nearest_point_indices(network_interface, mouse_position, select_threshold) {
let vector_data = network_interface.compute_modified_vector(layer)?;
let point_position = manipulator_point_id.get_position(&vector_data)?;
@ -575,7 +609,7 @@ impl ShapeState {
}
let selected_shape_state = self.selected_shape_state.get(&layer)?;
let already_selected = selected_shape_state.is_selected(manipulator_point_id);
let already_selected = selected_shape_state.is_point_selected(manipulator_point_id);
// Offset to snap the selected point to the cursor
let offset = mouse_position - network_interface.document_metadata().transform_to_viewport(layer).transform_point2(point_position);
@ -633,7 +667,7 @@ impl ShapeState {
// Select all connected points
while let Some(point) = selected_stack.pop() {
let anchor_point = ManipulatorPointId::Anchor(point);
if !state.is_selected(anchor_point) {
if !state.is_point_selected(anchor_point) {
state.select_point(anchor_point);
selected_stack.extend(vector_data.connected_points(point));
}
@ -674,6 +708,13 @@ impl ShapeState {
}
}
/// Deselects all segments across every selected layer
pub fn deselect_all_segments(&mut self) {
for state in self.selected_shape_state.values_mut() {
state.selected_segments.clear()
}
}
pub fn update_selected_anchors_status(&mut self, status: bool) {
for state in self.selected_shape_state.values_mut() {
self.ignore_anchors = !status;
@ -739,10 +780,18 @@ impl ShapeState {
self.selected_shape_state.values().flat_map(|state| &state.selected_points)
}
pub fn selected_segments(&self) -> impl Iterator<Item = &'_ SegmentId> {
self.selected_shape_state.values().flat_map(|state| &state.selected_segments)
}
pub fn selected_points_in_layer(&self, layer: LayerNodeIdentifier) -> Option<&HashSet<ManipulatorPointId>> {
self.selected_shape_state.get(&layer).map(|state| &state.selected_points)
}
pub fn selected_segments_in_layer(&self, layer: LayerNodeIdentifier) -> Option<&HashSet<SegmentId>> {
self.selected_shape_state.get(&layer).map(|state| &state.selected_segments)
}
pub fn move_primary(&self, segment: SegmentId, delta: DVec2, layer: LayerNodeIdentifier, responses: &mut VecDeque<Message>) {
responses.add(GraphOperationMessage::Vector {
layer,
@ -769,7 +818,7 @@ impl ShapeState {
let Some((start, _end, bezier)) = vector_data.segment_points_from_id(segment) else { continue };
if let BezierHandles::Quadratic { handle } = bezier.handles {
if selected.is_some_and(|selected| selected.is_selected(ManipulatorPointId::Anchor(start))) {
if selected.is_some_and(|selected| selected.is_point_selected(ManipulatorPointId::Anchor(start))) {
continue;
}
@ -853,6 +902,9 @@ impl ShapeState {
let non_zero_handles = handles.iter().filter(|handle| handle.length(vector_data) > 1e-6).count();
let handle_segments = handles.iter().map(|handles| handles.segment).collect::<Vec<_>>();
// Check if the anchor is connected to linear segments and has no handles
let linear_segments = vector_data.connected_linear_segments(point_id) != 0;
// Grab the next and previous manipulator groups by simply looking at the next / previous index
let points = handles.iter().map(|handle| vector_data.other_point(handle.segment, point_id));
let anchor_positions = points
@ -894,7 +946,7 @@ impl ShapeState {
handle_direction *= -1.;
}
if non_zero_handles != 0 {
if non_zero_handles != 0 && !linear_segments {
let [a, b] = handles.as_slice() else { return };
let (non_zero_handle, zero_handle) = if a.length(vector_data) > 1e-6 { (a, b) } else { (b, a) };
let Some(direction) = non_zero_handle
@ -1015,9 +1067,9 @@ impl ShapeState {
}
}
/// Move the selected points by dragging the mouse.
/// Move the selected points and segments by dragging the mouse.
#[allow(clippy::too_many_arguments)]
pub fn move_selected_points(
pub fn move_selected_points_and_segments(
&self,
handle_lengths: Option<OpposingHandleLengths>,
document: &DocumentMessageHandler,
@ -1043,7 +1095,17 @@ impl ShapeState {
};
let delta = delta_transform.inverse().transform_vector2(delta);
for &point in state.selected_points.iter() {
// Make a new collection of anchor points which needs to be moved
let mut affected_points = state.selected_points.clone();
for (segment_id, _, start, end) in vector_data.segment_bezier_iter() {
if state.is_segment_selected(segment_id) {
affected_points.insert(ManipulatorPointId::Anchor(start));
affected_points.insert(ManipulatorPointId::Anchor(end));
}
}
for &point in affected_points.iter() {
if self.is_point_ignored(&point) {
continue;
}
@ -1058,7 +1120,7 @@ impl ShapeState {
};
let Some(anchor_id) = point.get_anchor(&vector_data) else { continue };
if state.is_selected(ManipulatorPointId::Anchor(anchor_id)) {
if state.is_point_selected(ManipulatorPointId::Anchor(anchor_id)) {
continue;
}
@ -1077,7 +1139,7 @@ impl ShapeState {
continue;
}
if state.is_selected(other.to_manipulator_point()) {
if state.is_point_selected(other.to_manipulator_point()) {
// If two colinear handles are being dragged at the same time but not the anchor, it is necessary to break the colinear state.
let handles = [handle, other];
let modification_type = VectorModificationType::SetG1Continuous { handles, enabled: false };
@ -1128,12 +1190,12 @@ impl ShapeState {
// ii) The anchor is not selected.
let anchor = handles[0].to_manipulator_point().get_anchor(&vector_data)?;
let anchor_selected = state.is_selected(ManipulatorPointId::Anchor(anchor));
let anchor_selected = state.is_point_selected(ManipulatorPointId::Anchor(anchor));
if anchor_selected {
return None;
}
let handles_selected = handles.map(|handle| state.is_selected(handle.to_manipulator_point()));
let handles_selected = handles.map(|handle| state.is_point_selected(handle.to_manipulator_point()));
let other = match handles_selected {
[true, false] => handles[1],
@ -1211,11 +1273,15 @@ impl ShapeState {
continue;
};
let selected_segments = &state.selected_segments;
for point in std::mem::take(&mut state.selected_points) {
match point {
ManipulatorPointId::Anchor(anchor) => {
if let Some(handles) = Self::dissolve_anchor(anchor, responses, layer, &vector_data) {
missing_anchors.insert(anchor, handles);
if !vector_data.all_connected(anchor).any(|a| selected_segments.contains(&a.segment)) {
missing_anchors.insert(anchor, handles);
}
}
deleted_anchors.insert(anchor);
}
@ -1260,6 +1326,8 @@ impl ShapeState {
continue;
}
// Avoid reconnecting to points which have adjacent segments selected
// Grab the handles from the opposite side of the segment(s) being deleted and make it relative to the anchor
let [handle_start, handle_end] = [start, end].map(|(handle, _)| {
let handle = handle.opposite();
@ -1307,6 +1375,20 @@ impl ShapeState {
}
}
pub fn delete_selected_segments(&mut self, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
for (&layer, state) in &self.selected_shape_state {
let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else {
continue;
};
for (segment, _, start, end) in vector_data.segment_bezier_iter() {
if state.selected_segments.contains(&segment) {
self.dissolve_segment(responses, layer, &vector_data, segment, [start, end]);
}
}
}
}
pub fn break_path_at_selected_point(&self, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
for (&layer, state) in &self.selected_shape_state {
let Some(vector_data) = document.network_interface.compute_modified_vector(layer) else { continue };
@ -1696,10 +1778,13 @@ impl ShapeState {
.filter(|&handle| anchor.abs_diff_eq(handle, 1e-5))
.count();
// Check if the anchor is connected to linear segments.
let one_or_more_segment_linear = vector_data.connected_linear_segments(id) != 0;
// Check by comparing the handle positions to the anchor if this manipulator group is a point
for point in self.selected_points() {
let Some(point_id) = point.as_anchor() else { continue };
if positions != 0 {
if positions != 0 || one_or_more_segment_linear {
self.convert_manipulator_handles_to_colinear(&vector_data, point_id, responses, layer);
} else {
for handle in vector_data.all_connected(point_id) {
@ -1751,6 +1836,7 @@ impl ShapeState {
false
}
#[allow(clippy::too_many_arguments)]
pub fn select_all_in_shape(
&mut self,
network_interface: &NodeNetworkInterface,
@ -1758,13 +1844,17 @@ impl ShapeState {
selection_change: SelectionChange,
path_overlay_mode: PathOverlayMode,
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
select_segments: bool,
// Here, "selection mode" represents touched or enclosed, not to be confused with editing modes
selection_mode: SelectionMode,
) {
let selected_points = self.selected_points().cloned().collect::<HashSet<_>>();
let selected_segments = selected_segments(network_interface, self);
for (&layer, state) in &mut self.selected_shape_state {
if selection_change == SelectionChange::Clear {
state.clear_points()
state.clear_points();
state.clear_segments();
}
let vector_data = network_interface.compute_modified_vector(layer);
@ -1790,7 +1880,46 @@ impl ShapeState {
None
};
// Selection segments
for (id, bezier, _, _) in vector_data.segment_bezier_iter() {
if select_segments {
// Select segments if they lie inside the bounding box or lasso polygon
let segment_bbox = calculate_bezier_bbox(bezier);
let bottom_left = transform.transform_point2(segment_bbox[0]);
let top_right = transform.transform_point2(segment_bbox[1]);
let select = match selection_shape {
SelectionShape::Box(quad) => {
let enclosed = quad[0].min(quad[1]).cmple(bottom_left).all() && quad[0].max(quad[1]).cmpge(top_right).all();
match selection_mode {
SelectionMode::Enclosed => enclosed,
_ => {
// Check for intersection with the segment
enclosed || is_intersecting(bezier, quad, transform)
}
}
}
SelectionShape::Lasso(_) => {
let polygon = polygon_subpath.as_ref().expect("If `selection_shape` is a polygon then subpath is constructed beforehand.");
// Sample 10 points on the bezier and check if all or some lie inside the polygon
let points = bezier.compute_lookup_table(Some(10), None);
match selection_mode {
SelectionMode::Enclosed => points.map(|p| transform.transform_point2(p)).all(|p| polygon.contains_point(p)),
_ => points.map(|p| transform.transform_point2(p)).any(|p| polygon.contains_point(p)),
}
}
};
if select {
match selection_change {
SelectionChange::Shrink => state.deselect_segment(id),
_ => state.select_segment(id),
}
}
}
// Selecting handles
for (position, id) in [(bezier.handle_start(), ManipulatorPointId::PrimaryHandle(id)), (bezier.handle_end(), ManipulatorPointId::EndHandle(id))] {
let Some(position) = position else { continue };
let transformed_position = transform.transform_point2(position);
@ -1823,6 +1952,7 @@ impl ShapeState {
}
}
// Checking for selection of anchor points
for (&id, &position) in vector_data.point_domain.ids().iter().zip(vector_data.point_domain.positions()) {
let transformed_position = transform.transform_point2(position);

View file

@ -8,11 +8,12 @@ use crate::messages::tool::common_functionality::graph_modification_utils::get_t
use crate::messages::tool::common_functionality::transformation_cage::SelectedEdges;
use crate::messages::tool::tool_messages::path_tool::PathOverlayMode;
use crate::messages::tool::utility_types::ToolType;
use bezier_rs::Bezier;
use bezier_rs::{Bezier, BezierHandles};
use glam::{DAffine2, DVec2};
use graphene_std::renderer::Quad;
use graphene_std::text::{FontCache, load_face};
use graphene_std::text::{FontCache, load_font};
use graphene_std::vector::{HandleExt, HandleId, ManipulatorPointId, PointId, SegmentId, VectorData, VectorModificationType};
use kurbo::{CubicBez, Line, ParamCurveExtrema, PathSeg, Point, QuadBez};
/// Determines if a path should be extended. Goal in viewport space. Returns the path and if it is extending from the start, if applicable.
pub fn should_extend(
@ -69,8 +70,8 @@ pub fn text_bounding_box(layer: LayerNodeIdentifier, document: &DocumentMessageH
return Quad::from_box([DVec2::ZERO, DVec2::ZERO]);
};
let buzz_face = font_cache.get(font).map(|data| load_face(data));
let far = graphene_std::text::bounding_box(text, buzz_face.as_ref(), typesetting, false);
let font_data = font_cache.get(font).map(|data| load_font(data));
let far = graphene_std::text::bounding_box(text, font_data, typesetting, false);
Quad::from_box([DVec2::ZERO, far])
}
@ -204,6 +205,71 @@ pub fn is_visible_point(
}
}
/// Function to find the bounding box of bezier (uses method from kurbo)
pub fn calculate_bezier_bbox(bezier: Bezier) -> [DVec2; 2] {
let start = Point::new(bezier.start.x, bezier.start.y);
let end = Point::new(bezier.end.x, bezier.end.y);
let bbox = match bezier.handles {
BezierHandles::Cubic { handle_start, handle_end } => {
let p1 = Point::new(handle_start.x, handle_start.y);
let p2 = Point::new(handle_end.x, handle_end.y);
CubicBez::new(start, p1, p2, end).bounding_box()
}
BezierHandles::Quadratic { handle } => {
let p1 = Point::new(handle.x, handle.y);
QuadBez::new(start, p1, end).bounding_box()
}
BezierHandles::Linear => Line::new(start, end).bounding_box(),
};
[DVec2::new(bbox.x0, bbox.y0), DVec2::new(bbox.x1, bbox.y1)]
}
pub fn is_intersecting(bezier: Bezier, quad: [DVec2; 2], transform: DAffine2) -> bool {
let to_layerspace = transform.inverse();
let quad = [to_layerspace.transform_point2(quad[0]), to_layerspace.transform_point2(quad[1])];
let start = Point::new(bezier.start.x, bezier.start.y);
let end = Point::new(bezier.end.x, bezier.end.y);
let segment = match bezier.handles {
BezierHandles::Cubic { handle_start, handle_end } => {
let p1 = Point::new(handle_start.x, handle_start.y);
let p2 = Point::new(handle_end.x, handle_end.y);
PathSeg::Cubic(CubicBez::new(start, p1, p2, end))
}
BezierHandles::Quadratic { handle } => {
let p1 = Point::new(handle.x, handle.y);
PathSeg::Quad(QuadBez::new(start, p1, end))
}
BezierHandles::Linear => PathSeg::Line(Line::new(start, end)),
};
// Create a list of all the sides
let sides = [
Line::new((quad[0].x, quad[0].y), (quad[1].x, quad[0].y)),
Line::new((quad[0].x, quad[0].y), (quad[0].x, quad[1].y)),
Line::new((quad[1].x, quad[1].y), (quad[1].x, quad[0].y)),
Line::new((quad[1].x, quad[1].y), (quad[0].x, quad[1].y)),
];
let mut is_intersecting = false;
for line in sides {
let intersections = segment.intersect_line(line);
let mut intersects = false;
for intersection in intersections {
if intersection.line_t <= 1. && intersection.line_t >= 0. && intersection.segment_t <= 1. && intersection.segment_t >= 0. {
// There is a valid intersection point
intersects = true;
break;
}
}
if intersects {
is_intersecting = true;
break;
}
}
is_intersecting
}
#[allow(clippy::too_many_arguments)]
pub fn resize_bounds(
document: &DocumentMessageHandler,
responses: &mut VecDeque<Message>,
@ -221,7 +287,7 @@ pub fn resize_bounds(
let snap = Some(SizeSnapData {
manager: snap_manager,
points: snap_candidates,
snap_data: SnapData::ignore(document, input, &dragging_layers),
snap_data: SnapData::ignore(document, input, dragging_layers),
});
let (position, size) = movement.new_size(input.mouse.position, bounds.original_bound_transform, center, constrain, snap);
let (delta, mut pivot) = movement.bounds_to_scale_transform(position, size);
@ -238,11 +304,12 @@ pub fn resize_bounds(
}
});
let mut selected = Selected::new(&mut bounds.original_transforms, &mut pivot, &dragging_layers, responses, &document.network_interface, None, &tool, None);
let mut selected = Selected::new(&mut bounds.original_transforms, &mut pivot, dragging_layers, responses, &document.network_interface, None, &tool, None);
selected.apply_transformation(bounds.original_bound_transform * transformation * bounds.original_bound_transform.inverse(), None);
}
}
#[allow(clippy::too_many_arguments)]
pub fn rotate_bounds(
document: &DocumentMessageHandler,
responses: &mut VecDeque<Message>,
@ -280,7 +347,7 @@ pub fn rotate_bounds(
let mut selected = Selected::new(
&mut bounds.original_transforms,
&mut bounds.center_of_transformation,
&dragging_layers,
dragging_layers,
responses,
&document.network_interface,
None,
@ -313,7 +380,7 @@ pub fn skew_bounds(
}
});
let mut selected = Selected::new(&mut bounds.original_transforms, &mut pivot, &layers, responses, &document.network_interface, None, &tool, None);
let mut selected = Selected::new(&mut bounds.original_transforms, &mut pivot, layers, responses, &document.network_interface, None, &tool, None);
selected.apply_transformation(bounds.original_bound_transform * transformation * bounds.original_bound_transform.inverse(), None);
}
}
@ -365,7 +432,7 @@ pub fn transforming_transform_cage(
let mut selected = Selected::new(
&mut bounds.original_transforms,
&mut bounds.center_of_transformation,
&layers_dragging,
layers_dragging,
responses,
&document.network_interface,
None,
@ -423,7 +490,7 @@ pub fn transforming_transform_cage(
}
// No resize, rotate, or skew
return (false, false, false);
(false, false, false)
}
/// Calculates similarity metric between new bezier curve and two old beziers by using sampled points.

View file

@ -33,6 +33,7 @@ pub struct PathTool {
#[derive(Default)]
pub struct PathToolOptions {
path_overlay_mode: PathOverlayMode,
path_editing_mode: PathEditingMode,
}
#[impl_message(Message, ToolMessage, Path)]
@ -70,6 +71,7 @@ pub enum PathToolMessage {
lasso_select: Key,
handle_drag_from_anchor: Key,
drag_restore_handle: Key,
molding_in_segment_edit: Key,
},
NudgeSelectedPoints {
delta_x: f64,
@ -123,9 +125,26 @@ pub enum PathOverlayMode {
FrontierHandles = 2,
}
#[derive(PartialEq, Eq, Hash, Copy, Clone, Debug)]
pub struct PathEditingMode {
point_editing_mode: bool,
segment_editing_mode: bool,
}
impl Default for PathEditingMode {
fn default() -> Self {
Self {
point_editing_mode: true,
segment_editing_mode: false,
}
}
}
#[derive(PartialEq, Eq, Clone, Debug, Hash, serde::Serialize, serde::Deserialize, specta::Type)]
pub enum PathOptionsUpdate {
OverlayModeType(PathOverlayMode),
PointEditingMode { enabled: bool },
SegmentEditingMode { enabled: bool },
}
impl ToolMetadata for PathTool {
@ -210,6 +229,19 @@ impl LayoutHolder for PathTool {
.for_checkbox(&mut checkbox_id)
.widget_holder();
let point_editing_mode = CheckboxInput::new(self.options.path_editing_mode.point_editing_mode)
// TODO(Keavon): Replace with a real icon
.icon("Dot")
.tooltip("Point Editing Mode")
.on_update(|input| PathToolMessage::UpdateOptions(PathOptionsUpdate::PointEditingMode { enabled: input.checked }).into())
.widget_holder();
let segment_editing_mode = CheckboxInput::new(self.options.path_editing_mode.segment_editing_mode)
// TODO(Keavon): Replace with a real icon
.icon("Remove")
.tooltip("Segment Editing Mode")
.on_update(|input| PathToolMessage::UpdateOptions(PathOptionsUpdate::SegmentEditingMode { enabled: input.checked }).into())
.widget_holder();
let path_overlay_mode_widget = RadioInput::new(vec![
RadioEntryData::new("all")
.icon("HandleVisibilityAll")
@ -234,8 +266,12 @@ impl LayoutHolder for PathTool {
y_location,
unrelated_seperator.clone(),
colinear_handle_checkbox,
related_seperator,
related_seperator.clone(),
colinear_handles_label,
unrelated_seperator.clone(),
point_editing_mode,
related_seperator.clone(),
segment_editing_mode,
unrelated_seperator,
path_overlay_mode_widget,
],
@ -253,6 +289,14 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for PathToo
self.options.path_overlay_mode = overlay_mode_type;
responses.add(OverlaysMessage::Draw);
}
PathOptionsUpdate::PointEditingMode { enabled } => {
self.options.path_editing_mode.point_editing_mode = enabled;
responses.add(OverlaysMessage::Draw);
}
PathOptionsUpdate::SegmentEditingMode { enabled } => {
self.options.path_editing_mode.segment_editing_mode = enabled;
responses.add(OverlaysMessage::Draw);
}
},
ToolMessage::Path(PathToolMessage::ClosePath) => {
responses.add(DocumentMessage::AddTransaction);
@ -416,6 +460,7 @@ struct PathToolData {
angle: f64,
opposite_handle_position: Option<DVec2>,
last_clicked_point_was_selected: bool,
last_clicked_segment_was_selected: bool,
snapping_axis: Option<Axis>,
alt_clicked_on_anchor: bool,
alt_dragging_from_anchor: bool,
@ -427,6 +472,7 @@ struct PathToolData {
frontier_handles_info: Option<HashMap<SegmentId, Vec<PointId>>>,
adjacent_anchor_offset: Option<DVec2>,
sliding_point_info: Option<SlidingPointInfo>,
started_drawing_from_inside: bool,
}
impl PathToolData {
@ -487,6 +533,7 @@ impl PathToolData {
self.selection_status = selection_status;
}
// TODO: This function is for basic point select mode. We definitely need to make a new one for the segment select mode.
#[allow(clippy::too_many_arguments)]
fn mouse_down(
&mut self,
@ -498,7 +545,10 @@ impl PathToolData {
lasso_select: bool,
handle_drag_from_anchor: bool,
drag_zero_handle: bool,
molding_in_segment_edit: bool,
path_overlay_mode: PathOverlayMode,
segment_editing_mode: bool,
point_editing_mode: bool,
) -> PathToolFsmState {
self.double_click_handled = false;
self.opposing_handle_lengths = None;
@ -521,6 +571,7 @@ impl PathToolData {
SELECTION_THRESHOLD,
path_overlay_mode,
self.frontier_handles_info.clone(),
point_editing_mode,
) {
responses.add(DocumentMessage::StartTransaction);
@ -604,25 +655,51 @@ impl PathToolData {
}
PathToolFsmState::Dragging(self.dragging_state)
}
// We didn't find a point nearby, so we will see if there is a segment to insert a point on
else if let Some(closed_segment) = &mut self.segment {
// We didn't find a point nearby, so we will see if there is a segment to select or insert a point on
else if let Some(segment) = shape_editor.upper_closest_segment(&document.network_interface, input.mouse.position, SELECTION_THRESHOLD) {
responses.add(DocumentMessage::StartTransaction);
// Calculating and storing handle positions
let handle1 = ManipulatorPointId::PrimaryHandle(closed_segment.segment());
let handle2 = ManipulatorPointId::EndHandle(closed_segment.segment());
if segment_editing_mode && !molding_in_segment_edit {
let layer = segment.layer();
let segment_id = segment.segment();
let already_selected = shape_editor.selected_shape_state.get(&layer).is_some_and(|state| state.is_segment_selected(segment_id));
self.last_clicked_segment_was_selected = already_selected;
if let Some(vector_data) = document.network_interface.compute_modified_vector(closed_segment.layer()) {
if let (Some(pos1), Some(pos2)) = (handle1.get_position(&vector_data), handle2.get_position(&vector_data)) {
self.molding_info = Some((pos1, pos2))
if !(already_selected && extend_selection) {
let retain_existing_selection = extend_selection || already_selected;
if !retain_existing_selection {
shape_editor.deselect_all_segments();
shape_editor.deselect_all_points();
}
// Add to selected segments
if let Some(selected_shape_state) = shape_editor.selected_shape_state.get_mut(&layer) {
selected_shape_state.select_segment(segment_id);
}
}
}
PathToolFsmState::MoldingSegment
self.drag_start_pos = input.mouse.position;
let viewport_to_document = document.metadata().document_to_viewport.inverse();
self.previous_mouse_position = viewport_to_document.transform_point2(input.mouse.position);
responses.add(OverlaysMessage::Draw);
PathToolFsmState::Dragging(self.dragging_state)
} else {
let handle1 = ManipulatorPointId::PrimaryHandle(segment.segment());
let handle2 = ManipulatorPointId::EndHandle(segment.segment());
if let Some(vector_data) = document.network_interface.compute_modified_vector(segment.layer()) {
if let (Some(pos1), Some(pos2)) = (handle1.get_position(&vector_data), handle2.get_position(&vector_data)) {
self.molding_info = Some((pos1, pos2))
}
}
PathToolFsmState::MoldingSegment
}
}
// We didn't find a segment, so consider selecting the nearest shape instead
// We didn't find a segment, so consider selecting the nearest shape instead and start drawing
else if let Some(layer) = document.click(input) {
shape_editor.deselect_all_points();
shape_editor.deselect_all_segments();
if extend_selection {
responses.add(NodeGraphMessage::SelectedNodesAdd { nodes: vec![layer.to_node()] });
} else {
@ -631,9 +708,10 @@ impl PathToolData {
self.drag_start_pos = input.mouse.position;
self.previous_mouse_position = document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position);
responses.add(DocumentMessage::StartTransaction);
self.started_drawing_from_inside = true;
PathToolFsmState::Dragging(self.dragging_state)
let selection_shape = if lasso_select { SelectionShapeType::Lasso } else { SelectionShapeType::Box };
PathToolFsmState::Drawing { selection_shape }
}
// Start drawing
else {
@ -655,7 +733,7 @@ impl PathToolData {
let transform = document.metadata().transform_to_document(layer);
let mut layer_manipulators = HashSet::with_hasher(NoHashBuilder);
for point in state.selected() {
for point in state.selected_points() {
let Some(anchor) = point.get_anchor(&vector_data) else { continue };
layer_manipulators.insert(anchor);
let Some([handle1, handle2]) = point.get_handle_pair(&vector_data) else { continue };
@ -759,7 +837,7 @@ impl PathToolData {
}
// Only count selected handles
let selected_handle = selection.selected().next()?.as_handle()?;
let selected_handle = selection.selected_points().next()?.as_handle()?;
let handle_id = selected_handle.to_manipulator_point();
let layer_to_document = document.metadata().transform_to_document(*layer);
@ -886,7 +964,7 @@ impl PathToolData {
let drag_start = self.drag_start_pos;
let opposite_delta = drag_start - current_mouse;
shape_editor.move_selected_points(None, document, opposite_delta, false, true, false, None, false, responses);
shape_editor.move_selected_points_and_segments(None, document, opposite_delta, false, true, false, None, false, responses);
// Calculate the projected delta and shift the points along that delta
let delta = current_mouse - drag_start;
@ -898,7 +976,7 @@ impl PathToolData {
_ => DVec2::new(delta.x, 0.),
};
shape_editor.move_selected_points(None, document, projected_delta, false, true, false, None, false, responses);
shape_editor.move_selected_points_and_segments(None, document, projected_delta, false, true, false, None, false, responses);
}
fn stop_snap_along_axis(&mut self, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, input: &InputPreprocessorMessageHandler, responses: &mut VecDeque<Message>) {
@ -914,12 +992,12 @@ impl PathToolData {
_ => DVec2::new(opposite_delta.x, 0.),
};
shape_editor.move_selected_points(None, document, opposite_projected_delta, false, true, false, None, false, responses);
shape_editor.move_selected_points_and_segments(None, document, opposite_projected_delta, false, true, false, None, false, responses);
// Calculate what actually would have been the original delta for the point, and apply that
let delta = current_mouse - drag_start;
shape_editor.move_selected_points(None, document, delta, false, true, false, None, false, responses);
shape_editor.move_selected_points_and_segments(None, document, delta, false, true, false, None, false, responses);
self.snapping_axis = None;
}
@ -941,6 +1019,28 @@ impl PathToolData {
tangent_vector.try_normalize()
}
fn update_closest_segment(&mut self, shape_editor: &mut ShapeState, position: DVec2, document: &DocumentMessageHandler, path_overlay_mode: PathOverlayMode) {
// Check if there is no point nearby
if shape_editor
.find_nearest_visible_point_indices(&document.network_interface, position, SELECTION_THRESHOLD, path_overlay_mode, self.frontier_handles_info.clone())
.is_some()
{
self.segment = None;
}
// If already hovering on a segment, then recalculate its closest point
else if let Some(closest_segment) = &mut self.segment {
closest_segment.update_closest_point(document.metadata(), position);
if closest_segment.too_far(position, SEGMENT_INSERTION_DISTANCE) {
self.segment = None;
}
}
// If not, check that if there is some closest segment or not
else if let Some(closest_segment) = shape_editor.upper_closest_segment(&document.network_interface, position, SEGMENT_INSERTION_DISTANCE) {
self.segment = Some(closest_segment);
}
}
fn start_sliding_point(&mut self, shape_editor: &mut ShapeState, document: &DocumentMessageHandler) -> bool {
let single_anchor_selected = shape_editor.selected_points().count() == 1 && shape_editor.selected_points().any(|point| matches!(point, ManipulatorPointId::Anchor(_)));
@ -1203,7 +1303,7 @@ impl PathToolData {
self.temporary_colinear_handles = false;
skip_opposite = true;
}
shape_editor.move_selected_points(handle_lengths, document, snapped_delta, equidistant, true, was_alt_dragging, opposite, skip_opposite, responses);
shape_editor.move_selected_points_and_segments(handle_lengths, document, snapped_delta, equidistant, true, was_alt_dragging, opposite, skip_opposite, responses);
self.previous_mouse_position += document_to_viewport.inverse().transform_vector2(snapped_delta);
} else {
let Some(axis) = self.snapping_axis else { return };
@ -1212,7 +1312,7 @@ impl PathToolData {
Axis::Y => DVec2::new(0., unsnapped_delta.y),
_ => DVec2::new(unsnapped_delta.x, 0.),
};
shape_editor.move_selected_points(handle_lengths, document, projected_delta, equidistant, true, false, opposite, false, responses);
shape_editor.move_selected_points_and_segments(handle_lengths, document, projected_delta, equidistant, true, false, opposite, false, responses);
self.previous_mouse_position += document_to_viewport.inverse().transform_vector2(unsnapped_delta);
}
@ -1236,7 +1336,7 @@ impl Fsm for PathToolFsmState {
fn transition(self, event: ToolMessage, tool_data: &mut Self::ToolData, tool_action_data: &mut ToolActionHandlerData, tool_options: &Self::ToolOptions, responses: &mut VecDeque<Message>) -> Self {
let ToolActionHandlerData { document, input, shape_editor, .. } = tool_action_data;
update_dynamic_hints(self, responses, shape_editor, document, tool_data);
update_dynamic_hints(self, responses, shape_editor, document, tool_data, tool_options);
let ToolMessage::Path(event) = event else { return self };
match (self, event) {
@ -1320,48 +1420,43 @@ impl Fsm for PathToolFsmState {
match self {
Self::Ready => {
// Check if there is no point nearby
if shape_editor
.find_nearest_visible_point_indices(
&document.network_interface,
input.mouse.position,
SELECTION_THRESHOLD,
tool_options.path_overlay_mode,
tool_data.frontier_handles_info.clone(),
)
.is_some()
{
tool_data.segment = None;
}
// If already hovering on a segment, then recalculate its closest point
else if let Some(closest_segment) = &mut tool_data.segment {
closest_segment.update_closest_point(document.metadata(), input.mouse.position);
if closest_segment.too_far(input.mouse.position, SEGMENT_INSERTION_DISTANCE) {
tool_data.segment = None;
}
}
// If not, check that if there is some closest segment or not
else if let Some(closest_segment) = shape_editor.upper_closest_segment(&document.network_interface, input.mouse.position, SEGMENT_INSERTION_DISTANCE) {
tool_data.segment = Some(closest_segment);
}
tool_data.update_closest_segment(shape_editor, input.mouse.position, document, tool_options.path_overlay_mode);
if let Some(closest_segment) = &tool_data.segment {
let perp = closest_segment.calculate_perp(document);
let point = closest_segment.closest_point(document.metadata());
if tool_options.path_editing_mode.segment_editing_mode {
let transform = document.metadata().transform_to_viewport(closest_segment.layer());
// Draw an X on the segment
if tool_data.delete_segment_pressed {
let angle = 45_f64.to_radians();
let tilted_line = DVec2::from_angle(angle).rotate(perp);
let tilted_perp = tilted_line.perp();
overlay_context.outline_overlay_bezier(closest_segment.bezier(), transform);
overlay_context.line(point - tilted_line * SEGMENT_OVERLAY_SIZE, point + tilted_line * SEGMENT_OVERLAY_SIZE, Some(COLOR_OVERLAY_BLUE), None);
overlay_context.line(point - tilted_perp * SEGMENT_OVERLAY_SIZE, point + tilted_perp * SEGMENT_OVERLAY_SIZE, Some(COLOR_OVERLAY_BLUE), None);
}
// Draw a line on the segment
else {
overlay_context.line(point - perp * SEGMENT_OVERLAY_SIZE, point + perp * SEGMENT_OVERLAY_SIZE, Some(COLOR_OVERLAY_BLUE), None);
// Draw the anchors again
let display_anchors = overlay_context.visibility_settings.anchors();
if display_anchors {
let start_pos = transform.transform_point2(closest_segment.bezier().start);
let end_pos = transform.transform_point2(closest_segment.bezier().end);
let start_id = closest_segment.points()[0];
let end_id = closest_segment.points()[1];
if let Some(shape_state) = shape_editor.selected_shape_state.get_mut(&closest_segment.layer()) {
overlay_context.manipulator_anchor(start_pos, shape_state.is_point_selected(ManipulatorPointId::Anchor(start_id)), None);
overlay_context.manipulator_anchor(end_pos, shape_state.is_point_selected(ManipulatorPointId::Anchor(end_id)), None);
}
}
} else {
let perp = closest_segment.calculate_perp(document);
let point = closest_segment.closest_point(document.metadata());
// Draw an X on the segment
if tool_data.delete_segment_pressed {
let angle = 45_f64.to_radians();
let tilted_line = DVec2::from_angle(angle).rotate(perp);
let tilted_perp = tilted_line.perp();
overlay_context.line(point - tilted_line * SEGMENT_OVERLAY_SIZE, point + tilted_line * SEGMENT_OVERLAY_SIZE, Some(COLOR_OVERLAY_BLUE), None);
overlay_context.line(point - tilted_perp * SEGMENT_OVERLAY_SIZE, point + tilted_perp * SEGMENT_OVERLAY_SIZE, Some(COLOR_OVERLAY_BLUE), None);
}
// Draw a line on the segment
else {
overlay_context.line(point - perp * SEGMENT_OVERLAY_SIZE, point + perp * SEGMENT_OVERLAY_SIZE, Some(COLOR_OVERLAY_BLUE), None);
}
}
}
}
@ -1381,11 +1476,13 @@ impl Fsm for PathToolFsmState {
let quad = tool_data.selection_quad(document.metadata());
let polygon = &tool_data.lasso_polygon;
match (selection_shape, selection_mode) {
(SelectionShapeType::Box, SelectionMode::Enclosed) => overlay_context.dashed_quad(quad, None, fill_color, Some(4.), Some(4.), Some(0.5)),
(SelectionShapeType::Lasso, SelectionMode::Enclosed) => overlay_context.dashed_polygon(polygon, None, fill_color, Some(4.), Some(4.), Some(0.5)),
(SelectionShapeType::Box, _) => overlay_context.quad(quad, None, fill_color),
(SelectionShapeType::Lasso, _) => overlay_context.polygon(polygon, None, fill_color),
match (selection_shape, selection_mode, tool_data.started_drawing_from_inside) {
// Don't draw box if it is from inside a shape and selection just began
(SelectionShapeType::Box, SelectionMode::Enclosed, false) => overlay_context.dashed_quad(quad, None, fill_color, Some(4.), Some(4.), Some(0.5)),
(SelectionShapeType::Lasso, SelectionMode::Enclosed, _) => overlay_context.dashed_polygon(polygon, None, fill_color, Some(4.), Some(4.), Some(0.5)),
(SelectionShapeType::Box, _, false) => overlay_context.quad(quad, None, fill_color),
(SelectionShapeType::Lasso, _, _) => overlay_context.polygon(polygon, None, fill_color),
(SelectionShapeType::Box, _, _) => {}
}
}
Self::Dragging(_) => {
@ -1431,12 +1528,14 @@ impl Fsm for PathToolFsmState {
lasso_select,
handle_drag_from_anchor,
drag_restore_handle,
molding_in_segment_edit,
},
) => {
let extend_selection = input.keyboard.get(extend_selection as usize);
let lasso_select = input.keyboard.get(lasso_select as usize);
let handle_drag_from_anchor = input.keyboard.get(handle_drag_from_anchor as usize);
let drag_zero_handle = input.keyboard.get(drag_restore_handle as usize);
let molding_in_segment_edit = input.keyboard.get(molding_in_segment_edit as usize);
tool_data.selection_mode = None;
tool_data.lasso_polygon.clear();
@ -1450,7 +1549,10 @@ impl Fsm for PathToolFsmState {
lasso_select,
handle_drag_from_anchor,
drag_zero_handle,
molding_in_segment_edit,
tool_options.path_overlay_mode,
tool_options.path_editing_mode.segment_editing_mode,
tool_options.path_editing_mode.point_editing_mode,
)
}
(
@ -1466,6 +1568,7 @@ impl Fsm for PathToolFsmState {
},
) => {
tool_data.previous_mouse_position = document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position);
tool_data.started_drawing_from_inside = false;
if selection_shape == SelectionShapeType::Lasso {
extend_lasso(&mut tool_data.lasso_polygon, input.mouse.position);
@ -1527,12 +1630,6 @@ impl Fsm for PathToolFsmState {
tool_data.handle_drag_toggle = true;
}
if tool_data.selection_status.is_none() {
if let Some(layer) = document.click(input) {
shape_editor.select_all_anchors_in_layer(document, layer);
}
}
let anchor_and_handle_toggled = input.keyboard.get(move_anchor_with_handles as usize);
let initial_press = anchor_and_handle_toggled && !tool_data.select_anchor_toggled;
let released_from_toggle = tool_data.select_anchor_toggled && !anchor_and_handle_toggled;
@ -1718,6 +1815,11 @@ impl Fsm for PathToolFsmState {
if tool_data.drag_start_pos == previous_mouse {
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] });
} else {
let selection_mode = match tool_action_data.preferences.get_selection_mode() {
SelectionMode::Directional => tool_data.calculate_selection_mode_from_direction(document.metadata()),
selection_mode => selection_mode,
};
match selection_shape {
SelectionShapeType::Box => {
let bbox = [tool_data.drag_start_pos, previous_mouse];
@ -1727,6 +1829,8 @@ impl Fsm for PathToolFsmState {
selection_change,
tool_options.path_overlay_mode,
tool_data.frontier_handles_info.clone(),
tool_options.path_editing_mode.segment_editing_mode,
selection_mode,
);
}
SelectionShapeType::Lasso => shape_editor.select_all_in_shape(
@ -1735,6 +1839,8 @@ impl Fsm for PathToolFsmState {
selection_change,
tool_options.path_overlay_mode,
tool_data.frontier_handles_info.clone(),
tool_options.path_editing_mode.segment_editing_mode,
selection_mode,
),
}
}
@ -1746,6 +1852,7 @@ impl Fsm for PathToolFsmState {
(PathToolFsmState::Dragging { .. }, PathToolMessage::Escape | PathToolMessage::RightClick) => {
if tool_data.handle_drag_toggle && tool_data.drag_start_pos.distance(input.mouse.position) > DRAG_THRESHOLD {
shape_editor.deselect_all_points();
shape_editor.deselect_all_segments();
shape_editor.select_points_by_manipulator_id(&tool_data.saved_points_before_handle_drag);
tool_data.saved_points_before_handle_drag.clear();
@ -1794,8 +1901,17 @@ impl Fsm for PathToolFsmState {
let document_to_viewport = document.metadata().document_to_viewport;
let previous_mouse = document_to_viewport.transform_point2(tool_data.previous_mouse_position);
if tool_data.drag_start_pos == previous_mouse {
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] });
let selection_mode = match tool_action_data.preferences.get_selection_mode() {
SelectionMode::Directional => tool_data.calculate_selection_mode_from_direction(document.metadata()),
selection_mode => selection_mode,
};
if tool_data.drag_start_pos.distance(previous_mouse) < 1e-8 {
// If click happens inside of a shape then don't set selected nodes to empty
if document.click(input).is_none() {
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![] });
}
} else {
match selection_shape {
SelectionShapeType::Box => {
@ -1806,6 +1922,8 @@ impl Fsm for PathToolFsmState {
select_kind,
tool_options.path_overlay_mode,
tool_data.frontier_handles_info.clone(),
tool_options.path_editing_mode.segment_editing_mode,
selection_mode,
);
}
SelectionShapeType::Lasso => shape_editor.select_all_in_shape(
@ -1814,6 +1932,8 @@ impl Fsm for PathToolFsmState {
select_kind,
tool_options.path_overlay_mode,
tool_data.frontier_handles_info.clone(),
tool_options.path_editing_mode.segment_editing_mode,
selection_mode,
),
}
}
@ -1834,32 +1954,31 @@ impl Fsm for PathToolFsmState {
tool_data.frontier_handles_info.clone(),
);
let nearest_segment = tool_data.segment.clone();
if let Some(segment) = &mut tool_data.segment {
if !drag_occurred && !tool_data.molding_segment {
let segment_mode = tool_options.path_editing_mode.segment_editing_mode;
if !drag_occurred && !tool_data.molding_segment && !segment_mode {
if tool_data.delete_segment_pressed {
if let Some(vector_data) = document.network_interface.compute_modified_vector(segment.layer()) {
shape_editor.dissolve_segment(responses, segment.layer(), &vector_data, segment.segment(), segment.points());
responses.add(DocumentMessage::EndTransaction);
}
} else {
segment.adjusted_insert_and_select(shape_editor, responses, extend_selection);
responses.add(DocumentMessage::EndTransaction);
}
} else {
responses.add(DocumentMessage::EndTransaction);
}
tool_data.segment = None;
tool_data.molding_info = None;
tool_data.molding_segment = false;
tool_data.temporary_adjacent_handles_while_molding = None;
return PathToolFsmState::Ready;
}
let segment_mode = tool_options.path_editing_mode.segment_editing_mode;
if let Some((layer, nearest_point)) = nearest_point {
let clicked_selected = shape_editor.selected_points().any(|&point| nearest_point == point);
if !drag_occurred && extend_selection {
let clicked_selected = shape_editor.selected_points().any(|&point| nearest_point == point);
if clicked_selected && tool_data.last_clicked_point_was_selected {
shape_editor.selected_shape_state.entry(layer).or_default().deselect_point(nearest_point);
} else {
@ -1867,6 +1986,49 @@ impl Fsm for PathToolFsmState {
}
responses.add(OverlaysMessage::Draw);
}
if !drag_occurred && !extend_selection && clicked_selected {
if tool_data.saved_points_before_anchor_convert_smooth_sharp.is_empty() {
tool_data.saved_points_before_anchor_convert_smooth_sharp = shape_editor.selected_points().copied().collect::<HashSet<_>>();
}
shape_editor.deselect_all_points();
shape_editor.deselect_all_segments();
shape_editor.selected_shape_state.entry(layer).or_default().select_point(nearest_point);
responses.add(OverlaysMessage::Draw);
}
}
// Segment editing mode
else if let Some(nearest_segment) = nearest_segment {
if segment_mode {
let clicked_selected = shape_editor.selected_segments().any(|&segment| segment == nearest_segment.segment());
if !drag_occurred && extend_selection {
if clicked_selected && tool_data.last_clicked_segment_was_selected {
shape_editor
.selected_shape_state
.entry(nearest_segment.layer())
.or_default()
.deselect_segment(nearest_segment.segment());
} else {
shape_editor.selected_shape_state.entry(nearest_segment.layer()).or_default().select_segment(nearest_segment.segment());
}
responses.add(OverlaysMessage::Draw);
}
if !drag_occurred && !extend_selection && clicked_selected {
shape_editor.deselect_all_segments();
shape_editor.deselect_all_points();
shape_editor.selected_shape_state.entry(nearest_segment.layer()).or_default().select_segment(nearest_segment.segment());
responses.add(OverlaysMessage::Draw);
}
}
}
// Deselect all points if the user clicks the filled region of the shape
else if tool_data.drag_start_pos.distance(input.mouse.position) <= DRAG_THRESHOLD {
shape_editor.deselect_all_points();
shape_editor.deselect_all_segments();
}
if tool_data.temporary_colinear_handles {
@ -1892,25 +2054,6 @@ impl Fsm for PathToolFsmState {
tool_data.select_anchor_toggled = false;
}
if let Some((layer, nearest_point)) = nearest_point {
if !drag_occurred && !extend_selection {
let clicked_selected = shape_editor.selected_points().any(|&point| nearest_point == point);
if clicked_selected {
if tool_data.saved_points_before_anchor_convert_smooth_sharp.is_empty() {
tool_data.saved_points_before_anchor_convert_smooth_sharp = shape_editor.selected_points().copied().collect::<HashSet<_>>();
}
shape_editor.deselect_all_points();
shape_editor.selected_shape_state.entry(layer).or_default().select_point(nearest_point);
responses.add(OverlaysMessage::Draw);
}
}
}
// Deselect all points if the user clicks the filled region of the shape
else if tool_data.drag_start_pos.distance(input.mouse.position) <= DRAG_THRESHOLD {
shape_editor.deselect_all_points();
}
tool_data.snapping_axis = None;
tool_data.sliding_point_info = None;
@ -1926,6 +2069,7 @@ impl Fsm for PathToolFsmState {
(_, PathToolMessage::Delete) => {
// Delete the selected points and clean up overlays
responses.add(DocumentMessage::AddTransaction);
shape_editor.delete_selected_segments(document, responses);
shape_editor.delete_selected_points(document, responses);
responses.add(PathToolMessage::SelectionChanged);
@ -2024,6 +2168,7 @@ impl Fsm for PathToolFsmState {
if let Some(layer) = document.click(input) {
// Select all points in the layer
shape_editor.select_connected_anchors(document, layer, input.mouse.position);
responses.add(OverlaysMessage::Draw);
}
PathToolFsmState::Ready
@ -2033,7 +2178,7 @@ impl Fsm for PathToolFsmState {
PathToolFsmState::Ready
}
(_, PathToolMessage::NudgeSelectedPoints { delta_x, delta_y }) => {
shape_editor.move_selected_points(
shape_editor.move_selected_points_and_segments(
tool_data.opposing_handle_lengths.take(),
document,
(delta_x, delta_y).into(),
@ -2113,10 +2258,6 @@ enum SelectionStatus {
}
impl SelectionStatus {
fn is_none(&self) -> bool {
self == &SelectionStatus::None
}
fn as_one(&self) -> Option<&SingleSelectedPoint> {
match self {
SelectionStatus::One(one) => Some(one),
@ -2246,9 +2387,7 @@ fn calculate_lock_angle(
}
fn check_handle_over_adjacent_anchor(handle_id: ManipulatorPointId, vector_data: &VectorData) -> Option<PointId> {
let Some((anchor, handle_position)) = handle_id.get_anchor(&vector_data).zip(handle_id.get_position(vector_data)) else {
return None;
};
let (anchor, handle_position) = handle_id.get_anchor(vector_data).zip(handle_id.get_position(vector_data))?;
let check_if_close = |point_id: &PointId| {
let Some(anchor_position) = vector_data.point_domain.position_from_id(*point_id) else {
@ -2257,7 +2396,7 @@ fn check_handle_over_adjacent_anchor(handle_id: ManipulatorPointId, vector_data:
(anchor_position - handle_position).length() < 10.
};
vector_data.connected_points(anchor).find(|point| check_if_close(point))
vector_data.connected_points(anchor).find(check_if_close)
}
fn calculate_adjacent_anchor_tangent(
currently_dragged_handle: ManipulatorPointId,
@ -2313,7 +2452,7 @@ fn calculate_adjacent_anchor_tangent(
};
let angle = shared_segment_handle
.get_position(&vector_data)
.get_position(vector_data)
.zip(adjacent_anchor_position)
.map(|(handle, anchor)| -(handle - anchor).angle_to(DVec2::X));
@ -2324,7 +2463,14 @@ fn calculate_adjacent_anchor_tangent(
}
}
fn update_dynamic_hints(state: PathToolFsmState, responses: &mut VecDeque<Message>, shape_editor: &mut ShapeState, document: &DocumentMessageHandler, tool_data: &PathToolData) {
fn update_dynamic_hints(
state: PathToolFsmState,
responses: &mut VecDeque<Message>,
shape_editor: &mut ShapeState,
document: &DocumentMessageHandler,
tool_data: &PathToolData,
tool_options: &PathToolOptions,
) {
// Condinting based on currently selected segment if it has any one g1 continuous handle
let hint_data = match state {
@ -2358,12 +2504,27 @@ fn update_dynamic_hints(state: PathToolFsmState, responses: &mut VecDeque<Messag
drag_selected_hints.push(HintInfo::multi_keys([[Key::Control], [Key::Shift]], "Slide").prepend_plus());
}
let mut hint_data = vec![
HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Select Point"), HintInfo::keys([Key::Shift], "Extend").prepend_plus()]),
HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Select Area"), HintInfo::keys([Key::Control], "Lasso").prepend_plus()]),
HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Insert Point on Segment")]),
HintGroup(vec![HintInfo::keys_and_mouse([Key::Alt], MouseMotion::Lmb, "Delete Segment")]),
];
let mut hint_data = match (tool_data.segment.is_some(), tool_options.path_editing_mode.segment_editing_mode) {
(true, true) => {
vec![
HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Select Segment"), HintInfo::keys([Key::Shift], "Extend").prepend_plus()]),
HintGroup(vec![HintInfo::keys_and_mouse([Key::KeyA], MouseMotion::Lmb, "Mold Segment")]),
]
}
(true, false) => {
vec![
HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Insert Point on Segment")]),
HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Mold Segment")]),
HintGroup(vec![HintInfo::keys_and_mouse([Key::Alt], MouseMotion::Lmb, "Delete Segment")]),
]
}
(false, _) => {
vec![
HintGroup(vec![HintInfo::mouse(MouseMotion::Lmb, "Select Point"), HintInfo::keys([Key::Shift], "Extend").prepend_plus()]),
HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Select Area"), HintInfo::keys([Key::Control], "Lasso").prepend_plus()]),
]
}
};
if at_least_one_anchor_selected {
// TODO: Dynamically show either "Smooth" or "Sharp" based on the current state

View file

@ -18,7 +18,7 @@ use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput};
use graphene_std::Color;
use graphene_std::renderer::Quad;
use graphene_std::text::{Font, FontCache, TypesettingConfig, lines_clipping, load_face};
use graphene_std::text::{Font, FontCache, TypesettingConfig, lines_clipping, load_font};
use graphene_std::vector::style::Fill;
#[derive(Default)]
@ -35,6 +35,7 @@ pub struct TextOptions {
font_name: String,
font_style: String,
fill: ToolColorOptions,
tilt: f64,
}
impl Default for TextOptions {
@ -42,10 +43,11 @@ impl Default for TextOptions {
Self {
font_size: 24.,
line_height_ratio: 1.2,
character_spacing: 1.,
character_spacing: 0.,
font_name: graphene_std::consts::DEFAULT_FONT_FAMILY.into(),
font_style: graphene_std::consts::DEFAULT_FONT_STYLE.into(),
fill: ToolColorOptions::new_primary(),
tilt: 0.,
}
}
}
@ -468,8 +470,8 @@ impl Fsm for TextToolFsmState {
transform: document.metadata().transform_to_viewport(tool_data.layer).to_cols_array(),
});
if let Some(editing_text) = tool_data.editing_text.as_mut() {
let buzz_face = font_cache.get(&editing_text.font).map(|data| load_face(data));
let far = graphene_std::text::bounding_box(&tool_data.new_text, buzz_face.as_ref(), editing_text.typesetting, false);
let font_data = font_cache.get(&editing_text.font).map(|data| load_font(data));
let far = graphene_std::text::bounding_box(&tool_data.new_text, font_data, editing_text.typesetting, false);
if far.x != 0. && far.y != 0. {
let quad = Quad::from_box([DVec2::ZERO, far]);
let transformed_quad = document.metadata().transform_to_viewport(tool_data.layer) * quad;
@ -517,8 +519,8 @@ impl Fsm for TextToolFsmState {
// Draw red overlay if text is clipped
let transformed_quad = layer_transform * bounds;
if let Some((text, font, typesetting)) = graph_modification_utils::get_text(layer.unwrap(), &document.network_interface) {
let buzz_face = font_cache.get(font).map(|data| load_face(data));
if lines_clipping(text.as_str(), buzz_face, typesetting) {
let font_data = font_cache.get(font).map(|data| load_font(data));
if lines_clipping(text.as_str(), font_data, typesetting) {
overlay_context.line(transformed_quad.0[2], transformed_quad.0[3], Some(COLOR_OVERLAY_RED), Some(3.));
}
}
@ -784,6 +786,7 @@ impl Fsm for TextToolFsmState {
max_width: constraint_size.map(|size| size.x),
character_spacing: tool_options.character_spacing,
max_height: constraint_size.map(|size| size.y),
tilt: tool_options.tilt,
},
font: Font::new(tool_options.font_name.clone(), tool_options.font_style.clone()),
color: tool_options.fill.active_color(),

View file

@ -180,14 +180,28 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
*selected.pivot = selected.mean_average_of_pivots();
self.local_pivot = document.metadata().document_to_viewport.inverse().transform_point2(*selected.pivot);
self.grab_target = document.metadata().document_to_viewport.inverse().transform_point2(selected.mean_average_of_pivots());
} else if let Some(vector_data) = selected_layers.first().and_then(|&layer| document.network_interface.compute_modified_vector(layer)) {
}
// Here vector data from all layers is not considered which can be a problem in pivot calculation
else if let Some(vector_data) = selected_layers.first().and_then(|&layer| document.network_interface.compute_modified_vector(layer)) {
*selected.original_transforms = OriginalTransforms::default();
let viewspace = document.metadata().transform_to_viewport(selected_layers[0]);
let selected_points = shape_editor.selected_points().collect::<Vec<_>>();
let selected_segments = shape_editor.selected_segments().collect::<HashSet<_>>();
let mut affected_points = shape_editor.selected_points().copied().collect::<Vec<_>>();
for (segment_id, _, start, end) in vector_data.segment_bezier_iter() {
if selected_segments.contains(&segment_id) {
affected_points.push(ManipulatorPointId::Anchor(start));
affected_points.push(ManipulatorPointId::Anchor(end));
}
}
let affected_point_refs = affected_points.iter().collect();
let get_location = |point: &&ManipulatorPointId| point.get_position(&vector_data).map(|position| viewspace.transform_point2(position));
if let Some((new_pivot, grab_target)) = calculate_pivot(&selected_points, &vector_data, viewspace, |point: &ManipulatorPointId| get_location(&point)) {
if let Some((new_pivot, grab_target)) = calculate_pivot(&affected_point_refs, &vector_data, viewspace, |point: &ManipulatorPointId| get_location(&point)) {
*selected.pivot = new_pivot;
self.local_pivot = document_to_viewport.inverse().transform_point2(*selected.pivot);
@ -390,7 +404,8 @@ impl MessageHandler<TransformLayerMessage, TransformData<'_>> for TransformLayer
}
TransformLayerMessage::BeginGRS { transform_type } => {
let selected_points: Vec<&ManipulatorPointId> = shape_editor.selected_points().collect();
if (using_path_tool && selected_points.is_empty())
let selected_segments = shape_editor.selected_segments().collect::<Vec<_>>();
if (using_path_tool && selected_points.is_empty() && selected_segments.is_empty())
|| (!using_path_tool && !using_select_tool && !using_pen_tool && !using_shape_tool)
|| selected_layers.is_empty()
|| transform_type.equivalent_to(self.transform_operation)

View file

@ -227,9 +227,7 @@ impl NodeRuntime {
}
async fn update_network(&mut self, mut graph: NodeNetwork) -> Result<ResolvedDocumentNodeTypesDelta, String> {
if cfg!(not(test)) {
preprocessor::expand_network(&mut graph, &self.substitutions);
}
preprocessor::expand_network(&mut graph, &self.substitutions);
let scoped_network = wrap_network_in_scope(graph, self.editor_api.clone());

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">
<circle cx="6" cy="6" r="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 102 B

View file

@ -760,6 +760,8 @@
.text-input {
word-break: break-all;
unicode-bidi: plaintext;
text-align: left;
}
.text-input div {
@ -773,6 +775,8 @@
overflow-wrap: anywhere;
white-space: pre-wrap;
word-break: normal;
unicode-bidi: plaintext;
text-align: left;
display: inline-block;
// Workaround to force Chrome to display the flashing text entry cursor when text is empty
padding-left: 1px;

View file

@ -158,6 +158,7 @@
background: none;
color: var(--color-e-nearwhite);
caret-color: var(--color-e-nearwhite);
unicode-bidi: plaintext;
&::selection {
background-color: var(--color-4-dimgray);

View file

@ -13,6 +13,7 @@ import Checkmark from "@graphite-frontend/assets/icon-12px-solid/checkmark.svg";
import Clipped from "@graphite-frontend/assets/icon-12px-solid/clipped.svg";
import CloseX from "@graphite-frontend/assets/icon-12px-solid/close-x.svg";
import Delay from "@graphite-frontend/assets/icon-12px-solid/delay.svg";
import Dot from "@graphite-frontend/assets/icon-12px-solid/dot.svg";
import DropdownArrow from "@graphite-frontend/assets/icon-12px-solid/dropdown-arrow.svg";
import Edit12px from "@graphite-frontend/assets/icon-12px-solid/edit-12px.svg";
import Empty12px from "@graphite-frontend/assets/icon-12px-solid/empty-12px.svg";
@ -55,6 +56,7 @@ const SOLID_12PX = {
Clipped: { svg: Clipped, size: 12 },
CloseX: { svg: CloseX, size: 12 },
Delay: { svg: Delay, size: 12 },
Dot: { svg: Dot, size: 12 },
DropdownArrow: { svg: DropdownArrow, size: 12 },
Edit12px: { svg: Edit12px, size: 12 },
Empty12px: { svg: Empty12px, size: 12 },

View file

@ -29,10 +29,11 @@ ctor = { workspace = true }
rand_chacha = { workspace = true }
bezier-rs = { workspace = true }
specta = { workspace = true }
rustybuzz = { workspace = true }
image = { workspace = true }
half = { workspace = true }
tinyvec = { workspace = true }
parley = { workspace = true }
skrifa = { workspace = true }
kurbo = { workspace = true }
log = { workspace = true }
base64 = { workspace = true }

View file

@ -22,11 +22,12 @@ impl Default for Font {
/// A cache of all loaded font data and preview urls along with the default font (send from `init_app` in `editor_api.rs`)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, DynAny)]
pub struct FontCache {
/// Actual font file data used for rendering a font with ttf_parser and rustybuzz
/// Actual font file data used for rendering a font
font_file_data: HashMap<Font, Vec<u8>>,
/// Web font preview URLs used for showing fonts when live editing
preview_urls: HashMap<Font, String>,
}
impl FontCache {
/// Returns the font family name if the font is cached, otherwise returns the fallback font family name if that is cached
pub fn resolve_font<'a>(&'a self, font: &'a Font) -> Option<&'a Font> {
@ -40,7 +41,7 @@ impl FontCache {
}
/// Try to get the bytes for a font
pub fn get<'a>(&'a self, font: &Font) -> Option<&'a Vec<u8>> {
pub fn get(&self, font: &Font) -> Option<&Vec<u8>> {
self.resolve_font(font).and_then(|font| self.font_file_data.get(font))
}

View file

@ -1,29 +1,67 @@
use crate::vector::PointId;
use bezier_rs::{ManipulatorGroup, Subpath};
use glam::DVec2;
use rustybuzz::ttf_parser::{GlyphId, OutlineBuilder};
use rustybuzz::{GlyphBuffer, UnicodeBuffer};
use core::cell::RefCell;
use glam::{DAffine2, DVec2};
use parley::fontique::Blob;
use parley::{Alignment, AlignmentOptions, FontContext, GlyphRun, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty};
use skrifa::GlyphId;
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::raw::FontRef as ReadFontsRef;
use skrifa::{MetadataProvider, OutlineGlyph};
use std::sync::Arc;
struct Builder {
// Thread-local storage avoids expensive re-initialization of font and layout contexts
// across multiple text rendering operations within the same thread
thread_local! {
static FONT_CONTEXT: RefCell<FontContext> = RefCell::new(FontContext::new());
static LAYOUT_CONTEXT: RefCell<LayoutContext<()>> = RefCell::new(LayoutContext::new());
}
struct PathBuilder {
current_subpath: Subpath<PointId>,
glyph_subpaths: Vec<Subpath<PointId>>,
other_subpaths: Vec<Subpath<PointId>>,
text_cursor: DVec2,
offset: DVec2,
ascender: f64,
origin: DVec2,
scale: f64,
id: PointId,
}
impl Builder {
impl PathBuilder {
fn point(&self, x: f32, y: f32) -> DVec2 {
self.text_cursor + self.offset + DVec2::new(x as f64, self.ascender - y as f64) * self.scale
// Y-axis inversion converts from font coordinate system (Y-up) to graphics coordinate system (Y-down)
DVec2::new(self.origin.x + x as f64, self.origin.y - y as f64) * self.scale
}
fn set_origin(&mut self, x: f64, y: f64) {
self.origin = DVec2::new(x, y);
}
fn draw_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], style_skew: Option<DAffine2>, skew: DAffine2) {
let location_ref = LocationRef::new(normalized_coords);
let settings = DrawSettings::unhinted(Size::new(size), location_ref);
glyph.draw(settings, self).unwrap();
// Apply transforms in correct order: style-based skew first, then user-requested skew
// This ensures font synthesis (italic) is applied before user transformations
for glyph_subpath in &mut self.glyph_subpaths {
if let Some(style_skew) = style_skew {
glyph_subpath.apply_transform(style_skew);
}
glyph_subpath.apply_transform(skew);
}
if !self.glyph_subpaths.is_empty() {
self.other_subpaths.extend(core::mem::take(&mut self.glyph_subpaths));
}
}
}
impl OutlineBuilder for Builder {
impl OutlinePen for PathBuilder {
fn move_to(&mut self, x: f32, y: f32) {
if !self.current_subpath.is_empty() {
self.other_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
}
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id()));
}
@ -47,36 +85,10 @@ impl OutlineBuilder for Builder {
fn close(&mut self) {
self.current_subpath.set_closed(true);
self.other_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
}
}
fn font_properties(buzz_face: &rustybuzz::Face, font_size: f64, line_height_ratio: f64) -> (f64, f64, UnicodeBuffer) {
let scale = (buzz_face.units_per_em() as f64).recip() * font_size;
let line_height = font_size * line_height_ratio;
let buffer = UnicodeBuffer::new();
(scale, line_height, buffer)
}
fn push_str(buffer: &mut UnicodeBuffer, word: &str) {
buffer.push_str(word);
}
fn wrap_word(max_width: Option<f64>, glyph_buffer: &GlyphBuffer, font_size: f64, character_spacing: f64, x_pos: f64, space_glyph: Option<GlyphId>) -> bool {
if let Some(max_width) = max_width {
// We don't word wrap spaces (to match the browser)
let all_glyphs = glyph_buffer.glyph_positions().iter().zip(glyph_buffer.glyph_infos());
let non_space_glyphs = all_glyphs.take_while(|(_, info)| space_glyph != Some(GlyphId(info.glyph_id as u16)));
let word_length: f64 = non_space_glyphs.map(|(pos, _)| pos.x_advance as f64 * character_spacing).sum();
let scaled_word_length = word_length * font_size;
if scaled_word_length + x_pos > max_width {
return true;
}
}
false
}
#[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
pub struct TypesettingConfig {
pub font_size: f64,
@ -84,6 +96,7 @@ pub struct TypesettingConfig {
pub character_spacing: f64,
pub max_width: Option<f64>,
pub max_height: Option<f64>,
pub tilt: f64,
}
impl Default for TypesettingConfig {
@ -91,163 +104,130 @@ impl Default for TypesettingConfig {
Self {
font_size: 24.,
line_height_ratio: 1.2,
character_spacing: 1.,
character_spacing: 0.,
max_width: None,
max_height: None,
tilt: 0.,
}
}
}
pub fn to_path(str: &str, buzz_face: Option<rustybuzz::Face>, typesetting: TypesettingConfig) -> Vec<Subpath<PointId>> {
let Some(buzz_face) = buzz_face else { return vec![] };
let space_glyph = buzz_face.glyph_index(' ');
fn render_glyph_run(glyph_run: &GlyphRun<'_, ()>, path_builder: &mut PathBuilder, tilt: f64) {
let mut run_x = glyph_run.offset();
let run_y = glyph_run.baseline();
let (scale, line_height, mut buffer) = font_properties(&buzz_face, typesetting.font_size, typesetting.line_height_ratio);
let run = glyph_run.run();
let mut builder = Builder {
// User-requested tilt applied around baseline to avoid vertical displacement
// Translation ensures rotation point is at the baseline, not origin
let skew = DAffine2::from_translation(DVec2::new(0., run_y as f64))
* DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.])
* DAffine2::from_translation(DVec2::new(0., -run_y as f64));
let synthesis = run.synthesis();
// Font synthesis (e.g., synthetic italic) applied separately from user transforms
// This preserves the distinction between font styling and user transformations
let style_skew = synthesis.skew().map(|angle| {
DAffine2::from_translation(DVec2::new(0., run_y as f64))
* DAffine2::from_cols_array(&[1., 0., -angle.to_radians().tan() as f64, 1., 0., 0.])
* DAffine2::from_translation(DVec2::new(0., -run_y as f64))
});
let font = run.font();
let font_size = run.font_size();
let normalized_coords = run.normalized_coords().iter().map(|coord| NormalizedCoord::from_bits(*coord)).collect::<Vec<_>>();
// TODO: This can be cached for better performance
let font_collection_ref = font.data.as_ref();
let font_ref = ReadFontsRef::from_index(font_collection_ref, font.index).unwrap();
let outlines = font_ref.outline_glyphs();
for glyph in glyph_run.glyphs() {
let glyph_x = run_x + glyph.x;
let glyph_y = run_y - glyph.y;
run_x += glyph.advance;
let glyph_id = GlyphId::from(glyph.id);
if let Some(glyph_outline) = outlines.get(glyph_id) {
path_builder.set_origin(glyph_x as f64, glyph_y as f64);
path_builder.draw_glyph(&glyph_outline, font_size, &normalized_coords, style_skew, skew);
}
}
}
fn layout_text(str: &str, font_data: Option<Blob<u8>>, typesetting: TypesettingConfig) -> Option<Layout<()>> {
let font_cx = FONT_CONTEXT.with(Clone::clone);
let mut font_cx = font_cx.borrow_mut();
let layout_cx = LAYOUT_CONTEXT.with(Clone::clone);
let mut layout_cx = layout_cx.borrow_mut();
let font_family = font_data.and_then(|font_data| {
font_cx
.collection
.register_fonts(font_data, None)
.first()
.and_then(|(family_id, _)| font_cx.collection.family_name(*family_id).map(String::from))
})?;
const DISPLAY_SCALE: f32 = 1.;
let mut builder = layout_cx.ranged_builder(&mut font_cx, str, DISPLAY_SCALE, true);
builder.push_default(StyleProperty::FontSize(typesetting.font_size as f32));
builder.push_default(StyleProperty::LetterSpacing(typesetting.character_spacing as f32));
builder.push_default(StyleProperty::FontStack(parley::FontStack::Single(parley::FontFamily::Named(std::borrow::Cow::Owned(font_family)))));
builder.push_default(LineHeight::FontSizeRelative(typesetting.line_height_ratio as f32));
let mut layout: Layout<()> = builder.build(str);
layout.break_all_lines(typesetting.max_width.map(|mw| mw as f32));
layout.align(typesetting.max_width.map(|max_w| max_w as f32), Alignment::Left, AlignmentOptions::default());
Some(layout)
}
pub fn to_path(str: &str, font_data: Option<Blob<u8>>, typesetting: TypesettingConfig) -> Vec<Subpath<PointId>> {
let Some(layout) = layout_text(str, font_data, typesetting) else { return Vec::new() };
let mut path_builder = PathBuilder {
current_subpath: Subpath::new(Vec::new(), false),
glyph_subpaths: Vec::new(),
other_subpaths: Vec::new(),
text_cursor: DVec2::ZERO,
offset: DVec2::ZERO,
ascender: (buzz_face.ascender() as f64 / buzz_face.height() as f64) * typesetting.font_size / scale,
scale,
origin: DVec2::ZERO,
scale: layout.scale() as f64,
id: PointId::ZERO,
};
for line in str.split('\n') {
for (index, word) in SplitWordsIncludingSpaces::new(line).enumerate() {
push_str(&mut buffer, word);
let glyph_buffer = rustybuzz::shape(&buzz_face, &[], buffer);
// Don't wrap the first word
if index != 0 && wrap_word(typesetting.max_width, &glyph_buffer, scale, typesetting.character_spacing, builder.text_cursor.x, space_glyph) {
builder.text_cursor = DVec2::new(0., builder.text_cursor.y + line_height);
for line in layout.lines() {
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(glyph_run) = item {
render_glyph_run(&glyph_run, &mut path_builder, typesetting.tilt);
}
for (glyph_position, glyph_info) in glyph_buffer.glyph_positions().iter().zip(glyph_buffer.glyph_infos()) {
let glyph_id = GlyphId(glyph_info.glyph_id as u16);
if let Some(max_width) = typesetting.max_width {
if space_glyph != Some(glyph_id) && builder.text_cursor.x + (glyph_position.x_advance as f64 * builder.scale * typesetting.character_spacing) >= max_width {
builder.text_cursor = DVec2::new(0., builder.text_cursor.y + line_height);
}
}
// Clip when the height is exceeded
if typesetting.max_height.is_some_and(|max_height| builder.text_cursor.y > max_height - line_height) {
return builder.other_subpaths;
}
builder.offset = DVec2::new(glyph_position.x_offset as f64, glyph_position.y_offset as f64) * builder.scale;
buzz_face.outline_glyph(glyph_id, &mut builder);
if !builder.current_subpath.is_empty() {
builder.other_subpaths.push(std::mem::replace(&mut builder.current_subpath, Subpath::new(Vec::new(), false)));
}
builder.text_cursor += DVec2::new(glyph_position.x_advance as f64 * typesetting.character_spacing, glyph_position.y_advance as f64) * builder.scale;
}
buffer = glyph_buffer.clear();
}
builder.text_cursor = DVec2::new(0., builder.text_cursor.y + line_height);
}
builder.other_subpaths
path_builder.other_subpaths
}
pub fn bounding_box(str: &str, buzz_face: Option<&rustybuzz::Face>, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
// Show blank layer if font has not loaded
let Some(buzz_face) = buzz_face else { return DVec2::ZERO };
let space_glyph = buzz_face.glyph_index(' ');
let (scale, line_height, mut buffer) = font_properties(buzz_face, typesetting.font_size, typesetting.line_height_ratio);
let [mut text_cursor, mut bounds] = [DVec2::ZERO; 2];
pub fn bounding_box(str: &str, font_data: Option<Blob<u8>>, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
if !for_clipping_test {
if let (Some(max_height), Some(max_width)) = (typesetting.max_height, typesetting.max_width) {
return DVec2::new(max_width, max_height);
}
}
for line in str.split('\n') {
for (index, word) in SplitWordsIncludingSpaces::new(line).enumerate() {
push_str(&mut buffer, word);
let Some(layout) = layout_text(str, font_data, typesetting) else { return DVec2::ZERO };
let glyph_buffer = rustybuzz::shape(buzz_face, &[], buffer);
// Don't wrap the first word
if index != 0 && wrap_word(typesetting.max_width, &glyph_buffer, scale, typesetting.character_spacing, text_cursor.x, space_glyph) {
text_cursor = DVec2::new(0., text_cursor.y + line_height);
}
for (glyph_position, glyph_info) in glyph_buffer.glyph_positions().iter().zip(glyph_buffer.glyph_infos()) {
let glyph_id = GlyphId(glyph_info.glyph_id as u16);
if let Some(max_width) = typesetting.max_width {
if space_glyph != Some(glyph_id) && text_cursor.x + (glyph_position.x_advance as f64 * scale * typesetting.character_spacing) >= max_width {
text_cursor = DVec2::new(0., text_cursor.y + line_height);
}
}
text_cursor += DVec2::new(glyph_position.x_advance as f64 * typesetting.character_spacing, glyph_position.y_advance as f64) * scale;
bounds = bounds.max(text_cursor + DVec2::new(0., line_height));
}
buffer = glyph_buffer.clear();
}
text_cursor = DVec2::new(0., text_cursor.y + line_height);
bounds = bounds.max(text_cursor);
}
if !for_clipping_test {
if let Some(max_width) = typesetting.max_width {
bounds.x = max_width;
}
if let Some(max_height) = typesetting.max_height {
bounds.y = max_height;
}
}
bounds
DVec2::new(layout.full_width() as f64, layout.height() as f64)
}
pub fn load_face(data: &[u8]) -> rustybuzz::Face<'_> {
rustybuzz::Face::from_slice(data, 0).expect("Loading font failed")
pub fn load_font(data: &[u8]) -> Blob<u8> {
Blob::new(Arc::new(data.to_vec()))
}
pub fn lines_clipping(str: &str, buzz_face: Option<rustybuzz::Face>, typesetting: TypesettingConfig) -> bool {
pub fn lines_clipping(str: &str, font_data: Option<Blob<u8>>, typesetting: TypesettingConfig) -> bool {
let Some(max_height) = typesetting.max_height else { return false };
let bounds = bounding_box(str, buzz_face.as_ref(), typesetting, true);
let bounds = bounding_box(str, font_data, typesetting, true);
max_height < bounds.y
}
struct SplitWordsIncludingSpaces<'a> {
text: &'a str,
start_byte: usize,
}
impl<'a> SplitWordsIncludingSpaces<'a> {
pub fn new(text: &'a str) -> Self {
Self { text, start_byte: 0 }
}
}
impl<'a> Iterator for SplitWordsIncludingSpaces<'a> {
type Item = &'a str;
fn next(&mut self) -> Option<Self::Item> {
let mut eaten_chars = self.text[self.start_byte..].char_indices().skip_while(|(_, c)| *c != ' ').skip_while(|(_, c)| *c == ' ');
let start_byte = self.start_byte;
self.start_byte = eaten_chars.next().map_or(self.text.len(), |(offset, _)| self.start_byte + offset);
(self.start_byte > start_byte).then(|| self.text.get(start_byte..self.start_byte)).flatten()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_words_including_spaces() {
let mut split_words = SplitWordsIncludingSpaces::new("hello world .");
assert_eq!(split_words.next(), Some("hello "));
assert_eq!(split_words.next(), Some("world "));
assert_eq!(split_words.next(), Some("."));
assert_eq!(split_words.next(), None);
}
}

View file

@ -19,10 +19,10 @@ async fn transform<T: 'n + 'static>(
translate: DVec2,
rotate: f64,
scale: DVec2,
shear: DVec2,
skew: DVec2,
_pivot: DVec2,
) -> Instances<T> {
let matrix = DAffine2::from_scale_angle_translation(scale, rotate, translate) * DAffine2::from_cols_array(&[1., shear.y, shear.x, 1., 0., 0.]);
let matrix = DAffine2::from_scale_angle_translation(scale, rotate, translate) * DAffine2::from_cols_array(&[1., skew.y, skew.x, 1., 0., 0.]);
let footprint = ctx.try_footprint().copied();

View file

@ -11,13 +11,14 @@ use crate::transform::Transform;
use crate::vector::click_target::{ClickTargetType, FreePoint};
use crate::{AlphaBlending, Color, GraphicGroupTable};
pub use attributes::*;
use bezier_rs::ManipulatorGroup;
use bezier_rs::{BezierHandles, ManipulatorGroup};
use core::borrow::Borrow;
use core::hash::Hash;
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
pub use indexed::VectorDataIndex;
use kurbo::{Affine, Rect, Shape};
pub use modification::*;
use std::borrow::Borrow;
use std::collections::HashMap;
// TODO: Eventually remove this migration document upgrade code
@ -333,6 +334,13 @@ impl VectorData {
index.flat_map(|index| self.segment_domain.connected_points(index).map(|index| self.point_domain.ids()[index]))
}
/// Returns the number of linear segments connected to the given point.
pub fn connected_linear_segments(&self, point_id: PointId) -> usize {
self.segment_bezier_iter()
.filter(|(_, bez, start, end)| ((*start == point_id || *end == point_id) && matches!(bez.handles, BezierHandles::Linear)))
.count()
}
/// Get an array slice of all segment IDs.
pub fn segment_ids(&self) -> &[SegmentId] {
self.segment_domain.ids()

View file

@ -9,23 +9,37 @@ fn text<'i: 'n>(
editor: &'i WasmEditorApi,
text: String,
font_name: Font,
#[default(24.)] font_size: f64,
#[default(1.2)] line_height_ratio: f64,
#[default(1.)] character_spacing: f64,
#[default(None)] max_width: Option<f64>,
#[default(None)] max_height: Option<f64>,
#[unit(" px")]
#[default(24.)]
font_size: f64,
#[unit("x")]
#[default(1.2)]
line_height_ratio: f64,
#[unit(" px")]
#[default(0.)]
character_spacing: f64,
#[unit(" px")]
#[default(None)]
max_width: Option<f64>,
#[unit(" px")]
#[default(None)]
max_height: Option<f64>,
#[unit("°")]
#[default(0.)]
tilt: f64,
) -> VectorDataTable {
let buzz_face = editor.font_cache.get(&font_name).map(|data| load_face(data));
let typesetting = TypesettingConfig {
font_size,
line_height_ratio,
character_spacing,
max_width,
max_height,
tilt,
};
let result = VectorData::from_subpaths(to_path(&text, buzz_face, typesetting), false);
let font_data = editor.font_cache.get(&font_name).map(|f| load_font(f));
let result = VectorData::from_subpaths(to_path(&text, font_data, typesetting), false);
VectorDataTable::new(result)
}

View file

@ -61,7 +61,7 @@ pub fn generate_node_substitutions() -> HashMap<String, DocumentNode> {
(
NodeId(i as u64),
match inputs.len() {
1 => {
1 if false => {
let input = inputs.iter().next().unwrap();
let input_ty = input.nested_type();

View file

@ -164,9 +164,23 @@ meta_description = "Open source free software. A vector graphics creativity suit
---
<!-- As a new entrant to the open source digital content creation landscape, Graphite has a unique formula for success: -->
<div class="diptych sizzle-video">
<div class="block text">
Starting life as a vector editor, Graphite is evolving into a generalized, all-in-one graphics toolbox that's built more like a game engine than a conventional creative app. The editor's tools wrap its node graph core, providing user-friendly workflows for vector, raster, and beyond.
Starting life as a vector editor, Graphite is evolving into a generalized, all-in-one graphics toolbox that's built more like a game engine than a conventional creative app. The editor's tools wrap its node graph core, providing user-friendly workflows for vector, raster, animation, and beyond.
<a href="https://editor.graphite.rs" class="button arrow">Start creating</a>
</div>
<div class="block video">
<video loop muted playsinline disablepictureinpicture disableremoteplayback data-auto-play preload="none" poster="https://static.graphite.rs/content/index/sizzle-compilation-poster.avif">
<source src="https://static.graphite.rs/content/index/sizzle-compilation.webm" type="video/webm" />
<source src="https://static.graphite.rs/content/index/sizzle-compilation.mp4" type="video/mp4" />
</video>
</div>
</div>
</div>
<div class="block workflows">
@ -276,7 +290,23 @@ Once it's ready to shine, Graphite's code architecture is structured to deliver
</div>
</section>
<!-- ▙ OVERVIEW ▟ -->
<!-- -->
<!-- -->
<!-- ▛ DONATE ▜ -->
<section id="donate" class="block">
<div class="block">
<h2 class="heart">Support the mission</h2>
Free software doesn't grow on trees! Chip in your share of the (very real) development costs so you're not leaving others to pick up the tab. Becoming a member (or giving a one-time donation) lets you help maintain Graphite's sustainability and independence.
<a href="/donate" class="button arrow">Become a member</a>
</div>
</section>
<!-- ▙ DONATE ▟ -->
<!-- -->
<!-- ▛ PROCEDURALISM ▜ -->
<section id="proceduralism" class="feature-box-outer">
<div class="feature-box-inner">
@ -363,23 +393,7 @@ Graphite's representation of artwork as a node graph lets you customize, compose
</div>
</section>
<!-- ▙ PROCEDURALISM ▟ -->
<!-- -->
<!-- ▛ DONATE ▜ -->
<section id="donate" class="block">
<div class="block">
## Support the mission
Free software doesn't grow on trees! Chip in your share of the (very real) development costs so you're not leaving others to pick up the tab. Becoming a member (or giving a one-time donation) lets you help maintain Graphite's sustainability and independence.
<a href="/donate" class="button arrow">Become a member</a>
</div>
</section>
<!-- ▙ DONATE ▟ -->
<!-- -->
<!-- -->
<!-- ▛ NEWSLETTER ▜ -->
<section id="newsletter" class="feature-box-narrow">
<div id="newsletter-success"><!-- Used only as a URL hash fragment anchor --></div>

View file

@ -2,7 +2,8 @@
title = "Graphite features"
[extra]
css = ["/page/features.css", "/component/feature-box.css", "/component/feature-icons.css"]
css = ["/page/features.css", "/component/feature-box.css", "/component/feature-icons.css", "/component/youtube-embed.css"]
js = ["/js/youtube-embed.js"]
+++
<section>
@ -17,6 +18,20 @@ In 2025, stay tuned for performance improvements, native multiplatform desktop a
</div>
</section>
<section>
<div class="block">
<div class="block video-container">
<div>
<div class="youtube-embed aspect-16x9">
<img data-youtube-embed="ZUbcwUC5lxA" loading="lazy" src="https://static.graphite.rs/content/features/podcast-interview-youtube.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" alt="Rust-Powered Graphics Editor: How Graphite's Syntax Trees Revolutionize Image Editing" />
</div>
</div>
</div>
</div>
</section>
<section>
<div class="diptych">

View file

@ -35,7 +35,9 @@ Comments should usually be placed on a separate line above the code they are ref
## Blank lines
Please make a habit of grouping together related lines of code in blocks separated by blank lines. If you have dozens of lines comprising a single unbroken block of logic, you are likely not splitting it apart enough to aid readability. Find sensible places to partition the logic and insert blank lines between each. Roughly 10% of the code you write should ideally be blank lines, otherwise you are likely underutilizing them at the expense of readability.
Please make a habit of grouping together related lines of code in blocks separated by blank lines. These are like your paragraphs if you were writing a novel — they greatly aid readability and your copy editor would have significant concerns with your writing if they were absent.
If you have dozens of lines comprising a single unbroken block of logic, you are likely not splitting it apart enough to aid readability. Find sensible places to partition the logic and insert blank lines between each. Roughly 10% of the code you write should ideally be blank lines, otherwise you are likely underutilizing them at the expense of readability.
## Imports

View file

@ -151,19 +151,6 @@ body > .page {
.heart.heart {
// The same color is also used below in the SVG after the `%23` (URL-encoded `#`)
color: #cc304f;
&::after {
content: "";
background-image: url('data:image/svg+xml;utf8,\
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8,15C5.12471,9.753694 0.5,8.795225 0.5,4.736524 C0.5,-0.507473 7.468734,0 8,4.967381 C8.531266,0 15.5,-0.507473 15.5,4.736524 C15.5,8.795225 10.87529,9.753694 8,15z" fill="%23cc304f" /></svg>\
');
display: inline-block;
width: 0.75em;
height: 0.75em;
margin-left: 0.25em;
margin-bottom: -0.1em;
vertical-align: baseline;
}
}
@media screen and (max-width: 1200px) {
@ -683,6 +670,19 @@ hr,
}
}
.heart::after {
content: "";
background-image: url('data:image/svg+xml;utf8,\
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M8,15C5.12471,9.753694 0.5,8.795225 0.5,4.736524 C0.5,-0.507473 7.468734,0 8,4.967381 C8.531266,0 15.5,-0.507473 15.5,4.736524 C15.5,8.795225 10.87529,9.753694 8,15z" fill="%23cc304f" /></svg>\
');
display: inline-block;
width: 0.75em;
height: 0.75em;
margin-left: 0.25em;
margin-bottom: -0.1em;
vertical-align: baseline;
}
// blockquote {
// padding: 32px 80px;
// background: rgba(0, 0, 0, 0.0625);

View file

@ -1,3 +1,13 @@
.video-container {
background: var(--color-fog);
> div {
margin: calc(20 * var(--variable-px)) auto;
width: 100%;
max-width: 800px;
}
}
#roadmap {
width: 100%;
text-align: center;

View file

@ -18,24 +18,25 @@
#tagline {
h1 {
span {
position: relative;
position: relative;
&::after {
content: "";
pointer-events: none;
position: absolute;
left: 0;
right: 0;
top: 100%;
// Dimensions: 480x40
height: 100%;
margin-top: -0.2em;
background: url("https://static.graphite.rs/textures/text-sketch-underline.png");
background-repeat: no-repeat;
background-size: contain;
&::after {
content: "";
pointer-events: none;
position: absolute;
left: 0;
right: 0;
top: 100%;
// Dimensions: 480x40
height: 100%;
margin-top: -0.2em;
background: url("https://static.graphite.rs/textures/text-sketch-underline.png");
background-repeat: no-repeat;
background-size: contain;
}
}
}}
}
p {
font-size: 1.2rem;
@ -112,15 +113,61 @@
// OVERVIEW
#overview {
background-color: var(--color-cloud);
.sizzle-video {
display: flex;
flex-wrap: nowrap;
max-width: 100%;
.block {
min-width: 0;
flex-direction: row;
&.text {
flex: 1 4 100%;
flex-direction: column;
p:has(.button) {
margin-top: 20px;
}
}
&.video {
flex: 0 1 fit-content;
}
}
@media screen and (max-width: 900px) {
flex-wrap: wrap;
.block.video {
flex: 1 1 100%;
justify-content: center;
}
}
@media screen and (max-width: 1100px) {
p:has(.button) {
display: none;
}
}
}
}
// OVERVIEW
// DONATE
#donate {
h2 {
color: #cc304f;
}
}
// DONATE
// PROCEDURALISM
#proceduralism {
background-color: var(--color-slate);
color: white;
margin-top: 0;
.diptych {
background: black;
color: var(--color-fog);
@ -183,9 +230,6 @@
}
// PROCEDURALISM
// DONATE
// DONATE
// NEWSLETTER
#newsletter {
background-color: var(--color-peach);
@ -355,7 +399,7 @@
margin: calc(20 * var(--variable-px)) auto;
width: 100%;
max-width: 800px;
+ p {
margin-top: 0;
}
@ -377,7 +421,7 @@
// RECENT NEWS
#recent-news {
background-color: var(--color-parchment);
.banner img {
width: 100%;
height: auto;