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:
Brandon Thomas 2026-09-30 07:37:38 -04:00
parent 3696e4ee80
commit 627931fc72
18 changed files with 1498 additions and 504 deletions

534
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"

View File

@ -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);

View File

@ -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
}

View File

@ -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(),

View File

@ -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

View File

@ -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);
}

View File

@ -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) {

View File

@ -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();
}
}
}

View File

@ -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();

View File

@ -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),

View File

@ -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())

View File

@ -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);
}

View File

@ -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 };
}
}

View File

@ -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));

View File

@ -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,

View File

@ -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));

View File

@ -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) }),
);
}