mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
egui 0.37: migrate FilmCraft input and rendering integration
This commit is contained in:
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
timeout-minutes: 120
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
|
||||
@@ -32,6 +32,6 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
skip-tests: true
|
||||
run-package-check: true
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: filmcraft
|
||||
app-title: FilmCraft
|
||||
craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
Generated
+193
-88
@@ -4,21 +4,21 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "accesskit"
|
||||
version = "0.24.1"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
|
||||
checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
|
||||
dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_atspi_common"
|
||||
version = "0.18.1"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
|
||||
checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.36.0",
|
||||
"accesskit_consumer",
|
||||
"atspi-common",
|
||||
"phf",
|
||||
"serde",
|
||||
@@ -27,43 +27,37 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.35.0"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
|
||||
checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.36.0"
|
||||
name = "accesskit_ios"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
|
||||
checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.16.1",
|
||||
"accesskit_consumer",
|
||||
"hashbrown 0.17.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-ui-kit 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_macos"
|
||||
version = "0.26.3"
|
||||
version = "0.27.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
|
||||
checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.38.0",
|
||||
"hashbrown 0.16.1",
|
||||
"accesskit_consumer",
|
||||
"hashbrown 0.17.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
@@ -71,9 +65,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_unix"
|
||||
version = "0.21.1"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
|
||||
checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_atspi_common",
|
||||
@@ -89,13 +83,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_windows"
|
||||
version = "0.32.1"
|
||||
version = "0.35.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
|
||||
checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
"hashbrown 0.16.1",
|
||||
"accesskit_consumer",
|
||||
"hashbrown 0.17.1",
|
||||
"static_assertions",
|
||||
"windows 0.62.2",
|
||||
"windows-core 0.62.2",
|
||||
@@ -103,11 +97,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_winit"
|
||||
version = "0.32.2"
|
||||
version = "0.34.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
|
||||
checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_ios",
|
||||
"accesskit_macos",
|
||||
"accesskit_unix",
|
||||
"accesskit_windows",
|
||||
@@ -248,6 +243,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -1027,7 +1023,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
@@ -1295,9 +1291,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"emath",
|
||||
@@ -1329,9 +1324,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "eframe"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1353,6 +1347,7 @@ dependencies = [
|
||||
"pollster 1.0.1",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"send_wrapper",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1364,9 +1359,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "egui"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"ahash",
|
||||
@@ -1384,9 +1378,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1404,30 +1397,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "egui-winit"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"accesskit_winit",
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"image",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-ui-kit 0.3.2",
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
@@ -1439,9 +1435,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1484,9 +1479,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "emath"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
@@ -1546,9 +1540,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1564,16 +1557,17 @@ dependencies = [
|
||||
"self_cell",
|
||||
"skrifa",
|
||||
"smallvec",
|
||||
"unicode-bidi",
|
||||
"unicode-general-category",
|
||||
"unicode-properties",
|
||||
"unicode-segmentation",
|
||||
"vello_cpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
@@ -2424,6 +2418,12 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
@@ -2453,6 +2453,12 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -2940,12 +2946,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "glifo"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -3162,6 +3168,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -3170,7 +3185,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
@@ -3181,7 +3196,7 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3480,12 +3495,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kittest"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
|
||||
checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
"accesskit_consumer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3758,6 +3773,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -4311,6 +4335,16 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
@@ -4430,10 +4464,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
@@ -4442,9 +4487,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
@@ -4452,9 +4497,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
@@ -4465,9 +4510,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
@@ -5438,6 +5483,12 @@ version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "send_wrapper"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
||||
|
||||
[[package]]
|
||||
name = "seq-macro"
|
||||
version = "0.3.6"
|
||||
@@ -6241,6 +6292,17 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "type-map"
|
||||
version = "0.5.1"
|
||||
@@ -6303,6 +6365,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
@@ -6379,9 +6447,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
|
||||
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
@@ -6394,9 +6462,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vello_cpu"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
|
||||
checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"glifo",
|
||||
@@ -7263,8 +7331,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
|
||||
source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"android-activity",
|
||||
@@ -7345,6 +7412,24 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 1.1.5",
|
||||
"thiserror 2.0.21",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
@@ -7696,3 +7781,23 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_system_font_provider"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
|
||||
|
||||
+25
-4
@@ -77,10 +77,13 @@ craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-lib
|
||||
|
||||
# ui
|
||||
# Shared frontend components; keep this dependency above the engine boundary.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa", features = ["serde"] }
|
||||
# egui 0.37: bundled fonts are opt-in, and the monochrome symbol/emoji faces moved to their own
|
||||
# feature. FilmCraft paints ✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑ in panel chrome, so both features are part of the
|
||||
# production font policy (system_fonts stays off).
|
||||
egui = { version = "0.37", features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
|
||||
[workspace.lints.rust]
|
||||
@@ -153,3 +156,21 @@ opt-level = 3
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" }
|
||||
|
||||
@@ -426,6 +426,10 @@ impl Services for WebServices {
|
||||
|
||||
/// Offer bytes as a browser download named `name`. Downloads are clicked at least
|
||||
/// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378).
|
||||
thread_local! {
|
||||
static DOWNLOAD_CLOCK: web_time::Instant = web_time::Instant::now();
|
||||
}
|
||||
|
||||
pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let blob = bytes_to_blob(data, "application/octet-stream")?;
|
||||
@@ -434,7 +438,8 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
a.set_href(&url);
|
||||
a.set_download(file_name(name));
|
||||
a.style().set_property("display", "none")?;
|
||||
let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now()));
|
||||
let now_ms = DOWNLOAD_CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0);
|
||||
let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(now_ms));
|
||||
let name = name.to_owned();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if delay > 0.0 {
|
||||
|
||||
@@ -28,10 +28,17 @@ enum Outcome {
|
||||
Clean { ok: bool },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Autosave {
|
||||
policy: Policy,
|
||||
outcomes: Rc<RefCell<Vec<Outcome>>>,
|
||||
/// Monotonic host clock. Web `logic` may run for hidden tabs; egui's input time is stale there.
|
||||
clock: web_time::Instant,
|
||||
}
|
||||
|
||||
impl Default for Autosave {
|
||||
fn default() -> Self {
|
||||
Self { policy: Policy::default(), outcomes: Rc::default(), clock: web_time::Instant::now() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Autosave {
|
||||
@@ -40,7 +47,7 @@ impl Autosave {
|
||||
if !opfs::available() {
|
||||
return;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
let now = self.clock.elapsed().as_secs_f64();
|
||||
for o in self.outcomes.borrow_mut().drain(..) {
|
||||
match o {
|
||||
Outcome::Snapshot { revision, ok } => self.policy.written(revision, ok, now),
|
||||
@@ -64,7 +71,9 @@ impl Autosave {
|
||||
}
|
||||
})
|
||||
};
|
||||
let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()});
|
||||
// No wall-clock timestamp: recovery policy scheduling uses the monotonic clock
|
||||
// above, and the snapshot schema needs no display time.
|
||||
let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision});
|
||||
let d1 = done(self.outcomes.clone(), failed.clone(), left.clone(), ctx.clone());
|
||||
let d2 = done(self.outcomes.clone(), failed, left, ctx.clone());
|
||||
opfs::write_then(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false), Some(d1));
|
||||
|
||||
@@ -68,8 +68,14 @@ thread_local! {
|
||||
static TRANSFERS: RefCell<Transfers> = RefCell::new(Transfers::default());
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Monotonic browser clock. `Date::now()` is wall time and can move backwards across NTP or
|
||||
/// suspend/resume; codec idle/staleness accounting must not.
|
||||
static CLOCK: web_time::Instant = web_time::Instant::now();
|
||||
}
|
||||
|
||||
fn now_ms() -> f64 {
|
||||
js_sys::Date::now()
|
||||
CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0)
|
||||
}
|
||||
|
||||
fn get(o: &JsValue, k: &str) -> JsValue {
|
||||
|
||||
@@ -27,7 +27,7 @@ filmcraft-gpu = { workspace = true }
|
||||
filmcraft-ui-egui = { workspace = true }
|
||||
filmcraft-platform = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
# Constant-time comparison of control-channel credentials.
|
||||
subtle = "2.6"
|
||||
|
||||
@@ -55,7 +55,7 @@ impl Note {
|
||||
use filmcraft_ui_egui::i18n::{fmt, t};
|
||||
match self {
|
||||
Note::Fallback(device) => fmt(t("Audio hardware: the default output device cannot be opened; using '{device}'"), &[("device", device)]),
|
||||
Note::NoDevice => t("No audio output device found: playing without sound (check Settings ▸ Audio Hardware)").to_string(),
|
||||
Note::NoDevice => t("No audio output device found: playing without sound (check Settings > Audio Hardware)").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,6 +826,7 @@ fn close_project(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Other("the project has unsaved changes: save it first, or pass {\"force\": true} to discard them".into()));
|
||||
}
|
||||
s.project = std::sync::Arc::new(Project::new("Untitled"));
|
||||
s.document_epoch = s.document_epoch.wrapping_add(1);
|
||||
s.history = Default::default();
|
||||
s.history.limit = 200;
|
||||
s.state = Session::default().state;
|
||||
|
||||
@@ -1140,6 +1140,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
cmd!("file.newProject", "Project…", ["File", "New"], Some("Cmd+Alt+N"), r#"{"name":str}"#, always, |s, p| {
|
||||
let name = str_p(p, "name").unwrap_or("Untitled").to_string();
|
||||
s.project = std::sync::Arc::new(filmcraft_project::Project::new(&name));
|
||||
s.document_epoch = s.document_epoch.wrapping_add(1);
|
||||
s.history = Default::default();
|
||||
s.history.limit = 200;
|
||||
s.state = crate::Session::default().state;
|
||||
@@ -1154,6 +1155,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
cmd!("file.openDemoProject", "Demo Project", ["File", "New"], None, "{}", always, |s, _| {
|
||||
let (p, seq) = crate::demo::demo_project(&s.media);
|
||||
s.project = std::sync::Arc::new(p);
|
||||
s.document_epoch = s.document_epoch.wrapping_add(1);
|
||||
s.history = Default::default();
|
||||
s.history.limit = 200;
|
||||
s.state = crate::Session::default().state;
|
||||
@@ -4385,6 +4387,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti
|
||||
let first = proj.sequences().next().map(|i| i.id);
|
||||
s.media.add_stored(&proj, path.as_deref());
|
||||
s.project = std::sync::Arc::new(proj);
|
||||
s.document_epoch = s.document_epoch.wrapping_add(1);
|
||||
s.history = Default::default();
|
||||
s.history.limit = 200;
|
||||
s.state = crate::Session::default().state;
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
//! at the playhead.
|
||||
|
||||
use filmcraft_geom::Vec2;
|
||||
use filmcraft_project::graphic::{self, LayerContent, SHAPE_OPTS, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer};
|
||||
use filmcraft_project::graphic::{
|
||||
self, LayerContent, SHAPE_OPTS, ensure_layer_uids, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer,
|
||||
};
|
||||
use filmcraft_project::{ClipId, EffectInstance, ItemId, ItemKind, Label, Param, ParamValue, Sequence, TrackKind};
|
||||
use filmcraft_render::graphic_clip::{item_layer_specs, layer_local_bounds, layer_quad, text_layout};
|
||||
use filmcraft_time::{Tick, TimeRange};
|
||||
@@ -138,6 +140,9 @@ pub(crate) fn place_video_clip(
|
||||
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad(cmd, "bad item"))?;
|
||||
ti.name = name;
|
||||
ti.effects.extend(extra);
|
||||
// Every new graphic layer gets a stable uid; a new clip holds layers from `extra` plus
|
||||
// any source-graphic layers, so do it after both are in place (duplicates are repaired).
|
||||
ensure_layer_uids(&mut ti.effects);
|
||||
for e in &mut ti.effects {
|
||||
filmcraft_project::resolve_auto_points(e, (w, h), src_size);
|
||||
}
|
||||
@@ -179,6 +184,9 @@ fn add_layer(s: &mut Session, clip: ClipId, mut layer: filmcraft_project::Effect
|
||||
// new layers go in front: after the last graphic layer
|
||||
let pos = layer_indices(&it.effects).last().map_or(it.effects.len(), |l| l + 1);
|
||||
it.effects.insert(pos, layer);
|
||||
// Assign a uid to the new layer (and lazily upgrade any uid-less legacy layers) inside
|
||||
// this undo step, so undo/redo keeps the same identities.
|
||||
ensure_layer_uids(&mut it.effects);
|
||||
let n = layer_indices(&it.effects).len() - 1;
|
||||
st.selection = vec![clip];
|
||||
st.graphic_layers = vec![n];
|
||||
|
||||
@@ -95,6 +95,31 @@ fn graphic_placement_takes_a_track_and_a_time() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every graphic layer gets a stable uid when it is created, both in a new clip and when added to
|
||||
/// an existing one. Two byte-identical layers with different text must not share an identity.
|
||||
#[test]
|
||||
fn new_graphic_layers_get_unique_stable_uids() {
|
||||
let mut s = demo();
|
||||
let clip = ClipId(s.execute("graphics.newText", json!({"text": "one", "position": [960, 540], "size": 100})).unwrap()["clip"].as_u64().unwrap());
|
||||
s.execute("graphics.newText", json!({"text": "two", "position": [960, 540], "size": 100, "clip": clip.0})).unwrap();
|
||||
s.execute("graphics.newShape", json!({"shape": "rectangle", "clip": clip.0})).unwrap();
|
||||
let uids = |s: &mut Session| -> Vec<u64> {
|
||||
s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"].as_array().unwrap().iter().map(|l| l["uid"].as_u64().unwrap()).collect()
|
||||
};
|
||||
let first = uids(&mut s);
|
||||
assert_eq!(first.len(), 3);
|
||||
assert!(first.iter().all(|uid| *uid != 0), "every created layer is assigned a uid: {first:?}");
|
||||
assert_eq!(first.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 3, "uids are unique: {first:?}");
|
||||
// A real reorder keeps the same per-layer identity, and undo/redo restores it unchanged.
|
||||
s.execute("graphics.arrangeLayer", json!({"clip": clip.0, "layer": 0, "to": "front"})).unwrap();
|
||||
let reordered = uids(&mut s);
|
||||
assert_eq!(reordered, vec![first[1], first[2], first[0]]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(uids(&mut s), first, "undo restores the same identities, not fresh ones");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(uids(&mut s), reordered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_makes_a_graphic_clip_above_the_footage() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -406,6 +406,10 @@ pub struct Session {
|
||||
pub project: Arc<Project>,
|
||||
pub history: History,
|
||||
pub revision: u64,
|
||||
/// Bumped whenever the session installs a different project document (`file.newProject`,
|
||||
/// open/import/recovery/template). Frontends use it to discard UI state (text edits, IME
|
||||
/// owners) that belongs to the replaced document even if item ids are reused.
|
||||
pub document_epoch: u64,
|
||||
pub saved_revision: u64,
|
||||
pub path: Option<String>,
|
||||
pub state: EditorState,
|
||||
@@ -524,6 +528,7 @@ impl Session {
|
||||
project: Arc::new(Project::new("Untitled")),
|
||||
history: History { limit: 200, ..Default::default() },
|
||||
revision: 1,
|
||||
document_epoch: 0,
|
||||
saved_revision: 1,
|
||||
path: None,
|
||||
state: EditorState {
|
||||
|
||||
@@ -98,7 +98,7 @@ fn cache_key(n: &Narration) -> String {
|
||||
|
||||
fn tts_err(cmd: &str, e: tts::TtsError) -> EngineError {
|
||||
match e {
|
||||
tts::TtsError::NotInstalled => bad(cmd, "the natural voices are not downloaded yet: Text to Speech ▸ Download natural voices (tts.downloadVoices)"),
|
||||
tts::TtsError::NotInstalled => bad(cmd, "the natural voices are not downloaded yet: Text to Speech > Download natural voices (tts.downloadVoices)"),
|
||||
e => bad(cmd, e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,6 +678,7 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
proj.name = name.clone();
|
||||
proj.root.name = name;
|
||||
s.project = std::sync::Arc::new(proj);
|
||||
s.document_epoch = s.document_epoch.wrapping_add(1);
|
||||
s.history = Default::default();
|
||||
s.history.limit = 200;
|
||||
s.state = Session::default().state;
|
||||
@@ -1062,7 +1063,7 @@ pub(crate) fn warn_untrusted_scratch(s: &mut Session) {
|
||||
let list = ignored.join(", ");
|
||||
log::warn!("project scratch disks outside the project folder not used: {list}");
|
||||
s.toast(format!(
|
||||
"Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings ▸ Scratch Disks to use it."
|
||||
"Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings > Scratch Disks to use it."
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -752,11 +752,11 @@ pub fn on_open(s: &mut Session) -> usize {
|
||||
list.iter().filter(|m| m.status == "missing").filter_map(|m| s.media.follow(&m.path).err().map(|why| format!("{} ({why})", m.path))).collect();
|
||||
if !refused.is_empty() {
|
||||
log::warn!("media paths stored in the project not opened: {}", refused.join(", "));
|
||||
s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File ▸ Link Media… reconnects them", refused.len()));
|
||||
s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File > Link Media… reconnects them", refused.len()));
|
||||
}
|
||||
s.offline.prompt = !s.offline.missing.is_empty();
|
||||
if !s.offline.missing.is_empty() {
|
||||
s.toast(format!("{} media file(s) are missing; File ▸ Link Media… reconnects them", s.offline.missing.len()));
|
||||
s.toast(format!("{} media file(s) are missing; File > Link Media… reconnects them", s.offline.missing.len()));
|
||||
}
|
||||
list.len()
|
||||
}
|
||||
|
||||
@@ -216,6 +216,9 @@ pub fn decode(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
||||
/// Refuse only values that would overflow or exhaust memory later (`Sequence::check_bounds`);
|
||||
/// structural rules such as overlaps are left to the editor, so projects that open today still open.
|
||||
fn validate_loaded(mut loaded: Loaded) -> Result<Loaded, FormatError> {
|
||||
// Graphic layers written before they carried a uid get a stable identity now, before any
|
||||
// undo history exists. The field is optional in the schema, so this is not a schema upgrade.
|
||||
loaded.project.ensure_graphic_layer_uids();
|
||||
// Out-of-range audio stream data is repaired, not refused: failing the load would lose the
|
||||
// whole project over one bad index. A media item keeps its first `MAX_AUDIO_STREAMS` streams; a
|
||||
// clip pointing past that plays stream 0.
|
||||
@@ -421,6 +424,32 @@ mod tests {
|
||||
assert_eq!(loaded.project.sequence(seq).unwrap().video_tracks[0].items.len(), 3);
|
||||
}
|
||||
|
||||
/// Opening a project written before graphic layers had uids assigns stable, unique identities
|
||||
/// without a schema upgrade. The old file still opens, and its two byte-identical layers (same
|
||||
/// geometry, different text) can no longer be confused after a reorder.
|
||||
#[test]
|
||||
fn opening_a_legacy_project_assigns_unique_graphic_layer_uids() {
|
||||
use filmcraft_project::graphic::{layer_indices, new_text_layer};
|
||||
use filmcraft_project::{ItemKind, SequenceSettings, TrackKind};
|
||||
use filmcraft_time::{FrameRate, Tick, TimeRange};
|
||||
let mut p = Project::new("Legacy graphics");
|
||||
let rate = FrameRate::FPS_25;
|
||||
let seq = p.new_sequence("Sequence 01", SequenceSettings::default(), 1, 1, None);
|
||||
let graphic = p.add_item("Graphic", filmcraft_project::Label::Rose, ItemKind::Graphic { width: 1920, height: 1080, rate }, None);
|
||||
let mut item = p.make_track_item(graphic, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(100)), rate).unwrap();
|
||||
item.effects = vec![new_text_layer("one", Default::default(), 100.0), new_text_layer("two", Default::default(), 100.0)];
|
||||
assert!(item.effects.iter().all(|e| filmcraft_project::gtemplate::layer_uid(e) == 0));
|
||||
p.sequence_mut(seq).unwrap().video_tracks[0].items = vec![item];
|
||||
let loaded = decode(&encode(&p, false)).expect("an old project still opens");
|
||||
let item = &loaded.project.sequence(seq).unwrap().video_tracks[0].items[0];
|
||||
let uids: Vec<u64> = layer_indices(&item.effects).into_iter().map(|i| filmcraft_project::gtemplate::layer_uid(&item.effects[i])).collect();
|
||||
assert_eq!(uids.len(), 2);
|
||||
assert!(uids.iter().all(|uid| *uid != 0), "opening assigned uids: {uids:?}");
|
||||
assert_ne!(uids[0], uids[1], "identical geometry and different text still get distinct identities");
|
||||
let again = decode(&encode(&loaded.project, false)).expect("the upgraded project round-trips");
|
||||
assert_eq!(again.project, loaded.project, "assigned uids are stable across saves");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_current() {
|
||||
let p = Project::new("Round Trip");
|
||||
|
||||
@@ -108,7 +108,7 @@ impl VideoEncoder for NvencEncoder {
|
||||
return Err(ExportError::Encode(format!("NVENC: a {}x{} picture for a {}x{} encoder", f.width, f.height, self.w, self.h)));
|
||||
}
|
||||
let (w, h) = (self.w as usize, self.h as usize);
|
||||
let hint = if self.enc.codec() == Codec::H264 { " (turn Export ▸ Hardware encoding off to use the software encoder)" } else { "" };
|
||||
let hint = if self.enc.codec() == Codec::H264 { " (turn Export > Hardware encoding off to use the software encoder)" } else { "" };
|
||||
let ps = match (self.enc.is_ten_bit(), f.hdr) {
|
||||
// Main 10 takes the encoded R'G'B' floats: 10-bit limited-range BT.2020 NCL (or the signal's matrix)
|
||||
(true, Some(rgb)) => {
|
||||
|
||||
@@ -35,6 +35,47 @@ pub fn is_layer(e: &EffectInstance) -> bool {
|
||||
is_layer_id(&e.effect)
|
||||
}
|
||||
|
||||
/// Give every graphic layer without a uid (or with a uid another layer already uses) a fresh one
|
||||
/// that is unique among `effects`; non-layer effects are left alone.
|
||||
///
|
||||
/// This is the project-format lazy upgrade: files written before layers had uids (or with a
|
||||
/// duplicate written by a hostile/buggy producer) get stable identities when they are opened, so
|
||||
/// reorder/delete/undo and the Program monitor's text editor keep addressing the same layer.
|
||||
/// The optional `layer.uid` field means the file schema does not change; a save simply writes the
|
||||
/// assigned uid. Returns whether anything changed.
|
||||
pub fn ensure_layer_uids(effects: &mut [EffectInstance]) -> bool {
|
||||
let mut used: std::collections::BTreeSet<u64> =
|
||||
effects.iter().filter(|e| is_layer(e)).filter_map(|e| e.layer.as_ref()).map(|x| x.uid).filter(|uid| *uid != 0).collect();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut next = 1u64;
|
||||
let mut changed = false;
|
||||
for e in effects.iter_mut().filter(|e| is_layer(e)) {
|
||||
let uid = e.layer.as_ref().map_or(0, |x| x.uid);
|
||||
if uid != 0 && seen.insert(uid) {
|
||||
continue;
|
||||
}
|
||||
// Zero means "not assigned yet"; a repeated uid must move to a free one so two identical
|
||||
// layers can never share an identity.
|
||||
while used.contains(&next) {
|
||||
let Some(after) = next.checked_add(1) else {
|
||||
// A hostile file used every uid; leave this layer without one rather than loop.
|
||||
next = 0;
|
||||
break;
|
||||
};
|
||||
next = after;
|
||||
}
|
||||
if next == 0 {
|
||||
continue;
|
||||
}
|
||||
e.layer.get_or_insert_with(Default::default).uid = next;
|
||||
used.insert(next);
|
||||
seen.insert(next);
|
||||
changed = true;
|
||||
next = next.saturating_add(1);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn p(id: &'static str, label: &'static str, kind: ParamKind, default: ParamValue, animatable: bool, group: &'static str) -> ParamDef {
|
||||
ParamDef { id, label, kind, default, animatable, group: if group.is_empty() { None } else { Some(group) } }
|
||||
}
|
||||
@@ -499,6 +540,43 @@ mod tests {
|
||||
assert_eq!(layer_display_name(&e, 0), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigning_a_new_layer_uid_never_reuses_an_existing_layers_uid() {
|
||||
let make = |uid| {
|
||||
let mut layer = new_text_layer("text", Vec2::new(10.0, 20.0), 40.0);
|
||||
layer.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid, ..Default::default() }));
|
||||
layer
|
||||
};
|
||||
for initial in [[1, 0, 2, 1], [0, 1, 2, 1], [1, 2, 0, 1]] {
|
||||
let mut layers: Vec<_> = initial.into_iter().map(make).collect();
|
||||
assert!(ensure_layer_uids(&mut layers));
|
||||
let uids: Vec<_> = layers.iter().map(crate::gtemplate::layer_uid).collect();
|
||||
assert_eq!(uids.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 4, "{uids:?}");
|
||||
assert_eq!(uids[initial.iter().position(|uid| *uid == 1).unwrap()], 1);
|
||||
assert_eq!(uids[initial.iter().position(|uid| *uid == 2).unwrap()], 2);
|
||||
assert!(!ensure_layer_uids(&mut layers), "a second pass must preserve identity");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_uids_are_unique_and_repair_legacy_or_duplicate_entries() {
|
||||
let a = new_text_layer("one", Vec2::new(10.0, 20.0), 40.0);
|
||||
let mut b = new_text_layer("two", Vec2::new(10.0, 20.0), 40.0);
|
||||
let mut shape = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance();
|
||||
// Legacy file: no uids at all; one layer already carries a duplicate uid.
|
||||
assert_eq!(crate::gtemplate::layer_uid(&a), 0);
|
||||
b.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid: 7, ..Default::default() }));
|
||||
shape.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid: 7, ..Default::default() }));
|
||||
let mut effects = vec![a, b, shape];
|
||||
assert!(ensure_layer_uids(&mut effects));
|
||||
let uids: Vec<u64> = effects.iter().filter(|e| is_layer(e)).map(crate::gtemplate::layer_uid).collect();
|
||||
assert!(uids.iter().all(|uid| *uid != 0));
|
||||
let unique: std::collections::BTreeSet<u64> = uids.iter().copied().collect();
|
||||
assert_eq!(unique.len(), uids.len(), "every layer keeps its own identity: {uids:?}");
|
||||
// Idempotent: a second pass changes nothing.
|
||||
assert!(!ensure_layer_uids(&mut effects));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_centre_and_shadow_direction() {
|
||||
let mut e = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance();
|
||||
|
||||
@@ -350,14 +350,8 @@ pub fn layer_uid(e: &EffectInstance) -> u64 {
|
||||
}
|
||||
|
||||
/// Give every layer without a uid a fresh one (unique among `layers`).
|
||||
pub fn ensure_uids(layers: &mut [EffectInstance]) {
|
||||
let mut next = layers.iter().map(layer_uid).max().unwrap_or(0) + 1;
|
||||
for l in layers.iter_mut() {
|
||||
if layer_uid(l) == 0 {
|
||||
l.layer.get_or_insert_with(Default::default).uid = next;
|
||||
next += 1;
|
||||
}
|
||||
}
|
||||
pub fn ensure_uids(layers: &mut [EffectInstance]) -> bool {
|
||||
graphic::ensure_layer_uids(layers)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -285,6 +285,38 @@ impl Param {
|
||||
self.value_at(t).as_f64().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Borrowing text evaluation for read-only observers (the Program monitor's IME producer).
|
||||
///
|
||||
/// Text parameters never interpolate, so the result is the applicable static or keyframed
|
||||
/// `ParamValue::Text`; an interpolating non-text value yields `None`. Unlike
|
||||
/// [`Self::value_at`] this does not clone the document-sized string.
|
||||
pub fn text_ref_at(&self, t: Tick) -> Option<&str> {
|
||||
fn text(v: &ParamValue) -> Option<&str> {
|
||||
match v {
|
||||
ParamValue::Text(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let k = &self.keyframes;
|
||||
if k.is_empty() {
|
||||
return text(&self.value);
|
||||
}
|
||||
if t <= k[0].time {
|
||||
return text(&k[0].value);
|
||||
}
|
||||
let last = &k[k.len() - 1];
|
||||
if t >= last.time {
|
||||
return text(&last.value);
|
||||
}
|
||||
let i = match k.binary_search_by_key(&t, |kf| kf.time) {
|
||||
Ok(i) => return text(&k[i].value),
|
||||
Err(i) => i - 1,
|
||||
};
|
||||
// Hold semantics match `value_at`: non-interpolating values (including text) stay until
|
||||
// the next keyframe; an interpolating segment never evaluates to text.
|
||||
if k[i].interp == Interpolation::Hold || !k[i].value.interpolates() { text(&k[i].value) } else { None }
|
||||
}
|
||||
|
||||
/// Allocation-free [`Param::f64_at`] for scalar parameters (Float / Choice / Bool), for
|
||||
/// per-sample evaluation in the audio mixer. Non-scalar values evaluate to 0.
|
||||
pub fn scalar_at(&self, t: Tick) -> f64 {
|
||||
@@ -405,6 +437,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_ref_at_borrows_without_cloning_and_holds_non_interpolating_values() {
|
||||
let p = Param::new(ParamValue::Text("abc".into()));
|
||||
assert_eq!(p.text_ref_at(Tick(0)), Some("abc"));
|
||||
let f = Param::new(ParamValue::Float(1.0));
|
||||
assert_eq!(f.text_ref_at(Tick(0)), None);
|
||||
let mut p = Param::new(ParamValue::Text("a".into()));
|
||||
p.toggle_animation(Tick(0));
|
||||
p.set_at(Tick(100), ParamValue::Text("b".into()));
|
||||
assert_eq!(p.text_ref_at(Tick(50)), Some("a"), "text does not interpolate");
|
||||
assert_eq!(p.text_ref_at(Tick(100)), Some("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_off_keeps_value() {
|
||||
let mut p = Param::new(ParamValue::Float(1.0));
|
||||
|
||||
@@ -1747,6 +1747,32 @@ fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source:
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// Lazy upgrade for graphic layers written before they had stable uids: assign a unique
|
||||
/// [`crate::graphic_design::LayerExtra::uid`] to every uid-less or duplicated graphic layer
|
||||
/// in sequences and source graphics.
|
||||
///
|
||||
/// Called when a project file is opened, before undo history starts, so every later undo
|
||||
/// snapshot carries the same identity. The field is already optional in the file schema, so
|
||||
/// this changes no schema version and older builds still read the file. Returns whether any
|
||||
/// layer changed.
|
||||
pub fn ensure_graphic_layer_uids(&mut self) -> bool {
|
||||
let mut changed = false;
|
||||
for item in self.items.values_mut() {
|
||||
if let ItemKind::Sequence(seq) = &mut item.kind {
|
||||
let seq = std::sync::Arc::make_mut(seq);
|
||||
for track in seq.video_tracks.iter_mut().chain(seq.audio_tracks.iter_mut()) {
|
||||
for it in &mut track.items {
|
||||
changed |= crate::graphic::ensure_layer_uids(&mut it.effects);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for graphic in self.source_graphics.values_mut() {
|
||||
changed |= crate::graphic::ensure_layer_uids(&mut graphic.layers);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Available picture and sound, resolving a subclip to its parent media. Missing parents,
|
||||
/// cycles and overly deep subclip chains carry neither stream.
|
||||
pub fn source_streams(&self, item: ItemId) -> (bool, bool) {
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn slate(w: usize, h: usize, file_name: &str, reason: OfflineReason) -> Imag
|
||||
}
|
||||
let hint = match reason {
|
||||
OfflineReason::MadeOffline => "Link Media… brings it back",
|
||||
_ => "File ▸ Link Media… reconnects it",
|
||||
_ => "File > Link Media… reconnects it",
|
||||
};
|
||||
text(&mut img, hint, "Regular", Vec2::new(cx as f64, (ty0 + tri_h + s * 0.205) as f64), s * 0.028, [0.85, 0.72, 0.72, 1.0], pw * 0.94);
|
||||
img
|
||||
|
||||
@@ -27,7 +27,7 @@ filmcraft-export = { workspace = true }
|
||||
filmcraft-gpu = { workspace = true }
|
||||
filmcraft-scopes = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -52,6 +52,9 @@ filmcraft-codecs = { workspace = true }
|
||||
# itself stays OS-independent and web-portable)
|
||||
filmcraft-platform = { workspace = true }
|
||||
filmcraft-interchange = { workspace = true }
|
||||
# A dependency's dev-dependencies do not propagate through egui_kittest: name the fonts this
|
||||
# test root needs explicitly so glyph and accessibility assertions run against the bundled faces.
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
|
||||
tempfile = "3"
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ impl Registry {
|
||||
}
|
||||
/// Observe the widget without changing its accessibility role or input behavior.
|
||||
pub fn add_response(&mut self, id: &str, response: &egui::Response, label: &str) {
|
||||
// The automation label is also the name a screen reader and kittest use. Keep an
|
||||
// existing visible label/placeholder; this only fills a missing name.
|
||||
crate::widgets::accessible_name(response.clone(), label);
|
||||
self.add(id, response.rect, label);
|
||||
if let Some(element) = self.elements.last_mut() {
|
||||
element.widget_id = Some(format!("{:016x}", response.id.value()));
|
||||
@@ -53,11 +56,74 @@ impl Registry {
|
||||
pub fn begin_frame(&mut self) {
|
||||
self.previous = std::mem::take(&mut self.elements);
|
||||
}
|
||||
pub fn find(&self, id: &str) -> Option<&Element> {
|
||||
self.previous.iter().chain(self.elements.iter()).find(|e| e.id == id)
|
||||
/// The entries of the last accepted UI pass, or the pass before it while the current pass is
|
||||
/// still being laid out (pass 0 clears `elements`; `drain_ui_requests` runs after the layout).
|
||||
pub fn all(&self) -> &[Element] {
|
||||
if self.elements.is_empty() { &self.previous } else { &self.elements }
|
||||
}
|
||||
/// Elements whose id starts with a prefix.
|
||||
/// A widget drawn in the current accepted pass. Unlike [`Self::find`] this never falls back to
|
||||
/// the previous pass, for callers that need fresh geometry (e.g. a screenshot crop).
|
||||
pub fn find_current(&self, id: &str) -> Option<&Element> {
|
||||
self.elements.iter().find(|e| e.id == id)
|
||||
}
|
||||
/// Look up a widget, preferring the layout just drawn. The previous pass is only used before
|
||||
/// this pass has registered anything (input handling runs before the panels draw).
|
||||
pub fn find(&self, id: &str) -> Option<&Element> {
|
||||
self.all().iter().find(|e| e.id == id)
|
||||
}
|
||||
/// Elements whose id starts with a prefix, from the current accepted pass when there is one.
|
||||
pub fn query(&self, prefix: &str) -> Vec<&Element> {
|
||||
self.previous.iter().filter(|e| e.id.starts_with(prefix)).collect()
|
||||
self.all().iter().filter(|e| e.id.starts_with(prefix)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{Rect, pos2, vec2};
|
||||
|
||||
use super::Registry;
|
||||
|
||||
fn rect(x: f32) -> Rect {
|
||||
Rect::from_min_size(pos2(x, 0.0), vec2(10.0, 10.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookups_prefer_the_current_accepted_pass() {
|
||||
let mut registry = Registry::default();
|
||||
registry.add("panel.project", rect(1.0), "old");
|
||||
registry.begin_frame();
|
||||
registry.add("panel.project", rect(200.0), "new");
|
||||
assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(200.0));
|
||||
assert_eq!(registry.query("panel.").iter().map(|e| e.rect[0]).collect::<Vec<_>>(), vec![200.0]);
|
||||
assert_eq!(registry.find_current("panel.project").map(|e| e.rect[0]), Some(200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_elements_do_not_resolve_from_a_populated_current_pass() {
|
||||
let mut registry = Registry::default();
|
||||
registry.add("dialog.closed", rect(1.0), "old control");
|
||||
registry.begin_frame();
|
||||
assert!(registry.find("dialog.closed").is_some(), "before drawing, the previous pass remains usable");
|
||||
registry.add("panel.current", rect(200.0), "current control");
|
||||
assert!(registry.find("dialog.closed").is_none(), "a closed control cannot target stale coordinates");
|
||||
assert_eq!(registry.find("panel.current").map(|e| e.rect[0]), Some(200.0));
|
||||
assert!(registry.query("dialog.").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_previous_pass_is_only_a_fallback_before_this_pass_draws() {
|
||||
let mut registry = Registry::default();
|
||||
registry.add("panel.project", rect(1.0), "old");
|
||||
registry.begin_frame();
|
||||
assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(1.0));
|
||||
assert_eq!(registry.query("panel.").len(), 1);
|
||||
assert!(registry.find_current("panel.project").is_none());
|
||||
|
||||
// A discarded pass is cleared and redrawn, so exactly one entry per widget remains.
|
||||
registry.add("panel.project", rect(2.0), "discarded");
|
||||
registry.elements.clear();
|
||||
registry.add("panel.project", rect(3.0), "accepted");
|
||||
assert_eq!(registry.elements.len(), 1);
|
||||
assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(3.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const LOGO_DARK_INK: &[u8] = include_bytes!("../../../docs/brand/artcraft-logo.p
|
||||
/// The wordmark for a dark (`dark = true`) or light UI: light ink on dark themes. Its aspect
|
||||
/// ratio is width / height of the returned texture.
|
||||
pub fn wordmark(ctx: &egui::Context, dark: bool) -> Option<egui::TextureHandle> {
|
||||
let id = egui::Id::new(("artcraft-wordmark", dark));
|
||||
let id = egui::Id::unique(("artcraft-wordmark", dark));
|
||||
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(id)) {
|
||||
return Some(t);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,795 @@
|
||||
//! Bounded, one-shot window screenshots for `ui.screenshot`.
|
||||
//!
|
||||
//! A request record owns the output path, crop, reply, deadline, document/focus identity and the
|
||||
//! surface geometry it was admitted against. `craft_ui::capture::CaptureSlot` owns the strong
|
||||
//! `egui::Context`; its callback holds only a weak reference and never writes a file. Files are
|
||||
//! encoded and written only from the logic drain, after the deadline and every identity/geometry
|
||||
//! check has passed.
|
||||
//!
|
||||
//! Web qualification note: the pinned eframe web runner does not implement
|
||||
//! `ViewportCommand::RequestPaintWhileHidden` (the native runner does). A hidden web capture that
|
||||
//! is still settling therefore reaches its timeout. That fork gap is recorded as a prerequisite;
|
||||
//! this module does not claim a hidden web capture works.
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use craft_ui::capture::CaptureSlot;
|
||||
use egui::ViewportCommand;
|
||||
use serde_json::{Value, json};
|
||||
use web_time::{Duration, Instant};
|
||||
|
||||
use crate::FilmcraftApp;
|
||||
|
||||
/// At most this many queued, in-flight or completed-but-not-yet-drained requests.
|
||||
pub(crate) const MAX_REQUESTS: usize = 4;
|
||||
/// Per-image bound from the suite contract.
|
||||
pub(crate) const MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024;
|
||||
/// Aggregate bound, counting completed images that have not been drained yet.
|
||||
pub(crate) const MAX_TOTAL_BYTES: usize = 256 * 1024 * 1024;
|
||||
/// The raw RGBA image is not the only copy alive while a capture is drained: the callback's
|
||||
/// `ColorImage`, the cropped RGBA copy and the encoded PNG exist together, and on the web the PNG
|
||||
/// is base64-encoded into the reply while all of those are still retained. Reserve this multiple
|
||||
/// of the raw pixel bytes so admission actually bounds peak memory, not just the first buffer.
|
||||
pub(crate) const CAPTURE_BUFFER_FACTOR: usize = 5;
|
||||
|
||||
/// Bytes reserved for one `width`×`height` RGBA capture, including the transient encode copies.
|
||||
pub(crate) fn admitted_bytes(expected: [usize; 2]) -> Option<usize> {
|
||||
expected[0].checked_mul(expected[1])?.checked_mul(std::mem::size_of::<egui::Color32>())?.checked_mul(CAPTURE_BUFFER_FACTOR)
|
||||
}
|
||||
|
||||
const READY_DELAY: Duration = Duration::from_millis(250);
|
||||
/// Accepted first UI passes (never discarded passes or hidden logic ticks).
|
||||
const SETTLE_PASSES: u32 = 3;
|
||||
const DEADLINE: Duration = Duration::from_secs(10);
|
||||
/// Give up waiting for the exact monitor frame/timeline rest and capture what is shown (5 s).
|
||||
const SETTLE_MAX: Duration = Duration::from_secs(5);
|
||||
/// Accepted passes to wait for a named crop target that is not drawn yet (e.g. a panel shown by
|
||||
/// the preceding control request). After this the request fails instead of capturing the window.
|
||||
const CROP_SETTLE_PASSES: u32 = 3;
|
||||
const REPAINT_INTERVAL: Duration = Duration::from_millis(16);
|
||||
|
||||
/// The host surface as reported by one `RawInput`. Raw input is fresh on hidden logic-only ticks;
|
||||
/// `Context::input`/`viewport_rect` describe the last UI pass and are stale there.
|
||||
///
|
||||
/// Equality covers only what changes the captured pixels (viewport size, pixels per point and the
|
||||
/// GPU texture limit). Minimized, occluded and native window focus are presentation flags: a
|
||||
/// hidden or unfocused window still captures the forced paint that was already issued, so they
|
||||
/// must neither invalidate an in-flight screenshot nor bump `capture_epoch`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct SurfaceGeometry {
|
||||
/// Logical viewport size in points. A minimized window keeps its last known size so a forced
|
||||
/// hidden paint can still be requested; `None` only before any size was ever reported.
|
||||
viewport: Option<egui::Vec2>,
|
||||
/// Native pixels per point times egui's interface zoom.
|
||||
pixels_per_point: f32,
|
||||
max_texture_side: usize,
|
||||
minimized: bool,
|
||||
occluded: bool,
|
||||
}
|
||||
|
||||
impl PartialEq for SurfaceGeometry {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.viewport == other.viewport && self.pixels_per_point == other.pixels_per_point && self.max_texture_side == other.max_texture_side
|
||||
}
|
||||
}
|
||||
|
||||
impl SurfaceGeometry {
|
||||
fn from_raw(raw: &egui::RawInput, ctx: &egui::Context, previous: Option<Self>) -> Option<Self> {
|
||||
let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default();
|
||||
let rect = info.inner_rect.or(raw.screen_rect);
|
||||
let size = rect.map(|rect| rect.size()).filter(|size| size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0);
|
||||
let viewport = size.or(previous.and_then(|previous| previous.viewport));
|
||||
let zoom = ctx.zoom_factor();
|
||||
if !zoom.is_finite() || zoom <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let pixels_per_point = info
|
||||
.native_pixels_per_point
|
||||
.map(|native| native * zoom)
|
||||
.filter(|pixels| pixels.is_finite() && *pixels > 0.0)
|
||||
.or_else(|| previous.map(|previous| previous.pixels_per_point))
|
||||
.unwrap_or_else(|| ctx.pixels_per_point());
|
||||
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|input| input.max_texture_side));
|
||||
let minimized = info.minimized.unwrap_or_else(|| previous.is_some_and(|previous| previous.minimized));
|
||||
let occluded = info.occluded.unwrap_or_else(|| previous.is_some_and(|previous| previous.occluded));
|
||||
Some(Self { viewport, pixels_per_point, max_texture_side, minimized, occluded })
|
||||
}
|
||||
|
||||
fn expected_size(self) -> Option<[usize; 2]> {
|
||||
let viewport = self.viewport?;
|
||||
if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let axis = |points: f32| -> Option<usize> {
|
||||
let pixels = (f64::from(points) * f64::from(self.pixels_per_point)).round();
|
||||
if !pixels.is_finite() || pixels <= 0.0 || pixels > usize::MAX as f64 {
|
||||
return None;
|
||||
}
|
||||
Some(pixels as usize)
|
||||
};
|
||||
let [width, height] = [axis(viewport.x)?, axis(viewport.y)?];
|
||||
// Match the wgpu backend's `surface_fit`: when either axis exceeds the device limit, both
|
||||
// axes shrink by the same factor and the longer side becomes exactly the limit. Clamping
|
||||
// each axis independently predicted a wider/taller image than the surface actually has.
|
||||
let max = if self.max_texture_side == 0 { usize::MAX } else { self.max_texture_side };
|
||||
if width <= max && height <= max {
|
||||
return Some([width, height]);
|
||||
}
|
||||
let longest = width.max(height);
|
||||
let scale = max as f64 / longest as f64;
|
||||
let fit = |side: usize| -> usize { if side == longest { max } else { ((side as f64 * scale).floor() as usize).clamp(1, max) } };
|
||||
Some([fit(width), fit(height)])
|
||||
}
|
||||
|
||||
fn expected_bytes(self) -> Option<usize> {
|
||||
let [width, height] = self.expected_size()?;
|
||||
width.checked_mul(height)?.checked_mul(std::mem::size_of::<egui::Color32>())
|
||||
}
|
||||
}
|
||||
|
||||
/// Which sequence and project revision the request belongs to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct DocumentId {
|
||||
sequence: Option<u64>,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
/// Viewport geometry plus the immutable document and widget-focus identity a capture is bound to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct CaptureGeometry {
|
||||
surface: SurfaceGeometry,
|
||||
document: DocumentId,
|
||||
focus: Option<egui::Id>,
|
||||
}
|
||||
|
||||
impl CaptureGeometry {
|
||||
fn expected_size(self) -> Option<[usize; 2]> {
|
||||
self.surface.expected_size()
|
||||
}
|
||||
|
||||
fn expected_bytes(self) -> Option<usize> {
|
||||
self.surface.expected_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// A screenshot crop named by a widget id. Resolved at issue time from the pass that is actually
|
||||
/// captured, never from the control callback that admitted the request (that pass may be two
|
||||
/// layouts old or may not have drawn the panel at all).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CropTarget {
|
||||
Element(String),
|
||||
}
|
||||
|
||||
impl CropTarget {
|
||||
fn resolve(&self, registry: &crate::automation::Registry) -> Result<[f32; 4], String> {
|
||||
let Self::Element(id) = self;
|
||||
let element = registry
|
||||
.find_current(id)
|
||||
.ok_or_else(|| format!("screenshot failed: `{id}` was not drawn in the captured pass (the panel may be closed or hidden; see ui.elements)"))?;
|
||||
let [x, y, width, height] = element.rect;
|
||||
if ![x, y, width, height].iter().all(|value| value.is_finite()) || width <= 0.0 || height <= 0.0 {
|
||||
return Err(format!("screenshot failed: `{id}` has an empty or non-finite rectangle"));
|
||||
}
|
||||
Ok(element.rect)
|
||||
}
|
||||
}
|
||||
|
||||
struct Shot {
|
||||
token: u64,
|
||||
path: Option<String>,
|
||||
crop_target: Option<CropTarget>,
|
||||
/// The crop resolved from the accepted pass that was captured (set at issue time).
|
||||
crop: Option<[f32; 4]>,
|
||||
/// Accepted passes the named crop target was missing, while a just-shown panel lays out.
|
||||
crop_misses: u32,
|
||||
reply: Sender<Value>,
|
||||
deadline: Instant,
|
||||
ready_at: Instant,
|
||||
frames: u32,
|
||||
issued: bool,
|
||||
document: DocumentId,
|
||||
focus: Option<egui::Id>,
|
||||
geometry: CaptureGeometry,
|
||||
geometry_epoch: u64,
|
||||
expected: [usize; 2],
|
||||
reserved: usize,
|
||||
slot: Option<CaptureSlot>,
|
||||
callback: Option<egui::ScreenshotCallback>,
|
||||
}
|
||||
|
||||
/// All admitted screenshots. `requests` includes queued, issued and completed-but-undrained
|
||||
/// captures; nothing is removed before it has been replied to or timed out.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Shots {
|
||||
token: u64,
|
||||
requests: Vec<Shot>,
|
||||
}
|
||||
|
||||
impl Shots {
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.requests.is_empty()
|
||||
}
|
||||
|
||||
fn has_unissued(&self) -> bool {
|
||||
self.requests.iter().any(|shot| !shot.issued)
|
||||
}
|
||||
|
||||
fn reserved(&self) -> usize {
|
||||
self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.reserved))
|
||||
}
|
||||
}
|
||||
|
||||
fn next_token(shots: &mut Shots) -> u64 {
|
||||
let mut token = shots.token.wrapping_add(1);
|
||||
if token == 0 {
|
||||
token = 1;
|
||||
}
|
||||
shots.token = token;
|
||||
token
|
||||
}
|
||||
|
||||
fn error_reply(token: u64, message: impl std::fmt::Display) -> Value {
|
||||
json!({"ok": false, "error": message.to_string(), "request": token})
|
||||
}
|
||||
|
||||
fn reject(reply: Sender<Value>, token: u64, message: impl std::fmt::Display) {
|
||||
let _ = reply.send(error_reply(token, message));
|
||||
}
|
||||
|
||||
fn active_document(app: &FilmcraftApp) -> DocumentId {
|
||||
DocumentId { sequence: app.session.state.active_sequence.map(|id| id.0), revision: app.session.revision }
|
||||
}
|
||||
|
||||
fn current_focus(ctx: &egui::Context) -> Option<egui::Id> {
|
||||
ctx.memory(|memory| memory.focused())
|
||||
}
|
||||
|
||||
/// Refresh the fresh raw-input surface/identity snapshot. Called from `raw_input_hook`, so it runs
|
||||
/// on both UI and hidden logic-only callbacks and never reads the stale `InputState` rectangle.
|
||||
pub(crate) fn note_raw_geometry(app: &mut FilmcraftApp, ctx: &egui::Context, raw: &egui::RawInput) {
|
||||
let previous = app.capture_geometry;
|
||||
let Some(surface) = SurfaceGeometry::from_raw(raw, ctx, previous.map(|geometry| geometry.surface)) else {
|
||||
app.capture_geometry = None;
|
||||
app.capture_epoch = app.capture_epoch.wrapping_add(1);
|
||||
return;
|
||||
};
|
||||
let geometry = CaptureGeometry { surface, document: active_document(app), focus: current_focus(ctx) };
|
||||
let changed = app.capture_geometry != Some(geometry);
|
||||
// Keep the presentation flags fresh even when they did not change the capture geometry.
|
||||
app.capture_geometry = Some(geometry);
|
||||
if changed {
|
||||
app.capture_epoch = app.capture_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick up document/revision and keyboard-focus changes made since the raw-input snapshot (a
|
||||
/// control request, or the UI pass that admitted a `ui.screenshot` at its end).
|
||||
fn refresh_identity(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(geometry) = app.capture_geometry else { return };
|
||||
let refreshed = CaptureGeometry { document: active_document(app), focus: current_focus(ctx), ..geometry };
|
||||
let changed = refreshed != geometry;
|
||||
app.capture_geometry = Some(refreshed);
|
||||
if changed {
|
||||
app.capture_epoch = app.capture_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit one `ui.screenshot` request without issuing it. Counts queued, in-flight and
|
||||
/// completed-but-undrained requests and reserves the raw image plus its encode/encode-reply buffers
|
||||
/// before capture.
|
||||
pub(crate) fn admit(app: &mut FilmcraftApp, ctx: &egui::Context, path: Option<String>, crop: Option<CropTarget>, reply: Sender<Value>) {
|
||||
refresh_identity(app, ctx);
|
||||
let token = next_token(&mut app.shots);
|
||||
let Some(geometry) = app.capture_geometry else {
|
||||
return reject(reply, token, "screenshot failed: the window geometry is not known yet");
|
||||
};
|
||||
let Some(expected) = geometry.expected_size() else {
|
||||
return reject(reply, token, "screenshot failed: the window has no capturable size yet");
|
||||
};
|
||||
let Some(raw) = geometry.expected_bytes() else {
|
||||
return reject(reply, token, "screenshot failed: the window size overflows the screenshot budget");
|
||||
};
|
||||
if raw > MAX_IMAGE_BYTES {
|
||||
return reject(
|
||||
reply,
|
||||
token,
|
||||
format!(
|
||||
"screenshot budget exceeded: {raw} raw bytes for one {}×{} image, over the {MAX_IMAGE_BYTES}-byte per-image limit",
|
||||
expected[0], expected[1]
|
||||
),
|
||||
);
|
||||
}
|
||||
let Some(reserved) = admitted_bytes(expected) else {
|
||||
return reject(reply, token, "screenshot failed: the window size overflows the screenshot budget");
|
||||
};
|
||||
if reserved > MAX_TOTAL_BYTES {
|
||||
return reject(
|
||||
reply,
|
||||
token,
|
||||
format!(
|
||||
"screenshot budget exceeded: one {}×{} capture needs up to {reserved} bytes including the crop/encode reply buffers, over the {MAX_TOTAL_BYTES}-byte limit",
|
||||
expected[0], expected[1]
|
||||
),
|
||||
);
|
||||
}
|
||||
if app.shots.requests.len() >= MAX_REQUESTS {
|
||||
return reject(reply, token, format!("screenshot budget exceeded: {MAX_REQUESTS} requests are already outstanding"));
|
||||
}
|
||||
let total = app.shots.reserved().saturating_add(reserved);
|
||||
if total > MAX_TOTAL_BYTES {
|
||||
return reject(
|
||||
reply,
|
||||
token,
|
||||
format!("screenshot budget exceeded: {total} bytes reserved by outstanding requests, over the {MAX_TOTAL_BYTES}-byte limit"),
|
||||
);
|
||||
}
|
||||
let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), MAX_IMAGE_BYTES);
|
||||
let now = Instant::now();
|
||||
app.shots.requests.push(Shot {
|
||||
token,
|
||||
path,
|
||||
crop_target: crop,
|
||||
crop: None,
|
||||
crop_misses: 0,
|
||||
reply,
|
||||
deadline: now + DEADLINE,
|
||||
ready_at: now + READY_DELAY,
|
||||
frames: 0,
|
||||
issued: false,
|
||||
document: geometry.document,
|
||||
focus: geometry.focus,
|
||||
geometry,
|
||||
geometry_epoch: app.capture_epoch,
|
||||
expected,
|
||||
reserved,
|
||||
slot: Some(slot),
|
||||
callback: Some(callback),
|
||||
});
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
fn identity_reason(app: &FilmcraftApp, ctx: &egui::Context, shot: &Shot) -> Option<&'static str> {
|
||||
if active_document(app) != shot.document {
|
||||
Some("the document or project revision changed while the capture was queued")
|
||||
} else if current_focus(ctx) != shot.focus {
|
||||
Some("the keyboard focus changed while the capture was queued")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry_reason(app: &FilmcraftApp, shot: &Shot) -> Option<&'static str> {
|
||||
if app.capture_geometry != Some(shot.geometry) || app.capture_epoch != shot.geometry_epoch {
|
||||
Some("the window geometry changed while the capture was queued")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_geometry(app: &mut FilmcraftApp, ctx: &egui::Context, index: usize) -> Result<(), String> {
|
||||
let now = Instant::now();
|
||||
let current = app.capture_geometry.ok_or_else(|| "the window geometry is no longer known".to_string())?;
|
||||
let expected = current.expected_size().ok_or_else(|| "the window has no capturable size".to_string())?;
|
||||
let raw = current.expected_bytes().ok_or_else(|| "the window size overflows the screenshot budget".to_string())?;
|
||||
if raw > MAX_IMAGE_BYTES {
|
||||
return Err(format!("the window now needs {raw} raw bytes, over the {MAX_IMAGE_BYTES}-byte per-image limit"));
|
||||
}
|
||||
let reserved = admitted_bytes(expected).ok_or_else(|| "the window size overflows the screenshot budget".to_string())?;
|
||||
let others = app.shots.requests.iter().enumerate().filter(|(i, _)| *i != index).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.reserved));
|
||||
if others.saturating_add(reserved) > MAX_TOTAL_BYTES {
|
||||
return Err(format!("the window now needs {reserved} bytes, over the {MAX_TOTAL_BYTES}-byte aggregate limit"));
|
||||
}
|
||||
let Some(current_geometry) = app.capture_geometry else { return Err("the window geometry changed repeatedly".into()) };
|
||||
let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), MAX_IMAGE_BYTES);
|
||||
let shot = app.shots.requests.get_mut(index).ok_or_else(|| "the screenshot request disappeared".to_string())?;
|
||||
if let Some(old) = shot.slot.take() {
|
||||
old.cancel();
|
||||
}
|
||||
shot.callback = Some(callback);
|
||||
shot.slot = Some(slot);
|
||||
shot.geometry = current_geometry;
|
||||
shot.geometry_epoch = app.capture_epoch;
|
||||
shot.expected = expected;
|
||||
shot.reserved = reserved;
|
||||
shot.frames = 0;
|
||||
shot.crop_misses = 0;
|
||||
shot.ready_at = now + READY_DELAY;
|
||||
shot.issued = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Issue settled requests. Called from every real `App::ui` pass (never from a hidden
|
||||
/// `run_logic` tick), including the accepted final pass of a multipass layout. `monitor_inexact`
|
||||
/// describes the pass that just drew the monitor, so consume it here: it must not stay sticky and
|
||||
/// delay every later screenshot until the 5 s settle maximum.
|
||||
pub(crate) fn issue(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let monitor_inexact = std::mem::take(&mut app.monitor_inexact);
|
||||
if ctx.will_discard() {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut failures: Vec<(usize, String)> = Vec::new();
|
||||
let mut retries: Vec<usize> = Vec::new();
|
||||
for i in 0..app.shots.requests.len() {
|
||||
if app.shots.requests[i].issued {
|
||||
continue;
|
||||
}
|
||||
app.shots.requests[i].frames = app.shots.requests[i].frames.saturating_add(1);
|
||||
let shot = &app.shots.requests[i];
|
||||
if now < shot.ready_at || shot.frames < SETTLE_PASSES {
|
||||
continue;
|
||||
}
|
||||
// After a seek the monitor can show the nearest cached picture until the exact frame is
|
||||
// decoded, and the Timeline can still be animating. Capture once those have settled, or
|
||||
// after the same bounded maximum the old `Ui` settle path used.
|
||||
if (monitor_inexact || app.timeline_still < 2) && now < shot.ready_at + SETTLE_MAX {
|
||||
continue;
|
||||
}
|
||||
if let Some(reason) = identity_reason(app, ctx, shot) {
|
||||
failures.push((i, reason.to_string()));
|
||||
continue;
|
||||
}
|
||||
if geometry_reason(app, shot).is_some() {
|
||||
retries.push(i);
|
||||
continue;
|
||||
}
|
||||
let crop_result = match app.shots.requests[i].crop_target.as_ref() {
|
||||
Some(target) => target.resolve(&app.auto).map(Some),
|
||||
None => Ok(None),
|
||||
};
|
||||
let resolved_crop = match crop_result {
|
||||
Ok(crop) => crop,
|
||||
Err(message) => {
|
||||
let shot = &mut app.shots.requests[i];
|
||||
if shot.crop_misses < CROP_SETTLE_PASSES {
|
||||
// The panel may be shown by a request processed just before this admission;
|
||||
// let the next accepted pass draw it before giving up.
|
||||
shot.crop_misses = shot.crop_misses.saturating_add(1);
|
||||
ctx.request_repaint();
|
||||
continue;
|
||||
}
|
||||
failures.push((i, message));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let shot = &mut app.shots.requests[i];
|
||||
let Some(callback) = shot.callback.take() else {
|
||||
failures.push((i, "screenshot failed: the capture callback was already consumed".into()));
|
||||
continue;
|
||||
};
|
||||
shot.crop = resolved_crop;
|
||||
ctx.send_viewport_cmd_to(ctx.viewport_id(), ViewportCommand::Screenshot(callback));
|
||||
shot.issued = true;
|
||||
}
|
||||
for i in retries.into_iter().rev() {
|
||||
if let Err(message) = retry_geometry(app, ctx, i) {
|
||||
failures.push((i, message));
|
||||
}
|
||||
}
|
||||
for (i, message) in failures.into_iter().rev() {
|
||||
let shot = app.shots.requests.remove(i);
|
||||
if let Some(slot) = shot.slot {
|
||||
slot.cancel();
|
||||
}
|
||||
reject(shot.reply, shot.token, message);
|
||||
}
|
||||
if !app.shots.is_empty() {
|
||||
ctx.request_repaint_after(REPAINT_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain completed slots and expire timed-out requests. Call from every logic callback (including
|
||||
/// hidden ones). The deadline is checked before taking or writing an image.
|
||||
pub(crate) fn drain(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut i = 0;
|
||||
while i < app.shots.requests.len() {
|
||||
let now = Instant::now();
|
||||
if now >= app.shots.requests[i].deadline {
|
||||
let shot = app.shots.requests.remove(i);
|
||||
if let Some(slot) = shot.slot {
|
||||
slot.cancel();
|
||||
}
|
||||
reject(shot.reply, shot.token, "no frame was presented before the screenshot timeout");
|
||||
continue;
|
||||
}
|
||||
if let Some(reason) = identity_reason(app, ctx, &app.shots.requests[i]) {
|
||||
let shot = app.shots.requests.remove(i);
|
||||
if let Some(slot) = shot.slot {
|
||||
slot.cancel();
|
||||
}
|
||||
reject(shot.reply, shot.token, format!("screenshot failed: {reason}"));
|
||||
continue;
|
||||
}
|
||||
if geometry_reason(app, &app.shots.requests[i]).is_some() {
|
||||
if !app.shots.requests[i].issued {
|
||||
// The next accepted UI pass re-creates the slot against the fresh geometry.
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Geometry changed while the callback was already issued: reject stale pixels rather
|
||||
// than pairing them with the new surface.
|
||||
let shot = app.shots.requests.remove(i);
|
||||
if let Some(slot) = shot.slot {
|
||||
slot.cancel();
|
||||
}
|
||||
reject(shot.reply, shot.token, "screenshot failed: the window geometry changed while the capture was in flight");
|
||||
continue;
|
||||
}
|
||||
if !app.shots.requests[i].issued {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(result) = app.shots.requests[i].slot.as_ref().and_then(CaptureSlot::take) else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let shot = app.shots.requests.remove(i);
|
||||
if let Some(slot) = shot.slot {
|
||||
slot.cancel();
|
||||
}
|
||||
match result {
|
||||
Ok(image) => {
|
||||
let reply = if image.size != shot.expected {
|
||||
error_reply(
|
||||
shot.token,
|
||||
format!(
|
||||
"screenshot is {}×{} pixels, expected {}×{}: the window changed size while it was captured",
|
||||
image.size[0], image.size[1], shot.expected[0], shot.expected[1]
|
||||
),
|
||||
)
|
||||
} else {
|
||||
let mut reply = crate::control::save_screenshot_with_ppp(&image, shot.path.as_deref(), shot.crop, shot.geometry.surface.pixels_per_point);
|
||||
if let Some(object) = reply.as_object_mut() {
|
||||
object.insert("request".into(), json!(shot.token));
|
||||
}
|
||||
reply
|
||||
};
|
||||
let _ = shot.reply.send(reply);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = shot.reply.send(error_reply(shot.token, error));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !app.shots.is_empty() {
|
||||
ctx.request_repaint_after(REPAINT_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel every queued/in-flight capture (app exit). Late callbacks see a dropped slot and write
|
||||
/// nothing; clients receive one error reply instead of hanging past the transport timeout.
|
||||
pub(crate) fn cancel_all(app: &mut FilmcraftApp) {
|
||||
for shot in app.shots.requests.drain(..) {
|
||||
if let Some(slot) = shot.slot {
|
||||
slot.cancel();
|
||||
}
|
||||
let _ = shot.reply.send(error_reply(shot.token, "screenshot cancelled: the app is closing"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for a painted frame while a request is still queued, and on hidden logic ticks when a
|
||||
/// capture is waiting for settlement. The screenshot command itself forces one paint once issued.
|
||||
pub(crate) fn request_hidden_paint(app: &FilmcraftApp, ctx: &egui::Context) {
|
||||
if app.shots.has_unissued() {
|
||||
ctx.send_viewport_cmd(ViewportCommand::RequestPaintWhileHidden);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use egui::{Color32, ColorImage, Rect, ViewportId, ViewportInfo, pos2, vec2};
|
||||
|
||||
use super::*;
|
||||
use crate::automation::Registry;
|
||||
use filmcraft_engine::Session;
|
||||
|
||||
fn app() -> FilmcraftApp {
|
||||
FilmcraftApp::new(Session::default())
|
||||
}
|
||||
|
||||
fn geometry(app: &FilmcraftApp) -> CaptureGeometry {
|
||||
CaptureGeometry {
|
||||
surface: SurfaceGeometry {
|
||||
viewport: Some(egui::vec2(800.0, 600.0)),
|
||||
pixels_per_point: 1.0,
|
||||
max_texture_side: 8192,
|
||||
minimized: false,
|
||||
occluded: false,
|
||||
},
|
||||
document: active_document(app),
|
||||
focus: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn raw() -> egui::RawInput {
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }
|
||||
}
|
||||
|
||||
fn admit_one(app: &mut FilmcraftApp, ctx: &egui::Context) -> (Receiver<Value>, u64) {
|
||||
app.capture_geometry = Some(geometry(app));
|
||||
let (tx, rx) = channel();
|
||||
admit(app, ctx, None, None, tx);
|
||||
let token = app.shots.requests.first().map(|shot| shot.token).unwrap_or_default();
|
||||
(rx, token)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_geometry_uses_the_fresh_surface_not_the_previous_pass() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let mut raw = raw();
|
||||
let _ = raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(2.0), ..Default::default() });
|
||||
note_raw_geometry(&mut app, &ctx, &raw);
|
||||
let geometry = app.capture_geometry.expect("geometry from raw input");
|
||||
assert_eq!(geometry.surface.expected_size(), Some([1600, 1200]));
|
||||
assert!(app.capture_epoch > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_windows_predict_the_backends_uniformly_scaled_surface() {
|
||||
let app = app();
|
||||
let mut surface = geometry(&app).surface;
|
||||
surface.viewport = Some(vec2(16_384.0, 4_000.0));
|
||||
surface.max_texture_side = 8192;
|
||||
assert_eq!(surface.expected_size(), Some([8192, 2000]), "both axes scale by the same factor, like egui-wgpu's surface_fit");
|
||||
surface.viewport = Some(vec2(4_000.0, 16_384.0));
|
||||
assert_eq!(surface.expected_size(), Some([2000, 8192]));
|
||||
surface.viewport = Some(vec2(1920.0, 1080.0));
|
||||
assert_eq!(surface.expected_size(), Some([1920, 1080]), "within the limit nothing shrinks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_flags_do_not_invalidate_an_issued_capture() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let (rx, token) = admit_one(&mut app, &ctx);
|
||||
let callback = app.shots.requests[0].callback.take().expect("a callback");
|
||||
app.shots.requests[0].issued = true;
|
||||
// The window is minimized or another application took focus while the callback was in
|
||||
// flight. Those flags do not change captured pixels and must not reject the image.
|
||||
let epoch = app.capture_epoch;
|
||||
let mut raw = egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
|
||||
max_texture_side: Some(8192),
|
||||
focused: false,
|
||||
..Default::default()
|
||||
};
|
||||
let viewport = raw.viewports.entry(ViewportId::ROOT).or_default();
|
||||
viewport.native_pixels_per_point = Some(1.0);
|
||||
viewport.minimized = Some(true);
|
||||
viewport.occluded = Some(true);
|
||||
note_raw_geometry(&mut app, &ctx, &raw);
|
||||
assert_eq!(app.capture_epoch, epoch, "presentation-only raw input retains the issued capture");
|
||||
callback.complete(Arc::new(ColorImage::filled([800, 600], Color32::RED)));
|
||||
drain(&mut app, &ctx);
|
||||
let reply = rx.try_recv().expect("the issued capture completes instead of being rejected");
|
||||
assert_eq!(reply["request"], token);
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_counts_outstanding_and_completed_but_undrained_requests() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let _receivers: Vec<_> = (0..MAX_REQUESTS).map(|_| admit_one(&mut app, &ctx)).collect();
|
||||
assert_eq!(app.shots.requests.len(), MAX_REQUESTS);
|
||||
let image = Arc::new(ColorImage::filled([800, 600], Color32::RED));
|
||||
app.shots.requests[0].callback.take().expect("a callback").complete(image);
|
||||
let (tx, rx) = channel();
|
||||
admit(&mut app, &ctx, None, None, tx);
|
||||
assert_eq!(app.shots.requests.len(), MAX_REQUESTS, "the fifth request is rejected, not admitted");
|
||||
let reply = rx.recv().expect("a rejection reply");
|
||||
assert_eq!(reply["ok"], false);
|
||||
assert!(reply["error"].as_str().unwrap_or_default().contains("outstanding"), "{reply}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_completion_after_timeout_does_not_write_or_reply_twice() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let (rx, token) = admit_one(&mut app, &ctx);
|
||||
let callback = app.shots.requests[0].callback.take().expect("a callback");
|
||||
app.shots.requests[0].issued = true;
|
||||
app.shots.requests[0].deadline = Instant::now() - Duration::from_millis(1);
|
||||
drain(&mut app, &ctx);
|
||||
let reply = rx.recv().expect("timeout reply");
|
||||
assert_eq!(reply["request"], token);
|
||||
assert_eq!(reply["ok"], false);
|
||||
callback.complete(Arc::new(ColorImage::filled([800, 600], Color32::RED)));
|
||||
assert!(rx.try_recv().is_err(), "a late image must not produce another reply");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_limit_images_are_rejected_for_encode_buffer_memory() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
// 8000×4000 RGBA is exactly 128,000,000 raw bytes, under the per-image bound, but the
|
||||
// crop/PNG/base64 copies make its real peak several times larger than the aggregate bound.
|
||||
app.capture_geometry = Some(CaptureGeometry {
|
||||
surface: SurfaceGeometry {
|
||||
viewport: Some(vec2(8000.0, 4000.0)),
|
||||
pixels_per_point: 1.0,
|
||||
max_texture_side: 16384,
|
||||
minimized: false,
|
||||
occluded: false,
|
||||
},
|
||||
document: active_document(&app),
|
||||
focus: None,
|
||||
});
|
||||
let (tx, rx) = channel();
|
||||
admit(&mut app, &ctx, None, None, tx);
|
||||
assert!(app.shots.requests.is_empty(), "the image is rejected before a slot or buffer is created");
|
||||
let reply = rx.recv().expect("a rejection reply");
|
||||
assert_eq!(reply["ok"], false);
|
||||
assert!(reply["error"].as_str().unwrap_or_default().contains("budget"), "{reply}");
|
||||
assert!(admitted_bytes([8000, 4000]).is_some_and(|bytes| bytes > MAX_TOTAL_BYTES));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_is_resolved_from_the_current_pass_not_the_previous_one() {
|
||||
let mut registry = Registry::default();
|
||||
let old = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 50.0));
|
||||
let fresh = Rect::from_min_size(pos2(300.0, 200.0), vec2(400.0, 250.0));
|
||||
registry.add("panel.project", old, "Project");
|
||||
registry.begin_frame(); // the previous pass keeps the old rect; this pass has not drawn yet
|
||||
let target = CropTarget::Element("panel.project".into());
|
||||
assert!(target.resolve(®istry).is_err(), "a stale rect must not be used");
|
||||
registry.add("panel.project", fresh, "Project");
|
||||
assert_eq!(target.resolve(®istry).expect("fresh rect"), [300.0, 200.0, 400.0, 250.0]);
|
||||
assert!(CropTarget::Element("panel.missing".into()).resolve(®istry).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_runs_on_the_accepted_final_pass_of_a_multipass_layout() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let (_rx, _token) = admit_one(&mut app, &ctx);
|
||||
app.shots.requests[0].ready_at = Instant::now() - Duration::from_millis(1);
|
||||
app.shots.requests[0].frames = SETTLE_PASSES;
|
||||
app.timeline_still = 2;
|
||||
let mut passes = 0;
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
passes += 1;
|
||||
if ui.ctx().current_pass_index() == 0 {
|
||||
ui.ctx().request_discard("capture pass test");
|
||||
}
|
||||
issue(&mut app, ui.ctx());
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(passes, 2, "the first pass was discarded and the second accepted");
|
||||
assert!(app.shots.requests[0].issued, "the accepted pass must issue the capture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_inexact_is_consumed_by_each_issue_attempt() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.monitor_inexact = true;
|
||||
issue(&mut app, &ctx);
|
||||
assert!(!app.monitor_inexact, "a sticky flag would delay every later screenshot by up to five seconds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_geometry_change_retries_an_unissued_capture_with_new_budget() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let (_rx, _) = admit_one(&mut app, &ctx);
|
||||
app.shots.requests[0].ready_at = Instant::now() - Duration::from_millis(1);
|
||||
app.shots.requests[0].frames = SETTLE_PASSES;
|
||||
app.timeline_still = 2;
|
||||
let mut changed = geometry(&app).surface;
|
||||
changed.viewport = Some(egui::vec2(1024.0, 768.0));
|
||||
app.capture_geometry = Some(CaptureGeometry { surface: changed, ..geometry(&app) });
|
||||
app.capture_epoch += 1;
|
||||
issue(&mut app, &ctx);
|
||||
assert_eq!(app.shots.requests.len(), 1);
|
||||
assert_eq!(app.shots.requests[0].expected, [1024, 768]);
|
||||
assert!(!app.shots.requests[0].issued, "the retry waits for its own settling passes");
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ fn find_system_font(sample: &str, preferred: &[&str]) -> Option<Arc<FontData>> {
|
||||
let by_weight = |f: &&Arc<filmcraft_text::fonts::Face>| f.info.weight.abs_diff(400);
|
||||
let found = preferred.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight));
|
||||
let face = found.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?;
|
||||
Some(Arc::new(FontData { font: std::borrow::Cow::Owned(face.data()?), index: face.info.index, tweak: Default::default() }))
|
||||
Some(Arc::new(FontData::from_blob(Arc::new(face.data()?), face.info.index)))
|
||||
}
|
||||
|
||||
/// Whether [`install`] has a Hangul face to add (an installed system face; none is bundled).
|
||||
@@ -100,3 +100,14 @@ fn install_chinese(fonts: &mut FontDefinitions) {
|
||||
stack.extend(order.iter().map(|f| name(f)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod font_blob_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_ttc_face_index_survives_font_data_construction() {
|
||||
let font = FontData::from_blob(Arc::new(vec![0u8; 4]), 7);
|
||||
assert_eq!(font.index, 7, "the face index must travel with the shared blob");
|
||||
}
|
||||
}
|
||||
|
||||
+128
-18
@@ -27,6 +27,8 @@ use serde_json::{Value, json};
|
||||
|
||||
const MAX_CLICK_COUNT: u64 = 10;
|
||||
const MAX_DRAG_STEPS: u64 = 1_024;
|
||||
/// Synthetic input is consumed a step per frame; bound what a client can queue ahead of that.
|
||||
const MAX_SYNTHETIC_EVENTS: usize = 16_384;
|
||||
|
||||
fn bounded_repetitions(params: &Value, key: &str, default: u64, minimum: u64, maximum: u64) -> Result<u64, String> {
|
||||
let value = params.get(key).map(|value| value.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))).transpose()?.unwrap_or(default);
|
||||
@@ -64,7 +66,8 @@ pub enum Outcome {
|
||||
AfterInput,
|
||||
Screenshot {
|
||||
path: Option<String>,
|
||||
crop: Option<[f32; 4]>,
|
||||
/// Resolved to fresh geometry at issue time, not here.
|
||||
crop: Option<crate::capture::CropTarget>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -110,6 +113,9 @@ fn point(app: &FilmcraftApp, p: &Value) -> Result<egui::Pos2, String> {
|
||||
pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
let p = &req.params;
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
if matches!(req.method.as_str(), "ui.click" | "ui.move" | "ui.drag" | "ui.scroll" | "ui.key" | "ui.type") && app.synthetic.len() >= MAX_SYNTHETIC_EVENTS {
|
||||
return err("too many synthetic input events are queued; wait for the current input to be processed");
|
||||
}
|
||||
match req.method.as_str() {
|
||||
"engine.execute" | "ui.menu.invoke" => {
|
||||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||||
@@ -293,8 +299,48 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
}
|
||||
// the Type tool's text editing state: {"clip", "layer", "caret", "anchor"} (byte offsets) or null
|
||||
if let Some(v) = p.get("gfxEdit") {
|
||||
match serde_json::from_value(v.clone()) {
|
||||
Ok(e) => app.ui.gfx_edit = e,
|
||||
match serde_json::from_value::<Option<crate::state::GfxEdit>>(v.clone()) {
|
||||
Ok(mut e) => {
|
||||
// An explicit `ui.set.gfxEdit {clip, layer}` names the layer currently at
|
||||
// that index. Resolve its identity from the document instead of inheriting
|
||||
// a stale uid/fingerprint from a previous edit session: after a reorder,
|
||||
// delete or undo the requested index may now hold a different layer.
|
||||
if let Some(new) = e.as_mut() {
|
||||
match crate::panels::graphics::current_layer_identity(&app.session, new.clip, new.layer) {
|
||||
Some((uid, fingerprint)) => {
|
||||
new.uid = uid;
|
||||
new.fingerprint = fingerprint;
|
||||
}
|
||||
None => {
|
||||
new.uid = 0;
|
||||
new.fingerprint = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let changed = match (&app.ui.gfx_edit, &e) {
|
||||
(None, None) => false,
|
||||
(Some(old), Some(new)) => {
|
||||
// A stable uid is the owner key. The compact fingerprint only
|
||||
// decides identity for a uid-less layer, and zero there means
|
||||
// "the requested index" rather than a different owner.
|
||||
old.clip != new.clip
|
||||
|| old.layer != new.layer
|
||||
|| old.uid != new.uid
|
||||
|| (new.uid == 0 && old.fingerprint != 0 && old.fingerprint != new.fingerprint)
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
if changed {
|
||||
app.restart_ime_target();
|
||||
} else if let (Some(old), Some(new)) = (&app.ui.gfx_edit, &e)
|
||||
&& (old.caret, old.anchor) != (new.caret, new.anchor)
|
||||
{
|
||||
// Caret is not part of the stable owner. Only a known composition
|
||||
// becomes stale; commit-only input without one remains eligible.
|
||||
app.invalidate_ime_composition();
|
||||
}
|
||||
app.ui.gfx_edit = e;
|
||||
}
|
||||
Err(e) => return err(format!("`gfxEdit`: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -391,6 +437,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
delta: egui::vec2(dx, dy),
|
||||
modifiers: modifiers(p),
|
||||
phase: egui::TouchPhase::Move,
|
||||
@@ -444,8 +491,14 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
ok(json!({"playing": app.playback.playing, "playhead": app.session.playhead().0}))
|
||||
}
|
||||
"ui.screenshot" => {
|
||||
let crop = s("panel").and_then(PanelKind::from_name).and_then(|pk| app.auto.find(&format!("panel.{}", pk.id())).map(|e| e.rect));
|
||||
let crop = crop.or_else(|| s("id").and_then(|id| app.auto.find(id).map(|e| e.rect)));
|
||||
// Keep the target symbolic: the crop must come from the layout that is actually
|
||||
// captured, not from whatever the registry held when this control request arrived.
|
||||
let crop = if let Some(panel) = s("panel") {
|
||||
let Some(pk) = PanelKind::from_name(panel) else { return err(format!("unknown panel `{panel}`")) };
|
||||
Some(crate::capture::CropTarget::Element(format!("panel.{}", pk.id())))
|
||||
} else {
|
||||
s("id").map(|id| crate::capture::CropTarget::Element(id.to_string()))
|
||||
};
|
||||
// Bring the window on screen so a frame is presented, without taking keyboard focus.
|
||||
app.raise_for_control(ctx);
|
||||
Outcome::Screenshot { path: s("path").map(str::to_string), crop }
|
||||
@@ -512,7 +565,7 @@ fn merge(v: &mut Value, patch: &Value) {
|
||||
pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value {
|
||||
let size = ctx.content_rect().size();
|
||||
let keyboard_focus = ctx.memory(|memory| memory.focused()).map(|id| {
|
||||
let elements: Vec<_> = app.auto.previous.iter().filter(|element| element.response_id == Some(id)).collect();
|
||||
let elements: Vec<_> = app.auto.all().iter().filter(|element| element.response_id == Some(id)).collect();
|
||||
json!({
|
||||
"widgetId": format!("{:016x}", id.value()),
|
||||
"elementIds": elements.iter().map(|element| element.id.as_str()).collect::<Vec<_>>(),
|
||||
@@ -533,24 +586,53 @@ pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value {
|
||||
"playhead": app.session.playhead().0,
|
||||
"activeSequence": app.session.state.active_sequence.map(|i| i.0),
|
||||
"selection": app.session.state.selection.iter().map(|c| c.0).collect::<Vec<_>>(),
|
||||
"elements": app.auto.previous.len(),
|
||||
"elements": app.auto.all().len(),
|
||||
"dialog": app.dialog.map(|d| format!("{d:?}")),
|
||||
})
|
||||
}
|
||||
|
||||
/// Save a screenshot (optionally cropped to a rect in points) as PNG; returns the reply JSON.
|
||||
pub fn save_screenshot(ctx: &egui::Context, image: &egui::ColorImage, path: Option<&str>, crop: Option<[f32; 4]>) -> Value {
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let [w, h] = image.size;
|
||||
let (x0, y0, cw, ch) = match crop {
|
||||
Some([x, y, cw, ch]) => {
|
||||
let x0 = ((x * ppp) as usize).min(w);
|
||||
let y0 = ((y * ppp) as usize).min(h);
|
||||
(x0, y0, ((cw * ppp) as usize).min(w - x0), ((ch * ppp) as usize).min(h - y0))
|
||||
}
|
||||
None => (0, 0, w, h),
|
||||
save_screenshot_with_ppp(image, path, crop, ctx.pixels_per_point())
|
||||
}
|
||||
|
||||
/// Intersect a crop rectangle in points with the captured image, converting through `ppp`.
|
||||
///
|
||||
/// Negative origins are clipped to the image rather than saturated while keeping the requested
|
||||
/// width, which would shift the captured region. A crop that does not overlap the image (or has a
|
||||
/// non-finite or non-positive size) is refused instead of returning a zero-sized or wrong image.
|
||||
fn crop_pixels(size: [usize; 2], crop: Option<[f32; 4]>, ppp: f32) -> Result<(usize, usize, usize, usize), String> {
|
||||
let [w, h] = size;
|
||||
let Some([x, y, cw, ch]) = crop else { return Ok((0, 0, w, h)) };
|
||||
if !(x.is_finite() && y.is_finite() && cw.is_finite() && ch.is_finite()) || cw <= 0.0 || ch <= 0.0 {
|
||||
return Err("screenshot crop must be a finite rectangle with positive width and height".into());
|
||||
}
|
||||
let ppp = f64::from(ppp);
|
||||
let clamp = |v: f64, max: usize| v.max(0.0).min(max as f64);
|
||||
let x0 = clamp(f64::from(x) * ppp, w).floor() as usize;
|
||||
let y0 = clamp(f64::from(y) * ppp, h).floor() as usize;
|
||||
let x1 = clamp((f64::from(x) + f64::from(cw)) * ppp, w).ceil() as usize;
|
||||
let y1 = clamp((f64::from(y) + f64::from(ch)) * ppp, h).ceil() as usize;
|
||||
let (cw, ch) = (x1.saturating_sub(x0), y1.saturating_sub(y0));
|
||||
if cw == 0 || ch == 0 {
|
||||
return Err("screenshot crop does not intersect the image".into());
|
||||
}
|
||||
Ok((x0, y0, cw, ch))
|
||||
}
|
||||
|
||||
/// Save a screenshot whose scale was captured with the request. Hidden logic ticks must not read
|
||||
/// `Context::pixels_per_point()` for an image produced from a different UI pass.
|
||||
pub fn save_screenshot_with_ppp(image: &egui::ColorImage, path: Option<&str>, crop: Option<[f32; 4]>, ppp: f32) -> Value {
|
||||
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
|
||||
let (x0, y0, cw, ch) = match crop_pixels(image.size, crop, ppp) {
|
||||
Ok(rect) => rect,
|
||||
Err(e) => return json!({"ok": false, "error": e}),
|
||||
};
|
||||
let mut rgba = Vec::with_capacity(cw * ch * 4);
|
||||
let [w, _] = image.size;
|
||||
let Some(capacity) = cw.checked_mul(ch).and_then(|pixels| pixels.checked_mul(std::mem::size_of::<egui::Color32>())) else {
|
||||
return json!({"ok": false, "error": "screenshot crop size overflows the image buffer"});
|
||||
};
|
||||
let mut rgba = Vec::with_capacity(capacity);
|
||||
for y in y0..y0 + ch {
|
||||
for x in x0..x0 + cw {
|
||||
let c = image.pixels[y * w + x];
|
||||
@@ -611,7 +693,12 @@ pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
!c
|
||||
}
|
||||
let mut raw = Vec::with_capacity((w as usize * 4 + 1) * h as usize);
|
||||
let row = (w as usize)
|
||||
.checked_mul(4)
|
||||
.and_then(|bytes| bytes.checked_add(1))
|
||||
.and_then(|bytes| bytes.checked_mul(h as usize))
|
||||
.ok_or_else(|| "PNG size overflows the image buffer".to_string())?;
|
||||
let mut raw = Vec::with_capacity(row);
|
||||
for y in 0..h as usize {
|
||||
raw.push(0);
|
||||
raw.extend_from_slice(&rgba[y * w as usize * 4..(y + 1) * w as usize * 4]);
|
||||
@@ -653,6 +740,29 @@ mod base64_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_crop_clips_negative_panel_origin() {
|
||||
// A panel starting two points left of / one point above the window: only the visible
|
||||
// 2×3 pixels are captured, not the requested 4×4 starting at (0, 0).
|
||||
assert_eq!(super::crop_pixels([8, 8], Some([-2.0, -1.0, 4.0, 4.0]), 1.0).unwrap(), (0, 0, 2, 3));
|
||||
// The same crop at 2× scale clips the same logical region to the pixel grid.
|
||||
assert_eq!(super::crop_pixels([16, 16], Some([-2.0, -1.0, 4.0, 4.0]), 2.0).unwrap(), (0, 0, 4, 6));
|
||||
// Partly outside on the right/bottom uses the visible remainder.
|
||||
assert_eq!(super::crop_pixels([8, 8], Some([6.0, 5.0, 4.0, 4.0]), 1.0).unwrap(), (6, 5, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_crop_outside_the_image_is_refused() {
|
||||
for crop in [[-8.0, -8.0, 4.0, 4.0], [8.0, 0.0, 4.0, 4.0], [0.0, 9.0, 4.0, 4.0]] {
|
||||
let err = super::crop_pixels([8, 8], Some(crop), 1.0).expect_err("a crop outside the image must be refused");
|
||||
assert!(err.contains("does not intersect"), "{err}");
|
||||
}
|
||||
for crop in [[0.0, 0.0, 0.0, 4.0], [0.0, 0.0, -4.0, 4.0], [f32::NAN, 0.0, 4.0, 4.0], [0.0, 0.0, f32::INFINITY, 4.0]] {
|
||||
let err = super::crop_pixels([8, 8], Some(crop), 1.0).expect_err("an invalid crop must be refused");
|
||||
assert!(err.contains("finite rectangle"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_event_repetition_is_bounded() {
|
||||
assert_eq!(super::bounded_repetitions(&json!({}), "count", 1, 0, super::MAX_CLICK_COUNT).unwrap(), 1);
|
||||
|
||||
@@ -231,7 +231,7 @@ const LIST_HEIGHT: f32 = 360.0;
|
||||
/// `about.credits.reverse`, `about.credits.grabBag`, `about.credits.table`,
|
||||
/// `about.credits.header.<n>` (table headers, by column).
|
||||
pub fn contributors_ui(ui: &mut egui::Ui, t: &Tokens, auto: &mut Registry) {
|
||||
let id = egui::Id::new("credits_view");
|
||||
let id = egui::Id::unique("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(tl!("Show"));
|
||||
|
||||
+15
-14
@@ -545,10 +545,11 @@ fn layout_inner(
|
||||
DockNode::Split { vertical, size, a, b } => {
|
||||
let axis = if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal };
|
||||
let Some(split) = split_rect(rect, axis, size.shared(), t.gap, 20.0) else { return };
|
||||
let id = egui::Id::new(("dock-gutter", path.to_string()));
|
||||
let id = egui::Id::unique(("dock-gutter", path.to_string()));
|
||||
let handle_style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus };
|
||||
let label = if *vertical { tl!("Height:") } else { tl!("Width:") };
|
||||
let response = ResizeHandle::new(id, axis, split.divider, label, handle_style).value(split.first_size(), 0.0..=split.available()).show(ui);
|
||||
response.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Splitter, response.response.enabled(), label));
|
||||
reg.add_response(&format!("dock.gutter.{path}"), &response.response, "gutter");
|
||||
if response.delta != 0.0
|
||||
&& let Some(resized) = split.resized(size.shared(), response.delta, 40.0)
|
||||
@@ -603,7 +604,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t
|
||||
// separately reorder open sequences.
|
||||
if let Some(panel) = active_panel {
|
||||
let grip = Rect::from_min_size(g.rect.min, vec2(if compact { g.rect.width() } else { 10.0 }, if compact { 8.0 } else { t.tab_h }));
|
||||
let response = ui.interact(grip, egui::Id::new(("panel-drag", g.path.clone())), Sense::drag());
|
||||
let response = ui.interact(grip, egui::Id::unique(("panel-drag", g.path.clone())), Sense::drag());
|
||||
reg.add_response(&format!("panel.drag.{}", panel.id()), &response, "Move panel");
|
||||
if response.drag_started() {
|
||||
actions.push(DockAction::BeginDrag(panel));
|
||||
@@ -664,13 +665,13 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t
|
||||
while last + 1 < tabs.len() && span(first, last + 1) <= room {
|
||||
last += 1;
|
||||
}
|
||||
let list_id = egui::Id::new(("tab-list", g.path.clone()));
|
||||
let list_id = egui::Id::unique(("tab-list", g.path.clone()));
|
||||
let more = (!all_fit).then(|| Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0)));
|
||||
let mut list_opened = false;
|
||||
if let Some(r) = more {
|
||||
let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("tab-overflow", g.path.clone())), Sense::click());
|
||||
let name = if g.panels.contains(&PanelKind::Timeline) { "timeline.tabs.more".to_string() } else { format!("panel.tabs.more.{}", g.path) };
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), "more tabs"));
|
||||
reg.add_response(&name, &resp, "more tabs");
|
||||
let c = if resp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, c);
|
||||
@@ -684,7 +685,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t
|
||||
}
|
||||
// The wheel over the strip steps through the tabs: down = next, up = previous, one tab
|
||||
// per wheel notch (trackpad scrolling adds up to a notch first), stopping at the ends.
|
||||
let wheel_id = egui::Id::new(("tab-wheel", g.path.clone()));
|
||||
let wheel_id = egui::Id::unique(("tab-wheel", g.path.clone()));
|
||||
if ui.rect_contains_pointer(strip) && !tabs.is_empty() {
|
||||
const NOTCH: f32 = 40.0;
|
||||
let total = ui.ctx().data(|d| d.get_temp::<f32>(wheel_id)).unwrap_or(0.0) + crate::panels::timeline::wheel_input(ui.ctx()).delta.y;
|
||||
@@ -711,8 +712,8 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t
|
||||
let tab_rect = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h));
|
||||
// a sequence tab keeps its identity when the tabs are reordered under the pointer
|
||||
let resp = match tab.seq {
|
||||
Some(id) => ui.interact(tab_rect, egui::Id::new(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()),
|
||||
None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag()),
|
||||
Some(id) => ui.interact(tab_rect, egui::Id::unique(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()),
|
||||
None => ui.interact(tab_rect, egui::Id::unique(("tab", g.path.clone(), i)), Sense::click_and_drag()),
|
||||
};
|
||||
if tab.seq.is_none() && resp.drag_started() {
|
||||
actions.push(DockAction::BeginDrag(*p));
|
||||
@@ -722,7 +723,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t
|
||||
} else {
|
||||
tab_rects.push((*p, tab_rect));
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, &tab.title));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Tab, resp.enabled(), is_active, &tab.title));
|
||||
ui.ctx().accesskit_node_builder(resp.id, |node| {
|
||||
node.set_role(egui::accesskit::Role::Tab);
|
||||
node.set_selected(is_active);
|
||||
@@ -734,7 +735,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t
|
||||
dragged = Some(id);
|
||||
}
|
||||
// Escape puts a dragged tab back where it was (egui ends the drag on Escape, #580)
|
||||
let home = egui::Id::new(("tab-seq-home", id));
|
||||
let home = egui::Id::unique(("tab-seq-home", id));
|
||||
if resp.drag_started()
|
||||
&& let Some(at) = seqs.open.iter().position(|(s, _)| *s == id)
|
||||
{
|
||||
@@ -761,8 +762,8 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t
|
||||
if let Some(id) = tab.seq.filter(|_| is_active) {
|
||||
// × closes the sequence's tab (the sequence stays in the project)
|
||||
let cr = Rect::from_center_size(pos2(tab_rect.min.x + 10.0, text_y), vec2(8.0, 8.0));
|
||||
let cresp = ui.interact(cr.expand(4.0), egui::Id::new(("tab-close", g.path.clone(), id)), Sense::click());
|
||||
cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, cresp.enabled(), "close sequence"));
|
||||
let cresp = ui.interact(cr.expand(4.0), egui::Id::unique(("tab-close", g.path.clone(), id)), Sense::click());
|
||||
cresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, cresp.enabled(), "close sequence"));
|
||||
reg.add_response(&format!("timeline.tab.{id}.close"), &cresp, "close sequence");
|
||||
let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
painter.line_segment([cr.left_top(), cr.right_bottom()], Stroke::new(1.2, cc));
|
||||
@@ -775,8 +776,8 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t
|
||||
}
|
||||
if is_active {
|
||||
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());
|
||||
mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, mresp.enabled(), "panel menu"));
|
||||
let mresp = ui.interact(mr.expand(3.0), egui::Id::unique(("tab-menu", g.path.clone())), Sense::click());
|
||||
mresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, mresp.enabled(), "panel menu"));
|
||||
reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu");
|
||||
let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
icons::paint(&painter, Rect::from_center_size(mr.center(), vec2(16.0, 16.0)), Icon::Hamburger, mc);
|
||||
|
||||
@@ -18,7 +18,7 @@ fn floating_rect(group: &dock::Floating, screen: Rect) -> Rect {
|
||||
}
|
||||
|
||||
fn drag_id() -> egui::Id {
|
||||
egui::Id::new("dock-panel-drag")
|
||||
egui::Id::unique("dock-panel-drag")
|
||||
}
|
||||
|
||||
pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
@@ -179,7 +179,7 @@ impl FilmcraftApp {
|
||||
.get(*index)
|
||||
.and_then(|group| group.panels.first())
|
||||
.and_then(|panel| {
|
||||
let id = egui::Id::new(("floating-panel", panel.id()));
|
||||
let id = egui::Id::unique(("floating-panel", panel.id()));
|
||||
layers.iter().position(|layer| layer.id == id)
|
||||
})
|
||||
.unwrap_or(usize::MAX)
|
||||
@@ -193,7 +193,7 @@ impl FilmcraftApp {
|
||||
if let Some(panel) = self.pending_panel_raise.take()
|
||||
&& let Some(owner) = self.ui.floating.iter().find(|group| group.panels.contains(&panel)).and_then(|group| group.panels.first())
|
||||
{
|
||||
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", owner.id()))));
|
||||
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::unique(("floating-panel", owner.id()))));
|
||||
}
|
||||
let maximized =
|
||||
self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel)));
|
||||
@@ -282,15 +282,14 @@ impl FilmcraftApp {
|
||||
DockAction::PanelMenu(panel, pos) => {
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
ctx.data_mut(|data| {
|
||||
data.insert_temp(egui::Id::new("panel-menu"), (panel, pos));
|
||||
data.insert_temp(egui::Id::new("panel-menu-opened"), frame);
|
||||
data.insert_temp(egui::Id::unique("panel-menu"), (panel, pos));
|
||||
data.insert_temp(egui::Id::unique("panel-menu-opened"), frame);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.panel_drag(&ctx, body, &targets);
|
||||
panels::panel_menu_popup(self, ui);
|
||||
self.cancel_value_drag(ui.ctx());
|
||||
}
|
||||
|
||||
fn floating_group(&mut self, ctx: &egui::Context, index: usize, seqs: &dock::SeqTabs, actions: &mut Vec<DockAction>) -> Option<DropTarget<PanelKind>> {
|
||||
@@ -298,7 +297,7 @@ impl FilmcraftApp {
|
||||
let panel = *floating.panels.first()?;
|
||||
let rect = floating_rect(&floating, ctx.content_rect());
|
||||
let t = self.tokens;
|
||||
let id = egui::Id::new(("floating-panel", panel.id()));
|
||||
let id = egui::Id::unique(("floating-panel", panel.id()));
|
||||
let mut output = None;
|
||||
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).constrain(false).interactable(true).show(ctx, |ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(rect.size(), Sense::hover());
|
||||
@@ -323,7 +322,7 @@ impl FilmcraftApp {
|
||||
ui.painter().rect_stroke(rect, t.radius, Stroke::new(1.0, t.focus), StrokeKind::Outside);
|
||||
let grip = Rect::from_min_max(rect.max - vec2(14.0, 14.0), rect.max);
|
||||
let response = ui.interact(grip, id.with("resize"), Sense::click_and_drag());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), tl!("Resize floating panel")));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, response.enabled(), tl!("Resize floating panel")));
|
||||
self.auto.add_response(&format!("panel.float.{index}.resize"), &response, "Resize floating panel");
|
||||
for step in 0..3 {
|
||||
let offset = 4.0 + step as f32 * 4.0;
|
||||
@@ -361,7 +360,7 @@ impl FilmcraftApp {
|
||||
let force_float = ctx.input(|input| input.modifiers.command);
|
||||
let edge = (!force_float).then(|| workspace_edge(body, pos, targets)).flatten();
|
||||
let proposal = (!force_float && edge.is_none()).then(|| resolve_drop(&panel, pos, targets)).flatten();
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop")));
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("dock-drop")));
|
||||
let t = self.tokens;
|
||||
if let Some((_, _, preview)) = edge {
|
||||
painter.rect_filled(preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2));
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(crate) fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let title = tlf!("Safe graphics mode: {mode}", mode = i18n::t(¬ice.mode));
|
||||
let (mut reset, mut close) = (false, false);
|
||||
let shown = egui::Window::new("graphics-notice")
|
||||
.id(egui::Id::new("graphics-notice"))
|
||||
.id(egui::Id::unique("graphics-notice"))
|
||||
.title_bar(false)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
|
||||
@@ -15,7 +15,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
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::BLACK));
|
||||
let drag = ui.interact(rect, egui::Id::new("header-drag"), Sense::click_and_drag());
|
||||
let drag = ui.interact(rect, egui::Id::unique("header-drag"), Sense::click_and_drag());
|
||||
if drag.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
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(tl!("Home"));
|
||||
let hresp = ui.interact(home, egui::Id::unique("hdr-home"), Sense::click()).on_hover_text(tl!("Home"));
|
||||
app.auto.add("header.home", home, "Home");
|
||||
if hresp.hovered() {
|
||||
p.rect_filled(home, 4.0, t.hover);
|
||||
@@ -41,7 +41,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let active = app.ui.mode == m;
|
||||
let galley = p.layout_no_wrap(shown.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());
|
||||
let resp = ui.interact(r, egui::Id::unique(("mode", label)), Sense::click());
|
||||
app.auto.add(&format!("header.mode.{}", label.to_ascii_lowercase()), r, label);
|
||||
let col = if active || resp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
let gw = galley.size().x;
|
||||
@@ -72,7 +72,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
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);
|
||||
let resp = ui.interact(r, egui::Id::unique(("hdr", id)), Sense::click()).on_hover_text(tip);
|
||||
app.auto.add(&format!("header.{id}"), r, tip);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
@@ -97,7 +97,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let qx = rect.max.x - 14.0 - 4.0 * 38.0; // the fifth button from the right
|
||||
if btn(ui, Icon::Export, "quickExport", tl!("Quick Export"), app).clicked() {
|
||||
app.ui.export.quick_open = !app.ui.export.quick_open;
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("quick-export-toggled"), true));
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("quick-export-toggled"), true));
|
||||
}
|
||||
crate::panels::export_mode::quick_export(app, ui.ctx(), pos2(qx - 340.0, rect.max.y + 4.0));
|
||||
// Appearance Mode: click for the next one (Auto, Light, Dark); the icon shows the current one.
|
||||
@@ -117,7 +117,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let ws = crate::i18n::t(&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());
|
||||
let wresp = ui.interact(wr, egui::Id::unique("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 });
|
||||
// Community: a labelled Discord button, always one click away.
|
||||
@@ -126,7 +126,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let g = p.layout_no_wrap(label.to_string(), Tokens::ui(12.0), Color32::WHITE);
|
||||
let w = g.size().x + 34.0;
|
||||
let r = Rect::from_min_size(pos2(wr.min.x - w - 14.0, rect.center().y - 12.0), vec2(w, 24.0));
|
||||
let resp = ui.interact(r, egui::Id::new("hdr-discord"), Sense::click()).on_hover_text(tl!("Join the ArtCraft Discord (discord.gg/artcraft)"));
|
||||
let resp = ui.interact(r, egui::Id::unique("hdr-discord"), Sense::click()).on_hover_text(tl!("Join the ArtCraft Discord (discord.gg/artcraft)"));
|
||||
app.auto.add("header.discord", r, "Join the ArtCraft Discord");
|
||||
ui.painter().rect_filled(r, 12.0, if resp.hovered() { t.accent_hover } else { t.accent });
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE);
|
||||
@@ -152,7 +152,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
crate::links::open(app, ui.ctx(), crate::links::DISCORD);
|
||||
}
|
||||
}
|
||||
let popup_id = egui::Id::new("workspaces-popup");
|
||||
let popup_id = egui::Id::unique("workspaces-popup");
|
||||
if ws_resp.clicked() || wresp.clicked() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(popup_id, true));
|
||||
}
|
||||
|
||||
@@ -3469,8 +3469,8 @@
|
||||
Commits Commits
|
||||
% of all commits % aller Commits
|
||||
Lines +/− Zeilen +/−
|
||||
Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Audioausgabe fehlgeschlagen: Wiedergabe ohne Ton (prüfe Voreinstellungen ▸ Audiohardware)
|
||||
Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) Audioausgabe reagiert nicht: Wiedergabe ohne Ton (prüfe Voreinstellungen ▸ Audiohardware)
|
||||
Audio output failed: playing without sound (check Settings > Audio Hardware) Audioausgabe fehlgeschlagen: Wiedergabe ohne Ton (prüfe Voreinstellungen > Audiohardware)
|
||||
Audio output is not responding: playing without sound (check Settings > Audio Hardware) Audioausgabe reagiert nicht: Wiedergabe ohne Ton (prüfe Voreinstellungen > Audiohardware)
|
||||
Clip Speed / Duration Clipgeschwindigkeit/-dauer
|
||||
Speed: Geschwindigkeit:
|
||||
Reverse Speed Rückwärts abspielen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 45.
|
@@ -3458,8 +3458,8 @@
|
||||
Commits Commits
|
||||
% of all commits % de todos los commits
|
||||
Lines +/− Líneas +/−
|
||||
Audio output failed: playing without sound (check Settings ▸ Audio Hardware) La salida de audio ha fallado: se reproduce sin sonido (revisa Ajustes ▸ Hardware de audio)
|
||||
Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) La salida de audio no responde: se reproduce sin sonido (revisa Ajustes ▸ Hardware de audio)
|
||||
Audio output failed: playing without sound (check Settings > Audio Hardware) La salida de audio ha fallado: se reproduce sin sonido (revisa Ajustes > Hardware de audio)
|
||||
Audio output is not responding: playing without sound (check Settings > Audio Hardware) La salida de audio no responde: se reproduce sin sonido (revisa Ajustes > Hardware de audio)
|
||||
Clip Speed / Duration Velocidad/duración del clip
|
||||
Speed: Velocidad:
|
||||
Reverse Speed Invertir velocidad
|
||||
@@ -3611,7 +3611,7 @@
|
||||
Shows the version, community links and credits in a FilmCraft window. Muestra la versión, los enlaces de la comunidad y los créditos en una ventana de FilmCraft.
|
||||
Couldn't open your web browser No se pudo abrir el navegador web
|
||||
Open this address in your browser yourself: Abre esta dirección tú mismo en el navegador:
|
||||
# View ▸ Reset Interface Zoom (#2253).
|
||||
# View > Reset Interface Zoom (#2253).
|
||||
Reset Interface Zoom Restablecer zoom de la interfaz
|
||||
Track Color Color de pista
|
||||
Open a sequence to search its graphics Abre una secuencia para buscar en sus gráficos
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -3437,8 +3437,8 @@
|
||||
Commits Commits
|
||||
% of all commits % de tous les commits
|
||||
Lines +/− Lignes +/−
|
||||
Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Échec de la sortie audio : lecture sans son (vérifiez Préférences ▸ Matériel audio)
|
||||
Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) La sortie audio ne répond pas : lecture sans son (vérifiez Préférences ▸ Matériel audio)
|
||||
Audio output failed: playing without sound (check Settings > Audio Hardware) Échec de la sortie audio : lecture sans son (vérifiez Préférences > Matériel audio)
|
||||
Audio output is not responding: playing without sound (check Settings > Audio Hardware) La sortie audio ne répond pas : lecture sans son (vérifiez Préférences > Matériel audio)
|
||||
Clip Speed / Duration Vitesse/durée du clip
|
||||
Speed: Vitesse :
|
||||
Reverse Speed Vitesse inversée
|
||||
@@ -3578,7 +3578,7 @@
|
||||
Export the labels to a preset file… Exporter les étiquettes dans un fichier de paramètre prédéfini…
|
||||
Show the presets folder Afficher le dossier des paramètres prédéfinis
|
||||
Delete this preset Supprimer ce paramètre prédéfini
|
||||
# View ▸ Reset Interface Zoom (#2253).
|
||||
# View > Reset Interface Zoom (#2253).
|
||||
Reset Interface Zoom Réinitialiser le zoom de l'interface
|
||||
Track Color Couleur de la piste
|
||||
Open a sequence to search its graphics Ouvrez une séquence pour rechercher dans ses graphiques
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 83.
|
@@ -3454,8 +3454,8 @@
|
||||
Commits Commit
|
||||
% of all commits % dari semua commit
|
||||
Lines +/− Baris +/−
|
||||
Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Output audio gagal: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio)
|
||||
Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) Output audio tidak merespons: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio)
|
||||
Audio output failed: playing without sound (check Settings > Audio Hardware) Output audio gagal: memutar tanpa suara (periksa Pengaturan > Perangkat Keras Audio)
|
||||
Audio output is not responding: playing without sound (check Settings > Audio Hardware) Output audio tidak merespons: memutar tanpa suara (periksa Pengaturan > Perangkat Keras Audio)
|
||||
Clip Speed / Duration Kecepatan / Durasi Klip
|
||||
Speed: Kecepatan:
|
||||
Reverse Speed Balik Kecepatan
|
||||
@@ -3576,7 +3576,7 @@
|
||||
Appearance Settings… Pengaturan Tampilan…
|
||||
Add to favorites Tambahkan ke favorit
|
||||
Remove from favorites Hapus dari favorit
|
||||
# View ▸ Reset Interface Zoom (#2253).
|
||||
# View > Reset Interface Zoom (#2253).
|
||||
Reset Interface Zoom Atur Ulang Zoom Antarmuka
|
||||
Track Color Warna Trek
|
||||
Open a sequence to search its graphics Buka sekuens untuk mencari di grafisnya
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 14.
|
@@ -3447,8 +3447,8 @@
|
||||
Commits Commit
|
||||
% of all commits % di tutti i commit
|
||||
Lines +/− Righe +/−
|
||||
Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Uscita audio non riuscita: riproduzione senza audio (controlla Impostazioni ▸ Hardware audio)
|
||||
Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) L'uscita audio non risponde: riproduzione senza audio (controlla Impostazioni ▸ Hardware audio)
|
||||
Audio output failed: playing without sound (check Settings > Audio Hardware) Uscita audio non riuscita: riproduzione senza audio (controlla Impostazioni > Hardware audio)
|
||||
Audio output is not responding: playing without sound (check Settings > Audio Hardware) L'uscita audio non risponde: riproduzione senza audio (controlla Impostazioni > Hardware audio)
|
||||
Clip Speed / Duration Velocità / durata clip
|
||||
Speed: Velocità:
|
||||
Reverse Speed Velocità inversa
|
||||
@@ -3602,7 +3602,7 @@
|
||||
Shows the version, community links and credits in a FilmCraft window. Mostra la versione, i link della community e i riconoscimenti in una finestra di FilmCraft.
|
||||
Couldn't open your web browser Impossibile aprire il browser web
|
||||
Open this address in your browser yourself: Apri tu questo indirizzo nel browser:
|
||||
# View ▸ Reset Interface Zoom (#2253).
|
||||
# View > Reset Interface Zoom (#2253).
|
||||
Reset Interface Zoom Reimposta zoom dell'interfaccia
|
||||
Track Color Colore traccia
|
||||
Open a sequence to search its graphics Apri una sequenza per cercare nelle sue grafiche
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
@@ -3443,8 +3443,8 @@
|
||||
Commits コミット
|
||||
% of all commits 全コミットに占める割合 (%)
|
||||
Lines +/− 行数 +/−
|
||||
Audio output failed: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力に失敗したため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください)
|
||||
Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力が応答しないため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください)
|
||||
Audio output failed: playing without sound (check Settings > Audio Hardware) オーディオ出力に失敗したため、無音で再生しています (「環境設定 > オーディオハードウェア」を確認してください)
|
||||
Audio output is not responding: playing without sound (check Settings > Audio Hardware) オーディオ出力が応答しないため、無音で再生しています (「環境設定 > オーディオハードウェア」を確認してください)
|
||||
Clip Speed / Duration クリップの速度 / 長さ
|
||||
Speed: 速度:
|
||||
Reverse Speed 逆再生する
|
||||
@@ -3610,7 +3610,7 @@
|
||||
Shows the version, community links and credits in a FilmCraft window. バージョン、コミュニティへのリンク、クレジットを FilmCraft のウィンドウに表示します。
|
||||
Couldn't open your web browser ウェブブラウザーを開けませんでした
|
||||
Open this address in your browser yourself: このアドレスをご自身でブラウザーで開いてください:
|
||||
# View ▸ Reset Interface Zoom (#2253).
|
||||
# View > Reset Interface Zoom (#2253).
|
||||
Reset Interface Zoom インターフェイスのズームをリセット
|
||||
Track Color トラックカラー
|
||||
Open a sequence to search its graphics シーケンスを開くとグラフィックスを検索できます
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 59.
|
@@ -272,8 +272,8 @@
|
||||
Audio Options 오디오 옵션
|
||||
Audio Out 오디오 출력
|
||||
Audio Out Point 오디오 끝 표시(Out)
|
||||
Audio output failed: playing without sound (check Settings ▸ Audio Hardware) 오디오 출력 실패: 소리 없이 재생 중 (설정 ▸ 오디오 하드웨어 확인)
|
||||
Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) 오디오 출력 응답 없음: 소리 없이 재생 중 (설정 ▸ 오디오 하드웨어 확인)
|
||||
Audio output failed: playing without sound (check Settings > Audio Hardware) 오디오 출력 실패: 소리 없이 재생 중 (설정 > 오디오 하드웨어 확인)
|
||||
Audio output is not responding: playing without sound (check Settings > Audio Hardware) 오디오 출력 응답 없음: 소리 없이 재생 중 (설정 > 오디오 하드웨어 확인)
|
||||
Audio Previews: 오디오 미리 보기:
|
||||
Audio Samples 오디오 샘플
|
||||
Audio Time Units 오디오 시간 단위
|
||||
@@ -3597,7 +3597,7 @@
|
||||
Shows the version, community links and credits in a FilmCraft window. FilmCraft 창에 버전, 커뮤니티 링크, 제작진을 보여 줍니다.
|
||||
Couldn't open your web browser 웹 브라우저를 열 수 없습니다
|
||||
Open this address in your browser yourself: 이 주소를 브라우저에서 직접 여세요:
|
||||
# View ▸ Reset Interface Zoom (#2253).
|
||||
# View > Reset Interface Zoom (#2253).
|
||||
Reset Interface Zoom 인터페이스 확대/축소 재설정
|
||||
Track Color 트랙 색상
|
||||
Open a sequence to search its graphics 그래픽을 검색하려면 시퀀스를 여세요
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2815 and column 7.
|
@@ -66,6 +66,25 @@ static ITALIAN: OnceLock<Catalog> = OnceLock::new();
|
||||
impl Language {
|
||||
pub const ALL: [Self; 12] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr, Self::Id, Self::Ko, Self::It];
|
||||
|
||||
/// BCP 47 tag for AccessKit and the web IME's hidden input. Keep the persisted spelling
|
||||
/// (`pt-br`, `zh-cn`) out of the protocol-facing value.
|
||||
pub fn bcp47(self) -> &'static str {
|
||||
match self {
|
||||
Self::En => "en",
|
||||
Self::Ja => "ja",
|
||||
Self::Es => "es",
|
||||
Self::PtBr => "pt-BR",
|
||||
Self::Uk => "uk",
|
||||
Self::ZhCn => "zh-CN",
|
||||
Self::De => "de",
|
||||
Self::Ru => "ru",
|
||||
Self::Fr => "fr",
|
||||
Self::Id => "id",
|
||||
Self::It => "it",
|
||||
Self::Ko => "ko",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::En => "English",
|
||||
@@ -289,7 +308,7 @@ pub fn system_japanese_font() -> Option<Arc<egui::FontData>> {
|
||||
// kept for the life of the process (one font, read once) so installing it again after a
|
||||
// theme change shares the bytes instead of copying the whole file
|
||||
let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice());
|
||||
Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() }))
|
||||
Some(Arc::new(egui::FontData::from_blob(Arc::new(bytes), face.info.index)))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
@@ -1104,3 +1123,19 @@ mod tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bcp47_tests {
|
||||
use super::Language;
|
||||
|
||||
#[test]
|
||||
fn accesskit_tags_use_protocol_spelling() {
|
||||
assert_eq!(Language::En.bcp47(), "en");
|
||||
assert_eq!(Language::PtBr.bcp47(), "pt-BR");
|
||||
assert_eq!(Language::ZhCn.bcp47(), "zh-CN");
|
||||
for language in Language::ALL {
|
||||
let tag = language.bcp47();
|
||||
assert!(!tag.is_empty() && tag.is_ascii(), "{tag:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3451,8 +3451,8 @@
|
||||
Commits Commits
|
||||
% of all commits % de todos os commits
|
||||
Lines +/− Linhas +/−
|
||||
Audio output failed: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio falhou: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio)
|
||||
Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio não está respondendo: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio)
|
||||
Audio output failed: playing without sound (check Settings > Audio Hardware) A saída de áudio falhou: reproduzindo sem som (verifique Preferências > Hardware de áudio)
|
||||
Audio output is not responding: playing without sound (check Settings > Audio Hardware) A saída de áudio não está respondendo: reproduzindo sem som (verifique Preferências > Hardware de áudio)
|
||||
Clip Speed / Duration Velocidade/duração do clipe
|
||||
Speed: Velocidade:
|
||||
Reverse Speed Inverter velocidade
|
||||
@@ -3579,7 +3579,7 @@
|
||||
|
||||
# The native macOS application menu (Settings…).
|
||||
Settings Preferências
|
||||
# View ▸ Reset Interface Zoom (#2253).
|
||||
# View > Reset Interface Zoom (#2253).
|
||||
Reset Interface Zoom Redefinir zoom da interface
|
||||
Track Color Cor da trilha
|
||||
Open a sequence to search its graphics Abra uma sequência para pesquisar nos gráficos dela
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 4.
|
@@ -3437,8 +3437,8 @@
|
||||
Commits 提交
|
||||
% of all commits 占全部提交的比例
|
||||
Lines +/− 代码行 +/−
|
||||
Audio output failed: playing without sound (check Settings ▸ Audio Hardware) 音频输出失败:无声播放(请检查设置 ▸ 音频硬件)
|
||||
Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) 音频输出无响应:无声播放(请检查设置 ▸ 音频硬件)
|
||||
Audio output failed: playing without sound (check Settings > Audio Hardware) 音频输出失败:无声播放(请检查设置 > 音频硬件)
|
||||
Audio output is not responding: playing without sound (check Settings > Audio Hardware) 音频输出无响应:无声播放(请检查设置 > 音频硬件)
|
||||
Clip Speed / Duration 剪辑速度 / 时长
|
||||
Speed: 速度:
|
||||
Reverse Speed 反转速度
|
||||
@@ -3586,7 +3586,7 @@
|
||||
Shows the version, community links and credits in a FilmCraft window. 在 FilmCraft 窗口中显示版本、社区链接和致谢。
|
||||
Couldn't open your web browser 无法打开网页浏览器
|
||||
Open this address in your browser yourself: 请自行在浏览器中打开此地址:
|
||||
# View ▸ Reset Interface Zoom (#2253).
|
||||
# View > Reset Interface Zoom (#2253).
|
||||
Reset Interface Zoom 重置界面缩放
|
||||
Track Color 轨道颜色
|
||||
Open a sequence to search its graphics 打开序列以搜索其中的图形
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3 and column 95.
|
+552
-183
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,7 @@ pub fn show_failed(app: &mut crate::FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut close = false;
|
||||
let mut elems: Vec<(&str, egui::Rect, String)> = Vec::new();
|
||||
egui::Window::new(tl!("Couldn't open your web browser"))
|
||||
.id(egui::Id::new("failed-link"))
|
||||
.id(egui::Id::unique("failed-link"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
@@ -97,7 +97,7 @@ pub fn show_failed(app: &mut crate::FilmcraftApp, ctx: &egui::Context) {
|
||||
ui.label(tl!("Open this address in your browser yourself:"));
|
||||
ui.horizontal(|ui| {
|
||||
let mut text = link.url.as_str();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(340.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut text).desired_width(340.0)), "Link address");
|
||||
elems.push(("failedLink.url", r.rect, link.url.clone()));
|
||||
let c = ui.button(tl!("Copy"));
|
||||
elems.push(("failedLink.copy", c.rect, "Copy".into()));
|
||||
|
||||
@@ -641,8 +641,20 @@ fn put_clip_names_on_system_clipboard(app: &FilmcraftApp, ctx: &egui::Context) {
|
||||
/// Paste the files or the image on the system clipboard: placed on the Timeline when it has focus,
|
||||
/// imported into the shown bin in the Project panel. None when the clipboard holds neither.
|
||||
fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option<Result<Value, String>> {
|
||||
let media = (app.hooks.clipboard_media.as_mut()?)()?;
|
||||
if app.stale_clipboard_media.as_ref() == Some(&clipboard_media_key(&media)) {
|
||||
// The paste event already carries the image bytes. Prefer them: re-reading the OS clipboard
|
||||
// is racy (it may have changed) and the web host installs no clipboard-media hook.
|
||||
let event_media = match app.pasted_image.take() {
|
||||
Some(image) => match paste_image_media(&image) {
|
||||
Ok(media) => Some(media),
|
||||
Err(error) => return Some(Err(error)),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let (media, from_event) = match event_media {
|
||||
Some(media) => (media, true),
|
||||
None => ((app.hooks.clipboard_media.as_mut()?)()?, false),
|
||||
};
|
||||
if !from_event && app.stale_clipboard_media.as_ref() == Some(&clipboard_media_key(&media)) {
|
||||
return None;
|
||||
}
|
||||
let paths = match media {
|
||||
@@ -658,6 +670,24 @@ fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option<Result<
|
||||
Some(app.session.execute("edit.pasteMedia", params).map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
/// The RGBA bytes of an image delivered by `egui::Event::PasteImage`.
|
||||
fn paste_image_media(image: &egui::ColorImage) -> Result<crate::ClipboardMedia, String> {
|
||||
let [width, height] = image.size;
|
||||
let len =
|
||||
width.checked_mul(height).and_then(|pixels| pixels.checked_mul(4)).ok_or_else(|| "the pasted image size overflows the clipboard buffer".to_string())?;
|
||||
let mut rgba = Vec::with_capacity(len);
|
||||
for c in &image.pixels {
|
||||
// `Color32` is premultiplied internally; PNG/RgbaImage expect straight alpha. Round-trip
|
||||
// through the straight sRGB form so a semi-transparent clipboard pixel keeps its colour.
|
||||
let [r, g, b, a] = c.to_srgba_unmultiplied();
|
||||
rgba.extend_from_slice(&[r, g, b, a]);
|
||||
}
|
||||
if rgba.len() != len {
|
||||
return Err("the pasted image does not match its reported size".into());
|
||||
}
|
||||
Ok(crate::ClipboardMedia::Image { width, height, rgba })
|
||||
}
|
||||
|
||||
/// A compressed PNG of a clipboard image; an error when its size and pixels disagree.
|
||||
fn encode_png(width: usize, height: usize, rgba: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?);
|
||||
@@ -898,7 +928,7 @@ pub fn external_commands() -> Vec<filmcraft_engine::shortcuts::CommandInfo> {
|
||||
|
||||
/// Draw the in-window menu bar.
|
||||
pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("interface-language"), app.ui.language));
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("interface-language"), app.ui.language));
|
||||
let items = menu_items(app);
|
||||
// Draw Edit ▸ Label with the user's live palette, not the baked-in label colours.
|
||||
let labels = app.session.prefs.labels.clone();
|
||||
@@ -959,7 +989,7 @@ fn menu_level(
|
||||
}
|
||||
subs.push(sub);
|
||||
let inner: Vec<&MenuItem> = items.iter().copied().filter(|x| x.path.get(depth).map(String::as_str) == Some(sub)).collect();
|
||||
let language = ui.ctx().data(|d| d.get_temp::<crate::i18n::Language>(egui::Id::new("interface-language"))).unwrap_or_default();
|
||||
let language = ui.ctx().data(|d| d.get_temp::<crate::i18n::Language>(egui::Id::unique("interface-language"))).unwrap_or_default();
|
||||
ui.menu_button(language.tr(sub), |ui| {
|
||||
ui.set_min_width(220.0);
|
||||
menu_level(ui, &inner, depth + 1, clicked, auto, labels);
|
||||
@@ -1084,6 +1114,21 @@ mod parse_shortcut_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A clipboard image is premultiplied inside `Color32`. The PNG writer expects straight
|
||||
/// alpha, so a semi-transparent pixel must come back with its colour, not darkened.
|
||||
#[test]
|
||||
fn pasted_images_keep_straight_alpha_colour() {
|
||||
// Red at 50% opacity: premultiplied storage is about (128, 0, 0, 128).
|
||||
let image = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[255, 0, 0, 128]);
|
||||
let media = paste_image_media(&image).expect("a 1×1 clipboard image");
|
||||
let crate::ClipboardMedia::Image { width, height, rgba } = media else { panic!("not an image") };
|
||||
assert_eq!((width, height), (1, 1));
|
||||
assert_eq!(rgba, vec![255, 0, 0, 128], "straight RGBA, not the premultiplied bytes");
|
||||
let png = encode_png(width, height, rgba).expect("a PNG");
|
||||
let decoded = image::load_from_memory(&png).expect("decode the pasted PNG").to_rgba8();
|
||||
assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 128]);
|
||||
}
|
||||
|
||||
/// What `cargo xtask parity` measures: every item sits under a top-level menu, and Window ▸
|
||||
/// Workspaces lists the built-in workspaces before the layout commands, none checked.
|
||||
#[test]
|
||||
|
||||
@@ -191,13 +191,13 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let kind = if matches!(target, FxTarget::Clip { .. }) { tl!("Clip Fx Editor") } else { tl!("Track Fx Editor") };
|
||||
let title = format!("{kind} - {name}: {place}");
|
||||
let k = key(&target);
|
||||
let drafts_id = egui::Id::new(("fx-editor-drafts", &k));
|
||||
let drafts_id = egui::Id::unique(("fx-editor-drafts", &k));
|
||||
let drafts: HashMap<String, f64> = ctx.data(|d| d.get_temp(drafts_id)).unwrap_or_default();
|
||||
let fx_id = inst.effect.clone();
|
||||
let mut ed = Ed { t: app.tokens, fx: &fx_id, target: &target, inst, mt, drafts, acts: Vec::new(), autos: Vec::new() };
|
||||
let mut open = true;
|
||||
let mut closed = false;
|
||||
egui::Window::new(title).id(egui::Id::new(("fx-editor", &k))).open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| {
|
||||
egui::Window::new(title).id(egui::Id::unique(("fx-editor", &k))).open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| {
|
||||
match ed.fx {
|
||||
"parametric_eq" => parametric(ui, &mut ed),
|
||||
"graphic_eq" | "graphic_eq_20" | "graphic_eq_30" => graphic(ui, &mut ed),
|
||||
@@ -322,7 +322,7 @@ fn parametric(ui: &mut egui::Ui, ed: &mut Ed) {
|
||||
let g = if gain { ed.v(&g_id) } else { 0.0 };
|
||||
let c = pos2(x_of(f, r), y_of(g, r, range));
|
||||
let nr = Rect::from_center_size(c, vec2(16.0, 16.0));
|
||||
let id = egui::Id::new(("peq-node", ed.target.clone(), pre));
|
||||
let id = egui::Id::unique(("peq-node", ed.target.clone(), pre));
|
||||
let resp = ui.interact(nr, id, Sense::click_and_drag());
|
||||
let on = ed.on(&on_id);
|
||||
let col = if on { ed.t.eq_node } else { ed.t.eq_node_off };
|
||||
@@ -390,7 +390,7 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) {
|
||||
let v = ed.v(&pid);
|
||||
let ky = track.center().y - (v / 24.0) as f32 * track.height() * 0.5;
|
||||
let knob = Rect::from_center_size(pos2(x, ky), vec2(colw.min(18.0), 8.0));
|
||||
let id = egui::Id::new(("geq-band", ed.target.clone(), i));
|
||||
let id = egui::Id::unique(("geq-band", ed.target.clone(), i));
|
||||
let resp = ui.interact(knob.union(track).expand(3.0), id, Sense::click_and_drag());
|
||||
ui.painter().rect_filled(knob, 2.0, if resp.hovered() || resp.dragged() { ed.t.eq_knob_active } else { ed.t.eq_knob });
|
||||
ui.painter().text(pos2(x, area.max.y - 22.0), Align2::CENTER_CENTER, format!("{v:+.0}"), Tokens::ui(8.5), ed.t.text_dim);
|
||||
@@ -470,7 +470,7 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) {
|
||||
for (k, pid) in ["xo1", "xo2", "xo3"].iter().enumerate() {
|
||||
let x = edges[k + 1];
|
||||
let hr = Rect::from_center_size(pos2(x, r.center().y), vec2(10.0, r.height()));
|
||||
let resp = ui.interact(hr, egui::Id::new(("mb-xo", ed.target.clone(), k)), Sense::drag());
|
||||
let resp = ui.interact(hr, egui::Id::unique(("mb-xo", ed.target.clone(), k)), Sense::drag());
|
||||
ui.painter()
|
||||
.line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(if resp.hovered() || resp.dragged() { 2.5 } else { 1.5 }, ed.t.crossover_handle));
|
||||
ui.painter().text(pos2(x + 3.0, r.min.y + 2.0), Align2::LEFT_TOP, format!("{:.0} Hz", xo[k]), Tokens::ui(9.0), ed.t.text_dim);
|
||||
|
||||
@@ -41,7 +41,9 @@ use crate::state::ClipDialogDraft;
|
||||
type Elems = Vec<(String, egui::Rect, String)>;
|
||||
|
||||
fn push(elems: &mut Elems, id: impl Into<String>, r: &egui::Response, label: impl Into<String>) {
|
||||
elems.push((id.into(), r.rect, label.into()));
|
||||
let label = label.into();
|
||||
crate::widgets::accessible_name(r.clone(), label.clone());
|
||||
elems.push((id.into(), r.rect, label));
|
||||
}
|
||||
|
||||
/// Dialog title and automation prefix of a command's dialog.
|
||||
@@ -300,7 +302,7 @@ fn frames(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: &
|
||||
/// Draw the open dialog, if any.
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.clip_dialog.clone() else { return };
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("clip-dialog-before-drag"), &mut d);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("clip-dialog-before-drag"), &mut d);
|
||||
let Some((title, pre)) = meta(&d.command) else {
|
||||
app.ui.clip_dialog = None;
|
||||
return;
|
||||
@@ -311,7 +313,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
// a never-saved project is saved through the Save dialog
|
||||
let can_save = app.session.path.is_some() || app.hooks.pick_save.is_some();
|
||||
// the title is translated; the window keeps one id whatever the interface language
|
||||
let id = egui::Id::new(("clip-dialog", pre));
|
||||
let id = egui::Id::unique(("clip-dialog", pre));
|
||||
egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(360.0).centered(ctx).show(ctx, |ui| {
|
||||
let p = &mut d.params;
|
||||
match d.command.as_str() {
|
||||
@@ -375,8 +377,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut v = p["name"].as_str().unwrap_or_default().to_string();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
let id = egui::Id::new("nest-name");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut v).desired_width(240.0).id(id));
|
||||
let id = egui::Id::unique("nest-name");
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut v).desired_width(240.0).id(id)), tl!("Name:"));
|
||||
push(&mut elems, format!("{pre}.name"), &r, "Name:");
|
||||
if r.changed() {
|
||||
p["name"] = json!(v);
|
||||
@@ -384,7 +386,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
action = Some("ok");
|
||||
}
|
||||
let first = egui::Id::new("nest-name-focused");
|
||||
let first = egui::Id::unique("nest-name-focused");
|
||||
if !ui.data(|d| d.get_temp::<bool>(first).unwrap_or(false)) {
|
||||
ui.data_mut(|d| d.insert_temp(first, true));
|
||||
r.request_focus();
|
||||
@@ -587,7 +589,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
action = Some("cancel");
|
||||
}
|
||||
if action.is_some() {
|
||||
ctx.data_mut(|d| d.remove::<bool>(egui::Id::new("nest-name-focused")));
|
||||
ctx.data_mut(|d| d.remove::<bool>(egui::Id::unique("nest-name-focused")));
|
||||
}
|
||||
match action {
|
||||
Some("cancel") => {
|
||||
|
||||
@@ -71,7 +71,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
.unwrap_or_else(|| "—".into());
|
||||
let n = items.len();
|
||||
egui::Window::new(tl!("Interpret Footage"))
|
||||
.id(egui::Id::new("Interpret Footage"))
|
||||
.id(egui::Id::unique("Interpret Footage"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
@@ -102,7 +102,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
ColorDialog::Sequence { working_space, wide_gamut, auto_tone_map } => {
|
||||
egui::Window::new(tl!("Sequence Color Management"))
|
||||
.id(egui::Id::new("Sequence Color Management"))
|
||||
.id(egui::Id::unique("Sequence Color Management"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
match d {
|
||||
Dialog::About => {
|
||||
egui::Window::new(tl!("About FilmCraft"))
|
||||
.id(egui::Id::new("About FilmCraft"))
|
||||
.id(egui::Id::unique("About FilmCraft"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
@@ -94,7 +94,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
/// Automation ids: `audioGain.<set|adjust|normalizeMax|normalizeAll>` (radio buttons),
|
||||
/// `audioGain.<mode>.value` (dB fields), `audioGain.peak`, `audioGain.ok`, `audioGain.cancel`.
|
||||
fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
let peak_id = egui::Id::new("audio-gain-peak");
|
||||
let peak_id = egui::Id::unique("audio-gain-peak");
|
||||
let rev = app.session.revision;
|
||||
let cached: Option<(u64, Option<f64>)> = ctx.data(|d| d.get_temp(peak_id));
|
||||
let peak = match cached {
|
||||
@@ -106,12 +106,12 @@ fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
}
|
||||
};
|
||||
let mut draft = app.ui.audio_gain.clone();
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("audio-gain-before-drag"), &mut draft);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("audio-gain-before-drag"), &mut draft);
|
||||
let mut keep = true;
|
||||
let mut apply = false;
|
||||
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
|
||||
egui::Window::new(tl!("Audio Gain"))
|
||||
.id(egui::Id::new("Audio Gain"))
|
||||
.id(egui::Id::unique("Audio Gain"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
@@ -192,14 +192,14 @@ fn add_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
let names = |tracks: &[filmcraft_engine::project::Track]| tracks.iter().map(|t| t.name.clone()).collect::<Vec<_>>();
|
||||
let (vnames, anames, snames) = (names(&seq.video_tracks), names(&seq.audio_tracks), names(&seq.submix_tracks));
|
||||
let mut d = app.ui.add_tracks.clone();
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("add-tracks-before-drag"), &mut d);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("add-tracks-before-drag"), &mut d);
|
||||
let mut keep = true;
|
||||
let mut apply = false;
|
||||
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
|
||||
let audio_types: [(&str, &str); 4] = [("standard", tl!("Standard")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))];
|
||||
let submix_types: [(&str, &str); 4] = [("stereo", tl!("Stereo")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))];
|
||||
egui::Window::new(tl!("Add Tracks"))
|
||||
.id(egui::Id::new("Add Tracks"))
|
||||
.id(egui::Id::unique("Add Tracks"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(320.0)
|
||||
@@ -330,7 +330,7 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
let mut apply = false;
|
||||
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
|
||||
egui::Window::new(tl!("Delete Tracks"))
|
||||
.id(egui::Id::new("Delete Tracks"))
|
||||
.id(egui::Id::unique("Delete Tracks"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
@@ -416,7 +416,7 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
/// Automation ids: `about.tab.about`, `about.tab.contributors`, `about.tab.models`; the About tab's
|
||||
/// links and the credits controls are listed on [`about_tab`] and [`crate::credits::contributors_ui`].
|
||||
fn about(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
||||
let tab_id = egui::Id::new("about_tab");
|
||||
let tab_id = egui::Id::unique("about_tab");
|
||||
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, (id, label)) in [("about", tl!("About")), ("contributors", tl!("Contributors")), ("models", tl!("Models"))].into_iter().enumerate() {
|
||||
|
||||
@@ -228,11 +228,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// only the arrow twirls the effect open or shut; the rest of the header selects it
|
||||
let tw = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0));
|
||||
icons::paint(bui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
|
||||
let twresp = bui.interact(tw.expand(3.0), egui::Id::new(("fx-twirl", clip.0, idx)), Sense::click());
|
||||
let twresp = bui.interact(tw.expand(3.0), egui::Id::unique(("fx-twirl", clip.0, idx)), Sense::click());
|
||||
app.auto.add(&format!("effectControls.effect.{}.twirl", e.effect), tw, "twirl");
|
||||
// fx enable toggle
|
||||
let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0));
|
||||
let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click());
|
||||
let fxresp = bui.interact(fxr, egui::Id::unique(("fxen", clip.0, idx)), Sense::click());
|
||||
bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint });
|
||||
if !e.enabled {
|
||||
bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint));
|
||||
@@ -244,7 +244,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
row_label(bui, pos2(r.min.x + 42.0, r.center().y), crate::i18n::t(def.name), r.max.x - 26.0 - (r.min.x + 42.0), Tokens::ui(12.0), t.text);
|
||||
// reset button
|
||||
let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
|
||||
let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect"));
|
||||
let rresp = bui.interact(rr, egui::Id::unique(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect"));
|
||||
icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim });
|
||||
if rresp.clicked() {
|
||||
actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx})));
|
||||
@@ -307,7 +307,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text);
|
||||
// click or drag anywhere on the ruler (or the bar) to move the playhead (after the rows, which
|
||||
// reach under it, so it gets the pointer first)
|
||||
let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag());
|
||||
let sresp = ui.interact(scrub, egui::Id::unique(("ec-scrub", clip.0)), Sense::click_and_drag());
|
||||
if (sresp.dragged() || sresp.clicked())
|
||||
&& let Some(pos) = sresp.interact_pointer_pos()
|
||||
{
|
||||
@@ -318,7 +318,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// the divider: a line the height of the panel, a resize cursor and a wider grab area
|
||||
let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y - 26.0));
|
||||
let dresp = ui.interact(divider, egui::Id::new("ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
let dresp = ui.interact(divider, egui::Id::unique("ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
if dresp.dragged() {
|
||||
app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list);
|
||||
}
|
||||
@@ -374,7 +374,7 @@ fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, lane:
|
||||
row_line(ui, r, lane, &t);
|
||||
ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, tl!("Custom Setup"), Tokens::ui(12.0), t.text_dim);
|
||||
let br = Rect::from_min_size(pos2(r.min.x + 160.0, r.min.y + 2.0), vec2(60.0, ROW_H - 4.0));
|
||||
let resp = ui.interact(br, egui::Id::new(("fx-custom-setup", clip.0, idx)), Sense::click());
|
||||
let resp = ui.interact(br, egui::Id::unique(("fx-custom-setup", clip.0, idx)), Sense::click());
|
||||
ui.painter().rect_filled(br, 3.0, if resp.hovered() { t.hover } else { t.field_bg });
|
||||
ui.painter().rect_stroke(br, 3.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
ui.painter().text(br.center(), Align2::CENTER_CENTER, tl!("Edit…"), Tokens::ui(11.5), t.text);
|
||||
@@ -511,7 +511,7 @@ fn properties_nav(
|
||||
_ => "Turn on animation and add keyframe",
|
||||
};
|
||||
let at = pos2(row.max.x - PROPS_NAV_X, row.center().y);
|
||||
let id = egui::Id::new(("props-nav", effect, param, clip.0));
|
||||
let id = egui::Id::unique(("props-nav", effect, param, clip.0));
|
||||
if keyframe_nav(app, ui, at, prm, mt, it, id, &format!("properties.{effect}.{param}"), tip, actions) {
|
||||
if it.effect(effect).is_none() {
|
||||
actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": effect})));
|
||||
@@ -568,7 +568,7 @@ pub(crate) fn param_row(
|
||||
let key = graph_key(clip, idx, pkey);
|
||||
let open = app.ui.expanded_fx.contains(&key);
|
||||
let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0));
|
||||
let tresp = ui.interact(tw.expand(2.0), egui::Id::new(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Show graphs"));
|
||||
let tresp = ui.interact(tw.expand(2.0), egui::Id::unique(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Show graphs"));
|
||||
icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
|
||||
app.auto.add(&format!("effectControls.{}.{}.graphs", e.effect, pkey), tw, "Show graphs");
|
||||
if tresp.clicked() {
|
||||
@@ -581,7 +581,7 @@ pub(crate) fn param_row(
|
||||
}
|
||||
if pd.animatable {
|
||||
let sw = Rect::from_center_size(pos2(x, r.center().y), vec2(14.0, 14.0));
|
||||
let resp = ui.interact(sw, egui::Id::new(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Toggle animation"));
|
||||
let resp = ui.interact(sw, egui::Id::unique(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Toggle animation"));
|
||||
icons::paint(ui.painter(), sw, Icon::Stopwatch, if param.is_animated() { t.accent } else { t.text_dim });
|
||||
if resp.clicked() {
|
||||
actions.push(("effects.toggleAnimation".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id}))));
|
||||
@@ -597,25 +597,27 @@ pub(crate) fn param_row(
|
||||
.max_rect(Rect::from_min_max(pos2(vx, r.min.y + 1.0), pos2(r.max.x - 26.0, r.max.y - 1.0)))
|
||||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||
);
|
||||
let id = egui::Id::new(("pv", clip.0, idx, pkey));
|
||||
let id = egui::Id::unique(("pv", clip.0, idx, pkey));
|
||||
let mut set: Option<Value> = None;
|
||||
match (&pd.kind, &value) {
|
||||
(ParamKind::Float { min, max, soft_min, soft_max, unit, decimals }, ParamValue::Float(v)) => {
|
||||
let speed = ((soft_max - soft_min) / 400.0).max(0.01);
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, id, *v, speed, (*min, *max), *decimals as usize, unit, &t);
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, crate::i18n::t(pd.label), id, *v, speed, (*min, *max), *decimals as usize, unit, &t);
|
||||
if let Some(nv) = nv {
|
||||
set = Some(json!(nv));
|
||||
}
|
||||
}
|
||||
(ParamKind::Angle, ParamValue::Float(v)) => {
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, id, *v, 0.5, (-36000.0, 36000.0), 1, "°", &t);
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, crate::i18n::t(pd.label), id, *v, 0.5, (-36000.0, 36000.0), 1, "°", &t);
|
||||
if let Some(nv) = nv {
|
||||
set = Some(json!(nv));
|
||||
}
|
||||
}
|
||||
(ParamKind::Point, ParamValue::Vec2(p)) => {
|
||||
let (_, nx) = crate::widgets::hot_number(&mut vui, id.with("x"), p.x, 1.0, (-100_000.0, 100_000.0), 1, "", &t);
|
||||
let (_, ny) = crate::widgets::hot_number(&mut vui, id.with("y"), p.y, 1.0, (-100_000.0, 100_000.0), 1, "", &t);
|
||||
let (_, nx) =
|
||||
crate::widgets::hot_number(&mut vui, &format!("{} X", crate::i18n::t(pd.label)), id.with("x"), p.x, 1.0, (-100_000.0, 100_000.0), 1, "", &t);
|
||||
let (_, ny) =
|
||||
crate::widgets::hot_number(&mut vui, &format!("{} Y", crate::i18n::t(pd.label)), id.with("y"), p.y, 1.0, (-100_000.0, 100_000.0), 1, "", &t);
|
||||
if nx.is_some() || ny.is_some() {
|
||||
set = Some(json!([nx.unwrap_or(p.x), ny.unwrap_or(p.y)]));
|
||||
}
|
||||
@@ -623,7 +625,7 @@ pub(crate) fn param_row(
|
||||
(ParamKind::Bool, ParamValue::Bool(b)) => {
|
||||
let mut v = *b;
|
||||
fit_to_row(&mut vui);
|
||||
if vui.checkbox(&mut v, "").changed() {
|
||||
if crate::widgets::accessible_name(vui.checkbox(&mut v, ""), crate::i18n::t(pd.label)).changed() {
|
||||
set = Some(json!(v));
|
||||
}
|
||||
}
|
||||
@@ -632,13 +634,14 @@ pub(crate) fn param_row(
|
||||
fit_to_row(&mut vui);
|
||||
// (narrower when the effect list is narrow, so it stays left of the divider)
|
||||
let w = vui.available_width().min(130.0);
|
||||
egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(w).show_ui(&mut vui, |ui| {
|
||||
let combo = egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(w).show_ui(&mut vui, |ui| {
|
||||
for (i, o) in opts.iter().enumerate() {
|
||||
if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() {
|
||||
set = Some(json!(i));
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::widgets::accessible_name(combo.response, crate::i18n::t(pd.label));
|
||||
}
|
||||
(ParamKind::Color, ParamValue::Color(c)) => {
|
||||
let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]);
|
||||
@@ -660,7 +663,7 @@ pub(crate) fn param_row(
|
||||
(ParamKind::Text, ParamValue::Text(s)) if !pd.id.ends_with("_lut") => {
|
||||
// edited in a buffer; committed (one undo step) when the field loses focus
|
||||
let mut buf = vui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| s.clone());
|
||||
let r = vui.add(egui::TextEdit::singleline(&mut buf).desired_width(160.0));
|
||||
let r = crate::widgets::accessible_name(vui.add(egui::TextEdit::singleline(&mut buf).desired_width(160.0)), pd.label);
|
||||
app.auto.add(&format!("effectControls.text.{}.{}", idx, pd.id), r.rect, pd.label);
|
||||
if r.lost_focus() {
|
||||
if buf != *s {
|
||||
@@ -685,7 +688,7 @@ pub(crate) fn param_row(
|
||||
if pd.animatable && param.is_animated() {
|
||||
let at = pos2(r.max.x - 42.0, r.center().y);
|
||||
let auto = format!("effectControls.{}.{}", e.effect, pkey);
|
||||
if keyframe_nav(app, ui, at, Some(param), mt, it, egui::Id::new(("knav", clip.0, idx, pkey)), &auto, "Add/Remove Keyframe", actions) {
|
||||
if keyframe_nav(app, ui, at, Some(param), mt, it, egui::Id::unique(("knav", clip.0, idx, pkey)), &auto, "Add/Remove Keyframe", actions) {
|
||||
actions.push(("effects.addKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id}))));
|
||||
}
|
||||
}
|
||||
@@ -693,7 +696,7 @@ pub(crate) fn param_row(
|
||||
// keyframes and gets one at the playhead)
|
||||
if mask.is_none() {
|
||||
let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(12.0, 12.0));
|
||||
let rresp = ui.interact(rr, egui::Id::new(("param-reset", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Reset Parameter"));
|
||||
let rresp = ui.interact(rr, egui::Id::unique(("param-reset", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Reset Parameter"));
|
||||
icons::paint(ui.painter(), rr, Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("effectControls.{}.{}.reset", e.effect, pkey), rr, "Reset Parameter");
|
||||
if rresp.clicked() {
|
||||
@@ -710,7 +713,7 @@ pub(crate) fn param_row(
|
||||
if !(-0.01..=1.01).contains(&f) {
|
||||
continue;
|
||||
}
|
||||
let id = egui::Id::new(("kf", clip.0, idx, pkey, k.time.0));
|
||||
let id = egui::Id::unique(("kf", clip.0, idx, pkey, k.time.0));
|
||||
let original = Rect::from_center_size(pos2(lane.min.x + f * lane.width(), y), vec2(15.0, 15.0));
|
||||
let resp = ui.interact(original.expand(2.0), id, Sense::click_and_drag());
|
||||
let reference = KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time };
|
||||
@@ -796,7 +799,7 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
for g in groups {
|
||||
let key = format!("lumetri:{g}");
|
||||
let open = !app.ui.collapsed_fx.contains(&key);
|
||||
let (resp, now_open) = crate::widgets::section_header(ui, egui::Id::new(&key), crate::i18n::t(g), open, &t, true);
|
||||
let (resp, now_open) = crate::widgets::section_header(ui, egui::Id::unique(&key), crate::i18n::t(g), open, &t, true);
|
||||
app.auto.add(&format!("lumetri.section.{g}"), resp.rect, g);
|
||||
if now_open != open {
|
||||
if now_open {
|
||||
@@ -816,8 +819,17 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let (ParamKind::Float { min, max, soft_min, soft_max, .. }, ParamValue::Float(x)) = (&pd.kind, &v) {
|
||||
let mut val = *x;
|
||||
let s = ui.add(egui::Slider::new(&mut val, *soft_min..=*soft_max).show_value(false));
|
||||
let (_, nv) =
|
||||
crate::widgets::hot_number(ui, egui::Id::new(("lum", pd.id)), val, (soft_max - soft_min) / 300.0, (*min, *max), 1, "", &t);
|
||||
let (_, nv) = crate::widgets::hot_number(
|
||||
ui,
|
||||
crate::i18n::t(pd.label),
|
||||
egui::Id::unique(("lum", pd.id)),
|
||||
val,
|
||||
(soft_max - soft_min) / 300.0,
|
||||
(*min, *max),
|
||||
1,
|
||||
"",
|
||||
&t,
|
||||
);
|
||||
if s.changed() {
|
||||
actions.push(json!({"clip": clip.0, "effect": idx, "param": pd.id, "value": val}));
|
||||
} else if let Some(nv) = nv {
|
||||
@@ -878,7 +890,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some((idx, c)) = reset {
|
||||
let rr = Rect::from_center_size(pos2(r.max.x - PROPS_NAV_X, 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() {
|
||||
if ui.interact(rr, egui::Id::unique(("props-reset", name, c)), Sense::click()).clicked() {
|
||||
actions.push(("effects.reset".into(), json!({"clip": c, "index": idx})));
|
||||
}
|
||||
}
|
||||
@@ -913,9 +925,29 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
);
|
||||
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);
|
||||
let (_, nx) = crate::widgets::hot_number(
|
||||
&mut vui,
|
||||
&format!("{label} X"),
|
||||
egui::Id::unique(("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);
|
||||
let (_, ny) = crate::widgets::hot_number(
|
||||
&mut vui,
|
||||
&format!("{label} Y"),
|
||||
egui::Id::unique(("pp", p, "y", clip.0)),
|
||||
v.y,
|
||||
speed,
|
||||
range,
|
||||
0,
|
||||
" Y",
|
||||
&t,
|
||||
);
|
||||
if nx.is_some() || ny.is_some() {
|
||||
actions.push((
|
||||
"effects.setParam".into(),
|
||||
@@ -926,7 +958,8 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
Some(ParamValue::Float(v)) => {
|
||||
let (_, nv) = crate::widgets::hot_number(
|
||||
&mut vui,
|
||||
egui::Id::new(("pp", p, clip.0)),
|
||||
label,
|
||||
egui::Id::unique(("pp", p, clip.0)),
|
||||
v,
|
||||
speed,
|
||||
range,
|
||||
@@ -949,7 +982,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
.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);
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Opacity"), egui::Id::unique(("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})));
|
||||
}
|
||||
@@ -966,7 +999,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
.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);
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, label, egui::Id::unique(("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"})));
|
||||
@@ -990,7 +1023,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
.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);
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Level"), egui::Id::unique(("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})));
|
||||
}
|
||||
@@ -1002,7 +1035,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
.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);
|
||||
let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Pan"), egui::Id::unique(("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})));
|
||||
}
|
||||
@@ -1034,7 +1067,7 @@ pub(crate) fn row_fill(r: Rect) -> Rect {
|
||||
/// keeps it.
|
||||
pub(crate) fn run(app: &mut FilmcraftApp, ctx: &egui::Context, actions: Vec<(String, Value)>) {
|
||||
let (down, press) = ctx.input(|i| (i.pointer.any_down(), i.pointer.press_start_time()));
|
||||
let key = egui::Id::new("effect-controls-drag-step");
|
||||
let key = egui::Id::unique("effect-controls-drag-step");
|
||||
let begun = ctx.data(|d| d.get_temp::<Option<f64>>(key)).flatten();
|
||||
let mut wrote = false;
|
||||
for (cmd, mut p) in actions {
|
||||
@@ -1129,7 +1162,7 @@ pub(crate) fn graph_rows(
|
||||
}
|
||||
// While the graph stays open its range only widens: refitted to the keyframes, a keyframe
|
||||
// dragged off the top or bottom edge was drawn back on that edge, the curve unchanged (#1918)
|
||||
let range_id = egui::Id::new(("kfg-range", clip.0, idx, mask, pd.id));
|
||||
let range_id = egui::Id::unique(("kfg-range", clip.0, idx, mask, pd.id));
|
||||
let frame = ui.ctx().cumulative_frame_nr();
|
||||
if let Some((a, b, seen)) = ui.data(|d| d.get_temp::<(f64, f64, u64)>(range_id))
|
||||
&& frame.saturating_sub(seen) <= 1
|
||||
@@ -1200,7 +1233,7 @@ pub(crate) fn graph_rows(
|
||||
if !v.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let id = egui::Id::new(("kfg", clip.0, idx, mask, pd.id, k.time.0));
|
||||
let id = egui::Id::unique(("kfg", clip.0, idx, mask, pd.id, k.time.0));
|
||||
let id = if ci == 0 { id } else { id.with(ci) };
|
||||
let dy: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0);
|
||||
let c = pos2(x_of(f), y_of(v) + dy);
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
};
|
||||
|
||||
fn state_id() -> Id {
|
||||
Id::new("effect-controls-keyframe-selection")
|
||||
Id::unique("effect-controls-keyframe-selection")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -112,7 +112,7 @@ impl Interaction {
|
||||
if cancelled {
|
||||
state.gesture = None;
|
||||
}
|
||||
let background = ui.interact(area, Id::new(("ec-select", clip)), Sense::click_and_drag());
|
||||
let background = ui.interact(area, Id::unique(("ec-select", clip)), Sense::click_and_drag());
|
||||
if background.clicked_by(egui::PointerButton::Primary) && !ui.input(|i| i.modifiers.shift) {
|
||||
app.ui.keyframe_selection.clear();
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ fn preset_grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) -> Option<
|
||||
}
|
||||
let pic = Rect::from_min_size(cell.min, vec2(cw, ch));
|
||||
let tex = preset_texture(app, ui.ctx(), p, 160);
|
||||
let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click_and_drag()).on_hover_text(crate::i18n::t(p.description));
|
||||
let resp = ui.interact(cell, egui::Id::unique(("lumetri-grid", p.name)), Sense::click_and_drag()).on_hover_text(crate::i18n::t(p.description));
|
||||
ui.painter().rect_filled(cell, 3.0, if resp.hovered() { t.hover } else { t.field_bg });
|
||||
ui.painter().image(tex, pic.shrink(2.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let label = ui.painter().layout(crate::i18n::t(p.name).to_string(), Tokens::ui(11.0), t.text, cw - 6.0);
|
||||
|
||||
@@ -64,7 +64,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let shown = if name == "Browse" { tl!("Browse") } else { tl!("Edit") };
|
||||
let g = painter.layout_no_wrap(shown.to_string(), Tokens::ui(12.5), t.text);
|
||||
let r = Rect::from_min_size(pos2(x, ty - 9.0), vec2(g.size().x, 20.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("es-tab", name)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("es-tab", name)), Sense::click());
|
||||
let col = if name == tab || resp.hovered() { t.text } else { t.text_dim };
|
||||
painter.text(pos2(x, ty), Align2::LEFT_CENTER, shown, Tokens::ui(12.5), col);
|
||||
if name == tab {
|
||||
@@ -210,7 +210,7 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra
|
||||
let mine: Vec<&es::Preset> = presets.iter().filter(|p| p.kind == kind).collect();
|
||||
let current = if st.preset.is_empty() { tl!("(Custom)").to_string() } else { crate::i18n::t(&st.preset).to_string() };
|
||||
let mut picked = None;
|
||||
let draft_id = egui::Id::new("es-preset-draft");
|
||||
let draft_id = egui::Id::unique("es-preset-draft");
|
||||
let mut draft: Option<String> = ui.data(|d| d.get_temp(draft_id));
|
||||
ui.horizontal(|ui| {
|
||||
let w = ui.available_width() - 56.0;
|
||||
@@ -242,7 +242,10 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra
|
||||
if let Some(mut d) = draft.take() {
|
||||
let mut done = false;
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d).hint_text(tl!("Preset name")).desired_width(ui.available_width() - 70.0));
|
||||
let r = crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut d).hint_text(tl!("Preset name")).desired_width(ui.available_width() - 70.0)),
|
||||
"Preset name",
|
||||
);
|
||||
cx.auto.add("essentialSound.preset.name", r.rect, "Preset name");
|
||||
let ok = ui.button(tl!("Save"));
|
||||
cx.auto.add("essentialSound.preset.ok", ok.rect, "Save");
|
||||
@@ -355,7 +358,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) {
|
||||
for kind in AudioType::ALL {
|
||||
let br = Rect::from_min_size(pos2(x, r.min.y + 2.0), vec2(30.0, 26.0));
|
||||
let sel = st.ducking.against.contains(&kind);
|
||||
let resp = ui.interact(br, egui::Id::new(("es-duck", kind.id())), Sense::click());
|
||||
let resp = ui.interact(br, egui::Id::unique(("es-duck", kind.id())), Sense::click());
|
||||
ui.painter().rect_filled(
|
||||
br,
|
||||
4.0,
|
||||
@@ -381,7 +384,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) {
|
||||
x += 34.0;
|
||||
}
|
||||
let br = Rect::from_min_size(pos2(x + 4.0, r.min.y + 2.0), vec2(84.0, 26.0));
|
||||
let resp = ui.interact(br, egui::Id::new("es-duck-untyped"), Sense::click());
|
||||
let resp = ui.interact(br, egui::Id::unique("es-duck-untyped"), Sense::click());
|
||||
let sel = st.ducking.against_untyped;
|
||||
ui.painter().rect_filled(
|
||||
br,
|
||||
@@ -463,7 +466,7 @@ fn section_header(ui: &mut egui::Ui, cx: &mut Ctx, sec: Section, open: bool, ena
|
||||
|
||||
/// A pill switch. Returns clicked.
|
||||
fn switch(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, on: bool, id: &str, label: &str) -> bool {
|
||||
let resp = ui.interact(r.expand(3.0), egui::Id::new(("es-sw", id)), Sense::click());
|
||||
let resp = ui.interact(r.expand(3.0), egui::Id::unique(("es-sw", id)), Sense::click());
|
||||
let p = ui.painter();
|
||||
let rad = r.height() / 2.0;
|
||||
if on {
|
||||
@@ -520,7 +523,7 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64,
|
||||
let y = track.center().y;
|
||||
let f = ((v - lo) / (hi - lo)).clamp(0.0, 1.0) as f32;
|
||||
let kx = track.min.x + f * track.width();
|
||||
let resp = ui.interact(track.expand2(vec2(6.0, 4.0)), egui::Id::new(("es-slider", key)), Sense::click_and_drag());
|
||||
let resp = ui.interact(track.expand2(vec2(6.0, 4.0)), egui::Id::unique(("es-slider", key)), Sense::click_and_drag());
|
||||
let line = if active { cx.t.switch_border } else { cx.t.slider_disabled_track };
|
||||
ui.painter().line_segment([pos2(track.min.x, y), pos2(kx - 7.0, y)], Stroke::new(1.5, line));
|
||||
ui.painter().line_segment([pos2(kx + 7.0, y), pos2(track.max.x, y)], Stroke::new(1.5, line));
|
||||
@@ -581,7 +584,7 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us
|
||||
let w = 84.0;
|
||||
for (i, o) in opts.iter().enumerate() {
|
||||
let br = Rect::from_min_size(pos2(r.min.x + 42.0 + i as f32 * (w + 4.0), r.min.y + 3.0), vec2(w, 22.0));
|
||||
let resp = ui.interact(br, egui::Id::new(("es-choice", key, i)), Sense::click());
|
||||
let resp = ui.interact(br, egui::Id::unique(("es-choice", key, i)), Sense::click());
|
||||
let on = i == sel;
|
||||
ui.painter().rect_filled(
|
||||
br,
|
||||
@@ -604,7 +607,7 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us
|
||||
|
||||
fn button(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, text: &str, id: &str) -> bool {
|
||||
let t = cx.t;
|
||||
let resp = ui.interact(r, egui::Id::new(("es-btn", id)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("es-btn", id)), Sense::click());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { cx.t.button_hover } else { t.field_bg });
|
||||
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, cx.t.button_border), StrokeKind::Inside);
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, text, Tokens::ui(12.0), t.text);
|
||||
|
||||
@@ -198,7 +198,7 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
elems.push((format!("progress.job.{id}"), r, format!("{label}: {status}")));
|
||||
if !done {
|
||||
let cr = Rect::from_center_size(pos2(r.max.x - 18.0, bar.center().y), vec2(20.0, 20.0));
|
||||
let resp = ui.interact(cr, egui::Id::new(("progress-cancel", id)), Sense::click());
|
||||
let resp = ui.interact(cr, egui::Id::unique(("progress-cancel", id)), Sense::click());
|
||||
let col = if resp.hovered() { t.danger } else { t.icon };
|
||||
let s = Stroke::new(1.5, col);
|
||||
ui.painter().line_segment([cr.center() + vec2(-5.0, -5.0), cr.center() + vec2(5.0, 5.0)], s);
|
||||
|
||||
@@ -342,7 +342,7 @@ fn opts(labels: &[&str]) -> Vec<(String, bool)> {
|
||||
|
||||
fn section(ui: &mut egui::Ui, reg: &mut Reg, open: &mut Vec<String>, key: &str, title: &str, t: &Tokens) -> bool {
|
||||
let is_open = open.iter().any(|k| k == key);
|
||||
let (r, now) = crate::widgets::section_header(ui, egui::Id::new(("export-sec", key)), title, is_open, t, true);
|
||||
let (r, now) = crate::widgets::section_header(ui, egui::Id::unique(("export-sec", key)), title, is_open, t, true);
|
||||
reg.add(format!("export.section.{key}"), r.rect, title);
|
||||
if now != is_open {
|
||||
if now {
|
||||
@@ -380,7 +380,7 @@ fn check(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut bool, label: &str)
|
||||
}
|
||||
|
||||
fn text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut String, hint: &str) {
|
||||
let r = ui.add(egui::TextEdit::singleline(v).hint_text(hint).desired_width(f32::INFINITY));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(v).hint_text(hint).desired_width(f32::INFINITY)), hint);
|
||||
reg.add(id, r.rect, hint);
|
||||
}
|
||||
|
||||
@@ -389,9 +389,9 @@ fn text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut String, hint: &str)
|
||||
/// when that is not usable yet (emptied to retype, a half-typed path); each non-empty value is
|
||||
/// returned as it comes. Leaving shows the stored value again.
|
||||
fn draft_text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, stored: &str, hint: &str) -> Option<String> {
|
||||
let draft_id = egui::Id::new(("export-draft", id));
|
||||
let draft_id = egui::Id::unique(("export-draft", id));
|
||||
let mut v = ui.data(|m| m.get_temp::<String>(draft_id)).unwrap_or_else(|| stored.to_string());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(f32::INFINITY));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(f32::INFINITY)), hint);
|
||||
reg.add(id, r.rect, hint);
|
||||
if r.has_focus() {
|
||||
ui.data_mut(|m| m.insert_temp(draft_id, v.clone()));
|
||||
@@ -1172,11 +1172,11 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id:
|
||||
// buttons
|
||||
let go = Rect::from_min_size(pos2(rect.max.x - 120.0, rect.max.y - 44.0), vec2(104.0, 30.0));
|
||||
let queue = Rect::from_min_size(pos2(go.min.x - 132.0, go.min.y), vec2(122.0, 30.0));
|
||||
let qresp = ui.interact(queue, egui::Id::new("export-queue-send"), Sense::click());
|
||||
let qresp = ui.interact(queue, egui::Id::unique("export-queue-send"), Sense::click());
|
||||
ui.painter().rect_stroke(queue, 15.0, egui::Stroke::new(1.0, if qresp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside);
|
||||
ui.painter().text(queue.center(), Align2::CENTER_CENTER, tl!("Send to Queue"), Tokens::semibold(12.5), t.text);
|
||||
reg.add("export.sendToQueue", queue, "Send to Queue");
|
||||
let resp = ui.interact(go, egui::Id::new("export-go"), Sense::click());
|
||||
let resp = ui.interact(go, egui::Id::unique("export-go"), Sense::click());
|
||||
ui.painter().rect_filled(go, 15.0, if resp.hovered() { t.accent_hover } else { t.accent });
|
||||
ui.painter().text(go.center(), Align2::CENTER_CENTER, tl!("Export"), Tokens::semibold(13.0), Color32::WHITE);
|
||||
reg.add("export.button", go, "Export");
|
||||
@@ -1233,8 +1233,8 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let favorite = |n: &str| app.session.export_presets.is_favorite(n);
|
||||
let q = m.query.to_lowercase();
|
||||
egui::Window::new(tl!("Preset Manager"))
|
||||
.id(egui::Id::new("Preset Manager"))
|
||||
.id(egui::Id::new("preset-manager"))
|
||||
.id(egui::Id::unique("Preset Manager"))
|
||||
.id(egui::Id::unique("preset-manager"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
@@ -1292,7 +1292,10 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Save current settings as:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut m.save_name).hint_text(tl!("Preset name")).desired_width(200.0));
|
||||
let r = crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut m.save_name).hint_text(tl!("Preset name")).desired_width(200.0)),
|
||||
"Preset name",
|
||||
);
|
||||
reg.add("presetManager.saveName", r.rect, "Preset name");
|
||||
let r = ui.add_enabled(!m.save_name.trim().is_empty(), egui::Button::new(tl!("Save")));
|
||||
reg.add("presetManager.save", r.rect, "Save");
|
||||
@@ -1463,7 +1466,7 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P
|
||||
let mut browse = false;
|
||||
let mut typed: Option<String> = None;
|
||||
let q = &mut app.ui.export;
|
||||
let area = egui::Area::new(egui::Id::new("quick-export")).order(egui::Order::Foreground).fixed_pos(anchor).show(ctx, |ui| {
|
||||
let area = egui::Area::new(egui::Id::unique("quick-export")).order(egui::Order::Foreground).fixed_pos(anchor).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_width(340.0);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1503,7 +1506,7 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P
|
||||
});
|
||||
let clicked_elsewhere = area.response.clicked_elsewhere();
|
||||
// the header click that opened the popup is not a click elsewhere
|
||||
let toggled = ctx.data_mut(|d| d.remove_temp::<bool>(egui::Id::new("quick-export-toggled")).unwrap_or(false));
|
||||
let toggled = ctx.data_mut(|d| d.remove_temp::<bool>(egui::Id::unique("quick-export-toggled")).unwrap_or(false));
|
||||
reg.flush(app);
|
||||
if let Some(v) = typed {
|
||||
// a file name goes in the shown folder; a typed or pasted path replaces both
|
||||
|
||||
@@ -42,7 +42,7 @@ pub fn show_recovery(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
}
|
||||
let mut choice = app.file_dialogs.recovery_choice.min(items.len() - 1);
|
||||
let mut open = true;
|
||||
egui::Modal::new(egui::Id::new("recovery-modal")).frame(modal_frame(&t)).show(ctx, |ui| {
|
||||
egui::Modal::new(egui::Id::unique("recovery-modal")).frame(modal_frame(&t)).show(ctx, |ui| {
|
||||
ui.set_width(520.0);
|
||||
Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| {
|
||||
ui.label(RichText::new(tl!("Recover Unsaved Changes")).size(18.0).strong().color(t.text));
|
||||
@@ -108,7 +108,7 @@ pub fn show_revert(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
let t = app.tokens;
|
||||
let file = app.session.path.as_deref().and_then(|p| std::path::Path::new(p).file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let mut open = true;
|
||||
let r = egui::Modal::new(egui::Id::new("revert-modal")).frame(modal_frame(&t)).show(ctx, |ui| {
|
||||
let r = egui::Modal::new(egui::Id::unique("revert-modal")).frame(modal_frame(&t)).show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| {
|
||||
ui.label(RichText::new(tl!("Revert")).size(18.0).strong().color(t.text));
|
||||
|
||||
@@ -20,7 +20,7 @@ struct Draft {
|
||||
replace: Option<String>,
|
||||
}
|
||||
fn draft_id() -> egui::Id {
|
||||
egui::Id::new("frame-export-draft")
|
||||
egui::Id::unique("frame-export-draft")
|
||||
}
|
||||
fn clean(name: &str) -> String {
|
||||
name.trim().chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect()
|
||||
@@ -41,7 +41,7 @@ pub fn open(app: &mut FilmcraftApp, ctx: &egui::Context, params: &Value) -> Resu
|
||||
} else if app.playback.playing {
|
||||
app.stop();
|
||||
}
|
||||
let folder = ctx.data(|m| m.get_temp::<String>(egui::Id::new("frame-export-folder"))).unwrap_or_else(|| {
|
||||
let folder = ctx.data(|m| m.get_temp::<String>(egui::Id::unique("frame-export-folder"))).unwrap_or_else(|| {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return "/exports".into();
|
||||
}
|
||||
@@ -65,11 +65,11 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut elems = Vec::new();
|
||||
let mut popup_rects = Vec::new();
|
||||
let frame = egui::Frame::new().fill(app.tokens.panel_bg).inner_margin(0).corner_radius(6);
|
||||
let position_id = egui::Id::new("frame-export-position");
|
||||
let position_id = egui::Id::unique("frame-export-position");
|
||||
let position = ctx.data(|m| m.get_temp::<egui::Pos2>(position_id));
|
||||
let mut movement = egui::Vec2::ZERO;
|
||||
// Only the custom white header moves the window; form controls keep their own gestures.
|
||||
let window_id = egui::Id::new("frame-export-window");
|
||||
let window_id = egui::Id::unique("frame-export-window");
|
||||
let mut window = egui::Window::new("Export Frame")
|
||||
.id(window_id)
|
||||
.title_bar(false)
|
||||
@@ -95,20 +95,20 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
);
|
||||
let x = egui::Rect::from_center_size(bar.right_center() - egui::vec2(16.0, 0.0), egui::vec2(24.0, 24.0));
|
||||
let header = egui::Rect::from_min_max(bar.min, egui::pos2(x.left(), bar.bottom()));
|
||||
let drag = ui.interact(header, egui::Id::new("export-frame-header"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
|
||||
let drag = ui.interact(header, egui::Id::unique("export-frame-header"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
|
||||
if drag.dragged() {
|
||||
movement = drag.drag_delta();
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
elems.push(("exportFrame.header".into(), header, "Move Export Frame".into()));
|
||||
let response = ui.interact(x, egui::Id::new("export-frame-close"), egui::Sense::click()).on_hover_text("Close");
|
||||
let response = ui.interact(x, egui::Id::unique("export-frame-close"), egui::Sense::click()).on_hover_text("Close");
|
||||
icons::paint(ui.painter(), x.shrink(7.0), Icon::Close, egui::Color32::from_gray(90));
|
||||
elems.push(("exportFrame.close".to_string(), x, "Close".to_string()));
|
||||
close = response.clicked();
|
||||
egui::Frame::NONE.inner_margin(14).show(ui, |ui| {
|
||||
egui::Grid::new("frame-export-settings").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| {
|
||||
ui.label("Name:");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)), "Name");
|
||||
elems.push(("exportFrame.name".into(), r.rect, d.name.clone()));
|
||||
ui.end_row();
|
||||
ui.label("Format:");
|
||||
@@ -144,7 +144,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
elems.push(("exportFrame.depth".into(), combo.response.rect, format!("{} Bit", d.depth)));
|
||||
ui.end_row();
|
||||
ui.label("Path:");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(300.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(300.0)), "Path");
|
||||
elems.push(("exportFrame.path".into(), r.rect, d.folder.clone()));
|
||||
ui.end_row();
|
||||
});
|
||||
@@ -255,7 +255,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
match app.session.execute("file.exportFrame", params) {
|
||||
Ok(result) => {
|
||||
app.ui.status = format!("Exported frame to {}", result["path"].as_str().unwrap_or_default());
|
||||
ctx.data_mut(|m| m.insert_temp(egui::Id::new("frame-export-folder"), d.folder.clone()));
|
||||
ctx.data_mut(|m| m.insert_temp(egui::Id::unique("frame-export-folder"), d.folder.clone()));
|
||||
close = true;
|
||||
}
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -86,7 +86,7 @@ type Elems = Vec<(String, Rect, String)>;
|
||||
struct Lib(Arc<(f64, Vec<LibraryEntry>)>);
|
||||
|
||||
fn lib_id() -> egui::Id {
|
||||
egui::Id::new("gfx-template-library")
|
||||
egui::Id::unique("gfx-template-library")
|
||||
}
|
||||
|
||||
/// The template library, re-read at most every two seconds (or after [`invalidate`]).
|
||||
@@ -110,7 +110,7 @@ const THUMB_W: u32 = 192;
|
||||
|
||||
fn thumb_texture(ctx: &egui::Context, e: &LibraryEntry) -> egui::TextureHandle {
|
||||
let px = thumbnail_cached(&e.template, THUMB_W);
|
||||
let key = egui::Id::new(("gfx-thumb", e.template.id.as_str(), Arc::as_ptr(&px) as usize));
|
||||
let key = egui::Id::unique(("gfx-thumb", e.template.id.as_str(), Arc::as_ptr(&px) as usize));
|
||||
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(key)) {
|
||||
return t;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ fn thumb_texture(ctx: &egui::Context, e: &LibraryEntry) -> egui::TextureHandle {
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
fn tab_button(ui: &mut egui::Ui, r: Rect, label: &str, on: bool, t: &Tokens) -> egui::Response {
|
||||
let resp = ui.interact(r, egui::Id::new(("eg-tab", label)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("eg-tab", label)), Sense::click());
|
||||
if on {
|
||||
ui.painter().line_segment([pos2(r.min.x + 6.0, r.max.y - 1.5), pos2(r.max.x - 6.0, r.max.y - 1.5)], Stroke::new(2.0, t.accent));
|
||||
}
|
||||
@@ -177,7 +177,10 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
b.set_clip_rect(rect);
|
||||
let st = &mut app.ui.gfx_templates;
|
||||
b.horizontal(|ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tl!("Search templates")).desired_width((ui.available_width() - 230.0).max(110.0)));
|
||||
let r = crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tl!("Search templates")).desired_width((ui.available_width() - 230.0).max(110.0))),
|
||||
"Search templates",
|
||||
);
|
||||
elems.push(("gfxTemplates.search".into(), r.rect, "Search".into()));
|
||||
let cur = if st.category.is_empty() { tl!("All Categories").to_string() } else { crate::i18n::t(&st.category).to_string() };
|
||||
let r = egui::ComboBox::from_id_salt("gfx-cat").selected_text(cur).width(130.0).show_ui(ui, |ui| {
|
||||
@@ -186,7 +189,8 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.selectable_value(&mut st.category, c.clone(), crate::i18n::t(c));
|
||||
}
|
||||
});
|
||||
elems.push(("gfxTemplates.category".into(), r.response.rect, "Category".into()));
|
||||
let r = crate::widgets::accessible_name(r.response, "Category");
|
||||
elems.push(("gfxTemplates.category".into(), r.rect, "Category".into()));
|
||||
let r = ui.button(tl!("Install…")).on_hover_text(tl!("Install Motion Graphics Template (.fcgt)"));
|
||||
elems.push(("gfxTemplates.install".into(), r.rect, "Install".into()));
|
||||
install = r.clicked();
|
||||
@@ -360,7 +364,10 @@ pub fn template_controls(
|
||||
let r = ui
|
||||
.horizontal(|ui| {
|
||||
ui.add_space(14.0);
|
||||
ui.add(egui::TextEdit::multiline(&mut s).desired_rows(rows).desired_width(ui.available_width() - 8.0))
|
||||
crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::multiline(&mut s).desired_rows(rows).desired_width(ui.available_width() - 8.0)),
|
||||
c.name.clone(),
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
autos.push((id, r.rect, c.name.clone()));
|
||||
@@ -387,7 +394,7 @@ pub fn template_controls(
|
||||
_ => String::new(),
|
||||
};
|
||||
let before = s.clone();
|
||||
let r = vui.add(egui::TextEdit::singleline(&mut s).desired_width(vui.available_width() - 4.0));
|
||||
let r = crate::widgets::accessible_name(vui.add(egui::TextEdit::singleline(&mut s).desired_width(vui.available_width() - 4.0)), c.name.clone());
|
||||
autos.push((id, r.rect, c.name.clone()));
|
||||
if s != before {
|
||||
set(json!(s), actions);
|
||||
@@ -647,7 +654,7 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut elems: Elems = Vec::new();
|
||||
let mut action: Option<bool> = None;
|
||||
egui::Window::new(tl!("Export As Motion Graphics Template"))
|
||||
.id(egui::Id::new("Export As Motion Graphics Template"))
|
||||
.id(egui::Id::unique("Export As Motion Graphics Template"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(420.0)
|
||||
@@ -655,15 +662,15 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
.show(ctx, |ui| {
|
||||
egui::Grid::new("gfx-export-grid").num_columns(2).show(ui, |ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)), "Name");
|
||||
elems.push(("exportTemplate.name".into(), r.rect, "Name".into()));
|
||||
ui.end_row();
|
||||
ui.label(tl!("Category:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.category).desired_width(260.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.category).desired_width(260.0)), "Category");
|
||||
elems.push(("exportTemplate.category".into(), r.rect, "Category".into()));
|
||||
ui.end_row();
|
||||
ui.label(tl!("Description:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.description).desired_width(260.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.description).desired_width(260.0)), "Description");
|
||||
elems.push(("exportTemplate.description".into(), r.rect, "Description".into()));
|
||||
ui.end_row();
|
||||
});
|
||||
@@ -674,7 +681,10 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.checkbox(&mut c.on, "");
|
||||
elems.push((format!("exportTemplate.control.{n}"), r.rect, c.name.clone()));
|
||||
ui.add(egui::TextEdit::singleline(&mut c.name).desired_width(220.0));
|
||||
crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut c.name).desired_width(220.0)),
|
||||
format!("{} property name", c.param),
|
||||
);
|
||||
ui.label(egui::RichText::new(c.param.replace('_', " ")).weak());
|
||||
});
|
||||
}
|
||||
@@ -726,7 +736,7 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut elems: Elems = Vec::new();
|
||||
let mut action: Option<bool> = None;
|
||||
egui::Window::new(tl!("Replace Fonts in Projects"))
|
||||
.id(egui::Id::new("Replace Fonts in Projects"))
|
||||
.id(egui::Id::unique("Replace Fonts in Projects"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(440.0)
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let cw = 260.0;
|
||||
for (i, (title, cmd, sub)) in cards.iter().enumerate() {
|
||||
let r = Rect::from_min_size(rect.min + vec2(24.0 + i as f32 * (cw + 16.0), 90.0), vec2(cw, 150.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("imp", *cmd)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("imp", *cmd)), Sense::click());
|
||||
app.auto.add(&format!("import.{cmd}"), r, title);
|
||||
ui.painter().rect_filled(r, 8.0, if resp.hovered() { t.hover } else { t.tl_header_bg });
|
||||
ui.painter().text(r.min + vec2(16.0, 110.0), Align2::LEFT_CENTER, *title, Tokens::semibold(14.0), t.text);
|
||||
@@ -56,7 +56,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let label = if primary { tl!("Join us on Discord") } else { crate::i18n::t(label) };
|
||||
let g = ui.painter().layout_no_wrap(label.to_string(), Tokens::ui(13.0), t.text);
|
||||
let r = Rect::from_min_size(pos2(x, rect.min.y + y + 18.0), vec2(g.size().x + 44.0, 34.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("imp-link", *id)), Sense::click()).on_hover_text(*url);
|
||||
let resp = ui.interact(r, egui::Id::unique(("imp-link", *id)), Sense::click()).on_hover_text(*url);
|
||||
app.auto.add(&format!("import.link.{}", id.trim_start_matches("help.")), r, label);
|
||||
let (bg, fg) = if primary {
|
||||
(if resp.hovered() { t.accent_hover } else { t.accent }, Color32::WHITE)
|
||||
@@ -92,7 +92,7 @@ fn recent(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, y: f32) -> f32
|
||||
let row =
|
||||
Rect::from_min_size(rect.min + vec2(16.0, y + 16.0 + i as f32 * RECENT_ROW_H), vec2(560.0_f32.min(rect.width() - 32.0).max(0.0), RECENT_ROW_H));
|
||||
let name = crate::menus::recent_name(path);
|
||||
let resp = ui.interact(row, egui::Id::new(("imp-recent", i)), Sense::click()).on_hover_text(path.as_str());
|
||||
let resp = ui.interact(row, egui::Id::unique(("imp-recent", i)), Sense::click()).on_hover_text(path.as_str());
|
||||
app.auto.add(&format!("import.recent.{i}"), row, &name);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(row, 4.0, t.hover);
|
||||
|
||||
@@ -29,7 +29,7 @@ struct Draft {
|
||||
}
|
||||
|
||||
fn draft_id() -> egui::Id {
|
||||
egui::Id::new("interchange-export-draft")
|
||||
egui::Id::unique("interchange-export-draft")
|
||||
}
|
||||
|
||||
const RATES: [u32; 4] = [32_000, 44_100, 48_000, 96_000];
|
||||
@@ -64,14 +64,14 @@ pub fn is_open(ctx: &egui::Context) -> bool {
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(Some(mut d)) = ctx.data(|m| m.get_temp::<Option<Draft>>(draft_id())) else { return };
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("interchange-export-before-drag"), &mut d);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("interchange-export-before-drag"), &mut d);
|
||||
let p = if d.omf { "omfExport" } else { "aafExport" };
|
||||
let mut close = false;
|
||||
let mut apply = false;
|
||||
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
|
||||
let title = if d.omf { "OMF Export Settings" } else { "AAF Export Settings" };
|
||||
let shown = if d.omf { tl!("OMF Export Settings") } else { tl!("AAF Export Settings") };
|
||||
egui::Window::new(shown).id(egui::Id::new(title)).collapsible(false).resizable(false).centered(ctx).show(ctx, |ui| {
|
||||
egui::Window::new(shown).id(egui::Id::unique(title)).collapsible(false).resizable(false).centered(ctx).show(ctx, |ui| {
|
||||
ui.set_min_width(380.0);
|
||||
egui::Grid::new("interchange-export-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
if d.omf {
|
||||
|
||||
@@ -278,7 +278,7 @@ fn focus_find_box(app: &mut FilmcraftApp, ctx: &egui::Context) -> Value {
|
||||
let p = app.ui.focused;
|
||||
let rect =
|
||||
app.auto.find(&format!("panel.{}", p.id())).map(|e| egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new(FOCUS_SEARCH), rect.unwrap_or(egui::Rect::EVERYTHING)));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(FOCUS_SEARCH), rect.unwrap_or(egui::Rect::EVERYTHING)));
|
||||
json!({"panel": p.title()})
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ pub const FOCUS_SEARCH: &str = "fc-focus-search";
|
||||
|
||||
/// Called by search fields: take focus when Select Find Box targets the panel they sit in.
|
||||
pub fn take_search_focus(ui: &egui::Ui, field: egui::Rect, resp: &egui::Response) {
|
||||
let id = egui::Id::new(FOCUS_SEARCH);
|
||||
let id = egui::Id::unique(FOCUS_SEARCH);
|
||||
let Some(panel) = ui.ctx().data(|d| d.get_temp::<egui::Rect>(id)) else { return };
|
||||
if panel.contains(field.center()) {
|
||||
resp.request_focus();
|
||||
@@ -355,7 +355,8 @@ fn begin_text_editing(app: &mut FilmcraftApp) -> Result<Value, String> {
|
||||
let len = pick["text"].as_str().map_or(0, str::len);
|
||||
app.session.execute("graphics.selectLayer", json!({"clip": clip, "layers": [layer]})).map_err(|e| e.to_string())?;
|
||||
// like Premiere, the whole text is selected so typing replaces it
|
||||
app.ui.gfx_edit = Some(crate::state::GfxEdit { clip, layer, caret: len, anchor: 0 });
|
||||
app.restart_ime_target();
|
||||
app.ui.gfx_edit = Some(crate::state::GfxEdit { clip, layer, caret: len, anchor: 0, ..Default::default() });
|
||||
app.show_panel(PanelKind::Program);
|
||||
Ok(json!({"clip": clip, "layer": layer}))
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
if open_section(ui, app, &e, "Curves", "curves_on", "curves", &mut sections) {
|
||||
sub(ui, &t, tl!("RGB Curves"));
|
||||
let key = egui::Id::new("lumetri-curve-channel");
|
||||
let key = egui::Id::unique("lumetri-curve-channel");
|
||||
let mut ch: usize = ui.data(|d| d.get_temp(key)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, c) in
|
||||
@@ -345,7 +345,7 @@ fn section(ui: &mut egui::Ui, app: &mut FilmcraftApp, name: &str, on: bool) -> (
|
||||
ui.painter().text(pos2(r.min.x + 20.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::semibold(13.0), t.text);
|
||||
// enable switch (section bypass)
|
||||
let sw = Rect::from_center_size(pos2(r.max.x - 18.0, r.center().y), vec2(26.0, 14.0));
|
||||
let sw_resp = ui.interact(sw.expand(3.0), egui::Id::new(("lumetri-switch", name)), Sense::click());
|
||||
let sw_resp = ui.interact(sw.expand(3.0), egui::Id::unique(("lumetri-switch", name)), Sense::click());
|
||||
ui.painter().rect_filled(sw, 7.0, if on { t.accent } else { Color32::from_rgb(0x55, 0x55, 0x55) });
|
||||
ui.painter().circle_filled(pos2(if on { sw.max.x - 7.0 } else { sw.min.x + 7.0 }, sw.center().y), 5.0, Color32::WHITE);
|
||||
app.auto.add(&format!("lumetri.switch.{name}"), sw.expand(3.0), &format!("{name} on/off"));
|
||||
@@ -421,7 +421,7 @@ fn slider(
|
||||
let f = ((v - soft_min) / (soft_max - soft_min)).clamp(0.0, 1.0) as f32;
|
||||
let kx = track.min.x + f * track.width();
|
||||
let hit = Rect::from_min_max(pos2(track.min.x - 6.0, row.min.y), pos2(track.max.x + 6.0, row.max.y));
|
||||
let resp = ui.interact(hit, egui::Id::new(("lslider", clip.0, id)), Sense::click_and_drag());
|
||||
let resp = ui.interact(hit, egui::Id::unique(("lslider", clip.0, id)), Sense::click_and_drag());
|
||||
ui.painter().circle_filled(pos2(kx, track.center().y), 5.0, t.panel_bg);
|
||||
ui.painter().circle_stroke(
|
||||
pos2(kx, track.center().y),
|
||||
@@ -443,8 +443,17 @@ fn slider(
|
||||
.max_rect(Rect::from_min_max(pos2(row.max.x - 52.0, row.min.y + 3.0), pos2(row.max.x, row.max.y - 3.0)))
|
||||
.layout(egui::Layout::right_to_left(egui::Align::Center)),
|
||||
);
|
||||
let (_, nv) =
|
||||
crate::widgets::hot_number(&mut vui, egui::Id::new(("lval", clip.0, id)), v, (soft_max - soft_min) / 300.0, (min, max), decimals as usize, "", t);
|
||||
let (_, nv) = crate::widgets::hot_number(
|
||||
&mut vui,
|
||||
label,
|
||||
egui::Id::unique(("lval", clip.0, id)),
|
||||
v,
|
||||
(soft_max - soft_min) / 300.0,
|
||||
(min, max),
|
||||
decimals as usize,
|
||||
"",
|
||||
t,
|
||||
);
|
||||
if let Some(nv) = out.or(nv) {
|
||||
actions.push(json!({"clip": clip.0, "effect": idx, "param": id, "value": (nv * 10f64.powi(decimals as i32)).round() / 10f64.powi(decimals as i32)}));
|
||||
}
|
||||
@@ -510,7 +519,7 @@ fn curve_editor(ui: &mut egui::Ui, e: &EffectInstance, id: &str, hue: bool, col:
|
||||
};
|
||||
let line: Vec<Pos2> = lut.iter().enumerate().map(|(i, y)| to_screen([i as f32 / 127.0, y.clamp(0.0, 1.0)])).collect();
|
||||
p.add(egui::Shape::line(line, Stroke::new(1.5, col)));
|
||||
let drag_key = egui::Id::new(("curve-drag", clip.0, id));
|
||||
let drag_key = egui::Id::unique(("curve-drag", clip.0, id));
|
||||
let mut dragging: Option<usize> = ui.data(|d| d.get_temp(drag_key));
|
||||
let mut new_pts = pts.clone();
|
||||
let mut changed = false;
|
||||
@@ -612,7 +621,7 @@ fn color_wheel(ui: &mut egui::Ui, e: &EffectInstance, mt: Tick, id: &str, lid: &
|
||||
p.rect_filled(track, 2.0, Color32::from_rgb(0x4b, 0x4b, 0x4b));
|
||||
let ky = track.center().y - l / 100.0 * track.height() / 2.0;
|
||||
p.circle_stroke(pos2(track.center().x, ky), 4.0, Stroke::new(1.5, Color32::from_rgb(0xb0, 0xb0, 0xb0)));
|
||||
let lr = ui.interact(track.expand2(vec2(6.0, 0.0)), egui::Id::new(("wl", clip.0, lid)), Sense::click_and_drag());
|
||||
let lr = ui.interact(track.expand2(vec2(6.0, 0.0)), egui::Id::unique(("wl", clip.0, lid)), Sense::click_and_drag());
|
||||
if (lr.dragged() || lr.clicked())
|
||||
&& let Some(pos) = lr.interact_pointer_pos()
|
||||
{
|
||||
@@ -627,8 +636,8 @@ fn color_wheel(ui: &mut egui::Ui, e: &EffectInstance, mt: Tick, id: &str, lid: &
|
||||
fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'static str, Value)>) {
|
||||
let t = app.tokens;
|
||||
sub(ui, &t, tl!("Color Match"));
|
||||
let key = egui::Id::new("lumetri-match-ref");
|
||||
let fd_key = egui::Id::new("lumetri-match-face");
|
||||
let key = egui::Id::unique("lumetri-match-ref");
|
||||
let fd_key = egui::Id::unique("lumetri-match-face");
|
||||
let mut tc: String = ui.data(|d| d.get_temp(key)).unwrap_or_else(|| "00:00:00:00".to_string());
|
||||
let mut face: bool = ui.data(|d| d.get_temp(fd_key)).unwrap_or(true);
|
||||
// Comparison View: the Program monitor shows the reference frame next to the current one, and
|
||||
@@ -645,7 +654,7 @@ fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'st
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new(tl!("Reference")).color(t.text_dim)));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut tc).desired_width(96.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut tc).desired_width(96.0)), "Reference timecode");
|
||||
app.auto.add("lumetri.match.reference", r.rect, "Reference timecode");
|
||||
});
|
||||
let r = ui.checkbox(&mut face, tl!("Face Detection (protect skin tones)"));
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(crate) fn effect_rows(
|
||||
.enumerate()
|
||||
{
|
||||
let br = Rect::from_center_size(pos2(r.min.x + 40.0 + i as f32 * 24.0, r.center().y), vec2(20.0, 18.0));
|
||||
let resp = ui.interact(br, egui::Id::new(("mask-new", clip.0, idx, kind)), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(br, egui::Id::unique(("mask-new", clip.0, idx, kind)), Sense::click()).on_hover_text(tip);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(br, 3.0, t.hover);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ pub(crate) fn effect_rows(
|
||||
crate::panels::effect_controls::row_line(ui, r, lane, &t);
|
||||
let tw = Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(10.0, 10.0));
|
||||
icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
|
||||
let twresp = ui.interact(tw.expand(3.0), egui::Id::new(("mask-twirl", clip.0, idx, k)), Sense::click());
|
||||
let twresp = ui.interact(tw.expand(3.0), egui::Id::unique(("mask-twirl", clip.0, idx, k)), Sense::click());
|
||||
// shape glyph: ellipse for smooth paths, square for polygons
|
||||
let path = m.path_at(mt);
|
||||
let glyph = Rect::from_center_size(pos2(r.min.x + 42.0, r.center().y), vec2(12.0, 12.0));
|
||||
@@ -118,13 +118,15 @@ pub(crate) fn effect_rows(
|
||||
let mut mui = ui.new_child(egui::UiBuilder::new().max_rect(mr).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
let mut mode = m.mode;
|
||||
crate::panels::effect_controls::fit_to_row(&mut mui);
|
||||
egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| {
|
||||
for md in MaskMode::ALL {
|
||||
if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() {
|
||||
actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "mode": md.label()})));
|
||||
let combo =
|
||||
egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| {
|
||||
for md in MaskMode::ALL {
|
||||
if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() {
|
||||
actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "mode": md.label()})));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
crate::widgets::accessible_name(combo.response, "Mask mode");
|
||||
app.auto.add(&format!("{base}.mode"), mr, "Mask mode");
|
||||
if twresp.clicked() {
|
||||
if open {
|
||||
@@ -166,7 +168,7 @@ pub(crate) fn effect_rows(
|
||||
let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
crate::panels::effect_controls::fit_to_row(&mut cui);
|
||||
let mut inv = m.inverted;
|
||||
if cui.checkbox(&mut inv, "").changed() {
|
||||
if crate::widgets::accessible_name(cui.checkbox(&mut inv, ""), "Invert").changed() {
|
||||
actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "inverted": inv})));
|
||||
}
|
||||
app.auto.add(&format!("{base}.inverted"), cr, "Inverted");
|
||||
@@ -286,7 +288,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
}
|
||||
v
|
||||
};
|
||||
let gesture_id = egui::Id::new("mask-gesture");
|
||||
let gesture_id = egui::Id::unique("mask-gesture");
|
||||
// one gesture (undo step) per pointer press
|
||||
let mut gesture: u64 = ui.data(|d| d.get_temp(gesture_id)).unwrap_or(0);
|
||||
if ui.input(|i| i.pointer.any_pressed()) {
|
||||
@@ -299,7 +301,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
};
|
||||
// ---- whole mask (drag inside)
|
||||
let bb = Rect::from_points(&screen).expand(2.0).intersect(pic);
|
||||
let body = ui.interact(bb, egui::Id::new("mask-body"), Sense::click_and_drag());
|
||||
let body = ui.interact(bb, egui::Id::unique("mask-body"), Sense::click_and_drag());
|
||||
app.auto.add("program.mask.body", bb, &m.name);
|
||||
let ptr_in = |p: Option<Pos2>| p.is_some_and(|p| inside(&flat, to_clip.apply(Vec2::new(p.x as f64, p.y as f64))));
|
||||
if body.drag_started_by(LEFT) {
|
||||
@@ -307,7 +309,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
ui.data_mut(|d| d.insert_temp(gesture_id.with("body"), ok));
|
||||
}
|
||||
if body.dragged_by(LEFT) && ui.data(|d| d.get_temp::<bool>(gesture_id.with("body"))).unwrap_or(false) && body.drag_delta() != egui::Vec2::ZERO {
|
||||
let d = drag_step(ui, egui::Id::new(("mask-body-drag", gesture)), body.drag_delta());
|
||||
let d = drag_step(ui, egui::Id::unique(("mask-body-drag", gesture)), body.drag_delta());
|
||||
if d != egui::Vec2::ZERO {
|
||||
actions.push(("masks.translate".into(), with(json!({"delta": clip_delta(d), "merge": format!("body-{gesture}")}))));
|
||||
}
|
||||
@@ -345,7 +347,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
let r = ui.interact(hr.expand(3.0), egui::Id::new(("mask-handle", which)), Sense::drag());
|
||||
let r = ui.interact(hr.expand(3.0), egui::Id::unique(("mask-handle", which)), Sense::drag());
|
||||
app.auto.add(&format!("program.mask.{which}"), hr, if which == "feather" { "Mask Feather" } else { "Mask Expansion" });
|
||||
if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO {
|
||||
let d = clip_delta(r.drag_delta());
|
||||
@@ -359,7 +361,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
// ---- corner roundness (masks with sharp corners: rectangles, polygons)
|
||||
actions.extend(roundness_handle(app, ui, &painter, &path, &to_screen, &target));
|
||||
// ---- tangent handles and vertices
|
||||
let sel_v_id = egui::Id::new(("mask-sel-vertex", sel.clip.0, sel.effect, sel.mask));
|
||||
let sel_v_id = egui::Id::unique(("mask-sel-vertex", sel.clip.0, sel.effect, sel.mask));
|
||||
let sel_v: Option<usize> = ui.data(|d| d.get_temp(sel_v_id));
|
||||
let alt = ui.input(|i| i.modifiers.alt);
|
||||
let cmd = ui.input(|i| i.modifiers.command);
|
||||
@@ -374,7 +376,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
painter.circle_filled(h, 3.5, Color32::WHITE);
|
||||
painter.circle_stroke(h, 3.5, Stroke::new(1.0, col));
|
||||
let hr = Rect::from_center_size(h, vec2(8.0, 8.0));
|
||||
let r = ui.interact(hr.expand(2.0), egui::Id::new(("mask-tan", which, i)), Sense::drag());
|
||||
let r = ui.interact(hr.expand(2.0), egui::Id::unique(("mask-tan", which, i)), Sense::drag());
|
||||
app.auto.add(&format!("program.mask.{which}.{i}"), hr, "Bezier handle");
|
||||
if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO {
|
||||
actions.push((
|
||||
@@ -390,7 +392,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
let selected = sel_v == Some(i);
|
||||
painter.rect_filled(vr, 0.0, if selected { col } else { Color32::WHITE });
|
||||
painter.rect_stroke(vr, 0.0, Stroke::new(1.0, col), egui::StrokeKind::Middle);
|
||||
let r = ui.interact(vr.expand(3.0), egui::Id::new(("mask-vertex", i)), Sense::click_and_drag()).on_hover_text(if cfg!(target_os = "macos") {
|
||||
let r = ui.interact(vr.expand(3.0), egui::Id::unique(("mask-vertex", i)), Sense::click_and_drag()).on_hover_text(if cfg!(target_os = "macos") {
|
||||
tl!("Drag: move · Alt-click: smooth/corner · Cmd-click: delete")
|
||||
} else {
|
||||
tl!("Drag: move · Alt-click: smooth/corner · Ctrl-click: delete")
|
||||
@@ -400,7 +402,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
ui.data_mut(|d| d.insert_temp(sel_v_id, i));
|
||||
}
|
||||
if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO {
|
||||
let d = drag_step(ui, egui::Id::new(("mask-vertex-drag", gesture, i)), r.drag_delta());
|
||||
let d = drag_step(ui, egui::Id::unique(("mask-vertex-drag", gesture, i)), r.drag_delta());
|
||||
if d != egui::Vec2::ZERO {
|
||||
actions.push(("masks.moveVertex".into(), with(json!({"vertex": i, "delta": clip_delta(d), "merge": format!("vertex-{gesture}")}))));
|
||||
}
|
||||
@@ -522,7 +524,7 @@ fn roundness_handle(
|
||||
(pa.x + pa.y).total_cmp(&(pb.x + pb.y))
|
||||
})?;
|
||||
let max_radius = corners.iter().map(|c| c.max_radius).fold(0.0, f64::max);
|
||||
let id = egui::Id::new("mask-roundness");
|
||||
let id = egui::Id::unique("mask-roundness");
|
||||
let dist_id = id.with("dist");
|
||||
let dragging = ui.ctx().is_being_dragged(id);
|
||||
let dist: f64 = if dragging { ui.data(|d| d.get_temp(dist_id)).unwrap_or(0.0) } else { 0.0 };
|
||||
@@ -615,7 +617,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
|
||||
let Some(to_screen) = clip_to_screen(app, &it, mt, pic, frame) else { return };
|
||||
let Some(to_clip) = to_screen.inverse() else { return };
|
||||
let col = Color32::from_rgb(0x5a, 0xb4, 0xff);
|
||||
let resp = ui.interact(pic, egui::Id::new("mask-pen"), Sense::click_and_drag());
|
||||
let resp = ui.interact(pic, egui::Id::unique("mask-pen"), Sense::click_and_drag());
|
||||
app.auto.add("program.maskPen", pic, "Pen mask");
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
@@ -630,7 +632,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
|
||||
_ => p,
|
||||
};
|
||||
let mut commit = false;
|
||||
let pressed = resp.drag_started_by(LEFT) || (resp.clicked() && !ui.data(|m| m.get_temp::<bool>(egui::Id::new("mask-pen-placed")).unwrap_or(false)));
|
||||
let pressed = resp.drag_started_by(LEFT) || (resp.clicked() && !ui.data(|m| m.get_temp::<bool>(egui::Id::unique("mask-pen-placed")).unwrap_or(false)));
|
||||
if pressed && let Some(p) = resp.interact_pointer_pos() {
|
||||
let near_first = d.points.first().is_some_and(|f| (sp(&to_screen, Vec2::new(f[0], f[1])) - p).length() < 8.0);
|
||||
if d.points.len() >= 3 && near_first {
|
||||
@@ -640,11 +642,11 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
|
||||
d.points.push([c.x, c.y, 0.0, 0.0]);
|
||||
// read the response before data_mut: it locks the context that drag_started_by reads
|
||||
let dragging = resp.drag_started_by(LEFT);
|
||||
ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), dragging));
|
||||
ui.data_mut(|m| m.insert_temp(egui::Id::unique("mask-pen-placed"), dragging));
|
||||
}
|
||||
}
|
||||
if resp.clicked() || resp.drag_stopped() {
|
||||
ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), false));
|
||||
ui.data_mut(|m| m.insert_temp(egui::Id::unique("mask-pen-placed"), false));
|
||||
}
|
||||
if resp.dragged_by(LEFT)
|
||||
&& !commit
|
||||
|
||||
@@ -54,7 +54,7 @@ const CACHE_SECS: f64 = 3.0;
|
||||
const ROW_H: f32 = 22.0;
|
||||
|
||||
fn cache_id() -> egui::Id {
|
||||
egui::Id::new("media-browser-cache")
|
||||
egui::Id::unique("media-browser-cache")
|
||||
}
|
||||
|
||||
fn with_cache<R>(ctx: &egui::Context, f: impl FnOnce(&mut Cache) -> R) -> R {
|
||||
@@ -106,7 +106,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
];
|
||||
for (icon, id, tip, enabled) in nav {
|
||||
let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("mb-nav", id)), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique(("mb-nav", id)), Sense::click()).on_hover_text(tip);
|
||||
app.auto.add(&format!("mediaBrowser.{id}"), r, tip);
|
||||
if resp.hovered() && enabled {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
@@ -122,7 +122,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let pw = (bar.max.x - x - 290.0).max(80.0);
|
||||
let pr = Rect::from_min_size(pos2(x + 4.0, bar.min.y + 1.0), vec2(pw, 22.0));
|
||||
let mut path = app.ui.media_browser.path_edit.clone().unwrap_or_else(|| dir.clone());
|
||||
let resp = ui.put(pr, egui::TextEdit::singleline(&mut path).id(egui::Id::new("mb-path")).font(egui::FontId::proportional(11.5)));
|
||||
let resp = crate::widgets::accessible_name(
|
||||
ui.put(pr, egui::TextEdit::singleline(&mut path).id(egui::Id::unique("mb-path")).font(egui::FontId::proportional(11.5))),
|
||||
"Path",
|
||||
);
|
||||
app.auto.add("mediaBrowser.path", pr, "Path");
|
||||
if resp.changed() {
|
||||
app.ui.media_browser.path_edit = Some(path.clone());
|
||||
@@ -138,7 +141,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// file types
|
||||
let fr = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(150.0, 22.0));
|
||||
let label = mb::FILE_TYPES.iter().find(|f| f.0 == prefs.file_types).map(|f| f.1.to_string()).unwrap_or_else(|| format!(".{}", prefs.file_types));
|
||||
let fresp = crate::widgets::dropdown_text(ui, fr, &label, &t, egui::Id::new("mb-types")).on_hover_text(tl!("File Types Displayed"));
|
||||
let fresp = crate::widgets::dropdown_text(ui, fr, &label, &t, egui::Id::unique("mb-types")).on_hover_text(tl!("File Types Displayed"));
|
||||
app.auto.add("mediaBrowser.fileTypes", fr, "File Types Displayed");
|
||||
egui::Popup::menu(&fresp).show(|ui| {
|
||||
for (k, l) in mb::FILE_TYPES {
|
||||
@@ -162,7 +165,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
x = fr.max.x + 6.0;
|
||||
for (icon, v, tip) in [(Icon::ListView, "list", tl!("List View")), (Icon::IconView, "thumbnails", tl!("Thumbnail View"))] {
|
||||
let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("mb-view", v)), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique(("mb-view", v)), Sense::click()).on_hover_text(tip);
|
||||
app.auto.add(&format!("mediaBrowser.view.{v}"), r, tip);
|
||||
if prefs.view == v {
|
||||
ui.painter().rect_filled(r, 3.0, t.pressed);
|
||||
@@ -203,7 +206,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let list_r = Rect::from_min_max(pos2(tree_r.max.x + 4.0, body.min.y), body.max);
|
||||
// splitter
|
||||
let split = Rect::from_min_max(pos2(tree_r.max.x, body.min.y), pos2(tree_r.max.x + 4.0, body.max.y));
|
||||
let sresp = ui.interact(split, egui::Id::new("mb-split"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
let sresp = ui.interact(split, egui::Id::unique("mb-split"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
if sresp.dragged() {
|
||||
app.ui.media_browser.tree_width = (tw + sresp.drag_delta().x).clamp(100.0, (rect.width() * 0.7).max(100.0));
|
||||
}
|
||||
@@ -233,7 +236,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let resp = ui.put(sr, egui::Slider::new(&mut sz, 60.0..=320.0).show_value(false));
|
||||
app.auto.add("mediaBrowser.thumbnailSize", sr, "Thumbnail size");
|
||||
// a drag ended by Escape goes back to the size it started from (#580)
|
||||
let orig_id = egui::Id::new("media-browser-thumbnail-size-before-drag");
|
||||
let orig_id = egui::Id::unique("media-browser-thumbnail-size-before-drag");
|
||||
if resp.drag_started() {
|
||||
ui.data_mut(|d| d.insert_temp(orig_id, prefs.thumbnail_size));
|
||||
}
|
||||
@@ -573,12 +576,12 @@ fn list(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, entries: &[Entry], c
|
||||
let hp = ui.painter().with_clip_rect(header.intersect(ui.clip_rect()));
|
||||
hp.rect_filled(header, 0.0, t.panel_bg);
|
||||
hp.line_segment([header.left_bottom(), header.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
let mut x = header.min.x + 4.0 - out.state.offset.x;
|
||||
let mut x = header.min.x + 4.0 - out.state.clamped_offset().x;
|
||||
for (c, w) in columns.iter().zip(&widths) {
|
||||
let cr = Rect::from_min_size(pos2(x, header.min.y), vec2(*w, 20.0));
|
||||
hp.text(pos2(cr.min.x + 4.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::t(c), Tokens::ui(11.0), t.text_dim);
|
||||
if cr.intersects(header) {
|
||||
let resp = ui.interact(cr.intersect(header), egui::Id::new(("mb-col", c)), Sense::click());
|
||||
let resp = ui.interact(cr.intersect(header), egui::Id::unique(("mb-col", c)), Sense::click());
|
||||
app.auto.add(&format!("mediaBrowser.header.{c}"), cr.intersect(header), c);
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button(tl!("Edit Columns…")).clicked() {
|
||||
@@ -656,7 +659,7 @@ fn thumbnails(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, entries: &[Ent
|
||||
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), cell.y), Sense::hover());
|
||||
for (k, e) in chunk.iter().enumerate() {
|
||||
let tr = Rect::from_min_size(pos2(row.min.x + 8.0 + k as f32 * cell.x, row.min.y + 6.0), vec2(size, th_h));
|
||||
let resp = ui.interact(tr.expand(2.0), egui::Id::new(("mb-thumb", &e.path)), Sense::click_and_drag());
|
||||
let resp = ui.interact(tr.expand(2.0), egui::Id::unique(("mb-thumb", &e.path)), Sense::click_and_drag());
|
||||
let sel = app.session.browser.selection.contains(&e.path);
|
||||
ui.painter().rect_filled(tr, 3.0, Color32::from_gray(14));
|
||||
if sel {
|
||||
@@ -815,7 +818,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut close = false;
|
||||
let mut ok = false;
|
||||
egui::Window::new(tl!("Edit Columns"))
|
||||
.id(egui::Id::new("mb-edit-columns"))
|
||||
.id(egui::Id::unique("mb-edit-columns"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(Align2::CENTER_CENTER)
|
||||
|
||||
@@ -129,7 +129,7 @@ fn match_params(d: &LinkMediaDraft) -> Value {
|
||||
}
|
||||
|
||||
fn preview_texture(app: &FilmcraftApp, ctx: &egui::Context, path: &str) -> Option<egui::TextureHandle> {
|
||||
let key = egui::Id::new(("link-preview", path));
|
||||
let key = egui::Id::unique(("link-preview", path));
|
||||
if let Some(t) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(key)) {
|
||||
return t;
|
||||
}
|
||||
@@ -161,7 +161,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let rows_max_h = (ctx.content_rect().height() * 0.3).clamp(90.0, 320.0);
|
||||
let width = (ctx.content_rect().width() - 40.0).clamp(1.0, 760.0);
|
||||
egui::Window::new(tl!("Link Media"))
|
||||
.id(egui::Id::new("Link Media"))
|
||||
.id(egui::Id::unique("Link Media"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(width)
|
||||
@@ -217,7 +217,10 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
ui.separator();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(tl!("Search in:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width((ui.available_width() * 0.5).min(360.0)).hint_text(tl!("Folder")));
|
||||
let r = crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width((ui.available_width() * 0.5).min(360.0)).hint_text(tl!("Folder"))),
|
||||
"Folder",
|
||||
);
|
||||
push(&mut elems, "linkMedia.folder", &r, "folder");
|
||||
let r = ui.button(tl!("Browse…"));
|
||||
push(&mut elems, "linkMedia.browse", &r, "Browse…");
|
||||
@@ -371,7 +374,7 @@ fn make_offline(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut ok = false;
|
||||
let n = app.session.state.project_selection.len();
|
||||
egui::Window::new(tl!("Make Offline"))
|
||||
.id(egui::Id::new("Make Offline"))
|
||||
.id(egui::Id::unique("Make Offline"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
@@ -416,7 +419,7 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut ok = false;
|
||||
let mut browse = false;
|
||||
egui::Window::new(tl!("Create Proxies"))
|
||||
.id(egui::Id::new("Create Proxies"))
|
||||
.id(egui::Id::unique("Create Proxies"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
@@ -435,8 +438,12 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(tl!("Destination")).strong());
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui
|
||||
.add(egui::TextEdit::singleline(&mut d.destination).desired_width(320.0).hint_text(tl!("Next to the original media, in a Proxies folder")));
|
||||
let r = crate::widgets::accessible_name(
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut d.destination).desired_width(320.0).hint_text(tl!("Next to the original media, in a Proxies folder")),
|
||||
),
|
||||
"Destination",
|
||||
);
|
||||
push(&mut elems, "proxies.destination", &r, "destination");
|
||||
let r = ui.button(tl!("Browse…"));
|
||||
push(&mut elems, "proxies.browse", &r, "Browse…");
|
||||
@@ -489,14 +496,14 @@ fn pm_params(d: &ProjectManagerDraft, dry: bool) -> Value {
|
||||
|
||||
fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.project_manager.clone() else { return };
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("project-manager-before-drag"), &mut d);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("project-manager-before-drag"), &mut d);
|
||||
let before = d.clone();
|
||||
let mut elems: Elems = Vec::new();
|
||||
let mut keep = true;
|
||||
let (mut ok, mut calc, mut browse) = (false, false, false);
|
||||
let seqs: Vec<(u64, String)> = app.session.project.sequences().map(|i| (i.id.0, i.name.clone())).collect();
|
||||
egui::Window::new(tl!("Project Manager"))
|
||||
.id(egui::Id::new("Project Manager"))
|
||||
.id(egui::Id::unique("Project Manager"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
@@ -555,7 +562,7 @@ fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
ui.separator();
|
||||
ui.label(RichText::new(tl!("Destination Path")).strong());
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(360.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(360.0)), "Destination");
|
||||
push(&mut elems, "pm.destination", &r, "destination");
|
||||
let r = ui.button(tl!("Browse…"));
|
||||
push(&mut elems, "pm.browse", &r, "Browse…");
|
||||
|
||||
@@ -48,7 +48,9 @@ impl Default for Extras {
|
||||
type Elems = Vec<(String, egui::Rect, String)>;
|
||||
|
||||
fn push(elems: &mut Elems, id: impl Into<String>, r: &egui::Response, label: impl Into<String>) {
|
||||
elems.push((id.into(), r.rect, label.into()));
|
||||
let label = label.into();
|
||||
crate::widgets::accessible_name(r.clone(), label.clone());
|
||||
elems.push((id.into(), r.rect, label));
|
||||
}
|
||||
|
||||
/// Dialog title, automation prefix and OK button text of a command's dialog.
|
||||
@@ -453,7 +455,7 @@ fn pairs(v: &[&str]) -> Vec<(String, String)> {
|
||||
/// Draw the open dialog, if any.
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.extras.dialog.clone() else { return };
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("menu-dialog-before-drag"), &mut d);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("menu-dialog-before-drag"), &mut d);
|
||||
let Some((title, pre, ok_text)) = meta(&d.command) else {
|
||||
app.ui.extras.dialog = None;
|
||||
return;
|
||||
@@ -463,7 +465,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut browse: Option<String> = None;
|
||||
let info_only = matches!(d.command.as_str(), "file.mediaProperties" | "clip.sourceSettings" | "help.systemCompatibilityReport");
|
||||
// the title is translated; the window keeps one id whatever the interface language
|
||||
let id = egui::Id::new(("menu-dialog", pre));
|
||||
let id = egui::Id::unique(("menu-dialog", pre));
|
||||
egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(420.0).centered(ctx).show(ctx, |ui| {
|
||||
let p = &mut d.params;
|
||||
match d.command.as_str() {
|
||||
|
||||
@@ -120,9 +120,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
/// An editable value: a text field whose edit is committed when it loses focus (Enter, Tab or a
|
||||
/// click elsewhere). Returns (field, new value) on commit.
|
||||
fn text_field(ui: &mut egui::Ui, item: ItemId, f: &Field, elems: &mut Vec<(String, Rect, String)>) -> Option<(String, String)> {
|
||||
let id = egui::Id::new(("md-draft", item.0, f.name.clone()));
|
||||
let id = egui::Id::unique(("md-draft", item.0, f.name.clone()));
|
||||
let mut draft: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| f.value.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut draft).desired_width(ui.available_width()).id_salt(("md-edit", item.0, f.name.clone())));
|
||||
let r = crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut draft).desired_width(ui.available_width()).id_salt(("md-edit", item.0, f.name.clone()))),
|
||||
f.name.clone(),
|
||||
);
|
||||
elems.push((field_id(&f.name), r.rect, f.value.clone()));
|
||||
let mut out = None;
|
||||
if r.has_focus() {
|
||||
|
||||
@@ -89,7 +89,7 @@ pub const INPUT_METER: u64 = u64::MAX - 1;
|
||||
/// strips), fed by the playing mix ([`filmcraft_render::mixer::LiveMix`]) with fast attack and a
|
||||
/// 20 dB/s fall. [`INPUT_METER`] holds the voice-over input.
|
||||
pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap<u64, Vec<[f32; 2]>> {
|
||||
let id = egui::Id::new("mixer-meters");
|
||||
let id = egui::Id::unique("mixer-meters");
|
||||
let frame = ui.ctx().cumulative_frame_nr();
|
||||
let (last, mut st): (u64, HashMap<u64, Vec<[f32; 2]>>) = ui.data(|d| d.get_temp(id)).unwrap_or_default();
|
||||
if last == frame && frame != 0 {
|
||||
@@ -223,7 +223,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let body = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 2.0), pos2(rect.max.x, footer.min.y));
|
||||
// effects / sends disclosure at the left edge
|
||||
let arrow = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 4.0), vec2(14.0, 18.0));
|
||||
let aresp = ui.interact(arrow, egui::Id::new("mixer-fx-toggle"), Sense::click()).on_hover_text(tl!("Show/Hide Effects and Sends"));
|
||||
let aresp = ui.interact(arrow, egui::Id::unique("mixer-fx-toggle"), Sense::click()).on_hover_text(tl!("Show/Hide Effects and Sends"));
|
||||
icons::paint(ui.painter(), arrow.shrink(2.0), if app.ui.mixer_fx_open { Icon::ChevronDown } else { Icon::ChevronRight }, t.icon);
|
||||
app.auto.add("mixer.showEffects", arrow, "Show/Hide Effects and Sends");
|
||||
if aresp.clicked() {
|
||||
@@ -231,10 +231,10 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// panel menu: Show/Hide Tracks, Meter Input(s) Only
|
||||
let menu_r = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 26.0), vec2(14.0, 18.0));
|
||||
let mresp = ui.interact(menu_r, egui::Id::new("mixer-menu"), Sense::click()).on_hover_text(tl!("Audio Track Mixer menu"));
|
||||
let mresp = ui.interact(menu_r, egui::Id::unique("mixer-menu"), Sense::click()).on_hover_text(tl!("Audio Track Mixer menu"));
|
||||
icons::paint(ui.painter(), menu_r.shrink(2.0), Icon::Hamburger, if mresp.hovered() { t.tab_text_active } else { t.icon });
|
||||
app.auto.add("mixer.menu", menu_r, "Audio Track Mixer menu");
|
||||
let show_hide_id = egui::Id::new("mixer-show-hide-open");
|
||||
let show_hide_id = egui::Id::unique("mixer-show-hide-open");
|
||||
let mut show_hide: bool = ui.data(|d| d.get_temp(show_hide_id)).unwrap_or(false);
|
||||
egui::Popup::menu(&mresp).show(|ui| {
|
||||
let r = ui.button(tl!("Show/Hide Tracks…"));
|
||||
@@ -252,7 +252,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if show_hide {
|
||||
let mut open = true;
|
||||
let mut close = false;
|
||||
egui::Window::new(tl!("Show/Hide Tracks")).id(egui::Id::new("mixer-show-hide")).collapsible(false).resizable(false).open(&mut open).show(
|
||||
egui::Window::new(tl!("Show/Hide Tracks")).id(egui::Id::unique("mixer-show-hide")).collapsible(false).resizable(false).open(&mut open).show(
|
||||
ui.ctx(),
|
||||
|ui| {
|
||||
for id in seq.audio_tracks.iter().chain(&seq.submix_tracks).map(|t| t.id) {
|
||||
@@ -294,7 +294,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// strips never get shorter than their controls plus a usable fader: scroll vertically instead
|
||||
let need = 4.0 + if app.ui.mixer_fx_open { 10.0 * SLOT_H + 10.0 } else { 0.0 } + 112.0 + 26.0 + 24.0 + 6.0 + 170.0 + 42.0;
|
||||
let clip = Rect::from_min_max(pos2(x0, body.min.y), pos2(body.max.x - 8.0, body.max.y - sb_h));
|
||||
let voff_id = egui::Id::new("mixer-vscroll");
|
||||
let voff_id = egui::Id::unique("mixer-vscroll");
|
||||
let max_voff = (need - (clip.height() - 4.0)).max(0.0);
|
||||
let mut voff: f32 = ui.data(|d| d.get_temp(voff_id)).unwrap_or(0.0);
|
||||
if ui.rect_contains_pointer(body) && !ui.input(|i| i.modifiers.shift) {
|
||||
@@ -307,7 +307,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let kh = (bar.height() * clip.height() / (need + 4.0)).max(20.0);
|
||||
let ky = bar.min.y + (bar.height() - kh) * voff / max_voff;
|
||||
let knob = Rect::from_min_size(pos2(bar.min.x, ky), vec2(bar.width(), kh));
|
||||
let kr = ui.interact(knob, egui::Id::new("mixer-vscroll-knob"), Sense::drag());
|
||||
let kr = ui.interact(knob, egui::Id::unique("mixer-vscroll-knob"), Sense::drag());
|
||||
ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { t.fader_knob });
|
||||
if kr.dragged() {
|
||||
voff = (voff + kr.drag_delta().y * (need + 4.0) / bar.height()).clamp(0.0, max_voff);
|
||||
@@ -343,7 +343,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
fn strip_scroll(app: &mut FilmcraftApp, ui: &egui::Ui, body: Rect, x0: f32, total: f32, avail: f32, key: &str) -> (f32, f32) {
|
||||
let t = app.tokens;
|
||||
let max_off = (total - avail).max(0.0);
|
||||
let off_id = egui::Id::new(format!("{key}-hscroll"));
|
||||
let off_id = egui::Id::unique(format!("{key}-hscroll"));
|
||||
let mut off: f32 = ui.data(|d| d.get_temp(off_id)).unwrap_or(0.0);
|
||||
if ui.rect_contains_pointer(body) {
|
||||
let sd = ui.input(|i| i.smooth_scroll_delta);
|
||||
@@ -356,7 +356,7 @@ fn strip_scroll(app: &mut FilmcraftApp, ui: &egui::Ui, body: Rect, x0: f32, tota
|
||||
let kw = (bar.width() * avail / total).max(20.0);
|
||||
let kx = bar.min.x + (bar.width() - kw) * off / max_off;
|
||||
let knob = Rect::from_min_size(pos2(kx, bar.min.y), vec2(kw, bar.height()));
|
||||
let kr = ui.interact(knob, egui::Id::new(format!("{key}-hscroll-knob")), Sense::drag());
|
||||
let kr = ui.interact(knob, egui::Id::unique(format!("{key}-hscroll-knob")), Sense::drag());
|
||||
ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { t.fader_knob });
|
||||
if kr.dragged() {
|
||||
off = (off + kr.drag_delta().x * total / bar.width()).clamp(0.0, max_off);
|
||||
@@ -401,10 +401,10 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
||||
let r = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, SLOT_H - 2.0));
|
||||
let fx = tr.effects.get(k);
|
||||
let text = match fx {
|
||||
Some(e) => format!("{}{}", if e.post_fader { "▸ " } else { "" }, e.def().map(|d| d.name).unwrap_or(&e.effect)),
|
||||
Some(e) => format!("{}{}", if e.post_fader { "> " } else { "" }, e.def().map(|d| d.name).unwrap_or(&e.effect)),
|
||||
None => String::new(),
|
||||
};
|
||||
let resp = slot_box(ui, r, &text, fx.is_some(), fx.is_some_and(|e| !e.enabled), &t, egui::Id::new((&ap, "fx", k)));
|
||||
let resp = slot_box(ui, r, &text, fx.is_some(), fx.is_some_and(|e| !e.enabled), &t, egui::Id::unique((&ap, "fx", k)));
|
||||
app.auto.add(&format!("{ap}.fx.{k}"), r, &if text.is_empty() { format!("Effect slot {}", k + 1) } else { text.clone() });
|
||||
let can_add = fx.is_none() && k == tr.effects.len();
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
@@ -495,7 +495,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
||||
y += SLOT_H;
|
||||
continue;
|
||||
}
|
||||
let resp = slot_box(ui, r, &text, snd.is_some(), snd.is_some_and(|s| s.muted), &t, egui::Id::new((&ap, "send", k)));
|
||||
let resp = slot_box(ui, r, &text, snd.is_some(), snd.is_some_and(|s| s.muted), &t, egui::Id::unique((&ap, "send", k)));
|
||||
app.auto.add(&format!("{ap}.send.{k}"), r, &if text.is_empty() { format!("Send slot {}", k + 1) } else { text.clone() });
|
||||
let can_add = snd.is_none() && k == tr.mixer.sends.len();
|
||||
let tg = targets(seq, id);
|
||||
@@ -558,7 +558,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
||||
// ---- input / output
|
||||
if !is_master {
|
||||
let ir = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0));
|
||||
let iresp = crate::widgets::dropdown_text(ui, ir, crate::i18n::t(tr.mixer.input_map.label()), &t, egui::Id::new((&ap, "input")));
|
||||
let iresp = crate::widgets::dropdown_text(ui, ir, crate::i18n::t(tr.mixer.input_map.label()), &t, egui::Id::unique((&ap, "input")));
|
||||
app.auto.add(&format!("{ap}.input"), ir, "Input channel mapping");
|
||||
egui::Popup::menu(&iresp).show(|ui| {
|
||||
for m in InputMap::ALL {
|
||||
@@ -573,7 +573,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
||||
let or = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0));
|
||||
let out_label =
|
||||
tr.mixer.output.map(|o| seq.mix_track(o).map(|s| s.name.clone()).unwrap_or_else(|| tl!("Mix").into())).unwrap_or_else(|| tl!("Mix").into());
|
||||
let oresp = crate::widgets::dropdown_text(ui, or, &out_label, &t, egui::Id::new((&ap, "output")));
|
||||
let oresp = crate::widgets::dropdown_text(ui, or, &out_label, &t, egui::Id::unique((&ap, "output")));
|
||||
app.auto.add(&format!("{ap}.output"), or, "Track output assignment");
|
||||
let tg = targets(seq, id);
|
||||
egui::Popup::menu(&oresp).show(|ui| {
|
||||
@@ -599,7 +599,8 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
||||
let pan = shown(app, &tr, LANE_PAN, cx.now);
|
||||
let kc = pos2(sr.center().x, y + 20.0);
|
||||
let kr = Rect::from_center_size(kc, vec2(38.0, 38.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text(tl!("Pan / balance (drag; double-click: centre)"));
|
||||
let kresp =
|
||||
ui.interact(kr, egui::Id::unique((&ap, "pan")), Sense::click_and_drag()).on_hover_text(tl!("Pan / balance (drag; double-click: centre)"));
|
||||
let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { t.control_handle_dim };
|
||||
ui.painter().circle_stroke(kc, 16.0, Stroke::new(2.0, ring));
|
||||
let ang = (pan / 100.0) as f32 * 135f32.to_radians();
|
||||
@@ -622,7 +623,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
||||
}
|
||||
let vr = Rect::from_center_size(pos2(kc.x, y + 48.0), vec2(56.0, 16.0));
|
||||
let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr).layout(egui::Layout::top_down(egui::Align::Center)));
|
||||
let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "panValue")), pan, 1.0, (-100.0, 100.0), 1, "", &t);
|
||||
let (vresp, nv) = crate::widgets::hot_number(&mut vui, tl!("Pan"), egui::Id::unique((&ap, "panValue")), pan, 1.0, (-100.0, 100.0), 1, "", &t);
|
||||
app.auto.add(&format!("{ap}.panValue"), vresp.rect, &format!("{pan:.1}"));
|
||||
if let Some(v) = nv {
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN, "value": v}));
|
||||
@@ -636,7 +637,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
||||
// ---- automation mode
|
||||
let mr = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0));
|
||||
let mode = tr.mixer.mode;
|
||||
let mresp = crate::widgets::dropdown_text(ui, mr, crate::i18n::t(mode.label()), &t, egui::Id::new((&ap, "mode")));
|
||||
let mresp = crate::widgets::dropdown_text(ui, mr, crate::i18n::t(mode.label()), &t, egui::Id::unique((&ap, "mode")));
|
||||
if cx.recording && mode.writes() {
|
||||
ui.painter().rect_stroke(mr, 4.0, Stroke::new(1.0, RED), StrokeKind::Inside);
|
||||
}
|
||||
@@ -658,7 +659,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
||||
let bw = 18.0;
|
||||
let bx = sr.center().x - bw * 1.5 - 6.0;
|
||||
let b = |i: f32| Rect::from_min_size(pos2(bx + i * (bw + 6.0), y), vec2(bw, 16.0));
|
||||
let m_r = crate::widgets::letter_toggle(ui, b(0.0), "M", muted, MUTE_ON, &t, egui::Id::new((&ap, "M")));
|
||||
let m_r = crate::widgets::letter_toggle(ui, b(0.0), "M", muted, MUTE_ON, &t, egui::Id::unique((&ap, "M")));
|
||||
app.auto.add(&format!("{ap}.mute"), b(0.0), "Mute Track");
|
||||
if m_r.clicked() {
|
||||
if cx.recording && mode.writes() {
|
||||
@@ -669,21 +670,21 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
||||
cx.cmd("mixer.setStrip", json!({"strip": sid, "muted": !tr.muted}));
|
||||
}
|
||||
}
|
||||
let s_r = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::new((&ap, "S")));
|
||||
let s_r = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::unique((&ap, "S")));
|
||||
app.auto.add(&format!("{ap}.solo"), b(1.0), "Solo Track");
|
||||
if s_r.clicked() {
|
||||
cx.cmd("mixer.setStrip", json!({"strip": sid, "solo": !tr.solo}));
|
||||
}
|
||||
let is_sub = seq.submix_tracks.iter().any(|s| s.id == id);
|
||||
if is_sub {
|
||||
let r_r = crate::widgets::letter_toggle(ui, b(2.0), "◆", tr.mixer.solo_safe, Color32::from_gray(0xc8), &t, egui::Id::new((&ap, "safe")))
|
||||
let r_r = crate::widgets::letter_toggle(ui, b(2.0), "◆", tr.mixer.solo_safe, Color32::from_gray(0xc8), &t, egui::Id::unique((&ap, "safe")))
|
||||
.on_hover_text(tl!("Solo safe"));
|
||||
app.auto.add(&format!("{ap}.soloSafe"), b(2.0), "Solo Safe");
|
||||
if r_r.clicked() {
|
||||
cx.cmd("mixer.setStrip", json!({"strip": sid, "soloSafe": !tr.mixer.solo_safe}));
|
||||
}
|
||||
} else {
|
||||
let r_r = crate::widgets::letter_toggle(ui, b(2.0), "R", tr.mixer.record_arm, RED, &t, egui::Id::new((&ap, "R")))
|
||||
let r_r = crate::widgets::letter_toggle(ui, b(2.0), "R", tr.mixer.record_arm, RED, &t, egui::Id::unique((&ap, "R")))
|
||||
.on_hover_text(tl!("Enable track for recording"));
|
||||
app.auto.add(&format!("{ap}.record"), b(2.0), "Enable Track for Recording");
|
||||
if r_r.clicked() {
|
||||
@@ -715,7 +716,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
||||
let cy = ypos(vol);
|
||||
let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0));
|
||||
let fresp =
|
||||
ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()).on_hover_text(tl!("Volume (drag; double-click: 0 dB)"));
|
||||
ui.interact(cap.expand(3.0), egui::Id::unique((&ap, "fader")), Sense::click_and_drag()).on_hover_text(tl!("Volume (drag; double-click: 0 dB)"));
|
||||
let held = app.session.previews.live.get(id, LANE_VOLUME).is_some();
|
||||
let col = if fresp.dragged() || held { t.accent } else { t.control_handle };
|
||||
ui.painter().rect_filled(cap, 3.0, t.control_handle_bg);
|
||||
@@ -758,7 +759,8 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s
|
||||
// fader value (hot text)
|
||||
let vr = Rect::from_min_size(pos2(x + 4.0, sr.max.y - name_h - value_h), vec2(56.0, value_h));
|
||||
let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr));
|
||||
let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t);
|
||||
let (vresp, nv) =
|
||||
crate::widgets::hot_number(&mut vui, tl!("Volume"), egui::Id::unique((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t);
|
||||
app.auto.add(&format!("{ap}.value"), vresp.rect, &db_text(vol));
|
||||
if let Some(v) = nv {
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_VOLUME, "value": v}));
|
||||
@@ -786,7 +788,7 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr
|
||||
let py = shown(app, tr, LANE_PAN51_Y, cx.now);
|
||||
let sq = Rect::from_min_size(pos2(r.min.x, r.min.y), vec2(46.0, 46.0));
|
||||
let resp =
|
||||
ui.interact(sq, egui::Id::new((ap, "pan51")), Sense::click_and_drag()).on_hover_text(tl!("5.1 panner (drag the puck; double-click: front centre)"));
|
||||
ui.interact(sq, egui::Id::unique((ap, "pan51")), Sense::click_and_drag()).on_hover_text(tl!("5.1 panner (drag the puck; double-click: front centre)"));
|
||||
let p = ui.painter();
|
||||
p.rect_filled(sq, 3.0, t.panner_bg);
|
||||
p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { t.panner_border }), StrokeKind::Inside);
|
||||
@@ -826,7 +828,7 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr
|
||||
small_text(ui, pos2(col_x, r.min.y + 6.0), Align2::LEFT_CENTER, tl!("Center"), 9.0, t.text_dim);
|
||||
let cr = Rect::from_min_size(pos2(col_x, r.min.y + 12.0), vec2(r.max.x - col_x, 14.0));
|
||||
let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr));
|
||||
let (cresp, nc) = crate::widgets::hot_number(&mut cui, egui::Id::new((ap, "pan51.center")), center, 1.0, (0.0, 100.0), 0, "%", &t);
|
||||
let (cresp, nc) = crate::widgets::hot_number(&mut cui, tl!("Center"), egui::Id::unique((ap, "pan51.center")), center, 1.0, (0.0, 100.0), 0, "%", &t);
|
||||
app.auto.add(&format!("{ap}.pan51.center"), cresp.rect, &format!("Center {center:.0} %"));
|
||||
if let Some(v) = nc {
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_CENTER, "value": v}));
|
||||
@@ -835,7 +837,7 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr
|
||||
let lr = Rect::from_min_size(pos2(col_x, r.min.y + 38.0), vec2(r.max.x - col_x, 14.0));
|
||||
let mut lui = ui.new_child(egui::UiBuilder::new().max_rect(lr));
|
||||
let (lresp, nl) =
|
||||
crate::widgets::hot_number(&mut lui, egui::Id::new((ap, "pan51.lfe")), lfe.max(FADER_MIN_DB), 0.5, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t);
|
||||
crate::widgets::hot_number(&mut lui, "LFE", egui::Id::unique((ap, "pan51.lfe")), lfe.max(FADER_MIN_DB), 0.5, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t);
|
||||
app.auto.add(&format!("{ap}.pan51.lfe"), lresp.rect, &format!("LFE {}", db_text(lfe)));
|
||||
if let Some(v) = nl {
|
||||
cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_LFE, "value": v}));
|
||||
@@ -863,7 +865,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx)
|
||||
let mut x = row.center().x - bw * buttons.len() as f32 / 2.0;
|
||||
for (icon, cmd, tip) in buttons {
|
||||
let r = Rect::from_min_size(pos2(x, row.min.y + 4.0), vec2(bw - 2.0, 26.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("mixer-transport", cmd)), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique(("mixer-transport", cmd)), Sense::click()).on_hover_text(tip);
|
||||
app.auto.add(&format!("mixer.transport.{cmd}"), r, tip);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
@@ -912,7 +914,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let meters = poll_meters(app, ui);
|
||||
let now = app.session.playhead();
|
||||
let mut acts: Vec<(String, Value)> = Vec::new();
|
||||
let kf_id = egui::Id::new("clip-mixer-keyframes");
|
||||
let kf_id = egui::Id::unique("clip-mixer-keyframes");
|
||||
let mut write_kf: HashMap<u64, bool> = ui.data(|d| d.get_temp(kf_id)).unwrap_or_default();
|
||||
// Like Premiere, the strips narrow so that every track fits the panel; below the narrowest
|
||||
// usable strip the row scrolls instead, so no track is out of reach (#1922).
|
||||
@@ -962,7 +964,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// pan knob
|
||||
let kc = pos2(sr.center().x, sr.min.y + 36.0);
|
||||
let kr = Rect::from_center_size(kc, vec2(30.0, 30.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag());
|
||||
let kresp = ui.interact(kr, egui::Id::unique((&ap, "pan")), Sense::click_and_drag());
|
||||
ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(t.control_handle_dim)));
|
||||
let ang = (pan / 100.0) as f32 * 135f32.to_radians();
|
||||
ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(t.control_handle_dim)));
|
||||
@@ -983,7 +985,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// automation mode (Clip Mixer)
|
||||
let mode = filmcraft_engine::mixer::clip_mode(&app.session, tr.id);
|
||||
let mdr = Rect::from_min_size(pos2(sr.min.x + 4.0, sr.min.y + 2.0), vec2(sr.width() - 8.0, 16.0));
|
||||
let mdresp = crate::widgets::dropdown_text(ui, mdr, crate::i18n::t(mode.label()), &t, egui::Id::new((&ap, "mode")));
|
||||
let mdresp = crate::widgets::dropdown_text(ui, mdr, crate::i18n::t(mode.label()), &t, egui::Id::unique((&ap, "mode")));
|
||||
app.auto.add(&format!("{ap}.mode"), mdr, &format!("Clip automation mode: {}", mode.label()));
|
||||
egui::Popup::menu(&mdresp).show(|ui| {
|
||||
for m in AutomationMode::ALL {
|
||||
@@ -998,18 +1000,18 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let y = sr.min.y + 72.0;
|
||||
let step = ((sr.width() - 6.0 - 18.0) / 2.0).min(24.0);
|
||||
let b = |k: f32| Rect::from_min_size(pos2(sr.center().x - step - 9.0 + k * step, y), vec2(18.0, 16.0));
|
||||
let mr = crate::widgets::letter_toggle(ui, b(0.0), "M", tr.muted, MUTE_ON, &t, egui::Id::new((&ap, "M")));
|
||||
let mr = crate::widgets::letter_toggle(ui, b(0.0), "M", tr.muted, MUTE_ON, &t, egui::Id::unique((&ap, "M")));
|
||||
app.auto.add(&format!("{ap}.mute"), b(0.0), "Mute Track");
|
||||
if mr.clicked() {
|
||||
acts.push(("mixer.setStrip".into(), json!({"strip": tr.id.0, "muted": !tr.muted})));
|
||||
}
|
||||
let so = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::new((&ap, "S")));
|
||||
let so = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::unique((&ap, "S")));
|
||||
app.auto.add(&format!("{ap}.solo"), b(1.0), "Solo Track");
|
||||
if so.clicked() {
|
||||
acts.push(("mixer.setStrip".into(), json!({"strip": tr.id.0, "solo": !tr.solo})));
|
||||
}
|
||||
let kresp =
|
||||
crate::widgets::icon_toggle(ui, b(2.0), Icon::Keyframe, kf, &t, egui::Id::new((&ap, "kf")), Some(t.accent)).on_hover_text(tl!("Write keyframes"));
|
||||
let kresp = crate::widgets::icon_toggle(ui, b(2.0), Icon::Keyframe, kf, &t, egui::Id::unique((&ap, "kf")), Some(t.accent))
|
||||
.on_hover_text(tl!("Write keyframes"));
|
||||
app.auto.add(&format!("{ap}.keyframe"), b(2.0), "Write Keyframes");
|
||||
if kresp.clicked() {
|
||||
write_kf.insert(tr.id.0, !kf);
|
||||
@@ -1026,7 +1028,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.painter().rect_filled(track, 2.0, t.control_well);
|
||||
let cy = ypos(vol);
|
||||
let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0));
|
||||
let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag());
|
||||
let fresp = ui.interact(cap.expand(3.0), egui::Id::unique((&ap, "fader")), Sense::click_and_drag());
|
||||
ui.painter().rect_filled(cap, 3.0, t.control_handle_bg);
|
||||
ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(t.control_handle)), StrokeKind::Inside);
|
||||
ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(t.control_handle)));
|
||||
@@ -1050,7 +1052,8 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if !active {
|
||||
vui.set_opacity(0.4);
|
||||
}
|
||||
let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t);
|
||||
let (vresp, nv) =
|
||||
crate::widgets::hot_number(&mut vui, tl!("Volume"), egui::Id::unique((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t);
|
||||
app.auto.add(&format!("{ap}.value"), vresp.rect, &db_text(vol));
|
||||
if let (Some(v), true) = (nv, active) {
|
||||
set(&mut acts, "volume", "level", v, !vresp.dragged() || vresp.drag_started());
|
||||
|
||||
@@ -124,7 +124,7 @@ enum DragPayload {
|
||||
}
|
||||
|
||||
fn payload_id() -> egui::Id {
|
||||
egui::Id::new("filmcraft-drag-payload")
|
||||
egui::Id::unique("filmcraft-drag-payload")
|
||||
}
|
||||
|
||||
pub fn start_drag_source(ui: &egui::Ui, source: source_drag::SourceDrag) {
|
||||
@@ -206,7 +206,7 @@ pub fn clear_drag(ui: &egui::Ui) {
|
||||
}
|
||||
|
||||
fn cancelled_id() -> egui::Id {
|
||||
egui::Id::new("filmcraft-drag-cancelled")
|
||||
egui::Id::unique("filmcraft-drag-cancelled")
|
||||
}
|
||||
|
||||
/// Abandon the drag in progress (Escape, #580): nothing is dropped, and nothing restarts it until
|
||||
@@ -255,7 +255,7 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) {
|
||||
_ => filmcraft_project::find_effect(e).map(|d| d.name.to_string()).unwrap_or_default(),
|
||||
},
|
||||
};
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("drag-ghost")));
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("drag-ghost")));
|
||||
let r = Rect::from_min_size(p + egui::vec2(12.0, 8.0), egui::vec2(label.len() as f32 * 7.0 + 16.0, 20.0));
|
||||
painter.rect_filled(r, 4.0, Color32::from_black_alpha(200));
|
||||
painter.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), Color32::WHITE);
|
||||
@@ -263,7 +263,7 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) {
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
// cleared next frame so drop targets see the release this frame
|
||||
let f = ctx.cumulative_frame_nr();
|
||||
let k = egui::Id::new("drag-release-frame");
|
||||
let k = egui::Id::unique("drag-release-frame");
|
||||
match ctx.data(|d| d.get_temp::<u64>(k)) {
|
||||
Some(prev) if prev < f => {
|
||||
clear_drag(ui);
|
||||
@@ -284,7 +284,7 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
||||
drag_ghost(app, ui);
|
||||
// bins opened in new windows (Project panel)
|
||||
project::floating(app, ui.ctx());
|
||||
let id = egui::Id::new("panel-menu");
|
||||
let id = egui::Id::unique("panel-menu");
|
||||
let Some((p, pos)) = ui.ctx().data(|d| d.get_temp::<(PanelKind, egui::Pos2)>(id)) else { return };
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
|
||||
@@ -358,7 +358,7 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
// A click elsewhere or Escape closes the menu. The click that opened it is over the tab, not
|
||||
// the menu, and must not close it again in the same frame.
|
||||
let fresh = ui.ctx().data(|d| d.get_temp::<u64>(egui::Id::new("panel-menu-opened"))) == Some(ui.ctx().cumulative_frame_nr());
|
||||
let fresh = ui.ctx().data(|d| d.get_temp::<u64>(egui::Id::unique("panel-menu-opened"))) == Some(ui.ctx().cumulative_frame_nr());
|
||||
if close || (!fresh && area.response.clicked_elsewhere()) || crate::widgets::escape_closes(ui.ctx()) {
|
||||
ui.ctx().data_mut(|d| d.remove::<(PanelKind, egui::Pos2)>(id));
|
||||
}
|
||||
|
||||
@@ -285,11 +285,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
||||
}
|
||||
// A click on the Program monitor around the picture lets go of what is selected on it (#683;
|
||||
// in the picture the overlays do that). Under the picture and the overlays, so they come first.
|
||||
if which == Which::Program && show_picture && ui.interact(video_area, egui::Id::new((prefix, "background")), Sense::click()).clicked() {
|
||||
if which == Which::Program && show_picture && ui.interact(video_area, egui::Id::unique((prefix, "background")), Sense::click()).clicked() {
|
||||
crate::panels::graphics::deselect_on_monitor(app, ui);
|
||||
}
|
||||
// Click/drag in the picture: the Hand tool pans a magnified picture, otherwise focus.
|
||||
let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() });
|
||||
let pic_resp = ui.interact(pic, egui::Id::unique((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() });
|
||||
app.auto.add(&format!("{prefix}.picture"), pic.intersect(video_area), "picture");
|
||||
// (Comparison View always fits both frames)
|
||||
if show_picture && !compare {
|
||||
@@ -326,7 +326,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
||||
let tc = format_time(time, rate, drop_frame, TimeDisplay::Timecode, 48000);
|
||||
let tc_rect = Rect::from_min_size(row1.min, vec2(110.0, row1.height()));
|
||||
let min = if which == Which::Program { Tick::ZERO } else { origin };
|
||||
match crate::widgets::timecode_field(ui, egui::Id::new((prefix, "timecode")), tc_rect, &tc, time, min, rate, drop_frame, t.timecode) {
|
||||
match crate::widgets::timecode_field(ui, egui::Id::unique((prefix, "timecode")), tc_rect, &tc, time, min, rate, drop_frame, t.timecode) {
|
||||
Some(Ok(to)) => {
|
||||
let cmd = if which == Which::Program { "playhead.set" } else { "source.setPlayhead" };
|
||||
if let Err(e) = app.session.execute(cmd, json!({"time": to.0})) {
|
||||
@@ -353,7 +353,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
||||
None => tl!("Fit").to_string(),
|
||||
Some(z) => format!("{}%", (z * 100.0).round() as i32),
|
||||
};
|
||||
let zresp = crate::widgets::dropdown_text(ui, zr, &zoom_label, &t, egui::Id::new((prefix, "zoom")));
|
||||
let zresp = crate::widgets::dropdown_text(ui, zr, &zoom_label, &t, egui::Id::unique((prefix, "zoom")));
|
||||
app.auto.add(&format!("{prefix}.zoom"), zr, "Select Zoom Level");
|
||||
monitor_view::zoom_menu(app, &zresp, which);
|
||||
let res = mv.res;
|
||||
@@ -371,7 +371,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
||||
if separate_source_drag {
|
||||
crate::panels::source_drag::controls(app, ui, Rect::from_min_size(pos2(row1.center().x - 52.0, settings_y + 26.0), vec2(104.0, 24.0)));
|
||||
}
|
||||
let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::new((prefix, "res")));
|
||||
let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::unique((prefix, "res")));
|
||||
app.auto.add(&format!("{prefix}.resolution"), rr, "Select Playback Resolution");
|
||||
egui::Popup::menu(&rresp).show(|ui| {
|
||||
for r in PlaybackRes::ALL {
|
||||
@@ -381,7 +381,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
||||
}
|
||||
});
|
||||
let wr = Rect::from_min_size(pos2(rr.max.x + 6.0, settings_y + 1.0), vec2(22.0, 22.0));
|
||||
let wresp = ui.interact(wr, egui::Id::new((prefix, "wrench")), Sense::click());
|
||||
let wresp = ui.interact(wr, egui::Id::unique((prefix, "wrench")), Sense::click());
|
||||
icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.tab_text_active } else { t.icon });
|
||||
app.auto.add(&format!("{prefix}.settings"), wr, "Settings");
|
||||
egui::Popup::menu(&wresp).show(|ui| {
|
||||
@@ -527,7 +527,7 @@ const EYEDROPPER_SCALE: f32 = 0.5;
|
||||
fn eyedropper(app: &mut FilmcraftApp, ui: &mut egui::Ui, resp: &egui::Response, pic: Rect, target: Target, rate: filmcraft_time::FrameRate, time: Tick) {
|
||||
let Some(armed) = app.ui.eyedropper.clone() else { return };
|
||||
let ctx = ui.ctx().clone();
|
||||
let pending_id = egui::Id::new("eyedropper-pending");
|
||||
let pending_id = egui::Id::unique("eyedropper-pending");
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.eyedropper = None;
|
||||
ctx.data_mut(|d| d.remove::<EyedropperPick>(pending_id));
|
||||
@@ -684,7 +684,7 @@ fn time_zoom_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Wh
|
||||
}
|
||||
app.auto.add(&format!("{prefix}.zoomBar"), thumb, "zoom scroll bar");
|
||||
app.auto.add(&format!("{prefix}.zoomBar.track"), bar, "zoom scroll bar track");
|
||||
let resp = ui.interact(bar, egui::Id::new((prefix, "zoomBar")), Sense::click_and_drag());
|
||||
let resp = ui.interact(bar, egui::Id::unique((prefix, "zoomBar")), Sense::click_and_drag());
|
||||
// the start that brings the thumb's middle under `x`
|
||||
let jump = |x: f32| ((x - bar.min.x - w / 2.0) as f64 * per_point).clamp(0.0, range);
|
||||
let mut set = None;
|
||||
@@ -696,7 +696,7 @@ fn time_zoom_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Wh
|
||||
}
|
||||
// (a drag is known to be one only after the pointer has moved: what was grabbed is what was
|
||||
// under the pointer when the button went down)
|
||||
let drag_id = egui::Id::new((prefix, "zoomBar-drag"));
|
||||
let drag_id = egui::Id::unique((prefix, "zoomBar-drag"));
|
||||
if resp.drag_started()
|
||||
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
|
||||
{
|
||||
@@ -763,7 +763,7 @@ fn mini_timeline(
|
||||
let xof = |tk: Tick| bar.min.x + (((tk - shown.0).0 as f64 / dur) as f32).clamp(0.0, 1.0) * bar.width();
|
||||
// a time off a zoomed ruler is not drawn (past either end of it all, it sits at that end)
|
||||
let shows = |tk: Tick| (tk >= shown.0 || shown.0 <= origin) && (tk <= shown.1 || shown.1 >= end);
|
||||
let resp = ui.interact(bar, egui::Id::new((which as u8, "scrub")), Sense::click_and_drag());
|
||||
let resp = ui.interact(bar, egui::Id::unique((which as u8, "scrub")), Sense::click_and_drag());
|
||||
app.auto.add(if which == Which::Program { "program.scrubBar" } else { "source.scrubBar" }, bar, "scrub bar");
|
||||
let interaction = crate::panels::source_range::interact(app, ui, bar, which, shown);
|
||||
let p = ui.painter();
|
||||
@@ -945,7 +945,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
|
||||
// Keep the existing button-editor affordance only when it has its own space.
|
||||
// Customize this monitor's transport separately from the other monitor.
|
||||
let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 5.0), vec2(22.0, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor"));
|
||||
let resp = ui.interact(r, egui::Id::unique((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor"));
|
||||
app.auto.add(&format!("{prefix}.transport.editor"), r, "Button Editor");
|
||||
icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim });
|
||||
let mut edited_hidden = hidden.clone();
|
||||
@@ -1009,8 +1009,8 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
|
||||
}
|
||||
if !fit.overflow.is_empty() {
|
||||
let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(TRANSPORT_BUTTON, TRANSPORT_BUTTON));
|
||||
let response = ui.interact(rect, egui::Id::new((prefix, "transport-more")), Sense::click()).on_hover_text(tl!("More transport controls"));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("More transport controls")));
|
||||
let response = ui.interact(rect, egui::Id::unique((prefix, "transport-more")), Sense::click()).on_hover_text(tl!("More transport controls"));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("More transport controls")));
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
@@ -1043,7 +1043,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
|
||||
|
||||
fn transport_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, prefix: &str, icon: Icon, command: &str, label: &str) -> bool {
|
||||
let t = app.tokens;
|
||||
let response = ui.interact(rect, egui::Id::new((prefix, command)), Sense::click()).on_hover_text(label);
|
||||
let response = ui.interact(rect, egui::Id::unique((prefix, command)), Sense::click()).on_hover_text(label);
|
||||
app.auto.add_response(&format!("{prefix}.transport.{command}"), &response, label);
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
|
||||
@@ -385,7 +385,7 @@ pub fn zoom_pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area:
|
||||
}
|
||||
let mut d = Vec2::ZERO;
|
||||
// a middle-button drag that began over the picture pans it, whatever the tool
|
||||
let held = egui::Id::new((prefix(w), "middle-pan"));
|
||||
let held = egui::Id::unique((prefix(w), "middle-pan"));
|
||||
let (pressed, down, moved) = ui.input(|i| (i.pointer.button_pressed(egui::PointerButton::Middle), i.pointer.middle_down(), i.pointer.delta()));
|
||||
let panning = down && (ui.data(|m| m.get_temp::<bool>(held)).unwrap_or(false) || (pressed && ui.rect_contains_pointer(area)));
|
||||
ui.data_mut(|m| m.insert_temp(held, panning));
|
||||
@@ -394,7 +394,7 @@ pub fn zoom_pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area:
|
||||
d += moved;
|
||||
}
|
||||
if app.ui.tool == Tool::Hand {
|
||||
let resp = ui.interact(area, egui::Id::new((prefix(w), "pan")), Sense::drag());
|
||||
let resp = ui.interact(area, egui::Id::unique((prefix(w), "pan")), Sense::drag());
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
||||
}
|
||||
@@ -506,9 +506,9 @@ pub fn rulers(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, top: Rect, le
|
||||
}
|
||||
}
|
||||
}
|
||||
let drag_id = egui::Id::new((pfx, "new-guide"));
|
||||
let drag_id = egui::Id::unique((pfx, "new-guide"));
|
||||
for (r, name, vertical) in [(top, "top", false), (left, "left", true)] {
|
||||
let resp = ui.interact(r, egui::Id::new((pfx, "ruler", name)), Sense::drag());
|
||||
let resp = ui.interact(r, egui::Id::unique((pfx, "ruler", name)), Sense::drag());
|
||||
app.auto.add(&format!("{pfx}.ruler.{name}"), r, if vertical { "left ruler" } else { "top ruler" });
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
|
||||
@@ -560,13 +560,13 @@ pub fn guides(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, p
|
||||
if v.lock_guides {
|
||||
continue;
|
||||
}
|
||||
let resp = ui.interact(hit, egui::Id::new((pfx, "guide", i)), Sense::drag());
|
||||
let resp = ui.interact(hit, egui::Id::unique((pfx, "guide", i)), Sense::drag());
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(if g.vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
|
||||
}
|
||||
let left = egui::PointerButton::Primary;
|
||||
// egui ends a drag on Escape: that stop puts the guide back where it was (#580)
|
||||
let before_id = egui::Id::new((pfx, "guide-before-drag", i));
|
||||
let before_id = egui::Id::unique((pfx, "guide-before-drag", i));
|
||||
if resp.drag_started_by(left) {
|
||||
ui.data_mut(|d| d.insert_temp(before_id, g.position));
|
||||
}
|
||||
@@ -651,7 +651,7 @@ pub fn waveform(
|
||||
}
|
||||
let x = xof(time);
|
||||
p.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, t.playhead));
|
||||
let resp = ui.interact(area, egui::Id::new("source-waveform"), Sense::click_and_drag());
|
||||
let resp = ui.interact(area, egui::Id::unique("source-waveform"), Sense::click_and_drag());
|
||||
app.auto.add("source.waveform", area, "audio waveform");
|
||||
// Horizontal waveform drags scrub; dragging vertically lifts the marked audio to the timeline.
|
||||
let delta = resp.drag_delta();
|
||||
@@ -694,7 +694,7 @@ pub fn compare_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, rate: f
|
||||
] {
|
||||
let r = Rect::from_min_max(pos2(x - 34.0, bar.min.y + 1.0), pos2(x - 2.0, bar.max.y - 1.0));
|
||||
x -= 34.0;
|
||||
let resp = ui.interact(r, egui::Id::new(("compare", key)), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique(("compare", key)), Sense::click()).on_hover_text(tip);
|
||||
ui.painter().rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.field_bg });
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(10.0), t.text);
|
||||
app.auto.add(&format!("program.compare.{key}"), r, tip);
|
||||
@@ -801,7 +801,7 @@ fn push(e: &mut Elems, id: &str, r: &egui::Response, label: &str) {
|
||||
/// Add Guide… / Save Guides as Template… / Manage Guides… dialogs.
|
||||
pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.guide_dialog.clone() else { return };
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("guide-dialog-before-drag"), &mut d);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("guide-dialog-before-drag"), &mut d);
|
||||
let mut elems: Elems = Vec::new();
|
||||
let mut close = crate::widgets::escape_closes(ctx);
|
||||
let mut act: Option<(String, Value)> = None;
|
||||
@@ -812,7 +812,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
GuideDialog::Manage { .. } => ("Manage Guides", tl!("Manage Guides")),
|
||||
};
|
||||
egui::Window::new(shown)
|
||||
.id(egui::Id::new(title))
|
||||
.id(egui::Id::unique(title))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(Align2::CENTER_CENTER)
|
||||
|
||||
@@ -95,7 +95,7 @@ fn methods() -> [(&'static str, &'static str); 5] {
|
||||
/// Draw the open Synchronize / Merge Clips / Create Multi-Camera dialog.
|
||||
pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.sync_dialog.clone() else { return };
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("sync-dialog-before-drag"), &mut d);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("sync-dialog-before-drag"), &mut d);
|
||||
let (title, pre) = match d.kind.as_str() {
|
||||
"merge" => (tl!("Merge Clips"), "merge"),
|
||||
"multicam" => (tl!("Create Multi-Camera Source Sequence"), "mcam"),
|
||||
@@ -106,7 +106,7 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let accent = app.tokens.accent;
|
||||
let names: Vec<String> = d.items.iter().filter_map(|i| app.session.project.item(ItemId(*i)).map(|it| it.name.clone())).collect();
|
||||
egui::Window::new(title)
|
||||
.id(egui::Id::new(("sync-dialog", pre)))
|
||||
.id(egui::Id::unique(("sync-dialog", pre)))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(440.0)
|
||||
@@ -116,7 +116,7 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
if pre != "sync" {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(if pre == "merge" { tl!("Clip Name:") } else { tl!("Sequence Name:") });
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)), "Sequence Name");
|
||||
push(&mut elems, format!("{pre}.name"), &r, "name");
|
||||
});
|
||||
ui.label(RichText::new(tlf!("{n} clip(s): {names}", n = names.len(), names = names.join(", "))).weak().small());
|
||||
@@ -141,9 +141,12 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
push(&mut elems, format!("{pre}.ignoreHours"), &r, "Ignore Hours");
|
||||
}
|
||||
"marker" => {
|
||||
let r = ui.add_enabled(
|
||||
d.method == "marker",
|
||||
egui::TextEdit::singleline(&mut d.marker).hint_text(tl!("first marker")).desired_width(140.0),
|
||||
let r = crate::widgets::accessible_name(
|
||||
ui.add_enabled(
|
||||
d.method == "marker",
|
||||
egui::TextEdit::singleline(&mut d.marker).hint_text(tl!("first marker")).desired_width(140.0),
|
||||
),
|
||||
"first marker",
|
||||
);
|
||||
push(&mut elems, format!("{pre}.marker"), &r, "marker name");
|
||||
}
|
||||
@@ -161,7 +164,10 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
"sync" => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Reference track:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.track).hint_text(tl!("lowest (V1…)")).desired_width(90.0));
|
||||
let r = crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut d.track).hint_text(tl!("lowest (V1…)")).desired_width(90.0)),
|
||||
"Reference track",
|
||||
);
|
||||
push(&mut elems, "sync.track", &r, "reference track");
|
||||
});
|
||||
}
|
||||
@@ -354,7 +360,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) {
|
||||
let cell_i = k - lay.first;
|
||||
let cell = Rect::from_min_size(pic.min + vec2((cell_i % cols) as f32 * cw, (cell_i / cols) as f32 * ch), vec2(cw, ch));
|
||||
let name = cams.cameras.get(angle).map(|c| c.name.clone()).unwrap_or_default();
|
||||
let resp = ui.interact(cell, egui::Id::new(("multicam-angle", k)), Sense::click()).on_hover_text(tlf!("Camera {n} — {name}", n = k + 1, name));
|
||||
let resp = ui.interact(cell, egui::Id::unique(("multicam-angle", k)), Sense::click()).on_hover_text(tlf!("Camera {n} — {name}", n = k + 1, name));
|
||||
app.auto.add(&format!("program.multicam.angle.{}", k + 1), cell, &name);
|
||||
if resp.clicked() {
|
||||
// modifiers held now, or carried by the click event (synthetic input)
|
||||
@@ -385,7 +391,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) {
|
||||
for (id, label, dx, cmd) in [("pagePrev", "◀", -70.0, "multicam.prevPage"), ("pageNext", "▶", 70.0, "multicam.nextPage")] {
|
||||
let r = Rect::from_center_size(mid + vec2(dx, 0.0), vec2(26.0, 18.0));
|
||||
let enabled = if cmd == "multicam.prevPage" { lay.page > 0 } else { lay.page + 1 < lay.pages };
|
||||
let resp = ui.interact(r, egui::Id::new(("multicam-page", id)), Sense::click()).on_hover_text(if dx < 0.0 {
|
||||
let resp = ui.interact(r, egui::Id::unique(("multicam-page", id)), Sense::click()).on_hover_text(if dx < 0.0 {
|
||||
tl!("Previous page")
|
||||
} else {
|
||||
tl!("Next page")
|
||||
@@ -401,7 +407,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) {
|
||||
// record indicator
|
||||
let rr = Rect::from_min_size(area.min + vec2(8.0, 8.0), vec2(64.0, 18.0));
|
||||
let rec_on = app.ui.multicam_record;
|
||||
let resp = ui.interact(rr, egui::Id::new("multicam-record"), Sense::click()).on_hover_text(tl!("Multi-Camera Record On/Off (0)"));
|
||||
let resp = ui.interact(rr, egui::Id::unique("multicam-record"), Sense::click()).on_hover_text(tl!("Multi-Camera Record On/Off (0)"));
|
||||
app.auto.add("program.multicam.record", rr, "Multi-Camera Record");
|
||||
let dot = if recording {
|
||||
Color32::from_rgb(0xe0, 0x3a, 0x3a)
|
||||
@@ -455,7 +461,7 @@ pub fn show_edit_cameras(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let th = thumb_w * q.settings.height.max(1) as f32 / q.settings.width.max(1) as f32;
|
||||
let tokens = app.tokens;
|
||||
egui::Window::new(tl!("Edit Cameras"))
|
||||
.id(egui::Id::new("Edit Cameras"))
|
||||
.id(egui::Id::unique("Edit Cameras"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(420.0)
|
||||
@@ -481,7 +487,10 @@ pub fn show_edit_cameras(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
elems.push((format!("editCameras.thumb.{a}"), tr, format!("Camera {}", a + 1)));
|
||||
ui.label(format!("{}", a + 1));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.names[a]).desired_width(180.0));
|
||||
let r = crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut d.names[a]).desired_width(180.0)),
|
||||
format!("Camera {}", a + 1),
|
||||
);
|
||||
push(&mut elems, format!("editCameras.name.{a}"), &r, "Camera name");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
})
|
||||
.inner;
|
||||
// each focus of the editor starts a new undo step; the edits while it keeps focus join it
|
||||
let typing = egui::Id::new("project-notes-typing");
|
||||
let typing = egui::Id::unique("project-notes-typing");
|
||||
if r.gained_focus() {
|
||||
ui.data_mut(|d| {
|
||||
let n = d.get_temp::<u64>(typing).unwrap_or(0).wrapping_add(1);
|
||||
|
||||
@@ -103,12 +103,12 @@ pub fn save_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut open = true;
|
||||
let mut close = false;
|
||||
let mut apply = false;
|
||||
egui::Window::new(tl!("Save Preset")).id(egui::Id::new("Save Preset")).open(&mut open).collapsible(false).resizable(false).default_width(340.0).show(
|
||||
egui::Window::new(tl!("Save Preset")).id(egui::Id::unique("Save Preset")).open(&mut open).collapsible(false).resizable(false).default_width(340.0).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
let r = ui.text_edit_singleline(&mut d.name);
|
||||
let r = crate::widgets::accessible_name(ui.text_edit_singleline(&mut d.name), tl!("Name"));
|
||||
app.auto.add("savePreset.name", r.rect, "Name");
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
@@ -127,7 +127,7 @@ pub fn save_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.label(tl!("Description:"));
|
||||
let r = ui.text_edit_multiline(&mut d.description);
|
||||
let r = crate::widgets::accessible_name(ui.text_edit_multiline(&mut d.description), tl!("Description"));
|
||||
app.auto.add("savePreset.description", r.rect, "Description");
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -310,7 +310,7 @@ fn tab_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, strip: Rect, docked: &[u
|
||||
_ => false,
|
||||
};
|
||||
let id = k.map_or("project.tab.project".to_string(), |k| format!("project.tab.{k}"));
|
||||
let resp = ui.interact(r, egui::Id::new(&id), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(&id), Sense::click());
|
||||
app.auto.add(&id, r, &label);
|
||||
if is_active || resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, if is_active { t.pressed } else { t.hover });
|
||||
@@ -321,7 +321,7 @@ fn tab_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, strip: Rect, docked: &[u
|
||||
}
|
||||
if let Some(k) = k {
|
||||
let cr = Rect::from_center_size(pos2(r.max.x - 11.0, r.center().y), vec2(12.0, 12.0));
|
||||
let cresp = ui.interact(cr, egui::Id::new(("ptab-close", k)), Sense::click()).on_hover_text(tl!("Close"));
|
||||
let cresp = ui.interact(cr, egui::Id::unique(("ptab-close", k)), Sense::click()).on_hover_text(tl!("Close"));
|
||||
app.auto.add(&format!("project.tab.{k}.close"), cr, "Close");
|
||||
icons::paint(ui.painter(), cr.shrink(2.0), Icon::Close, if cresp.hovered() { t.text } else { t.text_dim });
|
||||
if cresp.clicked() || resp.middle_clicked() {
|
||||
@@ -352,7 +352,7 @@ pub fn floating(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let mut open = true;
|
||||
let root = app.ui.project_panel.tabs[k].bin == app.session.project.root.id.0;
|
||||
egui::Window::new(if root { tlf!("Project: {name}", name = app.session.project.name) } else { tlf!("Bin: {name}", name) })
|
||||
.id(egui::Id::new(("bin-window", k)))
|
||||
.id(egui::Id::unique(("bin-window", k)))
|
||||
.open(&mut open)
|
||||
.default_size(vec2(520.0, 360.0))
|
||||
.min_size(vec2(320.0, 220.0))
|
||||
@@ -397,7 +397,7 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions
|
||||
Inst::Tab(k) => BinId(app.ui.project_panel.tabs[k].bin),
|
||||
};
|
||||
let up = Rect::from_min_size(pos2(head.min.x, head.center().y - 9.0), vec2(22.0, 18.0));
|
||||
let up_resp = ui.interact(up, egui::Id::new((&pre, "up")), Sense::click()).on_hover_text(tl!("Up one level"));
|
||||
let up_resp = ui.interact(up, egui::Id::unique((&pre, "up")), Sense::click()).on_hover_text(tl!("Up one level"));
|
||||
app.auto.add(&format!("{pre}.up"), up, "Up one level");
|
||||
let can_up = v.bin != top;
|
||||
if up_resp.hovered() && can_up {
|
||||
@@ -429,7 +429,7 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions
|
||||
app.ui.project_search = q;
|
||||
app.auto.add(&format!("{pre}.search"), search_rect, "Search");
|
||||
let find = Rect::from_min_size(pos2(search_rect.max.x + 8.0, row.min.y), vec2(24.0, 22.0));
|
||||
let fr = ui.interact(find, egui::Id::new((&pre, "find")), Sense::click()).on_hover_text(tl!("Find…"));
|
||||
let fr = ui.interact(find, egui::Id::unique((&pre, "find")), Sense::click()).on_hover_text(tl!("Find…"));
|
||||
app.auto.add(&format!("{pre}.find"), find, "Find…");
|
||||
if fr.hovered() {
|
||||
ui.painter().rect_filled(find, 3.0, t.hover);
|
||||
@@ -562,7 +562,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action
|
||||
(Icon::Freeform, ViewMode::Freeform, tl!("Freeform View")),
|
||||
] {
|
||||
let r = Rect::from_min_size(pos2(x, bar.min.y + 5.0), vec2(26.0, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new((pre, "pv", tip)), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique((pre, "pv", tip)), Sense::click()).on_hover_text(tip);
|
||||
app.auto.add(&format!("{pre}.view.{mode:?}"), r, tip);
|
||||
if v.mode == mode {
|
||||
ui.painter().rect_filled(r, 4.0, t.pressed);
|
||||
@@ -579,10 +579,10 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action
|
||||
let sw = (bar.width() - 300.0).clamp(40.0, 120.0);
|
||||
let sr = Rect::from_min_size(pos2(x + 8.0, bar.min.y + 8.0), vec2(sw, 16.0));
|
||||
let mut sz = v.icon_size;
|
||||
let resp = ui.put(sr, egui::Slider::new(&mut sz, 48.0..=400.0).show_value(false));
|
||||
let resp = crate::widgets::accessible_name(ui.put(sr, egui::Slider::new(&mut sz, 48.0..=400.0).show_value(false)), tl!("Zoom"));
|
||||
app.auto.add(&format!("{pre}.iconSize"), sr, "Zoom");
|
||||
// a drag ended by Escape goes back to the size it started from (#580)
|
||||
let orig_id = egui::Id::new((pre, "icon-size-before-drag"));
|
||||
let orig_id = egui::Id::unique((pre, "icon-size-before-drag"));
|
||||
if resp.drag_started() {
|
||||
ui.data_mut(|d| d.insert_temp(orig_id, v.icon_size));
|
||||
}
|
||||
@@ -606,7 +606,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action
|
||||
// Sort Icons (Icon view)
|
||||
if v.mode == ViewMode::Icon {
|
||||
let r = Rect::from_min_size(pos2(x, bar.min.y + 5.0), vec2(24.0, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new((pre, "sortIcons")), Sense::click()).on_hover_text(tl!("Sort Icons"));
|
||||
let resp = ui.interact(r, egui::Id::unique((pre, "sortIcons")), Sense::click()).on_hover_text(tl!("Sort Icons"));
|
||||
app.auto.add(&format!("{pre}.sortIcons"), r, "Sort Icons");
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
@@ -623,7 +623,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action
|
||||
(Icon::Automate, "clip.automateToSequence", tl!("Automate to Sequence")),
|
||||
] {
|
||||
let r = Rect::from_min_size(pos2(rx - 24.0, bar.min.y + 5.0), vec2(24.0, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new((pre, "pb", id)), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique((pre, "pb", id)), Sense::click()).on_hover_text(tip);
|
||||
app.auto.add(&format!("{pre}.button.{id}"), r, tip);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
|
||||
@@ -10,12 +10,13 @@ use crate::FilmcraftApp;
|
||||
use crate::panels::project::ProjectDialog;
|
||||
|
||||
fn push(app: &mut FilmcraftApp, id: &str, r: &egui::Response, label: &str) {
|
||||
crate::widgets::accessible_name(r.clone(), label);
|
||||
app.auto.add(id, r.rect, label);
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.project_panel.dialog.clone() else { return };
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("project-dialog-before-drag"), &mut d);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("project-dialog-before-drag"), &mut d);
|
||||
let mut close = false;
|
||||
let mut run: Option<(String, Value)> = None;
|
||||
let title = match &d {
|
||||
@@ -25,7 +26,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
ProjectDialog::FreeformOptions { .. } => tl!("Freeform View Options"),
|
||||
ProjectDialog::SaveArrangement { .. } => tl!("Save Arrangement"),
|
||||
};
|
||||
let mut win = egui::Window::new(title).id(egui::Id::new("project-dialog")).collapsible(false).resizable(false).centered(ctx);
|
||||
let mut win = egui::Window::new(title).id(egui::Id::unique("project-dialog")).collapsible(false).resizable(false).centered(ctx);
|
||||
// a fixed size keeps the two-list dialog from shifting while its lists settle
|
||||
if matches!(d, ProjectDialog::MetadataDisplay { .. }) {
|
||||
win = win.fixed_size(egui::vec2(480.0, 470.0));
|
||||
@@ -36,7 +37,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
ProjectDialog::SavePresetAs { name } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name");
|
||||
push(app, "projectDialog.name", &r, "Name");
|
||||
});
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
@@ -74,7 +75,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
ProjectDialog::SaveArrangement { name, bin } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name");
|
||||
push(app, "projectDialog.name", &r, "Name");
|
||||
});
|
||||
if ok_cancel(app, ui, &mut close) && !name.trim().is_empty() {
|
||||
@@ -124,7 +125,8 @@ fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec
|
||||
let all = filmcraft_engine::project_panel::all_columns(&app.session.project);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Show:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(filter).hint_text(tl!("Search fields")).desired_width(220.0));
|
||||
let r =
|
||||
crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(filter).hint_text(tl!("Search fields")).desired_width(220.0)), "Search fields");
|
||||
push(app, "projectDialog.filter", &r, "Search fields");
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
@@ -217,7 +219,7 @@ fn manage_presets(app: &mut FilmcraftApp, ui: &mut egui::Ui, selected: &mut usiz
|
||||
let filled = presets.get(*selected).is_some_and(Option::is_some);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Name:"));
|
||||
let r = ui.add_enabled(filled, egui::TextEdit::singleline(name).desired_width(200.0));
|
||||
let r = crate::widgets::accessible_name(ui.add_enabled(filled, egui::TextEdit::singleline(name).desired_width(200.0)), "Name");
|
||||
push(app, "projectDialog.name", &r, "Name");
|
||||
let b = ui.add_enabled(filled, egui::Button::new(tl!("Rename")));
|
||||
push(app, "projectDialog.rename", &b, "Rename");
|
||||
|
||||
@@ -414,11 +414,14 @@ fn rename_field(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, item: Option
|
||||
return false;
|
||||
}
|
||||
let mut text = rn.text;
|
||||
let id = egui::Id::new(("rename-field", pre, item, bin));
|
||||
let resp = ui.put(r, egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(font(app))).margin(vec2(3.0, 1.0)));
|
||||
let id = egui::Id::unique(("rename-field", pre, item, bin));
|
||||
let resp = crate::widgets::accessible_name(
|
||||
ui.put(r, egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(font(app))).margin(vec2(3.0, 1.0))),
|
||||
"Rename",
|
||||
);
|
||||
app.auto.add(&format!("{pre}.rename"), r, "Rename");
|
||||
// take keyboard focus the first frame the field is shown
|
||||
let shown = egui::Id::new("project-rename-shown");
|
||||
let shown = egui::Id::unique("project-rename-shown");
|
||||
if ui.ctx().data(|d| d.get_temp::<egui::Id>(shown)) != Some(id) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(shown, id));
|
||||
resp.request_focus();
|
||||
@@ -444,7 +447,7 @@ fn rename_field(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, item: Option
|
||||
/// A second click on the name of the only selected item starts renaming it (after the
|
||||
/// double-click interval, so a double-click still opens it).
|
||||
fn slow_click_rename(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Response, name_rect: Rect, id: ItemId, was_selected: bool) {
|
||||
let key = egui::Id::new("project-rename-pending");
|
||||
let key = egui::Id::unique("project-rename-pending");
|
||||
let now = ui.input(|i| i.time);
|
||||
if resp.double_clicked() {
|
||||
ui.ctx().data_mut(|d| d.remove::<(u64, f64)>(key));
|
||||
@@ -513,13 +516,13 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
|
||||
let hp = ui.painter().with_clip_rect(hr.intersect(ui.clip_rect()));
|
||||
hp.rect_filled(hr, 0.0, t.panel_bg);
|
||||
hp.line_segment([hr.left_bottom(), hr.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
let mut x = rect.min.x + LEAD - out.state.offset.x;
|
||||
let mut x = rect.min.x + LEAD - out.state.clamped_offset().x;
|
||||
let sort = app.session.prefs.project_panel.view.sort.clone();
|
||||
for (i, c) in lc.cols.iter().enumerate() {
|
||||
let cr = Rect::from_min_size(pos2(x, hr.min.y), vec2(c.width, header_h));
|
||||
let vis = cr.intersect(hr);
|
||||
if vis.width() > 1.0 {
|
||||
let id = egui::Id::new((&v.prefix, "col", &c.name));
|
||||
let id = egui::Id::unique((&v.prefix, "col", &c.name));
|
||||
let resp = ui.interact(vis, id, Sense::click()).on_hover_text(crate::i18n::t(&c.name));
|
||||
app.auto.add(&format!("{}.list.header.{}", v.prefix, c.name), vis, &c.name);
|
||||
if resp.hovered() {
|
||||
@@ -551,7 +554,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
|
||||
// resize handle on the column's right edge
|
||||
let hr2 = Rect::from_center_size(pos2(cr.max.x, hr.center().y), vec2(8.0, header_h));
|
||||
if hr2.intersects(hr) {
|
||||
let id = egui::Id::new((&v.prefix, "colsize", &c.name));
|
||||
let id = egui::Id::unique((&v.prefix, "colsize", &c.name));
|
||||
let resp = ui.interact(hr2.intersect(hr), id, Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeColumn);
|
||||
app.auto.add(&format!("{}.list.resize.{}", v.prefix, c.name), hr2.intersect(hr), "Resize column");
|
||||
hp.line_segment([pos2(cr.max.x, hr.min.y + 4.0), pos2(cr.max.x, hr.max.y - 4.0)], Stroke::new(1.0, t.separator));
|
||||
@@ -574,7 +577,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View
|
||||
/// sit on top). Starts the frame's [`note_hit`] list.
|
||||
fn background(ui: &egui::Ui, v: &View) -> egui::Response {
|
||||
ui.data_mut(|d| d.remove::<Vec<(u64, Rect)>>(hits_id(v)));
|
||||
ui.interact(ui.clip_rect(), egui::Id::new((&v.prefix, "background")), Sense::click_and_drag())
|
||||
ui.interact(ui.clip_rect(), egui::Id::unique((&v.prefix, "background")), Sense::click_and_drag())
|
||||
}
|
||||
|
||||
/// Empty space under the rows / cards, and the gaps between them (`bg`): click deselects,
|
||||
@@ -598,7 +601,7 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bg: &egui::R
|
||||
}
|
||||
|
||||
fn hits_id(v: &View) -> egui::Id {
|
||||
egui::Id::new((&v.prefix, "marquee-hits"))
|
||||
egui::Id::unique((&v.prefix, "marquee-hits"))
|
||||
}
|
||||
|
||||
/// Remember where an item's row or card was drawn this frame, for [`marquee`].
|
||||
@@ -612,7 +615,7 @@ fn note_hit(ui: &egui::Ui, v: &View, id: ItemId, r: Rect) {
|
||||
fn marquee(app: &mut FilmcraftApp, ui: &egui::Ui, v: &View, bg: &egui::Response, origin: egui::Pos2, actions: &mut Actions) {
|
||||
// (press point relative to `origin`, selection when the drag began, adds to it)
|
||||
type Drag = (egui::Vec2, Vec<u64>, bool);
|
||||
let key = egui::Id::new((&v.prefix, "marquee"));
|
||||
let key = egui::Id::unique((&v.prefix, "marquee"));
|
||||
let hits: Vec<(u64, Rect)> = ui.data_mut(|d| d.remove_temp(hits_id(v))).unwrap_or_default();
|
||||
let current: Vec<u64> = app.session.state.project_selection.iter().map(|i| i.0).collect();
|
||||
// the left button only: right-click opens the menu, the middle button is not a selection
|
||||
@@ -824,7 +827,7 @@ fn hover_time(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Response, r: R
|
||||
fn card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, id: ItemId, pre: &str, show_name: bool, show_dur: bool, sense: Sense) -> Option<egui::Response> {
|
||||
let t = app.tokens;
|
||||
let it = app.session.project.item(id).cloned()?;
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new((pre, "card", id.0)), sense);
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::unique((pre, "card", id.0)), sense);
|
||||
let th = Rect::from_min_size(r.min, vec2(r.width(), r.width() * 9.0 / 16.0));
|
||||
let selected = app.session.state.project_selection.contains(&id);
|
||||
let card_bg = Rect::from_min_max(th.min - vec2(6.0, 6.0), pos2(th.max.x + 6.0, r.max.y + 2.0));
|
||||
@@ -912,7 +915,7 @@ fn card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, id: ItemId, pre: &st
|
||||
fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &View, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let th = Rect::from_min_size(r.min, vec2(r.width(), r.width() * 9.0 / 16.0));
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new((&v.prefix, "bincard", b.id.0)), Sense::click_and_drag());
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::unique((&v.prefix, "bincard", b.id.0)), Sense::click_and_drag());
|
||||
let selected = app.ui.project_panel.selected_bin == Some(b.id.0);
|
||||
let bg = Rect::from_min_max(th.min - vec2(6.0, 6.0), pos2(th.max.x + 6.0, r.max.y + 2.0));
|
||||
ui.painter().rect_filled(bg, 4.0, if selected { t.tl_header_bg } else { t.panel_bg });
|
||||
@@ -1000,7 +1003,7 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &
|
||||
let opts = app.session.prefs.project_panel.freeform.clone();
|
||||
let mut cards: Vec<Card> = pp::freeform_layout(&app.session.project, Some(v.bin), &opts, rect.width());
|
||||
cards.retain(|c| matches_filter(app, ItemId(c.item), filter));
|
||||
let drag_key = egui::Id::new((&v.prefix, "ff-drag"));
|
||||
let drag_key = egui::Id::unique((&v.prefix, "ff-drag"));
|
||||
// (dragged item, offset so far)
|
||||
let dragging: Option<(u64, egui::Vec2)> = ui.ctx().data(|d| d.get_temp(drag_key));
|
||||
let sel: Vec<u64> = app.session.state.project_selection.iter().map(|i| i.0).collect();
|
||||
|
||||
@@ -68,7 +68,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
None => crate::dock::placeholder(ui, area, &t, "…"),
|
||||
}
|
||||
elems.push(("reference.picture".into(), area, "reference scopes".into()));
|
||||
let resp = ui.interact(area, egui::Id::new("reference-area"), Sense::click());
|
||||
let resp = ui.interact(area, egui::Id::unique("reference-area"), Sense::click());
|
||||
egui::Popup::context_menu(&resp).show(|ui| {
|
||||
for k in ScopeKind::ALL {
|
||||
let on = st.scopes.contains(&k);
|
||||
@@ -101,7 +101,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let w = if icon.is_some() { 24.0 } else { galley_w + 16.0 };
|
||||
let r = Rect::from_min_size(pos2(x, bar.center().y - 11.0), vec2(w, 22.0));
|
||||
x = r.max.x + 4.0;
|
||||
let resp = ui.interact(r, egui::Id::new(("reference-btn", id.to_string())), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("reference-btn", id.to_string())), Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 4.0, t.pressed);
|
||||
} else if resp.hovered() {
|
||||
|
||||
@@ -20,7 +20,7 @@ struct Draft {
|
||||
}
|
||||
|
||||
fn draft_id() -> egui::Id {
|
||||
egui::Id::new("remix-properties-draft")
|
||||
egui::Id::unique("remix-properties-draft")
|
||||
}
|
||||
|
||||
/// Open the dialog for the selected remixed clip (the menu entry without parameters).
|
||||
@@ -44,12 +44,12 @@ pub fn is_open(ctx: &egui::Context) -> bool {
|
||||
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
let Some(Some(mut d)) = ctx.data(|m| m.get_temp::<Option<Draft>>(draft_id())) else { return };
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("remix-dialog-before-drag"), &mut d);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("remix-dialog-before-drag"), &mut d);
|
||||
let mut close = false;
|
||||
let mut apply = false;
|
||||
let mut elems: Vec<(String, egui::Rect, String)> = Vec::new();
|
||||
egui::Window::new(tl!("Remix Properties"))
|
||||
.id(egui::Id::new("Remix Properties"))
|
||||
.id(egui::Id::unique("Remix Properties"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
|
||||
@@ -57,7 +57,7 @@ pub fn frame_signal(app: &mut FilmcraftApp, ctx: &egui::Context, slot: &str, fra
|
||||
let pq = hdr_seq && space == ColorSpace::Rec2100;
|
||||
let rate = q.settings.frame_rate;
|
||||
let rev = app.session.revision;
|
||||
let id = egui::Id::new(("scope-signal", slot));
|
||||
let id = egui::Id::unique(("scope-signal", slot));
|
||||
let cached: Option<(u64, Arc<Signal>, bool)> = ctx.data(|d| d.get_temp(id));
|
||||
let key = hash_of((seq_id.0, frame, rev, pq));
|
||||
if let Some((k, s, p)) = &cached
|
||||
@@ -99,7 +99,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
None => crate::dock::placeholder(ui, area, &t, if app.session.state.active_sequence.is_some() { "…" } else { tl!("(no sequence)") }),
|
||||
}
|
||||
// right-click anywhere on the scopes: the settings menu
|
||||
let resp = ui.interact(area, egui::Id::new("scopes-area"), Sense::click());
|
||||
let resp = ui.interact(area, egui::Id::unique("scopes-area"), Sense::click());
|
||||
egui::Popup::context_menu(&resp).show(|ui| settings_menu(app, ui));
|
||||
footer(app, ui, Rect::from_min_max(pos2(rect.min.x, area.max.y), rect.max), sf.as_ref().map(|s| s.space));
|
||||
}
|
||||
@@ -134,7 +134,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, space: Option<Colo
|
||||
let cresp = child.checkbox(&mut clamp, tl!("Clamp Signal"));
|
||||
app.auto.add("scopes.clamp", cresp.rect, "Clamp Signal");
|
||||
let wr = Rect::from_center_size(pos2(cr.min.x - 16.0, r.center().y), vec2(22.0, 22.0));
|
||||
let wresp = ui.interact(wr, egui::Id::new("scopes-wrench"), Sense::click());
|
||||
let wresp = ui.interact(wr, egui::Id::unique("scopes-wrench"), Sense::click());
|
||||
icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.tab_text_active } else { t.icon });
|
||||
app.auto.add("scopes.wrench", wr, "Scope settings");
|
||||
egui::Popup::menu(&wresp).show(|ui| settings_menu(app, ui));
|
||||
@@ -314,7 +314,7 @@ fn plotted(st: &ScopesState, sf: &ScopeFrame) -> Arc<Signal> {
|
||||
|
||||
/// A texture computed from (frame, settings, scope), kept until either changes.
|
||||
fn cached_texture(ctx: &egui::Context, slot: String, key: u64, make: impl FnOnce() -> (usize, usize, Vec<u8>)) -> Option<TextureHandle> {
|
||||
let id = egui::Id::new(("scope-tex", slot.clone()));
|
||||
let id = egui::Id::unique(("scope-tex", slot.clone()));
|
||||
if let Some((k, tex)) = ctx.data(|d| d.get_temp::<(u64, TextureHandle)>(id))
|
||||
&& k == key
|
||||
{
|
||||
|
||||
@@ -299,7 +299,7 @@ fn general(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, cur: &SequenceSetti
|
||||
.fold(0.0, f32::max);
|
||||
let list_w = 260.0;
|
||||
row(ui, label_w, "Sequence Name:", true, |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(list_w));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(list_w)), "Sequence Name");
|
||||
elems.push(("sequenceSettings.name".into(), r.rect, d.name.clone()));
|
||||
});
|
||||
row(ui, label_w, "Editing Mode:", false, |ui| fixed_combo(ui, elems, "editingMode", "Custom", list_w, NOT_YET));
|
||||
@@ -475,7 +475,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
let audio_samples = app.session.project.settings.audio_display_samples;
|
||||
let accent = app.tokens.accent;
|
||||
let mut d = app.ui.sequence_settings.clone();
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("sequence-settings-before-drag"), &mut d);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("sequence-settings-before-drag"), &mut d);
|
||||
let mut keep = true;
|
||||
let mut apply = false;
|
||||
let mut elems: Elems = Vec::new();
|
||||
@@ -487,7 +487,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
&[("general", "General"), ("color", "Color Management"), ("vr", "VR Properties")]
|
||||
};
|
||||
egui::Window::new(title)
|
||||
.id(egui::Id::new("Sequence Settings"))
|
||||
.id(egui::Id::unique("Sequence Settings"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(620.0)
|
||||
|
||||
@@ -303,7 +303,7 @@ fn appearance_modes(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft)
|
||||
let mode = choice_text(get(&d.values, key));
|
||||
ui.horizontal(|ui| {
|
||||
field_label(ui, "Appearance Mode");
|
||||
if let Some(v) = combo(app, ui, key, &mode, &static_opts(settings::APPEARANCE_MODES), 200.0) {
|
||||
if let Some(v) = combo(app, ui, key, "Appearance Mode", &mode, &static_opts(settings::APPEARANCE_MODES), 200.0) {
|
||||
put(&mut d.values, key, json!(v));
|
||||
}
|
||||
});
|
||||
@@ -428,7 +428,7 @@ fn choice_text(v: &Value) -> String {
|
||||
}
|
||||
|
||||
/// A dropdown of (value, label) options; returns the chosen value.
|
||||
fn combo(app: &mut FilmcraftApp, ui: &mut Ui, key: &str, current: &str, opts: &[(String, String)], width: f32) -> Option<String> {
|
||||
fn combo(app: &mut FilmcraftApp, ui: &mut Ui, key: &str, label: &str, current: &str, opts: &[(String, String)], width: f32) -> Option<String> {
|
||||
let shown = opts.iter().find(|o| o.0 == current).map(|o| o.1.clone()).unwrap_or_else(|| current.to_string());
|
||||
let mut chosen = None;
|
||||
let mut items: Vec<(String, Rect, String)> = Vec::new();
|
||||
@@ -447,7 +447,10 @@ fn combo(app: &mut FilmcraftApp, ui: &mut Ui, key: &str, current: &str, opts: &[
|
||||
}
|
||||
}
|
||||
});
|
||||
app.auto.add(&format!("settings.{key}"), r.response.rect, &shown);
|
||||
// The accessible name is the field's label, while the automation label stays the selected
|
||||
// value so `ui.elements` reports what the dropdown shows.
|
||||
let response = crate::widgets::accessible_name(r.response, crate::i18n::t(label));
|
||||
app.auto.add(&format!("settings.{key}"), response.rect, &shown);
|
||||
for (id, rect, l) in items {
|
||||
app.auto.add(&id, rect, &l);
|
||||
}
|
||||
@@ -522,13 +525,13 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi
|
||||
if let Some(x) = number(app, ui, f.key, cur.as_f64().unwrap_or(1.0), range, decimals, f.label) {
|
||||
put(&mut d.values, f.key, json!(if u == "frames" { x.round() } else { x }));
|
||||
}
|
||||
if let Some(v) = combo(app, ui, unit_key, &u, &static_opts(settings::DURATION_UNITS), 90.0) {
|
||||
if let Some(v) = combo(app, ui, unit_key, f.label, &u, &static_opts(settings::DURATION_UNITS), 90.0) {
|
||||
put(&mut d.values, unit_key, json!(v));
|
||||
}
|
||||
}
|
||||
Kind::Choice(opts) => {
|
||||
field_label(ui, f.label);
|
||||
if let Some(v) = combo(app, ui, f.key, &choice_text(&cur), &static_opts(opts), 300.0) {
|
||||
if let Some(v) = combo(app, ui, f.key, f.label, &choice_text(&cur), &static_opts(opts), 300.0) {
|
||||
let x = if cur.is_number() { v.parse::<u64>().map(Value::from).unwrap_or(json!(v)) } else { json!(v) };
|
||||
put(&mut d.values, f.key, x);
|
||||
}
|
||||
@@ -536,7 +539,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi
|
||||
Kind::Device(list) => {
|
||||
field_label(ui, f.label);
|
||||
let opts = device_opts(app, d, list);
|
||||
if let Some(v) = combo(app, ui, f.key, &choice_text(&cur), &opts, 300.0) {
|
||||
if let Some(v) = combo(app, ui, f.key, f.label, &choice_text(&cur), &opts, 300.0) {
|
||||
put(&mut d.values, f.key, json!(v));
|
||||
}
|
||||
}
|
||||
@@ -549,7 +552,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi
|
||||
let c = settings::parse_hex(&choice_text(get(&d.values, &format!("labels.colors.{}.color", settings::label_id(l))))).unwrap_or(l.rgb());
|
||||
ui.painter().rect_filled(sw, 2.0, Color32::from_rgb(c[0], c[1], c[2]));
|
||||
}
|
||||
if let Some(v) = combo(app, ui, f.key, &cur_s, &opts, 160.0) {
|
||||
if let Some(v) = combo(app, ui, f.key, f.label, &cur_s, &opts, 160.0) {
|
||||
put(&mut d.values, f.key, json!(v));
|
||||
}
|
||||
}
|
||||
@@ -570,7 +573,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi
|
||||
} else {
|
||||
ui.available_width().clamp(60.0, 220.0)
|
||||
};
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width).hint_text(hint));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut s).desired_width(width).hint_text(hint)), f.label);
|
||||
app.auto.add(&format!("settings.{}", f.key), r.rect, f.label);
|
||||
if r.changed() {
|
||||
put(&mut d.values, f.key, json!(s));
|
||||
@@ -608,9 +611,12 @@ fn color_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key:
|
||||
}
|
||||
// While the field has focus it shows what is typed, even when that is not a colour yet (one
|
||||
// character into `#2f6bdf`); each valid colour is kept as it comes. Leaving shows the kept one.
|
||||
let draft_id = egui::Id::new(("settings-hex", key));
|
||||
let draft_id = egui::Id::unique(("settings-hex", key));
|
||||
let mut text = ui.data(|m| m.get_temp::<String>(draft_id)).unwrap_or_else(|| choice_text(get(&d.values, key)));
|
||||
let t = ui.add(egui::TextEdit::singleline(&mut text).desired_width(70.0).font(egui::TextStyle::Monospace));
|
||||
let t = crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut text).desired_width(70.0).font(egui::TextStyle::Monospace)),
|
||||
format!("{label} hex"),
|
||||
);
|
||||
app.auto.add(&format!("settings.{key}.hex"), t.rect, label);
|
||||
if t.has_focus() {
|
||||
ui.data_mut(|m| m.insert_temp(draft_id, text.clone()));
|
||||
@@ -639,7 +645,7 @@ fn label_presets(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) {
|
||||
ui.horizontal(|ui| {
|
||||
let opts: Vec<(String, String)> = presets.iter().map(|p| (p.name.clone(), p.name.clone())).collect();
|
||||
let shown = if modified { tlf!("{name} (Modified)", name = labels.preset) } else { labels.preset.clone() };
|
||||
if let Some(name) = combo(app, ui, "labels.preset", &shown, &opts, 150.0) {
|
||||
if let Some(name) = combo(app, ui, "labels.preset", "Label Color Presets", &shown, &opts, 150.0) {
|
||||
pick = presets.iter().find(|p| p.name == name).cloned();
|
||||
}
|
||||
if preset_button(app, ui, "revert", Icon::Reset, tl!("Revert to the preset's colors and names"), modified) {
|
||||
@@ -730,7 +736,10 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str
|
||||
let id = settings::label_id(*l);
|
||||
let nk = format!("labels.colors.{id}.name");
|
||||
let mut name = choice_text(get(&d.values, &nk));
|
||||
let r = ui.add_sized(vec2(136.0, 22.0), egui::TextEdit::singleline(&mut name));
|
||||
let r = crate::widgets::accessible_name(
|
||||
ui.add_sized(vec2(136.0, 22.0), egui::TextEdit::singleline(&mut name)),
|
||||
format!("{} name", l.name()),
|
||||
);
|
||||
app.auto.add(&format!("settings.{nk}"), r.rect, l.name());
|
||||
if r.changed() {
|
||||
put(&mut d.values, &nk, json!(name));
|
||||
@@ -845,11 +854,11 @@ fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row]
|
||||
/// Draw the dialog; returns whether it stays open.
|
||||
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
let Some(mut d) = app.ui.settings.take() else { return false };
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("settings-dialog-before-drag"), &mut d);
|
||||
crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("settings-dialog-before-drag"), &mut d);
|
||||
let t = app.tokens;
|
||||
let mut close = false;
|
||||
let mut apply = false;
|
||||
let resp = egui::Modal::new(egui::Id::new("settings-modal")).frame(modal_frame(&t)).show(ctx, |ui| {
|
||||
let resp = egui::Modal::new(egui::Id::unique("settings-modal")).frame(modal_frame(&t)).show(ctx, |ui| {
|
||||
ui.set_width(W);
|
||||
// title bar (Premiere keeps "Preferences" as the window title)
|
||||
let (bar, _) = ui.allocate_exact_size(vec2(W, 30.0), Sense::hover());
|
||||
@@ -864,7 +873,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
ui.painter().rect(list, 2.0, t.field_bg, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside);
|
||||
for (i, c) in settings::categories().iter().enumerate() {
|
||||
let row = Rect::from_min_size(list.min + vec2(1.0, 1.0 + i as f32 * ROW_H), vec2(LIST_W - 2.0, ROW_H));
|
||||
let r = ui.interact(row, egui::Id::new(("settings-cat", c.id)), Sense::click());
|
||||
let r = ui.interact(row, egui::Id::unique(("settings-cat", c.id)), Sense::click());
|
||||
let sel = d.page == c.id;
|
||||
if sel {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_alt);
|
||||
|
||||
@@ -150,8 +150,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool {
|
||||
let mut close: Option<bool> = None; // Some(true) = OK, Some(false) = Cancel
|
||||
let mut open = true;
|
||||
egui::Window::new(tl!("Keyboard Shortcuts"))
|
||||
.id(egui::Id::new("Keyboard Shortcuts"))
|
||||
.id(egui::Id::new("keyboard-shortcuts"))
|
||||
.id(egui::Id::unique("Keyboard Shortcuts"))
|
||||
.id(egui::Id::unique("keyboard-shortcuts"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
@@ -262,7 +262,7 @@ fn header(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
if let Some(mut name) = app.shortcut_editor.save_name.clone() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(tl!("Save preset as:")).size(13.0).color(t.text_dim));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0));
|
||||
let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)), "Preset name");
|
||||
app.auto.add("shortcuts.saveAs.name", r.rect, "Preset name");
|
||||
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if small_button(app, ui, "shortcuts.saveAs.ok", tl!("Save"), !name.trim().is_empty()) || enter {
|
||||
@@ -483,7 +483,7 @@ fn keyboard(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect, t: &Tokens) {
|
||||
(true, true) => format!("shortcuts.mod.{}.right", &name[1..]),
|
||||
_ => format!("shortcuts.key.{name}"),
|
||||
};
|
||||
let resp = ui.interact(r, egui::Id::new(&id), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(&id), Sense::click());
|
||||
let p = ui.painter();
|
||||
let base = if is_mod {
|
||||
if mod_on { MOD_KEY } else { FREE_KEY.gamma_multiply(0.8) }
|
||||
@@ -594,7 +594,10 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke
|
||||
let plat = Platform::current();
|
||||
ui.horizontal(|ui| {
|
||||
let mut q = app.shortcut_editor.search.clone();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search commands or keys")).desired_width(320.0));
|
||||
let r = crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search commands or keys")).desired_width(320.0)),
|
||||
"Search commands or keys",
|
||||
);
|
||||
app.auto.add("shortcuts.search", r.rect, "Search");
|
||||
app.shortcut_editor.search = q;
|
||||
if let Some(sel) = app.shortcut_editor.selected.clone() {
|
||||
@@ -664,7 +667,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
let cresp = ui.interact(cell, egui::Id::new(("shortcut-cell", &id)), Sense::click());
|
||||
let cresp = ui.interact(cell, egui::Id::unique(("shortcut-cell", &id)), Sense::click());
|
||||
if recording {
|
||||
ui.painter().rect(cell, 3.0, t.field_bg, Stroke::new(1.5, t.accent), StrokeKind::Inside);
|
||||
} else if cresp.hovered() {
|
||||
@@ -677,7 +680,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke
|
||||
app.shortcut_editor.recording = Some((id.clone(), false));
|
||||
}
|
||||
let plus = Rect::from_min_size(pos2(r.max.x - 30.0, r.min.y + 3.0), vec2(20.0, 20.0));
|
||||
let presp = ui.interact(plus, egui::Id::new(("shortcut-add", &id)), Sense::click()).on_hover_text(tl!("Add another shortcut"));
|
||||
let presp = ui.interact(plus, egui::Id::unique(("shortcut-add", &id)), Sense::click()).on_hover_text(tl!("Add another shortcut"));
|
||||
ui.painter().text(
|
||||
plus.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
|
||||
@@ -42,8 +42,8 @@ pub fn controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect) {
|
||||
let left = row.center().x - (width * 3.0 + gap * 2.0) * 0.5;
|
||||
for (i, (id, label, video, audio, enabled)) in buttons.into_iter().enumerate() {
|
||||
let rect = Rect::from_min_size(pos2(left + i as f32 * (width + gap), row.min.y), vec2(width, row.height()));
|
||||
let response = ui.add_enabled_ui(enabled, |ui| ui.interact(rect, egui::Id::new(id), Sense::drag())).inner;
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
|
||||
let response = ui.add_enabled_ui(enabled, |ui| ui.interact(rect, egui::Id::unique(id), Sense::drag())).inner;
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label));
|
||||
let t = app.tokens;
|
||||
if response.hovered() || response.dragged() {
|
||||
ui.painter().rect_filled(rect, 2.0, t.hover);
|
||||
|
||||
@@ -22,7 +22,7 @@ struct Drag {
|
||||
revision: u64,
|
||||
}
|
||||
fn key(which: Which) -> egui::Id {
|
||||
egui::Id::new(("monitor-range-edit", which as u8))
|
||||
egui::Id::unique(("monitor-range-edit", which as u8))
|
||||
}
|
||||
fn self_item(app: &FilmcraftApp, which: Which) -> Option<ItemId> {
|
||||
if which == Which::Source { app.session.state.source_item } else { app.session.state.active_sequence }
|
||||
@@ -103,7 +103,7 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Whi
|
||||
}
|
||||
app.auto.add(id, rect, label);
|
||||
let response =
|
||||
ui.add_enabled_ui(enabled && rect.width() > 0.0, |ui| ui.interact(rect, egui::Id::new(id), Sense::click_and_drag())).inner.on_hover_text(label);
|
||||
ui.add_enabled_ui(enabled && rect.width() > 0.0, |ui| ui.interact(rect, egui::Id::unique(id), Sense::click_and_drag())).inner.on_hover_text(label);
|
||||
let cursor = if edge == RangeEdge::Move {
|
||||
if cfg!(windows) { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Grab }
|
||||
} else {
|
||||
@@ -197,7 +197,7 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Whi
|
||||
let rect = Rect::from_min_max(pos2(x - 7.0, bar.max.y - 12.0), pos2(x + 7.0, bar.max.y));
|
||||
let id = if which == Which::Source { "source.playhead" } else { "program.playhead" };
|
||||
app.auto.add(id, rect, "Scrub playhead");
|
||||
let playhead = ui.interact(rect, egui::Id::new(id), Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
let playhead = ui.interact(rect, egui::Id::unique(id), Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
if (playhead.dragged() || playhead.clicked())
|
||||
&& let Some(pos) = playhead.interact_pointer_pos()
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let text_w = tab_text_width(ui.painter(), shown);
|
||||
let w = text_w + 16.0;
|
||||
let r = Rect::from_min_size(pos2(x, rect.min.y + 4.0), vec2(w, 24.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("text-tab", tab)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("text-tab", tab)), Sense::click());
|
||||
let active = app.ui.text_tab == tab;
|
||||
ui.painter().text(
|
||||
pos2(r.min.x, r.center().y),
|
||||
@@ -65,7 +65,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
|
||||
fn tool_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, icon: Icon, id: &str, label: &str, enabled: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let resp = ui.interact(r, egui::Id::new(("text-tool", id)), if enabled { Sense::click() } else { Sense::hover() });
|
||||
let resp = ui.interact(r, egui::Id::unique(("text-tool", id)), if enabled { Sense::click() } else { Sense::hover() });
|
||||
if enabled && resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
.enumerate()
|
||||
{
|
||||
let r = Rect::from_center_size(c + vec2(0.0, 26.0 + i as f32 * 34.0), vec2(200.0, 26.0));
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(id), Sense::click());
|
||||
ui.painter().rect_filled(r, 13.0, if i == 0 { if resp.hovered() { t.accent_hover } else { t.accent } } else { t.field_bg });
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::semibold(12.0), if i == 0 { Color32::WHITE } else { t.text });
|
||||
app.auto.add(id, r, label);
|
||||
@@ -112,7 +112,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// the track shown: the one holding the first selected caption, else C1
|
||||
let sel = app.session.state.caption_selection.clone();
|
||||
let track_idx = sel.first().and_then(|c| seq.caption_tracks.iter().position(|tr| tr.caption(*c).is_some())).unwrap_or(0);
|
||||
let track_idx = ui.ctx().data(|d| d.get_temp::<usize>(egui::Id::new("text-cap-track"))).filter(|i| *i < seq.caption_tracks.len()).unwrap_or(track_idx);
|
||||
let track_idx = ui.ctx().data(|d| d.get_temp::<usize>(egui::Id::unique("text-cap-track"))).filter(|i| *i < seq.caption_tracks.len()).unwrap_or(track_idx);
|
||||
let track = &seq.caption_tracks[track_idx];
|
||||
|
||||
// ---- toolbar: search, track picker, add / split / merge / delete
|
||||
@@ -123,12 +123,12 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let mut x = bar.min.x + 180.0f32.min(bar.width() * 0.4 + 10.0);
|
||||
let picker = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(130.0, 22.0));
|
||||
let label = format!("C{} · {}", track_idx + 1, track.name);
|
||||
let presp = crate::widgets::dropdown_text(ui, picker, &label, &t, egui::Id::new("text-cap-track-picker"));
|
||||
let presp = crate::widgets::dropdown_text(ui, picker, &label, &t, egui::Id::unique("text-cap-track-picker"));
|
||||
app.auto.add("text.captions.track", picker, "Caption track");
|
||||
egui::Popup::menu(&presp).show(|ui| {
|
||||
for (i, tr) in seq.caption_tracks.iter().enumerate() {
|
||||
if ui.selectable_label(i == track_idx, format!("C{} · {} ({})", i + 1, tr.name, crate::i18n::t(tr.format.label()))).clicked() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("text-cap-track"), i));
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("text-cap-track"), i));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
@@ -209,14 +209,17 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
for (edge, val) in [("in", c.start), ("out", c.end())] {
|
||||
let key = egui::Id::new(("cap-tc", c.id.0, edge));
|
||||
let key = egui::Id::unique(("cap-tc", c.id.0, edge));
|
||||
let mut buf = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| tc(val));
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut buf)
|
||||
.id(key.with("te"))
|
||||
.desired_width(88.0)
|
||||
.font(Tokens::mono(11.0))
|
||||
.text_color(if edge == "in" { t.hot_text } else { t.text_dim }),
|
||||
let resp = crate::widgets::accessible_name(
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut buf)
|
||||
.id(key.with("te"))
|
||||
.desired_width(88.0)
|
||||
.font(Tokens::mono(11.0))
|
||||
.text_color(if edge == "in" { t.hot_text } else { t.text_dim }),
|
||||
),
|
||||
if edge == "in" { "Caption in" } else { "Caption out" },
|
||||
);
|
||||
app.auto.add(&format!("text.captions.{}.{edge}", c.id.0), resp.rect, if edge == "in" { "Caption in" } else { "Caption out" });
|
||||
if resp.has_focus() {
|
||||
@@ -234,18 +237,21 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some(sp) = &c.speaker {
|
||||
ui.label(egui::RichText::new(sp).size(11.0).color(t.text_dim).strong());
|
||||
}
|
||||
let key = egui::Id::new(("cap-text", c.id.0));
|
||||
let key = egui::Id::unique(("cap-text", c.id.0));
|
||||
let mut buf = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| c.text.clone());
|
||||
let resp = if crate::rtl::has_rtl(&buf) && !ui.memory(|m| m.has_focus(key.with("te"))) {
|
||||
reading_order(ui, &buf, key.with("te"))
|
||||
} else {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.id(key.with("te"))
|
||||
.desired_rows(1)
|
||||
.desired_width(ui.available_width())
|
||||
.font(Tokens::ui(12.5))
|
||||
.frame(egui::Frame::NONE),
|
||||
crate::widgets::accessible_name(
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.id(key.with("te"))
|
||||
.desired_rows(1)
|
||||
.desired_width(ui.available_width())
|
||||
.font(Tokens::ui(12.5))
|
||||
.frame(egui::Frame::NONE),
|
||||
),
|
||||
"Caption text",
|
||||
)
|
||||
};
|
||||
app.auto.add(&format!("text.captions.{}.text", c.id.0), resp.rect, "Caption text");
|
||||
@@ -346,7 +352,7 @@ fn graphics(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||||
// search
|
||||
let bar = Rect::from_min_size(rect.min + vec2(10.0, 2.0), vec2(rect.width() - 20.0, 26.0));
|
||||
let skey = egui::Id::new("text-gfx-search");
|
||||
let skey = egui::Id::unique("text-gfx-search");
|
||||
let mut search = ui.data(|d| d.get_temp::<String>(skey)).unwrap_or_default();
|
||||
let w = 220.0f32.min(bar.width());
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(bar.min, vec2(w, 24.0))));
|
||||
@@ -399,15 +405,18 @@ fn graphics(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.label(egui::RichText::new(label).size(11.0).color(t.text_dim));
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
let tkey = egui::Id::new(("gfx-text", g.clip.0, g.layer));
|
||||
let tkey = egui::Id::unique(("gfx-text", g.clip.0, g.layer));
|
||||
let mut buf = ui.data(|d| d.get_temp::<String>(tkey)).unwrap_or_else(|| g.text.clone());
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.id(tkey.with("te"))
|
||||
.desired_rows(1)
|
||||
.desired_width(ui.available_width())
|
||||
.font(Tokens::ui(12.5))
|
||||
.frame(egui::Frame::NONE),
|
||||
let resp = crate::widgets::accessible_name(
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.id(tkey.with("te"))
|
||||
.desired_rows(1)
|
||||
.desired_width(ui.available_width())
|
||||
.font(Tokens::ui(12.5))
|
||||
.frame(egui::Frame::NONE),
|
||||
),
|
||||
"Graphic text",
|
||||
);
|
||||
app.auto.add(&format!("text.graphics.{key}.text"), resp.rect, "Graphic text");
|
||||
if resp.has_focus() {
|
||||
@@ -463,9 +472,12 @@ pub fn caption_properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect)
|
||||
let (hr, _) = bui.allocate_exact_size(vec2(bui.available_width(), 30.0), Sense::hover());
|
||||
bui.painter().text(pos2(hr.min.x, hr.center().y), Align2::LEFT_CENTER, format!("C{} · {}", track_idx + 1, track_name), Tokens::semibold(12.5), t.text);
|
||||
bui.label(egui::RichText::new(tl!("Text")).size(12.0).color(t.text_dim));
|
||||
let key = egui::Id::new(("props-cap-text", c.id.0));
|
||||
let key = egui::Id::unique(("props-cap-text", c.id.0));
|
||||
let mut buf = bui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| c.text.clone());
|
||||
let resp = bui.add(egui::TextEdit::multiline(&mut buf).id(key.with("te")).desired_rows(3).desired_width(bui.available_width()).font(Tokens::ui(12.5)));
|
||||
let resp = crate::widgets::accessible_name(
|
||||
bui.add(egui::TextEdit::multiline(&mut buf).id(key.with("te")).desired_rows(3).desired_width(bui.available_width()).font(Tokens::ui(12.5))),
|
||||
"Caption text",
|
||||
);
|
||||
app.auto.add("properties.caption.text", resp.rect, &c.text);
|
||||
if resp.has_focus() {
|
||||
bui.data_mut(|d| d.insert_temp(key, buf.clone()));
|
||||
@@ -479,9 +491,12 @@ pub fn caption_properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect)
|
||||
for (edge, label, val) in [("in", tl!("In"), c.start), ("out", tl!("Out"), c.end())] {
|
||||
bui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(70.0, 22.0), egui::Label::new(egui::RichText::new(label).size(12.0).color(t.text_dim)));
|
||||
let key = egui::Id::new(("props-cap-tc", c.id.0, edge));
|
||||
let key = egui::Id::unique(("props-cap-tc", c.id.0, edge));
|
||||
let mut buf = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| tc(val));
|
||||
let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(key.with("te")).desired_width(100.0).font(Tokens::mono(11.5)));
|
||||
let resp = crate::widgets::accessible_name(
|
||||
ui.add(egui::TextEdit::singleline(&mut buf).id(key.with("te")).desired_width(100.0).font(Tokens::mono(11.5))),
|
||||
if edge == "in" { "In" } else { "Out" },
|
||||
);
|
||||
app.auto.add(&format!("properties.caption.{edge}"), resp.rect, &tc(val));
|
||||
if resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(key, buf.clone()));
|
||||
@@ -537,7 +552,7 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
};
|
||||
ui.painter().text(c - vec2(0.0, 8.0), Align2::CENTER_CENTER, note, Tokens::ui(12.0), t.text_dim);
|
||||
let r = Rect::from_center_size(c + vec2(0.0, 26.0), vec2(200.0, 26.0));
|
||||
let resp = ui.interact(r, egui::Id::new("text.transcript.generate"), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique("text.transcript.generate"), Sense::click());
|
||||
ui.painter().rect_filled(r, 13.0, if resp.hovered() { t.accent_hover } else { t.accent });
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, tl!("Transcribe"), Tokens::semibold(12.0), Color32::WHITE);
|
||||
app.auto.add("text.transcript.generate", r, "Transcribe");
|
||||
|
||||
@@ -121,7 +121,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if show_name {
|
||||
ui.painter().text(pos2(r.center().x, vr.max.y + 2.0), Align2::CENTER_TOP, &caption, Tokens::ui(11.0), t.text_dim);
|
||||
}
|
||||
let resp = ui.interact(r, egui::Id::new(("timecode-row", i)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("timecode-row", i)), Sense::click());
|
||||
elems.push((format!("timecode.row.{i}"), vr, value.clone()));
|
||||
egui::Popup::context_menu(&resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
@@ -169,7 +169,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// "+" adds a row (same settings as the last one, Duration by default)
|
||||
let ar = Rect::from_min_size(pos2(rect.max.x - 28.0, rect.max.y - 26.0), vec2(22.0, 22.0));
|
||||
let aresp = ui.interact(ar, egui::Id::new("timecode-add"), Sense::click());
|
||||
let aresp = ui.interact(ar, egui::Id::unique("timecode-add"), Sense::click());
|
||||
ui.painter().text(ar.center(), Align2::CENTER_CENTER, "+", Tokens::ui(16.0), if aresp.hovered() { t.tab_text_active } else { t.icon });
|
||||
elems.push(("timecode.addRow".into(), ar, "Add Row".into()));
|
||||
if aresp.on_hover_text(tl!("Add a timecode row")).clicked() {
|
||||
|
||||
@@ -644,7 +644,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// divider between video and audio
|
||||
let div = Rect::from_min_max(pos2(rect.min.x, split_y), pos2(content.max.x, split_y + DIVIDER_H));
|
||||
painter.rect_filled(div, 0.0, t.app_bg);
|
||||
let div_resp = ui.interact(div, egui::Id::new("tl-divider"), Sense::drag());
|
||||
let div_resp = ui.interact(div, egui::Id::unique("tl-divider"), Sense::drag());
|
||||
if div_resp.hovered() || div_resp.dragged() {
|
||||
ctx.set_cursor_icon(CursorIcon::ResizeVertical);
|
||||
}
|
||||
@@ -738,7 +738,7 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.painter().text(rect.center() + vec2(0.0, 50.0), Align2::CENTER_CENTER, hint, Tokens::ui(12.0), t.text_dim);
|
||||
}
|
||||
let b = Rect::from_center_size(rect.center() + vec2(0.0, 20.0), vec2(170.0, 26.0));
|
||||
let resp = ui.interact(b, egui::Id::new("tl-open-demo"), Sense::click());
|
||||
let resp = ui.interact(b, egui::Id::unique("tl-open-demo"), Sense::click());
|
||||
app.auto.add("timeline.openDemo", b, "Open Demo Project");
|
||||
ui.painter().rect_filled(b, 13.0, if resp.hovered() { t.accent_hover } else { t.accent });
|
||||
ui.painter().text(b.center(), Align2::CENTER_CENTER, tl!("Open Demo Project"), Tokens::semibold(12.0), Color32::WHITE);
|
||||
@@ -1275,7 +1275,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
||||
p.rect_filled(hrect, 0.0, c.gamma_multiply(TRACK_HEADER_TINT));
|
||||
p.rect_filled(Rect::from_min_max(strip.min, pos2(strip.min.x + 5.0, strip.max.y)), 0.0, c);
|
||||
}
|
||||
let cresp = ui.interact(strip.intersect(visible), egui::Id::new(("trackColor", r.track.0)), Sense::click()).on_hover_text(tl!("Track Color"));
|
||||
let cresp = ui.interact(strip.intersect(visible), egui::Id::unique(("trackColor", r.track.0)), Sense::click()).on_hover_text(tl!("Track Color"));
|
||||
app.auto.add(&format!("timeline.track.{}{}.color", if r.kind == TrackKind::Video { "V" } else { "A" }, r.index + 1), strip, "Track Color");
|
||||
if cresp.hovered() && tr.label.is_none() {
|
||||
p.rect_filled(Rect::from_min_max(strip.min, pos2(strip.min.x + 5.0, strip.max.y)), 0.0, t.hover);
|
||||
@@ -1307,7 +1307,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
||||
let patched = if r.kind == TrackKind::Video { tg.video_dest == Some(r.track) } else { tg.audio_dest == Some(r.track) };
|
||||
let pr = btn_rect(13.0);
|
||||
app.auto.add(&format!("timeline.track.{label}.sourcePatch"), pr, "Source patch");
|
||||
if patch_button(ui, pr, visible, &label, patched, false, egui::Id::new(("patch", r.track.0)), t).clicked() {
|
||||
if patch_button(ui, pr, visible, &label, patched, false, egui::Id::unique(("patch", r.track.0)), t).clicked() {
|
||||
actions.push(("timeline.setTargeting".into(), json!({"track": r.track.0, "sourcePatch": !patched})));
|
||||
}
|
||||
// 2. track lock
|
||||
@@ -1320,7 +1320,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
||||
if tr.locked { Icon::Lock } else { Icon::Unlock },
|
||||
true,
|
||||
t,
|
||||
egui::Id::new(("locked", r.track.0)),
|
||||
egui::Id::unique(("locked", r.track.0)),
|
||||
Some(if tr.locked { t.icon_active } else { t.text_dim }),
|
||||
);
|
||||
app.auto.add(&format!("timeline.track.{label}.locked"), lock_r, "Toggle Track Lock");
|
||||
@@ -1335,14 +1335,21 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
||||
let targeted = tg.targeted.contains(&r.track);
|
||||
let trr = btn_rect(61.0);
|
||||
app.auto.add(&format!("timeline.track.{label}.target"), trr, "Toggle track targeting");
|
||||
if patch_button(ui, trr, visible, &label, targeted, true, egui::Id::new(("target", r.track.0)), t).clicked() {
|
||||
if patch_button(ui, trr, visible, &label, targeted, true, egui::Id::unique(("target", r.track.0)), t).clicked() {
|
||||
actions.push(("timeline.setTargeting".into(), json!({"track": r.track.0, "targeted": !targeted})));
|
||||
}
|
||||
// 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));
|
||||
let sresp = crate::widgets::icon_toggle(
|
||||
ui,
|
||||
sr.intersect(visible),
|
||||
Icon::SyncLock,
|
||||
tr.sync_lock,
|
||||
t,
|
||||
egui::Id::unique(("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})));
|
||||
@@ -1356,7 +1363,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
||||
if tr.enabled { Icon::Eye } else { Icon::EyeOff },
|
||||
true,
|
||||
t,
|
||||
egui::Id::new(("enabled", r.track.0)),
|
||||
egui::Id::unique(("enabled", r.track.0)),
|
||||
Some(t.text_dim),
|
||||
);
|
||||
app.auto.add(&format!("timeline.track.{label}.enabled"), er, "Toggle Track Output");
|
||||
@@ -1372,7 +1379,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
||||
tr.muted,
|
||||
Color32::from_rgb(0x2d, 0x9d, 0x78),
|
||||
t,
|
||||
egui::Id::new(("mute", r.track.0)),
|
||||
egui::Id::unique(("mute", r.track.0)),
|
||||
);
|
||||
app.auto.add(&format!("timeline.track.{label}.muted"), mr, "Mute Track");
|
||||
if mresp.clicked() {
|
||||
@@ -1386,7 +1393,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
||||
tr.solo,
|
||||
Color32::from_rgb(0xf0, 0xf0, 0x4f),
|
||||
t,
|
||||
egui::Id::new(("solo", r.track.0)),
|
||||
egui::Id::unique(("solo", r.track.0)),
|
||||
);
|
||||
app.auto.add(&format!("timeline.track.{label}.solo"), s2, "Solo Track");
|
||||
if so.clicked() {
|
||||
@@ -1415,7 +1422,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
||||
continue;
|
||||
}
|
||||
app.auto.add(&format!("timeline.track.{label}.{name}"), edge, "Drag to change the track height");
|
||||
let eresp = ui.interact(edge, egui::Id::new(("trackh", name, r.track.0)), Sense::drag());
|
||||
let eresp = ui.interact(edge, egui::Id::unique(("trackh", name, r.track.0)), Sense::drag());
|
||||
if eresp.hovered() || eresp.dragged() {
|
||||
ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical);
|
||||
}
|
||||
@@ -1427,7 +1434,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows:
|
||||
}
|
||||
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)),
|
||||
egui::Id::unique(("hdr", r.track.0)),
|
||||
Sense::click(),
|
||||
);
|
||||
if resp.double_clicked() {
|
||||
@@ -1473,7 +1480,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
|
||||
// current timecode: Premiere's big blue timecode
|
||||
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 + 14.0, rect.min.y + 4.0), vec2(hw - 20.0, 20.0));
|
||||
let tc_id = egui::Id::new(("timeline-tc", seq_id));
|
||||
let tc_id = egui::Id::unique(("timeline-tc", seq_id));
|
||||
match crate::widgets::timecode_field(ui, tc_id, tc_rect, &tc, app.session.playhead(), Tick::ZERO, rate, seq.settings.drop_frame, t.timecode) {
|
||||
Some(Ok(time)) => {
|
||||
if let Err(e) = app.session.execute("playhead.set", json!({"time": time.0})) {
|
||||
@@ -1497,7 +1504,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
|
||||
];
|
||||
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);
|
||||
let resp = ui.interact(r, egui::Id::unique(("tl-toggle", key)), Sense::click()).on_hover_text(tip);
|
||||
app.auto.add(&format!("timeline.toggle.{key}"), r, tip);
|
||||
if toggle && on {
|
||||
p.rect_filled(r, 4.0, t.pressed);
|
||||
@@ -1615,7 +1622,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
|
||||
let hit = r.expand2(vec2(0.0, 2.0));
|
||||
app.auto.add(&format!("timeline.renderBar.{i}"), hit, what);
|
||||
if ui.rect_contains_pointer(hit) {
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new(("rb", i)), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique(("rb", i)), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
ui.label(what);
|
||||
});
|
||||
}
|
||||
@@ -1635,7 +1642,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
|
||||
let mr = marker_rect(layout, m.start);
|
||||
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| {
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique(("mk", m.id.0)), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
ui.label(&m.name);
|
||||
});
|
||||
}
|
||||
@@ -1677,7 +1684,7 @@ fn zoom_scrollbar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, seq_seco
|
||||
}
|
||||
app.auto.add("timeline.zoomBar", thumb, "zoom scroll bar");
|
||||
app.auto.add("timeline.zoomBar.track", bar, "zoom scroll bar track");
|
||||
let resp = ui.interact(bar, egui::Id::new("tl-zoombar"), Sense::click_and_drag());
|
||||
let resp = ui.interact(bar, egui::Id::unique("tl-zoombar"), Sense::click_and_drag());
|
||||
let range = (total - vis).max(0.0);
|
||||
// a press beside the thumb brings the thumb's middle under the pointer (and a drag goes on
|
||||
// from there)
|
||||
@@ -1767,7 +1774,7 @@ fn vertical_scrollbar(ui: &mut egui::Ui, bar: Rect, value: &mut f32, max: f32, i
|
||||
p.rect_filled(track, track.width() / 2.0, t.separator);
|
||||
let hover = ui.rect_contains_pointer(thumb);
|
||||
p.rect_filled(thumb, thumb.width() / 2.0, if hover { t.scroll_thumb_hover } else { t.scroll_thumb });
|
||||
let resp = ui.interact(bar, egui::Id::new(("tl-vs", id)), Sense::drag());
|
||||
let resp = ui.interact(bar, egui::Id::unique(("tl-vs", id)), Sense::drag());
|
||||
if resp.dragged() {
|
||||
let d = resp.drag_delta().y / (bar.height() - h).max(1.0) * max;
|
||||
*value = (*value + if invert { -d } else { d }).clamp(0.0, max);
|
||||
@@ -2256,7 +2263,7 @@ fn scrub_playhead(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, p: Po
|
||||
fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &Layout, rect: Rect) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let area = Rect::from_min_max(pos2(layout.content.min.x, layout.ruler.min.y), layout.content.max);
|
||||
let resp = ui.interact(area, egui::Id::new("timeline-area"), Sense::click_and_drag());
|
||||
let resp = ui.interact(area, egui::Id::unique("timeline-area"), Sense::click_and_drag());
|
||||
let pos = resp.hover_pos().or(resp.interact_pointer_pos());
|
||||
let mods = ctx.input(|i| i.modifiers);
|
||||
let tool = app.ui.tool;
|
||||
@@ -2806,11 +2813,11 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
|
||||
// ---- context menu on clips (right-clicking an unselected clip selects it first); on a clip's
|
||||
// edge, the edit point menu instead (right-clicking an unselected edit point selects it first)
|
||||
let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target");
|
||||
let edit_point_menu_id = egui::Id::unique("timeline.editPointMenu.target");
|
||||
// the clip right-clicked, remembered while its menu is open: Unlink leaves only it selected
|
||||
let menu_clip_id = egui::Id::new("timeline.clipMenu.clip");
|
||||
let menu_clip_id = egui::Id::unique("timeline.clipMenu.clip");
|
||||
// right-clicking empty time between clips selects the gap and opens its menu (#648, #668)
|
||||
let gap_menu_id = egui::Id::new("timeline.gapMenu.open");
|
||||
let gap_menu_id = egui::Id::unique("timeline.gapMenu.open");
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
@@ -2825,7 +2832,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
}
|
||||
}
|
||||
// on a marker of the ruler, the marker menu instead (right-clicking a marker selects it first)
|
||||
let marker_menu_id = egui::Id::new("timeline.markerMenu.target");
|
||||
let marker_menu_id = egui::Id::unique("timeline.markerMenu.target");
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
|
||||
@@ -97,9 +97,16 @@ fn band(r: &Row) -> Rect {
|
||||
pub fn header_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, r: &Row, rect: Rect, visible: Rect, label: &str, t: &Tokens) {
|
||||
let Some(tr) = seq.track(r.track) else { return };
|
||||
let showing = app.ui.timeline.track_lanes.get(&r.track.0).cloned();
|
||||
let resp =
|
||||
crate::widgets::icon_toggle(ui, rect.intersect(visible), Icon::Keyframe, showing.is_some(), t, egui::Id::new(("lanebtn", r.track.0)), Some(LANE_COL))
|
||||
.on_hover_text(tl!("Show Keyframes"));
|
||||
let resp = crate::widgets::icon_toggle(
|
||||
ui,
|
||||
rect.intersect(visible),
|
||||
Icon::Keyframe,
|
||||
showing.is_some(),
|
||||
t,
|
||||
egui::Id::unique(("lanebtn", r.track.0)),
|
||||
Some(LANE_COL),
|
||||
)
|
||||
.on_hover_text(tl!("Show Keyframes"));
|
||||
app.auto.add(&format!("timeline.track.{label}.keyframes"), rect, "Show Keyframes");
|
||||
let opts = lane_options(tr);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
@@ -131,7 +138,7 @@ struct KfDrag {
|
||||
}
|
||||
|
||||
fn drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-lane-kf-drag")
|
||||
egui::Id::unique("tl-lane-kf-drag")
|
||||
}
|
||||
|
||||
/// Draw the shown lanes (after clips) and handle their editing (after the timeline's own interaction,
|
||||
@@ -173,7 +180,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
// add keyframes: Pen tool, or ⌘-click with any tool
|
||||
let adding = app.ui.tool == Tool::Pen || ui.input(|i| i.modifiers.command);
|
||||
if adding {
|
||||
let bresp = ui.interact(vis, egui::Id::new(("lane-band", r.track.0)), Sense::click());
|
||||
let bresp = ui.interact(vis, egui::Id::unique(("lane-band", r.track.0)), Sense::click());
|
||||
if bresp.hovered() {
|
||||
ui.ctx().set_cursor_icon(CursorIcon::Crosshair);
|
||||
}
|
||||
@@ -199,7 +206,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
continue;
|
||||
}
|
||||
let hr = Rect::from_center_size(c, vec2(11.0, 11.0));
|
||||
let resp = ui.interact(hr, egui::Id::new(("lane-kf", r.track.0, i)), Sense::click_and_drag());
|
||||
let resp = ui.interact(hr, egui::Id::unique(("lane-kf", r.track.0, i)), Sense::click_and_drag());
|
||||
let hot = resp.hovered() || dragging.is_some();
|
||||
icons::paint(&p, Rect::from_center_size(c, vec2(9.0, 9.0)), Icon::Keyframe, if hot { Color32::WHITE } else { LANE_COL });
|
||||
app.auto.add(&format!("timeline.track.{label}.lane.kf.{i}"), hr, &format!("{name} keyframe {v:.1}"));
|
||||
@@ -221,7 +228,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
|
||||
}
|
||||
if dragging.is_some() {
|
||||
let nv = denorm(tr, &key, ((b.max.y - c.y) / b.height()).clamp(0.0, 1.0));
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("lane-kf-tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("lane-kf-tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
ui.label(if key == LANE_VOLUME { format!("{nv:.2} dB") } else { format!("{nv:.2}") });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ enum Gesture {
|
||||
}
|
||||
|
||||
fn drag_id() -> egui::Id {
|
||||
egui::Id::new("caption-drag")
|
||||
egui::Id::unique("caption-drag")
|
||||
}
|
||||
|
||||
/// Live drag state: (caption, gesture, delta ticks).
|
||||
@@ -85,7 +85,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re
|
||||
app.auto.add(&format!("timeline.captionTrack.{label}"), badge, &tr.name);
|
||||
// right-click the header: delete this track or every empty caption track (registered
|
||||
// before the lock / eye toggles so those stay on top)
|
||||
let hresp = ui.interact(hrect, egui::Id::new(("cap-header", tr.id.0)), Sense::click());
|
||||
let hresp = ui.interact(hrect, egui::Id::unique(("cap-header", tr.id.0)), Sense::click());
|
||||
hresp.context_menu(|ui| {
|
||||
for (key, text, cmd, p) in [
|
||||
("delete", tl!("Delete Caption Track"), "captions.deleteTrack", json!({"track": tr.id.0})),
|
||||
@@ -107,7 +107,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re
|
||||
if tr.locked { Icon::Lock } else { Icon::Unlock },
|
||||
true,
|
||||
t,
|
||||
egui::Id::new(("cap-lock", tr.id.0)),
|
||||
egui::Id::unique(("cap-lock", tr.id.0)),
|
||||
Some(if tr.locked { t.icon_active } else { t.text_dim }),
|
||||
);
|
||||
app.auto.add(&format!("timeline.captionTrack.{label}.locked"), lock_r, "Toggle Caption Track Lock");
|
||||
@@ -121,7 +121,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re
|
||||
if tr.enabled { Icon::Eye } else { Icon::EyeOff },
|
||||
true,
|
||||
t,
|
||||
egui::Id::new(("cap-eye", tr.id.0)),
|
||||
egui::Id::unique(("cap-eye", tr.id.0)),
|
||||
Some(t.text_dim),
|
||||
);
|
||||
app.auto.add(&format!("timeline.captionTrack.{label}.enabled"), eye_r, "Toggle Caption Track Output");
|
||||
@@ -198,7 +198,7 @@ fn interact_block(
|
||||
actions: &mut Vec<(String, Value)>,
|
||||
) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let resp = ui.interact(r, egui::Id::new(("caption-block", c.id.0)), Sense::click_and_drag());
|
||||
let resp = ui.interact(r, egui::Id::unique(("caption-block", c.id.0)), Sense::click_and_drag());
|
||||
let edge = 5.0f32.min(r.width() / 4.0);
|
||||
let gesture_at = |x: f32| {
|
||||
if x - r.min.x < edge {
|
||||
@@ -240,7 +240,7 @@ fn interact_block(
|
||||
Gesture::TrimIn => 1,
|
||||
Gesture::TrimOut => 2,
|
||||
};
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("caption-drag-origin"), p.x));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("caption-drag-origin"), p.x));
|
||||
ctx.data_mut(|d| d.insert_temp(drag_id(), (c.id.0, g, 0i64)));
|
||||
if !app.session.state.caption_selection.contains(&c.id) {
|
||||
actions.push(("captions.select".into(), json!({"captions": [c.id.0]})));
|
||||
@@ -250,7 +250,7 @@ fn interact_block(
|
||||
&& let (Some((id, g, _)), Some(p)) = (drag_state(&ctx), resp.interact_pointer_pos())
|
||||
&& id == c.id.0
|
||||
{
|
||||
let x0 = ctx.data(|d| d.get_temp::<f32>(egui::Id::new("caption-drag-origin"))).unwrap_or(p.x);
|
||||
let x0 = ctx.data(|d| d.get_temp::<f32>(egui::Id::unique("caption-drag-origin"))).unwrap_or(p.x);
|
||||
let pps = app.ui.timeline.pps;
|
||||
let d = rate.snap_nearest(Tick::from_seconds_f64((p.x - x0) as f64 / pps));
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id(), (id, g, d.0)));
|
||||
|
||||
@@ -191,7 +191,7 @@ fn run(app: &mut FilmcraftApp, acts: Vec<(&str, Value)>) {
|
||||
/// The level next to the pointer while dragging: one line, always as wide, so it does not jump.
|
||||
fn tip(ui: &egui::Ui, db: f64) {
|
||||
let text = if db <= FADER_MIN_DB { format!("{:>7} dB", "-∞") } else { format!("{db:>+7.2} dB") };
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("tl-volume-tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("tl-volume-tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).font(Tokens::mono(12.0))).wrap_mode(egui::TextWrapMode::Extend));
|
||||
});
|
||||
}
|
||||
@@ -206,7 +206,7 @@ struct KeyDrag {
|
||||
}
|
||||
|
||||
fn key_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-volume-key-drag")
|
||||
egui::Id::unique("tl-volume-key-drag")
|
||||
}
|
||||
|
||||
/// The keyframes on the lines of the audio clips in view: drag one in time and level, right-click
|
||||
@@ -256,7 +256,7 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layou
|
||||
continue;
|
||||
}
|
||||
let kr = Rect::from_center_size(c, vec2(11.0, 11.0));
|
||||
let resp = ui.interact(kr, egui::Id::new(("tl-volume-kf", it.id.0, i)), Sense::click_and_drag());
|
||||
let resp = ui.interact(kr, egui::Id::unique(("tl-volume-kf", it.id.0, i)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.clip.{}.volume.kf.{i}", it.id.0), kr, "Volume keyframe");
|
||||
let mine = held.filter(|h| h.clip == it.id && h.index == i);
|
||||
if resp.hovered() || mine.is_some() {
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 = 32.0;
|
||||
let held_id = egui::Id::new("tools-held");
|
||||
let held_id = egui::Id::unique("tools-held");
|
||||
if ui.input(|i| i.pointer.any_pressed()) {
|
||||
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
|
||||
}
|
||||
@@ -23,8 +23,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
} else {
|
||||
format!("{} ({})", app.ui.language.tr(current.label()), current.shortcut())
|
||||
};
|
||||
let resp = ui.interact(r, egui::Id::new(("tool", format!("{:?}", group[0]))), Sense::click_and_drag()).on_hover_text(tip);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), app.ui.tool == current, current.label()));
|
||||
let resp = ui.interact(r, egui::Id::unique(("tool", format!("{:?}", group[0]))), Sense::click_and_drag()).on_hover_text(tip);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, resp.enabled(), app.ui.tool == current, current.label()));
|
||||
for tl in &group {
|
||||
if *tl == current {
|
||||
app.auto.add_response(&format!("tools.{tl:?}"), &resp, tl.label());
|
||||
|
||||
@@ -398,7 +398,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
return;
|
||||
}
|
||||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||||
let id = egui::Id::new("program-transform");
|
||||
let id = egui::Id::unique("program-transform");
|
||||
let drag_id = id.with("drag");
|
||||
let double = ui.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary));
|
||||
// the monitor around the picture as well as on it (its clip reaches past the picture area)
|
||||
|
||||
@@ -179,7 +179,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id
|
||||
changes.push(c);
|
||||
}
|
||||
let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y));
|
||||
let dresp = ui.interact(divider, egui::Id::new("tr-ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
let dresp = ui.interact(divider, egui::Id::unique("tr-ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
if dresp.dragged() {
|
||||
app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list);
|
||||
}
|
||||
@@ -252,7 +252,7 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
let (_, cut, _) = trn::bounds(track, x)?;
|
||||
let clip_a = x.from.and_then(|c| track.item(c)).map(|it| (it.start, it.end(), it.name.clone()));
|
||||
let clip_b = x.to.and_then(|c| track.item(c)).map(|it| (it.start, it.end(), it.name.clone()));
|
||||
let mem = egui::Id::new(("tr-ec-lane-drag", id.0));
|
||||
let mem = egui::Id::unique(("tr-ec-lane-drag", id.0));
|
||||
let mut drag: Option<LaneDrag> = ui.data(|d| d.get_temp(mem));
|
||||
let v = drag.map_or_else(|| view(x, cut, frame), |d| d.view);
|
||||
let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect()));
|
||||
@@ -311,7 +311,7 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
elems.push(("effectControls.transition.timeline.ruler".into(), ruler, "time ruler".into()));
|
||||
// the ruler moves the playhead (once the sequence is no longer borrowed, below)
|
||||
let mut seek = None;
|
||||
let rresp = ui.interact(ruler, egui::Id::new(("tr-ec-ruler", id.0)), Sense::click_and_drag());
|
||||
let rresp = ui.interact(ruler, egui::Id::unique(("tr-ec-ruler", id.0)), Sense::click_and_drag());
|
||||
if (rresp.dragged() || rresp.clicked())
|
||||
&& let Some(pos) = rresp.interact_pointer_pos()
|
||||
{
|
||||
@@ -327,7 +327,7 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio
|
||||
None
|
||||
}
|
||||
};
|
||||
let sresp = ui.interact(span, egui::Id::new(("tr-ec-span", id.0)), Sense::click_and_drag());
|
||||
let sresp = ui.interact(span, egui::Id::unique(("tr-ec-span", id.0)), Sense::click_and_drag());
|
||||
if let Some(h) = sresp.hover_pos() {
|
||||
ui.ctx().set_cursor_icon(if grab(h.x).is_some() { egui::CursorIcon::ResizeColumn } else { egui::CursorIcon::Grab });
|
||||
}
|
||||
@@ -426,7 +426,7 @@ fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKin
|
||||
}
|
||||
(ParamKind::Bool, ParamValue::Bool(b)) => {
|
||||
let mut b = *b;
|
||||
let r = ui.checkbox(&mut b, "");
|
||||
let r = crate::widgets::accessible_name(ui.checkbox(&mut b, ""), eid.clone());
|
||||
elems.push((eid, r.rect, b.to_string()));
|
||||
r.changed().then(|| json!({"params": { pid: b }}))
|
||||
}
|
||||
@@ -444,7 +444,8 @@ fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKin
|
||||
}
|
||||
});
|
||||
elems.extend(options);
|
||||
elems.push((eid, r.response.rect, shown.to_string()));
|
||||
let response = crate::widgets::accessible_name(r.response, eid.clone());
|
||||
elems.push((eid, response.rect, shown.to_string()));
|
||||
picked.map(|i| json!({"params": { pid: i }}))
|
||||
}
|
||||
(ParamKind::Color, ParamValue::Color(c)) => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! outgoing side, in the incoming picture the incoming side, between them rolls.
|
||||
//!
|
||||
//! J/K/L and Space are routed to dynamic trimming / loop playback in `menus::invoke`; the engine
|
||||
//! state machine (`filmcraft_engine::trim`) is advanced here once per frame with the UI clock.
|
||||
//! state machine (`filmcraft_engine::trim`) is advanced from logic with the monotonic host clock.
|
||||
//!
|
||||
//! Automation ids: `trimMonitor.outgoing`, `trimMonitor.incoming`, `trimMonitor.roll`,
|
||||
//! `trimMonitor.outTimecode`, `trimMonitor.inTimecode`, `trimMonitor.outShift`,
|
||||
@@ -35,21 +35,21 @@ pub fn trim_mode(app: &FilmcraftApp) -> bool {
|
||||
!app.session.state.edit_points.is_empty() && app.ui.focused != crate::dock::PanelKind::Source
|
||||
}
|
||||
|
||||
/// Advance dynamic trimming / loop playback to the current UI time (once per frame).
|
||||
pub fn advance(app: &mut FilmcraftApp, ctx: &egui::Context) {
|
||||
/// Advance dynamic trimming / loop playback to a monotonic host time. Called from logic; a
|
||||
/// repeated call at the same time is idempotent because `trim.tick` receives an absolute clock.
|
||||
pub fn advance_at(app: &mut FilmcraftApp, clock: f64) {
|
||||
if !app.session.trim_play.active() {
|
||||
return;
|
||||
}
|
||||
let clock = ctx.input(|i| i.time);
|
||||
if let Err(e) = app.session.execute("trim.tick", json!({"clock": clock})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Route a transport command in trim mode (J/K/L/Space/Shift+K). `None` = not handled.
|
||||
pub fn route_transport(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str) -> Option<Result<Value, String>> {
|
||||
let clock = ctx.input(|i| i.time);
|
||||
let _ = ctx;
|
||||
let clock = app.now();
|
||||
let trimming = trim_mode(app);
|
||||
let (cmd, params) = match id {
|
||||
"playback.forward" if trimming => ("trim.shuttle", json!({"direction": "forward", "clock": clock})),
|
||||
@@ -239,7 +239,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// drag to trim: px → frames (6 px per frame), applied on release as one trim
|
||||
let fpp = 1.0 / 6.0;
|
||||
for (r, which) in [(lp, "out"), (rp, "in"), (mid, "roll")] {
|
||||
let resp = ui.interact(r, egui::Id::new(("trim-monitor-drag", which)), Sense::drag());
|
||||
let resp = ui.interact(r, egui::Id::unique(("trim-monitor-drag", which)), Sense::drag());
|
||||
if resp.dragged() || resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeColumn);
|
||||
}
|
||||
@@ -248,7 +248,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// the same one data_mut() holds, and that lock is not reentrant (deadlock)
|
||||
let dx = resp.drag_delta().x;
|
||||
let total = ui.ctx().data_mut(|d| {
|
||||
let v = d.get_temp_mut_or_default::<f32>(egui::Id::new("trim-monitor-dx"));
|
||||
let v = d.get_temp_mut_or_default::<f32>(egui::Id::unique("trim-monitor-dx"));
|
||||
*v += dx;
|
||||
*v
|
||||
});
|
||||
@@ -256,7 +256,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.painter().text(pos2(r.center().x, r.max.y - 14.0), Align2::CENTER_CENTER, shift_text(n), Tokens::semibold(14.0), col);
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
let total = ui.ctx().data_mut(|d| d.remove_temp::<f32>(egui::Id::new("trim-monitor-dx")).unwrap_or(0.0));
|
||||
let total = ui.ctx().data_mut(|d| d.remove_temp::<f32>(egui::Id::unique("trim-monitor-dx")).unwrap_or(0.0));
|
||||
let n = (total * fpp).round() as i64;
|
||||
// egui ends a drag on Escape: that stop abandons it (#580)
|
||||
if n != 0 && !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
@@ -295,10 +295,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
];
|
||||
let total: f32 = buttons.iter().map(|b| b.3 + 4.0).sum();
|
||||
let mut x = bar.center().x - total / 2.0;
|
||||
let ctx = ui.ctx().clone();
|
||||
for (id, label, tip, w) in buttons {
|
||||
let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(w, bar.height()));
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique(id), Sense::click()).on_hover_text(tip);
|
||||
let bg = if resp.is_pointer_button_down_on() {
|
||||
t.pressed
|
||||
} else if resp.hovered() {
|
||||
@@ -316,7 +315,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add(id, r, tip);
|
||||
if resp.clicked() {
|
||||
let res = match id {
|
||||
"trimMonitor.playAround" => app.session.execute("trim.playAround", json!({"clock": ctx.input(|i| i.time), "toggle": true})),
|
||||
"trimMonitor.playAround" => app.session.execute("trim.playAround", json!({"clock": app.now(), "toggle": true})),
|
||||
"trimMonitor.backwardMany" => app.session.execute("trim.backwardMany", json!({})),
|
||||
"trimMonitor.backward" => app.session.execute("trim.backward", json!({})),
|
||||
"trimMonitor.forward" => app.session.execute("trim.forward", json!({})),
|
||||
@@ -331,7 +330,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// exit trim mode
|
||||
let r = Rect::from_min_size(pos2(rect.max.x - 30.0, bar.min.y + 3.0), vec2(22.0, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new("trimMonitor.exit"), Sense::click()).on_hover_text(tl!("Exit Trim Mode"));
|
||||
let resp = ui.interact(r, egui::Id::unique("trimMonitor.exit"), Sense::click()).on_hover_text(tl!("Exit Trim Mode"));
|
||||
let c = if resp.hovered() { t.tab_text_active } else { t.text_dim };
|
||||
ui.painter().line_segment([r.min + vec2(6.0, 6.0), r.max - vec2(6.0, 6.0)], Stroke::new(1.5, c));
|
||||
ui.painter().line_segment([pos2(r.max.x - 6.0, r.min.y + 6.0), pos2(r.min.x + 6.0, r.max.y - 6.0)], Stroke::new(1.5, c));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user