mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-09 02:34:14 +00:00
Merge pull request #2367 from storytold/codex/egui-037-effectcraft-20261023
Migrate EffectCraft to egui 0.37
This commit is contained in:
+21
-1
@@ -1,9 +1,28 @@
|
||||
# Attribution
|
||||
|
||||
Every non-code asset in EffectCraft, with its author, source and licence. Each file listed here also has
|
||||
Every non-code asset in EffectCraft, with its author, source and licence. Each repository file listed here also has
|
||||
a `<file>.attribution` sidecar with full details. The rules are in [AGENTS.md](AGENTS.md) §1: no Adobe
|
||||
iconography, images or other Adobe assets; open licences only; every asset attributed.
|
||||
`cargo xtask assets` checks that this index and the sidecars cover every asset in the repository.
|
||||
The complete licence texts and copyright notices for the bundled fonts, including the fonts embedded
|
||||
by the pinned egui dependency, are reproduced in [`NOTICE`](NOTICE), which every package ships; the
|
||||
`assets/fonts/OFL-*.txt` files are the upstream copies kept beside the fonts.
|
||||
|
||||
## Bundled dependency fonts
|
||||
|
||||
These fonts are embedded by the pinned egui dependency (epaint_default_fonts 0.37 at
|
||||
`6d518fc799681571963842f95b55a6b68431b15a`); no font file is added to this repository.
|
||||
egui-icons is always embedded, `default_fonts` adds Ubuntu Light and Hack, and
|
||||
`monochrome_emoji_fonts` adds Noto Emoji and emoji-icon-font. The complete copyright notices
|
||||
and licence texts are in [`NOTICE`](NOTICE).
|
||||
|
||||
| Asset | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/NotoEmoji-Regular.ttf` | Google Inc. (The Noto Project Authors) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/OFL.txt | OFL-1.1 (complete text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/Ubuntu-Light.ttf` | Canonical Ltd. and Dalton Maag | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/UFL.txt | Ubuntu Font Licence 1.0 (complete text in `NOTICE`) |
|
||||
| `epaint_default_fonts/fonts/Hack-Regular.ttf` | Source Foundry Authors; Bitstream Inc. | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/Hack-Regular.txt; original https://github.com/source-foundry/Hack | MIT AND Bitstream-Vera (complete texts in `NOTICE`) |
|
||||
|
||||
## Asset files
|
||||
|
||||
@@ -15,6 +34,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
||||
| `assets/fonts/Inter-Bold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
| `assets/fonts/Inter-Italic.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
| `assets/fonts/NotoSerif-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/latin-greek-cyrillic | OFL-1.1 (`assets/fonts/OFL-NotoSerif.txt`) |
|
||||
| `assets/fonts/NotoSansSymbols2-Regular.ttf` | The Noto Project Authors | https://github.com/notofonts/symbols | OFL-1.1 (`assets/fonts/OFL-NotoSansSymbols2.txt`) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | The JetBrains Mono Project Authors | https://github.com/JetBrains/JetBrainsMono | OFL-1.1 (`assets/fonts/OFL-JetBrainsMono.txt`) |
|
||||
| `apps/effectcraft-web/web/favicon.svg` | EffectCraft contributors | Original work: web app icon, a copy of `assets/app-icon/effectcraft-small.svg` (the unicorn app icon) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | Original work: macOS DMG Finder window background, derived from the EffectCraft app icon (`assets/app-icon/effectcraft.svg`); rendered to a TIFF by `packaging/macos/package.sh` at package time | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) |
|
||||
|
||||
Generated
+157
-121
@@ -4,66 +4,60 @@ 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 0.13.1",
|
||||
"phf",
|
||||
"serde",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[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",
|
||||
@@ -222,6 +217,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -607,7 +603,7 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"indexmap",
|
||||
"once_cell",
|
||||
"phf 0.14.0",
|
||||
"phf",
|
||||
"rustc-hash 2.1.3",
|
||||
"static_assertions",
|
||||
]
|
||||
@@ -994,7 +990,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
@@ -1162,9 +1158,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",
|
||||
@@ -1721,9 +1716,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",
|
||||
@@ -1745,6 +1739,7 @@ dependencies = [
|
||||
"pollster 1.0.1",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"send_wrapper",
|
||||
"static_assertions",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1756,9 +1751,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",
|
||||
@@ -1776,9 +1770,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",
|
||||
@@ -1796,31 +1789,34 @@ 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",
|
||||
"webbrowser",
|
||||
"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",
|
||||
@@ -1832,9 +1828,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",
|
||||
@@ -1856,9 +1851,8 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[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",
|
||||
]
|
||||
@@ -1906,9 +1900,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",
|
||||
@@ -1924,16 +1917,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 = "equator"
|
||||
@@ -2541,9 +2535,9 @@ 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 0.2.0",
|
||||
@@ -3059,12 +3053,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]]
|
||||
@@ -3393,6 +3387,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 = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -3885,6 +3888,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 = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -3946,14 +3959,14 @@ 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 = [
|
||||
"phf_macros 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"serde",
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3962,18 +3975,9 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_macros 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3983,20 +3987,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4005,22 +3996,13 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
|
||||
dependencies = [
|
||||
"phf_generator 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.14.0"
|
||||
@@ -4605,6 +4587,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 = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -5372,6 +5360,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"
|
||||
@@ -5486,9 +5485,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",
|
||||
@@ -5501,9 +5500,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",
|
||||
@@ -6497,8 +6496,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",
|
||||
@@ -6570,6 +6568,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 = "write16"
|
||||
version = "1.0.0"
|
||||
@@ -6933,3 +6949,23 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[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"
|
||||
|
||||
+22
-4
@@ -106,10 +106,13 @@ pollster = "0.4"
|
||||
|
||||
# ui
|
||||
# Shared frontend components; keep this dependency above the engine boundary.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
# Shared UI and the complete egui family select reviewed immutable commits below.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] }
|
||||
# `default_fonts` and `monochrome_emoji_fonts` are explicit: the app's `FontDefinitions::default()`
|
||||
# and its `✔`/`▣` chrome glyphs need both, and kittest's own dev-dependencies do not propagate.
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
cpal = "0.16"
|
||||
# The operating system's interface language (MIT/Apache-2.0, no dependencies on desktop).
|
||||
@@ -218,3 +221,18 @@ codegen-units = 1
|
||||
[patch.crates-io]
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" }
|
||||
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" }
|
||||
|
||||
@@ -12,11 +12,280 @@ Third-party material
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ATTRIBUTION.md.
|
||||
|
||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt.
|
||||
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
Bundled fonts
|
||||
|
||||
EffectCraft embeds Inter, Noto Serif, JetBrains Mono and Noto Sans Symbols 2 from
|
||||
assets/fonts/. The pinned egui dependency (epaint_default_fonts, revision
|
||||
6d518fc799681571963842f95b55a6b68431b15a) additionally embeds Ubuntu Light, Hack,
|
||||
Noto Emoji, egui-icons and emoji-icon-font through EffectCraft's enabled `default_fonts`
|
||||
and `monochrome_emoji_fonts` features; no font file from that dependency is added to
|
||||
this repository. The complete copyright notices and licence texts required for all of
|
||||
these fonts follow.
|
||||
|
||||
The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR Apache-2.0.
|
||||
The shared patched syntax source is centralized in storytold/craft-libs, transferred from
|
||||
PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no
|
||||
Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters.
|
||||
|
||||
SIL Open Font License 1.1
|
||||
|
||||
Applies to Inter (assets/fonts/Inter-*.ttf), Noto Serif (assets/fonts/NotoSerif-Regular.ttf),
|
||||
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf), Noto Sans Symbols 2
|
||||
(assets/fonts/NotoSansSymbols2-Regular.ttf) and Noto Emoji (NotoEmoji-Regular.ttf).
|
||||
Copyright notices:
|
||||
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
|
||||
Copyright 2013 Google Inc. All Rights Reserved.
|
||||
|
||||
The fonts in assets/fonts/ also keep their upstream licence files at
|
||||
assets/fonts/OFL-<family>.txt. The complete licence text follows.
|
||||
|
||||
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 Font Licence 1.0
|
||||
|
||||
Applies to Ubuntu Light (Ubuntu-Light.ttf). Copyright notice:
|
||||
|
||||
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: MIT License and Bitstream Vera License
|
||||
|
||||
Applies to Hack (Hack-Regular.ttf). Upstream notice and both licence texts:
|
||||
|
||||
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.
|
||||
|
||||
MIT License (egui-icons and emoji-icon-font)
|
||||
|
||||
Applies to egui-icons.ttf, a five-glyph subset of emoji-icon-font, and the parent
|
||||
emoji-icon-font.ttf, both by John Slegers, https://github.com/jslegers/emoji-icon-font.
|
||||
|
||||
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.
|
||||
|
||||
@@ -87,6 +87,17 @@ pub struct WebApp {
|
||||
|
||||
impl eframe::App for WebApp {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
// Files dropped on the page are input: read them here, where the browser's drop event is
|
||||
// still the current pass's, not from a later (possibly logic-only) tick.
|
||||
//
|
||||
// The list is *taken* out of the raw input: a hidden tab runs `logic` and eframe appends
|
||||
// the same prepared raw input back for the next pass, so leaving the files there would
|
||||
// import them again on every hidden logic tick.
|
||||
let dropped_files = std::mem::take(&mut raw_input.dropped_files);
|
||||
let dropped: Vec<web_sys::File> = dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect();
|
||||
if !dropped.is_empty() {
|
||||
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
|
||||
}
|
||||
self.app.raw_input_hook(ctx, raw_input);
|
||||
}
|
||||
|
||||
@@ -95,18 +106,14 @@ impl eframe::App for WebApp {
|
||||
for f in actions {
|
||||
f(&mut self.app);
|
||||
}
|
||||
// Files dropped on the page: read them, then open/import.
|
||||
let dropped: Vec<web_sys::File> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.web_file().cloned()).collect());
|
||||
if !dropped.is_empty() {
|
||||
files::read_files(dropped, |paths| post(move |app| open_or_import(app, paths)));
|
||||
}
|
||||
self.app.logic(ctx, frame);
|
||||
self.app.tick(ctx, frame);
|
||||
api::poll_replies();
|
||||
// Render Queue outputs written this frame (here or by a worker) → downloads.
|
||||
files::flush_downloads();
|
||||
// What a reload comes back to.
|
||||
if self.persist {
|
||||
persist::snapshot(&self.app.session, ctx.input(|i| i.time), 1.0);
|
||||
// The app's monotonic clock: `ctx.input(|i| i.time)` is stale in a logic-only tick.
|
||||
persist::snapshot(&self.app.session, self.app.now(), 1.0);
|
||||
}
|
||||
api::set_info("gpu", json!({"compositor": self.app.gpu_adapter(), "viewerOnGpu": self.app.viewer_on_gpu()}));
|
||||
api::set_info("frameWorkers", frames::stats());
|
||||
@@ -114,6 +121,7 @@ impl eframe::App for WebApp {
|
||||
let d = &self.app.session.prefs.disk;
|
||||
diskcache::configure(d.disk_cache_enabled, d.disk_cache_max_gb, storage::quota());
|
||||
api::set_info("diskCache", diskcache::stats());
|
||||
self.app.finish_ime_logic(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
@@ -195,7 +203,9 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
let home = query_flag("home");
|
||||
// Browser storage: settings, recent projects, imported media, saved projects, auto-saves and
|
||||
// the last session come back.
|
||||
let t0 = js_sys::Date::now();
|
||||
// Monotonic (performance.now) rather than the wall clock: a system-time jump must not turn
|
||||
// the storage-load report negative or huge.
|
||||
let t0 = web_time::Instant::now();
|
||||
let stored = match persist::load(&query_value("storage").unwrap_or_default()).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
@@ -203,7 +213,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
0
|
||||
}
|
||||
};
|
||||
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": js_sys::Date::now() - t0}));
|
||||
api::set_info("storage", json!({"backend": persist::backend(), "entries": stored, "loadMs": t0.elapsed().as_secs_f64() * 1000.0}));
|
||||
audio::install_unlock();
|
||||
browse::restore();
|
||||
diskcache::init();
|
||||
@@ -214,6 +224,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
canvas,
|
||||
eframe::WebOptions::default(),
|
||||
Box::new(move |cc| {
|
||||
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
// The page owns this shared presentation device; worker devices retain their
|
||||
// separate owned-device handlers. No browser error-scope future is blocked on.
|
||||
|
||||
@@ -15,7 +15,7 @@ effectcraft-host = { workspace = true }
|
||||
effectcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
# `links`: links (Help menu, Discord, Settings) open in the system browser.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
|
||||
|
||||
@@ -203,6 +203,7 @@ fn main() -> eframe::Result {
|
||||
"EffectCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
EffectcraftApp::install_ime_reset_hooks(&cc.egui_ctx);
|
||||
match &cc.wgpu_render_state {
|
||||
Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
|
||||
None => log::info!("graphics: no wgpu device"),
|
||||
@@ -434,7 +435,8 @@ impl eframe::App for Desktop {
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
self.menu.update(&mut self.app, ctx);
|
||||
self.app.logic(ctx, frame);
|
||||
self.app.tick(ctx, frame);
|
||||
self.app.finish_ime_logic(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
asset: NotoSansSymbols2-Regular.ttf
|
||||
title: Noto Sans Symbols 2 Regular (chrome symbols such as ✔ and ▣)
|
||||
author: The Noto Project Authors
|
||||
source: https://github.com/notofonts/symbols (Google Fonts commit 47d54ca1ebb14fd864695f1d39dc734f39017c33, file ofl/notosanssymbols2/NotoSansSymbols2-Regular.ttf)
|
||||
license: OFL-1.1
|
||||
license-file: assets/fonts/OFL-NotoSansSymbols2.txt
|
||||
added: 2026-10-24 by @skylartaylor
|
||||
notes: Unmodified font file, sha256 7d5fb73b7ca67a6798101741f5d280a3d016a56a197afcd4199dbb57b4b82a21. Embedded with include_bytes! in crates/ui-egui/src/theme.rs as the last UI fallback so every family can draw the chrome symbols (U+2714 ✔, U+25A3 ▣) that Inter, Inter Medium, Inter SemiBold and JetBrains Mono lack.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
|
||||
|
||||
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:
|
||||
https://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.
|
||||
@@ -13,14 +13,23 @@ use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, has_layers, has_pr
|
||||
use crate::{EngineError, Event, Result, Session, cmd, query};
|
||||
|
||||
/// Run `f` (which may run several undoable commands) as one undo step named `label`; on error
|
||||
/// the project is restored.
|
||||
/// the project, editor state and undo history are restored.
|
||||
///
|
||||
/// Front-end events emitted by a failed `f` are discarded with the state. Pending-analysis
|
||||
/// queues and the journal are not part of this rollback and keep their existing behaviour.
|
||||
pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<T>) -> Result<T> {
|
||||
// The steps `f` records go to an empty stack (so the undo limit can't drop the snapshot
|
||||
// taken before them), then become one step on the real one.
|
||||
// taken before them), then become one step on the real one. Keep the complete pre-`f`
|
||||
// history and editor state for a failure: rollback must not leave a partially retargeted
|
||||
// editor or a `f`-recorded redo/branch behind.
|
||||
let saved_history = s.history.clone();
|
||||
let saved = std::mem::take(&mut s.history.undo);
|
||||
let before = s.project.clone();
|
||||
let before_state = s.state.clone();
|
||||
let event_mark = s.events.len();
|
||||
let r = f(s);
|
||||
let changed = !s.history.undo.is_empty();
|
||||
let interim_revision = s.ime_revision;
|
||||
s.history.undo = saved;
|
||||
s.history.merge_key = None;
|
||||
match r {
|
||||
@@ -36,9 +45,22 @@ pub(crate) fn grouped<T>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sessi
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
// Snapshot the failing editor before restoring the pre-`f` state: an intermediate
|
||||
// text.edit / text change / comp switch must invalidate the pre-`f` editor token
|
||||
// instead of reviving it.
|
||||
let edited = super::text_edit::active_editor_snapshot(s);
|
||||
s.project = before;
|
||||
s.state = before_state;
|
||||
s.history = saved_history;
|
||||
s.history.merge_key = None;
|
||||
// A failed intermediate comp switch must not reach the front end through an
|
||||
// `OpenComp` event while its state has been rolled back.
|
||||
s.events.truncate(event_mark);
|
||||
s.sanitize_after_replacement(edited.as_ref());
|
||||
// As in the batch rollback, the IME revision is a high-water mark: leave the highest
|
||||
// intermediate stamp rather than rewinding to before `f`.
|
||||
s.ime_revision = s.ime_revision.max(interim_revision);
|
||||
if changed {
|
||||
s.project = before;
|
||||
s.sanitize_state();
|
||||
s.bump();
|
||||
}
|
||||
Err(e)
|
||||
@@ -704,6 +726,36 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn grouped_rollback_restores_the_editor_state_and_history_after_a_transient_text_change() {
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 1})).unwrap();
|
||||
let lid = s.execute("layer.newText", json!({"text": "abcdef"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("text.edit", json!({"layer": lid, "caret": 3})).unwrap();
|
||||
let original_comp = s.active_comp_id().unwrap();
|
||||
let owner = s.state.text_edit.as_ref().unwrap().session;
|
||||
let other =
|
||||
s.execute("comp.new", json!({"name": "Other", "width": 160, "height": 90, "duration": 1, "open": false})).unwrap()["comp"].as_u64().unwrap();
|
||||
let undo = s.history.undo.len();
|
||||
s.drain_events();
|
||||
|
||||
let r = super::grouped(&mut s, "Rollback", |s| -> crate::Result<()> {
|
||||
// An intermediate document edit and state switch, both undone by the error path.
|
||||
s.execute("layer.setText", json!({"layer": lid, "text": "ab"}))?;
|
||||
s.execute("comp.open", json!({"comp": other}))?;
|
||||
Err(crate::EngineError::Other("stop".into()))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.active_comp_id(), Some(original_comp), "the pre-group comp is restored");
|
||||
assert_eq!(super::super::text_edit::layer_doc(&s, effectcraft_project::LayerId(lid)).unwrap().text, "abcdef", "the document is restored");
|
||||
assert_eq!(s.history.undo.len(), undo, "the partial grouped steps are not left in history");
|
||||
assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a transient replacement does not revive the old IME token");
|
||||
assert!(
|
||||
!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenComp(_))),
|
||||
"the failed intermediate comp switch is not published to the front end"
|
||||
);
|
||||
}
|
||||
|
||||
/// `File.execute()` opens the file as a well-formed `file://` URL (spaces, `#`, `%`).
|
||||
#[test]
|
||||
fn execute_file_opens_an_encoded_file_url() {
|
||||
|
||||
@@ -56,6 +56,9 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let label = str_p(p, "label").unwrap_or("Batch").to_string();
|
||||
let atomic = p.get("atomic").and_then(Value::as_bool).unwrap_or(true);
|
||||
let (project, history, state) = (s.project.clone(), s.history.clone(), s.state.clone());
|
||||
// Rolled-back steps may have queued events (an `OpenComp`, a Toast) for the state that is
|
||||
// about to be dropped: the UI must not act on them.
|
||||
let event_mark = s.events.len();
|
||||
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let run = |s: &mut Session, results: &[Value]| -> Result<Value> {
|
||||
@@ -75,10 +78,21 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Err(e) => {
|
||||
let id = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).unwrap_or("?");
|
||||
let note = if atomic {
|
||||
// The snapshot is the intermediate state the failing step left: comparing it
|
||||
// with the restored editor detects a restart or cross-comp switch inside the
|
||||
// batch, so the pre-batch token is never revived. Do not restore
|
||||
// `ime_revision`: a transient text edit inside the batch already advanced
|
||||
// this process-monotonic high-water mark, and rewinding it would reuse a
|
||||
// stamp (letting a wide-selection composition look current). An unrelated
|
||||
// failed batch did not advance it, so its value stays exactly as it was.
|
||||
let edited = crate::commands::text_edit::active_editor_snapshot(s);
|
||||
let interim_revision = s.ime_revision;
|
||||
s.project = project;
|
||||
s.history = history;
|
||||
s.state = state;
|
||||
s.sanitize_state();
|
||||
s.events.truncate(event_mark);
|
||||
s.sanitize_after_replacement(edited.as_ref());
|
||||
s.ime_revision = s.ime_revision.max(interim_revision);
|
||||
s.bump();
|
||||
"; the batch was rolled back (nothing changed)".to_string()
|
||||
} else {
|
||||
|
||||
@@ -227,6 +227,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.selected_keys.clear();
|
||||
Ok(())
|
||||
})?;
|
||||
// A removed layer may have been the edited one: end that session before another command
|
||||
// can reuse it. Undo restores a layer as a new editor, never by reviving the old IME session.
|
||||
s.sanitize_state();
|
||||
Ok(json!(ids.len()))
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,14 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = doc.char_len();
|
||||
let id = insert_layer(proj, st, cid, l)?;
|
||||
if edit {
|
||||
st.text_edit = Some(super::text_edit::TextEdit { layer: id, anchor: 0, caret: n, created: true, ..Default::default() });
|
||||
st.text_edit = Some(super::text_edit::TextEdit {
|
||||
layer: id,
|
||||
anchor: 0,
|
||||
caret: n,
|
||||
created: true,
|
||||
session: super::text_edit::next_edit_session(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -1474,6 +1481,9 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.bump();
|
||||
}
|
||||
// A changed Source Text is handled by `Session::edit`: its boundary clamps the selection and
|
||||
// starts a new IME epoch only when the CTI document actually changed, so an identical text
|
||||
// write stays a no-op and keeps the editor's owner.
|
||||
s.edit("Edit Text", merge_p(p), |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
|
||||
@@ -6,15 +6,58 @@
|
||||
//! Positions are character indices. The selection is `anchor..caret` (either order).
|
||||
|
||||
use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV};
|
||||
use effectcraft_project::{LayerId, LayerSource};
|
||||
use effectcraft_project::{ItemId, LayerId, LayerSource};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Session tokens for the on-canvas editor, unique across the process (so a recreated session
|
||||
/// never reuses a live IME context). Exhaustion wraps; it cannot panic.
|
||||
static NEXT_EDIT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
pub(super) fn next_edit_session() -> u64 {
|
||||
NEXT_EDIT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Process-global checked high-water allocator for [`crate::Session::ime_revision`] stamps.
|
||||
///
|
||||
/// egui's unavailable-IME receipt identity must be process-monotonic. Allocating the stamp from
|
||||
/// one process-global counter means neither a second session nor a rollback can reuse a value.
|
||||
/// The counter fails closed at `u64::MAX`: it returns `None` without wrapping, and callers keep
|
||||
/// their existing stamp. An unavailable before/after pair then compares equal, so it earns no
|
||||
/// receipt rather than claiming a mutation with a reused revision.
|
||||
static NEXT_IME_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
pub(crate) fn next_ime_revision() -> Option<u64> {
|
||||
allocate_ime_revision(&NEXT_IME_REVISION)
|
||||
}
|
||||
|
||||
fn allocate_ime_revision(counter: &std::sync::atomic::AtomicU64) -> Option<u64> {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
let mut current = counter.load(Relaxed);
|
||||
loop {
|
||||
let next = current.checked_add(1)?;
|
||||
match counter.compare_exchange_weak(current, next, Relaxed, Relaxed) {
|
||||
Ok(_) => return Some(next),
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_revision_allocation_stops_at_exhaustion_without_reusing_a_stamp() {
|
||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
||||
let counter = AtomicU64::new(u64::MAX - 1);
|
||||
assert_eq!(allocate_ime_revision(&counter), Some(u64::MAX));
|
||||
assert_eq!(allocate_ime_revision(&counter), None);
|
||||
assert_eq!(allocate_ime_revision(&counter), None);
|
||||
assert_eq!(counter.load(Relaxed), u64::MAX);
|
||||
}
|
||||
|
||||
use super::{CommandSpec, b_p, bad, comp_id, layer_mut, resolve_layer, str_p};
|
||||
use crate::{EditorState, EngineError, Result, Session, cmd};
|
||||
|
||||
/// An on-canvas text editing session.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextEdit {
|
||||
pub layer: LayerId,
|
||||
pub anchor: usize,
|
||||
@@ -28,6 +71,23 @@ pub struct TextEdit {
|
||||
/// Line position kept by repeated up / down moves (layout space).
|
||||
#[serde(default)]
|
||||
pub goal_x: Option<f64>,
|
||||
/// This editing session's identity.
|
||||
///
|
||||
/// It changes when the editor is started and when its layer or document is replaced, by
|
||||
/// undo/redo, a history jump, a rollback, or a time change showing another document. A
|
||||
/// restore that keeps the same composition, layer and byte/style-equal document (an opacity
|
||||
/// undo while editing) keeps it, so an active IME composition survives an unrelated change.
|
||||
/// It is deliberately not serialized: a saved token belongs to a previous process and
|
||||
/// cannot identify a new target. The IME owner uses it as its replacement epoch, so an old
|
||||
/// composition is never inherited by a recreated layer or a replaced document.
|
||||
#[serde(skip, default = "next_edit_session")]
|
||||
pub session: u64,
|
||||
}
|
||||
|
||||
impl Default for TextEdit {
|
||||
fn default() -> Self {
|
||||
Self { layer: LayerId(0), anchor: 0, caret: 0, pending: None, created: false, goal_x: None, session: next_edit_session() }
|
||||
}
|
||||
}
|
||||
|
||||
impl TextEdit {
|
||||
@@ -49,6 +109,139 @@ pub fn layer_doc(s: &Session, lid: LayerId) -> Option<TextDoc> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest committed-text window an IME observation copies.
|
||||
///
|
||||
/// This mirrors `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`: a selection wider than this is
|
||||
/// reported as unavailable rather than silently truncated.
|
||||
pub const MAX_IME_TEXT_BYTES: usize = 4000;
|
||||
|
||||
/// The editor's committed-text revision for an IME receipt whose surrounding text is
|
||||
/// unavailable (a selection wider than [`MAX_IME_TEXT_BYTES`]).
|
||||
///
|
||||
/// Unlike [`Session::revision`], unrelated project edits (an opacity undo) do not advance it, so
|
||||
/// they cannot make an active composition look stale.
|
||||
pub fn ime_stamp_revision(s: &Session) -> u64 {
|
||||
s.ime_revision
|
||||
}
|
||||
|
||||
/// Bounded committed text around an IME selection, with UTF-8 byte offsets.
|
||||
///
|
||||
/// The Type tool keeps the input method's pre-edit in egui's temporary data, never in the
|
||||
/// layer's [`TextDoc`], so [`Self::text`] is committed text only.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeSurrounding {
|
||||
/// At most [`MAX_IME_TEXT_BYTES`] bytes of the edited layer's text.
|
||||
pub text: String,
|
||||
/// The caret (primary selection end) as a UTF-8 byte offset in [`Self::text`].
|
||||
pub cursor: usize,
|
||||
/// The other selection end; larger than `cursor` for a reversed selection.
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
impl ImeSurrounding {
|
||||
/// A bounded window of `text` around the selection `cursor`/`anchor`.
|
||||
///
|
||||
/// Returns `None` when an offset is past the text or inside a character, or when the
|
||||
/// selection itself is wider than [`MAX_IME_TEXT_BYTES`]: the caller then reports
|
||||
/// surrounding text as unavailable instead of sending a truncated selection.
|
||||
pub fn from_text(text: &str, cursor: usize, anchor: usize) -> Option<Self> {
|
||||
if cursor > text.len() || anchor > text.len() || !text.is_char_boundary(cursor) || !text.is_char_boundary(anchor) {
|
||||
return None;
|
||||
}
|
||||
let (start, end) = (cursor.min(anchor), cursor.max(anchor));
|
||||
let selection = end - start;
|
||||
if selection > MAX_IME_TEXT_BYTES {
|
||||
return None;
|
||||
}
|
||||
// Spend the remaining budget on both sides of the selection, giving unused room on one
|
||||
// side to the other. Only this bounded window is copied.
|
||||
let budget = MAX_IME_TEXT_BYTES - selection;
|
||||
let mut before = (budget / 2).min(start);
|
||||
let mut after = (budget - before).min(text.len() - end);
|
||||
before = (budget - after).min(start);
|
||||
after = (budget - before).min(text.len() - end);
|
||||
let mut window_start = start - before;
|
||||
let mut window_end = end + after;
|
||||
// Byte budgets can land inside a multi-byte character: shrink the window so it never
|
||||
// splits one and the selection stays inside it.
|
||||
while window_start < start && !text.is_char_boundary(window_start) {
|
||||
window_start += 1;
|
||||
}
|
||||
while end < window_end && !text.is_char_boundary(window_end) {
|
||||
window_end -= 1;
|
||||
}
|
||||
Some(Self {
|
||||
text: text.get(window_start..window_end)?.to_owned(),
|
||||
cursor: cursor.checked_sub(window_start)?,
|
||||
anchor: anchor.checked_sub(window_start)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The edited layer's committed text around its selection, or `None` when there is no edit
|
||||
/// session, the layer is gone, or the selection is wider than [`MAX_IME_TEXT_BYTES`].
|
||||
///
|
||||
/// The caret and the anchor are converted to UTF-8 byte offsets separately, so a reversed
|
||||
/// selection stays reversed. A valid empty document is `Some` with empty text, never `None`.
|
||||
pub fn ime_surrounding(s: &Session) -> Option<ImeSurrounding> {
|
||||
let e = s.state.text_edit.as_ref()?;
|
||||
let comp = s.active_comp()?;
|
||||
let layer = comp.layer(e.layer)?;
|
||||
let prop = layer.props.prop("text/sourceText")?;
|
||||
// A static Source Text (the normal case) is borrowed in place; only an animated one has to
|
||||
// be evaluated into a temporary. Either way only the bounded window is copied.
|
||||
if !prop.is_animated() {
|
||||
return match &prop.value {
|
||||
KV::Text(doc) => {
|
||||
let (caret, anchor) = doc_bytes(doc, e.caret, e.anchor);
|
||||
ImeSurrounding::from_text(&doc.text, caret, anchor)
|
||||
}
|
||||
KV::Str(text) => {
|
||||
let (caret, anchor) = char_bytes(text, e.caret, e.anchor);
|
||||
ImeSurrounding::from_text(text, caret, anchor)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
match prop.value_at(layer.layer_time(s.time())) {
|
||||
KV::Text(doc) => {
|
||||
let (caret, anchor) = doc_bytes(&doc, e.caret, e.anchor);
|
||||
ImeSurrounding::from_text(&doc.text, caret, anchor)
|
||||
}
|
||||
KV::Str(text) => {
|
||||
let (caret, anchor) = char_bytes(&text, e.caret, e.anchor);
|
||||
ImeSurrounding::from_text(&text, caret, anchor)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte offsets of two character positions in `text` (clamped to its end), scanning once.
|
||||
///
|
||||
/// The second position may be before the first; the returned pair keeps the caller's order.
|
||||
fn char_bytes(text: &str, first: usize, second: usize) -> (usize, usize) {
|
||||
let (lo, hi) = if first <= second { (first, second) } else { (second, first) };
|
||||
let mut lo_byte = if lo == 0 { 0 } else { text.len() };
|
||||
let mut hi_byte = if hi == 0 { 0 } else { text.len() };
|
||||
if hi != 0 {
|
||||
for (i, (b, _)) in text.char_indices().enumerate() {
|
||||
if i == lo {
|
||||
lo_byte = b;
|
||||
}
|
||||
if i == hi {
|
||||
hi_byte = b;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if first <= second { (lo_byte, hi_byte) } else { (hi_byte, lo_byte) }
|
||||
}
|
||||
|
||||
/// Byte offsets of the editor's caret and anchor in a document, in that order.
|
||||
fn doc_bytes(doc: &TextDoc, caret: usize, anchor: usize) -> (usize, usize) {
|
||||
char_bytes(&doc.text, caret, anchor)
|
||||
}
|
||||
|
||||
/// The text layer a text command targets: `layer`, the edited layer, or the selected text layer.
|
||||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<LayerId> {
|
||||
let cid = comp_id(s, p)?;
|
||||
@@ -78,7 +271,7 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit {
|
||||
Some(e) if e.layer == lid => e.clone(),
|
||||
_ => {
|
||||
let n = layer_doc(s, lid).map_or(0, |d| d.char_len());
|
||||
let e = TextEdit { layer: lid, anchor: n, caret: n, ..Default::default() };
|
||||
let e = TextEdit { layer: lid, anchor: n, caret: n, session: next_edit_session(), ..Default::default() };
|
||||
s.state.selected_layers = vec![lid];
|
||||
s.state.text_edit = Some(e.clone());
|
||||
e
|
||||
@@ -101,7 +294,8 @@ pub(crate) fn edit_doc(
|
||||
) -> Result<()> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let t = s.time();
|
||||
s.edit(label, merge, |proj, st| {
|
||||
let revision = s.revision;
|
||||
s.edit_text_doc(label, merge, |proj, st| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("text", "not a text layer"))?;
|
||||
@@ -110,16 +304,28 @@ pub(crate) fn edit_doc(
|
||||
KV::Str(t) => TextDoc::plain(&t),
|
||||
_ => TextDoc::default(),
|
||||
};
|
||||
let before = first_line_name(&doc.text);
|
||||
let original = doc.clone();
|
||||
f(&mut doc, st)?;
|
||||
let after = first_line_name(&doc.text);
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc)));
|
||||
// Text layers are named after their text until renamed.
|
||||
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
|
||||
l.name = after;
|
||||
// A no-op (an IME deletion with nothing around the selection, a set to the value it
|
||||
// already has) must not turn a static Str into Text or add a keyframe at the CTI: that
|
||||
// would change the project and record an empty undo step.
|
||||
if doc != original {
|
||||
let before = first_line_name(&original.text);
|
||||
let after = first_line_name(&doc.text);
|
||||
pr.set_value_at(lt, KV::Text(Box::new(doc)));
|
||||
// Text layers are named after their text until renamed.
|
||||
if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) {
|
||||
l.name = after;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
// The editor's unavailable-text receipt identity follows only its own document: an edit to
|
||||
// another text layer while this editor is open must not cancel the composition.
|
||||
if s.revision != revision && s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid) {
|
||||
s.note_text_edit_change();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn range_p(p: &Value) -> Option<std::ops::Range<usize>> {
|
||||
@@ -153,7 +359,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let created = b_p(p, "created").unwrap_or(false);
|
||||
s.state.selected_layers = vec![lid];
|
||||
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, ..Default::default() });
|
||||
s.state.text_edit = Some(TextEdit { layer: lid, anchor, caret, created, session: next_edit_session(), ..Default::default() });
|
||||
s.bump();
|
||||
Ok(state_json(s))
|
||||
}
|
||||
@@ -314,6 +520,78 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// How many whole characters `before_bytes` / `after_bytes` cover around `r`.
|
||||
///
|
||||
/// This is the input method's own byte count, as winit reports it: a count that ends inside a
|
||||
/// UTF-8 character leaves that whole character (stock egui's `surrounding_bytes_to_chars`), so a
|
||||
/// malformed or platform-specific offset never splits or corrupts the document.
|
||||
fn surrounding_bytes_as_chars(text: &str, n: usize, r: std::ops::Range<usize>, before_bytes: usize, after_bytes: usize) -> (usize, usize) {
|
||||
let r = r.start.min(n)..r.end.min(n);
|
||||
let (start_byte, end_byte) = char_bytes(text, r.start, r.end);
|
||||
let from = start_byte.saturating_sub(before_bytes);
|
||||
let before = text.get(..start_byte).map_or(0, |head| head.char_indices().rev().take_while(|(i, _)| *i >= from).count());
|
||||
let after = text.get(end_byte..).map_or(0, |tail| tail.char_indices().take_while(|(i, c)| i.saturating_add(c.len_utf8()) <= after_bytes).count());
|
||||
(before.min(r.start), after.min(n.saturating_sub(r.end)))
|
||||
}
|
||||
|
||||
/// Delete the text an input method marks for replacement around the selection
|
||||
/// (`DeleteSurrounding`, `DeleteSurroundingBytes`).
|
||||
///
|
||||
/// Unlike [`delete`] with an explicit range, the **selection stays**: the characters before its
|
||||
/// start and after its end are removed in two separate ranges, and the following `Commit`
|
||||
/// replaces the selected text. `bytes` counts UTF-8 bytes (`before`/`after` are characters
|
||||
/// otherwise); a count that ends inside a character leaves that character.
|
||||
///
|
||||
/// The counts are computed from the one document `edit_doc` already evaluated, so an animated
|
||||
/// Source Text and a large static document are not copied or evaluated again just to decide what
|
||||
/// the input method asked to replace.
|
||||
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let lid = target(s, p, "text.deleteSurrounding")?;
|
||||
// Only an open edit session has a selection to keep: without one there is nothing an input
|
||||
// method could be replacing.
|
||||
let e = match &s.state.text_edit {
|
||||
Some(e) if e.layer == lid => e.clone(),
|
||||
_ => return Err(bad("text.deleteSurrounding", "not editing this text layer")),
|
||||
};
|
||||
let count = |k: &str| p.get(k).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX));
|
||||
let (before_count, after_count) = (count("before"), count("after"));
|
||||
let bytes = b_p(p, "bytes").unwrap_or(false);
|
||||
let merge = str_p(p, "merge").map(str::to_string);
|
||||
edit_doc(s, "Edit Text", merge.as_deref(), lid, |doc, st| {
|
||||
let n = doc.char_len();
|
||||
let r = e.range().start.min(n)..e.range().end.min(n);
|
||||
let (before, after) = if bytes {
|
||||
surrounding_bytes_as_chars(&doc.text, n, r.clone(), before_count, after_count)
|
||||
} else {
|
||||
(before_count.min(r.start), after_count.min(n.saturating_sub(r.end)))
|
||||
};
|
||||
let start = r.start.saturating_sub(before);
|
||||
let end = r.end.saturating_add(after).min(n);
|
||||
if start == r.start && end == r.end {
|
||||
// Deletion-only batches with an empty count, or a document with nothing around the
|
||||
// selection: no empty undo step.
|
||||
return Ok(());
|
||||
}
|
||||
// The selection keeps its text: remove after it first, then before it, so the two ranges
|
||||
// stay valid and the caret/selection shift by the characters before it.
|
||||
if end > r.end {
|
||||
doc.replace_range(r.end..end, "", None);
|
||||
}
|
||||
if r.start > start {
|
||||
doc.replace_range(start..r.start, "", None);
|
||||
}
|
||||
if let Some(ed) = st.text_edit.as_mut().filter(|ed| ed.layer == lid) {
|
||||
let shift = r.start.saturating_sub(start);
|
||||
ed.anchor = ed.anchor.saturating_sub(shift);
|
||||
ed.caret = ed.caret.saturating_sub(shift);
|
||||
ed.pending = None;
|
||||
ed.goal_x = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Selected text (plain) of the session, and its formatted fragment.
|
||||
fn selection_doc(s: &Session) -> Option<TextDoc> {
|
||||
let e = s.state.text_edit.as_ref()?;
|
||||
@@ -448,16 +726,111 @@ fn paste_formatting(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state_json(s))
|
||||
}
|
||||
|
||||
/// Keep the session valid after undo / external edits: clamp the selection, drop it when the
|
||||
/// Give the current editor a fresh replacement epoch after its document/layer was replaced.
|
||||
pub(crate) fn invalidate_session(s: &mut Session) {
|
||||
let replaced = s.state.text_edit.is_some();
|
||||
if let Some(e) = s.state.text_edit.as_mut() {
|
||||
e.session = next_edit_session();
|
||||
}
|
||||
if replaced {
|
||||
s.note_text_edit_change();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp the editor selection and give it a fresh IME epoch after its document was replaced.
|
||||
///
|
||||
/// This is the one replacement path for document changes outside `edit_doc`:
|
||||
/// [`crate::Session::edit`] calls [`after_external_change`] after every edit, [`crate::Session::set_time`]
|
||||
/// and [`crate::Session::sync_related_times`] call it after moving the CTI, and
|
||||
/// [`crate::Session::sanitize_after_replacement`] calls it with the editor document snapshotted
|
||||
/// before a history restore or rollback.
|
||||
/// Clamping first means a shortened document leaves the caret in range, so the next Backspace
|
||||
/// deletes instead of being swallowed by an out-of-range selection.
|
||||
pub(crate) fn document_replaced(s: &mut Session) {
|
||||
sanitize(s);
|
||||
invalidate_session(s);
|
||||
}
|
||||
|
||||
/// The identity and document of the active on-canvas editor at one moment.
|
||||
///
|
||||
/// The composition and the editor session are part of the identity: a restoration must not keep
|
||||
/// a composition alive when the snapshot came from another comp, or from an intermediate editor
|
||||
/// that was restarted during the operation being rolled back.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EditorSnapshot {
|
||||
/// The composition the editor was attached to.
|
||||
pub comp: ItemId,
|
||||
/// The edited layer.
|
||||
pub layer: LayerId,
|
||||
/// The editor session token, unique across the process.
|
||||
pub session: u64,
|
||||
/// The Source Text the editor was showing.
|
||||
pub doc: TextDoc,
|
||||
}
|
||||
|
||||
/// The identity and document the active on-canvas editor is showing.
|
||||
///
|
||||
/// [`crate::Session::edit`] and the time setters snapshot this before a change and compare it
|
||||
/// afterwards: only a change that actually replaces this document starts a new epoch, while a
|
||||
/// write to another property, layer or composition, a move to a time showing the same text, or a
|
||||
/// write that leaves the text as it was, keeps the editor's owner. With no editor open this is a
|
||||
/// single `Option` check, with no document copy.
|
||||
pub fn active_editor_snapshot(s: &Session) -> Option<EditorSnapshot> {
|
||||
let e = s.state.text_edit.as_ref()?;
|
||||
Some(EditorSnapshot { comp: s.active_comp_id()?, layer: e.layer, session: e.session, doc: layer_doc(s, e.layer)? })
|
||||
}
|
||||
|
||||
/// Finish an edit outside the editor: if it replaced the document the editor owned, clamp the
|
||||
/// selection and start a new IME epoch.
|
||||
///
|
||||
/// An edit that itself moves or closes the editor owns the resulting session, so an identity
|
||||
/// mismatch is not a replacement here.
|
||||
pub(crate) fn after_external_change(s: &mut Session, before: Option<&EditorSnapshot>) {
|
||||
let Some(before) = before else { return };
|
||||
let Some(e) = s.state.text_edit.as_ref() else { return };
|
||||
if s.state.active_comp != Some(before.comp) || e.layer != before.layer || e.session != before.session {
|
||||
return;
|
||||
}
|
||||
if layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
|
||||
document_replaced(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish a project-state replacement (undo, redo, a history jump, a rollback): keep the editor
|
||||
/// only when the restored state still has the same composition, layer and editor session and
|
||||
/// shows byte/style-equal text.
|
||||
///
|
||||
/// The restored session token is the one saved in the replaced state, so an intermediate
|
||||
/// restart or cross-comp switch must be detected against the snapshot instead of being assumed
|
||||
/// to be the editor's own change.
|
||||
pub(crate) fn after_replacement(s: &mut Session, before: Option<&EditorSnapshot>) {
|
||||
let Some(before) = before else {
|
||||
// No editor was open when the snapshot was taken: a restored editor must not inherit a
|
||||
// composition from the state the replacement replaced.
|
||||
if s.state.text_edit.is_some() {
|
||||
document_replaced(s);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let same = s.state.text_edit.as_ref().is_some_and(|e| s.state.active_comp == Some(before.comp) && e.layer == before.layer && e.session == before.session);
|
||||
if !same || layer_doc(s, before.layer).as_ref() != Some(&before.doc) {
|
||||
document_replaced(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the session valid after routine state cleanup: clamp the selection, drop it when the
|
||||
/// layer is gone or not in the active comp.
|
||||
///
|
||||
/// This is also called after project-item cleanup that leaves the edited document alone, so it
|
||||
/// must not advance [`TextEdit::session`]: that would make the viewer treat unrelated cleanup as
|
||||
/// a document replacement and interrupt the active IME composition. Replacement paths call
|
||||
/// [`document_replaced`] instead.
|
||||
pub(crate) fn sanitize(s: &mut Session) {
|
||||
let Some(e) = s.state.text_edit.clone() else { return };
|
||||
match layer_doc(s, e.layer) {
|
||||
Some(d) => {
|
||||
let n = d.char_len();
|
||||
if (e.anchor > n || e.caret > n)
|
||||
&& let Some(ed) = s.state.text_edit.as_mut()
|
||||
{
|
||||
if let Some(ed) = s.state.text_edit.as_mut() {
|
||||
ed.anchor = ed.anchor.min(n);
|
||||
ed.caret = ed.caret.min(n);
|
||||
}
|
||||
@@ -594,6 +967,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
editing_or_text_layer,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"text.deleteSurrounding",
|
||||
"Delete Text Around Selection",
|
||||
[],
|
||||
None,
|
||||
"{layer?, before?, after?, bytes?, merge?} (input-method replacement: the characters before and after the selection are deleted, the selection stays; bytes counts UTF-8 bytes)",
|
||||
editing_or_text_layer,
|
||||
delete_surrounding
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteTextMatchFormatting",
|
||||
"Paste Text and Match Formatting",
|
||||
|
||||
@@ -341,13 +341,14 @@ impl Session {
|
||||
let k = new_line.iter().position(|&i| i == target).unwrap_or(0);
|
||||
let undo: Vec<(String, Arc<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
|
||||
let redo: Vec<(String, Arc<Project>)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect();
|
||||
let edited = crate::commands::text_edit::active_editor_snapshot(self);
|
||||
let cur = std::mem::replace(&mut self.project, a.state[target].clone());
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.undo = undo;
|
||||
self.history.redo = redo;
|
||||
self.history.branches = branches;
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_state();
|
||||
self.sanitize_after_replacement(edited.as_ref());
|
||||
self.bump();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -394,6 +394,15 @@ pub struct Session {
|
||||
pub project: Arc<Project>,
|
||||
pub history: History,
|
||||
pub revision: u64,
|
||||
/// Process-unique monotonic revision of the on-canvas editor's committed text and session.
|
||||
/// Unlike [`Session::revision`], unrelated project edits do not advance it; the IME receipt
|
||||
/// uses it when surrounding text is unavailable. Read it with
|
||||
/// [`commands::text_edit::ime_stamp_revision`].
|
||||
///
|
||||
/// Stamps come from a process-global checked allocator and are never rewound: a failed
|
||||
/// atomic batch keeps the highest intermediate stamp. On allocator exhaustion no stamp is
|
||||
/// issued, so an unavailable receipt cannot be accepted with a reused revision.
|
||||
pub ime_revision: u64,
|
||||
pub saved_revision: u64,
|
||||
/// The project as last opened or saved: the project is unmodified while it is this very
|
||||
/// snapshot, so undoing back to the saved state clears the modified mark.
|
||||
@@ -562,6 +571,8 @@ impl Default for Session {
|
||||
project,
|
||||
history: History::default(),
|
||||
revision: 0,
|
||||
// A fresh session gets its own process-monotonic stamp even before its first edit.
|
||||
ime_revision: commands::text_edit::next_ime_revision().unwrap_or(u64::MAX),
|
||||
saved_revision: 0,
|
||||
path: None,
|
||||
state: EditorState { snapping: true, view_layout: 1, ..Default::default() },
|
||||
@@ -686,7 +697,25 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step.
|
||||
///
|
||||
/// Every edit is an external boundary for the on-canvas text editor: if it replaced the
|
||||
/// document the editor is showing, the selection is clamped and the editor gets a fresh IME
|
||||
/// epoch. The editor's own typing goes through `edit_text_doc`, which owns that
|
||||
/// document and must not treat its own keystrokes as a replacement.
|
||||
pub fn edit<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let out = self.edit_inner(label, merge, f)?;
|
||||
commands::text_edit::after_external_change(self, edited.as_ref());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`Session::edit`] for the on-canvas editor's own text changes. Its document is the one
|
||||
/// the editor owns, so a change here is typing, not a replacement behind the IME's back.
|
||||
pub(crate) fn edit_text_doc<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
|
||||
self.edit_inner(label, merge, f)
|
||||
}
|
||||
|
||||
fn edit_inner<T>(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result<T>) -> Result<T> {
|
||||
let before = self.project.clone();
|
||||
let mut p = (*self.project).clone();
|
||||
let mut st = self.state.clone();
|
||||
@@ -733,6 +762,19 @@ impl Session {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Note a change to the on-canvas editor's committed text or session.
|
||||
///
|
||||
/// Frontends compare this with [`commands::text_edit::ime_stamp_revision`]; an unrelated
|
||||
/// project change must not advance it.
|
||||
pub(crate) fn note_text_edit_change(&mut self) {
|
||||
// Checked process-wide allocation: a stamp is never wrapped or reused. On exhaustion
|
||||
// keep the previous value; an unavailable before/after pair then compares equal and
|
||||
// earns no receipt instead of accepting a stale revision.
|
||||
if let Some(revision) = commands::text_edit::next_ime_revision() {
|
||||
self.ime_revision = self.ime_revision.max(revision);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bump(&mut self) {
|
||||
self.revision += 1;
|
||||
// Back at the saved state (undo, redo, a history jump or a rolled-back batch).
|
||||
@@ -805,26 +847,48 @@ impl Session {
|
||||
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let Some((label, p)) = self.history.undo.pop() else { return false };
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let cur = std::mem::replace(&mut self.project, p);
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.redo.push((label, cur));
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_state();
|
||||
self.sanitize_after_replacement(edited.as_ref());
|
||||
self.bump();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> bool {
|
||||
let Some((label, p)) = self.history.redo.pop() else { return false };
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
let cur = std::mem::replace(&mut self.project, p);
|
||||
self.keep_view_settings(&cur);
|
||||
self.history.undo.push((label, cur));
|
||||
self.history.merge_key = None;
|
||||
self.sanitize_state();
|
||||
self.sanitize_after_replacement(edited.as_ref());
|
||||
self.bump();
|
||||
true
|
||||
}
|
||||
|
||||
/// Keep selections valid after the project was replaced by another state (undo, redo, a
|
||||
/// history jump, a rollback): the same layer id can name a different document, so the
|
||||
/// on-canvas editor gets a fresh IME epoch only when its own document changed.
|
||||
///
|
||||
/// Normally `before` is [`commands::text_edit::active_editor_snapshot`] taken from the
|
||||
/// state this call is about to replace. Atomic batch rollback is the exception: it has
|
||||
/// already restored the pre-batch state and passes the snapshot of the failing intermediate
|
||||
/// state, so a transient restart or component switch inside the batch still rejects reviving
|
||||
/// the old composition. A restored state that still has the same composition, layer and
|
||||
/// editor session and shows byte/style-equivalent text (an opacity or other property undo)
|
||||
/// keeps the editor's owner and selection; a different document, comp, layer or session clamps the
|
||||
/// selection and starts a new epoch. A closed editor stays closed, and a fallback comp's
|
||||
/// matching layer id never inherits the old comp's session. `None` means no editor was open
|
||||
/// when the snapshot was taken; if one is open after the replacement it is treated as an
|
||||
/// unverified document replacement, so it never inherits an old composition.
|
||||
pub fn sanitize_after_replacement(&mut self, before: Option<&commands::text_edit::EditorSnapshot>) {
|
||||
self.sanitize_state();
|
||||
commands::text_edit::after_replacement(self, before);
|
||||
}
|
||||
|
||||
/// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id
|
||||
/// counts only inside the comp that owns it, so the active comp never sees another comp's layers.
|
||||
pub fn sanitize_state(&mut self) {
|
||||
@@ -841,6 +905,8 @@ impl Session {
|
||||
});
|
||||
if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) {
|
||||
self.clear_live_selection();
|
||||
// A matching layer id in the fallback comp is not the document being edited.
|
||||
self.state.text_edit = None;
|
||||
self.state.active_comp = self.state.open_comps.first().copied();
|
||||
if let Some(id) = self.state.active_comp {
|
||||
self.recall_selection(id);
|
||||
@@ -900,13 +966,22 @@ impl Session {
|
||||
self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO)
|
||||
}
|
||||
|
||||
/// Move the active comp's current time.
|
||||
///
|
||||
/// Time is an external boundary for the on-canvas text editor too: an animated Source Text
|
||||
/// can show a different document at another time. The document is snapshotted only while an
|
||||
/// editor is open, so playback with no text editor does no text copying.
|
||||
pub fn set_time(&mut self, t: Tick) {
|
||||
if let Some(c) = self.state.active_comp {
|
||||
let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t));
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
self.state.times.insert(c, t);
|
||||
if self.prefs.general.sync_time_related_items {
|
||||
self.sync_related_times(c, t);
|
||||
// Direct call: this `set_time` owns the editor snapshot for both time changes,
|
||||
// so a synced move does not take a second snapshot or start a second epoch.
|
||||
self.sync_related_times_inner(c, t);
|
||||
}
|
||||
commands::text_edit::after_external_change(self, edited.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,6 +989,14 @@ impl Session {
|
||||
/// `comp` moves the current time of the comps nested in it (through their precomp layers'
|
||||
/// timing) and of the comps it is nested in, so every related viewer shows the same moment.
|
||||
pub fn sync_related_times(&mut self, comp: ItemId, t: Tick) {
|
||||
// Moving a related comp can move the active one, so the editor's document is checked
|
||||
// here too; the snapshot is skipped entirely when no text editor is open.
|
||||
let edited = commands::text_edit::active_editor_snapshot(self);
|
||||
self.sync_related_times_inner(comp, t);
|
||||
commands::text_edit::after_external_change(self, edited.as_ref());
|
||||
}
|
||||
|
||||
fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) {
|
||||
let mut seen = std::collections::BTreeSet::from([comp]);
|
||||
let mut todo = vec![(comp, t)];
|
||||
let mut found = vec![];
|
||||
@@ -957,6 +1040,9 @@ impl Session {
|
||||
self.state.active_comp = Some(id);
|
||||
self.recall_selection(id);
|
||||
self.state.current_track = None;
|
||||
// A text-edit session belongs to the comp that owned it. Switching comps ends it; even
|
||||
// a same-numbered layer in the new comp must not inherit the old IME composition.
|
||||
self.state.text_edit = None;
|
||||
}
|
||||
|
||||
/// Leave no comp active, keeping the one that was active in [`EditorState::comp_selections`].
|
||||
@@ -965,6 +1051,7 @@ impl Session {
|
||||
self.state.active_comp = None;
|
||||
self.clear_live_selection();
|
||||
self.state.current_track = None;
|
||||
self.state.text_edit = None;
|
||||
}
|
||||
|
||||
fn remember_selection(&mut self) {
|
||||
|
||||
@@ -6,7 +6,7 @@ use effectcraft_keyframe::{BaselineOption, Justify, Kerning, TextDoc};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::commands::text_edit::layer_doc;
|
||||
use crate::commands::text_edit::{MAX_IME_TEXT_BYTES, ime_stamp_revision, ime_surrounding, layer_doc};
|
||||
|
||||
fn session(text: &str) -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
@@ -24,6 +24,98 @@ fn sel(s: &Session) -> (usize, usize) {
|
||||
(e.anchor, e.caret)
|
||||
}
|
||||
|
||||
/// Start editing the text layer with everything selected (no caret given).
|
||||
fn begin_edit(s: &mut Session, t: u64) {
|
||||
s.execute("text.edit", json!({"layer": t})).unwrap();
|
||||
}
|
||||
|
||||
/// Start editing the text layer with the caret at `caret`.
|
||||
fn begin_edit_at(s: &mut Session, t: u64, caret: usize) {
|
||||
s.execute("text.edit", json!({"layer": t, "caret": caret})).unwrap();
|
||||
}
|
||||
|
||||
/// A session with one text layer already open in the editor at `caret`.
|
||||
fn editing(text: &str, caret: usize) -> (Session, u64) {
|
||||
let (mut s, t) = session(text);
|
||||
begin_edit_at(&mut s, t, caret);
|
||||
(s, t)
|
||||
}
|
||||
|
||||
/// A session with one text layer already open in the editor with everything selected.
|
||||
fn editing_all(text: &str) -> (Session, u64) {
|
||||
let (mut s, t) = session(text);
|
||||
begin_edit(&mut s, t);
|
||||
(s, t)
|
||||
}
|
||||
|
||||
/// The IME owner token of the open editor.
|
||||
fn ime_owner(s: &Session) -> u64 {
|
||||
s.state.text_edit.as_ref().unwrap().session
|
||||
}
|
||||
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.history.undo.len()
|
||||
}
|
||||
|
||||
/// The editor kept its document (same IME owner).
|
||||
fn assert_ime_owner(s: &Session, expected: u64, why: &str) {
|
||||
assert_eq!(ime_owner(s), expected, "{why}");
|
||||
}
|
||||
|
||||
/// The editor's document was replaced (new IME owner/epoch).
|
||||
fn assert_ime_epoch_changed(s: &Session, previous: u64, why: &str) {
|
||||
assert_ne!(ime_owner(s), previous, "{why}");
|
||||
}
|
||||
|
||||
fn assert_text(s: &Session, t: u64, expected: &str) {
|
||||
assert_eq!(doc(s, t).text, expected);
|
||||
}
|
||||
|
||||
/// A new, empty composition called "Other" (the standard second comp).
|
||||
fn new_other_comp(s: &mut Session) -> u64 {
|
||||
s.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap()["comp"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// Stopwatch the layer's Source Text (one key at the CTI).
|
||||
fn animate_source(s: &mut Session, t: u64) {
|
||||
s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap();
|
||||
}
|
||||
|
||||
/// Add a Source Text key at `time`.
|
||||
fn add_source_key(s: &mut Session, t: u64, time: f64, value: &str) {
|
||||
s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
|
||||
/// Write Source Text as a static value (a replacement when it differs from the shown text).
|
||||
fn set_source(s: &mut Session, t: u64, value: &str) {
|
||||
s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": value})).unwrap();
|
||||
}
|
||||
|
||||
/// Write the Source Text key at `time`.
|
||||
fn set_source_key(s: &mut Session, t: u64, time: f64, value: &str) {
|
||||
s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
|
||||
/// Set the Source Text key at the CTI (`keys.set`).
|
||||
fn set_source_cti_key(s: &mut Session, t: u64, time: f64, value: &str) {
|
||||
s.execute("keys.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
|
||||
/// Replace the whole Source Text of the layer (`layer.setText`).
|
||||
fn replace_text(s: &mut Session, t: u64, text: &str) {
|
||||
s.execute("layer.setText", json!({"layer": t, "text": text})).unwrap();
|
||||
}
|
||||
|
||||
/// Select the character range `start..end` in the open editor.
|
||||
fn select(s: &mut Session, start: usize, end: usize) {
|
||||
s.execute("text.setSelection", json!({"start": start, "end": end})).unwrap();
|
||||
}
|
||||
|
||||
/// Set both selection ends, keeping their direction (`anchor` is the other end).
|
||||
fn select_anchor(s: &mut Session, anchor: usize, caret: usize) {
|
||||
s.execute("text.setSelection", json!({"anchor": anchor, "caret": caret})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_text_with_a_range_creates_runs_and_undoes() {
|
||||
let (mut s, t) = session("Hello World");
|
||||
@@ -200,7 +292,596 @@ fn edit_state_round_trips_through_serde() {
|
||||
s.execute("text.edit", json!({"layer": t, "select": [1, 2]})).unwrap();
|
||||
let j = serde_json::to_value(&s.state).unwrap();
|
||||
let back: crate::EditorState = serde_json::from_value(j).unwrap();
|
||||
assert_eq!(back.text_edit, s.state.text_edit);
|
||||
let before = s.state.text_edit.clone().expect("editing");
|
||||
let after = back.text_edit.clone().expect("editing");
|
||||
assert_eq!((after.layer, after.anchor, after.caret), (before.layer, before.anchor, before.caret));
|
||||
assert_ne!(after.session, before.session, "a restored editor gets a process-unique replacement epoch");
|
||||
assert_ne!(after.session, 0, "the restored epoch is always a real session token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_session_epoch_tracks_replaced_text_and_layer_restoration() {
|
||||
let (mut s, t) = editing_all("abc");
|
||||
let first = ime_owner(&s);
|
||||
|
||||
// Replacing Source Text is a new document even though the layer id stays.
|
||||
replace_text(&mut s, t, "replaced");
|
||||
let replaced = ime_owner(&s);
|
||||
assert_ne!(replaced, first, "layer.setText starts a new IME epoch");
|
||||
|
||||
// Undo restores another document: it must not revive the token from before either edit.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let undone = ime_owner(&s);
|
||||
assert_ne!(undone, replaced);
|
||||
assert_ne!(undone, first);
|
||||
|
||||
// Clear + immediate undo in the same command sequence: the layer is gone long enough to
|
||||
// end the session, and the restored layer cannot inherit a composition.
|
||||
begin_edit(&mut s, t);
|
||||
s.execute("edit.clear", json!({"layers": [t]})).unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "clearing the edited layer ends the session");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "undo does not recreate the old composition session");
|
||||
begin_edit(&mut s, t);
|
||||
assert_ne!(ime_owner(&s), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_ime_deletion_keeps_a_static_plain_text_value_and_records_no_step() {
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
let (mut s, t) = session("abc");
|
||||
// Legacy/imported text can still be stored as a plain Str rather than a TextDoc.
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let pr = std::sync::Arc::make_mut(&mut s.project)
|
||||
.comp_mut(cid)
|
||||
.unwrap()
|
||||
.layer_mut(effectcraft_project::LayerId(t))
|
||||
.unwrap()
|
||||
.props
|
||||
.prop_mut("text/sourceText")
|
||||
.unwrap();
|
||||
pr.value = KV::Str("abc".into());
|
||||
begin_edit_at(&mut s, t, 0);
|
||||
let steps = undo_steps(&s);
|
||||
|
||||
// A non-zero before count at the document start deletes nothing.
|
||||
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap();
|
||||
|
||||
let pr = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().props.prop("text/sourceText").unwrap();
|
||||
assert!(matches!(&pr.value, KV::Str(_)), "a no-op keeps the static Str variant");
|
||||
assert!(pr.keys.is_empty());
|
||||
assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_ime_deletion_does_not_add_a_keyframe_to_animated_source_text() {
|
||||
let (mut s, t) = editing("abc", 0);
|
||||
animate_source(&mut s, t);
|
||||
let lid = effectcraft_project::LayerId(t);
|
||||
let keys = |s: &Session| s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap().keys.len();
|
||||
assert_eq!(keys(&s), 1, "one key from the stopwatch");
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
let steps = undo_steps(&s);
|
||||
|
||||
// A non-zero before count at the document start deletes nothing, at a time with no key.
|
||||
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap();
|
||||
|
||||
assert_eq!(keys(&s), 1, "a no-op does not key Source Text at the CTI");
|
||||
assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_writes_replace_the_edited_document_only_when_the_text_changes() {
|
||||
let (mut s, t) = session("abc");
|
||||
let other = s.execute("layer.newText", json!({"text": "other"})).unwrap()["layer"].as_u64().unwrap();
|
||||
begin_edit_at(&mut s, t, 1);
|
||||
let owner = ime_owner(&s);
|
||||
let steps = undo_steps(&s);
|
||||
|
||||
// Writing the text the document already has is a no-op: same document, same owner, no step.
|
||||
set_source(&mut s, t, "abc");
|
||||
assert_ime_owner(&s, owner, "a no-op write keeps the IME owner");
|
||||
assert_eq!(undo_steps(&s), steps, "a no-op write records no undo step");
|
||||
replace_text(&mut s, t, "abc");
|
||||
assert_ime_owner(&s, owner, "a no-op layer.setText keeps the IME owner");
|
||||
assert_eq!(undo_steps(&s), steps, "a no-op layer.setText records no undo step");
|
||||
|
||||
// Another property of the edited layer leaves its document alone.
|
||||
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
|
||||
assert_ime_owner(&s, owner, "an unrelated property keeps the IME owner");
|
||||
|
||||
// Source Text of another layer is not the editor's document either.
|
||||
set_source(&mut s, other, "changed");
|
||||
assert_ime_owner(&s, owner, "another layer's text keeps the IME owner");
|
||||
|
||||
// Replacing the edited layer's Source Text is a document replacement: a new IME epoch.
|
||||
set_source(&mut s, t, "replaced");
|
||||
assert_text(&s, t, "replaced");
|
||||
assert_ime_epoch_changed(&s, owner, "replacing the document changes the IME owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_of_an_unrelated_property_keep_the_ime_owner_and_selection() {
|
||||
let (mut s, t) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
|
||||
assert_ime_owner(&s, owner, "the opacity write keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "the opacity write keeps the selection");
|
||||
|
||||
// The opacity step does not change Source Text: restoring it keeps the same editor.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_ime_owner(&s, owner, "undoing only opacity keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "undoing only opacity keeps the selection");
|
||||
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_ime_owner(&s, owner, "redoing only opacity keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "redoing only opacity keeps the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_only_text_change_and_its_undo_replace_the_editor_epoch() {
|
||||
let (mut s, t) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// The same characters with another run style are still a different document.
|
||||
s.execute("layer.setText", json!({"layer": t, "range": [1, 4], "size": 12})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abcdef");
|
||||
assert_eq!(doc(&s, t).style_at(2).size, 12.0);
|
||||
assert_ime_epoch_changed(&s, owner, "a style-only change starts a new IME epoch");
|
||||
assert_eq!(sel(&s), (3, 3), "a same-length style change keeps the caret in range");
|
||||
let styled = ime_owner(&s);
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).style_at(2).size, 60.0);
|
||||
assert_ime_epoch_changed(&s, styled, "undoing a style change starts a new IME epoch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_jumps_keep_the_owner_over_unrelated_steps_and_replace_it_when_the_text_changes() {
|
||||
let (mut s, t) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 25})).unwrap();
|
||||
let states = s.execute("edit.history.list", json!({})).unwrap()["states"].as_array().unwrap().clone();
|
||||
let cur = states.iter().find(|n| n["current"] == true).unwrap()["index"].as_u64().unwrap();
|
||||
let parent = states.iter().find(|n| n["index"].as_u64() == Some(cur)).unwrap()["parent"].as_u64().unwrap();
|
||||
|
||||
// Both states show the same Source Text: jumping between them keeps the editor.
|
||||
s.execute("edit.history.goto", json!({"index": parent})).unwrap();
|
||||
assert_ime_owner(&s, owner, "jumping to another opacity state keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "and the selection");
|
||||
s.execute("edit.history.goto", json!({"index": cur})).unwrap();
|
||||
assert_ime_owner(&s, owner, "jumping back keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "and the selection");
|
||||
|
||||
// A history state whose Source Text differs is a document replacement.
|
||||
set_source(&mut s, t, "abc");
|
||||
let changed = ime_owner(&s);
|
||||
assert_ne!(changed, owner);
|
||||
s.execute("edit.history.goto", json!({"index": parent})).unwrap();
|
||||
assert_text(&s, t, "abcdef");
|
||||
assert_ime_epoch_changed(&s, changed, "jumping to a different document starts a new epoch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_batch_rollback_keeps_the_owner_for_unrelated_steps_and_replaces_it_for_text_changes() {
|
||||
let (mut s, t) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 50}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_ime_owner(&s, owner, "rolling back an unrelated step keeps the owner");
|
||||
assert_eq!(sel(&s), (3, 3), "and the selection");
|
||||
|
||||
// A partially applied text replacement was still shown before the rollback restores the
|
||||
// original document, so the editor must not revive the composition it had before the batch.
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "layer.setText", "params": {"layer": t, "text": "ab"}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_text(&s, t, "abcdef");
|
||||
assert_ime_epoch_changed(&s, owner, "a rolled-back text change does not revive the old owner");
|
||||
assert_eq!(sel(&s), (3, 3), "the restored document keeps the caret in range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_batch_rollback_after_a_transient_editor_restart_bumps_the_epoch() {
|
||||
let (mut s, t) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// `text.edit` restarts the session even on the layer that is already edited. The rollback
|
||||
// restores the pre-batch TextEdit (with the old token), so the intermediate restart must be
|
||||
// detected from the snapshot: neither the old composition nor its token may be revived.
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "text.edit", "params": {"layer": t, "caret": 1}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(sel(&s), (3, 3), "the restored selection is the pre-batch one");
|
||||
assert_ime_epoch_changed(&s, owner, "a canceled intermediate editor must not revive the old token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_batch_rollback_after_a_cross_comp_editor_switch_bumps_the_epoch() {
|
||||
let (mut s, t) = session("abcdef");
|
||||
let original = s.active_comp_id().unwrap();
|
||||
// Give the other comp a layer with the same id and the same Source Text, so only the comp
|
||||
// and editor-session identity distinguishes the two editors.
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().clone();
|
||||
let other = new_other_comp(&mut s);
|
||||
let other_id = effectcraft_project::ItemId(other);
|
||||
std::sync::Arc::make_mut(&mut s.project).comp_mut(other_id).unwrap().layers.push(layer);
|
||||
s.execute("comp.open", json!({"comp": original.0})).unwrap();
|
||||
begin_edit_at(&mut s, t, 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "comp.open", "params": {"comp": other}},
|
||||
{"command": "text.edit", "params": {"layer": t, "caret": 1}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.active_comp_id(), Some(original), "the batch restored the original comp");
|
||||
assert_eq!(sel(&s), (3, 3), "and the pre-batch selection");
|
||||
assert_ime_epoch_changed(&s, owner, "an identical layer in another comp must not revive the old token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_atomic_batch_drops_the_events_its_failed_steps_queued() {
|
||||
let (mut s, _) = session("abcdef");
|
||||
let original = s.active_comp_id().unwrap();
|
||||
// `comp.new` opens the comp it creates; go back so the batch's switch is a real one.
|
||||
let other = new_other_comp(&mut s);
|
||||
s.execute("comp.open", json!({"comp": original.0})).unwrap();
|
||||
s.drain_events();
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "comp.open", "params": {"comp": other}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.active_comp_id(), Some(original), "the batch restored the original comp");
|
||||
assert!(
|
||||
!s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenComp(id) if id.0 == other)),
|
||||
"the rolled-back step's OpenComp is not queued for the UI"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_batch_rollback_after_closing_the_editor_does_not_revive_it() {
|
||||
let (mut s, t) = session("abcdef");
|
||||
let original = s.active_comp_id().unwrap();
|
||||
let other = new_other_comp(&mut s);
|
||||
s.execute("comp.open", json!({"comp": original.0})).unwrap();
|
||||
begin_edit_at(&mut s, t, 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// The failing state has no editor at all: the restoration brings the pre-batch one back and
|
||||
// must treat it as unverified rather than revive its composition.
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "comp.open", "params": {"comp": other}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert!(s.state.text_edit.is_some(), "the batch restored the editor state");
|
||||
assert_eq!(sel(&s), (3, 3));
|
||||
assert_ime_epoch_changed(&s, owner, "a canceled editor close must not revive the old token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_without_an_editor_snapshot_replaces_an_open_editor() {
|
||||
let (mut s, _) = editing("abcdef", 3);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// `None` means no editor was open when the state was replaced; an editor that is open
|
||||
// afterwards cannot prove it kept its document.
|
||||
s.sanitize_after_replacement(None);
|
||||
assert_ime_epoch_changed(&s, owner, "an unverified restored editor starts a new epoch");
|
||||
assert_eq!(sel(&s), (3, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_to_a_shorter_document_clamps_the_caret() {
|
||||
let (mut s, t) = editing("abc", 3);
|
||||
replace_text(&mut s, t, "abcdef");
|
||||
select_anchor(&mut s, 6, 6);
|
||||
let owner = ime_owner(&s);
|
||||
assert_eq!(sel(&s), (6, 6));
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_text(&s, t, "abc");
|
||||
assert_eq!(sel(&s), (3, 3), "undoing to a shorter document clamps the caret");
|
||||
assert_ime_epoch_changed(&s, owner, "and starts a new epoch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ime_stamp_revision_follows_the_editor_not_unrelated_project_edits() {
|
||||
let (mut s, t) = editing("abcdef", 6);
|
||||
let revision = ime_stamp_revision(&s);
|
||||
|
||||
s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap();
|
||||
assert_eq!(ime_stamp_revision(&s), revision, "an unrelated property write does not advance it");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(ime_stamp_revision(&s), revision, "undoing it does not either");
|
||||
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 25}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(ime_stamp_revision(&s), revision, "a rolled-back unrelated batch restores it");
|
||||
|
||||
s.execute("text.insert", json!({"text": "x"})).unwrap();
|
||||
assert!(ime_stamp_revision(&s) > revision, "typing advances it");
|
||||
let typed = ime_stamp_revision(&s);
|
||||
s.execute("layer.setText", json!({"layer": t, "text": "y"})).unwrap();
|
||||
assert!(ime_stamp_revision(&s) > typed, "a replaced document advances it too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_atomic_batch_with_a_transient_edit_keeps_its_high_water_ime_revision() {
|
||||
let (mut s, t) = editing("abcdef", 6);
|
||||
let revision = ime_stamp_revision(&s);
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "text.insert", "params": {"text": "x"}},
|
||||
{"command": "text.delete", "params": {}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_text(&s, t, "abcdef");
|
||||
// The intermediate document edit is real even though the final document equals the
|
||||
// pre-batch one: its stamp must remain the high-water mark.
|
||||
assert!(ime_stamp_revision(&s) > revision, "a transient edit must not rewind the IME revision");
|
||||
let after_transient = ime_stamp_revision(&s);
|
||||
|
||||
// An unrelated failed batch really did not edit the document: it must preserve the revision
|
||||
// exactly, or every wide-selection composition would be canceled by unrelated work.
|
||||
let r = s.execute(
|
||||
"engine.batch",
|
||||
json!({"atomic": true, "steps": [
|
||||
{"command": "prop.set", "params": {"layer": t, "path": "transform/opacity", "value": 25}},
|
||||
{"command": "no.suchCommand", "params": {}}
|
||||
]}),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(ime_stamp_revision(&s), after_transient, "an unrelated rolled-back batch preserves the revision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replacing_property_write_clamps_the_selection_so_the_next_backspace_deletes() {
|
||||
let (mut s, t) = editing("abcdef", 6);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
set_source(&mut s, t, "a");
|
||||
assert_text(&s, t, "a");
|
||||
assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range");
|
||||
assert_ne!(ime_owner(&s), owner);
|
||||
|
||||
// Backspace at the clamped caret removes the character instead of no-opping on 5..6.
|
||||
s.execute("text.delete", json!({})).unwrap();
|
||||
assert_text(&s, t, "");
|
||||
assert_eq!(sel(&s), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_writes_on_source_text_replace_the_edited_document_only_when_the_cti_value_changes() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
// Keying the text the CTI already shows leaves the document equal.
|
||||
animate_source(&mut s, t);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
set_source_cti_key(&mut s, t, 0.0, "abc");
|
||||
assert_ime_owner(&s, owner, "a no-op key write keeps the IME owner");
|
||||
|
||||
// `keys.set` changes the key at the CTI: a document replacement.
|
||||
set_source_cti_key(&mut s, t, 0.0, "replaced");
|
||||
assert_text(&s, t, "replaced");
|
||||
assert_ime_epoch_changed(&s, owner, "a changed keyframe changes the IME owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_cti_keyframe_first_replaces_the_document_then_the_last_key_is_a_noop() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
animate_source(&mut s, t);
|
||||
add_source_key(&mut s, t, 1.0, "later");
|
||||
let owner = ime_owner(&s);
|
||||
let lid = effectcraft_project::LayerId(t);
|
||||
let pr = s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap();
|
||||
let uid = pr.uid;
|
||||
let times: Vec<_> = pr.keys.iter().map(|k| k.time).collect();
|
||||
assert_eq!(times.len(), 2);
|
||||
|
||||
// Deleting the key at the CTI (0s) while the 1s key remains shows the later text: replacing
|
||||
// the document starts a new epoch.
|
||||
s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[0] }];
|
||||
assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1));
|
||||
assert_text(&s, t, "later");
|
||||
let replaced = ime_owner(&s);
|
||||
assert_ne!(replaced, owner, "deleting the CTI key changed the document");
|
||||
|
||||
// Removing the last key turns its value into the static CTI value. That value is what the
|
||||
// CTI already showed, so the document does not change and the new owner stays.
|
||||
s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[1] }];
|
||||
assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1));
|
||||
assert_text(&s, t, "later");
|
||||
assert_ime_owner(&s, replaced, "removing the last key is a document no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_the_cti_to_a_shorter_animated_key_clamps_and_starts_a_new_epoch() {
|
||||
let (mut s, t) = session("abcdef");
|
||||
animate_source(&mut s, t);
|
||||
add_source_key(&mut s, t, 1.0, "a");
|
||||
begin_edit_at(&mut s, t, 6);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// The same time still shows the same document: no new epoch.
|
||||
s.execute("time.set", json!({"time": 0.0})).unwrap();
|
||||
assert_text(&s, t, "abcdef");
|
||||
assert_ime_owner(&s, owner, "an unchanged CTI document keeps the IME owner");
|
||||
|
||||
// The key at 1s holds shorter text: the document changes, the selection is clamped and the
|
||||
// editor gets a new epoch.
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
assert_text(&s, t, "a");
|
||||
assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range");
|
||||
let replaced = ime_owner(&s);
|
||||
assert_ne!(replaced, owner, "a changed CTI document starts a new IME epoch");
|
||||
|
||||
// Backspace at the clamped caret removes the character instead of no-opping on 5..6.
|
||||
s.execute("text.delete", json!({})).unwrap();
|
||||
assert_text(&s, t, "");
|
||||
assert_eq!(sel(&s), (0, 0));
|
||||
assert_ime_owner(&s, replaced, "typing after the replacement keeps its owner");
|
||||
|
||||
// Staying at the same time after the edit is a no-op for the document too.
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
assert_text(&s, t, "");
|
||||
assert_ime_owner(&s, replaced, "a no-op time move keeps the IME owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_related_times_replaces_the_edited_document_when_it_moves_the_active_comp() {
|
||||
let (mut s, t) = session("abcdef");
|
||||
animate_source(&mut s, t);
|
||||
add_source_key(&mut s, t, 1.0, "a");
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let r = s.execute("layer.precompose", json!({"layers": [t], "name": "Inner", "open": true})).unwrap();
|
||||
let inner = effectcraft_project::ItemId(r["comp"].as_u64().unwrap());
|
||||
assert_eq!(s.active_comp_id(), Some(inner));
|
||||
|
||||
begin_edit_at(&mut s, t, 6);
|
||||
let owner = ime_owner(&s);
|
||||
s.sync_related_times(outer, effectcraft_time::Tick::from_seconds_f64(1.0));
|
||||
|
||||
assert_text(&s, t, "a");
|
||||
assert_eq!(sel(&s), (1, 1), "the related time move clamps the selection");
|
||||
assert_ime_epoch_changed(&s, owner, "the related time move changed the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_preset_that_writes_source_text_replaces_the_edited_document() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
let owner = ime_owner(&s);
|
||||
let preset = json!({
|
||||
"effectcraftAnimationPreset": 1,
|
||||
"properties": [{"path": "text/sourceText", "value": {"t": "Text", "v": {"text": "replaced"}}, "keys": []}],
|
||||
});
|
||||
s.execute("anim.applyPreset", json!({"preset": preset, "layers": [t]})).unwrap();
|
||||
assert_text(&s, t, "replaced");
|
||||
assert_ime_epoch_changed(&s, owner, "a preset that replaces source text changes the IME owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_property_write_keeps_the_edited_document_and_its_owner() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
let owner = ime_owner(&s);
|
||||
let steps = undo_steps(&s);
|
||||
|
||||
// null cannot coerce to a Text value: the edit fails before anything is committed.
|
||||
assert!(s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": null})).is_err());
|
||||
assert_text(&s, t, "abc");
|
||||
assert_ime_owner(&s, owner, "a failed write keeps the IME owner");
|
||||
assert_eq!(undo_steps(&s), steps, "a failed write records no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn property_writes_off_the_cti_keep_the_owner_until_they_change_the_cti_document() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
animate_source(&mut s, t);
|
||||
add_source_key(&mut s, t, 1.0, "later");
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// Writing a key at 1s leaves the CTI (0s) value as it was.
|
||||
set_source_key(&mut s, t, 1.0, "changed");
|
||||
assert_text(&s, t, "abc");
|
||||
assert_ime_owner(&s, owner, "an off-CTI write keeps the IME owner");
|
||||
|
||||
// Writing the CTI value itself replaces the document.
|
||||
set_source(&mut s, t, "cti");
|
||||
assert_text(&s, t, "cti");
|
||||
assert_ime_epoch_changed(&s, owner, "a CTI write changes the IME owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_project_cleanup_keeps_the_ime_owner_but_replacement_changes_it() {
|
||||
let (mut s, t) = editing("abc", 1);
|
||||
let owner = ime_owner(&s);
|
||||
|
||||
// file.removeUnusedFootage calls sanitize_state while the edited document is untouched.
|
||||
s.execute("file.importSolid", json!({"name": "Unused"})).unwrap();
|
||||
let removed = s.execute("file.removeUnusedFootage", json!({})).unwrap();
|
||||
assert_eq!(removed["removed"], json!(1), "the unused solid was actually removed");
|
||||
assert_ime_owner(&s, owner, "unrelated cleanup keeps the IME owner");
|
||||
|
||||
// Replacing the Source Text is a document replacement: a new owner.
|
||||
replace_text(&mut s, t, "replaced");
|
||||
assert_ime_epoch_changed(&s, owner, "replacing the document changes the owner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_session_does_not_cross_a_composition_switch() {
|
||||
let (mut s, _) = editing_all("abc");
|
||||
let other = new_other_comp(&mut s);
|
||||
s.execute("comp.open", json!({"comp": other})).unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "the old comp's editor is not reused in another comp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_active_comp_does_not_retarget_its_text_editor() {
|
||||
for replacement in [false, true] {
|
||||
let (mut s, t) = session("original");
|
||||
let original = s.active_comp_id().unwrap();
|
||||
let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().clone();
|
||||
let other = new_other_comp(&mut s);
|
||||
let other_id = effectcraft_project::ItemId(other);
|
||||
std::sync::Arc::make_mut(&mut s.project).comp_mut(other_id).unwrap().layers.push(layer);
|
||||
s.execute("comp.open", json!({"comp": other})).unwrap();
|
||||
s.execute("comp.open", json!({"comp": original.0})).unwrap();
|
||||
begin_edit_at(&mut s, t, 1);
|
||||
assert!(s.state.text_edit.is_some());
|
||||
|
||||
// Reproduce cleanup after a project replacement, bypassing Session::edit's earlier
|
||||
// document guard so this specifically exercises sanitize_state's fallback-comp branch.
|
||||
let edited = crate::commands::text_edit::active_editor_snapshot(&s);
|
||||
std::sync::Arc::make_mut(&mut s.project).items.remove(&original);
|
||||
if replacement {
|
||||
s.sanitize_after_replacement(edited.as_ref());
|
||||
} else {
|
||||
s.sanitize_state();
|
||||
}
|
||||
assert_eq!(s.active_comp_id(), Some(other_id));
|
||||
assert!(s.state.text_edit.is_none(), "the same-numbered layer belongs to a different comp");
|
||||
assert!(s.execute("text.insert", json!({"text": "wrong document"})).is_err());
|
||||
assert_eq!(doc(&s, t).text, "original", "the fallback comp was not edited");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -260,26 +941,32 @@ fn fonts_lists_bundled_and_installed_families() {
|
||||
assert_eq!(crate::font_styles("No Such Family").len(), 5);
|
||||
}
|
||||
|
||||
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
|
||||
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
|
||||
fn pinned_variable_font(family: &str) -> Vec<u8> {
|
||||
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
|
||||
let strings: Vec<Vec<u8>> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
|
||||
let mut name = u16s(&[0, 2, 30]);
|
||||
/// A big-endian `u16` array, as sfnt tables store them.
|
||||
fn u16s(v: &[u16]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_be_bytes()).collect()
|
||||
}
|
||||
|
||||
/// A `name` table with one Windows/Unicode English record per `(id, text)`.
|
||||
fn name_table(strings: &[(u16, String)]) -> Vec<u8> {
|
||||
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
|
||||
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
|
||||
let mut off = 0;
|
||||
for (id, st) in strings.iter().enumerate() {
|
||||
name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off]));
|
||||
for (id, st) in &enc {
|
||||
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
|
||||
off += st.len() as u16;
|
||||
}
|
||||
strings.iter().for_each(|st| name.extend(st));
|
||||
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
|
||||
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
|
||||
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
|
||||
enc.iter().for_each(|(_, st)| name.extend(st));
|
||||
name
|
||||
}
|
||||
|
||||
/// A single-face font with `tables` in order (zero checksums, offsets after the directory).
|
||||
fn sfnt(tables: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = u16s(&[1, 0, tables.len() as u16, 0, 0, 0]);
|
||||
let mut body: Vec<u8> = vec![];
|
||||
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
|
||||
out.extend(tag);
|
||||
for (tag, data) in tables {
|
||||
out.extend(*tag);
|
||||
out.extend([0u8; 4]);
|
||||
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
|
||||
out.extend(((12 + 16 * tables.len() + body.len()) as u32).to_be_bytes());
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
body.extend(data);
|
||||
}
|
||||
@@ -287,6 +974,15 @@ fn pinned_variable_font(family: &str) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
|
||||
/// 400 (so the helpers that look for an installed font with a real axis range never pick it).
|
||||
fn pinned_variable_font(family: &str) -> Vec<u8> {
|
||||
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]);
|
||||
fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256])));
|
||||
let name = name_table(&[(1, family.to_owned()), (2, "Regular".to_owned())]);
|
||||
sfnt(&[(b"fvar", fvar), (b"name", name)])
|
||||
}
|
||||
|
||||
/// The font menu's Variable Fonts filter (After Effects 26.3): `text.fonts {variable: true}`
|
||||
/// lists only families with variation axes, every listing says which families are variable, and
|
||||
/// the font menu's rows carry the flag the filter uses.
|
||||
@@ -321,7 +1017,6 @@ fn fonts_filter_variable_families() {
|
||||
/// `Light`) and the named `instances` (style, `wght`), each with a PostScript name
|
||||
/// `<family without spaces>-<style without spaces>`.
|
||||
fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
|
||||
let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
|
||||
let squash = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).collect::<String>();
|
||||
let mut strings: Vec<(u16, String)> = vec![(1, family.into()), (2, "Light".into())];
|
||||
let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, instances.len() as u16, 10]);
|
||||
@@ -334,25 +1029,7 @@ fn instanced_variable_font(family: &str, instances: &[(&str, f32)]) -> Vec<u8> {
|
||||
fvar.extend(((w * 65536.0) as i32).to_be_bytes());
|
||||
fvar.extend(u16s(&[id + 1]));
|
||||
}
|
||||
let enc: Vec<(u16, Vec<u8>)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect();
|
||||
let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]);
|
||||
let mut off = 0;
|
||||
for (id, st) in &enc {
|
||||
name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off]));
|
||||
off += st.len() as u16;
|
||||
}
|
||||
enc.iter().for_each(|(_, st)| name.extend(st));
|
||||
let mut out = u16s(&[1, 0, 2, 0, 0, 0]);
|
||||
let mut body: Vec<u8> = vec![];
|
||||
for (tag, data) in [(b"fvar", fvar), (b"name", name)] {
|
||||
out.extend(tag);
|
||||
out.extend([0u8; 4]);
|
||||
out.extend(((12 + 32 + body.len()) as u32).to_be_bytes());
|
||||
out.extend((data.len() as u32).to_be_bytes());
|
||||
body.extend(data);
|
||||
}
|
||||
out.extend(body);
|
||||
out
|
||||
sfnt(&[(b"fvar", fvar), (b"name", name_table(&strings))])
|
||||
}
|
||||
|
||||
/// #1563: a variable font's named instances are styles of its family in `text.fonts`, and a
|
||||
@@ -393,10 +1070,124 @@ fn set_text_schema_accepts_every_opentype_key_and_requires_none() {
|
||||
assert!(doc(&s, t).style_at(0).opentype.stylistic_set(20));
|
||||
}
|
||||
|
||||
/// Incoming IME replacement (stock `DeleteSurrounding` / the fork's `DeleteSurroundingBytes`):
|
||||
/// the characters around the selection go, the selected text stays for the following commit, and
|
||||
/// a byte count that ends inside a UTF-8 character leaves that character.
|
||||
#[test]
|
||||
fn ime_replacement_keeps_the_selection_and_never_splits_a_character() {
|
||||
let (mut s, t) = editing_all("abcdef");
|
||||
select(&mut s, 1, 4);
|
||||
// One character before "bcd" and everything after it: the selection survives for the commit.
|
||||
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 99, "merge": "typing-0"})).unwrap();
|
||||
assert_text(&s, t, "bcd");
|
||||
assert_eq!(sel(&s), (0, 3), "the selection is kept for the commit that follows");
|
||||
s.execute("text.insert", json!({"text": "X", "merge": "typing-0"})).unwrap();
|
||||
assert_text(&s, t, "X");
|
||||
assert_eq!(sel(&s), (1, 1));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "abcdef", "the surrounding delete and its commit are one undo step");
|
||||
// An empty count is a no-op (deletion-only batches, empty commits), never an undo step.
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
select(&mut s, 0, 1);
|
||||
s.execute("text.deleteSurrounding", json!({"before": 0, "after": 0})).unwrap();
|
||||
assert_text(&s, t, "X");
|
||||
|
||||
// Byte counts end inside a character: it stays whole (never a split UTF-8 sequence).
|
||||
let (mut s, t) = editing_all("A😀B");
|
||||
select(&mut s, 3, 3);
|
||||
s.execute("text.deleteSurrounding", json!({"before": 2, "after": 0, "bytes": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "A😀", "B is whole, the emoji only partly covered, so both are not one range");
|
||||
select(&mut s, 2, 2);
|
||||
s.execute("text.deleteSurrounding", json!({"before": 4, "after": 0, "bytes": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "A", "four bytes cover the whole emoji");
|
||||
|
||||
// A combining mark is one character of two bytes: one byte of it removes nothing.
|
||||
let (mut s, t) = editing_all("e\u{301}");
|
||||
select(&mut s, 2, 2);
|
||||
s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0, "bytes": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "e\u{301}", "a partial combining mark stays");
|
||||
s.execute("text.deleteSurrounding", json!({"before": 2, "after": 0, "bytes": true})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "e", "the whole combining mark is one character");
|
||||
}
|
||||
|
||||
/// The outgoing IME query: logical committed text (the pre-edit never enters the model), UTF-8
|
||||
/// byte offsets in either selection direction, a bounded window, and an explicit unavailable
|
||||
/// answer for a selection that cannot be represented.
|
||||
#[test]
|
||||
fn ime_surrounding_maps_characters_to_bytes_and_preserves_direction() {
|
||||
let (mut s, _) = editing_all("café😀");
|
||||
select_anchor(&mut s, 2, 5);
|
||||
let w = ime_surrounding(&s).expect("an available document");
|
||||
assert_eq!(w.text, "café😀");
|
||||
assert_eq!((w.cursor, w.anchor), (9, 2), "the caret is six characters in, after the emoji; the anchor stays at byte 2");
|
||||
assert_eq!(&w.text[w.cursor..], "");
|
||||
assert_eq!(&w.text[..w.anchor], "ca");
|
||||
|
||||
// Reversed: the caret stays the primary end and the anchor the other one.
|
||||
select_anchor(&mut s, 5, 2);
|
||||
let reversed = ime_surrounding(&s).expect("an available document");
|
||||
assert_eq!((reversed.cursor, reversed.anchor), (2, 9), "cursor > anchor is preserved after the conversion");
|
||||
assert_eq!(&reversed.text[reversed.cursor..reversed.anchor], "fé😀");
|
||||
|
||||
// An available empty document is not the unavailable state.
|
||||
let (s, _) = editing_all("");
|
||||
let empty = ime_surrounding(&s).expect("empty text is available");
|
||||
assert_eq!((empty.text.as_str(), empty.cursor, empty.anchor), ("", 0, 0));
|
||||
|
||||
// No edit session: unavailable, not an empty document.
|
||||
let (s, _) = session("text");
|
||||
assert!(ime_surrounding(&s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_surrounding_is_bounded_and_refuses_a_too_wide_selection() {
|
||||
// A long line: the query copies only a window around the caret, never the document.
|
||||
let text = "a".repeat(20_000);
|
||||
let (mut s, _) = editing(&text, 10_000);
|
||||
let w = ime_surrounding(&s).expect("a collapsed caret fits");
|
||||
assert!(w.text.len() <= MAX_IME_TEXT_BYTES, "the payload is bounded");
|
||||
assert!(w.text.len() > 1000, "the caret keeps a useful amount of context");
|
||||
assert!(w.text.is_char_boundary(w.cursor) && w.text.is_char_boundary(w.anchor));
|
||||
assert_eq!(w.cursor, w.anchor);
|
||||
|
||||
// A selection wider than the protocol limit is unavailable, not a truncated one.
|
||||
select(&mut s, 0, 20_000);
|
||||
assert!(ime_surrounding(&s).is_none(), "a too-wide selection is unavailable");
|
||||
|
||||
// Multi-byte text at the window boundary is never split, and offsets stay on boundaries.
|
||||
let text = "é".repeat(5_000);
|
||||
let (s, _) = editing(&text, 2_500);
|
||||
let w = ime_surrounding(&s).expect("a collapsed caret fits");
|
||||
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
|
||||
assert!(w.text.is_char_boundary(w.cursor));
|
||||
assert_eq!(&w.text[w.cursor..w.cursor + "é".len()], "é");
|
||||
}
|
||||
|
||||
/// A reversed selection survives a byte-count deletion: both endpoints shift by the removed
|
||||
/// characters before the selection, its direction stays reversed, and partial characters on
|
||||
/// either side stay whole.
|
||||
#[test]
|
||||
fn ime_byte_deletion_keeps_a_reversed_selection_direction() {
|
||||
let (mut s, t) = editing_all("😀ab😀");
|
||||
select_anchor(&mut s, 3, 1);
|
||||
let (anchor, caret) = sel(&s);
|
||||
assert_eq!((anchor, caret), (3, 1), "reversed before the replacement");
|
||||
|
||||
// Three bytes cover only part of either four-byte emoji: neither is split or removed.
|
||||
s.execute("text.deleteSurrounding", json!({"before": 3, "after": 3, "bytes": true})).unwrap();
|
||||
assert_text(&s, t, "😀ab😀");
|
||||
|
||||
// Four bytes cover each whole emoji: the selection text stays, shifted, still reversed.
|
||||
s.execute("text.deleteSurrounding", json!({"before": 4, "after": 4, "bytes": true})).unwrap();
|
||||
assert_text(&s, t, "ab");
|
||||
assert_eq!(sel(&s), (2, 0), "the anchor stays after the caret");
|
||||
let w = ime_surrounding(&s).expect("available");
|
||||
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("ab", 0, 2), "the outgoing offsets keep the direction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deselect_all_ends_the_open_edit() {
|
||||
let (mut s, t) = session("Hello");
|
||||
s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap();
|
||||
let (mut s, t) = editing("Hello", 5);
|
||||
s.execute("text.insert", json!({"text": "!"})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "Hello!");
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
//! The crate's shared integration-test binary (each module is a former `tests/<name>.rs`).
|
||||
//! `system_font_menu` is separate: it asserts that no system font scan has run in its process.
|
||||
|
||||
mod script_fallback;
|
||||
mod system_font_menu;
|
||||
mod vertical_punctuation;
|
||||
|
||||
@@ -22,7 +22,7 @@ effectcraft-render = { workspace = true }
|
||||
effectcraft-engine = { workspace = true }
|
||||
effectcraft-gpu = { 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 }
|
||||
@@ -36,6 +36,9 @@ image = { workspace = true }
|
||||
sys-locale = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Fonts for kittest: the workspace `egui` features are what test roots resolve, but stating them
|
||||
# here documents the requirement (kittest's own dev-dependencies do not propagate to consumers).
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
|
||||
pollster = "0.4"
|
||||
effectcraft-host = { workspace = true }
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::widgets::{open_popup, popup_is_open, pressed_outside, select_all};
|
||||
/// user picks a colour.
|
||||
pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response {
|
||||
let (rect, mut resp) = ui.allocate_exact_size(ui.spacing().interact_size, Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ColorButton, ui.is_enabled(), srgb_hex(*c)));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ColorWell, ui.is_enabled(), srgb_hex(*c)));
|
||||
let stroke = if resp.hovered() { ui.visuals().widgets.hovered.fg_stroke } else { ui.visuals().widgets.inactive.bg_stroke };
|
||||
ui.painter().rect_filled(rect, 2.0, srgb32(*c));
|
||||
ui.painter().rect_stroke(rect, 2.0, stroke, StrokeKind::Inside);
|
||||
@@ -78,7 +78,7 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
|
||||
}
|
||||
|
||||
fn open_pass_id() -> egui::Id {
|
||||
egui::Id::new("color-picker-open-pass")
|
||||
egui::Id::unique("color-picker-open-pass")
|
||||
}
|
||||
|
||||
/// A colour picker is open: it was drawn, and stayed open, in this pass or the last one (so one
|
||||
@@ -183,7 +183,7 @@ fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3])
|
||||
None => Pick { hsv: rgb_to_hsv(*c), rgb: *c },
|
||||
};
|
||||
// The slider's channel is remembered between pickers, as After Effects does.
|
||||
let chan_id = egui::Id::new("color-picker-channel");
|
||||
let chan_id = egui::Id::unique("color-picker-channel");
|
||||
let mut chan = ui.data(|d| d.get_temp::<usize>(chan_id)).and_then(|i| CHANS.get(i).map(|c| c.0)).unwrap_or(Chan::H);
|
||||
let before = pick;
|
||||
let mut out = Outcome::default();
|
||||
@@ -268,7 +268,7 @@ const CHANS: [(Chan, &str); 6] = [(Chan::H, "H"), (Chan::S, "S"), (Chan::V, "B")
|
||||
/// Drag or click on the square to set its two channels.
|
||||
fn square(ui: &mut Ui, chan: Chan, pick: &mut Pick) {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(SQUARE, SQUARE), Sense::click_and_drag());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color field"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, true, "Color field"));
|
||||
let (x, y) = chan.square();
|
||||
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) {
|
||||
*pick = pick.at(chan, (p.x - rect.min.x) / rect.width(), 1.0 - (p.y - rect.min.y) / rect.height());
|
||||
@@ -297,7 +297,7 @@ fn square(ui: &mut Ui, chan: Chan, pick: &mut Pick) {
|
||||
/// Drag or click on the slider to set `chan`; arrows on both sides mark its value.
|
||||
fn slider(ui: &mut Ui, chan: Chan, pick: &mut Pick) {
|
||||
let (outer, resp) = ui.allocate_exact_size(vec2(SLIDER_W + 12.0, SQUARE), Sense::click_and_drag());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color slider"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, true, "Color slider"));
|
||||
let bar = Rect::from_center_size(outer.center(), vec2(SLIDER_W, SQUARE));
|
||||
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) {
|
||||
*pick = pick.set(chan, 1.0 - (p.y - bar.min.y) / bar.height());
|
||||
@@ -332,10 +332,10 @@ fn slider(ui: &mut Ui, chan: Chan, pick: &mut Pick) {
|
||||
fn swatches(ui: &mut Ui, new: [f32; 3], original: [f32; 3]) -> bool {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover());
|
||||
let (top, bottom) = rect.split_top_bottom_at_fraction(0.5);
|
||||
let new_resp = ui.interact(top, ui.id().with("new-swatch"), Sense::hover());
|
||||
new_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "New color"));
|
||||
let old = ui.interact(bottom, ui.id().with("original-swatch"), Sense::click()).on_hover_text(tr("Original color: click to go back to it"));
|
||||
old.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Original color"));
|
||||
let new_resp = ui.interact(top, ui.scope_id().with("new-swatch"), Sense::hover());
|
||||
new_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "New color"));
|
||||
let old = ui.interact(bottom, ui.scope_id().with("original-swatch"), Sense::click()).on_hover_text(tr("Original color: click to go back to it"));
|
||||
old.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Original color"));
|
||||
ui.painter().rect_filled(top, 0.0, srgb32(new));
|
||||
ui.painter().rect_filled(bottom, 0.0, srgb32(original));
|
||||
ui.painter().rect_stroke(rect, 0.0, ui.visuals().widgets.inactive.bg_stroke, StrokeKind::Outside);
|
||||
|
||||
+448
-330
@@ -15,7 +15,7 @@
|
||||
//! - `ui.viewer.locate {x, y}`: screen point of a comp pixel; `ui.viewer.hit {x, y}`: comp pixel at a screen point
|
||||
//! - `ui.timeline.locate {layer, time?}`: screen point of a layer bar (at a comp time)
|
||||
//! - `ui.playback {action: play|stop|toggle}`
|
||||
//! - `ui.screenshot {path?, panel?, id?}`: PNG of the window (or one panel / element)
|
||||
//! - `ui.screenshot {path?, panel?, id?}`: PNG of the window (or one panel / element, resolved when the capture is issued)
|
||||
//! - `render.frame {comp?, time?, max_side?, path?, base64?}`: PNG of a comp frame rendered by the session
|
||||
//! - `ui.resize {width, height}` / `ui.focus` / `app.quit {force?}` (a modified project asks to save first unless `force`)
|
||||
//!
|
||||
@@ -53,7 +53,8 @@ pub enum Outcome {
|
||||
AfterInput,
|
||||
Screenshot {
|
||||
path: Option<String>,
|
||||
crop: Option<[f32; 4]>,
|
||||
/// The panel/element identity to capture, resolved again when the capture is issued.
|
||||
target: crate::ShotTarget,
|
||||
},
|
||||
/// Reply once background jobs end (a `wait: true` command whose job runs in a worker).
|
||||
AwaitJobs(JobWaiter),
|
||||
@@ -169,333 +170,431 @@ fn point(app: &EffectcraftApp, p: &Value) -> Result<egui::Pos2, String> {
|
||||
Ok(egui::pos2(x as f32, y as f32))
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlRequest, now: f64) -> Outcome {
|
||||
let p = &req.params;
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
match req.method.as_str() {
|
||||
"engine.execute" | "ui.menu.invoke" => {
|
||||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||||
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
||||
// `engine.execute` runs engine commands exactly as headless (never opens a dialog, e.g.
|
||||
// `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`,
|
||||
// `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click.
|
||||
let r = if req.method == "engine.execute" && effectcraft_engine::find_command(id).is_some() {
|
||||
execute_engine(app, id, params)
|
||||
} else {
|
||||
crate::menus::invoke(app, ctx, id, params).map(ok)
|
||||
};
|
||||
match r {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
app.ui.status = e.clone();
|
||||
err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
"engine.commands" => match app.session.execute("command.list", json!({"filter": p.get("filter"), "enabledOnly": p.get("enabledOnly")})) {
|
||||
Ok(v) => ok(v),
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"engine.execute" | "ui.menu.invoke" => execute_command(app, ctx, &req.method, p),
|
||||
"engine.commands" => engine_commands(app, p),
|
||||
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_items(app)).unwrap_or_default()),
|
||||
"ui.inspect" => ok(inspect(app, ctx)),
|
||||
"ui.elements" => {
|
||||
let prefix = s("prefix").unwrap_or("");
|
||||
ok(serde_json::to_value(app.auto.query(prefix)).unwrap_or_default())
|
||||
}
|
||||
"ui.set" => {
|
||||
if let Some(t) = s("tool") {
|
||||
match Tool::from_name(t) {
|
||||
Some(t) => app.ui.tool = t,
|
||||
None => return err(format!("unknown tool `{t}`")),
|
||||
}
|
||||
}
|
||||
if let Some(w) = s("workspace") {
|
||||
match crate::dock::WORKSPACES.iter().find(|x| x.eq_ignore_ascii_case(w)) {
|
||||
Some(w) => app.set_workspace(w),
|
||||
None => return err(format!("unknown workspace `{w}`")),
|
||||
}
|
||||
}
|
||||
if let Some(th) = s("theme") {
|
||||
match crate::theme::ThemeKind::from_name(th) {
|
||||
Some(k) => app.set_theme(ctx, k),
|
||||
None => return err("unknown theme (dark, darker, light)"),
|
||||
}
|
||||
}
|
||||
if let Some(f) = s("focused").and_then(PanelKind::from_name) {
|
||||
app.ui.focused = f;
|
||||
}
|
||||
if let Some(v) = p.get("viewer") {
|
||||
if let Some(z) = v.get("zoom") {
|
||||
app.ui.viewer.zoom = z.as_f64().map(|z| z as f32);
|
||||
}
|
||||
// The active comp's Resolution (`view.res.*`), saved with the project.
|
||||
if let Some(r) = v.get("res").and_then(Value::as_str) {
|
||||
let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else {
|
||||
return err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)"));
|
||||
};
|
||||
if let Some(cid) = app.session.active_comp_id()
|
||||
&& let Err(e) = app.session.set_resolution(cid, res)
|
||||
{
|
||||
return err(e);
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(a)) = v.get("pan") {
|
||||
app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32];
|
||||
}
|
||||
for (k, slot) in [
|
||||
("grid", &mut app.ui.viewer.grid),
|
||||
("rulers", &mut app.ui.viewer.rulers),
|
||||
("safeMargins", &mut app.ui.viewer.safe_margins),
|
||||
("transparencyGrid", &mut app.ui.viewer.transparency_grid),
|
||||
] {
|
||||
if let Some(b) = v.get(k).and_then(Value::as_bool) {
|
||||
*slot = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(tl) = p.get("timeline") {
|
||||
if let Some(v) = tl.get("pps") {
|
||||
app.ui.timeline.pps = v.as_f64();
|
||||
}
|
||||
if let Some(v) = tl.get("start").and_then(Value::as_f64) {
|
||||
app.ui.timeline.start = v;
|
||||
}
|
||||
if let Some(v) = tl.get("graphEditor").and_then(Value::as_bool) {
|
||||
app.ui.timeline.graph_editor = v;
|
||||
}
|
||||
if let Some(v) = tl.get("showModes").and_then(Value::as_bool) {
|
||||
app.ui.timeline.show_modes = v;
|
||||
}
|
||||
if let Some(Value::Array(a)) = tl.get("openLayers") {
|
||||
app.ui.timeline.open_layers = a.iter().filter_map(Value::as_u64).collect();
|
||||
}
|
||||
if let Some(Value::Array(a)) = tl.get("openGroups") {
|
||||
app.ui.timeline.open_groups = a.iter().filter_map(Value::as_u64).collect();
|
||||
}
|
||||
}
|
||||
if let Some(b) = p.get("menuBar").and_then(Value::as_bool) {
|
||||
app.ui.show_menu_bar = b;
|
||||
}
|
||||
// Project panel optional columns, e.g. ["type", "duration", "path", "comment"].
|
||||
if let Some(Value::Array(a)) = p.get("projectColumns") {
|
||||
let mut cols = vec![];
|
||||
for k in a.iter().filter_map(Value::as_str) {
|
||||
crate::panels::project::set_column(&mut cols, k, true);
|
||||
}
|
||||
app.ui.project_columns = cols;
|
||||
}
|
||||
// Wiggler / Smoother / Motion Sketch settings (fields of `AnimToolsState`).
|
||||
if let Some(Value::Object(m)) = p.get("animTools") {
|
||||
let mut cur = serde_json::to_value(&app.ui.anim_tools).unwrap_or_default();
|
||||
for (k, v) in m {
|
||||
cur[k] = v.clone();
|
||||
}
|
||||
match serde_json::from_value(cur) {
|
||||
Ok(v) => app.ui.anim_tools = v,
|
||||
Err(e) => return err(format!("animTools: {e}")),
|
||||
}
|
||||
}
|
||||
if let Some(b) = p.get("home").and_then(Value::as_bool) {
|
||||
app.ui.start_screen = b;
|
||||
}
|
||||
// Home screen tab: "home", "templates" or "learn".
|
||||
if let Some(tab) = p.get("homeTab").and_then(Value::as_str) {
|
||||
app.ui.home_learn = tab == "learn";
|
||||
app.ui.home_templates = tab == "templates";
|
||||
}
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.panel.show" | "ui.panel.close" => {
|
||||
let Some(panel) = s("panel").and_then(PanelKind::from_name) else { return err("unknown `panel`") };
|
||||
if req.method == "ui.panel.show" {
|
||||
app.show_panel(panel);
|
||||
} else {
|
||||
app.ui.dock.close(panel);
|
||||
}
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.click" | "ui.move" => {
|
||||
let pos = match point(app, p) {
|
||||
Ok(p) => p,
|
||||
Err(e) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()),
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let m = modifiers(p);
|
||||
// Validate the complete gesture before mutating the queue; the loopback control
|
||||
// channel is externally reachable and must not amplify tiny requests into huge work.
|
||||
const MAX_PENDING_EVENTS: usize = 4096;
|
||||
const MAX_CLICK_COUNT: u64 = 512;
|
||||
let pending = app.synthetic.len();
|
||||
let gesture_events = if req.method == "ui.click" {
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
||||
if count == 0 || count > MAX_CLICK_COUNT {
|
||||
return err(format!("`count` must be between 1 and {MAX_CLICK_COUNT}"));
|
||||
}
|
||||
let Some(events) = usize::try_from(count).ok().and_then(|n| n.checked_mul(2)).and_then(|n| n.checked_add(3)) else {
|
||||
return err("click event count overflow");
|
||||
};
|
||||
events
|
||||
} else {
|
||||
3
|
||||
};
|
||||
if pending.checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) {
|
||||
return err("synthetic input queue is full");
|
||||
}
|
||||
// egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event)
|
||||
// and the synthetic events arrive one per frame: hold them for the whole gesture.
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(m));
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
if req.method == "ui.click" {
|
||||
let button = match s("button") {
|
||||
Some("right") => egui::PointerButton::Secondary,
|
||||
Some("middle") => egui::PointerButton::Middle,
|
||||
_ => egui::PointerButton::Primary,
|
||||
};
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
||||
for _ in 0..count {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
|
||||
}
|
||||
}
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
Outcome::AfterInput
|
||||
}
|
||||
"ui.drag" => {
|
||||
let (Some(from), Some(to)) = (p.get("from"), p.get("to")) else { return err("need `from` and `to`") };
|
||||
let (a, b) = match (point(app, from), point(app, to)) {
|
||||
(Ok(a), Ok(b)) => (a, b),
|
||||
(Err(e), _) | (_, Err(e)) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()),
|
||||
(Err(e), _) | (_, Err(e)) => return err(e),
|
||||
};
|
||||
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2);
|
||||
const MAX_PENDING_EVENTS: usize = 4096;
|
||||
const MAX_DRAG_STEPS: u64 = 2048;
|
||||
if steps > MAX_DRAG_STEPS {
|
||||
return err(format!("`steps` must be at most {MAX_DRAG_STEPS}"));
|
||||
}
|
||||
let Some(gesture_events) = usize::try_from(steps).ok().and_then(|n| n.checked_add(5)) else { return err("drag event count overflow") };
|
||||
if app.synthetic.len().checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) {
|
||||
return err("synthetic input queue is full");
|
||||
}
|
||||
let m = modifiers(p);
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(m));
|
||||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
||||
for i in 1..=steps {
|
||||
let f = i as f32 / steps as f32;
|
||||
app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f));
|
||||
}
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
Outcome::AfterInput
|
||||
}
|
||||
"ui.scroll" => {
|
||||
let pos = match point(app, p) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(dx, dy),
|
||||
modifiers: modifiers(p),
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: egui::pos2(-100.0, -100.0),
|
||||
button: egui::PointerButton::Extra1,
|
||||
pressed: false,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
Outcome::AfterInput
|
||||
}
|
||||
"ui.key" => {
|
||||
let Some(name) = s("key") else { return err("missing `key`") };
|
||||
let Some((mut m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) };
|
||||
let extra = modifiers(p);
|
||||
m |= extra;
|
||||
let physical_key = name.starts_with("Physical+").then_some(key);
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers: m });
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: false, repeat: false, modifiers: m });
|
||||
Outcome::AfterInput
|
||||
}
|
||||
"ui.type" => {
|
||||
let Some(text) = s("text") else { return err("missing `text`") };
|
||||
app.synthetic.push(egui::Event::Text(text.to_string()));
|
||||
app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
|
||||
Outcome::AfterInput
|
||||
}
|
||||
"ui.viewer.locate" => {
|
||||
let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else {
|
||||
return err("need `x`, `y` (comp pixels)");
|
||||
};
|
||||
match crate::panels::viewer::comp_to_screen(ctx, [x as f32, y as f32]) {
|
||||
Some(pt) => ok(json!({"x": pt.x, "y": pt.y})),
|
||||
None => Outcome::Retry("the composition viewer is not visible".into()),
|
||||
}
|
||||
}
|
||||
"ui.viewer.hit" => {
|
||||
let pos = match point(app, p) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
match crate::panels::viewer::screen_to_comp(ctx, pos) {
|
||||
Some(c) => ok(json!({"x": c[0], "y": c[1]})),
|
||||
None => err("the composition viewer is not visible"),
|
||||
}
|
||||
}
|
||||
"ui.timeline.locate" => {
|
||||
let Some(l) = p.get("layer").and_then(Value::as_u64) else { return err("need `layer` (id)") };
|
||||
match crate::panels::timeline::locate(app, l, p.get("time").and_then(Value::as_f64)) {
|
||||
Some((x, y)) => ok(json!({"x": x, "y": y})),
|
||||
None => Outcome::Retry("layer not visible in the timeline".into()),
|
||||
}
|
||||
}
|
||||
"ui.playback" => {
|
||||
let now = ctx.input(|i| i.time);
|
||||
match s("action").unwrap_or("toggle") {
|
||||
"play" => app.play(now),
|
||||
"stop" => app.stop(),
|
||||
"status" => {}
|
||||
_ => app.toggle_play(now),
|
||||
}
|
||||
ok(
|
||||
json!({"playing": app.playback.playing, "time": app.session.time().seconds(), "audio": app.audio.is_some(), "levelsDb": app.meter.level_db, "peaksDb": app.meter.peak_db}),
|
||||
)
|
||||
}
|
||||
"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)));
|
||||
// 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 }
|
||||
}
|
||||
"ui.elements" => elements(app, p),
|
||||
"ui.set" => set_ui(app, ctx, p),
|
||||
"ui.panel.show" | "ui.panel.close" => panel(app, &req.method, p),
|
||||
"ui.click" | "ui.move" => click_or_move(app, &req.method, p),
|
||||
"ui.drag" => drag(app, p),
|
||||
"ui.scroll" => scroll(app, p),
|
||||
"ui.key" => key(app, p),
|
||||
"ui.type" => type_text(app, p),
|
||||
"ui.viewer.locate" => viewer_locate(ctx, p),
|
||||
"ui.viewer.hit" => viewer_hit(app, ctx, p),
|
||||
"ui.timeline.locate" => timeline_locate(app, p),
|
||||
"ui.playback" => playback(app, p, now),
|
||||
"ui.screenshot" => screenshot(app, ctx, p),
|
||||
"render.frame" => render_frame(app, p),
|
||||
"ui.resize" => {
|
||||
let w = p.get("width").and_then(Value::as_f64).unwrap_or(1600.0) as f32;
|
||||
let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0) as f32;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.focus" => {
|
||||
// Explicit request: activate the app and take keyboard focus.
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
ok(Value::Null)
|
||||
}
|
||||
"app.quit" => {
|
||||
// A modified project asks to save first unless `force: true`.
|
||||
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
|
||||
app.dialog_state.unsaved.quitting = true;
|
||||
}
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.resize" => resize(ctx, p),
|
||||
"ui.focus" => focus(ctx),
|
||||
"app.quit" => quit(app, ctx, p),
|
||||
m => err(format!("unknown method `{m}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_command(app: &mut EffectcraftApp, ctx: &egui::Context, method: &str, p: &Value) -> Outcome {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||||
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
||||
// `engine.execute` runs engine commands exactly as headless (never opens a dialog, e.g.
|
||||
// `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`,
|
||||
// `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click.
|
||||
let r = if method == "engine.execute" && effectcraft_engine::find_command(id).is_some() {
|
||||
execute_engine(app, id, params)
|
||||
} else {
|
||||
crate::menus::invoke(app, ctx, id, params).map(ok)
|
||||
};
|
||||
match r {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
app.ui.status = e.clone();
|
||||
err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn engine_commands(app: &mut EffectcraftApp, p: &Value) -> Outcome {
|
||||
match app.session.execute("command.list", json!({"filter": p.get("filter"), "enabledOnly": p.get("enabledOnly")})) {
|
||||
Ok(v) => ok(v),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn elements(app: &EffectcraftApp, p: &Value) -> Outcome {
|
||||
let prefix = p.get("prefix").and_then(Value::as_str).unwrap_or("");
|
||||
ok(serde_json::to_value(app.auto.query(prefix)).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Explicit request: activate the app and take keyboard focus.
|
||||
fn focus(ctx: &egui::Context) -> Outcome {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
ok(Value::Null)
|
||||
}
|
||||
|
||||
fn set_ui(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
if let Some(t) = s("tool") {
|
||||
match Tool::from_name(t) {
|
||||
Some(t) => app.ui.tool = t,
|
||||
None => return err(format!("unknown tool `{t}`")),
|
||||
}
|
||||
}
|
||||
if let Some(w) = s("workspace") {
|
||||
match crate::dock::WORKSPACES.iter().find(|x| x.eq_ignore_ascii_case(w)) {
|
||||
Some(w) => app.set_workspace(w),
|
||||
None => return err(format!("unknown workspace `{w}`")),
|
||||
}
|
||||
}
|
||||
if let Some(th) = s("theme") {
|
||||
match crate::theme::ThemeKind::from_name(th) {
|
||||
Some(k) => app.set_theme(ctx, k),
|
||||
None => return err("unknown theme (dark, darker, light)"),
|
||||
}
|
||||
}
|
||||
if let Some(f) = s("focused").and_then(PanelKind::from_name) {
|
||||
app.ui.focused = f;
|
||||
}
|
||||
if let Some(v) = p.get("viewer")
|
||||
&& let Err(e) = apply_viewer(app, v)
|
||||
{
|
||||
return err(e);
|
||||
}
|
||||
set_ui_layout(app, p)
|
||||
}
|
||||
|
||||
/// The remaining `ui.set` fields, applied in the same order after the viewer settings.
|
||||
fn set_ui_layout(app: &mut EffectcraftApp, p: &Value) -> Outcome {
|
||||
if let Some(tl) = p.get("timeline") {
|
||||
apply_timeline(app, tl);
|
||||
}
|
||||
if let Some(b) = p.get("menuBar").and_then(Value::as_bool) {
|
||||
app.ui.show_menu_bar = b;
|
||||
}
|
||||
// Project panel optional columns, e.g. ["type", "duration", "path", "comment"].
|
||||
if let Some(Value::Array(a)) = p.get("projectColumns") {
|
||||
apply_project_columns(app, a);
|
||||
}
|
||||
// Wiggler / Smoother / Motion Sketch settings (fields of `AnimToolsState`).
|
||||
if let Some(Value::Object(m)) = p.get("animTools")
|
||||
&& let Err(e) = apply_anim_tools(app, m)
|
||||
{
|
||||
return err(e);
|
||||
}
|
||||
if let Some(b) = p.get("home").and_then(Value::as_bool) {
|
||||
app.ui.start_screen = b;
|
||||
}
|
||||
// Home screen tab: "home", "templates" or "learn".
|
||||
if let Some(tab) = p.get("homeTab").and_then(Value::as_str) {
|
||||
app.ui.home_learn = tab == "learn";
|
||||
app.ui.home_templates = tab == "templates";
|
||||
}
|
||||
ok(Value::Null)
|
||||
}
|
||||
|
||||
fn apply_viewer(app: &mut EffectcraftApp, v: &Value) -> Result<(), String> {
|
||||
if let Some(z) = v.get("zoom") {
|
||||
app.ui.viewer.zoom = z.as_f64().map(|z| z as f32);
|
||||
}
|
||||
// The active comp's Resolution (`view.res.*`), saved with the project.
|
||||
if let Some(r) = v.get("res").and_then(Value::as_str) {
|
||||
let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else {
|
||||
return Err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)"));
|
||||
};
|
||||
if let Some(cid) = app.session.active_comp_id()
|
||||
&& let Err(e) = app.session.set_resolution(cid, res)
|
||||
{
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(a)) = v.get("pan") {
|
||||
app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32];
|
||||
}
|
||||
for (k, slot) in [
|
||||
("grid", &mut app.ui.viewer.grid),
|
||||
("rulers", &mut app.ui.viewer.rulers),
|
||||
("safeMargins", &mut app.ui.viewer.safe_margins),
|
||||
("transparencyGrid", &mut app.ui.viewer.transparency_grid),
|
||||
] {
|
||||
if let Some(b) = v.get(k).and_then(Value::as_bool) {
|
||||
*slot = b;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_timeline(app: &mut EffectcraftApp, tl: &Value) {
|
||||
if let Some(v) = tl.get("pps") {
|
||||
app.ui.timeline.pps = v.as_f64();
|
||||
}
|
||||
if let Some(v) = tl.get("start").and_then(Value::as_f64) {
|
||||
app.ui.timeline.start = v;
|
||||
}
|
||||
if let Some(v) = tl.get("graphEditor").and_then(Value::as_bool) {
|
||||
app.ui.timeline.graph_editor = v;
|
||||
}
|
||||
if let Some(v) = tl.get("showModes").and_then(Value::as_bool) {
|
||||
app.ui.timeline.show_modes = v;
|
||||
}
|
||||
if let Some(Value::Array(a)) = tl.get("openLayers") {
|
||||
app.ui.timeline.open_layers = a.iter().filter_map(Value::as_u64).collect();
|
||||
}
|
||||
if let Some(Value::Array(a)) = tl.get("openGroups") {
|
||||
app.ui.timeline.open_groups = a.iter().filter_map(Value::as_u64).collect();
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_project_columns(app: &mut EffectcraftApp, a: &[Value]) {
|
||||
let mut cols = vec![];
|
||||
for k in a.iter().filter_map(Value::as_str) {
|
||||
crate::panels::project::set_column(&mut cols, k, true);
|
||||
}
|
||||
app.ui.project_columns = cols;
|
||||
}
|
||||
|
||||
fn apply_anim_tools(app: &mut EffectcraftApp, m: &serde_json::Map<String, Value>) -> Result<(), String> {
|
||||
let mut cur = serde_json::to_value(&app.ui.anim_tools).unwrap_or_default();
|
||||
for (k, v) in m {
|
||||
cur[k] = v.clone();
|
||||
}
|
||||
match serde_json::from_value(cur) {
|
||||
Ok(v) => {
|
||||
app.ui.anim_tools = v;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("animTools: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn panel(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let Some(panel) = s("panel").and_then(PanelKind::from_name) else { return err("unknown `panel`") };
|
||||
if method == "ui.panel.show" {
|
||||
app.show_panel(panel);
|
||||
} else {
|
||||
app.ui.dock.close(panel);
|
||||
}
|
||||
ok(Value::Null)
|
||||
}
|
||||
|
||||
fn click_gesture_events(method: &str, p: &Value) -> Result<usize, String> {
|
||||
if method != "ui.click" {
|
||||
return Ok(3);
|
||||
}
|
||||
const MAX_CLICK_COUNT: u64 = 512;
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
||||
if count == 0 || count > MAX_CLICK_COUNT {
|
||||
return Err(format!("`count` must be between 1 and {MAX_CLICK_COUNT}"));
|
||||
}
|
||||
usize::try_from(count).ok().and_then(|n| n.checked_mul(2)).and_then(|n| n.checked_add(3)).ok_or_else(|| "click event count overflow".to_string())
|
||||
}
|
||||
|
||||
fn click_or_move(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let pos = match point(app, p) {
|
||||
Ok(p) => p,
|
||||
Err(e) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()),
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let m = modifiers(p);
|
||||
// Validate the complete gesture before mutating the queue; the loopback control
|
||||
// channel is externally reachable and must not amplify tiny requests into huge work.
|
||||
const MAX_PENDING_EVENTS: usize = 4096;
|
||||
let pending = app.synthetic.len();
|
||||
let gesture_events = match click_gesture_events(method, p) {
|
||||
Ok(events) => events,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
if pending.checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) {
|
||||
return err("synthetic input queue is full");
|
||||
}
|
||||
// egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event)
|
||||
// and the synthetic events arrive one per frame: hold them for the whole gesture.
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(m));
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
if method == "ui.click" {
|
||||
let button = match s("button") {
|
||||
Some("right") => egui::PointerButton::Secondary,
|
||||
Some("middle") => egui::PointerButton::Middle,
|
||||
_ => egui::PointerButton::Primary,
|
||||
};
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
||||
for _ in 0..count {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
|
||||
}
|
||||
}
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
Outcome::AfterInput
|
||||
}
|
||||
|
||||
fn drag(app: &mut EffectcraftApp, p: &Value) -> Outcome {
|
||||
let (Some(from), Some(to)) = (p.get("from"), p.get("to")) else { return err("need `from` and `to`") };
|
||||
let (a, b) = match (point(app, from), point(app, to)) {
|
||||
(Ok(a), Ok(b)) => (a, b),
|
||||
(Err(e), _) | (_, Err(e)) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()),
|
||||
(Err(e), _) | (_, Err(e)) => return err(e),
|
||||
};
|
||||
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2);
|
||||
const MAX_PENDING_EVENTS: usize = 4096;
|
||||
const MAX_DRAG_STEPS: u64 = 2048;
|
||||
if steps > MAX_DRAG_STEPS {
|
||||
return err(format!("`steps` must be at most {MAX_DRAG_STEPS}"));
|
||||
}
|
||||
let Some(gesture_events) = usize::try_from(steps).ok().and_then(|n| n.checked_add(5)) else { return err("drag event count overflow") };
|
||||
if app.synthetic.len().checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) {
|
||||
return err("synthetic input queue is full");
|
||||
}
|
||||
let m = modifiers(p);
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(m));
|
||||
app.synthetic.push(egui::Event::PointerMoved(a));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
||||
for i in 1..=steps {
|
||||
let f = i as f32 / steps as f32;
|
||||
app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f));
|
||||
}
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
|
||||
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
Outcome::AfterInput
|
||||
}
|
||||
|
||||
fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome {
|
||||
let pos = match point(app, p) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32;
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
app.synthetic.push(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(dx, dy),
|
||||
modifiers: modifiers(p),
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
});
|
||||
app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: egui::pos2(-100.0, -100.0),
|
||||
button: egui::PointerButton::Extra1,
|
||||
pressed: false,
|
||||
modifiers: Default::default(),
|
||||
});
|
||||
Outcome::AfterInput
|
||||
}
|
||||
|
||||
fn key(app: &mut EffectcraftApp, p: &Value) -> Outcome {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let Some(name) = s("key") else { return err("missing `key`") };
|
||||
let Some((mut m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) };
|
||||
let extra = modifiers(p);
|
||||
m |= extra;
|
||||
let physical_key = name.starts_with("Physical+").then_some(key);
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers: m });
|
||||
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: false, repeat: false, modifiers: m });
|
||||
Outcome::AfterInput
|
||||
}
|
||||
|
||||
fn type_text(app: &mut EffectcraftApp, p: &Value) -> Outcome {
|
||||
let Some(text) = p.get("text").and_then(Value::as_str) else { return err("missing `text`") };
|
||||
app.synthetic.push(egui::Event::Text(text.to_string()));
|
||||
app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
|
||||
Outcome::AfterInput
|
||||
}
|
||||
|
||||
fn viewer_locate(ctx: &egui::Context, p: &Value) -> Outcome {
|
||||
let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else {
|
||||
return err("need `x`, `y` (comp pixels)");
|
||||
};
|
||||
match crate::panels::viewer::comp_to_screen(ctx, [x as f32, y as f32]) {
|
||||
Some(pt) => ok(json!({"x": pt.x, "y": pt.y})),
|
||||
None => Outcome::Retry("the composition viewer is not visible".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn viewer_hit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
|
||||
let pos = match point(app, p) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
match crate::panels::viewer::screen_to_comp(ctx, pos) {
|
||||
Some(c) => ok(json!({"x": c[0], "y": c[1]})),
|
||||
None => err("the composition viewer is not visible"),
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline_locate(app: &EffectcraftApp, p: &Value) -> Outcome {
|
||||
let Some(l) = p.get("layer").and_then(Value::as_u64) else { return err("need `layer` (id)") };
|
||||
match crate::panels::timeline::locate(app, l, p.get("time").and_then(Value::as_f64)) {
|
||||
Some((x, y)) => ok(json!({"x": x, "y": y})),
|
||||
None => Outcome::Retry("layer not visible in the timeline".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn playback(app: &mut EffectcraftApp, p: &Value, now: f64) -> Outcome {
|
||||
let action = p.get("action").and_then(Value::as_str).unwrap_or("toggle");
|
||||
match action {
|
||||
"play" => app.play(now),
|
||||
"stop" => app.stop(),
|
||||
"status" => {}
|
||||
_ => app.toggle_play(now),
|
||||
}
|
||||
ok(
|
||||
json!({"playing": app.playback.playing, "time": app.session.time().seconds(), "audio": app.audio.is_some(), "levelsDb": app.meter.level_db, "peaksDb": app.meter.peak_db}),
|
||||
)
|
||||
}
|
||||
|
||||
fn screenshot(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
// The panel/element is an identity, not a rectangle: it is resolved when the capture
|
||||
// is issued (after the UI settles), so a panel that moves meanwhile is not cropped
|
||||
// with its old rectangle.
|
||||
let target = match s("panel") {
|
||||
Some(name) => match PanelKind::from_name(name) {
|
||||
Some(pk) => crate::ShotTarget::Panel(pk),
|
||||
None => return err(format!("unknown panel `{name}`")),
|
||||
},
|
||||
None => s("id").map_or(crate::ShotTarget::Window, |id| crate::ShotTarget::Id(id.to_string())),
|
||||
};
|
||||
// Asking for one element and silently capturing the whole window would hide a missing
|
||||
// panel: wait for it like any other not-yet-laid-out control.
|
||||
let missing = match &target {
|
||||
crate::ShotTarget::Panel(pk) => app.auto.find(&format!("panel.{}", pk.id())).is_none(),
|
||||
crate::ShotTarget::Id(id) => app.auto.find(id).is_none(),
|
||||
crate::ShotTarget::Window | crate::ShotTarget::Rect(_) => false,
|
||||
};
|
||||
if missing {
|
||||
return Outcome::Retry(match &target {
|
||||
crate::ShotTarget::Panel(pk) => format!("the {} panel is not on screen", pk.title()),
|
||||
crate::ShotTarget::Id(id) => format!("no element `{id}` is on screen"),
|
||||
crate::ShotTarget::Window | crate::ShotTarget::Rect(_) => "the element is not on screen".into(),
|
||||
});
|
||||
}
|
||||
// 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), target }
|
||||
}
|
||||
|
||||
fn resize(ctx: &egui::Context, p: &Value) -> Outcome {
|
||||
let w = p.get("width").and_then(Value::as_f64).unwrap_or(1600.0) as f32;
|
||||
let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0) as f32;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
|
||||
ok(Value::Null)
|
||||
}
|
||||
|
||||
fn quit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
|
||||
// A modified project asks to save first unless `force: true`.
|
||||
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
|
||||
app.dialog_state.unsaved.quitting = true;
|
||||
}
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ok(Value::Null)
|
||||
}
|
||||
|
||||
/// `render.frame {comp?, time?, max_side?, path?, base64?}`: render a comp frame through the session
|
||||
/// (independent of the viewer zoom/resolution) and return it as PNG, written to `path` (default a
|
||||
/// temp file) or inline as base64 when `base64: true`.
|
||||
@@ -555,7 +654,8 @@ pub fn base64(data: &[u8]) -> String {
|
||||
}
|
||||
|
||||
pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
|
||||
let size = ctx.content_rect().size();
|
||||
// A logic-only tick refreshes the viewport information but not `InputState`'s screen rect.
|
||||
let size = ctx.input(|i| i.viewport().inner_rect).map_or_else(|| ctx.content_rect().size(), |r| r.size());
|
||||
let keyboard_focus = ctx.memory(|m| m.focused()).map(|id| {
|
||||
let response = ctx.read_response(id);
|
||||
let rect = response.as_ref().map(|r| [r.rect.min.x, r.rect.min.y, r.rect.width(), r.rect.height()]);
|
||||
@@ -588,7 +688,9 @@ pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
|
||||
"selectedKeys": app.session.state.selected_keys.len(),
|
||||
"elements": app.auto.previous.len(),
|
||||
"keyboardFocus": keyboard_focus,
|
||||
"windowFocused": ctx.input(|i| i.focused),
|
||||
// Fresh viewport focus only: in a logic-only tick `InputState::focused` still holds the
|
||||
// previous full pass's value, and the app field is that same fresh value.
|
||||
"windowFocused": ctx.input(|i| i.viewport().focused).unwrap_or(app.focused),
|
||||
"workspaceStorageError": app.workspace_store.error,
|
||||
"dialog": app.dialog.map(|d| format!("{d:?}")),
|
||||
"renderMs": app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0),
|
||||
@@ -596,21 +698,37 @@ pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
|
||||
}
|
||||
|
||||
/// 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();
|
||||
///
|
||||
/// `crop` is relative to the image's own origin (the issued surface's top-left corner), and
|
||||
/// `scale` is the returned image's pixels per point in x and y, derived from the image size and
|
||||
/// the issued rect. The image can be smaller than requested when the adapter-limit fit (E2)
|
||||
/// applies, so its own scale — never the issued or current pixels-per-point — cuts the crop:
|
||||
/// any other scale reads a shifted or rescaled region.
|
||||
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, crop: Option<[f32; 4]>, scale: [f64; 2]) -> Value {
|
||||
let [w, h] = image.size;
|
||||
let [sx, sy] = scale;
|
||||
if w == 0 || h == 0 || !sx.is_finite() || sx <= 0.0 || !sy.is_finite() || sy <= 0.0 {
|
||||
return json!({"ok": false, "error": "the screenshot has no pixels to save"});
|
||||
}
|
||||
if image.pixels.len() != w.saturating_mul(h) {
|
||||
return json!({"ok": false, "error": "the screenshot's pixels do not match its dimensions"});
|
||||
}
|
||||
let px = |v: f64, s: f64| (v * s).max(0.0) as usize;
|
||||
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))
|
||||
Some([x, y, cw, ch]) if [x, y, cw, ch].iter().all(|v| v.is_finite()) => {
|
||||
let x0 = px(x as f64, sx).min(w);
|
||||
let y0 = px(y as f64, sy).min(h);
|
||||
(x0, y0, px(cw as f64, sx).min(w - x0), px(ch as f64, sy).min(h - y0))
|
||||
}
|
||||
Some(_) => return json!({"ok": false, "error": "the screenshot crop is not a finite rectangle"}),
|
||||
None => (0, 0, w, h),
|
||||
};
|
||||
let mut rgba = Vec::with_capacity(cw * ch * 4);
|
||||
let mut rgba = Vec::with_capacity(cw.saturating_mul(ch).saturating_mul(4));
|
||||
for y in y0..y0 + ch {
|
||||
for x in x0..x0 + cw {
|
||||
let c = image.pixels[y * w + x];
|
||||
let Some(c) = image.pixels.get(y * w + x) else {
|
||||
return json!({"ok": false, "error": "the screenshot's pixels do not match its dimensions"});
|
||||
};
|
||||
rgba.extend_from_slice(&[c.r(), c.g(), c.b(), 255]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ impl Default for View {
|
||||
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. Every
|
||||
/// control registers an automation id under `about.credits.`.
|
||||
pub fn contributors_ui(ui: &mut egui::Ui, 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(tr("Show"));
|
||||
|
||||
@@ -1057,7 +1057,7 @@ fn resize_handle(gap: Rect, vertical: bool, path: &str, t: &Tokens) -> ResizeHan
|
||||
let axis = if vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal };
|
||||
let style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus };
|
||||
let label = if vertical { crate::i18n::tr("Height:") } else { crate::i18n::tr("Width:") };
|
||||
ResizeHandle::new(egui::Id::new(("dock-gutter", path.to_string())), axis, gap, label, style)
|
||||
ResizeHandle::new(egui::Id::unique(("dock-gutter", path.to_string())), axis, gap, label, style)
|
||||
}
|
||||
|
||||
/// The least body height of an open stacked panel (when the stack has room for it).
|
||||
@@ -1224,7 +1224,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
|
||||
let w = galley.size().x + 16.0 + menu_w + deco_w;
|
||||
if x + w > strip.max.x - 20.0 && k > ai {
|
||||
let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("tab-overflow", g.path.clone())), Sense::click());
|
||||
icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, t.tab_text);
|
||||
icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, t.tab_text);
|
||||
crate::widgets::focus_outline(ui, &resp, t);
|
||||
@@ -1241,7 +1241,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
|
||||
Some((_, r)) => *r = r.union(tab),
|
||||
None => out.tabs.push((*p, tab)),
|
||||
}
|
||||
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), k)), Sense::click_and_drag());
|
||||
let resp = ui.interact(tab, egui::Id::unique(("tab", g.path.clone(), k)), Sense::click_and_drag());
|
||||
if resp.drag_started() {
|
||||
out.actions.push(DockAction::BeginDrag(*p));
|
||||
}
|
||||
@@ -1259,7 +1259,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
|
||||
let mut dx = tab.min.x + 6.0;
|
||||
if is_active && d.viewer {
|
||||
let cr = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(10.0, 10.0));
|
||||
let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), k)), Sense::click());
|
||||
let cresp = ui.interact(cr.expand(2.0), egui::Id::unique(("tab-close", g.path.clone(), k)), Sense::click());
|
||||
let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
let kk = 3.5;
|
||||
painter.line_segment([cr.center() + vec2(-kk, -kk), cr.center() + vec2(kk, kk)], Stroke::new(1.2, cc));
|
||||
@@ -1277,7 +1277,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
|
||||
label_x = dx;
|
||||
if lock {
|
||||
let lr = Rect::from_center_size(pos2(dx + 6.0, text_y), vec2(12.0, 12.0));
|
||||
let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), k)), Sense::click());
|
||||
let lresp = ui.interact(lr.expand(2.0), egui::Id::unique(("tab-lock", g.path.clone(), k)), Sense::click());
|
||||
let lc = if d.locked {
|
||||
t.tab_text_active
|
||||
} else if lresp.hovered() {
|
||||
@@ -1299,7 +1299,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
|
||||
painter.galley_with_override_text_color(pos2(label_x, text_y - galley.size().y / 2.0), galley, col);
|
||||
if is_active {
|
||||
let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0));
|
||||
let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click());
|
||||
let mresp = ui.interact(mr.expand(3.0), egui::Id::unique(("tab-menu", g.path.clone())), Sense::click());
|
||||
reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu");
|
||||
let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
for dy in [-3.5, 0.0, 3.5] {
|
||||
@@ -1335,7 +1335,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
|
||||
{
|
||||
let rest = Rect::from_min_max(pos2((x - 8.0).max(strip.min.x), strip.min.y), strip.max);
|
||||
if rest.width() > 0.0 {
|
||||
let resp = ui.interact(rest, egui::Id::new(("tab-rest", g.path.clone())), Sense::click());
|
||||
let resp = ui.interact(rest, egui::Id::unique(("tab-rest", g.path.clone())), Sense::click());
|
||||
reg.add(&format!("panel.header.{}", e.panel.id()), rest, &e.label);
|
||||
if resp.clicked() {
|
||||
out.actions.push(DockAction::ToggleStacked(e.panel));
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{EffectcraftApp, panels};
|
||||
use craft_ui::docking::{Action, DropTarget, resolve_drop};
|
||||
|
||||
fn drag_id() -> egui::Id {
|
||||
egui::Id::new("dock-tab-drag")
|
||||
egui::Id::unique("dock-tab-drag")
|
||||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
@@ -183,6 +183,51 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one action after the panel bodies have drawn. A panel-window pass runs this at
|
||||
/// its own pass end so the final IME descriptor sees the post-action state.
|
||||
fn handle_dock_action(&mut self, ctx: &egui::Context, action: DockAction) {
|
||||
match action {
|
||||
DockAction::Activate(p) => {
|
||||
let _ = self.dock_command(ctx, Action::Activate { panel: p });
|
||||
}
|
||||
DockAction::ToggleStacked(p) => {
|
||||
let _ = self.dock_command(ctx, Action::SetStackOpen { panel: p, open: !self.ui.dock.is_visible(p) });
|
||||
}
|
||||
DockAction::Focus(p) => {
|
||||
self.ui.focused = p;
|
||||
panels::viewer_text::panel_clicked(self, ctx, p);
|
||||
// A click in (or on the tab of) another Composition viewer makes it active.
|
||||
if let Some(id) = panels::viewers::id_of(p) {
|
||||
panels::viewers::activate(self, ctx, id);
|
||||
}
|
||||
}
|
||||
DockAction::Close(p) => {
|
||||
let _ = self.dock_command(ctx, Action::Close { panel: p });
|
||||
}
|
||||
DockAction::PanelMenu(p, pos) => {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("panel-menu"), (p, pos)));
|
||||
}
|
||||
DockAction::BeginDrag(p) => ctx.data_mut(|d| {
|
||||
d.insert_temp(drag_id(), p);
|
||||
}),
|
||||
DockAction::ToggleLock(p) => {
|
||||
if !self.ui.locked_tabs.remove(&p.id()) {
|
||||
self.ui.locked_tabs.insert(p.id().to_string());
|
||||
}
|
||||
}
|
||||
// The Timeline's comp tabs: show that comp / close its Timeline.
|
||||
DockAction::ActivateDoc(p, id) => {
|
||||
let _ = self.dock_command(ctx, Action::Activate { panel: p });
|
||||
if self.session.state.active_comp.map(|c| c.0) != Some(id) {
|
||||
let _ = crate::menus::invoke(self, ctx, "comp.open", serde_json::json!({"comp": id}));
|
||||
}
|
||||
}
|
||||
DockAction::CloseDoc(_, id) => {
|
||||
let _ = crate::menus::invoke(self, ctx, "comp.close", serde_json::json!({"comp": id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) {
|
||||
let t = self.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
@@ -244,18 +289,28 @@ impl EffectcraftApp {
|
||||
.get(*index)
|
||||
.and_then(|group| group.panels.first())
|
||||
.and_then(|panel| {
|
||||
let id = egui::Id::new(("floating-panel", panel.id()));
|
||||
let id = egui::Id::unique(("floating-panel", panel.id()));
|
||||
layers.iter().position(|layer| layer.id == id)
|
||||
})
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
// A native window is kept only for a panel that still floats.
|
||||
// A native window is kept only for a panel that still floats. A native viewport that
|
||||
// goes away releases its IME context: its composition must not reappear elsewhere.
|
||||
let ui_state = &mut self.ui;
|
||||
let gone: Vec<egui::ViewportId> = ui_state
|
||||
.panel_windows
|
||||
.iter()
|
||||
.filter(|w| !ui_state.floating.iter().any(|group| group.panels.contains(&w.panel)))
|
||||
.map(|w| panel_window_id(w.panel))
|
||||
.collect();
|
||||
ui_state.panel_windows.retain(|w| ui_state.floating.iter().any(|group| group.panels.contains(&w.panel)));
|
||||
for viewport in gone {
|
||||
panels::viewer_text::end_viewport(self, &ctx, viewport);
|
||||
}
|
||||
for i in floating_order {
|
||||
// Groups in their own native window take no tab drops from the main window.
|
||||
if let Some(window) = self.ui.floating.get(i).and_then(|group| self.panel_window_of(&group.panels)) {
|
||||
self.panel_window(&ctx, i, window, &info, &mut actions);
|
||||
self.panel_window(&ctx, i, window, &info);
|
||||
continue;
|
||||
}
|
||||
if let Some((g, tabs)) = self.floating_window(&ctx, i, &info, &mut actions) {
|
||||
@@ -267,46 +322,7 @@ impl EffectcraftApp {
|
||||
let _ = self.dock_command(&ctx, action);
|
||||
}
|
||||
for a in actions {
|
||||
match a {
|
||||
DockAction::Activate(p) => {
|
||||
let _ = self.dock_command(&ctx, Action::Activate { panel: p });
|
||||
}
|
||||
DockAction::ToggleStacked(p) => {
|
||||
let _ = self.dock_command(&ctx, Action::SetStackOpen { panel: p, open: !self.ui.dock.is_visible(p) });
|
||||
}
|
||||
DockAction::Focus(p) => {
|
||||
self.ui.focused = p;
|
||||
panels::viewer_text::panel_clicked(self, &ctx, p);
|
||||
// A click in (or on the tab of) another Composition viewer makes it active.
|
||||
if let Some(id) = panels::viewers::id_of(p) {
|
||||
panels::viewers::activate(self, &ctx, id);
|
||||
}
|
||||
}
|
||||
DockAction::Close(p) => {
|
||||
let _ = self.dock_command(&ctx, Action::Close { panel: p });
|
||||
}
|
||||
DockAction::PanelMenu(p, pos) => {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("panel-menu"), (p, pos)));
|
||||
}
|
||||
DockAction::BeginDrag(p) => ctx.data_mut(|d| {
|
||||
d.insert_temp(drag_id(), p);
|
||||
}),
|
||||
DockAction::ToggleLock(p) => {
|
||||
if !self.ui.locked_tabs.remove(&p.id()) {
|
||||
self.ui.locked_tabs.insert(p.id().to_string());
|
||||
}
|
||||
}
|
||||
// The Timeline's comp tabs: show that comp / close its Timeline.
|
||||
DockAction::ActivateDoc(p, id) => {
|
||||
let _ = self.dock_command(&ctx, Action::Activate { panel: p });
|
||||
if self.session.state.active_comp.map(|c| c.0) != Some(id) {
|
||||
let _ = crate::menus::invoke(self, &ctx, "comp.open", serde_json::json!({"comp": id}));
|
||||
}
|
||||
}
|
||||
DockAction::CloseDoc(_, id) => {
|
||||
let _ = crate::menus::invoke(self, &ctx, "comp.close", serde_json::json!({"comp": id}));
|
||||
}
|
||||
}
|
||||
self.handle_dock_action(&ctx, a);
|
||||
}
|
||||
self.tab_drag(&ctx, &groups, &float_groups, &tab_rects);
|
||||
panels::panel_menu_popup(self, ui);
|
||||
@@ -330,7 +346,7 @@ impl EffectcraftApp {
|
||||
(screen.min.x - rect.min.x).max(0.0) + (screen.max.x - 40.0 - rect.min.x).min(0.0),
|
||||
(screen.min.y - rect.min.y).max(0.0) + (screen.max.y - 30.0 - rect.min.y).min(0.0),
|
||||
));
|
||||
let id = egui::Id::new(("floating-panel", f.panels.first().map(|p| p.id()).unwrap_or_default()));
|
||||
let id = egui::Id::unique(("floating-panel", f.panels.first().map(|p| p.id()).unwrap_or_default()));
|
||||
let mut group = None;
|
||||
// Not constrained to the window: the group is drawn where its rect says (the clamp above
|
||||
// keeps its strip reachable), so it follows the pointer past the window's edges.
|
||||
@@ -366,7 +382,7 @@ impl EffectcraftApp {
|
||||
// Resize from the corner.
|
||||
let grip = Rect::from_min_max(r.max - vec2(14.0, 14.0), r.max);
|
||||
let rs = ui.interact(grip, id.with("resize"), Sense::click_and_drag());
|
||||
rs.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, rs.enabled(), crate::i18n::tr("Resize floating panel")));
|
||||
rs.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Splitter, rs.enabled(), crate::i18n::tr("Resize floating panel")));
|
||||
self.auto.add_response(&format!("panel.float.{i}.resize"), &rs, "Resize floating panel");
|
||||
for k in 0..3 {
|
||||
let o = 4.0 + k as f32 * 4.0;
|
||||
@@ -456,7 +472,7 @@ impl EffectcraftApp {
|
||||
|
||||
/// One floating group in its own native window: the group's tab strip and active panel fill
|
||||
/// the window. Closing the window brings the group back into the main window.
|
||||
fn panel_window(&mut self, ctx: &egui::Context, i: usize, window: PanelWindow, info: &dock::TabInfo, actions: &mut Vec<DockAction>) {
|
||||
fn panel_window(&mut self, ctx: &egui::Context, i: usize, window: PanelWindow, info: &dock::TabInfo) {
|
||||
let t = self.tokens;
|
||||
let Some(f) = self.ui.floating.get(i).cloned() else { return };
|
||||
let active = f.active.min(f.panels.len().saturating_sub(1));
|
||||
@@ -472,8 +488,14 @@ impl EffectcraftApp {
|
||||
.with_min_inner_size(dock::PANEL_WINDOW_MIN);
|
||||
let mut closed = false;
|
||||
let mut placed = None;
|
||||
let mut pass_viewport = None;
|
||||
let mut actions = Vec::new();
|
||||
ctx.show_viewport_immediate(panel_window_id(window.panel), builder, |ui, class| {
|
||||
pass_viewport = Some(ui.ctx().viewport_id());
|
||||
let native = matches!(class, egui::ViewportClass::Immediate | egui::ViewportClass::Deferred);
|
||||
if native {
|
||||
panels::viewer_text::begin_ui(ui.ctx());
|
||||
}
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| {
|
||||
let r = ui.max_rect();
|
||||
let g = Group {
|
||||
@@ -495,6 +517,12 @@ impl EffectcraftApp {
|
||||
panels::panel_menu_popup(self, ui);
|
||||
}
|
||||
});
|
||||
// Apply this window's panel/tab actions before publishing its IME descriptor: a
|
||||
// click-away or close in the floating panel must own the pass's final output.
|
||||
for action in actions.drain(..) {
|
||||
self.handle_dock_action(ui.ctx(), action);
|
||||
}
|
||||
panels::viewer_text::finish_ime(self, ui);
|
||||
// An embedded window (no native windows on this platform) reads the main window's
|
||||
// input: its close request and place aren't this window's.
|
||||
if native {
|
||||
@@ -509,6 +537,10 @@ impl EffectcraftApp {
|
||||
});
|
||||
if closed {
|
||||
self.ui.panel_windows.retain(|w| !f.panels.contains(&w.panel));
|
||||
if let Some(viewport) = pass_viewport {
|
||||
panels::viewer_text::end_viewport(self, ctx, viewport);
|
||||
}
|
||||
panels::viewer_text::end_viewport(self, ctx, panel_window_id(window.panel));
|
||||
ctx.data_mut(|d| d.remove::<[f32; 4]>(spawn_id));
|
||||
return;
|
||||
}
|
||||
@@ -562,7 +594,7 @@ impl EffectcraftApp {
|
||||
let over_group = targets.iter().any(|target| target.rect.contains(pos));
|
||||
let float_drop = ctx.input(|i| i.modifiers.command);
|
||||
let plan = (!float_drop).then(|| resolve_drop(&p, pos, &targets)).flatten();
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop")));
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("dock-drop")));
|
||||
if let Some(proposal) = &plan {
|
||||
let preview = proposal.preview.shrink(2.0);
|
||||
painter.rect_filled(preview, t.radius, t.focus.gamma_multiply(0.18));
|
||||
@@ -612,7 +644,7 @@ pub fn panel_window_id(panel: PanelKind) -> egui::ViewportId {
|
||||
|
||||
/// Where a native panel window was told to open (kept for as long as it stays open).
|
||||
fn panel_window_spawn_id(panel: PanelKind) -> egui::Id {
|
||||
egui::Id::new(("panel-window-spawn", panel.id()))
|
||||
egui::Id::unique(("panel-window-spawn", panel.id()))
|
||||
}
|
||||
|
||||
/// A new floating group's default rect near the centre of `screen`.
|
||||
|
||||
@@ -26,7 +26,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Brand mark.
|
||||
let brand = Rect::from_min_size(pos2(x, cy - 12.0), vec2(24.0, 24.0));
|
||||
paint_logo(&p, brand);
|
||||
let bresp = ui.interact(brand, egui::Id::new("brand"), Sense::click());
|
||||
let bresp = ui.interact(brand, egui::Id::unique("brand"), Sense::click());
|
||||
app.auto.add_response("header.about", &bresp, "About EffectCraft");
|
||||
widgets::focus_outline(ui, &bresp, &t);
|
||||
if bresp.on_hover_text(tr("About EffectCraft")).clicked() {
|
||||
@@ -36,7 +36,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
|
||||
// Home.
|
||||
let home = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
|
||||
let home_response = widgets::icon_button(ui, home, Icon::Home, app.ui.start_screen, &t, egui::Id::new("tool-home")).on_hover_text(tr("Home"));
|
||||
let home_response = widgets::icon_button(ui, home, Icon::Home, app.ui.start_screen, &t, egui::Id::unique("tool-home")).on_hover_text(tr("Home"));
|
||||
app.auto.add_response("header.home", &home_response, "Home");
|
||||
if home_response.clicked() {
|
||||
app.ui.start_screen = !app.ui.start_screen;
|
||||
@@ -46,7 +46,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
x += 6.0;
|
||||
|
||||
// Tool slots (with group separators after camera tools and pan-behind).
|
||||
let held_id = egui::Id::new("tool-slot-held");
|
||||
let held_id = egui::Id::unique("tool-slot-held");
|
||||
if ui.input(|i| i.pointer.any_pressed()) {
|
||||
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
|
||||
}
|
||||
@@ -54,7 +54,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let cur = app.ui.slot_tools.get(si).copied().unwrap_or(slot[0]);
|
||||
let r = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
|
||||
let active = slot.contains(&app.ui.tool);
|
||||
let id = egui::Id::new(("tool-slot", si));
|
||||
let id = egui::Id::unique(("tool-slot", si));
|
||||
let resp = widgets::icon_button(ui, r, cur.icon(), active, &t, id);
|
||||
if slot.len() > 1 {
|
||||
// Flyout corner triangle.
|
||||
@@ -174,7 +174,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
|
||||
// The engine owns snapping (View ▸ Snapping); the checkbox mirrors it.
|
||||
app.ui.snapping = app.session.state.snapping;
|
||||
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() {
|
||||
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::unique("snapping")).clicked() {
|
||||
let _ = app.session.execute("view.snapping", serde_json::json!({}));
|
||||
app.ui.snapping = app.session.state.snapping;
|
||||
}
|
||||
@@ -188,7 +188,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// text at its left, which left a wide gap on the right (#581).
|
||||
let label = p.layout_no_wrap(tr("Discord").to_string(), Tokens::semibold(12.0), Color32::WHITE);
|
||||
let discord = Rect::from_min_max(pos2(rx - (10.0 + 14.0 + 6.0 + label.size().x + 12.0), cy - 13.0), pos2(rx, cy + 13.0));
|
||||
let dresp = ui.interact(discord, egui::Id::new("hdr-discord"), Sense::click());
|
||||
let dresp = ui.interact(discord, egui::Id::unique("hdr-discord"), Sense::click());
|
||||
let dc = Color32::from_rgb(0x58, 0x65, 0xf2);
|
||||
p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc });
|
||||
icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE);
|
||||
@@ -202,7 +202,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
[("hdr-github", Icon::Code, "EffectCraft on GitHub", "help.github"), ("hdr-web", Icon::Globe, "EffectCraft on getartcraft.com", "help.appPage")]
|
||||
{
|
||||
let r = Rect::from_min_max(pos2(rx - 26.0, cy - 13.0), pos2(rx, cy + 13.0));
|
||||
if widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(id)).on_hover_text(tip).clicked() {
|
||||
if widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique(id)).on_hover_text(tip).clicked() {
|
||||
let _ = app.session.execute(cmd, json!({}));
|
||||
}
|
||||
app.auto.add(&format!("header.{}", &id[4..]), r, tip);
|
||||
@@ -216,7 +216,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
};
|
||||
let tip = tr_args("Appearance Mode: {}", &[&mode]);
|
||||
let r = Rect::from_min_max(pos2(rx - 26.0, cy - 13.0), pos2(rx, cy + 13.0));
|
||||
let response = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new("hdr-appearance")).on_hover_text(&tip);
|
||||
let response = widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique("hdr-appearance")).on_hover_text(&tip);
|
||||
if response.clicked() {
|
||||
app.cycle_appearance(ui.ctx());
|
||||
}
|
||||
@@ -252,17 +252,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
rx -= 10.0;
|
||||
// Workspace tabs (right to left), with a » menu of all.
|
||||
let more = Rect::from_min_max(pos2(rx - 20.0, cy - 11.0), pos2(rx, cy + 11.0));
|
||||
let mresp = ui.interact(more, egui::Id::new("ws-more"), Sense::click());
|
||||
let mresp = ui.interact(more, egui::Id::unique("ws-more"), Sense::click());
|
||||
p.text(more.center(), Align2::CENTER_CENTER, "»", Tokens::ui(15.0), if mresp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add("header.workspaces", more, "Workspaces");
|
||||
crate::widgets::context_menu(&mresp, |ui| ws_menu(app, ui));
|
||||
if mresp.clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("ws-pop"));
|
||||
widgets::open_popup(ui, egui::Id::unique("ws-pop"));
|
||||
}
|
||||
let names: Vec<String> = crate::dock::WORKSPACES.iter().map(|s| crate::i18n::command_label("window.workspace", s).to_string()).collect();
|
||||
if let Some(i) = widgets::popup_menu(
|
||||
ui,
|
||||
egui::Id::new("ws-pop"),
|
||||
egui::Id::unique("ws-pop"),
|
||||
more.left_bottom() - vec2(140.0, 0.0),
|
||||
&names,
|
||||
crate::dock::WORKSPACES.iter().position(|w| *w == app.ui.workspace),
|
||||
@@ -281,7 +281,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
break;
|
||||
}
|
||||
let active = app.ui.workspace == name;
|
||||
let resp = ui.interact(r, egui::Id::new(("ws", name)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("ws", name)), Sense::click());
|
||||
let col = if active {
|
||||
t.hot_text
|
||||
} else if resp.hovered() {
|
||||
@@ -305,8 +305,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
/// Features in Collapsed Compositions and Text Layers (`view.snappingOptions`).
|
||||
fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
let t = app.tokens;
|
||||
let pop = egui::Id::new("snap-options");
|
||||
if widgets::icon_button(ui, r, Icon::ChevronDown, false, &t, egui::Id::new("snap-options-button")).on_hover_text(tr("Snapping options")).clicked() {
|
||||
let pop = egui::Id::unique("snap-options");
|
||||
if widgets::icon_button(ui, r, Icon::ChevronDown, false, &t, egui::Id::unique("snap-options-button")).on_hover_text(tr("Snapping options")).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add("header.snappingOptions", r, "Snapping options");
|
||||
@@ -349,7 +349,7 @@ fn gradient_colors(app: &EffectcraftApp, layers: &[u64], paint: &str) -> Option<
|
||||
|
||||
/// The colour picker of the Fill or Stroke swatch.
|
||||
fn picker_id(key: &str) -> egui::Id {
|
||||
egui::Id::new(("tool-pick", key))
|
||||
egui::Id::unique(("tool-pick", key))
|
||||
}
|
||||
|
||||
/// Tool Creates Shape / Tool Creates Mask: with a shape layer selected, whether the shape tools
|
||||
@@ -359,7 +359,7 @@ fn creates_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, mut x: f32, cy:
|
||||
for (mask, icon, tip, key) in [(false, Icon::ShapeLayer, "Tool Creates Shape", "createsShape"), (true, Icon::MaskVis, "Tool Creates Mask", "createsMask")] {
|
||||
let r = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
|
||||
let on = app.session.state.shape_tool.creates_mask == mask;
|
||||
if widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("tool-creates", key))).on_hover_text(tip).clicked() {
|
||||
if widgets::icon_button(ui, r, icon, on, &t, egui::Id::unique(("tool-creates", key))).on_hover_text(tip).clicked() {
|
||||
tool_options(app, json!({"createsMask": mask}));
|
||||
}
|
||||
app.auto.add(&format!("header.{key}"), r, tip);
|
||||
@@ -388,14 +388,14 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
|
||||
let options = format!("{title} Options");
|
||||
let g = p.layout_no_wrap(format!("{title}:"), Tokens::ui(12.0), t.text_dim);
|
||||
let wr = Rect::from_min_size(pos2(x, cy - g.size().y / 2.0), g.size());
|
||||
let wresp = ui.interact(wr, egui::Id::new(("tool-paint-word", key)), Sense::click()).on_hover_text(&options);
|
||||
let wresp = ui.interact(wr, egui::Id::unique(("tool-paint-word", key)), Sense::click()).on_hover_text(&options);
|
||||
let col = if wresp.hovered() { t.text } else { t.text_dim };
|
||||
if wresp.hovered() {
|
||||
p.line_segment([pos2(wr.min.x, wr.max.y), pos2(wr.max.x - 3.0, wr.max.y)], Stroke::new(1.0, col));
|
||||
}
|
||||
p.galley_with_override_text_color(wr.min, g, col);
|
||||
app.auto.add(&format!("header.{key}Options"), wr, &options);
|
||||
let opts = egui::Id::new(("tool-paint-options", key));
|
||||
let opts = egui::Id::unique(("tool-paint-options", key));
|
||||
if wresp.clicked() {
|
||||
widgets::open_popup(ui, opts);
|
||||
}
|
||||
@@ -403,7 +403,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
|
||||
// the Gradient Editor (the selected shape layer's Gradient Fill / Gradient Stroke), and
|
||||
// opens the options otherwise; Alt-click steps through the kinds (as in After Effects).
|
||||
let sr = Rect::from_min_size(pos2(wr.max.x + 6.0, cy - 8.0), vec2(20.0, 16.0));
|
||||
let sresp = ui.interact(sr, egui::Id::new(("tool-paint-swatch", key)), Sense::click()).on_hover_text(format!("{title}: {}", tp.kind.label()));
|
||||
let sresp = ui.interact(sr, egui::Id::unique(("tool-paint-swatch", key)), Sense::click()).on_hover_text(format!("{title}: {}", tp.kind.label()));
|
||||
paint_swatch(p, sr, &tp, sresp.hovered(), &t);
|
||||
app.auto.add(&format!("header.{key}"), sr, &format!("{title} Color"));
|
||||
if sresp.clicked() {
|
||||
@@ -435,7 +435,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
|
||||
x = sr.max.x + 12.0;
|
||||
}
|
||||
let width = app.session.state.shape_tool.stroke_width;
|
||||
let (r, v, _) = widgets::hot_number_at(ui, pos2(x - 6.0, cy - 9.0), egui::Id::new("tool-stroke-w"), width, 0.2, (0.0, 1000.0), 0, " px", &t);
|
||||
let (r, v, _) = widgets::hot_number_at(ui, pos2(x - 6.0, cy - 9.0), egui::Id::unique("tool-stroke-w"), width, 0.2, (0.0, 1000.0), 0, " px", &t);
|
||||
if let Some(v) = v {
|
||||
tool_options(app, json!({"strokeWidth": v}));
|
||||
paint(app, "strokeWidth", json!(v));
|
||||
@@ -466,7 +466,7 @@ fn paint_kind(app: &mut EffectcraftApp, shape_layers: &[u64], key: &str, kind: P
|
||||
/// Fill Options / Stroke Options: None, Solid Color, Linear Gradient or Radial Gradient, and the
|
||||
/// blend mode and opacity new shapes get (After Effects' dialogs, applied as they change).
|
||||
fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, shape_layers: &[u64], key: &str, title: &str, pos: egui::Pos2) {
|
||||
let id = egui::Id::new(("tool-paint-options", key));
|
||||
let id = egui::Id::unique(("tool-paint-options", key));
|
||||
if !widgets::popup_is_open(ui, id) {
|
||||
return;
|
||||
}
|
||||
@@ -573,7 +573,7 @@ fn roto_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
let label = tr("Diameter:");
|
||||
let lr = p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
|
||||
x = lr.max.x + 6.0;
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t);
|
||||
app.auto.add("header.roto.diameter", r, label);
|
||||
if let Some(nv) = nv
|
||||
&& let Err(e) = app.session.execute("roto.options", json!({key: nv}))
|
||||
@@ -591,7 +591,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
||||
p.text(pos2(x, cy), Align2::LEFT_CENTER, tr("Mesh:"), Tokens::ui(12.0), t.text_dim);
|
||||
x += 40.0;
|
||||
let show = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
|
||||
if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::new("puppet-show-mesh")).clicked() {
|
||||
if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::unique("puppet-show-mesh")).clicked() {
|
||||
let _ = app.session.execute("puppet.mesh", json!({"showMesh": !o.show_mesh}));
|
||||
}
|
||||
app.auto.add("header.puppet.showMesh", show, "Show mesh");
|
||||
@@ -600,7 +600,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
||||
for (label, key, v, range) in [("Expansion:", "expansion", o.expansion, (-100.0, 200.0)), ("Density:", "density", o.density, (0.0, 100.0))] {
|
||||
p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
|
||||
x += if key == "expansion" { 64.0 } else { 52.0 };
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("puppet-opt", key)), v, 0.2, range, 0, "", &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("puppet-opt", key)), v, 0.2, range, 0, "", &t);
|
||||
app.auto.add(&format!("header.puppet.{key}"), r, label);
|
||||
if let Some(nv) = nv {
|
||||
let _ = app.session.execute("puppet.mesh", json!({key: nv, "merge": format!("puppet-opt-{key}")}));
|
||||
@@ -611,7 +611,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
||||
let label = "Record Options...";
|
||||
let w = 104.0;
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(w, 20.0));
|
||||
let resp = ui.interact(r, egui::Id::new("puppet-record-options"), egui::Sense::click()).on_hover_text(tr("⌘/Ctrl-drag a pin to record its motion"));
|
||||
let resp = ui.interact(r, egui::Id::unique("puppet-record-options"), egui::Sense::click()).on_hover_text(tr("⌘/Ctrl-drag a pin to record its motion"));
|
||||
p.rect_stroke(r, 3.0, egui::Stroke::new(1.0, if resp.hovered() { t.accent } else { t.field_border }), egui::StrokeKind::Inside);
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), t.text);
|
||||
app.auto.add("header.puppet.recordOptions", r, label);
|
||||
@@ -625,7 +625,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
||||
let label = "Follow-Through...";
|
||||
let r = Rect::from_min_size(pos2(r.max.x + 8.0, cy - 10.0), vec2(w, 20.0));
|
||||
let resp = ui
|
||||
.interact(r, egui::Id::new("puppet-follow"), egui::Sense::click())
|
||||
.interact(r, egui::Id::unique("puppet-follow"), egui::Sense::click())
|
||||
.on_hover_text(tr("Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)"));
|
||||
p.rect_stroke(r, 3.0, egui::Stroke::new(1.0, if resp.hovered() { t.accent } else { t.field_border }), egui::StrokeKind::Inside);
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), t.text);
|
||||
@@ -668,7 +668,7 @@ static ARTCRAFT_MARK: &[u8] = include_bytes!("../../../docs/brand/artcraft-mark.
|
||||
/// 24 px Tools bar to the About dialog). Nothing is drawn if it can't be decoded.
|
||||
pub fn paint_logo(p: &egui::Painter, r: Rect) {
|
||||
let ctx = p.ctx();
|
||||
let id = egui::Id::new("artcraft-mark");
|
||||
let id = egui::Id::unique("artcraft-mark");
|
||||
let tex = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)).unwrap_or_else(|| {
|
||||
let tex = image::load_from_memory_with_format(ARTCRAFT_MARK, image::ImageFormat::Png).ok().map(|img| {
|
||||
let img = img.to_rgba8();
|
||||
|
||||
+1501
-283
File diff suppressed because it is too large
Load Diff
@@ -61,16 +61,16 @@ struct KeyOpened {
|
||||
}
|
||||
|
||||
fn pending_id() -> Id {
|
||||
Id::new("menu-keys-first")
|
||||
Id::unique("menu-keys-first")
|
||||
}
|
||||
fn opened_id() -> Id {
|
||||
Id::new("menu-keys-opened")
|
||||
Id::unique("menu-keys-opened")
|
||||
}
|
||||
fn active_id() -> Id {
|
||||
Id::new("menu-keys-active")
|
||||
Id::unique("menu-keys-active")
|
||||
}
|
||||
fn alt_id() -> Id {
|
||||
Id::new("menu-keys-alt")
|
||||
Id::unique("menu-keys-alt")
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
@@ -118,7 +118,7 @@ impl Nav {
|
||||
}
|
||||
let Some(level) = self.levels.get_mut(depth) else { return };
|
||||
if level.menu.is_none() {
|
||||
level.menu = Some(find_menu_root(ui).id);
|
||||
level.menu = Some(find_menu_root(ui).scope_id);
|
||||
level.sizing = ui.is_sizing_pass();
|
||||
}
|
||||
level.rows.push(Row { id: r.id, enabled: r.enabled(), submenu });
|
||||
|
||||
@@ -367,7 +367,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
return invoke(app, ctx, "project.delete", json!({}));
|
||||
}
|
||||
if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::RenderQueue {
|
||||
let selected = ctx.data(|d| d.get_temp::<(Vec<u64>, Option<u64>)>(egui::Id::new("rq-ui"))).and_then(|(_, id)| id);
|
||||
let selected = ctx.data(|d| d.get_temp::<(Vec<u64>, Option<u64>)>(egui::Id::unique("rq-ui"))).and_then(|(_, id)| id);
|
||||
return match selected {
|
||||
Some(item) => run_engine(app, ctx, "renderQueue.remove", json!({"item": item})),
|
||||
None => Err("select a Render Queue item first".into()),
|
||||
@@ -381,7 +381,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
}
|
||||
return crate::panels::delete_items::delete_confirmed(app, ctx, json!({}));
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
let now = app.now();
|
||||
// Closing a modified project asks to save it first; the command runs once answered.
|
||||
if crate::panels::unsaved::guard(app, id, ¶ms) {
|
||||
return Ok(json!({"dialog": "unsavedChanges"}));
|
||||
@@ -915,7 +915,7 @@ fn toggle(slot: &mut bool, p: &Value) -> Value {
|
||||
|
||||
/// Perform a frontend command (from `Event::Frontend`).
|
||||
pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Value) -> Result<Value, String> {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let now = app.now();
|
||||
let v = &mut app.ui.viewer;
|
||||
Ok(match id {
|
||||
"mediaBrowser.copySourcePath" => {
|
||||
@@ -1244,7 +1244,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
"view.lookAt" => {
|
||||
let r: Vec<f64> = p.get("rect").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
if r.len() == 4 {
|
||||
let map: Option<crate::panels::viewer::ViewerMap> = ctx.data(|d| d.get_temp(egui::Id::new("viewer-map")));
|
||||
let map: Option<crate::panels::viewer::ViewerMap> = ctx.data(|d| d.get_temp(egui::Id::unique("viewer-map")));
|
||||
if let Some(m) = map {
|
||||
let (rw, rh) = (((r[2] - r[0]) as f32).max(1.0), ((r[3] - r[1]) as f32).max(1.0));
|
||||
let zoom = ((m.area.width() - 40.0) / rw).min((m.area.height() - 40.0) / rh).clamp(0.015, 32.0);
|
||||
@@ -1820,17 +1820,17 @@ pub fn forwardable_during_text_edit(mods: egui::Modifiers, key: egui::Key) -> bo
|
||||
/// run the Spacebar shortcut.
|
||||
/// The system clipboard's text of this frame's paste event, for the Edit ▸ Paste it runs.
|
||||
fn system_paste_id() -> egui::Id {
|
||||
egui::Id::new("menus-system-paste")
|
||||
egui::Id::unique("menus-system-paste")
|
||||
}
|
||||
|
||||
fn space_tap_id() -> egui::Id {
|
||||
egui::Id::new("spacebar-tap")
|
||||
egui::Id::unique("spacebar-tap")
|
||||
}
|
||||
|
||||
/// The modifiers held when this frame's input began: the state the last pass ended with, if it
|
||||
/// ran this (egui keeps only the state after the frame's events). Records this pass's state.
|
||||
fn modifiers_at_start(ctx: &egui::Context) -> Option<egui::Modifiers> {
|
||||
let id = egui::Id::new("shortcut-modifiers");
|
||||
let id = egui::Id::unique("shortcut-modifiers");
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
let now = ctx.input(|i| i.modifiers);
|
||||
ctx.data_mut(|d| {
|
||||
@@ -2066,12 +2066,12 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
/// The id under which [`menu_bar`] remembers which of its menus was open at the end of the last
|
||||
/// frame (its title's open look, and the hover that switches between the titles).
|
||||
fn open_menu_id() -> egui::Id {
|
||||
egui::Id::new("menu-bar-open")
|
||||
egui::Id::unique("menu-bar-open")
|
||||
}
|
||||
|
||||
/// The id under which [`menu_bar`] remembers a title press still in progress (press-and-browse).
|
||||
fn browse_menu_id() -> egui::Id {
|
||||
egui::Id::new("menu-bar-browse")
|
||||
egui::Id::unique("menu-bar-browse")
|
||||
}
|
||||
|
||||
/// Draw the in-window menu bar (the engine's AE menu tree).
|
||||
|
||||
@@ -22,7 +22,7 @@ fn label(p: &egui::Painter, x: f32, y: f32, s: &str, t: &Tokens) {
|
||||
/// A dropdown with options; returns the new index when changed.
|
||||
fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, options: &[&str], cur: usize) -> Option<usize> {
|
||||
let t = app.tokens;
|
||||
let id = egui::Id::new(("animtool-dd", auto));
|
||||
let id = egui::Id::unique(("animtool-dd", auto));
|
||||
if widgets::dropdown(ui, r, options.get(cur).copied().unwrap_or(""), &t, id).clicked() {
|
||||
widgets::open_popup(ui, id);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, opti
|
||||
|
||||
fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 {
|
||||
let t = app.tokens;
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::new(("animtool-n", auto)), v, speed, range, dec, suffix, &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("animtool-n", auto)), v, speed, range, dec, suffix, &t);
|
||||
app.auto.add(auto, r, &format!("{v}"));
|
||||
nv.unwrap_or(v)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &st
|
||||
fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("animtool-cb", auto))).clicked();
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("animtool-cb", auto))).clicked();
|
||||
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
|
||||
app.auto.add(auto, r, text);
|
||||
if clicked { !on } else { on }
|
||||
@@ -109,7 +109,7 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.ui.anim_tools = st.clone();
|
||||
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
|
||||
let enabled = app.session.state.selected_keys.len() >= 2;
|
||||
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("wg-apply")).clicked() {
|
||||
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("wg-apply")).clicked() {
|
||||
let params = json!({
|
||||
"apply": if st.wiggle_spatial { "spatial" } else { "temporal" },
|
||||
"noise": if st.wiggle_smooth { "smooth" } else { "jagged" },
|
||||
@@ -150,7 +150,7 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
y += ROW + 8.0;
|
||||
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
|
||||
let enabled = app.session.state.selected_keys.len() >= 3;
|
||||
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("sm-apply")).clicked() {
|
||||
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("sm-apply")).clicked() {
|
||||
apply(app, "keys.smooth", json!({"tolerance": tol}));
|
||||
}
|
||||
app.auto.add("smoother.apply", b, "Apply");
|
||||
@@ -196,7 +196,7 @@ pub fn motion_sketch(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let b = Rect::from_min_size(pos2(x0, y), vec2(120.0, 24.0));
|
||||
let armed = st.sketch_armed;
|
||||
let has_layer = !app.session.state.selected_layers.is_empty();
|
||||
if widgets::text_button(ui, b, if armed { "Cancel Capture" } else { "Start Capture" }, has_layer && !armed, &t, egui::Id::new("ms-start")).clicked() {
|
||||
if widgets::text_button(ui, b, if armed { "Cancel Capture" } else { "Start Capture" }, has_layer && !armed, &t, egui::Id::unique("ms-start")).clicked() {
|
||||
if !has_layer {
|
||||
app.ui.status = "Select a layer to sketch its motion".into();
|
||||
} else {
|
||||
@@ -219,7 +219,7 @@ struct Capture {
|
||||
}
|
||||
|
||||
fn capture_id() -> egui::Id {
|
||||
egui::Id::new("motion-sketch-capture")
|
||||
egui::Id::unique("motion-sketch-capture")
|
||||
}
|
||||
|
||||
/// The Motion Sketch overlay on the Composition viewer (`area`): while armed, a press starts
|
||||
@@ -230,9 +230,9 @@ pub fn sketch_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(map) = ctx.data(|d| d.get_temp::<crate::panels::viewer::ViewerMap>(egui::Id::new("viewer-map"))) else { return };
|
||||
let Some(map) = ctx.data(|d| d.get_temp::<crate::panels::viewer::ViewerMap>(egui::Id::unique("viewer-map"))) else { return };
|
||||
let area = map.area;
|
||||
let resp = ui.interact(area, egui::Id::new("motion-sketch-overlay"), Sense::drag());
|
||||
let resp = ui.interact(area, egui::Id::unique("motion-sketch-overlay"), Sense::drag());
|
||||
app.auto.add("motionSketch.overlay", area, "Motion Sketch capture area");
|
||||
let now = ctx.input(|i| i.time);
|
||||
let painter = ui.painter().with_clip_rect(area);
|
||||
@@ -398,7 +398,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
|
||||
}
|
||||
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
|
||||
let enabled = app.session.state.selected_keys.len() >= 2;
|
||||
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("mi-apply")).clicked() {
|
||||
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("mi-apply")).clicked() {
|
||||
match app.session.execute("mask.interpolate", json!({})) {
|
||||
Ok(r) => app.ui.status = format!("Mask Interpolation: {} keyframes added ({} vertices)", r["keys"], r["vertices"]),
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
|
||||
@@ -217,11 +217,11 @@ pub fn viewer_hook(
|
||||
// Interaction over the image.
|
||||
// While Space (Hand tool) is held the overlay only hovers, so the viewer underneath gets the pan drag.
|
||||
let sense = if super::space_hand(&ctx) { Sense::hover() } else { Sense::click_and_drag() };
|
||||
let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), sense);
|
||||
let resp = ui.interact(map.area, egui::Id::unique("viewer-camera-tracker"), sense);
|
||||
app.auto.add("viewer.cameraTracker", map.area, "3D Camera Tracker points");
|
||||
// The overlay is on top of the viewer, so a middle-button drag lands here: pan the viewer like the Hand tool.
|
||||
if resp.dragged_by(egui::PointerButton::Middle) {
|
||||
let pid = egui::Id::new("viewer-camera-tracker-pan");
|
||||
let pid = egui::Id::unique("viewer-camera-tracker-pan");
|
||||
if resp.drag_started() {
|
||||
let start_pan = app.ui.viewer.pan;
|
||||
ui.data_mut(|d| d.insert_temp(pid, start_pan));
|
||||
@@ -237,7 +237,7 @@ pub fn viewer_hook(
|
||||
let hover = resp.hover_pos();
|
||||
let point_at = |p: Pos2| hits.iter().filter(|h| h.1.distance(p) <= h.2 + 3.0).min_by(|x, y| x.1.distance(p).total_cmp(&y.1.distance(p))).map(|h| h.0);
|
||||
let tri = hover.filter(|p| point_at(*p).is_none()).and_then(|p| triangle_at(&hits, p));
|
||||
let mid = egui::Id::new("viewer-camera-tracker-marquee");
|
||||
let mid = egui::Id::unique("viewer-camera-tracker-marquee");
|
||||
let marquee: Option<Pos2> = ui.data(|d| d.get_temp(mid));
|
||||
// Hover target / the selection's target.
|
||||
if marquee.is_none() {
|
||||
@@ -284,7 +284,7 @@ pub fn viewer_hook(
|
||||
}
|
||||
}
|
||||
// Right-click: the points under the cursor (selection, else the hovered triangle).
|
||||
let cid = egui::Id::new("viewer-camera-tracker-menu-points");
|
||||
let cid = egui::Id::unique("viewer-camera-tracker-menu-points");
|
||||
if resp.secondary_clicked() {
|
||||
let pts: Vec<u32> = if !selected.is_empty() {
|
||||
selected.clone()
|
||||
@@ -413,12 +413,12 @@ pub fn editor(
|
||||
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
|
||||
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
|
||||
let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0));
|
||||
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::new(("ct-analyze", g.uid))).clicked() && !busy {
|
||||
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy {
|
||||
actions.push(("camera.analyze".into(), me.clone()));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze");
|
||||
if running {
|
||||
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::new(("ct-cancel", g.uid))).clicked() {
|
||||
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() {
|
||||
actions.push(("camera.cancel".into(), json!({})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel");
|
||||
@@ -426,7 +426,7 @@ pub fn editor(
|
||||
let params = effectcraft_engine::camera_track::static_params(g);
|
||||
let solve = ct::solve(¶ms);
|
||||
if solve.is_some() {
|
||||
if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::new(("ct-camera", g.uid))).clicked() {
|
||||
if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() {
|
||||
actions.push(("camera.create".into(), me.clone()));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera");
|
||||
|
||||
@@ -182,7 +182,7 @@ fn timecode_field(ui: &mut egui::Ui, buf: &mut Option<String>, secs: &mut f64, r
|
||||
}
|
||||
|
||||
fn name_focus_id() -> egui::Id {
|
||||
egui::Id::new("comp-preset-name-focus")
|
||||
egui::Id::unique("comp-preset-name-focus")
|
||||
}
|
||||
|
||||
/// Save Preset's Choose Name prompt (drawn in its own modal over the dialog while
|
||||
@@ -302,7 +302,7 @@ pub fn show(
|
||||
});
|
||||
auto.add("dialog.comp.preset", r.response.rect, "Preset");
|
||||
let (save_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover());
|
||||
let save = crate::widgets::icon_button(ui, save_r, Icon::Plus, d.preset_name.is_some(), t, egui::Id::new("comp-preset-save"))
|
||||
let save = crate::widgets::icon_button(ui, save_r, Icon::Plus, d.preset_name.is_some(), t, egui::Id::unique("comp-preset-save"))
|
||||
.on_hover_text(tr("Save Preset…"));
|
||||
auto.add("dialog.comp.presetSave", save_r, "Save Preset");
|
||||
if save.clicked() {
|
||||
@@ -311,7 +311,7 @@ pub fn show(
|
||||
}
|
||||
if let Some(q) = own {
|
||||
let (del_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover());
|
||||
let del = crate::widgets::icon_button(ui, del_r, Icon::Trash, false, t, egui::Id::new("comp-preset-delete"))
|
||||
let del = crate::widgets::icon_button(ui, del_r, Icon::Trash, false, t, egui::Id::unique("comp-preset-delete"))
|
||||
.on_hover_text(tr("Delete Preset"));
|
||||
auto.add("dialog.comp.presetDelete", del_r, "Delete Preset");
|
||||
if del.clicked() {
|
||||
|
||||
@@ -191,7 +191,7 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok
|
||||
/// window.
|
||||
pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) {
|
||||
let size = size.min(modal_space(ctx));
|
||||
let id = egui::Id::new(("modal", title));
|
||||
let id = egui::Id::unique(("modal", title));
|
||||
// Dragging the title row moves the dialog off what it edits. The offset is kept within the
|
||||
// window, so dragging back never has to undo a part the dialog could not follow.
|
||||
let offset_id = id.with("offset");
|
||||
@@ -276,7 +276,7 @@ const CREDITS_HEIGHT: f32 = 380.0;
|
||||
fn about(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut close = false;
|
||||
let mut cmd: Option<&str> = None;
|
||||
let tab_id = egui::Id::new("about_tab");
|
||||
let tab_id = egui::Id::unique("about_tab");
|
||||
modal(ctx, "About EffectCraft", vec2(680.0, 520.0), t, |ui| {
|
||||
let mut tab = ui.data_mut(|d| d.get_temp::<usize>(tab_id)).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -479,7 +479,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
(ds.solid_name.clone(), ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all);
|
||||
let (editing, shared) = (ds.solid_layer, ds.solid_shared);
|
||||
let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080));
|
||||
let lock_id = egui::Id::new("solid-lock-aspect");
|
||||
let lock_id = egui::Id::unique("solid-lock-aspect");
|
||||
let mut lock = ctx.data(|m| m.get_temp::<bool>(lock_id)).unwrap_or(true);
|
||||
let auto = &mut app.auto;
|
||||
modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| {
|
||||
@@ -630,6 +630,8 @@ fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
.collect();
|
||||
modal(ctx, "Command Palette", vec2(560.0, 470.0), t, |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Type a command or effect…")).desired_width(f32::INFINITY).font(Tokens::ui(14.0)));
|
||||
// A placeholder disappears once typing starts; keep the field named for assistive input.
|
||||
ctx.accesskit_node_builder(r.id, |node| node.set_label(tr("Type a command or effect…")));
|
||||
// Take the keyboard when the palette opens (and if anything else takes it away), but not
|
||||
// while the field already has it: egui's `request_focus` interrupts the IME composition,
|
||||
// and asking on every frame cancelled the pinyin being typed, so no Chinese could be
|
||||
@@ -748,7 +750,7 @@ mod tests {
|
||||
assert_eq!(app.dialog_state.palette_query, "nihao");
|
||||
// Something else taking the keyboard takes it away for a frame; the field claims it back
|
||||
// (a request is what gets the keys back, and there is no composition to interrupt then).
|
||||
ctx.memory_mut(|m| m.request_focus(egui::Id::new("elsewhere")));
|
||||
ctx.memory_mut(|m| m.request_focus(egui::Id::unique("elsewhere")));
|
||||
assert!(frame_with(&mut app, &ctx, vec![]).platform_output.ime.is_none(), "the field lost the keyboard");
|
||||
assert!(frame_with(&mut app, &ctx, vec![]).platform_output.ime.is_some(), "and takes it back");
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ fn key_navigator(
|
||||
let on_key = prop.keys.iter().any(|k| (layer.comp_time(k.time).0 - ectx.time.0).abs() <= half);
|
||||
for (what, dx) in [("prev", -44.0), ("key", -32.0), ("next", -20.0)] {
|
||||
let cr = Rect::from_center_size(pos2(r.max.x + dx, cy), vec2(12.0, 16.0));
|
||||
let resp = ui.interact(cr, egui::Id::new(("ec-nav", uid, what)), Sense::click());
|
||||
let resp = ui.interact(cr, egui::Id::unique(("ec-nav", uid, what)), Sense::click());
|
||||
let col = if resp.hovered() { t.text } else { t.text_dim };
|
||||
match what {
|
||||
"prev" => triangle(p, cr.center(), true, col),
|
||||
@@ -228,7 +228,7 @@ fn prop_row(
|
||||
}
|
||||
let swr = Rect::from_center_size(pos2(r.min.x + indent, cy), vec2(14.0, 14.0));
|
||||
if !prop.static_only && !matches!(prop.ui, ParamUi::Hidden) {
|
||||
let resp = ui.interact(swr, egui::Id::new(("ec-sw", uid)), Sense::click());
|
||||
let resp = ui.interact(swr, egui::Id::unique(("ec-sw", uid)), Sense::click());
|
||||
icons::paint(
|
||||
p,
|
||||
swr,
|
||||
@@ -272,7 +272,7 @@ fn prop_row(
|
||||
let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(vx - 6.0, r.max.y));
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
|
||||
p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text);
|
||||
let name_response = ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click());
|
||||
let name_response = ui.interact(name_clip, egui::Id::unique(("ec-prop-name", uid)), Sense::click());
|
||||
if name_response.double_clicked() {
|
||||
app.ui.timeline.search.clear();
|
||||
app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Property(uid));
|
||||
@@ -291,7 +291,7 @@ fn prop_row(
|
||||
let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| {
|
||||
let open = app.ui.fx_slider_open.contains(&uid);
|
||||
let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0));
|
||||
if widgets::twirl(ui, tw, open, egui::Id::new(("ec-stw", uid)), &t).clicked() {
|
||||
if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-stw", uid)), &t).clicked() {
|
||||
if open {
|
||||
app.ui.fx_slider_open.remove(&uid);
|
||||
} else {
|
||||
@@ -310,8 +310,8 @@ fn prop_row(
|
||||
let i = v.round().max(0.0) as usize;
|
||||
let label = opts.get(i).cloned().unwrap_or_else(|| format!("Mask {i}"));
|
||||
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0));
|
||||
let pop = egui::Id::new(("ec-mpop", uid));
|
||||
if widgets::dropdown(ui, dr, &label, &t, egui::Id::new(("ec-m", uid))).clicked() {
|
||||
let pop = egui::Id::unique(("ec-mpop", uid));
|
||||
if widgets::dropdown(ui, dr, &label, &t, egui::Id::unique(("ec-m", uid))).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
|
||||
@@ -320,7 +320,7 @@ fn prop_row(
|
||||
}
|
||||
}
|
||||
Value::Scalar(v) if matches!(prop.ui, ParamUi::Angle) => {
|
||||
let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("ec-v", uid)), *v, 1, &t);
|
||||
let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t);
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1));
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name);
|
||||
if let Some(nv) = nv {
|
||||
@@ -341,7 +341,7 @@ fn prop_row(
|
||||
let (vr, nv, _) = widgets::hot_number_at(
|
||||
ui,
|
||||
pos2(vx, cy - 9.0),
|
||||
egui::Id::new(("ec-v", uid)),
|
||||
egui::Id::unique(("ec-v", uid)),
|
||||
*v,
|
||||
speed,
|
||||
(min, max),
|
||||
@@ -365,7 +365,7 @@ fn prop_row(
|
||||
if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) {
|
||||
let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0));
|
||||
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "point");
|
||||
if fw::crosshair_button(ui, cr, active, egui::Id::new(("ec-xh", uid)), &t)
|
||||
if fw::crosshair_button(ui, cr, active, egui::Id::unique(("ec-xh", uid)), &t)
|
||||
.on_hover_text(crate::i18n::tr("Click, then click in the Composition panel"))
|
||||
.clicked()
|
||||
{
|
||||
@@ -375,7 +375,7 @@ fn prop_row(
|
||||
x += 22.0;
|
||||
}
|
||||
for d in 0..n.min(c.len()) {
|
||||
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t);
|
||||
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t);
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name);
|
||||
dims.push(vr);
|
||||
if let Some(nv) = nv {
|
||||
@@ -393,14 +393,14 @@ fn prop_row(
|
||||
}
|
||||
Value::Color(c) => {
|
||||
let sr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(30.0, 16.0));
|
||||
let pop = egui::Id::new(("ec-cpop", uid));
|
||||
if widgets::swatch(ui, sr, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::new(("ec-c", uid)), &t).clicked() {
|
||||
let pop = egui::Id::unique(("ec-cpop", uid));
|
||||
if widgets::swatch(ui, sr, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("ec-c", uid)), &t).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name);
|
||||
let er = Rect::from_center_size(pos2(sr.max.x + 14.0, cy), vec2(18.0, 18.0));
|
||||
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
|
||||
if fw::eyedropper_button(ui, er, active, egui::Id::new(("ec-eye", uid)), &t)
|
||||
if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t)
|
||||
.on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
|
||||
.clicked()
|
||||
{
|
||||
@@ -414,7 +414,7 @@ fn prop_row(
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0));
|
||||
if widgets::checkbox(ui, cr, *b, &t, egui::Id::new(("ec-b", uid))).clicked() {
|
||||
if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).clicked() {
|
||||
set(actions, json!(!b));
|
||||
}
|
||||
p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim);
|
||||
@@ -424,8 +424,8 @@ fn prop_row(
|
||||
if let ParamUi::Popup { options } = &prop.ui {
|
||||
let widest = options.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max);
|
||||
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0));
|
||||
let pop = egui::Id::new(("ec-epop", uid));
|
||||
if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::new(("ec-e", uid))).clicked() {
|
||||
let pop = egui::Id::unique(("ec-epop", uid));
|
||||
if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-e", uid))).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
|
||||
@@ -438,7 +438,7 @@ fn prop_row(
|
||||
let comp = app.session.active_comp_arc();
|
||||
if let Some(comp) = comp {
|
||||
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0));
|
||||
if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::new(("ec-l", uid))) {
|
||||
if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::unique(("ec-l", uid))) {
|
||||
set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
|
||||
@@ -447,8 +447,8 @@ fn prop_row(
|
||||
let si = src.value.as_enum() as usize;
|
||||
let opts: Vec<String> = effectcraft_engine::effects::LAYER_SOURCE_OPTIONS.iter().map(|s| s.to_string()).collect();
|
||||
let sr = Rect::from_min_size(pos2(dr.max.x + 6.0, cy - 9.0), vec2(((r.max.x - dr.max.x) - 64.0).clamp(70.0, 120.0), 18.0));
|
||||
let spop = egui::Id::new(("ec-lspop", uid));
|
||||
if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::new(("ec-ls", uid))).clicked() {
|
||||
let spop = egui::Id::unique(("ec-lspop", uid));
|
||||
if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-ls", uid))).clicked() {
|
||||
widgets::open_popup(ui, spop);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.source"), sr, &src.name);
|
||||
@@ -468,7 +468,7 @@ fn prop_row(
|
||||
Value::Str(s) if matches!(prop.ui, ParamUi::File { .. }) => {
|
||||
let bw = 70.0;
|
||||
let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0));
|
||||
if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::new(("ec-file", uid))).clicked() {
|
||||
if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::unique(("ec-file", uid))).clicked() {
|
||||
let filters: Vec<&str> = match &prop.ui {
|
||||
ParamUi::File { filters } => filters.iter().map(String::as_str).collect(),
|
||||
_ => vec![],
|
||||
@@ -493,7 +493,7 @@ fn prop_row(
|
||||
let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y));
|
||||
let np = p.with_clip_rect(nr.intersect(p.clip_rect()));
|
||||
np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text });
|
||||
let hover = ui.interact(nr, egui::Id::new(("ec-file-name", uid)), Sense::hover());
|
||||
let hover = ui.interact(nr, egui::Id::unique(("ec-file-name", uid)), Sense::hover());
|
||||
if !s.trim().is_empty() && !s.contains('\n') {
|
||||
hover.on_hover_text(s.as_str());
|
||||
}
|
||||
@@ -503,9 +503,11 @@ fn prop_row(
|
||||
// typed in a field, set when it loses the focus (Esc keeps the old text).
|
||||
Value::Str(s) if matches!(prop.ui, ParamUi::Text) => {
|
||||
let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0));
|
||||
let bid = egui::Id::new(("ec-text", uid));
|
||||
let bid = egui::Id::unique(("ec-text", uid));
|
||||
let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.clone());
|
||||
let resp = widgets::text_field(ui, fr, &mut buf, "", &t);
|
||||
// The field has no visible hint: give the screen reader the parameter's name.
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name));
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name);
|
||||
if resp.lost_focus() {
|
||||
if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
@@ -586,7 +588,7 @@ fn slider_row(
|
||||
let dec = decimals as usize;
|
||||
p.text(pos2(tr.min.x, tr.max.y + 4.0), Align2::LEFT_TOP, format!("{slider_min:.dec$}"), Tokens::ui(10.5), t.text_dim);
|
||||
p.text(pos2(tr.max.x, tr.max.y + 4.0), Align2::RIGHT_TOP, format!("{slider_max:.dec$}"), Tokens::ui(10.5), t.text_dim);
|
||||
let sresp = ui.interact(tr.expand2(vec2(4.0, 6.0)), egui::Id::new(("ec-slider", uid)), Sense::click_and_drag());
|
||||
let sresp = ui.interact(tr.expand2(vec2(4.0, 6.0)), egui::Id::unique(("ec-slider", uid)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.slider"), tr, &prop.name);
|
||||
if (sresp.dragged() || sresp.clicked())
|
||||
&& let Some(pt) = sresp.interact_pointer_pos()
|
||||
@@ -607,7 +609,7 @@ fn dial_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, layer: &Layer, prop: &P
|
||||
let uid = prop.uid;
|
||||
let d = (r.height() - 12.0).min(52.0);
|
||||
let dr = Rect::from_min_size(pos2(r.min.x + x0 + 40.0, r.min.y + 6.0), vec2(d, d));
|
||||
let id = egui::Id::new(("ec-dial", uid));
|
||||
let id = egui::Id::unique(("ec-dial", uid));
|
||||
let (resp, nv) = fw::angle_dial(ui, dr, v, id, &t);
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.dial"), dr, &prop.name);
|
||||
if let Some(nv) = nv {
|
||||
@@ -726,7 +728,7 @@ fn group_rows(
|
||||
if r.max.y >= rect.min.y && r.min.y <= rect.max.y {
|
||||
app.auto.add(&format!("effectControls.row.{}", sg.uid), r, &sg.name);
|
||||
app.auto.add(&format!("effectControls.group.{}.twirl", sg.uid), tw, &sg.name);
|
||||
if widgets::twirl(ui, tw, open, egui::Id::new(("ec-g", sg.uid)), &t).clicked() {
|
||||
if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-g", sg.uid)), &t).clicked() {
|
||||
toggle_closed(app, sg, open, ui.input(|i| i.modifiers.alt || i.modifiers.command));
|
||||
}
|
||||
let name = p.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &sg.name, Tokens::ui(12.0), t.text);
|
||||
@@ -757,12 +759,12 @@ fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l
|
||||
let busy = app.session.is_warp_analyzing();
|
||||
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
|
||||
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
|
||||
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::new(("warp-analyze", g.uid))).clicked() && !busy {
|
||||
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy {
|
||||
actions.push(("warp.analyze".into(), json!({"layer": layer.id.0, "effect": g.uid})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.warp.{}.analyze", g.uid), b1, "Analyze");
|
||||
if running {
|
||||
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::new(("warp-cancel", g.uid))).clicked() {
|
||||
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() {
|
||||
actions.push(("warp.cancel".into(), json!({})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel");
|
||||
@@ -788,13 +790,13 @@ fn roto_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l
|
||||
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
|
||||
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
|
||||
let label = if frozen { "Unfreeze" } else { "Freeze" };
|
||||
if widgets::text_button(ui, b1, label, frozen, &t, egui::Id::new(("roto-freeze-ec", g.uid))).clicked() && !running {
|
||||
if widgets::text_button(ui, b1, label, frozen, &t, egui::Id::unique(("roto-freeze-ec", g.uid))).clicked() && !running {
|
||||
let id = if frozen { "roto.unfreeze" } else { "roto.freeze" };
|
||||
actions.push((id.into(), json!({"layer": layer.id.0, "effect": g.uid})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.roto.{}.freeze", g.uid), b1, label);
|
||||
let (l2, id2) = if running { ("Stop", "roto.cancel") } else { ("Propagate", "roto.propagate") };
|
||||
if widgets::text_button(ui, b2, l2, false, &t, egui::Id::new(("roto-prop-ec", g.uid))).clicked() && !frozen {
|
||||
if widgets::text_button(ui, b2, l2, false, &t, egui::Id::unique(("roto-prop-ec", g.uid))).clicked() && !frozen {
|
||||
actions.push((id2.into(), json!({"layer": layer.id.0, "effect": g.uid})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.roto.{}.propagate", g.uid), b2, l2);
|
||||
@@ -819,13 +821,13 @@ fn object_matte_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
|
||||
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
|
||||
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
|
||||
let label = if frozen { "Unfreeze" } else { "Freeze" };
|
||||
if widgets::text_button(ui, b1, crate::i18n::tr(label), frozen, &t, egui::Id::new(("om-freeze-ec", g.uid))).clicked() && !busy {
|
||||
if widgets::text_button(ui, b1, crate::i18n::tr(label), frozen, &t, egui::Id::unique(("om-freeze-ec", g.uid))).clicked() && !busy {
|
||||
let id = if frozen { "objectMatte.unfreeze" } else { "objectMatte.freeze" };
|
||||
actions.push((id.into(), json!({"layer": layer.id.0, "effect": g.uid})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.objectMatte.{}.freeze", g.uid), b1, label);
|
||||
let (l2, id2) = if running { ("Stop", "roto.cancel") } else { ("Track", "objectMatte.propagate") };
|
||||
if widgets::text_button(ui, b2, crate::i18n::tr(l2), false, &t, egui::Id::new(("om-track-ec", g.uid))).clicked() && !frozen && (running || !busy) {
|
||||
if widgets::text_button(ui, b2, crate::i18n::tr(l2), false, &t, egui::Id::unique(("om-track-ec", g.uid))).clicked() && !frozen && (running || !busy) {
|
||||
actions.push((id2.into(), json!({"layer": layer.id.0, "effect": g.uid})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.objectMatte.{}.track", g.uid), b2, l2);
|
||||
@@ -869,7 +871,7 @@ fn curves_size(width: f32) -> f32 {
|
||||
fn frame_histogram(app: &mut EffectcraftApp, ctx: &egui::Context) -> Option<std::sync::Arc<[[u32; 256]; 5]>> {
|
||||
let img = app.viewer_pixels()?;
|
||||
let key = std::sync::Arc::as_ptr(&img) as usize;
|
||||
let id = egui::Id::new("ec-histogram");
|
||||
let id = egui::Id::unique("ec-histogram");
|
||||
if let Some((k, h)) = ctx.data(|d| d.get_temp::<(usize, std::sync::Arc<[[u32; 256]; 5]>)>(id))
|
||||
&& k == key
|
||||
{
|
||||
@@ -932,7 +934,8 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
let x0 = r.min.x + 30.0;
|
||||
let cy = r.min.y + 17.0;
|
||||
let names: [&str; 5] = [crate::i18n::tr("RGB"), crate::i18n::tr("Red"), crate::i18n::tr("Green"), crate::i18n::tr("Blue"), crate::i18n::tr("Alpha")];
|
||||
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel")) {
|
||||
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::unique(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel"))
|
||||
{
|
||||
match chan_prop {
|
||||
Some(pr) => actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": n}))),
|
||||
None => {
|
||||
@@ -942,7 +945,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
}
|
||||
let pencil = app.ui.fx_curve_pencil.contains(&euid);
|
||||
let mr = Rect::from_min_size(pos2(x0 + 146.0, cy - 9.0), vec2(56.0, 18.0));
|
||||
if widgets::dropdown(ui, mr, if pencil { "Pencil" } else { "Bezier" }, &t, egui::Id::new(("ec-cmode", euid))).clicked() {
|
||||
if widgets::dropdown(ui, mr, if pencil { "Pencil" } else { "Bezier" }, &t, egui::Id::unique(("ec-cmode", euid))).clicked() {
|
||||
if pencil {
|
||||
app.ui.fx_curve_pencil.remove(&euid);
|
||||
} else {
|
||||
@@ -956,7 +959,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
_ => fw::CurvePoints::identity(),
|
||||
};
|
||||
let rr = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 9.0), vec2(44.0, 18.0));
|
||||
let rresp = ui.interact(rr, egui::Id::new(("ec-creset", euid)), Sense::click());
|
||||
let rresp = ui.interact(rr, egui::Id::unique(("ec-creset", euid)), Sense::click());
|
||||
p.text(rr.center(), Align2::CENTER_CENTER, crate::i18n::tr("Reset"), Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim });
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.curves.reset"), rr, "Reset curve");
|
||||
if rresp.clicked() {
|
||||
@@ -967,7 +970,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
// Input/output gradients along the axes.
|
||||
let col = channel_color(ch);
|
||||
p.rect_filled(Rect::from_min_max(pos2(gr.min.x, gr.max.y + 3.0), pos2(gr.max.x, gr.max.y + 8.0)), 0.0, col.gamma_multiply(0.5));
|
||||
let id = egui::Id::new(("ec-curves", euid, ch));
|
||||
let id = egui::Id::unique(("ec-curves", euid, ch));
|
||||
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, pencil, true, id, &t);
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.curves.graph"), gr, "Curves");
|
||||
let started = resp.drag_started() || resp.clicked();
|
||||
@@ -1021,7 +1024,8 @@ fn levels_editor(
|
||||
};
|
||||
if effect == "ec.color.levelsic" {
|
||||
let names: [&str; 5] = [crate::i18n::tr("RGB"), crate::i18n::tr("Red"), crate::i18n::tr("Green"), crate::i18n::tr("Blue"), crate::i18n::tr("Alpha")];
|
||||
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel"))
|
||||
if let Some(n) =
|
||||
channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::unique(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel"))
|
||||
{
|
||||
app.ui.fx_levels_channel.insert(euid, n);
|
||||
}
|
||||
@@ -1043,24 +1047,24 @@ fn levels_editor(
|
||||
let (ib, iw, gm, ob, ow) = (val(&ids[0]), val(&ids[1]), val(&ids[2]), val(&ids[3]), val(&ids[4]));
|
||||
let mut set = |ui: &egui::Ui, id: &str, v: f64, started: bool| {
|
||||
if let Some(pr) = g.prop(id) {
|
||||
let key = gesture_key(ui, egui::Id::new(("ec-lv", euid, id)), started);
|
||||
let key = gesture_key(ui, egui::Id::unique(("ec-lv", euid, id)), started);
|
||||
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": v, "merge": key})));
|
||||
}
|
||||
};
|
||||
let track = Rect::from_min_size(pos2(hr.min.x, hr.max.y + 1.0), vec2(w, 10.0));
|
||||
let started = |ui: &egui::Ui| ui.input(|i| i.pointer.any_pressed());
|
||||
let (r0, nv) = fw::level_handle(ui, track, ib, Color32::BLACK, egui::Id::new(("ec-lib", euid, ch)), &t);
|
||||
let (r0, nv) = fw::level_handle(ui, track, ib, Color32::BLACK, egui::Id::unique(("ec-lib", euid, ch)), &t);
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.levels.inBlack"), r0, "Input Black");
|
||||
if let Some(v) = nv {
|
||||
set(ui, &ids[0], v.min(iw - 0.005), started(ui));
|
||||
}
|
||||
let gx = fw::gamma_handle(ib, iw, gm);
|
||||
let (r1, nv) = fw::level_handle(ui, track, gx, Color32::from_gray(128), egui::Id::new(("ec-lg", euid, ch)), &t);
|
||||
let (r1, nv) = fw::level_handle(ui, track, gx, Color32::from_gray(128), egui::Id::unique(("ec-lg", euid, ch)), &t);
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.levels.gamma"), r1, "Gamma");
|
||||
if let Some(v) = nv {
|
||||
set(ui, &ids[2], (fw::gamma_from_handle(ib, iw, v) * 100.0).round() / 100.0, started(ui));
|
||||
}
|
||||
let (r2, nv) = fw::level_handle(ui, track, iw, Color32::WHITE, egui::Id::new(("ec-liw", euid, ch)), &t);
|
||||
let (r2, nv) = fw::level_handle(ui, track, iw, Color32::WHITE, egui::Id::unique(("ec-liw", euid, ch)), &t);
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.levels.inWhite"), r2, "Input White");
|
||||
if let Some(v) = nv {
|
||||
set(ui, &ids[1], v.max(ib + 0.005), started(ui));
|
||||
@@ -1075,12 +1079,12 @@ fn levels_editor(
|
||||
p.rect_filled(Rect::from_min_max(pos2(x0, bar.min.y), pos2(x1, bar.max.y)), 0.0, Color32::from_gray(v));
|
||||
}
|
||||
let otrack = Rect::from_min_size(pos2(bar.min.x, bar.max.y + 1.0), vec2(w, 10.0));
|
||||
let (r3, nv) = fw::level_handle(ui, otrack, ob, Color32::BLACK, egui::Id::new(("ec-lob", euid, ch)), &t);
|
||||
let (r3, nv) = fw::level_handle(ui, otrack, ob, Color32::BLACK, egui::Id::unique(("ec-lob", euid, ch)), &t);
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.levels.outBlack"), r3, "Output Black");
|
||||
if let Some(v) = nv {
|
||||
set(ui, &ids[3], v, started(ui));
|
||||
}
|
||||
let (r4, nv) = fw::level_handle(ui, otrack, ow, Color32::WHITE, egui::Id::new(("ec-low", euid, ch)), &t);
|
||||
let (r4, nv) = fw::level_handle(ui, otrack, ow, Color32::WHITE, egui::Id::unique(("ec-low", euid, ch)), &t);
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.levels.outWhite"), r4, "Output White");
|
||||
if let Some(v) = nv {
|
||||
set(ui, &ids[4], v, started(ui));
|
||||
@@ -1111,7 +1115,7 @@ fn histogram_only(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
||||
|
||||
/// Open the rename field on effect `uid` (Enter with the effect selected), name selected.
|
||||
pub fn start_rename(ctx: &egui::Context, uid: u64, name: &str) {
|
||||
let id = egui::Id::new(("ec-rename", uid));
|
||||
let id = egui::Id::unique(("ec-rename", uid));
|
||||
ctx.data_mut(|d| d.insert_temp(id, name.to_string()));
|
||||
ctx.data_mut(|d| d.insert_temp(id.with("select"), true));
|
||||
ctx.memory_mut(|m| m.request_focus(id.with("field")));
|
||||
@@ -1135,7 +1139,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim);
|
||||
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
let body = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), rect.max);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("ec-scroll"), body);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("ec-scroll"), body);
|
||||
let mut y = body.min.y + 4.0 - scroll.offset;
|
||||
let mut actions: Actions = vec![];
|
||||
let bp = p.with_clip_rect(body);
|
||||
@@ -1153,12 +1157,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
);
|
||||
}
|
||||
// Clicking empty space deselects the effects.
|
||||
let bg = ui.interact(body, egui::Id::new("ec-bg"), Sense::click());
|
||||
let bg = ui.interact(body, egui::Id::unique("ec-bg"), Sense::click());
|
||||
if bg.clicked() {
|
||||
app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && fx.iter().any(|g| effect_active(g, layer.id, &[(*l, *u)]))));
|
||||
}
|
||||
let has_clip = !app.session.state.effect_clipboard.is_empty();
|
||||
let drag_id = egui::Id::new("ec-drag-effect");
|
||||
let drag_id = egui::Id::unique("ec-drag-effect");
|
||||
let dragging: Option<u64> = ctx.data(|d| d.get_temp(drag_id));
|
||||
// Header tops, for drag-to-reorder.
|
||||
let mut tops: Vec<(u64, f32)> = vec![];
|
||||
@@ -1185,16 +1189,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
);
|
||||
}
|
||||
let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0));
|
||||
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), Sense::click()).clicked() {
|
||||
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::unique(("ec-fx", g.uid)), Sense::click()).clicked() {
|
||||
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.effect.{}.fx", g.uid), fxr, &g.name);
|
||||
let tw = Rect::from_center_size(pos2(r.min.x + 32.0, r.center().y), vec2(12.0, 12.0));
|
||||
if widgets::twirl(ui, tw, open, egui::Id::new(("ec-tw", g.uid)), &t).clicked() {
|
||||
if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-tw", g.uid)), &t).clicked() {
|
||||
toggle_closed(app, g, open, ui.input(|i| i.modifiers.alt || i.modifiers.command));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.effect.{}.twirl", g.uid), tw, &g.name);
|
||||
let rename_id = egui::Id::new(("ec-rename", g.uid));
|
||||
let rename_id = egui::Id::unique(("ec-rename", g.uid));
|
||||
let editing: Option<String> = ctx.data(|d| d.get_temp(rename_id));
|
||||
let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.center().y - 9.0), vec2(40.0, 18.0));
|
||||
if let Some(mut value) = editing.as_ref().cloned() {
|
||||
@@ -1227,7 +1231,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::semibold(12.0), t.text);
|
||||
app.auto.add(&format!("effectControls.effect.{}.name", g.uid), name, &g.name);
|
||||
}
|
||||
let rresp = ui.interact(reset, egui::Id::new(("ec-reset", g.uid)), Sense::click());
|
||||
let rresp = ui.interact(reset, egui::Id::unique(("ec-reset", g.uid)), Sense::click());
|
||||
bp.text(reset.center(), Align2::CENTER_CENTER, crate::i18n::tr("Reset"), Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim });
|
||||
app.auto.add(&format!("effectControls.effect.{}.reset", g.uid), reset, "Reset");
|
||||
if rresp.clicked() {
|
||||
@@ -1235,10 +1239,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// About…: the effect's name and category (AE's About dialog).
|
||||
let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.center().y - 9.0), vec2(44.0, 18.0));
|
||||
let aresp = ui.interact(about, egui::Id::new(("ec-about", g.uid)), Sense::click());
|
||||
let aresp = ui.interact(about, egui::Id::unique(("ec-about", g.uid)), Sense::click());
|
||||
bp.text(about.center(), Align2::CENTER_CENTER, crate::i18n::tr("About..."), Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim });
|
||||
app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About");
|
||||
let apop = egui::Id::new(("ec-about-pop", g.uid));
|
||||
let apop = egui::Id::unique(("ec-about-pop", g.uid));
|
||||
if aresp.clicked() {
|
||||
widgets::open_popup(ui, apop);
|
||||
}
|
||||
@@ -1252,14 +1256,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let hresp = ui.interact(
|
||||
Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)),
|
||||
egui::Id::new(("ec-hdr", g.uid)),
|
||||
egui::Id::unique(("ec-hdr", g.uid)),
|
||||
if editing.is_some() { Sense::hover() } else { Sense::click_and_drag() },
|
||||
);
|
||||
if hresp.double_clicked() {
|
||||
ctx.data_mut(|d| d.insert_temp(rename_id, shown.clone()));
|
||||
ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true));
|
||||
}
|
||||
hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &shown));
|
||||
hresp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, &shown));
|
||||
app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name);
|
||||
if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() {
|
||||
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
|
||||
@@ -1351,7 +1355,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if effect == "ec.color.tint" {
|
||||
let r = Rect::from_min_size(pos2(body.min.x + PARAM_NAME_X, y + 2.0), vec2(120.0, ROW));
|
||||
let label = crate::i18n::tr("Swap Colors");
|
||||
let resp = widgets::text_button(ui, r, label, false, &t, egui::Id::new(("ec-tint-swap", g.uid)));
|
||||
let resp = widgets::text_button(ui, r, label, false, &t, egui::Id::unique(("ec-tint-swap", g.uid)));
|
||||
app.auto.add_response(&format!("effectControls.effect.{}.swapColors", g.uid), &resp, label);
|
||||
if resp.clicked() {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": g.uid})));
|
||||
@@ -1477,7 +1481,7 @@ pub fn viewer_hook(
|
||||
// GPU frames: read the shown frame back for sampling.
|
||||
app.viewer_pixels();
|
||||
}
|
||||
let resp = ui.interact(map.area, egui::Id::new("viewer-fx-pick"), Sense::click());
|
||||
let resp = ui.interact(map.area, egui::Id::unique("viewer-fx-pick"), Sense::click());
|
||||
app.auto.add("viewer.fxPick", map.area, &pick.name);
|
||||
let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) };
|
||||
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0));
|
||||
@@ -1542,7 +1546,7 @@ pub fn viewer_hook(
|
||||
app.ui.status.clear();
|
||||
} else {
|
||||
app.viewer_pixels();
|
||||
let resp = ui.interact(map.area, egui::Id::new("viewer-text-color-pick"), Sense::click());
|
||||
let resp = ui.interact(map.area, egui::Id::unique("viewer-text-color-pick"), Sense::click());
|
||||
app.auto.add("viewer.textColorPick", map.area, &pick.field);
|
||||
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0));
|
||||
painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180));
|
||||
@@ -1636,7 +1640,7 @@ pub fn viewer_hook(
|
||||
}
|
||||
for (pr, s) in pts.iter().zip(screen) {
|
||||
let hr = Rect::from_center_size(s, vec2(16.0, 16.0));
|
||||
let id = egui::Id::new(("viewer-fx-pt", pr.uid));
|
||||
let id = egui::Id::unique(("viewer-fx-pt", pr.uid));
|
||||
// The picker owns the click. Preview handles must not steal it or start a drag.
|
||||
let resp = (!picking).then(|| ui.interact(hr, id, Sense::drag()));
|
||||
let col = if picked_prop == Some(pr.uid) || resp.as_ref().is_some_and(|r| r.hovered() || r.dragged()) { t.accent } else { Color32::WHITE };
|
||||
|
||||
@@ -133,7 +133,7 @@ fn folder_row(
|
||||
if r.max.y < list.min.y || r.min.y > list.max.y {
|
||||
return open;
|
||||
}
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxcat", key)), Sense::click());
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::unique(("fxcat", key)), Sense::click());
|
||||
if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
@@ -179,7 +179,7 @@ fn effect_row(
|
||||
return;
|
||||
}
|
||||
let star = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(16.0, 16.0));
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::new((auto_prefix, e.id)), Sense::click_and_drag());
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::unique((auto_prefix, e.id)), Sense::click_and_drag());
|
||||
if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
@@ -194,7 +194,7 @@ fn effect_row(
|
||||
app.auto.add(&format!("{auto_prefix}.{}", e.id), r, e.name);
|
||||
// Favourite star (always shown when starred, else on hover).
|
||||
let fav = app.session.prefs.is_effect_favorite(e.id);
|
||||
let sresp = ui.interact(star.intersect(list), egui::Id::new((auto_prefix, e.id, "star")), Sense::click());
|
||||
let sresp = ui.interact(star.intersect(list), egui::Id::unique((auto_prefix, e.id, "star")), Sense::click());
|
||||
if fav || resp.hovered() || sresp.hovered() {
|
||||
let gold = Color32::from_rgb(0xe8, 0xc0, 0x40);
|
||||
filled_star(lp, star.center(), 6.0, fav.then_some(gold), if fav || sresp.hovered() { gold } else { t.text_faint });
|
||||
@@ -234,7 +234,7 @@ fn preset_row(
|
||||
if r.max.y < list.min.y || r.min.y > list.max.y {
|
||||
return;
|
||||
}
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click_and_drag());
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::unique(("fxpreset", auto_id)), Sense::click_and_drag());
|
||||
if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
@@ -251,7 +251,7 @@ fn preset_row(
|
||||
|
||||
/// User preset files, rescanned every few seconds (and on Refresh List).
|
||||
fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec<PresetFile>) -> Arc<Vec<PresetFile>> {
|
||||
let id = egui::Id::new("fx-user-presets");
|
||||
let id = egui::Id::unique("fx-user-presets");
|
||||
let now = ctx.input(|i| i.time);
|
||||
if !force
|
||||
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(id))
|
||||
@@ -281,8 +281,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add("effects.search", sr, "Search");
|
||||
// Contents menu.
|
||||
let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0));
|
||||
let mpop = egui::Id::new("fx-contents-menu");
|
||||
if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::new("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
|
||||
let mpop = egui::Id::unique("fx-contents-menu");
|
||||
if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
|
||||
widgets::open_popup(ui, mpop);
|
||||
}
|
||||
app.auto.add("effects.menu", mr, "Effects & Presets options");
|
||||
@@ -350,7 +350,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let view = app.ui.effects_view.clone();
|
||||
let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max);
|
||||
let lp = p.with_clip_rect(list);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("fx-scroll"), list);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("fx-scroll"), list);
|
||||
let query = q.trim().to_lowercase();
|
||||
let searching = !query.is_empty();
|
||||
let mut y = list.min.y - scroll.offset;
|
||||
@@ -462,7 +462,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
_ => None,
|
||||
}) && let Some(pos) = ctx.pointer_hover_pos()
|
||||
{
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("dnd-fx")));
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("dnd-fx")));
|
||||
let g = painter.layout_no_wrap(name, Tokens::ui(12.0), t.text);
|
||||
let r = Rect::from_min_size(pos + vec2(12.0, 8.0), g.size() + vec2(16.0, 8.0));
|
||||
painter.rect_filled(r, 4.0, Color32::from_rgba_premultiplied(40, 40, 40, 230));
|
||||
@@ -582,7 +582,7 @@ mod tests {
|
||||
for _ in 0..5 {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
let (_, shared) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(egui::Id::new("fx-user-presets"))).unwrap();
|
||||
let (_, shared) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(egui::Id::unique("fx-user-presets"))).unwrap();
|
||||
std::hint::black_box((*shared).clone());
|
||||
}
|
||||
before.push(start.elapsed());
|
||||
|
||||
@@ -79,6 +79,10 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) {
|
||||
}
|
||||
}
|
||||
});
|
||||
// The label beside the combo names it for a screen reader; the selected comp alone
|
||||
// doesn't say what the dropdown chooses. Keep the visible label and value together.
|
||||
let primary_name = format!("{} {}", tr("Primary:"), comp_name);
|
||||
r.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), &primary_name));
|
||||
app.auto.add("essential.primary", r.response.rect, &comp_name);
|
||||
});
|
||||
let solo = app.session.state.essential_solo;
|
||||
@@ -90,7 +94,7 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) {
|
||||
// ---- Name.
|
||||
let eg = project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default();
|
||||
let shown_name = if eg.name.is_empty() { comp_name.clone() } else { eg.name.clone() };
|
||||
let name_id = egui::Id::new("eg-name-buf");
|
||||
let name_id = egui::Id::unique("eg-name-buf");
|
||||
let mut name: String = ctx.data(|d| d.get_temp(name_id)).unwrap_or_else(|| shown_name.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(tr("Template name")).desired_width(f32::INFINITY));
|
||||
app.auto.add("essential.name", r.rect, &shown_name);
|
||||
@@ -291,7 +295,7 @@ fn link_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgCon
|
||||
|
||||
/// A control's name; double-click to rename (comments: edit the text).
|
||||
fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgControl, text: RichText, actions: &mut Actions) {
|
||||
let edit_id = egui::Id::new(("eg-rename", c.id));
|
||||
let edit_id = egui::Id::unique(("eg-rename", c.id));
|
||||
let editing: Option<String> = ui.ctx().data(|d| d.get_temp(edit_id));
|
||||
match editing {
|
||||
Some(mut buf) => {
|
||||
@@ -329,7 +333,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt
|
||||
KV::Str(s) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let id = egui::Id::new(("eg-text", auto));
|
||||
let id = egui::Id::unique(("eg-text", auto));
|
||||
let mut buf: String = ui.ctx().data(|d| d.get_temp(id)).unwrap_or_else(|| cur.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0));
|
||||
if r.has_focus() {
|
||||
|
||||
@@ -24,7 +24,7 @@ struct Cache {
|
||||
}
|
||||
|
||||
fn cache_id(cid: ItemId) -> egui::Id {
|
||||
egui::Id::new(("expr-error-bar", cid.0))
|
||||
egui::Id::unique(("expr-error-bar", cid.0))
|
||||
}
|
||||
|
||||
/// Both layout and painting read the same completed diagnostics; neither evaluates expressions.
|
||||
@@ -64,7 +64,7 @@ pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId
|
||||
let count = Rect::from_center_size(pos2(rect.max.x - 44.0, rect.center().y), vec2(40.0, 16.0));
|
||||
let prev = Rect::from_center_size(pos2(rect.max.x - 74.0, rect.center().y), vec2(16.0, 16.0));
|
||||
for (r, label, auto, d) in [(prev, "◀", "viewer.exprErrors.prev", n - 1), (next, "▶", "viewer.exprErrors.next", 1)] {
|
||||
let resp = ui.interact(r, egui::Id::new(("expr-bar", auto)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("expr-bar", auto)), Sense::click());
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if resp.hovered() { Color32::WHITE } else { t.text });
|
||||
app.auto.add(auto, r, label);
|
||||
if resp.clicked() {
|
||||
@@ -76,7 +76,7 @@ pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId
|
||||
let comp_name = app.session.project.item(cid).map(|it| it.name.clone()).unwrap_or_default();
|
||||
let Some(e) = c.errors.get(i).cloned() else { return };
|
||||
let msg_rect = Rect::from_min_max(pos2(rect.min.x + 26.0, rect.min.y), pos2(prev.min.x - 6.0, rect.max.y));
|
||||
let resp = ui.interact(msg_rect, egui::Id::new("expr-bar-msg"), Sense::click()).on_hover_text(tr("Click to reveal the expression in the timeline"));
|
||||
let resp = ui.interact(msg_rect, egui::Id::unique("expr-bar-msg"), Sense::click()).on_hover_text(tr("Click to reveal the expression in the timeline"));
|
||||
p.with_clip_rect(msg_rect).text(
|
||||
pos2(msg_rect.min.x, rect.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
|
||||
@@ -270,7 +270,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let w = 18.0 + label.len() as f32 * 6.6;
|
||||
let r = Rect::from_min_size(pos2(x - w, bar.min.y + 5.0), vec2(w, 20.0));
|
||||
x -= w + 6.0;
|
||||
if widgets::text_button(ui, r, label, on, &t, egui::Id::new(("flow-opt", key))).clicked() {
|
||||
if widgets::text_button(ui, r, label, on, &t, egui::Id::unique(("flow-opt", key))).clicked() {
|
||||
let v = if key == "direction" { json!({"direction": if o.vertical { "lr" } else { "tb" }}) } else { json!({key: !on}) };
|
||||
cmds.push(("flowchart.options".into(), v));
|
||||
}
|
||||
@@ -282,7 +282,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let g = build(&app.session.project, rid, &o);
|
||||
let pos = layout(&g, o.vertical);
|
||||
let origin = body.min + vec2(24.0 + o.pan[0], 24.0 + o.pan[1]);
|
||||
let bg = ui.interact(body, egui::Id::new("flow-bg"), Sense::click_and_drag());
|
||||
let bg = ui.interact(body, egui::Id::unique("flow-bg"), Sense::click_and_drag());
|
||||
if bg.dragged() {
|
||||
let d = bg.drag_delta();
|
||||
app.ui.flowchart.pan[0] += d.x;
|
||||
@@ -322,7 +322,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let (icon, fill) = kind_style(n.kind);
|
||||
let selected = o.selected.as_deref() == Some(n.id.as_str());
|
||||
let resp = ui.interact(r.intersect(body), egui::Id::new(("flow-node", &n.id)), Sense::click());
|
||||
let resp = ui.interact(r.intersect(body), egui::Id::unique(("flow-node", &n.id)), Sense::click());
|
||||
let fill = if resp.hovered() { fill.gamma_multiply(1.25) } else { fill };
|
||||
gp.rect_filled(r, 5.0, fill);
|
||||
gp.rect_stroke(
|
||||
|
||||
@@ -58,7 +58,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&& f.has_video
|
||||
{
|
||||
let key = (view.item.0, view.time.0, app.session.revision);
|
||||
let tid = egui::Id::new("footage-panel-tex");
|
||||
let tid = egui::Id::unique("footage-panel-tex");
|
||||
let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid));
|
||||
let tex = match cached {
|
||||
Some((k, tex, size)) if k == key => Some((tex, size)),
|
||||
@@ -97,7 +97,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
p.rect_filled(Rect::from_min_max(pos2(x_of(a), ruler.max.y - 4.0), pos2(x_of(b), ruler.max.y)), 0.0, t.work_area);
|
||||
let cx = x_of(view.time);
|
||||
p.line_segment([pos2(cx, ruler.min.y), pos2(cx, ruler.max.y)], Stroke::new(1.5, t.cti));
|
||||
let resp = ui.interact(ruler, egui::Id::new("footage-ruler"), Sense::click_and_drag());
|
||||
let resp = ui.interact(ruler, egui::Id::unique("footage-ruler"), Sense::click_and_drag());
|
||||
app.auto.add("footage.ruler", ruler, "Time ruler");
|
||||
if (resp.clicked() || resp.dragged())
|
||||
&& let Some(pt) = resp.interact_pointer_pos()
|
||||
@@ -106,7 +106,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
kit::exec(app, "footage.setTime", json!({"time": s}));
|
||||
}
|
||||
// Playback.
|
||||
let play_id = egui::Id::new("footage-playing");
|
||||
let play_id = egui::Id::unique("footage-playing");
|
||||
let playing: bool = ctx.data(|d| d.get_temp(play_id).unwrap_or(false));
|
||||
if playing {
|
||||
let dt = ctx.input(|i| i.stable_dt).min(0.1) as f64;
|
||||
|
||||
@@ -83,7 +83,7 @@ fn tabs(
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let w = if cols.is_empty() { 16.0 + name.chars().count() as f32 * 6.2 } else { 20.0 };
|
||||
let r = Rect::from_min_size(pos2(x, cy - 9.0), vec2(w, 18.0));
|
||||
let resp = ui.interact(r, egui::Id::new((id, i)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique((id, i)), Sense::click());
|
||||
let sel = i == cur;
|
||||
if sel {
|
||||
p.rect_filled(r, 3.0, t.row_selected);
|
||||
@@ -113,7 +113,7 @@ fn tabs(
|
||||
|
||||
fn reset_link(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str) -> bool {
|
||||
let t = app.tokens;
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(id), Sense::click());
|
||||
p.text(r.center(), Align2::CENTER_CENTER, tr("Reset"), Tokens::ui(11.5), if resp.hovered() { t.hot_text } else { t.text_dim });
|
||||
app.auto.add(id, r, "Reset");
|
||||
resp.clicked()
|
||||
@@ -192,7 +192,7 @@ fn rgb_curves(
|
||||
set_str(actions, layer, g, id, fw::CurvePoints::identity().format(), None);
|
||||
}
|
||||
let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size));
|
||||
let gid = egui::Id::new(("lumetri-rgb", euid, ch));
|
||||
let gid = egui::Id::unique(("lumetri-rgb", euid, ch));
|
||||
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, false, false, gid, &t);
|
||||
app.auto.add(&format!("{prefix}.graph"), gr, "RGB Curves");
|
||||
register_points(app, &prefix, gr, &cur.0);
|
||||
@@ -321,7 +321,7 @@ fn huesat_curves(
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(line, Stroke::new(1.5, Color32::from_gray(0xe8))));
|
||||
let gid = egui::Id::new(("lumetri-hs", euid, k));
|
||||
let gid = egui::Id::unique(("lumetri-hs", euid, k));
|
||||
let resp = ui.interact(gr.expand(6.0), gid, Sense::click_and_drag());
|
||||
app.auto.add(&format!("{prefix}.graph"), gr, name);
|
||||
let drag_id = gid.with("drag");
|
||||
@@ -476,7 +476,7 @@ fn colorama_wheel(
|
||||
}
|
||||
p.circle_stroke(c, outer, Stroke::new(1.0, Color32::from_black_alpha(160)));
|
||||
p.circle_stroke(c, inner, Stroke::new(1.0, Color32::from_black_alpha(160)));
|
||||
let wid = egui::Id::new(("colorama-wheel", euid));
|
||||
let wid = egui::Id::unique(("colorama-wheel", euid));
|
||||
let ring = Rect::from_center_size(c, vec2(outer * 2.0 + 40.0, outer * 2.0 + 40.0));
|
||||
let resp = ui.interact(ring, wid, Sense::click_and_drag());
|
||||
app.auto.add(&format!("{prefix}.wheel"), ring, "Output Cycle");
|
||||
@@ -712,7 +712,7 @@ fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, laye
|
||||
set_str(actions, layer, g, "arbitraryMap", join_map(&m), None);
|
||||
}
|
||||
let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size));
|
||||
let gid = egui::Id::new(("glow-map", euid, ch));
|
||||
let gid = egui::Id::unique(("glow-map", euid, ch));
|
||||
let (resp, edit) = fw::curves_graph(ui, gr, &map[ch], ch + 1, false, false, gid, &t);
|
||||
app.auto.add(&format!("{prefix}.graph"), gr, "Arbitrary Map");
|
||||
register_points(app, &prefix, gr, &map[ch].0);
|
||||
@@ -786,19 +786,19 @@ fn reshape_buttons(
|
||||
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
|
||||
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0));
|
||||
let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0));
|
||||
if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::new(("reshape-add", euid))).clicked() {
|
||||
if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::unique(("reshape-add", euid))).clicked() {
|
||||
let mut v = pairs.clone();
|
||||
v.push(add_pair(&pairs));
|
||||
set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point");
|
||||
if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::new(("reshape-remove", euid))).clicked() && !pairs.is_empty() {
|
||||
if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() {
|
||||
let mut v = pairs.clone();
|
||||
v.pop();
|
||||
set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point");
|
||||
if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::new(("reshape-clear", euid))).clicked() && !pairs.is_empty() {
|
||||
if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() {
|
||||
set_str(actions, layer, g, "correspondencePoints", String::new(), None);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear");
|
||||
@@ -857,8 +857,8 @@ fn extractor_layers(
|
||||
p.text(pos2(r.min.x + 37.0, r.center().y), Align2::LEFT_CENTER, tr("Layer"), Tokens::ui(12.0), t.text);
|
||||
let vx = (r.min.x + r.width() * 0.48).max(r.min.x + 161.0);
|
||||
let dr = Rect::from_min_size(pos2(vx, r.center().y - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0));
|
||||
let pop = egui::Id::new(("extractor-layers", euid));
|
||||
if widgets::dropdown(ui, dr, &label, &t, egui::Id::new(("extractor-layer", euid)))
|
||||
let pop = egui::Id::unique(("extractor-layers", euid));
|
||||
if widgets::dropdown(ui, dr, &label, &t, egui::Id::unique(("extractor-layer", euid)))
|
||||
.on_hover_text(tr("The OpenEXR layer shown in red, green, blue and alpha"))
|
||||
.clicked()
|
||||
{
|
||||
@@ -899,8 +899,8 @@ pub fn channel_popup(
|
||||
let t = app.tokens;
|
||||
let uid = prop.uid;
|
||||
let label = if cur.trim().is_empty() { NO_CHANNEL } else { cur };
|
||||
let pop = egui::Id::new(("ec-chpop", uid));
|
||||
if widgets::dropdown(ui, r, label, &t, egui::Id::new(("ec-ch", uid))).clicked() {
|
||||
let pop = egui::Id::unique(("ec-chpop", uid));
|
||||
if widgets::dropdown(ui, r, label, &t, egui::Id::unique(("ec-ch", uid))).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), r, &prop.name);
|
||||
@@ -992,7 +992,7 @@ pub fn reshape_overlay(
|
||||
painter.line_segment([ps, pd], Stroke::new(1.0, Color32::from_white_alpha(150)));
|
||||
for (end, s, col) in [(0usize, ps, ys), (1, pd, cs)] {
|
||||
let hr = Rect::from_center_size(s, vec2(12.0, 12.0));
|
||||
let id = egui::Id::new(("reshape-pt", g.uid, i, end));
|
||||
let id = egui::Id::unique(("reshape-pt", g.uid, i, end));
|
||||
let resp = ui.interact(hr, id, Sense::drag());
|
||||
let hot = resp.hovered() || resp.dragged();
|
||||
painter.rect_filled(Rect::from_center_size(s, vec2(7.0, 7.0)), 0.0, if hot { Color32::WHITE } else { col });
|
||||
@@ -1086,7 +1086,7 @@ pub fn mesh_warp_overlay(
|
||||
let accent = app.tokens.accent;
|
||||
for (k, s) in screen.iter().enumerate() {
|
||||
let hr = Rect::from_center_size(*s, vec2(10.0, 10.0));
|
||||
let id = egui::Id::new(("mesh-warp-vertex", g.uid, k));
|
||||
let id = egui::Id::unique(("mesh-warp-vertex", g.uid, k));
|
||||
let resp = ui.interact(hr, id, Sense::drag());
|
||||
let hot = resp.hovered() || resp.dragged();
|
||||
let dot = Rect::from_center_size(*s, vec2(5.0, 5.0));
|
||||
|
||||
@@ -291,7 +291,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
|
||||
// Clicking a row's empty space adds a stop there.
|
||||
for (track, rowr, id) in [(Track::Opacity, opacity_row, "dialog.gradient.addOpacity"), (Track::Color, color_row, "dialog.gradient.addColor")] {
|
||||
let r = ui.interact(rowr, egui::Id::new(("gradient-add", id)), Sense::click());
|
||||
let r = ui.interact(rowr, egui::Id::unique(("gradient-add", id)), Sense::click());
|
||||
app.auto.add(id, rowr, id);
|
||||
if r.clicked()
|
||||
&& let Some(p) = r.interact_pointer_pos()
|
||||
@@ -323,7 +323,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.field_border }),
|
||||
StrokeKind::Outside,
|
||||
);
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new(("gradient-stop", track == Track::Color, id)), Sense::click_and_drag());
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::unique(("gradient-stop", track == Track::Color, id)), Sense::click_and_drag());
|
||||
let name = match track {
|
||||
Track::Opacity => format!("dialog.gradient.opacityStop.{i}"),
|
||||
Track::Color => format!("dialog.gradient.colorStop.{i}"),
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn viewer_hook(
|
||||
}
|
||||
for (pr, s) in pts.iter().zip(screen) {
|
||||
let hr = Rect::from_center_size(s, vec2(16.0, 16.0));
|
||||
let id = egui::Id::new(("viewer-gradient-pt", pr.uid));
|
||||
let id = egui::Id::unique(("viewer-gradient-pt", pr.uid));
|
||||
let resp = ui.interact(hr, id, Sense::drag());
|
||||
let col = if resp.hovered() || resp.dragged() { t.accent } else { Color32::WHITE };
|
||||
fw::draw_crosshair(painter, s + vec2(1.0, 1.0), 5.0, Color32::from_black_alpha(160));
|
||||
|
||||
@@ -190,7 +190,7 @@ impl ButtonBar {
|
||||
const MORE_W: f32 = 24.0;
|
||||
|
||||
fn new(ctx: &egui::Context, bar: Rect) -> Self {
|
||||
let chosen = ctx.data_mut(|d| d.remove_temp::<String>(egui::Id::new(MORE_POPUP).with("chosen")));
|
||||
let chosen = ctx.data_mut(|d| d.remove_temp::<String>(egui::Id::unique(MORE_POPUP).with("chosen")));
|
||||
ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen }
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ impl ButtonBar {
|
||||
}
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(pos2(bar.max.x - 8.0 - Self::MORE_W, bar.center().y - 9.0), vec2(Self::MORE_W, 18.0));
|
||||
let pid = egui::Id::new(MORE_POPUP);
|
||||
let pid = egui::Id::unique(MORE_POPUP);
|
||||
let resp = ui.interact(r, pid.with("button"), Sense::click()).on_hover_text(crate::i18n::tr("More"));
|
||||
p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x2c, 0x2c, 0x2c) });
|
||||
p.text(r.center(), Align2::CENTER_CENTER, "»", Tokens::ui(12.0), t.text);
|
||||
@@ -252,7 +252,7 @@ fn text_button(
|
||||
return bar.chosen.as_deref() == Some(id);
|
||||
}
|
||||
let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique(("graph-btn", id)), Sense::click()).on_hover_text(tip);
|
||||
p.rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
@@ -278,14 +278,14 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
|
||||
let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
|
||||
// Background first so keys and handles (added later) sit on top for hit-testing.
|
||||
let bg = ui.interact(plot, egui::Id::new("graph-bg"), Sense::click_and_drag());
|
||||
let bg = ui.interact(plot, egui::Id::unique("graph-bg"), Sense::click_and_drag());
|
||||
p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
|
||||
app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
|
||||
let speed = speed_graph(app, comp);
|
||||
let cs = curves(app, comp, ectx, tm, plot, speed);
|
||||
// Just opened, or the graph type changed (value and speed have different units): frame the
|
||||
// graphs (Fit Selection to View, else Fit All Graphs to View).
|
||||
let was_speed_id = egui::Id::new("graph-was-speed");
|
||||
let was_speed_id = egui::Id::unique("graph-was-speed");
|
||||
let type_changed = ctx.data(|d| d.get_temp::<bool>(was_speed_id)).is_some_and(|was| was != speed);
|
||||
ctx.data_mut(|d| d.insert_temp(was_speed_id, speed));
|
||||
if ctx.data_mut(|d| d.remove_temp::<bool>(fit_pending_id())).unwrap_or(false) || type_changed {
|
||||
@@ -322,7 +322,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
let pad = (hi - lo) * 0.08;
|
||||
let auto = (lo - pad, hi + pad);
|
||||
// While a key or handle is being dragged the scale stays put (no feedback loop).
|
||||
let drag_id = egui::Id::new("graph-dragging");
|
||||
let drag_id = egui::Id::unique("graph-dragging");
|
||||
let dragging = ctx.data(|d| d.get_temp::<bool>(drag_id)).unwrap_or(false) && ctx.input(|i| i.pointer.any_down());
|
||||
let (lo, hi) = if app.ui.timeline.graph_auto_zoom && !dragging { auto } else { app.ui.timeline.graph_range.unwrap_or(auto) };
|
||||
if app.ui.timeline.graph_auto_zoom && !dragging {
|
||||
@@ -398,7 +398,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
let kp = pos2(tm.x(k.t), ymap(k.v));
|
||||
// In the speed graph an end key rises with the handle being dragged; skipping it once it
|
||||
// leaves the plot would drop the handle's widget and end the drag at the top edge.
|
||||
let handle_dragged = || ["in", "out"].iter().any(|side| ctx.is_being_dragged(egui::Id::new(("ghandle", uid, d, k.idx, *side))));
|
||||
let handle_dragged = || ["in", "out"].iter().any(|side| ctx.is_being_dragged(egui::Id::unique(("ghandle", uid, d, k.idx, *side))));
|
||||
if !plot.expand(6.0).contains(kp) && !handle_dragged() {
|
||||
continue;
|
||||
}
|
||||
@@ -411,7 +411,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
pc.circle_filled(hp, 3.5, Color32::from_rgb(0xd8, 0xd8, 0x60));
|
||||
let side = if h.out { "out" } else { "in" };
|
||||
let hr = Rect::from_center_size(hp, vec2(10.0, 10.0));
|
||||
let hresp = ui.interact(hr, egui::Id::new(("ghandle", uid, d, k.idx, side)), Sense::drag());
|
||||
let hresp = ui.interact(hr, egui::Id::unique(("ghandle", uid, d, k.idx, side)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.graph.handle.{uid}.{d}.{}.{side}", k.idx), hr, "Bezier handle");
|
||||
// The same side's handles of every selected key move with it, by as much as the
|
||||
// pointer moved (After Effects), in one undo step.
|
||||
@@ -464,7 +464,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col);
|
||||
pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside);
|
||||
let kr = Rect::from_center_size(kp, vec2(11.0, 11.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new(("gkey", uid, d, k.idx)), Sense::click_and_drag());
|
||||
let kresp = ui.interact(kr, egui::Id::unique(("gkey", uid, d, k.idx)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let key = &c.prop.keys[k.idx];
|
||||
@@ -526,12 +526,12 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
let br = Rect::from_two_pos(o, cur);
|
||||
pc.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
|
||||
pc.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("graph-box"), br));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("graph-box"), br));
|
||||
}
|
||||
if bg.drag_stopped()
|
||||
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("graph-box")))
|
||||
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("graph-box")))
|
||||
{
|
||||
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::new("graph-box")));
|
||||
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::unique("graph-box")));
|
||||
let keys: Vec<serde_json::Value> = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
|
||||
actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
|
||||
}
|
||||
@@ -628,14 +628,14 @@ fn fit_all(app: &mut EffectcraftApp) {
|
||||
}
|
||||
|
||||
fn fit_pending_id() -> egui::Id {
|
||||
egui::Id::new("graph-fit-pending")
|
||||
egui::Id::unique("graph-fit-pending")
|
||||
}
|
||||
|
||||
/// Called every Timeline frame with whether the Graph Editor is on: switching it on (header
|
||||
/// button, menu, Shift+F3 or the control channel) frames the graphs on its first frame, when the
|
||||
/// plot rect is known, as After Effects does.
|
||||
pub(crate) fn track_open(ctx: &egui::Context, on: bool) {
|
||||
let was_id = egui::Id::new("graph-editor-was-on");
|
||||
let was_id = egui::Id::unique("graph-editor-was-on");
|
||||
let was = ctx.data(|d| d.get_temp::<bool>(was_id)).unwrap_or(false);
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(was_id, on);
|
||||
@@ -740,7 +740,7 @@ struct KeyGrab {
|
||||
}
|
||||
|
||||
fn key_grab_id() -> egui::Id {
|
||||
egui::Id::new("graph-key-grab")
|
||||
egui::Id::unique("graph-key-grab")
|
||||
}
|
||||
|
||||
/// A Bezier handle moving with a handle drag: its key, side and dimension, the key's place
|
||||
@@ -787,7 +787,7 @@ impl HandleGrab {
|
||||
type HandleDrag = (Pos2, Vec<HandleGrab>);
|
||||
|
||||
fn handle_drag_id() -> egui::Id {
|
||||
egui::Id::new("graph-handle-drag")
|
||||
egui::Id::unique("graph-handle-drag")
|
||||
}
|
||||
|
||||
/// Which axes a drag may move on (time, value): both, or with Shift only the one the pointer moved more along.
|
||||
|
||||
@@ -35,7 +35,7 @@ struct BoxDrag {
|
||||
}
|
||||
|
||||
fn box_id() -> egui::Id {
|
||||
egui::Id::new("graph-transform-box")
|
||||
egui::Id::unique("graph-transform-box")
|
||||
}
|
||||
|
||||
/// Bounds of the selected keys: (t0, v0, t1, v1).
|
||||
@@ -80,7 +80,7 @@ pub(crate) fn transform_box(
|
||||
let value_ok = !speed && keys.iter().all(|k| k.editable);
|
||||
let col = Color32::from_rgb(0xd8, 0xd8, 0xd8);
|
||||
let ctx = ui.ctx().clone();
|
||||
let drag_id = egui::Id::new("graph-dragging");
|
||||
let drag_id = egui::Id::unique("graph-dragging");
|
||||
let hp = handle_points(r);
|
||||
let data = |q: Pos2| [tm.t(q.x), vmap(q.y)];
|
||||
// Which data point each handle sits on, and its opposite.
|
||||
@@ -100,7 +100,7 @@ pub(crate) fn transform_box(
|
||||
let mut resp_any: Option<egui::Response> = None;
|
||||
if !handles {
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, col.gamma_multiply(0.7)), StrokeKind::Middle);
|
||||
let resp = ui.interact(r, egui::Id::new("graph-box-inside"), Sense::drag());
|
||||
let resp = ui.interact(r, egui::Id::unique("graph-box-inside"), Sense::drag());
|
||||
app.auto.add("timeline.graph.transformBox", r, "Transform box");
|
||||
if resp.drag_started()
|
||||
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
|
||||
@@ -117,7 +117,7 @@ pub(crate) fn transform_box(
|
||||
let hr = Rect::from_center_size(*h, vec2(7.0, 7.0));
|
||||
p.rect_filled(hr, 0.0, Color32::from_gray(0x20));
|
||||
p.rect_stroke(hr, 0.0, Stroke::new(1.0, col), StrokeKind::Middle);
|
||||
let resp = ui.interact(hr.expand(2.0), egui::Id::new(("graph-box-handle", i)), Sense::drag());
|
||||
let resp = ui.interact(hr.expand(2.0), egui::Id::unique(("graph-box-handle", i)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.graph.transformBox.{i}"), hr, "Transform box handle");
|
||||
if resp.drag_started() {
|
||||
let opp = [2, 3, 0, 1, 6, 7, 4, 5][i];
|
||||
@@ -255,7 +255,7 @@ struct AltScale {
|
||||
}
|
||||
|
||||
fn alt_id() -> egui::Id {
|
||||
egui::Id::new("timeline-alt-scale")
|
||||
egui::Id::unique("timeline-alt-scale")
|
||||
}
|
||||
|
||||
/// Selected keys' comp-time range.
|
||||
|
||||
@@ -251,7 +251,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
|
||||
let mut y = rail.min.y + 20.0;
|
||||
for (label, id, primary) in [("New Project", "file.newProject", true), ("Open Project…", "file.open", false)] {
|
||||
let r = Rect::from_min_size(pos2(x0, y), vec2(w, 34.0));
|
||||
if widgets::text_button(ui, r, tr(label), primary, &t, egui::Id::new(("home", id))).clicked() {
|
||||
if widgets::text_button(ui, r, tr(label), primary, &t, egui::Id::unique(("home", id))).clicked() {
|
||||
actions.push((id, json!({})));
|
||||
}
|
||||
app.auto.add(&format!("home.{id}"), r, label);
|
||||
@@ -268,7 +268,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
|
||||
};
|
||||
for (k, (label, icon)) in [("Home", Icon::Home), ("Templates", Icon::Grid), ("Learn", Icon::Sparkle)].into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(rail.min.x + 8.0, y), vec2(rail.width() - 16.0, 34.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("home-tab", label)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("home-tab", label)), Sense::click());
|
||||
let on = page == k;
|
||||
if on || resp.hovered() {
|
||||
p.rect_filled(r, 6.0, if on { t.row_selected } else { t.hover });
|
||||
@@ -299,7 +299,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
|
||||
cy += 22.0;
|
||||
for (icon, label, cmd) in links {
|
||||
let r = Rect::from_min_size(pos2(x0, cy), vec2(w, 26.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("home-link", cmd)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("home-link", cmd)), Sense::click());
|
||||
let discord = cmd == "help.discord";
|
||||
if discord {
|
||||
p.rect_filled(r, 13.0, Color32::from_rgb(0x58, 0x65, 0xf2));
|
||||
@@ -320,7 +320,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
|
||||
let sw = (w - 6.0) / 2.0;
|
||||
for (i, (name, slug)) in sib.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + (i % 2) as f32 * (sw + 6.0), cy + (i / 2) as f32 * 26.0), vec2(sw, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("sib", *slug)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("sib", *slug)), Sense::click());
|
||||
p.rect_filled(r, 11.0, if resp.hovered() { t.hover } else { t.field_bg });
|
||||
p.text(r.center(), Align2::CENTER_CENTER, *name, Tokens::ui(11.0), t.text);
|
||||
app.auto.add(&format!("home.sibling.{slug}"), r, name);
|
||||
@@ -363,7 +363,7 @@ fn home_page(
|
||||
let tw = ((w - gap * 3.0) / 4.0).clamp(120.0, 220.0);
|
||||
for (k, (label, id, icon)) in tiles.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + k as f32 * (tw + gap), y), vec2(tw, 64.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("home-tile", id)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("home-tile", id)), Sense::click());
|
||||
p.rect_filled(r, 8.0, if resp.hovered() { t.hover } else { t.panel_bg });
|
||||
p.rect_stroke(r, 8.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.separator }), StrokeKind::Inside);
|
||||
let ir = Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(32.0, 32.0));
|
||||
@@ -385,7 +385,7 @@ fn home_page(
|
||||
// Recent: heading, filter, clear.
|
||||
p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, tr("Recent"), Tokens::semibold(17.0), t.tab_text_active);
|
||||
let entries = recent_entries(&app.session.prefs);
|
||||
let fid = egui::Id::new("home-filter");
|
||||
let fid = egui::Id::unique("home-filter");
|
||||
let mut filter: String = ctx.data(|d| d.get_temp(fid)).unwrap_or_default();
|
||||
let fr = Rect::from_min_size(pos2(x0 + w - 240.0, y), vec2(240.0, 26.0));
|
||||
widgets::search_field(ui, fr, &mut filter, tr("Filter recent files"), &t);
|
||||
@@ -393,7 +393,7 @@ fn home_page(
|
||||
ctx.data_mut(|d| d.insert_temp(fid, filter.clone()));
|
||||
if !entries.is_empty() {
|
||||
let cr = Rect::from_min_size(pos2(fr.min.x - 96.0, y + 3.0), vec2(84.0, 20.0));
|
||||
let resp = ui.interact(cr, egui::Id::new("home-clear-recent"), Sense::click());
|
||||
let resp = ui.interact(cr, egui::Id::unique("home-clear-recent"), Sense::click());
|
||||
p.text(cr.right_center(), Align2::RIGHT_CENTER, tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint });
|
||||
app.auto.add("home.clearRecent", cr, "Clear Recent Projects");
|
||||
if resp.clicked() {
|
||||
@@ -430,7 +430,7 @@ fn home_page(
|
||||
if r.min.y > area.max.y {
|
||||
break;
|
||||
}
|
||||
let resp = ui.interact(r, egui::Id::new(("home-recent", e.index)), Sense::click()).on_hover_text(&e.path);
|
||||
let resp = ui.interact(r, egui::Id::unique(("home-recent", e.index)), Sense::click()).on_hover_text(&e.path);
|
||||
if resp.hovered() {
|
||||
p.rect_filled(r, 6.0, t.hover);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let x0 = rect.min.x + 12.0;
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("info-scroll"), rect);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("info-scroll"), rect);
|
||||
let mut y = rect.min.y + 14.0 - scroll.offset;
|
||||
if app.pointer_comp.is_some() {
|
||||
// GPU frames: read the shown frame back for sampling.
|
||||
|
||||
@@ -41,11 +41,11 @@ struct Tex {
|
||||
}
|
||||
|
||||
fn tex_id() -> egui::Id {
|
||||
egui::Id::new("layer-panel-tex")
|
||||
egui::Id::unique("layer-panel-tex")
|
||||
}
|
||||
|
||||
fn fit_id() -> egui::Id {
|
||||
egui::Id::new("layer-panel-fit")
|
||||
egui::Id::unique("layer-panel-fit")
|
||||
}
|
||||
|
||||
/// The Layer panel's last Fit magnification (points per layer pixel), for View ▸ Zoom In / Zoom
|
||||
@@ -144,8 +144,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
names.extend(fx.groups().map(|g| g.name.clone()));
|
||||
}
|
||||
let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0));
|
||||
let pop = egui::Id::new("layer-view-pop");
|
||||
if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::new("layer-view")).clicked() {
|
||||
let pop = egui::Id::unique("layer-view-pop");
|
||||
if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::unique("layer-view")).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add("layerPanel.view", vr, "View");
|
||||
@@ -233,7 +233,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let space = super::space_hand(&ctx);
|
||||
if space || matches!(app.ui.tool, Tool::Hand | Tool::Zoom) {
|
||||
let resp = ui.interact(area, egui::Id::new("layer-panel-nav"), Sense::click_and_drag());
|
||||
let resp = ui.interact(area, egui::Id::unique("layer-panel-nav"), Sense::click_and_drag());
|
||||
let alt = ui.input(|i| i.modifiers.alt);
|
||||
if resp.hovered() {
|
||||
ctx.set_cursor_icon(match app.ui.tool {
|
||||
@@ -264,10 +264,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
return;
|
||||
}
|
||||
// Interaction: paint tools.
|
||||
let resp = ui.interact(area, egui::Id::new("layer-panel-interact"), Sense::click_and_drag());
|
||||
let resp = ui.interact(area, egui::Id::unique("layer-panel-interact"), Sense::click_and_drag());
|
||||
let kind = app.ui.tool.paint_kind();
|
||||
let o = app.session.state.paint.clone();
|
||||
let did = egui::Id::new("layer-panel-drag");
|
||||
let did = egui::Id::unique("layer-panel-drag");
|
||||
if let Some(hp) = resp.hover_pos() {
|
||||
let lp = to_layer(hp);
|
||||
app.pointer_comp = Some([lp[0] as f32, lp[1] as f32]);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! edits made in the dialog back, OK keeps them (one undo step per control drag).
|
||||
|
||||
use crate::i18n::tr;
|
||||
use effectcraft_engine::commands::text_edit::active_editor_snapshot;
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
use effectcraft_engine::project::styles::{BLENDING, STYLES};
|
||||
use effectcraft_engine::project::{Layer, LayerId, ParamUi, Property};
|
||||
@@ -258,13 +259,85 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool) {
|
||||
&& !ok
|
||||
{
|
||||
let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project);
|
||||
app.session.project = checkpoint.project;
|
||||
app.session.history = checkpoint.history;
|
||||
app.session.sanitize_state();
|
||||
if changed {
|
||||
// Snapshot only when Cancel has something to restore: opening and cancelling without
|
||||
// edits must not copy the whole TextDoc.
|
||||
let edited = active_editor_snapshot(&app.session);
|
||||
app.session.project = checkpoint.project;
|
||||
app.session.history = checkpoint.history;
|
||||
app.session.sanitize_after_replacement(edited.as_ref());
|
||||
app.session.bump();
|
||||
} else {
|
||||
// No edit was committed, so nothing can have become invalid. In particular, do not
|
||||
// call sanitize_state here: its text-edit cleanup clones the whole TextDoc.
|
||||
app.session.history = checkpoint.history;
|
||||
}
|
||||
}
|
||||
app.session.history.merge_key = None;
|
||||
app.dialog = None;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::commands::text_edit::layer_doc;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_engine::project::styles::BLENDING;
|
||||
use serde_json::json;
|
||||
|
||||
fn editing_app() -> (EffectcraftApp, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Styles", "width": 320, "height": 180, "duration": 1})).unwrap();
|
||||
let lid = s.execute("layer.newText", json!({"text": "abcdef"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("text.edit", json!({"layer": lid, "caret": 3})).unwrap();
|
||||
(EffectcraftApp::new(s), lid)
|
||||
}
|
||||
|
||||
fn editor_state(app: &EffectcraftApp) -> Option<(LayerId, u64, usize, usize)> {
|
||||
app.session.state.text_edit.as_ref().map(|e| (e.layer, e.session, e.anchor, e.caret))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_keeps_an_open_text_editor_when_the_dialog_did_not_change_its_text() {
|
||||
let (mut app, lid) = editing_app();
|
||||
open(&mut app, &json!({"layer": lid, "style": BLENDING})).unwrap();
|
||||
// A live preview edit changes the project but not the edited Source Text.
|
||||
app.session.execute("prop.set", json!({"layer": lid, "path": "transform/opacity", "value": 40})).unwrap();
|
||||
let before = editor_state(&app);
|
||||
assert!(before.is_some());
|
||||
|
||||
finish(&mut app, false);
|
||||
assert_eq!(editor_state(&app), before, "Cancel keeps an editor whose document it did not replace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_without_a_dialog_edit_keeps_the_open_editor_and_its_document_untouched() {
|
||||
let (mut app, lid) = editing_app();
|
||||
open(&mut app, &json!({"layer": lid, "style": BLENDING})).unwrap();
|
||||
let before = editor_state(&app);
|
||||
let project = app.session.project.clone();
|
||||
let undo = app.session.history.undo.len();
|
||||
assert!(before.is_some());
|
||||
|
||||
finish(&mut app, false);
|
||||
assert!(std::sync::Arc::ptr_eq(&app.session.project, &project), "a no-op Cancel restores the same project");
|
||||
assert_eq!(app.session.history.undo.len(), undo, "and the same undo history");
|
||||
assert_eq!(editor_state(&app), before, "and leaves the open editor untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_replaces_an_open_text_editor_when_the_preview_changed_its_text() {
|
||||
let (mut app, lid) = editing_app();
|
||||
open(&mut app, &json!({"layer": lid, "style": BLENDING})).unwrap();
|
||||
app.session.execute("layer.setText", json!({"layer": lid, "text": "ab"})).unwrap();
|
||||
let preview = editor_state(&app).expect("still editing");
|
||||
assert_eq!(layer_doc(&app.session, LayerId(lid)).unwrap().text, "ab");
|
||||
|
||||
finish(&mut app, false);
|
||||
let restored = editor_state(&app).expect("the editor survives the rollback");
|
||||
assert_eq!(layer_doc(&app.session, LayerId(lid)).unwrap().text, "abcdef", "Cancel restores the pre-dialog text");
|
||||
assert_ne!(restored.1, preview.1, "the restored editor starts a new epoch");
|
||||
assert_eq!((restored.2, restored.3), (2, 2), "the caret stays in the restored document");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
|
||||
if r.min.y > area.max.y {
|
||||
break;
|
||||
}
|
||||
let resp = ui.interact(r, egui::Id::new(("learn-card", &tut.id)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("learn-card", &tut.id)), Sense::click());
|
||||
p.rect_filled(r, 8.0, if resp.hovered() { Color32::from_rgb(0x2c, 0x2e, 0x37) } else { Color32::from_rgb(0x24, 0x26, 0x2e) });
|
||||
// A small original badge: the step count in a circle.
|
||||
let badge = Rect::from_min_size(pos2(r.min.x + 16.0, r.min.y + 16.0), vec2(44.0, 44.0));
|
||||
@@ -50,7 +50,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
|
||||
p.galley(pos2(tx, r.min.y + 84.0), g, t.text_faint);
|
||||
let br = Rect::from_min_size(pos2(r.max.x - 104.0, r.min.y + 16.0), vec2(88.0, 28.0));
|
||||
let label = if running.as_deref() == Some(tut.id.as_str()) { "Resume" } else { "Start" };
|
||||
let clicked = widgets::text_button(ui, br, label, true, &t, egui::Id::new(("learn-start", &tut.id))).clicked();
|
||||
let clicked = widgets::text_button(ui, br, label, true, &t, egui::Id::unique(("learn-start", &tut.id))).clicked();
|
||||
app.auto.add(&format!("home.learn.{}", tut.id), r, &tut.title);
|
||||
app.auto.add(&format!("home.learn.{}.start", tut.id), br, label);
|
||||
if clicked || resp.clicked() {
|
||||
@@ -92,7 +92,7 @@ pub fn coach(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
{
|
||||
let time = ctx.input(|i| i.time);
|
||||
let pulse = (0.5 + 0.5 * (time * 4.0).sin()) as f32;
|
||||
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("learn-highlight")));
|
||||
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("learn-highlight")));
|
||||
let hr = r.expand(3.0 + 2.0 * pulse);
|
||||
p.rect_stroke(hr, 5.0, Stroke::new(2.0, t.accent.gamma_multiply(0.6 + 0.4 * pulse)), StrokeKind::Outside);
|
||||
app.auto.add("learn.highlight", hr, tp);
|
||||
@@ -100,7 +100,7 @@ pub fn coach(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
let w = 340.0;
|
||||
let mut action: Option<&str> = None;
|
||||
let area = egui::Area::new(egui::Id::new("learn-coach"))
|
||||
let area = egui::Area::new(egui::Id::unique("learn-coach"))
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(pos2(screen.max.x - w - 20.0, screen.max.y - 250.0))
|
||||
.show(ctx, |ui| {
|
||||
|
||||
@@ -244,7 +244,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
// ---------------------------------------------------------------- timeline drawing + gestures
|
||||
|
||||
fn drag_state_id() -> egui::Id {
|
||||
egui::Id::new("marker-drag")
|
||||
egui::Id::unique("marker-drag")
|
||||
}
|
||||
|
||||
/// Snap `t` (comp seconds) to the CTI, the work area and other marker times within 8 px.
|
||||
@@ -301,7 +301,7 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp:
|
||||
p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text);
|
||||
}
|
||||
let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, y1));
|
||||
let id = egui::Id::new(("marker", r.layer, r.index));
|
||||
let id = egui::Id::unique(("marker", r.layer, r.index));
|
||||
let resp = ui.interact(hit, id, Sense::click_and_drag());
|
||||
app.auto.add(&r.auto_id(), hit, if m.comment.is_empty() { "marker" } else { &m.comment });
|
||||
let tip = if m.comment.is_empty() { "Marker".to_string() } else { m.comment.clone() };
|
||||
@@ -391,7 +391,7 @@ pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &C
|
||||
/// one undo step. Dropped back on the bin or outside the time graph's width, nothing is added.
|
||||
pub(crate) fn marker_bin(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &Comp, tm: TMap, bin: Rect, strip: Rect) {
|
||||
let t = app.tokens;
|
||||
let resp = ui.interact(bin, egui::Id::new("marker-bin"), Sense::drag()).on_hover_text(tr("Composition marker bin: drag a marker to the time ruler"));
|
||||
let resp = ui.interact(bin, egui::Id::unique("marker-bin"), Sense::drag()).on_hover_text(tr("Composition marker bin: drag a marker to the time ruler"));
|
||||
app.auto.add("timeline.markerBin", bin, "Composition marker bin");
|
||||
let hot = resp.hovered() || resp.dragged();
|
||||
if hot {
|
||||
@@ -436,7 +436,7 @@ fn nested_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
||||
return;
|
||||
}
|
||||
// Where they land, once per revision (with time remapping that samples the layer's frames).
|
||||
let (key, rev) = (egui::Id::new(("nested-markers", cid.0, layer.id.0)), app.session.revision);
|
||||
let (key, rev) = (egui::Id::unique(("nested-markers", cid.0, layer.id.0)), app.session.revision);
|
||||
let cached = ui.ctx().data(|d| d.get_temp::<(u64, Vec<(Tick, usize)>)>(key)).filter(|(r, _)| *r == rev);
|
||||
let at = cached.map(|(_, v)| v).unwrap_or_else(|| {
|
||||
let v: Vec<(Tick, usize)> =
|
||||
@@ -457,7 +457,7 @@ fn nested_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
||||
}
|
||||
let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, r.max.y - 3.0));
|
||||
let tip = if m.comment.is_empty() { format!("Marker in {name}") } else { format!("{} (marker in {name})", m.comment) };
|
||||
let resp = ui.interact(hit, egui::Id::new(("nested-marker", layer.id.0, i)), Sense::click()).on_hover_text(&tip);
|
||||
let resp = ui.interact(hit, egui::Id::unique(("nested-marker", layer.id.0, i)), Sense::click()).on_hover_text(&tip);
|
||||
app.auto.add(&format!("timeline.layer.{}.nestedMarker.{i}", layer.id.0), hit, &tip);
|
||||
if resp.double_clicked() {
|
||||
let opened =
|
||||
|
||||
@@ -29,7 +29,7 @@ type Listing = (f64, Arc<Result<Vec<mb::Entry>, String>>);
|
||||
|
||||
/// Thumbnail of a file (cached per path; `None` while not decoded or not decodable).
|
||||
fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut u32) -> Thumb {
|
||||
let id = egui::Id::new(("mb-thumb", path));
|
||||
let id = egui::Id::unique(("mb-thumb", path));
|
||||
if let Some(t) = ctx.data(|d| d.get_temp::<Thumb>(id)) {
|
||||
return t;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let st = app.session.state.media_browser.clone();
|
||||
let dir = st.folder.clone().unwrap_or_else(|| b.home());
|
||||
// Listing cached per folder for a second.
|
||||
let lid = egui::Id::new(("mb-list", dir.clone(), st.importable_only));
|
||||
let lid = egui::Id::unique(("mb-list", dir.clone(), st.importable_only));
|
||||
let now = ctx.input(|i| i.time);
|
||||
let listing: Option<Listing> = ctx.data(|d| d.get_temp(lid));
|
||||
let entries = match listing {
|
||||
@@ -137,7 +137,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
);
|
||||
for (i, (name, path)) in places.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("mb-fav", i)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("mb-fav", i)), Sense::click());
|
||||
if *path == dir {
|
||||
p.rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
@@ -162,7 +162,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let tile = vec2(140.0, 112.0);
|
||||
let cols = ((list.width() - 8.0) / tile.x).floor().max(1.0) as usize;
|
||||
let mut budget = 2u32;
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("mb-scroll"), list);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("mb-scroll"), list);
|
||||
let lp = p.with_clip_rect(list);
|
||||
let mut actions: Vec<(&str, Value)> = vec![];
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
@@ -173,7 +173,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if !r.intersects(list) {
|
||||
continue;
|
||||
}
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::new(("mb-entry", &e.path)), Sense::click_and_drag());
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::unique(("mb-entry", &e.path)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("mediaBrowser.entry.{}", e.name), r, &e.name);
|
||||
lp.rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.row });
|
||||
let thumb = Rect::from_min_max(r.min + vec2(6.0, 6.0), pos2(r.max.x - 6.0, r.max.y - 26.0));
|
||||
@@ -264,7 +264,7 @@ fn rows_of(v: &Value) -> Vec<(String, String)> {
|
||||
|
||||
/// An editable comment field; returns the edited text when it changes.
|
||||
fn comment_field(ui: &mut egui::Ui, r: Rect, id: &str, text: &str) -> Option<String> {
|
||||
let key = egui::Id::new(("meta-comment", id));
|
||||
let key = egui::Id::unique(("meta-comment", id));
|
||||
let mut buf: String = ui.data(|d| d.get_temp(key)).unwrap_or_else(|| text.to_string());
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r));
|
||||
let resp = child.add(egui::TextEdit::multiline(&mut buf).desired_width(r.width()).desired_rows(2).hint_text(tr("Add a comment")));
|
||||
@@ -438,8 +438,8 @@ mod tests {
|
||||
kind: Some("video"),
|
||||
})
|
||||
.collect();
|
||||
let old_id = egui::Id::new("old-list");
|
||||
let new_id = egui::Id::new("new-list");
|
||||
let old_id = egui::Id::unique("old-list");
|
||||
let new_id = egui::Id::unique("new-list");
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(old_id, (0.0, Ok::<_, String>(entries.clone())));
|
||||
d.insert_temp(new_id, (0.0, Arc::new(Ok::<_, String>(entries))));
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
// The panel scrolls when it is shorter than its controls (the default workspace shows just
|
||||
// the transport row, as in After Effects).
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("preview-scroll"), rect);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("preview-scroll"), rect);
|
||||
let panel_clip = ui.clip_rect();
|
||||
ui.set_clip_rect(rect.intersect(panel_clip));
|
||||
let y = rect.min.y + 12.0 - scroll.offset;
|
||||
@@ -42,7 +42,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
for (icon, cmd, tip) in buttons {
|
||||
let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0));
|
||||
let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon };
|
||||
if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::new(("pv", cmd))).on_hover_text(tip).clicked() {
|
||||
if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::unique(("pv", cmd))).on_hover_text(tip).clicked() {
|
||||
// The play button plays with the options of the shortcut shown below.
|
||||
let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) };
|
||||
let _ = crate::menus::invoke(app, &ctx, cmd, params);
|
||||
@@ -117,7 +117,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() {
|
||||
let lx = xv + k as f32 * 100.0;
|
||||
p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(lx + 52.0, yy - 9.0), egui::Id::new(("pv-roll", key)), v, 0.05, (0.0, 600.0), 1, "s", &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(lx + 52.0, yy - 9.0), egui::Id::unique(("pv-roll", key)), v, 0.05, (0.0, 600.0), 1, "s", &t);
|
||||
app.auto.add(&format!("preview.{key}"), r, text);
|
||||
if let Some(nv) = nv {
|
||||
changes.push(json!({"shortcut": current.id(), key: nv}));
|
||||
@@ -160,7 +160,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let sx = xv + 106.0;
|
||||
p.text(pos2(sx, yy), Align2::LEFT_CENTER, tr("Skip"), Tokens::ui(12.0), t.text_dim);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(sx + 30.0, yy - 9.0), egui::Id::new("pv-skip"), o.skip as f64, 0.1, (0.0, 99.0), 0, "", &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(sx + 30.0, yy - 9.0), egui::Id::unique("pv-skip"), o.skip as f64, 0.1, (0.0, 99.0), 0, "", &t);
|
||||
app.auto.add("preview.skip", r, "Skip");
|
||||
if let Some(nv) = nv {
|
||||
changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32}));
|
||||
@@ -182,7 +182,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
if o.resolution == PreviewResolution::Custom {
|
||||
let (r, nv, _) =
|
||||
widgets::hot_number_at(ui, pos2(xv + 106.0, yy - 9.0), egui::Id::new("pv-custom-res"), o.custom_resolution as f64, 0.1, (1.0, 40.0), 0, "", &t);
|
||||
widgets::hot_number_at(ui, pos2(xv + 106.0, yy - 9.0), egui::Id::unique("pv-custom-res"), o.custom_resolution as f64, 0.1, (1.0, 40.0), 0, "", &t);
|
||||
app.auto.add("preview.customResolution", r, "Custom Resolution");
|
||||
if let Some(nv) = nv {
|
||||
changes.push(json!({"shortcut": current.id(), "customResolution": nv.round() as u32}));
|
||||
@@ -240,7 +240,7 @@ fn fmt_rate(r: f64) -> String {
|
||||
fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: egui::Pos2, key: &str, text: &str, on: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(pos2(at.x, at.y - 8.0), vec2(16.0, 16.0));
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("pv-check", key))).clicked();
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("pv-check", key))).clicked();
|
||||
p.text(pos2(r.max.x + 5.0, at.y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
|
||||
app.auto.add(&format!("preview.{key}"), r, text);
|
||||
clicked
|
||||
@@ -250,7 +250,7 @@ fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at:
|
||||
/// returns the chosen index.
|
||||
fn dropdown_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, key: &str, text: &str, items: &[String], current: Option<usize>) -> Option<usize> {
|
||||
let t = app.tokens;
|
||||
let pid = egui::Id::new(("pv-pop", key));
|
||||
let pid = egui::Id::unique(("pv-pop", key));
|
||||
if widgets::dropdown(ui, r, text, &t, pid.with("btn")).clicked() {
|
||||
let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false));
|
||||
ui.data_mut(|d| d.insert_temp(pid.with("open"), !open));
|
||||
@@ -318,7 +318,7 @@ pub fn audio(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let clip = Rect::from_min_max(pos2(bar.min.x, top - 14.0), pos2(bar.max.x, top - 4.0));
|
||||
p.rect_filled(clip, 1.0, if m.clipped[c] { red } else { t.field_bg });
|
||||
let id = if c == 0 { "audio.clipLeft" } else { "audio.clipRight" };
|
||||
if ui.interact(clip, egui::Id::new(id), Sense::click()).clicked() {
|
||||
if ui.interact(clip, egui::Id::unique(id), Sense::click()).clicked() {
|
||||
app.meter.clipped[c] = false;
|
||||
}
|
||||
app.auto.add(id, clip, if c == 0 { "Left clip indicator" } else { "Right clip indicator" });
|
||||
@@ -370,7 +370,7 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let nodes = app.session.history_tree();
|
||||
let row_h = 22.0;
|
||||
let cur = nodes.iter().position(|n| n.current).unwrap_or(0);
|
||||
let scroll_id = egui::Id::new("history-scroll");
|
||||
let scroll_id = egui::Id::unique("history-scroll");
|
||||
let mut scroll = widgets::PanelScroll::begin(ui, scroll_id, rect);
|
||||
// The current state scrolls into view when it changes (undo, redo, a new step).
|
||||
if ui.data(|d| d.get_temp::<usize>(scroll_id.with("current"))) != Some(cur) {
|
||||
@@ -390,7 +390,7 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
for n in nodes.iter().skip(first).take(visible) {
|
||||
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), row_h));
|
||||
y += row_h;
|
||||
let resp = ui.interact(r, egui::Id::new(("hist", n.id.as_str())), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("hist", n.id.as_str())), Sense::click());
|
||||
if n.current {
|
||||
p.rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
@@ -423,7 +423,7 @@ pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let Some(c) = app.session.active_comp_arc() else { return };
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("markers-scroll"), rect);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("markers-scroll"), rect);
|
||||
let mut y = rect.min.y + 8.0 - scroll.offset;
|
||||
if c.markers.is_empty() {
|
||||
p.text(rect.center(), Align2::CENTER_CENTER, tr("No composition markers"), Tokens::ui(12.0), t.text_faint);
|
||||
@@ -437,7 +437,7 @@ pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let tc = crate::panels::timecode(&app.session, &c, m.time);
|
||||
p.text(pos2(r.min.x + 12.0, r.center().y), Align2::LEFT_CENTER, tc, Tokens::mono(11.5), t.timecode);
|
||||
p.text(pos2(r.min.x + 120.0, r.center().y), Align2::LEFT_CENTER, &m.comment, Tokens::ui(12.0), t.text);
|
||||
if ui.interact(r, egui::Id::new(("mk", m.time.0)), Sense::click()).clicked() {
|
||||
if ui.interact(r, egui::Id::unique(("mk", m.time.0)), Sense::click()).clicked() {
|
||||
app.session.set_time(m.time);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
|
||||
|
||||
/// The ≡ panel menu (opened from a tab).
|
||||
pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
let id = egui::Id::new("panel-menu");
|
||||
let id = egui::Id::unique("panel-menu");
|
||||
let Some((panel, pos)) = ui.ctx().data(|d| d.get_temp::<(PanelKind, egui::Pos2)>(id)) else { return };
|
||||
// The menu opens in the window that shows the panel (a panel can have its own window).
|
||||
if app.panel_viewport(ui.ctx(), panel) != ui.ctx().viewport_id() {
|
||||
|
||||
@@ -36,7 +36,7 @@ fn number(
|
||||
let t = app.tokens;
|
||||
p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), if enabled { t.text_dim } else { t.text_faint });
|
||||
let lw = p.layout_no_wrap(label.to_string(), Tokens::ui(11.5), t.text).size().x;
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + lw + 6.0, y), egui::Id::new(("paint-num", key)), v, 0.5, range, 0, suffix, &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + lw + 6.0, y), egui::Id::unique(("paint-num", key)), v, 0.5, range, 0, suffix, &t);
|
||||
app.auto.add(key, r, label);
|
||||
nv.filter(|_| enabled)
|
||||
}
|
||||
@@ -58,8 +58,8 @@ fn popup_row(
|
||||
let t = app.tokens;
|
||||
p.text(pos2(x, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), if enabled { t.text_dim } else { t.text_faint });
|
||||
let r = Rect::from_min_size(pos2(x + 70.0, y), vec2((w - 70.0).max(60.0), 20.0));
|
||||
let pop = egui::Id::new(("paint-pop", key));
|
||||
if widgets::dropdown(ui, r, options.get(cur).map(String::as_str).unwrap_or(""), &t, egui::Id::new(("paint-dd", key))).clicked() && enabled {
|
||||
let pop = egui::Id::unique(("paint-pop", key));
|
||||
if widgets::dropdown(ui, r, options.get(cur).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("paint-dd", key))).clicked() && enabled {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(key, r, label);
|
||||
@@ -70,6 +70,15 @@ fn rgb(c: [f64; 4]) -> [f32; 3] {
|
||||
[c[0] as f32, c[1] as f32, c[2] as f32]
|
||||
}
|
||||
|
||||
/// The swap-colours icon: one arrow right above one arrow left.
|
||||
fn paint_swap_icon(p: &egui::Painter, rect: Rect, color: Color32) {
|
||||
let stroke = Stroke::new(1.0, color);
|
||||
let (left, right) = (rect.left() + 2.0, rect.right() - 2.0);
|
||||
let (top, bottom) = (rect.top() + 3.0, rect.bottom() - 3.0);
|
||||
p.arrow(pos2(left, top), vec2(right - left, 0.0), stroke);
|
||||
p.arrow(pos2(right, bottom), vec2(left - right, 0.0), stroke);
|
||||
}
|
||||
|
||||
pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
@@ -91,33 +100,35 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Colour swatches (foreground over background) with swap / reset.
|
||||
let fg = Rect::from_min_size(pos2(x0, y), vec2(26.0, 20.0));
|
||||
let bg = Rect::from_min_size(pos2(x0 + 14.0, y + 10.0), vec2(26.0, 20.0));
|
||||
widgets::swatch(ui, bg, [o.background[0] as f32, o.background[1] as f32, o.background[2] as f32, 1.0], egui::Id::new("paint-bg"), &t);
|
||||
let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::new("paint-bg-hit"), Sense::click());
|
||||
widgets::swatch(ui, bg, [o.background[0] as f32, o.background[1] as f32, o.background[2] as f32, 1.0], egui::Id::unique("paint-bg"), &t);
|
||||
let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::unique("paint-bg-hit"), Sense::click());
|
||||
if bresp.clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("paint-bg-pop"));
|
||||
widgets::open_popup(ui, egui::Id::unique("paint-bg-pop"));
|
||||
}
|
||||
if widgets::swatch(ui, fg, [o.color[0] as f32, o.color[1] as f32, o.color[2] as f32, 1.0], egui::Id::new("paint-fg"), &t).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("paint-fg-pop"));
|
||||
if widgets::swatch(ui, fg, [o.color[0] as f32, o.color[1] as f32, o.color[2] as f32, 1.0], egui::Id::unique("paint-fg"), &t).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::unique("paint-fg-pop"));
|
||||
}
|
||||
app.auto.add("paint.color", fg, "Foreground color");
|
||||
app.auto.add("paint.background", bg, "Background color");
|
||||
let mut fc = rgb(o.color);
|
||||
if crate::widgets::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) {
|
||||
if crate::widgets::color_popup(ui, egui::Id::unique("paint-fg-pop"), fg.left_bottom(), &mut fc) {
|
||||
set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
|
||||
}
|
||||
let mut bc = rgb(o.background);
|
||||
if crate::widgets::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) {
|
||||
if crate::widgets::color_popup(ui, egui::Id::unique("paint-bg-pop"), bg.left_bottom(), &mut bc) {
|
||||
set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
|
||||
}
|
||||
let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
|
||||
let sresp = ui.interact(swap, egui::Id::new("paint-swap"), Sense::click());
|
||||
p.text(swap.center(), Align2::CENTER_CENTER, "⇄", Tokens::ui(12.0), if sresp.hovered() { t.text } else { t.text_dim });
|
||||
let sresp = ui.interact(swap, egui::Id::unique("paint-swap"), Sense::click());
|
||||
// Two arrows (foreground over background): drawn, so the icon does not depend on a font
|
||||
// carrying U+21C4 (the bundled fonts do not).
|
||||
paint_swap_icon(&p, swap, if sresp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add("paint.swapColors", swap, "Swap colors");
|
||||
if sresp.on_hover_text(tr("Swap Paint Colors (X)")).clicked() {
|
||||
set(app, json!({"color": o.background, "background": o.color}));
|
||||
}
|
||||
let reset = Rect::from_min_size(pos2(x0 + 46.0, y + 18.0), vec2(18.0, 14.0));
|
||||
let rresp = ui.interact(reset, egui::Id::new("paint-reset-colors"), Sense::click());
|
||||
let rresp = ui.interact(reset, egui::Id::unique("paint-reset-colors"), Sense::click());
|
||||
p.rect_filled(Rect::from_min_size(reset.min + vec2(4.0, 3.0), vec2(7.0, 6.0)), 0.0, Color32::WHITE);
|
||||
p.rect_stroke(Rect::from_min_size(reset.min + vec2(8.0, 6.0), vec2(7.0, 6.0)), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
|
||||
app.auto.add("paint.resetColors", reset, "Default colors");
|
||||
@@ -146,7 +157,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let (r, nv, _) = widgets::hot_number_at(
|
||||
ui,
|
||||
pos2(x0 + w - 52.0, y + 1.0),
|
||||
egui::Id::new("paint-custom-frames"),
|
||||
egui::Id::unique("paint-custom-frames"),
|
||||
o.custom_frames as f64,
|
||||
0.2,
|
||||
(1.0, 10000.0),
|
||||
@@ -174,7 +185,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, tr("Preset:"), Tokens::ui(11.5), t.text_dim);
|
||||
for i in 1..=5u32 {
|
||||
let r = Rect::from_min_size(pos2(x0 + 56.0 + (i - 1) as f32 * 26.0, y), vec2(22.0, 20.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("clone-preset", i)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("clone-preset", i)), Sense::click());
|
||||
let on = o.clone_preset == i;
|
||||
p.rect_filled(
|
||||
r,
|
||||
@@ -210,7 +221,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
.enumerate()
|
||||
{
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * (w / 2.0), y), vec2(18.0, 20.0));
|
||||
if widgets::checkbox(ui, r, on, &t, egui::Id::new(key)).clicked() {
|
||||
if widgets::checkbox(ui, r, on, &t, egui::Id::unique(key)).clicked() {
|
||||
set(app, json!({k: !on}));
|
||||
}
|
||||
p.text(pos2(r.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
|
||||
@@ -220,7 +231,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let off = o.clone_offset.unwrap_or([0.0, 0.0]);
|
||||
p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Offset:"), Tokens::ui(11.5), t.text_dim);
|
||||
for (d, v) in off.iter().enumerate() {
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 56.0 + d as f32 * 64.0, y), egui::Id::new(("clone-off", d)), *v, 1.0, (-1e5, 1e5), 0, "", &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 56.0 + d as f32 * 64.0, y), egui::Id::unique(("clone-off", d)), *v, 1.0, (-1e5, 1e5), 0, "", &t);
|
||||
app.auto.add(&format!("paint.cloneOffset.{d}"), r, "Clone offset");
|
||||
if let Some(nv) = nv {
|
||||
let mut no = off;
|
||||
@@ -237,7 +248,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
y += 26.0;
|
||||
let r = Rect::from_min_size(pos2(x0, y), vec2(18.0, 20.0));
|
||||
if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::new("paint.showOverlay")).clicked() {
|
||||
if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::unique("paint.showOverlay")).clicked() {
|
||||
set(app, json!({"showOverlay": !o.show_overlay}));
|
||||
}
|
||||
app.auto.add("paint.showOverlay", r, "Clone Source Overlay");
|
||||
@@ -246,7 +257,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
set(app, json!({"overlayOpacity": nv}));
|
||||
}
|
||||
let dr = Rect::from_min_size(pos2(x0 + w - 18.0, y), vec2(18.0, 20.0));
|
||||
if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::new("paint.overlayDifference")).clicked() {
|
||||
if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::unique("paint.overlayDifference")).clicked() {
|
||||
set(app, json!({"overlayDifference": !o.overlay_difference}));
|
||||
}
|
||||
app.auto.add("paint.overlayDifference", dr, "Difference mode overlay");
|
||||
@@ -287,7 +298,7 @@ pub fn brushes(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let cols = ((w / cell).floor() as usize).max(1);
|
||||
for (i, tip) in BRUSH_PRESETS.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + (i % cols) as f32 * cell, y + (i / cols) as f32 * cell), vec2(cell - 2.0, cell - 2.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("brush-preset", i)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("brush-preset", i)), Sense::click());
|
||||
let on = o.preset == Some(i);
|
||||
p.rect_filled(
|
||||
r,
|
||||
@@ -332,7 +343,7 @@ pub fn brushes(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Spacing (checkbox in AE turns spacing on; we always space dabs, so it shows the value).
|
||||
p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Spacing:"), Tokens::ui(11.5), t.text_dim);
|
||||
let _ = r;
|
||||
let (sr, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 58.0, y), egui::Id::new("brushes-spacing"), o.spacing, 0.5, (1.0, 1000.0), 0, " %", &t);
|
||||
let (sr, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 58.0, y), egui::Id::unique("brushes-spacing"), o.spacing, 0.5, (1.0, 1000.0), 0, " %", &t);
|
||||
app.auto.add("brushes.spacing", sr, "Spacing");
|
||||
if let Some(nv) = nv {
|
||||
set(app, json!({"spacing": nv}));
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{EffectcraftApp, widgets};
|
||||
/// A dropdown; returns the newly chosen index.
|
||||
pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, options: &[&str], cur: usize) -> Option<usize> {
|
||||
let t = app.tokens;
|
||||
let id = egui::Id::new(("kit-dd", auto));
|
||||
let id = egui::Id::unique(("kit-dd", auto));
|
||||
let text = options.get(cur).copied().unwrap_or("");
|
||||
if widgets::dropdown(ui, r, text, &t, id).clicked() {
|
||||
widgets::open_popup(ui, id);
|
||||
@@ -23,7 +23,7 @@ pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str
|
||||
pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("kit-cb", auto))).clicked();
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("kit-cb", auto))).clicked();
|
||||
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
|
||||
let full = Rect::from_min_size(at, vec2(22.0 + text.len() as f32 * 6.5, 16.0));
|
||||
app.auto.add(auto, full, text);
|
||||
@@ -33,7 +33,7 @@ pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, aut
|
||||
/// A text button; true when clicked.
|
||||
pub fn button(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, text: &str, primary: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let clicked = widgets::text_button(ui, r, text, primary, &t, egui::Id::new(("kit-btn", auto))).clicked();
|
||||
let clicked = widgets::text_button(ui, r, text, primary, &t, egui::Id::unique(("kit-btn", auto))).clicked();
|
||||
app.auto.add(auto, r, text);
|
||||
clicked
|
||||
}
|
||||
@@ -47,7 +47,7 @@ pub fn label(ui: &egui::Ui, at: egui::Pos2, text: &str, t: &Tokens) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 {
|
||||
let t = app.tokens;
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::new(("kit-n", auto)), v, speed, range, dec, suffix, &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("kit-n", auto)), v, speed, range, dec, suffix, &t);
|
||||
app.auto.add(auto, r, &format!("{v}"));
|
||||
nv.unwrap_or(v)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut ui = area(ui, rect);
|
||||
let loop_id = egui::Id::new("createNulls.loop");
|
||||
let loop_id = egui::Id::unique("createNulls.loop");
|
||||
let mut looping: bool = ui.data(|d| d.get_temp(loop_id)).unwrap_or(false);
|
||||
let ready = app.session.is_enabled("paths.nullsFollowPoints") && !app.session.state.selected_layers.is_empty();
|
||||
ui.label(RichText::new(tr("Select a layer with a mask or shape path (or the path property), or Puppet pins.")).color(t.text_dim).small());
|
||||
@@ -70,7 +70,7 @@ pub fn vr_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.label(RichText::new(tr("No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…")).color(t.text_faint));
|
||||
return;
|
||||
}
|
||||
let sel_id = egui::Id::new("vrEditor.selected");
|
||||
let sel_id = egui::Id::unique("vrEditor.selected");
|
||||
let active = app.session.state.active_comp.map(|c| c.0);
|
||||
let mut sel: u64 = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0);
|
||||
// Default: the active comp's environment, else the first.
|
||||
|
||||
@@ -209,7 +209,7 @@ pub fn navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
};
|
||||
let g = p.layout_no_wrap(label.clone(), Tokens::ui(11.5), col);
|
||||
let r = Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(g.size().x + 12.0, rect.height() - 4.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("comp-nav", id.0)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("comp-nav", id.0)), Sense::click());
|
||||
if active {
|
||||
p.rect_filled(r, 3.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
@@ -223,7 +223,7 @@ pub fn navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
x = r.max.x + 2.0;
|
||||
}
|
||||
let fr = Rect::from_min_size(pos2(rect.max.x - 26.0, rect.min.y + 2.0), vec2(22.0, rect.height() - 4.0));
|
||||
if crate::widgets::icon_button(ui, fr, crate::icons::Icon::Flowchart, false, &t, egui::Id::new("comp-nav-flow"))
|
||||
if crate::widgets::icon_button(ui, fr, crate::icons::Icon::Flowchart, false, &t, egui::Id::unique("comp-nav-flow"))
|
||||
.on_hover_text(tr("Composition Mini-Flowchart"))
|
||||
.clicked()
|
||||
{
|
||||
@@ -248,64 +248,65 @@ pub fn mini_flowchart(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let downs = downstream(&proj, cid);
|
||||
let mut open = None;
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(egui::Id::new("mini-flowchart")).order(egui::Order::Foreground).constrain(true).fixed_pos(pos2(at[0], at[1])).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(t.panel_bg).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
|
||||
let col_w = 160.0;
|
||||
let rows = ups.len().max(downs.len()).max(1) as f32;
|
||||
let h = rows * 26.0 + 8.0;
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(col_w * 3.0 + 60.0, h), Sense::hover());
|
||||
let p = ui.painter().clone();
|
||||
let node = |ui: &mut egui::Ui, app: &mut EffectcraftApp, r: Rect, id: ItemId, active: bool| {
|
||||
let resp = ui.interact(r, egui::Id::new(("mfc", id.0)), Sense::click());
|
||||
p.rect_filled(
|
||||
r,
|
||||
4.0,
|
||||
if active {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.field_bg
|
||||
},
|
||||
);
|
||||
p.text(r.center(), Align2::CENTER_CENTER, name(&proj, id), Tokens::ui(11.5), if active { Color32::WHITE } else { t.text });
|
||||
app.auto.add(&format!("flowchart.mini.{}", id.0), r, &name(&proj, id));
|
||||
resp.clicked()
|
||||
};
|
||||
let mid = Rect::from_center_size(rect.center(), vec2(col_w, 22.0));
|
||||
if node(ui, app, mid, cid, true) {
|
||||
close = true;
|
||||
}
|
||||
let col = |i: usize, n: usize, x: f32| {
|
||||
let y0 = rect.center().y - (n as f32 * 26.0) / 2.0 + 13.0;
|
||||
Rect::from_center_size(pos2(x, y0 + i as f32 * 26.0), vec2(col_w, 22.0))
|
||||
};
|
||||
let arrow = |a: egui::Pos2, b: egui::Pos2| {
|
||||
p.line_segment([a, b], Stroke::new(1.2, t.text_dim));
|
||||
let d = (b - a).normalized();
|
||||
let n = vec2(-d.y, d.x);
|
||||
p.add(egui::Shape::convex_polygon(vec![b, b - d * 7.0 + n * 3.5, b - d * 7.0 - n * 3.5], t.text_dim, Stroke::NONE));
|
||||
};
|
||||
for (i, u) in ups.iter().enumerate() {
|
||||
let r = col(i, ups.len(), rect.min.x + col_w / 2.0);
|
||||
// Upstream comps contain the active one: arrow from the nested comp up into them.
|
||||
arrow(mid.left_center(), r.right_center());
|
||||
if node(ui, app, r, *u, false) {
|
||||
open = Some(*u);
|
||||
let area =
|
||||
egui::Area::new(egui::Id::unique("mini-flowchart")).order(egui::Order::Foreground).constrain(true).fixed_pos(pos2(at[0], at[1])).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(t.panel_bg).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
|
||||
let col_w = 160.0;
|
||||
let rows = ups.len().max(downs.len()).max(1) as f32;
|
||||
let h = rows * 26.0 + 8.0;
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(col_w * 3.0 + 60.0, h), Sense::hover());
|
||||
let p = ui.painter().clone();
|
||||
let node = |ui: &mut egui::Ui, app: &mut EffectcraftApp, r: Rect, id: ItemId, active: bool| {
|
||||
let resp = ui.interact(r, egui::Id::unique(("mfc", id.0)), Sense::click());
|
||||
p.rect_filled(
|
||||
r,
|
||||
4.0,
|
||||
if active {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.field_bg
|
||||
},
|
||||
);
|
||||
p.text(r.center(), Align2::CENTER_CENTER, name(&proj, id), Tokens::ui(11.5), if active { Color32::WHITE } else { t.text });
|
||||
app.auto.add(&format!("flowchart.mini.{}", id.0), r, &name(&proj, id));
|
||||
resp.clicked()
|
||||
};
|
||||
let mid = Rect::from_center_size(rect.center(), vec2(col_w, 22.0));
|
||||
if node(ui, app, mid, cid, true) {
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
for (i, d) in downs.iter().enumerate() {
|
||||
let r = col(i, downs.len(), rect.max.x - col_w / 2.0);
|
||||
arrow(r.left_center(), mid.right_center());
|
||||
if node(ui, app, r, *d, false) {
|
||||
open = Some(*d);
|
||||
let col = |i: usize, n: usize, x: f32| {
|
||||
let y0 = rect.center().y - (n as f32 * 26.0) / 2.0 + 13.0;
|
||||
Rect::from_center_size(pos2(x, y0 + i as f32 * 26.0), vec2(col_w, 22.0))
|
||||
};
|
||||
let arrow = |a: egui::Pos2, b: egui::Pos2| {
|
||||
p.line_segment([a, b], Stroke::new(1.2, t.text_dim));
|
||||
let d = (b - a).normalized();
|
||||
let n = vec2(-d.y, d.x);
|
||||
p.add(egui::Shape::convex_polygon(vec![b, b - d * 7.0 + n * 3.5, b - d * 7.0 - n * 3.5], t.text_dim, Stroke::NONE));
|
||||
};
|
||||
for (i, u) in ups.iter().enumerate() {
|
||||
let r = col(i, ups.len(), rect.min.x + col_w / 2.0);
|
||||
// Upstream comps contain the active one: arrow from the nested comp up into them.
|
||||
arrow(mid.left_center(), r.right_center());
|
||||
if node(ui, app, r, *u, false) {
|
||||
open = Some(*u);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ups.is_empty() && downs.is_empty() {
|
||||
p.text(pos2(rect.center().x, rect.max.y - 4.0), Align2::CENTER_BOTTOM, tr("No nested compositions"), Tokens::ui(10.5), t.text_faint);
|
||||
}
|
||||
for (i, d) in downs.iter().enumerate() {
|
||||
let r = col(i, downs.len(), rect.max.x - col_w / 2.0);
|
||||
arrow(r.left_center(), mid.right_center());
|
||||
if node(ui, app, r, *d, false) {
|
||||
open = Some(*d);
|
||||
}
|
||||
}
|
||||
if ups.is_empty() && downs.is_empty() {
|
||||
p.text(pos2(rect.center().x, rect.max.y - 4.0), Align2::CENTER_BOTTOM, tr("No nested compositions"), Tokens::ui(10.5), t.text_faint);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let outside = crate::widgets::pressed_outside(ctx, &area.response);
|
||||
if let Some(id) = open {
|
||||
app.session.open_comp(id);
|
||||
|
||||
@@ -126,8 +126,8 @@ type PendingThumb = (u64, Arc<Mutex<Option<Option<(u32, u32, Vec<u8>)>>>>);
|
||||
/// Rendered on a background thread (inline on wasm32): until the new one lands the previous
|
||||
/// thumbnail stays up, so selecting a big comp or editing it never stalls the panel.
|
||||
fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, it: &Item) -> Option<egui::TextureHandle> {
|
||||
let key = egui::Id::new(("proj-thumb", it.id.0));
|
||||
let pkey = egui::Id::new(("proj-thumb-pending", it.id.0));
|
||||
let key = egui::Id::unique(("proj-thumb", it.id.0));
|
||||
let pkey = egui::Id::unique(("proj-thumb-pending", it.id.0));
|
||||
let rev = app.session.revision;
|
||||
let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(key));
|
||||
let mut tex = cached.as_ref().map(|(_, t)| t.clone());
|
||||
@@ -192,7 +192,7 @@ fn fit(r: Rect, w: f32, h: f32) -> Rect {
|
||||
/// An inline text edit in progress: (item, field `name`|`comment`, text).
|
||||
type Editing = (u64, String, String);
|
||||
fn edit_id() -> egui::Id {
|
||||
egui::Id::new("proj-inline-edit")
|
||||
egui::Id::unique("proj-inline-edit")
|
||||
}
|
||||
|
||||
/// Start renaming the selected item (Enter in the Project panel).
|
||||
@@ -206,27 +206,27 @@ pub fn begin_rename(app: &EffectcraftApp, ctx: &egui::Context) {
|
||||
|
||||
/// Where a Shift-extended selection starts (the last item clicked or moved to without Shift).
|
||||
fn anchor_id() -> egui::Id {
|
||||
egui::Id::new("project-selection-anchor")
|
||||
egui::Id::unique("project-selection-anchor")
|
||||
}
|
||||
|
||||
/// The item the arrow keys move from: the last item clicked or moved to.
|
||||
fn cursor_id() -> egui::Id {
|
||||
egui::Id::new("project-selection-cursor")
|
||||
egui::Id::unique("project-selection-cursor")
|
||||
}
|
||||
|
||||
/// An item row's widget: its click / drag area, keyboard focus and accessibility node.
|
||||
fn row_id(item: ItemId) -> egui::Id {
|
||||
egui::Id::new(("pitem", item.0))
|
||||
egui::Id::unique(("pitem", item.0))
|
||||
}
|
||||
|
||||
/// The last row drawn with keyboard focus (egui drops the focus of a row scrolled out of view).
|
||||
fn focused_row_id() -> egui::Id {
|
||||
egui::Id::new("project-focused-row")
|
||||
egui::Id::unique("project-focused-row")
|
||||
}
|
||||
|
||||
/// The item whose selection the panel last scrolled into view.
|
||||
fn selection_seen_id() -> egui::Id {
|
||||
egui::Id::new("proj-sel-seen")
|
||||
egui::Id::unique("proj-sel-seen")
|
||||
}
|
||||
|
||||
/// The focused widget is one of the panel's rows, or nothing has keyboard focus: the panel's keys
|
||||
@@ -424,7 +424,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add("project.details.name", r, &it.name);
|
||||
let ar = Rect::from_min_size(pos2(r.max.x + 1.0, name_y - 7.0), vec2(arrow_w, 14.0));
|
||||
let tip = crate::i18n::tr("Compositions that use this item");
|
||||
let aresp = ui.interact(ar, egui::Id::new("proj-used-in"), Sense::click()).on_hover_text(tip);
|
||||
let aresp = ui.interact(ar, egui::Id::unique("proj-used-in"), Sense::click()).on_hover_text(tip);
|
||||
icons::paint(&p, Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), Icon::ChevronDown, if aresp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add("project.details.usedIn", ar, tip);
|
||||
// The comma sits right after the arrow (its glyph ends short of the hit area).
|
||||
@@ -506,7 +506,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if hr.width() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click());
|
||||
let resp = ui.interact(hr.intersect(hdr), egui::Id::unique(("proj-sort", *key)), Sense::click());
|
||||
hp.with_clip_rect(hr).text(
|
||||
pos2(*x, hdr.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
@@ -534,7 +534,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
Some(o) => crate::i18n::tr_args("Sort by {}, {}", &[&column, &o]),
|
||||
None => crate::i18n::tr_args("Sort by {}", &[&column]),
|
||||
};
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
|
||||
egui::WidgetInfo::labeled(egui::Role::Button, true, name)
|
||||
});
|
||||
if resp.clicked() {
|
||||
if app.ui.project_sort == *key {
|
||||
@@ -546,7 +546,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
column_menu(app, &resp);
|
||||
}
|
||||
let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::CLICK);
|
||||
let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::unique("proj-hdr-rest"), Sense::CLICK);
|
||||
column_menu(app, &hresp);
|
||||
app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)");
|
||||
// Rows.
|
||||
@@ -557,7 +557,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// a click deselects, a drag draws a selection box, a double-click imports, as in After
|
||||
// Effects (File ▸ Import ▸ File...).
|
||||
// Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key.
|
||||
let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::CLICK | Sense::DRAG);
|
||||
let empty = ui.interact(list, egui::Id::unique("proj-empty"), Sense::CLICK | Sense::DRAG);
|
||||
app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
|
||||
if overflow > 0.0 && ui.rect_contains_pointer(list) {
|
||||
let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
|
||||
@@ -648,9 +648,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// For screen readers: a tree item named by the item and its type, with its selection,
|
||||
// depth and (a folder) open state. One Tab stop per row; the panel's keys then apply.
|
||||
let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TreeItem, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
|
||||
ctx.accesskit_node_builder(resp.id, |n| {
|
||||
n.set_role(egui::accesskit::Role::TreeItem);
|
||||
n.set_selected(selected);
|
||||
n.set_level(depth.saturating_add(1));
|
||||
if let Some(open) = open {
|
||||
@@ -668,10 +667,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if it.is_folder() {
|
||||
let open = app.ui.project_open_folders.contains(&id.0);
|
||||
let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0));
|
||||
let tresp = widgets::twirl(ui, tw, open, egui::Id::new(("pf", id.0)), &t);
|
||||
let tresp = widgets::twirl(ui, tw, open, egui::Id::unique(("pf", id.0)), &t);
|
||||
tresp.widget_info(|| {
|
||||
let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]);
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
|
||||
egui::WidgetInfo::labeled(egui::Role::Button, true, name)
|
||||
});
|
||||
if tresp.clicked() {
|
||||
// Alt/Cmd-click twirls every folder inside it too.
|
||||
@@ -702,7 +701,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
} else {
|
||||
lp.rect_stroke(pr, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
|
||||
}
|
||||
let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::new(("pproxy", id.0)), Sense::click()).on_hover_text(format!(
|
||||
let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::unique(("pproxy", id.0)), Sense::click()).on_hover_text(format!(
|
||||
"Proxy: {} ({})",
|
||||
px.footage.path,
|
||||
if px.enabled { "in use" } else { "off" }
|
||||
@@ -710,7 +709,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
|
||||
presp.widget_info(|| {
|
||||
let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]);
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
|
||||
egui::WidgetInfo::labeled(egui::Role::Button, true, name)
|
||||
});
|
||||
if presp.clicked() {
|
||||
actions.push(("file.useProxy".into(), json!({"item": id.0})));
|
||||
@@ -728,7 +727,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let m = Rect::from_center_size(pos2(r.min.x + name_w - 8.0, r.center().y), vec2(13.0, 12.0));
|
||||
lp.add(egui::Shape::convex_polygon(vec![m.center_top(), m.right_bottom(), m.left_bottom()], t.warning, Stroke::NONE));
|
||||
lp.text(m.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK);
|
||||
ui.interact(m.expand(2.0).intersect(list), egui::Id::new(("pproblem", id.0)), Sense::hover()).on_hover_text(why.as_str());
|
||||
ui.interact(m.expand(2.0).intersect(list), egui::Id::unique(("pproblem", id.0)), Sense::hover()).on_hover_text(why.as_str());
|
||||
app.auto.add(&format!("project.item.{}.problem", id.0), m, why);
|
||||
}
|
||||
let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
|
||||
@@ -782,13 +781,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Label swatch: click for the label colour menu.
|
||||
let sw = Rect::from_center_size(pos2(label_x, r.center().y), vec2(10.0, 10.0));
|
||||
lp.rect_filled(sw, 2.0, t.label(it.label));
|
||||
let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::new(("plabel", id.0)), Sense::click());
|
||||
let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::unique(("plabel", id.0)), Sense::click());
|
||||
app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name());
|
||||
sresp.widget_info(|| {
|
||||
let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]);
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
|
||||
egui::WidgetInfo::labeled(egui::Role::Button, true, name)
|
||||
});
|
||||
let pop = egui::Id::new(("plabel-pop", id.0));
|
||||
let pop = egui::Id::unique(("plabel-pop", id.0));
|
||||
if sresp.clicked() || sresp.secondary_clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
@@ -813,10 +812,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if cell_edit(app, ui, &mut actions, "comment", cr) {
|
||||
continue;
|
||||
}
|
||||
let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::CLICK);
|
||||
let cresp = ui.interact(cell, egui::Id::unique(("pcomment", id.0)), Sense::CLICK);
|
||||
app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment);
|
||||
cresp.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Other, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment]))
|
||||
egui::WidgetInfo::labeled(egui::Role::Unknown, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment]))
|
||||
});
|
||||
if cresp.double_clicked() {
|
||||
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "comment".into(), it.comment.clone())));
|
||||
@@ -840,7 +839,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
// Part of the row: not a second Tab stop.
|
||||
let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::CLICK | Sense::DRAG);
|
||||
let nresp = ui.interact(name_clip, egui::Id::unique(("pname", id.0)), Sense::CLICK | Sense::DRAG);
|
||||
app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
|
||||
let resp = resp.union(nresp);
|
||||
if resp.clicked() {
|
||||
@@ -1017,7 +1016,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&& let Some(pos) = ctx.pointer_hover_pos()
|
||||
&& let Some(it) = app.session.project.item(ItemId(*iid))
|
||||
{
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("dnd-ghost")));
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("dnd-ghost")));
|
||||
let g = painter.layout_no_wrap(it.name.clone(), Tokens::ui(12.0), t.text);
|
||||
let r = Rect::from_min_size(pos + vec2(12.0, 8.0), g.size() + vec2(16.0, 8.0));
|
||||
painter.rect_filled(r, 4.0, Color32::from_rgba_premultiplied(40, 40, 40, 230));
|
||||
@@ -1033,9 +1032,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
(Icon::NewComp, "newComp", "Create a new Composition"),
|
||||
] {
|
||||
let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0));
|
||||
let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip);
|
||||
let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique(("pfoot", id))).on_hover_text(tip);
|
||||
app.auto.add(&format!("project.{id}"), r, tip);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip)));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(tip)));
|
||||
if resp.clicked() {
|
||||
let footage =
|
||||
app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).is_some_and(|i| matches!(i.kind, ItemKind::Footage(_)));
|
||||
@@ -1065,10 +1064,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let depth = app.session.project.settings.bit_depth.label();
|
||||
let dr = Rect::from_min_size(pos2(x + 4.0, foot.min.y + 5.0), vec2(48.0, 18.0));
|
||||
let dresp = ui.interact(dr, egui::Id::new("bpc"), Sense::click());
|
||||
let dresp = ui.interact(dr, egui::Id::unique("bpc"), Sense::click());
|
||||
p.text(dr.center(), Align2::CENTER_CENTER, depth, Tokens::ui(11.5), if dresp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add("project.bitDepth", dr, depth);
|
||||
dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr_args("Project color depth: {}", &[&depth])));
|
||||
dresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr_args("Project color depth: {}", &[&depth])));
|
||||
// Click cycles 8/16/32 bpc; Alt-click opens Project Settings (as in After Effects).
|
||||
if dresp.clicked() {
|
||||
if ui.input(|i| i.modifiers.alt) {
|
||||
@@ -1079,8 +1078,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
dresp.on_hover_text(crate::i18n::tr("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings"));
|
||||
let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0));
|
||||
let trash = widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items"));
|
||||
trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr("Delete selected project items")));
|
||||
let trash =
|
||||
widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::unique("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items"));
|
||||
trash.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr("Delete selected project items")));
|
||||
if trash.clicked() && !app.session.state.project_selection.is_empty() {
|
||||
actions.push(("project.delete".into(), json!({})));
|
||||
}
|
||||
@@ -1105,7 +1105,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if empty.clicked() && !adding {
|
||||
app.session.state.project_selection.clear();
|
||||
}
|
||||
let box_id = egui::Id::new("proj-marquee");
|
||||
let box_id = egui::Id::unique("proj-marquee");
|
||||
if let (Some(start), Some(end)) = (ctx.input(|i| i.pointer.press_origin()), empty.interact_pointer_pos())
|
||||
&& empty.dragged()
|
||||
{
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None };
|
||||
let x0 = rect.min.x + PAD;
|
||||
let w = rect.width() - 2.0 * PAD;
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("props-scroll"), rect);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect);
|
||||
let top = rect.min.y + 8.0 - scroll.offset;
|
||||
let mut y = top;
|
||||
let mut actions: Actions = vec![];
|
||||
@@ -87,7 +87,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some(tr) = layer.transform().cloned() {
|
||||
section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform");
|
||||
let rr = Rect::from_min_size(pos2(x0 + w - 40.0, y), vec2(40.0, ROW_H));
|
||||
let resp = ui.interact(rr, egui::Id::new("props-reset"), Sense::click());
|
||||
let resp = ui.interact(rr, egui::Id::unique("props-reset"), Sense::click());
|
||||
p.text(rr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Reset"), Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.accent });
|
||||
app.auto.add("properties.transform.reset", rr, "Reset Transform");
|
||||
if resp.clicked() {
|
||||
@@ -123,12 +123,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Both buttons grow with their (translated) labels; English keeps its 116 / 84 px.
|
||||
let text_w = |s: &str| p.layout_no_wrap(s.to_string(), Tokens::ui(12.0), t.text).size().x;
|
||||
let br = Rect::from_min_size(pos2(x0, y), vec2(116f32.max(text_w(crate::i18n::tr("Add Animator")) + 36.0), 24.0));
|
||||
let resp = ui.interact(br, egui::Id::new("props-add-animator"), Sense::click());
|
||||
let resp = ui.interact(br, egui::Id::unique("props-add-animator"), Sense::click());
|
||||
p.rect_stroke(br, 4.0, Stroke::new(1.0, if resp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside);
|
||||
icons::paint(&p, Rect::from_center_size(pos2(br.min.x + 14.0, br.center().y), vec2(10.0, 10.0)), Icon::Plus, t.text);
|
||||
p.text(pos2(br.min.x + 26.0, br.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Add Animator"), Tokens::ui(12.0), t.text);
|
||||
app.auto.add("properties.addAnimator", br, "Add Animator");
|
||||
let pop = egui::Id::new("props-add-animator-pop");
|
||||
let pop = egui::Id::unique("props-add-animator-pop");
|
||||
if resp.clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
@@ -145,11 +145,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// Our own presets (original work, see presets/text_animators.json).
|
||||
let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84f32.max(text_w(crate::i18n::tr("Presets")) + 16.0), 24.0));
|
||||
let presp = ui.interact(pr, egui::Id::new("props-text-presets"), Sense::click());
|
||||
let presp = ui.interact(pr, egui::Id::unique("props-text-presets"), Sense::click());
|
||||
p.rect_stroke(pr, 4.0, Stroke::new(1.0, if presp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside);
|
||||
p.text(pr.center(), Align2::CENTER_CENTER, crate::i18n::tr("Presets"), Tokens::ui(12.0), t.text);
|
||||
app.auto.add("properties.textPresets", pr, "Text Animation Presets");
|
||||
let ppop = egui::Id::new("props-text-presets-pop");
|
||||
let ppop = egui::Id::unique("props-text-presets-pop");
|
||||
if presp.clicked() {
|
||||
widgets::open_popup(ui, ppop);
|
||||
}
|
||||
@@ -207,7 +207,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
let cells = [("prev", r.min.x + 4.0), ("key", r.min.x + 16.0), ("next", r.min.x + 28.0)];
|
||||
for (what, cx) in cells {
|
||||
let cr = Rect::from_center_size(pos2(cx, cy), vec2(12.0, 16.0));
|
||||
let resp = ui.interact(cr, egui::Id::new(("props-nav", uid, what)), Sense::click());
|
||||
let resp = ui.interact(cr, egui::Id::unique(("props-nav", uid, what)), Sense::click());
|
||||
let col = if resp.hovered() { t.text } else { t.text_dim };
|
||||
match what {
|
||||
"prev" => triangle(p, cr.center(), true, col),
|
||||
@@ -237,7 +237,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
}
|
||||
} else {
|
||||
let swr = Rect::from_center_size(pos2(r.min.x + 16.0, cy), vec2(14.0, 14.0));
|
||||
let resp = ui.interact(swr, egui::Id::new(("props-sw", uid)), Sense::click());
|
||||
let resp = ui.interact(swr, egui::Id::unique(("props-sw", uid)), Sense::click());
|
||||
icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name);
|
||||
if resp.clicked() {
|
||||
@@ -247,7 +247,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text);
|
||||
let vx = r.min.x + (r.width() * 0.5).max(130.0);
|
||||
let label = Rect::from_min_max(pos2(r.min.x + 42.0, r.min.y), pos2(vx - 20.0, r.max.y));
|
||||
let resp = ui.interact(label, egui::Id::new(("props-label", uid)), Sense::click());
|
||||
let resp = ui.interact(label, egui::Id::unique(("props-label", uid)), Sense::click());
|
||||
app.auto.add(&format!("{base}.label"), label, &pr.name);
|
||||
if resp.double_clicked() {
|
||||
app.ui.timeline.search.clear();
|
||||
@@ -271,7 +271,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
match &value {
|
||||
Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => {
|
||||
let (_, deg) = super::fx_widgets::split_angle(*v);
|
||||
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, decimals(deg), &t);
|
||||
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, decimals(deg), &t);
|
||||
app.auto.add(&format!("{base}.value"), dr, &pr.name);
|
||||
app.auto.add(&format!("{base}.revolutions"), rr, &pr.name);
|
||||
if let Some(nv) = nv {
|
||||
@@ -280,7 +280,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
}
|
||||
Value::Scalar(v) => {
|
||||
let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) };
|
||||
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t);
|
||||
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t);
|
||||
app.auto.add(&format!("{base}.value"), vr, &pr.name);
|
||||
if let Some(nv) = nv {
|
||||
set(actions, json!(nv));
|
||||
@@ -297,7 +297,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
if linkable {
|
||||
// Constrain Proportions toggle, left of the values.
|
||||
let lr = Rect::from_center_size(pos2(x - 12.0, cy), vec2(12.0, 12.0));
|
||||
let resp = ui.interact(lr, egui::Id::new(("props-link", uid)), Sense::click()).on_hover_text(tr("Constrain Proportions"));
|
||||
let resp = ui.interact(lr, egui::Id::unique(("props-link", uid)), Sense::click()).on_hover_text(tr("Constrain Proportions"));
|
||||
icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
|
||||
app.auto.add(&format!("{base}.constrain"), lr, "Constrain Proportions");
|
||||
if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
|
||||
@@ -306,7 +306,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
}
|
||||
for d in 0..n.min(c.len()) {
|
||||
let (vr, nv, _) =
|
||||
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t);
|
||||
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t);
|
||||
app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name);
|
||||
if let Some(nv) = nv {
|
||||
let mut nc = c.clone();
|
||||
@@ -356,14 +356,14 @@ fn text_section(
|
||||
y += ROW_H + 4.0;
|
||||
// Font family and style.
|
||||
let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
|
||||
let pop = egui::Id::new("props-font-pop");
|
||||
let pop = egui::Id::unique("props-font-pop");
|
||||
if let Some(f) = super::text_panels::font_picker(app, ui, pop, fr, &doc.font, "properties.text.font", true) {
|
||||
set(actions, json!({"font": f}));
|
||||
}
|
||||
y += 28.0;
|
||||
let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
|
||||
let spop = egui::Id::new("props-style-pop");
|
||||
if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("props-style")).clicked() {
|
||||
let spop = egui::Id::unique("props-style-pop");
|
||||
if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::unique("props-style")).clicked() {
|
||||
widgets::open_popup(ui, spop);
|
||||
}
|
||||
app.auto.add("properties.text.style", sr, "Font style");
|
||||
@@ -377,7 +377,7 @@ fn text_section(
|
||||
let col2 = x0 + w / 2.0;
|
||||
let field = |app: &mut EffectcraftApp, ui: &mut egui::Ui, x: f32, y: f32, glyph: &str, key: &str, v: f64, range: (f64, f64), suffix: &str| {
|
||||
p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::new(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::unique(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t);
|
||||
app.auto.add(&format!("properties.text.{key}"), r, key);
|
||||
nv
|
||||
};
|
||||
@@ -394,7 +394,7 @@ fn text_section(
|
||||
// Auto Leading: click to switch to an explicit value (120% of the size).
|
||||
p.text(pos2(col2, y + 9.0), Align2::LEFT_CENTER, "A", Tokens::semibold(11.0), t.text_dim);
|
||||
let r = Rect::from_min_size(pos2(col2 + 26.0, y), vec2(40.0, 18.0));
|
||||
let resp = ui.interact(r, egui::Id::new("props-leading-auto"), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique("props-leading-auto"), Sense::click());
|
||||
p.text(
|
||||
pos2(r.min.x + 2.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
@@ -420,14 +420,14 @@ fn text_section(
|
||||
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
|
||||
let ry = y + i as f32 * 24.0;
|
||||
let cr = Rect::from_min_size(pos2(x0, ry + 2.0), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).clicked() {
|
||||
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("props-apply", key))).clicked() {
|
||||
let k = if key == "fill" { "applyFill" } else { "applyStroke" };
|
||||
set(actions, json!({k: !on}));
|
||||
}
|
||||
app.auto.add(&format!("properties.text.{key}.enabled"), cr, label);
|
||||
let sw = Rect::from_min_size(pos2(x0 + 24.0, ry + 1.0), vec2(16.0, 16.0));
|
||||
let pop = egui::Id::new(("props-color-pop", key));
|
||||
if widgets::swatch(ui, sw, c, egui::Id::new(("props-color", key)), &t).clicked() {
|
||||
let pop = egui::Id::unique(("props-color-pop", key));
|
||||
if widgets::swatch(ui, sw, c, egui::Id::unique(("props-color", key)), &t).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("properties.text.{key}"), sw, label);
|
||||
@@ -461,7 +461,7 @@ fn paragraph_section(
|
||||
for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * 26.0, y), vec2(22.0, 22.0));
|
||||
let on = doc.justify == j;
|
||||
let resp = ui.interact(r, egui::Id::new(("props-para", key)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("props-para", key)), Sense::click());
|
||||
p.rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
@@ -516,7 +516,7 @@ fn paint_align_glyph(p: &egui::Painter, r: Rect, j: Justify, c: Color32) {
|
||||
fn more_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, y: f32, id: &str, auto: &str, panel: PanelKind) -> f32 {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(pos2(x0, y), vec2(64.0, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(id), Sense::click());
|
||||
p.rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.field_bg });
|
||||
p.text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, "···", Tokens::semibold(12.0), t.text);
|
||||
p.text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr("More"), Tokens::ui(12.0), t.text);
|
||||
|
||||
@@ -70,7 +70,7 @@ struct Cached {
|
||||
pub fn overlay(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId, layer: &Layer, t: Tick) -> Option<Overlay> {
|
||||
let fx = layer.effects()?.groups().find(|g| puppet::is_puppet(g))?;
|
||||
let key = (app.session.revision, cid.0, layer.id.0, t.0);
|
||||
let id = egui::Id::new(("puppet-overlay", layer.id.0));
|
||||
let id = egui::Id::unique(("puppet-overlay", layer.id.0));
|
||||
if let Some(c) = ctx.data(|d| d.get_temp::<Cached>(id))
|
||||
&& c.key == key
|
||||
{
|
||||
|
||||
@@ -444,7 +444,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
|
||||
let state_id = egui::Id::new("rq-ui");
|
||||
let state_id = egui::Id::unique("rq-ui");
|
||||
let (mut open, mut selected): (Vec<u64>, Option<u64>) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
|
||||
|
||||
// ---------------------------------------------------------- Current Render strip
|
||||
@@ -468,20 +468,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0));
|
||||
let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0));
|
||||
let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty();
|
||||
if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::new("rq-render")).clicked() && can_render {
|
||||
if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render {
|
||||
actions.push(("renderQueue.render", json!({"wait": false})));
|
||||
}
|
||||
app.auto.add("renderQueue.render", render_b, "Render");
|
||||
// Notify (sound + notification when the queue finishes).
|
||||
let notify = app.session.project.render_prefs.notify;
|
||||
let nb = Rect::from_min_size(pos2(render_b.min.x - 8.0 - 2.0 * bw - 8.0, render_b.min.y + 4.0), vec2(18.0, 18.0));
|
||||
if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).clicked() {
|
||||
if widgets::checkbox(ui, nb, notify, &t, egui::Id::unique("rq-notify")).clicked() {
|
||||
actions.push(("renderQueue.setNotify", json!({"notify": !notify})));
|
||||
}
|
||||
p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim);
|
||||
app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" });
|
||||
if rendering {
|
||||
if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::new("rq-stop")).clicked() {
|
||||
if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::unique("rq-stop")).clicked() {
|
||||
actions.push(("renderQueue.stop", json!({})));
|
||||
}
|
||||
app.auto.add("renderQueue.stop", stop_b, "Stop");
|
||||
@@ -538,7 +538,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// ---------------------------------------------------------- items
|
||||
let list = Rect::from_min_max(pos2(rect.min.x, head.max.y), pos2(rect.max.x, rect.max.y - FOOT_H));
|
||||
let lp = p.with_clip_rect(list);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("rq-scroll"), list);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("rq-scroll"), list);
|
||||
// Items scroll under the header and footer: clip their widgets (and hit tests) to the list.
|
||||
let panel_clip = ui.clip_rect();
|
||||
ui.set_clip_rect(list.intersect(panel_clip));
|
||||
@@ -546,7 +546,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if queue.is_empty() {
|
||||
lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, tr("The render queue is empty."), Tokens::ui(12.0), t.text_faint);
|
||||
}
|
||||
let editing_id = egui::Id::new("rq-edit-output");
|
||||
let editing_id = egui::Id::unique("rq-edit-output");
|
||||
let mut editing: Option<(u64, String)> = ui.data(|d| d.get_temp(editing_id));
|
||||
for (k, it) in queue.iter().enumerate() {
|
||||
let n = k + 1;
|
||||
@@ -565,7 +565,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
t.row_alt
|
||||
},
|
||||
);
|
||||
let row_resp = ui.interact(row, egui::Id::new(("rq-row", it.id)), Sense::click());
|
||||
let row_resp = ui.interact(row, egui::Id::unique(("rq-row", it.id)), Sense::click());
|
||||
if row_resp.clicked() {
|
||||
selected = Some(it.id);
|
||||
app.ui.focused = crate::dock::PanelKind::RenderQueue;
|
||||
@@ -592,7 +592,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add(&aid("row"), row, &format!("#{n}"));
|
||||
let is_open = open.contains(&it.id);
|
||||
let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0));
|
||||
if widgets::twirl(ui, tw, is_open, egui::Id::new(("rq-twirl", it.id)), &t).clicked() {
|
||||
if widgets::twirl(ui, tw, is_open, egui::Id::unique(("rq-twirl", it.id)), &t).clicked() {
|
||||
if is_open {
|
||||
open.retain(|i| *i != it.id);
|
||||
} else {
|
||||
@@ -601,7 +601,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
app.auto.add(&aid("twirl"), tw, "Twirl");
|
||||
let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0));
|
||||
if widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))).clicked() && !rendering {
|
||||
if widgets::checkbox(ui, cb, it.render, &t, egui::Id::unique(("rq-cb", it.id))).clicked() && !rendering {
|
||||
actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render})));
|
||||
}
|
||||
app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" });
|
||||
@@ -619,7 +619,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let st_rect = Rect::from_min_size(pos2(cx_status, row.min.y), vec2(w_status, ROW_H));
|
||||
lp.text(pos2(cx_status + 2.0, row.center().y), Align2::LEFT_CENTER, &status, Tokens::ui(12.0), status_color(&t, &it.status));
|
||||
if let RenderStatus::Failed(e) = &it.status {
|
||||
ui.interact(st_rect, egui::Id::new(("rq-st", it.id)), Sense::hover()).on_hover_text(e);
|
||||
ui.interact(st_rect, egui::Id::unique(("rq-st", it.id)), Sense::hover()).on_hover_text(e);
|
||||
}
|
||||
app.auto.add(&aid("status"), st_rect, &status);
|
||||
if let Some(s) = it.started {
|
||||
@@ -651,7 +651,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
};
|
||||
lp.text(pos2(cx_num - 6.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
|
||||
let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0));
|
||||
let did = egui::Id::new(("rq-dd", it.id, sub));
|
||||
let did = egui::Id::unique(("rq-dd", it.id, sub));
|
||||
let shown: String = if summary.chars().count() > 31 { summary.chars().take(30).chain(std::iter::once('…')).collect() } else { summary.clone() };
|
||||
if widgets::dropdown(ui, dd, &shown, &t, did).clicked() && !rendering {
|
||||
widgets::open_popup(ui, did);
|
||||
@@ -684,7 +684,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Log: Errors Only / Plus Settings / Plus Per Frame Info.
|
||||
let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
|
||||
lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim);
|
||||
let lid = egui::Id::new(("rq-log", it.id));
|
||||
let lid = egui::Id::unique(("rq-log", it.id));
|
||||
if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
|
||||
widgets::open_popup(ui, lid);
|
||||
}
|
||||
@@ -699,7 +699,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let ox = dd.max.x + 14.0;
|
||||
lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
|
||||
let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
|
||||
let oid = egui::Id::new(("rq-out", it.id));
|
||||
let oid = egui::Id::unique(("rq-out", it.id));
|
||||
if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
|
||||
widgets::open_popup(ui, oid);
|
||||
}
|
||||
@@ -735,7 +735,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
} else {
|
||||
let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
|
||||
let resp = ui.interact(path_rect, egui::Id::new(("rq-path", it.id)), Sense::click());
|
||||
let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
|
||||
let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
|
||||
lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
|
||||
// Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
|
||||
|
||||
@@ -64,7 +64,7 @@ pub fn draw_overlay(
|
||||
(scale * 1000.0) as u32,
|
||||
format!("{}{:?}{:?}{}", o.view, o.overlay_color, o.boundary_color, o.overlay_opacity),
|
||||
);
|
||||
let id = egui::Id::new("roto-overlay");
|
||||
let id = egui::Id::unique("roto-overlay");
|
||||
let cached: Option<Overlay> = ctx.data(|d| d.get_temp(id));
|
||||
let ov = match cached {
|
||||
Some(c) if c.key == key => Some(c),
|
||||
@@ -149,7 +149,7 @@ pub fn interact(
|
||||
let ctx = ui.ctx().clone();
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let Some(kind) = app.ui.tool.roto_kind(mods.alt) else { return false };
|
||||
let resp = ui.interact(area, egui::Id::new("layer-panel-roto"), Sense::click_and_drag());
|
||||
let resp = ui.interact(area, egui::Id::unique("layer-panel-roto"), Sense::click_and_drag());
|
||||
let o = app.session.state.roto.clone();
|
||||
let refine = kind.starts_with("refine");
|
||||
let dia = if refine { o.refine_diameter } else { o.diameter };
|
||||
@@ -170,7 +170,7 @@ pub fn interact(
|
||||
}
|
||||
// ⌘/Ctrl-drag resizes the brush (After Effects): the circle stays at the press point and its
|
||||
// edge follows the pointer. No stroke is painted.
|
||||
let rid = egui::Id::new("layer-panel-roto-resize");
|
||||
let rid = egui::Id::unique("layer-panel-roto-resize");
|
||||
if resp.drag_started()
|
||||
&& mods.command
|
||||
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
|
||||
@@ -194,7 +194,7 @@ pub fn interact(
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let did = egui::Id::new("layer-panel-roto-drag");
|
||||
let did = egui::Id::unique("layer-panel-roto-drag");
|
||||
if resp.drag_started()
|
||||
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
|
||||
{
|
||||
@@ -277,8 +277,8 @@ pub fn span_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
|
||||
p.line_segment([pos2(cx, r.min.y), pos2(cx, r.max.y)], Stroke::new(1.0, t.timecode));
|
||||
p.rect_stroke(track, 2.0, Stroke::new(1.0, Color32::from_black_alpha(140)), StrokeKind::Outside);
|
||||
app.auto.add("layerPanel.rotoSpan", track, "Segmentation span");
|
||||
let resp = ui.interact(track, egui::Id::new("roto-span-bar"), Sense::click_and_drag());
|
||||
let hid = egui::Id::new("roto-span-handle");
|
||||
let resp = ui.interact(track, egui::Id::unique("roto-span-bar"), Sense::click_and_drag());
|
||||
let hid = egui::Id::unique("roto-span-handle");
|
||||
if resp.drag_started()
|
||||
&& let (Some(pos), Some(_)) = (resp.interact_pointer_pos(), d.base)
|
||||
{
|
||||
@@ -322,7 +322,7 @@ pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
for (id, label) in [("alpha", tr("Alpha")), ("alphaBoundary", tr("Boundary")), ("alphaOverlay", tr("Overlay"))] {
|
||||
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(66.0, 20.0));
|
||||
let on = view == id;
|
||||
if widgets::text_button(ui, r, label, on, &t, egui::Id::new(("roto-view", id))).clicked() {
|
||||
if widgets::text_button(ui, r, label, on, &t, egui::Id::unique(("roto-view", id))).clicked() {
|
||||
let v = if on { "none" } else { id };
|
||||
let _ = app.session.execute("roto.options", json!({"view": v}));
|
||||
}
|
||||
@@ -337,7 +337,7 @@ pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
x += 8.0;
|
||||
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(74.0, 20.0));
|
||||
let label = if frozen { tr("Unfreeze") } else { tr("Freeze") };
|
||||
if widgets::text_button(ui, r, label, frozen, &t, egui::Id::new(("roto-freeze", uid))).clicked() {
|
||||
if widgets::text_button(ui, r, label, frozen, &t, egui::Id::unique(("roto-freeze", uid))).clicked() {
|
||||
let id = if frozen { "roto.unfreeze" } else { "roto.freeze" };
|
||||
if let Err(e) = app.session.execute(id, json!({"layer": layer.id.0, "effect": uid})) {
|
||||
app.ui.status = e.to_string();
|
||||
@@ -347,7 +347,7 @@ pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
x += 80.0;
|
||||
if running {
|
||||
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(60.0, 20.0));
|
||||
if widgets::text_button(ui, r, tr("Stop"), false, &t, egui::Id::new("roto-stop")).clicked() {
|
||||
if widgets::text_button(ui, r, tr("Stop"), false, &t, egui::Id::unique("roto-stop")).clicked() {
|
||||
let _ = app.session.execute("roto.cancel", json!({}));
|
||||
}
|
||||
app.auto.add("layerPanel.roto.stop", r, "Stop");
|
||||
|
||||
@@ -22,7 +22,7 @@ struct State {
|
||||
}
|
||||
|
||||
fn state_id() -> egui::Id {
|
||||
egui::Id::new("rq-templates-dialog")
|
||||
egui::Id::unique("rq-templates-dialog")
|
||||
}
|
||||
|
||||
/// Open the dialog for `kind` (`renderSettings` | `outputModule`).
|
||||
|
||||
@@ -97,7 +97,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let time = app.session.time();
|
||||
let key = (app.session.revision, cid.0, time.0, format!("{}{}{}{}{}x{}", st.scope, st.standard, st.float, st.clamp, o.width, o.height), 0);
|
||||
let ctx = ui.ctx().clone();
|
||||
let id = egui::Id::new("scopes-cache");
|
||||
let id = egui::Id::unique("scopes-cache");
|
||||
let cached: Option<Cached> = ctx.data(|d| d.get_temp(id));
|
||||
let cached = match cached {
|
||||
Some(c) if c.key == key => c,
|
||||
|
||||
@@ -110,7 +110,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let mut run_now = false;
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(in_rect));
|
||||
let te = egui::TextEdit::multiline(&mut app.ui.script_console.input)
|
||||
.id(egui::Id::new("script-console-input"))
|
||||
.id(egui::Id::unique("script-console-input"))
|
||||
.font(Tokens::mono(12.0))
|
||||
.desired_width(in_rect.width())
|
||||
.desired_rows(4)
|
||||
@@ -124,10 +124,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let bar = Rect::from_min_size(pos2(rect.min.x, rect.max.y - bar_h), vec2(rect.width(), bar_h));
|
||||
let run_r = Rect::from_min_size(pos2(bar.max.x - 64.0, bar.min.y + 2.0), vec2(56.0, 20.0));
|
||||
let clear_r = Rect::from_min_size(pos2(bar.max.x - 128.0, bar.min.y + 2.0), vec2(56.0, 20.0));
|
||||
let run_resp = ui.interact(run_r, egui::Id::new("script-console-run"), Sense::click());
|
||||
let run_resp = ui.interact(run_r, egui::Id::unique("script-console-run"), Sense::click());
|
||||
p.rect_filled(run_r, 3.0, if run_resp.hovered() { t.accent_hover } else { t.accent });
|
||||
p.text(run_r.center(), Align2::CENTER_CENTER, tr("Run"), Tokens::ui(12.0), egui::Color32::WHITE);
|
||||
let clear_resp = ui.interact(clear_r, egui::Id::new("script-console-clear"), Sense::click());
|
||||
let clear_resp = ui.interact(clear_r, egui::Id::unique("script-console-clear"), Sense::click());
|
||||
p.rect_stroke(clear_r, 3.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
p.text(clear_r.center(), Align2::CENTER_CENTER, tr("Clear"), Tokens::ui(12.0), t.text);
|
||||
app.auto.add("scriptConsole.run", run_r, "Run");
|
||||
|
||||
@@ -51,7 +51,7 @@ fn draw_children(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2
|
||||
let tw = (tab.text.chars().count() as f32 * 7.0 + 20.0).max(48.0);
|
||||
let hr = Rect::from_min_size(pos2(x, r.min.y), vec2(tw, 22.0));
|
||||
let active = i == w.active_tab;
|
||||
let resp = ui.interact(hr, egui::Id::new(("sui-tab", win, tab.id)), Sense::click());
|
||||
let resp = ui.interact(hr, egui::Id::unique(("sui-tab", win, tab.id)), Sense::click());
|
||||
ui.painter().rect_filled(
|
||||
hr,
|
||||
3.0,
|
||||
@@ -86,7 +86,7 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2,
|
||||
let services = app.session.services.clone();
|
||||
let rect = rect_of(origin, w.bounds);
|
||||
register(app, win, w, rect);
|
||||
let id = egui::Id::new(("sui", win, w.id));
|
||||
let id = egui::Id::unique(("sui", win, w.id));
|
||||
let tip = |r: egui::Response| if w.help_tip.is_empty() { r } else { r.on_hover_text(&w.help_tip) };
|
||||
// onDraw: the script paints the control. Without drawOSControl() its paint replaces the
|
||||
// default look (buttons and toggles still respond to clicks); with it, it goes on top.
|
||||
@@ -316,7 +316,7 @@ fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: &
|
||||
/// A ScriptUIImage as a texture (decoded once and kept; `None` when it can't be read or
|
||||
/// decoded).
|
||||
fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) -> Option<egui::TextureHandle> {
|
||||
let id = egui::Id::new(("scriptui-image", img));
|
||||
let id = egui::Id::unique(("scriptui-image", img));
|
||||
if let Some(cached) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
|
||||
return cached;
|
||||
}
|
||||
@@ -382,7 +382,7 @@ fn wants_mouse(w: &Widget, depth: usize) -> bool {
|
||||
/// same control; it replaces the plain click the control reported, so a checkbox toggles once).
|
||||
/// Only events a listener wants are sent; none while `active` is false (a modal dialog is up).
|
||||
fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: &Widget, active: bool, acts: &mut Vec<Act>) {
|
||||
let id = egui::Id::new(("sui-mouse", win));
|
||||
let id = egui::Id::unique(("sui-mouse", win));
|
||||
if !wants_mouse(root, 0) {
|
||||
ui.data_mut(|d| d.remove::<MouseTrack>(id));
|
||||
return;
|
||||
@@ -490,7 +490,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec<Act>) {
|
||||
Ok(r) => {
|
||||
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
|
||||
let msg = e["message"].as_str().unwrap_or("script error").to_string();
|
||||
app.toast = Some((msg, ctx.input(|i| i.time)));
|
||||
app.toast = Some((msg, app.now()));
|
||||
} else if let Some(o) = r["output"].as_str().filter(|o| !o.is_empty()) {
|
||||
app.ui.status = o.lines().last().unwrap_or_default().to_string();
|
||||
}
|
||||
@@ -512,7 +512,7 @@ pub fn show_windows(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let blocked = windows.iter().any(|w| w.modal);
|
||||
if blocked {
|
||||
// A modal dialog blocks the app behind it, as in After Effects.
|
||||
let r = egui::Area::new(egui::Id::new("scriptui-modal-block")).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| {
|
||||
let r = egui::Area::new(egui::Id::unique("scriptui-modal-block")).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| {
|
||||
ui.allocate_exact_size(screen.size(), Sense::click_and_drag());
|
||||
ui.painter().rect_filled(screen, 0.0, Color32::from_black_alpha(90));
|
||||
});
|
||||
@@ -523,7 +523,7 @@ pub fn show_windows(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let mut open = true;
|
||||
let order = if w.modal { egui::Order::Foreground } else { egui::Order::Middle };
|
||||
let resp = egui::Window::new(if w.title.is_empty() { &w.script } else { &w.title })
|
||||
.id(egui::Id::new(("scriptui-window", w.id)))
|
||||
.id(egui::Id::unique(("scriptui-window", w.id)))
|
||||
.order(order)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
|
||||
@@ -346,7 +346,7 @@ fn theme_card(
|
||||
let favorite = favorites.iter().any(|id| id == value);
|
||||
let tip = tr(if favorite { "Remove from favorites" } else { "Add to favorites" });
|
||||
let r = ui.add_visible(favorite || hovered, egui::Button::new("").min_size(vec2(24.0, 24.0)).frame(false)).on_hover_text(tip);
|
||||
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{}: {}", tip, tr(label))));
|
||||
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{}: {}", tip, tr(label))));
|
||||
reg(app, &format!("settings.appearance.favoriteThemes.{value}"), &r, tip);
|
||||
if favorite || hovered {
|
||||
crate::icons::paint(
|
||||
|
||||
@@ -377,7 +377,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
}
|
||||
let id = format!("shortcuts.key.{}", name.trim_start_matches('#'));
|
||||
app.auto.add(&id, r, label);
|
||||
let resp = ui.interact(r, egui::Id::new(("sc-key", name, r.min.x as i32)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("sc-key", name, r.min.x as i32)), Sense::click());
|
||||
if resp.clicked() {
|
||||
if let Some(i) = mod_idx {
|
||||
st.mods[i] = !st.mods[i];
|
||||
|
||||
@@ -69,7 +69,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
|
||||
// Save the open project as a user template.
|
||||
let can_save = app.session.project.comps().next().is_some();
|
||||
let sr = Rect::from_min_size(pos2(area.max.x - 210.0, y - 4.0), vec2(210.0, 28.0));
|
||||
let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::new("home-templates-save")).clicked();
|
||||
let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::unique("home-templates-save")).clicked();
|
||||
app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…");
|
||||
if save {
|
||||
if can_save {
|
||||
@@ -101,7 +101,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
|
||||
let (cx, cy) = (full.min.x + (i % cols) as f32 * (card_w + gap), full.min.y + (i / cols) as f32 * (card_h + gap));
|
||||
let r = Rect::from_min_size(pos2(cx, cy), vec2(card_w, card_h));
|
||||
let visible = r.intersects(ui.clip_rect());
|
||||
let resp = ui.interact(r, egui::Id::new(("template-card", &tpl.id)), Sense::click()).on_hover_text(&tpl.description);
|
||||
let resp = ui.interact(r, egui::Id::unique(("template-card", &tpl.id)), Sense::click()).on_hover_text(&tpl.description);
|
||||
p.rect_filled(r, 8.0, if resp.hovered() { Color32::from_rgb(0x2c, 0x2e, 0x37) } else { Color32::from_rgb(0x24, 0x26, 0x2e) });
|
||||
let tr = Rect::from_min_size(r.min, vec2(card_w, thumb_h));
|
||||
let tex = if visible { thumb(app, &ctx, &tpl.id, &mut budget) } else { None };
|
||||
@@ -137,7 +137,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
|
||||
let mut del_hit = false;
|
||||
if !tpl.builtin {
|
||||
let dr = Rect::from_min_size(pos2(tr.max.x - 30.0, tr.min.y + 6.0), vec2(24.0, 24.0));
|
||||
let dresp = ui.interact(dr, egui::Id::new(("template-del", &tpl.id)), Sense::click()).on_hover_text(crate::i18n::tr("Delete this template"));
|
||||
let dresp = ui.interact(dr, egui::Id::unique(("template-del", &tpl.id)), Sense::click()).on_hover_text(crate::i18n::tr("Delete this template"));
|
||||
if resp.hovered() || dresp.hovered() {
|
||||
p.circle_filled(dr.center(), 12.0, if dresp.hovered() { Color32::from_rgb(0xc0, 0x3a, 0x3a) } else { Color32::from_black_alpha(170) });
|
||||
icons::paint(&p, dr.shrink(6.0), Icon::Close, Color32::WHITE);
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn text_target(app: &EffectcraftApp) -> Option<TextTarget> {
|
||||
|
||||
fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, label: &str, on: bool, bold: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let resp = ui.interact(r, egui::Id::new(("text-toggle", id)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("text-toggle", id)), Sense::click());
|
||||
p.rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
@@ -88,7 +88,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let target = text_target(app);
|
||||
let enabled = target.is_some();
|
||||
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("character-scroll"), rect);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("character-scroll"), rect);
|
||||
let top = rect.min.y + 10.0 - scroll.offset;
|
||||
let mut y = top;
|
||||
let x0 = rect.min.x + 10.0;
|
||||
@@ -96,14 +96,14 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let mut actions: Vec<serde_json::Value> = vec![];
|
||||
// Font family + style.
|
||||
let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
|
||||
let pop = egui::Id::new("char-font-pop");
|
||||
let pop = egui::Id::unique("char-font-pop");
|
||||
if let Some(f) = font_picker(app, ui, pop, fr, &doc.font, "character.font", enabled) {
|
||||
actions.push(json!({"font": f}));
|
||||
}
|
||||
y += 28.0;
|
||||
let sr = Rect::from_min_size(pos2(x0, y), vec2(w - 84.0, 22.0));
|
||||
let spop = egui::Id::new("char-style-pop");
|
||||
if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("char-style")).clicked() && enabled {
|
||||
let spop = egui::Id::unique("char-style-pop");
|
||||
if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::unique("char-style")).clicked() && enabled {
|
||||
widgets::open_popup(ui, spop);
|
||||
}
|
||||
app.auto.add("character.style", sr, "Font style");
|
||||
@@ -135,7 +135,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let fx = x0 + col as f32 * (w / 2.0);
|
||||
let fy = y + row as f32 * 28.0;
|
||||
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::new(("char", key)), v, 0.5, range, 0, suffix, &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("char", key)), v, 0.5, range, 0, suffix, &t);
|
||||
app.auto.add(&format!("character.{key}"), r, key);
|
||||
if let Some(nv) = nv
|
||||
&& enabled
|
||||
@@ -153,8 +153,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
Kerning::Manual(v) => format!("{v:.0}"),
|
||||
};
|
||||
let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
|
||||
let kpop = egui::Id::new("char-kerning-pop");
|
||||
if widgets::dropdown(ui, kr, &label, &t, egui::Id::new("char-kerning")).clicked() && enabled {
|
||||
let kpop = egui::Id::unique("char-kerning-pop");
|
||||
if widgets::dropdown(ui, kr, &label, &t, egui::Id::unique("char-kerning")).clicked() && enabled {
|
||||
widgets::open_popup(ui, kpop);
|
||||
}
|
||||
app.auto.add("character.kerning", kr, "Kerning");
|
||||
@@ -174,8 +174,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let fy = y + 2.0 * 28.0;
|
||||
let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
|
||||
let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
|
||||
let pop = egui::Id::new("char-stroke-order-pop");
|
||||
if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::new("char-stroke-order")).clicked() && enabled {
|
||||
let pop = egui::Id::unique("char-stroke-order-pop");
|
||||
if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add("character.strokeOverFill", dr, "Stroke order");
|
||||
@@ -188,13 +188,13 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
|
||||
let fx = x0 + i as f32 * (w / 2.0);
|
||||
let cr = Rect::from_min_size(pos2(fx, y + 3.0), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-apply", key))).clicked() && enabled {
|
||||
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-apply", key))).clicked() && enabled {
|
||||
actions.push(json!({if key == "fill" { "applyFill" } else { "applyStroke" }: !on}));
|
||||
}
|
||||
app.auto.add(&format!("character.{key}.enabled"), cr, label);
|
||||
let fs = Rect::from_min_size(pos2(fx + 20.0, y), vec2(26.0, 20.0));
|
||||
let pop = egui::Id::new(("char-color-pop", key));
|
||||
if widgets::swatch(ui, fs, c, egui::Id::new(("char-color", key)), &t).clicked() && enabled {
|
||||
let pop = egui::Id::unique(("char-color-pop", key));
|
||||
if widgets::swatch(ui, fs, c, egui::Id::unique(("char-color", key)), &t).clicked() && enabled {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("character.{key}"), fs, label);
|
||||
@@ -204,7 +204,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let er = Rect::from_min_size(pos2(fs.max.x + 4.0, y + 1.0), vec2(18.0, 18.0));
|
||||
let active = app.ui.text_color_pick.as_ref().is_some_and(|pick| pick.layer == target.as_ref().map_or(0, |tt| tt.layer) && pick.field == key);
|
||||
let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("char-eye", key)), &t);
|
||||
let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("char-eye", key)), &t);
|
||||
app.auto.add(&format!("character.{key}.eyedropper"), er, &format!("{label} eyedropper"));
|
||||
if eye.clicked() {
|
||||
if active {
|
||||
@@ -240,17 +240,17 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
y += 32.0;
|
||||
// Ligatures.
|
||||
let lr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::new("char-ligatures")).clicked() && enabled {
|
||||
if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::unique("char-ligatures")).clicked() && enabled {
|
||||
actions.push(json!({"ligatures": !doc.ligatures}));
|
||||
}
|
||||
app.auto.add("character.ligatures", lr, "Ligatures");
|
||||
p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, tr("Ligatures"), Tokens::ui(11.5), t.text_dim);
|
||||
// OpenType: a popup with the font's layout features (like the Character panel's OpenType menu).
|
||||
let or = Rect::from_min_size(pos2(sr.max.x + 6.0, sr.min.y), vec2(78.0, 22.0));
|
||||
let opop = egui::Id::new("char-opentype-pop");
|
||||
let opop = egui::Id::unique("char-opentype-pop");
|
||||
let n_on = doc.opentype.feature_settings().len();
|
||||
let olabel = if n_on > 0 { format!("OT ({n_on})") } else { "OpenType".to_string() };
|
||||
if widgets::dropdown(ui, or, &olabel, &t, egui::Id::new("char-opentype")).clicked() && enabled {
|
||||
if widgets::dropdown(ui, or, &olabel, &t, egui::Id::unique("char-opentype")).clicked() && enabled {
|
||||
widgets::open_popup(ui, opop);
|
||||
}
|
||||
app.auto.add("character.opentype", or, "OpenType");
|
||||
@@ -264,7 +264,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
.enumerate()
|
||||
{
|
||||
let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key))).clicked() && enabled {
|
||||
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-vert", key))).clicked() && enabled {
|
||||
actions.push(json!({key: !on}));
|
||||
}
|
||||
app.auto.add(&format!("character.{key}"), cr, label);
|
||||
@@ -288,7 +288,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let v = doc.variations.iter().chain(&instance).find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default);
|
||||
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, &tag, Tokens::semibold(10.5), t.text_dim);
|
||||
let (r, nv, _) =
|
||||
widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::new(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t);
|
||||
widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::unique(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t);
|
||||
app.auto.add(&format!("character.axis.{tag}"), r, &a.name);
|
||||
if let Some(nv) = nv
|
||||
&& enabled
|
||||
@@ -461,7 +461,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
|
||||
let x0 = rect.min.x + 10.0;
|
||||
let w = rect.width() - 20.0;
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("paragraph-scroll"), rect);
|
||||
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("paragraph-scroll"), rect);
|
||||
let top = rect.min.y + 10.0 - scroll.offset;
|
||||
let mut y = top;
|
||||
let mut actions: Vec<serde_json::Value> = vec![];
|
||||
@@ -469,7 +469,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 3.0), y), vec2(bw, 24.0));
|
||||
let on = doc.justify == j;
|
||||
let resp = ui.interact(r, egui::Id::new(("para", key)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("para", key)), Sense::click());
|
||||
p.rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
@@ -504,7 +504,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let fx = x0 + (i % 2) as f32 * (w / 2.0);
|
||||
let fy = y + (i / 2) as f32 * 28.0;
|
||||
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::new(("para-f", key)), v, 0.5, (-5000.0, 5000.0), 0, " px", &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("para-f", key)), v, 0.5, (-5000.0, 5000.0), 0, " px", &t);
|
||||
app.auto.add(&format!("paragraph.{key}"), r, key);
|
||||
if let Some(nv) = nv
|
||||
&& enabled
|
||||
@@ -521,8 +521,8 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if world_ready || rtl {
|
||||
let dr = Rect::from_min_size(pos2(x0, y), vec2(w / 2.0 - 4.0, 22.0));
|
||||
let dirs = vec!["Left-to-Right Text".to_string(), "Right-to-Left Text".to_string()];
|
||||
let dpop = egui::Id::new("para-direction-pop");
|
||||
if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::new("para-direction")).clicked() && enabled {
|
||||
let dpop = egui::Id::unique("para-direction-pop");
|
||||
if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::unique("para-direction")).clicked() && enabled {
|
||||
widgets::open_popup(ui, dpop);
|
||||
}
|
||||
app.auto.add("paragraph.direction", dr, "Text direction");
|
||||
@@ -536,9 +536,9 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
} else {
|
||||
vec!["Every-line Composer".to_string(), "Single-line Composer".to_string()]
|
||||
};
|
||||
let cpop = egui::Id::new("para-composer-pop");
|
||||
let cpop = egui::Id::unique("para-composer-pop");
|
||||
let single = doc.composer == Composer::SingleLine;
|
||||
if widgets::dropdown(ui, cr, &comps[single as usize], &t, egui::Id::new("para-composer")).clicked() && enabled {
|
||||
if widgets::dropdown(ui, cr, &comps[single as usize], &t, egui::Id::unique("para-composer")).clicked() && enabled {
|
||||
widgets::open_popup(ui, cpop);
|
||||
}
|
||||
app.auto.add("paragraph.composer", cr, "Composer");
|
||||
@@ -547,7 +547,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
y += 30.0;
|
||||
let hr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::new("para-hanging")).clicked() && enabled {
|
||||
if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::unique("para-hanging")).clicked() && enabled {
|
||||
actions.push(json!({"hangingPunctuation": !doc.hanging_punctuation}));
|
||||
}
|
||||
app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation");
|
||||
@@ -610,7 +610,10 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
|
||||
search.request_focus();
|
||||
}
|
||||
ui.memory_mut(|m| m.set_focus_lock_filter(search_id, egui::EventFilter { vertical_arrows: true, escape: true, ..Default::default() }));
|
||||
if widgets::icon_button(ui, arrow, crate::icons::Icon::ChevronDown, false, &t, id.with("arrow")).clicked() {
|
||||
if widgets::icon_button(ui, arrow, crate::icons::Icon::ChevronDown, false, &t, id.with("arrow"))
|
||||
.on_hover_text(crate::i18n::tr("Close font search"))
|
||||
.clicked()
|
||||
{
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(id.with("open"), false);
|
||||
d.remove::<FontSearch>(state_id);
|
||||
@@ -697,7 +700,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
|
||||
let row_width = ui.available_width();
|
||||
let label_width = if preview { (row_width - 100.0).max(40.0) } else { row_width };
|
||||
let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == state.selected, ""));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), index == state.selected, &r.display));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), index == state.selected, &r.display));
|
||||
let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height()));
|
||||
ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text(
|
||||
label_rect.left_center(),
|
||||
@@ -713,7 +716,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
|
||||
resp.scroll_to_me(None);
|
||||
}
|
||||
if preview && ui.is_rect_visible(resp.rect) {
|
||||
let key = egui::Id::new(("font-preview", &r.family));
|
||||
let key = egui::Id::unique(("font-preview", &r.family));
|
||||
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
@@ -765,7 +768,7 @@ pub fn align(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, tr("Align Layers to:"), Tokens::ui(11.5), t.text_dim);
|
||||
let dr = Rect::from_min_size(pos2(x0 + 100.0, y), vec2(110.0, 20.0));
|
||||
let to_sel = app.ui.align_to_selection;
|
||||
let pid = egui::Id::new("align-to-pop");
|
||||
let pid = egui::Id::unique("align-to-pop");
|
||||
if widgets::dropdown(ui, dr, if to_sel { "Selection" } else { "Composition" }, &t, pid.with("btn")).clicked() {
|
||||
widgets::open_popup(ui, pid);
|
||||
}
|
||||
@@ -785,7 +788,7 @@ pub fn align(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * 34.0 + if i >= 3 { 12.0 } else { 0.0 }, y), vec2(30.0, 28.0));
|
||||
let resp = ui.interact(r, egui::Id::new((kind, *op)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique((kind, *op)), Sense::click());
|
||||
p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.field_bg });
|
||||
align_glyph(&p, r, op, row == 1, &t);
|
||||
let tip = align_tip(kind, op);
|
||||
|
||||
@@ -106,7 +106,7 @@ pub fn viewer_hook(
|
||||
// A short grab zone around the triangle, so the line above doesn't steal the
|
||||
// layer's scale handles.
|
||||
let hr = Rect::from_two_pos(sp, sp + up * 8.0 + inward * 6.0).expand(3.0);
|
||||
let id = egui::Id::new(("viewer-text-range", pr.uid));
|
||||
let id = egui::Id::unique(("viewer-text-range", pr.uid));
|
||||
let on_box_handle = ctx.pointer_latest_pos().is_some_and(|p| box_handles.iter().any(|c| c.distance(p) < 9.0));
|
||||
let free = ctx.is_being_dragged(id) || !on_box_handle;
|
||||
let resp = (interactive && free).then(|| ui.interact(hr, id, Sense::drag()));
|
||||
|
||||
@@ -123,7 +123,7 @@ const EXPR_MAX_LINES: usize = 100;
|
||||
|
||||
/// The text of the expression being edited (while its editor has the keyboard).
|
||||
fn expr_buf_id(prop: u64) -> egui::Id {
|
||||
egui::Id::new(("expr-buf", prop))
|
||||
egui::Id::unique(("expr-buf", prop))
|
||||
}
|
||||
|
||||
/// Insert an expression editor row after every property that has an expression: as many lines
|
||||
@@ -164,7 +164,7 @@ const WHIP_EXPR: u8 = 1;
|
||||
const WHIP_PROP: u8 = 2;
|
||||
|
||||
fn pick_whip_id() -> egui::Id {
|
||||
egui::Id::new("tl-pickwhip")
|
||||
egui::Id::unique("tl-pickwhip")
|
||||
}
|
||||
|
||||
/// A pick whip at `rect`: dragging it starts a pick whip of `kind` from `layer`'s `prop` (0 for
|
||||
@@ -180,7 +180,7 @@ fn pick_whip(
|
||||
label: &str,
|
||||
hint: &str,
|
||||
) -> egui::Response {
|
||||
let resp = ui.interact(rect, egui::Id::new(("pick-whip", kind, layer, prop)), Sense::drag()).on_hover_text(hint);
|
||||
let resp = ui.interact(rect, egui::Id::unique(("pick-whip", kind, layer, prop)), Sense::drag()).on_hover_text(hint);
|
||||
icons::paint(p, rect.shrink(1.0), Icon::PickWhip, if resp.hovered() || resp.dragged() { app.tokens.text } else { app.tokens.text_dim });
|
||||
app.auto.add(auto, rect, label);
|
||||
if resp.drag_started() {
|
||||
@@ -195,7 +195,7 @@ fn pick_whip(
|
||||
type WhipTargets = (u64, Vec<(Rect, u64, u64, Vec<Rect>)>);
|
||||
|
||||
fn whip_targets_id() -> egui::Id {
|
||||
egui::Id::new("tl-pickwhip-targets")
|
||||
egui::Id::unique("tl-pickwhip-targets")
|
||||
}
|
||||
|
||||
/// While a pick whip is being dragged, `layer`'s property `prop` shown at `row` in another panel
|
||||
@@ -264,7 +264,7 @@ fn centered_on(span: f64, duration: f64, cti: f64) -> f64 {
|
||||
type WhipInto = (String, [usize; 2]);
|
||||
|
||||
fn whip_into_id(prop: u64) -> egui::Id {
|
||||
egui::Id::new(("tl-pickwhip-into", prop))
|
||||
egui::Id::unique(("tl-pickwhip-into", prop))
|
||||
}
|
||||
|
||||
/// An expression editor to take the keyboard when it is next drawn: its property, the text to
|
||||
@@ -280,7 +280,7 @@ struct ExprFocus {
|
||||
}
|
||||
|
||||
fn expr_focus_id() -> egui::Id {
|
||||
egui::Id::new("tl-expr-focus")
|
||||
egui::Id::unique("tl-expr-focus")
|
||||
}
|
||||
|
||||
fn focus_expression(ctx: &egui::Context, prop: u64, select: [usize; 2]) {
|
||||
@@ -561,7 +561,7 @@ fn ruler_label(fno: i64, fps_i: i64, settings: &effectcraft_engine::project::Pro
|
||||
}
|
||||
|
||||
fn tl_map_id() -> egui::Id {
|
||||
egui::Id::new("timeline-map")
|
||||
egui::Id::unique("timeline-map")
|
||||
}
|
||||
|
||||
/// The column right of the time graph: the composition marker bin over the layers' scroll bar.
|
||||
@@ -594,7 +594,7 @@ pub(crate) fn set_zoom(tl: &mut crate::state::TimelineState, comp: &Comp, pps: f
|
||||
|
||||
/// `;`: from the whole comp, zoom in to frame level around the CTI; otherwise show the whole comp.
|
||||
pub fn toggle_frame_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return };
|
||||
let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::unique("timeline-graph-area"))) else { return };
|
||||
let Some(comp) = app.session.active_comp_arc() else { return };
|
||||
let fit = fit_pps(w, &comp);
|
||||
if app.ui.timeline.pps.is_some_and(|p| p > fit) {
|
||||
@@ -615,14 +615,14 @@ pub fn toggle_fit_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
let Some((start, pps)) = tl.before_fit.take() else { return };
|
||||
let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return };
|
||||
let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::unique("timeline-graph-area"))) else { return };
|
||||
let Some(comp) = app.session.active_comp_arc() else { return };
|
||||
set_zoom(&mut app.ui.timeline, &comp, pps, fit_pps(w, &comp), start, 0.0);
|
||||
}
|
||||
|
||||
/// Zoom the time ruler around the CTI.
|
||||
pub fn zoom(app: &mut EffectcraftApp, ctx: &egui::Context, k: f64) {
|
||||
let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return };
|
||||
let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::unique("timeline-graph-area"))) else { return };
|
||||
let Some(comp) = app.session.active_comp_arc() else { return };
|
||||
let fit = fit_pps(w, &comp);
|
||||
let pps = app.ui.timeline.pps.unwrap_or(fit);
|
||||
@@ -703,18 +703,18 @@ fn double_click_action(layer: &Layer) -> Option<(String, serde_json::Value)> {
|
||||
/// Open the rename field on layer `id`; it takes the keyboard focus on its first frame only.
|
||||
fn start_rename(ctx: &egui::Context, id: u64, name: &str) {
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(egui::Id::new("tl-rename"), (id, name.to_string()));
|
||||
d.insert_temp(egui::Id::unique("tl-rename"), (id, name.to_string()));
|
||||
d.insert_temp(rename_focus_id(), true);
|
||||
});
|
||||
}
|
||||
|
||||
/// The Timeline's open group rename field: (layer, group uid, text).
|
||||
fn group_rename_id() -> egui::Id {
|
||||
egui::Id::new("tl-group-rename")
|
||||
egui::Id::unique("tl-group-rename")
|
||||
}
|
||||
|
||||
fn rename_focus_id() -> egui::Id {
|
||||
egui::Id::new("tl-rename-focus")
|
||||
egui::Id::unique("tl-rename-focus")
|
||||
}
|
||||
|
||||
fn group_visible(g: &PropGroup, layer: &Layer) -> bool {
|
||||
@@ -1276,7 +1276,7 @@ fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon
|
||||
|
||||
/// Layers being dragged to a new place in the stack (timeline rows).
|
||||
fn layer_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drag")
|
||||
egui::Id::unique("tl-layer-drag")
|
||||
}
|
||||
|
||||
/// A keyframe drag in progress: where the pointer started, the grabbed key's comp time
|
||||
@@ -1291,7 +1291,7 @@ struct KeyDrag {
|
||||
}
|
||||
|
||||
fn key_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-key-drag")
|
||||
egui::Id::unique("tl-key-drag")
|
||||
}
|
||||
|
||||
/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): the bar it started on, where
|
||||
@@ -1305,7 +1305,7 @@ struct QuickBar {
|
||||
}
|
||||
|
||||
fn quick_bar_id() -> egui::Id {
|
||||
egui::Id::new("tl-quick-bar")
|
||||
egui::Id::unique("tl-quick-bar")
|
||||
}
|
||||
|
||||
/// How many layers a Quick Offset of `layers` staggers (#1012): the unlocked ones, each once, if
|
||||
@@ -1328,7 +1328,7 @@ fn quick_offset_note(frames: i64, layers: usize) -> String {
|
||||
|
||||
/// A Timeline drag's readout by the pointer (After Effects shows it in the Info panel).
|
||||
fn drag_tip(ctx: &egui::Context, t: &Tokens, ptr: Pos2, note: String) {
|
||||
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip")));
|
||||
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("tl-key-drag-tip")));
|
||||
let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text);
|
||||
let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0));
|
||||
fg.rect_filled(r, 3.0, t.panel_bg);
|
||||
@@ -1346,7 +1346,7 @@ struct SwitchDrag {
|
||||
}
|
||||
|
||||
fn switch_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-switch-drag")
|
||||
egui::Id::unique("tl-switch-drag")
|
||||
}
|
||||
|
||||
/// The press-and-drag gesture of the Timeline's switches and stopwatches (`switch`, on `row`,
|
||||
@@ -1439,7 +1439,7 @@ fn layer_switch(
|
||||
}
|
||||
|
||||
fn anchor_id(comp: ItemId) -> egui::Id {
|
||||
egui::Id::new(("timeline-selection-anchor", comp.0))
|
||||
egui::Id::unique(("timeline-selection-anchor", comp.0))
|
||||
}
|
||||
|
||||
/// Remember `layer` as where the next Shift-click selection starts.
|
||||
@@ -1472,7 +1472,7 @@ fn insertion(layer_rows: &[(Rect, LayerId)], py: f32, top: f32) -> (Option<usize
|
||||
}
|
||||
|
||||
fn layer_drop_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drop")
|
||||
egui::Id::unique("tl-layer-drop")
|
||||
}
|
||||
|
||||
/// Whether a Project item can replace a layer's source (the same set `layer.replaceSource`
|
||||
@@ -1551,7 +1551,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let cw = cols(rect.min.x - oscroll, left_w + overflow, vis);
|
||||
let graph_x0 = rect.min.x + left_w + 1.0;
|
||||
let graph_x1 = rect.max.x - GUTTER_W;
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0)));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0)));
|
||||
let fit_pps = fit_pps(graph_x1 - graph_x0, &comp);
|
||||
let pps = app.ui.timeline.pps.unwrap_or(fit_pps);
|
||||
if app.ui.timeline.pps.is_none() {
|
||||
@@ -1587,7 +1587,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// ---- header (left): time display, search, switches.
|
||||
let tc = crate::panels::timecode(&app.session, &comp, time);
|
||||
let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0));
|
||||
let tc_resp = ui.interact(tc_rect, egui::Id::new("tl-timecode"), Sense::click_and_drag());
|
||||
let tc_resp = ui.interact(tc_rect, egui::Id::unique("tl-timecode"), Sense::click_and_drag());
|
||||
p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode);
|
||||
// Under it the other style (frames under timecode, timecode under frames) and the rate.
|
||||
let settings = &app.session.project.settings;
|
||||
@@ -1613,19 +1613,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let _ = app.session.execute("time.set", json!({"frame": f.max(0)}));
|
||||
}
|
||||
if tc_resp.double_clicked() {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-tc-edit"), tc.clone()));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-tc-edit"), tc.clone()));
|
||||
}
|
||||
if let Some(mut buf) = ctx.data(|d| d.get_temp::<String>(egui::Id::new("tl-tc-edit"))) {
|
||||
if let Some(mut buf) = ctx.data(|d| d.get_temp::<String>(egui::Id::unique("tl-tc-edit"))) {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(tc_rect));
|
||||
let r = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::semibold(16.0)).desired_width(150.0));
|
||||
if r.lost_focus() {
|
||||
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
let _ = app.session.execute("time.set", json!({"timecode": buf}));
|
||||
}
|
||||
ctx.data_mut(|d| d.remove::<String>(egui::Id::new("tl-tc-edit")));
|
||||
ctx.data_mut(|d| d.remove::<String>(egui::Id::unique("tl-tc-edit")));
|
||||
} else {
|
||||
widgets::keep_focus(&ctx, &r, &buf);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-tc-edit"), buf));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-tc-edit"), buf));
|
||||
}
|
||||
}
|
||||
let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0));
|
||||
@@ -1643,7 +1643,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
(Icon::Flowchart, false, "flowchart", "Composition Mini-Flowchart"),
|
||||
] {
|
||||
let r = Rect::from_min_size(pos2(bx - 24.0, top + 11.0), vec2(24.0, 24.0));
|
||||
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("tl-sw", id))).on_hover_text(tip);
|
||||
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::unique(("tl-sw", id))).on_hover_text(tip);
|
||||
app.auto.add(&format!("timeline.switch.{id}"), r, tip);
|
||||
if resp.clicked() {
|
||||
match id {
|
||||
@@ -1675,7 +1675,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// The middle scrolls at the current zoom; each end is a handle that moves that side of the
|
||||
// visible span (pulled in: zoomed in; stretched to the whole track: the whole comp).
|
||||
let nresp = ui.interact(nav, egui::Id::new("tl-nav"), Sense::drag());
|
||||
let nresp = ui.interact(nav, egui::Id::unique("tl-nav"), Sense::drag());
|
||||
app.auto.add("timeline.navigator", nav, "Time navigator");
|
||||
if nresp.dragged() {
|
||||
let d = nresp.drag_delta().x as f64 / nav.width() as f64 * comp.duration.seconds();
|
||||
@@ -1688,7 +1688,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let (t0, t1) = (tm.start, tm.t(graph_x1));
|
||||
for (end, x) in [("start", nav_vis.min.x), ("end", nav_vis.max.x)] {
|
||||
let hr = Rect::from_center_size(pos2(x, nav.center().y), vec2(10.0, nav.height() + 6.0));
|
||||
let hresp = ui.interact(hr, egui::Id::new(("tl-nav", end)), Sense::drag());
|
||||
let hresp = ui.interact(hr, egui::Id::unique(("tl-nav", end)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.navigator.{end}"), hr, if end == "start" { "Time navigator start" } else { "Time navigator end" });
|
||||
if hresp.hovered() || hresp.dragged() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
@@ -1712,7 +1712,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
p.with_clip_rect(wa_row).rect_filled(wa, 0.0, t.tl_work_area_bar);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let bar = wa.intersect(wa_row);
|
||||
let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::click_and_drag());
|
||||
let bresp = ui.interact(bar, egui::Id::unique("wa-bar"), Sense::click_and_drag());
|
||||
app.auto.add("timeline.workArea.bar", bar, "Work area");
|
||||
// Right-click: the work area's commands (After Effects).
|
||||
crate::widgets::context_menu(&bresp, |ui| {
|
||||
@@ -1722,7 +1722,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
actions.extend(chosen);
|
||||
});
|
||||
let bar_start = egui::Id::new("wa-bar-start");
|
||||
let bar_start = egui::Id::unique("wa-bar-start");
|
||||
if bresp.drag_started() {
|
||||
ctx.data_mut(|d| d.insert_temp(bar_start, wa0));
|
||||
}
|
||||
@@ -1745,7 +1745,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
Rect::from_min_max(pos2(hx - 4.0, wa.min.y), pos2(hx + 1.0, wa.max.y))
|
||||
};
|
||||
p.rect_filled(handle, 2.0, t.accent);
|
||||
let resp = ui.interact(hr, egui::Id::new(("wa", set)), Sense::drag());
|
||||
let resp = ui.interact(hr, egui::Id::unique(("wa", set)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.workArea.{set}"), hr, set);
|
||||
if resp.dragged()
|
||||
&& let Some(pt) = resp.interact_pointer_pos()
|
||||
@@ -1841,8 +1841,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// Scrub in the ruler: the current time jumps to the pointer as soon as the button goes down
|
||||
// (After Effects), then follows it.
|
||||
let rresp =
|
||||
ui.interact(Rect::from_min_max(pos2(graph_x0, ruler.min.y + 12.0), pos2(bin.min.x, ruler.max.y)), egui::Id::new("tl-ruler"), Sense::click_and_drag());
|
||||
let rresp = ui.interact(
|
||||
Rect::from_min_max(pos2(graph_x0, ruler.min.y + 12.0), pos2(bin.min.x, ruler.max.y)),
|
||||
egui::Id::unique("tl-ruler"),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
if ((rresp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || rresp.clicked())
|
||||
&& let Some(pt) = rresp.interact_pointer_pos()
|
||||
{
|
||||
@@ -1888,7 +1891,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&& ui.input(|i| i.modifiers.command)
|
||||
&& let Some(cid) = app.session.active_comp_id()
|
||||
{
|
||||
let now = ui.input(|i| i.time);
|
||||
// The app clock: the audio scrub's idle timeout compares against it in the tick.
|
||||
let now = app.now();
|
||||
let t = app.session.time();
|
||||
app.scrub_audio(cid, t, now);
|
||||
}
|
||||
@@ -1945,7 +1949,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
let hdr_rect = Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y));
|
||||
let hresp = ui.interact(hdr_rect, egui::Id::new("tl-colhdr"), Sense::click());
|
||||
let hresp = ui.interact(hdr_rect, egui::Id::unique("tl-colhdr"), Sense::click());
|
||||
app.auto.add("timeline.header", hdr_rect, "Columns (right-click)");
|
||||
for (id, label) in COLUMNS {
|
||||
if let Some(x0) = match id {
|
||||
@@ -1974,10 +1978,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
if hresp.secondary_clicked() {
|
||||
let under = hresp.interact_pointer_pos().and_then(|pt| column_at(&cw, pt.x));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-colhdr-under"), under.unwrap_or("")));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-colhdr-under"), under.unwrap_or("")));
|
||||
}
|
||||
crate::widgets::context_menu(&hresp, |ui| {
|
||||
let under: &str = ctx.data(|d| d.get_temp(egui::Id::new("tl-colhdr-under"))).unwrap_or("");
|
||||
let under: &str = ctx.data(|d| d.get_temp(egui::Id::unique("tl-colhdr-under"))).unwrap_or("");
|
||||
if !under.is_empty() && under != "name" && ui.button(crate::i18n::tr("Hide This")).clicked() {
|
||||
actions.push(("timeline.column".into(), json!({"column": under, "visible": false})));
|
||||
ui.close();
|
||||
@@ -2083,9 +2087,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
|
||||
// Registered before the rows so keys, bars and editors on top of it get the pointer first.
|
||||
let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
|
||||
let empty = ui.interact(empty_rect, egui::Id::new("tl-graph-bg"), Sense::click_and_drag());
|
||||
let empty = ui.interact(empty_rect, egui::Id::unique("tl-graph-bg"), Sense::click_and_drag());
|
||||
let outline_rect = Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y));
|
||||
let outline_empty = ui.interact(outline_rect, egui::Id::new("tl-layer-bg"), Sense::click_and_drag());
|
||||
let outline_empty = ui.interact(outline_rect, egui::Id::unique("tl-layer-bg"), Sense::click_and_drag());
|
||||
app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
|
||||
let mut hit_rows: Vec<(Rect, Row)> = vec![];
|
||||
// The value fields of multi-dimension properties (per dimension), for the pick whip.
|
||||
@@ -2147,7 +2151,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
br,
|
||||
icon,
|
||||
on,
|
||||
egui::Id::new(("av", layer.id.0, i)),
|
||||
egui::Id::unique(("av", layer.id.0, i)),
|
||||
layer.id.0,
|
||||
name,
|
||||
true,
|
||||
@@ -2162,10 +2166,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if vis.label {
|
||||
let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
|
||||
lp.rect_filled(lr, 2.0, t.label(layer.label));
|
||||
let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click());
|
||||
let lresp = ui.interact(lr, egui::Id::unique(("label", layer.id.0)), Sense::click());
|
||||
app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name());
|
||||
// Click (or right-click) the swatch for the label menu (After Effects).
|
||||
let pop = egui::Id::new(("label-pop", layer.id.0));
|
||||
let pop = egui::Id::unique(("label-pop", layer.id.0));
|
||||
if lresp.clicked() || lresp.secondary_clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
@@ -2182,13 +2186,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 };
|
||||
let row_resp = ui.interact(
|
||||
Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)),
|
||||
egui::Id::new(("row", layer.id.0)),
|
||||
egui::Id::unique(("row", layer.id.0)),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
// Twirl + icon + name.
|
||||
let tw = Rect::from_center_size(pos2(cw.name + 8.0, cy), vec2(14.0, 14.0));
|
||||
let open = app.ui.timeline.open_layers.contains(&layer.id.0);
|
||||
if widgets::twirl(ui, tw, open, egui::Id::new(("twirl", layer.id.0)), &t).clicked() {
|
||||
if widgets::twirl(ui, tw, open, egui::Id::unique(("twirl", layer.id.0)), &t).clicked() {
|
||||
ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
|
||||
}
|
||||
app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl");
|
||||
@@ -2203,7 +2207,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
cw.name + 34.0
|
||||
};
|
||||
let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y));
|
||||
let rename_id = egui::Id::new("tl-rename");
|
||||
let rename_id = egui::Id::unique("tl-rename");
|
||||
let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id));
|
||||
if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0))));
|
||||
@@ -2298,7 +2302,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
br,
|
||||
icon,
|
||||
on,
|
||||
egui::Id::new(("sw", layer.id.0, i)),
|
||||
egui::Id::unique(("sw", layer.id.0, i)),
|
||||
layer.id.0,
|
||||
name,
|
||||
false,
|
||||
@@ -2312,9 +2316,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Modes.
|
||||
if app.ui.timeline.show_modes && layer.source.is_av() {
|
||||
let mr = Rect::from_min_size(pos2(cw.mode + 2.0, cy - 9.0), vec2(80.0, 18.0));
|
||||
let mresp = widgets::dropdown(ui, mr, layer.blend_mode.label(), &t, egui::Id::new(("mode", layer.id.0)));
|
||||
let mresp = widgets::dropdown(ui, mr, layer.blend_mode.label(), &t, egui::Id::unique(("mode", layer.id.0)));
|
||||
app.auto.add(&format!("timeline.layer.{}.mode", layer.id.0), mr, "Mode");
|
||||
let pop = egui::Id::new(("mode-pop", layer.id.0));
|
||||
let pop = egui::Id::unique(("mode-pop", layer.id.0));
|
||||
if mresp.clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
@@ -2327,7 +2331,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0));
|
||||
app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency");
|
||||
if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() {
|
||||
if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).clicked() {
|
||||
// The click gives the selection the state the clicked row toggles to,
|
||||
// whatever the first selected layer's value (as the switches do).
|
||||
actions.push((
|
||||
@@ -2347,9 +2351,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
};
|
||||
// The cell is too narrow for the mode as well: it is in the tooltip and ticked in the menu.
|
||||
let tip = layer.track_matte.map(|m| format!("{label} · {}", crate::i18n::tr(m.kind.label())));
|
||||
let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::new(("trkmat", layer.id.0)));
|
||||
let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::unique(("trkmat", layer.id.0)));
|
||||
app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte");
|
||||
let pop = egui::Id::new(("trk-pop", layer.id.0));
|
||||
let pop = egui::Id::unique(("trk-pop", layer.id.0));
|
||||
if tresp.clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
@@ -2393,9 +2397,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
);
|
||||
let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0));
|
||||
let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", idx_of(l.id), l.name)).unwrap_or_else(|| "None".into());
|
||||
let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::new(("parent", layer.id.0)));
|
||||
let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::unique(("parent", layer.id.0)));
|
||||
app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent");
|
||||
let pop = egui::Id::new(("par-pop", layer.id.0));
|
||||
let pop = egui::Id::unique(("par-pop", layer.id.0));
|
||||
if presp.clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
@@ -2455,7 +2459,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
continue;
|
||||
}
|
||||
let gresp = ui
|
||||
.interact(gr, egui::Id::new(("ghost", side, layer.id.0)), Sense::click_and_drag())
|
||||
.interact(gr, egui::Id::unique(("ghost", side, layer.id.0)), Sense::click_and_drag())
|
||||
.on_hover_text(crate::i18n::tr("Drag to slip the source"));
|
||||
// Outside the bar it is empty track area: a plain click deselects.
|
||||
if gresp.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
|
||||
@@ -2465,7 +2469,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if gresp.hovered() || gresp.dragged() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
let acc_id = egui::Id::new(("slip-acc", layer.id.0));
|
||||
let acc_id = egui::Id::unique(("slip-acc", layer.id.0));
|
||||
if gresp.dragged() {
|
||||
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
|
||||
acc += gresp.drag_delta().x;
|
||||
@@ -2488,9 +2492,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name);
|
||||
// Interactions: move body, trim edges.
|
||||
let (body_r, in_r, out_r) = trim_zones(bar);
|
||||
let body = ui.interact(body_r, egui::Id::new(("bar", layer.id.0)), Sense::click_and_drag());
|
||||
let lin = ui.interact(in_r, egui::Id::new(("bar-in", layer.id.0)), Sense::drag());
|
||||
let lout = ui.interact(out_r, egui::Id::new(("bar-out", layer.id.0)), Sense::drag());
|
||||
let body = ui.interact(body_r, egui::Id::unique(("bar", layer.id.0)), Sense::click_and_drag());
|
||||
let lin = ui.interact(in_r, egui::Id::unique(("bar-in", layer.id.0)), Sense::drag());
|
||||
let lout = ui.interact(out_r, egui::Id::unique(("bar-out", layer.id.0)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In");
|
||||
app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out");
|
||||
if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
|
||||
@@ -2534,7 +2538,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
continue;
|
||||
}
|
||||
if resp.dragged() {
|
||||
let acc_id = egui::Id::new(("bar-acc", layer.id.0, kind));
|
||||
let acc_id = egui::Id::unique(("bar-acc", layer.id.0, kind));
|
||||
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
|
||||
acc += resp.drag_delta().x;
|
||||
let frames = ((acc as f64 / pps) / fd).trunc() as i64;
|
||||
@@ -2554,7 +2558,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ctx.data_mut(|d| d.insert_temp(acc_id, acc));
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
ctx.data_mut(|d| d.remove::<f32>(egui::Id::new(("bar-acc", layer.id.0, kind))));
|
||||
ctx.data_mut(|d| d.remove::<f32>(egui::Id::unique(("bar-acc", layer.id.0, kind))));
|
||||
ui_actions.push(UiAct::EndMerge);
|
||||
}
|
||||
}
|
||||
@@ -2603,7 +2607,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
|
||||
if *has_children {
|
||||
let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0));
|
||||
if widgets::twirl(ui, tw, *open, egui::Id::new(("gtw", uid)), &t).clicked() {
|
||||
if widgets::twirl(ui, tw, *open, egui::Id::unique(("gtw", uid)), &t).clicked() {
|
||||
// Alt/Cmd-click twirls the whole tree under the group (as on a layer).
|
||||
ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
|
||||
}
|
||||
@@ -2611,7 +2615,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
if let Some(en) = fx {
|
||||
let fr_ = Rect::from_center_size(pos2(cw.switches + SW * 3.0 + SW / 2.0, cy), vec2(16.0, 16.0));
|
||||
if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::new(("gfx", uid)), Sense::click()).clicked() {
|
||||
if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::unique(("gfx", uid)), Sense::click()).clicked() {
|
||||
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid})));
|
||||
}
|
||||
app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name);
|
||||
@@ -2620,7 +2624,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Layer style eye switch in the A/V column, like AE.
|
||||
let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0));
|
||||
lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::new(("geye", uid)), Sense::click()).clicked() {
|
||||
if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::unique(("geye", uid)), Sense::click()).clicked() {
|
||||
actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
|
||||
}
|
||||
app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);
|
||||
@@ -2651,7 +2655,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if *uid == GEOM_BIT | layer.id.0 {
|
||||
let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Change Renderer…"), Tokens::ui(11.5), t.accent);
|
||||
let resp = ui
|
||||
.interact(lr, egui::Id::new(("tl-change-renderer", layer.id.0)), Sense::click())
|
||||
.interact(lr, egui::Id::unique(("tl-change-renderer", layer.id.0)), Sense::click())
|
||||
.on_hover_text(crate::i18n::tr("Composition Settings ▸ 3D Renderer"));
|
||||
if resp.hovered() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
@@ -2667,8 +2671,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&& let GroupKind::Mask { mode, inverted, .. } = g.kind
|
||||
{
|
||||
let mr = Rect::from_min_size(pos2(cw.switches + 4.0, cy - 9.0), vec2(80.0, 18.0));
|
||||
let pop = egui::Id::new(("maskmode", uid));
|
||||
if widgets::dropdown(ui, mr, mode.label(), &t, egui::Id::new(("mm", uid))).clicked() {
|
||||
let pop = egui::Id::unique(("maskmode", uid));
|
||||
if widgets::dropdown(ui, mr, mode.label(), &t, egui::Id::unique(("mm", uid))).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
let opts: Vec<String> = effectcraft_engine::project::MaskMode::ALL.iter().map(|m| m.label().to_string()).collect();
|
||||
@@ -2676,13 +2680,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]})));
|
||||
}
|
||||
let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0));
|
||||
if widgets::checkbox(ui, ir, inverted, &t, egui::Id::new(("minv", uid))).clicked() {
|
||||
if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).clicked() {
|
||||
actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted})));
|
||||
}
|
||||
lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim);
|
||||
}
|
||||
let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
|
||||
let gr = ui.interact(gr_rect, egui::Id::new(("grow", uid)), Sense::click());
|
||||
let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click());
|
||||
app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
|
||||
if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
|
||||
@@ -2721,7 +2725,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
RowKind::Expr { uid, lines } => {
|
||||
let Some(prop) = layer.props.find(*uid) else { continue };
|
||||
let Some(ex) = prop.expr.clone() else { continue };
|
||||
let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::new(("expr-edit", uid)));
|
||||
let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::unique(("expr-edit", uid)));
|
||||
// The same background as its property's row, selection included.
|
||||
let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
|
||||
lp.rect_filled(
|
||||
@@ -2740,7 +2744,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text);
|
||||
// "=" enable switch and the property pick whip (AE's expression controls).
|
||||
let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0));
|
||||
let en = ui.interact(en_r, egui::Id::new(("expr-en", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Enable Expression"));
|
||||
let en = ui.interact(en_r, egui::Id::unique(("expr-en", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Enable Expression"));
|
||||
lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
let expr_col = t.expr_text;
|
||||
lp.text(
|
||||
@@ -2789,11 +2793,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
{
|
||||
let wr = Rect::from_center_size(pos2(cw.switches + 50.0, cy), vec2(14.0, 14.0));
|
||||
lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
|
||||
let _ = ui.interact(wr, egui::Id::new(("expr-err", uid)), Sense::hover()).on_hover_text(e);
|
||||
let _ = ui.interact(wr, egui::Id::unique(("expr-err", uid)), Sense::hover()).on_hover_text(e);
|
||||
}
|
||||
// ⓕ: the Expression Language menu inserts at the cursor.
|
||||
let lang_r = Rect::from_center_size(pos2(cw.switches + 70.0, cy), vec2(16.0, 16.0));
|
||||
let lang = ui.interact(lang_r, egui::Id::new(("expr-lang", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Expression Language Menu"));
|
||||
let lang = ui.interact(lang_r, egui::Id::unique(("expr-lang", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Expression Language Menu"));
|
||||
lp.circle_stroke(lang_r.center(), 6.5, Stroke::new(1.0, if lang.hovered() { t.text } else { t.text_dim }));
|
||||
lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu");
|
||||
@@ -2848,7 +2852,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
|
||||
// Its bottom edge drags to set its height in whole lines, as in After Effects.
|
||||
let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y));
|
||||
let eresp = ui.interact(edge, egui::Id::new(("expr-edge", uid)), Sense::drag());
|
||||
let eresp = ui.interact(edge, egui::Id::unique(("expr-edge", uid)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height");
|
||||
if eresp.hovered() || eresp.dragged() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical);
|
||||
@@ -2904,17 +2908,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
kc,
|
||||
Stroke::new(1.0, t.text_dim),
|
||||
));
|
||||
if ui.interact(prev, egui::Id::new(("kprev", uid)), Sense::click()).clicked()
|
||||
if ui.interact(prev, egui::Id::unique(("kprev", uid)), Sense::click()).clicked()
|
||||
&& let Some(k) = prop.keys.iter().rev().find(|k| layer.comp_time(k.time).0 < time.0 - fr.frame_duration().0 / 2)
|
||||
{
|
||||
ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
|
||||
}
|
||||
if ui.interact(next, egui::Id::new(("knext", uid)), Sense::click()).clicked()
|
||||
if ui.interact(next, egui::Id::unique(("knext", uid)), Sense::click()).clicked()
|
||||
&& let Some(k) = prop.keys.iter().find(|k| layer.comp_time(k.time).0 > time.0 + fr.frame_duration().0 / 2)
|
||||
{
|
||||
ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
|
||||
}
|
||||
if ui.interact(mid, egui::Id::new(("kmid", uid)), Sense::click()).clicked() {
|
||||
if ui.interact(mid, egui::Id::unique(("kmid", uid)), Sense::click()).clicked() {
|
||||
actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
|
||||
@@ -2923,7 +2927,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// dragging on over other stopwatches gives them the same state (#284).
|
||||
if !prop.static_only {
|
||||
let swr = Rect::from_center_size(pos2(indent, cy), vec2(15.0, 15.0));
|
||||
let resp = ui.interact(swr, egui::Id::new(("stopwatch", uid)), Sense::click_and_drag());
|
||||
let resp = ui.interact(swr, egui::Id::unique(("stopwatch", uid)), Sense::click_and_drag());
|
||||
let col = if prop.is_animated() {
|
||||
t.hot_text
|
||||
} else if resp.hovered() {
|
||||
@@ -2960,14 +2964,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let pname = if prop.has_expression() { format!("{base} =") } else { base };
|
||||
lp.text(pos2(name_x, cy), Align2::LEFT_CENTER, &pname, Tokens::ui(12.0), if prop.has_expression() { t.expr_text } else { t.text });
|
||||
let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y));
|
||||
let name_resp = ui.interact(name_rect, egui::Id::new(("pname", uid)), Sense::click_and_drag());
|
||||
let name_resp = ui.interact(name_rect, egui::Id::unique(("pname", uid)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name);
|
||||
if name_resp.drag_started() {
|
||||
// Drop on the Essential Graphics panel to expose the property.
|
||||
egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
|
||||
}
|
||||
if name_resp.dragged() {
|
||||
egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::new("prop-drag-tip"), egui::PopupAnchor::Pointer)
|
||||
egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::unique("prop-drag-tip"), egui::PopupAnchor::Pointer)
|
||||
.show(|ui| ui.label(&prop.name));
|
||||
}
|
||||
if name_resp.clicked() {
|
||||
@@ -3034,7 +3038,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol);
|
||||
}
|
||||
let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag());
|
||||
let kresp = ui.interact(kr, egui::Id::unique(("key", uid, k.time.0)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
|
||||
// Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame.
|
||||
app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name));
|
||||
@@ -3204,12 +3208,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let box_rect = Rect::from_two_pos(start, end).intersect(outline_rect);
|
||||
lp.rect_filled(box_rect, 0.0, t.accent.gamma_multiply(0.12));
|
||||
lp.rect_stroke(box_rect, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-layer-box"), box_rect));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-layer-box"), box_rect));
|
||||
}
|
||||
if outline_empty.drag_stopped()
|
||||
&& let Some(box_rect) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("tl-layer-box")))
|
||||
&& let Some(box_rect) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("tl-layer-box")))
|
||||
{
|
||||
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::new("tl-layer-box")));
|
||||
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::unique("tl-layer-box")));
|
||||
let layers: Vec<u64> = hit_rows
|
||||
.iter()
|
||||
.filter(|(r, row)| matches!(row.kind, RowKind::Layer) && r.intersects(box_rect))
|
||||
@@ -3222,7 +3226,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// or a property's, also over a property in Effect Controls).
|
||||
if let Some((kind, src_layer, src_prop, start)) = ctx.data(|d| d.get_temp::<PickWhip>(pick_whip_id())) {
|
||||
let ptr = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(start);
|
||||
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("tl-pickwhip-line")));
|
||||
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("tl-pickwhip-line")));
|
||||
fg.line_segment([start, ptr], Stroke::new(1.5, t.accent));
|
||||
fg.circle_stroke(ptr, 4.0, Stroke::new(1.5, t.accent));
|
||||
let target = hit_rows.iter().find(|(r, _)| r.contains(ptr)).map(|(r, row)| (*r, row.clone()));
|
||||
@@ -3297,7 +3301,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let br = Rect::from_two_pos(origin, cur);
|
||||
gp.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
|
||||
gp.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-box"), br));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-box"), br));
|
||||
}
|
||||
// Layer reorder drag: a line where the layers will land; the release moves them there.
|
||||
if let Some(moving) = ctx.data(|d| d.get_temp::<Vec<u64>>(layer_drag_id())) {
|
||||
@@ -3395,9 +3399,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
if empty.drag_stopped()
|
||||
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("tl-box")))
|
||||
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("tl-box")))
|
||||
{
|
||||
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::new("tl-box")));
|
||||
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::unique("tl-box")));
|
||||
let mut keys = vec![];
|
||||
let mut yy = rows_rect.min.y - app.ui.timeline.scroll_y;
|
||||
for row in &rows {
|
||||
@@ -3457,7 +3461,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.ui.timeline.scroll_y = widgets::scroll_bar(ui, &mut app.auto, "timeline.scroll", track, app.ui.timeline.scroll_y, max_scroll, &t);
|
||||
}
|
||||
let tgl = Rect::from_min_size(pos2(footer.min.x + 10.0, footer.min.y + 3.0), vec2(150.0, 18.0));
|
||||
let tresp = ui.interact(tgl, egui::Id::new("tl-toggle-modes"), Sense::click());
|
||||
let tresp = ui.interact(tgl, egui::Id::unique("tl-toggle-modes"), Sense::click());
|
||||
p.text(
|
||||
tgl.left_center(),
|
||||
Align2::LEFT_CENTER,
|
||||
@@ -3488,7 +3492,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
(Icon::MountainLarge, 16.0, zs.max.x + 14.0, "timeline.zoomInButton", "Zoom In (Time)", ZOOM_STEP),
|
||||
] {
|
||||
let r = Rect::from_center_size(pos2(cx, footer.center().y), vec2(20.0, 20.0));
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::click()).on_hover_text(crate::i18n::tr(tip));
|
||||
let resp = ui.interact(r, egui::Id::unique(id), Sense::click()).on_hover_text(crate::i18n::tr(tip));
|
||||
icons::paint(&p, Rect::from_center_size(r.center(), vec2(size, size)), icon, if resp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(id, r, tip);
|
||||
if resp.clicked() {
|
||||
@@ -3501,7 +3505,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let knob = pos2(zs.min.x + zs.width() * frac, zs.center().y);
|
||||
p.circle_filled(knob, 6.0, t.text);
|
||||
// Drag the knob, or click the track to jump there.
|
||||
let zresp = ui.interact(zs.expand2(vec2(6.0, 8.0)), egui::Id::new("tl-zoom"), Sense::click_and_drag());
|
||||
let zresp = ui.interact(zs.expand2(vec2(6.0, 8.0)), egui::Id::unique("tl-zoom"), Sense::click_and_drag());
|
||||
app.auto.add("timeline.zoomSlider", zs, "Zoom");
|
||||
if (zresp.dragged() || zresp.clicked())
|
||||
&& let Some(pt) = zresp.interact_pointer_pos()
|
||||
@@ -3521,7 +3525,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// keys and markers there).
|
||||
if super::space_hand(&ctx) {
|
||||
let area = Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(rect.max.x, rows_rect.max.y));
|
||||
let hand = ui.interact(area, egui::Id::new("tl-hand"), Sense::drag());
|
||||
let hand = ui.interact(area, egui::Id::unique("tl-hand"), Sense::drag());
|
||||
app.auto.add("timeline.hand", area, "Hand (hold Spacebar)");
|
||||
if hand.hovered() || hand.dragged() {
|
||||
ctx.set_cursor_icon(if hand.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
||||
@@ -3719,7 +3723,7 @@ fn layer_cells(
|
||||
let lid = layer.id.0;
|
||||
if cw.vis.comment {
|
||||
let cr = Rect::from_min_max(pos2(cw.comment + 4.0, r.min.y + 1.0), pos2(cw.comment + COMMENT_W - 4.0, r.max.y - 1.0));
|
||||
let edit_id = egui::Id::new("tl-comment-edit");
|
||||
let edit_id = egui::Id::unique("tl-comment-edit");
|
||||
let editing: Option<(u64, String)> = ctx.data(|d| d.get_temp(edit_id));
|
||||
app.auto.add(&format!("timeline.layer.{lid}.comment"), cr, &layer.comment);
|
||||
if let Some((el, mut buf)) = editing.filter(|(el, _)| *el == lid) {
|
||||
@@ -3735,7 +3739,8 @@ fn layer_cells(
|
||||
ctx.data_mut(|d| d.insert_temp(edit_id, (el, buf)));
|
||||
}
|
||||
} else {
|
||||
let resp = ui.interact(cr, egui::Id::new(("tl-comment", lid)), Sense::click()).on_hover_text(crate::i18n::tr("Double-click to edit the comment"));
|
||||
let resp =
|
||||
ui.interact(cr, egui::Id::unique(("tl-comment", lid)), Sense::click()).on_hover_text(crate::i18n::tr("Double-click to edit the comment"));
|
||||
lp.with_clip_rect(cr.intersect(lp.clip_rect())).text(pos2(cr.min.x + 2.0, cy), Align2::LEFT_CENTER, &layer.comment, Tokens::ui(11.5), t.text_dim);
|
||||
if resp.double_clicked() {
|
||||
ctx.data_mut(|d| d.insert_temp(edit_id, (lid, layer.comment.clone())));
|
||||
@@ -3758,13 +3763,13 @@ fn layer_cells(
|
||||
} else {
|
||||
crate::panels::timecode(&app.session, comp, Tick::from_seconds_f64(secs))
|
||||
};
|
||||
let resp = ui.interact(cr, egui::Id::new(("tl-cell", kind, lid)), Sense::drag()).on_hover_text(crate::i18n::tr("Drag to change"));
|
||||
let resp = ui.interact(cr, egui::Id::unique(("tl-cell", kind, lid)), Sense::drag()).on_hover_text(crate::i18n::tr("Drag to change"));
|
||||
lp.text(pos2(cr.min.x + 4.0, cy), Align2::LEFT_CENTER, &tc, Tokens::mono(11.0), if resp.hovered() || resp.dragged() { t.hot_text } else { t.timecode });
|
||||
app.auto.add(&format!("timeline.layer.{lid}.{kind}"), cr, &tc);
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
let acc_id = egui::Id::new(("tl-cell-acc", kind, lid));
|
||||
let acc_id = egui::Id::unique(("tl-cell-acc", kind, lid));
|
||||
if resp.dragged() {
|
||||
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
|
||||
acc += resp.drag_delta().x;
|
||||
@@ -3789,7 +3794,7 @@ fn layer_cells(
|
||||
if cw.vis.stretch {
|
||||
let cr = Rect::from_min_max(pos2(cw.stretch + 2.0, r.min.y + 1.0), pos2(cw.stretch + STRETCH_W - 2.0, r.max.y - 1.0));
|
||||
let label = format!("{:.1}%", layer.stretch * 100.0);
|
||||
let resp = ui.interact(cr, egui::Id::new(("tl-stretch", lid)), Sense::click()).on_hover_text(crate::i18n::tr("Time Stretch…"));
|
||||
let resp = ui.interact(cr, egui::Id::unique(("tl-stretch", lid)), Sense::click()).on_hover_text(crate::i18n::tr("Time Stretch…"));
|
||||
lp.text(pos2(cr.min.x + 4.0, cy), Align2::LEFT_CENTER, &label, Tokens::ui(11.5), if resp.hovered() { t.hot_text } else { t.text });
|
||||
app.auto.add(&format!("timeline.layer.{lid}.stretch"), cr, &label);
|
||||
if resp.clicked() {
|
||||
@@ -3846,7 +3851,7 @@ fn dash_buttons(
|
||||
[("+", "shape.dashes.add", "Add a Dash or Gap"), ("\u{2212}", "shape.dashes.remove", "Remove a Dash or Gap")].into_iter().enumerate()
|
||||
{
|
||||
let r = Rect::from_center_size(pos2(x + 12.0 + i as f32 * 20.0, cy), vec2(16.0, 16.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("tl-dash", uid, i)), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique(("tl-dash", uid, i)), Sense::click()).on_hover_text(tip);
|
||||
p.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
p.text(r.center(), Align2::CENTER_CENTER, sym, Tokens::ui(12.0), if resp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("timeline.group.{uid}.{}", if i == 0 { "addDash" } else { "removeDash" }), r, tip);
|
||||
@@ -3907,10 +3912,10 @@ fn shape_add_popup(
|
||||
p.text(pos2(x + 6.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Add:"), Tokens::ui(11.5), t.text_dim);
|
||||
let br = Rect::from_center_size(pos2(x + 40.0, cy), vec2(16.0, 16.0));
|
||||
let resp =
|
||||
ui.interact(br, egui::Id::new(("tl-shapeadd", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Add a shape, fill, stroke or path operation"));
|
||||
ui.interact(br, egui::Id::unique(("tl-shapeadd", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Add a shape, fill, stroke or path operation"));
|
||||
icons::paint(p, br.shrink(3.0), Icon::ChevronRight, if resp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("timeline.group.{uid}.add"), br, "Add:");
|
||||
let pop = egui::Id::new(("tl-shapeadd-pop", uid));
|
||||
let pop = egui::Id::unique(("tl-shapeadd-pop", uid));
|
||||
if resp.clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
@@ -3956,14 +3961,14 @@ fn text_anim_popups(
|
||||
};
|
||||
p.text(pos2(x + 6.0, cy), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
|
||||
let br = Rect::from_center_size(pos2(x + 6.0 + if key == "add" { 34.0 } else { 62.0 }, cy), vec2(16.0, 16.0));
|
||||
let resp = ui.interact(br, egui::Id::new(("tl-textanim", uid)), Sense::click()).on_hover_text(if key == "add" {
|
||||
let resp = ui.interact(br, egui::Id::unique(("tl-textanim", uid)), Sense::click()).on_hover_text(if key == "add" {
|
||||
"Add property or selector"
|
||||
} else {
|
||||
"Animate text"
|
||||
});
|
||||
icons::paint(p, br.shrink(3.0), Icon::ChevronRight, if resp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("timeline.group.{uid}.{key}"), br, label);
|
||||
let pop = egui::Id::new(("tl-textanim-pop", uid));
|
||||
let pop = egui::Id::unique(("tl-textanim-pop", uid));
|
||||
if resp.clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
@@ -4339,7 +4344,7 @@ fn value_editor(
|
||||
_ => (-1e9, 1e9, 1, 1.0),
|
||||
};
|
||||
if matches!(prop.ui, ParamUi::Angle) {
|
||||
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, 1, &t);
|
||||
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &t);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name);
|
||||
if let Some(nv) = nv {
|
||||
@@ -4349,7 +4354,7 @@ fn value_editor(
|
||||
// A Range Selector's Start, End and Offset count characters with Units = Index (#1814).
|
||||
let pct = (matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity") && !effectcraft_engine::render::text::in_index_units(layer, uid);
|
||||
let suffix = if pct { "%" } else { "" };
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &t);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &t);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
if let Some(nv) = nv {
|
||||
set(actions, json!(nv));
|
||||
@@ -4368,7 +4373,7 @@ fn value_editor(
|
||||
let linked = linkable && !app.ui.timeline.unlinked.contains(&uid);
|
||||
if linkable {
|
||||
let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
|
||||
let resp = ui.interact(lr, egui::Id::new(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions"));
|
||||
let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions"));
|
||||
icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
|
||||
app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions");
|
||||
if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
|
||||
@@ -4378,7 +4383,8 @@ fn value_editor(
|
||||
}
|
||||
for d in 0..n.min(c.len()) {
|
||||
let suffix = if pct && d + 1 == n { "%" } else { "" };
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t);
|
||||
let (r, nv, _) =
|
||||
widgets::hot_number_at(ui, pos2(x, y), egui::Id::unique(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
|
||||
dims.push(r);
|
||||
if let Some(nv) = nv {
|
||||
@@ -4407,9 +4413,9 @@ fn value_editor(
|
||||
}
|
||||
Value::Color(c) => {
|
||||
let r = Rect::from_min_size(pos2(x, at.y - 7.0), vec2(26.0, 14.0));
|
||||
let resp = widgets::swatch(ui, r, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::new(("sw", uid)), &t);
|
||||
let resp = widgets::swatch(ui, r, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("sw", uid)), &t);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
let pop = egui::Id::new(("cpop", uid));
|
||||
let pop = egui::Id::unique(("cpop", uid));
|
||||
if resp.clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
@@ -4417,7 +4423,7 @@ fn value_editor(
|
||||
// Stroke colours, text and effect colours), as in After Effects' Timeline (#633).
|
||||
let er = Rect::from_center_size(pos2(r.max.x + 12.0, at.y), vec2(16.0, 16.0));
|
||||
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
|
||||
if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("tl-eye", uid)), &t)
|
||||
if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("tl-eye", uid)), &t)
|
||||
.on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
|
||||
.clicked()
|
||||
{
|
||||
@@ -4431,7 +4437,7 @@ fn value_editor(
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0));
|
||||
if widgets::checkbox(ui, r, *b, &t, egui::Id::new(("cb", uid))).clicked() {
|
||||
if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).clicked() {
|
||||
set(actions, json!(!b));
|
||||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
@@ -4447,8 +4453,8 @@ fn value_editor(
|
||||
if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) {
|
||||
let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
|
||||
let label = options.get(*i as usize).cloned().unwrap_or_default();
|
||||
let pop = egui::Id::new(("epop", uid));
|
||||
if widgets::dropdown(ui, r, &label, &t, egui::Id::new(("en", uid))).clicked() {
|
||||
let pop = egui::Id::unique(("epop", uid));
|
||||
if widgets::dropdown(ui, r, &label, &t, egui::Id::unique(("en", uid))).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
@@ -4469,7 +4475,7 @@ fn value_editor(
|
||||
// undoable prop.set as the other value arms.
|
||||
let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
|
||||
if let Some(comp) = app.session.active_comp_arc()
|
||||
&& let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::new(("tl-lay", uid)))
|
||||
&& let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::unique(("tl-lay", uid)))
|
||||
{
|
||||
set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
|
||||
}
|
||||
@@ -4478,7 +4484,7 @@ fn value_editor(
|
||||
Value::Gradient(_) => {
|
||||
// Opens the Gradient Editor at the current time (#612).
|
||||
let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::new(("gradient-edit", uid)), Sense::click());
|
||||
let resp = ui.interact(r.expand(2.0), egui::Id::unique(("gradient-edit", uid)), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ fn current(app: &EffectcraftApp) -> Option<(LayerId, Uid)> {
|
||||
}
|
||||
|
||||
fn source_id() -> egui::Id {
|
||||
egui::Id::new("tracker-motion-source")
|
||||
egui::Id::unique("tracker-motion-source")
|
||||
}
|
||||
|
||||
/// Motion Source: the current track's layer, the chosen source, or the selected layer.
|
||||
@@ -88,7 +88,7 @@ fn button(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, text: &str, enab
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, text, Tokens::medium(12.0), t.text_faint);
|
||||
return false;
|
||||
}
|
||||
widgets::text_button(ui, r, text, false, &t, egui::Id::new(id)).clicked()
|
||||
widgets::text_button(ui, r, text, false, &t, egui::Id::unique(id)).clicked()
|
||||
}
|
||||
|
||||
fn run(app: &mut EffectcraftApp, ctx: &egui::Context, cmd: &str, p: serde_json::Value) {
|
||||
@@ -170,7 +170,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
comp.layers.iter().enumerate().filter(|(_, l)| trackable(l)).map(|(i, l)| (l.id, format!("{}. {}", i + 1, l.name))).collect();
|
||||
let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0));
|
||||
let name = source.and_then(|s| srcs.iter().find(|(l, _)| *l == s)).map(|(_, n)| n.clone()).unwrap_or_else(|| "None".into());
|
||||
let did = egui::Id::new("tracker-source-dd");
|
||||
let did = egui::Id::unique("tracker-source-dd");
|
||||
if widgets::dropdown(ui, dd, &name, &t, did).clicked() {
|
||||
widgets::open_popup(ui, did);
|
||||
}
|
||||
@@ -195,7 +195,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
source.and_then(|s| comp.layer(s)).map(|l| l.trackers().map(|(g, _)| (g.uid, g.name.clone())).collect()).unwrap_or_default();
|
||||
let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0));
|
||||
let cname = cur.and_then(|(_, u)| trackers.iter().find(|(g, _)| *g == u)).map(|(_, n)| n.clone()).unwrap_or_else(|| "None".into());
|
||||
let did = egui::Id::new("tracker-current-dd");
|
||||
let did = egui::Id::unique("tracker-current-dd");
|
||||
if widgets::dropdown(ui, dd, &cname, &t, did).clicked() && !trackers.is_empty() {
|
||||
widgets::open_popup(ui, did);
|
||||
}
|
||||
@@ -226,7 +226,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
label(&p, pos2(x0, y + 10.0), "Track Type:", &t);
|
||||
let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0));
|
||||
let kind = settings.as_ref().map(|s| s.kind).unwrap_or_default();
|
||||
let did = egui::Id::new("tracker-type-dd");
|
||||
let did = egui::Id::unique("tracker-type-dd");
|
||||
if widgets::dropdown(ui, dd, if enabled { kind.label() } else { "" }, &t, did).clicked() && enabled {
|
||||
widgets::open_popup(ui, did);
|
||||
}
|
||||
@@ -246,7 +246,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
("scale", "Scale", settings.as_ref().is_some_and(|s| s.scale)),
|
||||
] {
|
||||
let cr = Rect::from_min_size(pos2(xx, y + 2.0), vec2(16.0, 16.0));
|
||||
let resp = widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::new(("tracker-cb", key)));
|
||||
let resp = widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::unique(("tracker-cb", key)));
|
||||
app.auto.add(&format!("tracker.{key}"), cr, text);
|
||||
p.text(pos2(cr.max.x + 4.0, cr.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), if transformish { t.text } else { t.text_faint });
|
||||
if resp.clicked() && transformish {
|
||||
@@ -294,7 +294,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
(effectcraft_engine::tracking::Direction::FrameForward, true, true, "frameForward", "Analyze 1 frame forward"),
|
||||
] {
|
||||
let r = Rect::from_min_size(pos2(bx, y), vec2(30.0, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("tracker-analyze", id)), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique(("tracker-analyze", id)), Sense::click()).on_hover_text(tip);
|
||||
let stop = tracking && running_dir == Some(dir) && !frame;
|
||||
let on = enabled && (!tracking || stop);
|
||||
p.rect_filled(r, 4.0, if resp.hovered() && on { t.hover } else { t.field_bg });
|
||||
@@ -366,7 +366,7 @@ fn mask_mode(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, p
|
||||
label(p, pos2(x0, y + 10.0), "Method:", &t);
|
||||
let method = app.session.state.mask_track_method;
|
||||
let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0));
|
||||
let did = egui::Id::new("tracker-mask-method-dd");
|
||||
let did = egui::Id::unique("tracker-mask-method-dd");
|
||||
if widgets::dropdown(ui, dd, method.label(), &t, did).clicked() {
|
||||
widgets::open_popup(ui, did);
|
||||
}
|
||||
@@ -387,7 +387,7 @@ fn mask_mode(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, p
|
||||
(effectcraft_engine::tracking::Direction::FrameForward, true, true, "frameForward", "Track selected masks 1 frame forward"),
|
||||
] {
|
||||
let r = Rect::from_min_size(pos2(bx, y), vec2(30.0, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("tracker-mask-track", id)), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::unique(("tracker-mask-track", id)), Sense::click()).on_hover_text(tip);
|
||||
let stop = running && running_dir == Some(dir) && !frame;
|
||||
let on = !running || stop;
|
||||
p.rect_filled(r, 4.0, if resp.hovered() && on { t.hover } else { t.field_bg });
|
||||
|
||||
@@ -38,7 +38,7 @@ impl ViewerMap {
|
||||
}
|
||||
|
||||
fn map_id() -> egui::Id {
|
||||
egui::Id::new("viewer-map")
|
||||
egui::Id::unique("viewer-map")
|
||||
}
|
||||
|
||||
pub fn comp_to_screen(ctx: &egui::Context, p: [f32; 2]) -> Option<Pos2> {
|
||||
@@ -53,7 +53,7 @@ pub fn screen_to_comp(ctx: &egui::Context, p: Pos2) -> Option<[f64; 2]> {
|
||||
|
||||
/// The fit magnification from the last frame.
|
||||
pub fn last_fit(ctx: &egui::Context) -> f32 {
|
||||
ctx.data(|d| d.get_temp::<f32>(egui::Id::new("viewer-fit"))).unwrap_or(0.5)
|
||||
ctx.data(|d| d.get_temp::<f32>(egui::Id::unique("viewer-fit"))).unwrap_or(0.5)
|
||||
}
|
||||
|
||||
/// What a drag in the viewer is doing.
|
||||
@@ -207,7 +207,7 @@ enum Gesture {
|
||||
|
||||
/// Pen tool path in progress: (layer, mask uid).
|
||||
fn pen_id() -> egui::Id {
|
||||
egui::Id::new("viewer-pen")
|
||||
egui::Id::unique("viewer-pen")
|
||||
}
|
||||
|
||||
use super::viewer_overlays::{self as ov, VertexHit};
|
||||
@@ -610,7 +610,7 @@ fn aux_views(
|
||||
(app.session.revision, cid.0, t.0, i, scale.to_bits(), format!("{cam:?}")).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let id = egui::Id::new(("viewer-aux", i));
|
||||
let id = egui::Id::unique(("viewer-aux", i));
|
||||
let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id));
|
||||
let tex = match cached {
|
||||
Some((k, tex)) if k == key => tex,
|
||||
@@ -662,7 +662,7 @@ fn aux_views(
|
||||
fn pane_selector(app: &mut EffectcraftApp, ui: &mut egui::Ui, pane: usize, area: Rect, current: View3D) -> Option<View3D> {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(area.min + vec2(6.0, 6.0), vec2(124.0, 20.0));
|
||||
let pid = egui::Id::new(("viewer-pane-view", pane));
|
||||
let pid = egui::Id::unique(("viewer-pane-view", pane));
|
||||
let response = widgets::dropdown(ui, r, crate::i18n::tr(current.label()), &t, pid);
|
||||
if response.clicked() {
|
||||
let open = widgets::popup_is_open(ui, pid);
|
||||
@@ -717,7 +717,7 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo
|
||||
(app.session.revision, lv.comp.0, t.0, scale.to_bits(), dc_key).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let id = egui::Id::new("viewer-locked");
|
||||
let id = egui::Id::unique("viewer-locked");
|
||||
let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id));
|
||||
let tex = match cached {
|
||||
Some((k, tex)) if k == key => tex,
|
||||
@@ -744,7 +744,7 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo
|
||||
app.auto.add("viewer.locked", r, &label);
|
||||
// Close the locked viewer.
|
||||
let close = Rect::from_min_size(pos2(r.max.x - 26.0, r.min.y + 4.0), vec2(20.0, 20.0));
|
||||
let resp = ui.interact(close, egui::Id::new("viewer-locked-close"), Sense::click());
|
||||
let resp = ui.interact(close, egui::Id::unique("viewer-locked-close"), Sense::click());
|
||||
p.text(close.center(), Align2::CENTER_CENTER, "\u{00d7}", Tokens::ui(14.0), if resp.hovered() { Color32::WHITE } else { Color32::from_white_alpha(170) });
|
||||
app.auto.add("viewer.locked.close", close, "Close Locked Viewer");
|
||||
if resp.clicked() {
|
||||
@@ -793,7 +793,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let name = app.session.project.item(oc).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let g = p.layout_no_wrap(name.clone(), Tokens::ui(11.5), t.text);
|
||||
let r = Rect::from_min_size(pos2(x, nav.min.y + 3.0), vec2(g.size().x + 16.0, 18.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("nav", oc.0)), Sense::click());
|
||||
let resp = ui.interact(r, egui::Id::unique(("nav", oc.0)), Sense::click());
|
||||
let active = oc == cid;
|
||||
p.rect_filled(
|
||||
r,
|
||||
@@ -835,7 +835,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01);
|
||||
// Fit up to 100% follows the panel's size like Fit does, and stops at 100%.
|
||||
let fit = if app.ui.viewer.fit_up_to_100 { fit.min(1.0 / ctx.pixels_per_point()) } else { fit };
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-fit"), fit));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("viewer-fit"), fit));
|
||||
let zoom = app.ui.viewer.zoom.unwrap_or(fit);
|
||||
let center = area.center() + vec2(app.ui.viewer.pan[0], app.ui.viewer.pan[1]);
|
||||
// On the physical pixel grid, so at 100 % every comp pixel covers exactly one screen pixel
|
||||
@@ -856,7 +856,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Queue the frame on screen before any prefetch.
|
||||
app.request_frame_urgent(cid, comp.frame_rate.frame_at(app.session.time()), scale);
|
||||
}
|
||||
crate::tick_playback(app, &ctx, scale);
|
||||
crate::note_viewer_scale(app, scale);
|
||||
let time = app.session.time();
|
||||
let frame = comp.frame_rate.frame_at(time);
|
||||
let key = app.frame_key(cid, frame, scale);
|
||||
@@ -1274,8 +1274,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
super::viewer_drop::show(app, ui, &painter, &map, &ectx);
|
||||
|
||||
// Interaction.
|
||||
let resp = ui.interact(area, egui::Id::new("viewer-interact"), Sense::click_and_drag());
|
||||
let menu_hits_id = egui::Id::new(("viewer-menu-hits", cid.0));
|
||||
let resp = ui.interact(area, egui::Id::unique("viewer-interact"), Sense::click_and_drag());
|
||||
let menu_hits_id = egui::Id::unique(("viewer-menu-hits", cid.0));
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
@@ -1343,7 +1343,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
|
||||
vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
|
||||
};
|
||||
let gid = egui::Id::new("viewer-gesture");
|
||||
let gid = egui::Id::unique("viewer-gesture");
|
||||
// An overlay on top of the viewer (e.g. the camera tracker points) takes the hover, but the pointer is still over the viewer: keep wheel zoom working.
|
||||
let hover_pos = resp.hover_pos().or_else(|| if resp.contains_pointer() { ui.input(|i| i.pointer.hover_pos()) } else { None });
|
||||
if let Some(hp) = hover_pos {
|
||||
@@ -1371,7 +1371,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
Tool::ObjectMatte => {
|
||||
// The tool draws its own crosshair (and the layer it would matte) while no box
|
||||
// is being dragged.
|
||||
if ui.data(|d| d.get_temp::<Gesture>(egui::Id::new("viewer-gesture"))).is_none() {
|
||||
if ui.data(|d| d.get_temp::<Gesture>(egui::Id::unique("viewer-gesture"))).is_none() {
|
||||
super::object_matte_tool::hover(app, &painter, &map, &ectx, hp);
|
||||
}
|
||||
egui::CursorIcon::None
|
||||
@@ -1431,10 +1431,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// A pen click (press + release without a drag) ends its tangent gesture on release.
|
||||
if ui.input(|i| i.pointer.primary_released())
|
||||
&& matches!(ui.data(|d| d.get_temp::<Gesture>(egui::Id::new("viewer-gesture"))), Some(Gesture::Pen { .. }))
|
||||
&& matches!(ui.data(|d| d.get_temp::<Gesture>(egui::Id::unique("viewer-gesture"))), Some(Gesture::Pen { .. }))
|
||||
&& !resp.drag_stopped()
|
||||
{
|
||||
ui.data_mut(|d| d.remove::<Gesture>(egui::Id::new("viewer-gesture")));
|
||||
ui.data_mut(|d| d.remove::<Gesture>(egui::Id::unique("viewer-gesture")));
|
||||
}
|
||||
// On-canvas text editing (Type tool, double-click a text layer) owns its clicks and drags.
|
||||
let text_owns = super::viewer_text::hook(app, ui, &painter, &map, &ectx, &resp, space_pan);
|
||||
@@ -1654,7 +1654,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let gesture: Option<Gesture> = ui.data(|d| d.get_temp(gid));
|
||||
if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) {
|
||||
let cpt = map.to_comp(pos);
|
||||
let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::<u64>(egui::Id::new("viewer-drag-n")).unwrap_or(0)));
|
||||
let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::<u64>(egui::Id::unique("viewer-drag-n")).unwrap_or(0)));
|
||||
match g {
|
||||
Gesture::Pan { start_pan } => {
|
||||
// No total drag on the release frame: keep the pan reached so far (it used to
|
||||
@@ -1922,8 +1922,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
ui.data_mut(|d| {
|
||||
d.remove::<Gesture>(gid);
|
||||
let n: u64 = d.get_temp(egui::Id::new("viewer-drag-n")).unwrap_or(0);
|
||||
d.insert_temp(egui::Id::new("viewer-drag-n"), n + 1);
|
||||
let n: u64 = d.get_temp(egui::Id::unique("viewer-drag-n")).unwrap_or(0);
|
||||
d.insert_temp(egui::Id::unique("viewer-drag-n"), n + 1);
|
||||
});
|
||||
}
|
||||
if resp.clicked()
|
||||
@@ -2082,7 +2082,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let r = Rect::from_min_size(area.min + vec2(10.0, 10.0), vec2(330.0, 22.0));
|
||||
painter.rect_filled(r, 11.0, Color32::from_black_alpha(190));
|
||||
painter.text(r.center(), Align2::CENTER_CENTER, crate::i18n::tr("Video preview is off (Preview panel): audio only"), Tokens::ui(11.5), t.timecode);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("viewer-video-off-chip"), r));
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("viewer-video-off-chip"), r));
|
||||
}
|
||||
|
||||
vt::draw_snap(&ctx, &painter, &map, &ectx);
|
||||
@@ -2261,7 +2261,7 @@ fn pen_cursor(ctx: &egui::Context, at: Pos2, icon: crate::icons::Icon) {
|
||||
Icon::PenConvert => vec2(8.0, 3.5),
|
||||
_ => vec2(8.0, 1.8),
|
||||
};
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("viewer-pen-cursor")));
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("viewer-pen-cursor")));
|
||||
let rect = Rect::from_min_size(at - tip, vec2(16.0, 16.0));
|
||||
for d in [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(0.0, 1.0)] {
|
||||
crate::icons::paint(&painter, rect.translate(d), icon, Color32::BLACK);
|
||||
@@ -2281,7 +2281,7 @@ fn pen_press(
|
||||
mods: egui::Modifiers,
|
||||
) {
|
||||
use effectcraft_engine::project::LayerSource;
|
||||
let gid = egui::Id::new("viewer-gesture");
|
||||
let gid = egui::Id::unique("viewer-gesture");
|
||||
let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id()));
|
||||
let c0 = map.to_comp(pos);
|
||||
let corr = vt::snap(app, ui.ctx(), ectx, map, &[], &[c0], mods);
|
||||
@@ -2607,12 +2607,12 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let c = rect.center();
|
||||
p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text);
|
||||
let b1 = Rect::from_center_size(c + vec2(0.0, 0.0), vec2(220.0, 30.0));
|
||||
if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::new("empty-newcomp")).clicked() {
|
||||
if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::unique("empty-newcomp")).clicked() {
|
||||
crate::panels::dialogs::open_new_comp(app);
|
||||
}
|
||||
app.auto.add("viewer.empty.newComp", b1, "New Composition");
|
||||
let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0));
|
||||
if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::new("empty-fromfootage")).clicked() {
|
||||
if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() {
|
||||
let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({}));
|
||||
}
|
||||
app.auto.add("viewer.empty.import", b2, "New Composition From Footage");
|
||||
|
||||
@@ -394,7 +394,7 @@ pub(crate) fn motion_path(
|
||||
// ---------------------------------------------------------------- free transform
|
||||
|
||||
fn ft_id() -> egui::Id {
|
||||
egui::Id::new("viewer-free-transform")
|
||||
egui::Id::unique("viewer-free-transform")
|
||||
}
|
||||
|
||||
/// Enter Free Transform on a path (double-click a path with the Selection tool).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ pub(crate) const SNAP_PX: f64 = 8.0;
|
||||
const SNAP_COLOR: Color32 = Color32::from_rgb(0xff, 0xd2, 0x3c);
|
||||
|
||||
fn snap_id() -> egui::Id {
|
||||
egui::Id::new("viewer-snap")
|
||||
egui::Id::unique("viewer-snap")
|
||||
}
|
||||
|
||||
/// Layer snapping is on: the Snapping checkbox (View ▸ Snapping), inverted while Cmd/Ctrl is held.
|
||||
@@ -226,10 +226,10 @@ pub(crate) fn rulers(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMa
|
||||
p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], tick);
|
||||
p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], tick);
|
||||
let cr = ui
|
||||
.interact(corner, egui::Id::new("vw-ruler-origin"), Sense::click_and_drag())
|
||||
.interact(corner, egui::Id::unique("vw-ruler-origin"), Sense::click_and_drag())
|
||||
.on_hover_text(crate::i18n::tr("Ruler zero point (drag to move, double-click to reset)"));
|
||||
app.auto.add("viewer.ruler.origin", corner, "Ruler zero point");
|
||||
let fg = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("vw-ruler-fg")));
|
||||
let fg = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("vw-ruler-fg")));
|
||||
if cr.dragged()
|
||||
&& let Some(hp) = cr.interact_pointer_pos()
|
||||
{
|
||||
@@ -248,7 +248,7 @@ pub(crate) fn rulers(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMa
|
||||
// Guides dragged out of the rulers: the top ruler makes horizontal guides, the left one
|
||||
// vertical guides.
|
||||
for (r, vertical, id) in [(top, false, "top"), (left, true, "left")] {
|
||||
let resp = ui.interact(r, egui::Id::new(("vw-ruler", id)), Sense::drag()).on_hover_text(crate::i18n::tr("Drag to create a guide"));
|
||||
let resp = ui.interact(r, egui::Id::unique(("vw-ruler", id)), Sense::drag()).on_hover_text(crate::i18n::tr("Drag to create a guide"));
|
||||
app.auto.add(&format!("viewer.ruler.{id}"), r, if vertical { "Vertical ruler" } else { "Horizontal ruler" });
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
|
||||
@@ -320,11 +320,11 @@ pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, c
|
||||
|
||||
/// Remember the region of interest the displayed texture was rendered with.
|
||||
pub(crate) fn set_texture_roi(ctx: &egui::Context, roi: Option<[f64; 4]>) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-tex-roi"), roi));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("viewer-tex-roi"), roi));
|
||||
}
|
||||
|
||||
fn texture_roi(ctx: &egui::Context) -> Option<[f64; 4]> {
|
||||
ctx.data(|d| d.get_temp::<Option<[f64; 4]>>(egui::Id::new("viewer-tex-roi"))).flatten()
|
||||
ctx.data(|d| d.get_temp::<Option<[f64; 4]>>(egui::Id::unique("viewer-tex-roi"))).flatten()
|
||||
}
|
||||
|
||||
/// The comp area ([x, y, w, h], comp pixels) the shown frame covers: its region of interest or
|
||||
@@ -398,7 +398,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
||||
(std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let id = egui::Id::new("viewer-snapshot-tex");
|
||||
let id = egui::Id::unique("viewer-snapshot-tex");
|
||||
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
|
||||
Some((k, tex)) if k == key => tex,
|
||||
_ => {
|
||||
@@ -454,7 +454,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
||||
(std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key, minify).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let id = egui::Id::new("viewer-display-tex");
|
||||
let id = egui::Id::unique("viewer-display-tex");
|
||||
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
|
||||
Some((k, tex)) if k == key => tex,
|
||||
_ => {
|
||||
@@ -479,7 +479,7 @@ fn fitted_uv(ctx: &egui::Context, size: [usize; 2], minify: usize) -> Rect {
|
||||
/// A popup list above `anchor` (selected entries highlighted); entries register
|
||||
/// `viewer.<auto>.<n>` automation ids.
|
||||
fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option<usize> {
|
||||
let pid = egui::Id::new(id);
|
||||
let pid = egui::Id::unique(id);
|
||||
if !widgets::popup_is_open(ui, pid) {
|
||||
return None;
|
||||
}
|
||||
@@ -506,7 +506,7 @@ fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, it
|
||||
}
|
||||
|
||||
fn toggle_popup(ui: &egui::Ui, id: &str) {
|
||||
let pid = egui::Id::new(id).with("open");
|
||||
let pid = egui::Id::unique(id).with("open");
|
||||
let open: bool = ui.data(|d| d.get_temp(pid).unwrap_or(false));
|
||||
ui.data_mut(|d| d.insert_temp(pid, !open));
|
||||
}
|
||||
@@ -529,8 +529,8 @@ pub(crate) fn magnification_menu(
|
||||
let t = app.tokens;
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let pop = format!("{id}-pop");
|
||||
let response =
|
||||
widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new(id)).on_hover_text(crate::i18n::tr("Magnification ratio popup"));
|
||||
let response = widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::unique(id))
|
||||
.on_hover_text(crate::i18n::tr("Magnification ratio popup"));
|
||||
if response.clicked() {
|
||||
toggle_popup(ui, &pop);
|
||||
}
|
||||
@@ -637,7 +637,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0));
|
||||
let scale = app.viewer_scale(zoom, ppp);
|
||||
let res = app.viewer_res();
|
||||
let response = widgets::dropdown(ui, r, &resolution_label(res, scale), &t, egui::Id::new("vw-res"))
|
||||
let response = widgets::dropdown(ui, r, &resolution_label(res, scale), &t, egui::Id::unique("vw-res"))
|
||||
.on_hover_text(crate::i18n::tr("Resolution/Down Sample Factor Popup"));
|
||||
if response.clicked() {
|
||||
toggle_popup(ui, "vw-res-pop");
|
||||
@@ -665,7 +665,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
|
||||
let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0));
|
||||
// The tip is the English source: the tooltip is looked up in the catalog, while the
|
||||
// automation label stays English for scripts and tests.
|
||||
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(crate::i18n::tr(tip));
|
||||
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::unique(("vw-btn", id))).on_hover_text(crate::i18n::tr(tip));
|
||||
auto.add_response(&format!("viewer.{id}"), &resp, tip);
|
||||
*x += 24.0;
|
||||
(resp.clicked(), r)
|
||||
@@ -786,7 +786,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
|
||||
let renderers = [(Renderer::Classic3D, "Classic 3D", "classic3D"), (Renderer::Advanced3D, "Advanced 3D", "advanced3D")];
|
||||
let current = renderers.iter().find(|(r, ..)| *r == comp.renderer).map_or("Classic 3D", |(_, label, _)| *label);
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0));
|
||||
if widgets::dropdown(ui, r, crate::i18n::tr(current), &t, egui::Id::new("vw-3d")).clicked() {
|
||||
if widgets::dropdown(ui, r, crate::i18n::tr(current), &t, egui::Id::unique("vw-3d")).clicked() {
|
||||
toggle_popup(ui, "vw-3d-pop");
|
||||
}
|
||||
app.auto.add("viewer.renderer3d", r, current);
|
||||
@@ -801,7 +801,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
|
||||
let cid = app.session.active_comp_id();
|
||||
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
|
||||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
|
||||
let response = widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam"));
|
||||
let response = widgets::dropdown(ui, r, cur.label(), &t, egui::Id::unique("vw-cam"));
|
||||
if response.clicked() {
|
||||
toggle_popup(ui, "vw-cam-pop");
|
||||
}
|
||||
@@ -824,8 +824,8 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
|
||||
let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode);
|
||||
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
|
||||
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
|
||||
let resp = ui.interact(tr, egui::Id::new("vw-timecode"), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Current time"));
|
||||
let resp = ui.interact(tr, egui::Id::unique("vw-timecode"), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Current time"));
|
||||
if resp.has_focus() {
|
||||
p.rect_stroke(tr.shrink(1.0), 2.0, Stroke::new(1.0, t.focus), StrokeKind::Inside);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
p.rect_filled(rect, 0.0, t.pasteboard);
|
||||
let resp = ui.interact(rect, egui::Id::new(("passive-viewer", id)), Sense::click()).on_hover_text(tr("Click to make this viewer active"));
|
||||
let resp = ui.interact(rect, egui::Id::unique(("passive-viewer", id)), Sense::click()).on_hover_text(tr("Click to make this viewer active"));
|
||||
app.auto.add(&format!("viewers.{id}"), rect, "Composition viewer (click to make it active)");
|
||||
match comp_of(app, id).and_then(|cid| Some((cid, app.session.project.comp(cid)?.clone()))) {
|
||||
Some((cid, c)) => {
|
||||
@@ -137,7 +137,7 @@ fn set_texture(
|
||||
dc: Option<effectcraft_engine::viewer::DisplayColor>,
|
||||
dkey: u64,
|
||||
) {
|
||||
let dc_id = egui::Id::new(("passive-viewer-display", id));
|
||||
let dc_id = egui::Id::unique(("passive-viewer-display", id));
|
||||
let same_display = ctx.data(|d| d.get_temp::<u64>(dc_id)) == Some(dkey);
|
||||
if let Some((k, _)) = app.passive_tex.get_mut(&id)
|
||||
&& *k == key
|
||||
|
||||
@@ -13,7 +13,7 @@ struct State {
|
||||
}
|
||||
|
||||
fn state_id() -> Id {
|
||||
Id::new("numeric-pointer-wrap")
|
||||
Id::unique("numeric-pointer-wrap")
|
||||
}
|
||||
|
||||
/// Opt in only number scrubs, never object, keyframe, panel or scrollbar drags.
|
||||
@@ -231,12 +231,12 @@ mod tests {
|
||||
crate::widgets::drag_value(egui::DragValue::new(&mut self.value).speed(1.0).max_decimals(3)),
|
||||
);
|
||||
} else if self.ordinary {
|
||||
ui.interact(Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), Id::new("ordinary"), egui::Sense::drag());
|
||||
ui.interact(Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), Id::unique("ordinary"), egui::Sense::drag());
|
||||
} else if self.angle {
|
||||
let (rr, dr, value) = crate::panels::fx_widgets::angle_field(
|
||||
ui,
|
||||
pos2(100.0, 100.0),
|
||||
Id::new("scrub"),
|
||||
Id::unique("scrub"),
|
||||
self.value,
|
||||
1,
|
||||
&crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark),
|
||||
@@ -249,7 +249,7 @@ mod tests {
|
||||
let (_, value, _) = crate::widgets::hot_number_at(
|
||||
ui,
|
||||
pos2(100.0, 100.0),
|
||||
Id::new("scrub"),
|
||||
Id::unique("scrub"),
|
||||
self.value,
|
||||
1.0,
|
||||
(-1e9, 1e9),
|
||||
|
||||
@@ -11,10 +11,14 @@ use serde_json::json;
|
||||
use crate::EffectcraftApp;
|
||||
use crate::frames::FrameKey;
|
||||
|
||||
/// Run once per frame, before the panels draw.
|
||||
pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
memory_watch(app, ctx);
|
||||
/// Settings read once per tick, before any paint (they must keep working while hidden).
|
||||
pub fn tick(app: &mut EffectcraftApp, ctx: &egui::Context, now: f64) {
|
||||
memory_watch(app, ctx, now);
|
||||
home_on_open(app, ctx);
|
||||
}
|
||||
|
||||
/// Settings that show or draw something: run in the UI pass, before the panels draw.
|
||||
pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
video_preview(app, ctx);
|
||||
}
|
||||
|
||||
@@ -29,7 +33,7 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
|
||||
if hovered != 0 {
|
||||
let label =
|
||||
if hovered == 1 { crate::i18n::tr("Drop file to import").to_string() } else { crate::i18n::tr_args("Drop {} files to import", &[&hovered]) };
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("native-file-drop")));
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("native-file-drop")));
|
||||
painter.rect_stroke(bounds.shrink(8.0), 6.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside);
|
||||
let hint = Rect::from_center_size(bounds.center(), vec2(300.0, 46.0));
|
||||
painter.rect_filled(hint, 6.0, app.tokens.panel_bg);
|
||||
@@ -120,9 +124,8 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
|
||||
|
||||
/// Re-read the system's free memory every 30 s (cache budgets follow it; on Windows each
|
||||
/// reading starts PowerShell in the background, so it isn't done more often).
|
||||
fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let id = egui::Id::new("prefs-memory-watch");
|
||||
pub(crate) fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context, now: f64) {
|
||||
let id = egui::Id::unique("prefs-memory-watch");
|
||||
let last = ctx.data(|d| d.get_temp::<f64>(id));
|
||||
if last.is_some_and(|t| now - t < 30.0) {
|
||||
return;
|
||||
@@ -135,8 +138,8 @@ fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
/// Startup ▸ Show Home Screen When Opening a Project.
|
||||
fn home_on_open(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let id = egui::Id::new("prefs-last-project");
|
||||
pub(crate) fn home_on_open(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let id = egui::Id::unique("prefs-last-project");
|
||||
let path = app.session.path.clone();
|
||||
let last = ctx.data(|d| d.get_temp::<Option<String>>(id));
|
||||
ctx.data_mut(|d| d.insert_temp(id, path.clone()));
|
||||
@@ -169,7 +172,7 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
let v = app.session.prefs.video.clone();
|
||||
let tex_id = egui::Id::new("video-preview-tex");
|
||||
let tex_id = egui::Id::unique("video-preview-tex");
|
||||
let update = !app.playback.playing || v.output_during_playback;
|
||||
let key = app.viewer_shown.as_ref().map(|(_, k)| *k);
|
||||
let cur: Option<(FrameKey, egui::TextureHandle)> = ctx.data(|d| d.get_temp(tex_id));
|
||||
|
||||
@@ -322,6 +322,10 @@ static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regula
|
||||
static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf");
|
||||
static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
|
||||
static JETBRAINS_MONO: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf");
|
||||
/// Chrome symbols (`✔` U+2714, `▣` U+25A3, …) that Inter, Inter Medium/SemiBold and
|
||||
/// JetBrains Mono do not cover. 0.37's default font set no longer includes the old emoji
|
||||
/// fonts, and named families (`medium`, `semibold`) never received egui's default fallbacks.
|
||||
static NOTO_SYMBOLS2: &[u8] = include_bytes!("../../../assets/fonts/NotoSansSymbols2-Regular.ttf");
|
||||
|
||||
/// Install fonts (Inter, Inter Medium/SemiBold, JetBrains Mono) and egui visuals. `language` is the
|
||||
/// resolved interface language from [`crate::i18n::language`] (`en`, `ja`, `zh-hans` or `zh-hant`),
|
||||
@@ -336,16 +340,20 @@ pub fn install(ctx: &egui::Context, t: &Tokens, language: &str) {
|
||||
fonts.font_data.insert("inter-medium".into(), Arc::new(FontData::from_static(INTER_MEDIUM)));
|
||||
fonts.font_data.insert("inter-semibold".into(), Arc::new(FontData::from_static(INTER_SEMIBOLD)));
|
||||
fonts.font_data.insert("jbmono".into(), Arc::new(FontData::from_static(JETBRAINS_MONO)));
|
||||
fonts.font_data.insert("noto-symbols2".into(), Arc::new(FontData::from_static(NOTO_SYMBOLS2)));
|
||||
fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "inter".into());
|
||||
fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into());
|
||||
fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]);
|
||||
fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]);
|
||||
// Inter and egui's own fallbacks have no small triangles (the `▸` of "File ▸ Import"), which
|
||||
// would draw as missing-glyph boxes: JetBrains Mono fills in the symbols the others lack.
|
||||
// Noto Sans Symbols 2 follows it for the check/box glyphs (`✔`, `▣`) that JetBrains Mono
|
||||
// lacks; without it, named families such as `medium` fall straight through to tofu.
|
||||
for (family, stack) in &mut fonts.families {
|
||||
if *family != FontFamily::Monospace {
|
||||
stack.push("jbmono".into());
|
||||
}
|
||||
stack.push("noto-symbols2".into());
|
||||
}
|
||||
install_script_fallbacks(&mut fonts, language);
|
||||
ctx.set_fonts(fonts);
|
||||
@@ -786,7 +794,8 @@ mod tests {
|
||||
// another test has loaded a system face without `◻` (the fallback tries loaded faces first).
|
||||
let uv = |s: String| ctx.fonts_mut(|f| f.layout_no_wrap(s, id.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect);
|
||||
let missing = uv("\u{10ffff}".into());
|
||||
for c in "▸•›·▶◀●○⚠…×".chars() {
|
||||
// Every chrome symbol the panels, menus, shortcut labels and buttons draw.
|
||||
for c in "✔▣✓⚠▶◀▸●○…×›•·−←→↑↓⌘⇧⌥⌃".chars() {
|
||||
let drawn = uv(c.to_string());
|
||||
assert_ne!((drawn.min, drawn.max), (missing.min, missing.max), "{c} has no glyph in {id:?}");
|
||||
}
|
||||
@@ -807,7 +816,7 @@ mod tests {
|
||||
}
|
||||
let Some(font) = face.font() else { return };
|
||||
let bytes = font.data().as_bytes();
|
||||
let covered = fonts_def.font_data.values().any(|d| d.font.as_ref() == bytes && d.index == face.info.index);
|
||||
let covered = fonts_def.font_data.values().any(|d| d.bytes() == bytes && d.index == face.info.index);
|
||||
assert!(covered, "a face covering Hangul must be registered with egui");
|
||||
}
|
||||
|
||||
@@ -823,7 +832,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let data = fonts.font_data.get(name).unwrap_or_else(|| panic!("{name} is missing its font data"));
|
||||
(data.font.as_ref(), data.index)
|
||||
(data.bytes(), data.index)
|
||||
})
|
||||
.collect();
|
||||
for (i, a) in faces.iter().enumerate() {
|
||||
|
||||
@@ -23,8 +23,8 @@ struct TabOrder {
|
||||
}
|
||||
|
||||
fn tab_order_id(ui: &Ui) -> egui::Id {
|
||||
let panel = ui.stack().iter().find(|s| s.tags().contains(PANEL_TAG)).map_or(egui::Id::NULL, |s| s.id);
|
||||
egui::Id::new(("hot-tab-order", panel))
|
||||
let panel = ui.stack().iter().find(|s| s.tags().contains(PANEL_TAG)).map_or(egui::Id::NULL, |s| s.scope_id);
|
||||
egui::Id::unique(("hot-tab-order", panel))
|
||||
}
|
||||
|
||||
/// Puts hot-text field `id` in its panel's Tab order. True when a Tab in the field before it
|
||||
@@ -274,8 +274,13 @@ pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id
|
||||
}
|
||||
|
||||
/// Icon button (toolbar style) with optional active state.
|
||||
///
|
||||
/// The icon alone carries no text, so the button's accessible name is its tooltip: callers chain
|
||||
/// `.on_hover_text(…)`, which names the node this reports (an icon button with neither is unnamed
|
||||
/// and kittest's accessibility check flags it).
|
||||
pub fn icon_button(ui: &mut Ui, rect: Rect, icon: Icon, active: bool, t: &Tokens, id: egui::Id) -> Response {
|
||||
let resp = ui.interact(rect, id, Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), active, ""));
|
||||
if active {
|
||||
ui.painter().rect_filled(rect, 4.0, t.accent);
|
||||
} else if resp.hovered() {
|
||||
@@ -338,7 +343,7 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea
|
||||
// As tall as the field, so the text and the hint sit on its middle (they sat high, #581).
|
||||
.min_size(inner.size())
|
||||
.font(Tokens::ui(12.0))
|
||||
.vertical_align(egui::Align::Center),
|
||||
.align(egui::Align2::LEFT_CENTER),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -350,6 +355,15 @@ pub fn dropdown(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) -
|
||||
/// [`dropdown`] with a tooltip `tip` (shown always; it replaces the elided text's tooltip).
|
||||
pub fn dropdown_tip(ui: &mut Ui, rect: Rect, text: &str, tip: Option<&str>, t: &Tokens, id: egui::Id) -> Response {
|
||||
let resp = ui.interact(rect, id, Sense::click());
|
||||
// A combo box shows its current value; with no separate label widget that value is also the
|
||||
// name a screen reader announces. An empty value falls back to the tooltip (or a generic name)
|
||||
// so the control is still reachable.
|
||||
let name = match (text.is_empty(), tip) {
|
||||
(false, _) => text.to_string(),
|
||||
(true, Some(tip)) if !tip.is_empty() => tip.to_string(),
|
||||
_ => crate::i18n::tr("Options").to_string(),
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), &name));
|
||||
ui.painter().rect_filled(rect, 3.0, if resp.hovered() { t.hover } else { t.field_bg });
|
||||
ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
// Text that doesn't fit before the arrow is elided (narrow panels).
|
||||
@@ -382,6 +396,7 @@ pub fn dropdown_tip(ui: &mut Ui, rect: Rect, text: &str, tip: Option<&str>, t: &
|
||||
/// Text button with a subtle fill.
|
||||
pub fn text_button(ui: &mut Ui, rect: Rect, text: &str, primary: bool, t: &Tokens, id: egui::Id) -> Response {
|
||||
let resp = ui.interact(rect, id, Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), text));
|
||||
let bg = if primary {
|
||||
if resp.hovered() { t.accent_hover } else { t.accent }
|
||||
} else if resp.hovered() {
|
||||
@@ -423,7 +438,7 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro
|
||||
let thumb_len = (len * len / (len + max.max(0.0)).max(1.0)).max(16.0).min(len).max(0.0);
|
||||
let travel = (len - thumb_len).max(1.0);
|
||||
let thumb_at = |s: f32| track.min[a] + (len - thumb_len) * if max > 0.0 { (s / max).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::new(key), Sense::drag());
|
||||
let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::unique(key), Sense::drag());
|
||||
let pressed = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.any_pressed());
|
||||
let mut s = scroll;
|
||||
if let Some(p) = resp.interact_pointer_pos().filter(|p| pressed && !(thumb_at(scroll)..=thumb_at(scroll) + thumb_len).contains(&p[a])) {
|
||||
@@ -494,8 +509,8 @@ pub fn check_label(on: bool, label: &str) -> String {
|
||||
if on { format!("✓ {label}") } else { format!(" {label}") }
|
||||
}
|
||||
|
||||
fn label_swatch_id() -> egui::Id {
|
||||
egui::Id::new("label-swatch")
|
||||
fn label_swatch_id() -> egui::IdSalt {
|
||||
egui::IdSalt::new("label-swatch")
|
||||
}
|
||||
|
||||
/// The colour swatch of a Label menu row, as an atom for its button: Label menus show each
|
||||
@@ -506,7 +521,7 @@ pub fn label_swatch() -> egui::Atom<'static> {
|
||||
}
|
||||
|
||||
/// Paint the [`label_swatch`] of a laid-out button: label `l`'s colour, an empty box for None.
|
||||
pub fn paint_label_swatch(ui: &Ui, out: &egui::AtomLayoutResponse, l: effectcraft_engine::color::Label, t: &Tokens) {
|
||||
pub fn paint_label_swatch(ui: &Ui, out: &egui::WidgetAtomResponse, l: effectcraft_engine::color::Label, t: &Tokens) {
|
||||
let Some(r) = out.rect(label_swatch_id()) else { return };
|
||||
let r = Rect::from_center_size(r.center(), vec2(11.0, 11.0));
|
||||
if l == effectcraft_engine::color::Label::None {
|
||||
@@ -642,7 +657,7 @@ pub(crate) fn note_open_popup_list(ctx: &egui::Context) {
|
||||
|
||||
/// The pass a [`popup_list`] was last drawn in.
|
||||
fn popup_list_pass_id() -> egui::Id {
|
||||
egui::Id::new("popup-list-pass")
|
||||
egui::Id::unique("popup-list-pass")
|
||||
}
|
||||
|
||||
/// Whether a menu, context menu or popup list is open, or the menu bar has the keyboard (as of
|
||||
@@ -664,8 +679,16 @@ pub fn any_menu_open(ctx: &egui::Context) -> bool {
|
||||
/// and the scroll bar are how a normally opened menu scrolls.
|
||||
pub fn menu_scroll<R>(ui: &mut Ui, browsing: bool, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let max_h = ui.ctx().content_rect().height() - 40.0;
|
||||
let out = egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add);
|
||||
edge_scroll(ui, &out, browsing);
|
||||
let pending = ui.scope_id().with("menu-edge-scroll-offset");
|
||||
let target = ui.ctx().data_mut(|d| d.remove_temp::<f32>(pending));
|
||||
let mut area = egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h);
|
||||
if let Some(target) = target {
|
||||
area = area.vertical_scroll_offset(target);
|
||||
}
|
||||
let out = area.show(ui, add);
|
||||
if let Some(target) = edge_scroll(ui, &out, browsing) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(pending, target));
|
||||
}
|
||||
out.inner
|
||||
}
|
||||
|
||||
@@ -674,9 +697,11 @@ pub fn menu_scroll<R>(ui: &mut Ui, browsing: bool, add: impl FnOnce(&mut Ui) ->
|
||||
/// a run and stops at the end — and stops at once when the pointer leaves the band or the hold.
|
||||
///
|
||||
/// This runs after `ScrollArea::show`, which laid the menu out for the offset it had, so the new
|
||||
/// offset goes into the area's state and shows on the next frame: a frame of lag, invisible at
|
||||
/// these speeds. egui's own scrolling (wheel, drag, scroll bar) is untouched.
|
||||
fn edge_scroll<R>(ui: &Ui, out: &egui::containers::scroll_area::ScrollAreaOutput<R>, browsing: bool) {
|
||||
/// offset is supplied once to the next draw's builder: a frame of lag, invisible at these speeds.
|
||||
/// That explicit vertical offset cancels prior momentum on that draw; later wheel and scroll-bar
|
||||
/// input still applies normally. A replay can consume the target but cannot queue another step
|
||||
/// at the same input time.
|
||||
fn edge_scroll<R>(ui: &Ui, out: &egui::containers::scroll_area::ScrollAreaOutput<R>, browsing: bool) -> Option<f32> {
|
||||
/// How far from the top / bottom edge the pointer starts the crawl (points).
|
||||
const BAND: f32 = 18.0;
|
||||
/// The pause before the crawl starts: a pointer merely grazing the edge must not jump.
|
||||
@@ -712,23 +737,29 @@ fn edge_scroll<R>(ui: &Ui, out: &egui::containers::scroll_area::ScrollAreaOutput
|
||||
now
|
||||
};
|
||||
if dir == 0 {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
if now - since < f64::from(DELAY) {
|
||||
// A pointer resting still fires no events: keep the frames coming until the crawl runs.
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let speed = (CRAWL + RAMP * ((now - since) as f32 - DELAY)).min(RUN);
|
||||
let crawl = ctx.input(|i| i.unstable_dt).min(0.05) * speed * dir as f32;
|
||||
let new = (out.state.offset.y + crawl).clamp(0.0, over);
|
||||
if new != out.state.offset.y {
|
||||
let mut state = out.state;
|
||||
state.offset.y = new;
|
||||
state.store(ctx, out.id);
|
||||
let old = out.state.unclamped_offset().y;
|
||||
let new = (old + crawl).clamp(0.0, over);
|
||||
if new == old {
|
||||
// At the end of the list the crawl stands still and asks for no more frames.
|
||||
ctx.request_repaint();
|
||||
return None;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let stepped = out.id.with("menu-edge-scroll-step");
|
||||
if ctx.data(|d| d.get_temp::<f64>(stepped)) == Some(now) {
|
||||
return None;
|
||||
}
|
||||
// Stamp the queue operation, not consumption: a later real frame must still take a step.
|
||||
ctx.data_mut(|d| d.insert_temp(stepped, now));
|
||||
Some(new)
|
||||
}
|
||||
|
||||
/// egui's menu style with no gap between the rows: the gap the app's item spacing left between
|
||||
|
||||
@@ -87,3 +87,18 @@ mod ui_workflow_selection;
|
||||
mod ui_workspace_focus;
|
||||
mod viewer_geometry;
|
||||
mod viewer_passive_display;
|
||||
|
||||
/// Register the managed build's canonical craft-fonts checkout (`CRAFT_FONTS_DIR/fonts`) through
|
||||
/// the same directory source production scans for `EFFECTCRAFT_FONT_DIRS`. The UI tests then render
|
||||
/// real Japanese glyphs from the canonical external input instead of relying on host fonts.
|
||||
pub(crate) fn add_canonical_test_fonts() {
|
||||
use effectcraft_text::fonts::{self, DirectorySource};
|
||||
static REGISTER: std::sync::Once = std::sync::Once::new();
|
||||
REGISTER.call_once(|| {
|
||||
let Some(root) = std::env::var_os("CRAFT_FONTS_DIR") else { return };
|
||||
let dir = std::path::PathBuf::from(root).join("fonts");
|
||||
if dir.is_dir() {
|
||||
fonts::add_source(&DirectorySource { dirs: vec![dir] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ fn render_queue_panel_snapshot() {
|
||||
let ids: Vec<u64> = s.project.render_queue.iter().take(3).map(|i| i.id).collect();
|
||||
let mut app = Some(EffectcraftApp::new(s));
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app.take().expect("app"));
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("rq-ui"), (ids, None::<u64>)));
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("rq-ui"), (ids, None::<u64>)));
|
||||
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::RenderQueue);
|
||||
h.run_steps(4);
|
||||
let img = h.render().expect("render");
|
||||
|
||||
@@ -154,7 +154,9 @@ fn pointer_drop_merges_floats_and_redocks_without_project_changes() {
|
||||
let from = rect(&h, "panel.tab.Info").center();
|
||||
drag(&mut h, from, pos2(700.0, 400.0), Modifiers::COMMAND);
|
||||
assert_eq!(h.state().ui.floating.len(), 1);
|
||||
assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Button);
|
||||
// The grip is a resize handle: the migration's role mapping (`ResizeHandle → Splitter`) applies
|
||||
// to floating panels too, and the widget reports it (`dock_ui.rs`).
|
||||
assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Splitter);
|
||||
action_log.push(action_state(&h, "floated"));
|
||||
let original = h.state().ui.floating[0].rect;
|
||||
let grip = rect(&h, "panel.float.0.resize").center();
|
||||
@@ -162,7 +164,7 @@ fn pointer_drop_merges_floats_and_redocks_without_project_changes() {
|
||||
assert!(h.state().ui.floating[0].rect[2] > original[2] + 25.0);
|
||||
assert!(h.state().ui.floating[0].rect[3] > original[3] + 15.0);
|
||||
action_log.push(action_state(&h, "resized"));
|
||||
let grip_id = egui::Id::new(("floating-panel", PanelKind::Info.id())).with("resize");
|
||||
let grip_id = egui::Id::unique(("floating-panel", PanelKind::Info.id())).with("resize");
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id));
|
||||
let before_keys = h.state().ui.floating[0].rect;
|
||||
for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] {
|
||||
@@ -365,7 +367,7 @@ fn accordion_resize_routes_pointer_and_keyboard_without_changing_closed_entries(
|
||||
};
|
||||
h.run_steps(3);
|
||||
let project = h.state().session.project.clone();
|
||||
let id = egui::Id::new(("dock-gutter", "s0".to_string()));
|
||||
let id = egui::Id::unique(("dock-gutter", "s0".to_string()));
|
||||
h.ctx.memory_mut(|memory| memory.request_focus(id));
|
||||
h.run_steps(2);
|
||||
for pressed in [true, false] {
|
||||
|
||||
@@ -29,7 +29,8 @@ fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
|
||||
fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) -> Value {
|
||||
let ctx = h.ctx.clone();
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
match control::handle(h.state_mut(), &ctx, &req) {
|
||||
let now = h.state().now();
|
||||
match control::handle(h.state_mut(), &ctx, &req, now) {
|
||||
Outcome::Done(v) => v,
|
||||
_ => panic!("{method}: no immediate reply"),
|
||||
}
|
||||
@@ -70,7 +71,7 @@ fn interactive(ctx: &egui::Context) -> Vec<(Rect, String)> {
|
||||
}
|
||||
let info = w.info(r.id);
|
||||
// Selectable text (labels sense drags to select their text) is not a control.
|
||||
if info.is_some_and(|i| i.typ == egui::WidgetType::Label) {
|
||||
if info.is_some_and(|i| i.role == egui::Role::Label) {
|
||||
continue;
|
||||
}
|
||||
out.push((ir, info.and_then(|i| i.label.clone()).unwrap_or_default()));
|
||||
|
||||
@@ -9,6 +9,7 @@ use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
|
||||
use effectcraft_ui_egui::frames::{RemoteDone, RemoteFrame, RemoteFrames, RemoteJob};
|
||||
use egui::{Event, Modifiers, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
@@ -30,6 +31,58 @@ impl AudioDevice for Fake {
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame renderer whose frames the test releases explicitly. Playback then runs against a
|
||||
/// controlled clock and controlled cache contents instead of racing however many frame workers
|
||||
/// the host has; a frame that is not released is queued again by the next playback step.
|
||||
#[derive(Clone, Default)]
|
||||
struct GatedFrames {
|
||||
pending: Arc<Mutex<Vec<(i64, u32, u32, RemoteDone)>>>,
|
||||
}
|
||||
|
||||
impl RemoteFrames for GatedFrames {
|
||||
fn slots(&self) -> usize {
|
||||
1024
|
||||
}
|
||||
|
||||
fn start(&self, job: RemoteJob, done: RemoteDone) {
|
||||
let (frame, width, height) = job.project.comp(job.comp).map_or((-1, 1, 1), |c| {
|
||||
let scale = job.opts.scale.max(0.001);
|
||||
let width = (c.width as f64 * scale).round().clamp(1.0, 16_384.0) as u32;
|
||||
let height = (c.height as f64 * scale).round().clamp(1.0, 16_384.0) as u32;
|
||||
(c.frame_rate.frame_at(job.t), width, height)
|
||||
});
|
||||
self.pending.lock().unwrap().push((frame, width, height, done));
|
||||
}
|
||||
}
|
||||
|
||||
impl GatedFrames {
|
||||
/// Put `frames` in the cache, dropping every other queued frame (playback asks again).
|
||||
fn render(&self, h: &mut Harness<'_, EffectcraftApp>, cid: ItemId, frames: &[i64]) {
|
||||
let scale = h.state().shown_series(cid).scale as f64 / 1000.0;
|
||||
for &f in frames {
|
||||
h.state().request_frame(cid, f, scale);
|
||||
}
|
||||
while h.state().frames.dispatch_remote() != 0 {}
|
||||
let pending = std::mem::take(&mut *self.pending.lock().unwrap());
|
||||
for (frame, width, height, done) in pending {
|
||||
if frames.contains(&frame) {
|
||||
let rgba = vec![0; (width as usize).saturating_mul(height as usize).saturating_mul(4)];
|
||||
done(Ok(RemoteFrame { width, height, rgba, ms: 1.0 }));
|
||||
} else {
|
||||
done(Err("held back by the test renderer".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop everything queued without caching it (the warm-up passes request viewer frames).
|
||||
fn discard(&self, h: &mut Harness<'_, EffectcraftApp>) {
|
||||
while h.state().frames.dispatch_remote() != 0 {}
|
||||
for (_, _, _, done) in std::mem::take(&mut *self.pending.lock().unwrap()) {
|
||||
done(Err("held back by the test renderer".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 2 s, 30 fps comp whose solid sounds a tone (Audio switch on).
|
||||
fn setup(audible: bool, fake: &Fake) -> (EffectcraftApp, ItemId) {
|
||||
setup_long(audible, fake, 2.0)
|
||||
@@ -120,42 +173,74 @@ fn cmd_dragging_the_current_time_scrubs_and_an_idle_scrub_closes() {
|
||||
fn preview_with_audio_shows_every_frame_and_sounds_once_cached() {
|
||||
let fake = Fake::default();
|
||||
let (mut app, cid) = setup(true, &fake);
|
||||
// Playback requests at most twice as many frames ahead per step as there are render threads.
|
||||
// Four times that many frames take at least four steps to cache, so frames are shown silently
|
||||
// first however fast the renderer is (with a fixed 2 s comp, a machine with many cores cached
|
||||
// all of it before the first step and the sound started at once).
|
||||
// Hold rendering in the test: the cache cannot fill ahead of what the test releases, so the
|
||||
// silently shown frames and the moment the sound starts do not depend on the host's threads.
|
||||
let gate = GatedFrames::default();
|
||||
let renderer: Arc<dyn RemoteFrames> = Arc::new(gate.clone());
|
||||
app.frames.set_remote(Some(renderer));
|
||||
let ahead = (app.frames.parallelism() * 2).max(4);
|
||||
app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap();
|
||||
// A little under one frame of input time per step.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 31.0).build_eframe(|_| app);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
|
||||
// Playback measures time on the app clock, never `ctx.input(|i| i.time)`; drive that clock
|
||||
// explicitly at one frame per step.
|
||||
h.state_mut().use_manual_clock();
|
||||
h.run_steps(2);
|
||||
gate.discard(&mut h);
|
||||
let frame = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let app = h.state();
|
||||
app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time())
|
||||
};
|
||||
let now = h.ctx.input(|i| i.time);
|
||||
let now = h.state().now();
|
||||
h.state_mut().play(now);
|
||||
assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet");
|
||||
let mut last = frame(&h);
|
||||
let mut shown = 0;
|
||||
// Wait on the clock, not a step count: under a loaded machine (parallel test binaries) the
|
||||
// background cache can take far longer than 2000 short steps to get ahead.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while std::time::Instant::now() < deadline {
|
||||
// Show frames while only those frames are cached: they play silently first.
|
||||
for _ in 0..4 {
|
||||
let target = last + 1;
|
||||
gate.render(&mut h, cid, &[target]);
|
||||
h.state_mut().clock_mut().advance(1.0 / 30.0);
|
||||
h.step();
|
||||
if h.state().audio.is_some() {
|
||||
break;
|
||||
}
|
||||
let f = frame(&h);
|
||||
assert!(f == last || f == last + 1 || f < last, "{last} → {f}: a frame was skipped");
|
||||
// Accumulating 1/30 in f64 can leave the same frame up for one more step; it must never
|
||||
// jump over a frame.
|
||||
assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped");
|
||||
shown += usize::from(f != last);
|
||||
last = f;
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached");
|
||||
// A frame that has not rendered holds the clock at the frame on screen; once it renders it is
|
||||
// shown, never jumped over. This exercises the production hold path, not a test-only shortcut.
|
||||
let waiting = last;
|
||||
h.state_mut().clock_mut().advance(1.0 / 30.0);
|
||||
h.step();
|
||||
assert_eq!(frame(&h), waiting, "an uncached frame holds the preview instead of being skipped");
|
||||
gate.render(&mut h, cid, &[waiting + 1]);
|
||||
// f64 clock accumulation need not put the next step exactly on the next frame boundary;
|
||||
// keep stepping until the held frame appears, and never allow it to jump past that frame.
|
||||
let mut showed_waiting = false;
|
||||
for _ in 0..4 {
|
||||
h.state_mut().clock_mut().advance(1.0 / 30.0);
|
||||
h.step();
|
||||
let f = frame(&h);
|
||||
assert!(f == waiting || f == waiting + 1, "{waiting} → {f}: the held frame was skipped");
|
||||
if f == waiting + 1 {
|
||||
showed_waiting = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(showed_waiting, "the waiting frame is shown once it renders");
|
||||
shown += 1;
|
||||
last = waiting + 1;
|
||||
assert!(shown > 0, "frames played silently first");
|
||||
// A frame that isn't cached stops the sound rather than being skipped. (Frames render fast
|
||||
// enough here to be cached again before the check: keep them out of the cache.)
|
||||
assert!(h.state().audio.is_none() && h.state().playback.audio_held, "the rest of the range is not cached: the sound waits");
|
||||
// Cache everything ahead; only then may the sound start.
|
||||
let end = h.state().playback.plan.expect("a preview plan").end;
|
||||
let rest: Vec<i64> = ((last + 1)..=end).collect();
|
||||
gate.render(&mut h, cid, &rest);
|
||||
h.step();
|
||||
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached");
|
||||
// A frame that isn't cached stops the sound rather than being skipped.
|
||||
gate.discard(&mut h);
|
||||
h.state().frames.set_budget(0);
|
||||
h.state().frames.clear();
|
||||
h.step();
|
||||
@@ -168,27 +253,47 @@ fn preview_with_audio_shows_every_frame_and_sounds_once_cached() {
|
||||
#[test]
|
||||
fn preview_with_audio_sounds_once_rendering_keeps_up() {
|
||||
let fake = Fake::default();
|
||||
let (app, cid) = setup_long(true, &fake, 30.0);
|
||||
let (mut app, cid) = setup_long(true, &fake, 30.0);
|
||||
// Release only the frames this test chooses, and drive the app clock: rendering cannot race
|
||||
// the clock, so a frame is never skipped by a slow or loaded test host.
|
||||
let gate = GatedFrames::default();
|
||||
let renderer: Arc<dyn RemoteFrames> = Arc::new(gate.clone());
|
||||
app.frames.set_remote(Some(renderer));
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
|
||||
h.state_mut().use_manual_clock();
|
||||
h.run_steps(2);
|
||||
gate.discard(&mut h);
|
||||
let ahead = (h.state().frames.parallelism() * 2).max(4) as i64;
|
||||
let frame = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let app = h.state();
|
||||
app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time())
|
||||
};
|
||||
let now = h.ctx.input(|i| i.time);
|
||||
h.state_mut().play(now);
|
||||
let start = h.state().now();
|
||||
h.state_mut().play(start);
|
||||
assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet");
|
||||
let mut last = frame(&h);
|
||||
// Two seconds of playback, each step giving the frame workers time to keep up.
|
||||
for _ in 0..60 {
|
||||
let mut shown = 0;
|
||||
// Two seconds of playback at one frame per clock step: keep the prefetch window cached, but
|
||||
// not the rest of the 30 s comp, so the sound has to start by the real-time rule.
|
||||
for step in 1..=60 {
|
||||
let target = last + 1;
|
||||
let window: Vec<i64> = (target..target + ahead).collect();
|
||||
gate.render(&mut h, cid, &window);
|
||||
// Set the clock to this step's exact time rather than adding 1/30 repeatedly: accumulated
|
||||
// f64 error can put one step just below a frame boundary and the next just above it, so
|
||||
// `floor(elapsed × fps)` would otherwise jump from frame N to N + 2 for a one-frame step.
|
||||
let now = h.state().now();
|
||||
let tick_time = start + step as f64 / 30.0;
|
||||
h.state_mut().clock_mut().advance(tick_time - now);
|
||||
h.step();
|
||||
if h.state().audio.is_some() {
|
||||
break;
|
||||
}
|
||||
let f = frame(&h);
|
||||
assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped");
|
||||
shown += usize::from(f != last);
|
||||
last = f;
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900");
|
||||
assert!(shown >= 3, "the sound starts only after the silent preview kept up for a moment");
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ fn timeline_blank_outline_and_time_graph_create_layers() {
|
||||
let area = rect(&h, "timeline.layerMarquee");
|
||||
let p = if graph { pos2(area.max.x + 150.0, area.center().y) } else { area.center() };
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("New ⏵").click();
|
||||
h.get_by_label("New").click();
|
||||
h.run_steps(3);
|
||||
snapshot(&mut h, if graph { "timeline-graph" } else { "timeline-outline" });
|
||||
h.get_by_label("Null Object").click();
|
||||
@@ -81,7 +81,7 @@ fn timeline_blank_menu_opens_solid_settings() {
|
||||
let mut h = harness();
|
||||
let p = rect(&h, "timeline.layerMarquee").center();
|
||||
right_click(&mut h, p);
|
||||
h.get_by_label("New ⏵").click();
|
||||
h.get_by_label("New").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Solid…").click();
|
||||
h.run_steps(3);
|
||||
@@ -252,13 +252,13 @@ fn timeline_layer_context_menu_has_the_effects_submenu_and_applies_an_effect() {
|
||||
let r = rect(&h, &format!("timeline.layer.{layer}.row"));
|
||||
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
|
||||
// The palette sits between Blending Mode and Arrange, as After Effects' layer menu does.
|
||||
let blending = h.get_by_label("Blending Mode ⏵").rect();
|
||||
let effects = h.get_by_label("Effects ⏵").rect();
|
||||
let arrange = h.get_by_label("Arrange ⏵").rect();
|
||||
let blending = h.get_by_label("Blending Mode").rect();
|
||||
let effects = h.get_by_label("Effects").rect();
|
||||
let arrange = h.get_by_label("Arrange").rect();
|
||||
assert!(blending.bottom() <= effects.top() && effects.bottom() <= arrange.top(), "Effects between Blending Mode and Arrange");
|
||||
h.get_by_label("Effects ⏵").click();
|
||||
h.get_by_label("Effects").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Blur & Sharpen ⏵").click();
|
||||
h.get_by_label("Blur & Sharpen").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Gaussian Blur").click();
|
||||
h.run_steps(3);
|
||||
@@ -283,11 +283,11 @@ fn timeline_layer_context_menu_has_layer_styles_submenu() {
|
||||
h.run_steps(3);
|
||||
let r = rect(&h, &format!("timeline.layer.{solid}.row"));
|
||||
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
|
||||
let blending = h.get_by_label("Blending Mode ⏵").rect();
|
||||
let styles = h.get_by_label("Layer Styles ⏵").rect();
|
||||
let effects = h.get_by_label("Effects ⏵").rect();
|
||||
let blending = h.get_by_label("Blending Mode").rect();
|
||||
let styles = h.get_by_label("Layer Styles").rect();
|
||||
let effects = h.get_by_label("Effects").rect();
|
||||
assert!(blending.bottom() <= styles.top() && styles.bottom() <= effects.top(), "Layer Styles between Blending Mode and Effects");
|
||||
h.get_by_label("Layer Styles ⏵").click();
|
||||
h.get_by_label("Layer Styles").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Layer Style Options...").is_some());
|
||||
assert!(h.query_by_label("Drop Shadow").is_some());
|
||||
@@ -351,7 +351,7 @@ fn layer_context_menu_adds_a_layer_marker() {
|
||||
h.run_steps(3);
|
||||
let r = rect(&h, &format!("timeline.layer.{top}.row"));
|
||||
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
|
||||
h.get_by_label("Markers ⏵").click();
|
||||
h.get_by_label("Markers").click();
|
||||
h.run_steps(3);
|
||||
snapshot(&mut h, "layer-markers");
|
||||
h.get_by_label("Add Marker").click();
|
||||
|
||||
@@ -175,6 +175,7 @@ fn viewer_zooms_and_pans_over_the_track_points() {
|
||||
delta: egui::vec2(0.0, 120.0),
|
||||
modifiers: Default::default(),
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
});
|
||||
h.run_steps(20);
|
||||
assert_ne!(h.state().ui.viewer.zoom, zoom, "the wheel zooms over the track points");
|
||||
|
||||
@@ -18,6 +18,8 @@ fn frames(app: &mut EffectcraftApp) -> Vec<(bool, egui::Modifiers)> {
|
||||
if raw.events.is_empty() {
|
||||
break;
|
||||
}
|
||||
// The harness feeds the batch itself; a real UI pass would apply it now.
|
||||
app.mark_synthetic_applied();
|
||||
let mut button = false;
|
||||
for e in &raw.events {
|
||||
match e {
|
||||
@@ -34,7 +36,8 @@ fn frames(app: &mut EffectcraftApp) -> Vec<(bool, egui::Modifiers)> {
|
||||
fn send(app: &mut EffectcraftApp, method: &str, params: Value) {
|
||||
let ctx = egui::Context::default();
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
let _ = control::handle(app, &ctx, &req);
|
||||
let now = app.now();
|
||||
let _ = control::handle(app, &ctx, &req, now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -54,7 +54,7 @@ fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() {
|
||||
h.run_steps(3);
|
||||
let before_project = h.state().session.project.clone();
|
||||
let before_times = h.state().session.state.times.clone();
|
||||
let id = Id::new(("dock-gutter", String::new()));
|
||||
let id = Id::unique(("dock-gutter", String::new()));
|
||||
h.ctx.memory_mut(|memory| memory.request_focus(id));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id));
|
||||
|
||||
@@ -421,7 +421,7 @@ fn right_click_in_effect_controls_shows_the_effect_menu() {
|
||||
app.show_panel(PanelKind::EffectControls);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app);
|
||||
h.run_steps(3);
|
||||
let category = " Blur & Sharpen ⏵";
|
||||
let category = " Blur & Sharpen";
|
||||
// An effect's header: its own menu.
|
||||
let header = rect_of(&h, &format!("effectControls.effect.{fill}")).center();
|
||||
right_click(&mut h, header);
|
||||
|
||||
@@ -32,7 +32,9 @@ fn harness(language: &str) -> (Harness<'static, EffectcraftApp>, u64) {
|
||||
app.show_panel(PanelKind::EffectControls);
|
||||
app.show_panel(PanelKind::EffectsPresets);
|
||||
app.show_panel(PanelKind::Timeline);
|
||||
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| app);
|
||||
// The Chinese/Japanese names under test have no installed face on every host: draw tofu
|
||||
// instead of failing the missing-glyph gate (the glyph coverage test covers the bundled set).
|
||||
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).allow_missing_glyphs().build_eframe(|_| app);
|
||||
h.run_steps(4);
|
||||
(h, fx)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ fn harness() -> (Harness<'static, EffectcraftApp>, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4, "fps": 24})).unwrap();
|
||||
let comp = s.active_comp_id().unwrap().0;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
// Arabic, Hebrew, Devanagari and Thai names typed below need installed system faces: draw
|
||||
// tofu on a host without them rather than failing the missing-glyph gate.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).allow_missing_glyphs().build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(4);
|
||||
(h, comp)
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ fn comp_thumbnail_renders_off_the_ui_thread() {
|
||||
for _ in 0..400 {
|
||||
h.step();
|
||||
if h.state().auto.find("project.thumbnail").is_some()
|
||||
&& h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::new(("proj-thumb", main.0)))).is_some()
|
||||
&& h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::unique(("proj-thumb", main.0)))).is_some()
|
||||
{
|
||||
landed = true;
|
||||
break;
|
||||
|
||||
@@ -111,6 +111,7 @@ fn wheel_zooms_around_the_pointer_and_hand_tool_pans() {
|
||||
delta: vec2(0.0, 120.0),
|
||||
modifiers: Default::default(),
|
||||
phase: egui::TouchPhase::Move,
|
||||
source: egui::MouseWheelSource::Wheel,
|
||||
});
|
||||
h.run_steps(20);
|
||||
let after = rect(&h, "layerPanel.canvas");
|
||||
|
||||
@@ -63,7 +63,7 @@ fn none_pick_clears(h: &mut Harness<'_, EffectcraftApp>, value_id: &str, none: &
|
||||
let steps = h.state().session.history.undo.len();
|
||||
let at = rect_of(h, value_id).center();
|
||||
click(h, at);
|
||||
h.get_by_label(none).click();
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, none).click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(layer_value(h, layer, prop), None, "selecting None clears the value");
|
||||
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one clear undo step");
|
||||
@@ -101,11 +101,11 @@ fn timeline_source_drop_down_picks_the_layer_and_undo_restores_none() {
|
||||
let at = rect_of(&h, &value_id).center();
|
||||
click(&mut h, at);
|
||||
let entry = entry_for(&h, sky);
|
||||
assert!(h.query_by_label("None").is_some(), "the list starts with None");
|
||||
assert!(h.query_by_label(&entry).is_some(), "the comp's layers are listed ({entry})");
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, "None").is_some(), "the list starts with None");
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, &entry).is_some(), "the comp's layers are listed ({entry})");
|
||||
|
||||
let steps = h.state().session.history.undo.len();
|
||||
h.get_by_label(&entry).click();
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(layer_value(&h, light, source), Some(sky), "the Source holds the layer id");
|
||||
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
|
||||
@@ -119,8 +119,16 @@ fn timeline_source_drop_down_picks_the_layer_and_undo_restores_none() {
|
||||
assert_eq!(layer_value(&h, light, source), Some(sky), "dismissing the list writes nothing");
|
||||
let reopen = rect_of(&h, &value_id).center();
|
||||
click(&mut h, reopen);
|
||||
assert_eq!(h.get_by_label(&entry).accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current entry is highlighted");
|
||||
assert_eq!(h.get_by_label("None").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False), "None is not the current entry");
|
||||
assert_eq!(
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).accesskit_node().toggled(),
|
||||
Some(egui::accesskit::Toggled::True),
|
||||
"the current entry is highlighted"
|
||||
);
|
||||
assert_eq!(
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "None").accesskit_node().toggled(),
|
||||
Some(egui::accesskit::Toggled::False),
|
||||
"None is not the current entry"
|
||||
);
|
||||
click(&mut h, pos2(1500.0, 300.0));
|
||||
h.run_steps(2);
|
||||
|
||||
@@ -154,9 +162,9 @@ fn effect_controls_layer_param_picks_through_the_shared_widget() {
|
||||
let row = rect_of(&h, &value_id);
|
||||
click(&mut h, row.center());
|
||||
let entry = entry_for(&h, map);
|
||||
assert!(h.query_by_label(&entry).is_some(), "the comp's layers are listed ({entry})");
|
||||
assert!(h.query_by_role_and_label(egui::accesskit::Role::Button, &entry).is_some(), "the comp's layers are listed ({entry})");
|
||||
let steps = h.state().session.history.undo.len();
|
||||
h.get_by_label(&entry).click();
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(layer_value(&h, base, prop), Some(map), "the parameter holds the layer id");
|
||||
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
|
||||
@@ -200,7 +208,7 @@ fn snapshot_source_dropdown_open_and_chosen() {
|
||||
click(&mut h, at);
|
||||
save(&mut h, "source_dropdown_open.png");
|
||||
let entry = entry_for(&h, sky);
|
||||
h.get_by_label(&entry).click();
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, &entry).click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(layer_value(&h, light, source), Some(sky));
|
||||
save(&mut h, "source_chosen.png");
|
||||
|
||||
@@ -38,7 +38,7 @@ fn invoke(h: &mut Harness<'_, EffectcraftApp>, command: &str, params: Value) {
|
||||
}
|
||||
|
||||
fn locked_center(h: &Harness<'_, EffectcraftApp>, images: &Images) -> [u8; 4] {
|
||||
let (_, texture) = h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::new("viewer-locked"))).unwrap();
|
||||
let (_, texture) = h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::unique("viewer-locked"))).unwrap();
|
||||
let images = images.borrow();
|
||||
let image = images.get(&texture.id()).unwrap();
|
||||
image.pixels[(image.size[1] / 2) * image.size[0] + image.size[0] / 2].to_array()
|
||||
|
||||
@@ -42,7 +42,7 @@ type Listing = (f64, Arc<Result<Vec<Entry>, String>>);
|
||||
|
||||
fn listing(h: &Harness<'_, EffectcraftApp>, only: bool) -> Listing {
|
||||
let dir = h.state().session.state.media_browser.folder.clone().unwrap_or_else(|| "/Shoot".into());
|
||||
h.ctx.data(|d| d.get_temp(egui::Id::new(("mb-list", dir, only)))).unwrap()
|
||||
h.ctx.data(|d| d.get_temp(egui::Id::unique(("mb-list", dir, only)))).unwrap()
|
||||
}
|
||||
|
||||
fn names(list: &Arc<Result<Vec<Entry>, String>>) -> Vec<&str> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user