UI fidelity pass from the Premiere 26 black-box reference: measured palette and label colours, square panels with 4 pt gutters, tab strips with underline + menu glyph, header layout, native macOS menu bar (muda), status/hint bar, Properties panel, timeline restyle (clip bodies, name band, fx badge, head thumbnails, rectified waveforms, hatched transitions, track header layout with patch/target buttons, markers row, 30 pt toolbar, zoom bar knobs), monitor controls, meters, tools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3696e4ee80
commit
627931fc72
|
|
@ -368,6 +368,29 @@ dependencies = [
|
|||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
|
||||
dependencies = [
|
||||
"atk-sys",
|
||||
"glib",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk-sys"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
|
|
@ -534,6 +557,31 @@ version = "1.12.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cairo-rs"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cairo-sys-rs",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cairo-sys-rs"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.13.0"
|
||||
|
|
@ -603,6 +651,16 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
|
|
@ -785,6 +843,15 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
|
|
@ -1207,6 +1274,16 @@ version = "0.4.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1217,6 +1294,7 @@ dependencies = [
|
|||
"filmcraft-engine",
|
||||
"filmcraft-ui-egui",
|
||||
"log",
|
||||
"muda",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
]
|
||||
|
|
@ -1631,6 +1709,64 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
|
||||
dependencies = [
|
||||
"cairo-rs",
|
||||
"gdk-pixbuf",
|
||||
"gdk-sys",
|
||||
"gio",
|
||||
"glib",
|
||||
"libc",
|
||||
"pango",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk-pixbuf"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
|
||||
dependencies = [
|
||||
"gdk-pixbuf-sys",
|
||||
"gio",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk-pixbuf-sys"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
|
||||
dependencies = [
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gdk-sys"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
|
||||
dependencies = [
|
||||
"cairo-sys-rs",
|
||||
"gdk-pixbuf-sys",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"pkg-config",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
|
|
@ -1674,6 +1810,38 @@ dependencies = [
|
|||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"gio-sys",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio-sys"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
|
|
@ -1685,6 +1853,53 @@ dependencies = [
|
|||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"gio-sys",
|
||||
"glib-macros",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"smallvec",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-macros"
|
||||
version = "0.18.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"proc-macro-crate 2.0.2",
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-sys"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glifo"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1779,6 +1994,17 @@ dependencies = [
|
|||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpu-allocator"
|
||||
version = "0.28.0"
|
||||
|
|
@ -1793,6 +2019,58 @@ dependencies = [
|
|||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
|
||||
dependencies = [
|
||||
"atk",
|
||||
"cairo-rs",
|
||||
"field-offset",
|
||||
"futures-channel",
|
||||
"gdk",
|
||||
"gdk-pixbuf",
|
||||
"gio",
|
||||
"glib",
|
||||
"gtk-sys",
|
||||
"gtk3-macros",
|
||||
"libc",
|
||||
"pango",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk-sys"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
|
||||
dependencies = [
|
||||
"atk-sys",
|
||||
"cairo-sys-rs",
|
||||
"gdk-pixbuf-sys",
|
||||
"gdk-sys",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gtk3-macros"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
|
||||
dependencies = [
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "guillotiere"
|
||||
version = "0.7.0"
|
||||
|
|
@ -1846,6 +2124,18 @@ dependencies = [
|
|||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
|
|
@ -2039,6 +2329,16 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
|
|
@ -2221,6 +2521,27 @@ dependencies = [
|
|||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"gtk",
|
||||
"keyboard-types",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"once_cell",
|
||||
"png",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
|
|
@ -2338,7 +2659,7 @@ version = "0.7.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
|
|
@ -2727,6 +3048,31 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
|
||||
dependencies = [
|
||||
"gio",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pango-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango-sys"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
|
||||
dependencies = [
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
|
|
@ -2951,13 +3297,57 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"toml_edit 0.19.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
|
||||
dependencies = [
|
||||
"toml_datetime 0.6.3",
|
||||
"toml_edit 0.20.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
"toml_edit 0.25.15+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3445,6 +3835,15 @@ dependencies = [
|
|||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
|
|
@ -3781,6 +4180,16 @@ dependencies = [
|
|||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
|
|
@ -3803,6 +4212,25 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
|
||||
dependencies = [
|
||||
"cfg-expr",
|
||||
"heck 0.5.0",
|
||||
"pkg-config",
|
||||
"toml",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
|
|
@ -3919,6 +4347,27 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.3",
|
||||
"toml_edit 0.20.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
|
|
@ -3928,6 +4377,30 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.19.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime 0.6.3",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.3",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.15+spec-1.1.0"
|
||||
|
|
@ -3935,9 +4408,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3946,7 +4419,7 @@ version = "1.1.3+spec-1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4070,6 +4543,12 @@ dependencies = [
|
|||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
|
@ -4510,6 +4989,22 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
|
|
@ -4519,6 +5014,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
|
|
@ -4940,6 +5441,15 @@ dependencies = [
|
|||
"xkbcommon-dl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
|
|
@ -5054,7 +5564,7 @@ dependencies = [
|
|||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
|
|
@ -5090,7 +5600,7 @@ version = "5.19.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
|
|
@ -5106,7 +5616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
|
|
@ -5117,7 +5627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
|
@ -5187,7 +5697,7 @@ dependencies = [
|
|||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
|
|
@ -5199,7 +5709,7 @@ version = "5.15.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
|
|
@ -5216,5 +5726,5 @@ dependencies = [
|
|||
"quote",
|
||||
"serde",
|
||||
"syn 3.0.6",
|
||||
"winnow",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -18,3 +18,6 @@ serde_json = { workspace = true }
|
|||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = "0.16"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = "0.21"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
mod audio;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
use filmcraft_engine::Session;
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
|
|
@ -75,6 +77,12 @@ fn main() -> eframe::Result {
|
|||
}));
|
||||
app.hooks.pick_open_project =
|
||||
Some(Box::new(|| rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).pick_file().map(|p| p.to_string_lossy().to_string())));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let rx = native_menu::install(&app, cc.egui_ctx.clone());
|
||||
app.command_inbox = Some(rx);
|
||||
app.ui.show_menu_bar = false;
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
//! Native macOS menu bar (muda), generated from the command registry — like Premiere, menus live in
|
||||
//! the system menu bar. Items send command ids to the app's command inbox.
|
||||
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, menu_items};
|
||||
use muda::accelerator::Accelerator;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
fn accel(s: &str) -> Option<Accelerator> {
|
||||
// Only shortcuts with a modifier become native key equivalents (single keys stay in-app so
|
||||
// typing in fields keeps working).
|
||||
if !(s.contains("Cmd+") || s.contains("Ctrl+") || s.contains("Alt+")) {
|
||||
return None;
|
||||
}
|
||||
let mapped = s
|
||||
.replace("Cmd+", "CmdOrCtrl+")
|
||||
.replace(";", "Semicolon")
|
||||
.replace("'", "Quote")
|
||||
.replace("/", "Slash")
|
||||
.replace("=", "Equal")
|
||||
.replace("\\", "Backslash");
|
||||
mapped.parse().ok()
|
||||
}
|
||||
|
||||
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver<String> {
|
||||
let items = menu_items(app);
|
||||
let bar = Menu::new();
|
||||
let app_menu = Submenu::new("FilmCraft", true);
|
||||
let _ = app_menu.append_items(&[
|
||||
&MenuItem::with_id("app.about", "About FilmCraft", true, None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::hide(None),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::quit(None),
|
||||
]);
|
||||
let _ = bar.append(&app_menu);
|
||||
for top in MENUS {
|
||||
let sub = Submenu::new(top, true);
|
||||
let mine: Vec<&Item> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect();
|
||||
let mut subs: Vec<(String, Submenu)> = Vec::new();
|
||||
for it in mine {
|
||||
let mi = MenuItem::with_id(it.id.clone(), &it.label, true, it.shortcut.as_deref().and_then(accel));
|
||||
if let Some(name) = it.path.get(1) {
|
||||
if let Some((_, s)) = subs.iter().find(|(n, _)| n == name) {
|
||||
let _ = s.append(&mi);
|
||||
} else {
|
||||
let s = Submenu::new(name, true);
|
||||
let _ = s.append(&mi);
|
||||
let _ = sub.append(&s);
|
||||
subs.push((name.clone(), s));
|
||||
}
|
||||
} else {
|
||||
let _ = sub.append(&mi);
|
||||
}
|
||||
}
|
||||
let _ = bar.append(&sub);
|
||||
}
|
||||
bar.init_for_nsapp();
|
||||
if let Some(help) = bar.items().last().and_then(|i| i.as_submenu().cloned()) {
|
||||
help.set_as_help_menu_for_nsapp();
|
||||
}
|
||||
Box::leak(Box::new(bar));
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(ev) = MenuEvent::receiver().recv() {
|
||||
if tx.send(ev.id().0.clone()).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
|
@ -94,24 +94,37 @@ impl Label {
|
|||
Label::Yellow => "Yellow",
|
||||
}
|
||||
}
|
||||
/// Label colours (Premiere 26 Spectrum defaults, measured; see plan/premiere/02-ui-ux.md §1.5).
|
||||
pub fn rgb(self) -> [u8; 3] {
|
||||
match self {
|
||||
Label::Violet => [166, 137, 219],
|
||||
Label::Iris => [138, 151, 230],
|
||||
Label::Caribbean => [37, 185, 148],
|
||||
Label::Lavender => [224, 143, 222],
|
||||
Label::Cerulean => [55, 161, 209],
|
||||
Label::Forest => [96, 163, 57],
|
||||
Label::Rose => [225, 92, 124],
|
||||
Label::Mango => [237, 150, 58],
|
||||
Label::Purple => [146, 83, 196],
|
||||
Label::Blue => [60, 104, 229],
|
||||
Label::Teal => [36, 145, 157],
|
||||
Label::Magenta => [224, 71, 172],
|
||||
Label::Tan => [199, 167, 124],
|
||||
Label::Green => [78, 173, 72],
|
||||
Label::Brown => [150, 97, 58],
|
||||
Label::Yellow => [228, 208, 66],
|
||||
Label::Violet => [0x38, 0x0e, 0xa7],
|
||||
Label::Iris => [0x1d, 0x4a, 0x64],
|
||||
Label::Caribbean => [0x35, 0x54, 0x18],
|
||||
Label::Lavender => [0x6b, 0x1c, 0x82],
|
||||
Label::Cerulean => [0x23, 0x53, 0x5a],
|
||||
Label::Forest => [0x40, 0x4a, 0x11],
|
||||
Label::Rose => [0x80, 0x18, 0x36],
|
||||
Label::Mango => [0x80, 0x3f, 0x17],
|
||||
Label::Purple => [0x59, 0x0d, 0xb0],
|
||||
Label::Blue => [0x19, 0x2d, 0x94],
|
||||
Label::Teal => [0x1f, 0x4d, 0x45],
|
||||
Label::Magenta => [0x79, 0x1c, 0x56],
|
||||
Label::Tan => [0x6c, 0x5b, 0x47],
|
||||
Label::Green => [0x29, 0x5c, 0x2d],
|
||||
Label::Brown => [0x58, 0x3d, 0x14],
|
||||
Label::Yellow => [0x6e, 0x66, 0x28],
|
||||
}
|
||||
}
|
||||
/// Marker colours (Markers panel chips).
|
||||
pub fn marker_rgb(self) -> [u8; 3] {
|
||||
match self {
|
||||
Label::Rose | Label::Magenta => [0xc1, 0x3c, 0x3d],
|
||||
Label::Purple | Label::Violet | Label::Lavender => [0xa9, 0x8c, 0xaf],
|
||||
Label::Mango | Label::Brown | Label::Tan => [0xda, 0x76, 0x39],
|
||||
Label::Yellow => [0xc9, 0xa3, 0x44],
|
||||
Label::Blue | Label::Iris | Label::Cerulean => [0x56, 0x8b, 0xf4],
|
||||
Label::Teal | Label::Caribbean => [0x72, 0xf0, 0xd7],
|
||||
_ => [0x75, 0x85, 0x42],
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<Label> {
|
||||
|
|
@ -815,7 +828,7 @@ impl Project {
|
|||
pub fn make_track_item(&mut self, item: ItemId, kind: TrackKind, start: Tick, source: TimeRange, seq_rate: FrameRate) -> Option<TrackItem> {
|
||||
let it = self.items.get(&item)?;
|
||||
let name = it.name.clone();
|
||||
let label = if kind == TrackKind::Audio && !matches!(it.kind, ItemKind::Sequence(_)) { Label::Forest } else { it.label };
|
||||
let label = it.label;
|
||||
let effects = match kind {
|
||||
TrackKind::Video => effect::intrinsic_video(),
|
||||
TrackKind::Audio => effect::intrinsic_audio(),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
//! thin gutters; each group has a tab strip (active tab bright, with a panel menu "≡"); the focused
|
||||
//! panel gets a blue outline. Gutters drag to resize; workspaces are serialized trees.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use egui::{Align2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::icons::{self, Icon};
|
||||
|
|
@ -153,7 +153,7 @@ pub const WORKSPACES: [&str; 9] = ["Editing", "Assembly", "Color", "Effects", "A
|
|||
pub fn workspace(name: &str) -> DockNode {
|
||||
use PanelKind::*;
|
||||
use SplitSize::*;
|
||||
let bottom_editing = || hsplit(FixedA(38.0), tabs(&[Tools], 0), hsplit(FixedB(58.0), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0)));
|
||||
let bottom_editing = || hsplit(FixedA(64.0), tabs(&[Tools], 0), hsplit(FixedB(116.0), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0)));
|
||||
match name {
|
||||
"Assembly" => vsplit(
|
||||
Ratio(0.52),
|
||||
|
|
@ -209,11 +209,20 @@ pub fn workspace(name: &str) -> DockNode {
|
|||
),
|
||||
hsplit(Ratio(0.26), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), bottom_editing()),
|
||||
),
|
||||
// Editing (default), Learning, Review
|
||||
// Editing (default), Learning, Review — Premiere 26 factory layout:
|
||||
// top: Source group | Program | Properties; bottom: Project group | Tools | Timeline | Meters.
|
||||
_ => vsplit(
|
||||
Ratio(0.5),
|
||||
hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer, Metadata], 0), tabs(&[Program], 0)),
|
||||
hsplit(Ratio(0.27), tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0), bottom_editing()),
|
||||
Ratio(0.57),
|
||||
hsplit(
|
||||
FixedB(450.0),
|
||||
hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer, Metadata], 0), tabs(&[Program], 0)),
|
||||
tabs(&[Properties, EssentialGraphics, Text], 0),
|
||||
),
|
||||
hsplit(
|
||||
Ratio(0.29),
|
||||
tabs(&[Project, MediaBrowser, Libraries, Info, Effects, Markers, History], 0),
|
||||
hsplit(FixedA(64.0), tabs(&[Tools], 0), hsplit(FixedB(116.0), tabs(&[Timeline], 0), tabs(&[AudioMeters], 0))),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -386,19 +395,18 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
|
|||
}
|
||||
} else {
|
||||
let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h));
|
||||
let mut x = strip.min.x + 10.0;
|
||||
let mut x = strip.min.x + 12.0;
|
||||
let text_y = strip.min.y + 16.0;
|
||||
for (i, p) in g.panels.iter().enumerate() {
|
||||
let is_active = i == g.active;
|
||||
let font = if is_active { Tokens::semibold(12.0) } else { Tokens::ui(12.0) };
|
||||
let galley = painter.layout_no_wrap(p.title().to_string(), font, if is_active { t.tab_text_active } else { t.tab_text });
|
||||
let menu_w = if is_active { 18.0 } else { 0.0 };
|
||||
let galley = painter.layout_no_wrap(p.title().to_string(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
|
||||
let menu_w = if is_active { 20.0 } else { 0.0 };
|
||||
let w = galley.size().x + 16.0 + menu_w;
|
||||
if x + w > strip.max.x - 20.0 && i > g.active {
|
||||
// overflow chevron
|
||||
let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 4.0), vec2(18.0, t.tab_h - 8.0));
|
||||
let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click());
|
||||
icons::paint(&painter, r.shrink(3.0), Icon::ChevronRight, t.tab_text);
|
||||
icons::paint(&painter, r.shrink(3.0).translate(vec2(4.0, 0.0)), Icon::ChevronRight, t.tab_text);
|
||||
icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, t.tab_text);
|
||||
icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, t.tab_text);
|
||||
if resp.clicked() {
|
||||
let next = g.panels[(g.active + 1) % g.panels.len()];
|
||||
actions.push(DockAction::Activate(next));
|
||||
|
|
@ -408,15 +416,21 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
|
|||
let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h));
|
||||
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click());
|
||||
reg.add(&format!("panel.tab.{}", p.id()), tab, p.title());
|
||||
if resp.hovered() && !is_active {
|
||||
painter.rect_filled(tab.shrink2(vec2(2.0, 4.0)), t.radius_sm, t.hover);
|
||||
}
|
||||
painter.galley(pos2(tab.min.x + 8.0, tab.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
|
||||
let label_x = tab.min.x + 8.0;
|
||||
let label_w = galley.size().x;
|
||||
let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
painter.galley_with_override_text_color(pos2(label_x, text_y - galley.size().y / 2.0), galley, col);
|
||||
if is_active {
|
||||
let mr = Rect::from_center_size(pos2(tab.max.x - 8.0 - menu_w / 2.0 + 4.0, tab.center().y), vec2(14.0, 14.0));
|
||||
let mresp = ui.interact(mr, egui::Id::new(("tab-menu", g.path.clone())), Sense::click());
|
||||
let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0));
|
||||
let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click());
|
||||
reg.add(&format!("panel.menu.{}", p.id()), mr, "panel menu");
|
||||
icons::paint(&painter, mr, Icon::Hamburger, if mresp.hovered() { t.tab_text_active } else { t.tab_text });
|
||||
let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
for dy in [-3.5, 0.0, 3.5] {
|
||||
painter.line_segment([pos2(mr.min.x, mr.center().y + dy), pos2(mr.max.x, mr.center().y + dy)], Stroke::new(1.5, mc));
|
||||
}
|
||||
// 1 pt underline spanning label + ≡, 23 pt below the frame top
|
||||
let uy = strip.min.y + 23.0;
|
||||
painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active));
|
||||
if mresp.clicked() {
|
||||
actions.push(DockAction::PanelMenu(*p, mr.left_bottom()));
|
||||
}
|
||||
|
|
@ -428,12 +442,12 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
|
|||
if resp.middle_clicked() {
|
||||
actions.push(DockAction::Close(*p));
|
||||
}
|
||||
x += w + 2.0;
|
||||
x += w + 8.0;
|
||||
}
|
||||
}
|
||||
// focus outline
|
||||
if active_panel == Some(focused) {
|
||||
painter.rect_stroke(g.rect.shrink(0.5), t.radius, Stroke::new(1.5, t.focus), StrokeKind::Inside);
|
||||
painter.rect_stroke(g.rect, 0.0, Stroke::new(1.0, t.focus), StrokeKind::Inside);
|
||||
}
|
||||
// clicking anywhere in the panel focuses it
|
||||
if let Some(p) = active_panel
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
//! The header bar: app mark + menus on the left, Import / Edit / Export mode tabs in the centre,
|
||||
//! project title, workspace switcher and quick actions on the right.
|
||||
//! The header bar (Premiere 26 layout, 38 pt): Home and Import / Edit / Export on the left, the
|
||||
//! document title centred, the workspace name and quick actions on the right. On macOS the menus
|
||||
//! live in the native menu bar (set up by the app); elsewhere a compact in-window menu bar follows
|
||||
//! the mode tabs.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
|
||||
|
|
@ -12,8 +14,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let t = app.tokens;
|
||||
let p = ui.painter().clone();
|
||||
p.rect_filled(rect, 0.0, t.header_bg);
|
||||
p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, Color32::from_rgb(0, 0, 0)));
|
||||
// Window drag area (integrated title bar on macOS).
|
||||
p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, Color32::BLACK));
|
||||
let drag = ui.interact(rect, egui::Id::new("header-drag"), Sense::click_and_drag());
|
||||
if drag.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
|
|
@ -22,109 +23,113 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
|
||||
}
|
||||
let mut x = rect.min.x + if app.integrated_titlebar { 78.0 } else { 10.0 };
|
||||
// App mark: a small film-frame glyph in the accent colour.
|
||||
let mark = Rect::from_center_size(pos2(x + 11.0, rect.center().y), vec2(22.0, 22.0));
|
||||
p.rect_filled(mark, 5.0, Color32::from_rgb(38, 28, 90));
|
||||
p.rect_stroke(mark, 5.0, Stroke::new(1.0, Color32::from_rgb(120, 104, 255)), egui::StrokeKind::Inside);
|
||||
p.text(mark.center(), Align2::CENTER_CENTER, "Fc", Tokens::semibold(11.0), Color32::from_rgb(200, 190, 255));
|
||||
app.auto.add("header.home", mark, "Home");
|
||||
x = mark.max.x + 8.0;
|
||||
// Menu bar
|
||||
let menu_rect = Rect::from_min_max(pos2(x, rect.min.y + 6.0), pos2(x + 560.0, rect.max.y - 6.0));
|
||||
let mut mu = ui.new_child(egui::UiBuilder::new().max_rect(menu_rect).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
mu.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
|
||||
mu.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
|
||||
crate::menus::menu_bar(app, &mut mu);
|
||||
// Mode tabs centred
|
||||
let modes = [(Mode::Import, "Import"), (Mode::Edit, "Edit"), (Mode::Export, "Export")];
|
||||
let tab_w = 74.0;
|
||||
let total = tab_w * 3.0;
|
||||
let mut tx = rect.center().x - total / 2.0;
|
||||
for (m, label) in modes {
|
||||
let r = Rect::from_min_size(pos2(tx, rect.min.y + 6.0), vec2(tab_w, rect.height() - 12.0));
|
||||
let mut x = rect.min.x + if app.integrated_titlebar { 104.0 } else { 14.0 };
|
||||
// Home
|
||||
let home = Rect::from_center_size(pos2(x + 10.0, rect.center().y), vec2(26.0, 26.0));
|
||||
let hresp = ui.interact(home, egui::Id::new("hdr-home"), Sense::click()).on_hover_text("Home");
|
||||
app.auto.add("header.home", home, "Home");
|
||||
if hresp.hovered() {
|
||||
p.rect_filled(home, 4.0, t.hover);
|
||||
}
|
||||
icons::paint(&p, home.shrink(5.0), Icon::Home, if hresp.hovered() { t.text } else { t.text_dim });
|
||||
if hresp.clicked() {
|
||||
app.ui.mode = Mode::Import;
|
||||
}
|
||||
x = home.max.x + 16.0;
|
||||
// Mode tabs (14 pt; active = primary text with a 2 pt underline under the label)
|
||||
for (m, label) in [(Mode::Import, "Import"), (Mode::Edit, "Edit"), (Mode::Export, "Export")] {
|
||||
let active = app.ui.mode == m;
|
||||
let galley = p.layout_no_wrap(label.to_string(), Tokens::ui(14.0), if active { t.tab_text_active } else { t.tab_text });
|
||||
let r = Rect::from_min_size(pos2(x - 6.0, rect.min.y + 6.0), vec2(galley.size().x + 12.0, rect.height() - 12.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("mode", label)), Sense::click());
|
||||
app.auto.add(&format!("header.mode.{}", label.to_ascii_lowercase()), r, label);
|
||||
let active = app.ui.mode == m;
|
||||
if resp.hovered() && !active {
|
||||
p.rect_filled(r, 5.0, t.hover);
|
||||
}
|
||||
p.text(
|
||||
r.center() - vec2(0.0, 1.0),
|
||||
Align2::CENTER_CENTER,
|
||||
label,
|
||||
if active { Tokens::semibold(13.0) } else { Tokens::ui(13.0) },
|
||||
if active { t.tab_text_active } else { t.tab_text },
|
||||
);
|
||||
let col = if active || resp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
let gw = galley.size().x;
|
||||
p.galley_with_override_text_color(pos2(x, rect.center().y - galley.size().y / 2.0), galley, col);
|
||||
if active {
|
||||
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 1.0), vec2(label.len() as f32 * 7.5, 2.0));
|
||||
p.rect_filled(u, 1.0, t.tab_text_active);
|
||||
p.line_segment([pos2(x, rect.min.y + 29.0), pos2(x + gw, rect.min.y + 29.0)], Stroke::new(2.0, t.tab_text_active));
|
||||
}
|
||||
if resp.clicked() {
|
||||
app.ui.mode = m;
|
||||
}
|
||||
tx += tab_w;
|
||||
x += gw + 24.0;
|
||||
}
|
||||
// Right side: title, workspaces, quick export, fullscreen
|
||||
let mut rx = rect.max.x - 12.0;
|
||||
let btn = |ui: &mut egui::Ui, rx: &mut f32, icon: Icon, id: &str, tip: &str, app: &mut FilmcraftApp| -> bool {
|
||||
let r = Rect::from_center_size(pos2(*rx - 14.0, rect.center().y), vec2(28.0, 28.0));
|
||||
*rx -= 32.0;
|
||||
// In-window menus when there is no native menu bar.
|
||||
if app.ui.show_menu_bar {
|
||||
let menu_rect = Rect::from_min_max(pos2(x + 6.0, rect.min.y + 7.0), pos2(x + 520.0, rect.max.y - 7.0));
|
||||
let mut mu = ui.new_child(egui::UiBuilder::new().max_rect(menu_rect).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
mu.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
|
||||
mu.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
|
||||
mu.style_mut().visuals.override_text_color = Some(t.text_dim);
|
||||
crate::menus::menu_bar(app, &mut mu);
|
||||
}
|
||||
// Document title, centred
|
||||
let title = format!("{}{}", app.session.project.name, if app.session.is_dirty() { " - Edited" } else { "" });
|
||||
p.text(pos2(rect.center().x, rect.center().y), Align2::CENTER_CENTER, title, Tokens::ui(14.0), t.tab_text_active);
|
||||
// Right cluster: icons at ~38 pt pitch, then workspace name in caps.
|
||||
let mut rx = rect.max.x - 14.0;
|
||||
let mut btn = |ui: &mut egui::Ui, icon: Icon, id: &str, tip: &str, app: &mut FilmcraftApp| -> egui::Response {
|
||||
let r = Rect::from_center_size(pos2(rx - 14.0, rect.center().y), vec2(28.0, 28.0));
|
||||
rx -= 38.0;
|
||||
let resp = ui.interact(r, egui::Id::new(("hdr", id)), Sense::click()).on_hover_text(tip);
|
||||
app.auto.add(&format!("header.{id}"), r, tip);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 5.0, t.hover);
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(7.0), icon, t.icon);
|
||||
resp.clicked()
|
||||
icons::paint(ui.painter(), r.shrink(6.0), icon, if resp.hovered() { t.text } else { t.text_dim });
|
||||
resp
|
||||
};
|
||||
if btn(ui, &mut rx, Icon::Fullscreen, "fullscreen", "Full screen", app) {
|
||||
if btn(ui, Icon::Fullscreen, "fullscreen", "Full screen", app).clicked() {
|
||||
let fs = ui.ctx().input(|i| i.viewport().fullscreen.unwrap_or(false));
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Fullscreen(!fs));
|
||||
}
|
||||
if btn(ui, &mut rx, Icon::Bell, "notifications", "Progress & notifications", app) {
|
||||
app.ui.status = "No background jobs running".into();
|
||||
if btn(ui, Icon::Speaker, "volume", "Volume", app).clicked() {
|
||||
app.ui.status = "Master volume: use the Audio Track Mixer".into();
|
||||
}
|
||||
if btn(ui, &mut rx, Icon::Export, "quickExport", "Quick Export", app) {
|
||||
if btn(ui, Icon::Search, "search", "Search", app).clicked() {
|
||||
app.show_panel(crate::dock::PanelKind::Effects);
|
||||
}
|
||||
if btn(ui, Icon::Bell, "notifications", "Progress", app).clicked() {
|
||||
app.ui.mode = Mode::Export;
|
||||
}
|
||||
let ws_clicked = btn(ui, &mut rx, Icon::Workspaces, "workspaces", "Workspaces", app);
|
||||
let ws_rect = Rect::from_center_size(pos2(rx + 18.0, rect.center().y), vec2(28.0, 28.0));
|
||||
if btn(ui, Icon::Export, "quickExport", "Quick Export", app).clicked() {
|
||||
app.ui.mode = Mode::Export;
|
||||
}
|
||||
let ws_resp = btn(ui, Icon::Workspaces, "workspaces", "Workspaces", app);
|
||||
// workspace name (caps)
|
||||
let ws = app.ui.workspace.to_uppercase();
|
||||
let wg = p.layout_no_wrap(ws.clone(), Tokens::ui(11.0), t.text_dim);
|
||||
let wr = Rect::from_min_size(pos2(rx - wg.size().x + 10.0, rect.center().y - 10.0), vec2(wg.size().x + 8.0, 20.0));
|
||||
let wresp = ui.interact(wr, egui::Id::new("hdr-ws-name"), Sense::click());
|
||||
app.auto.add("header.workspaceName", wr, &ws);
|
||||
p.galley_with_override_text_color(pos2(wr.min.x + 4.0, rect.center().y - wg.size().y / 2.0), wg, if wresp.hovered() { t.text } else { t.text_dim });
|
||||
let popup_id = egui::Id::new("workspaces-popup");
|
||||
if ws_clicked {
|
||||
if ws_resp.clicked() || wresp.clicked() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(popup_id, true));
|
||||
}
|
||||
let open = ui.ctx().data(|d| d.get_temp::<bool>(popup_id).unwrap_or(false));
|
||||
if open {
|
||||
let area = egui::Area::new(popup_id.with("area")).order(egui::Order::Foreground).fixed_pos(pos2(ws_rect.max.x - 200.0, ws_rect.max.y + 4.0)).show(
|
||||
ui.ctx(),
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
ui.label(egui::RichText::new("Workspaces").strong());
|
||||
ui.separator();
|
||||
for w in crate::dock::WORKSPACES {
|
||||
let sel = app.ui.workspace == w;
|
||||
if ui.selectable_label(sel, w).clicked() {
|
||||
app.set_workspace(w);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(popup_id, false));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Reset to Saved Layout").clicked() {
|
||||
let n = app.ui.workspace.clone();
|
||||
app.set_workspace(&n);
|
||||
let anchor = pos2(wr.min.x, rect.max.y + 4.0);
|
||||
let area = egui::Area::new(popup_id.with("area")).order(egui::Order::Foreground).fixed_pos(anchor).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(220.0);
|
||||
for w in crate::dock::WORKSPACES {
|
||||
let sel = app.ui.workspace == w;
|
||||
if ui.selectable_label(sel, w).clicked() {
|
||||
app.set_workspace(w);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(popup_id, false));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
if area.response.clicked_elsewhere() && !ws_clicked {
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Reset to Saved Layout").clicked() {
|
||||
let n = app.ui.workspace.clone();
|
||||
app.set_workspace(&n);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(popup_id, false));
|
||||
}
|
||||
});
|
||||
});
|
||||
if area.response.clicked_elsewhere() && !(ws_resp.clicked() || wresp.clicked()) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(popup_id, false));
|
||||
}
|
||||
}
|
||||
// Project title (with unsaved dot)
|
||||
let title = format!("{}{}", app.session.project.name, if app.session.is_dirty() { " •" } else { "" });
|
||||
let right_start = rx - 8.0;
|
||||
p.text(pos2(right_start, rect.center().y), Align2::RIGHT_CENTER, title, Tokens::ui(12.5), t.text_dim);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use egui::{Color32, TextureHandle, TextureOptions};
|
||||
use egui::{TextureHandle, TextureOptions};
|
||||
use filmcraft_engine::{Services, Session};
|
||||
use filmcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -102,6 +102,8 @@ pub struct FilmcraftApp {
|
|||
bindings: Vec<(egui::Modifiers, egui::Key, String)>,
|
||||
pub toast: Option<(String, f64)>,
|
||||
pub tl: panels::timeline::TlState,
|
||||
/// Commands from outside the UI (native menu bar), invoked on the UI thread.
|
||||
pub command_inbox: Option<Receiver<String>>,
|
||||
/// GPU compositor (when running on wgpu): device state + compositor + the egui texture it feeds.
|
||||
pub gpu: Option<GpuState>,
|
||||
}
|
||||
|
|
@ -185,6 +187,7 @@ impl FilmcraftApp {
|
|||
bindings: menus::bindings(),
|
||||
toast: None,
|
||||
tl: Default::default(),
|
||||
command_inbox: None,
|
||||
gpu: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -606,30 +609,79 @@ impl FilmcraftApp {
|
|||
}
|
||||
}
|
||||
self.handle_drops(&ctx);
|
||||
if let Some(rx) = self.command_inbox.take() {
|
||||
while let Ok(id) = rx.try_recv() {
|
||||
if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) {
|
||||
self.ui.status = e;
|
||||
}
|
||||
}
|
||||
self.command_inbox = Some(rx);
|
||||
}
|
||||
self.handle_shortcuts(&ctx);
|
||||
self.advance_playback(&ctx);
|
||||
let t = self.tokens;
|
||||
let full = ui.max_rect();
|
||||
ui.painter().rect_filled(full, 0.0, t.app_bg);
|
||||
let header_h = 40.0;
|
||||
let header_h = 38.0;
|
||||
let header = egui::Rect::from_min_size(full.min, egui::vec2(full.width(), header_h));
|
||||
header::show(self, ui, header);
|
||||
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 1.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0));
|
||||
let status_h = 20.0;
|
||||
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 1.0, header.max.y + 1.0), egui::pos2(full.max.x - 1.0, full.max.y - status_h - 2.0));
|
||||
match self.ui.mode {
|
||||
state::Mode::Edit => self.dock_area(ui, body),
|
||||
state::Mode::Import => panels::import_mode::show(self, ui, body),
|
||||
state::Mode::Export => panels::export_mode::show(self, ui, body),
|
||||
}
|
||||
panels::dialogs::show(self, &ctx);
|
||||
if !self.ui.status.is_empty() {
|
||||
let r = egui::Rect::from_min_size(egui::pos2(full.center().x - 260.0, full.max.y - 34.0), egui::vec2(520.0, 26.0));
|
||||
ui.painter().rect_filled(r, 13.0, Color32::from_black_alpha(210));
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, &self.ui.status, Tokens::ui(12.0), t.text);
|
||||
let resp = ui.interact(r, egui::Id::new("status-toast"), egui::Sense::click());
|
||||
if resp.clicked() {
|
||||
self.ui.status.clear();
|
||||
}
|
||||
// Status / hint bar
|
||||
let sb = egui::Rect::from_min_max(egui::pos2(full.min.x, full.max.y - status_h), full.max);
|
||||
ui.painter().rect_filled(sb, 0.0, egui::Color32::from_rgb(0x1c, 0x1c, 0x1c));
|
||||
let hint = if !self.ui.status.is_empty() { self.ui.status.clone() } else { self.hint_text() };
|
||||
ui.painter().text(egui::pos2(sb.min.x + 10.0, sb.center().y), egui::Align2::LEFT_CENTER, hint, Tokens::ui(11.0), t.text_dim);
|
||||
let resp = ui.interact(sb, egui::Id::new("status-bar"), egui::Sense::click());
|
||||
if resp.clicked() {
|
||||
self.ui.status.clear();
|
||||
}
|
||||
let jobs_running = self
|
||||
.session
|
||||
.jobs
|
||||
.iter()
|
||||
.filter(|j| !j.progress.finished.load(std::sync::atomic::Ordering::Relaxed) && j.progress.error.lock().map(|e| e.is_none()).unwrap_or(true))
|
||||
.count();
|
||||
let right = if jobs_running > 0 {
|
||||
let f = self
|
||||
.session
|
||||
.jobs
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|j| !j.progress.finished.load(std::sync::atomic::Ordering::Relaxed))
|
||||
.map(|j| j.progress.fraction())
|
||||
.unwrap_or(0.0);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
format!("Exporting… {:.0}%", f * 100.0)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
ui.painter().text(egui::pos2(sb.max.x - 10.0, sb.center().y), egui::Align2::RIGHT_CENTER, right, Tokens::ui(11.0), t.text_dim);
|
||||
}
|
||||
|
||||
/// Contextual hint for the status bar (Premiere shows tool/gesture hints here).
|
||||
fn hint_text(&self) -> String {
|
||||
match self.ui.tool {
|
||||
state::Tool::Selection => "Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options.",
|
||||
state::Tool::TrackSelectForward => "Click to select all clips to the right in all tracks. Shift-click for a single track.",
|
||||
state::Tool::TrackSelectBackward => "Click to select all clips to the left in all tracks. Shift-click for a single track.",
|
||||
state::Tool::Ripple => "Drag an edit point to ripple trim; later clips move to keep the gap closed.",
|
||||
state::Tool::Rolling => "Drag an edit point to roll it: the out of one clip and the in of the next move together.",
|
||||
state::Tool::RateStretch => "Drag an edge to change the clip's speed so it fills the new duration.",
|
||||
state::Tool::Razor => "Click to split a clip. Shift-click to split all tracks.",
|
||||
state::Tool::Slip => "Drag a clip to slip its source in/out without moving it.",
|
||||
state::Tool::Slide => "Drag a clip to slide it between its neighbours.",
|
||||
state::Tool::Hand => "Drag to scroll the timeline.",
|
||||
state::Tool::Zoom => "Click to zoom in; Opt-click to zoom out.",
|
||||
_ => "",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) {
|
||||
|
|
|
|||
|
|
@ -36,9 +36,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
};
|
||||
let seq = app.session.active_sequence().expect("seq").clone();
|
||||
let split = rect.min.x + (rect.width() * 0.58).max(260.0).min(rect.width() - 60.0);
|
||||
let head = Rect::from_min_size(rect.min + vec2(8.0, 2.0), vec2(split - rect.min.x - 8.0, 24.0));
|
||||
ui.painter().text(pos2(head.min.x, head.center().y), Align2::LEFT_CENTER, format!("Source · {}", it.name), Tokens::ui(11.5), t.text_dim);
|
||||
ui.painter().text(pos2(split - 6.0, head.center().y), Align2::RIGHT_CENTER, format!("{} · {}", seq_name(app), it.name), Tokens::ui(11.5), t.text);
|
||||
let head = Rect::from_min_size(rect.min + vec2(8.0, 4.0), vec2(split - rect.min.x - 12.0, 24.0));
|
||||
// Premiere: two pill tabs — "Source · clip" and "Sequence · clip" (active)
|
||||
let pill = |ui: &mut egui::Ui, r: Rect, text: &str, active: bool| {
|
||||
ui.painter().rect_filled(r, 4.0, if active { Color32::from_rgb(0x3a, 0x3a, 0x3a) } else { t.panel_bg });
|
||||
if !active {
|
||||
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
|
||||
}
|
||||
let cp = ui.painter().with_clip_rect(r.shrink(2.0));
|
||||
cp.text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(11.5), if active { t.text } else { t.text_dim });
|
||||
};
|
||||
let half = (head.width() - 6.0) / 2.0;
|
||||
pill(ui, Rect::from_min_size(head.min, vec2(half, 24.0)), &format!("Source · {}", it.name), false);
|
||||
pill(ui, Rect::from_min_size(head.min + vec2(half + 6.0, 0.0), vec2(half, 24.0)), &format!("{} · {}", seq_name(app), it.name), true);
|
||||
// keyframe lane header: mini ruler over the clip's duration
|
||||
let lane = Rect::from_min_max(pos2(split + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 6.0, rect.max.y - 26.0));
|
||||
ui.painter().rect_filled(lane, 0.0, t.tl_bg);
|
||||
|
|
@ -316,3 +326,185 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties panel (Premiere 26): a compact inspector for the selected clip.
|
||||
pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let Some((clip, it, kind)) = selected_clip(app) else {
|
||||
crate::dock::placeholder(ui, rect, &t, "Select a clip to see its properties");
|
||||
return;
|
||||
};
|
||||
let ph = app.session.playhead();
|
||||
let mt = it.source_time_at(ph.clamp(it.start, it.end() - Tick(1)));
|
||||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||||
let mut bui = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(14.0, 8.0))).id_salt("props"));
|
||||
// header: clip name + menu
|
||||
let (hr, _) = bui.allocate_exact_size(vec2(bui.available_width(), 30.0), Sense::hover());
|
||||
let sw = Rect::from_center_size(pos2(hr.min.x + 8.0, hr.center().y), vec2(12.0, 12.0));
|
||||
let lc = it.label.rgb();
|
||||
bui.painter().rect_filled(sw, 2.0, Color32::from_rgb(lc[0], lc[1], lc[2]));
|
||||
bui.painter().text(pos2(hr.min.x + 22.0, hr.center().y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.5), t.text);
|
||||
let row = |ui: &mut egui::Ui, label: &str| -> Rect {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
|
||||
r
|
||||
};
|
||||
let section = |ui: &mut egui::Ui, app: &mut FilmcraftApp, name: &str, reset: Option<(usize, u64)>, actions: &mut Vec<(String, Value)>| -> bool {
|
||||
ui.add_space(4.0);
|
||||
let key = format!("props:{name}");
|
||||
let open = !app.ui.collapsed_fx.contains(&key);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.separator));
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 6.0, r.center().y), vec2(10.0, 10.0)),
|
||||
if open { Icon::ChevronDown } else { Icon::ChevronRight },
|
||||
t.text_dim,
|
||||
);
|
||||
ui.painter().text(pos2(r.min.x + 18.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::semibold(13.0), t.text);
|
||||
if let Some((idx, c)) = reset {
|
||||
let rr = Rect::from_center_size(pos2(r.max.x - 10.0, r.center().y), vec2(14.0, 14.0));
|
||||
icons::paint(ui.painter(), rr, Icon::Reset, t.text_dim);
|
||||
if ui.interact(rr, egui::Id::new(("props-reset", name, c)), Sense::click()).clicked() {
|
||||
actions.push(("effects.reset".into(), json!({"clip": c, "index": idx})));
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
if open {
|
||||
app.ui.collapsed_fx.push(key);
|
||||
} else {
|
||||
app.ui.collapsed_fx.retain(|k| *k != key);
|
||||
}
|
||||
}
|
||||
open
|
||||
};
|
||||
let diamond = |ui: &mut egui::Ui, r: Rect, animated: bool, id: egui::Id| -> bool {
|
||||
let dr = Rect::from_center_size(pos2(r.max.x - 10.0, r.center().y), vec2(10.0, 10.0));
|
||||
let resp = ui.interact(dr.expand(3.0), id, Sense::click());
|
||||
if animated {
|
||||
icons::paint(ui.painter(), dr, Icon::Keyframe, t.hot_text);
|
||||
} else {
|
||||
let c = dr.center();
|
||||
let s = 4.5;
|
||||
ui.painter()
|
||||
.add(egui::Shape::closed_line(vec![c + vec2(0.0, -s), c + vec2(s, 0.0), c + vec2(0.0, s), c + vec2(-s, 0.0)], Stroke::new(1.2, t.text_dim)));
|
||||
}
|
||||
resp.clicked()
|
||||
};
|
||||
let eff_idx = |id: &str| it.effects.iter().position(|e| e.effect == id);
|
||||
let val = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).map(|p| p.value_at(mt));
|
||||
let anim = |eid: &str, p: &str| it.effect(eid).and_then(|e| e.param(p)).is_some_and(|p| p.is_animated());
|
||||
if kind == filmcraft_project::TrackKind::Video {
|
||||
if section(&mut bui, app, "Transform", eff_idx("motion").map(|i| (i, clip.0)), &mut actions) {
|
||||
for (label, p, unit, speed, range) in [
|
||||
("Position", "position", "", 1.0, (-100_000.0, 100_000.0)),
|
||||
("Anchor point", "anchor", "", 1.0, (-100_000.0, 100_000.0)),
|
||||
("Scale", "scale", " %", 0.5, (0.0, 10000.0)),
|
||||
("Rotation", "rotation", " °", 0.5, (-36000.0, 36000.0)),
|
||||
] {
|
||||
let r = row(&mut bui, label);
|
||||
let mut vui = bui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0)))
|
||||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||
);
|
||||
match val("motion", p) {
|
||||
Some(ParamValue::Vec2(v)) => {
|
||||
let (_, nx) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "x", clip.0)), v.x, speed, range, 0, " X", &t);
|
||||
vui.add_space(10.0);
|
||||
let (_, ny) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "y", clip.0)), v.y, speed, range, 0, " Y", &t);
|
||||
if nx.is_some() || ny.is_some() {
|
||||
actions.push((
|
||||
"effects.setParam".into(),
|
||||
json!({"clip": clip.0, "effect": "motion", "param": p, "value": [nx.unwrap_or(v.x), ny.unwrap_or(v.y)]}),
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(ParamValue::Float(v)) => {
|
||||
let (_, nv) =
|
||||
crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, clip.0)), v, speed, range, if p == "scale" { 0 } else { 1 }, unit, &t);
|
||||
if let Some(nv) = nv {
|
||||
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "motion", "param": p, "value": nv})));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if diamond(&mut bui, r, anim("motion", p), egui::Id::new(("pd", p, clip.0))) {
|
||||
actions.push(("effects.toggleAnimation".into(), json!({"clip": clip.0, "effect": "motion", "param": p})));
|
||||
}
|
||||
}
|
||||
let r = row(&mut bui, "Opacity");
|
||||
let mut vui = bui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0)))
|
||||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||
);
|
||||
if let Some(ParamValue::Float(v)) = val("opacity", "opacity") {
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-op", clip.0)), v, 0.5, (0.0, 100.0), 0, " %", &t);
|
||||
if let Some(nv) = nv {
|
||||
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "opacity", "param": "opacity", "value": nv})));
|
||||
}
|
||||
}
|
||||
if diamond(&mut bui, r, anim("opacity", "opacity"), egui::Id::new(("pd-op", clip.0))) {
|
||||
actions.push(("effects.toggleAnimation".into(), json!({"clip": clip.0, "effect": "opacity", "param": "opacity"})));
|
||||
}
|
||||
}
|
||||
let crop = eff_idx("crop");
|
||||
if section(&mut bui, app, "Crop", crop.map(|i| (i, clip.0)), &mut actions) {
|
||||
for (label, p) in [("Left", "left"), ("Top", "top"), ("Right", "right"), ("Bottom", "bottom")] {
|
||||
let r = row(&mut bui, label);
|
||||
let mut vui = bui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0)))
|
||||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||
);
|
||||
let v = val("crop", p).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-crop", p, clip.0)), v, 0.2, (0.0, 100.0), 1, " %", &t);
|
||||
if let Some(nv) = nv {
|
||||
if crop.is_none() {
|
||||
actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": "crop"})));
|
||||
}
|
||||
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "crop", "param": p, "value": nv})));
|
||||
}
|
||||
diamond(&mut bui, r, anim("crop", p), egui::Id::new(("pd-crop", p, clip.0)));
|
||||
}
|
||||
}
|
||||
} else if section(&mut bui, app, "Audio", eff_idx("volume").map(|i| (i, clip.0)), &mut actions) {
|
||||
let r = row(&mut bui, "Level");
|
||||
let mut vui = bui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0)))
|
||||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||
);
|
||||
let v = val("volume", "level").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-vol", clip.0)), v, 0.1, (-96.0, 15.0), 1, " dB", &t);
|
||||
if let Some(nv) = nv {
|
||||
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "volume", "param": "level", "value": nv})));
|
||||
}
|
||||
if diamond(&mut bui, r, anim("volume", "level"), egui::Id::new(("pd-vol", clip.0))) {
|
||||
actions.push(("effects.toggleAnimation".into(), json!({"clip": clip.0, "effect": "volume", "param": "level"})));
|
||||
}
|
||||
let r = row(&mut bui, "Pan");
|
||||
let mut vui = bui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(r.min.x + 150.0, r.min.y + 4.0), pos2(r.max.x - 24.0, r.max.y - 4.0)))
|
||||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||
);
|
||||
let v = val("panner", "balance").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-pan", clip.0)), v, 0.5, (-100.0, 100.0), 0, "", &t);
|
||||
if let Some(nv) = nv {
|
||||
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "panner", "param": "balance", "value": nv})));
|
||||
}
|
||||
}
|
||||
// speed footer
|
||||
bui.add_space(12.0);
|
||||
let (br, bresp) = bui.allocate_exact_size(vec2(118.0, 26.0), Sense::click());
|
||||
bui.painter().rect_filled(br, 4.0, if bresp.hovered() { t.hover } else { t.panel_bg });
|
||||
bui.painter().rect_stroke(br, 4.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
|
||||
bui.painter().text(br.center(), Align2::CENTER_CENTER, format!("Speed {:.0}%", it.speed * 100.0), Tokens::ui(12.0), t.text);
|
||||
for (cmd, p) in actions {
|
||||
if let Err(e) = app.session.execute(&cmd, p) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,22 +33,28 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, st));
|
||||
let _ = now;
|
||||
let area = Rect::from_min_max(pos2(rect.min.x + 6.0, rect.min.y + 6.0), pos2(rect.max.x - 22.0, rect.max.y - 24.0));
|
||||
let w = ((area.width() - 3.0) / 2.0).max(3.0);
|
||||
// Premiere: black meter area, scale 0 … −57 dB in 3 dB steps on the right, "dB" at the foot.
|
||||
let area = Rect::from_min_max(pos2(rect.min.x + 8.0, rect.min.y + 8.0), pos2(rect.max.x - 26.0, rect.max.y - 30.0));
|
||||
ui.painter().rect_filled(
|
||||
Rect::from_min_max(pos2(rect.min.x + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 4.0, rect.max.y - 26.0)),
|
||||
0.0,
|
||||
egui::Color32::BLACK,
|
||||
);
|
||||
let w = ((area.width() - 4.0) / 2.0).max(3.0);
|
||||
for c in 0..2 {
|
||||
let r = Rect::from_min_size(pos2(area.min.x + c as f32 * (w + 3.0), area.min.y), vec2(w, area.height()));
|
||||
let r = Rect::from_min_size(pos2(area.min.x + c as f32 * (w + 4.0), area.min.y), vec2(w, area.height()));
|
||||
crate::widgets::meter_bar(ui.painter(), r, st[c], st[c + 2], &t);
|
||||
}
|
||||
// dB scale
|
||||
for db in [0, -6, -12, -18, -24, -30, -36, -42, -48, -54] {
|
||||
let y = area.max.y - area.height() * ((db as f32 + 60.0) / 60.0);
|
||||
ui.painter().text(pos2(rect.max.x - 4.0, y), Align2::RIGHT_CENTER, format!("{db}"), Tokens::ui(8.5), t.text_faint);
|
||||
let mut db = 0;
|
||||
while db >= -57 {
|
||||
let y = area.min.y + area.height() * (-db as f32 / 60.0);
|
||||
ui.painter().text(pos2(rect.max.x - 6.0, y), Align2::RIGHT_CENTER, format!("{db}"), Tokens::ui(8.5), t.text_dim);
|
||||
db -= 3;
|
||||
}
|
||||
// S S solo buttons at the bottom (Premiere has solo toggles per channel)
|
||||
ui.painter().text(pos2(rect.max.x - 6.0, area.max.y + 10.0), Align2::RIGHT_CENTER, "dB", Tokens::ui(8.5), t.text_dim);
|
||||
for c in 0..2 {
|
||||
let r = Rect::from_min_size(pos2(area.min.x + c as f32 * (w + 3.0), rect.max.y - 20.0), vec2(w.max(12.0), 14.0));
|
||||
ui.painter().rect_stroke(r, 7.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "S", Tokens::ui(8.5), t.text_dim);
|
||||
let r = Rect::from_center_size(pos2(area.min.x + c as f32 * (w + 4.0) + w / 2.0, rect.max.y - 14.0), vec2(14.0, 14.0));
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "S", Tokens::ui(10.0), t.text_dim);
|
||||
}
|
||||
if app.playback.playing {
|
||||
ui.ctx().request_repaint();
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect)
|
|||
PanelKind::EffectControls => effect_controls::show(app, ui, rect),
|
||||
PanelKind::AudioMeters => meters::show(app, ui, rect),
|
||||
PanelKind::LumetriColor => effect_controls::lumetri_panel(app, ui, rect),
|
||||
PanelKind::Properties => effect_controls::properties_panel(app, ui, rect),
|
||||
PanelKind::History => misc::history(app, ui, rect),
|
||||
PanelKind::Markers => misc::markers(app, ui, rect),
|
||||
PanelKind::Info => misc::info(app, ui, rect),
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ pub enum Which {
|
|||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let controls_h = 26.0 + 18.0 + 34.0;
|
||||
let controls_h = 28.0 + 24.0 + 36.0;
|
||||
let video_area = Rect::from_min_max(rect.min + vec2(4.0, 4.0), pos2(rect.max.x - 4.0, rect.max.y - controls_h));
|
||||
let (target, frame_size, rate, time, duration, drop_frame, mark_in, mark_out, name) = match which {
|
||||
Which::Program => {
|
||||
|
|
@ -174,9 +174,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
|||
}
|
||||
|
||||
// ---- controls row: timecode | zoom | res | wrench | duration
|
||||
let row1 = Rect::from_min_size(pos2(rect.min.x + 8.0, video_area.max.y + 2.0), vec2(rect.width() - 16.0, 24.0));
|
||||
let row1 = Rect::from_min_size(pos2(rect.min.x + 14.0, video_area.max.y + 2.0), vec2(rect.width() - 28.0, 26.0));
|
||||
let tc = format_time(time, rate, drop_frame, TimeDisplay::Timecode, 48000);
|
||||
ui.painter().text(pos2(row1.min.x, row1.center().y), Align2::LEFT_CENTER, &tc, Tokens::mono(15.0), t.timecode);
|
||||
ui.painter().text(pos2(row1.min.x, row1.center().y), Align2::LEFT_CENTER, &tc, Tokens::semibold(15.0), t.hot_text);
|
||||
app.auto.add(&format!("{prefix}.timecode"), Rect::from_min_size(row1.min, vec2(110.0, row1.height())), &tc);
|
||||
let dur_tc = format_time(
|
||||
mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - mark_in.unwrap_or(Tick::ZERO),
|
||||
|
|
@ -185,15 +185,15 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
|||
TimeDisplay::Timecode,
|
||||
48000,
|
||||
);
|
||||
ui.painter().text(pos2(row1.max.x, row1.center().y), Align2::RIGHT_CENTER, &dur_tc, Tokens::mono(15.0), t.text);
|
||||
ui.painter().text(pos2(row1.max.x, row1.center().y), Align2::RIGHT_CENTER, &dur_tc, Tokens::semibold(15.0), t.text_dim);
|
||||
// zoom + resolution dropdowns centred-ish
|
||||
let zr = Rect::from_min_size(pos2(row1.center().x - 70.0, row1.min.y + 2.0), vec2(56.0, 20.0));
|
||||
let zr = Rect::from_min_size(pos2(row1.min.x + 116.0, row1.min.y), vec2(70.0, 24.0));
|
||||
let zoom_label = match if which == Which::Program { app.ui.program.zoom } else { app.ui.source.zoom } {
|
||||
None => "Fit".to_string(),
|
||||
Some(z) => format!("{}%", (z * 100.0) as i32),
|
||||
};
|
||||
crate::widgets::dropdown_text(ui, zr, &zoom_label, &t, egui::Id::new((prefix, "zoom")));
|
||||
let rr = Rect::from_min_size(pos2(zr.max.x + 6.0, row1.min.y + 2.0), vec2(56.0, 20.0));
|
||||
let rr = Rect::from_min_size(pos2(row1.max.x - 196.0, row1.min.y), vec2(62.0, 24.0));
|
||||
let rresp = crate::widgets::dropdown_text(ui, rr, res.label(), &t, egui::Id::new((prefix, "res")));
|
||||
app.auto.add(&format!("{prefix}.resolution"), rr, "Select Playback Resolution");
|
||||
egui::Popup::menu(&rresp).show(|ui| {
|
||||
|
|
@ -223,11 +223,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
|||
});
|
||||
|
||||
// ---- mini timeline / scrub bar
|
||||
let bar = Rect::from_min_size(pos2(rect.min.x + 8.0, row1.max.y + 1.0), vec2(rect.width() - 16.0, 16.0));
|
||||
let bar = Rect::from_min_size(pos2(rect.min.x + 14.0, row1.max.y + 2.0), vec2(rect.width() - 28.0, 22.0));
|
||||
mini_timeline(app, ui, bar, which, time, duration, rate, mark_in, mark_out);
|
||||
|
||||
// ---- transport buttons
|
||||
let row3 = Rect::from_min_size(pos2(rect.min.x, bar.max.y + 2.0), vec2(rect.width(), 30.0));
|
||||
let row3 = Rect::from_min_size(pos2(rect.min.x, bar.max.y + 4.0), vec2(rect.width(), 32.0));
|
||||
transport(app, ui, row3, which);
|
||||
}
|
||||
|
||||
|
|
@ -256,29 +256,44 @@ fn mini_timeline(
|
|||
) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter();
|
||||
let track = Rect::from_min_max(pos2(bar.min.x, bar.center().y - 1.0), pos2(bar.max.x, bar.center().y + 1.0));
|
||||
p.rect_filled(track, 1.0, t.separator);
|
||||
let dur = duration.0.max(1) as f64;
|
||||
let xof = |tk: Tick| bar.min.x + ((tk.0 as f64 / dur) as f32).clamp(0.0, 1.0) * bar.width();
|
||||
// ticks: minor 4 pt, major 10 pt, ~20 pt spacing
|
||||
let n = ((bar.width() / 20.0) as i32).max(2);
|
||||
for i in 0..=n {
|
||||
let x = bar.min.x + bar.width() * i as f32 / n as f32;
|
||||
let h = if i % 5 == 0 { 8.0 } else { 3.5 };
|
||||
p.line_segment([pos2(x, bar.max.y - h), pos2(x, bar.max.y)], Stroke::new(1.0, t.text_faint));
|
||||
}
|
||||
if mark_in.is_some() || mark_out.is_some() {
|
||||
let a = xof(mark_in.unwrap_or(Tick::ZERO));
|
||||
let b = xof(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration));
|
||||
p.rect_filled(Rect::from_min_max(pos2(a, bar.min.y + 3.0), pos2(b, bar.max.y - 3.0)), 2.0, Color32::from_white_alpha(46));
|
||||
p.rect_filled(Rect::from_min_max(pos2(a, bar.min.y + 8.0), pos2(b, bar.max.y)), 0.0, Color32::from_rgb(0x5c, 0x5c, 0x5c));
|
||||
}
|
||||
// markers (program)
|
||||
if which == Which::Program
|
||||
&& let Some(q) = app.session.active_sequence()
|
||||
{
|
||||
for m in &q.markers {
|
||||
let x = xof(m.start);
|
||||
let c = Color32::from_rgb(m.color.rgb()[0], m.color.rgb()[1], m.color.rgb()[2]);
|
||||
p.rect_filled(Rect::from_min_size(pos2(x - 2.0, bar.min.y), vec2(4.0, 5.0)), 1.0, c);
|
||||
let c = m.color.marker_rgb();
|
||||
let c = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
let y = bar.min.y;
|
||||
p.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(x - 3.5, y), pos2(x + 3.5, y), pos2(x + 3.5, y + 7.0), pos2(x, y + 10.0), pos2(x - 3.5, y + 7.0)],
|
||||
c,
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
}
|
||||
let x = xof(time);
|
||||
// playhead: blue triangle over a line
|
||||
p.add(egui::Shape::convex_polygon(vec![pos2(x - 5.0, bar.min.y), pos2(x + 5.0, bar.min.y), pos2(x, bar.min.y + 6.0)], t.playhead, Stroke::NONE));
|
||||
p.line_segment([pos2(x, bar.min.y + 4.0), pos2(x, bar.max.y)], Stroke::new(1.5, t.playhead));
|
||||
let hy = bar.max.y - 11.0;
|
||||
p.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(x - 5.5, hy), pos2(x + 5.5, hy), pos2(x + 5.5, hy + 5.0), pos2(x, hy + 9.0), pos2(x - 5.5, hy + 5.0)],
|
||||
t.playhead,
|
||||
Stroke::NONE,
|
||||
));
|
||||
p.line_segment([pos2(x, hy + 8.0), pos2(x, bar.max.y)], Stroke::new(1.0, t.playhead));
|
||||
let resp = ui.interact(bar, egui::Id::new((which as u8, "scrub")), Sense::click_and_drag());
|
||||
app.auto.add(if which == Which::Program { "program.scrubBar" } else { "source.scrubBar" }, bar, "scrub bar");
|
||||
if (resp.dragged() || resp.clicked())
|
||||
|
|
|
|||
|
|
@ -352,15 +352,14 @@ fn icon_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, root: &Bin, filter: &str
|
|||
}
|
||||
let lc = it.label.rgb();
|
||||
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x, r.max.y + 6.0), vec2(8.0, 12.0)), 1.5, Color32::from_rgb(lc[0], lc[1], lc[2]));
|
||||
let clip = ui.painter().with_clip_rect(Rect::from_min_max(pos2(r.min.x + 12.0, r.max.y), pos2(r.max.x - 44.0, r.max.y + 20.0)));
|
||||
clip.text(pos2(r.min.x + 12.0, r.max.y + 12.0), Align2::LEFT_CENTER, &it.name, Tokens::ui(11.0), t.text);
|
||||
ui.painter().text(
|
||||
pos2(r.max.x, r.max.y + 12.0),
|
||||
Align2::RIGHT_CENTER,
|
||||
format_time(dur, it.frame_rate(), false, TimeDisplay::Timecode, 48000),
|
||||
Tokens::ui(10.0),
|
||||
t.text_dim,
|
||||
);
|
||||
let secs = dur.seconds().max(0.0) as u64;
|
||||
let dtext =
|
||||
if secs >= 3600 { format!("{}:{:02}:{:02}", secs / 3600, (secs / 60) % 60, secs % 60) } else { format!("{}:{:02}", secs / 60, secs % 60) };
|
||||
let dg = ui.painter().layout_no_wrap(dtext, Tokens::ui(11.0), t.text_dim);
|
||||
let dw = dg.size().x;
|
||||
ui.painter().galley(pos2(r.max.x - dw, r.max.y + 12.0 - dg.size().y / 2.0), dg, t.text_dim);
|
||||
let clip = ui.painter().with_clip_rect(Rect::from_min_max(pos2(r.min.x, r.max.y), pos2(r.max.x - dw - 8.0, r.max.y + 20.0)));
|
||||
clip.text(pos2(r.min.x + 12.0, r.max.y + 12.0), Align2::LEFT_CENTER, &it.name, Tokens::ui(11.5), t.text);
|
||||
app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
|
||||
item_interactions(app, ui, &resp, *id, &it.kind, actions);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@ use crate::icons::{self, Icon};
|
|||
use crate::state::{TimelineView, Tool};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
const TOP_H: f32 = 64.0; // timecode + ruler block
|
||||
const RULER_H: f32 = 30.0;
|
||||
const SCROLLBAR_H: f32 = 14.0;
|
||||
const DIVIDER_H: f32 = 6.0;
|
||||
const MASTER_H: f32 = 30.0;
|
||||
const TOP_H: f32 = 58.0; // timecode + toolbar (left) / ruler (right)
|
||||
const RULER_H: f32 = 44.0;
|
||||
const SCROLLBAR_H: f32 = 17.0;
|
||||
const DIVIDER_H: f32 = 5.0;
|
||||
const MASTER_H: f32 = 34.0;
|
||||
const SNAP_PX: f32 = 9.0;
|
||||
|
||||
/// Transient interaction state.
|
||||
|
|
@ -253,7 +253,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
for trn in &tr.transitions {
|
||||
let x0 = layout.x_of(trn.start);
|
||||
let x1 = layout.x_of(trn.end());
|
||||
let tr_rect = Rect::from_min_max(pos2(x0, r.rect.min.y + 1.0), pos2(x1, r.rect.min.y + (r.rect.height() * 0.5).max(14.0)));
|
||||
let tr_rect = Rect::from_min_max(pos2(x0, r.rect.min.y + 17.0), pos2(x1, r.rect.max.y - 1.0));
|
||||
draw_transition(&p, tr_rect, trn, &t);
|
||||
app.auto.add(&format!("timeline.transition.{}", trn.id.0), tr_rect, &trn.effect.effect);
|
||||
}
|
||||
|
|
@ -356,6 +356,22 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
|
||||
fn lighten(c: Color32, f: f32) -> Color32 {
|
||||
let l = |v: u8| (v as f32 + (255.0 - v as f32) * f).round() as u8;
|
||||
Color32::from_rgb(l(c.r()), l(c.g()), l(c.b()))
|
||||
}
|
||||
|
||||
/// Whether a clip lies inside the sequence In/Out range (Premiere brightens those).
|
||||
fn in_range(app: &FilmcraftApp, it: &TrackItem) -> bool {
|
||||
let Some(q) = app.session.active_sequence() else { return false };
|
||||
if q.mark_in.is_none() && q.mark_out.is_none() {
|
||||
return false;
|
||||
}
|
||||
let a = q.mark_in.unwrap_or(Tick::ZERO);
|
||||
let b = q.mark_out.map(|o| o + q.settings.frame_rate.frame_duration()).unwrap_or(Tick::MAX);
|
||||
it.start < b && it.end() > a
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_clip(
|
||||
app: &mut FilmcraftApp,
|
||||
|
|
@ -369,124 +385,142 @@ fn draw_clip(
|
|||
rate: FrameRate,
|
||||
) {
|
||||
let base = label_color(it.label);
|
||||
let inr = in_range(app, it);
|
||||
let fill = if !it.enabled {
|
||||
Color32::from_rgb(70, 70, 70)
|
||||
} else if kind == TrackKind::Video {
|
||||
base.gamma_multiply(0.92)
|
||||
Color32::from_rgb(0x2a, 0x2a, 0x2a)
|
||||
} else if inr {
|
||||
lighten(base, 0.12)
|
||||
} else if selected {
|
||||
lighten(base, 0.06)
|
||||
} else {
|
||||
base.gamma_multiply(0.8)
|
||||
base
|
||||
};
|
||||
let r = 3.0;
|
||||
p.rect_filled(body, r, fill);
|
||||
p.rect_filled(body, 0.0, fill);
|
||||
// 1 pt lighter top edge
|
||||
p.line_segment([pos2(body.min.x, body.min.y + 0.5), pos2(body.max.x, body.min.y + 0.5)], Stroke::new(1.0, lighten(fill, 0.15)));
|
||||
let name_h = 16.0;
|
||||
let w = body.width();
|
||||
// thumbnails (video) under the name band
|
||||
if kind == TrackKind::Video && app.ui.timeline.show_thumbnails && body.height() > 30.0 && w > 24.0 && it.enabled {
|
||||
let th_rect = Rect::from_min_max(pos2(body.min.x + 1.0, body.min.y + name_h), pos2(body.max.x - 1.0, body.max.y - 1.0));
|
||||
let th_h = th_rect.height();
|
||||
// head thumbnail (video): left-aligned, aspect-correct, below the name band
|
||||
if kind == TrackKind::Video && app.ui.timeline.show_thumbnails && body.height() > 28.0 && w > 20.0 && it.enabled {
|
||||
let th = Rect::from_min_max(pos2(body.min.x + 1.0, body.min.y + name_h), pos2(body.max.x - 1.0, body.max.y - 1.0));
|
||||
let aspect = app.session.project.item(it.item).and_then(|pi| match &pi.kind {
|
||||
filmcraft_project::ItemKind::Media(m) => m.info.video.as_ref().map(|v| v.width as f32 / v.height as f32),
|
||||
filmcraft_project::ItemKind::Sequence(s) => Some(s.settings.width as f32 / s.settings.height as f32),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(aspect) = aspect {
|
||||
let th_w = th_h * aspect;
|
||||
let n = ((th_rect.width() / th_w).ceil() as usize).clamp(1, 60);
|
||||
let clip_p = p.with_clip_rect(th_rect.intersect(p.clip_rect()));
|
||||
for k in 0..n {
|
||||
let x = th_rect.min.x + k as f32 * th_w;
|
||||
if x > clip_p.clip_rect().max.x || x + th_w < clip_p.clip_rect().min.x {
|
||||
continue;
|
||||
}
|
||||
let frac = (k as f64 * th_w as f64) / w.max(1.0) as f64;
|
||||
let tl = it.start + Tick((it.duration.0 as f64 * frac) as i64);
|
||||
let mt = it.source_time_at(tl);
|
||||
let mt = rate.snap(mt);
|
||||
// quantise thumbnail times to 1/2 s so the cache hits
|
||||
let q = Tick((mt.0 / (TICKS_PER_SECOND / 2)) * (TICKS_PER_SECOND / 2));
|
||||
if let Some((tex, _)) = app.thumbnail(ctx, it.item, q, 128) {
|
||||
let tr = Rect::from_min_size(pos2(x, th_rect.min.y), vec2(th_w, th_h));
|
||||
clip_p.image(tex, tr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(235));
|
||||
}
|
||||
let tw = (th.height() * aspect).min(th.width());
|
||||
let mt = rate.snap(it.source_in);
|
||||
if let Some((tex, _)) = app.thumbnail(ctx, it.item, mt, 160) {
|
||||
let r = Rect::from_min_size(th.min, vec2(tw, th.height()));
|
||||
let cp = p.with_clip_rect(th.intersect(p.clip_rect()));
|
||||
cp.image(tex, r, Rect::from_min_max(pos2(0.0, 0.0), pos2((tw / (th.height() * aspect)).min(1.0), 1.0)), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
// waveform (audio)
|
||||
if kind == TrackKind::Audio && app.ui.timeline.show_waveforms && w > 4.0 {
|
||||
draw_waveform(app, p, body, it, fill);
|
||||
// waveform (audio): rectified, lower 40 %
|
||||
if kind == TrackKind::Audio && app.ui.timeline.show_waveforms && w > 3.0 {
|
||||
draw_waveform(app, p, body, it, if inr { Color32::from_rgb(0xc7, 0xe9, 0xfa) } else { Color32::from_rgb(0x79, 0xc0, 0xf9) });
|
||||
}
|
||||
let clip_p = p.with_clip_rect(body.shrink2(vec2(1.0, 0.0)).intersect(p.clip_rect()));
|
||||
// name band
|
||||
let text_col = if it.enabled { Color32::from_rgb(20, 20, 24) } else { t.text_dim };
|
||||
let mut tx = body.min.x + 5.0;
|
||||
if it.has_standard_effects() || it.has_modified_intrinsics() {
|
||||
let fx = Rect::from_min_size(pos2(tx, body.min.y + 3.0), vec2(15.0, 10.0));
|
||||
if fx.max.x < body.max.x {
|
||||
p.rect_filled(fx, 2.0, if it.has_standard_effects() { Color32::from_rgb(236, 196, 58) } else { Color32::from_rgba_unmultiplied(0, 0, 0, 70) });
|
||||
p.text(fx.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(8.5), Color32::from_rgb(30, 30, 30));
|
||||
tx += 18.0;
|
||||
}
|
||||
}
|
||||
if w > 30.0 {
|
||||
let clip_p = p.with_clip_rect(body.shrink2(vec2(3.0, 0.0)).intersect(p.clip_rect()));
|
||||
if w > 24.0 {
|
||||
let mut label = it.name.clone();
|
||||
if (it.speed - 1.0).abs() > 1e-6 || it.reverse {
|
||||
label = format!("{label} [{}%]", ((if it.reverse { -1.0 } else { 1.0 }) * it.speed * 100.0).round());
|
||||
}
|
||||
clip_p.text(pos2(tx.max(clip_p.clip_rect().min.x + 4.0), body.min.y + 8.0), Align2::LEFT_CENTER, label, Tokens::ui(10.5), text_col);
|
||||
clip_p.text(
|
||||
pos2(body.min.x + 6.0, body.min.y + 8.5),
|
||||
Align2::LEFT_CENTER,
|
||||
label,
|
||||
Tokens::ui(11.5),
|
||||
if it.enabled { Color32::from_rgb(0xd9, 0xd9, 0xd9) } else { t.text_faint },
|
||||
);
|
||||
}
|
||||
if it.link.is_none() && kind == TrackKind::Video && w > 60.0 {
|
||||
// unlinked items show no link indicator; linked ones are default
|
||||
// fx badge right-aligned in the header (italic-ish "fx")
|
||||
if (it.has_standard_effects() || it.has_modified_intrinsics()) && w > 40.0 {
|
||||
let col = if it.has_standard_effects() { Color32::from_rgb(0xe8, 0xc5, 0x4a) } else { Color32::from_rgb(0x91, 0xa2, 0xac) };
|
||||
clip_p.text(
|
||||
pos2(body.max.x - 6.0, body.min.y + 8.5),
|
||||
Align2::RIGHT_CENTER,
|
||||
"fx",
|
||||
egui::FontId::new(11.0, egui::FontFamily::Name("semibold".into())),
|
||||
col,
|
||||
);
|
||||
}
|
||||
// corner triangles where the clip reaches the media's first/last frame (no handles)
|
||||
let media_dur = app.session.project.item(it.item).map(|i| i.duration());
|
||||
let no_head = it.source_in <= Tick::ZERO;
|
||||
let no_tail = media_dur.is_some_and(|d| d.0 > 0 && it.source_out() >= d - rate.frame_duration());
|
||||
let tri = 5.0;
|
||||
if no_head && w > 12.0 {
|
||||
clip_p.add(egui::Shape::convex_polygon(
|
||||
vec![body.min, body.min + vec2(tri, 0.0), body.min + vec2(0.0, tri)],
|
||||
Color32::from_white_alpha(220),
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
if no_tail && w > 12.0 {
|
||||
let tr = pos2(body.max.x, body.min.y);
|
||||
clip_p.add(egui::Shape::convex_polygon(vec![tr, tr + vec2(0.0, tri), tr - vec2(tri, 0.0)], Color32::from_white_alpha(220), Stroke::NONE));
|
||||
}
|
||||
// 1 pt black separator at the clip's left edge
|
||||
p.line_segment([pos2(body.min.x + 0.5, body.min.y), pos2(body.min.x + 0.5, body.max.y)], Stroke::new(1.0, Color32::BLACK));
|
||||
if selected {
|
||||
p.rect_stroke(body, r, Stroke::new(1.5, t.clip_selected_border), StrokeKind::Inside);
|
||||
} else {
|
||||
p.rect_stroke(body, r, Stroke::new(1.0, Color32::from_black_alpha(90)), StrokeKind::Inside);
|
||||
p.rect_stroke(body.shrink(1.0), 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside);
|
||||
p.rect_stroke(body, 0.0, Stroke::new(2.0, t.clip_selected_border), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &TrackItem, fill: Color32) {
|
||||
let peaks = request_peaks(app, it.item);
|
||||
let Some(peaks) = peaks else { return };
|
||||
// peaks are per 256 samples at 48 kHz
|
||||
fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &TrackItem, col: Color32) {
|
||||
let Some(peaks) = request_peaks(app, it.item) else { return };
|
||||
let spp = 256.0;
|
||||
let sr = 48_000.0;
|
||||
let area = body.shrink2(vec2(1.0, 3.0));
|
||||
let area = Rect::from_min_max(pos2(area.min.x, area.min.y + 12.0), area.max);
|
||||
if area.height() < 4.0 {
|
||||
let zone_h = (body.height() * 0.42).max(8.0);
|
||||
let area = Rect::from_min_max(pos2(body.min.x + 1.0, body.max.y - zone_h - 1.0), pos2(body.max.x - 1.0, body.max.y - 1.0));
|
||||
let clip = p.clip_rect().intersect(area);
|
||||
if clip.width() <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let clip = p.clip_rect().intersect(area);
|
||||
let mid = area.center().y;
|
||||
let amp = area.height() * 0.5;
|
||||
// Like Premiere, the waveform reflects clip gain (not the Volume effect); normalise so quiet
|
||||
// material stays readable.
|
||||
let peak = peaks.iter().fold(0f32, |m, (a, b)| m.max(a.abs()).max(b.abs())).max(1e-4);
|
||||
let gain = filmcraft_render::audio::db_to_gain(it.gain_db) * (0.9 / peak).min(8.0);
|
||||
let col = fill.linear_multiply(0.45);
|
||||
let dark = Color32::from_rgba_unmultiplied(8, 40, 16, 150);
|
||||
let gain = filmcraft_render::audio::db_to_gain(it.gain_db) / peak;
|
||||
let mut mesh = egui::Mesh::default();
|
||||
let x_start = clip.min.x.floor() as i32;
|
||||
let x_end = clip.max.x.ceil() as i32;
|
||||
let dur_px = body.width().max(1.0);
|
||||
for x in (x_start..x_end).step_by(1) {
|
||||
let f = (x as f32 - body.min.x) / dur_px;
|
||||
let tl = it.start + Tick((it.duration.0 as f64 * f as f64) as i64);
|
||||
let tl2 = it.start + Tick((it.duration.0 as f64 * ((x as f32 + 1.0 - body.min.x) / dur_px) as f64) as i64);
|
||||
let s0 = (it.source_time_at(tl).seconds() * sr / spp) as usize;
|
||||
let s1 = ((it.source_time_at(tl2).seconds() * sr / spp) as usize).max(s0 + 1);
|
||||
let mut lo = 0f32;
|
||||
let mut hi = 0f32;
|
||||
for x in (clip.min.x.floor() as i32)..(clip.max.x.ceil() as i32) {
|
||||
let f0 = (x as f32 - body.min.x) / dur_px;
|
||||
let f1 = (x as f32 + 1.0 - body.min.x) / dur_px;
|
||||
let t0 = it.start + Tick((it.duration.0 as f64 * f0 as f64) as i64);
|
||||
let t1 = it.start + Tick((it.duration.0 as f64 * f1 as f64) as i64);
|
||||
let s0 = (it.source_time_at(t0).seconds() * sr / spp) as usize;
|
||||
let s1 = ((it.source_time_at(t1).seconds() * sr / spp) as usize).max(s0 + 1);
|
||||
let mut m = 0f32;
|
||||
for (a, b) in peaks.iter().skip(s0).take(s1 - s0) {
|
||||
lo = lo.min(*a);
|
||||
hi = hi.max(*b);
|
||||
m = m.max(a.abs()).max(b.abs());
|
||||
}
|
||||
// logarithmic display scale (Premiere's default): −48 dB → 0, 0 dB → full
|
||||
let db = 20.0 * (m * gain).max(1e-5).log10();
|
||||
let h = ((db + 48.0) / 48.0).clamp(0.0, 1.0) * area.height();
|
||||
if h > 0.3 {
|
||||
mesh.add_colored_rect(Rect::from_min_max(pos2(x as f32, area.max.y - h), pos2(x as f32 + 1.0, area.max.y)), col);
|
||||
}
|
||||
let y0 = mid - (hi * gain).clamp(-1.0, 1.0) * amp;
|
||||
let y1 = mid - (lo * gain).clamp(-1.0, 1.0) * amp;
|
||||
let r = Rect::from_min_max(pos2(x as f32, y0.min(mid - 0.5)), pos2(x as f32 + 1.0, y1.max(mid + 0.5)));
|
||||
mesh.add_colored_rect(r, dark);
|
||||
}
|
||||
let _ = col;
|
||||
p.add(mesh);
|
||||
// channel label box
|
||||
let cb = Rect::from_min_size(pos2(body.min.x + 3.0, body.max.y - 11.0), vec2(8.0, 9.0));
|
||||
if body.width() > 30.0 {
|
||||
p.rect_filled(cb, 1.0, Color32::from_black_alpha(160));
|
||||
p.text(cb.center(), Align2::CENTER_CENTER, "1", Tokens::ui(7.5), Color32::from_rgb(0xd9, 0xd9, 0xd9));
|
||||
}
|
||||
// volume rubber band (white line with a black shadow) at mid-height of the upper zone
|
||||
let level = it.effect("volume").map(|e| e.f64_at("level", it.source_in)).unwrap_or(0.0);
|
||||
let upper = Rect::from_min_max(pos2(body.min.x, body.min.y + 16.0), pos2(body.max.x, area.min.y));
|
||||
if upper.height() > 6.0 {
|
||||
let norm = ((level + 60.0) / 66.0).clamp(0.0, 1.0) as f32;
|
||||
let y = upper.max.y - norm * upper.height();
|
||||
let cp = p.with_clip_rect(body.intersect(p.clip_rect()));
|
||||
cp.line_segment([pos2(body.min.x, y + 1.0), pos2(body.max.x, y + 1.0)], Stroke::new(1.0, Color32::BLACK));
|
||||
cp.line_segment([pos2(body.min.x, y), pos2(body.max.x, y)], Stroke::new(1.0, Color32::WHITE));
|
||||
}
|
||||
}
|
||||
|
||||
fn request_peaks(app: &mut FilmcraftApp, item: ItemId) -> Option<Arc<Vec<(f32, f32)>>> {
|
||||
|
|
@ -535,16 +569,57 @@ fn request_peaks(app: &mut FilmcraftApp, item: ItemId) -> Option<Arc<Vec<(f32, f
|
|||
}
|
||||
|
||||
fn draw_transition(p: &egui::Painter, r: Rect, trn: &filmcraft_project::Transition, _t: &Tokens) {
|
||||
let fill = Color32::from_rgba_unmultiplied(120, 120, 150, 200);
|
||||
p.rect_filled(r, 2.0, fill);
|
||||
// diagonal (Premiere shows a diagonal line across transitions)
|
||||
let clip = p.with_clip_rect(r.intersect(p.clip_rect()));
|
||||
clip.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.0, Color32::from_black_alpha(120)));
|
||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_black_alpha(140)), StrokeKind::Inside);
|
||||
if r.width() > 50.0 {
|
||||
let name = trn.effect.def().map(|d| d.name).unwrap_or(&trn.effect.effect);
|
||||
clip.text(pos2(r.min.x + 4.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(9.5), Color32::from_rgb(20, 20, 24));
|
||||
let cp = p.with_clip_rect(r.intersect(p.clip_rect()));
|
||||
cp.rect_filled(r, 0.0, Color32::from_black_alpha(90));
|
||||
// 45° hatching
|
||||
let step = 4.0;
|
||||
let mut x = r.min.x - r.height();
|
||||
while x < r.max.x {
|
||||
cp.line_segment([pos2(x, r.max.y), pos2(x + r.height(), r.min.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(0xd9, 0xd9, 0xd9, 110)));
|
||||
x += step;
|
||||
}
|
||||
let audio = matches!(trn.effect.def().map(|d| d.kind), Some(filmcraft_project::EffectKind::AudioTransition));
|
||||
if audio {
|
||||
// crossing fade curves
|
||||
let n = 16;
|
||||
let a: Vec<Pos2> = (0..=n)
|
||||
.map(|i| {
|
||||
pos2(r.min.x + r.width() * i as f32 / n as f32, r.min.y + r.height() * (1.0 - ((i as f32 / n as f32) * std::f32::consts::FRAC_PI_2).sin()))
|
||||
})
|
||||
.collect();
|
||||
let b: Vec<Pos2> = (0..=n)
|
||||
.map(|i| {
|
||||
pos2(r.min.x + r.width() * i as f32 / n as f32, r.min.y + r.height() * (1.0 - ((i as f32 / n as f32) * std::f32::consts::FRAC_PI_2).cos()))
|
||||
})
|
||||
.collect();
|
||||
cp.add(egui::Shape::line(a, Stroke::new(1.0, Color32::WHITE)));
|
||||
cp.add(egui::Shape::line(b, Stroke::new(1.0, Color32::WHITE)));
|
||||
}
|
||||
cp.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_rgb(0xeb, 0xeb, 0xeb)), StrokeKind::Inside);
|
||||
if r.width() > 34.0 && !audio {
|
||||
let name = trn.effect.def().map(|d| d.name).unwrap_or(&trn.effect.effect);
|
||||
let tr = Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2((r.width() - 4.0).min(80.0), 13.0));
|
||||
cp.rect_filled(tr, 0.0, Color32::from_black_alpha(170));
|
||||
cp.text(pos2(tr.min.x + 3.0, tr.center().y), Align2::LEFT_CENTER, name, Tokens::ui(10.5), Color32::from_rgb(0xd9, 0xd9, 0xd9));
|
||||
}
|
||||
}
|
||||
|
||||
/// A Premiere track-header button (patch / target): blue fill when on, full track height.
|
||||
fn patch_button(ui: &mut egui::Ui, r: Rect, clip: Rect, label: &str, on: bool, show_off: bool, id: egui::Id, t: &Tokens) -> egui::Response {
|
||||
let resp = ui.interact(r.intersect(clip), id, Sense::click());
|
||||
let p = ui.painter().with_clip_rect(clip);
|
||||
if on {
|
||||
p.rect_filled(r, 2.0, Color32::from_rgb(0x26, 0x5b, 0xc1));
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::semibold(10.0), Color32::from_rgb(0xeb, 0xeb, 0xeb));
|
||||
} else if show_off {
|
||||
if resp.hovered() {
|
||||
p.rect_filled(r, 2.0, t.hover);
|
||||
}
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::semibold(10.0), t.text_dim);
|
||||
} else if resp.hovered() {
|
||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
|
@ -555,87 +630,111 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
|||
for r in rows {
|
||||
let tr = seq.track(r.track).expect("track");
|
||||
let clip_rect = if r.kind == TrackKind::Video { vclip } else { aclip };
|
||||
let hrect = Rect::from_min_max(pos2(rect.min.x, r.rect.min.y), pos2(rect.min.x + hw - 2.0, r.rect.max.y));
|
||||
let hrect = Rect::from_min_max(pos2(rect.min.x, r.rect.min.y), pos2(rect.min.x + hw, r.rect.max.y));
|
||||
let visible = hrect.intersect(clip_rect);
|
||||
if visible.height() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let p = ui.painter().with_clip_rect(visible);
|
||||
p.rect_filled(hrect.shrink2(vec2(0.0, 0.5)), 0.0, t.tl_header_bg);
|
||||
p.rect_filled(hrect, 0.0, t.tl_header_bg);
|
||||
p.line_segment([pos2(hrect.min.x, hrect.max.y - 0.5), pos2(hrect.max.x, hrect.max.y - 0.5)], Stroke::new(1.0, t.separator));
|
||||
let label = format!("{}{}", if r.kind == TrackKind::Video { "V" } else { "A" }, r.index + 1);
|
||||
let cy = hrect.min.y + 13.0f32.min(hrect.height() / 2.0);
|
||||
// source patch (left): shows the source track when patched
|
||||
let btn_rect = |x0: f32| Rect::from_min_max(pos2(hrect.min.x + x0, hrect.min.y + 1.0), pos2(hrect.min.x + x0 + 24.0, hrect.max.y - 2.0));
|
||||
// 1. source patch (absent when unpatched)
|
||||
let patched = if r.kind == TrackKind::Video { tg.video_dest == Some(r.track) } else { tg.audio_dest == Some(r.track) };
|
||||
let pr = Rect::from_center_size(pos2(hrect.min.x + 14.0, cy), vec2(22.0, 17.0));
|
||||
let presp = ui.interact(pr.intersect(visible), egui::Id::new(("patch", r.track.0)), Sense::click());
|
||||
let pr = btn_rect(13.0);
|
||||
app.auto.add(&format!("timeline.track.{label}.sourcePatch"), pr, "Source patch");
|
||||
if patched {
|
||||
p.rect_filled(pr, 3.0, t.accent);
|
||||
p.text(pr.center(), Align2::CENTER_CENTER, label.clone(), Tokens::semibold(10.0), Color32::WHITE);
|
||||
} else {
|
||||
p.rect_stroke(pr, 3.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
}
|
||||
if presp.clicked() {
|
||||
if patch_button(ui, pr, visible, &label, patched, false, egui::Id::new(("patch", r.track.0)), t).clicked() {
|
||||
actions.push(("timeline.setTargeting".into(), json!({"track": r.track.0, "sourcePatch": !patched})));
|
||||
}
|
||||
// target toggle
|
||||
// 2. track lock
|
||||
let small = |x: f32, y: f32| Rect::from_center_size(pos2(hrect.min.x + x, y), vec2(18.0, 18.0));
|
||||
let upper_y = hrect.min.y + (16.0f32).min(hrect.height() / 2.0);
|
||||
let lock_r = small(49.0, hrect.center().y);
|
||||
let lresp = crate::widgets::icon_toggle(
|
||||
ui,
|
||||
lock_r.intersect(visible),
|
||||
if tr.locked { Icon::Lock } else { Icon::Unlock },
|
||||
true,
|
||||
t,
|
||||
egui::Id::new(("locked", r.track.0)),
|
||||
Some(if tr.locked { Color32::from_rgb(0xd1, 0xd1, 0xd1) } else { t.text_dim }),
|
||||
);
|
||||
app.auto.add(&format!("timeline.track.{label}.locked"), lock_r, "Toggle Track Lock");
|
||||
if lresp.clicked() {
|
||||
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "locked": !tr.locked})));
|
||||
}
|
||||
if tr.locked {
|
||||
// diagonal hatch over locked lanes is drawn by the lane painter; here a subtle tint
|
||||
p.rect_filled(Rect::from_min_max(pos2(hrect.max.x - 4.0, hrect.min.y), hrect.max), 0.0, Color32::from_rgb(0x4b, 0x4b, 0x4b));
|
||||
}
|
||||
// 3. target
|
||||
let targeted = tg.targeted.contains(&r.track);
|
||||
let trr = Rect::from_center_size(pos2(hrect.min.x + 40.0, cy), vec2(24.0, 17.0));
|
||||
let tresp = ui.interact(trr.intersect(visible), egui::Id::new(("target", r.track.0)), Sense::click());
|
||||
let trr = btn_rect(61.0);
|
||||
app.auto.add(&format!("timeline.track.{label}.target"), trr, "Toggle track targeting");
|
||||
p.rect_filled(trr, 3.0, if targeted { Color32::from_rgb(70, 70, 70) } else { Color32::TRANSPARENT });
|
||||
p.rect_stroke(trr, 3.0, Stroke::new(1.0, if targeted { t.accent } else { t.separator }), StrokeKind::Inside);
|
||||
p.text(trr.center(), Align2::CENTER_CENTER, label.clone(), Tokens::semibold(10.0), if targeted { t.tab_text_active } else { t.text_dim });
|
||||
if tresp.clicked() {
|
||||
if patch_button(ui, trr, visible, &label, targeted, true, egui::Id::new(("target", r.track.0)), t).clicked() {
|
||||
actions.push(("timeline.setTargeting".into(), json!({"track": r.track.0, "targeted": !targeted})));
|
||||
}
|
||||
// sync lock, lock
|
||||
let mut x = hrect.min.x + 60.0;
|
||||
let mut toggle = |icon: Icon, on: bool, name: &str, key: &str, on_col: Option<Color32>, actions: &mut Vec<(String, Value)>, app: &mut FilmcraftApp| {
|
||||
let br = Rect::from_center_size(pos2(x + 9.0, cy), vec2(18.0, 18.0));
|
||||
let resp = crate::widgets::icon_toggle(ui, br.intersect(visible), icon, on, t, egui::Id::new((key, r.track.0)), on_col);
|
||||
app.auto.add(&format!("timeline.track.{label}.{key}"), br, name);
|
||||
if resp.clicked() {
|
||||
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, key: !on})));
|
||||
}
|
||||
x += 20.0;
|
||||
};
|
||||
toggle(Icon::SyncLock, tr.sync_lock, "Toggle Sync Lock", "syncLock", Some(t.icon), &mut actions, app);
|
||||
toggle(
|
||||
if tr.locked { Icon::Lock } else { Icon::Unlock },
|
||||
tr.locked,
|
||||
"Toggle Track Lock",
|
||||
"locked",
|
||||
Some(Color32::from_rgb(230, 190, 70)),
|
||||
&mut actions,
|
||||
app,
|
||||
);
|
||||
// 4. upper line icons
|
||||
let mut x = 102.0;
|
||||
let sr = small(x, upper_y);
|
||||
let sresp =
|
||||
crate::widgets::icon_toggle(ui, sr.intersect(visible), Icon::SyncLock, tr.sync_lock, t, egui::Id::new(("syncLock", r.track.0)), Some(t.text_dim));
|
||||
app.auto.add(&format!("timeline.track.{label}.syncLock"), sr, "Toggle Sync Lock");
|
||||
if sresp.clicked() {
|
||||
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "syncLock": !tr.sync_lock})));
|
||||
}
|
||||
x += 24.0;
|
||||
if r.kind == TrackKind::Video {
|
||||
toggle(if tr.enabled { Icon::Eye } else { Icon::EyeOff }, tr.enabled, "Toggle Track Output", "enabled", Some(t.icon), &mut actions, app);
|
||||
let er = small(x, upper_y);
|
||||
let eresp = crate::widgets::icon_toggle(
|
||||
ui,
|
||||
er.intersect(visible),
|
||||
if tr.enabled { Icon::Eye } else { Icon::EyeOff },
|
||||
true,
|
||||
t,
|
||||
egui::Id::new(("enabled", r.track.0)),
|
||||
Some(t.text_dim),
|
||||
);
|
||||
app.auto.add(&format!("timeline.track.{label}.enabled"), er, "Toggle Track Output");
|
||||
if eresp.clicked() {
|
||||
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "enabled": !tr.enabled})));
|
||||
}
|
||||
} else {
|
||||
let mr = Rect::from_center_size(pos2(x + 9.0, cy), vec2(17.0, 15.0));
|
||||
let mresp =
|
||||
crate::widgets::letter_toggle(ui, mr.intersect(visible), "M", tr.muted, Color32::from_rgb(70, 190, 110), t, egui::Id::new(("mute", r.track.0)));
|
||||
let mr = Rect::from_center_size(pos2(hrect.min.x + x, upper_y), vec2(14.0, 14.0));
|
||||
let mresp = crate::widgets::letter_toggle(
|
||||
ui,
|
||||
mr.intersect(visible),
|
||||
"M",
|
||||
tr.muted,
|
||||
Color32::from_rgb(0x2d, 0x9d, 0x78),
|
||||
t,
|
||||
egui::Id::new(("mute", r.track.0)),
|
||||
);
|
||||
app.auto.add(&format!("timeline.track.{label}.muted"), mr, "Mute Track");
|
||||
if mresp.clicked() {
|
||||
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "muted": !tr.muted})));
|
||||
}
|
||||
let sr = Rect::from_center_size(pos2(x + 29.0, cy), vec2(17.0, 15.0));
|
||||
let sresp =
|
||||
crate::widgets::letter_toggle(ui, sr.intersect(visible), "S", tr.solo, Color32::from_rgb(235, 200, 60), t, egui::Id::new(("solo", r.track.0)));
|
||||
app.auto.add(&format!("timeline.track.{label}.solo"), sr, "Solo Track");
|
||||
if sresp.clicked() {
|
||||
let s2 = Rect::from_center_size(pos2(hrect.min.x + x + 20.0, upper_y), vec2(14.0, 14.0));
|
||||
let so = crate::widgets::letter_toggle(
|
||||
ui,
|
||||
s2.intersect(visible),
|
||||
"S",
|
||||
tr.solo,
|
||||
Color32::from_rgb(0xf0, 0xf0, 0x4f),
|
||||
t,
|
||||
egui::Id::new(("solo", r.track.0)),
|
||||
);
|
||||
app.auto.add(&format!("timeline.track.{label}.solo"), s2, "Solo Track");
|
||||
if so.clicked() {
|
||||
actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "solo": !tr.solo})));
|
||||
}
|
||||
let vr = Rect::from_center_size(pos2(x + 49.0, cy), vec2(17.0, 15.0));
|
||||
crate::widgets::icon_toggle(ui, vr.intersect(visible), Icon::Mic, false, t, egui::Id::new(("vo", r.track.0)), None);
|
||||
x += 62.0;
|
||||
let vr = small(x + 42.0, upper_y);
|
||||
crate::widgets::icon_toggle(ui, vr.intersect(visible), Icon::Mic, true, t, egui::Id::new(("vo", r.track.0)), Some(t.text_dim));
|
||||
}
|
||||
// name (when the track is tall enough) or label
|
||||
if hrect.height() >= 38.0 {
|
||||
p.text(pos2(hrect.min.x + 60.0, hrect.max.y - 11.0), Align2::LEFT_CENTER, &tr.name, Tokens::ui(10.5), t.text_dim);
|
||||
} else if x + 30.0 < hrect.max.x {
|
||||
p.text(pos2(hrect.max.x - 8.0, cy), Align2::RIGHT_CENTER, &tr.name, Tokens::ui(10.5), t.text_faint);
|
||||
// 5. name on the lower line (or inline when short)
|
||||
if hrect.height() >= 40.0 {
|
||||
p.text(pos2(hrect.min.x + 94.0, hrect.min.y + 34.0), Align2::LEFT_CENTER, &tr.name, Tokens::ui(11.0), t.text_dim);
|
||||
}
|
||||
// resize track height by dragging the header's bottom edge
|
||||
let edge = Rect::from_min_max(pos2(hrect.min.x, hrect.max.y - 3.0), pos2(hrect.max.x, hrect.max.y + 2.0)).intersect(visible);
|
||||
|
|
@ -650,13 +749,18 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
|||
*h = (*h + d).clamp(22.0, 220.0);
|
||||
}
|
||||
}
|
||||
// double-click header toggles tall/short
|
||||
let resp = ui.interact(hrect.intersect(visible), egui::Id::new(("hdr", r.track.0)), Sense::click());
|
||||
let resp = ui.interact(
|
||||
Rect::from_min_max(pos2(hrect.min.x + 90.0, hrect.min.y + 26.0), hrect.max).intersect(visible),
|
||||
egui::Id::new(("hdr", r.track.0)),
|
||||
Sense::click(),
|
||||
);
|
||||
if resp.double_clicked() {
|
||||
let h = if r.kind == TrackKind::Video { &mut app.ui.timeline.video_track_h } else { &mut app.ui.timeline.audio_track_h };
|
||||
*h = if *h < 60.0 { 96.0 } else { 36.0 };
|
||||
*h = if *h < 50.0 { 64.0 } else { 30.0 };
|
||||
}
|
||||
}
|
||||
// column separator
|
||||
ui.painter().line_segment([pos2(rect.min.x + hw - 0.5, vclip.min.y), pos2(rect.min.x + hw - 0.5, aclip.max.y + MASTER_H)], Stroke::new(1.0, t.separator));
|
||||
for (cmd, params) in actions {
|
||||
if let Err(e) = app.session.execute(&cmd, params) {
|
||||
app.ui.status = e.to_string();
|
||||
|
|
@ -667,34 +771,36 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
|||
fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequence, layout: &Layout, t: &Tokens, seq_id: ItemId) {
|
||||
let p = ui.painter().clone();
|
||||
let rate = seq.settings.frame_rate;
|
||||
// sequence tabs (open sequences)
|
||||
let tabs_rect = Rect::from_min_max(pos2(rect.min.x, rect.min.y), pos2(rect.max.x, rect.min.y + TOP_H - RULER_H));
|
||||
let _ = tabs_rect;
|
||||
// big timecode
|
||||
let hw = app.ui.timeline.header_w;
|
||||
let ruler = layout.ruler;
|
||||
// background of the whole top block
|
||||
p.rect_filled(Rect::from_min_max(rect.min, pos2(rect.max.x, ruler.max.y)), 0.0, t.panel_bg);
|
||||
// current timecode: 14 pt semibold accent
|
||||
let tc = format_time(app.session.playhead(), rate, seq.settings.drop_frame, TimeDisplay::Timecode, seq.settings.sample_rate as i64);
|
||||
let tc_rect = Rect::from_min_size(pos2(rect.min.x + 10.0, rect.min.y + 6.0), vec2(hw - 16.0, 24.0));
|
||||
p.text(pos2(tc_rect.min.x, tc_rect.center().y), Align2::LEFT_CENTER, &tc, Tokens::mono(18.0), t.timecode);
|
||||
let tc_rect = Rect::from_min_size(pos2(rect.min.x + 14.0, rect.min.y + 4.0), vec2(hw - 20.0, 20.0));
|
||||
p.text(pos2(tc_rect.min.x, tc_rect.center().y), Align2::LEFT_CENTER, &tc, Tokens::semibold(15.0), t.hot_text);
|
||||
app.auto.add("timeline.timecode", tc_rect, &tc);
|
||||
// toggle row
|
||||
let mut x = rect.min.x + 10.0;
|
||||
let y = rect.min.y + TOP_H - 16.0;
|
||||
let toggles: [(Icon, &str, bool, &str); 5] = [
|
||||
(Icon::Nest, "nest", true, "Insert and overwrite sequences as nests or individual clips"),
|
||||
(Icon::Magnet, "snap", app.session.state.snapping, "Snap in Timeline (S)"),
|
||||
(Icon::Link, "linked", app.session.state.linked_selection, "Linked Selection"),
|
||||
(Icon::Marker, "marker", false, "Add Marker (M)"),
|
||||
(Icon::Wrench, "settings", false, "Timeline Display Settings"),
|
||||
// toolbar: 30 × 30 buttons, "on" = #4b4b4b fill
|
||||
let mut x = rect.min.x + 12.0;
|
||||
let y = rect.min.y + 26.0;
|
||||
let toggles: [(Icon, &str, bool, bool, &str); 6] = [
|
||||
(Icon::Nest, "nest", true, true, "Insert and overwrite sequences as nests or individual clips"),
|
||||
(Icon::Magnet, "snap", app.session.state.snapping, true, "Snap in Timeline (S)"),
|
||||
(Icon::Link, "linked", app.session.state.linked_selection, true, "Linked Selection"),
|
||||
(Icon::Captions, "captions", false, false, "Caption track options"),
|
||||
(Icon::Marker, "marker", false, false, "Add Marker (M)"),
|
||||
(Icon::Wrench, "settings", false, false, "Timeline Display Settings"),
|
||||
];
|
||||
for (icon, key, on, tip) in toggles {
|
||||
let r = Rect::from_center_size(pos2(x + 11.0, y), vec2(22.0, 22.0));
|
||||
for (icon, key, on, toggle, tip) in toggles {
|
||||
let r = Rect::from_min_size(pos2(x, y), vec2(28.0, 28.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("tl-toggle", key)), Sense::click()).on_hover_text(tip);
|
||||
app.auto.add(&format!("timeline.toggle.{key}"), r, tip);
|
||||
if resp.hovered() {
|
||||
p.rect_filled(r, 3.0, t.hover);
|
||||
if toggle && on {
|
||||
p.rect_filled(r, 4.0, Color32::from_rgb(0x4b, 0x4b, 0x4b));
|
||||
} else if resp.hovered() {
|
||||
p.rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
let col = if on && matches!(key, "snap" | "linked") { t.icon_active } else { t.icon };
|
||||
icons::paint(&p, r.shrink(4.0), icon, col);
|
||||
icons::paint(&p, r.shrink(7.0), icon, if toggle && on { t.text } else { t.text_dim });
|
||||
if resp.clicked() {
|
||||
let _ = match key {
|
||||
"snap" => app.session.execute("sequence.snap", json!({})),
|
||||
|
|
@ -709,8 +815,8 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
|
|||
ui.checkbox(&mut app.ui.timeline.show_waveforms, "Show Audio Waveform");
|
||||
ui.separator();
|
||||
if ui.button("Expand All Tracks").clicked() {
|
||||
app.ui.timeline.video_track_h = 90.0;
|
||||
app.ui.timeline.audio_track_h = 70.0;
|
||||
app.ui.timeline.video_track_h = 64.0;
|
||||
app.ui.timeline.audio_track_h = 64.0;
|
||||
}
|
||||
if ui.button("Minimize All Tracks").clicked() {
|
||||
app.ui.timeline.video_track_h = 26.0;
|
||||
|
|
@ -718,17 +824,16 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
|
|||
}
|
||||
});
|
||||
}
|
||||
x += 26.0;
|
||||
x += 30.0;
|
||||
}
|
||||
// ruler
|
||||
let ruler = layout.ruler;
|
||||
p.rect_filled(Rect::from_min_max(pos2(ruler.min.x, ruler.min.y - (TOP_H - RULER_H)), ruler.max), 0.0, t.panel_bg);
|
||||
p.rect_filled(ruler, 0.0, t.tl_ruler_bg);
|
||||
let pps = layout.pps;
|
||||
// choose a tick step in frames so labels are ≥ 90 px apart
|
||||
// ----- ruler: markers row · labels · ticks · 2 pt render bar
|
||||
let clip = p.with_clip_rect(ruler);
|
||||
let marker_y = ruler.min.y + 2.0;
|
||||
let label_y = ruler.min.y + 24.0;
|
||||
let tick_base = ruler.max.y - 3.0;
|
||||
let fps = rate.as_f64();
|
||||
let base = rate.timecode_base();
|
||||
let steps_frames: Vec<i64> = vec![
|
||||
let steps: Vec<i64> = vec![
|
||||
1,
|
||||
2,
|
||||
5,
|
||||
|
|
@ -747,67 +852,56 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
|
|||
base * 1800,
|
||||
base * 3600,
|
||||
];
|
||||
let frame_px = pps / fps;
|
||||
let label_step = *steps_frames.iter().find(|s| **s as f64 * frame_px >= 90.0).unwrap_or(&(base * 3600));
|
||||
let minor = *steps_frames.iter().find(|s| **s as f64 * frame_px >= 9.0).unwrap_or(&label_step);
|
||||
let f0 = rate.frame_at(layout.tick_at(ruler.min.x)).max(0);
|
||||
let f1 = rate.frame_at(layout.tick_at(ruler.max.x)) + 1;
|
||||
let mut f = (f0 / minor) * minor;
|
||||
let clip = p.with_clip_rect(ruler);
|
||||
while f <= f1 {
|
||||
let x = layout.x_of(rate.tick_of(f));
|
||||
let major = f % label_step == 0;
|
||||
let h = if major {
|
||||
10.0
|
||||
} else if f % (minor * 5) == 0 {
|
||||
6.0
|
||||
} else {
|
||||
3.5
|
||||
};
|
||||
clip.line_segment([pos2(x, ruler.max.y - h), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick));
|
||||
if major {
|
||||
let label = format_time(rate.tick_of(f), rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000);
|
||||
clip.text(pos2(x + 3.0, ruler.min.y + 8.0), Align2::LEFT_CENTER, label, Tokens::ui(9.5), t.tl_ruler_text);
|
||||
}
|
||||
f += minor;
|
||||
}
|
||||
// in/out on ruler
|
||||
let frame_px = layout.pps / fps;
|
||||
let label_step = *steps.iter().find(|s| **s as f64 * frame_px >= 110.0).unwrap_or(&(base * 3600));
|
||||
let minor = *steps.iter().find(|s| **s as f64 * frame_px >= 12.0).unwrap_or(&label_step);
|
||||
// In/Out band
|
||||
if seq.mark_in.is_some() || seq.mark_out.is_some() {
|
||||
let a = layout.x_of(seq.mark_in.unwrap_or(Tick::ZERO));
|
||||
let b = layout.x_of(seq.mark_out.map(|o| o + rate.frame_duration()).unwrap_or(seq.duration()));
|
||||
clip.rect_filled(Rect::from_min_max(pos2(a, ruler.min.y + 14.0), pos2(b, ruler.max.y - 6.0)), 0.0, Color32::from_white_alpha(40));
|
||||
if seq.mark_in.is_some() {
|
||||
clip.line_segment([pos2(a, ruler.min.y + 12.0), pos2(a, ruler.max.y)], Stroke::new(1.5, Color32::from_white_alpha(170)));
|
||||
}
|
||||
if seq.mark_out.is_some() {
|
||||
clip.line_segment([pos2(b, ruler.min.y + 12.0), pos2(b, ruler.max.y)], Stroke::new(1.5, Color32::from_white_alpha(170)));
|
||||
}
|
||||
clip.rect_filled(Rect::from_min_max(pos2(a, label_y + 7.0), pos2(b, tick_base)), 0.0, Color32::from_rgb(0x5c, 0x5c, 0x5c));
|
||||
}
|
||||
// render bar: yellow where effects/transitions need GPU work, green where cached
|
||||
let rb = Rect::from_min_max(pos2(ruler.min.x, ruler.max.y - 3.0), pos2(ruler.max.x, ruler.max.y));
|
||||
let f0 = rate.frame_at(layout.tick_at(ruler.min.x)).max(0);
|
||||
let f1 = rate.frame_at(layout.tick_at(ruler.max.x)) + 1;
|
||||
let mut f = (f0 / minor) * minor;
|
||||
while f <= f1 {
|
||||
let x = layout.x_of(rate.tick_of(f));
|
||||
let major = f % label_step == 0;
|
||||
let h = if major { 10.0 } else { 4.0 };
|
||||
clip.line_segment([pos2(x, tick_base - h), pos2(x, tick_base)], Stroke::new(1.0, t.tl_ruler_tick));
|
||||
if major {
|
||||
let label = format_time(rate.tick_of(f), rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000);
|
||||
clip.text(pos2(x + 1.0, label_y), Align2::CENTER_CENTER, label, Tokens::ui(11.0), t.tl_ruler_text);
|
||||
}
|
||||
f += minor;
|
||||
}
|
||||
// render bar
|
||||
let rb = Rect::from_min_max(pos2(ruler.min.x, ruler.max.y - 2.0), ruler.max);
|
||||
for tr in &seq.video_tracks {
|
||||
for it in &tr.items {
|
||||
let heavy = it.has_standard_effects() || it.has_modified_intrinsics();
|
||||
let col = if heavy { t.render_yellow } else { continue };
|
||||
if !(it.has_standard_effects() || it.has_modified_intrinsics()) {
|
||||
continue;
|
||||
}
|
||||
let r = Rect::from_min_max(pos2(layout.x_of(it.start), rb.min.y), pos2(layout.x_of(it.end()), rb.max.y)).intersect(ruler);
|
||||
clip.rect_filled(r, 0.0, col);
|
||||
clip.rect_filled(r, 0.0, t.render_yellow);
|
||||
}
|
||||
for trn in &tr.transitions {
|
||||
let r = Rect::from_min_max(pos2(layout.x_of(trn.start), rb.min.y), pos2(layout.x_of(trn.end()), rb.max.y)).intersect(ruler);
|
||||
clip.rect_filled(r, 0.0, t.render_yellow);
|
||||
}
|
||||
}
|
||||
// markers
|
||||
// markers: 8 × 12 pt pentagons in the marker colour, on the top row
|
||||
for m in &seq.markers {
|
||||
let x = layout.x_of(m.start);
|
||||
let c = label_color(m.color);
|
||||
let y0 = ruler.min.y + 14.0;
|
||||
let shape = vec![pos2(x - 5.0, y0), pos2(x + 5.0, y0), pos2(x + 5.0, y0 + 7.0), pos2(x, y0 + 11.0), pos2(x - 5.0, y0 + 7.0)];
|
||||
clip.add(egui::Shape::convex_polygon(shape, c, Stroke::NONE));
|
||||
let c = m.color.marker_rgb();
|
||||
let c = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
let shape =
|
||||
vec![pos2(x - 4.0, marker_y), pos2(x + 4.0, marker_y), pos2(x + 4.0, marker_y + 8.0), pos2(x, marker_y + 12.0), pos2(x - 4.0, marker_y + 8.0)];
|
||||
if m.duration > Tick::ZERO {
|
||||
clip.rect_filled(Rect::from_min_max(pos2(x, y0), pos2(layout.x_of(m.start + m.duration), y0 + 7.0)), 0.0, c.gamma_multiply(0.6));
|
||||
clip.rect_filled(Rect::from_min_max(pos2(x, marker_y), pos2(layout.x_of(m.start + m.duration), marker_y + 8.0)), 0.0, c.gamma_multiply(0.6));
|
||||
}
|
||||
let mr = Rect::from_center_size(pos2(x, y0 + 5.0), vec2(10.0, 11.0));
|
||||
clip.add(egui::Shape::convex_polygon(shape, c, Stroke::NONE));
|
||||
let mr = Rect::from_center_size(pos2(x, marker_y + 6.0), vec2(10.0, 13.0));
|
||||
app.auto.add(&format!("timeline.marker.{}", m.id.0), mr, &m.name);
|
||||
if ui.rect_contains_pointer(mr) && !m.name.is_empty() {
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new(("mk", m.id.0)), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
|
|
@ -821,27 +915,27 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
|
|||
|
||||
fn zoom_scrollbar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, dur_s: f64, t: &Tokens) {
|
||||
let p = ui.painter();
|
||||
p.rect_filled(bar, bar.height() / 2.0, t.field_bg);
|
||||
p.rect_filled(bar, bar.height() / 2.0, t.separator);
|
||||
let v = &mut app.ui.timeline;
|
||||
let total = (dur_s * 1.15).max(v.scroll + bar.width() as f64 / v.pps);
|
||||
let vis = bar.width() as f64 / v.pps;
|
||||
let a = bar.min.x + (v.scroll / total) as f32 * bar.width();
|
||||
let b = bar.min.x + ((v.scroll + vis) / total).min(1.0) as f32 * bar.width();
|
||||
let thumb = Rect::from_min_max(pos2(a, bar.min.y + 1.0), pos2(b.max(a + 16.0), bar.max.y - 1.0));
|
||||
let thumb = Rect::from_min_max(pos2(a, bar.min.y), pos2(b.max(a + 20.0), bar.max.y));
|
||||
let hover = ui.rect_contains_pointer(thumb);
|
||||
p.rect_filled(thumb, thumb.height() / 2.0, if hover { Color32::from_rgb(110, 110, 110) } else { Color32::from_rgb(85, 85, 85) });
|
||||
// end handles (circles) to zoom
|
||||
for x in [thumb.min.x + 5.0, thumb.max.x - 5.0] {
|
||||
p.circle_filled(pos2(x, thumb.center().y), 3.0, Color32::from_rgb(170, 170, 170));
|
||||
p.rect_filled(thumb, thumb.height() / 2.0, if hover { Color32::from_rgb(0x6a, 0x6a, 0x6a) } else { Color32::from_rgb(0x4b, 0x4b, 0x4b) });
|
||||
for x in [thumb.min.x + thumb.height() / 2.0, thumb.max.x - thumb.height() / 2.0] {
|
||||
p.circle_filled(pos2(x, thumb.center().y), 4.5, t.panel_bg);
|
||||
p.circle_stroke(pos2(x, thumb.center().y), 4.5, Stroke::new(1.5, Color32::from_rgb(0xd1, 0xd1, 0xd1)));
|
||||
}
|
||||
app.auto.add("timeline.zoomBar", thumb, "zoom scroll bar");
|
||||
let resp = ui.interact(bar, egui::Id::new("tl-zoombar"), Sense::drag());
|
||||
if resp.drag_started()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let mode = if (pos.x - thumb.min.x).abs() < 8.0 {
|
||||
let mode = if (pos.x - thumb.min.x).abs() < 9.0 {
|
||||
1
|
||||
} else if (pos.x - thumb.max.x).abs() < 8.0 {
|
||||
} else if (pos.x - thumb.max.x).abs() < 9.0 {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
|
|
@ -955,7 +1049,7 @@ pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit {
|
|||
for trn in &tr.transitions {
|
||||
let x0 = layout.x_of(trn.start);
|
||||
let x1 = layout.x_of(trn.end());
|
||||
if pos.x >= x0 && pos.x <= x1 && pos.y < row.rect.min.y + (row.rect.height() * 0.5).max(14.0) {
|
||||
if pos.x >= x0 && pos.x <= x1 && pos.y > row.rect.min.y + 17.0 {
|
||||
return Hit::Transition { track: row.track, id: trn.id };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use crate::state::Tool;
|
|||
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let mut y = rect.min.y + 4.0;
|
||||
let size = (rect.width() - 6.0).clamp(24.0, 34.0);
|
||||
let size = 32.0;
|
||||
for group in Tool::groups() {
|
||||
let current = if group.contains(&app.ui.tool) { app.ui.tool } else { group[0] };
|
||||
let r = Rect::from_min_size(pos2(rect.center().x - size / 2.0, y), vec2(size, size));
|
||||
|
|
@ -24,11 +24,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
let active = group.contains(&app.ui.tool);
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 4.0, t.pressed);
|
||||
ui.painter().rect_filled(Rect::from_center_size(r.center(), vec2(26.0, 26.0)), 4.0, t.accent);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(size * 0.22), current.icon(), if active { t.icon_active } else { t.icon });
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(17.0, 17.0)), current.icon(), if active { egui::Color32::WHITE } else { t.icon });
|
||||
if group.len() > 1 {
|
||||
let c = r.right_bottom() - vec2(3.0, 3.0);
|
||||
ui.painter().add(egui::Shape::convex_polygon(vec![c, c - vec2(5.0, 0.0), c - vec2(0.0, 5.0)], t.text_dim, egui::Stroke::NONE));
|
||||
|
|
|
|||
|
|
@ -197,9 +197,9 @@ impl Default for TimelineView {
|
|||
v_scroll: 0.0,
|
||||
a_scroll: 0.0,
|
||||
split: 0.5,
|
||||
video_track_h: 46.0,
|
||||
audio_track_h: 44.0,
|
||||
header_w: 196.0,
|
||||
video_track_h: 60.0,
|
||||
audio_track_h: 56.0,
|
||||
header_w: 204.0,
|
||||
show_thumbnails: true,
|
||||
show_waveforms: true,
|
||||
follow: true,
|
||||
|
|
@ -276,7 +276,7 @@ impl Default for UiState {
|
|||
timeline: TimelineView::default(),
|
||||
program: MonitorView::default(),
|
||||
source: MonitorView::default(),
|
||||
project_view: ProjectView::List,
|
||||
project_view: ProjectView::Icon,
|
||||
project_search: String::new(),
|
||||
effects_search: String::new(),
|
||||
icon_size: 110.0,
|
||||
|
|
|
|||
|
|
@ -85,64 +85,66 @@ pub struct Tokens {
|
|||
|
||||
impl Tokens {
|
||||
pub fn for_kind(kind: ThemeKind) -> Self {
|
||||
// Measured from Premiere 26 "Darkest" (plan/premiere/02-ui-ux.md §1).
|
||||
let dark = Tokens {
|
||||
kind,
|
||||
app_bg: Color32::from_rgb(10, 10, 10),
|
||||
header_bg: Color32::from_rgb(19, 19, 19),
|
||||
panel_bg: Color32::from_rgb(29, 29, 29),
|
||||
tab_bg: Color32::from_rgb(29, 29, 29),
|
||||
tab_text: Color32::from_rgb(160, 160, 160),
|
||||
tab_text_active: Color32::from_rgb(236, 236, 236),
|
||||
focus: Color32::from_rgb(57, 138, 243),
|
||||
accent: Color32::from_rgb(57, 138, 243),
|
||||
accent_hover: Color32::from_rgb(88, 160, 250),
|
||||
text: Color32::from_rgb(214, 214, 214),
|
||||
text_dim: Color32::from_rgb(160, 160, 160),
|
||||
text_faint: Color32::from_rgb(110, 110, 110),
|
||||
icon: Color32::from_rgb(190, 190, 190),
|
||||
icon_active: Color32::from_rgb(57, 138, 243),
|
||||
hover: Color32::from_rgb(52, 52, 52),
|
||||
pressed: Color32::from_rgb(64, 64, 64),
|
||||
field_bg: Color32::from_rgb(20, 20, 20),
|
||||
field_border: Color32::from_rgb(58, 58, 58),
|
||||
separator: Color32::from_rgb(44, 44, 44),
|
||||
row_alt: Color32::from_rgb(33, 33, 33),
|
||||
row_selected: Color32::from_rgb(52, 76, 110),
|
||||
hot_text: Color32::from_rgb(76, 158, 255),
|
||||
tl_bg: Color32::from_rgb(24, 24, 24),
|
||||
tl_track_bg: Color32::from_rgb(29, 29, 29),
|
||||
tl_track_bg_alt: Color32::from_rgb(33, 33, 33),
|
||||
tl_header_bg: Color32::from_rgb(36, 36, 36),
|
||||
tl_ruler_bg: Color32::from_rgb(29, 29, 29),
|
||||
tl_ruler_tick: Color32::from_rgb(92, 92, 92),
|
||||
tl_ruler_text: Color32::from_rgb(150, 150, 150),
|
||||
playhead: Color32::from_rgb(57, 138, 243),
|
||||
in_out_shade: Color32::from_rgba_unmultiplied(120, 120, 120, 40),
|
||||
clip_selected_border: Color32::from_rgb(240, 240, 240),
|
||||
render_red: Color32::from_rgb(210, 50, 45),
|
||||
render_yellow: Color32::from_rgb(222, 196, 40),
|
||||
render_green: Color32::from_rgb(64, 180, 70),
|
||||
monitor_bg: Color32::from_rgb(0, 0, 0),
|
||||
timecode: Color32::from_rgb(76, 158, 255),
|
||||
danger: Color32::from_rgb(230, 80, 80),
|
||||
radius: 4.0,
|
||||
radius_sm: 3.0,
|
||||
gap: 3.0,
|
||||
tab_h: 28.0,
|
||||
app_bg: Color32::from_rgb(0, 0, 0),
|
||||
header_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
||||
panel_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
||||
tab_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
||||
tab_text: Color32::from_rgb(0xb0, 0xb0, 0xb0),
|
||||
tab_text_active: Color32::from_rgb(0xd1, 0xd1, 0xd1),
|
||||
focus: Color32::from_rgb(0x57, 0x94, 0xec),
|
||||
accent: Color32::from_rgb(0x2f, 0x6b, 0xdf),
|
||||
accent_hover: Color32::from_rgb(0x3f, 0x7c, 0xe8),
|
||||
text: Color32::from_rgb(0xd1, 0xd1, 0xd1),
|
||||
text_dim: Color32::from_rgb(0xb0, 0xb0, 0xb0),
|
||||
text_faint: Color32::from_rgb(0x6e, 0x6e, 0x6e),
|
||||
icon: Color32::from_rgb(0xb0, 0xb0, 0xb0),
|
||||
icon_active: Color32::from_rgb(0xd1, 0xd1, 0xd1),
|
||||
hover: Color32::from_rgb(0x2c, 0x2c, 0x2c),
|
||||
pressed: Color32::from_rgb(0x4b, 0x4b, 0x4b),
|
||||
field_bg: Color32::from_rgb(0x0e, 0x0e, 0x0e),
|
||||
field_border: Color32::from_rgb(0x30, 0x30, 0x30),
|
||||
separator: Color32::from_rgb(0x30, 0x30, 0x30),
|
||||
row_alt: Color32::from_rgb(0x21, 0x21, 0x21),
|
||||
row_selected: Color32::from_rgb(0x33, 0x33, 0x33),
|
||||
hot_text: Color32::from_rgb(0x57, 0x94, 0xec),
|
||||
tl_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
||||
tl_track_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
||||
tl_track_bg_alt: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
||||
tl_header_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
||||
tl_ruler_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
||||
tl_ruler_tick: Color32::from_rgb(0x8d, 0x8d, 0x8d),
|
||||
tl_ruler_text: Color32::from_rgb(0xb0, 0xb0, 0xb0),
|
||||
playhead: Color32::from_rgb(0x58, 0x95, 0xec),
|
||||
in_out_shade: Color32::from_rgb(0x3f, 0x3f, 0x3f),
|
||||
clip_selected_border: Color32::from_rgb(0xeb, 0xeb, 0xeb),
|
||||
render_red: Color32::from_rgb(0xe3, 0x48, 0x50),
|
||||
render_yellow: Color32::from_rgb(0xf0, 0xf0, 0x4f),
|
||||
render_green: Color32::from_rgb(0x2d, 0x9d, 0x78),
|
||||
monitor_bg: Color32::from_rgb(0x1d, 0x1d, 0x1d),
|
||||
timecode: Color32::from_rgb(0x57, 0x94, 0xec),
|
||||
danger: Color32::from_rgb(0xdc, 0x51, 0x3d),
|
||||
radius: 0.0,
|
||||
radius_sm: 4.0,
|
||||
gap: 4.0,
|
||||
tab_h: 32.0,
|
||||
};
|
||||
match kind {
|
||||
ThemeKind::Dark => dark,
|
||||
ThemeKind::Medium => Tokens {
|
||||
app_bg: Color32::from_rgb(22, 22, 22),
|
||||
header_bg: Color32::from_rgb(34, 34, 34),
|
||||
panel_bg: Color32::from_rgb(46, 46, 46),
|
||||
tab_bg: Color32::from_rgb(46, 46, 46),
|
||||
field_bg: Color32::from_rgb(34, 34, 34),
|
||||
tl_bg: Color32::from_rgb(40, 40, 40),
|
||||
tl_track_bg: Color32::from_rgb(46, 46, 46),
|
||||
tl_track_bg_alt: Color32::from_rgb(50, 50, 50),
|
||||
tl_header_bg: Color32::from_rgb(54, 54, 54),
|
||||
tl_ruler_bg: Color32::from_rgb(46, 46, 46),
|
||||
app_bg: Color32::from_rgb(20, 20, 20),
|
||||
header_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
||||
panel_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
||||
tab_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
||||
field_bg: Color32::from_rgb(0x22, 0x22, 0x22),
|
||||
tl_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
||||
tl_track_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
||||
tl_track_bg_alt: Color32::from_rgb(0x32, 0x32, 0x32),
|
||||
tl_header_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
||||
tl_ruler_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
||||
monitor_bg: Color32::from_rgb(0x32, 0x32, 0x32),
|
||||
row_alt: Color32::from_rgb(50, 50, 50),
|
||||
hover: Color32::from_rgb(64, 64, 64),
|
||||
..dark
|
||||
|
|
@ -215,7 +217,7 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
|
|||
v.hyperlink_color = t.accent;
|
||||
v.window_stroke = Stroke::new(1.0, t.field_border);
|
||||
v.window_corner_radius = egui::CornerRadius::same(6);
|
||||
v.menu_corner_radius = egui::CornerRadius::same(5);
|
||||
v.menu_corner_radius = egui::CornerRadius::same(6);
|
||||
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) };
|
||||
v.window_shadow = v.popup_shadow;
|
||||
for w in [&mut v.widgets.noninteractive, &mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open] {
|
||||
|
|
@ -242,10 +244,10 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
|
|||
ctx.global_style_mut(|s| {
|
||||
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
|
||||
s.spacing.button_padding = egui::vec2(8.0, 3.0);
|
||||
s.spacing.interact_size.y = 20.0;
|
||||
s.spacing.interact_size.y = 24.0;
|
||||
s.spacing.menu_margin = egui::Margin::same(4);
|
||||
s.text_styles.insert(TextStyle::Body, FontId::new(12.5, FontFamily::Proportional));
|
||||
s.text_styles.insert(TextStyle::Button, FontId::new(12.5, FontFamily::Proportional));
|
||||
s.text_styles.insert(TextStyle::Body, FontId::new(12.0, FontFamily::Proportional));
|
||||
s.text_styles.insert(TextStyle::Button, FontId::new(12.0, FontFamily::Proportional));
|
||||
s.text_styles.insert(TextStyle::Small, FontId::new(11.0, FontFamily::Proportional));
|
||||
s.text_styles.insert(TextStyle::Heading, FontId::new(15.0, FontFamily::Name("semibold".into())));
|
||||
s.text_styles.insert(TextStyle::Monospace, FontId::new(12.0, FontFamily::Monospace));
|
||||
|
|
|
|||
|
|
@ -129,23 +129,27 @@ pub fn search_field(ui: &mut Ui, text: &mut String, hint: &str, width: f32, t: &
|
|||
|
||||
/// Premiere-style dropdown text ("Fit ▾").
|
||||
pub fn dropdown_text(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) -> Response {
|
||||
// Premiere: 24 pt, #0e0e0e fill, 1 pt #303030 border, radius 4, chevron at the right.
|
||||
let resp = ui.interact(rect, id, Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
ui.painter().text(pos2(rect.min.x + 6.0, rect.center().y), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 8.0, rect.center().y), vec2(9.0, 9.0)), Icon::ChevronDown, t.text_dim);
|
||||
ui.painter().rect_filled(rect, 4.0, t.field_bg);
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
4.0,
|
||||
Stroke::new(1.0, if resp.hovered() { Color32::from_rgb(0x4b, 0x4b, 0x4b) } else { t.field_border }),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
ui.painter().text(pos2(rect.min.x + 9.0, rect.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 11.0, rect.center().y), vec2(9.0, 9.0)), Icon::ChevronDown, t.text_dim);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Level meter bar (dBFS), vertical.
|
||||
pub fn meter_bar(painter: &egui::Painter, rect: Rect, db: f32, peak_db: f32, t: &Tokens) {
|
||||
painter.rect_filled(rect, 0.0, Color32::from_rgb(12, 12, 12));
|
||||
// Premiere: green gradient (#579f51 → #70dc5d), yellow above −12 dB, red above −3 dB.
|
||||
let norm = |d: f32| ((d + 60.0) / 60.0).clamp(0.0, 1.0);
|
||||
let h = rect.height() * norm(db);
|
||||
if h > 0.0 {
|
||||
// gradient green → yellow → red
|
||||
let n = 24;
|
||||
let n = 40;
|
||||
for i in 0..n {
|
||||
let f0 = i as f32 / n as f32;
|
||||
let f1 = (i + 1) as f32 / n as f32;
|
||||
|
|
@ -156,20 +160,20 @@ pub fn meter_bar(painter: &egui::Painter, rect: Rect, db: f32, peak_db: f32, t:
|
|||
let y0 = rect.max.y - (f1 * rect.height()).min(h);
|
||||
let db_here = -60.0 + f1 * 60.0;
|
||||
let c = if db_here > -3.0 {
|
||||
Color32::from_rgb(230, 60, 50)
|
||||
Color32::from_rgb(0xe3, 0x48, 0x50)
|
||||
} else if db_here > -12.0 {
|
||||
Color32::from_rgb(225, 200, 50)
|
||||
Color32::from_rgb(0xf0, 0xf0, 0x4f)
|
||||
} else {
|
||||
Color32::from_rgb(60, 190, 80)
|
||||
let k = f1 / 0.8;
|
||||
let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * k) as u8;
|
||||
Color32::from_rgb(lerp(0x57, 0x70), lerp(0x9f, 0xdc), lerp(0x51, 0x5d))
|
||||
};
|
||||
painter.rect_filled(Rect::from_min_max(pos2(rect.min.x, y0), pos2(rect.max.x, y1)), 0.0, c);
|
||||
}
|
||||
}
|
||||
let py = rect.max.y - rect.height() * norm(peak_db);
|
||||
if peak_db > -60.0 {
|
||||
painter.line_segment(
|
||||
[pos2(rect.min.x, py), pos2(rect.max.x, py)],
|
||||
Stroke::new(1.0, if peak_db > -0.5 { t.danger } else { Color32::from_rgb(200, 200, 200) }),
|
||||
);
|
||||
}
|
||||
let py = (rect.max.y - rect.height() * norm(peak_db)).min(rect.max.y - 1.0);
|
||||
painter.line_segment(
|
||||
[pos2(rect.min.x, py), pos2(rect.max.x, py)],
|
||||
Stroke::new(1.0, if peak_db > -0.5 { t.danger } else { Color32::from_rgb(0xf0, 0xf0, 0x4f) }),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue