Merge #3249 (egui 0.37) into docking-frame-trial

This commit is contained in:
aiongg
2026-10-25 17:16:23 +00:00
135 changed files with 9017 additions and 1369 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 }}
+9
View File
@@ -172,3 +172,12 @@ SHA-256 (`crates/speech/src/models.rs`, `docs/transcripts.md`).
| Asset | Author | Source | Licence |
|---|---|---|---|
| Whisper tiny / base / small speech-recognition weights (`model.safetensors`, `config.json`, `generation_config.json`, `tokenizer.json`) | OpenAI | `https://huggingface.co/openai/whisper-{tiny,base,small}` (revisions `169d4a43…`, `e37978b9…`, `973afd24…`); weights released with `https://github.com/openai/whisper` | MIT (weights and code, github.com/openai/whisper); the Hugging Face conversion is labelled Apache-2.0 |
## egui dependency fonts
| Asset | Author | Source | Licence | Use |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | Ubuntu Font Licence 1.0 (Copyright 2011 Canonical Ltd.; full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional fallback, included by `default_fonts`; `system_fonts` is not enabled. Dependency asset unmodified here; no font file copied into this repository. |
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors (Google) | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | OFL-1.1 (Copyright 2013 Google Inc.; full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is the monochrome emoji fallback, included because the workspace enables `monochrome_emoji_fonts`. Dependency asset unmodified here; no font file copied into this repository. |
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors; Bitstream Inc.; DejaVu contributors | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT (Copyright (c) 2018 Source Foundry Authors) and the Bitstream Vera License (Copyright (c) 2003 Bitstream, Inc.); the DejaVu work is public domain; full upstream notices in `NOTICE` | Hack-Regular.ttf is egui's default monospace fallback, included by `default_fonts`. Dependency asset unmodified here; no font file copied into this repository. |
Generated
+195 -90
View File
@@ -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",
]
@@ -1014,12 +1010,12 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"thiserror 2.0.21",
]
@@ -1027,7 +1023,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f"
dependencies = [
"egui",
"log",
@@ -1299,9 +1295,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1333,9 +1328,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1357,6 +1351,7 @@ dependencies = [
"pollster 1.0.1",
"profiling",
"raw-window-handle",
"send_wrapper",
"static_assertions",
"wasm-bindgen",
"web-sys",
@@ -1368,9 +1363,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1388,9 +1382,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1408,30 +1401,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1443,9 +1439,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"eframe",
"egui",
@@ -1488,9 +1483,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
]
@@ -1550,9 +1544,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1568,16 +1561,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equivalent"
@@ -2431,6 +2425,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"
@@ -2460,6 +2460,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"
@@ -2947,12 +2953,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",
@@ -3169,6 +3175,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"
@@ -3177,7 +3192,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
"serde",
"serde_core",
]
@@ -3188,7 +3203,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -3487,12 +3502,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]]
@@ -3765,6 +3780,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"
@@ -4318,6 +4342,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"
@@ -4437,10 +4471,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",
@@ -4449,9 +4494,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",
@@ -4459,9 +4504,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",
@@ -4472,9 +4517,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",
]
@@ -5445,6 +5490,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"
@@ -6248,6 +6299,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"
@@ -6310,6 +6372,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"
@@ -6386,9 +6454,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",
@@ -6401,9 +6469,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",
@@ -7270,8 +7338,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=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -7352,6 +7419,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"
@@ -7703,3 +7788,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+27 -6
View File
@@ -72,15 +72,18 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
proptest = "1"
roxmltree = "0.21"
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", default-features = false }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false }
# 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 = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", features = ["serde", "native-windows"] }
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 = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde", "native-windows"] }
# 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 = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+265
View File
@@ -19,3 +19,268 @@ Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian) — OFL-1.1. Their licence texts ship with the packages as OFL-<family>.txt.
Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Noto Emoji font (dependency asset)
----------------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
embeds NotoEmoji-Regular.ttf, the monochrome emoji fallback used when egui's
`monochrome_emoji_fonts` feature is enabled. The font's copyright notice and the
upstream SIL Open Font License 1.1 text follow unchanged.
Copyright 2013 Google Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Ubuntu Light font (dependency asset)
------------------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
embeds Ubuntu-Light.ttf as a default fallback. The font's copyright notice and the
upstream Ubuntu Font Licence 1.0 text follow unchanged.
Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Hack font (dependency asset)
----------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
embeds Hack-Regular.ttf as the default monospace fallback. The upstream notice and
licences follow unchanged.
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
The work in the DejaVu project was committed to the public domain.
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
MIT License
Copyright (c) 2018 Source Foundry Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BITSTREAM VERA LICENSE
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
+6 -1
View File
@@ -424,6 +424,10 @@ impl Services for WebServices {
}
}
thread_local! {
static DOWNLOAD_CLOCK: web_time::Instant = web_time::Instant::now();
}
/// 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).
pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
@@ -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 {
+5 -2
View File
@@ -94,12 +94,13 @@ impl eframe::App for WebApp {
self.app.raw_input_hook(ctx, raw_input);
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
/// Hidden logic pass: host actions, app logic, API replies, jobs, audio, autosave, then IME reset; it does not draw UI.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
for f in actions {
f(self, ctx);
}
self.app.logic(ctx, frame);
self.app.logic_inner(ctx);
api::poll_replies();
// exports advance between frames (no threads)
if self.app.session.pump_jobs(std::time::Duration::from_millis(30)) {
@@ -109,6 +110,7 @@ impl eframe::App for WebApp {
a.pump();
}
self.autosave.tick(&self.app.session, ctx);
self.app.finish_ime_reset_logic(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
@@ -159,6 +161,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
canvas,
web_options,
Box::new(move |cc| {
filmcraft_ui_egui::install_ime_reset_callbacks(&cc.egui_ctx);
set_context(cc.egui_ctx.clone());
let ctx = cc.egui_ctx.clone();
fs::set_on_data(move || ctx.request_repaint());
+13 -3
View File
@@ -28,10 +28,18 @@ 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 {
/// Default policy with a monotonic host clock, so hidden-tab logic never uses a stale egui input time.
fn default() -> Self {
Self { policy: Policy::default(), outcomes: Rc::default(), clock: web_time::Instant::now() }
}
}
impl Autosave {
@@ -40,7 +48,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 +72,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));
+8 -1
View File
@@ -68,8 +68,15 @@ 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();
}
/// Milliseconds from a monotonic browser clock; codec idle and staleness accounting must never use wall time.
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 {
+1 -1
View File
@@ -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"
+2 -1
View File
@@ -51,11 +51,12 @@ enum Note {
}
impl Note {
/// Localized status text for a fallback device (name interpolated through `fmt`) or for no output device.
fn text(&self) -> String {
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(),
}
}
}
+1
View File
@@ -190,6 +190,7 @@ fn native_options(agent: bool) -> eframe::NativeOptions {
fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<AtomicBool>) -> eframe::AppCreator<'static> {
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch;
Box::new(move |cc| {
filmcraft_ui_egui::install_ime_reset_callbacks(&cc.egui_ctx);
created.store(true, Ordering::Relaxed);
// A fallback graphics mode says so in the window, with a button back to the default.
let notice = graphics.degraded().then(|| {
+1
View File
@@ -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;
+3
View File
@@ -1201,6 +1201,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;
@@ -1216,6 +1217,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;
@@ -4661,6 +4663,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;
+9 -1
View File
@@ -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];
+25
View File
@@ -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();
+5
View File
@@ -431,6 +431,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,
@@ -549,6 +553,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 {
+1 -1
View File
@@ -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()),
}
}
+2 -1
View File
@@ -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."
));
}
+2 -2
View File
@@ -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()
}
+29
View File
@@ -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();
// Embedded LUT text is rendered on the hot path. Drop oversize entries at load time so a
// hostile project cannot retain an unrenderable multi-megabyte source or repeatedly offer it
// to renderers. Clips that refer to one simply have no LUT, like a deleted library entry.
@@ -437,6 +440,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");
+1 -1
View File
@@ -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)) => {
+78
View File
@@ -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();
+2 -8
View File
@@ -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)
}
// ------------------------------------------------------------------------------------------------
+45
View File
@@ -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));
+26
View File
@@ -1750,6 +1750,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) {
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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"
+74 -4
View File
@@ -61,6 +61,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_in(response.ctx.viewport_id(), id, response.rect, label);
if let Some(element) = self.elements.last_mut() {
element.widget_id = Some(format!("{:016x}", response.id.value()));
@@ -72,11 +75,78 @@ 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;
/// Test helper: a 10×10 rect at `x` for pass-order fixtures.
fn rect(x: f32) -> Rect {
Rect::from_min_size(pos2(x, 0.0), vec2(10.0, 10.0))
}
/// Lookups and prefix queries must resolve the pass just drawn, not stale geometry.
#[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));
}
/// Once the current pass registers an element, a removed id must not resolve from the previous pass.
#[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());
}
/// The previous pass is usable only before the current pass draws; a discarded pass leaves exactly one entry.
#[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));
}
}
+1 -1
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -53,7 +53,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).
@@ -103,3 +103,15 @@ fn install_chinese(fonts: &mut FontDefinitions) {
stack.extend(order.iter().map(|f| name(f)));
}
}
#[cfg(test)]
mod font_blob_tests {
use super::*;
/// `FontData::from_blob` retains the TTC face index when constructed from a shared blob.
#[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");
}
}
+133 -18
View File
@@ -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;
/// Read a pointer repetition count, using `default` when `key` is absent or null.
///
@@ -71,7 +73,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>,
},
}
@@ -114,9 +117,14 @@ fn point(app: &FilmcraftApp, p: &Value) -> Result<egui::Pos2, String> {
Ok(egui::pos2(x as f32, y as f32))
}
/// Execute one control request. Synthetic input is capped by `MAX_SYNTHETIC_EVENTS` before queueing, screenshot crops stay symbolic until capture, and
/// `gfxEdit` layer identity is resolved from the document.
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`") };
@@ -305,8 +313,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}")),
}
}
@@ -403,6 +451,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,
@@ -456,8 +505,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 }
@@ -521,10 +576,11 @@ fn merge(v: &mut Value, patch: &Value) {
}
}
/// Read-only JSON snapshot; focus and element counts come from the current accepted registry pass.
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<_>>(),
@@ -545,24 +601,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];
@@ -623,7 +708,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]);
@@ -772,6 +862,31 @@ mod base64_tests {
}
}
/// Negative and overhanging panel origins clip to the visible image without shifting the requested region, at any scale.
#[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));
}
/// Crops with no overlap or a non-finite/non-positive size are refused, never rendered as a zero-sized image.
#[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);
+1 -1
View File
@@ -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"));
+9 -9
View File
@@ -638,12 +638,12 @@ pub fn sequence_tabs(ui: &mut egui::Ui, strip: Rect, seqs: &SeqTabs, t: &Tokens,
while last + 1 < seqs.open.len() && span(first, last + 1) <= room {
last += 1;
}
let list_id = egui::Id::new("timeline-tab-list");
let list_id = egui::Id::unique("timeline-tab-list");
let more = (!all_fit).then(|| Rect::from_min_size(pos2(strip.max.x - 22.0, strip.center().y - 10.0), vec2(18.0, 20.0)));
let mut list_opened = false;
if let Some(r) = more {
let resp = ui.interact(r, egui::Id::new("timeline-tab-overflow"), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs"));
let resp = ui.interact(r, egui::Id::unique("timeline-tab-overflow"), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), "more tabs"));
reg.add_response("timeline.tabs.more", &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);
@@ -657,7 +657,7 @@ pub fn sequence_tabs(ui: &mut egui::Ui, strip: Rect, seqs: &SeqTabs, t: &Tokens,
}
// The wheel over the strip steps through the tabs: down = next, up = previous, one tab per
// notch (trackpad scrolling adds up to a notch first), stopping at the ends.
let wheel_id = egui::Id::new("timeline-tab-wheel");
let wheel_id = egui::Id::unique("timeline-tab-wheel");
if ui.rect_contains_pointer(strip) && !seqs.open.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;
@@ -680,8 +680,8 @@ pub fn sequence_tabs(ui: &mut egui::Ui, strip: Rect, seqs: &SeqTabs, t: &Tokens,
let w = widths.get(i).copied().unwrap_or(0.0);
let tab_rect = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, strip.height()));
// a sequence tab keeps its identity when the tabs are reordered under the pointer
let resp = ui.interact(tab_rect, egui::Id::new(("tab-seq", *id)), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, name));
let resp = ui.interact(tab_rect, egui::Id::unique(("tab-seq", *id)), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Tab, resp.enabled(), is_active, name));
ui.ctx().accesskit_node_builder(resp.id, |node| {
node.set_role(egui::accesskit::Role::Tab);
node.set_selected(is_active);
@@ -692,7 +692,7 @@ pub fn sequence_tabs(ui: &mut egui::Ui, strip: Rect, seqs: &SeqTabs, t: &Tokens,
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() {
ui.data_mut(|d| d.insert_temp(home, i));
}
@@ -711,8 +711,8 @@ pub fn sequence_tabs(ui: &mut egui::Ui, strip: Rect, seqs: &SeqTabs, t: &Tokens,
if 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", *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", *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));
+5 -6
View File
@@ -19,7 +19,7 @@ use crate::{FilmcraftApp, panels};
/// The dock area's egui id; `DockArea::hit_rects` and `float_space` read by it.
pub fn area_id() -> egui::Id {
egui::Id::new("filmcraft-docking")
egui::Id::unique("filmcraft-docking")
}
/// Where each panel body was drawn last frame, and on which layer.
@@ -311,14 +311,13 @@ impl FilmcraftApp {
if let Some((panel, pos)) = menu {
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);
});
}
let hits = DockArea::<PanelKind>::hit_rects(&ctx, area_id());
self.register_dock_hits(&hits);
panels::panel_menu_popup(self, ui);
self.cancel_value_drag(ui.ctx());
}
/// The Timeline's tabs stand for its open sequences: a close from the dock area (a tab's
@@ -546,8 +545,8 @@ impl DockContent<PanelKind> for FilmContent<'_> {
self.seq_actions.extend(dock::sequence_tabs(ui, strip, &self.seqs, &t, &mut self.app.auto));
}
let rect = Rect::from_center_size(menu.center(), vec2(18.0, 18.0));
let response = ui.interact(rect, egui::Id::new(("panel-menu-button", active.id())), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), "panel menu"));
let response = ui.interact(rect, egui::Id::unique(("panel-menu-button", active.id())), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, response.enabled(), "panel menu"));
self.app.auto.add_response(&format!("panel.menu.{}", active.id()), &response, "panel menu");
let color = if response.hovered() { t.tab_text_active } else { t.tab_text };
icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::Hamburger, color);
+1 -1
View File
@@ -44,7 +44,7 @@ pub(crate) fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
let title = tlf!("Safe graphics mode: {mode}", mode = i18n::t(&notice.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)
+9 -8
View File
@@ -13,12 +13,13 @@ use crate::theme::Tokens;
/// Height of the header bar. With `integrated_titlebar` it sits in the macOS title-bar strip.
pub const HEIGHT: f32 = 38.0;
/// Draw the header bar; dragging starts a native window move and double-click toggles maximized.
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().clone();
p.rect_filled(rect, 0.0, t.header_bg);
p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, Color32::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);
}
@@ -29,7 +30,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);
@@ -44,7 +45,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;
@@ -75,7 +76,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);
@@ -100,7 +101,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.
@@ -120,7 +121,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.
@@ -129,7 +130,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);
@@ -155,7 +156,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));
}
+2 -2
View File
@@ -3470,8 +3470,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.
+3 -3
View File
@@ -3467,8 +3467,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
@@ -3620,7 +3620,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.
+3 -3
View File
@@ -3438,8 +3438,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
@@ -3579,7 +3579,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.
+3 -3
View File
@@ -3455,8 +3455,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
@@ -3577,7 +3577,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.
+3 -3
View File
@@ -3448,8 +3448,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
@@ -3603,7 +3603,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.
+3 -3
View File
@@ -3452,8 +3452,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 逆再生する
@@ -3619,7 +3619,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.
+3 -3
View File
@@ -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 오디오 시간 단위
@@ -3598,7 +3598,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.
+39 -3
View File
@@ -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",
@@ -250,7 +269,7 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
const JAPANESE_SAMPLE: &str = "日本語ファイル編集あア";
/// Installed families preferred for Japanese interface text, best first (Gothic / sans-serif faces
/// read best at menu sizes). Any other installed face that covers [`JAPANESE_SAMPLE`] is used if
/// read best at menu sizes). Any other installed face that covers `JAPANESE_SAMPLE` is used if
/// none of these is present.
const PREFERRED_JAPANESE: &[&str] = &[
"Hiragino Sans",
@@ -289,13 +308,13 @@ 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()
}
/// Whether the craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`) supplies a
/// Japanese interface font: some craft-fonts face covers [`JAPANESE_SAMPLE`]. `theme::install` adds
/// Japanese interface font: some craft-fonts face covers `JAPANESE_SAMPLE`. `theme::install` adds
/// these faces to every font family, so nothing else needs installing (and no system scan runs).
pub fn craft_japanese_font() -> bool {
filmcraft_text::fonts::craft_japanese().next().is_some()
@@ -1104,3 +1123,20 @@ mod tests {
});
}
}
#[cfg(test)]
mod bcp47_tests {
use super::Language;
/// Protocol-facing BCP 47 tags use `pt-BR`/`zh-CN`; every tag is non-empty ASCII.
#[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:?}");
}
}
}
+3 -3
View File
@@ -3452,8 +3452,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
@@ -3580,7 +3580,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.
+3 -3
View File
@@ -3446,8 +3446,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 反转速度
@@ -3595,7 +3595,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.
+490
View File
@@ -0,0 +1,490 @@
//! Ordered admission for the Program monitor's interrupted input method.
//!
//! A reset request is not an acknowledgement. Geometry/focus/output loss retires the local
//! token, and a later actual custom descriptor requests a fresh fence even for the same caret.
use std::sync::Arc;
use egui::{Context, IMEPurpose, Id, ImeResetId, ImeTarget, ViewportId, ViewportIdMap};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Destination {
pub document: u64,
pub target: ImeTarget,
pub caret: usize,
pub anchor: usize,
pub text: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct OutputContext {
pub owner: Id,
pub generation: u64,
pub purpose: IMEPurpose,
}
impl OutputContext {
/// Snapshot the owner/generation/purpose triple that identifies one published custom IME
/// output.
pub(crate) fn of(ime: &egui::output::IMEOutput) -> Self {
Self { owner: ime.owner, generation: ime.context_generation, purpose: ime.purpose }
}
}
#[derive(Clone, Copy, Debug)]
enum Phase {
NeedsRequest,
Awaiting(ImeResetId),
Suspended,
AllocationFailed,
}
#[derive(Clone, Copy, Debug)]
struct Pending {
// The retired composition is separate from the current destination (A -> B releases B).
origin: ImeTarget,
destination: Option<Destination>,
output: Option<OutputContext>,
phase: Phase,
}
#[derive(Default)]
pub(crate) struct State {
pending: ViewportIdMap<Pending>,
incoming: ViewportIdMap<Destination>,
completed_origin: ViewportIdMap<ImeTarget>,
pub viewport: ViewportId,
custom_output: Option<OutputContext>,
}
impl State {
/// Start a pass for `ctx`'s viewport and clear the per-pass published-output record, so
/// `finish` cannot mistake a previous pass's descriptor for this pass's.
pub(crate) fn begin_pass(&mut self, ctx: &Context) {
self.viewport = ctx.viewport_id();
self.custom_output = None;
}
/// Whether a reset fence still guards `viewport`. While blocked, IME input for the custom
/// editor must not be admitted.
pub(crate) fn blocked(&self, viewport: ViewportId) -> bool {
self.pending.contains_key(&viewport)
}
/// The composition target being retired for `viewport`: the pending origin, or the last
/// completed origin until `terminal` clears it.
pub(crate) fn origin(&self, viewport: ViewportId) -> Option<ImeTarget> {
self.pending.get(&viewport).map(|p| p.origin).or_else(|| self.completed_origin.get(&viewport).copied())
}
/// Whether `destination` differs from the last one remembered for `viewport`; false when
/// nothing has been remembered yet.
pub(crate) fn incoming_changed(&self, viewport: ViewportId, destination: Destination) -> bool {
self.incoming.get(&viewport).is_some_and(|old| *old != destination)
}
/// Record the latest destination for `viewport`, so the next event can distinguish a real
/// target change from repeated observation of the same target.
pub(crate) fn remember(&mut self, viewport: ViewportId, destination: Destination) {
self.incoming.insert(viewport, destination);
}
/// Acknowledge a terminal IME event for `viewport`: forget the incoming destination and
/// completed origin so stale targets cannot be reused.
pub(crate) fn terminal(&mut self, viewport: ViewportId) {
self.incoming.remove(&viewport);
self.completed_origin.remove(&viewport);
}
/// Context-less commands retire an obsolete ID synchronously. Repeated unchanged
/// reconciliation is inert; the next live local destination flushes NeedsRequest.
pub(crate) fn interrupt(&mut self, origin: ImeTarget, destination: Option<Destination>) {
let viewport = self.viewport;
if let Some(pending) = self.pending.get_mut(&viewport) {
if pending.destination != destination {
pending.destination = destination;
// An unavailable route cannot be rearmed by reconciliation or a control.
// Only the actual eligible final descriptor may leave Suspended.
if !matches!(pending.phase, Phase::Suspended) {
pending.phase = Phase::NeedsRequest;
}
}
} else {
self.pending.insert(viewport, Pending { origin, destination, output: None, phase: Phase::NeedsRequest });
}
}
/// Move a pending fence to `Suspended` and drop its old output binding. Only an actually
/// eligible final descriptor in `finish` may rearm it; `sync`/`interrupt` reconciliation cannot.
pub(crate) fn suspend(&mut self, viewport: ViewportId) {
if let Some(pending) = self.pending.get_mut(&viewport) {
pending.phase = Phase::Suspended;
pending.output = None;
}
}
/// Whether the custom output published in this pass is the one recorded by `produced`.
/// False when no custom output was produced in this pass.
pub(crate) fn owns_output(&self, ctx: &Context) -> bool {
self.custom_output.is_some() && ctx.output(|out| out.ime.as_ref().map(OutputContext::of)) == self.custom_output
}
/// Allocate a reset fence for the current viewport after clearing the previous fence's
/// output binding. Allocation failure leaves the phase `AllocationFailed`, which still
/// blocks input; a later real destination change can request again.
fn request(ctx: &Context, pending: &mut Pending) {
// A new fence invalidates any output context bound to the previous fence. Clearing it
// also lets `finish` recognize this request as already made in this pass, even when an
// earlier pass had bound a different descriptor to the old destination.
pending.output = None;
pending.phase = ctx.request_ime_reset(ctx.viewport_id()).map_or(Phase::AllocationFailed, Phase::Awaiting);
}
/// Must run with the live local ed/text before EACH raw event, never a staged engine copy.
pub(crate) fn sync(&mut self, ctx: &Context, destination: Destination) {
let Some(pending) = self.pending.get_mut(&ctx.viewport_id()) else { return };
let changed = pending.destination != Some(destination);
pending.destination = Some(destination);
// A suspended route cannot admit events until an actual eligible output rearms it.
if !matches!(pending.phase, Phase::Suspended) && (changed || matches!(pending.phase, Phase::NeedsRequest)) {
Self::request(ctx, pending);
}
}
/// Validate a completion token and release the viewport's guard. The token must belong to
/// this viewport and equal the current `Awaiting` id, and `destination` must equal the
/// pending destination; `ctx.output` is deliberately not consulted because raw events run
/// before this pass publishes its descriptor. Any mismatch returns false and keeps the
/// guard. Success records the origin and destination for the next observation.
pub(crate) fn complete(&mut self, ctx: &Context, id: ImeResetId, destination: Destination) -> bool {
let viewport = ctx.viewport_id();
let Some(pending) = self.pending.get(&viewport).copied() else { return false };
// The opaque token is necessary but not sufficient: the destination carries the owner
// and replacement epoch, Film publishes only `IMEPurpose::Normal`, and a generation,
// purpose or focus transition supersedes or suspends this guard before it can be rearmed. Do not read
// `ctx.output` here: raw events are processed before this pass has published its custom
// descriptor, so that would compare against an empty or stale output. The reducer test
// `changed_output_context_supersedes_and_allocation_failure_stays_blocked` binds a token
// to one live owner/generation/purpose by rejecting it after each transition.
if id.viewport_id() != viewport || pending.destination != Some(destination) || !matches!(pending.phase, Phase::Awaiting(current) if current == id) {
return false;
}
self.pending.remove(&viewport);
self.completed_origin.insert(viewport, pending.origin);
self.remember(viewport, destination);
true
}
/// Record the custom output descriptor published in this pass, for `finish` and
/// `owns_output` to compare against the actual output.
pub(crate) fn produced(&mut self, key: OutputContext) {
self.custom_output = Some(key);
}
/// Actual custom eligibility and actual final app output are independent facts. Never
/// overwrite a stock field or infer native completion from a descriptor/publication.
pub(crate) fn finish(&mut self, ctx: &Context, destination: Option<Destination>, eligible: bool) {
let viewport = ctx.viewport_id();
let actual = ctx.output(|out| out.ime.as_ref().map(OutputContext::of));
let custom = self.custom_output;
let Some(pending) = self.pending.get_mut(&viewport) else { return };
let Some(destination) = destination.filter(|_| eligible && custom.is_some() && actual == custom) else {
self.suspend(viewport);
return;
};
let changed = pending.destination != Some(destination) || pending.output != custom;
let activate = matches!(pending.phase, Phase::Suspended | Phase::NeedsRequest);
// `sync` may already have requested this pass, while `finish` is the first place the
// actual custom output context is known. Bind that same request to the descriptor instead
// of allocating a second, silently superseded ID for the same destination transition.
let requested_by_sync = matches!(pending.phase, Phase::Awaiting(_)) && pending.output.is_none();
pending.destination = Some(destination);
if activate || (changed && !requested_by_sync) {
Self::request(ctx, pending);
}
// A request clears the previous fence's binding. Bind the confirmed descriptor only
// after requesting, so the next pass compares against this output rather than mistaking
// an earlier pass's unbound request for a same-pass sync request.
pending.output = custom;
}
/// Forget all reset state for a closed viewport: pending fence, incoming destination and
/// completed origin.
pub(crate) fn end_viewport(&mut self, viewport: ViewportId) {
self.pending.remove(&viewport);
self.incoming.remove(&viewport);
self.completed_origin.remove(&viewport);
}
}
/// Temp-data key marking whether `viewport` is inside a UI pass rather than a hidden logic
/// callback.
fn mode_id(viewport: ViewportId) -> Id {
Id::unique(("filmcraft-ime-reset-ui-pass", viewport))
}
/// Whether the current callback is a UI pass (between the installed begin/end-pass hooks)
/// for this viewport, rather than `run_logic`. Defaults to false when no hook ran.
pub(crate) fn in_ui_pass(ctx: &Context) -> bool {
ctx.data(|data| data.get_temp::<bool>(mode_id(ctx.viewport_id()))).unwrap_or(false)
}
/// Install before the first run_ui. The callbacks receive Ui and capture no Context/app.
pub(crate) fn install(ctx: &Context) {
let installed = Id::unique("filmcraft-ime-reset-mode-installed");
if ctx.data(|data| data.get_temp::<bool>(installed)).unwrap_or(false) {
return;
}
ctx.on_begin_pass(
"filmcraft IME reset UI entry",
Arc::new(|ui| {
let viewport = ui.ctx().viewport_id();
ui.data_mut(|data| data.insert_temp(mode_id(viewport), true));
}),
);
ctx.on_end_pass(
"filmcraft IME reset UI exit",
Arc::new(|ui| {
let viewport = ui.ctx().viewport_id();
ui.data_mut(|data| data.insert_temp(mode_id(viewport), false));
}),
);
ctx.data_mut(|data| data.insert_temp(installed, true));
}
#[cfg(test)]
mod tests {
use super::*;
/// A fixed destination for one editor owner with caret 3 and committed-text stamp 17.
fn destination() -> Destination {
Destination { document: 1, target: ImeTarget::new(Id::unique("film-model-editor"), 1), caret: 3, anchor: 3, text: 17 }
}
/// Build the IME output a custom producer publishes for `destination` with the given
/// generation and purpose.
fn output(destination: Destination, generation: u64, purpose: IMEPurpose) -> egui::output::IMEOutput {
egui::output::IMEOutput::new(destination.target.owner, generation, purpose, egui::Rect::ZERO, egui::Rect::ZERO)
}
// These reducer tests use real Context requests/output, not native acknowledgements.
/// Run one real UI pass that publishes a custom `output` and calls `finish` as eligible;
/// return the reset request the Context attached, if any.
fn custom_pass(ctx: &Context, state: &mut State, destination: Destination, generation: u64, purpose: IMEPurpose) -> Option<ImeResetId> {
let mut result = ctx.run_ui(egui::RawInput::default(), |ui| {
state.begin_pass(ui.ctx());
let ime = output(destination, generation, purpose);
state.produced(OutputContext::of(&ime));
ui.ctx().output_mut(|out| out.ime = Some(ime));
state.finish(ui.ctx(), Some(destination), true);
});
let id = result.platform_output.ime.as_ref().and_then(|ime| ime.reset_request);
result.textures_delta.clear();
id
}
/// A generation or purpose change supersedes the old fence, old tokens cannot complete, an
/// allocation failure stays blocked, and a later real destination change retries.
#[test]
fn changed_output_context_supersedes_and_allocation_failure_stays_blocked() {
let ctx = Context::default();
install(&ctx);
let mut state = State::default();
let destination = destination();
state.interrupt(destination.target, Some(destination));
let first = custom_pass(&ctx, &mut state, destination, 1, IMEPurpose::Normal).unwrap();
assert!(custom_pass(&ctx, &mut state, destination, 1, IMEPurpose::Normal).is_none());
let second = custom_pass(&ctx, &mut state, destination, 2, IMEPurpose::Normal).unwrap();
let third = custom_pass(&ctx, &mut state, destination, 2, IMEPurpose::Password).unwrap();
assert_ne!(first, second);
assert_ne!(second, third);
// The old opaque IDs are rejected even though document/target/caret/text are unchanged.
assert!(!state.complete(&ctx, first, destination));
assert!(!state.complete(&ctx, second, destination));
let other_owner = Destination { target: ImeTarget::new(Id::unique("film-other-owner"), 1), ..destination };
assert!(!state.complete(&ctx, third, other_owner), "the live destination owner is part of the admission boundary");
assert!(state.blocked(ViewportId::ROOT));
// Model token exhaustion explicitly; do not attempt to forge an opaque ID.
state.pending.get_mut(&ViewportId::ROOT).unwrap().phase = Phase::AllocationFailed;
for _ in 0..3 {
assert!(custom_pass(&ctx, &mut state, destination, 2, IMEPurpose::Password).is_none());
assert!(state.blocked(ViewportId::ROOT));
}
let changed = Destination { caret: 1, anchor: 1, ..destination };
state.sync(&ctx, changed);
let retry = custom_pass(&ctx, &mut state, changed, 2, IMEPurpose::Password).unwrap();
assert!(state.complete(&ctx, retry, changed));
assert!(!state.complete(&ctx, retry, changed));
}
/// `sync` followed by `finish` in the same pass allocates exactly one request and keeps it
/// as the awaiting boundary.
#[test]
fn same_pass_sync_and_finish_keep_one_request() {
let ctx = Context::default();
install(&ctx);
let mut state = State::default();
let destination = destination();
state.interrupt(destination.target, Some(destination));
let mut requested = None;
let mut result = ctx.run_ui(egui::RawInput::default(), |ui| {
state.begin_pass(ui.ctx());
let ime = output(destination, 1, IMEPurpose::Normal);
state.produced(OutputContext::of(&ime));
ui.ctx().output_mut(|out| out.ime = Some(ime));
state.sync(ui.ctx(), destination);
if let Phase::Awaiting(id) = state.pending[&ViewportId::ROOT].phase {
requested = Some(id);
}
state.finish(ui.ctx(), Some(destination), true);
assert!(matches!(
state.pending[&ViewportId::ROOT].phase,
Phase::Awaiting(id) if Some(id) == requested
));
});
assert_eq!(result.platform_output.ime.as_ref().and_then(|ime| ime.reset_request), requested);
result.textures_delta.clear();
}
/// `finish` reuses the replacement request allocated by `sync` instead of allocating a
/// second one, and the stale token cannot release the guard.
#[test]
fn stale_output_binding_does_not_force_a_second_request_on_sync_then_finish() {
let ctx = Context::default();
install(&ctx);
let mut state = State::default();
let destination = destination();
state.interrupt(destination.target, Some(destination));
let stale = custom_pass(&ctx, &mut state, destination, 1, IMEPurpose::Normal).unwrap();
assert!(matches!(
state.pending[&ViewportId::ROOT].phase,
Phase::Awaiting(id) if id == stale
));
assert!(state.pending[&ViewportId::ROOT].output.is_some());
// A destination transition retires the fence at the admission boundary but leaves the
// previous output binding attached until the next actual request replaces it.
let changed = Destination { caret: 1, anchor: 1, ..destination };
state.interrupt(destination.target, Some(changed));
assert!(state.pending[&ViewportId::ROOT].output.is_some(), "an old output context remains bound");
let mut requested_by_sync = None;
let mut result = ctx.run_ui(egui::RawInput::default(), |ui| {
state.begin_pass(ui.ctx());
let ime = output(changed, 2, IMEPurpose::Normal);
state.produced(OutputContext::of(&ime));
ui.ctx().output_mut(|out| out.ime = Some(ime));
state.sync(ui.ctx(), changed);
if let Phase::Awaiting(id) = state.pending[&ViewportId::ROOT].phase {
requested_by_sync = Some(id);
}
state.finish(ui.ctx(), Some(changed), true);
assert!(
matches!(
state.pending[&ViewportId::ROOT].phase,
Phase::Awaiting(id) if Some(id) == requested_by_sync
),
"finish must reuse the request sync allocated for this destination transition"
);
});
let replacement = requested_by_sync.expect("sync allocated the replacement request");
assert_ne!(replacement, stale);
assert_eq!(result.platform_output.ime.as_ref().and_then(|ime| ime.reset_request), Some(replacement));
result.textures_delta.clear();
// The retired R1 is no longer the awaiting boundary and cannot release the guard.
assert!(!state.complete(&ctx, stale, changed));
assert!(state.blocked(ViewportId::ROOT));
assert!(state.complete(&ctx, replacement, changed));
assert!(!state.blocked(ViewportId::ROOT));
}
/// If custom eligibility is lost after `sync`, stock IME may echo the queued token but it
/// cannot release the suspended guard; only a later eligible descriptor can.
#[test]
fn queued_before_absence_token_may_reach_stock_but_cannot_release_suspended_guard() {
let ctx = Context::default();
install(&ctx);
let mut state = State::default();
let destination = destination();
state.interrupt(destination.target, Some(destination));
let mut queued = None;
let mut absent = ctx.run_ui(egui::RawInput::default(), |ui| {
state.begin_pass(ui.ctx());
state.sync(ui.ctx(), destination);
if let Phase::Awaiting(id) = state.pending[&ViewportId::ROOT].phase {
queued = Some(id); // actual Context request returned by the production reducer
}
// A later same-pass layout/control loss prevents any descriptor publication.
state.finish(ui.ctx(), Some(destination), false);
});
assert!(absent.platform_output.ime.is_none());
absent.textures_delta.clear();
let queued = queued.unwrap();
let stock_id = Id::unique("film-model-stock-field");
let mut text = String::from("stock");
let mut stock = ctx.run_ui(egui::RawInput::default(), |ui| {
state.begin_pass(ui.ctx());
ui.memory_mut(|memory| memory.request_focus(stock_id));
ui.add(egui::TextEdit::singleline(&mut text).id(stock_id));
// Destination changes on an ineligible route cannot allocate a replacement.
state.interrupt(destination.target, Some(Destination { caret: 0, anchor: 0, ..destination }));
state.sync(ui.ctx(), destination);
assert!(!state.complete(ui.ctx(), queued, destination));
state.finish(ui.ctx(), Some(destination), false);
});
assert_eq!(
stock.platform_output.ime.as_ref().and_then(|ime| ime.reset_request),
Some(queued),
"core may attach ONLY the already queued R1; the public app API cannot cancel it"
);
assert!(matches!(state.pending[&ViewportId::ROOT].phase, Phase::Suspended));
stock.textures_delta.clear();
ctx.memory_mut(|memory| memory.surrender_focus(stock_id));
let replacement = custom_pass(&ctx, &mut state, destination, 1, IMEPurpose::Normal).unwrap();
assert_ne!(replacement, queued);
assert!(!state.complete(&ctx, queued, destination));
assert!(state.complete(&ctx, replacement, destination));
}
/// UI-pass mode is scoped per viewport, `install` is idempotent, and a child viewport's
/// completion cannot release ROOT's guard.
#[test]
fn pass_mode_is_per_viewport_and_foreign_completion_cannot_release_a_real_guard() {
let ctx = Context::default();
install(&ctx);
install(&ctx); // creator reuse must not register another pair of callbacks
let mut state = State::default();
let destination = destination();
state.interrupt(destination.target, Some(destination));
let root = custom_pass(&ctx, &mut state, destination, 1, IMEPurpose::Normal).unwrap();
let foreign_viewport = ViewportId::from_hash_of("film-other-window");
// Register an actual child viewport; a request for an unlisted viewport would be dropped
// by `Memory::begin_pass` before it could exercise the viewport check at all.
let mut raw = egui::RawInput { viewport_id: foreign_viewport, ..Default::default() };
raw.viewports.insert(foreign_viewport, egui::ViewportInfo { parent: Some(ViewportId::ROOT), ..Default::default() });
let mut foreign = None;
let mut foreign_output = ctx.run_ui(raw, |ui| {
assert_eq!(ui.ctx().viewport_id(), foreign_viewport);
assert!(in_ui_pass(ui.ctx()));
foreign = ui.ctx().request_ime_reset(foreign_viewport);
});
foreign_output.textures_delta.clear();
let foreign = foreign.expect("the registered child viewport allocated a real request");
let mut full = ctx.run_ui(egui::RawInput::default(), |ui| {
assert!(in_ui_pass(ui.ctx()));
assert!(!ui.data(|data| data.get_temp::<bool>(mode_id(foreign_viewport))).unwrap_or(false));
state.begin_pass(ui.ctx());
assert!(!state.complete(ui.ctx(), foreign, destination), "a child viewport's completion cannot release ROOT's guard");
assert!(state.blocked(ViewportId::ROOT));
assert!(state.complete(ui.ctx(), root, destination));
assert!(!state.complete(ui.ctx(), foreign, destination));
assert!(!state.blocked(ViewportId::ROOT));
});
full.textures_delta.clear();
assert!(!in_ui_pass(&ctx));
let _logic = ctx.run_logic(&egui::RawInput::default(), |ctx| assert!(!in_ui_pass(ctx)));
}
}
+787 -220
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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()));
+68 -4
View File
@@ -655,8 +655,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 {
@@ -672,6 +684,34 @@ fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option<Result<
Some(app.session.execute("edit.pasteMedia", params).map_err(|e| e.to_string()))
}
/// Upper bound on the RGBA copy made from a clipboard image, matching the screenshot per-image
/// bound. The event's own `ColorImage` is still alive while this copy is encoded, so reject before
/// allocating it rather than only checking for integer overflow.
const MAX_CLIPBOARD_IMAGE_BYTES: usize = 128 * 1024 * 1024;
/// 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())?;
if len > MAX_CLIPBOARD_IMAGE_BYTES {
return Err(format!(
"the pasted image is too large: {width}×{height} needs {len} RGBA bytes, over the {MAX_CLIPBOARD_IMAGE_BYTES}-byte clipboard image limit"
));
}
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())?);
@@ -912,7 +952,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();
@@ -973,7 +1013,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);
@@ -1098,6 +1138,30 @@ 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]);
}
/// An image whose RGBA size exceeds the 128 MiB cap is rejected before any RGBA copy is allocated.
#[test]
fn oversized_clipboard_images_are_rejected_before_allocating_rgba() {
let side = 100_000;
let image = egui::ColorImage { size: [side, side], source_size: egui::vec2(100_000.0, 100_000.0), pixels: Vec::new() };
let Err(error) = paste_image_media(&image) else { panic!("an oversized clipboard image was accepted") };
assert!(error.contains("too large"), "{error}");
}
/// 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]
+1 -1
View File
@@ -5,7 +5,7 @@
//! the desktop app on Linux, where the XDG portal call would otherwise hold the UI thread until the
//! user chooses, and GNOME offers to force-quit the window after about five seconds) gets the
//! dialog and answers on a channel; the frame loop keeps running, and the continuation runs on the
//! frame the answer arrives ([`FilmcraftApp::poll_native_dialog`]). Without it the host's
//! frame the answer arrives (`FilmcraftApp::poll_native_dialog`). Without it the host's
//! synchronous pickers (`pick_files`, `pick_save_as`, …) are called and the continuation runs at
//! once, as before: headless tests, the control channel's stand-ins and other hosts are unchanged.
//!
+5 -5
View File
@@ -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);
+9 -7
View File
@@ -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") => {
+2 -2
View File
@@ -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)
+8 -8
View File
@@ -35,7 +35,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)
@@ -95,7 +95,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 {
@@ -107,12 +107,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)
@@ -193,14 +193,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)
@@ -331,7 +331,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)
@@ -417,7 +417,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() {
+97 -39
View File
@@ -11,6 +11,7 @@ use crate::FilmcraftApp;
use crate::icons::{self, Icon};
use crate::state::KeyframeRef;
use crate::theme::Tokens;
use crate::widgets::HotNumberOptions;
#[path = "effect_controls_selection.rs"]
mod selection;
@@ -228,11 +229,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 +245,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 +308,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 +319,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 +375,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 +512,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 +569,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 +582,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 +598,43 @@ 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,
HotNumberOptions::new(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, HotNumberOptions::new(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,
HotNumberOptions::new(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,
HotNumberOptions::new(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 +642,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 +651,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 +680,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)), crate::i18n::t(pd.label));
app.auto.add(&format!("effectControls.text.{}.{}", idx, pd.id), r.rect, pd.label);
if r.lost_focus() {
if buf != *s {
@@ -685,7 +705,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 +713,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 +730,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 +816,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 +836,13 @@ 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,
HotNumberOptions::new((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 +903,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 +938,21 @@ 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,
HotNumberOptions::new(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,
HotNumberOptions::new(speed, range, 0, " Y", &t),
);
if nx.is_some() || ny.is_some() {
actions.push((
"effects.setParam".into(),
@@ -926,13 +963,10 @@ 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,
if p == "scale" { 0 } else { 1 },
unit,
&t,
HotNumberOptions::new(speed, range, if p == "scale" { 0 } else { 1 }, unit, &t),
);
if let Some(nv) = nv {
actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "motion", "param": p, "value": nv})));
@@ -949,7 +983,13 @@ 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,
HotNumberOptions::new(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 +1006,13 @@ 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,
HotNumberOptions::new(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 +1036,13 @@ 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,
HotNumberOptions::new(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 +1054,13 @@ 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,
HotNumberOptions::new(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 +1092,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 +1187,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 +1258,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);
@@ -11,8 +11,9 @@ use crate::{
state::KeyframeRef,
};
/// Key for the keyframe-selection gesture state in egui's temp store; `begin` resets it when the clip changes.
fn state_id() -> Id {
Id::new("effect-controls-keyframe-selection")
Id::unique("effect-controls-keyframe-selection")
}
#[derive(Clone)]
@@ -103,6 +104,8 @@ fn update(group: &mut Group, points: &[Point], pos: Pos2, shift: bool, playhead:
}
impl Interaction {
/// Begin keyframe selection for `clip`: reset on a clip change, cancel a gesture on Escape, start a Shift marquee from the existing selection, and keep
/// gestures as UI-only previews.
pub(crate) fn begin(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect, clip: u64) -> Self {
let mut state = ui.data(|d| d.get_temp::<State>(state_id())).unwrap_or_default();
if state.clip != clip {
@@ -112,7 +115,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();
}
+1 -1
View File
@@ -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);
+12 -9
View File
@@ -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);
+1 -1
View File
@@ -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);
+14 -11
View File
@@ -358,7 +358,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 {
@@ -396,7 +396,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);
}
@@ -405,9 +405,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()));
@@ -1195,11 +1195,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");
@@ -1256,8 +1256,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)
@@ -1315,7 +1315,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");
@@ -1486,7 +1489,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| {
@@ -1526,7 +1529,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
+2 -2
View File
@@ -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));
+9 -9
View File
@@ -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
+23 -13
View File
@@ -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)
+3 -3
View File
@@ -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 {
+4 -3
View File
@@ -290,7 +290,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()})
}
@@ -299,7 +299,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();
@@ -367,7 +367,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}))
}
+16 -10
View File
@@ -17,6 +17,7 @@ use crate::FilmcraftApp;
use crate::icons::{self, Icon};
use crate::native_dialogs::FileDialog;
use crate::theme::Tokens;
use crate::widgets::HotNumberOptions;
type Actions = Vec<Value>;
@@ -127,7 +128,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 +346,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 +422,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 +444,13 @@ 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,
HotNumberOptions::new((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 +516,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 +618,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 +633,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 +651,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)"));
+24 -22
View File
@@ -58,7 +58,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);
}
@@ -91,7 +91,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));
@@ -119,13 +119,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 {
@@ -167,7 +169,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");
@@ -287,7 +289,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()) {
@@ -321,7 +323,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) {
@@ -331,7 +333,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
// (inside the outline's box but outside the shape, the drag is a marquee too)
let body_marquee = selection_tool && !ui.data(|d| d.get_temp::<bool>(gesture_id.with("body"))).unwrap_or(false);
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}")}))));
}
@@ -369,7 +371,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());
@@ -383,7 +385,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 n_v = path.vertices.len();
// (stored with the vertex count: once a vertex is added or deleted, by any edit or by undo, the
// indices name other vertices and the selection goes)
@@ -402,7 +404,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((
@@ -418,7 +420,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
let selected = sel_vs.contains(&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")
@@ -434,7 +436,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, (n_v, sel_vs.clone())));
}
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 {
let which = if sel_vs.len() > 1 && sel_vs.contains(&i) { json!({"vertices": sel_vs}) } else { json!({"vertex": i}) };
let mut p = with(which);
@@ -594,7 +596,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 };
@@ -687,7 +689,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);
@@ -702,7 +704,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 {
@@ -712,11 +714,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
+15 -11
View File
@@ -53,8 +53,9 @@ const PREVIEW_BASE: u64 = 1 << 44;
const CACHE_SECS: f64 = 3.0;
const ROW_H: f32 = 22.0;
/// Temp-store key for the Media Browser cache: listings stay fresh for 3 s and preview texture ids start at `PREVIEW_BASE`.
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 +107,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 +123,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 +142,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 +166,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 +207,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 +237,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 +577,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 +660,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 +819,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)
+17 -10
View File
@@ -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…");
+5 -3
View File
@@ -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() {
+5 -2
View File
@@ -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() {
+2 -2
View File
@@ -81,14 +81,14 @@ pub fn route_history(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, para
if !app.session.history.can_redo() {
return Some(Err("nothing to redo".into()));
}
ctx.data_mut(|d| d.insert_temp(egui::Id::new(CONFIRM_DELETE_REDOABLE), true));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(CONFIRM_DELETE_REDOABLE), true));
Some(Ok(serde_json::json!({"dialog": "deleteRedoable"})))
}
/// The Delete Redoable Actions confirmation (automation ids `history.deleteRedoable.cancel` and
/// `history.deleteRedoable.ok`). It closes by itself when nothing is left to redo.
pub fn history_dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) {
let key = egui::Id::new(CONFIRM_DELETE_REDOABLE);
let key = egui::Id::unique(CONFIRM_DELETE_REDOABLE);
if !ctx.data(|d| d.get_temp::<bool>(key)).unwrap_or(false) {
return;
}
+70 -38
View File
@@ -34,6 +34,7 @@ use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::icons::{self, Icon};
use crate::theme::Tokens;
use crate::widgets::HotNumberOptions;
// ------------------------------------------------------------------------------------- fader law
@@ -90,7 +91,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 (peak holds 6 dB/s) while live, dropping within a quarter second after Stop. [`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 {
@@ -226,7 +227,7 @@ fn panel_key(app: &FilmcraftApp, id: &str) -> String {
/// Show/Hide Tracks… (`mixer.showHideTracks`): shows the Audio Track Mixer and opens its dialog.
pub(crate) fn open_show_hide_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> Value {
app.show_panel(crate::dock::PanelKind::AudioTrackMixer);
ctx.data_mut(|d| d.insert_temp(egui::Id::new(SHOW_HIDE_TRACKS_OPEN), true));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(SHOW_HIDE_TRACKS_OPEN), true));
json!({"dialog": "showHideTracks"})
}
@@ -256,7 +257,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() {
@@ -264,10 +265,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(SHOW_HIDE_TRACKS_OPEN);
let show_hide_id = egui::Id::unique(SHOW_HIDE_TRACKS_OPEN);
let mut show_hide: bool = ui.data(|d| d.get_temp(show_hide_id)).unwrap_or(false);
let (show_hide_key, meter_input_key) = (panel_key(app, "mixer.showHideTracks"), panel_key(app, "mixer.meterInputOnly"));
egui::Popup::menu(&mresp).show(|ui| {
@@ -286,7 +287,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) {
@@ -328,7 +329,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) {
@@ -341,7 +342,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);
@@ -377,7 +378,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);
@@ -390,7 +391,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);
@@ -414,6 +415,7 @@ fn run_actions(app: &mut FilmcraftApp, ctx: &egui::Context, acts: Vec<(String, V
}
}
/// Draw one mixer strip. A missing track is a no-op; automation ids use `mixer.<label>`; the master strip has `MASTER_STRIP` handling.
fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, sr: Rect, meter: &[[f32; 2]]) {
let t = cx.t;
let seq = cx.seq;
@@ -435,10 +437,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| {
@@ -529,7 +531,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);
@@ -592,7 +594,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 {
@@ -607,7 +609,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| {
@@ -633,7 +635,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();
@@ -656,7 +659,13 @@ 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,
HotNumberOptions::new(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}));
@@ -670,7 +679,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);
}
@@ -692,7 +701,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() {
@@ -703,21 +712,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() {
@@ -749,7 +758,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);
@@ -792,7 +801,13 @@ 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),
HotNumberOptions::new(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}));
@@ -820,7 +835,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);
@@ -860,7 +875,13 @@ 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,
HotNumberOptions::new(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}));
@@ -868,8 +889,13 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr
small_text(ui, pos2(col_x, r.min.y + 32.0), Align2::LEFT_CENTER, "LFE", 9.0, t.text_dim);
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);
let (lresp, nl) = crate::widgets::hot_number(
&mut lui,
"LFE",
egui::Id::unique((ap, "pan51.lfe")),
lfe.max(FADER_MIN_DB),
HotNumberOptions::new(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}));
@@ -897,7 +923,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);
@@ -946,7 +972,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).
@@ -996,7 +1022,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)));
@@ -1017,7 +1043,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 {
@@ -1032,18 +1058,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);
@@ -1060,7 +1086,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)));
@@ -1084,7 +1110,13 @@ 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),
HotNumberOptions::new(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());
+8 -6
View File
@@ -123,8 +123,9 @@ enum DragPayload {
Template(String, String),
}
/// Single egui temp slot holding the active drag payload: a project item or bin, a source, an effect or a graphics template.
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) {
@@ -210,8 +211,9 @@ pub fn clear_drag(ui: &egui::Ui) {
ui.ctx().data_mut(|d| d.insert_temp::<Option<DragPayloadBox>>(payload_id(), None));
}
/// Temp-store key marking a cancelled drag until the button is released, so it cannot restart immediately.
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
@@ -260,7 +262,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);
@@ -268,7 +270,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);
@@ -289,7 +291,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| {
@@ -341,7 +343,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));
}
+16 -14
View File
@@ -315,11 +315,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 {
@@ -356,7 +356,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})) {
@@ -383,7 +383,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;
@@ -401,7 +401,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 {
@@ -411,7 +411,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| {
@@ -572,7 +572,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));
@@ -729,7 +729,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;
@@ -741,7 +741,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())
{
@@ -808,7 +808,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();
@@ -944,6 +944,7 @@ fn set_transport_visible(hidden: &mut Vec<String>, command: &str, visible: bool)
/// Width and height of a transport button.
const TRANSPORT_BUTTON: f32 = 28.0;
/// Draw a monitor's Source or Program transport. Row buttons use `transport_button`; overflow-menu entries call `invoke_transport` directly.
fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) {
let t = app.tokens;
let src = which == Which::Source;
@@ -990,7 +991,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();
@@ -1054,8 +1055,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);
}
@@ -1086,9 +1087,10 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
}
}
/// Draw one transport button with a unique id, automation registration, hover fill, proxy accent and a larger play icon; returns whether it was clicked.
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);
+10 -10
View File
@@ -444,7 +444,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));
@@ -453,7 +453,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 });
}
@@ -565,9 +565,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 });
@@ -619,13 +619,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));
}
@@ -710,7 +710,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();
@@ -753,7 +753,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);
@@ -861,7 +861,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;
@@ -872,7 +872,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)
+21 -12
View File
@@ -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");
});
}
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -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| {
+10 -10
View File
@@ -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);
+8 -6
View File
@@ -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");
+16 -13
View File
@@ -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();
+2 -2
View File
@@ -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() {
+3 -3
View File
@@ -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)
+4 -4
View File
@@ -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)
+25 -16
View File
@@ -1,4 +1,4 @@
//! The Settings dialog (app menu ▸ Settings ▸ <category>; Edit ▸ Preferences in the in-window
//! The Settings dialog (app menu ▸ Settings ▸ `<category>`; Edit ▸ Preferences in the in-window
//! menu bar; General is Cmd+,). One window titled "Preferences": the category list on the left,
//! the category's fields on the right, Help / Reset… / Cancel / OK at the bottom. The page layout
//! comes from the engine's field schema ([`filmcraft_engine::settings::categories`]); edits go to a
@@ -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);
+10 -7
View File
@@ -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,
+2 -2
View File
@@ -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);
+4 -3
View File
@@ -21,8 +21,9 @@ struct Drag {
pointer_x: f32,
revision: u64,
}
/// Temp-store key for the in/out range drag, per monitor, so Source and Program drags never alias.
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 +104,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 +198,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()
{
+52 -37
View File
@@ -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();
@@ -179,7 +179,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(list.shrink(4.0)).id_salt("caption-list"));
let current = track.caption_at(ph).map(|c| c.id);
if current.is_none() {
ui.data_mut(|d| d.remove::<u64>(egui::Id::new("caption-follow")));
ui.data_mut(|d| d.remove::<u64>(egui::Id::unique("caption-follow")));
}
egui::ScrollArea::vertical().auto_shrink([false, false]).id_salt("caption-scroll").show(&mut child, |ui| {
ui.set_width(list.width() - 12.0);
@@ -212,14 +212,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() {
@@ -237,18 +240,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");
@@ -271,7 +277,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.painter().rect_stroke(row, 3.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
// the list follows the playhead: each caption it reaches is brought to the middle
// once, but never while a field is being edited
let follow = egui::Id::new("caption-follow");
let follow = egui::Id::unique("caption-follow");
if ui.data(|d| d.get_temp::<u64>(follow)) != Some(c.id.0) && ui.memory(|m| m.focused().is_none()) {
ui.scroll_to_rect(row, Some(egui::Align::Center));
ui.data_mut(|d| d.insert_temp(follow, c.id.0));
@@ -356,7 +362,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))));
@@ -409,15 +415,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() {
@@ -473,9 +482,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()));
@@ -489,9 +501,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()));
@@ -547,7 +562,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");
+2 -2
View File
@@ -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() {
+41 -25
View File
@@ -379,6 +379,8 @@ fn label_color(app: &FilmcraftApp, l: filmcraft_project::Label) -> Color32 {
crate::panels::settings::label_color(app, l)
}
/// Draw the Timeline and run its interaction. A missing or damaged active sequence shows the empty state; `stable_dt` is capped, and a project snapshot is kept
/// while commands mutate the session.
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
@@ -730,7 +732,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);
}
@@ -816,6 +818,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let _ = (visible, TICKS_PER_SECOND);
}
/// Shown when no sequence is open: offers the demo project and accepts project or OS-file drops to create a sequence.
fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
ui.painter().text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, tl!("Drop media here to create sequence."), Tokens::ui(13.0), t.text_dim);
@@ -824,7 +827,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);
@@ -1384,6 +1387,7 @@ fn patch_button(ui: &mut egui::Ui, r: Rect, clip: Rect, label: &str, on: bool, s
resp
}
/// Draw only the visible track headers, including colour label, targeting, lock and header controls clipped to each row.
#[allow(clippy::too_many_arguments)]
fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: &[Row], rect: Rect, vclip: Rect, aclip: Rect, t: &Tokens) {
let hw = app.ui.timeline.header_w;
@@ -1408,7 +1412,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);
@@ -1440,7 +1444,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
@@ -1453,7 +1457,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");
@@ -1468,14 +1472,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})));
@@ -1489,7 +1500,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");
@@ -1505,7 +1516,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() {
@@ -1519,7 +1530,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() {
@@ -1548,7 +1559,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);
}
@@ -1560,7 +1571,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() {
@@ -1596,6 +1607,7 @@ pub fn ruler_steps(frame_px: f64, base: i64) -> (i64, i64) {
(minor, label_step)
}
/// Draw editable timecode, the timeline toolbar and ruler. Rejected timecode edits report a status message; toolbar command errors are ignored.
fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequence, layout: &Layout, t: &Tokens, seq_id: ItemId) {
let p = ui.painter().clone();
let rate = seq.settings.frame_rate;
@@ -1606,7 +1618,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})) {
@@ -1630,7 +1642,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);
@@ -1754,7 +1766,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);
});
}
@@ -1773,7 +1785,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
app.auto.add("timeline.workArea", r.intersect(ruler), "Work Area Bar");
let fmt = |tick: Tick| format_time(tick, rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000);
if ui.rect_contains_pointer(r.expand2(vec2(EDGE_PX, 1.0)).intersect(ruler)) {
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("work-area-tip"), egui::PopupAnchor::Pointer).show(|ui| {
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("work-area-tip"), egui::PopupAnchor::Pointer).show(|ui| {
ui.label(tlf!("Start: {tc}", tc = fmt(area.start)));
ui.label(tlf!("End: {tc}", tc = fmt(area.end())));
ui.label(tlf!("Duration: {tc}", tc = fmt(area.duration)));
@@ -1795,7 +1807,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);
});
}
@@ -1821,6 +1833,8 @@ fn zoom_thumb(bar: Rect, seq_seconds: f64, scroll: f64, pps: f64) -> (f32, f32,
(bar.min.x + at, w, if travel > 0.5 { range / travel as f64 } else { 0.0 })
}
/// Draw the zoom scroll bar: the thumb reflects the visible range, a press beside it centres the thumb under the pointer, and the thumb and track are
/// registered for automation.
fn zoom_scrollbar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, seq_seconds: f64, t: &Tokens) {
let p = ui.painter();
p.rect_filled(bar, bar.height() / 2.0, t.separator);
@@ -1837,7 +1851,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)
@@ -1927,7 +1941,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);
@@ -2415,10 +2429,12 @@ fn scrub_playhead(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, p: Po
app.session.set_playhead(tt);
}
/// Handle Timeline pointer input: remember the grab from the press frame, jump the playhead on a ruler press, select a marker on its press, and reset the
/// cancel flag once the button is released.
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;
@@ -3012,11 +3028,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()
{
@@ -3031,7 +3047,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()
{

Some files were not shown because too many files have changed in this diff Show More