diff --git a/lightningbeam-ui/.gitignore b/lightningbeam-ui/.gitignore index c91cf97..d08bb02 100644 --- a/lightningbeam-ui/.gitignore +++ b/lightningbeam-ui/.gitignore @@ -15,3 +15,8 @@ # OS .DS_Store Thumbs.db + +# Local, machine-specific cargo config (e.g. PKG_CONFIG_PATH for a local ffmpeg). +# CI/packaging build ffmpeg from source (see .github/workflows/build.yml), so this +# must never leak into a commit. +.cargo/config.toml diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index c0e474c..1f6c4ca 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -197,7 +197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" dependencies = [ "alsa-sys", - "bitflags 2.10.0", + "bitflags 2.13.0", "cfg-if", "libc", ] @@ -219,7 +219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" dependencies = [ "android-properties", - "bitflags 2.10.0", + "bitflags 2.13.0", "cc", "cesu8", "jni", @@ -319,7 +319,7 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -700,10 +700,10 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.10.5", "proc-macro2", "quote", "regex", @@ -741,9 +741,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -796,7 +796,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -910,7 +910,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -935,7 +935,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "log", "polling", "rustix 0.38.44", @@ -949,7 +949,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "polling", "rustix 1.1.2", "slab", @@ -1303,7 +1303,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "core-foundation 0.10.1", "libc", ] @@ -1359,7 +1359,7 @@ dependencies = [ "ndk-context", "num-derive", "num-traits", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-audio-toolbox", "objc2-avf-audio", "objc2-core-audio", @@ -1369,7 +1369,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -1445,7 +1445,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "crossterm_winapi", "libc", "mio", @@ -1754,10 +1754,10 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.6.2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -1866,7 +1866,7 @@ version = "0.33.3" dependencies = [ "accesskit", "ahash 0.8.12", - "bitflags 2.10.0", + "bitflags 2.13.0", "emath", "epaint", "log", @@ -2234,7 +2234,7 @@ version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d658424d233cbd993a972dd73a66ca733acd12a494c68995c9ac32ae1fe65b40" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "ffmpeg-sys-next", "libc", ] @@ -2348,13 +2348,22 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bf886368962a7d8456f136073ed33ed1b0770c690d2063731bb6a776e99f33" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -2371,6 +2380,27 @@ dependencies = [ "ttf-parser 0.21.1", ] +[[package]] +name = "fontique" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?branch=main#bf3debd02240f6706d9b0153fb944b788c11dac2" +dependencies = [ + "hashbrown 0.17.1", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts 0.40.2", + "roxmltree 0.21.1", + "smallvec", + "windows 0.62.2", + "windows-core 0.62.2", + "yeslogic-fontconfig-sys", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2662,7 +2692,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -2727,7 +2757,7 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cfg_aliases", "cgl", "dispatch2", @@ -2735,7 +2765,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2804,7 +2834,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "gpu-alloc-types", ] @@ -2814,7 +2844,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -2835,7 +2865,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -2846,7 +2876,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -2937,6 +2967,18 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "harfrust" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0589ddd0d2935dd2845827ac606b4081c266225d613b268ed2910f832889cab" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "read-fonts 0.40.2", + "smallvec", +] + [[package]] name = "hash32" version = "0.3.1" @@ -2984,6 +3026,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "hashlink" version = "0.9.1" @@ -3061,7 +3112,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -3075,35 +3126,58 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locale_core" -version = "2.1.1" +name = "icu_locale" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] [[package]] -name = "icu_normalizer" -version = "2.1.1" +name = "icu_locale_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3115,15 +3189,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -3135,18 +3209,20 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -3154,6 +3230,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + [[package]] name = "idna" version = "1.1.0" @@ -3362,7 +3459,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "serde", "unicode-segmentation", ] @@ -3457,7 +3554,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "libc", "redox_syscall 0.5.18", ] @@ -3509,9 +3606,10 @@ dependencies = [ "image", "kurbo 0.12.0", "lru", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "parley", "pathdiff", "rstar", "rusqlite", @@ -3529,7 +3627,7 @@ dependencies = [ [[package]] name = "lightningbeam-editor" -version = "1.0.5-alpha" +version = "1.0.7-alpha" dependencies = [ "beamdsp", "bytemuck", @@ -3580,7 +3678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b407ca668368d1d5a86cea58ac82d9f9f9ca4bac1e9dce6f16f875f0f081a911" dependencies = [ "ahash 0.8.12", - "bitflags 2.10.0", + "bitflags 2.13.0", "const-str", "cssparser", "cssparser-color", @@ -3689,7 +3787,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" dependencies = [ "cc", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", "time", ] @@ -3746,9 +3844,9 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -3778,7 +3876,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block", "core-graphics-types 0.2.0", "foreign-types", @@ -3896,7 +3994,7 @@ checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.10.0", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "codespan-reporting", @@ -3927,7 +4025,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "jni-sys", "log", "ndk-sys", @@ -3974,7 +4072,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -4142,9 +4240,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -4155,7 +4253,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4171,10 +4269,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.6.2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-cloud-kit 0.3.2", "objc2-core-data 0.3.2", "objc2-core-foundation", @@ -4192,9 +4290,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "libc", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -4207,7 +4305,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4217,7 +4315,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -4230,8 +4328,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4253,7 +4351,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-audio-types", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4265,8 +4363,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", ] [[package]] @@ -4275,7 +4373,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4287,8 +4385,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4298,11 +4396,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.6.2", "dispatch2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -4311,9 +4409,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] @@ -4336,7 +4434,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4358,8 +4456,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", ] @@ -4370,8 +4468,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -4389,7 +4487,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "dispatch", "libc", @@ -4402,10 +4500,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.6.2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -4415,8 +4513,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -4438,7 +4536,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4450,7 +4548,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4463,8 +4561,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4484,7 +4582,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit 0.2.2", @@ -4516,7 +4614,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -4525,9 +4623,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -4625,7 +4723,7 @@ version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cssparser", "log", "phf", @@ -4678,6 +4776,45 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "parlance" +version = "0.1.0" +source = "git+https://github.com/linebender/parley?branch=main#bf3debd02240f6706d9b0153fb944b788c11dac2" + +[[package]] +name = "parley" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?branch=main#bf3debd02240f6706d9b0153fb944b788c11dac2" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_core", + "parley_data", + "skrifa 0.43.2", +] + +[[package]] +name = "parley_core" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?branch=main#bf3debd02240f6706d9b0153fb944b788c11dac2" +dependencies = [ + "icu_properties", +] + +[[package]] +name = "parley_data" +version = "0.11.0" +source = "git+https://github.com/linebender/parley?branch=main#bf3debd02240f6706d9b0153fb944b788c11dac2" +dependencies = [ + "icu_properties", +] + [[package]] name = "password-hash" version = "0.4.2" @@ -4909,7 +5046,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -4963,6 +5100,8 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -5148,9 +5287,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -5238,7 +5377,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cassowary", "compact_str", "crossterm", @@ -5335,7 +5474,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" dependencies = [ "bytemuck", - "font-types", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487889119a5f19ff7c0a20637196bdc76b9f54ebec17e3588b5d75e4999f8773" +dependencies = [ + "bytemuck", + "font-types 0.12.0", + "once_cell", ] [[package]] @@ -5353,7 +5503,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -5452,7 +5602,7 @@ dependencies = [ "dispatch2", "js-sys", "log", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5510,7 +5660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" dependencies = [ "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.13.0", "serde", "serde_derive", "unicode-ident", @@ -5522,6 +5672,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rstar" version = "0.12.2" @@ -5545,7 +5704,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -5580,7 +5739,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5593,7 +5752,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.11.0", @@ -5612,7 +5771,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bytemuck", "smallvec", "ttf-parser 0.21.1", @@ -5862,7 +6021,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbe997d0f2480442d727488fbe2150779114cbe480ecdbadef58b33e0318ffb" +dependencies = [ + "bytemuck", + "read-fonts 0.40.2", ] [[package]] @@ -5896,7 +6065,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", @@ -5921,7 +6090,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "calloop 0.14.3", "calloop-wayland-source 0.4.1", "cursor-icon", @@ -5968,7 +6137,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -6473,11 +6642,12 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -6755,7 +6925,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "rustybuzz", "simplecss", "siphasher", @@ -6781,7 +6951,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "simplecss", "siphasher", "strict-num", @@ -6841,7 +7011,7 @@ dependencies = [ "log", "peniko", "png 0.17.16", - "skrifa", + "skrifa 0.37.0", "static_assertions", "thiserror 2.0.17", "vello_encoding", @@ -6857,7 +7027,7 @@ dependencies = [ "bytemuck", "guillotiere", "peniko", - "skrifa", + "skrifa 0.37.0", "smallvec", ] @@ -6994,7 +7164,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "rustix 1.1.2", "wayland-backend", "wayland-scanner", @@ -7006,7 +7176,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "cursor-icon", "wayland-backend", ] @@ -7028,7 +7198,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -7040,7 +7210,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7053,7 +7223,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dfe33d551eb8bffd03ff067a8b44bb963919157841a99957151299a6307d19c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7066,7 +7236,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7079,7 +7249,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7139,7 +7309,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", "url", "web-sys", @@ -7158,7 +7328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", - "bitflags 2.10.0", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "document-features", @@ -7189,7 +7359,7 @@ dependencies = [ "arrayvec", "bit-set", "bit-vec", - "bitflags 2.10.0", + "bitflags 2.13.0", "bytemuck", "cfg_aliases", "document-features", @@ -7249,7 +7419,7 @@ dependencies = [ "arrayvec", "ash", "bit-set", - "bitflags 2.10.0", + "bitflags 2.13.0", "block", "bytemuck", "cfg-if", @@ -7294,7 +7464,7 @@ version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bytemuck", "js-sys", "log", @@ -7364,11 +7534,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -7380,6 +7562,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -7406,6 +7597,19 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-future" version = "0.2.1" @@ -7414,7 +7618,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -7483,6 +7698,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -7501,6 +7726,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.1.0" @@ -7520,6 +7754,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -7646,6 +7889,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -7844,7 +8096,7 @@ dependencies = [ "ahash 0.8.12", "android-activity", "atomic-waker", - "bitflags 2.10.0", + "bitflags 2.13.0", "block2 0.5.1", "bytemuck", "calloop 0.13.0", @@ -8008,7 +8260,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "dlib", "log", "once_cell", @@ -8043,10 +8295,21 @@ dependencies = [ ] [[package]] -name = "yoke" -version = "0.8.1" +name = "yeslogic-fontconfig-sys" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -8055,9 +8318,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -8206,21 +8469,23 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -8228,9 +8493,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/lightningbeam-ui/Cargo.toml b/lightningbeam-ui/Cargo.toml index 1c2c1b1..b20938d 100644 --- a/lightningbeam-ui/Cargo.toml +++ b/lightningbeam-ui/Cargo.toml @@ -24,6 +24,11 @@ vello = { git = "https://github.com/linebender/vello", branch = "main" } wgpu = { version = "27", features = ["vulkan", "metal", "gles"] } kurbo = { version = "0.12", features = ["serde"] } peniko = "0.5" +# Text layout/shaping for text layers. Pinned to git main to match the peniko 0.5 +# / skrifa 0.37 that vello's git-main resolves to — a released parley still pins +# peniko 0.4, which would split `peniko::Font` into two incompatible types at the +# vello `draw_glyphs` boundary. +parley = { git = "https://github.com/linebender/parley", branch = "main" } # Windowing winit = "0.30" diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index c8a8fef..2a457c7 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -18,6 +18,9 @@ bytemuck = { version = "1.14", features = ["derive"] } kurbo = { workspace = true } vello = { workspace = true } +# Text layout/shaping (text layers) +parley = { workspace = true } + # Image decoding for image fills image = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationMono-Regular.ttf b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationMono-Regular.ttf new file mode 100644 index 0000000..1b3fc9b Binary files /dev/null and b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationMono-Regular.ttf differ diff --git a/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSans-Regular.ttf b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSans-Regular.ttf new file mode 100644 index 0000000..adb8947 Binary files /dev/null and b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSans-Regular.ttf differ diff --git a/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSerif-Regular.ttf b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSerif-Regular.ttf new file mode 100644 index 0000000..3e98e40 Binary files /dev/null and b/lightningbeam-ui/lightningbeam-core/assets/fonts/LiberationSerif-Regular.ttf differ diff --git a/lightningbeam-ui/lightningbeam-core/assets/fonts/README.txt b/lightningbeam-ui/lightningbeam-core/assets/fonts/README.txt new file mode 100644 index 0000000..aba7f4c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/assets/fonts/README.txt @@ -0,0 +1,14 @@ +Bundled fonts for Lightningbeam text layers +=========================================== + +These are the Liberation fonts, used as the built-in serif / sans-serif / monospaced +families so text renders deterministically without depending on system fonts: + + LiberationSans-Regular.ttf + LiberationSerif-Regular.ttf + LiberationMono-Regular.ttf + +License: SIL Open Font License, Version 1.1. +Copyright (c) Red Hat, Inc., with Reserved Font Name "Liberation". +Full license text: https://openfontlicense.org/ (also distributed with the Liberation +fonts package as its OFL.txt / LICENSE). diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs index a1f438b..326550a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_clip_instance.rs @@ -104,7 +104,7 @@ impl Action for AddClipInstanceAction { AnyLayer::Group(_) => { return Err("Cannot add clip instances directly to group layers".to_string()); } - AnyLayer::Raster(_) => { + AnyLayer::Raster(_) | AnyLayer::Text(_) => { return Err("Cannot add clip instances directly to group layers".to_string()); } } @@ -145,8 +145,8 @@ impl Action for AddClipInstanceAction { AnyLayer::Group(_) => { // Group layers don't have clip instances, nothing to rollback } - AnyLayer::Raster(_) => { - // Raster layers don't have clip instances, nothing to rollback + AnyLayer::Raster(_) | AnyLayer::Text(_) => { + // Raster/text layers don't have clip instances, nothing to rollback } } self.executed = false; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs index 7eac030..c2f7b79 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_layer.rs @@ -138,6 +138,7 @@ impl Action for AddLayerAction { AnyLayer::Effect(_) => "Add effect layer", AnyLayer::Group(_) => "Add group layer", AnyLayer::Raster(_) => "Add raster layer", + AnyLayer::Text(_) => "Add text layer", } .to_string() } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/create_text_clip.rs b/lightningbeam-ui/lightningbeam-core/src/actions/create_text_clip.rs new file mode 100644 index 0000000..3df1f4c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/create_text_clip.rs @@ -0,0 +1,133 @@ +//! Create-text-clip action +//! +//! Used by the text tool when the active layer is a **vector** layer: it creates a +//! VectorClip containing a single text layer, registers it, and places a clip +//! instance in the parent vector layer. The editor then enters that clip so the +//! text is directly editable. All of this is one undoable step so undo never +//! leaves an orphan clip. + +use crate::action::Action; +use crate::clip::{ClipInstance, VectorClip}; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::text_layer::TextLayer; +use uuid::Uuid; + +/// Minimum clip duration (seconds) when the document has no duration set yet. +const FALLBACK_DURATION: f64 = 10.0; + +pub struct CreateTextClipAction { + /// The vector layer to place the clip instance in. + parent_layer_id: Uuid, + /// The text layer to embed (its id is stable across redo). + text_layer: TextLayer, + /// Instance position in the parent layer's space. + position: (f64, f64), + + // Assigned on first execute; reused on redo so ids are stable. + clip_id: Option, + instance_id: Option, + executed: bool, +} + +impl CreateTextClipAction { + pub fn new(parent_layer_id: Uuid, text_layer: TextLayer, position: (f64, f64)) -> Self { + Self { + parent_layer_id, + text_layer, + position, + clip_id: None, + instance_id: None, + executed: false, + } + } + + /// Preset the clip + instance ids (so the caller knows them up-front for + /// entering the clip immediately after execute). Ids stay stable across redo. + pub fn with_ids(mut self, clip_id: Uuid, instance_id: Uuid) -> Self { + self.clip_id = Some(clip_id); + self.instance_id = Some(instance_id); + self + } + + /// The vector clip id (after execute). + pub fn clip_id(&self) -> Option { + self.clip_id + } + + /// The clip instance id placed in the parent layer (after execute). + pub fn instance_id(&self) -> Option { + self.instance_id + } + + /// The embedded text layer's id. + pub fn text_layer_id(&self) -> Uuid { + self.text_layer.layer.id + } +} + +impl Action for CreateTextClipAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let clip_id = self.clip_id.unwrap_or_else(Uuid::new_v4); + self.clip_id = Some(clip_id); + let instance_id = self.instance_id.unwrap_or_else(Uuid::new_v4); + self.instance_id = Some(instance_id); + + let duration = if document.duration > 0.0 { document.duration } else { FALLBACK_DURATION }; + + // Build the clip with the text layer as its single root layer. + let mut clip = VectorClip::with_id( + clip_id, + "Text", + self.text_layer.box_width.max(1.0), + self.text_layer.box_height.max(1.0), + duration, + ); + // A movie clip (not keyframe-gated) so the text persists across the timeline. + clip.is_group = false; + clip.layers.add_root(AnyLayer::Text(self.text_layer.clone())); + // Registers the clip and its layers in layer_to_clip_map for O(1) lookup. + document.add_vector_clip(clip); + + // Place an instance of the clip in the parent vector layer. + let layer = document + .get_layer_mut(&self.parent_layer_id) + .ok_or_else(|| format!("Parent layer {} not found", self.parent_layer_id))?; + let AnyLayer::Vector(vector_layer) = layer else { + return Err("Text clip can only be created inside a vector layer".to_string()); + }; + let mut instance = ClipInstance::with_id(instance_id, clip_id); + instance.transform.x = self.position.0; + instance.transform.y = self.position.1; + vector_layer.clip_instances.push(instance); + + self.executed = true; + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + if !self.executed { + return Ok(()); + } + // Remove the clip instance from the parent layer. + if let (Some(instance_id), Some(AnyLayer::Vector(vector_layer))) = + (self.instance_id, document.get_layer_mut(&self.parent_layer_id)) + { + vector_layer.clip_instances.retain(|ci| ci.id != instance_id); + } + // Remove the clip and its layer_to_clip_map registrations. + if let Some(clip_id) = self.clip_id { + if let Some(clip) = document.vector_clips.remove(&clip_id) { + for node in &clip.layers.roots { + document.layer_to_clip_map.remove(&node.data.id()); + } + } + } + self.executed = false; + Ok(()) + } + + fn description(&self) -> String { + "Add text".to_string() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs index 9d8d79e..01fd71e 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/loop_clip_instances.rs @@ -37,6 +37,7 @@ impl Action for LoopClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; for (instance_id, _old_dur, new_dur, _old_lb, new_lb) in loops { @@ -61,6 +62,7 @@ impl Action for LoopClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; for (instance_id, old_dur, _new_dur, old_lb, _new_lb) in loops { diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 29db4f5..81a36f6 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -44,6 +44,9 @@ pub mod resize_raster_layer; pub mod move_layer; pub mod set_fill_paint; pub mod set_image_fill; +pub mod create_text_clip; +pub mod set_text_content; +pub mod resize_text_box; pub use add_clip_instance::AddClipInstanceAction; pub use add_effect::AddEffectAction; @@ -84,3 +87,6 @@ pub use set_fill_paint::SetFillPaintAction; pub use set_image_fill::SetImageFillAction; pub use change_bpm::ChangeBpmAction; pub use change_fps::ChangeFpsAction; +pub use create_text_clip::CreateTextClipAction; +pub use set_text_content::SetTextContentAction; +pub use resize_text_box::ResizeTextBoxAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs index fba7ee7..dd70075 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_clip_instances.rs @@ -58,6 +58,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -97,6 +98,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| { @@ -132,6 +134,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; // Update timeline_start for each clip instance @@ -159,6 +162,7 @@ impl Action for MoveClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; // Restore original timeline_start for each clip instance diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs index afadffb..a043c7b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/remove_clip_instances.rs @@ -46,6 +46,7 @@ impl Action for RemoveClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; // Find and remove the instance, saving it for rollback @@ -72,6 +73,7 @@ impl Action for RemoveClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; clip_instances.push(instance); diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/resize_text_box.rs b/lightningbeam-ui/lightningbeam-core/src/actions/resize_text_box.rs new file mode 100644 index 0000000..abe3d51 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/resize_text_box.rs @@ -0,0 +1,59 @@ +//! Resize-text-box action +//! +//! Updates a text layer's box origin and dimensions (which changes the wrap +//! width, so parley re-wraps the text). One undoable step. + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use kurbo::Point; +use uuid::Uuid; + +pub struct ResizeTextBoxAction { + layer_id: Uuid, + new_origin: Point, + new_width: f64, + new_height: f64, + old: Option<(Point, f64, f64)>, +} + +impl ResizeTextBoxAction { + pub fn new(layer_id: Uuid, new_origin: Point, new_width: f64, new_height: f64) -> Self { + Self { layer_id, new_origin, new_width, new_height, old: None } + } +} + +impl Action for ResizeTextBoxAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let AnyLayer::Text(text_layer) = layer else { + return Err("ResizeTextBoxAction target is not a text layer".to_string()); + }; + if self.old.is_none() { + self.old = Some((text_layer.box_origin, text_layer.box_width, text_layer.box_height)); + } + text_layer.box_origin = self.new_origin; + text_layer.box_width = self.new_width.max(1.0); + text_layer.box_height = self.new_height.max(1.0); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let Some((origin, w, h)) = self.old else { return Ok(()) }; + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + if let AnyLayer::Text(text_layer) = layer { + text_layer.box_origin = origin; + text_layer.box_width = w; + text_layer.box_height = h; + } + Ok(()) + } + + fn description(&self) -> String { + "Resize text box".to_string() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_text_content.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_text_content.rs new file mode 100644 index 0000000..cb9c991 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_text_content.rs @@ -0,0 +1,61 @@ +//! Set-text-content action +//! +//! Replaces a text layer's [`TextContent`] (text string, font size, color, family, +//! alignment) as one undoable step. Used both by in-place editing (text changes) +//! and the info panel (style changes). + +use crate::action::Action; +use crate::document::Document; +use crate::layer::AnyLayer; +use crate::text_layer::TextContent; +use uuid::Uuid; + +pub struct SetTextContentAction { + layer_id: Uuid, + new: TextContent, + old: Option, +} + +impl SetTextContentAction { + pub fn new(layer_id: Uuid, new: TextContent) -> Self { + Self { layer_id, new, old: None } + } + + /// Construct with an explicit `old` value. Used by in-place editing, which mutates + /// the document live for preview and then records one undoable step capturing the + /// content as it was *before* editing began. + pub fn with_old(layer_id: Uuid, old: TextContent, new: TextContent) -> Self { + Self { layer_id, new, old: Some(old) } + } +} + +impl Action for SetTextContentAction { + fn execute(&mut self, document: &mut Document) -> Result<(), String> { + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + let AnyLayer::Text(text_layer) = layer else { + return Err("SetTextContentAction target is not a text layer".to_string()); + }; + if self.old.is_none() { + self.old = Some(text_layer.content.clone()); + } + text_layer.content = self.new.clone(); + Ok(()) + } + + fn rollback(&mut self, document: &mut Document) -> Result<(), String> { + let Some(old) = self.old.clone() else { return Ok(()) }; + let layer = document + .get_layer_mut(&self.layer_id) + .ok_or_else(|| format!("Layer {} not found", self.layer_id))?; + if let AnyLayer::Text(text_layer) = layer { + text_layer.content = old; + } + Ok(()) + } + + fn description(&self) -> String { + "Edit text".to_string() + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs index f5de9a4..5e2101d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/split_clip_instance.rs @@ -114,6 +114,7 @@ impl Action for SplitClipInstanceAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => return Err("Cannot split clip instances on group layers".to_string()), AnyLayer::Raster(_) => return Err("Cannot split clip instances on group layers".to_string()), + AnyLayer::Text(_) => return Err("Cannot split clip instances on group layers".to_string()), }; let instance = clip_instances @@ -233,7 +234,7 @@ impl Action for SplitClipInstanceAction { AnyLayer::Group(_) => { return Err("Cannot split clip instances on group layers".to_string()); } - AnyLayer::Raster(_) => { + AnyLayer::Raster(_) | AnyLayer::Text(_) => { return Err("Cannot split clip instances on group layers".to_string()); } } @@ -294,8 +295,8 @@ impl Action for SplitClipInstanceAction { AnyLayer::Group(_) => { // Group layers don't have clip instances, nothing to rollback } - AnyLayer::Raster(_) => { - // Raster layers don't have clip instances, nothing to rollback + AnyLayer::Raster(_) | AnyLayer::Text(_) => { + // Raster/text layers don't have clip instances, nothing to rollback } } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs index 433e6dc..e8d21b9 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/transform_clip_instances.rs @@ -101,6 +101,7 @@ impl Action for TransformClipInstancesAction { AnyLayer::Effect(_) => {} AnyLayer::Group(_) => {} AnyLayer::Raster(_) => {} + AnyLayer::Text(_) => {} } Ok(()) } @@ -140,6 +141,7 @@ impl Action for TransformClipInstancesAction { AnyLayer::Effect(_) => {} AnyLayer::Group(_) => {} AnyLayer::Raster(_) => {} + AnyLayer::Text(_) => {} } Ok(()) } diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs index 9595ba3..60c1bd7 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/trim_clip_instances.rs @@ -101,6 +101,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -138,6 +139,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) { @@ -182,6 +184,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; let instance = clip_instances.iter() @@ -275,6 +278,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; // Apply trims @@ -315,6 +319,7 @@ impl Action for TrimClipInstancesAction { AnyLayer::Effect(el) => &mut el.clip_instances, AnyLayer::Group(_) => continue, AnyLayer::Raster(_) => continue, + AnyLayer::Text(_) => continue, }; // Restore original trim values diff --git a/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs b/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs index 770b544..6300df3 100644 --- a/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs +++ b/lightningbeam-ui/lightningbeam-core/src/beam_archive.rs @@ -62,6 +62,10 @@ pub enum MediaKind { /// the keyframe id). Decoded eagerly on load and shown while the full-res pixels /// page in, so cold scrubs don't flash blank. See `raster_proxy_media_id`. RasterProxy = 6, + /// An embedded font file (TTF/OTF) used by a text layer (keyed by a content hash + /// of the bytes so identical fonts dedupe). Registered into the font collection + /// on load so documents render faithfully on machines lacking the font. + Font = 7, } impl MediaKind { @@ -74,6 +78,7 @@ impl MediaKind { 4 => Some(Self::Waveform), 5 => Some(Self::Thumbnail), 6 => Some(Self::RasterProxy), + 7 => Some(Self::Font), _ => None, } } diff --git a/lightningbeam-ui/lightningbeam-core/src/clip.rs b/lightningbeam-ui/lightningbeam-core/src/clip.rs index f483380..17a306a 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clip.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clip.rs @@ -122,6 +122,7 @@ impl VectorClip { AnyLayer::Effect(el) => &el.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; for ci in clip_instances { // Compute end position of this clip instance in beats @@ -223,6 +224,17 @@ impl VectorClip { Some(existing) => existing.union(transformed_bounds), }); } + } else if let AnyLayer::Text(text_layer) = &layer_node.data { + // Text layers contribute their box bounds (so a text-only clip is + // selectable/draggable, not a degenerate point). + let r = Rect::from_origin_size( + text_layer.box_origin, + (text_layer.box_width, text_layer.box_height), + ); + combined_bounds = Some(match combined_bounds { + None => r, + Some(existing) => existing.union(r), + }); } } diff --git a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs index 4355754..a9ea32b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/clipboard.rs +++ b/lightningbeam-ui/lightningbeam-core/src/clipboard.rs @@ -62,6 +62,7 @@ impl ClipboardLayerType { AnyLayer::Effect(_) => ClipboardLayerType::Effect, AnyLayer::Group(_) => ClipboardLayerType::Vector, AnyLayer::Raster(_) => ClipboardLayerType::Vector, + AnyLayer::Text(_) => ClipboardLayerType::Vector, } } @@ -449,6 +450,13 @@ fn regen_any_layer(layer: &AnyLayer, id_map: &mut HashMap) -> AnyLay nl.layer.id = new_layer_id; AnyLayer::Raster(nl) } + AnyLayer::Text(tl) => { + let new_layer_id = Uuid::new_v4(); + id_map.insert(tl.layer.id, new_layer_id); + let mut nl = tl.clone(); + nl.layer.id = new_layer_id; + AnyLayer::Text(nl) + } AnyLayer::Group(gl) => { let new_layer_id = Uuid::new_v4(); id_map.insert(gl.layer.id, new_layer_id); diff --git a/lightningbeam-ui/lightningbeam-core/src/document.rs b/lightningbeam-ui/lightningbeam-core/src/document.rs index 62eba1c..a27b405 100644 --- a/lightningbeam-ui/lightningbeam-core/src/document.rs +++ b/lightningbeam-ui/lightningbeam-core/src/document.rs @@ -489,8 +489,8 @@ impl Document { } } } - crate::layer::AnyLayer::Raster(_) => { - // Raster layers don't have clip instances + crate::layer::AnyLayer::Raster(_) | crate::layer::AnyLayer::Text(_) => { + // Raster and text layers don't have clip instances } crate::layer::AnyLayer::Group(group) => { // Recurse into group children to find their clip instance endpoints @@ -530,8 +530,8 @@ impl Document { } } } - crate::layer::AnyLayer::Raster(_) => { - // Raster layers don't have clip instances + crate::layer::AnyLayer::Raster(_) | crate::layer::AnyLayer::Text(_) => { + // Raster and text layers don't have clip instances } crate::layer::AnyLayer::Group(g) => { process_group_children(&g.children, doc, max_end, calc_end); @@ -937,6 +937,7 @@ impl Document { AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; let instance = instances.iter().find(|inst| &inst.id == instance_id)?; @@ -977,6 +978,7 @@ impl Document { AnyLayer::Effect(effect) => &effect.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; for instance in instances { @@ -1039,6 +1041,7 @@ impl Document { AnyLayer::Vector(_) => return Some(desired_start), // Shouldn't reach here AnyLayer::Group(_) => return Some(desired_start), // Groups don't have own clips AnyLayer::Raster(_) => return Some(desired_start), // Raster layers don't have own clips + AnyLayer::Text(_) => return Some(desired_start), // Text layers don't have own clips }; let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new(); @@ -1134,6 +1137,7 @@ impl Document { AnyLayer::Vector(v) => &v.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; // Collect non-group clip ranges @@ -1205,6 +1209,7 @@ impl Document { AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; for other in instances { @@ -1253,6 +1258,7 @@ impl Document { AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; let mut nearest_start = f64::MAX; @@ -1300,6 +1306,7 @@ impl Document { AnyLayer::Vector(vector) => &vector.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; let mut nearest_end = 0.0; diff --git a/lightningbeam-ui/lightningbeam-core/src/file_io.rs b/lightningbeam-ui/lightningbeam-core/src/file_io.rs index 4df18b7..98129ee 100644 --- a/lightningbeam-ui/lightningbeam-core/src/file_io.rs +++ b/lightningbeam-ui/lightningbeam-core/src/file_io.rs @@ -242,6 +242,21 @@ fn thumbnail_media_id(clip_id: Uuid) -> Uuid { Uuid::from_u128(clip_id.as_u128() ^ SENTINEL) } +/// Content-hash id for an embedded font row, so identical font files dedupe to one +/// row regardless of which / how many text layers use them. A 128-bit id is built +/// from two salted 64-bit hashes of the bytes. +fn font_media_id(bytes: &[u8]) -> Uuid { + use std::hash::{Hash, Hasher}; + let mut h1 = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h1); + let mut h2 = std::collections::hash_map::DefaultHasher::new(); + 0xF0E1_D2C3_B4A5_9687u64.hash(&mut h2); + bytes.hash(&mut h2); + let hi = h1.finish() as u128; + let lo = h2.finish() as u128; + Uuid::from_u128((hi << 64) | lo) +} + /// Derived id for a raster keyframe's low-res proxy row (distinct from the keyframe's /// own full-res `Raster` row, which is keyed by the raw keyframe id). fn raster_proxy_media_id(kf_id: Uuid) -> Uuid { @@ -535,6 +550,32 @@ pub fn save_beam( } let _ = image_count; + // --- embedded fonts -> media rows (Font), keyed by content hash so identical + // fonts dedupe. Embed every non-bundled family used by a text layer so the + // document renders faithfully on machines lacking the font. The bundled + // three ship with the app and are never embedded. --- + { + use crate::layer::AnyLayer; + let mut families: HashSet = HashSet::new(); + for layer in document.all_layers() { + if let AnyLayer::Text(tl) = layer { + let fam = &tl.content.font_family; + if !fam.is_empty() && !crate::fonts::is_bundled(fam) { + families.insert(fam.clone()); + } + } + } + for fam in families { + if let Some(bytes) = crate::fonts::family_font_bytes(&fam) { + let id = font_media_id(&bytes); + if !txn.media_exists(id)? { + txn.put_media_packed(id, MediaKind::Font, &fam, &bytes, MediaMeta::default())?; + } + live_media.insert(id); + } + } + } + // --- orphan cleanup: drop media for removed clips/keyframes --- let removed = txn.retain_media(&live_media)?; @@ -598,6 +639,17 @@ fn load_beam_sqlite(path: &Path) -> Result { eprintln!("📊 [LOAD_BEAM] Starting load_beam() (SQLite container)..."); let archive = BeamArchive::open(path)?; + + // Register document-embedded fonts into the runtime font collection so text + // layers referencing them render faithfully even if the host lacks the font. + if let Ok(font_ids) = archive.media_ids_of_kind(MediaKind::Font) { + for id in font_ids { + if let Ok(bytes) = archive.read_media_full(id) { + crate::fonts::register_embedded(bytes); + } + } + } + let json = archive.get_project_json()?; let beam_project: BeamProject = serde_json::from_str(&json) .map_err(|e| format!("Failed to deserialize project.json: {}", e))?; diff --git a/lightningbeam-ui/lightningbeam-core/src/fonts.rs b/lightningbeam-ui/lightningbeam-core/src/fonts.rs new file mode 100644 index 0000000..40f2e80 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/fonts.rs @@ -0,0 +1,296 @@ +//! Font registry + text layout for text layers. +//! +//! Wraps a thread-local parley [`FontContext`]/[`LayoutContext`]. Three fonts are +//! bundled (serif, sans-serif, monospaced) so text renders deterministically even +//! offline; system fonts are also enumerated (parley's fontique source) and +//! document-embedded fonts can be registered at load time (see `file_io`). +//! +//! The same `linebender_resource_handle` crate (0.1.1) backs both vello's +//! `peniko::FontData` and parley's `FontData`, so a glyph run's `run.font()` can be +//! handed straight to `vello::Scene::draw_glyphs` with no conversion. + +use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use parley::{ + Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, Layout, LayoutContext, + PositionedLayoutItem, StyleProperty, +}; +use vello::peniko::Blob; + +use crate::text_layer::{TextAlign, TextContent}; + +// ── Bundled fonts (SIL OFL; vendored under assets/fonts) ───────────────────── +static BUNDLED_SANS: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Regular.ttf"); +static BUNDLED_SERIF: &[u8] = include_bytes!("../assets/fonts/LiberationSerif-Regular.ttf"); +static BUNDLED_MONO: &[u8] = include_bytes!("../assets/fonts/LiberationMono-Regular.ttf"); + +/// Parley requires a brush type, but glyph color is applied by vello at draw time, +/// so we use a zero-sized placeholder. +#[derive(Clone, Copy, PartialEq, Default, Debug)] +pub struct NoBrush; + +struct FontStore { + fcx: FontContext, + lcx: LayoutContext, + /// Registered family names of the three bundled fonts (sans, serif, mono). + bundled: Vec, + /// The default family (bundled sans-serif), used when `font_family` is empty. + default_family: String, +} + +impl FontStore { + fn new() -> Self { + let mut fcx = FontContext::new(); + let mut bundled = Vec::new(); + for bytes in [BUNDLED_SANS, BUNDLED_SERIF, BUNDLED_MONO] { + let blob = Blob::new(Arc::new(bytes) as Arc + Send + Sync>); + for (family_id, _) in fcx.collection.register_fonts(blob, None) { + if let Some(name) = fcx.collection.family_name(family_id) { + let name = name.to_string(); + if !bundled.contains(&name) { + bundled.push(name); + } + } + } + } + let default_family = bundled.first().cloned().unwrap_or_else(|| "sans-serif".to_string()); + Self { fcx, lcx: LayoutContext::new(), bundled, default_family } + } +} + +thread_local! { + static STORE: RefCell = RefCell::new(FontStore::new()); +} + +/// The default family name (bundled sans-serif), used when a text layer's +/// `font_family` is empty. +pub fn default_family() -> String { + STORE.with(|s| s.borrow().default_family.clone()) +} + +/// Whether `family` is one of the three bundled families (which therefore must +/// never be embedded into a `.beam`). +pub fn is_bundled(family: &str) -> bool { + STORE.with(|s| s.borrow().bundled.iter().any(|f| f == family)) +} + +/// True if a shorter word-prefix of `name` is itself a family in `set` — i.e. `name` +/// is a variant of a base family (e.g. "Noto Sans Arabic" when "Noto Sans" exists). +/// Used to collapse the many script/region-specific fonts (mostly Noto) into their +/// base family in the picker; parley's fallback still resolves the right script font +/// automatically when rendering non-Latin text. +fn is_variant_of_listed_base(name: &str, set: &std::collections::HashSet<&str>) -> bool { + let words: Vec<&str> = name.split(' ').collect(); + for k in 1..words.len() { + if set.contains(words[..k].join(" ").as_str()) { + return true; + } + } + false +} + +/// Available family names for the info-panel picker: the three bundled families first, +/// then a consolidated, alphabetical list of system families — script/region variants +/// whose base family is present are dropped (monospace variants are kept). +pub fn families() -> Vec { + STORE.with(|s| { + let s = &mut *s.borrow_mut(); + + let mut system: Vec = + s.fcx.collection.family_names().map(|n| n.to_string()).collect(); + system.sort(); + system.dedup(); + let set: std::collections::HashSet<&str> = system.iter().map(|x| x.as_str()).collect(); + + let mut out = s.bundled.clone(); + for name in &system { + if out.iter().any(|b| b == name) { + continue; + } + // Keep base families and monospace variants; drop script/region variants + // whose base family is already in the list. + if name.contains("Mono") || !is_variant_of_listed_base(name, &set) { + out.push(name.clone()); + } + } + out + }) +} + +/// Register a document-embedded font (raw TTF/OTF bytes) into the runtime font +/// collection so text layers referencing its family resolve to it. Returns the +/// registered family names. +pub fn register_embedded(bytes: Vec) -> Vec { + STORE.with(|s| { + let s = &mut *s.borrow_mut(); + let blob = Blob::new(Arc::new(bytes) as Arc + Send + Sync>); + let mut names = Vec::new(); + for (family_id, _) in s.fcx.collection.register_fonts(blob, None) { + if let Some(name) = s.fcx.collection.family_name(family_id) { + names.push(name.to_string()); + } + } + names + }) +} + +/// Whether `family` currently resolves to a registered family (bundled, system, +/// or embedded). Used to flag "missing font" on load. +pub fn family_available(family: &str) -> bool { + if family.is_empty() { + return true; + } + STORE.with(|s| { + let s = &mut *s.borrow_mut(); + s.fcx.collection.family_id(family).is_some() + }) +} + +/// The raw bytes of the font file that `family` resolves to (for embedding into a +/// `.beam`). Returns `None` if the family resolves to no glyphs. Lays out a single +/// glyph and reads the resolved run's font blob (the same `linebender_resource_handle` +/// blob vello uses). +pub fn family_font_bytes(family: &str) -> Option> { + let content = TextContent { + text: "A".to_string(), + font_family: family.to_string(), + ..TextContent::default() + }; + with_layout(&content, 10_000.0, |layout| { + for line in layout.lines() { + for item in line.items() { + if let PositionedLayoutItem::GlyphRun(run) = item { + return Some(run.run().font().data.data().to_vec()); + } + } + } + None + }) +} + +// ── Background font preloading ─────────────────────────────────────────────── +// +// Loading a family's bytes (`family_font_bytes`) is the expensive part (font file IO + +// shaping). Doing it for the whole picker on the UI thread causes hitches, so a +// background thread (started at app launch) loads every picker family's bytes ahead of +// time into a shared map. The UI then only has to *register* them with its renderer +// (cheap), and synchronously loads the few stragglers only if needed before they're ready. + +struct PreloadState { + loaded: HashMap>, + started: bool, + done: bool, +} + +fn preload() -> &'static Mutex { + static P: OnceLock> = OnceLock::new(); + P.get_or_init(|| Mutex::new(PreloadState { + loaded: HashMap::new(), + started: false, + done: false, + })) +} + +/// Start (once) a background thread that loads every picker font's bytes. Idempotent; +/// safe to call every frame or from app startup. +pub fn start_preload() { + { + let mut p = preload().lock().unwrap(); + if p.started { + return; + } + p.started = true; + } + let _ = std::thread::Builder::new() + .name("font-preload".into()) + .spawn(|| { + // This thread gets its own thread-local FontContext (enumerates system fonts + // off the UI thread). Bytes are plain `Vec` (Send) handed back via the map. + for fam in families() { + if let Some(bytes) = family_font_bytes(&fam) { + preload().lock().unwrap().loaded.insert(fam, bytes); + } + } + preload().lock().unwrap().done = true; + }); +} + +/// Remove and return a preloaded font's bytes if the background thread has them ready. +pub fn take_preloaded(family: &str) -> Option> { + preload().lock().unwrap().loaded.remove(family) +} + +/// Whether the background preloader has finished loading every family. +pub fn preload_done() -> bool { + preload().lock().unwrap().done +} + +/// Caret rectangle (x0, y0, x1, y1, in layout space relative to the box origin) for +/// `byte_index` into `content.text`, wrapped to `max_width`. +pub fn caret_geometry(content: &TextContent, max_width: f32, byte_index: usize) -> Option<(f64, f64, f64, f64)> { + let caret_w = (content.font_size as f32 * 0.06).max(1.0); + with_layout(content, max_width, |layout| { + let cur = parley::Cursor::from_byte_index(layout, byte_index, parley::Affinity::Downstream); + let bb = cur.geometry(layout, caret_w); + Some((bb.x0, bb.y0, bb.x1, bb.y1)) + }) +} + +/// Selection highlight rectangles (each x0, y0, x1, y1 in layout space) for the byte +/// range `[start, end)` into `content.text`, wrapped to `max_width`. +pub fn selection_geometry(content: &TextContent, max_width: f32, start: usize, end: usize) -> Vec<(f64, f64, f64, f64)> { + if start == end { + return Vec::new(); + } + with_layout(content, max_width, |layout| { + let anchor = parley::Cursor::from_byte_index(layout, start, parley::Affinity::Downstream); + let focus = parley::Cursor::from_byte_index(layout, end, parley::Affinity::Downstream); + let sel = parley::Selection::new(anchor, focus); + sel.geometry(layout) + .into_iter() + .map(|(bb, _)| (bb.x0, bb.y0, bb.x1, bb.y1)) + .collect() + }) +} + +fn alignment_of(a: TextAlign) -> Alignment { + match a { + TextAlign::Left => Alignment::Left, + TextAlign::Center => Alignment::Center, + TextAlign::Right => Alignment::Right, + TextAlign::Justify => Alignment::Justify, + } +} + +/// Build a parley layout for `content` wrapped to `max_width` (document units), +/// then invoke `f` with it. The layout is rebuilt on each call (v1: no cache). +pub fn with_layout( + content: &TextContent, + max_width: f32, + f: impl FnOnce(&Layout) -> R, +) -> R { + STORE.with(|s| { + let s = &mut *s.borrow_mut(); + let default_family = s.default_family.clone(); + let FontStore { fcx, lcx, .. } = s; + + let mut builder = lcx.ranged_builder(fcx, &content.text, 1.0, true); + builder.push_default(StyleProperty::FontSize(content.font_size as f32)); + + let family_name = if content.font_family.is_empty() { + default_family + } else { + content.font_family.clone() + }; + let family = FontFamily::Single(FontFamilyName::Named(Cow::Owned(family_name))); + builder.push_default(StyleProperty::FontFamily(family)); + + let mut layout = builder.build(&content.text); + layout.break_all_lines(Some(max_width)); + layout.align(alignment_of(content.align), AlignmentOptions::default()); + f(&layout) + }) +} diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index ef845c8..005e999 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -9,6 +9,7 @@ use crate::vector_graph::VectorGraph; use crate::effect_layer::EffectLayer; use crate::object::ShapeInstance; use crate::raster_layer::RasterLayer; +use crate::text_layer::TextLayer; use crate::shape::Shape; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -31,6 +32,8 @@ pub enum LayerType { Group, /// Raster pixel-buffer painting layer Raster, + /// Text layer (a single editable text box) + Text, } /// Common trait for all layer types @@ -866,6 +869,7 @@ impl GroupLayer { AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Group(_) => &[], // no nested groups AnyLayer::Raster(_) => &[], // raster layers have no clip instances + AnyLayer::Text(_) => &[], // raster layers have no clip instances }; for ci in instances { result.push((child_id, ci)); @@ -884,6 +888,7 @@ pub enum AnyLayer { Effect(EffectLayer), Group(GroupLayer), Raster(RasterLayer), + Text(TextLayer), } impl LayerTrait for AnyLayer { @@ -895,6 +900,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.id(), AnyLayer::Group(l) => l.id(), AnyLayer::Raster(l) => l.id(), + AnyLayer::Text(l) => l.id(), } } @@ -906,6 +912,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.name(), AnyLayer::Group(l) => l.name(), AnyLayer::Raster(l) => l.name(), + AnyLayer::Text(l) => l.name(), } } @@ -917,6 +924,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_name(name), AnyLayer::Group(l) => l.set_name(name), AnyLayer::Raster(l) => l.set_name(name), + AnyLayer::Text(l) => l.set_name(name), } } @@ -928,6 +936,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.has_custom_name(), AnyLayer::Group(l) => l.has_custom_name(), AnyLayer::Raster(l) => l.has_custom_name(), + AnyLayer::Text(l) => l.has_custom_name(), } } @@ -939,6 +948,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_has_custom_name(custom), AnyLayer::Group(l) => l.set_has_custom_name(custom), AnyLayer::Raster(l) => l.set_has_custom_name(custom), + AnyLayer::Text(l) => l.set_has_custom_name(custom), } } @@ -950,6 +960,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.visible(), AnyLayer::Group(l) => l.visible(), AnyLayer::Raster(l) => l.visible(), + AnyLayer::Text(l) => l.visible(), } } @@ -961,6 +972,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_visible(visible), AnyLayer::Group(l) => l.set_visible(visible), AnyLayer::Raster(l) => l.set_visible(visible), + AnyLayer::Text(l) => l.set_visible(visible), } } @@ -972,6 +984,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.opacity(), AnyLayer::Group(l) => l.opacity(), AnyLayer::Raster(l) => l.opacity(), + AnyLayer::Text(l) => l.opacity(), } } @@ -983,6 +996,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_opacity(opacity), AnyLayer::Group(l) => l.set_opacity(opacity), AnyLayer::Raster(l) => l.set_opacity(opacity), + AnyLayer::Text(l) => l.set_opacity(opacity), } } @@ -994,6 +1008,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.volume(), AnyLayer::Group(l) => l.volume(), AnyLayer::Raster(l) => l.volume(), + AnyLayer::Text(l) => l.volume(), } } @@ -1005,6 +1020,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_volume(volume), AnyLayer::Group(l) => l.set_volume(volume), AnyLayer::Raster(l) => l.set_volume(volume), + AnyLayer::Text(l) => l.set_volume(volume), } } @@ -1016,6 +1032,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.muted(), AnyLayer::Group(l) => l.muted(), AnyLayer::Raster(l) => l.muted(), + AnyLayer::Text(l) => l.muted(), } } @@ -1027,6 +1044,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_muted(muted), AnyLayer::Group(l) => l.set_muted(muted), AnyLayer::Raster(l) => l.set_muted(muted), + AnyLayer::Text(l) => l.set_muted(muted), } } @@ -1038,6 +1056,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.soloed(), AnyLayer::Group(l) => l.soloed(), AnyLayer::Raster(l) => l.soloed(), + AnyLayer::Text(l) => l.soloed(), } } @@ -1049,6 +1068,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_soloed(soloed), AnyLayer::Group(l) => l.set_soloed(soloed), AnyLayer::Raster(l) => l.set_soloed(soloed), + AnyLayer::Text(l) => l.set_soloed(soloed), } } @@ -1060,6 +1080,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.locked(), AnyLayer::Group(l) => l.locked(), AnyLayer::Raster(l) => l.locked(), + AnyLayer::Text(l) => l.locked(), } } @@ -1071,6 +1092,7 @@ impl LayerTrait for AnyLayer { AnyLayer::Effect(l) => l.set_locked(locked), AnyLayer::Group(l) => l.set_locked(locked), AnyLayer::Raster(l) => l.set_locked(locked), + AnyLayer::Text(l) => l.set_locked(locked), } } } @@ -1085,6 +1107,7 @@ impl AnyLayer { AnyLayer::Effect(l) => &l.layer, AnyLayer::Group(l) => &l.layer, AnyLayer::Raster(l) => &l.layer, + AnyLayer::Text(l) => &l.layer, } } @@ -1097,6 +1120,7 @@ impl AnyLayer { AnyLayer::Effect(l) => &mut l.layer, AnyLayer::Group(l) => &mut l.layer, AnyLayer::Raster(l) => &mut l.layer, + AnyLayer::Text(l) => &mut l.layer, } } diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index db5a9d1..eadda98 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -54,6 +54,8 @@ pub mod svg_export; pub mod snap; pub mod webcam; pub mod raster_layer; +pub mod text_layer; +pub mod fonts; pub mod raster_store; pub mod brush_settings; pub mod brush_engine; diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 63ffcb3..01b098d 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -698,6 +698,11 @@ pub fn render_layer_isolated( }; } } + AnyLayer::Text(text_layer) => { + // Text composites as vector geometry (glyphs in the Vello scene). + rendered.has_content = + render_text_layer_to_scene(text_layer, time, &mut rendered.scene, base_transform); + } } rendered @@ -757,6 +762,57 @@ fn render_raster_layer_to_scene( scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect); } +/// Render a text layer's glyphs into a Vello scene. +/// +/// Text is laid out with parley (wrapped to the box width) and drawn via +/// `Scene::draw_glyphs`. The box origin offsets the whole layout; `base_transform` +/// carries the layer/clip-instance transform and camera. Returns whether anything +/// was drawn. +fn render_text_layer_to_scene( + layer: &crate::text_layer::TextLayer, + time: f64, + scene: &mut Scene, + base_transform: Affine, +) -> bool { + let content = layer.content_at(time); + if content.text.is_empty() { + return false; + } + let color = vello::peniko::Color::new(content.color); + let origin = Affine::translate((layer.box_origin.x, layer.box_origin.y)); + let mut drew = false; + crate::fonts::with_layout(content, layer.box_width as f32, |layout| { + for line in layout.lines() { + for item in line.items() { + let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue }; + let run = glyph_run.run(); + let font = run.font(); + let font_size = run.font_size(); + let synthesis = run.synthesis(); + let glyph_xform = synthesis + .skew() + .map(|angle| Affine::skew((angle as f64).to_radians().tan(), 0.0)); + drew = true; + scene + .draw_glyphs(font) + .font_size(font_size) + .brush(color) + .transform(base_transform * origin) + .glyph_transform(glyph_xform) + .draw( + Fill::NonZero, + glyph_run.positioned_glyphs().map(|g| vello::Glyph { + id: g.id as u32, + x: g.x, + y: g.y, + }), + ); + } + } + }); + drew +} + // ============================================================================ // Legacy Single-Scene Rendering (kept for backwards compatibility) // ============================================================================ @@ -884,6 +940,11 @@ fn render_layer( if let Some(ex) = extract.as_deref_mut() { ex.drew_other = true; } render_raster_layer_to_scene(raster_layer, time, scene, base_transform); } + AnyLayer::Text(text_layer) => { + // Text is non-video content — force the Vello fallback if extracting. + if let Some(ex) = extract.as_deref_mut() { ex.drew_other = true; } + render_text_layer_to_scene(text_layer, time, scene, base_transform); + } } } @@ -1900,7 +1961,9 @@ fn render_vector_content_cpu( render_vector_content_cpu(document, time, child, pixmap, base_transform, parent_opacity, image_cache); } } - AnyLayer::Audio(_) | AnyLayer::Video(_) | AnyLayer::Effect(_) | AnyLayer::Raster(_) => {} + // Text is not rendered in the tiny-skia CPU fallback (GPU path only) for v1. + AnyLayer::Audio(_) | AnyLayer::Video(_) | AnyLayer::Effect(_) | AnyLayer::Raster(_) + | AnyLayer::Text(_) => {} } } diff --git a/lightningbeam-ui/lightningbeam-core/src/text_layer.rs b/lightningbeam-ui/lightningbeam-core/src/text_layer.rs new file mode 100644 index 0000000..96387b6 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/text_layer.rs @@ -0,0 +1,145 @@ +//! Text layer for Lightningbeam +//! +//! A text layer holds a single editable text field inside a resizable box, +//! with editable size, color, and font. Text is rendered as vector glyphs +//! (via parley + Vello) so it composites through the same path as vector art. +//! +//! The text/style fields are grouped in [`TextContent`] so they can move to a +//! per-keyframe model later without touching call sites; v1 stores a single +//! static instance and reads it through [`TextLayer::content_at`]. + +use crate::layer::{Layer, LayerTrait, LayerType}; +use kurbo::Point; +use serde::{Deserialize, Serialize}; +use std::hash::{Hash, Hasher}; +use uuid::Uuid; + +/// Horizontal alignment of text within the box. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TextAlign { + Left, + Center, + Right, + Justify, +} + +impl Default for TextAlign { + fn default() -> Self { + TextAlign::Left + } +} + +/// The text content + styling for a text layer. +/// +/// Grouped as its own struct so a future keyframed model can store +/// `Vec` of these without changing the layer's public shape. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextContent { + /// The text string. + pub text: String, + /// Font size in pixels (document space). + pub font_size: f64, + /// Fill color, linear RGBA in 0..=1. + pub color: [f32; 4], + /// Logical font family name. Empty string = the bundled default font. + pub font_family: String, + /// Horizontal alignment within the box. + pub align: TextAlign, +} + +impl Default for TextContent { + fn default() -> Self { + Self { + text: String::new(), + font_size: 48.0, + color: [1.0, 1.0, 1.0, 1.0], + font_family: String::new(), + align: TextAlign::Left, + } + } +} + +impl TextContent { + /// A stable hash of everything that affects shaped layout (everything except + /// `color`, which only affects the brush, not glyph positions). Used to key + /// the renderer's external parley-layout cache, including the wrap width. + pub fn layout_hash(&self, box_width: f64) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.text.hash(&mut h); + self.font_size.to_bits().hash(&mut h); + self.font_family.hash(&mut h); + self.align.hash(&mut h); + box_width.to_bits().hash(&mut h); + h.finish() + } +} + +impl Hash for TextAlign { + fn hash(&self, state: &mut H) { + (*self as u8).hash(state); + } +} + +/// A text layer: a single resizable text box. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TextLayer { + /// Base layer properties (id, name, opacity, visibility, animation data, …). + pub layer: Layer, + /// Top-left of the text box, in layer/clip-local space. + pub box_origin: Point, + /// Box width in document units (drives text wrapping). + pub box_width: f64, + /// Box height in document units. + pub box_height: f64, + /// The text content + styling (single static instance for v1). + pub content: TextContent, +} + +impl TextLayer { + /// Default text-box dimensions for a freshly created layer. + pub const DEFAULT_WIDTH: f64 = 300.0; + pub const DEFAULT_HEIGHT: f64 = 100.0; + + /// Create a new, empty text layer with the given name, positioned at `origin`. + pub fn new(name: impl Into, origin: Point) -> Self { + Self { + layer: Layer::new(LayerType::Text, name), + box_origin: origin, + box_width: Self::DEFAULT_WIDTH, + box_height: Self::DEFAULT_HEIGHT, + content: TextContent::default(), + } + } + + /// Read the active text content at `time`. v1 is static, so `time` is unused; + /// a future keyframed model will select the active keyframe here. + pub fn content_at(&self, _time: f64) -> &TextContent { + &self.content + } + + /// Mutable access to the active text content at `time`. + pub fn content_at_mut(&mut self, _time: f64) -> &mut TextContent { + &mut self.content + } +} + +// Delegate all LayerTrait methods to self.layer (mirrors RasterLayer). +impl LayerTrait for TextLayer { + fn id(&self) -> Uuid { self.layer.id } + fn name(&self) -> &str { &self.layer.name } + fn set_name(&mut self, name: String) { self.layer.name = name; } + fn has_custom_name(&self) -> bool { self.layer.has_custom_name } + fn set_has_custom_name(&mut self, custom: bool) { self.layer.has_custom_name = custom; } + fn visible(&self) -> bool { self.layer.visible } + fn set_visible(&mut self, visible: bool) { self.layer.visible = visible; } + fn opacity(&self) -> f64 { self.layer.opacity } + fn set_opacity(&mut self, opacity: f64) { self.layer.opacity = opacity; } + fn volume(&self) -> f64 { self.layer.volume } + fn set_volume(&mut self, volume: f64) { self.layer.volume = volume; } + fn muted(&self) -> bool { self.layer.muted } + fn set_muted(&mut self, muted: bool) { self.layer.muted = muted; } + fn soloed(&self) -> bool { self.layer.soloed } + fn set_soloed(&mut self, soloed: bool) { self.layer.soloed = soloed; } + fn locked(&self) -> bool { self.layer.locked } + fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index 3ec96b7..88d9c47 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -381,7 +381,9 @@ impl Tool { use crate::layer::LayerType; match layer_type { None | Some(LayerType::Vector) => Tool::all(), - Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split], + // The Text tool is available on audio/video/raster too: clicking the + // stage with it there creates a new top-level text layer. + Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split, Tool::Text], Some(LayerType::Raster) => &[ // Brush tools Tool::Draw, Tool::Pencil, Tool::Pen, Tool::Airbrush, @@ -397,9 +399,10 @@ impl Tool { // Transform Tool::Transform, Tool::Warp, Tool::Liquify, // Utility - Tool::Eyedropper, + Tool::Eyedropper, Tool::Text, ], - _ => &[Tool::Select], + Some(LayerType::Text) => &[Tool::Select, Tool::Text, Tool::Transform], + _ => &[Tool::Select, Tool::Text], } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 435f7cf..eb45720 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -2130,7 +2130,7 @@ impl EditorApp { AnyLayer::Audio(al) => find_splittable_clips(&al.clip_instances, split_time, document), AnyLayer::Video(vl) => find_splittable_clips(&vl.clip_instances, split_time, document), AnyLayer::Effect(el) => find_splittable_clips(&el.clip_instances, split_time, document), - AnyLayer::Group(_) | AnyLayer::Raster(_) => vec![], + AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => vec![], }; for instance_id in active_layer_clips { @@ -2148,7 +2148,7 @@ impl EditorApp { AnyLayer::Audio(al) => find_splittable_clips(&al.clip_instances, split_time, document), AnyLayer::Video(vl) => find_splittable_clips(&vl.clip_instances, split_time, document), AnyLayer::Effect(el) => find_splittable_clips(&el.clip_instances, split_time, document), - AnyLayer::Group(_) | AnyLayer::Raster(_) => vec![], + AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => vec![], }; if member_splittable.contains(member_instance_id) { clips_to_split.push((*member_layer_id, *member_instance_id)); @@ -2499,7 +2499,7 @@ impl EditorApp { AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, - AnyLayer::Group(_) | AnyLayer::Raster(_) => &[], + AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => &[], }; let instances: Vec<_> = clip_slice .iter() @@ -2911,7 +2911,7 @@ impl EditorApp { AnyLayer::Audio(al) => &al.clip_instances, AnyLayer::Video(vl) => &vl.clip_instances, AnyLayer::Effect(el) => &el.clip_instances, - AnyLayer::Group(_) | AnyLayer::Raster(_) => &[], + AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => &[], }; instances.iter() .filter(|ci| selection.contains_clip_instance(&ci.id)) @@ -3303,7 +3303,7 @@ impl EditorApp { AnyLayer::Video(_) => hint.has_video = true, AnyLayer::Audio(_) => hint.has_audio = true, AnyLayer::Raster(_) => hint.has_raster = true, - AnyLayer::Vector(_) | AnyLayer::Effect(_) => hint.has_vector = true, + AnyLayer::Vector(_) | AnyLayer::Effect(_) | AnyLayer::Text(_) => hint.has_vector = true, AnyLayer::Group(g) => scan(&g.children, hint), } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs index 0132c8f..cc39985 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/asset_library.rs @@ -1292,6 +1292,9 @@ impl AssetLibraryPane { lightningbeam_core::layer::AnyLayer::Raster(_) => { // Raster layers don't have their own clip instances } + lightningbeam_core::layer::AnyLayer::Text(_) => { + // Text layers don't have their own clip instances + } } } false diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 0cb524a..0391dcd 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -46,10 +46,16 @@ pub struct InfopanelPane { fps_drag_start: Option, /// Resize mode for the active raster layer's "to document size" action (scale vs canvas). raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode, + /// Font families already registered into egui for the family-picker previews + /// (rendered each entry in its own font), so we only load each once. + registered_preview_fonts: std::collections::HashSet, } impl InfopanelPane { pub fn new() -> Self { + // Kick off background loading of the font-picker fonts at startup so the dropdown + // is ready without a hitch by the time the user opens it. + lightningbeam_core::fonts::start_preload(); let presets = bundled_brushes(); let default_eraser_idx = presets.iter().position(|p| p.name == "Brush"); Self { @@ -64,8 +70,26 @@ impl InfopanelPane { selected_tool_gradient_stop: None, fps_drag_start: None, raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode::Scale, + registered_preview_fonts: std::collections::HashSet::new(), } } + + /// Register pre-loaded `bytes` for `family` into egui under `FontFamily::Name(family)` + /// so a label can be rendered in that font. Cheap (no font IO); the add is idempotent + /// and batched into one atlas rebuild at frame end, so the preview appears next frame. + fn register_font_bytes(&mut self, ctx: &egui::Context, family: &str, bytes: Vec) { + if family.is_empty() || !self.registered_preview_fonts.insert(family.to_string()) { + return; + } + ctx.add_font(egui::epaint::text::FontInsert::new( + family, + egui::FontData::from_owned(bytes), + vec![egui::epaint::text::InsertFontFamily { + family: egui::FontFamily::Name(family.into()), + priority: egui::epaint::text::FontPriority::Highest, + }], + )); + } } /// Aggregated info about the current DCEL selection @@ -1167,6 +1191,7 @@ impl InfopanelPane { AnyLayer::Effect(_) => "Effect", AnyLayer::Group(_) => "Group", AnyLayer::Raster(_) => "Raster", + AnyLayer::Text(_) => "Text", }; ui.horizontal(|ui| { ui.label("Type:"); @@ -1247,6 +1272,186 @@ impl InfopanelPane { }); } + /// Render a text-layer section: edit the text content, font, size, color, + /// alignment, and box size of the active text layer. Driven by the *active* + /// layer so it appears whenever a text layer is selected. + fn render_text_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_id: Uuid) { + use lightningbeam_core::text_layer::TextAlign; + use lightningbeam_core::actions::{ResizeTextBoxAction, SetTextContentAction}; + + // Snapshot current values, then drop the document borrow before mutating shared. + let snapshot = { + let document = shared.action_executor.document(); + match document.get_layer(&layer_id) { + Some(AnyLayer::Text(tl)) => { + Some((tl.content.clone(), tl.box_origin, tl.box_width, tl.box_height)) + } + _ => None, + } + }; + let Some((content0, origin, w0, h0)) = snapshot else { return }; + + // Families egui has actually bound (added fonts take effect next frame). Using an + // unbound `FontFamily::Name` panics, so only preview-render families in this set. + let bound_families: std::collections::HashSet = + ui.ctx().fonts(|f| f.families().into_iter().collect()); + // Set when the font dropdown popup is open this frame (its closure only runs then), + // so we can synchronously finish loading any not-yet-preloaded fonts the user sees. + let mut dropdown_open = false; + + egui::CollapsingHeader::new("Text") + .id_salt(("text_layer", path)) + .default_open(true) + .show(ui, |ui| { + ui.add_space(4.0); + let mut content = content0.clone(); + let mut content_changed = false; + + ui.label("Text:"); + if ui + .add(egui::TextEdit::multiline(&mut content.text).desired_rows(2).desired_width(f32::INFINITY)) + .changed() + { + content_changed = true; + } + + ui.horizontal(|ui| { + ui.label("Size:"); + if ui + .add(egui::DragValue::new(&mut content.font_size).range(1.0..=2000.0).speed(0.5)) + .changed() + { + content_changed = true; + } + }); + + ui.horizontal(|ui| { + ui.label("Color:"); + // content.color is sRGB-encoded RGBA in 0..1 (matches peniko Color::new). + let mut col = egui::Color32::from_rgba_unmultiplied( + (content.color[0] * 255.0).round() as u8, + (content.color[1] * 255.0).round() as u8, + (content.color[2] * 255.0).round() as u8, + (content.color[3] * 255.0).round() as u8, + ); + if ui.color_edit_button_srgba(&mut col).changed() { + content.color = [ + col.r() as f32 / 255.0, + col.g() as f32 / 255.0, + col.b() as f32 / 255.0, + col.a() as f32 / 255.0, + ]; + content_changed = true; + } + }); + + ui.horizontal(|ui| { + ui.label("Align:"); + for (label, a) in [("L", TextAlign::Left), ("C", TextAlign::Center), ("R", TextAlign::Right), ("J", TextAlign::Justify)] { + if ui.selectable_label(content.align == a, label).clicked() { + content.align = a; + content_changed = true; + } + } + }); + + // Font family picker (bundled families first, then system; embedded fonts + // register under their own names on load). + ui.horizontal(|ui| { + ui.label("Font:"); + let current_label = if content.font_family.is_empty() { + "(Default)".to_string() + } else { + content.font_family.clone() + }; + egui::ComboBox::from_id_salt(("text_font", path)) + .selected_text(current_label) + .show_ui(ui, |ui| { + dropdown_open = true; + if ui.selectable_label(content.font_family.is_empty(), "(Default)").clicked() { + content.font_family.clear(); + content_changed = true; + } + // The popup closure only runs while the dropdown is open, so each + // entry is rendered in its own font and queued for registration. + for fam in lightningbeam_core::fonts::families() { + let named = egui::FontFamily::Name(fam.as_str().into()); + // Render in its own font only once egui has bound it; otherwise + // plain (and queue it for registration so it previews next frame). + let label = if bound_families.contains(&named) { + egui::RichText::new(&fam).family(named) + } else { + egui::RichText::new(&fam) + }; + if ui.selectable_label(content.font_family == fam, label).clicked() { + content.font_family = fam.clone(); + content_changed = true; + } + } + }); + // Missing-font indicator. + if !content.font_family.is_empty() + && !lightningbeam_core::fonts::family_available(&content.font_family) + { + ui.colored_label(egui::Color32::from_rgb(230, 160, 60), "⚠ missing"); + } + }); + + // Box size. + let mut bw = w0; + let mut bh = h0; + let mut box_changed = false; + ui.horizontal(|ui| { + ui.label("Box:"); + if ui.add(egui::DragValue::new(&mut bw).range(1.0..=100000.0).speed(1.0)).changed() { + box_changed = true; + } + ui.label("×"); + if ui.add(egui::DragValue::new(&mut bh).range(1.0..=100000.0).speed(1.0)).changed() { + box_changed = true; + } + }); + + ui.add_space(4.0); + + if content_changed { + shared.pending_actions.push(Box::new(SetTextContentAction::new(layer_id, content))); + } + if box_changed { + shared.pending_actions.push(Box::new(ResizeTextBoxAction::new(layer_id, origin, bw, bh))); + } + }); + + // Register fonts the background preloader has finished (cheap: bytes already loaded), + // a few per frame so each atlas rebuild stays small. Only fall back to synchronous + // loading for families the user is actively viewing in the open dropdown that the + // background hasn't reached yet. Repaint while there's more to do or the preloader + // is still running, so registration keeps flowing without input. + let ctx = ui.ctx().clone(); + const PER_FRAME: usize = 4; + let mut budget = PER_FRAME; + let mut more = false; + for fam in lightningbeam_core::fonts::families() { + if self.registered_preview_fonts.contains(&fam) { + continue; + } + let bytes = lightningbeam_core::fonts::take_preloaded(&fam).or_else(|| { + if dropdown_open { lightningbeam_core::fonts::family_font_bytes(&fam) } else { None } + }); + if let Some(bytes) = bytes { + if budget == 0 { + more = true; + break; + } + self.register_font_bytes(&ctx, &fam, bytes); + budget -= 1; + } + } + if more || !lightningbeam_core::fonts::preload_done() { + ctx.request_repaint(); + } + } + /// Render clip instance info section fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) { let document = shared.action_executor.document(); @@ -1270,6 +1475,7 @@ impl InfopanelPane { AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], }; if let Some(ci) = instances.iter().find(|c| c.id == ci_id) { found = true; @@ -1601,6 +1807,7 @@ impl PaneRenderer for InfopanelPane { // active (independent of selection focus, since painting doesn't focus the layer). if let Some(active_id) = *shared.active_layer_id { self.render_raster_layer_section(ui, path, shared, active_id); + self.render_text_layer_section(ui, path, shared, active_id); } // Onion-skinning view settings — always available, regardless of selection. diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 92d85be..a34094f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -575,6 +575,9 @@ struct VelloRenderContext { is_playing: bool, /// Active layer for tool operations active_layer_id: Option, + /// Text layer being edited in place, with the caret + selection as byte offsets + /// into its text (start, end). Drives the Vello-drawn caret/selection. + text_edit: Option<(uuid::Uuid, usize, usize)>, /// Delta for drag preview (world space) drag_delta: Option, /// Current selection state @@ -2109,6 +2112,74 @@ impl egui_wgpu::CallbackTrait for VelloCallback { } } + // Active text-layer border: the same black/yellow marching-ants outline as + // raster, around the text box bounds (so its size is visible while selected). + if let Some(active_id) = self.ctx.active_layer_id { + if let Some(lightningbeam_core::layer::AnyLayer::Text(tl)) = self.ctx.document.get_layer(&active_id) { + let rect = vello::kurbo::Rect::from_origin_size( + tl.box_origin, + (tl.box_width, tl.box_height), + ); + let inv_zoom = 1.0 / (self.ctx.zoom as f64).max(1e-3); + let stroke_w = 1.5 * inv_zoom; + let dash = 6.0 * inv_zoom; + let pattern = [dash, dash]; + scene.stroke( + &vello::kurbo::Stroke::new(stroke_w).with_dashes(0.0, pattern), + overlay_transform, + vello::peniko::Color::new([0.0, 0.0, 0.0, 1.0]), + None, + &rect, + ); + scene.stroke( + &vello::kurbo::Stroke::new(stroke_w).with_dashes(dash, pattern), + overlay_transform, + vello::peniko::Color::new([1.0, 0.85, 0.0, 1.0]), + None, + &rect, + ); + + // 8 resize handles (corners + edge midpoints), constant screen size. + let (ox, oy, bw, bh) = (tl.box_origin.x, tl.box_origin.y, tl.box_width, tl.box_height); + let hs = 4.0 * inv_zoom; + let handle_pts = [ + (ox, oy), (ox + bw, oy), (ox + bw, oy + bh), (ox, oy + bh), + (ox + bw * 0.5, oy), (ox + bw, oy + bh * 0.5), + (ox + bw * 0.5, oy + bh), (ox, oy + bh * 0.5), + ]; + for (hx, hy) in handle_pts { + let hr = vello::kurbo::Rect::new(hx - hs, hy - hs, hx + hs, hy + hs); + scene.fill(vello::peniko::Fill::NonZero, overlay_transform, + vello::peniko::Color::new([1.0, 1.0, 1.0, 1.0]), None, &hr); + scene.stroke(&vello::kurbo::Stroke::new(stroke_w), overlay_transform, + vello::peniko::Color::new([0.0, 0.0, 0.0, 1.0]), None, &hr); + } + } + } + + // In-place text editing: Vello-drawn selection highlight + caret for the layer + // being edited (egui drives the input; we render the caret ourselves so it tracks + // any clip-instance transform). Byte offsets come from the egui field's cursor. + if let Some((edit_id, sel_start, sel_end)) = self.ctx.text_edit { + if let Some(lightningbeam_core::layer::AnyLayer::Text(tl)) = self.ctx.document.get_layer(&edit_id) { + use vello::peniko::{Color, Fill}; + let box_xf = overlay_transform * vello::kurbo::Affine::translate((tl.box_origin.x, tl.box_origin.y)); + let content = tl.content_at(self.ctx.playback_time); + let max_w = tl.box_width as f32; + // Selection highlight (behind would be ideal; semi-transparent so glyphs show through). + let (lo, hi) = (sel_start.min(sel_end), sel_start.max(sel_end)); + for (x0, y0, x1, y1) in lightningbeam_core::fonts::selection_geometry(content, max_w, lo, hi) { + let r = vello::kurbo::Rect::new(x0, y0, x1, y1); + scene.fill(Fill::NonZero, box_xf, Color::new([0.30, 0.55, 1.0, 0.35]), None, &r); + } + // Caret at the primary cursor (sel_end). + if let Some((x0, y0, x1, y1)) = lightningbeam_core::fonts::caret_geometry(content, max_w, sel_end) { + let r = vello::kurbo::Rect::new(x0, y0, x1.max(x0 + 1.0), y1); + scene.fill(Fill::NonZero, box_xf, Color::new([0.05, 0.05, 0.05, 1.0]), None, &r); + } + } + } + // Render drag preview objects with transparency if let (Some(delta), Some(active_layer_id)) = (self.ctx.drag_delta, self.ctx.active_layer_id) { if let Some(layer) = self.ctx.document.get_layer(&active_layer_id) { @@ -3267,6 +3338,28 @@ pub struct StagePane { /// Synthetic drag/click override for test mode replay (debug builds only) #[cfg(debug_assertions)] replay_override: Option, + + // --- Text layer in-place editing --- + /// The text layer currently being edited in place (None = not editing). + text_edit_layer: Option, + /// Working buffer mirrored to/from the hidden egui TextEdit while editing. + text_edit_buffer: String, + /// Set the frame editing begins so the egui field grabs focus exactly once. + text_edit_request_focus: bool, + /// Caret + selection as BYTE offsets into the text (anchor, focus), for the Vello caret. + text_edit_cursor: Option<(usize, usize)>, + /// The text content captured when editing began, for a single clean undo step. + text_edit_original: Option, + /// True when the current edit is of a freshly-created layer whose creation is still the + /// top undo entry — so committing it empty can remove it by rolling that creation back. + /// Cleared if another action (e.g. a resize) is pushed during the edit. + text_edit_is_new: bool, + /// If the current edit created a new clip (vector branch), its id — so an empty commit + /// also exits that clip after removing it. + text_edit_created_clip: Option, + /// Active drag of a text box resize handle: + /// (layer_id, handle_index, start_origin_x, start_origin_y, start_w, start_h). + text_box_drag: Option<(uuid::Uuid, usize, f64, f64, f64, f64)>, } /// Synthetic drag/click state injected during test mode replay @@ -3620,6 +3713,27 @@ fn selection_stipple_brush() -> &'static vello::peniko::ImageBrush { }) } +/// Convert a character index (egui caret) to a byte offset (parley/`str`). +fn char_to_byte(s: &str, char_idx: usize) -> usize { + s.char_indices().nth(char_idx).map(|(b, _)| b).unwrap_or(s.len()) +} + +/// Diagonal resize cursor for an axis-aligned box corner (0=TL, 1=TR, 2=BR, 3=BL). +fn corner_resize_cursor(corner: usize) -> egui::CursorIcon { + match corner % 4 { + 0 | 2 => egui::CursorIcon::ResizeNwSe, // TL / BR + _ => egui::CursorIcon::ResizeNeSw, // TR / BL + } +} + +/// Axis resize cursor for an axis-aligned box edge (0=Top, 1=Right, 2=Bottom, 3=Left). +fn edge_resize_cursor(edge: usize) -> egui::CursorIcon { + match edge % 4 { + 0 | 2 => egui::CursorIcon::ResizeVertical, // Top / Bottom + _ => egui::CursorIcon::ResizeHorizontal, // Right / Left + } +} + impl StagePane { pub fn new() -> Self { let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -3661,6 +3775,14 @@ impl StagePane { pending_tool_readback_b: None, #[cfg(debug_assertions)] replay_override: None, + text_edit_layer: None, + text_edit_buffer: String::new(), + text_edit_request_focus: false, + text_edit_cursor: None, + text_edit_original: None, + text_edit_is_new: false, + text_edit_created_clip: None, + text_box_drag: None, } } @@ -4836,6 +4958,364 @@ impl StagePane { } } + /// Text tool: click the stage to create a text layer. + /// - Active layer is Vector → create a clip (VectorClip + instance) containing a + /// text layer, then enter that clip so the text is directly editable. + /// - Otherwise (raster/video/none) → create a new top-level text layer. + fn handle_text_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::text_layer::{TextLayer, TextContent}; + use lightningbeam_core::actions::{AddLayerAction, CreateTextClipAction}; + use vello::kurbo::Point; + + // Allow dragging the active text layer's resize handles with the Text tool too + // (runs on drag events, so it must precede the click-only guard below). + if self.handle_text_box_resize(ui, response, world_pos, shared) { + return; + } + // Only act on a click (ignore drags / hovers). + if !self.rsp_clicked(response) { + return; + } + + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + + // Click on an existing text box → edit it (don't spawn a duplicate). + if let Some(hit_id) = self.text_layer_at(point, shared) { + if self.text_edit_layer != Some(hit_id) { + if let Some(cur) = self.text_edit_layer { + self.commit_text_edit(shared, cur); + } + *shared.active_layer_id = Some(hit_id); + self.begin_text_edit(hit_id); + } + return; + } + + // Empty space → commit any in-progress edit, then create a new text layer. + if let Some(cur) = self.text_edit_layer { + self.commit_text_edit(shared, cur); + } + + let fc = *shared.fill_color; + let color = [ + fc.r() as f32 / 255.0, + fc.g() as f32 / 255.0, + fc.b() as f32 / 255.0, + fc.a() as f32 / 255.0, + ]; + + let is_vector = shared + .active_layer_id + .and_then(|id| shared.action_executor.document().get_layer(&id)) + .map_or(false, |l| matches!(l, AnyLayer::Vector(_))); + + if is_vector { + let parent_id = shared.active_layer_id.unwrap(); + // The text layer sits at the clip's origin; the instance is placed at the click. + let mut tl = TextLayer::new("Text", Point::ZERO); + tl.content = TextContent { color, ..TextContent::default() }; + let text_layer_id = tl.layer.id; + let clip_id = uuid::Uuid::new_v4(); + let instance_id = uuid::Uuid::new_v4(); + let action = CreateTextClipAction::new(parent_id, tl, (point.x, point.y)) + .with_ids(clip_id, instance_id); + if shared.action_executor.execute(Box::new(action)).is_ok() { + // Enter the new clip; main.rs activates the clip's first layer (the text layer). + *shared.pending_enter_clip = Some((clip_id, instance_id, parent_id)); + self.begin_text_edit(text_layer_id); + self.text_edit_is_new = true; + self.text_edit_created_clip = Some(clip_id); + } + } else { + // Raster / video / group / no layer → new top-level text layer, created in + // the current editing context (root, or the clip currently being edited). + let mut tl = TextLayer::new("Text", point); + tl.content = TextContent { color, ..TextContent::default() }; + let text_layer_id = tl.layer.id; + let action = AddLayerAction::new(AnyLayer::Text(tl)) + .with_target_clip(shared.editing_clip_id); + if shared.action_executor.execute(Box::new(action)).is_ok() { + *shared.active_layer_id = Some(text_layer_id); + self.begin_text_edit(text_layer_id); + self.text_edit_is_new = true; + } + } + } + + /// Handle dragging a text box resize handle. Returns true if the interaction was + /// consumed (so the normal select/create logic should be skipped this frame). + /// `world_pos` is in the active layer's local space (clip-local when editing a clip), + /// which matches the box coordinates, so hit-testing is axis-aligned here. + /// Index (0..8) of the text-box resize handle under `(mx,my)`, if any. + /// 0=TL 1=TR 2=BR 3=BL (corners), 4=Top 5=Right 6=Bottom 7=Left (edges). + fn text_handle_at(ox: f64, oy: f64, bw: f64, bh: f64, mx: f64, my: f64, tol: f64) -> Option { + let pts = [ + (ox, oy), (ox + bw, oy), (ox + bw, oy + bh), (ox, oy + bh), + (ox + bw * 0.5, oy), (ox + bw, oy + bh * 0.5), + (ox + bw * 0.5, oy + bh), (ox, oy + bh * 0.5), + ]; + pts.iter().position(|(hx, hy)| (hx - mx).abs() <= tol && (hy - my).abs() <= tol) + } + + /// Resize cursor for a text-box handle index (reuses the shared corner/edge mapping). + fn text_handle_cursor(idx: usize) -> egui::CursorIcon { + if idx < 4 { corner_resize_cursor(idx) } else { edge_resize_cursor(idx - 4) } + } + + fn handle_text_box_resize( + &mut self, + ui: &egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + ) -> bool { + use lightningbeam_core::layer::AnyLayer; + + let Some(layer_id) = *shared.active_layer_id else { return false }; + let Some((ox, oy, bw, bh)) = (match shared.action_executor.document().get_layer(&layer_id) { + Some(AnyLayer::Text(tl)) => Some((tl.box_origin.x, tl.box_origin.y, tl.box_width, tl.box_height)), + _ => None, + }) else { return false }; + + let (mx, my) = (world_pos.x as f64, world_pos.y as f64); + let tol = 6.0 / (self.zoom as f64).max(1e-3); + + // Hover feedback: show a resize cursor over a handle when not already dragging. + if self.text_box_drag.is_none() { + if let Some(idx) = Self::text_handle_at(ox, oy, bw, bh, mx, my, tol) { + ui.ctx().set_cursor_icon(Self::text_handle_cursor(idx)); + } + } + + // Begin a handle drag: hit-test the 8 handles. + if self.rsp_drag_started(response) && self.text_box_drag.is_none() { + if let Some(idx) = Self::text_handle_at(ox, oy, bw, bh, mx, my, tol) { + self.text_box_drag = Some((layer_id, idx, ox, oy, bw, bh)); + return true; + } + } + + let Some((drag_id, idx, sox, soy, sbw, sbh)) = self.text_box_drag else { return false }; + if drag_id != layer_id { return false; } + + if self.rsp_dragged(response) { + let (no, nw, nh) = Self::resize_text_box(idx, sox, soy, sbw, sbh, mx, my); + // Live preview (no undo entry). + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) { + tl.box_origin = no; + tl.box_width = nw; + tl.box_height = nh; + } + return true; + } + + if self.rsp_drag_stopped(response) { + let (no, nw, nh) = Self::resize_text_box(idx, sox, soy, sbw, sbh, mx, my); + // Restore the start box, then record one undoable resize. + { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) { + tl.box_origin = vello::kurbo::Point::new(sox, soy); + tl.box_width = sbw; + tl.box_height = sbh; + } + } + let action = lightningbeam_core::actions::ResizeTextBoxAction::new(layer_id, no, nw, nh); + let _ = shared.action_executor.execute(Box::new(action)); + self.text_box_drag = None; + // A resize is now the top undo entry, so the creation can no longer be + // rolled back by an empty-commit; keep the layer even if left empty. + self.text_edit_is_new = false; + return true; + } + + true + } + + /// Compute a new (origin, width, height) for a text box given a dragged handle. + /// Handles: 0=TL 1=TR 2=BR 3=BL (corners), 4=T 5=R 6=B 7=L (edges). + fn resize_text_box(idx: usize, ox: f64, oy: f64, w: f64, h: f64, mx: f64, my: f64) + -> (vello::kurbo::Point, f64, f64) + { + const MIN: f64 = 8.0; + let (mut l, mut r, mut t, mut b) = (ox, ox + w, oy, oy + h); + match idx { + 0 => { l = mx; t = my; } + 1 => { r = mx; t = my; } + 2 => { r = mx; b = my; } + 3 => { l = mx; b = my; } + 4 => { t = my; } + 5 => { r = mx; } + 6 => { b = my; } + 7 => { l = mx; } + _ => {} + } + if r - l < MIN { + if matches!(idx, 0 | 3 | 7) { l = r - MIN; } else { r = l + MIN; } + } + if b - t < MIN { + if matches!(idx, 0 | 1 | 4) { t = b - MIN; } else { b = t + MIN; } + } + (vello::kurbo::Point::new(l, t), r - l, b - t) + } + + /// Begin in-place editing of an existing text layer (not a fresh creation). + fn begin_text_edit(&mut self, layer_id: uuid::Uuid) { + self.text_edit_layer = Some(layer_id); + self.text_edit_buffer.clear(); + self.text_edit_request_focus = true; + self.text_edit_cursor = None; + self.text_edit_original = None; // captured on the first update frame + self.text_edit_is_new = false; + self.text_edit_created_clip = None; + } + + /// Clear all in-place text editing state. + fn finish_text_edit_state(&mut self) { + self.text_edit_layer = None; + self.text_edit_buffer.clear(); + self.text_edit_cursor = None; + self.text_edit_original = None; + self.text_edit_request_focus = false; + self.text_edit_is_new = false; + self.text_edit_created_clip = None; + } + + /// Commit the current in-place edit as a single undoable step. + fn commit_text_edit(&mut self, shared: &mut SharedPaneState, layer_id: uuid::Uuid) { + use lightningbeam_core::actions::SetTextContentAction; + + // A freshly-created layer left empty is removed by rolling back its creation + // (the top undo entry), which cleanly reverses both the root and clip branches. + if self.text_edit_is_new && self.text_edit_buffer.trim().is_empty() { + let _ = shared.action_executor.undo(); + if *shared.active_layer_id == Some(layer_id) { + *shared.active_layer_id = None; + } + // If creating it entered a new clip, leave that clip (it no longer exists). + if self.text_edit_created_clip.is_some() { + *shared.pending_exit_clip = true; + } + self.finish_text_edit_state(); + return; + } + + if let Some(orig) = self.text_edit_original.take() { + let mut final_content = orig.clone(); + final_content.text = self.text_edit_buffer.clone(); + if final_content != orig { + // The document already holds `final_content` (live preview); with_old + // records the pre-edit value so undo restores it in one step. + let action = SetTextContentAction::with_old(layer_id, orig, final_content); + let _ = shared.action_executor.execute(Box::new(action)); + } + } + self.finish_text_edit_state(); + } + + /// Find the topmost text layer in the current editing context whose box contains + /// `point` (in the context's local space, which matches `world_pos`). + fn text_layer_at(&self, point: vello::kurbo::Point, shared: &SharedPaneState) -> Option { + use lightningbeam_core::layer::AnyLayer; + let doc = shared.action_executor.document(); + let layers = doc.context_layers(shared.editing_clip_id.as_ref()); + for layer in layers.into_iter().rev() { + if let AnyLayer::Text(tl) = layer { + let r = vello::kurbo::Rect::from_origin_size( + tl.box_origin, (tl.box_width, tl.box_height)); + if r.contains(point) { + return Some(tl.layer.id); + } + } + } + None + } + + /// Drive the hybrid in-place text editor: a hidden, focused egui `TextEdit` captures + /// keystrokes/IME and the caret position; the text + caret are rendered in Vello. + /// The field is parked far offscreen (`constrain(false)` so egui doesn't clamp it back + /// on-screen) — invisible and not intercepting pointer events over the text box. + fn update_text_editing(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) { + use lightningbeam_core::layer::AnyLayer; + let Some(layer_id) = self.text_edit_layer else { return }; + + // Current text, or bail (and clear) if the layer is gone / not text anymore. + let cur_text = { + let doc = shared.action_executor.document(); + match doc.get_layer(&layer_id) { + Some(AnyLayer::Text(tl)) => { + if self.text_edit_original.is_none() { + self.text_edit_original = Some(tl.content.clone()); + self.text_edit_buffer = tl.content.text.clone(); + } + tl.content.text.clone() + } + _ => { self.finish_text_edit_state(); return; } + } + }; + + // Escape cancels: restore the pre-edit content and exit. + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if let Some(orig) = self.text_edit_original.take() { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) { + tl.content = orig; + } + } + self.finish_text_edit_state(); + return; + } + + // Hidden, focused TextEdit parked offscreen (constrain(false) keeps egui from + // clamping it back into view). Captures keyboard/IME + caret only. + let te_id = egui::Id::new(("lb_text_edit", layer_id)); + let output = egui::Area::new(egui::Id::new(("lb_text_edit_area", layer_id))) + .constrain(false) + .fixed_pos(egui::pos2(-100_000.0, -100_000.0)) + .show(ui.ctx(), |ui| { + egui::TextEdit::multiline(&mut self.text_edit_buffer) + .id(te_id) + .desired_width(1.0) + .show(ui) + }) + .inner; + + // Acquire and hold keyboard focus; once acquired, losing it commits the edit. + if self.text_edit_request_focus { + if output.response.has_focus() { + self.text_edit_request_focus = false; + } else { + output.response.request_focus(); + } + } else if !output.response.has_focus() { + self.commit_text_edit(shared, layer_id); + return; + } + + // Caret/selection → byte offsets for the Vello caret. + if let Some(cr) = output.cursor_range { + let anchor = char_to_byte(&self.text_edit_buffer, cr.secondary.index); + let focus = char_to_byte(&self.text_edit_buffer, cr.primary.index); + self.text_edit_cursor = Some((anchor, focus)); + } + + // Live preview: push the buffer into the document text (no undo entry). + if self.text_edit_buffer != cur_text { + let doc = shared.action_executor.document_mut(); + if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) { + tl.content.text = self.text_edit_buffer.clone(); + } + } + } + fn handle_ellipse_tool( &mut self, ui: &mut egui::Ui, @@ -9928,29 +10408,17 @@ impl StagePane { // Check corner handles with correct diagonal cursors for (idx, corner) in world_corners.iter().enumerate() { if point.distance(*corner) < tolerance { - // Top-left (0) and bottom-right (2): NW-SE diagonal (\) - // Top-right (1) and bottom-left (3): NE-SW diagonal (/) - let cursor = match idx { - 0 | 2 => egui::CursorIcon::ResizeNwSe, // Top-left, Bottom-right - 1 | 3 => egui::CursorIcon::ResizeNeSw, // Top-right, Bottom-left - _ => egui::CursorIcon::Default, - }; - ui.ctx().set_cursor_icon(cursor); + ui.ctx().set_cursor_icon(corner_resize_cursor(idx)); hovering_handle = true; break; } } - // Check edge handles + // Check edge handles (midpoints are ordered top, right, bottom, left) if !hovering_handle { for (idx, edge_pos) in edge_midpoints.iter().enumerate() { if point.distance(*edge_pos) < tolerance { - let cursor = match idx { - 0 | 2 => egui::CursorIcon::ResizeVertical, // Top/Bottom - 1 | 3 => egui::CursorIcon::ResizeHorizontal, // Right/Left - _ => egui::CursorIcon::Default, - }; - ui.ctx().set_cursor_icon(cursor); + ui.ctx().set_cursor_icon(edge_resize_cursor(idx)); hovering_handle = true; break; } @@ -10863,10 +11331,19 @@ impl StagePane { match *shared.selected_tool { Tool::Select => { - let is_raster = shared.active_layer_id.and_then(|id| { + let active = shared.active_layer_id.and_then(|id| { shared.action_executor.document().get_layer(&id) - }).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); - if is_raster { + }); + let is_raster = matches!(active, Some(lightningbeam_core::layer::AnyLayer::Raster(_))); + let is_text = matches!(active, Some(lightningbeam_core::layer::AnyLayer::Text(_))); + if is_text && self.handle_text_box_resize(ui, &response, world_pos, shared) { + // Consumed by a resize-handle drag. + } else if is_text && response.double_clicked() { + // Double-click a selected text layer to edit it in place. + if let Some(id) = *shared.active_layer_id { + self.begin_text_edit(id); + } + } else if is_raster { self.handle_raster_select_tool(ui, &response, world_pos, shared); } else { self.handle_select_tool(ui, &response, world_pos, shift_held, shared); @@ -10932,6 +11409,9 @@ impl StagePane { Tool::Gradient => { self.handle_raster_gradient_tool(ui, &response, world_pos, shared); } + Tool::Text => { + self.handle_text_tool(ui, &response, world_pos, shared); + } _ => { // Other tools not implemented yet } @@ -11765,6 +12245,9 @@ impl PaneRenderer for StagePane { // Handle input for pan/zoom and tool controls self.handle_input(ui, rect, shared); + // Drive the in-place text editor (hidden egui field → Vello caret). + self.update_text_editing(ui, shared); + // Handle asset drag-and-drop from Asset Library if let Some(dragging) = shared.dragging_asset.clone() { if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { @@ -12143,6 +12626,9 @@ impl PaneRenderer for StagePane { container_path: shared.container_path.clone(), is_playing: *shared.is_playing, active_layer_id: *shared.active_layer_id, + text_edit: self.text_edit_layer.and_then(|id| { + self.text_edit_cursor.map(|(s, e)| (id, s, e)) + }), drag_delta, selection: shared.selection.clone(), fill_color: *shared.fill_color, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 5cfdca3..f233858 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -212,6 +212,7 @@ fn effective_clip_duration( AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION), AnyLayer::Group(_) => None, AnyLayer::Raster(_) => None, + AnyLayer::Text(_) => None, } } @@ -685,6 +686,7 @@ fn layer_clips<'a>( AnyLayer::Effect(l) => &l.clip_instances, AnyLayer::Group(_) => &[], AnyLayer::Raster(_) => &[], + AnyLayer::Text(_) => &[], } } @@ -718,6 +720,7 @@ fn collect_clip_instances<'a>( } } AnyLayer::Raster(_) => {} + AnyLayer::Text(_) => {} } } @@ -768,6 +771,7 @@ fn layer_type_info(layer: &AnyLayer) -> (&'static str, egui::Color32) { AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)), AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)), AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)), + AnyLayer::Text(_) => ("Text", egui::Color32::from_rgb(220, 200, 120)), } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index 019df99..28315bd 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -43,6 +43,7 @@ impl PaneRenderer for ToolbarPane { AnyLayer::Effect(_) => LayerType::Effect, AnyLayer::Group(_) => LayerType::Group, AnyLayer::Raster(_) => LayerType::Raster, + AnyLayer::Text(_) => LayerType::Text, }); // Auto-switch to Select if the current tool isn't available for this layer type