diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index fcc93be..936283d 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -114,6 +114,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -172,6 +183,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -502,6 +563,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "781dd20c3aff0bd194fe7d2a977dd92f21c173891f3a03b677359e5fa457e5d5" +dependencies = [ + "simd-abstraction", +] + [[package]] name = "bit-set" version = "0.6.0" @@ -544,6 +614,18 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block" version = "0.1.6" @@ -593,6 +675,28 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytemuck" version = "1.24.0" @@ -756,6 +860,46 @@ dependencies = [ "libc", ] +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -787,6 +931,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "com" version = "0.6.0" @@ -837,6 +987,35 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-str" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21077772762a1002bb421c3af42ac1725fa56066bfc53d9a55bb79905df2aaf3" +dependencies = [ + "const-str-proc-macro", +] + +[[package]] +name = "const-str-proc-macro" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e1e0fdd2e5d3041e530e1b21158aeeef8b5d0e306bc5c1e3d6cf0930d10e25a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -955,6 +1134,38 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9be934d936a0fbed5bcdc01042b770de1398bf79d0e192f49fa7faea0e99281e" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-color" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556c099a61d85989d7af52b692e35a8d68a57e7df8c6d07563dc0778b3960c9f" +dependencies = [ + "cssparser", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.110", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -972,6 +1183,34 @@ dependencies = [ "winapi", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "data-url" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a30bfce702bcfa94e906ef82421f2c0e61c076ad76030c16ee5d2e9a32fe193" +dependencies = [ + "matches", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1045,6 +1284,21 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "ecolor" version = "0.29.1" @@ -1061,7 +1315,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ac2645a9bf4826eb4e91488b1f17b8eaddeef09396706b2f14066461338e24f" dependencies = [ - "ahash", + "ahash 0.8.12", "bytemuck", "document-features", "egui", @@ -1099,7 +1353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53eafabcce0cb2325a59a98736efe0bf060585b437763f8c476957fb274bb974" dependencies = [ "accesskit", - "ahash", + "ahash 0.8.12", "emath", "epaint", "log", @@ -1112,7 +1366,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d00fd5d06d8405397e64a928fa0ef3934b3c30273ea7603e3dc4627b1f7a1a82" dependencies = [ - "ahash", + "ahash 0.8.12", "bytemuck", "document-features", "egui", @@ -1132,7 +1386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a9c430f4f816340e8e8c1b20eec274186b1be6bc4c7dfc467ed50d57abc36c6" dependencies = [ "accesskit_winit", - "ahash", + "ahash 0.8.12", "arboard", "egui", "log", @@ -1149,7 +1403,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3c1f5cd8dfe2ade470a218696c66cf556fcfd701e7830fa2e9f4428292a2a1" dependencies = [ - "ahash", + "ahash 0.8.12", "egui", "enum-map", "image", @@ -1164,7 +1418,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e39bccc683cd43adab530d8f21a13eb91e80de10bcc38c3f1c16601b6f62b26" dependencies = [ - "ahash", + "ahash 0.8.12", "bytemuck", "egui", "glow 0.14.2", @@ -1245,7 +1499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a32af8da821bd4f43f2c137e295459ee2e1661d87ca8779dfa0eaf45d870e20f" dependencies = [ "ab_glyph", - "ahash", + "ahash 0.8.12", "bytemuck", "ecolor", "emath", @@ -1489,6 +1743,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.31" @@ -2012,6 +2272,21 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2237,6 +2512,8 @@ checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", "hashbrown 0.16.0", + "serde", + "serde_core", ] [[package]] @@ -2250,6 +2527,21 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -2373,6 +2665,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "lebe" version = "0.5.3" @@ -2450,12 +2748,14 @@ dependencies = [ name = "lightningbeam-editor" version = "0.1.0" dependencies = [ + "clap", "eframe", "egui-wgpu", "egui_extras", "image", "kurbo 0.11.3", "lightningbeam-core", + "lightningcss", "muda", "peniko 0.5.0", "pollster", @@ -2467,6 +2767,46 @@ dependencies = [ "winit", ] +[[package]] +name = "lightningcss" +version = "1.0.0-alpha.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b407ca668368d1d5a86cea58ac82d9f9f9ca4bac1e9dce6f16f875f0f081a911" +dependencies = [ + "ahash 0.8.12", + "bitflags 2.10.0", + "const-str", + "cssparser", + "cssparser-color", + "dashmap", + "data-encoding", + "getrandom 0.3.4", + "indexmap", + "itertools 0.10.5", + "lazy_static", + "lightningcss-derive", + "parcel_selectors", + "parcel_sourcemap", + "pastey", + "pathdiff", + "rayon", + "serde", + "serde-content", + "smallvec", +] + +[[package]] +name = "lightningcss-derive" +version = "1.0.0-alpha.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c12744d1279367caed41739ef094c325d53fb0ffcd4f9b84a368796f870252" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -2530,6 +2870,12 @@ dependencies = [ "libc", ] +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3095,6 +3441,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "orbclient" version = "0.3.49" @@ -3114,6 +3466,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f222829ae9293e33a9f5e9f440c6760a3d450a64affe1846486b140db81c1f4" + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -3148,6 +3506,36 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parcel_selectors" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196" +dependencies = [ + "bitflags 2.10.0", + "cssparser", + "log", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash 2.1.1", + "smallvec", +] + +[[package]] +name = "parcel_sourcemap" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485b74d7218068b2b7c0e3ff12fbc61ae11d57cb5d8224f525bd304c6be05bbb" +dependencies = [ + "base64-simd", + "data-url 0.1.1", + "rkyv", + "serde", + "serde_json", + "vlq", +] + [[package]] name = "parking" version = "2.2.1" @@ -3183,6 +3571,18 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "peniko" version = "0.2.0" @@ -3221,6 +3621,16 @@ dependencies = [ "phf_shared", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -3374,6 +3784,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "presser" version = "0.3.1" @@ -3461,6 +3877,26 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "pxfm" version = "0.1.25" @@ -3519,6 +3955,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -3569,7 +4011,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.12.1", "libc", "libfuzzer-sys", "log", @@ -3665,6 +4107,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3710,6 +4161,35 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "roxmltree" version = "0.19.0" @@ -3831,6 +4311,12 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "semver" version = "1.0.27" @@ -3847,6 +4333,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-content" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3753ca04f350fa92d00b6146a3555e63c55388c9ef2e11e09bce2ff1c0b509c6" +dependencies = [ + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3926,6 +4421,15 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-abstraction" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cadb29c57caadc51ff8346233b5cec1d240b68ce55cf1afc764818791876987" +dependencies = [ + "outref", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -3941,6 +4445,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.2" @@ -4098,6 +4608,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "svg_fmt" version = "0.4.5" @@ -4170,6 +4686,12 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4547,7 +5069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b84ea542ae85c715f07b082438a4231c3760539d902e11d093847a0b22963032" dependencies = [ "base64 0.22.1", - "data-url", + "data-url 0.3.2", "flate2", "fontdb", "imagesize", @@ -4573,7 +5095,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bd4e3c291f45d152929a31f0f6c819245e2921bfd01e7bd91201a9af39a2bdc" dependencies = [ - "data-url", + "data-url 0.3.2", "flate2", "imagesize", "kurbo 0.9.5", @@ -4603,6 +5125,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.18.1" @@ -4683,6 +5211,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vlq" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd7eed29412da847b0f78bcec0ac98588165988a8cfe41d4ea1d429f8ccfff" + [[package]] name = "walkdir" version = "2.5.0" @@ -5412,7 +5946,7 @@ version = "0.30.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" dependencies = [ - "ahash", + "ahash 0.8.12", "android-activity", "atomic-waker", "bitflags 2.10.0", @@ -5488,6 +6022,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index 61004b8..f6f9c83 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -33,3 +33,5 @@ resvg = { workspace = true } # Utilities pollster = { workspace = true } +lightningcss = "1.0.0-alpha.68" +clap = { version = "4.5", features = ["derive"] } diff --git a/lightningbeam-ui/lightningbeam-editor/assets/styles.css b/lightningbeam-ui/lightningbeam-editor/assets/styles.css new file mode 100644 index 0000000..8bc6516 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/assets/styles.css @@ -0,0 +1,178 @@ +/* Lightningbeam Editor Styles + * CSS with variables and selector-based theming + */ + +/* ============================================ + LIGHT MODE VARIABLES + ============================================ */ +:root { + /* Base colors */ + --bg-primary: #f6f6f6; + --bg-secondary: #ccc; + --bg-panel: #aaa; + --bg-header: #ddd; + --background-color: #ccc; + --foreground-color: #ddd; + --highlight: #ddd; + --shadow: #999; + --shade: #aaa; + + /* Text colors */ + --text-primary: #0f0f0f; + --text-secondary: #666; + --text-tertiary: #999; + + /* Border colors */ + --border-light: #bbb; + --border-medium: #999; + --border-dark: #555; + + /* UI backgrounds */ + --grid-bg: #555; + --grid-hover: #666; + + /* Dimensions */ + --header-height: 40px; + --pane-border-width: 1px; +} + +/* ============================================ + DARK MODE VARIABLES + ============================================ */ +@media (prefers-color-scheme: dark) { + :root { + /* Base colors */ + --bg-primary: #2f2f2f; + --bg-secondary: #3f3f3f; + --bg-panel: #222222; + --bg-header: #444; + --background-color: #333; + --foreground-color: #888; + --highlight: #4f4f4f; + --shadow: #111; + --shade: #222; + + /* Text colors */ + --text-primary: #f6f6f6; + --text-secondary: #aaa; + --text-tertiary: #777; + + /* Border colors */ + --border-light: #555; + --border-medium: #444; + --border-dark: #333; + + /* UI backgrounds */ + --grid-bg: #0f0f0f; + --grid-hover: #1a1a1a; + } +} + +/* ============================================ + COMPONENT STYLES (applies to both modes) + ============================================ */ + +/* Pane headers */ +.pane-header { + background-color: var(--bg-header); + color: var(--text-primary); + height: var(--header-height); + border-color: var(--border-medium); +} + +/* Pane content areas */ +.pane-content { + background-color: var(--bg-primary); + border-color: var(--border-light); +} + +/* General panel */ +.panel { + background-color: var(--bg-panel); + border-color: var(--border-medium); +} + +/* Grid backgrounds */ +.grid { + background-color: var(--grid-bg); +} + +.grid-hover { + background-color: var(--grid-hover); +} + +/* Specific pane IDs */ +#stage { + background-color: var(--bg-primary); +} + +#timeline { + background-color: var(--shade); +} + +#toolbar { + background-color: var(--bg-secondary); +} + +#infopanel { + background-color: var(--bg-panel); +} + +#node-editor { + background-color: #2d2d2d; + border-color: #4d4d4d; +} + +/* Timeline specific elements */ +.timeline-background { + background-color: var(--shade); +} + +.timeline-header { + background-color: var(--shadow); +} + +.timeline-spacer { + background-color: var(--shadow); +} + +.timeline-scrubber { + background-color: #cc2222; + border-color: #cc2222; +} + +.timeline-layer-active { + background-color: var(--highlight); +} + +.timeline-layer-inactive { + background-color: var(--background-color); +} + +.timeline-row-active { + background-color: var(--grid-bg); +} + +.timeline-row-inactive { + background-color: var(--foreground-color); +} + +/* Buttons */ +.button { + background-color: var(--bg-primary); + color: var(--text-primary); + border-color: var(--border-medium); +} + +.button-hover { + border-color: #396cd8; +} + +/* Text */ +.text-primary { + color: var(--text-primary); +} + +.text-secondary { + color: var(--text-secondary); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 11cc91b..98a5723 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -3,6 +3,7 @@ use lightningbeam_core::layout::{LayoutDefinition, LayoutNode}; use lightningbeam_core::pane::PaneType; use lightningbeam_core::tool::Tool; use std::collections::HashMap; +use clap::Parser; mod panes; use panes::{PaneInstance, PaneRenderer, SharedPaneState}; @@ -10,9 +11,46 @@ use panes::{PaneInstance, PaneRenderer, SharedPaneState}; mod menu; use menu::{MenuAction, MenuSystem}; +mod theme; +use theme::{Theme, ThemeMode}; + +/// Lightningbeam Editor - Animation and video editing software +#[derive(Parser, Debug)] +#[command(name = "Lightningbeam Editor")] +#[command(author, version, about, long_about = None)] +struct Args { + /// Use light theme + #[arg(long, conflicts_with = "dark")] + light: bool, + + /// Use dark theme + #[arg(long, conflicts_with = "light")] + dark: bool, +} + fn main() -> eframe::Result { println!("πŸš€ Starting Lightningbeam Editor..."); + // Parse command line arguments + let args = Args::parse(); + + // Determine theme mode from arguments + let theme_mode = if args.light { + ThemeMode::Light + } else if args.dark { + ThemeMode::Dark + } else { + ThemeMode::System + }; + + // Load theme + let mut theme = Theme::load_default().expect("Failed to load theme"); + theme.set_mode(theme_mode); + println!("βœ… Loaded theme with {} selectors (mode: {:?})", theme.len(), theme_mode); + + // Debug: print theme info + theme.debug_print(); + // Load layouts from JSON let layouts = load_layouts(); println!("βœ… Loaded {} layouts", layouts.len()); @@ -39,7 +77,7 @@ fn main() -> eframe::Result { eframe::run_native( "Lightningbeam Editor", options, - Box::new(move |cc| Ok(Box::new(EditorApp::new(cc, layouts)))), + Box::new(move |cc| Ok(Box::new(EditorApp::new(cc, layouts, theme)))), ) } @@ -191,15 +229,38 @@ struct EditorApp { pane_instances: HashMap, // Pane instances per path menu_system: Option, // Native menu system for event checking pending_view_action: Option, // Pending view action (zoom, recenter) to be handled by hovered pane + theme: Theme, // Theme system for colors and dimensions + document: lightningbeam_core::document::Document, // Active document being edited } impl EditorApp { - fn new(cc: &eframe::CreationContext, layouts: Vec) -> Self { + fn new(cc: &eframe::CreationContext, layouts: Vec, theme: Theme) -> Self { let current_layout = layouts[0].layout.clone(); // Initialize native menu system let menu_system = MenuSystem::new().ok(); + // Create default document with a simple test scene + let mut document = lightningbeam_core::document::Document::with_size("Untitled Animation", 1920.0, 1080.0) + .with_duration(10.0) + .with_framerate(60.0); + + // Add a test layer with a simple shape to visualize + use lightningbeam_core::layer::{AnyLayer, VectorLayer}; + use lightningbeam_core::object::Object; + use lightningbeam_core::shape::{Shape, ShapeColor}; + use vello::kurbo::{Circle, Shape as KurboShape}; + + let circle = Circle::new((200.0, 150.0), 50.0); + let path = circle.to_path(0.1); + let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250)); + let object = Object::new(shape.id); + + let mut vector_layer = VectorLayer::new("Layer 1"); + vector_layer.add_shape(shape); + vector_layer.add_object(object); + document.root.add_child(AnyLayer::Vector(vector_layer)); + Self { layouts, current_layout_index: 0, @@ -216,6 +277,8 @@ impl EditorApp { pane_instances: HashMap::new(), // Initialize empty, panes created on-demand menu_system, pending_view_action: None, + theme, + document, } } @@ -527,6 +590,8 @@ impl eframe::App for EditorApp { &mut self.pending_view_action, &mut fallback_pane_priority, &mut pending_handlers, + &self.theme, + &mut self.document, ); // Execute action on the best handler (two-phase dispatch) @@ -603,10 +668,12 @@ fn render_layout_node( pending_view_action: &mut Option, fallback_pane_priority: &mut Option, pending_handlers: &mut Vec, + theme: &Theme, + document: &mut lightningbeam_core::document::Document, ) { match node { LayoutNode::Pane { name } => { - render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers); + render_pane(ui, name, rect, selected_pane, layout_action, split_preview_mode, icon_cache, tool_icon_cache, selected_tool, fill_color, stroke_color, pane_instances, path, pending_view_action, fallback_pane_priority, pending_handlers, theme, document); } LayoutNode::HorizontalGrid { percent, children } => { // Handle dragging @@ -649,6 +716,8 @@ fn render_layout_node( pending_view_action, fallback_pane_priority, pending_handlers, + theme, + document, ); let mut right_path = path.clone(); @@ -672,6 +741,8 @@ fn render_layout_node( pending_view_action, fallback_pane_priority, pending_handlers, + theme, + document, ); // Draw divider with interaction @@ -787,6 +858,8 @@ fn render_layout_node( pending_view_action, fallback_pane_priority, pending_handlers, + theme, + document, ); let mut bottom_path = path.clone(); @@ -810,6 +883,8 @@ fn render_layout_node( pending_view_action, fallback_pane_priority, pending_handlers, + theme, + document, ); // Draw divider with interaction @@ -905,6 +980,8 @@ fn render_pane( pending_view_action: &mut Option, fallback_pane_priority: &mut Option, pending_handlers: &mut Vec, + theme: &Theme, + document: &mut lightningbeam_core::document::Document, ) { let pane_type = PaneType::from_name(pane_name); @@ -1048,14 +1125,93 @@ fn render_pane( egui::Color32::from_gray(220), ); - // TODO: Add pane-specific header controls here - // For example, Timeline pane would add playback controls + // Create header controls area (positioned after title) + let title_width = 150.0; // Approximate width for title + let header_controls_rect = egui::Rect::from_min_size( + header_rect.min + egui::vec2(icon_padding * 2.0 + icon_size + 8.0 + title_width, 0.0), + egui::vec2(header_rect.width() - (icon_padding * 2.0 + icon_size + 8.0 + title_width), header_height), + ); - // Make pane content clickable + // Render pane-specific header controls (if pane has them) + if let Some(pane_type) = pane_type { + // Get or create pane instance for header rendering + let needs_new_instance = pane_instances + .get(path) + .map(|instance| instance.pane_type() != pane_type) + .unwrap_or(true); + + if needs_new_instance { + pane_instances.insert(path.clone(), panes::PaneInstance::new(pane_type)); + } + + if let Some(pane_instance) = pane_instances.get_mut(path) { + let mut header_ui = ui.new_child(egui::UiBuilder::new().max_rect(header_controls_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); + let mut shared = panes::SharedPaneState { + tool_icon_cache, + icon_cache, + selected_tool, + fill_color, + stroke_color, + pending_view_action, + fallback_pane_priority, + theme, + pending_handlers, + document, + }; + pane_instance.render_header(&mut header_ui, &mut shared); + } + } + + // Make pane content clickable (use full rect for split preview interaction) let pane_id = ui.id().with(("pane", path)); - let response = ui.interact(content_rect, pane_id, egui::Sense::click()); + let response = ui.interact(rect, pane_id, egui::Sense::click()); - // Handle split preview mode + // Render pane-specific content using trait-based system + if let Some(pane_type) = pane_type { + // Get or create pane instance for this path + // Check if we need a new instance (either doesn't exist or type changed) + let needs_new_instance = pane_instances + .get(path) + .map(|instance| instance.pane_type() != pane_type) + .unwrap_or(true); + + if needs_new_instance { + pane_instances.insert(path.clone(), PaneInstance::new(pane_type)); + } + + // Get the pane instance and render its content + if let Some(pane_instance) = pane_instances.get_mut(path) { + // Create shared state + let mut shared = SharedPaneState { + tool_icon_cache, + icon_cache, + selected_tool, + fill_color, + stroke_color, + pending_view_action, + fallback_pane_priority, + theme, + pending_handlers, + document, + }; + + // Render pane content (header was already rendered above) + pane_instance.render_content(ui, content_rect, path, &mut shared); + } + } else { + // Unknown pane type - draw placeholder + let content_text = "Unknown pane type"; + let text_pos = content_rect.center(); + ui.painter().text( + text_pos, + egui::Align2::CENTER_CENTER, + content_text, + egui::FontId::proportional(16.0), + egui::Color32::from_gray(150), + ); + } + + // Handle split preview mode (rendered AFTER pane content for proper z-ordering) if let SplitPreviewMode::Active { is_horizontal, hovered_pane, @@ -1126,8 +1282,12 @@ fn render_pane( ); } + // Create a high-priority interaction for split preview (rendered last = highest priority) + let split_preview_id = ui.id().with(("split_preview", path)); + let split_response = ui.interact(rect, split_preview_id, egui::Sense::click()); + // If clicked, perform the split - if response.clicked() { + if split_response.clicked() { if *is_horizontal { *layout_action = Some(LayoutAction::SplitHorizontal(path.clone(), *split_percent)); } else { @@ -1141,60 +1301,6 @@ fn render_pane( } else if response.clicked() { *selected_pane = Some(path.clone()); } - - // Render pane-specific content using trait-based system - if let Some(pane_type) = pane_type { - // Get or create pane instance for this path - // Check if we need a new instance (either doesn't exist or type changed) - let needs_new_instance = pane_instances - .get(path) - .map(|instance| instance.pane_type() != pane_type) - .unwrap_or(true); - - if needs_new_instance { - pane_instances.insert(path.clone(), PaneInstance::new(pane_type)); - } - - // Get the pane instance and render it - if let Some(pane_instance) = pane_instances.get_mut(path) { - // Create shared state - let mut shared = SharedPaneState { - tool_icon_cache, - icon_cache, - selected_tool, - fill_color, - stroke_color, - pending_view_action, - fallback_pane_priority, - pending_handlers, - }; - - // Render pane header (if it has one) - let has_header = pane_instance.render_header(ui, &mut shared); - - // Adjust content rect if header was rendered - let final_content_rect = if has_header { - // Header was drawn by the pane, adjust content area - content_rect - } else { - content_rect - }; - - // Render pane content - pane_instance.render_content(ui, final_content_rect, path, &mut shared); - } - } else { - // Unknown pane type - draw placeholder - let content_text = "Unknown pane type"; - let text_pos = content_rect.center(); - ui.painter().text( - text_pos, - egui::Align2::CENTER_CENTER, - content_text, - egui::FontId::proportional(16.0), - egui::Color32::from_gray(150), - ); - } } /// Render toolbar with tool buttons diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 4306309..42fb252 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -39,9 +39,12 @@ pub struct SharedPaneState<'a> { /// Lower number = higher priority. None = no fallback pane seen yet /// Priority order: Stage(0) > Timeline(1) > PianoRoll(2) > NodeEditor(3) pub fallback_pane_priority: &'a mut Option, + pub theme: &'a crate::theme::Theme, /// Registry of handlers for the current pending action /// Panes register themselves here during render, execution happens after pub pending_handlers: &'a mut Vec, + /// Active document being edited + pub document: &'a mut lightningbeam_core::document::Document, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 57dfa0f..d13ffec 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -6,24 +6,28 @@ use eframe::egui; use super::{NodePath, PaneRenderer, SharedPaneState}; use std::sync::{Arc, Mutex}; -/// Resources for a single Vello instance -struct VelloResources { +/// Shared Vello resources (created once, reused by all Stage panes) +struct SharedVelloResources { renderer: Arc>, - texture: Option, - texture_view: Option, - // Blit pipeline for rendering texture to screen blit_pipeline: wgpu::RenderPipeline, blit_bind_group_layout: wgpu::BindGroupLayout, sampler: wgpu::Sampler, +} + +/// Per-instance Vello resources (created for each Stage pane) +struct InstanceVelloResources { + texture: Option, + texture_view: Option, blit_bind_group: Option, } /// Container for all Vello instances, stored in egui's CallbackResources pub struct VelloResourcesMap { - instances: std::collections::HashMap, + shared: Option>, + instances: std::collections::HashMap, } -impl VelloResources { +impl SharedVelloResources { pub fn new(device: &wgpu::Device) -> Result { let renderer = vello::Renderer::new( device, @@ -118,20 +122,27 @@ impl VelloResources { ..Default::default() }); - println!("βœ… Vello renderer and blit pipeline initialized"); + println!("βœ… Vello shared resources initialized (renderer and shaders)"); Ok(Self { renderer: Arc::new(Mutex::new(renderer)), - texture: None, - texture_view: None, blit_pipeline, blit_bind_group_layout, sampler, - blit_bind_group: None, }) } +} - fn ensure_texture(&mut self, device: &wgpu::Device, width: u32, height: u32) { +impl InstanceVelloResources { + pub fn new() -> Self { + Self { + texture: None, + texture_view: None, + blit_bind_group: None, + } + } + + fn ensure_texture(&mut self, device: &wgpu::Device, shared: &SharedVelloResources, width: u32, height: u32) { // Clamp to GPU limits (most GPUs support up to 8192) let max_texture_size = 8192; let width = width.min(max_texture_size); @@ -157,10 +168,10 @@ impl VelloResources { let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - // Create bind group for blit pipeline + // Create bind group for blit pipeline (using shared layout and sampler) let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("vello_blit_bind_group"), - layout: &self.blit_bind_group_layout, + layout: &shared.blit_bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, @@ -168,7 +179,7 @@ impl VelloResources { }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::Sampler(&self.sampler), + resource: wgpu::BindingResource::Sampler(&shared.sampler), }, ], }); @@ -185,11 +196,12 @@ struct VelloCallback { pan_offset: egui::Vec2, zoom: f32, instance_id: u64, + document: lightningbeam_core::document::Document, } impl VelloCallback { - fn new(rect: egui::Rect, pan_offset: egui::Vec2, zoom: f32, instance_id: u64) -> Self { - Self { rect, pan_offset, zoom, instance_id } + fn new(rect: egui::Rect, pan_offset: egui::Vec2, zoom: f32, instance_id: u64, document: lightningbeam_core::document::Document) -> Self { + Self { rect, pan_offset, zoom, instance_id, document } } } @@ -205,15 +217,26 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Get or create the resources map if !resources.contains::() { resources.insert(VelloResourcesMap { + shared: None, instances: std::collections::HashMap::new(), }); } let map: &mut VelloResourcesMap = resources.get_mut().unwrap(); - // Get or create resources for this specific instance - let vello_resources = map.instances.entry(self.instance_id).or_insert_with(|| { - VelloResources::new(device).expect("Failed to initialize Vello renderer") + // Initialize shared resources if not yet created (only happens once for first Stage pane) + if map.shared.is_none() { + map.shared = Some(Arc::new( + SharedVelloResources::new(device).expect("Failed to initialize shared Vello resources") + )); + } + + let shared = map.shared.as_ref().unwrap().clone(); + + // Get or create per-instance resources + let instance_resources = map.instances.entry(self.instance_id).or_insert_with(|| { + println!("βœ… Creating instance resources for Stage pane #{}", self.instance_id); + InstanceVelloResources::new() }); // Ensure texture is the right size @@ -224,46 +247,21 @@ impl egui_wgpu::CallbackTrait for VelloCallback { return Vec::new(); } - vello_resources.ensure_texture(device, width, height); + instance_resources.ensure_texture(device, &shared, width, height); // Build Vello scene using the document renderer let mut scene = vello::Scene::new(); - // Create a test document with a simple shape - use lightningbeam_core::document::Document; - use lightningbeam_core::layer::{AnyLayer, VectorLayer}; - use lightningbeam_core::object::Object; - use lightningbeam_core::shape::{Shape, ShapeColor}; - use vello::kurbo::{Circle, Shape as KurboShape}; - - let mut doc = Document::new("Test Animation"); - - // Create a simple circle shape - let circle = Circle::new((200.0, 150.0), 50.0); - let path = circle.to_path(0.1); - let shape = Shape::new(path).with_fill(ShapeColor::rgb(100, 150, 250)); - - // Create an object for the shape - let object = Object::new(shape.id); - - // Create a vector layer - let mut vector_layer = VectorLayer::new("Layer 1"); - vector_layer.add_shape(shape); - vector_layer.add_object(object); - - // Add to document - doc.root.add_child(AnyLayer::Vector(vector_layer)); - // Build camera transform: translate for pan, scale for zoom use vello::kurbo::Affine; let camera_transform = Affine::translate((self.pan_offset.x as f64, self.pan_offset.y as f64)) * Affine::scale(self.zoom as f64); // Render the document to the scene with camera transform - lightningbeam_core::renderer::render_document_with_transform(&doc, &mut scene, camera_transform); + lightningbeam_core::renderer::render_document_with_transform(&self.document, &mut scene, camera_transform); - // Render scene to texture - if let Some(texture_view) = &vello_resources.texture_view { + // Render scene to texture using shared renderer + if let Some(texture_view) = &instance_resources.texture_view { let render_params = vello::RenderParams { base_color: vello::peniko::Color::rgb8(45, 45, 48), // Dark background width, @@ -271,7 +269,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { antialiasing_method: vello::AaConfig::Msaa16, }; - if let Ok(mut renderer) = vello_resources.renderer.lock() { + if let Ok(mut renderer) = shared.renderer.lock() { renderer .render_to_texture(device, queue, &scene, texture_view, &render_params) .ok(); @@ -293,20 +291,26 @@ impl egui_wgpu::CallbackTrait for VelloCallback { None => return, // Resources not initialized yet }; - // Get resources for this specific instance - let vello_resources = match map.instances.get(&self.instance_id) { + // Get shared resources + let shared = match &map.shared { + Some(s) => s, + None => return, // Shared resources not initialized yet + }; + + // Get instance resources + let instance_resources = match map.instances.get(&self.instance_id) { Some(r) => r, None => return, // Instance not initialized yet }; // Check if we have a bind group (texture ready) - let bind_group = match &vello_resources.blit_bind_group { + let bind_group = match &instance_resources.blit_bind_group { Some(bg) => bg, None => return, // Texture not ready yet }; - // Render fullscreen quad with our texture - render_pass.set_pipeline(&vello_resources.blit_pipeline); + // Render fullscreen quad with our texture (using shared pipeline) + render_pass.set_pipeline(&shared.blit_pipeline); render_pass.set_bind_group(0, bind_group, &[]); render_pass.draw(0..4, 0..1); // Triangle strip: 4 vertices } @@ -527,7 +531,7 @@ impl PaneRenderer for StagePane { } // Use egui's custom painting callback for Vello - let callback = VelloCallback::new(rect, self.pan_offset, self.zoom, self.instance_id); + let callback = VelloCallback::new(rect, self.pan_offset, self.zoom, self.instance_id, shared.document.clone()); let cb = egui_wgpu::Callback::new_paint_callback( rect, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 7587867..ac5f613 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -1,30 +1,554 @@ -/// Timeline pane - frame-based animation timeline +/// Timeline pane - Modern GarageBand-style timeline /// -/// This will eventually render keyframes, layers, and playback controls. -/// For now, it's a placeholder. +/// Phase 1 Implementation: Time Ruler & Playhead +/// - Time-based ruler (seconds, not frames) +/// - Playhead for current time +/// - Zoom/pan controls +/// - Basic layer visualization use eframe::egui; use super::{NodePath, PaneRenderer, SharedPaneState}; +const RULER_HEIGHT: f32 = 30.0; +const LAYER_HEIGHT: f32 = 60.0; +const LAYER_HEADER_WIDTH: f32 = 200.0; +const MIN_PIXELS_PER_SECOND: f32 = 20.0; +const MAX_PIXELS_PER_SECOND: f32 = 500.0; + pub struct TimelinePane { - // TODO: Add state for zoom, scroll, playback, etc. + /// Current playback time in seconds + current_time: f64, + + /// Horizontal zoom level (pixels per second) + pixels_per_second: f32, + + /// Horizontal scroll offset (in seconds) + viewport_start_time: f64, + + /// Vertical scroll offset (in pixels) + viewport_scroll_y: f32, + + /// Total duration of the animation + duration: f64, + + /// Is the user currently dragging the playhead? + is_scrubbing: bool, + + /// Is the user panning the timeline? + is_panning: bool, + last_pan_pos: Option, + + /// Is playback currently active? + is_playing: bool, + + /// Currently selected/active layer index + active_layer: usize, } impl TimelinePane { pub fn new() -> Self { - Self {} + Self { + current_time: 0.0, + pixels_per_second: 100.0, + viewport_start_time: 0.0, + active_layer: 0, + viewport_scroll_y: 0.0, + duration: 10.0, // Default 10 seconds + is_scrubbing: false, + is_panning: false, + last_pan_pos: None, + is_playing: false, + } + } + + /// Execute a view action with the given parameters + /// Called from main.rs after determining this is the best handler + pub fn execute_view_action(&mut self, action: &crate::menu::MenuAction, zoom_center: egui::Vec2) { + use crate::menu::MenuAction; + match action { + MenuAction::ZoomIn => self.zoom_in(zoom_center.x), + MenuAction::ZoomOut => self.zoom_out(zoom_center.x), + MenuAction::ActualSize => self.actual_size(), + MenuAction::RecenterView => self.recenter(), + _ => {} // Not a view action we handle + } + } + + /// Zoom in by a fixed increment + pub fn zoom_in(&mut self, center_x: f32) { + self.apply_zoom_at_point(0.2, center_x); + } + + /// Zoom out by a fixed increment + pub fn zoom_out(&mut self, center_x: f32) { + self.apply_zoom_at_point(-0.2, center_x); + } + + /// Reset zoom to 100 pixels per second + pub fn actual_size(&mut self) { + self.pixels_per_second = 100.0; + } + + /// Reset pan to start and zoom to default + pub fn recenter(&mut self) { + self.viewport_start_time = 0.0; + self.viewport_scroll_y = 0.0; + self.pixels_per_second = 100.0; + } + + /// Apply zoom while keeping the time under the cursor stationary + fn apply_zoom_at_point(&mut self, zoom_delta: f32, mouse_x: f32) { + let old_zoom = self.pixels_per_second; + + // Calculate time position under mouse before zoom + let time_at_mouse = self.x_to_time(mouse_x); + + // Apply zoom + let new_zoom = (old_zoom * (1.0 + zoom_delta)).clamp(MIN_PIXELS_PER_SECOND, MAX_PIXELS_PER_SECOND); + self.pixels_per_second = new_zoom; + + // Adjust viewport so the same time stays under the mouse + let new_mouse_x = self.time_to_x(time_at_mouse); + let time_delta = (new_mouse_x - mouse_x) / new_zoom; + self.viewport_start_time = (self.viewport_start_time + time_delta as f64).max(0.0); + } + + /// Convert time (seconds) to pixel x-coordinate + fn time_to_x(&self, time: f64) -> f32 { + ((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32 + } + + /// Convert pixel x-coordinate to time (seconds) + fn x_to_time(&self, x: f32) -> f64 { + self.viewport_start_time + (x / self.pixels_per_second) as f64 + } + + /// Calculate appropriate interval for time ruler based on zoom level + fn calculate_ruler_interval(&self) -> f64 { + // Target: 50-100px between major ticks + let target_px = 75.0; + let target_seconds = target_px / self.pixels_per_second; + + // Standard intervals: 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100... + let intervals = [0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0]; + + // Find the interval closest to our target + intervals.iter() + .min_by_key(|&&interval| ((interval - target_seconds as f64).abs() * 1000.0) as i32) + .copied() + .unwrap_or(1.0) + } + + /// Render the time ruler at the top + fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme) { + let painter = ui.painter(); + + // Background + let bg_style = theme.style(".timeline-background", ui.ctx()); + let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_rgb(34, 34, 34)); + painter.rect_filled( + rect, + 0.0, + bg_color, + ); + + // Get text color from theme + let text_style = theme.style(".text-primary", ui.ctx()); + let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); + + // Calculate interval for tick marks + let interval = self.calculate_ruler_interval(); + + // Draw tick marks and labels + let start_time = (self.viewport_start_time / interval).floor() * interval; + let end_time = self.x_to_time(rect.width()); + + let mut time = start_time; + while time <= end_time { + let x = self.time_to_x(time); + + if x >= 0.0 && x <= rect.width() { + // Major tick mark + painter.line_segment( + [ + rect.min + egui::vec2(x, rect.height() - 10.0), + rect.min + egui::vec2(x, rect.height()), + ], + egui::Stroke::new(1.0, egui::Color32::from_gray(100)), + ); + + // Time label + let label = format!("{:.1}s", time); + painter.text( + rect.min + egui::vec2(x + 2.0, 5.0), + egui::Align2::LEFT_TOP, + label, + egui::FontId::proportional(12.0), + text_color, + ); + } + + // Minor tick marks (subdivisions) + let minor_interval = interval / 5.0; + for i in 1..5 { + let minor_time = time + minor_interval * i as f64; + let minor_x = self.time_to_x(minor_time); + + if minor_x >= 0.0 && minor_x <= rect.width() { + painter.line_segment( + [ + rect.min + egui::vec2(minor_x, rect.height() - 5.0), + rect.min + egui::vec2(minor_x, rect.height()), + ], + egui::Stroke::new(1.0, egui::Color32::from_gray(60)), + ); + } + } + + time += interval; + } + } + + /// Render the playhead (current time indicator) + fn render_playhead(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme) { + let x = self.time_to_x(self.current_time); + + if x >= 0.0 && x <= rect.width() { + let painter = ui.painter(); + let scrubber_style = theme.style(".timeline-scrubber", ui.ctx()); + let scrubber_color = scrubber_style.background_color.unwrap_or(egui::Color32::from_rgb(204, 34, 34)); + + // Red vertical line + painter.line_segment( + [ + rect.min + egui::vec2(x, 0.0), + egui::pos2(rect.min.x + x, rect.max.y), + ], + egui::Stroke::new(2.0, scrubber_color), + ); + + // Playhead handle (triangle at top) + let handle_size = 8.0; + let points = vec![ + rect.min + egui::vec2(x, 0.0), + rect.min + egui::vec2(x - handle_size / 2.0, handle_size), + rect.min + egui::vec2(x + handle_size / 2.0, handle_size), + ]; + painter.add(egui::Shape::convex_polygon( + points, + scrubber_color, + egui::Stroke::NONE, + )); + } + } + + /// Render layer header column (left side with track names and controls) + fn render_layer_headers(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme) { + let painter = ui.painter(); + + // Background for header column + let header_style = theme.style(".timeline-header", ui.ctx()); + let header_bg = header_style.background_color.unwrap_or(egui::Color32::from_rgb(17, 17, 17)); + painter.rect_filled( + rect, + 0.0, + header_bg, + ); + + // Theme colors for active/inactive layers + let active_style = theme.style(".timeline-layer-active", ui.ctx()); + let inactive_style = theme.style(".timeline-layer-inactive", ui.ctx()); + let active_color = active_style.background_color.unwrap_or(egui::Color32::from_rgb(79, 79, 79)); + let inactive_color = inactive_style.background_color.unwrap_or(egui::Color32::from_rgb(51, 51, 51)); + + // Get text color from theme + let text_style = theme.style(".text-primary", ui.ctx()); + let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); + + // Test: Draw 3 layer headers + for i in 0..3 { + let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y; + + // Skip if layer is outside visible area + if y + LAYER_HEIGHT < rect.min.y || y > rect.max.y { + continue; + } + + let header_rect = egui::Rect::from_min_size( + egui::pos2(rect.min.x, y), + egui::vec2(LAYER_HEADER_WIDTH, LAYER_HEIGHT), + ); + + // Active vs inactive background colors + let bg_color = if i == self.active_layer { + active_color + } else { + inactive_color + }; + + painter.rect_filled(header_rect, 0.0, bg_color); + + // Layer name + painter.text( + header_rect.min + egui::vec2(10.0, 10.0), + egui::Align2::LEFT_TOP, + format!("Layer {}", i + 1), + egui::FontId::proportional(14.0), + text_color, + ); + + // Separator line at bottom + painter.line_segment( + [ + egui::pos2(header_rect.min.x, header_rect.max.y), + egui::pos2(header_rect.max.x, header_rect.max.y), + ], + egui::Stroke::new(1.0, egui::Color32::from_gray(20)), + ); + } + + // Right border for header column + painter.line_segment( + [ + egui::pos2(rect.max.x, rect.min.y), + egui::pos2(rect.max.x, rect.max.y), + ], + egui::Stroke::new(1.0, egui::Color32::from_gray(20)), + ); + } + + /// Render layer rows (timeline content area) + fn render_layers(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme) { + let painter = ui.painter(); + + // Theme colors for active/inactive layers + let active_style = theme.style(".timeline-row-active", ui.ctx()); + let inactive_style = theme.style(".timeline-row-inactive", ui.ctx()); + let active_color = active_style.background_color.unwrap_or(egui::Color32::from_rgb(85, 85, 85)); + let inactive_color = inactive_style.background_color.unwrap_or(egui::Color32::from_rgb(136, 136, 136)); + + // Test: Draw 3 layer rows + for i in 0..3 { + let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y; + + // Skip if layer is outside visible area + if y + LAYER_HEIGHT < rect.min.y || y > rect.max.y { + continue; + } + + let layer_rect = egui::Rect::from_min_size( + egui::pos2(rect.min.x, y), + egui::vec2(rect.width(), LAYER_HEIGHT), + ); + + // Active vs inactive background colors + let bg_color = if i == self.active_layer { + active_color + } else { + inactive_color + }; + + painter.rect_filled(layer_rect, 0.0, bg_color); + + // Grid lines matching ruler + let interval = self.calculate_ruler_interval(); + let start_time = (self.viewport_start_time / interval).floor() * interval; + let end_time = self.x_to_time(rect.width()); + + let mut time = start_time; + while time <= end_time { + let x = self.time_to_x(time); + + if x >= 0.0 && x <= rect.width() { + painter.line_segment( + [ + egui::pos2(rect.min.x + x, y), + egui::pos2(rect.min.x + x, y + LAYER_HEIGHT), + ], + egui::Stroke::new(1.0, egui::Color32::from_gray(30)), + ); + } + + time += interval; + } + + // Separator line at bottom + painter.line_segment( + [ + egui::pos2(layer_rect.min.x, layer_rect.max.y), + egui::pos2(layer_rect.max.x, layer_rect.max.y), + ], + egui::Stroke::new(1.0, egui::Color32::from_gray(20)), + ); + } + } + + /// Handle mouse input for scrubbing, panning, and zooming + fn handle_input(&mut self, ui: &mut egui::Ui, full_timeline_rect: egui::Rect, ruler_rect: egui::Rect, content_rect: egui::Rect) { + let response = ui.allocate_rect(full_timeline_rect, egui::Sense::click_and_drag()); + + // Only process input if mouse is over the timeline pane + if !response.hovered() { + self.is_panning = false; + self.last_pan_pos = None; + self.is_scrubbing = false; + return; + } + + let alt_held = ui.input(|i| i.modifiers.alt); + let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command); + + // Get mouse position relative to content area + let mouse_pos = response.hover_pos().unwrap_or(content_rect.center()); + let mouse_x = (mouse_pos.x - content_rect.min.x).max(0.0); + + // Calculate max vertical scroll based on number of layers + // TODO: Get actual layer count from document - for now using test count of 3 + const TEST_LAYER_COUNT: usize = 3; + let total_content_height = TEST_LAYER_COUNT as f32 * LAYER_HEIGHT; + let visible_height = content_rect.height(); + let max_scroll_y = (total_content_height - visible_height).max(0.0); + + // Scrubbing (clicking/dragging on ruler, but only when not panning) + if ruler_rect.contains(ui.input(|i| i.pointer.hover_pos().unwrap_or_default())) && !alt_held { + if response.clicked() || (response.dragged() && !self.is_panning) { + if let Some(pos) = response.interact_pointer_pos() { + let x = (pos.x - content_rect.min.x).max(0.0); + self.current_time = self.x_to_time(x).max(0.0).min(self.duration); + self.is_scrubbing = true; + } + } else if !response.dragged() { + self.is_scrubbing = false; + } + } else { + if !response.dragged() { + self.is_scrubbing = false; + } + } + + // Distinguish between mouse wheel (discrete) and trackpad (smooth) + let mut handled = false; + ui.input(|i| { + for event in &i.raw.events { + if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event { + match unit { + egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => { + // Real mouse wheel (discrete clicks) -> always zoom horizontally + let zoom_delta = if ctrl_held || modifiers.ctrl { + delta.y * 0.01 // Ctrl+wheel: faster zoom + } else { + delta.y * 0.005 // Normal zoom + }; + self.apply_zoom_at_point(zoom_delta, mouse_x); + handled = true; + } + egui::MouseWheelUnit::Point => { + // Trackpad (smooth scrolling) + if ctrl_held || modifiers.ctrl { + // Ctrl held: zoom + let zoom_delta = delta.y * 0.005; + self.apply_zoom_at_point(zoom_delta, mouse_x); + handled = true; + } + // Otherwise let scroll_delta handle panning (below) + } + } + } + } + }); + + // Handle scroll_delta for trackpad panning (when Ctrl not held) + if !handled { + let scroll_delta = ui.input(|i| i.smooth_scroll_delta); + if scroll_delta.x.abs() > 0.0 || scroll_delta.y.abs() > 0.0 { + // Horizontal scroll: pan timeline (inverted: positive delta scrolls left/earlier in time) + let delta_time = scroll_delta.x / self.pixels_per_second; + self.viewport_start_time = (self.viewport_start_time - delta_time as f64).max(0.0); + + // Vertical scroll: scroll layers vertically (clamped to content bounds) + self.viewport_scroll_y = (self.viewport_scroll_y - scroll_delta.y).clamp(0.0, max_scroll_y); + } + } + + // Handle panning with Alt+Drag (timeline scrolls left/right, layers scroll up/down) + if alt_held && response.dragged() && !self.is_scrubbing { + if let Some(last_pos) = self.last_pan_pos { + if let Some(current_pos) = response.interact_pointer_pos() { + let delta = current_pos - last_pos; + + // Horizontal pan: timeline + let delta_time = delta.x / self.pixels_per_second; + self.viewport_start_time = (self.viewport_start_time - delta_time as f64).max(0.0); + + // Vertical pan: layers (clamped to content bounds) + self.viewport_scroll_y = (self.viewport_scroll_y - delta.y).clamp(0.0, max_scroll_y); + } + } + self.last_pan_pos = response.interact_pointer_pos(); + self.is_panning = true; + } else { + if !response.dragged() { + self.is_panning = false; + self.last_pan_pos = None; + } + } } } impl PaneRenderer for TimelinePane { - fn render_header(&mut self, ui: &mut egui::Ui, _shared: &mut SharedPaneState) -> bool { - // TODO: Add playback controls (play/pause, frame counter, zoom) - ui.horizontal(|ui| { - ui.label("⏯"); - ui.label("Frame: 0"); - ui.label("FPS: 24"); + fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { + ui.spacing_mut().item_spacing.x = 2.0; // Small spacing between button groups + + // Main playback controls group + ui.group(|ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; // No spacing between buttons + let button_size = egui::vec2(32.0, 28.0); // Larger buttons + + // Go to start + if ui.add_sized(button_size, egui::Button::new("|β—€")).clicked() { + self.current_time = 0.0; + } + + // Rewind (step backward) + if ui.add_sized(button_size, egui::Button::new("β—€β—€")).clicked() { + self.current_time = (self.current_time - 0.1).max(0.0); + } + + // Play/Pause toggle + let play_pause_text = if self.is_playing { "⏸" } else { "β–Ά" }; + if ui.add_sized(button_size, egui::Button::new(play_pause_text)).clicked() { + self.is_playing = !self.is_playing; + // TODO: Actually start/stop playback + } + + // Fast forward (step forward) + if ui.add_sized(button_size, egui::Button::new("β–Άβ–Ά")).clicked() { + self.current_time = (self.current_time + 0.1).min(self.duration); + } + + // Go to end + if ui.add_sized(button_size, egui::Button::new("β–Ά|")).clicked() { + self.current_time = self.duration; + } + }); }); - true // Header was rendered + + ui.separator(); + + // Get text color from theme + let text_style = shared.theme.style(".text-primary", ui.ctx()); + let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); + + // Time display + ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", self.current_time, self.duration)); + + ui.separator(); + + // Zoom display + ui.colored_label(text_color, format!("Zoom: {:.0}px/s", self.pixels_per_second)); + + true } fn render_content( @@ -32,23 +556,128 @@ impl PaneRenderer for TimelinePane { ui: &mut egui::Ui, rect: egui::Rect, _path: &NodePath, - _shared: &mut SharedPaneState, + shared: &mut SharedPaneState, ) { - // Placeholder rendering - ui.painter().rect_filled( - rect, - 0.0, - egui::Color32::from_rgb(40, 30, 50), + // Split into layer header column (left) and timeline content (right) + let header_column_rect = egui::Rect::from_min_size( + rect.min, + egui::vec2(LAYER_HEADER_WIDTH, rect.height()), ); - let text = "Timeline Pane\n(TODO: Implement frame scrubbing)"; - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - text, - egui::FontId::proportional(16.0), - egui::Color32::from_gray(150), + let timeline_rect = egui::Rect::from_min_size( + rect.min + egui::vec2(LAYER_HEADER_WIDTH, 0.0), + egui::vec2(rect.width() - LAYER_HEADER_WIDTH, rect.height()), ); + + // Split timeline into ruler and content areas + let ruler_rect = egui::Rect::from_min_size( + timeline_rect.min, + egui::vec2(timeline_rect.width(), RULER_HEIGHT), + ); + + let content_rect = egui::Rect::from_min_size( + timeline_rect.min + egui::vec2(0.0, RULER_HEIGHT), + egui::vec2(timeline_rect.width(), timeline_rect.height() - RULER_HEIGHT), + ); + + // Split header column into ruler area (top) and layer headers (bottom) + let header_ruler_spacer = egui::Rect::from_min_size( + header_column_rect.min, + egui::vec2(LAYER_HEADER_WIDTH, RULER_HEIGHT), + ); + + let layer_headers_rect = egui::Rect::from_min_size( + header_column_rect.min + egui::vec2(0.0, RULER_HEIGHT), + egui::vec2(LAYER_HEADER_WIDTH, header_column_rect.height() - RULER_HEIGHT), + ); + + // Save original clip rect to restore at the end + let original_clip_rect = ui.clip_rect(); + + // Render spacer above layer headers (same height as ruler) + let spacer_style = shared.theme.style(".timeline-spacer", ui.ctx()); + let spacer_bg = spacer_style.background_color.unwrap_or(egui::Color32::from_rgb(17, 17, 17)); + ui.painter().rect_filled( + header_ruler_spacer, + 0.0, + spacer_bg, + ); + + // Render layer header column with clipping + ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); + self.render_layer_headers(ui, layer_headers_rect, shared.theme); + + // Render time ruler (clip to ruler rect) + ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); + self.render_ruler(ui, ruler_rect, shared.theme); + + // Render layer rows with clipping + ui.set_clip_rect(content_rect.intersect(original_clip_rect)); + self.render_layers(ui, content_rect, shared.theme); + + // Render playhead on top (clip to timeline area) + ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); + self.render_playhead(ui, timeline_rect, shared.theme); + + // Restore original clip rect + ui.set_clip_rect(original_clip_rect); + + // Handle input (use full rect including header column) + self.handle_input(ui, rect, ruler_rect, content_rect); + + // Register handler for pending view actions (two-phase dispatch) + // Priority: Mouse-over (0-99) > Fallback Timeline(1001) + const TIMELINE_MOUSE_OVER_PRIORITY: u32 = 0; + const TIMELINE_FALLBACK_PRIORITY: u32 = 1001; + + let mouse_over = ui.rect_contains_pointer(rect); + + // Determine our priority for this action + let our_priority = if mouse_over { + TIMELINE_MOUSE_OVER_PRIORITY // High priority - mouse is over this pane + } else { + TIMELINE_FALLBACK_PRIORITY // Low priority - just a fallback option + }; + + // Check if we should register as a handler (better priority than current best) + let should_register = shared.pending_view_action.is_some() && + shared.fallback_pane_priority.map_or(true, |p| our_priority < p); + + if should_register { + // Update fallback priority tracker + *shared.fallback_pane_priority = Some(our_priority); + + // Register as a handler (don't execute yet - that happens after all panes render) + if let Some(action) = &shared.pending_view_action { + use crate::menu::MenuAction; + + // Determine zoom center point (use x-position only for timeline horizontal zoom) + let center = if mouse_over { + // Use mouse position for zoom-to-cursor + let mouse_pos = ui.input(|i| i.pointer.hover_pos()).unwrap_or(rect.center()); + mouse_pos - rect.min + } else { + // Use center of viewport for fallback + rect.size() / 2.0 + }; + + // Only register for actions we can handle + match action { + MenuAction::ZoomIn | MenuAction::ZoomOut | + MenuAction::ActualSize | MenuAction::RecenterView => { + shared.pending_handlers.push(super::ViewActionHandler { + priority: our_priority, + pane_path: _path.clone(), + zoom_center: center, + }); + } + _ => { + // Not a view action we handle - reset priority so others can try + *shared.fallback_pane_priority = None; + } + } + } + } } fn name(&self) -> &str { diff --git a/lightningbeam-ui/lightningbeam-editor/src/theme.rs b/lightningbeam-ui/lightningbeam-editor/src/theme.rs new file mode 100644 index 0000000..1aa8bd1 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/theme.rs @@ -0,0 +1,405 @@ +/// Theme system for Lightningbeam Editor +/// +/// Parses CSS rules from assets/styles.css at runtime +/// and provides type-safe access to styles via selectors. + +use eframe::egui; +use lightningcss::stylesheet::{ParserOptions, PrinterOptions, StyleSheet}; +use lightningcss::traits::ToCss; +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemeMode { + Light, + Dark, + System, // Follow system preference +} + +/// Style properties that can be applied to UI elements +#[derive(Debug, Clone, Default)] +pub struct Style { + pub background_color: Option, + pub border_color: Option, + pub text_color: Option, + pub width: Option, + pub height: Option, + // Add more properties as needed +} + +impl Style { + /// Merge another style into this one (other's properties override if present) + pub fn merge(&mut self, other: &Style) { + if other.background_color.is_some() { + self.background_color = other.background_color; + } + if other.border_color.is_some() { + self.border_color = other.border_color; + } + if other.text_color.is_some() { + self.text_color = other.text_color; + } + if other.width.is_some() { + self.width = other.width; + } + if other.height.is_some() { + self.height = other.height; + } + } +} + +#[derive(Debug, Clone)] +pub struct Theme { + light_variables: HashMap, + dark_variables: HashMap, + light_styles: HashMap, + dark_styles: HashMap, + current_mode: ThemeMode, +} + +impl Theme { + /// Load theme from CSS file + pub fn from_css(css: &str) -> Result { + let stylesheet = StyleSheet::parse( + css, + ParserOptions::default(), + ).map_err(|e| format!("Failed to parse CSS: {:?}", e))?; + + let mut light_variables = HashMap::new(); + let mut dark_variables = HashMap::new(); + let mut light_styles = HashMap::new(); + let mut dark_styles = HashMap::new(); + + // First pass: Extract CSS custom properties from :root + for rule in &stylesheet.rules.0 { + match rule { + lightningcss::rules::CssRule::Style(style_rule) => { + let selectors = style_rule.selectors.0.iter() + .filter_map(|s| s.to_css_string(PrinterOptions::default()).ok()) + .collect::>(); + + // Check if this is :root + if selectors.iter().any(|s| s.contains(":root")) { + extract_css_variables(&style_rule.declarations, &mut light_variables)?; + } + } + lightningcss::rules::CssRule::Media(media_rule) => { + let media_str = media_rule.query.to_css_string(PrinterOptions::default()) + .unwrap_or_default(); + + if media_str.contains("prefers-color-scheme") && media_str.contains("dark") { + for inner_rule in &media_rule.rules.0 { + if let lightningcss::rules::CssRule::Style(style_rule) = inner_rule { + let selectors = style_rule.selectors.0.iter() + .filter_map(|s| s.to_css_string(PrinterOptions::default()).ok()) + .collect::>(); + + if selectors.iter().any(|s| s.contains(":root")) { + extract_css_variables(&style_rule.declarations, &mut dark_variables)?; + } + } + } + } + } + _ => {} + } + } + + // Second pass: Parse style rules and resolve var() references + // We need to parse selectors TWICE - once with light variables, once with dark variables + for rule in &stylesheet.rules.0 { + match rule { + lightningcss::rules::CssRule::Style(style_rule) => { + let selectors = style_rule.selectors.0.iter() + .filter_map(|s| s.to_css_string(PrinterOptions::default()).ok()) + .collect::>(); + + for selector in selectors { + let selector = selector.trim(); + // Only process class and ID selectors + if selector.starts_with('.') || selector.starts_with('#') { + // Parse with light variables + let light_style = parse_style_properties(&style_rule.declarations, &light_variables)?; + light_styles.insert(selector.to_string(), light_style); + + // Also parse with dark variables (merge dark over light) + let mut dark_vars = light_variables.clone(); + dark_vars.extend(dark_variables.clone()); + let dark_style = parse_style_properties(&style_rule.declarations, &dark_vars)?; + dark_styles.insert(selector.to_string(), dark_style); + } + } + } + lightningcss::rules::CssRule::Media(media_rule) => { + let media_str = media_rule.query.to_css_string(PrinterOptions::default()) + .unwrap_or_default(); + + eprintln!("πŸ” Found media query: {}", media_str); + eprintln!(" Contains {} rules", media_rule.rules.0.len()); + + if media_str.contains("prefers-color-scheme") && media_str.contains("dark") { + eprintln!(" βœ“ This is a dark mode media query!"); + for (i, inner_rule) in media_rule.rules.0.iter().enumerate() { + eprintln!(" Rule {}: {:?}", i, std::mem::discriminant(inner_rule)); + if let lightningcss::rules::CssRule::Style(style_rule) = inner_rule { + let selectors = style_rule.selectors.0.iter() + .filter_map(|s| s.to_css_string(PrinterOptions::default()).ok()) + .collect::>(); + + eprintln!(" Found selectors: {:?}", selectors); + + for selector in selectors { + let selector = selector.trim(); + if selector.starts_with('.') || selector.starts_with('#') { + // Merge dark and light variables (dark overrides light) + let mut vars = light_variables.clone(); + vars.extend(dark_variables.clone()); + let style = parse_style_properties(&style_rule.declarations, &vars)?; + dark_styles.insert(selector.to_string(), style); + eprintln!(" Added dark style for: {}", selector); + } + } + } + } + } + } + _ => {} + } + } + + Ok(Self { + light_variables, + dark_variables, + light_styles, + dark_styles, + current_mode: ThemeMode::System, + }) + } + + /// Load theme from embedded CSS file + pub fn load_default() -> Result { + let css = include_str!("../assets/styles.css"); + Self::from_css(css) + } + + /// Set the current theme mode + pub fn set_mode(&mut self, mode: ThemeMode) { + self.current_mode = mode; + } + + /// Get the current theme mode + pub fn mode(&self) -> ThemeMode { + self.current_mode + } + + /// Get style for a selector (e.g., ".panel" or "#timeline-header") + pub fn style(&self, selector: &str, ctx: &egui::Context) -> Style { + let is_dark = match self.current_mode { + ThemeMode::Light => false, + ThemeMode::Dark => true, + ThemeMode::System => ctx.style().visuals.dark_mode, + }; + + if is_dark { + // Try dark style first, fall back to light style + self.dark_styles.get(selector).cloned() + .or_else(|| self.light_styles.get(selector).cloned()) + .unwrap_or_default() + } else { + self.light_styles.get(selector).cloned().unwrap_or_default() + } + } + + /// Get a CSS variable value and parse as color (backward compatibility helper) + /// This allows old code using theme.color("variable-name") to work + pub fn color(&self, var_name: &str) -> Option { + // Try light variables first, then dark variables + let value = self.light_variables.get(var_name) + .or_else(|| self.dark_variables.get(var_name))?; + parse_hex_color(value) + } + + /// Get the number of loaded selectors + pub fn len(&self) -> usize { + self.light_styles.len() + } + + /// Check if theme has no styles + pub fn is_empty(&self) -> bool { + self.light_styles.is_empty() + } + + /// Debug: print loaded theme info + pub fn debug_print(&self) { + println!("πŸ“Š Theme Debug Info:"); + println!(" Light variables: {}", self.light_variables.len()); + for (k, v) in self.light_variables.iter().take(5) { + println!(" --{}: {}", k, v); + } + println!(" Dark variables: {}", self.dark_variables.len()); + for (k, v) in self.dark_variables.iter().take(5) { + println!(" --{}: {}", k, v); + } + println!(" Light styles: {}", self.light_styles.len()); + for k in self.light_styles.keys().take(5) { + println!(" {}", k); + } + println!(" Dark styles: {}", self.dark_styles.len()); + for k in self.dark_styles.keys().take(5) { + println!(" {}", k); + } + } +} + +/// Extract CSS custom properties (--variables) from declarations +fn extract_css_variables( + declarations: &lightningcss::declaration::DeclarationBlock, + variables: &mut HashMap, +) -> Result<(), String> { + for property in &declarations.declarations { + if let lightningcss::properties::Property::Custom(_) = property { + let property_css = property.to_css_string(false, PrinterOptions::default()) + .map_err(|e| format!("Failed to serialize property: {:?}", e))?; + + if let Some((name, value)) = property_css.split_once(':') { + let name = name.trim().strip_prefix("--").unwrap_or(name.trim()).to_string(); + let value = value.trim().to_string(); + variables.insert(name, value); + } + } + } + Ok(()) +} + +/// Parse style properties from CSS declarations into a Style struct, resolving var() references +fn parse_style_properties( + declarations: &lightningcss::declaration::DeclarationBlock, + variables: &HashMap, +) -> Result { + let mut style = Style::default(); + + for property in &declarations.declarations { + // Convert property to CSS string and parse + let prop_str = property.to_css_string(false, PrinterOptions::default()) + .map_err(|e| format!("Failed to serialize property: {:?}", e))?; + + // Parse property name and value + if let Some((name, value)) = prop_str.split_once(':') { + let name = name.trim(); + let value = value.trim().trim_end_matches(';'); + + match name { + "background-color" => { + style.background_color = parse_color_value(value, variables); + } + "border-color" | "border-top-color" => { + style.border_color = parse_color_value(value, variables); + } + "color" => { + style.text_color = parse_color_value(value, variables); + } + "width" => { + style.width = parse_dimension_value(value, variables); + } + "height" => { + style.height = parse_dimension_value(value, variables); + } + _ => {} + } + } + } + + Ok(style) +} + +/// Parse a CSS color value (hex or var()) +fn parse_color_value(value: &str, variables: &HashMap) -> Option { + let value = value.trim(); + + // Check if it's a var() reference + if let Some(var_name) = parse_var_reference(value) { + let resolved = variables.get(&var_name)?; + return parse_hex_color(resolved); + } + + // Try to parse as direct hex color + parse_hex_color(value) +} + +/// Parse a CSS dimension value (px or var()) +fn parse_dimension_value(value: &str, variables: &HashMap) -> Option { + let value = value.trim(); + + // Check if it's a var() reference + if let Some(var_name) = parse_var_reference(value) { + let resolved = variables.get(&var_name)?; + return parse_dimension_string(resolved); + } + + // Try to parse as direct dimension + parse_dimension_string(value) +} + +/// Parse a var() reference to get the variable name +fn parse_var_reference(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.starts_with("var(") && trimmed.ends_with(')') { + let inner = trimmed.strip_prefix("var(")?.strip_suffix(')')?; + let var_name = inner.trim().strip_prefix("--")?; + Some(var_name.to_string()) + } else { + None + } +} + +/// Parse hex color string to egui::Color32 +fn parse_hex_color(value: &str) -> Option { + let value = value.trim(); + if !value.starts_with('#') { + return None; + } + + let hex = value.trim_start_matches('#'); + match hex.len() { + 3 => { + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?; + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?; + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?; + Some(egui::Color32::from_rgb(r, g, b)) + } + 6 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(egui::Color32::from_rgb(r, g, b)) + } + 8 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + let a = u8::from_str_radix(&hex[6..8], 16).ok()?; + Some(egui::Color32::from_rgba_unmultiplied(r, g, b, a)) + } + _ => None, + } +} + +/// Parse dimension string (e.g., "50px" or "25") +fn parse_dimension_string(value: &str) -> Option { + let value = value.trim(); + if let Some(stripped) = value.strip_suffix("px") { + stripped.trim().parse::().ok() + } else { + value.parse::().ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_default_theme() { + let theme = Theme::load_default().expect("Failed to load default theme"); + assert!(!theme.is_empty(), "Theme should have styles loaded"); + } +}