From df5f86ae31dbb17be54cbc06c01ac87a634c3b1f Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sat, 24 Oct 2026 11:13:28 +0200 Subject: [PATCH] egui 0.37: migrate FilmCraft input and rendering integration --- .github/workflows/ci.yml | 2 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- Cargo.lock | 281 ++- Cargo.toml | 29 +- apps/filmcraft-web/src/fs.rs | 7 +- apps/filmcraft-web/src/recovery.rs | 15 +- apps/filmcraft-web/src/webcodecs.rs | 8 +- apps/filmcraft/Cargo.toml | 2 +- apps/filmcraft/src/audio.rs | 2 +- crates/engine/src/clip_ops.rs | 1 + crates/engine/src/commands.rs | 3 + crates/engine/src/graphics.rs | 10 +- crates/engine/src/graphics_tests.rs | 25 + crates/engine/src/lib.rs | 5 + crates/engine/src/narration.rs | 2 +- crates/engine/src/project_tools.rs | 3 +- crates/engine/src/relink.rs | 4 +- crates/format/src/lib.rs | 29 + crates/platform/src/nvenc/export.rs | 2 +- crates/project/src/graphic.rs | 78 + crates/project/src/gtemplate.rs | 10 +- crates/project/src/keyframe.rs | 45 + crates/project/src/lib.rs | 26 + crates/render/src/offline.rs | 2 +- crates/ui-egui/Cargo.toml | 5 +- crates/ui-egui/src/automation.rs | 74 +- crates/ui-egui/src/brand.rs | 2 +- crates/ui-egui/src/capture.rs | 795 +++++++++ crates/ui-egui/src/cjk.rs | 13 +- crates/ui-egui/src/control.rs | 146 +- crates/ui-egui/src/credits.rs | 2 +- crates/ui-egui/src/dock.rs | 29 +- crates/ui-egui/src/dock_ui.rs | 17 +- crates/ui-egui/src/graphics_notice.rs | 2 +- crates/ui-egui/src/header.rs | 16 +- crates/ui-egui/src/i18n/de.tsv | 4 +- crates/ui-egui/src/i18n/es.tsv | 6 +- crates/ui-egui/src/i18n/fr.tsv | 6 +- crates/ui-egui/src/i18n/id.tsv | 6 +- crates/ui-egui/src/i18n/it.tsv | 6 +- crates/ui-egui/src/i18n/ja.tsv | 6 +- crates/ui-egui/src/i18n/ko.tsv | 6 +- crates/ui-egui/src/i18n/mod.rs | 37 +- crates/ui-egui/src/i18n/pt-br.tsv | 6 +- crates/ui-egui/src/i18n/zh-cn.tsv | 6 +- crates/ui-egui/src/lib.rs | 735 ++++++-- crates/ui-egui/src/links.rs | 4 +- crates/ui-egui/src/menus.rs | 53 +- crates/ui-egui/src/panels/audio_fx_editor.rs | 10 +- crates/ui-egui/src/panels/clip_dialogs.rs | 16 +- crates/ui-egui/src/panels/color_dialogs.rs | 4 +- crates/ui-egui/src/panels/dialogs.rs | 16 +- crates/ui-egui/src/panels/effect_controls.rs | 101 +- .../src/panels/effect_controls_selection.rs | 4 +- crates/ui-egui/src/panels/effects.rs | 2 +- crates/ui-egui/src/panels/essential_sound.rs | 21 +- crates/ui-egui/src/panels/events.rs | 2 +- crates/ui-egui/src/panels/export_mode.rs | 25 +- crates/ui-egui/src/panels/file_dialogs.rs | 4 +- crates/ui-egui/src/panels/frame_export.rs | 18 +- crates/ui-egui/src/panels/graphics.rs | 1105 +++++++++++- .../ui-egui/src/panels/graphics_templates.rs | 36 +- crates/ui-egui/src/panels/import_mode.rs | 6 +- .../ui-egui/src/panels/interchange_export.rs | 6 +- crates/ui-egui/src/panels/keyboard.rs | 7 +- crates/ui-egui/src/panels/lumetri.rs | 29 +- crates/ui-egui/src/panels/masks.rs | 46 +- crates/ui-egui/src/panels/media_browser.rs | 25 +- crates/ui-egui/src/panels/media_dialogs.rs | 27 +- crates/ui-egui/src/panels/menu_dialogs.rs | 8 +- crates/ui-egui/src/panels/metadata.rs | 7 +- crates/ui-egui/src/panels/mixer.rs | 75 +- crates/ui-egui/src/panels/mod.rs | 12 +- crates/ui-egui/src/panels/monitor.rs | 28 +- crates/ui-egui/src/panels/monitor_view.rs | 20 +- crates/ui-egui/src/panels/multicam.rs | 33 +- crates/ui-egui/src/panels/notes.rs | 2 +- crates/ui-egui/src/panels/presets.rs | 6 +- crates/ui-egui/src/panels/project.rs | 20 +- crates/ui-egui/src/panels/project_dialogs.rs | 14 +- crates/ui-egui/src/panels/project_views.rs | 29 +- crates/ui-egui/src/panels/reference.rs | 4 +- crates/ui-egui/src/panels/remix.rs | 6 +- crates/ui-egui/src/panels/scopes.rs | 8 +- .../ui-egui/src/panels/sequence_settings.rs | 6 +- crates/ui-egui/src/panels/settings.rs | 39 +- crates/ui-egui/src/panels/shortcuts_dialog.rs | 17 +- crates/ui-egui/src/panels/source_drag.rs | 4 +- crates/ui-egui/src/panels/source_range.rs | 6 +- crates/ui-egui/src/panels/text.rs | 85 +- crates/ui-egui/src/panels/timecode.rs | 4 +- crates/ui-egui/src/panels/timeline.rs | 55 +- .../ui-egui/src/panels/timeline_automation.rs | 21 +- .../ui-egui/src/panels/timeline_captions.rs | 14 +- crates/ui-egui/src/panels/timeline_volume.rs | 6 +- crates/ui-egui/src/panels/tools.rs | 6 +- .../ui-egui/src/panels/transform_handles.rs | 2 +- .../ui-egui/src/panels/transition_controls.rs | 13 +- crates/ui-egui/src/panels/trim_monitor.rs | 25 +- crates/ui-egui/src/panels/tts.rs | 6 +- crates/ui-egui/src/panels/voiceover.rs | 12 +- crates/ui-egui/src/panels/workspaces.rs | 4 +- crates/ui-egui/src/rtl.rs | 273 +-- crates/ui-egui/src/scrub.rs | 2 +- crates/ui-egui/src/source_playback.rs | 4 +- crates/ui-egui/src/state.rs | 8 + crates/ui-egui/src/theme_favorites.rs | 2 +- crates/ui-egui/src/widgets.rs | 71 +- crates/ui-egui/tests/it/clipboard_paste_ui.rs | 45 +- crates/ui-egui/tests/it/deselect_ui.rs | 2 +- .../ui-egui/tests/it/graphics_handles_ui.rs | 851 +++++++++ crates/ui-egui/tests/it/masks_ui.rs | 3 + crates/ui-egui/tests/it/mixer_ui.rs | 2 +- crates/ui-egui/tests/it/monitor_geometry.rs | 5 +- crates/ui-egui/tests/it/panels_ui.rs | 1 + crates/ui-egui/tests/it/shared_docking_ui.rs | 4 +- crates/ui-egui/tests/it/ui_control_input.rs | 17 +- crates/ui-egui/tests/it/ui_dock_resize.rs | 4 +- crates/ui-egui/tests/it/ui_keyboard_focus.rs | 10 +- docs/egui-037-migration-plan.md | 1563 +++++++++++++++++ packaging/linux/package.sh | 1 - packaging/windows/filmcraft.wxs | 11 + packaging/windows/package.ps1 | 14 + 124 files changed, 6431 insertions(+), 1146 deletions(-) create mode 100644 crates/ui-egui/src/capture.rs create mode 100644 docs/egui-037-migration-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5df1ae3a3..69f8850e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # Same craft-fonts commit as release.yml. - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 timeout-minutes: 120 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 6018ad42c..dc78b3ae0 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,6 +32,6 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 skip-tests: true run-package-check: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 711a108d1..0724495e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: filmcraft app-title: FilmCraft - craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/Cargo.lock b/Cargo.lock index 9ff01577b..ce1a61540 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -27,43 +27,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows 0.62.2", "windows-core 0.62.2", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -248,6 +243,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1027,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" dependencies = [ "egui", "serde", @@ -1295,9 +1291,8 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", "emath", @@ -1329,9 +1324,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1353,6 +1347,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1364,9 +1359,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "accesskit", "ahash", @@ -1384,9 +1378,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1404,30 +1397,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", "egui", @@ -1439,9 +1435,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "eframe", "egui", @@ -1484,9 +1479,8 @@ dependencies = [ [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", ] @@ -1546,9 +1540,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1564,16 +1557,17 @@ dependencies = [ "self_cell", "skrifa", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" [[package]] name = "equivalent" @@ -2424,6 +2418,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -2453,6 +2453,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2940,12 +2946,12 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -3162,6 +3168,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -3170,7 +3185,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", "serde", "serde_core", ] @@ -3181,7 +3196,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3480,12 +3495,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3758,6 +3773,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -4311,6 +4335,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "p256" version = "0.13.2" @@ -4430,10 +4464,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.13.1" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -4442,9 +4487,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -4452,9 +4497,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -4465,9 +4510,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -5438,6 +5483,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "seq-macro" version = "0.3.6" @@ -6241,6 +6292,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -6303,6 +6365,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -6379,9 +6447,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -6394,9 +6462,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -7263,8 +7331,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181" dependencies = [ "ahash", "android-activity", @@ -7345,6 +7412,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "x11-dl" version = "2.21.0" @@ -7696,3 +7781,23 @@ dependencies = [ "syn 3.0.6", "winnow 1.0.4", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" diff --git a/Cargo.toml b/Cargo.toml index 2801176d2..da3882c92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,10 +77,13 @@ craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-lib # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -egui = "0.36" -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa", features = ["serde"] } +# egui 0.37: bundled fonts are opt-in, and the monochrome symbol/emoji faces moved to their own +# feature. FilmCraft paints ✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑ in panel chrome, so both features are part of the +# production font policy (system_fonts stays off). +egui = { version = "0.37", features = ["default_fonts", "monochrome_emoji_fonts"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" [workspace.lints.rust] @@ -153,3 +156,21 @@ opt-level = 3 [profile.release] lto = "thin" codegen-units = 1 + +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" } diff --git a/apps/filmcraft-web/src/fs.rs b/apps/filmcraft-web/src/fs.rs index d7ec9072f..a6b23b1c7 100644 --- a/apps/filmcraft-web/src/fs.rs +++ b/apps/filmcraft-web/src/fs.rs @@ -426,6 +426,10 @@ impl Services for WebServices { /// Offer bytes as a browser download named `name`. Downloads are clicked at least /// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378). +thread_local! { + static DOWNLOAD_CLOCK: web_time::Instant = web_time::Instant::now(); +} + pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> { let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; let blob = bytes_to_blob(data, "application/octet-stream")?; @@ -434,7 +438,8 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> { a.set_href(&url); a.set_download(file_name(name)); a.style().set_property("display", "none")?; - let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now())); + let now_ms = DOWNLOAD_CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0); + let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(now_ms)); let name = name.to_owned(); wasm_bindgen_futures::spawn_local(async move { if delay > 0.0 { diff --git a/apps/filmcraft-web/src/recovery.rs b/apps/filmcraft-web/src/recovery.rs index 6001531e8..5c58e44b6 100644 --- a/apps/filmcraft-web/src/recovery.rs +++ b/apps/filmcraft-web/src/recovery.rs @@ -28,10 +28,17 @@ enum Outcome { Clean { ok: bool }, } -#[derive(Default)] pub struct Autosave { policy: Policy, outcomes: Rc>>, + /// Monotonic host clock. Web `logic` may run for hidden tabs; egui's input time is stale there. + clock: web_time::Instant, +} + +impl Default for Autosave { + fn default() -> Self { + Self { policy: Policy::default(), outcomes: Rc::default(), clock: web_time::Instant::now() } + } } impl Autosave { @@ -40,7 +47,7 @@ impl Autosave { if !opfs::available() { return; } - let now = ctx.input(|i| i.time); + let now = self.clock.elapsed().as_secs_f64(); for o in self.outcomes.borrow_mut().drain(..) { match o { Outcome::Snapshot { revision, ok } => self.policy.written(revision, ok, now), @@ -64,7 +71,9 @@ impl Autosave { } }) }; - let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()}); + // No wall-clock timestamp: recovery policy scheduling uses the monotonic clock + // above, and the snapshot schema needs no display time. + let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision}); let d1 = done(self.outcomes.clone(), failed.clone(), left.clone(), ctx.clone()); let d2 = done(self.outcomes.clone(), failed, left, ctx.clone()); opfs::write_then(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false), Some(d1)); diff --git a/apps/filmcraft-web/src/webcodecs.rs b/apps/filmcraft-web/src/webcodecs.rs index 48fa7a1c8..b16bf0db1 100644 --- a/apps/filmcraft-web/src/webcodecs.rs +++ b/apps/filmcraft-web/src/webcodecs.rs @@ -68,8 +68,14 @@ thread_local! { static TRANSFERS: RefCell = RefCell::new(Transfers::default()); } +thread_local! { + /// Monotonic browser clock. `Date::now()` is wall time and can move backwards across NTP or + /// suspend/resume; codec idle/staleness accounting must not. + static CLOCK: web_time::Instant = web_time::Instant::now(); +} + fn now_ms() -> f64 { - js_sys::Date::now() + CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0) } fn get(o: &JsValue, k: &str) -> JsValue { diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index e0730dc37..7db181b39 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -27,7 +27,7 @@ filmcraft-gpu = { workspace = true } filmcraft-ui-egui = { workspace = true } filmcraft-platform = { workspace = true } egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] } serde_json = { workspace = true } # Constant-time comparison of control-channel credentials. subtle = "2.6" diff --git a/apps/filmcraft/src/audio.rs b/apps/filmcraft/src/audio.rs index cb56e059e..26a5cf727 100644 --- a/apps/filmcraft/src/audio.rs +++ b/apps/filmcraft/src/audio.rs @@ -55,7 +55,7 @@ impl Note { use filmcraft_ui_egui::i18n::{fmt, t}; match self { Note::Fallback(device) => fmt(t("Audio hardware: the default output device cannot be opened; using '{device}'"), &[("device", device)]), - Note::NoDevice => t("No audio output device found: playing without sound (check Settings ▸ Audio Hardware)").to_string(), + Note::NoDevice => t("No audio output device found: playing without sound (check Settings > Audio Hardware)").to_string(), } } } diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index 8a85b9eee..78fc017d5 100644 --- a/crates/engine/src/clip_ops.rs +++ b/crates/engine/src/clip_ops.rs @@ -826,6 +826,7 @@ fn close_project(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("the project has unsaved changes: save it first, or pass {\"force\": true} to discard them".into())); } s.project = std::sync::Arc::new(Project::new("Untitled")); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = Session::default().state; diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5c564e69e..767c737fe 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1140,6 +1140,7 @@ fn build() -> Vec { cmd!("file.newProject", "Project…", ["File", "New"], Some("Cmd+Alt+N"), r#"{"name":str}"#, always, |s, p| { let name = str_p(p, "name").unwrap_or("Untitled").to_string(); s.project = std::sync::Arc::new(filmcraft_project::Project::new(&name)); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = crate::Session::default().state; @@ -1154,6 +1155,7 @@ fn build() -> Vec { cmd!("file.openDemoProject", "Demo Project", ["File", "New"], None, "{}", always, |s, _| { let (p, seq) = crate::demo::demo_project(&s.media); s.project = std::sync::Arc::new(p); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = crate::Session::default().state; @@ -4385,6 +4387,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti let first = proj.sequences().next().map(|i| i.id); s.media.add_stored(&proj, path.as_deref()); s.project = std::sync::Arc::new(proj); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = crate::Session::default().state; diff --git a/crates/engine/src/graphics.rs b/crates/engine/src/graphics.rs index 998b1e2f7..58b3eaada 100644 --- a/crates/engine/src/graphics.rs +++ b/crates/engine/src/graphics.rs @@ -8,7 +8,9 @@ //! at the playhead. use filmcraft_geom::Vec2; -use filmcraft_project::graphic::{self, LayerContent, SHAPE_OPTS, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer}; +use filmcraft_project::graphic::{ + self, LayerContent, SHAPE_OPTS, ensure_layer_uids, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer, +}; use filmcraft_project::{ClipId, EffectInstance, ItemId, ItemKind, Label, Param, ParamValue, Sequence, TrackKind}; use filmcraft_render::graphic_clip::{item_layer_specs, layer_local_bounds, layer_quad, text_layout}; use filmcraft_time::{Tick, TimeRange}; @@ -138,6 +140,9 @@ pub(crate) fn place_video_clip( let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad(cmd, "bad item"))?; ti.name = name; ti.effects.extend(extra); + // Every new graphic layer gets a stable uid; a new clip holds layers from `extra` plus + // any source-graphic layers, so do it after both are in place (duplicates are repaired). + ensure_layer_uids(&mut ti.effects); for e in &mut ti.effects { filmcraft_project::resolve_auto_points(e, (w, h), src_size); } @@ -179,6 +184,9 @@ fn add_layer(s: &mut Session, clip: ClipId, mut layer: filmcraft_project::Effect // new layers go in front: after the last graphic layer let pos = layer_indices(&it.effects).last().map_or(it.effects.len(), |l| l + 1); it.effects.insert(pos, layer); + // Assign a uid to the new layer (and lazily upgrade any uid-less legacy layers) inside + // this undo step, so undo/redo keeps the same identities. + ensure_layer_uids(&mut it.effects); let n = layer_indices(&it.effects).len() - 1; st.selection = vec![clip]; st.graphic_layers = vec![n]; diff --git a/crates/engine/src/graphics_tests.rs b/crates/engine/src/graphics_tests.rs index e8893104e..8069fd17b 100644 --- a/crates/engine/src/graphics_tests.rs +++ b/crates/engine/src/graphics_tests.rs @@ -95,6 +95,31 @@ fn graphic_placement_takes_a_track_and_a_time() { } } +/// Every graphic layer gets a stable uid when it is created, both in a new clip and when added to +/// an existing one. Two byte-identical layers with different text must not share an identity. +#[test] +fn new_graphic_layers_get_unique_stable_uids() { + let mut s = demo(); + let clip = ClipId(s.execute("graphics.newText", json!({"text": "one", "position": [960, 540], "size": 100})).unwrap()["clip"].as_u64().unwrap()); + s.execute("graphics.newText", json!({"text": "two", "position": [960, 540], "size": 100, "clip": clip.0})).unwrap(); + s.execute("graphics.newShape", json!({"shape": "rectangle", "clip": clip.0})).unwrap(); + let uids = |s: &mut Session| -> Vec { + s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"].as_array().unwrap().iter().map(|l| l["uid"].as_u64().unwrap()).collect() + }; + let first = uids(&mut s); + assert_eq!(first.len(), 3); + assert!(first.iter().all(|uid| *uid != 0), "every created layer is assigned a uid: {first:?}"); + assert_eq!(first.iter().copied().collect::>().len(), 3, "uids are unique: {first:?}"); + // A real reorder keeps the same per-layer identity, and undo/redo restores it unchanged. + s.execute("graphics.arrangeLayer", json!({"clip": clip.0, "layer": 0, "to": "front"})).unwrap(); + let reordered = uids(&mut s); + assert_eq!(reordered, vec![first[1], first[2], first[0]]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(uids(&mut s), first, "undo restores the same identities, not fresh ones"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(uids(&mut s), reordered); +} + #[test] fn new_text_makes_a_graphic_clip_above_the_footage() { let mut s = demo(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 47a9cfd6e..432cea674 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -406,6 +406,10 @@ pub struct Session { pub project: Arc, pub history: History, pub revision: u64, + /// Bumped whenever the session installs a different project document (`file.newProject`, + /// open/import/recovery/template). Frontends use it to discard UI state (text edits, IME + /// owners) that belongs to the replaced document even if item ids are reused. + pub document_epoch: u64, pub saved_revision: u64, pub path: Option, pub state: EditorState, @@ -524,6 +528,7 @@ impl Session { project: Arc::new(Project::new("Untitled")), history: History { limit: 200, ..Default::default() }, revision: 1, + document_epoch: 0, saved_revision: 1, path: None, state: EditorState { diff --git a/crates/engine/src/narration.rs b/crates/engine/src/narration.rs index cc9f1fdf8..562271dd1 100644 --- a/crates/engine/src/narration.rs +++ b/crates/engine/src/narration.rs @@ -98,7 +98,7 @@ fn cache_key(n: &Narration) -> String { fn tts_err(cmd: &str, e: tts::TtsError) -> EngineError { match e { - tts::TtsError::NotInstalled => bad(cmd, "the natural voices are not downloaded yet: Text to Speech ▸ Download natural voices (tts.downloadVoices)"), + tts::TtsError::NotInstalled => bad(cmd, "the natural voices are not downloaded yet: Text to Speech > Download natural voices (tts.downloadVoices)"), e => bad(cmd, e.to_string()), } } diff --git a/crates/engine/src/project_tools.rs b/crates/engine/src/project_tools.rs index 136229370..dbe414254 100644 --- a/crates/engine/src/project_tools.rs +++ b/crates/engine/src/project_tools.rs @@ -678,6 +678,7 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result { proj.name = name.clone(); proj.root.name = name; s.project = std::sync::Arc::new(proj); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = Session::default().state; @@ -1062,7 +1063,7 @@ pub(crate) fn warn_untrusted_scratch(s: &mut Session) { let list = ignored.join(", "); log::warn!("project scratch disks outside the project folder not used: {list}"); s.toast(format!( - "Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings ▸ Scratch Disks to use it." + "Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings > Scratch Disks to use it." )); } diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index 7a75826e9..9649e5815 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -752,11 +752,11 @@ pub fn on_open(s: &mut Session) -> usize { list.iter().filter(|m| m.status == "missing").filter_map(|m| s.media.follow(&m.path).err().map(|why| format!("{} ({why})", m.path))).collect(); if !refused.is_empty() { log::warn!("media paths stored in the project not opened: {}", refused.join(", ")); - s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File ▸ Link Media… reconnects them", refused.len())); + s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File > Link Media… reconnects them", refused.len())); } s.offline.prompt = !s.offline.missing.is_empty(); if !s.offline.missing.is_empty() { - s.toast(format!("{} media file(s) are missing; File ▸ Link Media… reconnects them", s.offline.missing.len())); + s.toast(format!("{} media file(s) are missing; File > Link Media… reconnects them", s.offline.missing.len())); } list.len() } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9ecd10aab..a68117bea 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -216,6 +216,9 @@ pub fn decode(bytes: &[u8]) -> Result { /// Refuse only values that would overflow or exhaust memory later (`Sequence::check_bounds`); /// structural rules such as overlaps are left to the editor, so projects that open today still open. fn validate_loaded(mut loaded: Loaded) -> Result { + // Graphic layers written before they carried a uid get a stable identity now, before any + // undo history exists. The field is optional in the schema, so this is not a schema upgrade. + loaded.project.ensure_graphic_layer_uids(); // Out-of-range audio stream data is repaired, not refused: failing the load would lose the // whole project over one bad index. A media item keeps its first `MAX_AUDIO_STREAMS` streams; a // clip pointing past that plays stream 0. @@ -421,6 +424,32 @@ mod tests { assert_eq!(loaded.project.sequence(seq).unwrap().video_tracks[0].items.len(), 3); } + /// Opening a project written before graphic layers had uids assigns stable, unique identities + /// without a schema upgrade. The old file still opens, and its two byte-identical layers (same + /// geometry, different text) can no longer be confused after a reorder. + #[test] + fn opening_a_legacy_project_assigns_unique_graphic_layer_uids() { + use filmcraft_project::graphic::{layer_indices, new_text_layer}; + use filmcraft_project::{ItemKind, SequenceSettings, TrackKind}; + use filmcraft_time::{FrameRate, Tick, TimeRange}; + let mut p = Project::new("Legacy graphics"); + let rate = FrameRate::FPS_25; + let seq = p.new_sequence("Sequence 01", SequenceSettings::default(), 1, 1, None); + let graphic = p.add_item("Graphic", filmcraft_project::Label::Rose, ItemKind::Graphic { width: 1920, height: 1080, rate }, None); + let mut item = p.make_track_item(graphic, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(100)), rate).unwrap(); + item.effects = vec![new_text_layer("one", Default::default(), 100.0), new_text_layer("two", Default::default(), 100.0)]; + assert!(item.effects.iter().all(|e| filmcraft_project::gtemplate::layer_uid(e) == 0)); + p.sequence_mut(seq).unwrap().video_tracks[0].items = vec![item]; + let loaded = decode(&encode(&p, false)).expect("an old project still opens"); + let item = &loaded.project.sequence(seq).unwrap().video_tracks[0].items[0]; + let uids: Vec = layer_indices(&item.effects).into_iter().map(|i| filmcraft_project::gtemplate::layer_uid(&item.effects[i])).collect(); + assert_eq!(uids.len(), 2); + assert!(uids.iter().all(|uid| *uid != 0), "opening assigned uids: {uids:?}"); + assert_ne!(uids[0], uids[1], "identical geometry and different text still get distinct identities"); + let again = decode(&encode(&loaded.project, false)).expect("the upgraded project round-trips"); + assert_eq!(again.project, loaded.project, "assigned uids are stable across saves"); + } + #[test] fn roundtrip_current() { let p = Project::new("Round Trip"); diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index 4344cb52f..2f83fa00f 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -108,7 +108,7 @@ impl VideoEncoder for NvencEncoder { return Err(ExportError::Encode(format!("NVENC: a {}x{} picture for a {}x{} encoder", f.width, f.height, self.w, self.h))); } let (w, h) = (self.w as usize, self.h as usize); - let hint = if self.enc.codec() == Codec::H264 { " (turn Export ▸ Hardware encoding off to use the software encoder)" } else { "" }; + let hint = if self.enc.codec() == Codec::H264 { " (turn Export > Hardware encoding off to use the software encoder)" } else { "" }; let ps = match (self.enc.is_ten_bit(), f.hdr) { // Main 10 takes the encoded R'G'B' floats: 10-bit limited-range BT.2020 NCL (or the signal's matrix) (true, Some(rgb)) => { diff --git a/crates/project/src/graphic.rs b/crates/project/src/graphic.rs index 978917e32..23994171a 100644 --- a/crates/project/src/graphic.rs +++ b/crates/project/src/graphic.rs @@ -35,6 +35,47 @@ pub fn is_layer(e: &EffectInstance) -> bool { is_layer_id(&e.effect) } +/// Give every graphic layer without a uid (or with a uid another layer already uses) a fresh one +/// that is unique among `effects`; non-layer effects are left alone. +/// +/// This is the project-format lazy upgrade: files written before layers had uids (or with a +/// duplicate written by a hostile/buggy producer) get stable identities when they are opened, so +/// reorder/delete/undo and the Program monitor's text editor keep addressing the same layer. +/// The optional `layer.uid` field means the file schema does not change; a save simply writes the +/// assigned uid. Returns whether anything changed. +pub fn ensure_layer_uids(effects: &mut [EffectInstance]) -> bool { + let mut used: std::collections::BTreeSet = + effects.iter().filter(|e| is_layer(e)).filter_map(|e| e.layer.as_ref()).map(|x| x.uid).filter(|uid| *uid != 0).collect(); + let mut seen = std::collections::BTreeSet::new(); + let mut next = 1u64; + let mut changed = false; + for e in effects.iter_mut().filter(|e| is_layer(e)) { + let uid = e.layer.as_ref().map_or(0, |x| x.uid); + if uid != 0 && seen.insert(uid) { + continue; + } + // Zero means "not assigned yet"; a repeated uid must move to a free one so two identical + // layers can never share an identity. + while used.contains(&next) { + let Some(after) = next.checked_add(1) else { + // A hostile file used every uid; leave this layer without one rather than loop. + next = 0; + break; + }; + next = after; + } + if next == 0 { + continue; + } + e.layer.get_or_insert_with(Default::default).uid = next; + used.insert(next); + seen.insert(next); + changed = true; + next = next.saturating_add(1); + } + changed +} + fn p(id: &'static str, label: &'static str, kind: ParamKind, default: ParamValue, animatable: bool, group: &'static str) -> ParamDef { ParamDef { id, label, kind, default, animatable, group: if group.is_empty() { None } else { Some(group) } } } @@ -499,6 +540,43 @@ mod tests { assert_eq!(layer_display_name(&e, 0), "Hello"); } + #[test] + fn assigning_a_new_layer_uid_never_reuses_an_existing_layers_uid() { + let make = |uid| { + let mut layer = new_text_layer("text", Vec2::new(10.0, 20.0), 40.0); + layer.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid, ..Default::default() })); + layer + }; + for initial in [[1, 0, 2, 1], [0, 1, 2, 1], [1, 2, 0, 1]] { + let mut layers: Vec<_> = initial.into_iter().map(make).collect(); + assert!(ensure_layer_uids(&mut layers)); + let uids: Vec<_> = layers.iter().map(crate::gtemplate::layer_uid).collect(); + assert_eq!(uids.iter().copied().collect::>().len(), 4, "{uids:?}"); + assert_eq!(uids[initial.iter().position(|uid| *uid == 1).unwrap()], 1); + assert_eq!(uids[initial.iter().position(|uid| *uid == 2).unwrap()], 2); + assert!(!ensure_layer_uids(&mut layers), "a second pass must preserve identity"); + } + } + + #[test] + fn layer_uids_are_unique_and_repair_legacy_or_duplicate_entries() { + let a = new_text_layer("one", Vec2::new(10.0, 20.0), 40.0); + let mut b = new_text_layer("two", Vec2::new(10.0, 20.0), 40.0); + let mut shape = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance(); + // Legacy file: no uids at all; one layer already carries a duplicate uid. + assert_eq!(crate::gtemplate::layer_uid(&a), 0); + b.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid: 7, ..Default::default() })); + shape.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid: 7, ..Default::default() })); + let mut effects = vec![a, b, shape]; + assert!(ensure_layer_uids(&mut effects)); + let uids: Vec = effects.iter().filter(|e| is_layer(e)).map(crate::gtemplate::layer_uid).collect(); + assert!(uids.iter().all(|uid| *uid != 0)); + let unique: std::collections::BTreeSet = uids.iter().copied().collect(); + assert_eq!(unique.len(), uids.len(), "every layer keeps its own identity: {uids:?}"); + // Idempotent: a second pass changes nothing. + assert!(!ensure_layer_uids(&mut effects)); + } + #[test] fn defaults_centre_and_shadow_direction() { let mut e = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance(); diff --git a/crates/project/src/gtemplate.rs b/crates/project/src/gtemplate.rs index 082cf18b6..644b9b28f 100644 --- a/crates/project/src/gtemplate.rs +++ b/crates/project/src/gtemplate.rs @@ -350,14 +350,8 @@ pub fn layer_uid(e: &EffectInstance) -> u64 { } /// Give every layer without a uid a fresh one (unique among `layers`). -pub fn ensure_uids(layers: &mut [EffectInstance]) { - let mut next = layers.iter().map(layer_uid).max().unwrap_or(0) + 1; - for l in layers.iter_mut() { - if layer_uid(l) == 0 { - l.layer.get_or_insert_with(Default::default).uid = next; - next += 1; - } - } +pub fn ensure_uids(layers: &mut [EffectInstance]) -> bool { + graphic::ensure_layer_uids(layers) } // ------------------------------------------------------------------------------------------------ diff --git a/crates/project/src/keyframe.rs b/crates/project/src/keyframe.rs index 1ddfbc156..bf6754c2c 100644 --- a/crates/project/src/keyframe.rs +++ b/crates/project/src/keyframe.rs @@ -285,6 +285,38 @@ impl Param { self.value_at(t).as_f64().unwrap_or(0.0) } + /// Borrowing text evaluation for read-only observers (the Program monitor's IME producer). + /// + /// Text parameters never interpolate, so the result is the applicable static or keyframed + /// `ParamValue::Text`; an interpolating non-text value yields `None`. Unlike + /// [`Self::value_at`] this does not clone the document-sized string. + pub fn text_ref_at(&self, t: Tick) -> Option<&str> { + fn text(v: &ParamValue) -> Option<&str> { + match v { + ParamValue::Text(s) => Some(s.as_str()), + _ => None, + } + } + let k = &self.keyframes; + if k.is_empty() { + return text(&self.value); + } + if t <= k[0].time { + return text(&k[0].value); + } + let last = &k[k.len() - 1]; + if t >= last.time { + return text(&last.value); + } + let i = match k.binary_search_by_key(&t, |kf| kf.time) { + Ok(i) => return text(&k[i].value), + Err(i) => i - 1, + }; + // Hold semantics match `value_at`: non-interpolating values (including text) stay until + // the next keyframe; an interpolating segment never evaluates to text. + if k[i].interp == Interpolation::Hold || !k[i].value.interpolates() { text(&k[i].value) } else { None } + } + /// Allocation-free [`Param::f64_at`] for scalar parameters (Float / Choice / Bool), for /// per-sample evaluation in the audio mixer. Non-scalar values evaluate to 0. pub fn scalar_at(&self, t: Tick) -> f64 { @@ -405,6 +437,19 @@ mod tests { } } + #[test] + fn text_ref_at_borrows_without_cloning_and_holds_non_interpolating_values() { + let p = Param::new(ParamValue::Text("abc".into())); + assert_eq!(p.text_ref_at(Tick(0)), Some("abc")); + let f = Param::new(ParamValue::Float(1.0)); + assert_eq!(f.text_ref_at(Tick(0)), None); + let mut p = Param::new(ParamValue::Text("a".into())); + p.toggle_animation(Tick(0)); + p.set_at(Tick(100), ParamValue::Text("b".into())); + assert_eq!(p.text_ref_at(Tick(50)), Some("a"), "text does not interpolate"); + assert_eq!(p.text_ref_at(Tick(100)), Some("b")); + } + #[test] fn toggle_off_keeps_value() { let mut p = Param::new(ParamValue::Float(1.0)); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 5b21cdaec..f1f6a4916 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1747,6 +1747,32 @@ fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source: } impl Project { + /// Lazy upgrade for graphic layers written before they had stable uids: assign a unique + /// [`crate::graphic_design::LayerExtra::uid`] to every uid-less or duplicated graphic layer + /// in sequences and source graphics. + /// + /// Called when a project file is opened, before undo history starts, so every later undo + /// snapshot carries the same identity. The field is already optional in the file schema, so + /// this changes no schema version and older builds still read the file. Returns whether any + /// layer changed. + pub fn ensure_graphic_layer_uids(&mut self) -> bool { + let mut changed = false; + for item in self.items.values_mut() { + if let ItemKind::Sequence(seq) = &mut item.kind { + let seq = std::sync::Arc::make_mut(seq); + for track in seq.video_tracks.iter_mut().chain(seq.audio_tracks.iter_mut()) { + for it in &mut track.items { + changed |= crate::graphic::ensure_layer_uids(&mut it.effects); + } + } + } + } + for graphic in self.source_graphics.values_mut() { + changed |= crate::graphic::ensure_layer_uids(&mut graphic.layers); + } + changed + } + /// Available picture and sound, resolving a subclip to its parent media. Missing parents, /// cycles and overly deep subclip chains carry neither stream. pub fn source_streams(&self, item: ItemId) -> (bool, bool) { diff --git a/crates/render/src/offline.rs b/crates/render/src/offline.rs index 248670db2..40abec3d0 100644 --- a/crates/render/src/offline.rs +++ b/crates/render/src/offline.rs @@ -87,7 +87,7 @@ pub fn slate(w: usize, h: usize, file_name: &str, reason: OfflineReason) -> Imag } let hint = match reason { OfflineReason::MadeOffline => "Link Media… brings it back", - _ => "File ▸ Link Media… reconnects it", + _ => "File > Link Media… reconnects it", }; text(&mut img, hint, "Regular", Vec2::new(cx as f64, (ty0 + tri_h + s * 0.205) as f64), s * 0.028, [0.85, 0.72, 0.72, 1.0], pw * 0.94); img diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f1d7b8991..aaaccf5e7 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -27,7 +27,7 @@ filmcraft-export = { workspace = true } filmcraft-gpu = { workspace = true } filmcraft-scopes = { workspace = true } egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -52,6 +52,9 @@ filmcraft-codecs = { workspace = true } # itself stays OS-independent and web-portable) filmcraft-platform = { workspace = true } filmcraft-interchange = { workspace = true } +# A dependency's dev-dependencies do not propagate through egui_kittest: name the fonts this +# test root needs explicitly so glyph and accessibility assertions run against the bundled faces. +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } tempfile = "3" diff --git a/crates/ui-egui/src/automation.rs b/crates/ui-egui/src/automation.rs index 850fcb8dd..eb18aed37 100644 --- a/crates/ui-egui/src/automation.rs +++ b/crates/ui-egui/src/automation.rs @@ -42,6 +42,9 @@ impl Registry { } /// Observe the widget without changing its accessibility role or input behavior. pub fn add_response(&mut self, id: &str, response: &egui::Response, label: &str) { + // The automation label is also the name a screen reader and kittest use. Keep an + // existing visible label/placeholder; this only fills a missing name. + crate::widgets::accessible_name(response.clone(), label); self.add(id, response.rect, label); if let Some(element) = self.elements.last_mut() { element.widget_id = Some(format!("{:016x}", response.id.value())); @@ -53,11 +56,74 @@ impl Registry { pub fn begin_frame(&mut self) { self.previous = std::mem::take(&mut self.elements); } - pub fn find(&self, id: &str) -> Option<&Element> { - self.previous.iter().chain(self.elements.iter()).find(|e| e.id == id) + /// The entries of the last accepted UI pass, or the pass before it while the current pass is + /// still being laid out (pass 0 clears `elements`; `drain_ui_requests` runs after the layout). + pub fn all(&self) -> &[Element] { + if self.elements.is_empty() { &self.previous } else { &self.elements } } - /// Elements whose id starts with a prefix. + /// A widget drawn in the current accepted pass. Unlike [`Self::find`] this never falls back to + /// the previous pass, for callers that need fresh geometry (e.g. a screenshot crop). + pub fn find_current(&self, id: &str) -> Option<&Element> { + self.elements.iter().find(|e| e.id == id) + } + /// Look up a widget, preferring the layout just drawn. The previous pass is only used before + /// this pass has registered anything (input handling runs before the panels draw). + pub fn find(&self, id: &str) -> Option<&Element> { + self.all().iter().find(|e| e.id == id) + } + /// Elements whose id starts with a prefix, from the current accepted pass when there is one. pub fn query(&self, prefix: &str) -> Vec<&Element> { - self.previous.iter().filter(|e| e.id.starts_with(prefix)).collect() + self.all().iter().filter(|e| e.id.starts_with(prefix)).collect() + } +} + +#[cfg(test)] +mod tests { + use egui::{Rect, pos2, vec2}; + + use super::Registry; + + fn rect(x: f32) -> Rect { + Rect::from_min_size(pos2(x, 0.0), vec2(10.0, 10.0)) + } + + #[test] + fn lookups_prefer_the_current_accepted_pass() { + let mut registry = Registry::default(); + registry.add("panel.project", rect(1.0), "old"); + registry.begin_frame(); + registry.add("panel.project", rect(200.0), "new"); + assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(200.0)); + assert_eq!(registry.query("panel.").iter().map(|e| e.rect[0]).collect::>(), vec![200.0]); + assert_eq!(registry.find_current("panel.project").map(|e| e.rect[0]), Some(200.0)); + } + + #[test] + fn removed_elements_do_not_resolve_from_a_populated_current_pass() { + let mut registry = Registry::default(); + registry.add("dialog.closed", rect(1.0), "old control"); + registry.begin_frame(); + assert!(registry.find("dialog.closed").is_some(), "before drawing, the previous pass remains usable"); + registry.add("panel.current", rect(200.0), "current control"); + assert!(registry.find("dialog.closed").is_none(), "a closed control cannot target stale coordinates"); + assert_eq!(registry.find("panel.current").map(|e| e.rect[0]), Some(200.0)); + assert!(registry.query("dialog.").is_empty()); + } + + #[test] + fn the_previous_pass_is_only_a_fallback_before_this_pass_draws() { + let mut registry = Registry::default(); + registry.add("panel.project", rect(1.0), "old"); + registry.begin_frame(); + assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(1.0)); + assert_eq!(registry.query("panel.").len(), 1); + assert!(registry.find_current("panel.project").is_none()); + + // A discarded pass is cleared and redrawn, so exactly one entry per widget remains. + registry.add("panel.project", rect(2.0), "discarded"); + registry.elements.clear(); + registry.add("panel.project", rect(3.0), "accepted"); + assert_eq!(registry.elements.len(), 1); + assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(3.0)); } } diff --git a/crates/ui-egui/src/brand.rs b/crates/ui-egui/src/brand.rs index 4c41d080e..3bab46e2d 100644 --- a/crates/ui-egui/src/brand.rs +++ b/crates/ui-egui/src/brand.rs @@ -7,7 +7,7 @@ const LOGO_DARK_INK: &[u8] = include_bytes!("../../../docs/brand/artcraft-logo.p /// The wordmark for a dark (`dark = true`) or light UI: light ink on dark themes. Its aspect /// ratio is width / height of the returned texture. pub fn wordmark(ctx: &egui::Context, dark: bool) -> Option { - let id = egui::Id::new(("artcraft-wordmark", dark)); + let id = egui::Id::unique(("artcraft-wordmark", dark)); if let Some(t) = ctx.data(|d| d.get_temp::(id)) { return Some(t); } diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs new file mode 100644 index 000000000..94234f500 --- /dev/null +++ b/crates/ui-egui/src/capture.rs @@ -0,0 +1,795 @@ +//! Bounded, one-shot window screenshots for `ui.screenshot`. +//! +//! A request record owns the output path, crop, reply, deadline, document/focus identity and the +//! surface geometry it was admitted against. `craft_ui::capture::CaptureSlot` owns the strong +//! `egui::Context`; its callback holds only a weak reference and never writes a file. Files are +//! encoded and written only from the logic drain, after the deadline and every identity/geometry +//! check has passed. +//! +//! Web qualification note: the pinned eframe web runner does not implement +//! `ViewportCommand::RequestPaintWhileHidden` (the native runner does). A hidden web capture that +//! is still settling therefore reaches its timeout. That fork gap is recorded as a prerequisite; +//! this module does not claim a hidden web capture works. + +use std::sync::mpsc::Sender; + +use craft_ui::capture::CaptureSlot; +use egui::ViewportCommand; +use serde_json::{Value, json}; +use web_time::{Duration, Instant}; + +use crate::FilmcraftApp; + +/// At most this many queued, in-flight or completed-but-not-yet-drained requests. +pub(crate) const MAX_REQUESTS: usize = 4; +/// Per-image bound from the suite contract. +pub(crate) const MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024; +/// Aggregate bound, counting completed images that have not been drained yet. +pub(crate) const MAX_TOTAL_BYTES: usize = 256 * 1024 * 1024; +/// The raw RGBA image is not the only copy alive while a capture is drained: the callback's +/// `ColorImage`, the cropped RGBA copy and the encoded PNG exist together, and on the web the PNG +/// is base64-encoded into the reply while all of those are still retained. Reserve this multiple +/// of the raw pixel bytes so admission actually bounds peak memory, not just the first buffer. +pub(crate) const CAPTURE_BUFFER_FACTOR: usize = 5; + +/// Bytes reserved for one `width`×`height` RGBA capture, including the transient encode copies. +pub(crate) fn admitted_bytes(expected: [usize; 2]) -> Option { + expected[0].checked_mul(expected[1])?.checked_mul(std::mem::size_of::())?.checked_mul(CAPTURE_BUFFER_FACTOR) +} + +const READY_DELAY: Duration = Duration::from_millis(250); +/// Accepted first UI passes (never discarded passes or hidden logic ticks). +const SETTLE_PASSES: u32 = 3; +const DEADLINE: Duration = Duration::from_secs(10); +/// Give up waiting for the exact monitor frame/timeline rest and capture what is shown (5 s). +const SETTLE_MAX: Duration = Duration::from_secs(5); +/// Accepted passes to wait for a named crop target that is not drawn yet (e.g. a panel shown by +/// the preceding control request). After this the request fails instead of capturing the window. +const CROP_SETTLE_PASSES: u32 = 3; +const REPAINT_INTERVAL: Duration = Duration::from_millis(16); + +/// The host surface as reported by one `RawInput`. Raw input is fresh on hidden logic-only ticks; +/// `Context::input`/`viewport_rect` describe the last UI pass and are stale there. +/// +/// Equality covers only what changes the captured pixels (viewport size, pixels per point and the +/// GPU texture limit). Minimized, occluded and native window focus are presentation flags: a +/// hidden or unfocused window still captures the forced paint that was already issued, so they +/// must neither invalidate an in-flight screenshot nor bump `capture_epoch`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct SurfaceGeometry { + /// Logical viewport size in points. A minimized window keeps its last known size so a forced + /// hidden paint can still be requested; `None` only before any size was ever reported. + viewport: Option, + /// Native pixels per point times egui's interface zoom. + pixels_per_point: f32, + max_texture_side: usize, + minimized: bool, + occluded: bool, +} + +impl PartialEq for SurfaceGeometry { + fn eq(&self, other: &Self) -> bool { + self.viewport == other.viewport && self.pixels_per_point == other.pixels_per_point && self.max_texture_side == other.max_texture_side + } +} + +impl SurfaceGeometry { + fn from_raw(raw: &egui::RawInput, ctx: &egui::Context, previous: Option) -> Option { + let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default(); + let rect = info.inner_rect.or(raw.screen_rect); + let size = rect.map(|rect| rect.size()).filter(|size| size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0); + let viewport = size.or(previous.and_then(|previous| previous.viewport)); + let zoom = ctx.zoom_factor(); + if !zoom.is_finite() || zoom <= 0.0 { + return None; + } + let pixels_per_point = info + .native_pixels_per_point + .map(|native| native * zoom) + .filter(|pixels| pixels.is_finite() && *pixels > 0.0) + .or_else(|| previous.map(|previous| previous.pixels_per_point)) + .unwrap_or_else(|| ctx.pixels_per_point()); + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return None; + } + let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|input| input.max_texture_side)); + let minimized = info.minimized.unwrap_or_else(|| previous.is_some_and(|previous| previous.minimized)); + let occluded = info.occluded.unwrap_or_else(|| previous.is_some_and(|previous| previous.occluded)); + Some(Self { viewport, pixels_per_point, max_texture_side, minimized, occluded }) + } + + fn expected_size(self) -> Option<[usize; 2]> { + let viewport = self.viewport?; + if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 { + return None; + } + let axis = |points: f32| -> Option { + let pixels = (f64::from(points) * f64::from(self.pixels_per_point)).round(); + if !pixels.is_finite() || pixels <= 0.0 || pixels > usize::MAX as f64 { + return None; + } + Some(pixels as usize) + }; + let [width, height] = [axis(viewport.x)?, axis(viewport.y)?]; + // Match the wgpu backend's `surface_fit`: when either axis exceeds the device limit, both + // axes shrink by the same factor and the longer side becomes exactly the limit. Clamping + // each axis independently predicted a wider/taller image than the surface actually has. + let max = if self.max_texture_side == 0 { usize::MAX } else { self.max_texture_side }; + if width <= max && height <= max { + return Some([width, height]); + } + let longest = width.max(height); + let scale = max as f64 / longest as f64; + let fit = |side: usize| -> usize { if side == longest { max } else { ((side as f64 * scale).floor() as usize).clamp(1, max) } }; + Some([fit(width), fit(height)]) + } + + fn expected_bytes(self) -> Option { + let [width, height] = self.expected_size()?; + width.checked_mul(height)?.checked_mul(std::mem::size_of::()) + } +} + +/// Which sequence and project revision the request belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct DocumentId { + sequence: Option, + revision: u64, +} + +/// Viewport geometry plus the immutable document and widget-focus identity a capture is bound to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CaptureGeometry { + surface: SurfaceGeometry, + document: DocumentId, + focus: Option, +} + +impl CaptureGeometry { + fn expected_size(self) -> Option<[usize; 2]> { + self.surface.expected_size() + } + + fn expected_bytes(self) -> Option { + self.surface.expected_bytes() + } +} + +/// A screenshot crop named by a widget id. Resolved at issue time from the pass that is actually +/// captured, never from the control callback that admitted the request (that pass may be two +/// layouts old or may not have drawn the panel at all). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CropTarget { + Element(String), +} + +impl CropTarget { + fn resolve(&self, registry: &crate::automation::Registry) -> Result<[f32; 4], String> { + let Self::Element(id) = self; + let element = registry + .find_current(id) + .ok_or_else(|| format!("screenshot failed: `{id}` was not drawn in the captured pass (the panel may be closed or hidden; see ui.elements)"))?; + let [x, y, width, height] = element.rect; + if ![x, y, width, height].iter().all(|value| value.is_finite()) || width <= 0.0 || height <= 0.0 { + return Err(format!("screenshot failed: `{id}` has an empty or non-finite rectangle")); + } + Ok(element.rect) + } +} + +struct Shot { + token: u64, + path: Option, + crop_target: Option, + /// The crop resolved from the accepted pass that was captured (set at issue time). + crop: Option<[f32; 4]>, + /// Accepted passes the named crop target was missing, while a just-shown panel lays out. + crop_misses: u32, + reply: Sender, + deadline: Instant, + ready_at: Instant, + frames: u32, + issued: bool, + document: DocumentId, + focus: Option, + geometry: CaptureGeometry, + geometry_epoch: u64, + expected: [usize; 2], + reserved: usize, + slot: Option, + callback: Option, +} + +/// All admitted screenshots. `requests` includes queued, issued and completed-but-undrained +/// captures; nothing is removed before it has been replied to or timed out. +#[derive(Default)] +pub(crate) struct Shots { + token: u64, + requests: Vec, +} + +impl Shots { + pub(crate) fn is_empty(&self) -> bool { + self.requests.is_empty() + } + + fn has_unissued(&self) -> bool { + self.requests.iter().any(|shot| !shot.issued) + } + + fn reserved(&self) -> usize { + self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.reserved)) + } +} + +fn next_token(shots: &mut Shots) -> u64 { + let mut token = shots.token.wrapping_add(1); + if token == 0 { + token = 1; + } + shots.token = token; + token +} + +fn error_reply(token: u64, message: impl std::fmt::Display) -> Value { + json!({"ok": false, "error": message.to_string(), "request": token}) +} + +fn reject(reply: Sender, token: u64, message: impl std::fmt::Display) { + let _ = reply.send(error_reply(token, message)); +} + +fn active_document(app: &FilmcraftApp) -> DocumentId { + DocumentId { sequence: app.session.state.active_sequence.map(|id| id.0), revision: app.session.revision } +} + +fn current_focus(ctx: &egui::Context) -> Option { + ctx.memory(|memory| memory.focused()) +} + +/// Refresh the fresh raw-input surface/identity snapshot. Called from `raw_input_hook`, so it runs +/// on both UI and hidden logic-only callbacks and never reads the stale `InputState` rectangle. +pub(crate) fn note_raw_geometry(app: &mut FilmcraftApp, ctx: &egui::Context, raw: &egui::RawInput) { + let previous = app.capture_geometry; + let Some(surface) = SurfaceGeometry::from_raw(raw, ctx, previous.map(|geometry| geometry.surface)) else { + app.capture_geometry = None; + app.capture_epoch = app.capture_epoch.wrapping_add(1); + return; + }; + let geometry = CaptureGeometry { surface, document: active_document(app), focus: current_focus(ctx) }; + let changed = app.capture_geometry != Some(geometry); + // Keep the presentation flags fresh even when they did not change the capture geometry. + app.capture_geometry = Some(geometry); + if changed { + app.capture_epoch = app.capture_epoch.wrapping_add(1); + } +} + +/// Pick up document/revision and keyboard-focus changes made since the raw-input snapshot (a +/// control request, or the UI pass that admitted a `ui.screenshot` at its end). +fn refresh_identity(app: &mut FilmcraftApp, ctx: &egui::Context) { + let Some(geometry) = app.capture_geometry else { return }; + let refreshed = CaptureGeometry { document: active_document(app), focus: current_focus(ctx), ..geometry }; + let changed = refreshed != geometry; + app.capture_geometry = Some(refreshed); + if changed { + app.capture_epoch = app.capture_epoch.wrapping_add(1); + } +} + +/// Admit one `ui.screenshot` request without issuing it. Counts queued, in-flight and +/// completed-but-undrained requests and reserves the raw image plus its encode/encode-reply buffers +/// before capture. +pub(crate) fn admit(app: &mut FilmcraftApp, ctx: &egui::Context, path: Option, crop: Option, reply: Sender) { + refresh_identity(app, ctx); + let token = next_token(&mut app.shots); + let Some(geometry) = app.capture_geometry else { + return reject(reply, token, "screenshot failed: the window geometry is not known yet"); + }; + let Some(expected) = geometry.expected_size() else { + return reject(reply, token, "screenshot failed: the window has no capturable size yet"); + }; + let Some(raw) = geometry.expected_bytes() else { + return reject(reply, token, "screenshot failed: the window size overflows the screenshot budget"); + }; + if raw > MAX_IMAGE_BYTES { + return reject( + reply, + token, + format!( + "screenshot budget exceeded: {raw} raw bytes for one {}×{} image, over the {MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + ), + ); + } + let Some(reserved) = admitted_bytes(expected) else { + return reject(reply, token, "screenshot failed: the window size overflows the screenshot budget"); + }; + if reserved > MAX_TOTAL_BYTES { + return reject( + reply, + token, + format!( + "screenshot budget exceeded: one {}×{} capture needs up to {reserved} bytes including the crop/encode reply buffers, over the {MAX_TOTAL_BYTES}-byte limit", + expected[0], expected[1] + ), + ); + } + if app.shots.requests.len() >= MAX_REQUESTS { + return reject(reply, token, format!("screenshot budget exceeded: {MAX_REQUESTS} requests are already outstanding")); + } + let total = app.shots.reserved().saturating_add(reserved); + if total > MAX_TOTAL_BYTES { + return reject( + reply, + token, + format!("screenshot budget exceeded: {total} bytes reserved by outstanding requests, over the {MAX_TOTAL_BYTES}-byte limit"), + ); + } + let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), MAX_IMAGE_BYTES); + let now = Instant::now(); + app.shots.requests.push(Shot { + token, + path, + crop_target: crop, + crop: None, + crop_misses: 0, + reply, + deadline: now + DEADLINE, + ready_at: now + READY_DELAY, + frames: 0, + issued: false, + document: geometry.document, + focus: geometry.focus, + geometry, + geometry_epoch: app.capture_epoch, + expected, + reserved, + slot: Some(slot), + callback: Some(callback), + }); + ctx.request_repaint(); +} + +fn identity_reason(app: &FilmcraftApp, ctx: &egui::Context, shot: &Shot) -> Option<&'static str> { + if active_document(app) != shot.document { + Some("the document or project revision changed while the capture was queued") + } else if current_focus(ctx) != shot.focus { + Some("the keyboard focus changed while the capture was queued") + } else { + None + } +} + +fn geometry_reason(app: &FilmcraftApp, shot: &Shot) -> Option<&'static str> { + if app.capture_geometry != Some(shot.geometry) || app.capture_epoch != shot.geometry_epoch { + Some("the window geometry changed while the capture was queued") + } else { + None + } +} + +fn retry_geometry(app: &mut FilmcraftApp, ctx: &egui::Context, index: usize) -> Result<(), String> { + let now = Instant::now(); + let current = app.capture_geometry.ok_or_else(|| "the window geometry is no longer known".to_string())?; + let expected = current.expected_size().ok_or_else(|| "the window has no capturable size".to_string())?; + let raw = current.expected_bytes().ok_or_else(|| "the window size overflows the screenshot budget".to_string())?; + if raw > MAX_IMAGE_BYTES { + return Err(format!("the window now needs {raw} raw bytes, over the {MAX_IMAGE_BYTES}-byte per-image limit")); + } + let reserved = admitted_bytes(expected).ok_or_else(|| "the window size overflows the screenshot budget".to_string())?; + let others = app.shots.requests.iter().enumerate().filter(|(i, _)| *i != index).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.reserved)); + if others.saturating_add(reserved) > MAX_TOTAL_BYTES { + return Err(format!("the window now needs {reserved} bytes, over the {MAX_TOTAL_BYTES}-byte aggregate limit")); + } + let Some(current_geometry) = app.capture_geometry else { return Err("the window geometry changed repeatedly".into()) }; + let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), MAX_IMAGE_BYTES); + let shot = app.shots.requests.get_mut(index).ok_or_else(|| "the screenshot request disappeared".to_string())?; + if let Some(old) = shot.slot.take() { + old.cancel(); + } + shot.callback = Some(callback); + shot.slot = Some(slot); + shot.geometry = current_geometry; + shot.geometry_epoch = app.capture_epoch; + shot.expected = expected; + shot.reserved = reserved; + shot.frames = 0; + shot.crop_misses = 0; + shot.ready_at = now + READY_DELAY; + shot.issued = false; + Ok(()) +} + +/// Issue settled requests. Called from every real `App::ui` pass (never from a hidden +/// `run_logic` tick), including the accepted final pass of a multipass layout. `monitor_inexact` +/// describes the pass that just drew the monitor, so consume it here: it must not stay sticky and +/// delay every later screenshot until the 5 s settle maximum. +pub(crate) fn issue(app: &mut FilmcraftApp, ctx: &egui::Context) { + let monitor_inexact = std::mem::take(&mut app.monitor_inexact); + if ctx.will_discard() { + return; + } + let now = Instant::now(); + let mut failures: Vec<(usize, String)> = Vec::new(); + let mut retries: Vec = Vec::new(); + for i in 0..app.shots.requests.len() { + if app.shots.requests[i].issued { + continue; + } + app.shots.requests[i].frames = app.shots.requests[i].frames.saturating_add(1); + let shot = &app.shots.requests[i]; + if now < shot.ready_at || shot.frames < SETTLE_PASSES { + continue; + } + // After a seek the monitor can show the nearest cached picture until the exact frame is + // decoded, and the Timeline can still be animating. Capture once those have settled, or + // after the same bounded maximum the old `Ui` settle path used. + if (monitor_inexact || app.timeline_still < 2) && now < shot.ready_at + SETTLE_MAX { + continue; + } + if let Some(reason) = identity_reason(app, ctx, shot) { + failures.push((i, reason.to_string())); + continue; + } + if geometry_reason(app, shot).is_some() { + retries.push(i); + continue; + } + let crop_result = match app.shots.requests[i].crop_target.as_ref() { + Some(target) => target.resolve(&app.auto).map(Some), + None => Ok(None), + }; + let resolved_crop = match crop_result { + Ok(crop) => crop, + Err(message) => { + let shot = &mut app.shots.requests[i]; + if shot.crop_misses < CROP_SETTLE_PASSES { + // The panel may be shown by a request processed just before this admission; + // let the next accepted pass draw it before giving up. + shot.crop_misses = shot.crop_misses.saturating_add(1); + ctx.request_repaint(); + continue; + } + failures.push((i, message)); + continue; + } + }; + let shot = &mut app.shots.requests[i]; + let Some(callback) = shot.callback.take() else { + failures.push((i, "screenshot failed: the capture callback was already consumed".into())); + continue; + }; + shot.crop = resolved_crop; + ctx.send_viewport_cmd_to(ctx.viewport_id(), ViewportCommand::Screenshot(callback)); + shot.issued = true; + } + for i in retries.into_iter().rev() { + if let Err(message) = retry_geometry(app, ctx, i) { + failures.push((i, message)); + } + } + for (i, message) in failures.into_iter().rev() { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, message); + } + if !app.shots.is_empty() { + ctx.request_repaint_after(REPAINT_INTERVAL); + } +} + +/// Drain completed slots and expire timed-out requests. Call from every logic callback (including +/// hidden ones). The deadline is checked before taking or writing an image. +pub(crate) fn drain(app: &mut FilmcraftApp, ctx: &egui::Context) { + let mut i = 0; + while i < app.shots.requests.len() { + let now = Instant::now(); + if now >= app.shots.requests[i].deadline { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, "no frame was presented before the screenshot timeout"); + continue; + } + if let Some(reason) = identity_reason(app, ctx, &app.shots.requests[i]) { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, format!("screenshot failed: {reason}")); + continue; + } + if geometry_reason(app, &app.shots.requests[i]).is_some() { + if !app.shots.requests[i].issued { + // The next accepted UI pass re-creates the slot against the fresh geometry. + i += 1; + continue; + } + // Geometry changed while the callback was already issued: reject stale pixels rather + // than pairing them with the new surface. + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, "screenshot failed: the window geometry changed while the capture was in flight"); + continue; + } + if !app.shots.requests[i].issued { + i += 1; + continue; + } + let Some(result) = app.shots.requests[i].slot.as_ref().and_then(CaptureSlot::take) else { + i += 1; + continue; + }; + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + match result { + Ok(image) => { + let reply = if image.size != shot.expected { + error_reply( + shot.token, + format!( + "screenshot is {}×{} pixels, expected {}×{}: the window changed size while it was captured", + image.size[0], image.size[1], shot.expected[0], shot.expected[1] + ), + ) + } else { + let mut reply = crate::control::save_screenshot_with_ppp(&image, shot.path.as_deref(), shot.crop, shot.geometry.surface.pixels_per_point); + if let Some(object) = reply.as_object_mut() { + object.insert("request".into(), json!(shot.token)); + } + reply + }; + let _ = shot.reply.send(reply); + } + Err(error) => { + let _ = shot.reply.send(error_reply(shot.token, error)); + } + } + } + if !app.shots.is_empty() { + ctx.request_repaint_after(REPAINT_INTERVAL); + } +} + +/// Cancel every queued/in-flight capture (app exit). Late callbacks see a dropped slot and write +/// nothing; clients receive one error reply instead of hanging past the transport timeout. +pub(crate) fn cancel_all(app: &mut FilmcraftApp) { + for shot in app.shots.requests.drain(..) { + if let Some(slot) = shot.slot { + slot.cancel(); + } + let _ = shot.reply.send(error_reply(shot.token, "screenshot cancelled: the app is closing")); + } +} + +/// Ask for a painted frame while a request is still queued, and on hidden logic ticks when a +/// capture is waiting for settlement. The screenshot command itself forces one paint once issued. +pub(crate) fn request_hidden_paint(app: &FilmcraftApp, ctx: &egui::Context) { + if app.shots.has_unissued() { + ctx.send_viewport_cmd(ViewportCommand::RequestPaintWhileHidden); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::mpsc::{Receiver, channel}; + + use egui::{Color32, ColorImage, Rect, ViewportId, ViewportInfo, pos2, vec2}; + + use super::*; + use crate::automation::Registry; + use filmcraft_engine::Session; + + fn app() -> FilmcraftApp { + FilmcraftApp::new(Session::default()) + } + + fn geometry(app: &FilmcraftApp) -> CaptureGeometry { + CaptureGeometry { + surface: SurfaceGeometry { + viewport: Some(egui::vec2(800.0, 600.0)), + pixels_per_point: 1.0, + max_texture_side: 8192, + minimized: false, + occluded: false, + }, + document: active_document(app), + focus: None, + } + } + + fn raw() -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() } + } + + fn admit_one(app: &mut FilmcraftApp, ctx: &egui::Context) -> (Receiver, u64) { + app.capture_geometry = Some(geometry(app)); + let (tx, rx) = channel(); + admit(app, ctx, None, None, tx); + let token = app.shots.requests.first().map(|shot| shot.token).unwrap_or_default(); + (rx, token) + } + + #[test] + fn raw_geometry_uses_the_fresh_surface_not_the_previous_pass() { + let ctx = egui::Context::default(); + let mut app = app(); + let mut raw = raw(); + let _ = raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(2.0), ..Default::default() }); + note_raw_geometry(&mut app, &ctx, &raw); + let geometry = app.capture_geometry.expect("geometry from raw input"); + assert_eq!(geometry.surface.expected_size(), Some([1600, 1200])); + assert!(app.capture_epoch > 0); + } + + #[test] + fn oversized_windows_predict_the_backends_uniformly_scaled_surface() { + let app = app(); + let mut surface = geometry(&app).surface; + surface.viewport = Some(vec2(16_384.0, 4_000.0)); + surface.max_texture_side = 8192; + assert_eq!(surface.expected_size(), Some([8192, 2000]), "both axes scale by the same factor, like egui-wgpu's surface_fit"); + surface.viewport = Some(vec2(4_000.0, 16_384.0)); + assert_eq!(surface.expected_size(), Some([2000, 8192])); + surface.viewport = Some(vec2(1920.0, 1080.0)); + assert_eq!(surface.expected_size(), Some([1920, 1080]), "within the limit nothing shrinks"); + } + + #[test] + fn presentation_flags_do_not_invalidate_an_issued_capture() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, token) = admit_one(&mut app, &ctx); + let callback = app.shots.requests[0].callback.take().expect("a callback"); + app.shots.requests[0].issued = true; + // The window is minimized or another application took focus while the callback was in + // flight. Those flags do not change captured pixels and must not reject the image. + let epoch = app.capture_epoch; + let mut raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), + max_texture_side: Some(8192), + focused: false, + ..Default::default() + }; + let viewport = raw.viewports.entry(ViewportId::ROOT).or_default(); + viewport.native_pixels_per_point = Some(1.0); + viewport.minimized = Some(true); + viewport.occluded = Some(true); + note_raw_geometry(&mut app, &ctx, &raw); + assert_eq!(app.capture_epoch, epoch, "presentation-only raw input retains the issued capture"); + callback.complete(Arc::new(ColorImage::filled([800, 600], Color32::RED))); + drain(&mut app, &ctx); + let reply = rx.try_recv().expect("the issued capture completes instead of being rejected"); + assert_eq!(reply["request"], token); + assert_eq!(reply["ok"], true, "{reply}"); + } + + #[test] + fn admission_counts_outstanding_and_completed_but_undrained_requests() { + let ctx = egui::Context::default(); + let mut app = app(); + let _receivers: Vec<_> = (0..MAX_REQUESTS).map(|_| admit_one(&mut app, &ctx)).collect(); + assert_eq!(app.shots.requests.len(), MAX_REQUESTS); + let image = Arc::new(ColorImage::filled([800, 600], Color32::RED)); + app.shots.requests[0].callback.take().expect("a callback").complete(image); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, None, tx); + assert_eq!(app.shots.requests.len(), MAX_REQUESTS, "the fifth request is rejected, not admitted"); + let reply = rx.recv().expect("a rejection reply"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("outstanding"), "{reply}"); + } + + #[test] + fn a_late_completion_after_timeout_does_not_write_or_reply_twice() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, token) = admit_one(&mut app, &ctx); + let callback = app.shots.requests[0].callback.take().expect("a callback"); + app.shots.requests[0].issued = true; + app.shots.requests[0].deadline = Instant::now() - Duration::from_millis(1); + drain(&mut app, &ctx); + let reply = rx.recv().expect("timeout reply"); + assert_eq!(reply["request"], token); + assert_eq!(reply["ok"], false); + callback.complete(Arc::new(ColorImage::filled([800, 600], Color32::RED))); + assert!(rx.try_recv().is_err(), "a late image must not produce another reply"); + } + + #[test] + fn near_limit_images_are_rejected_for_encode_buffer_memory() { + let ctx = egui::Context::default(); + let mut app = app(); + // 8000×4000 RGBA is exactly 128,000,000 raw bytes, under the per-image bound, but the + // crop/PNG/base64 copies make its real peak several times larger than the aggregate bound. + app.capture_geometry = Some(CaptureGeometry { + surface: SurfaceGeometry { + viewport: Some(vec2(8000.0, 4000.0)), + pixels_per_point: 1.0, + max_texture_side: 16384, + minimized: false, + occluded: false, + }, + document: active_document(&app), + focus: None, + }); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, None, tx); + assert!(app.shots.requests.is_empty(), "the image is rejected before a slot or buffer is created"); + let reply = rx.recv().expect("a rejection reply"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("budget"), "{reply}"); + assert!(admitted_bytes([8000, 4000]).is_some_and(|bytes| bytes > MAX_TOTAL_BYTES)); + } + + #[test] + fn a_crop_is_resolved_from_the_current_pass_not_the_previous_one() { + let mut registry = Registry::default(); + let old = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 50.0)); + let fresh = Rect::from_min_size(pos2(300.0, 200.0), vec2(400.0, 250.0)); + registry.add("panel.project", old, "Project"); + registry.begin_frame(); // the previous pass keeps the old rect; this pass has not drawn yet + let target = CropTarget::Element("panel.project".into()); + assert!(target.resolve(®istry).is_err(), "a stale rect must not be used"); + registry.add("panel.project", fresh, "Project"); + assert_eq!(target.resolve(®istry).expect("fresh rect"), [300.0, 200.0, 400.0, 250.0]); + assert!(CropTarget::Element("panel.missing".into()).resolve(®istry).is_err()); + } + + #[test] + fn issue_runs_on_the_accepted_final_pass_of_a_multipass_layout() { + let ctx = egui::Context::default(); + let mut app = app(); + let (_rx, _token) = admit_one(&mut app, &ctx); + app.shots.requests[0].ready_at = Instant::now() - Duration::from_millis(1); + app.shots.requests[0].frames = SETTLE_PASSES; + app.timeline_still = 2; + let mut passes = 0; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + passes += 1; + if ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("capture pass test"); + } + issue(&mut app, ui.ctx()); + }); + output.textures_delta.clear(); + assert_eq!(passes, 2, "the first pass was discarded and the second accepted"); + assert!(app.shots.requests[0].issued, "the accepted pass must issue the capture"); + } + + #[test] + fn monitor_inexact_is_consumed_by_each_issue_attempt() { + let ctx = egui::Context::default(); + let mut app = app(); + app.monitor_inexact = true; + issue(&mut app, &ctx); + assert!(!app.monitor_inexact, "a sticky flag would delay every later screenshot by up to five seconds"); + } + + #[test] + fn a_geometry_change_retries_an_unissued_capture_with_new_budget() { + let ctx = egui::Context::default(); + let mut app = app(); + let (_rx, _) = admit_one(&mut app, &ctx); + app.shots.requests[0].ready_at = Instant::now() - Duration::from_millis(1); + app.shots.requests[0].frames = SETTLE_PASSES; + app.timeline_still = 2; + let mut changed = geometry(&app).surface; + changed.viewport = Some(egui::vec2(1024.0, 768.0)); + app.capture_geometry = Some(CaptureGeometry { surface: changed, ..geometry(&app) }); + app.capture_epoch += 1; + issue(&mut app, &ctx); + assert_eq!(app.shots.requests.len(), 1); + assert_eq!(app.shots.requests[0].expected, [1024, 768]); + assert!(!app.shots.requests[0].issued, "the retry waits for its own settling passes"); + } +} diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index bf089a2f6..a72222db0 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -50,7 +50,7 @@ fn find_system_font(sample: &str, preferred: &[&str]) -> Option> { let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); let found = preferred.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight)); let face = found.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?; - Some(Arc::new(FontData { font: std::borrow::Cow::Owned(face.data()?), index: face.info.index, tweak: Default::default() })) + Some(Arc::new(FontData::from_blob(Arc::new(face.data()?), face.info.index))) } /// Whether [`install`] has a Hangul face to add (an installed system face; none is bundled). @@ -100,3 +100,14 @@ fn install_chinese(fonts: &mut FontDefinitions) { stack.extend(order.iter().map(|f| name(f))); } } + +#[cfg(test)] +mod font_blob_tests { + use super::*; + + #[test] + fn a_ttc_face_index_survives_font_data_construction() { + let font = FontData::from_blob(Arc::new(vec![0u8; 4]), 7); + assert_eq!(font.index, 7, "the face index must travel with the shared blob"); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 960fa8915..5327e414e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -27,6 +27,8 @@ use serde_json::{Value, json}; const MAX_CLICK_COUNT: u64 = 10; const MAX_DRAG_STEPS: u64 = 1_024; +/// Synthetic input is consumed a step per frame; bound what a client can queue ahead of that. +const MAX_SYNTHETIC_EVENTS: usize = 16_384; fn bounded_repetitions(params: &Value, key: &str, default: u64, minimum: u64, maximum: u64) -> Result { let value = params.get(key).map(|value| value.as_u64().ok_or_else(|| format!("`{key}` must be a non-negative integer"))).transpose()?.unwrap_or(default); @@ -64,7 +66,8 @@ pub enum Outcome { AfterInput, Screenshot { path: Option, - crop: Option<[f32; 4]>, + /// Resolved to fresh geometry at issue time, not here. + crop: Option, }, } @@ -110,6 +113,9 @@ fn point(app: &FilmcraftApp, p: &Value) -> Result { pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); + if matches!(req.method.as_str(), "ui.click" | "ui.move" | "ui.drag" | "ui.scroll" | "ui.key" | "ui.type") && app.synthetic.len() >= MAX_SYNTHETIC_EVENTS { + return err("too many synthetic input events are queued; wait for the current input to be processed"); + } match req.method.as_str() { "engine.execute" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; @@ -293,8 +299,48 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) } // the Type tool's text editing state: {"clip", "layer", "caret", "anchor"} (byte offsets) or null if let Some(v) = p.get("gfxEdit") { - match serde_json::from_value(v.clone()) { - Ok(e) => app.ui.gfx_edit = e, + match serde_json::from_value::>(v.clone()) { + Ok(mut e) => { + // An explicit `ui.set.gfxEdit {clip, layer}` names the layer currently at + // that index. Resolve its identity from the document instead of inheriting + // a stale uid/fingerprint from a previous edit session: after a reorder, + // delete or undo the requested index may now hold a different layer. + if let Some(new) = e.as_mut() { + match crate::panels::graphics::current_layer_identity(&app.session, new.clip, new.layer) { + Some((uid, fingerprint)) => { + new.uid = uid; + new.fingerprint = fingerprint; + } + None => { + new.uid = 0; + new.fingerprint = 0; + } + } + } + let changed = match (&app.ui.gfx_edit, &e) { + (None, None) => false, + (Some(old), Some(new)) => { + // A stable uid is the owner key. The compact fingerprint only + // decides identity for a uid-less layer, and zero there means + // "the requested index" rather than a different owner. + old.clip != new.clip + || old.layer != new.layer + || old.uid != new.uid + || (new.uid == 0 && old.fingerprint != 0 && old.fingerprint != new.fingerprint) + } + _ => true, + }; + if changed { + app.restart_ime_target(); + } else if let (Some(old), Some(new)) = (&app.ui.gfx_edit, &e) + && (old.caret, old.anchor) != (new.caret, new.anchor) + { + // Caret is not part of the stable owner. Only a known composition + // becomes stale; commit-only input without one remains eligible. + app.invalidate_ime_composition(); + } + app.ui.gfx_edit = e; + } Err(e) => return err(format!("`gfxEdit`: {e}")), } } @@ -391,6 +437,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.synthetic.push(egui::Event::PointerMoved(pos)); app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Wheel, delta: egui::vec2(dx, dy), modifiers: modifiers(p), phase: egui::TouchPhase::Move, @@ -444,8 +491,14 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) ok(json!({"playing": app.playback.playing, "playhead": app.session.playhead().0})) } "ui.screenshot" => { - let crop = s("panel").and_then(PanelKind::from_name).and_then(|pk| app.auto.find(&format!("panel.{}", pk.id())).map(|e| e.rect)); - let crop = crop.or_else(|| s("id").and_then(|id| app.auto.find(id).map(|e| e.rect))); + // Keep the target symbolic: the crop must come from the layout that is actually + // captured, not from whatever the registry held when this control request arrived. + let crop = if let Some(panel) = s("panel") { + let Some(pk) = PanelKind::from_name(panel) else { return err(format!("unknown panel `{panel}`")) }; + Some(crate::capture::CropTarget::Element(format!("panel.{}", pk.id()))) + } else { + s("id").map(|id| crate::capture::CropTarget::Element(id.to_string())) + }; // Bring the window on screen so a frame is presented, without taking keyboard focus. app.raise_for_control(ctx); Outcome::Screenshot { path: s("path").map(str::to_string), crop } @@ -512,7 +565,7 @@ fn merge(v: &mut Value, patch: &Value) { pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size(); let keyboard_focus = ctx.memory(|memory| memory.focused()).map(|id| { - let elements: Vec<_> = app.auto.previous.iter().filter(|element| element.response_id == Some(id)).collect(); + let elements: Vec<_> = app.auto.all().iter().filter(|element| element.response_id == Some(id)).collect(); json!({ "widgetId": format!("{:016x}", id.value()), "elementIds": elements.iter().map(|element| element.id.as_str()).collect::>(), @@ -533,24 +586,53 @@ pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value { "playhead": app.session.playhead().0, "activeSequence": app.session.state.active_sequence.map(|i| i.0), "selection": app.session.state.selection.iter().map(|c| c.0).collect::>(), - "elements": app.auto.previous.len(), + "elements": app.auto.all().len(), "dialog": app.dialog.map(|d| format!("{d:?}")), }) } /// Save a screenshot (optionally cropped to a rect in points) as PNG; returns the reply JSON. pub fn save_screenshot(ctx: &egui::Context, image: &egui::ColorImage, path: Option<&str>, crop: Option<[f32; 4]>) -> Value { - let ppp = ctx.pixels_per_point(); - let [w, h] = image.size; - let (x0, y0, cw, ch) = match crop { - Some([x, y, cw, ch]) => { - let x0 = ((x * ppp) as usize).min(w); - let y0 = ((y * ppp) as usize).min(h); - (x0, y0, ((cw * ppp) as usize).min(w - x0), ((ch * ppp) as usize).min(h - y0)) - } - None => (0, 0, w, h), + save_screenshot_with_ppp(image, path, crop, ctx.pixels_per_point()) +} + +/// Intersect a crop rectangle in points with the captured image, converting through `ppp`. +/// +/// Negative origins are clipped to the image rather than saturated while keeping the requested +/// width, which would shift the captured region. A crop that does not overlap the image (or has a +/// non-finite or non-positive size) is refused instead of returning a zero-sized or wrong image. +fn crop_pixels(size: [usize; 2], crop: Option<[f32; 4]>, ppp: f32) -> Result<(usize, usize, usize, usize), String> { + let [w, h] = size; + let Some([x, y, cw, ch]) = crop else { return Ok((0, 0, w, h)) }; + if !(x.is_finite() && y.is_finite() && cw.is_finite() && ch.is_finite()) || cw <= 0.0 || ch <= 0.0 { + return Err("screenshot crop must be a finite rectangle with positive width and height".into()); + } + let ppp = f64::from(ppp); + let clamp = |v: f64, max: usize| v.max(0.0).min(max as f64); + let x0 = clamp(f64::from(x) * ppp, w).floor() as usize; + let y0 = clamp(f64::from(y) * ppp, h).floor() as usize; + let x1 = clamp((f64::from(x) + f64::from(cw)) * ppp, w).ceil() as usize; + let y1 = clamp((f64::from(y) + f64::from(ch)) * ppp, h).ceil() as usize; + let (cw, ch) = (x1.saturating_sub(x0), y1.saturating_sub(y0)); + if cw == 0 || ch == 0 { + return Err("screenshot crop does not intersect the image".into()); + } + Ok((x0, y0, cw, ch)) +} + +/// Save a screenshot whose scale was captured with the request. Hidden logic ticks must not read +/// `Context::pixels_per_point()` for an image produced from a different UI pass. +pub fn save_screenshot_with_ppp(image: &egui::ColorImage, path: Option<&str>, crop: Option<[f32; 4]>, ppp: f32) -> Value { + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let (x0, y0, cw, ch) = match crop_pixels(image.size, crop, ppp) { + Ok(rect) => rect, + Err(e) => return json!({"ok": false, "error": e}), }; - let mut rgba = Vec::with_capacity(cw * ch * 4); + let [w, _] = image.size; + let Some(capacity) = cw.checked_mul(ch).and_then(|pixels| pixels.checked_mul(std::mem::size_of::())) else { + return json!({"ok": false, "error": "screenshot crop size overflows the image buffer"}); + }; + let mut rgba = Vec::with_capacity(capacity); for y in y0..y0 + ch { for x in x0..x0 + cw { let c = image.pixels[y * w + x]; @@ -611,7 +693,12 @@ pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result, String> { } !c } - let mut raw = Vec::with_capacity((w as usize * 4 + 1) * h as usize); + let row = (w as usize) + .checked_mul(4) + .and_then(|bytes| bytes.checked_add(1)) + .and_then(|bytes| bytes.checked_mul(h as usize)) + .ok_or_else(|| "PNG size overflows the image buffer".to_string())?; + let mut raw = Vec::with_capacity(row); for y in 0..h as usize { raw.push(0); raw.extend_from_slice(&rgba[y * w as usize * 4..(y + 1) * w as usize * 4]); @@ -653,6 +740,29 @@ mod base64_tests { } } + #[test] + fn screenshot_crop_clips_negative_panel_origin() { + // A panel starting two points left of / one point above the window: only the visible + // 2×3 pixels are captured, not the requested 4×4 starting at (0, 0). + assert_eq!(super::crop_pixels([8, 8], Some([-2.0, -1.0, 4.0, 4.0]), 1.0).unwrap(), (0, 0, 2, 3)); + // The same crop at 2× scale clips the same logical region to the pixel grid. + assert_eq!(super::crop_pixels([16, 16], Some([-2.0, -1.0, 4.0, 4.0]), 2.0).unwrap(), (0, 0, 4, 6)); + // Partly outside on the right/bottom uses the visible remainder. + assert_eq!(super::crop_pixels([8, 8], Some([6.0, 5.0, 4.0, 4.0]), 1.0).unwrap(), (6, 5, 2, 3)); + } + + #[test] + fn screenshot_crop_outside_the_image_is_refused() { + for crop in [[-8.0, -8.0, 4.0, 4.0], [8.0, 0.0, 4.0, 4.0], [0.0, 9.0, 4.0, 4.0]] { + let err = super::crop_pixels([8, 8], Some(crop), 1.0).expect_err("a crop outside the image must be refused"); + assert!(err.contains("does not intersect"), "{err}"); + } + for crop in [[0.0, 0.0, 0.0, 4.0], [0.0, 0.0, -4.0, 4.0], [f32::NAN, 0.0, 4.0, 4.0], [0.0, 0.0, f32::INFINITY, 4.0]] { + let err = super::crop_pixels([8, 8], Some(crop), 1.0).expect_err("an invalid crop must be refused"); + assert!(err.contains("finite rectangle"), "{err}"); + } + } + #[test] fn synthetic_event_repetition_is_bounded() { assert_eq!(super::bounded_repetitions(&json!({}), "count", 1, 0, super::MAX_CLICK_COUNT).unwrap(), 1); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index c1976eb80..0fcc36cfe 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -231,7 +231,7 @@ const LIST_HEIGHT: f32 = 360.0; /// `about.credits.reverse`, `about.credits.grabBag`, `about.credits.table`, /// `about.credits.header.` (table headers, by column). pub fn contributors_ui(ui: &mut egui::Ui, t: &Tokens, auto: &mut Registry) { - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(tl!("Show")); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index d144bd57e..867d24740 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -545,10 +545,11 @@ fn layout_inner( DockNode::Split { vertical, size, a, b } => { let axis = if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }; let Some(split) = split_rect(rect, axis, size.shared(), t.gap, 20.0) else { return }; - let id = egui::Id::new(("dock-gutter", path.to_string())); + let id = egui::Id::unique(("dock-gutter", path.to_string())); let handle_style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus }; let label = if *vertical { tl!("Height:") } else { tl!("Width:") }; let response = ResizeHandle::new(id, axis, split.divider, label, handle_style).value(split.first_size(), 0.0..=split.available()).show(ui); + response.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Splitter, response.response.enabled(), label)); reg.add_response(&format!("dock.gutter.{path}"), &response.response, "gutter"); if response.delta != 0.0 && let Some(resized) = split.resized(size.shared(), response.delta, 40.0) @@ -603,7 +604,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t // separately reorder open sequences. if let Some(panel) = active_panel { let grip = Rect::from_min_size(g.rect.min, vec2(if compact { g.rect.width() } else { 10.0 }, if compact { 8.0 } else { t.tab_h })); - let response = ui.interact(grip, egui::Id::new(("panel-drag", g.path.clone())), Sense::drag()); + let response = ui.interact(grip, egui::Id::unique(("panel-drag", g.path.clone())), Sense::drag()); reg.add_response(&format!("panel.drag.{}", panel.id()), &response, "Move panel"); if response.drag_started() { actions.push(DockAction::BeginDrag(panel)); @@ -664,13 +665,13 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t while last + 1 < tabs.len() && span(first, last + 1) <= room { last += 1; } - let list_id = egui::Id::new(("tab-list", g.path.clone())); + let list_id = egui::Id::unique(("tab-list", g.path.clone())); let more = (!all_fit).then(|| Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0))); let mut list_opened = false; if let Some(r) = more { - let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("tab-overflow", g.path.clone())), Sense::click()); let name = if g.panels.contains(&PanelKind::Timeline) { "timeline.tabs.more".to_string() } else { format!("panel.tabs.more.{}", g.path) }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), "more tabs")); reg.add_response(&name, &resp, "more tabs"); let c = if resp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, c); @@ -684,7 +685,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t } // The wheel over the strip steps through the tabs: down = next, up = previous, one tab // per wheel notch (trackpad scrolling adds up to a notch first), stopping at the ends. - let wheel_id = egui::Id::new(("tab-wheel", g.path.clone())); + let wheel_id = egui::Id::unique(("tab-wheel", g.path.clone())); if ui.rect_contains_pointer(strip) && !tabs.is_empty() { const NOTCH: f32 = 40.0; let total = ui.ctx().data(|d| d.get_temp::(wheel_id)).unwrap_or(0.0) + crate::panels::timeline::wheel_input(ui.ctx()).delta.y; @@ -711,8 +712,8 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t let tab_rect = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h)); // a sequence tab keeps its identity when the tabs are reordered under the pointer let resp = match tab.seq { - Some(id) => ui.interact(tab_rect, egui::Id::new(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()), - None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag()), + Some(id) => ui.interact(tab_rect, egui::Id::unique(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()), + None => ui.interact(tab_rect, egui::Id::unique(("tab", g.path.clone(), i)), Sense::click_and_drag()), }; if tab.seq.is_none() && resp.drag_started() { actions.push(DockAction::BeginDrag(*p)); @@ -722,7 +723,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t } else { tab_rects.push((*p, tab_rect)); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, &tab.title)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Tab, resp.enabled(), is_active, &tab.title)); ui.ctx().accesskit_node_builder(resp.id, |node| { node.set_role(egui::accesskit::Role::Tab); node.set_selected(is_active); @@ -734,7 +735,7 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t dragged = Some(id); } // Escape puts a dragged tab back where it was (egui ends the drag on Escape, #580) - let home = egui::Id::new(("tab-seq-home", id)); + let home = egui::Id::unique(("tab-seq-home", id)); if resp.drag_started() && let Some(at) = seqs.open.iter().position(|(s, _)| *s == id) { @@ -761,8 +762,8 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t if let Some(id) = tab.seq.filter(|_| is_active) { // × closes the sequence's tab (the sequence stays in the project) let cr = Rect::from_center_size(pos2(tab_rect.min.x + 10.0, text_y), vec2(8.0, 8.0)); - let cresp = ui.interact(cr.expand(4.0), egui::Id::new(("tab-close", g.path.clone(), id)), Sense::click()); - cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, cresp.enabled(), "close sequence")); + let cresp = ui.interact(cr.expand(4.0), egui::Id::unique(("tab-close", g.path.clone(), id)), Sense::click()); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, cresp.enabled(), "close sequence")); reg.add_response(&format!("timeline.tab.{id}.close"), &cresp, "close sequence"); let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; painter.line_segment([cr.left_top(), cr.right_bottom()], Stroke::new(1.2, cc)); @@ -775,8 +776,8 @@ pub fn draw_group_chrome_details(ui: &mut egui::Ui, g: &Group, seqs: &SeqTabs, t } if is_active { let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0)); - let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click()); - mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, mresp.enabled(), "panel menu")); + let mresp = ui.interact(mr.expand(3.0), egui::Id::unique(("tab-menu", g.path.clone())), Sense::click()); + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, mresp.enabled(), "panel menu")); reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu"); let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, Rect::from_center_size(mr.center(), vec2(16.0, 16.0)), Icon::Hamburger, mc); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 98951de89..ef69ce477 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -18,7 +18,7 @@ fn floating_rect(group: &dock::Floating, screen: Rect) -> Rect { } fn drag_id() -> egui::Id { - egui::Id::new("dock-panel-drag") + egui::Id::unique("dock-panel-drag") } pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { @@ -179,7 +179,7 @@ impl FilmcraftApp { .get(*index) .and_then(|group| group.panels.first()) .and_then(|panel| { - let id = egui::Id::new(("floating-panel", panel.id())); + let id = egui::Id::unique(("floating-panel", panel.id())); layers.iter().position(|layer| layer.id == id) }) .unwrap_or(usize::MAX) @@ -193,7 +193,7 @@ impl FilmcraftApp { if let Some(panel) = self.pending_panel_raise.take() && let Some(owner) = self.ui.floating.iter().find(|group| group.panels.contains(&panel)).and_then(|group| group.panels.first()) { - ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", owner.id())))); + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::unique(("floating-panel", owner.id())))); } let maximized = self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))); @@ -282,15 +282,14 @@ impl FilmcraftApp { DockAction::PanelMenu(panel, pos) => { let frame = ctx.cumulative_frame_nr(); ctx.data_mut(|data| { - data.insert_temp(egui::Id::new("panel-menu"), (panel, pos)); - data.insert_temp(egui::Id::new("panel-menu-opened"), frame); + data.insert_temp(egui::Id::unique("panel-menu"), (panel, pos)); + data.insert_temp(egui::Id::unique("panel-menu-opened"), frame); }); } } } self.panel_drag(&ctx, body, &targets); panels::panel_menu_popup(self, ui); - self.cancel_value_drag(ui.ctx()); } fn floating_group(&mut self, ctx: &egui::Context, index: usize, seqs: &dock::SeqTabs, actions: &mut Vec) -> Option> { @@ -298,7 +297,7 @@ impl FilmcraftApp { let panel = *floating.panels.first()?; let rect = floating_rect(&floating, ctx.content_rect()); let t = self.tokens; - let id = egui::Id::new(("floating-panel", panel.id())); + let id = egui::Id::unique(("floating-panel", panel.id())); let mut output = None; egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).constrain(false).interactable(true).show(ctx, |ui| { let (rect, _) = ui.allocate_exact_size(rect.size(), Sense::hover()); @@ -323,7 +322,7 @@ impl FilmcraftApp { ui.painter().rect_stroke(rect, t.radius, Stroke::new(1.0, t.focus), StrokeKind::Outside); let grip = Rect::from_min_max(rect.max - vec2(14.0, 14.0), rect.max); let response = ui.interact(grip, id.with("resize"), Sense::click_and_drag()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), tl!("Resize floating panel"))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, response.enabled(), tl!("Resize floating panel"))); self.auto.add_response(&format!("panel.float.{index}.resize"), &response, "Resize floating panel"); for step in 0..3 { let offset = 4.0 + step as f32 * 4.0; @@ -361,7 +360,7 @@ impl FilmcraftApp { let force_float = ctx.input(|input| input.modifiers.command); let edge = (!force_float).then(|| workspace_edge(body, pos, targets)).flatten(); let proposal = (!force_float && edge.is_none()).then(|| resolve_drop(&panel, pos, targets)).flatten(); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("dock-drop"))); let t = self.tokens; if let Some((_, _, preview)) = edge { painter.rect_filled(preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2)); diff --git a/crates/ui-egui/src/graphics_notice.rs b/crates/ui-egui/src/graphics_notice.rs index a94a69294..b1cd14e62 100644 --- a/crates/ui-egui/src/graphics_notice.rs +++ b/crates/ui-egui/src/graphics_notice.rs @@ -44,7 +44,7 @@ pub(crate) fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let title = tlf!("Safe graphics mode: {mode}", mode = i18n::t(¬ice.mode)); let (mut reset, mut close) = (false, false); let shown = egui::Window::new("graphics-notice") - .id(egui::Id::new("graphics-notice")) + .id(egui::Id::unique("graphics-notice")) .title_bar(false) .collapsible(false) .resizable(false) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 5b8f23c85..74e465d02 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -15,7 +15,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = ui.painter().clone(); p.rect_filled(rect, 0.0, t.header_bg); p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, Color32::BLACK)); - let drag = ui.interact(rect, egui::Id::new("header-drag"), Sense::click_and_drag()); + let drag = ui.interact(rect, egui::Id::unique("header-drag"), Sense::click_and_drag()); if drag.drag_started() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } @@ -26,7 +26,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut x = rect.min.x + if app.integrated_titlebar { 104.0 } else { 14.0 }; // Home let home = Rect::from_center_size(pos2(x + 10.0, rect.center().y), vec2(26.0, 26.0)); - let hresp = ui.interact(home, egui::Id::new("hdr-home"), Sense::click()).on_hover_text(tl!("Home")); + let hresp = ui.interact(home, egui::Id::unique("hdr-home"), Sense::click()).on_hover_text(tl!("Home")); app.auto.add("header.home", home, "Home"); if hresp.hovered() { p.rect_filled(home, 4.0, t.hover); @@ -41,7 +41,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let active = app.ui.mode == m; let galley = p.layout_no_wrap(shown.to_string(), Tokens::ui(14.0), if active { t.tab_text_active } else { t.tab_text }); let r = Rect::from_min_size(pos2(x - 6.0, rect.min.y + 6.0), vec2(galley.size().x + 12.0, rect.height() - 12.0)); - let resp = ui.interact(r, egui::Id::new(("mode", label)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("mode", label)), Sense::click()); app.auto.add(&format!("header.mode.{}", label.to_ascii_lowercase()), r, label); let col = if active || resp.hovered() { t.tab_text_active } else { t.tab_text }; let gw = galley.size().x; @@ -72,7 +72,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut btn = |ui: &mut egui::Ui, icon: Icon, id: &str, tip: &str, app: &mut FilmcraftApp| -> egui::Response { let r = Rect::from_center_size(pos2(rx - 14.0, rect.center().y), vec2(28.0, 28.0)); rx -= 38.0; - let resp = ui.interact(r, egui::Id::new(("hdr", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("hdr", id)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("header.{id}"), r, tip); if resp.hovered() { ui.painter().rect_filled(r, 4.0, t.hover); @@ -97,7 +97,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let qx = rect.max.x - 14.0 - 4.0 * 38.0; // the fifth button from the right if btn(ui, Icon::Export, "quickExport", tl!("Quick Export"), app).clicked() { app.ui.export.quick_open = !app.ui.export.quick_open; - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("quick-export-toggled"), true)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("quick-export-toggled"), true)); } crate::panels::export_mode::quick_export(app, ui.ctx(), pos2(qx - 340.0, rect.max.y + 4.0)); // Appearance Mode: click for the next one (Auto, Light, Dark); the icon shows the current one. @@ -117,7 +117,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let ws = crate::i18n::t(&app.ui.workspace).to_uppercase(); let wg = p.layout_no_wrap(ws.clone(), Tokens::ui(11.0), t.text_dim); let wr = Rect::from_min_size(pos2(rx - wg.size().x + 10.0, rect.center().y - 10.0), vec2(wg.size().x + 8.0, 20.0)); - let wresp = ui.interact(wr, egui::Id::new("hdr-ws-name"), Sense::click()); + let wresp = ui.interact(wr, egui::Id::unique("hdr-ws-name"), Sense::click()); app.auto.add("header.workspaceName", wr, &ws); p.galley_with_override_text_color(pos2(wr.min.x + 4.0, rect.center().y - wg.size().y / 2.0), wg, if wresp.hovered() { t.text } else { t.text_dim }); // Community: a labelled Discord button, always one click away. @@ -126,7 +126,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let g = p.layout_no_wrap(label.to_string(), Tokens::ui(12.0), Color32::WHITE); let w = g.size().x + 34.0; let r = Rect::from_min_size(pos2(wr.min.x - w - 14.0, rect.center().y - 12.0), vec2(w, 24.0)); - let resp = ui.interact(r, egui::Id::new("hdr-discord"), Sense::click()).on_hover_text(tl!("Join the ArtCraft Discord (discord.gg/artcraft)")); + let resp = ui.interact(r, egui::Id::unique("hdr-discord"), Sense::click()).on_hover_text(tl!("Join the ArtCraft Discord (discord.gg/artcraft)")); app.auto.add("header.discord", r, "Join the ArtCraft Discord"); ui.painter().rect_filled(r, 12.0, if resp.hovered() { t.accent_hover } else { t.accent }); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); @@ -152,7 +152,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::links::open(app, ui.ctx(), crate::links::DISCORD); } } - let popup_id = egui::Id::new("workspaces-popup"); + let popup_id = egui::Id::unique("workspaces-popup"); if ws_resp.clicked() || wresp.clicked() { ui.ctx().data_mut(|d| d.insert_temp(popup_id, true)); } diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 76991909c..ab9180564 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3469,8 +3469,8 @@ Commits Commits % of all commits % aller Commits Lines +/− Zeilen +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Audioausgabe fehlgeschlagen: Wiedergabe ohne Ton (prüfe Voreinstellungen ▸ Audiohardware) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) Audioausgabe reagiert nicht: Wiedergabe ohne Ton (prüfe Voreinstellungen ▸ Audiohardware) + Audio output failed: playing without sound (check Settings > Audio Hardware) Audioausgabe fehlgeschlagen: Wiedergabe ohne Ton (prüfe Voreinstellungen > Audiohardware) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) Audioausgabe reagiert nicht: Wiedergabe ohne Ton (prüfe Voreinstellungen > Audiohardware) Clip Speed / Duration Clipgeschwindigkeit/-dauer Speed: Geschwindigkeit: Reverse Speed Rückwärts abspielen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2ac92decc..f9a30efa0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3458,8 +3458,8 @@ Commits Commits % of all commits % de todos los commits Lines +/− Líneas +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) La salida de audio ha fallado: se reproduce sin sonido (revisa Ajustes ▸ Hardware de audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) La salida de audio no responde: se reproduce sin sonido (revisa Ajustes ▸ Hardware de audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) La salida de audio ha fallado: se reproduce sin sonido (revisa Ajustes > Hardware de audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) La salida de audio no responde: se reproduce sin sonido (revisa Ajustes > Hardware de audio) Clip Speed / Duration Velocidad/duración del clip Speed: Velocidad: Reverse Speed Invertir velocidad @@ -3611,7 +3611,7 @@ Shows the version, community links and credits in a FilmCraft window. Muestra la versión, los enlaces de la comunidad y los créditos en una ventana de FilmCraft. Couldn't open your web browser No se pudo abrir el navegador web Open this address in your browser yourself: Abre esta dirección tú mismo en el navegador: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Restablecer zoom de la interfaz Track Color Color de pista Open a sequence to search its graphics Abre una secuencia para buscar en sus gráficos diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6778b0322..451668e92 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3437,8 +3437,8 @@ Commits Commits % of all commits % de tous les commits Lines +/− Lignes +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Échec de la sortie audio : lecture sans son (vérifiez Préférences ▸ Matériel audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) La sortie audio ne répond pas : lecture sans son (vérifiez Préférences ▸ Matériel audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) Échec de la sortie audio : lecture sans son (vérifiez Préférences > Matériel audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) La sortie audio ne répond pas : lecture sans son (vérifiez Préférences > Matériel audio) Clip Speed / Duration Vitesse/durée du clip Speed: Vitesse : Reverse Speed Vitesse inversée @@ -3578,7 +3578,7 @@ Export the labels to a preset file… Exporter les étiquettes dans un fichier de paramètre prédéfini… Show the presets folder Afficher le dossier des paramètres prédéfinis Delete this preset Supprimer ce paramètre prédéfini -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Réinitialiser le zoom de l'interface Track Color Couleur de la piste Open a sequence to search its graphics Ouvrez une séquence pour rechercher dans ses graphiques diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8ffc4f5b3..3b9782032 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3454,8 +3454,8 @@ Commits Commit % of all commits % dari semua commit Lines +/− Baris +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Output audio gagal: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) Output audio tidak merespons: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) Output audio gagal: memutar tanpa suara (periksa Pengaturan > Perangkat Keras Audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) Output audio tidak merespons: memutar tanpa suara (periksa Pengaturan > Perangkat Keras Audio) Clip Speed / Duration Kecepatan / Durasi Klip Speed: Kecepatan: Reverse Speed Balik Kecepatan @@ -3576,7 +3576,7 @@ Appearance Settings… Pengaturan Tampilan… Add to favorites Tambahkan ke favorit Remove from favorites Hapus dari favorit -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Atur Ulang Zoom Antarmuka Track Color Warna Trek Open a sequence to search its graphics Buka sekuens untuk mencari di grafisnya diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ef02460c3..8a85a9db1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3447,8 +3447,8 @@ Commits Commit % of all commits % di tutti i commit Lines +/− Righe +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Uscita audio non riuscita: riproduzione senza audio (controlla Impostazioni ▸ Hardware audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) L'uscita audio non risponde: riproduzione senza audio (controlla Impostazioni ▸ Hardware audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) Uscita audio non riuscita: riproduzione senza audio (controlla Impostazioni > Hardware audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) L'uscita audio non risponde: riproduzione senza audio (controlla Impostazioni > Hardware audio) Clip Speed / Duration Velocità / durata clip Speed: Velocità: Reverse Speed Velocità inversa @@ -3602,7 +3602,7 @@ Shows the version, community links and credits in a FilmCraft window. Mostra la versione, i link della community e i riconoscimenti in una finestra di FilmCraft. Couldn't open your web browser Impossibile aprire il browser web Open this address in your browser yourself: Apri tu questo indirizzo nel browser: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Reimposta zoom dell'interfaccia Track Color Colore traccia Open a sequence to search its graphics Apri una sequenza per cercare nelle sue grafiche diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d0fb3d4e7..c0b7ebc3d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3443,8 +3443,8 @@ Commits コミット % of all commits 全コミットに占める割合 (%) Lines +/− 行数 +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力に失敗したため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力が応答しないため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください) + Audio output failed: playing without sound (check Settings > Audio Hardware) オーディオ出力に失敗したため、無音で再生しています (「環境設定 > オーディオハードウェア」を確認してください) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) オーディオ出力が応答しないため、無音で再生しています (「環境設定 > オーディオハードウェア」を確認してください) Clip Speed / Duration クリップの速度 / 長さ Speed: 速度: Reverse Speed 逆再生する @@ -3610,7 +3610,7 @@ Shows the version, community links and credits in a FilmCraft window. バージョン、コミュニティへのリンク、クレジットを FilmCraft のウィンドウに表示します。 Couldn't open your web browser ウェブブラウザーを開けませんでした Open this address in your browser yourself: このアドレスをご自身でブラウザーで開いてください: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom インターフェイスのズームをリセット Track Color トラックカラー Open a sequence to search its graphics シーケンスを開くとグラフィックスを検索できます diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 73d5adf68..66fd140b7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -272,8 +272,8 @@ Audio Options 오디오 옵션 Audio Out 오디오 출력 Audio Out Point 오디오 끝 표시(Out) - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) 오디오 출력 실패: 소리 없이 재생 중 (설정 ▸ 오디오 하드웨어 확인) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) 오디오 출력 응답 없음: 소리 없이 재생 중 (설정 ▸ 오디오 하드웨어 확인) + Audio output failed: playing without sound (check Settings > Audio Hardware) 오디오 출력 실패: 소리 없이 재생 중 (설정 > 오디오 하드웨어 확인) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) 오디오 출력 응답 없음: 소리 없이 재생 중 (설정 > 오디오 하드웨어 확인) Audio Previews: 오디오 미리 보기: Audio Samples 오디오 샘플 Audio Time Units 오디오 시간 단위 @@ -3597,7 +3597,7 @@ Shows the version, community links and credits in a FilmCraft window. FilmCraft 창에 버전, 커뮤니티 링크, 제작진을 보여 줍니다. Couldn't open your web browser 웹 브라우저를 열 수 없습니다 Open this address in your browser yourself: 이 주소를 브라우저에서 직접 여세요: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom 인터페이스 확대/축소 재설정 Track Color 트랙 색상 Open a sequence to search its graphics 그래픽을 검색하려면 시퀀스를 여세요 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 80f9dfe01..3fc984673 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -66,6 +66,25 @@ static ITALIAN: OnceLock = OnceLock::new(); impl Language { pub const ALL: [Self; 12] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr, Self::Id, Self::Ko, Self::It]; + /// BCP 47 tag for AccessKit and the web IME's hidden input. Keep the persisted spelling + /// (`pt-br`, `zh-cn`) out of the protocol-facing value. + pub fn bcp47(self) -> &'static str { + match self { + Self::En => "en", + Self::Ja => "ja", + Self::Es => "es", + Self::PtBr => "pt-BR", + Self::Uk => "uk", + Self::ZhCn => "zh-CN", + Self::De => "de", + Self::Ru => "ru", + Self::Fr => "fr", + Self::Id => "id", + Self::It => "it", + Self::Ko => "ko", + } + } + pub fn name(self) -> &'static str { match self { Self::En => "English", @@ -289,7 +308,7 @@ pub fn system_japanese_font() -> Option> { // kept for the life of the process (one font, read once) so installing it again after a // theme change shares the bytes instead of copying the whole file let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice()); - Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() })) + Some(Arc::new(egui::FontData::from_blob(Arc::new(bytes), face.info.index))) }) .clone() } @@ -1104,3 +1123,19 @@ mod tests { }); } } + +#[cfg(test)] +mod bcp47_tests { + use super::Language; + + #[test] + fn accesskit_tags_use_protocol_spelling() { + assert_eq!(Language::En.bcp47(), "en"); + assert_eq!(Language::PtBr.bcp47(), "pt-BR"); + assert_eq!(Language::ZhCn.bcp47(), "zh-CN"); + for language in Language::ALL { + let tag = language.bcp47(); + assert!(!tag.is_empty() && tag.is_ascii(), "{tag:?}"); + } + } +} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 7cbb49a58..8b4d7acba 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3451,8 +3451,8 @@ Commits Commits % of all commits % de todos os commits Lines +/− Linhas +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio falhou: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio não está respondendo: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) + Audio output failed: playing without sound (check Settings > Audio Hardware) A saída de áudio falhou: reproduzindo sem som (verifique Preferências > Hardware de áudio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) A saída de áudio não está respondendo: reproduzindo sem som (verifique Preferências > Hardware de áudio) Clip Speed / Duration Velocidade/duração do clipe Speed: Velocidade: Reverse Speed Inverter velocidade @@ -3579,7 +3579,7 @@ # The native macOS application menu (Settings…). Settings Preferências -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Redefinir zoom da interface Track Color Cor da trilha Open a sequence to search its graphics Abra uma sequência para pesquisar nos gráficos dela diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 244646f47..5c4f4d29e 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3437,8 +3437,8 @@ Commits 提交 % of all commits 占全部提交的比例 Lines +/− 代码行 +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) 音频输出失败:无声播放(请检查设置 ▸ 音频硬件) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) 音频输出无响应:无声播放(请检查设置 ▸ 音频硬件) + Audio output failed: playing without sound (check Settings > Audio Hardware) 音频输出失败:无声播放(请检查设置 > 音频硬件) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) 音频输出无响应:无声播放(请检查设置 > 音频硬件) Clip Speed / Duration 剪辑速度 / 时长 Speed: 速度: Reverse Speed 反转速度 @@ -3586,7 +3586,7 @@ Shows the version, community links and credits in a FilmCraft window. 在 FilmCraft 窗口中显示版本、社区链接和致谢。 Couldn't open your web browser 无法打开网页浏览器 Open this address in your browser yourself: 请自行在浏览器中打开此地址: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom 重置界面缩放 Track Color 轨道颜色 Open a sequence to search its graphics 打开序列以搜索其中的图形 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 14d66da4c..d6bdea8d9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ macro_rules! tlf { pub mod automation; pub mod brand; pub mod calc; +pub mod capture; mod cjk; pub mod control; pub mod crash; @@ -67,6 +68,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender}; +use egui::ime_context::{ImeContextTracker, ImeTarget}; use egui::{TextureHandle, TextureOptions}; use filmcraft_engine::{Services, Session}; use filmcraft_time::Tick; @@ -224,16 +226,16 @@ pub struct Playback { pub playing: bool, pub speed: f64, pub looping: bool, - /// Wall-clock (egui time, s) and timeline tick when playback (re)started. + /// Monotonic host time (s) and timeline tick when playback (re)started. anchor_time: f64, anchor_tick: Tick, /// Audio frames played at anchor (when the audio clock drives). pub audio_clock: bool, - /// The audio clock's last reading and when (egui time, s) it last moved: a device that stops + /// The audio clock's last reading and when (monotonic host s) it last moved: a device that stops /// consuming samples hands the clock back to the wall clock (see [`AUDIO_STALL_S`]). audio_seen: (u64, f64), /// When playback lost its output (a failed open or a dead stream), when it is opened again - /// (egui time, s; 0 = not retrying). Pause/play proves a fresh stream works, so a stream the + /// (monotonic host s; 0 = not retrying). Pause/play proves a fresh stream works, so a stream the /// device keeps dropping is reopened on a timer (see [`AUDIO_RETRY_S`]). audio_retry_at: f64, /// Audio underruns for the current (or last) play (desktop: sound is mixed ahead). @@ -241,7 +243,7 @@ pub struct Playback { pub audio_stats: std::sync::Arc, /// Shown / dropped frame accounting for the current (or last) play. pub meter: frames::PlaybackMeter, - /// Waiting for the first frames before starting the clock: when the wait began (egui time, + /// Waiting for the first frames before starting the clock: when the wait began (monotonic host time, /// s; negative = not yet stamped), and whether the Program monitor has them ready. pub preroll: Option, pub preroll_ready: bool, @@ -265,12 +267,6 @@ pub const AUDIO_STALL_S: f64 = 1.0; /// timer brings the sound back when the device recovers (#2503). pub const AUDIO_RETRY_S: f64 = 2.0; -/// How long `ui.screenshot` waits for the window to present the frame. -const SCREENSHOT_TIMEOUT_S: f64 = 10.0; -/// How long `ui.screenshot` waits for the UI to settle (monitors showing their exact frames, the -/// timeline zoom animation finished) before capturing what is there. -const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0; - /// Export entries that run from the menus through the save panel: command, filter label, /// extension. pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 7] = [ @@ -283,6 +279,30 @@ pub(crate) const EXPORT_SAVE_DIALOGS: [(&str, &str, &str); 7] = [ ("file.exportSelectionProject", "FilmCraft Project", "fcproj"), ]; +/// The IME composition currently in flight on the Program monitor. +/// +/// `TerminalPending` is an observed empty preedit: the input method cleared the visible +/// composition, but its terminal commit/delete is a separate event and may still arrive. Keeping +/// the owner means a target switch turns that state into `Stale`, so the pending old terminal +/// event is rejected instead of being applied to the replacement. +/// +/// `Stale` is a tombstone: the target was replaced or the editor closed while a composition was +/// active, so the preedit/delete/commit events of that composition are dropped instead of being +/// inserted into the replacement target. A rejected commit consumes it immediately, so a preedit +/// later in the same raw batch starts a new composition for the current target. A rejected empty +/// preedit or delete is only tentatively terminal: it keeps the tombstone through the rest of the +/// batch so its paired old commit is rejected too, and the tombstone is consumed at batch end when +/// no commit follows. A lone non-empty preedit keeps it across batches until the rejected +/// composition's commit, so a preedit/commit split across frames cannot retarget either. A later +/// composition on the new target is legitimate and must be accepted, so the tombstone is never +/// permanent (see `panels::graphics::text_edit`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ImeComposition { + Active(ImeTarget), + TerminalPending(ImeTarget), + Stale, +} + pub struct FilmcraftApp { pub session: Session, pub ui: UiState, @@ -299,6 +319,29 @@ pub struct FilmcraftApp { /// The native file dialog the host is showing ([`Self::pick`]). native_dialog: Option, pub auto: automation::Registry, + /// IME owner/generation/cause state for the custom editors on the Program monitor. State is + /// kept per egui viewport; a closed viewport is released in [`Self::on_exit`]. + pub(crate) ime_context: ImeContextTracker, + /// Bumped when the custom text edit target is (re)created, a sequence/document context + /// changes, or a control request replaces `gfxEdit`. It is the replacement epoch of the + /// otherwise stable sequence/clip/component owner. + pub(crate) ime_replacement_epoch: u64, + /// The (viewport, cumulative pass) whose one IME observation has already been made. + ime_observed_pass: Option<(egui::ViewportId, u64)>, + /// The composition in flight (including an empty preedit awaiting its terminal event), or a + /// tombstone for a composition whose target was replaced or caret/focus was interrupted. + pub(crate) ime_composition: Option, + /// A known composition was interrupted locally; ask the platform to cancel it once even + /// when the same editor owner/generation remains focused after a caret-only move. + pub(crate) ime_interrupt_now: bool, + /// The editor drawn in this pass; the final observation runs after control drain. + pub(crate) ime_drawn_editor: Option, + /// Project document generation last seen by the UI (the engine bumps it on replacement). + ime_document_epoch: u64, + /// Active sequence last seen by the UI; `sequence.close` need not emit `OpenSequence`. + ime_active_sequence: Option, + /// Viewports this app's custom IME producer has observed, released when the app exits. + ime_viewports: Vec, /// Named textures (monitors, thumbnails) with the key they show. textures: HashMap, /// The UI pass each thumbnail texture was last used in: thumbnails beyond @@ -315,8 +358,33 @@ pub struct FilmcraftApp { synthetic_modifiers: egui::Modifiers, /// Requests waiting for the UI to show an element: (request, give-up time). deferred: Vec<(ControlRequest, f64)>, + /// Control requests that need a fresh layout/response; processed only in a real UI pass. + ui_requests: Vec<(ControlRequest, f64)>, last_ui_time: f64, pub(crate) synthetic: Vec, + /// One synthetic step handed to `raw_input_hook` but not yet consumed by a real UI pass. + /// Hidden `run_logic` callbacks do not process raw input: the same step is offered again until + /// an actual pass consumes it, rather than being dropped or advancing the queue. + synthetic_pending_step: Option>, + /// The pending step is already present in the `RawInput` the eframe integration preserves + /// across hidden logic ticks. Appending it again on the next hook would duplicate it. + synthetic_step_offered: bool, + /// Same for the `ModifiersChanged`/focus boundary events, which hidden ticks would otherwise + /// accumulate without bound. + synthetic_modifiers_offered: bool, + /// An image delivered by `Event::PasteImage` for the paste action this frame. Native and web + /// hosts deliver the bytes in the event; re-reading the OS clipboard can see a different image + /// (or none, on the web), so the menu path consumes this payload first. + pub(crate) pasted_image: Option>, + /// Monotonic host clock for playback, deadlines and hidden work. Never `ctx.input(i.time)`. + clock_start: web_time::Instant, + /// The latest time egui itself reported for a layout pass. Native and web hosts supply a + /// monotonic wall clock; deterministic harnesses supply their own stepped clock. Taking the + /// later of this and the host clock keeps simulated tests on their clock while a hidden + /// logic tick (whose `InputState::time` is stale) still advances by host time. + ui_clock: f64, + #[cfg(test)] + test_now: Option, /// macOS: a Control+Click already translated to a secondary (context) click, so its release is /// translated too even if Control is let go mid-click. AppKit delivers Control+Click to the /// view as a *primary* press with the Control modifier (`rightMouseDown:` only fires for a @@ -326,13 +394,18 @@ pub struct FilmcraftApp { /// The mouse press in progress (its start time) and the undo history when it began, for Escape /// to take back what the press changed (#580). value_press: Option<(f64, filmcraft_engine::UndoMark)>, + /// Escape was pressed during the current press in pass 0; apply the rollback after the final + /// non-discarded UI pass (pass 1+ has no key event to re-read). + escape_cancel: bool, /// BS.1770 loudness of the programme as it plays, and the next sample position to feed. pub(crate) loudness: Option<(filmcraft_audio_dsp::LoudnessMeter, i64)>, /// Status message last shown and when it first appeared (messages expire after a few seconds). status_seen: (String, f64), - /// Screenshots waiting for their frame: (token, path, crop, reply, give-up time). - pending_screenshots: Vec<(u64, Option, Option<[f32; 4]>, Sender, f64)>, - queued_screenshots: Vec<(u64, f64, u32)>, + /// Admitted `ui.screenshot` requests and their completion slots. + pub(crate) shots: capture::Shots, + /// Fresh raw-input surface geometry, refreshed in `raw_input_hook` for UI and logic callbacks. + pub(crate) capture_geometry: Option, + pub(crate) capture_epoch: u64, /// A paused monitor drew a stand-in (nearest cached) picture last frame: its exact frame is /// still decoding. pub(crate) monitor_inexact: bool, @@ -345,7 +418,6 @@ pub struct FilmcraftApp { /// the frame before the last one, so its timeline rects are final from 2 on. pub(crate) timeline_still: u32, input_waiters: Vec>, - next_token: u64, styled: bool, /// A panic in the UI pass, shown in an error window until dismissed (see [`crash`]). pub ui_error: Option, @@ -552,6 +624,8 @@ impl FilmcraftApp { let workspaces = session.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| dock::WorkspacePrefs::load(&d.join(dock::WORKSPACES_FILE))).unwrap_or_default(); let language = i18n::Language::parse(&session.prefs.general.interface_language).unwrap_or_default(); + let ime_document_epoch = session.document_epoch; + let ime_active_sequence = session.state.active_sequence; Self { session, ui: UiState { language, ..UiState::default() }, @@ -567,6 +641,15 @@ impl FilmcraftApp { file_dialogs: Default::default(), native_dialog: None, auto: Default::default(), + ime_context: ImeContextTracker::new(), + ime_replacement_epoch: 0, + ime_observed_pass: None, + ime_composition: None, + ime_interrupt_now: false, + ime_drawn_editor: None, + ime_document_epoch, + ime_active_sequence, + ime_viewports: Vec::new(), textures: HashMap::new(), thumb_used: HashMap::new(), control_rx: None, @@ -575,20 +658,30 @@ impl FilmcraftApp { native_window_focused: None, synthetic_modifiers: egui::Modifiers::NONE, deferred: Vec::new(), + ui_requests: Vec::new(), last_ui_time: 0.0, synthetic: Vec::new(), + synthetic_pending_step: None, + synthetic_step_offered: false, + synthetic_modifiers_offered: false, + pasted_image: None, + clock_start: web_time::Instant::now(), + ui_clock: 0.0, + #[cfg(test)] + test_now: None, mac_ctrl_click_armed: false, value_press: None, + escape_cancel: false, loudness: None, status_seen: (String::new(), 0.0), - pending_screenshots: Vec::new(), - queued_screenshots: Vec::new(), + shots: capture::Shots::default(), + capture_geometry: None, + capture_epoch: 0, monitor_inexact: false, monitor_asked: HashMap::new(), program_shown: None, timeline_still: 0, input_waiters: Vec::new(), - next_token: 1, styled: false, ui_error: None, panic_next_frame: false, @@ -628,6 +721,81 @@ impl FilmcraftApp { self } + /// Start a new replacement epoch for the Program monitor's text edit target. + /// + /// The owner key names the sequence, clip and component. A recreated edit session on the + /// same key (or a document/sequence replacement) must not inherit the previous IME context, + /// so the app advances this epoch and the tracker resets the generation once. + pub(crate) fn restart_ime_target(&mut self) { + self.ime_replacement_epoch = self.ime_replacement_epoch.wrapping_add(1); + self.invalidate_ime_composition(); + } + + /// Remember that the in-flight composition's target no longer applies. This includes an empty + /// preedit whose terminal commit/delete has not arrived yet. The next late commit or delete + /// from it is dropped; a rejected commit is a terminal boundary, so a fresh preedit after it + /// starts a new composition. A tentative boundary (empty preedit/delete) keeps the tombstone + /// through its raw input batch, so the paired old commit is rejected too. + pub(crate) fn invalidate_ime_composition(&mut self) { + if matches!(self.ime_composition, Some(ImeComposition::Active(_) | ImeComposition::TerminalPending(_))) { + self.ime_composition = Some(ImeComposition::Stale); + self.ime_interrupt_now = true; + } + } + + /// Notice a document replacement or an active-sequence change that did not emit + /// `OpenSequence` (`file.newProject`, a project with no sequence, `sequence.close`). The + /// edit target and any in-flight composition belong to the old context. + fn sync_ime_target_context(&mut self, ctx: &egui::Context) { + let document = self.session.document_epoch; + let sequence = self.session.state.active_sequence; + let replaced = document != self.ime_document_epoch; + if !replaced && sequence == self.ime_active_sequence { + return; + } + self.ime_document_epoch = document; + self.ime_active_sequence = sequence; + self.restart_ime_target(); + self.ui.gfx_edit = None; + self.ime_drawn_editor = None; + let text_edit = egui::Id::unique(panels::graphics::TEXT_EDIT_ID); + ctx.memory_mut(|memory| memory.surrender_focus(text_edit)); + ctx.data_mut(|data| data.remove::(text_edit.with("panel"))); + } + + /// Mark this viewport/pass as observed by the custom producer, so the end-of-pass fallback + /// does not observe it a second time. + pub(crate) fn note_ime_observed(&mut self, viewport: egui::ViewportId, pass_nr: u64) { + self.ime_observed_pass = Some((viewport, pass_nr)); + if !self.ime_viewports.contains(&viewport) { + self.ime_viewports.push(viewport); + } + } + + /// Observe `None` once for a pass where no visible, focused custom IME owner drew a + /// descriptor. This is the explicit hidden/unfocused signal the tracker needs; a gap alone + /// is a fallback, not a replacement for it. + pub(crate) fn finish_ime_pass(&mut self, ctx: &egui::Context) { + let viewport = ctx.viewport_id(); + let pass_nr = ctx.cumulative_pass_nr_for(viewport); + // The Program monitor deferred its observation until all control requests for this pass + // have run. It reports `true` even when it observed an explicit `None` for a hidden or + // replaced editor. + if panels::graphics::finish_graphics_ime(self, ctx) { + return; + } + if self.ime_observed_pass == Some((viewport, pass_nr)) { + return; + } + // No custom descriptor already disables its platform context; do not carry a stale + // one-shot interruption into a later fresh focus session. + if !ctx.will_discard() { + self.ime_interrupt_now = false; + } + self.ime_context.observe(viewport, None); + self.note_ime_observed(viewport, pass_nr); + } + /// Isolate an automation-owned app from native keyboard, pointer, drop and menu input. /// Window close/quit and screenshot delivery remain available. pub fn with_control_input_only(mut self, rx: Receiver) -> Self { @@ -639,6 +807,25 @@ impl FilmcraftApp { self.session.services.clone() } + /// Time used by logic-only ticks, playback and deadlines. + /// + /// The host clock is authoritative (egui's `InputState::time` is stale during + /// `Context::run_logic`), but an egui pass's reported time can be ahead of it: deterministic + /// harnesses step a simulated clock without sleeping. Taking the later value keeps those + /// runs deterministic while a hidden tick still moves on the monotonic host clock. + pub(crate) fn now(&self) -> f64 { + #[cfg(test)] + if let Some(now) = self.test_now { + return now; + } + self.clock_start.elapsed().as_secs_f64().max(self.ui_clock) + } + + #[cfg(test)] + pub(crate) fn set_now_for_test(&mut self, now: f64) { + self.test_now = Some(now); + } + /// Rebuild the frame server if the session's media pool was replaced (e.g. project opened). fn sync_pool(&mut self) { if !Arc::ptr_eq(&self.frames.pool, &self.session.media) || !Arc::ptr_eq(&self.frames.previews, &self.session.previews) { @@ -759,6 +946,7 @@ impl FilmcraftApp { self.ui.language = language; } i18n::set_current(self.ui.language); + ctx.set_language(Some(self.ui.language.bcp47().to_owned())); } self.frames.set_cache_budget(p.memory.frame_cache_mb as usize * (1 << 20)); self.ui.play_after_render = p.timeline.play_after_rendering; @@ -776,7 +964,7 @@ impl FilmcraftApp { } if (hardware_changed || pitch_changed) && self.playback.playing && self.playback.preroll.is_none() { self.playback.anchor_tick = self.session.playhead(); - let now = ctx.input(|i| i.time); + let now = self.now(); self.playback.anchor_time = now; self.playback.audio_seen = (0, now); self.start_audio(now); @@ -1039,7 +1227,7 @@ impl FilmcraftApp { if self.playback.playing { self.stop(); } - let a = self.audio.as_mut().ok_or("no audio output (check Settings ▸ Audio Hardware)")?; + let a = self.audio.as_mut().ok_or("no audio output (check Settings > Audio Hardware)")?; let sr = a.sample_rate().max(1); a.stop(); let step = f64::from(audio.sample_rate.max(1)) / f64::from(sr); @@ -1129,7 +1317,7 @@ impl FilmcraftApp { if !self.playback.playing { return; } - let now = ctx.input(|i| i.time); + let now = self.now(); if let Some(since) = self.playback.preroll { let since = if since < 0.0 { now } else { since }; self.playback.preroll = Some(since); @@ -1170,7 +1358,7 @@ impl FilmcraftApp { audio.stop(); } log::warn!("audio output lost its playback clock; playing without sound"); - self.ui.status = tl!("Audio output failed: playing without sound (check Settings ▸ Audio Hardware)").into(); + self.ui.status = tl!("Audio output failed: playing without sound (check Settings > Audio Hardware)").into(); } if let Some((f, sr)) = reading { if f != self.playback.audio_seen.0 { @@ -1186,7 +1374,7 @@ impl FilmcraftApp { a.stop(); } log::warn!("audio output stalled (no samples consumed for {AUDIO_STALL_S} s); playing without sound"); - self.ui.status = tl!("Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware)").into(); + self.ui.status = tl!("Audio output is not responding: playing without sound (check Settings > Audio Hardware)").into(); } } let elapsed = match reading { @@ -1538,12 +1726,14 @@ impl FilmcraftApp { fn divider_focused(&self, ctx: &egui::Context) -> bool { let Some(focused) = ctx.memory(|m| m.focused()) else { return false }; self.auto - .previous + .all() .iter() .any(|e| e.response_id == Some(focused) && (e.id.starts_with("dock.gutter.") || (e.id.starts_with("panel.float.") && e.id.ends_with(".resize")))) } fn handle_shortcuts(&mut self, ctx: &egui::Context) { + // A paste image payload belongs to the paste event in this pass only. + self.pasted_image = None; let workspaces = (dock::names(&self.workspaces), self.ui.workspace.clone()); if self.bindings_rev != self.session.shortcuts.revision || workspaces != self.menu_workspaces { self.menu_workspaces = workspaces; @@ -1586,6 +1776,14 @@ impl FilmcraftApp { } self.v_down = *pressed; } + egui::Event::PasteImage(image) => { + // Keep the delivered pixels; re-reading the OS clipboard later is racy and + // the web host has no clipboard-media hook at all. + self.pasted_image = Some(image.clone()); + if modifiers.command { + self.v_down = true; + } + } egui::Event::Paste(_) if modifiers.command => self.v_down = true, _ => {} } @@ -1657,6 +1855,8 @@ impl FilmcraftApp { self.ui.status = e; } } + // Only the paste action of this pass may consume the event's image. + self.pasted_image = None; } // ---------------------------------------------------------------- control channel @@ -1671,17 +1871,25 @@ impl FilmcraftApp { fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; - let now = ctx.input(|i| i.time); + let now = self.now(); let mut reqs: Vec<(ControlRequest, f64)> = std::mem::take(&mut self.deferred); while let Ok(req) = rx.try_recv() { // UI requests need rendered frames: raise the window if `ui` hasn't run recently // (occluded macOS windows stop running `ui`). if req.method.starts_with("ui.") && now - self.last_ui_time > 0.25 { self.raise_for_control(ctx); + // A hidden native window still needs a real UI pass for widget state/geometry. + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); } reqs.push((req, now + 3.0)); } for (req, deadline) in reqs { + if req.method.starts_with("ui.") && req.method != "ui.screenshot" { + self.ui_requests.push((req, deadline)); + ctx.request_repaint(); + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + continue; + } let reply = req.reply.clone(); match control::handle(self, ctx, &req) { control::Outcome::Done(v) => { @@ -1697,72 +1905,56 @@ impl FilmcraftApp { } control::Outcome::AfterInput => self.input_waiters.push(reply), control::Outcome::Screenshot { path, crop } => { - let token = self.next_token; - self.next_token += 1; - let settle = ctx.input(|i| i.time) + 0.25; - self.queued_screenshots.push((token, settle, 0)); - self.pending_screenshots.push((token, path, crop, reply, settle + SCREENSHOT_TIMEOUT_S)); + capture::admit(self, ctx, path, crop, reply); } } } self.control_rx = Some(rx); - } - - /// Capture once the UI shows the current state (`settled`): after a seek the monitor shows the - /// nearest cached picture until the exact frame is decoded, and an agent must not be handed - /// the stand-in. - fn issue_screenshots(&mut self, ctx: &egui::Context, settled: bool) { - let now = ctx.input(|i| i.time); - let mut any = false; - self.queued_screenshots.retain_mut(|(token, at, frames)| { - *frames += 1; - any = true; - if now >= *at && *frames >= 3 && (settled || now >= *at + SCREENSHOT_SETTLE_MAX_S) { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); - false - } else { - true - } - }); - // A hidden window (or a sleeping display) never presents, so its screenshot never - // arrives: give up instead of waiting (and repainting) forever. - self.pending_screenshots.retain(|(.., reply, deadline)| { + self.ui_requests.retain(|(req, deadline)| { if now > *deadline { - let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden or display asleep)"})); + let _ = req.reply.send(json!({"ok": false, "error": "no UI pass ran before the control request deadline"})); false } else { true } }); - if any { - ctx.request_repaint(); - } else if !self.pending_screenshots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } } - fn collect_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_screenshots.is_empty() { - return; - } - let events: Vec<_> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; - Some((token, image.clone())) + /// Process control requests that need the current layout/response. Called at the end of a + /// final, non-discarded UI pass; `ui.click`/`ui.drag` queue synthetic input for the next pass. + fn drain_ui_requests(&mut self, ctx: &egui::Context) { + let reqs = std::mem::take(&mut self.ui_requests); + let now = self.now(); + let mut pending = reqs.into_iter(); + while let Some((req, deadline)) = pending.next() { + // A panel/dock/resize request changes the next layout. Anything after it in this + // batch (ui.click, ui.elements, ui.screenshot, …) must see the new geometry, so it + // waits for the next accepted pass rather than reading the layout drawn before it. + let changes_layout = matches!(req.method.as_str(), "ui.set" | "ui.panel.show" | "ui.panel.close" | "ui.resize"); + let reply = req.reply.clone(); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Retry(msg) => { + if now < deadline { + self.ui_requests.push((req, deadline)); + ctx.request_repaint(); + } else { + let _ = reply.send(json!({"ok": false, "error": msg})); } - _ => None, - }) - .collect() - }); - for (token, image) in events { - if let Some(i) = self.pending_screenshots.iter().position(|(t, ..)| *t == token) { - let (_, path, crop, reply, _) = self.pending_screenshots.remove(i); - let r = control::save_screenshot(ctx, &image, path.as_deref(), crop); - let _ = reply.send(r); + } + control::Outcome::AfterInput => self.input_waiters.push(reply), + control::Outcome::Screenshot { path, crop } => capture::admit(self, ctx, path, crop, reply), + } + if changes_layout { + let rest: Vec<_> = pending.collect(); + if !rest.is_empty() { + self.ui_requests.extend(rest); + ctx.request_repaint(); + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + break; } } } @@ -1774,22 +1966,21 @@ impl FilmcraftApp { /// stamp the project's media files on a worker thread and read the changed ones again /// ([`filmcraft_engine::media_watch`]). #[cfg(not(target_arch = "wasm32"))] - fn tick_media_refresh(&mut self, ctx: &egui::Context) { + fn tick_media_refresh(&mut self, ctx: &egui::Context, now: f64) { if let Some(r) = self.session.poll_media_scan() { match r { Ok(r) => { if let Some(n) = r["refreshed"].as_array().map(Vec::len).filter(|n| *n > 0) { - self.toast = Some((format!("Refreshed {n} media item(s) changed on disk"), ctx.input(|i| i.time))); + self.toast = Some((format!("Refreshed {n} media item(s) changed on disk"), now)); } } - Err(e) => self.toast = Some((e.to_string(), ctx.input(|i| i.time))), + Err(e) => self.toast = Some((e.to_string(), now)), } } let media = &self.session.prefs.media; let (on, every) = (media.refresh_growing_files, f64::from(media.growing_refresh_seconds.max(1))); - let (last_key, focus_key) = (egui::Id::new("mediaWatch.lastScan"), egui::Id::new("mediaWatch.focused")); - let now = ctx.input(|i| i.time); - let focused = ctx.input(|i| i.focused); + let (last_key, focus_key) = (egui::Id::unique("mediaWatch.lastScan"), egui::Id::unique("mediaWatch.focused")); + let focused = self.native_window_focused.unwrap_or(false); let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); ctx.data_mut(|d| d.insert_temp(focus_key, focused)); if !on || filmcraft_engine::media_watch::media_files(&self.session.project, &self.session.media).is_empty() { @@ -1810,58 +2001,8 @@ impl FilmcraftApp { crash::injected_fault("injected UI fault"); } let ctx = ui.ctx().clone(); - self.auto.begin_frame(); - panels::metadata::follow_selection(self); - self.frames.set_context(&ctx); - self.session.poll_persistence(); - panels::trim_monitor::advance(self, &ctx); - if self.session.persistence.is_some() && self.session.is_dirty() { - // Keep polling the auto-save worker (status, "also save the project" results). - ctx.request_repaint_after(std::time::Duration::from_secs(1)); - } - self.sync_pool(); - self.apply_prefs(&ctx); - #[cfg(not(target_arch = "wasm32"))] - self.tick_media_refresh(&ctx); - for ev in self.session.drain_events() { - match ev { - filmcraft_engine::Event::OpenSequence(_) => { - // show the sequence with its own view (or fitted, the first time) - self.timeline_view_of = None; - if !self.ui.floating.iter().any(|group| group.panels.contains(&PanelKind::Timeline)) { - self.ui.dock.restore_timeline(); - } - let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Timeline }); - } - filmcraft_engine::Event::OpenSource(_) => { - let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Source }); - } - filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item), - filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))), - filmcraft_engine::Event::ProjectChanged { .. } => {} - } - } - self.sync_timeline_view(); - self.handle_drops(&ctx); - if let Some(rx) = self.command_inbox.take() { - while let Ok(id) = rx.try_recv() { - // control-only mode ignores native menu commands, but Quit stays available (#1943) - if self.control_input_only && id != "app.quit" { - continue; - } - if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) { - self.ui.status = e; - } - } - self.command_inbox = Some(rx); - } - self.begin_press(&ctx); - self.handle_shortcuts(&ctx); - self.sync_native_menu(); - self.advance_playback(&ctx); - self.advance_source_playback(&ctx); - self.scrub_audio(&ctx); - // Text to Speech: a selected narration clip loads into the panel + // Text to Speech panel selection and its repaint cadence are presentation state; they do + // not poll a background queue. panels::tts::follow_selection(self); if self.ui.tts.pending.is_some() || self.ui.tts.download_job.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(100)); @@ -1885,8 +2026,6 @@ impl FilmcraftApp { self.pending_timeline_drop = None; panels::dialogs::show(self, &ctx); if !self.ui.show_status_bar { - // no bar to draw the job in, but a finished preview render still plays - self.watch_jobs(ui.ctx()); return; } // Status / hint bar @@ -1904,7 +2043,7 @@ impl FilmcraftApp { } let hint = if !self.ui.status.is_empty() { self.ui.status.clone() } else { self.hint_text() }; ui.painter().text(egui::pos2(sb.min.x + 10.0, sb.center().y), egui::Align2::LEFT_CENTER, hint, Tokens::ui(11.0), t.text_dim); - let resp = ui.interact(sb, egui::Id::new("status-bar"), egui::Sense::click()); + let resp = ui.interact(sb, egui::Id::unique("status-bar"), egui::Sense::click()); if resp.clicked() { self.ui.status.clear(); } @@ -1913,7 +2052,7 @@ impl FilmcraftApp { /// The running job (export / render previews), if any; plays the rendered range when a preview /// render completes. Runs every frame, with or without the status bar. - fn watch_jobs(&mut self, ctx: &egui::Context) -> Option { + fn poll_jobs(&mut self, ctx: &egui::Context) -> Option { use std::sync::atomic::Ordering; let running = self.session.jobs.iter().rev().find(|j| !j.progress.finished.load(Ordering::Relaxed)).cloned(); // Play after rendering previews. @@ -1937,11 +2076,17 @@ impl FilmcraftApp { Some(job) } + /// The running job for drawing only; polling/play-after-render stays in logic. + fn running_job(&self) -> Option { + use std::sync::atomic::Ordering; + self.session.jobs.iter().rev().find(|j| !j.progress.finished.load(Ordering::Relaxed)).cloned() + } + /// Right side of the status bar: the running job (export / render previews) with a progress /// bar and a cancel button. fn job_status(&mut self, ui: &mut egui::Ui, sb: egui::Rect, t: &Tokens) { use std::sync::atomic::Ordering; - let Some(job) = self.watch_jobs(ui.ctx()) else { return }; + let Some(job) = self.running_job() else { return }; let f = job.progress.fraction().clamp(0.0, 1.0); let left = job.progress.eta().map(panels::left_text).unwrap_or_default(); let cancel = egui::Rect::from_center_size(egui::pos2(sb.max.x - 14.0, sb.center().y), egui::vec2(14.0, 14.0)); @@ -1957,7 +2102,7 @@ impl FilmcraftApp { Tokens::ui(11.0), t.text_dim, ); - let resp = ui.interact(cancel, egui::Id::new(("job-cancel", job.id)), egui::Sense::click()); + let resp = ui.interact(cancel, egui::Id::unique(("job-cancel", job.id)), egui::Sense::click()); let c = if resp.hovered() { t.hot_text } else { t.text_dim }; let k = 3.5; p.line_segment([cancel.center() - egui::vec2(k, k), cancel.center() + egui::vec2(k, k)], egui::Stroke::new(1.4, c)); @@ -2005,13 +2150,23 @@ impl FilmcraftApp { } } + /// Latch Escape during pass 0, where the key event is fresh. + fn latch_escape_cancel(&mut self, ctx: &egui::Context) { + if ctx.input(|i| i.pointer.any_down() && i.key_pressed(egui::Key::Escape)) { + self.escape_cancel = true; + } + } + /// Escape while a mouse button is held takes back every edit this press made (#580): value /// drags (one merged step or one step per change), color picks, anything committed on the - /// Escape frame. Run after the panels, so a value sent on the Escape frame goes with it. - fn cancel_value_drag(&mut self, ctx: &egui::Context) { - let escape = ctx.input(|i| i.pointer.any_down() && i.key_pressed(egui::Key::Escape)); + /// Escape frame. Called once after the final non-discarded UI pass; the latch outlives a + /// discarded pass, and pass 1+ has no Escape key event to re-read. + fn cancel_value_drag(&mut self, _ctx: &egui::Context) { + if !self.escape_cancel { + return; + } + self.escape_cancel = false; if let Some((press, mark)) = self.value_press - && escape && self.session.revert_to(mark) > 0 { self.value_press = Some((press, self.session.undo_mark())); @@ -2026,7 +2181,7 @@ impl FilmcraftApp { let Some(msg) = self.ui_error.clone() else { return }; let mut close = false; egui::Window::new(tl!("FilmCraft hit an error")) - .id(egui::Id::new("FilmCraft hit an error")) + .id(egui::Id::unique("FilmCraft hit an error")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -2089,27 +2244,32 @@ fn synthesize_mac_ctrl_click(armed: &mut bool, raw_input: &mut egui::RawInput) { } impl eframe::App for FilmcraftApp { - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { self.native_window_focused = Some(raw_input.focused); + capture::note_raw_geometry(self, ctx, raw_input); #[cfg(target_os = "macos")] if !self.control_input_only { synthesize_mac_ctrl_click(&mut self.mac_ctrl_click_armed, raw_input); } if self.control_input_only { - // Preserve screenshot responses and all viewport lifecycle/geometry metadata. - raw_input.events.retain(|event| matches!(event, egui::Event::Screenshot { .. })); + // Screenshot results arrive through `ScreenshotCallback`, not raw input. Preserve all + // viewport lifecycle/geometry metadata and drop native input only. + raw_input.events.clear(); raw_input.hovered_files.clear(); raw_input.dropped_files.clear(); } - if self.control_input_only || !self.synthetic.is_empty() { + if self.control_input_only || !self.synthetic.is_empty() || self.synthetic_pending_step.is_some() { // Native modifiers cannot replace those requested by a control gesture. A queued // reset runs in the frame after release, so clicked() still sees Shift/Alt. - raw_input.events.push(egui::Event::ModifiersChanged(self.synthetic_modifiers)); + if !self.synthetic_modifiers_offered { + raw_input.events.push(egui::Event::ModifiersChanged(self.synthetic_modifiers)); + self.synthetic_modifiers_offered = true; + } // Widget keyboard handling requires effective egui focus. Control input must work // in a background window without activating it or taking the user's OS keyboard. raw_input.focused = true; } - if !self.synthetic.is_empty() { + if self.synthetic_pending_step.is_none() && !self.synthetic.is_empty() { // Pointer events go one per frame so egui sees press → moves → release as a real drag // (all in one frame reads as a click); key/text runs go together up to a key release. let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); @@ -2121,16 +2281,32 @@ impl eframe::App for FilmcraftApp { .position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. })) .map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 }) }; - for event in self.synthetic.drain(..n) { + let step: Vec = self.synthetic.drain(..n).collect(); + for event in &step { if let egui::Event::ModifiersChanged(modifiers) = event { - self.synthetic_modifiers = modifiers; + self.synthetic_modifiers = *modifiers; } - raw_input.events.push(event); + } + self.synthetic_pending_step = Some(step); + self.synthetic_step_offered = false; + } + if let Some(step) = &self.synthetic_pending_step { + // `raw_input_hook` also runs before hidden `run_logic` ticks, whose raw input egui does + // not feed to widgets. Keep the exact same one-step batch pending and offer it again + // on the next callback; a real UI pass clears it at `ui()` start. Never coalesce a + // press with its release and, crucially, never append a step that the integration + // already preserved in its pending raw input: the UI pass would then see two copies. + if !self.synthetic_step_offered { + raw_input.events.extend(step.iter().cloned()); + self.synthetic_step_offered = true; } } } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Remember the pass's clock before anything reads `now()`. A hidden logic-only tick keeps + // the last UI time (which is stale there), so `now()` never runs behind the host clock. + self.ui_clock = self.ui_clock.max(ctx.input(|i| i.time)); // a native file dialog's answer first, so the panels draw what it chose (#260) self.poll_native_dialog(ctx); if !self.styled { @@ -2150,9 +2326,11 @@ impl eframe::App for FilmcraftApp { } else { self.fonts_ready = true; } - let now = ctx.input(|i| i.time); + // Monotonic host time. Native and web both call logic once per layout pass and through + // `run_logic` while hidden; `ctx.input(i.time)` is stale in that second case. + let now = self.now(); let dt = (now - self.last_time) as f32; - if dt > 0.0 { + if dt > 0.0 && dt.is_finite() { self.fps = self.fps * 0.9 + (1.0 / dt).min(480.0) * 0.1; } self.last_time = now; @@ -2160,68 +2338,173 @@ impl eframe::App for FilmcraftApp { // Nothing is shown while the window is hidden: not a dropped frame. self.playback.hidden = true; } - self.timeline_still = if self.ui.timeline.animating() { 0 } else { self.timeline_still.saturating_add(1) }; // Quitting with unsaved changes asks to save them first. if ctx.input(|i| i.viewport().close_requested()) && panels::clip_dialogs::intercept_quit(self) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } - let had_synthetic = !self.synthetic.is_empty(); + let had_synthetic = !self.synthetic.is_empty() || self.synthetic_pending_step.is_some(); self.drain_control(ctx); - if !self.synthetic.is_empty() && !had_synthetic { + if (!self.synthetic.is_empty() || self.synthetic_pending_step.is_some()) && !had_synthetic { // Occluded macOS windows stop running `ui`; bring the window forward (without taking // keyboard focus) so the input is processed. self.raise_for_control(ctx); } - if !self.synthetic.is_empty() { + if !self.synthetic.is_empty() || self.synthetic_pending_step.is_some() { ctx.request_repaint(); - } else if !self.input_waiters.is_empty() { - for w in self.input_waiters.drain(..) { - let _ = w.send(json!({"ok": true, "result": null})); + // The next step is consumed by a UI pass, not by another logic tick. Native eframe + // honours this; the pinned web runner logs it as unhandled (a recorded fork gap). + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + + // Background work: consumed queues and idempotent deadlines, safe on every logic call. + self.frames.set_context(ctx); + self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })); + self.session.poll_persistence(); + panels::trim_monitor::advance_at(self, now); + if self.session.trim_play.active() { + ctx.request_repaint(); + } + if self.session.persistence.is_some() && self.session.is_dirty() { + // Keep polling the auto-save worker (status, "also save the project" results) while hidden. + ctx.request_repaint_after(std::time::Duration::from_secs(1)); + } + self.sync_pool(); + #[cfg(not(target_arch = "wasm32"))] + self.tick_media_refresh(ctx, now); + for ev in self.session.drain_events() { + match ev { + filmcraft_engine::Event::OpenSequence(_) => { + // A sequence switch (including opening/reverting/recovering a project, which + // emits this event for the newly active sequence) is a new IME owner context. + self.restart_ime_target(); + // show the sequence with its own view (or fitted, the first time) + self.timeline_view_of = None; + if !self.ui.floating.iter().any(|group| group.panels.contains(&PanelKind::Timeline)) { + self.ui.dock.restore_timeline(); + } + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Timeline }); + } + filmcraft_engine::Event::OpenSource(_) => { + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Source }); + } + filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item), + filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, now)), + filmcraft_engine::Event::ProjectChanged { .. } => {} } } - let settled = !std::mem::take(&mut self.monitor_inexact) && self.timeline_still > 0; - self.issue_screenshots(ctx, settled); - self.collect_screenshots(ctx); + if let Some(rx) = self.command_inbox.take() { + while let Ok(id) = rx.try_recv() { + // control-only mode ignores native menu commands, but Quit stays available (#1943) + if self.control_input_only && id != "app.quit" { + continue; + } + if let Err(e) = menus::invoke(self, ctx, &id, json!({})) { + self.ui.status = e; + } + } + self.command_inbox = Some(rx); + } + self.advance_playback(ctx); + self.advance_source_playback(ctx); + self.poll_jobs(ctx); + capture::drain(self, ctx); + capture::request_hidden_paint(self, ctx); } fn on_exit(&mut self) { + // Late screenshot callbacks now find a dropped slot and write nothing; clients get one error. + capture::cancel_all(self); + for viewport in self.ime_viewports.drain(..) { + self.ime_context.end_viewport(viewport); + } + self.ime_observed_pass = None; // Flush the recovery journal and stop the auto-save worker; a session with nothing unsaved // removes its journal, one with unsaved changes keeps it for the next launch. self.session.shutdown(); } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { if !self.fonts_ready { + // This pass did not consume the pending synthetic input; the next hook must offer it + // again (the UI path does not preserve raw input across this early return). + self.synthetic_step_offered = false; + self.synthetic_modifiers_offered = false; + self.finish_ime_pass(ui.ctx()); ui.ctx().request_repaint(); return; } - // No frame worker threads on the web: render queued frames here, within a time budget - // that leaves room for the UI pass (a no-op where workers run). - self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })); + frame.set_ui_language(Some(self.ui.language.bcp47())); + let ctx = ui.ctx().clone(); + // A project/sequence replacement may have happened in the previous pass's control drain. + // Drop the old edit and IME composition before shortcuts or the frame see them. + self.sync_ime_target_context(&ctx); + // Each pass draws at most one editor; a stale entry from a previous pass must not be + // observed as this pass's editor. + self.ime_drawn_editor = None; + // Only an actual pass counts toward screenshot settlement; hidden logic ticks do not draw. + self.timeline_still = if self.ui.timeline.animating() { 0 } else { self.timeline_still.saturating_add(1) }; + // This is a real UI pass: the one synthetic step offered to raw_input_hook has been fed to + // egui now. A hidden logic tick never reaches this point and keeps it pending. + self.synthetic_pending_step = None; + self.synthetic_step_offered = false; + self.synthetic_modifiers_offered = false; + let first_pass = ctx.current_pass_index() == 0; + if first_pass { + // Setup and fresh-input handling happen where `RawInput::events` are available. Pass 1+ + // has no new events and must not re-run a click, shortcut or drop. + self.auto.begin_frame(); + self.apply_prefs(&ctx); + self.sync_timeline_view(); + panels::metadata::follow_selection(self); + self.handle_drops(&ctx); + self.begin_press(&ctx); + self.latch_escape_cancel(&ctx); + self.handle_shortcuts(&ctx); + self.sync_native_menu(); + self.scrub_audio(&ctx); + } else { + // Pass 1+ redraws the same frame; drop the discarded pass's registrations so lookups + // and `ui.elements` see one entry per widget, from the layout that is accepted. + self.auto.elements.clear(); + } // A panic in one panel must not close the app (losing unsaved work) or leave a blank // window: catch it, keep the session, and say what happened. if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.frame(ui))).is_err() { self.ui_error = Some(crash::take_last().unwrap_or_else(|| "unknown error".into())); self.playback.playing = false; } + if !ctx.will_discard() { + self.cancel_value_drag(&ctx); + } self.error_window(ui.ctx()); if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| graphics_notice::show(self, ui.ctx()))).is_err() { // a broken notice must not take the session down: drop it self.graphics_notice = None; } - let ctx = ui.ctx().clone(); - self.last_ui_time = ctx.input(|i| i.time); - if !self.synthetic.is_empty() { + self.last_ui_time = self.now(); + if !self.synthetic.is_empty() || self.synthetic_pending_step.is_some() { ctx.request_repaint(); - } else if !self.input_waiters.is_empty() { - ctx.request_repaint(); - for w in self.input_waiters.drain(..) { - let _ = w.send(json!({"ok": true, "result": null})); - } } if self.frames.queue_len() > 0 { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } + if !ctx.will_discard() { + self.drain_ui_requests(&ctx); + } + // Observe only after this pass's control requests ran: `ui.menu.invoke`/`engine.execute` + // may have edited the text or replaced the target. Every other pass (hidden editor, focus + // in another panel, no editor at all) reports an explicit `None`. + self.finish_ime_pass(&ctx); + // Ack `ui.*` waiters once the action had a final, non-discarded UI pass to apply in. Holding + // them through a discard avoids reporting success for a pass egui threw away. + if !ctx.will_discard() && self.synthetic.is_empty() && self.synthetic_pending_step.is_none() && !self.input_waiters.is_empty() { + for w in self.input_waiters.drain(..) { + let _ = w.send(json!({"ok": true, "result": null})); + } + } + // Only a real, accepted UI pass may issue a capture: it observes the final layout for this + // pass and `screenshot` itself forces the paint needed when the window is hidden. + capture::issue(self, &ctx); } } @@ -2315,9 +2598,9 @@ fn clipboard_events_as_keys(events: &mut Vec, modifiers: egui::Modi Event::Cut if modifiers.command => Some(Key::X), Event::Cut if modifiers.shift => Some(Key::Delete), Event::Cut => Some(Key::Cut), - Event::Paste(_) if modifiers.command => Some(Key::V), - Event::Paste(_) if modifiers.shift => Some(Key::Insert), - Event::Paste(_) => Some(Key::Paste), + Event::Paste(_) | Event::PasteImage(_) if modifiers.command => Some(Key::V), + Event::Paste(_) | Event::PasteImage(_) if modifiers.shift => Some(Key::Insert), + Event::Paste(_) | Event::PasteImage(_) => Some(Key::Paste), _ => None, }) .collect(); @@ -2340,6 +2623,10 @@ mod clipboard_key_tests { assert_eq!(keys(vec![Event::Copy], c), vec![(Key::C, c)]); assert_eq!(keys(vec![Event::Cut], c), vec![(Key::X, c)]); assert_eq!(keys(vec![Event::Paste("x".into())], c), vec![(Key::V, c)]); + let image = || Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::RED))); + assert_eq!(keys(vec![image()], c), vec![(Key::V, c)]); + assert_eq!(keys(vec![image()], Modifiers::SHIFT), vec![(Key::Insert, Modifiers::SHIFT)]); + assert_eq!(keys(vec![image()], Modifiers::NONE), vec![(Key::Paste, Modifiers::NONE)]); } #[test] @@ -2408,7 +2695,7 @@ mod mac_ctrl_click_tests { let mut secondary = false; // No renderer here: drop the pass output, clearing its texture deltas so epaint's drop // guard does not panic on the font texture. - let mut out = ctx.run_ui(raw, |ctx| secondary = ctx.input(|i| i.pointer.secondary_clicked())); + let mut out = ctx.run_ui(raw, |ui| secondary = ui.ctx().input(|i| i.pointer.secondary_clicked())); out.textures_delta.clear(); assert!(secondary); } @@ -2695,6 +2982,7 @@ mod audio_recovery_tests { app.playback.audio_clock = true; app.playback.anchor_tick = Tick::ZERO; app.playback.anchor_time = 0.0; + app.set_now_for_test(10.0); let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert!(!app.playback.audio_clock); @@ -2737,6 +3025,7 @@ mod audio_recovery_tests { app.playback.anchor_tick = Tick::ZERO; app.playback.anchor_time = 0.0; app.playback.audio_seen = (0, 10.0); + app.set_now_for_test(10.0); let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert!(app.playback.audio_clock, "the restarted stream is still the master clock"); @@ -2785,11 +3074,13 @@ mod audio_recovery_tests { assert!(app.playback.audio_clock && starts.load(Ordering::Relaxed) == 1); // the stream dies: the device stops reporting a playback clock ticking.store(false, Ordering::Relaxed); + app.set_now_for_test(10.0); let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert!(!app.playback.audio_clock); // the device recovers before the next retry: playback reopens it itself ticking.store(true, Ordering::Relaxed); + app.set_now_for_test(10.0 + AUDIO_RETRY_S + 0.5); let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0 + AUDIO_RETRY_S + 0.5), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert_eq!(starts.load(Ordering::Relaxed), 2, "the output is reopened without pause/play"); @@ -2811,6 +3102,7 @@ mod audio_recovery_tests { assert!(!app.playback.audio_clock, "no device yet: playing on the wall clock"); // headphones plug in while playing: the next retry opens them opens.store(true, Ordering::Relaxed); + app.set_now_for_test(AUDIO_RETRY_S + 0.5); let mut output = ctx.run_ui(egui::RawInput { time: Some(AUDIO_RETRY_S + 0.5), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert_eq!(starts.load(Ordering::Relaxed), 2, "the output is retried without pause/play"); @@ -2895,6 +3187,7 @@ mod voiceover_playback_tests { assert_eq!(app.session.playhead(), from); app.end_preroll(0.0); // and one second later the take is still playing and recording, not stopped at the end + app.set_now_for_test(1.0); let mut output = ctx.run_ui(egui::RawInput { time: Some(1.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); output.textures_delta.clear(); assert!(app.playback.playing); @@ -2972,3 +3265,79 @@ mod native_menu_tests { assert_eq!(last(&sent, "edit.redo"), Some(true), "the undone edit can be redone"); } } + +#[cfg(test)] +mod lifecycle_synthetic_tests { + use egui::{Event, Modifiers, PointerButton, pos2}; + use filmcraft_engine::Session; + + use super::FilmcraftApp; + + fn pointer_events(events: &[Event]) -> Vec { + events + .iter() + .filter_map(|event| match event { + Event::PointerMoved(pos) => Some(*pos), + _ => None, + }) + .collect() + } + + /// `raw_input_hook` runs before hidden logic ticks too, but those raw events are not fed to + /// widgets. The same one-step batch must remain pending, instead of advancing to the next + /// step or being silently lost; a real UI pass consumes it. + #[test] + fn one_synthetic_step_survives_hidden_logic_callbacks() { + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(Session::default()); + app.synthetic = vec![ + Event::PointerMoved(pos2(10.0, 10.0)), + Event::PointerMoved(pos2(20.0, 20.0)), + Event::PointerButton { pos: pos2(20.0, 20.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos: pos2(20.0, 20.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]; + let mut first = egui::RawInput::default(); + eframe::App::raw_input_hook(&mut app, &ctx, &mut first); + assert_eq!(pointer_events(&first.events), vec![pos2(10.0, 10.0)]); + assert_eq!(app.synthetic.len(), 3, "only one step was popped"); + assert_eq!(app.synthetic_pending_step.as_ref().map(Vec::len), Some(1)); + + // The integration keeps the raw input that did not reach widgets (native + // `pending_raw_input`, web `input.raw`) and replays it through the hook. + let mut hidden = first.clone(); + eframe::App::raw_input_hook(&mut app, &ctx, &mut hidden); + assert_eq!(pointer_events(&hidden.events), vec![pos2(10.0, 10.0)], "the preserved step is not appended twice"); + assert_eq!(app.synthetic.len(), 3, "a hidden callback must not advance the queue"); + + // A real UI pass consumes the pending step (App::ui clears it at its start). + app.synthetic_pending_step = None; + app.synthetic_step_offered = false; + app.synthetic_modifiers_offered = false; + let mut next = egui::RawInput::default(); + eframe::App::raw_input_hook(&mut app, &ctx, &mut next); + assert_eq!(pointer_events(&next.events), vec![pos2(20.0, 20.0)]); + assert_eq!(app.synthetic.len(), 2); + } + + /// eframe's hidden `run_logic` path saves the raw input (including our step) and hands it to + /// `raw_input_hook` again on the next hidden callback. The hook must not append the same step + /// a second time, or the eventual UI pass inserts text / clicks / runs a command more than once. + #[test] + fn hidden_callbacks_do_not_duplicate_a_step_preserved_in_raw_input() { + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(Session::default()); + app.synthetic = + vec![Event::Text("once".into()), Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]; + let mut first = egui::RawInput::default(); + eframe::App::raw_input_hook(&mut app, &ctx, &mut first); + let texts = |events: &[Event]| events.iter().filter(|e| matches!(e, Event::Text(t) if t == "once")).count(); + assert_eq!(texts(&first.events), 1); + + // What the integration preserves as pending_raw_input, then replays through the hook on + // the next hidden logic tick. + let mut preserved = first.clone(); + eframe::App::raw_input_hook(&mut app, &ctx, &mut preserved); + assert_eq!(texts(&preserved.events), 1, "the preserved raw input must keep exactly one copy"); + assert_eq!(app.synthetic_pending_step.as_ref().map(Vec::len), Some(2), "the queue did not advance"); + } +} diff --git a/crates/ui-egui/src/links.rs b/crates/ui-egui/src/links.rs index 37df98da1..1053b16eb 100644 --- a/crates/ui-egui/src/links.rs +++ b/crates/ui-egui/src/links.rs @@ -84,7 +84,7 @@ pub fn show_failed(app: &mut crate::FilmcraftApp, ctx: &egui::Context) { let mut close = false; let mut elems: Vec<(&str, egui::Rect, String)> = Vec::new(); egui::Window::new(tl!("Couldn't open your web browser")) - .id(egui::Id::new("failed-link")) + .id(egui::Id::unique("failed-link")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -97,7 +97,7 @@ pub fn show_failed(app: &mut crate::FilmcraftApp, ctx: &egui::Context) { ui.label(tl!("Open this address in your browser yourself:")); ui.horizontal(|ui| { let mut text = link.url.as_str(); - let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(340.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut text).desired_width(340.0)), "Link address"); elems.push(("failedLink.url", r.rect, link.url.clone())); let c = ui.button(tl!("Copy")); elems.push(("failedLink.copy", c.rect, "Copy".into())); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d4742ff59..6a252bdfc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -641,8 +641,20 @@ fn put_clip_names_on_system_clipboard(app: &FilmcraftApp, ctx: &egui::Context) { /// Paste the files or the image on the system clipboard: placed on the Timeline when it has focus, /// imported into the shown bin in the Project panel. None when the clipboard holds neither. fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option> { - let media = (app.hooks.clipboard_media.as_mut()?)()?; - if app.stale_clipboard_media.as_ref() == Some(&clipboard_media_key(&media)) { + // The paste event already carries the image bytes. Prefer them: re-reading the OS clipboard + // is racy (it may have changed) and the web host installs no clipboard-media hook. + let event_media = match app.pasted_image.take() { + Some(image) => match paste_image_media(&image) { + Ok(media) => Some(media), + Err(error) => return Some(Err(error)), + }, + None => None, + }; + let (media, from_event) = match event_media { + Some(media) => (media, true), + None => ((app.hooks.clipboard_media.as_mut()?)()?, false), + }; + if !from_event && app.stale_clipboard_media.as_ref() == Some(&clipboard_media_key(&media)) { return None; } let paths = match media { @@ -658,6 +670,24 @@ fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option Result { + let [width, height] = image.size; + let len = + width.checked_mul(height).and_then(|pixels| pixels.checked_mul(4)).ok_or_else(|| "the pasted image size overflows the clipboard buffer".to_string())?; + let mut rgba = Vec::with_capacity(len); + for c in &image.pixels { + // `Color32` is premultiplied internally; PNG/RgbaImage expect straight alpha. Round-trip + // through the straight sRGB form so a semi-transparent clipboard pixel keeps its colour. + let [r, g, b, a] = c.to_srgba_unmultiplied(); + rgba.extend_from_slice(&[r, g, b, a]); + } + if rgba.len() != len { + return Err("the pasted image does not match its reported size".into()); + } + Ok(crate::ClipboardMedia::Image { width, height, rgba }) +} + /// A compressed PNG of a clipboard image; an error when its size and pixels disagree. fn encode_png(width: usize, height: usize, rgba: Vec) -> Result, String> { let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?); @@ -898,7 +928,7 @@ pub fn external_commands() -> Vec { /// Draw the in-window menu bar. pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("interface-language"), app.ui.language)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("interface-language"), app.ui.language)); let items = menu_items(app); // Draw Edit ▸ Label with the user's live palette, not the baked-in label colours. let labels = app.session.prefs.labels.clone(); @@ -959,7 +989,7 @@ fn menu_level( } subs.push(sub); let inner: Vec<&MenuItem> = items.iter().copied().filter(|x| x.path.get(depth).map(String::as_str) == Some(sub)).collect(); - let language = ui.ctx().data(|d| d.get_temp::(egui::Id::new("interface-language"))).unwrap_or_default(); + let language = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("interface-language"))).unwrap_or_default(); ui.menu_button(language.tr(sub), |ui| { ui.set_min_width(220.0); menu_level(ui, &inner, depth + 1, clicked, auto, labels); @@ -1084,6 +1114,21 @@ mod parse_shortcut_tests { mod tests { use super::*; + /// A clipboard image is premultiplied inside `Color32`. The PNG writer expects straight + /// alpha, so a semi-transparent pixel must come back with its colour, not darkened. + #[test] + fn pasted_images_keep_straight_alpha_colour() { + // Red at 50% opacity: premultiplied storage is about (128, 0, 0, 128). + let image = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[255, 0, 0, 128]); + let media = paste_image_media(&image).expect("a 1×1 clipboard image"); + let crate::ClipboardMedia::Image { width, height, rgba } = media else { panic!("not an image") }; + assert_eq!((width, height), (1, 1)); + assert_eq!(rgba, vec![255, 0, 0, 128], "straight RGBA, not the premultiplied bytes"); + let png = encode_png(width, height, rgba).expect("a PNG"); + let decoded = image::load_from_memory(&png).expect("decode the pasted PNG").to_rgba8(); + assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 128]); + } + /// What `cargo xtask parity` measures: every item sits under a top-level menu, and Window ▸ /// Workspaces lists the built-in workspaces before the layout commands, none checked. #[test] diff --git a/crates/ui-egui/src/panels/audio_fx_editor.rs b/crates/ui-egui/src/panels/audio_fx_editor.rs index ba86d691a..8daec9b11 100644 --- a/crates/ui-egui/src/panels/audio_fx_editor.rs +++ b/crates/ui-egui/src/panels/audio_fx_editor.rs @@ -191,13 +191,13 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let kind = if matches!(target, FxTarget::Clip { .. }) { tl!("Clip Fx Editor") } else { tl!("Track Fx Editor") }; let title = format!("{kind} - {name}: {place}"); let k = key(&target); - let drafts_id = egui::Id::new(("fx-editor-drafts", &k)); + let drafts_id = egui::Id::unique(("fx-editor-drafts", &k)); let drafts: HashMap = ctx.data(|d| d.get_temp(drafts_id)).unwrap_or_default(); let fx_id = inst.effect.clone(); let mut ed = Ed { t: app.tokens, fx: &fx_id, target: &target, inst, mt, drafts, acts: Vec::new(), autos: Vec::new() }; let mut open = true; let mut closed = false; - egui::Window::new(title).id(egui::Id::new(("fx-editor", &k))).open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| { + egui::Window::new(title).id(egui::Id::unique(("fx-editor", &k))).open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| { match ed.fx { "parametric_eq" => parametric(ui, &mut ed), "graphic_eq" | "graphic_eq_20" | "graphic_eq_30" => graphic(ui, &mut ed), @@ -322,7 +322,7 @@ fn parametric(ui: &mut egui::Ui, ed: &mut Ed) { let g = if gain { ed.v(&g_id) } else { 0.0 }; let c = pos2(x_of(f, r), y_of(g, r, range)); let nr = Rect::from_center_size(c, vec2(16.0, 16.0)); - let id = egui::Id::new(("peq-node", ed.target.clone(), pre)); + let id = egui::Id::unique(("peq-node", ed.target.clone(), pre)); let resp = ui.interact(nr, id, Sense::click_and_drag()); let on = ed.on(&on_id); let col = if on { ed.t.eq_node } else { ed.t.eq_node_off }; @@ -390,7 +390,7 @@ fn graphic(ui: &mut egui::Ui, ed: &mut Ed) { let v = ed.v(&pid); let ky = track.center().y - (v / 24.0) as f32 * track.height() * 0.5; let knob = Rect::from_center_size(pos2(x, ky), vec2(colw.min(18.0), 8.0)); - let id = egui::Id::new(("geq-band", ed.target.clone(), i)); + let id = egui::Id::unique(("geq-band", ed.target.clone(), i)); let resp = ui.interact(knob.union(track).expand(3.0), id, Sense::click_and_drag()); ui.painter().rect_filled(knob, 2.0, if resp.hovered() || resp.dragged() { ed.t.eq_knob_active } else { ed.t.eq_knob }); ui.painter().text(pos2(x, area.max.y - 22.0), Align2::CENTER_CENTER, format!("{v:+.0}"), Tokens::ui(8.5), ed.t.text_dim); @@ -470,7 +470,7 @@ fn multiband(ui: &mut egui::Ui, ed: &mut Ed) { for (k, pid) in ["xo1", "xo2", "xo3"].iter().enumerate() { let x = edges[k + 1]; let hr = Rect::from_center_size(pos2(x, r.center().y), vec2(10.0, r.height())); - let resp = ui.interact(hr, egui::Id::new(("mb-xo", ed.target.clone(), k)), Sense::drag()); + let resp = ui.interact(hr, egui::Id::unique(("mb-xo", ed.target.clone(), k)), Sense::drag()); ui.painter() .line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(if resp.hovered() || resp.dragged() { 2.5 } else { 1.5 }, ed.t.crossover_handle)); ui.painter().text(pos2(x + 3.0, r.min.y + 2.0), Align2::LEFT_TOP, format!("{:.0} Hz", xo[k]), Tokens::ui(9.0), ed.t.text_dim); diff --git a/crates/ui-egui/src/panels/clip_dialogs.rs b/crates/ui-egui/src/panels/clip_dialogs.rs index 5d64f4254..b5874a731 100644 --- a/crates/ui-egui/src/panels/clip_dialogs.rs +++ b/crates/ui-egui/src/panels/clip_dialogs.rs @@ -41,7 +41,9 @@ use crate::state::ClipDialogDraft; type Elems = Vec<(String, egui::Rect, String)>; fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: impl Into) { - elems.push((id.into(), r.rect, label.into())); + let label = label.into(); + crate::widgets::accessible_name(r.clone(), label.clone()); + elems.push((id.into(), r.rect, label)); } /// Dialog title and automation prefix of a command's dialog. @@ -300,7 +302,7 @@ fn frames(ui: &mut egui::Ui, elems: &mut Elems, pre: &str, p: &mut Value, key: & /// Draw the open dialog, if any. pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.clip_dialog.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("clip-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("clip-dialog-before-drag"), &mut d); let Some((title, pre)) = meta(&d.command) else { app.ui.clip_dialog = None; return; @@ -311,7 +313,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { // a never-saved project is saved through the Save dialog let can_save = app.session.path.is_some() || app.hooks.pick_save.is_some(); // the title is translated; the window keeps one id whatever the interface language - let id = egui::Id::new(("clip-dialog", pre)); + let id = egui::Id::unique(("clip-dialog", pre)); egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(360.0).centered(ctx).show(ctx, |ui| { let p = &mut d.params; match d.command.as_str() { @@ -375,8 +377,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut v = p["name"].as_str().unwrap_or_default().to_string(); ui.horizontal(|ui| { ui.label(tl!("Name:")); - let id = egui::Id::new("nest-name"); - let r = ui.add(egui::TextEdit::singleline(&mut v).desired_width(240.0).id(id)); + let id = egui::Id::unique("nest-name"); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut v).desired_width(240.0).id(id)), tl!("Name:")); push(&mut elems, format!("{pre}.name"), &r, "Name:"); if r.changed() { p["name"] = json!(v); @@ -384,7 +386,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { action = Some("ok"); } - let first = egui::Id::new("nest-name-focused"); + let first = egui::Id::unique("nest-name-focused"); if !ui.data(|d| d.get_temp::(first).unwrap_or(false)) { ui.data_mut(|d| d.insert_temp(first, true)); r.request_focus(); @@ -587,7 +589,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { action = Some("cancel"); } if action.is_some() { - ctx.data_mut(|d| d.remove::(egui::Id::new("nest-name-focused"))); + ctx.data_mut(|d| d.remove::(egui::Id::unique("nest-name-focused"))); } match action { Some("cancel") => { diff --git a/crates/ui-egui/src/panels/color_dialogs.rs b/crates/ui-egui/src/panels/color_dialogs.rs index 9ee1c809f..a883b9843 100644 --- a/crates/ui-egui/src/panels/color_dialogs.rs +++ b/crates/ui-egui/src/panels/color_dialogs.rs @@ -71,7 +71,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { .unwrap_or_else(|| "—".into()); let n = items.len(); egui::Window::new(tl!("Interpret Footage")) - .id(egui::Id::new("Interpret Footage")) + .id(egui::Id::unique("Interpret Footage")) .open(&mut open) .collapsible(false) .resizable(false) @@ -102,7 +102,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } ColorDialog::Sequence { working_space, wide_gamut, auto_tone_map } => { egui::Window::new(tl!("Sequence Color Management")) - .id(egui::Id::new("Sequence Color Management")) + .id(egui::Id::unique("Sequence Color Management")) .open(&mut open) .collapsible(false) .resizable(false) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 8bc23dbc3..04495ebfd 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -34,7 +34,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { match d { Dialog::About => { egui::Window::new(tl!("About FilmCraft")) - .id(egui::Id::new("About FilmCraft")) + .id(egui::Id::unique("About FilmCraft")) .open(&mut open) .collapsible(false) .resizable(false) @@ -94,7 +94,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { /// Automation ids: `audioGain.` (radio buttons), /// `audioGain..value` (dB fields), `audioGain.peak`, `audioGain.ok`, `audioGain.cancel`. fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { - let peak_id = egui::Id::new("audio-gain-peak"); + let peak_id = egui::Id::unique("audio-gain-peak"); let rev = app.session.revision; let cached: Option<(u64, Option)> = ctx.data(|d| d.get_temp(peak_id)); let peak = match cached { @@ -106,12 +106,12 @@ fn audio_gain(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { } }; let mut draft = app.ui.audio_gain.clone(); - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("audio-gain-before-drag"), &mut draft); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("audio-gain-before-drag"), &mut draft); let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); egui::Window::new(tl!("Audio Gain")) - .id(egui::Id::new("Audio Gain")) + .id(egui::Id::unique("Audio Gain")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -192,14 +192,14 @@ fn add_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let names = |tracks: &[filmcraft_engine::project::Track]| tracks.iter().map(|t| t.name.clone()).collect::>(); let (vnames, anames, snames) = (names(&seq.video_tracks), names(&seq.audio_tracks), names(&seq.submix_tracks)); let mut d = app.ui.add_tracks.clone(); - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("add-tracks-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("add-tracks-before-drag"), &mut d); let mut keep = true; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); let audio_types: [(&str, &str); 4] = [("standard", tl!("Standard")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))]; let submix_types: [(&str, &str); 4] = [("stereo", tl!("Stereo")), ("5.1", "5.1"), ("adaptive", tl!("Adaptive")), ("mono", tl!("Mono"))]; egui::Window::new(tl!("Add Tracks")) - .id(egui::Id::new("Add Tracks")) + .id(egui::Id::unique("Add Tracks")) .collapsible(false) .resizable(false) .default_width(320.0) @@ -330,7 +330,7 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); egui::Window::new(tl!("Delete Tracks")) - .id(egui::Id::new("Delete Tracks")) + .id(egui::Id::unique("Delete Tracks")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -416,7 +416,7 @@ fn delete_tracks(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { /// Automation ids: `about.tab.about`, `about.tab.contributors`, `about.tab.models`; the About tab's /// links and the credits controls are listed on [`about_tab`] and [`crate::credits::contributors_ui`]. fn about(app: &mut FilmcraftApp, ui: &mut egui::Ui) { - let tab_id = egui::Id::new("about_tab"); + let tab_id = egui::Id::unique("about_tab"); let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); ui.horizontal(|ui| { for (i, (id, label)) in [("about", tl!("About")), ("contributors", tl!("Contributors")), ("models", tl!("Models"))].into_iter().enumerate() { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 887768294..eef9bcac1 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -228,11 +228,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // only the arrow twirls the effect open or shut; the rest of the header selects it let tw = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(10.0, 10.0)); icons::paint(bui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); - let twresp = bui.interact(tw.expand(3.0), egui::Id::new(("fx-twirl", clip.0, idx)), Sense::click()); + let twresp = bui.interact(tw.expand(3.0), egui::Id::unique(("fx-twirl", clip.0, idx)), Sense::click()); app.auto.add(&format!("effectControls.effect.{}.twirl", e.effect), tw, "twirl"); // fx enable toggle let fxr = Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(18.0, 14.0)); - let fxresp = bui.interact(fxr, egui::Id::new(("fxen", clip.0, idx)), Sense::click()); + let fxresp = bui.interact(fxr, egui::Id::unique(("fxen", clip.0, idx)), Sense::click()); bui.painter().text(fxr.center(), Align2::CENTER_CENTER, "fx", Tokens::semibold(10.5), if e.enabled { t.text } else { t.text_faint }); if !e.enabled { bui.painter().line_segment([fxr.left_bottom(), fxr.right_top()], Stroke::new(1.0, t.text_faint)); @@ -244,7 +244,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { row_label(bui, pos2(r.min.x + 42.0, r.center().y), crate::i18n::t(def.name), r.max.x - 26.0 - (r.min.x + 42.0), Tokens::ui(12.0), t.text); // reset button let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); - let rresp = bui.interact(rr, egui::Id::new(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect")); + let rresp = bui.interact(rr, egui::Id::unique(("fxreset", clip.0, idx)), Sense::click()).on_hover_text(tl!("Reset Effect")); icons::paint(bui.painter(), rr.shrink(2.0), Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); if rresp.clicked() { actions.push(("effects.reset".into(), json!({"clip": clip.0, "index": idx}))); @@ -307,7 +307,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().with_clip_rect(bar).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(10.0), t.text); // click or drag anywhere on the ruler (or the bar) to move the playhead (after the rows, which // reach under it, so it gets the pointer first) - let sresp = ui.interact(scrub, egui::Id::new(("ec-scrub", clip.0)), Sense::click_and_drag()); + let sresp = ui.interact(scrub, egui::Id::unique(("ec-scrub", clip.0)), Sense::click_and_drag()); if (sresp.dragged() || sresp.clicked()) && let Some(pos) = sresp.interact_pointer_pos() { @@ -318,7 +318,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // the divider: a line the height of the panel, a resize cursor and a wider grab area let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y - 26.0)); - let dresp = ui.interact(divider, egui::Id::new("ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let dresp = ui.interact(divider, egui::Id::unique("ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); if dresp.dragged() { app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list); } @@ -374,7 +374,7 @@ fn custom_setup_row(app: &mut FilmcraftApp, ui: &mut egui::Ui, body: Rect, lane: row_line(ui, r, lane, &t); ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, tl!("Custom Setup"), Tokens::ui(12.0), t.text_dim); let br = Rect::from_min_size(pos2(r.min.x + 160.0, r.min.y + 2.0), vec2(60.0, ROW_H - 4.0)); - let resp = ui.interact(br, egui::Id::new(("fx-custom-setup", clip.0, idx)), Sense::click()); + let resp = ui.interact(br, egui::Id::unique(("fx-custom-setup", clip.0, idx)), Sense::click()); ui.painter().rect_filled(br, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().rect_stroke(br, 3.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); ui.painter().text(br.center(), Align2::CENTER_CENTER, tl!("Edit…"), Tokens::ui(11.5), t.text); @@ -511,7 +511,7 @@ fn properties_nav( _ => "Turn on animation and add keyframe", }; let at = pos2(row.max.x - PROPS_NAV_X, row.center().y); - let id = egui::Id::new(("props-nav", effect, param, clip.0)); + let id = egui::Id::unique(("props-nav", effect, param, clip.0)); if keyframe_nav(app, ui, at, prm, mt, it, id, &format!("properties.{effect}.{param}"), tip, actions) { if it.effect(effect).is_none() { actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": effect}))); @@ -568,7 +568,7 @@ pub(crate) fn param_row( let key = graph_key(clip, idx, pkey); let open = app.ui.expanded_fx.contains(&key); let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)); - let tresp = ui.interact(tw.expand(2.0), egui::Id::new(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Show graphs")); + let tresp = ui.interact(tw.expand(2.0), egui::Id::unique(("twirl", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Show graphs")); icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); app.auto.add(&format!("effectControls.{}.{}.graphs", e.effect, pkey), tw, "Show graphs"); if tresp.clicked() { @@ -581,7 +581,7 @@ pub(crate) fn param_row( } if pd.animatable { let sw = Rect::from_center_size(pos2(x, r.center().y), vec2(14.0, 14.0)); - let resp = ui.interact(sw, egui::Id::new(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Toggle animation")); + let resp = ui.interact(sw, egui::Id::unique(("sw", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Toggle animation")); icons::paint(ui.painter(), sw, Icon::Stopwatch, if param.is_animated() { t.accent } else { t.text_dim }); if resp.clicked() { actions.push(("effects.toggleAnimation".into(), with_mask(json!({"clip": clip.0, "effect": idx, "param": pd.id})))); @@ -597,25 +597,27 @@ pub(crate) fn param_row( .max_rect(Rect::from_min_max(pos2(vx, r.min.y + 1.0), pos2(r.max.x - 26.0, r.max.y - 1.0))) .layout(egui::Layout::left_to_right(egui::Align::Center)), ); - let id = egui::Id::new(("pv", clip.0, idx, pkey)); + let id = egui::Id::unique(("pv", clip.0, idx, pkey)); let mut set: Option = None; match (&pd.kind, &value) { (ParamKind::Float { min, max, soft_min, soft_max, unit, decimals }, ParamValue::Float(v)) => { let speed = ((soft_max - soft_min) / 400.0).max(0.01); - let (_, nv) = crate::widgets::hot_number(&mut vui, id, *v, speed, (*min, *max), *decimals as usize, unit, &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, crate::i18n::t(pd.label), id, *v, speed, (*min, *max), *decimals as usize, unit, &t); if let Some(nv) = nv { set = Some(json!(nv)); } } (ParamKind::Angle, ParamValue::Float(v)) => { - let (_, nv) = crate::widgets::hot_number(&mut vui, id, *v, 0.5, (-36000.0, 36000.0), 1, "°", &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, crate::i18n::t(pd.label), id, *v, 0.5, (-36000.0, 36000.0), 1, "°", &t); if let Some(nv) = nv { set = Some(json!(nv)); } } (ParamKind::Point, ParamValue::Vec2(p)) => { - let (_, nx) = crate::widgets::hot_number(&mut vui, id.with("x"), p.x, 1.0, (-100_000.0, 100_000.0), 1, "", &t); - let (_, ny) = crate::widgets::hot_number(&mut vui, id.with("y"), p.y, 1.0, (-100_000.0, 100_000.0), 1, "", &t); + let (_, nx) = + crate::widgets::hot_number(&mut vui, &format!("{} X", crate::i18n::t(pd.label)), id.with("x"), p.x, 1.0, (-100_000.0, 100_000.0), 1, "", &t); + let (_, ny) = + crate::widgets::hot_number(&mut vui, &format!("{} Y", crate::i18n::t(pd.label)), id.with("y"), p.y, 1.0, (-100_000.0, 100_000.0), 1, "", &t); if nx.is_some() || ny.is_some() { set = Some(json!([nx.unwrap_or(p.x), ny.unwrap_or(p.y)])); } @@ -623,7 +625,7 @@ pub(crate) fn param_row( (ParamKind::Bool, ParamValue::Bool(b)) => { let mut v = *b; fit_to_row(&mut vui); - if vui.checkbox(&mut v, "").changed() { + if crate::widgets::accessible_name(vui.checkbox(&mut v, ""), crate::i18n::t(pd.label)).changed() { set = Some(json!(v)); } } @@ -632,13 +634,14 @@ pub(crate) fn param_row( fit_to_row(&mut vui); // (narrower when the effect list is narrow, so it stays left of the divider) let w = vui.available_width().min(130.0); - egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(w).show_ui(&mut vui, |ui| { + let combo = egui::ComboBox::from_id_salt(id).selected_text(opts.get(sel).map_or("", |o| crate::i18n::t(o))).width(w).show_ui(&mut vui, |ui| { for (i, o) in opts.iter().enumerate() { if ui.selectable_value(&mut sel, i, crate::i18n::t(o)).changed() { set = Some(json!(i)); } } }); + crate::widgets::accessible_name(combo.response, crate::i18n::t(pd.label)); } (ParamKind::Color, ParamValue::Color(c)) => { let mut rgba = egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]); @@ -660,7 +663,7 @@ pub(crate) fn param_row( (ParamKind::Text, ParamValue::Text(s)) if !pd.id.ends_with("_lut") => { // edited in a buffer; committed (one undo step) when the field loses focus let mut buf = vui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| s.clone()); - let r = vui.add(egui::TextEdit::singleline(&mut buf).desired_width(160.0)); + let r = crate::widgets::accessible_name(vui.add(egui::TextEdit::singleline(&mut buf).desired_width(160.0)), pd.label); app.auto.add(&format!("effectControls.text.{}.{}", idx, pd.id), r.rect, pd.label); if r.lost_focus() { if buf != *s { @@ -685,7 +688,7 @@ pub(crate) fn param_row( if pd.animatable && param.is_animated() { let at = pos2(r.max.x - 42.0, r.center().y); let auto = format!("effectControls.{}.{}", e.effect, pkey); - if keyframe_nav(app, ui, at, Some(param), mt, it, egui::Id::new(("knav", clip.0, idx, pkey)), &auto, "Add/Remove Keyframe", actions) { + if keyframe_nav(app, ui, at, Some(param), mt, it, egui::Id::unique(("knav", clip.0, idx, pkey)), &auto, "Add/Remove Keyframe", actions) { actions.push(("effects.addKeyframe".into(), with_mask(json!({"clip": clip.0, "effect": eff_json, "param": pd.id})))); } } @@ -693,7 +696,7 @@ pub(crate) fn param_row( // keyframes and gets one at the playhead) if mask.is_none() { let rr = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(12.0, 12.0)); - let rresp = ui.interact(rr, egui::Id::new(("param-reset", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Reset Parameter")); + let rresp = ui.interact(rr, egui::Id::unique(("param-reset", clip.0, idx, pkey)), Sense::click()).on_hover_text(tl!("Reset Parameter")); icons::paint(ui.painter(), rr, Icon::Reset, if rresp.hovered() { t.text } else { t.text_dim }); app.auto.add(&format!("effectControls.{}.{}.reset", e.effect, pkey), rr, "Reset Parameter"); if rresp.clicked() { @@ -710,7 +713,7 @@ pub(crate) fn param_row( if !(-0.01..=1.01).contains(&f) { continue; } - let id = egui::Id::new(("kf", clip.0, idx, pkey, k.time.0)); + let id = egui::Id::unique(("kf", clip.0, idx, pkey, k.time.0)); let original = Rect::from_center_size(pos2(lane.min.x + f * lane.width(), y), vec2(15.0, 15.0)); let resp = ui.interact(original.expand(2.0), id, Sense::click_and_drag()); let reference = KeyframeRef { clip: clip.0, effect: idx, param: pd.id.to_string(), mask, time: k.time }; @@ -796,7 +799,7 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { for g in groups { let key = format!("lumetri:{g}"); let open = !app.ui.collapsed_fx.contains(&key); - let (resp, now_open) = crate::widgets::section_header(ui, egui::Id::new(&key), crate::i18n::t(g), open, &t, true); + let (resp, now_open) = crate::widgets::section_header(ui, egui::Id::unique(&key), crate::i18n::t(g), open, &t, true); app.auto.add(&format!("lumetri.section.{g}"), resp.rect, g); if now_open != open { if now_open { @@ -816,8 +819,17 @@ pub fn lumetri_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if let (ParamKind::Float { min, max, soft_min, soft_max, .. }, ParamValue::Float(x)) = (&pd.kind, &v) { let mut val = *x; let s = ui.add(egui::Slider::new(&mut val, *soft_min..=*soft_max).show_value(false)); - let (_, nv) = - crate::widgets::hot_number(ui, egui::Id::new(("lum", pd.id)), val, (soft_max - soft_min) / 300.0, (*min, *max), 1, "", &t); + let (_, nv) = crate::widgets::hot_number( + ui, + crate::i18n::t(pd.label), + egui::Id::unique(("lum", pd.id)), + val, + (soft_max - soft_min) / 300.0, + (*min, *max), + 1, + "", + &t, + ); if s.changed() { actions.push(json!({"clip": clip.0, "effect": idx, "param": pd.id, "value": val})); } else if let Some(nv) = nv { @@ -878,7 +890,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some((idx, c)) = reset { let rr = Rect::from_center_size(pos2(r.max.x - PROPS_NAV_X, r.center().y), vec2(14.0, 14.0)); icons::paint(ui.painter(), rr, Icon::Reset, t.text_dim); - if ui.interact(rr, egui::Id::new(("props-reset", name, c)), Sense::click()).clicked() { + if ui.interact(rr, egui::Id::unique(("props-reset", name, c)), Sense::click()).clicked() { actions.push(("effects.reset".into(), json!({"clip": c, "index": idx}))); } } @@ -913,9 +925,29 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ); match val("motion", p) { Some(ParamValue::Vec2(v)) => { - let (_, nx) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "x", clip.0)), v.x, speed, range, 0, " X", &t); + let (_, nx) = crate::widgets::hot_number( + &mut vui, + &format!("{label} X"), + egui::Id::unique(("pp", p, "x", clip.0)), + v.x, + speed, + range, + 0, + " X", + &t, + ); vui.add_space(10.0); - let (_, ny) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp", p, "y", clip.0)), v.y, speed, range, 0, " Y", &t); + let (_, ny) = crate::widgets::hot_number( + &mut vui, + &format!("{label} Y"), + egui::Id::unique(("pp", p, "y", clip.0)), + v.y, + speed, + range, + 0, + " Y", + &t, + ); if nx.is_some() || ny.is_some() { actions.push(( "effects.setParam".into(), @@ -926,7 +958,8 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { Some(ParamValue::Float(v)) => { let (_, nv) = crate::widgets::hot_number( &mut vui, - egui::Id::new(("pp", p, clip.0)), + label, + egui::Id::unique(("pp", p, clip.0)), v, speed, range, @@ -949,7 +982,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); if let Some(ParamValue::Float(v)) = val("opacity", "opacity") { - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-op", clip.0)), v, 0.5, (0.0, 100.0), 0, " %", &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Opacity"), egui::Id::unique(("pp-op", clip.0)), v, 0.5, (0.0, 100.0), 0, " %", &t); if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "opacity", "param": "opacity", "value": nv}))); } @@ -966,7 +999,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("crop", p).and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-crop", p, clip.0)), v, 0.2, (0.0, 100.0), 1, " %", &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, label, egui::Id::unique(("pp-crop", p, clip.0)), v, 0.2, (0.0, 100.0), 1, " %", &t); if let Some(nv) = nv { if crop.is_none() { actions.push(("effects.apply".into(), json!({"clips": [clip.0], "effect": "crop"}))); @@ -990,7 +1023,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("volume", "level").and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-vol", clip.0)), v, 0.1, (-96.0, 15.0), 1, " dB", &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Level"), egui::Id::unique(("pp-vol", clip.0)), v, 0.1, (-96.0, 15.0), 1, " dB", &t); if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "volume", "param": "level", "value": nv}))); } @@ -1002,7 +1035,7 @@ pub fn properties_panel(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .layout(egui::Layout::left_to_right(egui::Align::Center)), ); let v = val("panner", "balance").and_then(|v| v.as_f64()).unwrap_or(0.0); - let (_, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("pp-pan", clip.0)), v, 0.5, (-100.0, 100.0), 0, "", &t); + let (_, nv) = crate::widgets::hot_number(&mut vui, tl!("Pan"), egui::Id::unique(("pp-pan", clip.0)), v, 0.5, (-100.0, 100.0), 0, "", &t); if let Some(nv) = nv { actions.push(("effects.setParam".into(), json!({"clip": clip.0, "effect": "panner", "param": "balance", "value": nv}))); } @@ -1034,7 +1067,7 @@ pub(crate) fn row_fill(r: Rect) -> Rect { /// keeps it. pub(crate) fn run(app: &mut FilmcraftApp, ctx: &egui::Context, actions: Vec<(String, Value)>) { let (down, press) = ctx.input(|i| (i.pointer.any_down(), i.pointer.press_start_time())); - let key = egui::Id::new("effect-controls-drag-step"); + let key = egui::Id::unique("effect-controls-drag-step"); let begun = ctx.data(|d| d.get_temp::>(key)).flatten(); let mut wrote = false; for (cmd, mut p) in actions { @@ -1129,7 +1162,7 @@ pub(crate) fn graph_rows( } // While the graph stays open its range only widens: refitted to the keyframes, a keyframe // dragged off the top or bottom edge was drawn back on that edge, the curve unchanged (#1918) - let range_id = egui::Id::new(("kfg-range", clip.0, idx, mask, pd.id)); + let range_id = egui::Id::unique(("kfg-range", clip.0, idx, mask, pd.id)); let frame = ui.ctx().cumulative_frame_nr(); if let Some((a, b, seen)) = ui.data(|d| d.get_temp::<(f64, f64, u64)>(range_id)) && frame.saturating_sub(seen) <= 1 @@ -1200,7 +1233,7 @@ pub(crate) fn graph_rows( if !v.is_finite() { continue; } - let id = egui::Id::new(("kfg", clip.0, idx, mask, pd.id, k.time.0)); + let id = egui::Id::unique(("kfg", clip.0, idx, mask, pd.id, k.time.0)); let id = if ci == 0 { id } else { id.with(ci) }; let dy: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0); let c = pos2(x_of(f), y_of(v) + dy); diff --git a/crates/ui-egui/src/panels/effect_controls_selection.rs b/crates/ui-egui/src/panels/effect_controls_selection.rs index c8db9a7d1..74069ada2 100644 --- a/crates/ui-egui/src/panels/effect_controls_selection.rs +++ b/crates/ui-egui/src/panels/effect_controls_selection.rs @@ -12,7 +12,7 @@ use crate::{ }; fn state_id() -> Id { - Id::new("effect-controls-keyframe-selection") + Id::unique("effect-controls-keyframe-selection") } #[derive(Clone)] @@ -112,7 +112,7 @@ impl Interaction { if cancelled { state.gesture = None; } - let background = ui.interact(area, Id::new(("ec-select", clip)), Sense::click_and_drag()); + let background = ui.interact(area, Id::unique(("ec-select", clip)), Sense::click_and_drag()); if background.clicked_by(egui::PointerButton::Primary) && !ui.input(|i| i.modifiers.shift) { app.ui.keyframe_selection.clear(); } diff --git a/crates/ui-egui/src/panels/effects.rs b/crates/ui-egui/src/panels/effects.rs index da0e66db3..3f897445e 100644 --- a/crates/ui-egui/src/panels/effects.rs +++ b/crates/ui-egui/src/panels/effects.rs @@ -288,7 +288,7 @@ fn preset_grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) -> Option< } let pic = Rect::from_min_size(cell.min, vec2(cw, ch)); let tex = preset_texture(app, ui.ctx(), p, 160); - let resp = ui.interact(cell, egui::Id::new(("lumetri-grid", p.name)), Sense::click_and_drag()).on_hover_text(crate::i18n::t(p.description)); + let resp = ui.interact(cell, egui::Id::unique(("lumetri-grid", p.name)), Sense::click_and_drag()).on_hover_text(crate::i18n::t(p.description)); ui.painter().rect_filled(cell, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().image(tex, pic.shrink(2.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let label = ui.painter().layout(crate::i18n::t(p.name).to_string(), Tokens::ui(11.0), t.text, cw - 6.0); diff --git a/crates/ui-egui/src/panels/essential_sound.rs b/crates/ui-egui/src/panels/essential_sound.rs index 958594db3..71e072288 100644 --- a/crates/ui-egui/src/panels/essential_sound.rs +++ b/crates/ui-egui/src/panels/essential_sound.rs @@ -64,7 +64,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let shown = if name == "Browse" { tl!("Browse") } else { tl!("Edit") }; let g = painter.layout_no_wrap(shown.to_string(), Tokens::ui(12.5), t.text); let r = Rect::from_min_size(pos2(x, ty - 9.0), vec2(g.size().x, 20.0)); - let resp = ui.interact(r, egui::Id::new(("es-tab", name)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("es-tab", name)), Sense::click()); let col = if name == tab || resp.hovered() { t.text } else { t.text_dim }; painter.text(pos2(x, ty), Align2::LEFT_CENTER, shown, Tokens::ui(12.5), col); if name == tab { @@ -210,7 +210,7 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra let mine: Vec<&es::Preset> = presets.iter().filter(|p| p.kind == kind).collect(); let current = if st.preset.is_empty() { tl!("(Custom)").to_string() } else { crate::i18n::t(&st.preset).to_string() }; let mut picked = None; - let draft_id = egui::Id::new("es-preset-draft"); + let draft_id = egui::Id::unique("es-preset-draft"); let mut draft: Option = ui.data(|d| d.get_temp(draft_id)); ui.horizontal(|ui| { let w = ui.available_width() - 56.0; @@ -242,7 +242,10 @@ fn typed_body(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, items: &[Tra if let Some(mut d) = draft.take() { let mut done = false; ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d).hint_text(tl!("Preset name")).desired_width(ui.available_width() - 70.0)); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut d).hint_text(tl!("Preset name")).desired_width(ui.available_width() - 70.0)), + "Preset name", + ); cx.auto.add("essentialSound.preset.name", r.rect, "Preset name"); let ok = ui.button(tl!("Save")); cx.auto.add("essentialSound.preset.ok", ok.rect, "Save"); @@ -355,7 +358,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) { for kind in AudioType::ALL { let br = Rect::from_min_size(pos2(x, r.min.y + 2.0), vec2(30.0, 26.0)); let sel = st.ducking.against.contains(&kind); - let resp = ui.interact(br, egui::Id::new(("es-duck", kind.id())), Sense::click()); + let resp = ui.interact(br, egui::Id::unique(("es-duck", kind.id())), Sense::click()); ui.painter().rect_filled( br, 4.0, @@ -381,7 +384,7 @@ fn ducking(ui: &mut egui::Ui, cx: &mut Ctx, st: &EssentialSound, on: bool) { x += 34.0; } let br = Rect::from_min_size(pos2(x + 4.0, r.min.y + 2.0), vec2(84.0, 26.0)); - let resp = ui.interact(br, egui::Id::new("es-duck-untyped"), Sense::click()); + let resp = ui.interact(br, egui::Id::unique("es-duck-untyped"), Sense::click()); let sel = st.ducking.against_untyped; ui.painter().rect_filled( br, @@ -463,7 +466,7 @@ fn section_header(ui: &mut egui::Ui, cx: &mut Ctx, sec: Section, open: bool, ena /// A pill switch. Returns clicked. fn switch(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, on: bool, id: &str, label: &str) -> bool { - let resp = ui.interact(r.expand(3.0), egui::Id::new(("es-sw", id)), Sense::click()); + let resp = ui.interact(r.expand(3.0), egui::Id::unique(("es-sw", id)), Sense::click()); let p = ui.painter(); let rad = r.height() / 2.0; if on { @@ -520,7 +523,7 @@ fn slider_track(ui: &mut egui::Ui, cx: &mut Ctx, track: Rect, key: &str, v: f64, let y = track.center().y; let f = ((v - lo) / (hi - lo)).clamp(0.0, 1.0) as f32; let kx = track.min.x + f * track.width(); - let resp = ui.interact(track.expand2(vec2(6.0, 4.0)), egui::Id::new(("es-slider", key)), Sense::click_and_drag()); + let resp = ui.interact(track.expand2(vec2(6.0, 4.0)), egui::Id::unique(("es-slider", key)), Sense::click_and_drag()); let line = if active { cx.t.switch_border } else { cx.t.slider_disabled_track }; ui.painter().line_segment([pos2(track.min.x, y), pos2(kx - 7.0, y)], Stroke::new(1.5, line)); ui.painter().line_segment([pos2(kx + 7.0, y), pos2(track.max.x, y)], Stroke::new(1.5, line)); @@ -581,7 +584,7 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us let w = 84.0; for (i, o) in opts.iter().enumerate() { let br = Rect::from_min_size(pos2(r.min.x + 42.0 + i as f32 * (w + 4.0), r.min.y + 3.0), vec2(w, 22.0)); - let resp = ui.interact(br, egui::Id::new(("es-choice", key, i)), Sense::click()); + let resp = ui.interact(br, egui::Id::unique(("es-choice", key, i)), Sense::click()); let on = i == sel; ui.painter().rect_filled( br, @@ -604,7 +607,7 @@ fn choice_row(ui: &mut egui::Ui, cx: &mut Ctx, key: &str, opts: &[&str], sel: us fn button(ui: &mut egui::Ui, cx: &mut Ctx, r: Rect, text: &str, id: &str) -> bool { let t = cx.t; - let resp = ui.interact(r, egui::Id::new(("es-btn", id)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("es-btn", id)), Sense::click()); ui.painter().rect_filled(r, 4.0, if resp.hovered() { cx.t.button_hover } else { t.field_bg }); ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, cx.t.button_border), StrokeKind::Inside); ui.painter().text(r.center(), Align2::CENTER_CENTER, text, Tokens::ui(12.0), t.text); diff --git a/crates/ui-egui/src/panels/events.rs b/crates/ui-egui/src/panels/events.rs index 85174e260..f0d672e3f 100644 --- a/crates/ui-egui/src/panels/events.rs +++ b/crates/ui-egui/src/panels/events.rs @@ -198,7 +198,7 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { elems.push((format!("progress.job.{id}"), r, format!("{label}: {status}"))); if !done { let cr = Rect::from_center_size(pos2(r.max.x - 18.0, bar.center().y), vec2(20.0, 20.0)); - let resp = ui.interact(cr, egui::Id::new(("progress-cancel", id)), Sense::click()); + let resp = ui.interact(cr, egui::Id::unique(("progress-cancel", id)), Sense::click()); let col = if resp.hovered() { t.danger } else { t.icon }; let s = Stroke::new(1.5, col); ui.painter().line_segment([cr.center() + vec2(-5.0, -5.0), cr.center() + vec2(5.0, 5.0)], s); diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index f1cf5224d..3833da4c5 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -342,7 +342,7 @@ fn opts(labels: &[&str]) -> Vec<(String, bool)> { fn section(ui: &mut egui::Ui, reg: &mut Reg, open: &mut Vec, key: &str, title: &str, t: &Tokens) -> bool { let is_open = open.iter().any(|k| k == key); - let (r, now) = crate::widgets::section_header(ui, egui::Id::new(("export-sec", key)), title, is_open, t, true); + let (r, now) = crate::widgets::section_header(ui, egui::Id::unique(("export-sec", key)), title, is_open, t, true); reg.add(format!("export.section.{key}"), r.rect, title); if now != is_open { if now { @@ -380,7 +380,7 @@ fn check(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut bool, label: &str) } fn text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut String, hint: &str) { - let r = ui.add(egui::TextEdit::singleline(v).hint_text(hint).desired_width(f32::INFINITY)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(v).hint_text(hint).desired_width(f32::INFINITY)), hint); reg.add(id, r.rect, hint); } @@ -389,9 +389,9 @@ fn text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, v: &mut String, hint: &str) /// when that is not usable yet (emptied to retype, a half-typed path); each non-empty value is /// returned as it comes. Leaving shows the stored value again. fn draft_text(ui: &mut egui::Ui, reg: &mut Reg, id: &str, stored: &str, hint: &str) -> Option { - let draft_id = egui::Id::new(("export-draft", id)); + let draft_id = egui::Id::unique(("export-draft", id)); let mut v = ui.data(|m| m.get_temp::(draft_id)).unwrap_or_else(|| stored.to_string()); - let r = ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(f32::INFINITY)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(f32::INFINITY)), hint); reg.add(id, r.rect, hint); if r.has_focus() { ui.data_mut(|m| m.insert_temp(draft_id, v.clone())); @@ -1172,11 +1172,11 @@ fn preview_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq_id: // buttons let go = Rect::from_min_size(pos2(rect.max.x - 120.0, rect.max.y - 44.0), vec2(104.0, 30.0)); let queue = Rect::from_min_size(pos2(go.min.x - 132.0, go.min.y), vec2(122.0, 30.0)); - let qresp = ui.interact(queue, egui::Id::new("export-queue-send"), Sense::click()); + let qresp = ui.interact(queue, egui::Id::unique("export-queue-send"), Sense::click()); ui.painter().rect_stroke(queue, 15.0, egui::Stroke::new(1.0, if qresp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside); ui.painter().text(queue.center(), Align2::CENTER_CENTER, tl!("Send to Queue"), Tokens::semibold(12.5), t.text); reg.add("export.sendToQueue", queue, "Send to Queue"); - let resp = ui.interact(go, egui::Id::new("export-go"), Sense::click()); + let resp = ui.interact(go, egui::Id::unique("export-go"), Sense::click()); ui.painter().rect_filled(go, 15.0, if resp.hovered() { t.accent_hover } else { t.accent }); ui.painter().text(go.center(), Align2::CENTER_CENTER, tl!("Export"), Tokens::semibold(13.0), Color32::WHITE); reg.add("export.button", go, "Export"); @@ -1233,8 +1233,8 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { let favorite = |n: &str| app.session.export_presets.is_favorite(n); let q = m.query.to_lowercase(); egui::Window::new(tl!("Preset Manager")) - .id(egui::Id::new("Preset Manager")) - .id(egui::Id::new("preset-manager")) + .id(egui::Id::unique("Preset Manager")) + .id(egui::Id::unique("preset-manager")) .open(&mut open) .collapsible(false) .resizable(true) @@ -1292,7 +1292,10 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { } ui.horizontal(|ui| { ui.label(tl!("Save current settings as:")); - let r = ui.add(egui::TextEdit::singleline(&mut m.save_name).hint_text(tl!("Preset name")).desired_width(200.0)); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut m.save_name).hint_text(tl!("Preset name")).desired_width(200.0)), + "Preset name", + ); reg.add("presetManager.saveName", r.rect, "Preset name"); let r = ui.add_enabled(!m.save_name.trim().is_empty(), egui::Button::new(tl!("Save"))); reg.add("presetManager.save", r.rect, "Save"); @@ -1463,7 +1466,7 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P let mut browse = false; let mut typed: Option = None; let q = &mut app.ui.export; - let area = egui::Area::new(egui::Id::new("quick-export")).order(egui::Order::Foreground).fixed_pos(anchor).show(ctx, |ui| { + let area = egui::Area::new(egui::Id::unique("quick-export")).order(egui::Order::Foreground).fixed_pos(anchor).show(ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_width(340.0); ui.horizontal(|ui| { @@ -1503,7 +1506,7 @@ pub fn quick_export(app: &mut FilmcraftApp, ctx: &egui::Context, anchor: egui::P }); let clicked_elsewhere = area.response.clicked_elsewhere(); // the header click that opened the popup is not a click elsewhere - let toggled = ctx.data_mut(|d| d.remove_temp::(egui::Id::new("quick-export-toggled")).unwrap_or(false)); + let toggled = ctx.data_mut(|d| d.remove_temp::(egui::Id::unique("quick-export-toggled")).unwrap_or(false)); reg.flush(app); if let Some(v) = typed { // a file name goes in the shown folder; a typed or pasted path replaces both diff --git a/crates/ui-egui/src/panels/file_dialogs.rs b/crates/ui-egui/src/panels/file_dialogs.rs index 23905b4ba..5c60b2b8e 100644 --- a/crates/ui-egui/src/panels/file_dialogs.rs +++ b/crates/ui-egui/src/panels/file_dialogs.rs @@ -42,7 +42,7 @@ pub fn show_recovery(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { } let mut choice = app.file_dialogs.recovery_choice.min(items.len() - 1); let mut open = true; - egui::Modal::new(egui::Id::new("recovery-modal")).frame(modal_frame(&t)).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("recovery-modal")).frame(modal_frame(&t)).show(ctx, |ui| { ui.set_width(520.0); Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| { ui.label(RichText::new(tl!("Recover Unsaved Changes")).size(18.0).strong().color(t.text)); @@ -108,7 +108,7 @@ pub fn show_revert(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let t = app.tokens; let file = app.session.path.as_deref().and_then(|p| std::path::Path::new(p).file_name()).map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let mut open = true; - let r = egui::Modal::new(egui::Id::new("revert-modal")).frame(modal_frame(&t)).show(ctx, |ui| { + let r = egui::Modal::new(egui::Id::unique("revert-modal")).frame(modal_frame(&t)).show(ctx, |ui| { ui.set_width(460.0); Frame::new().inner_margin(Margin { left: 28, right: 28, top: 24, bottom: 20 }).show(ui, |ui| { ui.label(RichText::new(tl!("Revert")).size(18.0).strong().color(t.text)); diff --git a/crates/ui-egui/src/panels/frame_export.rs b/crates/ui-egui/src/panels/frame_export.rs index 5e20887e3..59e9d14c0 100644 --- a/crates/ui-egui/src/panels/frame_export.rs +++ b/crates/ui-egui/src/panels/frame_export.rs @@ -20,7 +20,7 @@ struct Draft { replace: Option, } fn draft_id() -> egui::Id { - egui::Id::new("frame-export-draft") + egui::Id::unique("frame-export-draft") } fn clean(name: &str) -> String { name.trim().chars().map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }).collect() @@ -41,7 +41,7 @@ pub fn open(app: &mut FilmcraftApp, ctx: &egui::Context, params: &Value) -> Resu } else if app.playback.playing { app.stop(); } - let folder = ctx.data(|m| m.get_temp::(egui::Id::new("frame-export-folder"))).unwrap_or_else(|| { + let folder = ctx.data(|m| m.get_temp::(egui::Id::unique("frame-export-folder"))).unwrap_or_else(|| { if cfg!(target_arch = "wasm32") { return "/exports".into(); } @@ -65,11 +65,11 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut elems = Vec::new(); let mut popup_rects = Vec::new(); let frame = egui::Frame::new().fill(app.tokens.panel_bg).inner_margin(0).corner_radius(6); - let position_id = egui::Id::new("frame-export-position"); + let position_id = egui::Id::unique("frame-export-position"); let position = ctx.data(|m| m.get_temp::(position_id)); let mut movement = egui::Vec2::ZERO; // Only the custom white header moves the window; form controls keep their own gestures. - let window_id = egui::Id::new("frame-export-window"); + let window_id = egui::Id::unique("frame-export-window"); let mut window = egui::Window::new("Export Frame") .id(window_id) .title_bar(false) @@ -95,20 +95,20 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ); let x = egui::Rect::from_center_size(bar.right_center() - egui::vec2(16.0, 0.0), egui::vec2(24.0, 24.0)); let header = egui::Rect::from_min_max(bar.min, egui::pos2(x.left(), bar.bottom())); - let drag = ui.interact(header, egui::Id::new("export-frame-header"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + let drag = ui.interact(header, egui::Id::unique("export-frame-header"), egui::Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); if drag.dragged() { movement = drag.drag_delta(); ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } elems.push(("exportFrame.header".into(), header, "Move Export Frame".into())); - let response = ui.interact(x, egui::Id::new("export-frame-close"), egui::Sense::click()).on_hover_text("Close"); + let response = ui.interact(x, egui::Id::unique("export-frame-close"), egui::Sense::click()).on_hover_text("Close"); icons::paint(ui.painter(), x.shrink(7.0), Icon::Close, egui::Color32::from_gray(90)); elems.push(("exportFrame.close".to_string(), x, "Close".to_string())); close = response.clicked(); egui::Frame::NONE.inner_margin(14).show(ui, |ui| { egui::Grid::new("frame-export-settings").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { ui.label("Name:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)), "Name"); elems.push(("exportFrame.name".into(), r.rect, d.name.clone())); ui.end_row(); ui.label("Format:"); @@ -144,7 +144,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { elems.push(("exportFrame.depth".into(), combo.response.rect, format!("{} Bit", d.depth))); ui.end_row(); ui.label("Path:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(300.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width(300.0)), "Path"); elems.push(("exportFrame.path".into(), r.rect, d.folder.clone())); ui.end_row(); }); @@ -255,7 +255,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { match app.session.execute("file.exportFrame", params) { Ok(result) => { app.ui.status = format!("Exported frame to {}", result["path"].as_str().unwrap_or_default()); - ctx.data_mut(|m| m.insert_temp(egui::Id::new("frame-export-folder"), d.folder.clone())); + ctx.data_mut(|m| m.insert_temp(egui::Id::unique("frame-export-folder"), d.folder.clone())); close = true; } Err(e) => app.ui.status = e.to_string(), diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 7fb91b98d..c2e2ffbf8 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -22,7 +22,9 @@ //! `program.textEdit`, `graphics.*` in the panels. use crate::widgets::CenteredDialog as _; +use egui::ime_context::{CommittedText, ImeMutationStamp, ImeObservation, ImeTarget}; use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use filmcraft_engine::Session; use filmcraft_geom::{Affine, Vec2}; use filmcraft_project::graphic::{self, LayerContent, LayerSpec, eval_layer, layer_display_name, layer_indices}; use filmcraft_project::{ClipId, EffectInstance, ItemKind, ParamValue, TrackItem}; @@ -46,6 +48,45 @@ pub struct LayerView { pub local: [f32; 4], } +/// The Program monitor editor drawn in one pass, remembered so the final IME observation can run +/// after that pass's control requests have been drained. It holds only geometry, never a copy of +/// the layer's text. +#[derive(Clone, Copy, Debug)] +pub(crate) struct DrawnImeEditor { + pub viewport: egui::ViewportId, + pub pass_nr: u64, + pub document_epoch: u64, + pub sequence: Option, + pub target: ImeTarget, + pub clip: u64, + pub layer: usize, + /// Identity of the layer drawn at that index; a reorder after the draw is detected here even + /// when the numeric index (and therefore a uid-less owner key) stayed the same. + pub fingerprint: u64, + /// Identity of the committed text drawn in this pass. A control request that changes the + /// text after the draw must not publish the old cursor geometry with the new text; the next + /// pass draws and observes it. A caret/selection-only change keeps the text, so the owner and + /// generation can stay stable while the new selection is published. + pub text_stamp: u64, + /// Every layout-affecting layer property drawn in this pass, evaluated at the playhead. A + /// same-pass control request can change the font, style, alignment, spacing, box or transform + /// without changing the committed text or the layer identity; the cursor rectangle from the + /// old layout must then not be published with the new one. + pub layout_fingerprint: u64, + pub rect: Rect, + pub cursor_rect: Rect, +} + +/// Identity of committed text alone (no caret or selection). +fn ime_text_stamp(text: &str) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in text.as_bytes() { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h ^ (text.len() as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15) +} + impl LayerView { pub fn quad(&self) -> [Pos2; 4] { let b = self.local; @@ -503,12 +544,15 @@ fn new_text_action(app: &FilmcraftApp, pic: Rect, frame: (u32, u32), at: Pos2, c ("graphics.newText#edit".into(), prm) } -const TEXT_EDIT_ID: &str = "gfx-text-edit"; +pub(crate) const TEXT_EDIT_ID: &str = "gfx-text-edit"; fn end_edit(app: &mut FilmcraftApp, ui: &egui::Ui) { app.ui.gfx_edit = None; - ui.memory_mut(|m| m.surrender_focus(egui::Id::new(TEXT_EDIT_ID))); - ui.data_mut(|d| d.remove::(egui::Id::new(TEXT_EDIT_ID).with("panel"))); + // Any composition that was in flight belongs to the editor that just ended; a late commit + // must not be inserted into whatever is edited next. + app.invalidate_ime_composition(); + ui.memory_mut(|m| m.surrender_focus(egui::Id::unique(TEXT_EDIT_ID))); + ui.data_mut(|d| d.remove::(egui::Id::unique(TEXT_EDIT_ID).with("panel"))); } /// A click on the Program monitor's empty space (the picture's, or around it): end the text edit @@ -525,6 +569,9 @@ pub(crate) fn deselect_on_monitor(app: &mut FilmcraftApp, ui: &egui::Ui) { /// Draw the overlay and handle graphics tools on the Program monitor picture `pic`. pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32, u32)) { + // A project command may have reordered or deleted layers since the last pass. Follow the + // edited layer (or end the edit) before anything reads `gfx_edit`. + reconcile_gfx_edit(app); let t = app.tokens; let views = visible_layers(app, pic, frame); let tool = app.ui.tool; @@ -588,10 +635,11 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } } } - let id = egui::Id::new("gfx-overlay"); + let id = egui::Id::unique("gfx-overlay"); let resp = ui.interact(pic, id, if graphics_tool { Sense::click_and_drag() } else { Sense::hover() }); let hover = resp.hover_pos(); let mut actions: Vec<(String, Value)> = Vec::new(); + let mut pending_ime: Option = None; let drag_id = id.with("drag"); let mut drag: Option = ui.data(|d| d.get_temp(drag_id)); @@ -624,7 +672,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra // ---- text editing if let Some(ed) = editing.clone() { match views.iter().find(|v| v.clip.0 == ed.clip && v.layer == ed.layer && v.is_text()) { - Some(v) => text_edit(app, ui, v, ed, &resp, &mut actions), + Some(v) => text_edit(app, ui, v, ed, &resp, &mut actions, &mut pending_ime), None => end_edit(app, ui), } } @@ -644,10 +692,14 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra { let l = text_layout(tp); if let Some((x, y)) = v.to_local(p) { + let previous_selection = (ed.caret, ed.anchor); ed.caret = l.hit(x, y); if !shift { ed.anchor = ed.caret; } + if previous_selection != (ed.caret, ed.anchor) { + app.invalidate_ime_composition(); + } app.ui.gfx_edit = Some(ed); } drag = Some(DragState { kind: DragKind::TextSelect, clip: v.clip, layer: v.layer, start: p }); @@ -756,7 +808,11 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra { let l = text_layout(tp); if let Some((x, y)) = v.to_local(cur) { + let previous_selection = (ed.caret, ed.anchor); ed.caret = l.hit(x, y); + if previous_selection != (ed.caret, ed.anchor) { + app.invalidate_ime_composition(); + } app.ui.gfx_edit = Some(ed); } } @@ -838,10 +894,14 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra && let Some(tp) = text_of(&v.spec) && let Some((x, y)) = v.to_local(p) { + let previous_selection = (ed.caret, ed.anchor); ed.caret = text_layout(tp).hit(x, y); if !ui.input(|i| i.modifiers.shift) { ed.anchor = ed.caret; } + if previous_selection != (ed.caret, ed.anchor) { + app.invalidate_ime_composition(); + } app.ui.gfx_edit = Some(ed); } } @@ -911,35 +971,16 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } else if tool != Tool::Pen && !app.ui.pen_points.is_empty() { app.ui.pen_points.clear(); } - // ---- caret / selection drawing - if let Some(ed) = app.ui.gfx_edit.clone() - && let Some(v) = views.iter().find(|v| v.clip.0 == ed.clip && v.layer == ed.layer) - && let Some(tp) = text_of(&v.spec) - { - let l = text_layout(tp); - let caret = ed.caret.min(tp.text.len()); - for r in l.selection_rects(ed.anchor.min(tp.text.len()), caret) { - let q = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])].map(|(x, y)| sp(&v.to_screen, x, y)); - painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 90), Stroke::NONE)); - } - let (x, base, li) = l.caret(caret); - let (ascent, descent) = l.lines.get(li.min(l.lines.len().saturating_sub(1))).map_or((0.0, 0.0), |line| (line.ascent, line.descent)); - let (a, b) = (sp(&v.to_screen, x, base - ascent), sp(&v.to_screen, x, base + descent)); - painter.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); - painter.line_segment([a + vec2(1.0, 0.0), b + vec2(1.0, 0.0)], Stroke::new(0.5, Color32::BLACK)); - let er = Rect::from_points(&v.quad()).union(Rect::from_two_pos(a, b)); - app.auto.add("program.textEdit", er, &tp.text); - ui.ctx().output_mut(|o| { - o.ime = Some(egui::output::IMEOutput { - purpose: Default::default(), - rect: er, - cursor_rect: Rect::from_two_pos(a, b), - should_interrupt_composition: false, - }) + // ---- run accepted actions before observing committed text and caret geometry + let viewport = ui.ctx().viewport_id(); + let frame_nr = ui.ctx().cumulative_frame_nr_for(viewport); + for (action_index, (cmd, p)) in actions.into_iter().enumerate() { + let pending = pending_ime.as_ref().filter(|pending| pending.action_index == action_index); + let target = pending.and_then(|pending| current_graphics_ime_target(app, pending.clip, pending.layer)); + let actual_before = pending.and_then(|pending| { + pending.first_before.as_ref()?; + current_layer_text(&app.session, pending.clip, pending.layer).map(|text| ime_stamp(text, pending.before_cursor, pending.before_anchor)) }); - } - // ---- run - for (cmd, p) in actions { let (cmd, then_edit) = match cmd.strip_suffix("#edit") { Some(c) => (c.to_string(), true), None => (cmd, false), @@ -947,13 +988,196 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra match app.session.execute(&cmd, p) { Ok(r) => { if then_edit && let (Some(c), Some(l)) = (r["clip"].as_u64(), r["layer"].as_u64()) { - app.ui.gfx_edit = Some(GfxEdit { clip: c, layer: l as usize, caret: 0, anchor: 0 }); + app.restart_ime_target(); + let uid = current_layer_uid(&app.session, c, l as usize).unwrap_or(0); + app.ui.gfx_edit = Some(GfxEdit { clip: c, layer: l as usize, caret: 0, anchor: 0, uid, fingerprint: 0 }); app.ui.focused = crate::dock::PanelKind::Program; } + if let (Some(pending), Some(target)) = (pending, target) { + let actual_after = + current_layer_text(&app.session, pending.clip, pending.layer).map(|text| ime_stamp(text, pending.final_cursor, pending.final_anchor)); + let exact_ime_chain = !pending.application_mutation + && pending.first_before.as_ref().is_some_and(|before| actual_before.as_ref() == Some(before)) + && pending.after.last().is_some_and(|after| actual_after.as_ref() == Some(after)); + + if exact_ime_chain { + if let Some(mut before) = actual_before { + for after in &pending.after { + app.ime_context.note_input_method_apply(viewport, frame_nr, target, before.clone(), after.clone()); + + before = after.clone(); + } + } + } else if pending.application_mutation || pending.first_before.is_some() { + // An ordinary edit was mixed into the transaction, the model did not match + // the locally accepted result, or another action changed it first: the + // frame is application-origin even if an IME event was also present. + app.ime_context.note_application_change(viewport, frame_nr, target); + } + } } Err(e) => app.ui.status = e.to_string(), } } + // The model may have changed above; draw and observe the post-action caret and committed text. + let views = visible_layers(app, pic, frame); + if let Some(ed) = app.ui.gfx_edit.clone() + && let Some(v) = views.iter().find(|v| v.clip.0 == ed.clip && v.layer == ed.layer) + && let Some(tp) = text_of(&v.spec) + { + let l = text_layout(tp); + let caret = char_boundary_floor(&tp.text, ed.caret); + let anchor = char_boundary_floor(&tp.text, ed.anchor); + for r in l.selection_rects(anchor, caret) { + let q = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])].map(|(x, y)| sp(&v.to_screen, x, y)); + painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 90), Stroke::NONE)); + } + let (x, base, li) = l.caret(caret); + let (ascent, descent) = l.lines.get(li.min(l.lines.len().saturating_sub(1))).map_or((0.0, 0.0), |line| (line.ascent, line.descent)); + let (a, b) = (sp(&v.to_screen, x, base - ascent), sp(&v.to_screen, x, base + descent)); + let cursor_rect = Rect::from_two_pos(a, b); + painter.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); + painter.line_segment([a + vec2(1.0, 0.0), b + vec2(1.0, 0.0)], Stroke::new(0.5, Color32::BLACK)); + let er = Rect::from_points(&v.quad()).union(cursor_rect); + app.auto.add("program.textEdit", er, &tp.text); + // Do not publish yet: `drain_ui_requests` later in this pass may run a command or replace + // `gfxEdit`. `finish_graphics_ime` observes the post-drain state with this geometry. + note_graphics_ime_editor(app, ui, v, er, cursor_rect); + } +} + +/// Remember the focused Program monitor editor drawn in this pass. The actual observation is +/// made once by [`finish_graphics_ime`] after the pass's control requests have run. +pub(crate) fn note_graphics_ime_editor(app: &mut FilmcraftApp, ui: &egui::Ui, v: &LayerView, rect: Rect, cursor_rect: Rect) { + let ctx = ui.ctx(); + let viewport = ctx.viewport_id(); + let pass_nr = ctx.cumulative_pass_nr_for(viewport); + let sequence = app.session.state.active_sequence.map(|sequence| sequence.0); + let target = graphics_ime_target(sequence.unwrap_or(0), v.clip.0, v.layer, v.spec.uid, app.ime_replacement_epoch); + let fingerprint = current_layer_fingerprint(&app.session, v.clip.0, v.layer).unwrap_or(0); + let layout_fingerprint = current_layer_layout_fingerprint(&app.session, v.clip.0, v.layer).unwrap_or(0); + let text_stamp = text_of(&v.spec).map_or(0, |tp| ime_text_stamp(&tp.text)); + app.ime_drawn_editor = Some(DrawnImeEditor { + viewport, + pass_nr, + document_epoch: app.session.document_epoch, + sequence, + target, + clip: v.clip.0, + layer: v.layer, + fingerprint, + text_stamp, + layout_fingerprint, + rect, + cursor_rect, + }); +} + +/// Publish this pass's final IME observation, after all commands for the pass have been drained. +/// +/// The producer re-reads the committed text without evaluating/copying a full `LayerSpec`; a +/// target, document or sequence replaced since the overlay drew publishes `None` instead of +/// pairing an old owner with the new model. +pub(crate) fn finish_graphics_ime(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { + let viewport = ctx.viewport_id(); + let pass_nr = ctx.cumulative_pass_nr_for(viewport); + let Some(drawn) = app.ime_drawn_editor.take() else { return false }; + if drawn.viewport != viewport || drawn.pass_nr != pass_nr { + return false; + } + app.note_ime_observed(viewport, pass_nr); + let should_interrupt_composition = app.ime_interrupt_now; + if !ctx.will_discard() { + app.ime_interrupt_now = false; + } + if drawn.document_epoch != app.session.document_epoch || drawn.sequence != app.session.state.active_sequence.map(|sequence| sequence.0) { + app.ime_context.observe(viewport, None); + return true; + } + let eid = egui::Id::unique(TEXT_EDIT_ID); + if ctx.memory(|m| m.focused() != Some(eid)) { + app.ime_context.observe(viewport, None); + return true; + } + let Some(ed) = app.ui.gfx_edit.clone() else { + app.ime_context.observe(viewport, None); + return true; + }; + if ed.clip != drawn.clip || ed.layer != drawn.layer { + app.ime_context.observe(viewport, None); + return true; + } + let Some(sequence) = app.session.state.active_sequence else { + app.ime_context.observe(viewport, None); + return true; + }; + let Some(uid) = current_layer_uid(&app.session, ed.clip, ed.layer) else { + app.ime_context.observe(viewport, None); + return true; + }; + let target = graphics_ime_target(sequence.0, ed.clip, ed.layer, uid, app.ime_replacement_epoch); + if target != drawn.target || current_layer_fingerprint(&app.session, ed.clip, ed.layer).is_none_or(|fingerprint| fingerprint != drawn.fingerprint) { + // A control request replaced the edit target, reordered the layers, or changed the + // document after the overlay drew. Do not pair the old owner with the new content. + + app.ime_context.observe(viewport, None); + return true; + } + let Some(text) = current_layer_text(&app.session, ed.clip, ed.layer) else { + app.ime_context.observe(viewport, None); + return true; + }; + if ime_text_stamp(text) != drawn.text_stamp { + // A same-pass control request changed the text after the overlay drew. The published + // cursor rectangle and layout belong to the old text: report no descriptor for this pass + // and let the next pass draw and observe the correct geometry. The in-flight composition + // belongs to the text that was replaced, so its next delete/preedit/commit must not be + // inserted into the new text. + app.invalidate_ime_composition(); + if !ctx.will_discard() { + app.ime_interrupt_now = false; + } + app.ime_context.observe(viewport, None); + return true; + } + if current_layer_layout_fingerprint(&app.session, ed.clip, ed.layer).is_none_or(|fingerprint| fingerprint != drawn.layout_fingerprint) { + // A same-pass control request changed a layout-affecting property (font, style, + // alignment, spacing, box, transform, per-character style or pin) without changing the + // committed text. The old cursor rectangle belongs to the previous layout: publish no + // descriptor for this pass and let the next pass draw and observe the new geometry. + app.ime_context.observe(viewport, None); + return true; + } + // A caret/selection-only change after the draw keeps the committed text. Publish the new + // selection with the owner and generation unchanged; the cursor rectangle is one pass old, + // and the next pass refreshes it. Hostile offsets remain an explicit `Unavailable` snapshot. + let frame_nr = ctx.cumulative_frame_nr_for(viewport); + // FilmCraft's committed text never contains the IME pre-edit (preedit events are UI-only), + // so there is no preedit range to remove. A hostile or not-yet-clamped `gfxEdit` offset that + // is not a UTF-8 boundary is published as the explicit `Unavailable` snapshot, never as a + // nearby guessed offset. + let valid_offsets = ed.caret <= text.len() && ed.anchor <= text.len() && text.is_char_boundary(ed.caret) && text.is_char_boundary(ed.anchor); + let observation = ImeObservation::new(frame_nr, pass_nr, target) + .purpose(egui::IMEPurpose::Normal) + .committed(valid_offsets.then(|| CommittedText::new(text, ed.caret, ed.anchor))); + let update = app.ime_context.observe(viewport, Some(observation)); + + if let Some(update) = update { + let (rect, cursor_rect) = (drawn.rect, drawn.cursor_rect); + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { + owner: update.owner, + context_generation: update.context_generation, + purpose: update.purpose, + rect, + cursor_rect, + should_interrupt_composition, + change_cause: update.change_cause, + surrounding_text: update.surrounding_text, + }) + }); + } + true } fn layer_display_name_view(app: &FilmcraftApp, v: &LayerView) -> String { @@ -987,7 +1211,8 @@ fn start_edit_at(app: &mut FilmcraftApp, ui: &egui::Ui, v: &LayerView, p: Pos2, let Some(tp) = text_of(&v.spec) else { return }; let l = text_layout(tp); let caret = v.to_local(p).map_or(tp.text.len(), |(x, y)| l.hit(x, y)); - app.ui.gfx_edit = Some(GfxEdit { clip: v.clip.0, layer: v.layer, caret, anchor: caret }); + app.restart_ime_target(); + app.ui.gfx_edit = Some(GfxEdit { clip: v.clip.0, layer: v.layer, caret, anchor: caret, uid: v.spec.uid, fingerprint: 0 }); app.ui.focused = crate::dock::PanelKind::Program; let _ = ui; actions.push(("graphics.selectLayer".into(), json!({"clip": v.clip.0, "layers": [v.layer]}))); @@ -1004,31 +1229,403 @@ pub(crate) fn select_all_edited_text(app: &mut FilmcraftApp) -> bool { let Some(effect) = layer_indices(&it.effects).get(layer).and_then(|&i| it.effects.get(i)) else { return false }; let Some(len) = eval_layer(effect, it.source_time_at(t), (*width, *height)).as_ref().and_then(text_of).map(|tp| tp.text.len()) else { return false }; let Some(ed) = app.ui.gfx_edit.as_mut() else { return false }; + let moved = (ed.anchor, ed.caret) != (0, len); (ed.anchor, ed.caret) = (0, len); + if moved { + app.invalidate_ime_composition(); + } true } +/// One text edit action queued by [`text_edit`], with the accepted local IME receipt chain. +/// +/// The model command is executed later in the same pass. The receipt is reported to the tracker +/// only after that command succeeds and the post-command model still matches the local result. +struct PendingImeEdit { + action_index: usize, + clip: u64, + layer: usize, + /// The committed stamp immediately before the first accepted IME mutation. + first_before: Option, + before_cursor: usize, + before_anchor: usize, + /// One stamp immediately after each accepted IME commit/delete mutation. + after: Vec, + final_cursor: usize, + final_anchor: usize, + /// Ordinary text/paste/key/Cut changes were mixed into the same queued edit. + application_mutation: bool, +} + +fn ime_stamp(text: &str, cursor: usize, anchor: usize) -> ImeMutationStamp { + ImeMutationStamp::from_committed(text, cursor, anchor, None) +} + +/// The effect instance of layer `layer` of `clip` when it is enabled and on screen. +/// +/// This is the read-only path for the IME producer: it never evaluates a `LayerSpec` (which +/// clones the layer's full text) just to read an identity or a committed-text window. +fn current_layer_effect(session: &Session, clip: u64, layer: usize) -> Option<&EffectInstance> { + let seq = session.active_sequence()?; + let (_, it) = seq.find_item(ClipId(clip))?; + if !it.enabled { + return None; + } + let &effect_index = layer_indices(&it.effects).get(layer)?; + let effect = it.effects.get(effect_index)?; + effect.enabled.then_some(effect) +} + +/// Borrowed committed text of the edited layer at the playhead (no `TextProps`/full-text copy). +fn current_layer_text(session: &Session, clip: u64, layer: usize) -> Option<&str> { + let seq = session.active_sequence()?; + let t = session.playhead(); + let (_, it) = seq.find_item(ClipId(clip))?; + if !it.enabled { + return None; + } + let &effect_index = layer_indices(&it.effects).get(layer)?; + let effect = it.effects.get(effect_index)?; + if !effect.enabled || effect.effect != graphic::TEXT_LAYER { + return None; + } + effect.params.get("text")?.text_ref_at(it.source_time_at(t)) +} + +fn current_layer_uid(session: &Session, clip: u64, layer: usize) -> Option { + current_layer_effect(session, clip, layer).map(|effect| effect.layer.as_ref().map_or(0, |x| x.uid)) +} + +/// A compact identity for a layer that survives reorder/delete/undo when the layer has no uid. +/// +/// It uses static parameter values only, so a keyframed or animating position at the playhead +/// does not make the target look new, and it deliberately excludes the editable text, so typing +/// does not either. +fn layer_fingerprint(effect: &EffectInstance) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut hash = |bytes: &[u8]| { + for &b in bytes { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + }; + hash(effect.effect.as_bytes()); + hash(&[u8::from(effect.enabled)]); + hash(&effect.layer.as_ref().map_or(0, |x| x.uid).to_le_bytes()); + if let Some(name) = effect.params.get("name").and_then(|p| match &p.value { + ParamValue::Text(s) => Some(s.as_str()), + _ => None, + }) { + hash(name.as_bytes()); + } + for id in ["position", "size", "box_width", "box_height", "shape", "sides", "corner_radius", "vertical"] { + let Some(value) = effect.params.get(id).map(|p| &p.value) else { continue }; + match value { + ParamValue::Float(v) => hash(&v.to_bits().to_le_bytes()), + ParamValue::Vec2(v) => { + hash(&v.x.to_bits().to_le_bytes()); + hash(&v.y.to_bits().to_le_bytes()); + } + ParamValue::Choice(v) => hash(&u64::from(*v).to_le_bytes()), + ParamValue::Bool(v) => hash(&[u8::from(*v)]), + ParamValue::Text(s) => hash(s.as_bytes()), + _ => {} + } + } + h +} + +fn current_layer_fingerprint(session: &Session, clip: u64, layer: usize) -> Option { + current_layer_effect(session, clip, layer).map(layer_fingerprint) +} + +/// Stable uid and identity fingerprint of the layer currently at `layer`. +/// +/// A control request that explicitly names a clip and numeric layer means that index now, not +/// whichever layer an older edit session happened to own at that index. +pub(crate) fn current_layer_identity(session: &Session, clip: u64, layer: usize) -> Option<(u64, u64)> { + current_layer_effect(session, clip, layer).map(|effect| (effect.layer.as_ref().map_or(0, |extra| extra.uid), layer_fingerprint(effect))) +} + +/// Properties that change a text or shape layer's layout, and therefore the caret and candidate +/// rectangle, without necessarily changing its committed text or stable identity. Both static +/// values and keyframes at the playhead are included. +const LAYOUT_PARAMS: &[&str] = &[ + // Text layout. + "font", + "font_style", + "size", + "align", + "tracking", + "kerning", + "ligatures", + "leading", + "baseline_shift", + "faux_bold", + "faux_italic", + "caps", + "underline", + "box_width", + "box_height", + "vertical", + // Shape geometry. + "shape", + "sides", + "corner_radius", + "points", + // Transform. + "position", + "anchor", + "scale", + "scale_width", + "uniform_scale", + "rotation", +]; + +fn hash_bytes(h: &mut u64, bytes: &[u8]) { + for &b in bytes { + *h ^= u64::from(b); + *h = h.wrapping_mul(0x0000_0100_0000_01b3); + } +} + +fn hash_f64(h: &mut u64, v: f64) { + // Normalise non-finite values: NaN is "auto" for point parameters and must not make the + // fingerprint depend on payload bits or an evaluation detail. + if v.is_finite() { + hash_bytes(h, &v.to_bits().to_le_bytes()); + } else { + hash_bytes(h, b"non-finite"); + } +} + +fn hash_f32(h: &mut u64, v: f32) { + hash_f64(h, f64::from(v)); +} + +fn hash_option(h: &mut u64, value: Option, hash: impl FnOnce(&mut u64, T)) { + match value { + Some(v) => { + hash_bytes(h, &[1]); + hash(h, v); + } + None => hash_bytes(h, &[0]), + } +} + +fn hash_param_value(h: &mut u64, value: &ParamValue) { + match value { + ParamValue::Float(v) => { + hash_bytes(h, b"f"); + hash_f64(h, *v); + } + ParamValue::Vec2(v) => { + hash_bytes(h, b"v"); + hash_f64(h, v.x); + hash_f64(h, v.y); + } + ParamValue::Color(c) => { + hash_bytes(h, b"c"); + for v in c { + hash_f32(h, *v); + } + } + ParamValue::Bool(v) => { + hash_bytes(h, b"b"); + hash_bytes(h, &[u8::from(*v)]); + } + ParamValue::Choice(v) => { + hash_bytes(h, b"o"); + hash_bytes(h, &u64::from(*v).to_le_bytes()); + } + ParamValue::Text(v) => { + hash_bytes(h, b"t"); + hash_bytes(h, v.as_bytes()); + } + ParamValue::Curve(points) => { + hash_bytes(h, b"u"); + hash_bytes(h, &(points.len() as u64).to_le_bytes()); + for p in points { + hash_f32(h, p[0]); + hash_f32(h, p[1]); + } + } + // Mask paths are not used by layer layout parameters. + ParamValue::Path(_) => hash_bytes(h, b"p"), + } +} + +fn layout_fingerprint(effect: &EffectInstance, t: Tick) -> u64 { + let mut h: u64 = 0x9e37_79b9_7f4a_7c15; + hash_bytes(&mut h, effect.effect.as_bytes()); + hash_bytes(&mut h, &[u8::from(effect.enabled)]); + for id in LAYOUT_PARAMS { + let Some(param) = effect.params.get(*id) else { continue }; + hash_bytes(&mut h, id.as_bytes()); + hash_param_value(&mut h, ¶m.value_at(t)); + } + if let Some(extra) = &effect.layer { + hash_bytes(&mut h, b"runs"); + hash_bytes(&mut h, &(extra.runs.len() as u64).to_le_bytes()); + for run in &extra.runs { + hash_bytes(&mut h, &(run.start as u64).to_le_bytes()); + hash_bytes(&mut h, &(run.end as u64).to_le_bytes()); + hash_option(&mut h, run.style.font.as_ref(), |h, v| hash_bytes(h, v.as_bytes())); + hash_option(&mut h, run.style.font_style.as_ref(), |h, v| hash_bytes(h, v.as_bytes())); + hash_option(&mut h, run.style.size, hash_f32); + hash_option(&mut h, run.style.fill, |h, v| { + for c in v { + hash_f32(h, c); + } + }); + hash_option(&mut h, run.style.faux_bold, |h, v| hash_bytes(h, &[u8::from(v)])); + hash_option(&mut h, run.style.faux_italic, |h, v| hash_bytes(h, &[u8::from(v)])); + hash_option(&mut h, run.style.underline, |h, v| hash_bytes(h, &[u8::from(v)])); + hash_option(&mut h, run.style.tracking, hash_f32); + hash_option(&mut h, run.style.baseline_shift, hash_f32); + hash_option(&mut h, run.style.caps, |h, v| hash_bytes(h, &u64::from(v).to_le_bytes())); + } + hash_bytes(&mut h, b"pin"); + match &extra.pin { + Some(pin) => { + hash_bytes(&mut h, &[1]); + match pin.to { + filmcraft_project::graphic_design::PinTarget::Frame => hash_bytes(&mut h, b"frame"), + filmcraft_project::graphic_design::PinTarget::Layer(uid) => { + hash_bytes(&mut h, b"layer"); + hash_bytes(&mut h, &uid.to_le_bytes()); + } + } + hash_bytes(&mut h, &[u8::from(pin.left), u8::from(pin.top), u8::from(pin.right), u8::from(pin.bottom)]); + for offset in pin.offsets { + hash_f64(&mut h, offset); + } + } + None => hash_bytes(&mut h, &[0]), + } + } + h +} + +fn current_layer_layout_fingerprint(session: &Session, clip: u64, layer: usize) -> Option { + let sequence = session.active_sequence()?; + let t = session.playhead(); + let (_, item) = sequence.find_item(ClipId(clip))?; + let &effect_index = layer_indices(&item.effects).get(layer)?; + let effect = item.effects.get(effect_index)?; + Some(layout_fingerprint(effect, item.source_time_at(t))) +} + +/// Follow the edited layer through a real project edit (reorder, delete, undo/redo). +/// +/// A stable uid wins. For a uid-less layer the compact fingerprint finds the same layer at its +/// new index; with no candidate the layer was deleted (or replaced), so the edit is ended rather +/// than silently retargeted to whatever now occupies the old index. +fn resolve_gfx_edit(session: &Session, ed: &GfxEdit) -> Option<(usize, u64, u64)> { + let seq = session.active_sequence()?; + let (_, it) = seq.find_item(ClipId(ed.clip))?; + let indices = layer_indices(&it.effects); + let resolved = if ed.uid != 0 { + indices.iter().position(|&ei| it.effects.get(ei).and_then(|e| e.layer.as_ref()).is_some_and(|x| x.uid == ed.uid)) + } else { + let at = |i: usize| it.effects.get(indices[i]).map(layer_fingerprint); + let mut found = indices.get(ed.layer).and_then(|_| at(ed.layer)).filter(|fp| ed.fingerprint == 0 || *fp == ed.fingerprint).map(|_| ed.layer); + if found.is_none() && ed.fingerprint != 0 { + let mut best: Option<(usize, usize)> = None; + for i in 0..indices.len() { + if at(i) == Some(ed.fingerprint) { + let distance = i.abs_diff(ed.layer); + if best.is_none_or(|(d, _)| distance < d) { + best = Some((distance, i)); + } + } + } + found = best.map(|(_, i)| i); + } + found + }?; + let effect = it.effects.get(*indices.get(resolved)?)?; + let uid = effect.layer.as_ref().map_or(0, |x| x.uid); + Some((resolved, uid, layer_fingerprint(effect))) +} + +/// Keep `gfx_edit` on the layer it started on. Called before the overlay draws, so a command run +/// between two passes (arrange/delete/undo or a control edit) cannot shift the caret into another +/// layer. +fn reconcile_gfx_edit(app: &mut FilmcraftApp) { + let Some(ed) = app.ui.gfx_edit.clone() else { return }; + match resolve_gfx_edit(&app.session, &ed) { + Some((layer, uid, fingerprint)) => { + // A uid-less owner key includes the index, so an index change is a new context. A + // stable uid layer keeps the same owner and its composition may continue. + if layer != ed.layer && ed.uid == 0 { + app.invalidate_ime_composition(); + } + app.ui.gfx_edit = Some(GfxEdit { layer, uid, fingerprint, ..ed }); + } + None => { + app.ui.gfx_edit = None; + app.invalidate_ime_composition(); + } + } +} + +fn graphics_ime_target(sequence: u64, clip: u64, layer: usize, uid: u64, replacement_epoch: u64) -> ImeTarget { + // The owner excludes caret, selection, text, revision, geometry, visual order and pointers. + // `uid` is the stable layer component id; zero means "none", where the layer index is the + // only component identity available. + let component_uid = (uid != 0).then_some(uid); + let component_index = (uid == 0).then_some(layer as u64); + ImeTarget::new(egui::Id::unique(("filmcraft.graphics.ime", sequence, clip, component_uid, component_index)), replacement_epoch) +} + +fn current_graphics_ime_target(app: &FilmcraftApp, clip: u64, layer: usize) -> Option { + let sequence = app.session.state.active_sequence?.0; + let uid = current_layer_uid(&app.session, clip, layer)?; + Some(graphics_ime_target(sequence, clip, layer, uid, app.ime_replacement_epoch)) +} + +/// Is this IME commit/delete left over from a composition whose target or caret/focus was +/// interrupted? A terminal-pending composition still owns its pending commit/delete after an empty +/// preedit. `None` (no preedit was seen) still accepts a direct commit. +fn ime_event_is_stale(app: &FilmcraftApp, target: ImeTarget) -> bool { + match app.ime_composition { + Some(crate::ImeComposition::Stale) => true, + Some(crate::ImeComposition::Active(active) | crate::ImeComposition::TerminalPending(active)) => active != target, + None => false, + } +} + /// Keyboard handling while a text layer is being edited. -fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: GfxEdit, resp: &egui::Response, actions: &mut Vec<(String, Value)>) { +fn text_edit( + app: &mut FilmcraftApp, + ui: &mut egui::Ui, + v: &LayerView, + mut ed: GfxEdit, + resp: &egui::Response, + actions: &mut Vec<(String, Value)>, + pending_ime: &mut Option, +) { let _ = resp; // moving to another panel ends the edit; Properties and Essential Graphics style the text // being edited, so working there keeps it (#683). Only a move counts: an edit started with // another panel focused (an agent's `ui.set`) goes on until the focus moves. use crate::dock::PanelKind; - let panel_id = egui::Id::new(TEXT_EDIT_ID).with("panel"); + let panel_id = egui::Id::unique(TEXT_EDIT_ID).with("panel"); let before = ui.data(|d| d.get_temp::(panel_id)); ui.data_mut(|d| d.insert_temp(panel_id, app.ui.focused)); if before.is_some_and(|b| b != app.ui.focused) && !matches!(app.ui.focused, PanelKind::Program | PanelKind::Properties | PanelKind::EssentialGraphics) { end_edit(app, ui); return; } - let eid = egui::Id::new(TEXT_EDIT_ID); + let eid = egui::Id::unique(TEXT_EDIT_ID); // a real (non-clickable) widget holds keyboard focus so shortcuts pause while typing let fr = ui.interact(Rect::from_points(&v.quad()), eid, Sense::focusable_noninteractive()); - fr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Text layer")); + fr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, "Text layer")); // while another field has the keyboard (a value typed in Properties) the keys are its own; // the text takes the keyboard back once nothing has it (#683) if ui.memory(|m| m.focused().is_some_and(|f| f != eid)) { + app.invalidate_ime_composition(); return; } ui.memory_mut(|m| { @@ -1037,12 +1634,27 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G }); let Some(tp) = text_of(&v.spec) else { return }; let mut text = tp.text.clone(); - ed.caret = ed.caret.min(text.len()); - ed.anchor = ed.anchor.min(text.len()); + // `ui.set.gfxEdit` accepts arbitrary byte offsets. Clamp both ends to UTF-8 boundaries here, + // before any slice/replace_range/insert_str, so hostile offsets can never panic the editor. + clamp_edit_offsets(&text, &mut ed); let l = text_layout(tp); let events = ui.input(|i| i.events.clone()); + let sequence = app.session.state.active_sequence.map_or(0, |sequence| sequence.0); + let composition_target = graphics_ime_target(sequence, ed.clip, ed.layer, v.spec.uid, app.ime_replacement_epoch); let mut changed = false; let mut stop = false; + let mut application_mutation = false; + // A stale event was rejected in this raw input batch. A rejected commit is the composition's + // terminal boundary: consume the tombstone immediately, so a preedit later in the same raw + // batch starts a new composition. A stale empty preedit or delete is only tentatively + // terminal: keep the tombstone through the batch so its paired old commit is rejected too, + // then clear it at batch end when no commit followed. A lone non-empty preedit is only an + // update of the old composition: keep the tombstone across batches so its later commit is + // still dropped. + let mut stale_batch = false; + let mut stale_preedit_only = false; + let mut ime_before: Option<(usize, usize)> = None; + let mut ime_after: Vec = Vec::new(); let sel = |ed: &GfxEdit| (ed.caret.min(ed.anchor), ed.caret.max(ed.anchor)); let delete_sel = |text: &mut String, ed: &mut GfxEdit| -> bool { let (a, b) = sel(ed); @@ -1054,10 +1666,75 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G ed.anchor = a; true }; - for ev in events { + 'events: for ev in events { match ev { - // an input method (Chinese, Japanese, Korean) hands over its finished text as a commit (#1083) - egui::Event::Text(s) | egui::Event::Paste(s) | egui::Event::Ime(egui::ImeEvent::Commit(s)) => { + // The platform IME owns a composition between a non-empty preedit and its terminal + // commit; an empty preedit leaves that terminal event pending. Remember which target + // it belongs to; an event after the target was replaced must not edit the new layer. + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => { + if text.is_empty() { + // Dismissing a pre-edit clears the visible composition, but its terminal + // commit/delete may arrive in a later event. Keep the owner as + // terminal-pending so a target switch still rejects that event. A stale + // empty preedit must not lift the tombstone before a paired old commit. + if matches!(app.ime_composition, Some(crate::ImeComposition::Stale)) { + stale_batch = true; + stale_preedit_only = false; + } else { + app.ime_composition = Some(crate::ImeComposition::TerminalPending(composition_target)); + } + } else if matches!(app.ime_composition, Some(crate::ImeComposition::Stale)) { + // This pre-edit belongs to the composition whose target was replaced: an + // input method can emit further non-empty pre-edits before its final + // commit. Do not revive the old composition on the replacement target. + // Keep the tombstone past this batch: the old composition may commit in a + // later raw batch; a later empty preedit, delete or commit ends it. + stale_batch = true; + stale_preedit_only = true; + } else { + app.ime_composition = Some(crate::ImeComposition::Active(composition_target)); + } + } + // text-input-v3/Wayland delete-surrounding arrives in UTF-8 bytes. Only whole + // characters fully inside the reported range are deleted: a byte count that ends + // inside a character leaves that character (the fork's contract), so a combining + // accent or the letter next to an emoji is preserved. + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { + if ime_event_is_stale(app, composition_target) { + // Keep the tombstone for the commit paired with this delete. + stale_batch = true; + stale_preedit_only = false; + continue; + } + let (a, b) = sel(&ed); + let (before, after) = surrounding_byte_deletion_ranges(&text, a, b, before_bytes, after_bytes); + if !before.is_empty() || !after.is_empty() { + let (before_caret, before_anchor) = (ed.caret, ed.anchor); + apply_surrounding_deletion(&mut text, &mut ed, before, after); + ime_before.get_or_insert((before_caret, before_anchor)); + ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); + changed = true; + } + } + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { + if ime_event_is_stale(app, composition_target) { + stale_batch = true; + stale_preedit_only = false; + continue; + } + let (a, b) = sel(&ed); + let before = prev_n_chars(&text, a, before_chars)..a; + let after = b..next_n_chars(&text, b, after_chars); + if !before.is_empty() || !after.is_empty() { + let (before_caret, before_anchor) = (ed.caret, ed.anchor); + apply_surrounding_deletion(&mut text, &mut ed, before, after); + ime_before.get_or_insert((before_caret, before_anchor)); + ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); + changed = true; + } + } + // ordinary typing and paste are application-origin, even in the same pass as an IME event + egui::Event::Text(s) | egui::Event::Paste(s) => { let s: String = s.chars().filter(|c| !c.is_control() || *c == '\n').collect(); if s.is_empty() { continue; @@ -1066,14 +1743,49 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G text.insert_str(ed.caret, &s); ed.caret += s.len(); ed.anchor = ed.caret; + application_mutation = true; + changed = true; + } + // an input method (Chinese, Japanese, Korean) hands over its finished text as a commit (#1083) + egui::Event::Ime(egui::ImeEvent::Commit(s)) => { + if ime_event_is_stale(app, composition_target) { + // A commit is the composition's terminal boundary. Consume the tombstone now, + // so a preedit later in this same raw batch starts a new composition. The + // earlier empty preedit/delete already rejected the old composition's edits. + app.ime_composition = None; + stale_batch = true; + stale_preedit_only = false; + continue; + } + app.ime_composition = None; + let s: String = s.chars().filter(|c| !c.is_control() || *c == '\n').collect(); + let (before_caret, before_anchor) = (ed.caret, ed.anchor); + if s.is_empty() { + // A commit replaces the selection even when the input method produced no text + // (for example DeleteSurroundingBytes followed by an empty commit). An empty + // commit with no selection stays a no-op. + if delete_sel(&mut text, &mut ed) { + ime_before.get_or_insert((before_caret, before_anchor)); + ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); + changed = true; + } + continue; + } + delete_sel(&mut text, &mut ed); + text.insert_str(ed.caret, &s); + ed.caret += s.len(); + ed.anchor = ed.caret; + ime_before.get_or_insert((before_caret, before_anchor)); + ime_after.push(ime_stamp(&text, ed.caret, ed.anchor)); changed = true; } egui::Event::Copy | egui::Event::Cut => { let (a, b) = sel(&ed); if a < b { ui.ctx().copy_text(text[a..b].to_string()); - if matches!(ev, egui::Event::Cut) { - changed |= delete_sel(&mut text, &mut ed); + if matches!(ev, egui::Event::Cut) && delete_sel(&mut text, &mut ed) { + application_mutation = true; + changed = true; } } } @@ -1081,33 +1793,53 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G let shift = modifiers.shift; let mut moved = None; match key { - egui::Key::Escape => stop = true, + egui::Key::Escape => { + // Escape cancels the edit now: a commit later in the same raw batch must + // not still be inserted after the user cancelled. + stop = true; + break 'events; + } egui::Key::Enter => { delete_sel(&mut text, &mut ed); text.insert(ed.caret, '\n'); ed.caret += 1; ed.anchor = ed.caret; + application_mutation = true; changed = true; } egui::Key::Backspace => { - if !delete_sel(&mut text, &mut ed) && ed.caret > 0 { + let mut did = delete_sel(&mut text, &mut ed); + if !did && ed.caret > 0 { let p = if modifiers.alt || modifiers.command { word_left(&text, ed.caret) } else { prev_boundary(&text, ed.caret) }; text.replace_range(p..ed.caret, ""); ed.caret = p; ed.anchor = p; + did = true; + } + if did { + application_mutation = true; + changed = true; } - changed = true; } egui::Key::Delete => { - if !delete_sel(&mut text, &mut ed) && ed.caret < text.len() { + let mut did = delete_sel(&mut text, &mut ed); + if !did && ed.caret < text.len() { let n = next_boundary(&text, ed.caret); text.replace_range(ed.caret..n, ""); + did = true; + } + if did { + application_mutation = true; + changed = true; } - changed = true; } egui::Key::A if modifiers.command => { + let moved = (ed.anchor, ed.caret) != (0, text.len()); ed.anchor = 0; ed.caret = text.len(); + if moved { + app.invalidate_ime_composition(); + } } egui::Key::ArrowLeft => { let (a, _) = sel(&ed); @@ -1147,17 +1879,57 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G _ => {} } if let Some(m) = moved { + let previous_selection = (ed.caret, ed.anchor); ed.caret = m.min(text.len()); if !shift { ed.anchor = ed.caret; } + if previous_selection != (ed.caret, ed.anchor) { + app.invalidate_ime_composition(); + } } } + // A pointer press is handled later in this pass (caret placement, another layer, or + // ending the edit). Events after it in the same raw batch belong to the new target + // and must not be applied to the old one. + egui::Event::PointerButton { pressed: true, .. } => break 'events, + _ => {} + } + } + if stale_batch { + // A tentatively terminal empty preedit/delete with no following commit ended the rejected + // composition: clear the tombstone so a later composition on the replacement target is + // accepted. A lone non-empty preedit keeps it for that composition's later commit. An + // active or terminal-pending composition recorded for another target is equally stale; a + // fresh pre-edit on this target is kept. + match app.ime_composition { + Some(crate::ImeComposition::Stale) if !stale_preedit_only => app.ime_composition = None, + Some(crate::ImeComposition::Active(active) | crate::ImeComposition::TerminalPending(active)) if active != composition_target => { + app.ime_composition = None; + } _ => {} } } if changed && text != tp.text { + let action_index = actions.len(); + let first_before = + ime_before.and_then(|(cursor, anchor)| (!ime_after.is_empty()).then(|| ImeMutationStamp::from_committed(&tp.text, cursor, anchor, None))); + + *pending_ime = Some(PendingImeEdit { + action_index, + clip: ed.clip, + layer: ed.layer, + first_before, + before_cursor: ime_before.map_or(ed.caret, |(cursor, _)| cursor), + before_anchor: ime_before.map_or(ed.anchor, |(_, anchor)| anchor), + after: ime_after, + final_cursor: ed.caret, + final_anchor: ed.anchor, + application_mutation, + }); actions.push(("graphics.setText".into(), json!({"clip": ed.clip, "layer": ed.layer, "text": text, "merge": true}))); + } else { + *pending_ime = None; } if stop { end_edit(app, ui); @@ -1166,6 +1938,100 @@ fn text_edit(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &LayerView, mut ed: G } } +/// Clamp an edit caret/anchor to valid UTF-8 boundaries (hostile `ui.set.gfxEdit` offsets). +fn clamp_edit_offsets(text: &str, ed: &mut GfxEdit) { + ed.caret = char_boundary_floor(text, ed.caret); + ed.anchor = char_boundary_floor(text, ed.anchor); +} + +/// The two ranges `DeleteSurroundingBytes` deletes: whole characters fully contained in the +/// bytes before and after the sorted selection. The selection itself and any pre-edit (absent +/// from FilmCraft's committed model) are excluded. A byte count that ends inside a character +/// leaves that character whole. +fn surrounding_byte_deletion_ranges( + text: &str, + a: usize, + b: usize, + before_bytes: usize, + after_bytes: usize, +) -> (core::ops::Range, core::ops::Range) { + let a = char_boundary_floor(text, a); + let b = char_boundary_ceil(text, b).max(a); + let from = a.saturating_sub(before_bytes); + let before_chars = text[..a].char_indices().rev().take_while(|(index, _)| *index >= from).count(); + let after_chars = text[b..].char_indices().take_while(|(index, c)| index + c.len_utf8() <= after_bytes).count(); + (prev_n_chars(text, a, before_chars)..a, b..next_n_chars(text, b, after_chars)) +} + +/// Apply the two delete-surrounding ranges while preserving the selection's direction. +/// +/// Delete after the selection first, then before it, so the selection remains intact and both +/// endpoints shift only by the preceding deletion. This mirrors the stock `TextEdit`: a +/// surrounding deletion does not consume the selection, and the commit that usually follows +/// replaces it. +fn apply_surrounding_deletion(text: &mut String, ed: &mut GfxEdit, before: core::ops::Range, after: core::ops::Range) { + let before_end = before.end; + let before_len = before.len(); + if !after.is_empty() { + text.replace_range(after, ""); + } + if !before.is_empty() { + text.replace_range(before, ""); + } + let shift = |offset: usize| { + let offset = if offset >= before_end { offset - before_len } else { offset }; + offset.min(text.len()) + }; + ed.caret = shift(ed.caret); + ed.anchor = shift(ed.anchor); +} + +/// Largest char boundary not after `i`. +fn char_boundary_floor(s: &str, mut i: usize) -> usize { + i = i.min(s.len()); + while i > 0 && !s.is_char_boundary(i) { + i -= 1; + } + i +} + +/// Smallest char boundary not before `i`. +fn char_boundary_ceil(s: &str, mut i: usize) -> usize { + i = i.min(s.len()); + while i < s.len() && !s.is_char_boundary(i) { + i += 1; + } + i +} + +fn prev_n_chars(s: &str, mut i: usize, n: usize) -> usize { + i = char_boundary_floor(s, i); + for _ in 0..n { + if i == 0 { + break; + } + i = match s[..i].char_indices().next_back() { + Some((j, _)) => j, + None => 0, + }; + } + i +} + +fn next_n_chars(s: &str, mut i: usize, n: usize) -> usize { + i = char_boundary_ceil(s, i); + for _ in 0..n { + if i >= s.len() { + break; + } + i = match s[i..].char_indices().nth(1) { + Some((j, _)) => i + j, + None => s.len(), + }; + } + i +} + fn word_left(s: &str, i: usize) -> usize { let mut j = i; let b: Vec<(usize, char)> = s[..i].char_indices().collect(); @@ -1271,7 +2137,7 @@ impl Ctx<'_> { } let animated = self.e.params.get(id).is_some_and(|p| p.is_animated()); let sw = Rect::from_center_size(pos2(row.min.x + 8.0, row.center().y), vec2(12.0, 12.0)); - let resp = ui.interact(sw.expand(2.0), egui::Id::new(("gfxsw", self.clip.0, self.layer, id)), Sense::click()).on_hover_text(tl!("Toggle animation")); + let resp = ui.interact(sw.expand(2.0), egui::Id::unique(("gfxsw", self.clip.0, self.layer, id)), Sense::click()).on_hover_text(tl!("Toggle animation")); let tint = if animated { self.t.accent } else if resp.hovered() { @@ -1288,16 +2154,17 @@ impl Ctx<'_> { fn number(&mut self, ui: &mut egui::Ui, label: &str, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) { let v = pv(self.e, id, self.mt).as_f64().unwrap_or(0.0); let (r, mut vui) = self.row(ui, label); - let (resp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); + let (resp, nv) = + crate::widgets::hot_number(&mut vui, label, egui::Id::unique(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); self.auto(id, resp.rect, label); self.stopwatch(ui, r, id); if let Some(nv) = nv { self.set(id, json!(nv)); } } - fn number_in(&mut self, vui: &mut egui::Ui, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) { + fn number_in(&mut self, vui: &mut egui::Ui, label: &str, id: &str, speed: f64, range: (f64, f64), dec: usize, unit: &str) { let v = pv(self.e, id, self.mt).as_f64().unwrap_or(0.0); - let (resp, nv) = crate::widgets::hot_number(vui, egui::Id::new(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); + let (resp, nv) = crate::widgets::hot_number(vui, label, egui::Id::unique(("gfxp", self.clip.0, self.layer, id)), v, speed, range, dec, unit, &self.t); self.auto(id, resp.rect, id); if let Some(nv) = nv { self.set(id, json!(nv)); @@ -1309,7 +2176,8 @@ impl Ctx<'_> { self.stopwatch(ui, r, id); let (rx, nx) = crate::widgets::hot_number( &mut vui, - egui::Id::new(("gfxp", self.clip.0, self.layer, id, "x")), + &format!("{label} X"), + egui::Id::unique(("gfxp", self.clip.0, self.layer, id, "x")), v.x, 1.0, (-100_000.0, 100_000.0), @@ -1320,7 +2188,8 @@ impl Ctx<'_> { vui.add_space(10.0); let (ry, ny) = crate::widgets::hot_number( &mut vui, - egui::Id::new(("gfxp", self.clip.0, self.layer, id, "y")), + &format!("{label} Y"), + egui::Id::unique(("gfxp", self.clip.0, self.layer, id, "y")), v.y, 1.0, (-100_000.0, 100_000.0), @@ -1367,7 +2236,8 @@ impl Ctx<'_> { ui.selectable_value(&mut sel, i, crate::i18n::t(o)); } }); - self.auto(id, r.response.rect, id); + let response = crate::widgets::accessible_name(r.response, id); + self.auto(id, response.rect, id); if sel != cur { self.set(id, json!(sel)); } @@ -1573,7 +2443,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().text(pos2(r.min.x + 38.0, r.center().y), Align2::LEFT_CENTER, layer_display_name(e, li), Tokens::ui(12.0), t.text); // visibility let er = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); - let eresp = ui.interact(er, egui::Id::new(("gfx-eye", clip.0, li)), Sense::click()); + let eresp = ui.interact(er, egui::Id::unique(("gfx-eye", clip.0, li)), Sense::click()); icons::paint(ui.painter(), er.shrink(2.0), if e.enabled { Icon::Eye } else { Icon::EyeOff }, t.text_dim); autos.push((format!("graphics.layers.{li}.visible"), er, "Toggle visibility".into())); autos.push((format!("graphics.layers.{li}"), r, layer_display_name(e, li))); @@ -1648,7 +2518,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let (open, head) = section_header(ui, app, "Text", &t); // Text Properties: the layer type (point / paragraph) and text styling let wr = Rect::from_center_size(pos2(head.max.x - 16.0, head.center().y), vec2(22.0, 22.0)); - let wresp = ui.interact(wr, egui::Id::new(("gfx-text-props", clip.0, l)), Sense::click()); + let wresp = ui.interact(wr, egui::Id::unique(("gfx-text-props", clip.0, l)), Sense::click()); icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.text } else { t.text_dim }); cx.autos.push(("graphics.textProperties".into(), wr, "Text Properties".into())); if wresp.on_hover_text(tl!("Text Properties")).clicked() { @@ -1674,7 +2544,10 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let before = s.clone(); ui.horizontal(|ui| { ui.add_space(14.0); - let r = ui.add(egui::TextEdit::multiline(&mut s).desired_rows(2).desired_width(ui.available_width() - 8.0).font(Tokens::ui(12.0))); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::multiline(&mut s).desired_rows(2).desired_width(ui.available_width() - 8.0).font(Tokens::ui(12.0))), + "Source Text", + ); cx.autos.push(("graphics.sourceText".into(), r.rect, "Source Text".into())); }); if s != before { @@ -1700,7 +2573,8 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }); - cx.auto("font", r.response.rect, "Font"); + let response = crate::widgets::accessible_name(r.response, "Font"); + cx.auto("font", response.rect, "Font"); let styles: Vec = fams.iter().find(|(f, _)| f.eq_ignore_ascii_case(&family)).map(|(_, s)| s.clone()).unwrap_or_default(); let (_, mut vui) = cx.row(ui, tl!("Font Style")); let r = egui::ComboBox::from_id_salt(("gfx-style", clip.0, l)).selected_text(&style).width(170.0).show_ui(&mut vui, |ui| { @@ -1720,7 +2594,8 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }); - cx.auto("font_style", r.response.rect, "Font Style"); + let response = crate::widgets::accessible_name(r.response, "Font Style"); + cx.auto("font_style", response.rect, "Font Style"); cx.number(ui, tl!("Font Size"), "size", 0.5, (1.0, 2000.0), 0, ""); let align = match pv(e, "align", mt) { ParamValue::Choice(c) => c, @@ -1815,7 +2690,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { cx.check(&mut vui, on, ""); cx.color(&mut vui, col); vui.add_space(6.0); - cx.number_in(&mut vui, w, 0.2, (0.0, 1000.0), 1, ""); + cx.number_in(&mut vui, &format!("{label} width"), w, 0.2, (0.0, 1000.0), 1, ""); vui.add_space(6.0); cx.choice(&mut vui, kind, graphic::STROKE_OPTS, 70.0); } @@ -1845,7 +2720,7 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&id, r, &label); } let (down, press) = ui.ctx().input(|i| (i.pointer.any_down(), i.pointer.press_start_time())); - let key = egui::Id::new("gfx-props-drag-step"); + let key = egui::Id::unique("gfx-props-drag-step"); let begun = ui.ctx().data(|d| d.get_temp::>(key)).flatten(); let mut wrote = false; for (cmd, mut p) in actions { @@ -1874,7 +2749,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut close = crate::widgets::escape_closes(ctx); let mut ok = false; let name = |paragraph: bool| if paragraph { tl!("Paragraph Text") } else { tl!("Point Text") }; - let id = egui::Id::new("gfx-text-properties"); + let id = egui::Id::unique("gfx-text-properties"); egui::Window::new(tl!("Text Properties")).id(id).collapsible(false).resizable(false).centered(ctx).show(ctx, |ui| { ui.label(tl!("Text Layer Type")); let r = egui::ComboBox::from_id_salt("gfx-text-type").selected_text(name(d.paragraph)).width(190.0).show_ui(ui, |ui| { @@ -1926,3 +2801,101 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { } app.ui.text_props_dialog = if ok || close { None } else { Some(d) }; } + +#[cfg(test)] +mod ime_byte_delete_tests { + use super::{ + GfxEdit, apply_surrounding_deletion, char_boundary_ceil, char_boundary_floor, clamp_edit_offsets, graphics_ime_target, next_n_chars, prev_n_chars, + surrounding_byte_deletion_ranges, + }; + + #[test] + fn byte_boundaries_never_split_a_code_point() { + let s = "aé😀z"; // 1 + 2 + 4 + 1 bytes + assert_eq!(char_boundary_floor(s, 2), 1); + assert_eq!(char_boundary_ceil(s, 2), 3); + assert_eq!(char_boundary_floor(s, 4), 3); + assert_eq!(char_boundary_ceil(s, 4), 7); + assert_eq!(char_boundary_floor(s, 999), s.len()); + } + + #[test] + fn deleting_surrounding_bytes_leaves_partial_characters() { + let s = "aé😀z"; // a=1, é=1..3, 😀=3..7, z=7..8 + // A cursor after "aé" (byte 3): one byte of "é" is not a whole character, so nothing is + // deleted; two bytes are exactly "é". + assert_eq!(surrounding_byte_deletion_ranges(s, 3, 3, 1, 0), (3..3, 3..3)); + assert_eq!(surrounding_byte_deletion_ranges(s, 3, 3, 2, 0), (1..3, 3..3)); + assert_eq!(&s[1..3], "é"); + // After the cursor: the 4-byte emoji is only deleted when all four bytes are reported. + for partial in 1..4 { + assert_eq!(surrounding_byte_deletion_ranges(s, 3, 3, 0, partial), (3..3, 3..3), "after_bytes={partial}"); + } + assert_eq!(surrounding_byte_deletion_ranges(s, 3, 3, 0, 4), (3..3, 3..7)); + // The selection itself is excluded; only its surroundings are deleted. + assert_eq!(surrounding_byte_deletion_ranges(s, 1, 3, 0, 0), (1..1, 3..3)); + assert_eq!(surrounding_byte_deletion_ranges(s, 1, 3, 1, 4), (0..1, 3..7)); + } + + #[test] + fn surrounding_deletion_keeps_reversed_selection_direction() { + let mut text = "aé😀z".to_string(); + let mut ed = GfxEdit { clip: 1, layer: 0, caret: 3, anchor: 1, ..Default::default() }; + let (before, after) = surrounding_byte_deletion_ranges(&text, 1, 3, 1, 4); + apply_surrounding_deletion(&mut text, &mut ed, before, after); + assert_eq!(text, "éz"); + assert_eq!((ed.caret, ed.anchor), (2, 0), "primary caret stays after the anchor"); + } + + #[test] + fn hostile_edit_offsets_are_clamped_to_char_boundaries() { + let s = "é😀"; + let mut ed = GfxEdit { clip: 1, layer: 0, caret: 1, anchor: 3, ..Default::default() }; + clamp_edit_offsets(s, &mut ed); + assert_eq!((ed.caret, ed.anchor), (0, 2), "offsets inside the two characters move to boundaries"); + assert_eq!(s.get(ed.caret..ed.anchor), Some("é")); + } + + #[test] + fn character_deletion_is_bounded() { + let s = "aé😀z".to_string(); + assert_eq!(prev_n_chars(&s, s.len(), 99), 0); + assert_eq!(next_n_chars(&s, 0, 2), "aé".len()); + assert_eq!(prev_n_chars(&s, 5, 1), 1); + assert_eq!(next_n_chars(&s, 1, 1), 3); + } + + #[test] + fn tracker_state_is_scoped_per_viewport_and_closed_viewports_are_dropped() { + use egui::ViewportId; + use egui::ime_context::{CommittedText, ImeContextTracker, ImeObservation}; + + let mut tracker = ImeContextTracker::new(); + let target = graphics_ime_target(7, 11, 3, 42, 5); + let first_viewport = ViewportId::ROOT; + let second_viewport = ViewportId::from_hash_of("filmcraft-ime-second-viewport"); + let observe = |tracker: &mut ImeContextTracker, viewport| { + tracker.observe(viewport, Some(ImeObservation::new(0, 0, target).committed(Some(CommittedText::new("one", 3, 3))))) + }; + let first = observe(&mut tracker, first_viewport).expect("first viewport is observed"); + let second = observe(&mut tracker, second_viewport).expect("second viewport is observed"); + assert_ne!(first.context_generation, second.context_generation, "each viewport owns its generation"); + let repeated = observe(&mut tracker, first_viewport).expect("first viewport remains observed"); + assert_eq!(repeated.context_generation, first.context_generation, "a second viewport does not disturb the first"); + tracker.end_viewport(second_viewport); + let reopened = observe(&mut tracker, second_viewport).expect("the viewport can be observed again"); + assert_ne!(reopened.context_generation, second.context_generation, "closed viewport state is dropped"); + } + + #[test] + fn ime_owner_uses_stable_component_identity_and_explicit_replacement() { + let a = graphics_ime_target(7, 11, 3, 42, 5); + let b = graphics_ime_target(7, 11, 9, 42, 5); + assert_eq!(a, b, "the stable layer uid, not the mutable layer index, is the component"); + assert_ne!(a, graphics_ime_target(7, 11, 3, 42, 6), "replacement epoch is explicit"); + assert_ne!(a, graphics_ime_target(7, 11, 3, 43, 5), "a different component is a different owner"); + assert_ne!(a, graphics_ime_target(8, 11, 3, 42, 5), "sequence identity is part of the owner"); + let no_uid = graphics_ime_target(7, 11, 3, 0, 5); + assert_ne!(no_uid, graphics_ime_target(7, 11, 4, 0, 5), "without a layer uid, the index is the identity"); + } +} diff --git a/crates/ui-egui/src/panels/graphics_templates.rs b/crates/ui-egui/src/panels/graphics_templates.rs index 0aafa2eec..483658fdd 100644 --- a/crates/ui-egui/src/panels/graphics_templates.rs +++ b/crates/ui-egui/src/panels/graphics_templates.rs @@ -86,7 +86,7 @@ type Elems = Vec<(String, Rect, String)>; struct Lib(Arc<(f64, Vec)>); fn lib_id() -> egui::Id { - egui::Id::new("gfx-template-library") + egui::Id::unique("gfx-template-library") } /// The template library, re-read at most every two seconds (or after [`invalidate`]). @@ -110,7 +110,7 @@ const THUMB_W: u32 = 192; fn thumb_texture(ctx: &egui::Context, e: &LibraryEntry) -> egui::TextureHandle { let px = thumbnail_cached(&e.template, THUMB_W); - let key = egui::Id::new(("gfx-thumb", e.template.id.as_str(), Arc::as_ptr(&px) as usize)); + let key = egui::Id::unique(("gfx-thumb", e.template.id.as_str(), Arc::as_ptr(&px) as usize)); if let Some(t) = ctx.data(|d| d.get_temp::(key)) { return t; } @@ -125,7 +125,7 @@ fn thumb_texture(ctx: &egui::Context, e: &LibraryEntry) -> egui::TextureHandle { // ------------------------------------------------------------------------------------------------ fn tab_button(ui: &mut egui::Ui, r: Rect, label: &str, on: bool, t: &Tokens) -> egui::Response { - let resp = ui.interact(r, egui::Id::new(("eg-tab", label)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("eg-tab", label)), Sense::click()); if on { ui.painter().line_segment([pos2(r.min.x + 6.0, r.max.y - 1.5), pos2(r.max.x - 6.0, r.max.y - 1.5)], Stroke::new(2.0, t.accent)); } @@ -177,7 +177,10 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { b.set_clip_rect(rect); let st = &mut app.ui.gfx_templates; b.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tl!("Search templates")).desired_width((ui.available_width() - 230.0).max(110.0))); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut st.query).hint_text(tl!("Search templates")).desired_width((ui.available_width() - 230.0).max(110.0))), + "Search templates", + ); elems.push(("gfxTemplates.search".into(), r.rect, "Search".into())); let cur = if st.category.is_empty() { tl!("All Categories").to_string() } else { crate::i18n::t(&st.category).to_string() }; let r = egui::ComboBox::from_id_salt("gfx-cat").selected_text(cur).width(130.0).show_ui(ui, |ui| { @@ -186,7 +189,8 @@ fn browse(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.selectable_value(&mut st.category, c.clone(), crate::i18n::t(c)); } }); - elems.push(("gfxTemplates.category".into(), r.response.rect, "Category".into())); + let r = crate::widgets::accessible_name(r.response, "Category"); + elems.push(("gfxTemplates.category".into(), r.rect, "Category".into())); let r = ui.button(tl!("Install…")).on_hover_text(tl!("Install Motion Graphics Template (.fcgt)")); elems.push(("gfxTemplates.install".into(), r.rect, "Install".into())); install = r.clicked(); @@ -360,7 +364,10 @@ pub fn template_controls( let r = ui .horizontal(|ui| { ui.add_space(14.0); - ui.add(egui::TextEdit::multiline(&mut s).desired_rows(rows).desired_width(ui.available_width() - 8.0)) + crate::widgets::accessible_name( + ui.add(egui::TextEdit::multiline(&mut s).desired_rows(rows).desired_width(ui.available_width() - 8.0)), + c.name.clone(), + ) }) .inner; autos.push((id, r.rect, c.name.clone())); @@ -387,7 +394,7 @@ pub fn template_controls( _ => String::new(), }; let before = s.clone(); - let r = vui.add(egui::TextEdit::singleline(&mut s).desired_width(vui.available_width() - 4.0)); + let r = crate::widgets::accessible_name(vui.add(egui::TextEdit::singleline(&mut s).desired_width(vui.available_width() - 4.0)), c.name.clone()); autos.push((id, r.rect, c.name.clone())); if s != before { set(json!(s), actions); @@ -647,7 +654,7 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut elems: Elems = Vec::new(); let mut action: Option = None; egui::Window::new(tl!("Export As Motion Graphics Template")) - .id(egui::Id::new("Export As Motion Graphics Template")) + .id(egui::Id::unique("Export As Motion Graphics Template")) .collapsible(false) .resizable(false) .default_width(420.0) @@ -655,15 +662,15 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { .show(ctx, |ui| { egui::Grid::new("gfx-export-grid").num_columns(2).show(ui, |ui| { ui.label(tl!("Name:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0)), "Name"); elems.push(("exportTemplate.name".into(), r.rect, "Name".into())); ui.end_row(); ui.label(tl!("Category:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.category).desired_width(260.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.category).desired_width(260.0)), "Category"); elems.push(("exportTemplate.category".into(), r.rect, "Category".into())); ui.end_row(); ui.label(tl!("Description:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.description).desired_width(260.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.description).desired_width(260.0)), "Description"); elems.push(("exportTemplate.description".into(), r.rect, "Description".into())); ui.end_row(); }); @@ -674,7 +681,10 @@ fn export_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.horizontal(|ui| { let r = ui.checkbox(&mut c.on, ""); elems.push((format!("exportTemplate.control.{n}"), r.rect, c.name.clone())); - ui.add(egui::TextEdit::singleline(&mut c.name).desired_width(220.0)); + crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut c.name).desired_width(220.0)), + format!("{} property name", c.param), + ); ui.label(egui::RichText::new(c.param.replace('_', " ")).weak()); }); } @@ -726,7 +736,7 @@ fn replace_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut elems: Elems = Vec::new(); let mut action: Option = None; egui::Window::new(tl!("Replace Fonts in Projects")) - .id(egui::Id::new("Replace Fonts in Projects")) + .id(egui::Id::unique("Replace Fonts in Projects")) .collapsible(false) .resizable(false) .default_width(440.0) diff --git a/crates/ui-egui/src/panels/import_mode.rs b/crates/ui-egui/src/panels/import_mode.rs index 2af12cada..8f0e42714 100644 --- a/crates/ui-egui/src/panels/import_mode.rs +++ b/crates/ui-egui/src/panels/import_mode.rs @@ -28,7 +28,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let cw = 260.0; for (i, (title, cmd, sub)) in cards.iter().enumerate() { let r = Rect::from_min_size(rect.min + vec2(24.0 + i as f32 * (cw + 16.0), 90.0), vec2(cw, 150.0)); - let resp = ui.interact(r, egui::Id::new(("imp", *cmd)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("imp", *cmd)), Sense::click()); app.auto.add(&format!("import.{cmd}"), r, title); ui.painter().rect_filled(r, 8.0, if resp.hovered() { t.hover } else { t.tl_header_bg }); ui.painter().text(r.min + vec2(16.0, 110.0), Align2::LEFT_CENTER, *title, Tokens::semibold(14.0), t.text); @@ -56,7 +56,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let label = if primary { tl!("Join us on Discord") } else { crate::i18n::t(label) }; let g = ui.painter().layout_no_wrap(label.to_string(), Tokens::ui(13.0), t.text); let r = Rect::from_min_size(pos2(x, rect.min.y + y + 18.0), vec2(g.size().x + 44.0, 34.0)); - let resp = ui.interact(r, egui::Id::new(("imp-link", *id)), Sense::click()).on_hover_text(*url); + let resp = ui.interact(r, egui::Id::unique(("imp-link", *id)), Sense::click()).on_hover_text(*url); app.auto.add(&format!("import.link.{}", id.trim_start_matches("help.")), r, label); let (bg, fg) = if primary { (if resp.hovered() { t.accent_hover } else { t.accent }, Color32::WHITE) @@ -92,7 +92,7 @@ fn recent(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, y: f32) -> f32 let row = Rect::from_min_size(rect.min + vec2(16.0, y + 16.0 + i as f32 * RECENT_ROW_H), vec2(560.0_f32.min(rect.width() - 32.0).max(0.0), RECENT_ROW_H)); let name = crate::menus::recent_name(path); - let resp = ui.interact(row, egui::Id::new(("imp-recent", i)), Sense::click()).on_hover_text(path.as_str()); + let resp = ui.interact(row, egui::Id::unique(("imp-recent", i)), Sense::click()).on_hover_text(path.as_str()); app.auto.add(&format!("import.recent.{i}"), row, &name); if resp.hovered() { ui.painter().rect_filled(row, 4.0, t.hover); diff --git a/crates/ui-egui/src/panels/interchange_export.rs b/crates/ui-egui/src/panels/interchange_export.rs index 5095d6813..c83e55a6a 100644 --- a/crates/ui-egui/src/panels/interchange_export.rs +++ b/crates/ui-egui/src/panels/interchange_export.rs @@ -29,7 +29,7 @@ struct Draft { } fn draft_id() -> egui::Id { - egui::Id::new("interchange-export-draft") + egui::Id::unique("interchange-export-draft") } const RATES: [u32; 4] = [32_000, 44_100, 48_000, 96_000]; @@ -64,14 +64,14 @@ pub fn is_open(ctx: &egui::Context) -> bool { pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("interchange-export-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("interchange-export-before-drag"), &mut d); let p = if d.omf { "omfExport" } else { "aafExport" }; let mut close = false; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); let title = if d.omf { "OMF Export Settings" } else { "AAF Export Settings" }; let shown = if d.omf { tl!("OMF Export Settings") } else { tl!("AAF Export Settings") }; - egui::Window::new(shown).id(egui::Id::new(title)).collapsible(false).resizable(false).centered(ctx).show(ctx, |ui| { + egui::Window::new(shown).id(egui::Id::unique(title)).collapsible(false).resizable(false).centered(ctx).show(ctx, |ui| { ui.set_min_width(380.0); egui::Grid::new("interchange-export-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { if d.omf { diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index bdf744196..3e72ad2f7 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -278,7 +278,7 @@ fn focus_find_box(app: &mut FilmcraftApp, ctx: &egui::Context) -> Value { let p = app.ui.focused; let rect = app.auto.find(&format!("panel.{}", p.id())).map(|e| egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))); - ctx.data_mut(|d| d.insert_temp(egui::Id::new(FOCUS_SEARCH), rect.unwrap_or(egui::Rect::EVERYTHING))); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(FOCUS_SEARCH), rect.unwrap_or(egui::Rect::EVERYTHING))); json!({"panel": p.title()}) } @@ -287,7 +287,7 @@ pub const FOCUS_SEARCH: &str = "fc-focus-search"; /// Called by search fields: take focus when Select Find Box targets the panel they sit in. pub fn take_search_focus(ui: &egui::Ui, field: egui::Rect, resp: &egui::Response) { - let id = egui::Id::new(FOCUS_SEARCH); + let id = egui::Id::unique(FOCUS_SEARCH); let Some(panel) = ui.ctx().data(|d| d.get_temp::(id)) else { return }; if panel.contains(field.center()) { resp.request_focus(); @@ -355,7 +355,8 @@ fn begin_text_editing(app: &mut FilmcraftApp) -> Result { let len = pick["text"].as_str().map_or(0, str::len); app.session.execute("graphics.selectLayer", json!({"clip": clip, "layers": [layer]})).map_err(|e| e.to_string())?; // like Premiere, the whole text is selected so typing replaces it - app.ui.gfx_edit = Some(crate::state::GfxEdit { clip, layer, caret: len, anchor: 0 }); + app.restart_ime_target(); + app.ui.gfx_edit = Some(crate::state::GfxEdit { clip, layer, caret: len, anchor: 0, ..Default::default() }); app.show_panel(PanelKind::Program); Ok(json!({"clip": clip, "layer": layer})) } diff --git a/crates/ui-egui/src/panels/lumetri.rs b/crates/ui-egui/src/panels/lumetri.rs index 084d7d81a..f7591e2ff 100644 --- a/crates/ui-egui/src/panels/lumetri.rs +++ b/crates/ui-egui/src/panels/lumetri.rs @@ -127,7 +127,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } if open_section(ui, app, &e, "Curves", "curves_on", "curves", &mut sections) { sub(ui, &t, tl!("RGB Curves")); - let key = egui::Id::new("lumetri-curve-channel"); + let key = egui::Id::unique("lumetri-curve-channel"); let mut ch: usize = ui.data(|d| d.get_temp(key)).unwrap_or(0); ui.horizontal(|ui| { for (i, c) in @@ -345,7 +345,7 @@ fn section(ui: &mut egui::Ui, app: &mut FilmcraftApp, name: &str, on: bool) -> ( ui.painter().text(pos2(r.min.x + 20.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::t(name), Tokens::semibold(13.0), t.text); // enable switch (section bypass) let sw = Rect::from_center_size(pos2(r.max.x - 18.0, r.center().y), vec2(26.0, 14.0)); - let sw_resp = ui.interact(sw.expand(3.0), egui::Id::new(("lumetri-switch", name)), Sense::click()); + let sw_resp = ui.interact(sw.expand(3.0), egui::Id::unique(("lumetri-switch", name)), Sense::click()); ui.painter().rect_filled(sw, 7.0, if on { t.accent } else { Color32::from_rgb(0x55, 0x55, 0x55) }); ui.painter().circle_filled(pos2(if on { sw.max.x - 7.0 } else { sw.min.x + 7.0 }, sw.center().y), 5.0, Color32::WHITE); app.auto.add(&format!("lumetri.switch.{name}"), sw.expand(3.0), &format!("{name} on/off")); @@ -421,7 +421,7 @@ fn slider( let f = ((v - soft_min) / (soft_max - soft_min)).clamp(0.0, 1.0) as f32; let kx = track.min.x + f * track.width(); let hit = Rect::from_min_max(pos2(track.min.x - 6.0, row.min.y), pos2(track.max.x + 6.0, row.max.y)); - let resp = ui.interact(hit, egui::Id::new(("lslider", clip.0, id)), Sense::click_and_drag()); + let resp = ui.interact(hit, egui::Id::unique(("lslider", clip.0, id)), Sense::click_and_drag()); ui.painter().circle_filled(pos2(kx, track.center().y), 5.0, t.panel_bg); ui.painter().circle_stroke( pos2(kx, track.center().y), @@ -443,8 +443,17 @@ fn slider( .max_rect(Rect::from_min_max(pos2(row.max.x - 52.0, row.min.y + 3.0), pos2(row.max.x, row.max.y - 3.0))) .layout(egui::Layout::right_to_left(egui::Align::Center)), ); - let (_, nv) = - crate::widgets::hot_number(&mut vui, egui::Id::new(("lval", clip.0, id)), v, (soft_max - soft_min) / 300.0, (min, max), decimals as usize, "", t); + let (_, nv) = crate::widgets::hot_number( + &mut vui, + label, + egui::Id::unique(("lval", clip.0, id)), + v, + (soft_max - soft_min) / 300.0, + (min, max), + decimals as usize, + "", + t, + ); if let Some(nv) = out.or(nv) { actions.push(json!({"clip": clip.0, "effect": idx, "param": id, "value": (nv * 10f64.powi(decimals as i32)).round() / 10f64.powi(decimals as i32)})); } @@ -510,7 +519,7 @@ fn curve_editor(ui: &mut egui::Ui, e: &EffectInstance, id: &str, hue: bool, col: }; let line: Vec = lut.iter().enumerate().map(|(i, y)| to_screen([i as f32 / 127.0, y.clamp(0.0, 1.0)])).collect(); p.add(egui::Shape::line(line, Stroke::new(1.5, col))); - let drag_key = egui::Id::new(("curve-drag", clip.0, id)); + let drag_key = egui::Id::unique(("curve-drag", clip.0, id)); let mut dragging: Option = ui.data(|d| d.get_temp(drag_key)); let mut new_pts = pts.clone(); let mut changed = false; @@ -612,7 +621,7 @@ fn color_wheel(ui: &mut egui::Ui, e: &EffectInstance, mt: Tick, id: &str, lid: & p.rect_filled(track, 2.0, Color32::from_rgb(0x4b, 0x4b, 0x4b)); let ky = track.center().y - l / 100.0 * track.height() / 2.0; p.circle_stroke(pos2(track.center().x, ky), 4.0, Stroke::new(1.5, Color32::from_rgb(0xb0, 0xb0, 0xb0))); - let lr = ui.interact(track.expand2(vec2(6.0, 0.0)), egui::Id::new(("wl", clip.0, lid)), Sense::click_and_drag()); + let lr = ui.interact(track.expand2(vec2(6.0, 0.0)), egui::Id::unique(("wl", clip.0, lid)), Sense::click_and_drag()); if (lr.dragged() || lr.clicked()) && let Some(pos) = lr.interact_pointer_pos() { @@ -627,8 +636,8 @@ fn color_wheel(ui: &mut egui::Ui, e: &EffectInstance, mt: Tick, id: &str, lid: & fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'static str, Value)>) { let t = app.tokens; sub(ui, &t, tl!("Color Match")); - let key = egui::Id::new("lumetri-match-ref"); - let fd_key = egui::Id::new("lumetri-match-face"); + let key = egui::Id::unique("lumetri-match-ref"); + let fd_key = egui::Id::unique("lumetri-match-face"); let mut tc: String = ui.data(|d| d.get_temp(key)).unwrap_or_else(|| "00:00:00:00".to_string()); let mut face: bool = ui.data(|d| d.get_temp(fd_key)).unwrap_or(true); // Comparison View: the Program monitor shows the reference frame next to the current one, and @@ -645,7 +654,7 @@ fn match_controls(ui: &mut egui::Ui, app: &mut FilmcraftApp, out: &mut Vec<(&'st } ui.horizontal(|ui| { ui.add_sized(vec2(110.0, 20.0), egui::Label::new(egui::RichText::new(tl!("Reference")).color(t.text_dim))); - let r = ui.add(egui::TextEdit::singleline(&mut tc).desired_width(96.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut tc).desired_width(96.0)), "Reference timecode"); app.auto.add("lumetri.match.reference", r.rect, "Reference timecode"); }); let r = ui.checkbox(&mut face, tl!("Face Detection (protect skin tones)")); diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index a25e93643..299852c23 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -57,7 +57,7 @@ pub(crate) fn effect_rows( .enumerate() { let br = Rect::from_center_size(pos2(r.min.x + 40.0 + i as f32 * 24.0, r.center().y), vec2(20.0, 18.0)); - let resp = ui.interact(br, egui::Id::new(("mask-new", clip.0, idx, kind)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(br, egui::Id::unique(("mask-new", clip.0, idx, kind)), Sense::click()).on_hover_text(tip); if resp.hovered() { ui.painter().rect_filled(br, 3.0, t.hover); } @@ -90,7 +90,7 @@ pub(crate) fn effect_rows( crate::panels::effect_controls::row_line(ui, r, lane, &t); let tw = Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(10.0, 10.0)); icons::paint(ui.painter(), tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim); - let twresp = ui.interact(tw.expand(3.0), egui::Id::new(("mask-twirl", clip.0, idx, k)), Sense::click()); + let twresp = ui.interact(tw.expand(3.0), egui::Id::unique(("mask-twirl", clip.0, idx, k)), Sense::click()); // shape glyph: ellipse for smooth paths, square for polygons let path = m.path_at(mt); let glyph = Rect::from_center_size(pos2(r.min.x + 42.0, r.center().y), vec2(12.0, 12.0)); @@ -118,13 +118,15 @@ pub(crate) fn effect_rows( let mut mui = ui.new_child(egui::UiBuilder::new().max_rect(mr).layout(egui::Layout::left_to_right(egui::Align::Center))); let mut mode = m.mode; crate::panels::effect_controls::fit_to_row(&mut mui); - egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| { - for md in MaskMode::ALL { - if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() { - actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "mode": md.label()}))); + let combo = + egui::ComboBox::from_id_salt(("mask-mode", clip.0, idx, k)).selected_text(crate::i18n::t(mode.label())).width(88.0).show_ui(&mut mui, |ui| { + for md in MaskMode::ALL { + if ui.selectable_value(&mut mode, md, crate::i18n::t(md.label())).changed() { + actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "mode": md.label()}))); + } } - } - }); + }); + crate::widgets::accessible_name(combo.response, "Mask mode"); app.auto.add(&format!("{base}.mode"), mr, "Mask mode"); if twresp.clicked() { if open { @@ -166,7 +168,7 @@ pub(crate) fn effect_rows( let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr).layout(egui::Layout::left_to_right(egui::Align::Center))); crate::panels::effect_controls::fit_to_row(&mut cui); let mut inv = m.inverted; - if cui.checkbox(&mut inv, "").changed() { + if crate::widgets::accessible_name(cui.checkbox(&mut inv, ""), "Invert").changed() { actions.push(("masks.set".into(), json!({"clip": clip.0, "effect": idx, "mask": k, "inverted": inv}))); } app.auto.add(&format!("{base}.inverted"), cr, "Inverted"); @@ -286,7 +288,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } v }; - let gesture_id = egui::Id::new("mask-gesture"); + let gesture_id = egui::Id::unique("mask-gesture"); // one gesture (undo step) per pointer press let mut gesture: u64 = ui.data(|d| d.get_temp(gesture_id)).unwrap_or(0); if ui.input(|i| i.pointer.any_pressed()) { @@ -299,7 +301,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra }; // ---- whole mask (drag inside) let bb = Rect::from_points(&screen).expand(2.0).intersect(pic); - let body = ui.interact(bb, egui::Id::new("mask-body"), Sense::click_and_drag()); + let body = ui.interact(bb, egui::Id::unique("mask-body"), Sense::click_and_drag()); app.auto.add("program.mask.body", bb, &m.name); let ptr_in = |p: Option| p.is_some_and(|p| inside(&flat, to_clip.apply(Vec2::new(p.x as f64, p.y as f64)))); if body.drag_started_by(LEFT) { @@ -307,7 +309,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra ui.data_mut(|d| d.insert_temp(gesture_id.with("body"), ok)); } if body.dragged_by(LEFT) && ui.data(|d| d.get_temp::(gesture_id.with("body"))).unwrap_or(false) && body.drag_delta() != egui::Vec2::ZERO { - let d = drag_step(ui, egui::Id::new(("mask-body-drag", gesture)), body.drag_delta()); + let d = drag_step(ui, egui::Id::unique(("mask-body-drag", gesture)), body.drag_delta()); if d != egui::Vec2::ZERO { actions.push(("masks.translate".into(), with(json!({"delta": clip_delta(d), "merge": format!("body-{gesture}")})))); } @@ -345,7 +347,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra Stroke::NONE, )); } - let r = ui.interact(hr.expand(3.0), egui::Id::new(("mask-handle", which)), Sense::drag()); + let r = ui.interact(hr.expand(3.0), egui::Id::unique(("mask-handle", which)), Sense::drag()); app.auto.add(&format!("program.mask.{which}"), hr, if which == "feather" { "Mask Feather" } else { "Mask Expansion" }); if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO { let d = clip_delta(r.drag_delta()); @@ -359,7 +361,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra // ---- corner roundness (masks with sharp corners: rectangles, polygons) actions.extend(roundness_handle(app, ui, &painter, &path, &to_screen, &target)); // ---- tangent handles and vertices - let sel_v_id = egui::Id::new(("mask-sel-vertex", sel.clip.0, sel.effect, sel.mask)); + let sel_v_id = egui::Id::unique(("mask-sel-vertex", sel.clip.0, sel.effect, sel.mask)); let sel_v: Option = ui.data(|d| d.get_temp(sel_v_id)); let alt = ui.input(|i| i.modifiers.alt); let cmd = ui.input(|i| i.modifiers.command); @@ -374,7 +376,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra painter.circle_filled(h, 3.5, Color32::WHITE); painter.circle_stroke(h, 3.5, Stroke::new(1.0, col)); let hr = Rect::from_center_size(h, vec2(8.0, 8.0)); - let r = ui.interact(hr.expand(2.0), egui::Id::new(("mask-tan", which, i)), Sense::drag()); + let r = ui.interact(hr.expand(2.0), egui::Id::unique(("mask-tan", which, i)), Sense::drag()); app.auto.add(&format!("program.mask.{which}.{i}"), hr, "Bezier handle"); if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO { actions.push(( @@ -390,7 +392,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra let selected = sel_v == Some(i); painter.rect_filled(vr, 0.0, if selected { col } else { Color32::WHITE }); painter.rect_stroke(vr, 0.0, Stroke::new(1.0, col), egui::StrokeKind::Middle); - let r = ui.interact(vr.expand(3.0), egui::Id::new(("mask-vertex", i)), Sense::click_and_drag()).on_hover_text(if cfg!(target_os = "macos") { + let r = ui.interact(vr.expand(3.0), egui::Id::unique(("mask-vertex", i)), Sense::click_and_drag()).on_hover_text(if cfg!(target_os = "macos") { tl!("Drag: move · Alt-click: smooth/corner · Cmd-click: delete") } else { tl!("Drag: move · Alt-click: smooth/corner · Ctrl-click: delete") @@ -400,7 +402,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra ui.data_mut(|d| d.insert_temp(sel_v_id, i)); } if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO { - let d = drag_step(ui, egui::Id::new(("mask-vertex-drag", gesture, i)), r.drag_delta()); + let d = drag_step(ui, egui::Id::unique(("mask-vertex-drag", gesture, i)), r.drag_delta()); if d != egui::Vec2::ZERO { actions.push(("masks.moveVertex".into(), with(json!({"vertex": i, "delta": clip_delta(d), "merge": format!("vertex-{gesture}")})))); } @@ -522,7 +524,7 @@ fn roundness_handle( (pa.x + pa.y).total_cmp(&(pb.x + pb.y)) })?; let max_radius = corners.iter().map(|c| c.max_radius).fold(0.0, f64::max); - let id = egui::Id::new("mask-roundness"); + let id = egui::Id::unique("mask-roundness"); let dist_id = id.with("dist"); let dragging = ui.ctx().is_being_dragged(id); let dist: f64 = if dragging { ui.data(|d| d.get_temp(dist_id)).unwrap_or(0.0) } else { 0.0 }; @@ -615,7 +617,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 let Some(to_screen) = clip_to_screen(app, &it, mt, pic, frame) else { return }; let Some(to_clip) = to_screen.inverse() else { return }; let col = Color32::from_rgb(0x5a, 0xb4, 0xff); - let resp = ui.interact(pic, egui::Id::new("mask-pen"), Sense::click_and_drag()); + let resp = ui.interact(pic, egui::Id::unique("mask-pen"), Sense::click_and_drag()); app.auto.add("program.maskPen", pic, "Pen mask"); if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); @@ -630,7 +632,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 _ => p, }; let mut commit = false; - let pressed = resp.drag_started_by(LEFT) || (resp.clicked() && !ui.data(|m| m.get_temp::(egui::Id::new("mask-pen-placed")).unwrap_or(false))); + let pressed = resp.drag_started_by(LEFT) || (resp.clicked() && !ui.data(|m| m.get_temp::(egui::Id::unique("mask-pen-placed")).unwrap_or(false))); if pressed && let Some(p) = resp.interact_pointer_pos() { let near_first = d.points.first().is_some_and(|f| (sp(&to_screen, Vec2::new(f[0], f[1])) - p).length() < 8.0); if d.points.len() >= 3 && near_first { @@ -640,11 +642,11 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 d.points.push([c.x, c.y, 0.0, 0.0]); // read the response before data_mut: it locks the context that drag_started_by reads let dragging = resp.drag_started_by(LEFT); - ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), dragging)); + ui.data_mut(|m| m.insert_temp(egui::Id::unique("mask-pen-placed"), dragging)); } } if resp.clicked() || resp.drag_stopped() { - ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), false)); + ui.data_mut(|m| m.insert_temp(egui::Id::unique("mask-pen-placed"), false)); } if resp.dragged_by(LEFT) && !commit diff --git a/crates/ui-egui/src/panels/media_browser.rs b/crates/ui-egui/src/panels/media_browser.rs index 180f44a33..d3b62294d 100644 --- a/crates/ui-egui/src/panels/media_browser.rs +++ b/crates/ui-egui/src/panels/media_browser.rs @@ -54,7 +54,7 @@ const CACHE_SECS: f64 = 3.0; const ROW_H: f32 = 22.0; fn cache_id() -> egui::Id { - egui::Id::new("media-browser-cache") + egui::Id::unique("media-browser-cache") } fn with_cache(ctx: &egui::Context, f: impl FnOnce(&mut Cache) -> R) -> R { @@ -106,7 +106,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ]; for (icon, id, tip, enabled) in nav { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0)); - let resp = ui.interact(r, egui::Id::new(("mb-nav", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("mb-nav", id)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("mediaBrowser.{id}"), r, tip); if resp.hovered() && enabled { ui.painter().rect_filled(r, 3.0, t.hover); @@ -122,7 +122,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let pw = (bar.max.x - x - 290.0).max(80.0); let pr = Rect::from_min_size(pos2(x + 4.0, bar.min.y + 1.0), vec2(pw, 22.0)); let mut path = app.ui.media_browser.path_edit.clone().unwrap_or_else(|| dir.clone()); - let resp = ui.put(pr, egui::TextEdit::singleline(&mut path).id(egui::Id::new("mb-path")).font(egui::FontId::proportional(11.5))); + let resp = crate::widgets::accessible_name( + ui.put(pr, egui::TextEdit::singleline(&mut path).id(egui::Id::unique("mb-path")).font(egui::FontId::proportional(11.5))), + "Path", + ); app.auto.add("mediaBrowser.path", pr, "Path"); if resp.changed() { app.ui.media_browser.path_edit = Some(path.clone()); @@ -138,7 +141,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // file types let fr = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(150.0, 22.0)); let label = mb::FILE_TYPES.iter().find(|f| f.0 == prefs.file_types).map(|f| f.1.to_string()).unwrap_or_else(|| format!(".{}", prefs.file_types)); - let fresp = crate::widgets::dropdown_text(ui, fr, &label, &t, egui::Id::new("mb-types")).on_hover_text(tl!("File Types Displayed")); + let fresp = crate::widgets::dropdown_text(ui, fr, &label, &t, egui::Id::unique("mb-types")).on_hover_text(tl!("File Types Displayed")); app.auto.add("mediaBrowser.fileTypes", fr, "File Types Displayed"); egui::Popup::menu(&fresp).show(|ui| { for (k, l) in mb::FILE_TYPES { @@ -162,7 +165,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { x = fr.max.x + 6.0; for (icon, v, tip) in [(Icon::ListView, "list", tl!("List View")), (Icon::IconView, "thumbnails", tl!("Thumbnail View"))] { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0)); - let resp = ui.interact(r, egui::Id::new(("mb-view", v)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("mb-view", v)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("mediaBrowser.view.{v}"), r, tip); if prefs.view == v { ui.painter().rect_filled(r, 3.0, t.pressed); @@ -203,7 +206,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let list_r = Rect::from_min_max(pos2(tree_r.max.x + 4.0, body.min.y), body.max); // splitter let split = Rect::from_min_max(pos2(tree_r.max.x, body.min.y), pos2(tree_r.max.x + 4.0, body.max.y)); - let sresp = ui.interact(split, egui::Id::new("mb-split"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let sresp = ui.interact(split, egui::Id::unique("mb-split"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); if sresp.dragged() { app.ui.media_browser.tree_width = (tw + sresp.drag_delta().x).clamp(100.0, (rect.width() * 0.7).max(100.0)); } @@ -233,7 +236,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let resp = ui.put(sr, egui::Slider::new(&mut sz, 60.0..=320.0).show_value(false)); app.auto.add("mediaBrowser.thumbnailSize", sr, "Thumbnail size"); // a drag ended by Escape goes back to the size it started from (#580) - let orig_id = egui::Id::new("media-browser-thumbnail-size-before-drag"); + let orig_id = egui::Id::unique("media-browser-thumbnail-size-before-drag"); if resp.drag_started() { ui.data_mut(|d| d.insert_temp(orig_id, prefs.thumbnail_size)); } @@ -573,12 +576,12 @@ fn list(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, entries: &[Entry], c let hp = ui.painter().with_clip_rect(header.intersect(ui.clip_rect())); hp.rect_filled(header, 0.0, t.panel_bg); hp.line_segment([header.left_bottom(), header.right_bottom()], Stroke::new(1.0, t.separator)); - let mut x = header.min.x + 4.0 - out.state.offset.x; + let mut x = header.min.x + 4.0 - out.state.clamped_offset().x; for (c, w) in columns.iter().zip(&widths) { let cr = Rect::from_min_size(pos2(x, header.min.y), vec2(*w, 20.0)); hp.text(pos2(cr.min.x + 4.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::t(c), Tokens::ui(11.0), t.text_dim); if cr.intersects(header) { - let resp = ui.interact(cr.intersect(header), egui::Id::new(("mb-col", c)), Sense::click()); + let resp = ui.interact(cr.intersect(header), egui::Id::unique(("mb-col", c)), Sense::click()); app.auto.add(&format!("mediaBrowser.header.{c}"), cr.intersect(header), c); resp.context_menu(|ui| { if ui.button(tl!("Edit Columns…")).clicked() { @@ -656,7 +659,7 @@ fn thumbnails(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, entries: &[Ent let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), cell.y), Sense::hover()); for (k, e) in chunk.iter().enumerate() { let tr = Rect::from_min_size(pos2(row.min.x + 8.0 + k as f32 * cell.x, row.min.y + 6.0), vec2(size, th_h)); - let resp = ui.interact(tr.expand(2.0), egui::Id::new(("mb-thumb", &e.path)), Sense::click_and_drag()); + let resp = ui.interact(tr.expand(2.0), egui::Id::unique(("mb-thumb", &e.path)), Sense::click_and_drag()); let sel = app.session.browser.selection.contains(&e.path); ui.painter().rect_filled(tr, 3.0, Color32::from_gray(14)); if sel { @@ -815,7 +818,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut close = false; let mut ok = false; egui::Window::new(tl!("Edit Columns")) - .id(egui::Id::new("mb-edit-columns")) + .id(egui::Id::unique("mb-edit-columns")) .collapsible(false) .resizable(false) .pivot(Align2::CENTER_CENTER) diff --git a/crates/ui-egui/src/panels/media_dialogs.rs b/crates/ui-egui/src/panels/media_dialogs.rs index 3a2dcb0ec..b91b98852 100644 --- a/crates/ui-egui/src/panels/media_dialogs.rs +++ b/crates/ui-egui/src/panels/media_dialogs.rs @@ -129,7 +129,7 @@ fn match_params(d: &LinkMediaDraft) -> Value { } fn preview_texture(app: &FilmcraftApp, ctx: &egui::Context, path: &str) -> Option { - let key = egui::Id::new(("link-preview", path)); + let key = egui::Id::unique(("link-preview", path)); if let Some(t) = ctx.data(|d| d.get_temp::>(key)) { return t; } @@ -161,7 +161,7 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { let rows_max_h = (ctx.content_rect().height() * 0.3).clamp(90.0, 320.0); let width = (ctx.content_rect().width() - 40.0).clamp(1.0, 760.0); egui::Window::new(tl!("Link Media")) - .id(egui::Id::new("Link Media")) + .id(egui::Id::unique("Link Media")) .collapsible(false) .resizable(false) .default_width(width) @@ -217,7 +217,10 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.separator(); ui.horizontal_wrapped(|ui| { ui.label(tl!("Search in:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width((ui.available_width() * 0.5).min(360.0)).hint_text(tl!("Folder"))); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut d.folder).desired_width((ui.available_width() * 0.5).min(360.0)).hint_text(tl!("Folder"))), + "Folder", + ); push(&mut elems, "linkMedia.folder", &r, "folder"); let r = ui.button(tl!("Browse…")); push(&mut elems, "linkMedia.browse", &r, "Browse…"); @@ -371,7 +374,7 @@ fn make_offline(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut ok = false; let n = app.session.state.project_selection.len(); egui::Window::new(tl!("Make Offline")) - .id(egui::Id::new("Make Offline")) + .id(egui::Id::unique("Make Offline")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -416,7 +419,7 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut ok = false; let mut browse = false; egui::Window::new(tl!("Create Proxies")) - .id(egui::Id::new("Create Proxies")) + .id(egui::Id::unique("Create Proxies")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -435,8 +438,12 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.add_space(4.0); ui.label(RichText::new(tl!("Destination")).strong()); ui.horizontal(|ui| { - let r = ui - .add(egui::TextEdit::singleline(&mut d.destination).desired_width(320.0).hint_text(tl!("Next to the original media, in a Proxies folder"))); + let r = crate::widgets::accessible_name( + ui.add( + egui::TextEdit::singleline(&mut d.destination).desired_width(320.0).hint_text(tl!("Next to the original media, in a Proxies folder")), + ), + "Destination", + ); push(&mut elems, "proxies.destination", &r, "destination"); let r = ui.button(tl!("Browse…")); push(&mut elems, "proxies.browse", &r, "Browse…"); @@ -489,14 +496,14 @@ fn pm_params(d: &ProjectManagerDraft, dry: bool) -> Value { fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.project_manager.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("project-manager-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("project-manager-before-drag"), &mut d); let before = d.clone(); let mut elems: Elems = Vec::new(); let mut keep = true; let (mut ok, mut calc, mut browse) = (false, false, false); let seqs: Vec<(u64, String)> = app.session.project.sequences().map(|i| (i.id.0, i.name.clone())).collect(); egui::Window::new(tl!("Project Manager")) - .id(egui::Id::new("Project Manager")) + .id(egui::Id::unique("Project Manager")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) @@ -555,7 +562,7 @@ fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { ui.separator(); ui.label(RichText::new(tl!("Destination Path")).strong()); ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(360.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.destination).desired_width(360.0)), "Destination"); push(&mut elems, "pm.destination", &r, "destination"); let r = ui.button(tl!("Browse…")); push(&mut elems, "pm.browse", &r, "Browse…"); diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index 99151da72..10122f438 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -48,7 +48,9 @@ impl Default for Extras { type Elems = Vec<(String, egui::Rect, String)>; fn push(elems: &mut Elems, id: impl Into, r: &egui::Response, label: impl Into) { - elems.push((id.into(), r.rect, label.into())); + let label = label.into(); + crate::widgets::accessible_name(r.clone(), label.clone()); + elems.push((id.into(), r.rect, label)); } /// Dialog title, automation prefix and OK button text of a command's dialog. @@ -453,7 +455,7 @@ fn pairs(v: &[&str]) -> Vec<(String, String)> { /// Draw the open dialog, if any. pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.extras.dialog.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("menu-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("menu-dialog-before-drag"), &mut d); let Some((title, pre, ok_text)) = meta(&d.command) else { app.ui.extras.dialog = None; return; @@ -463,7 +465,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut browse: Option = None; let info_only = matches!(d.command.as_str(), "file.mediaProperties" | "clip.sourceSettings" | "help.systemCompatibilityReport"); // the title is translated; the window keeps one id whatever the interface language - let id = egui::Id::new(("menu-dialog", pre)); + let id = egui::Id::unique(("menu-dialog", pre)); egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(420.0).centered(ctx).show(ctx, |ui| { let p = &mut d.params; match d.command.as_str() { diff --git a/crates/ui-egui/src/panels/metadata.rs b/crates/ui-egui/src/panels/metadata.rs index 88312f1a1..94a46cf26 100644 --- a/crates/ui-egui/src/panels/metadata.rs +++ b/crates/ui-egui/src/panels/metadata.rs @@ -120,9 +120,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { /// An editable value: a text field whose edit is committed when it loses focus (Enter, Tab or a /// click elsewhere). Returns (field, new value) on commit. fn text_field(ui: &mut egui::Ui, item: ItemId, f: &Field, elems: &mut Vec<(String, Rect, String)>) -> Option<(String, String)> { - let id = egui::Id::new(("md-draft", item.0, f.name.clone())); + let id = egui::Id::unique(("md-draft", item.0, f.name.clone())); let mut draft: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| f.value.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut draft).desired_width(ui.available_width()).id_salt(("md-edit", item.0, f.name.clone()))); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut draft).desired_width(ui.available_width()).id_salt(("md-edit", item.0, f.name.clone()))), + f.name.clone(), + ); elems.push((field_id(&f.name), r.rect, f.value.clone())); let mut out = None; if r.has_focus() { diff --git a/crates/ui-egui/src/panels/mixer.rs b/crates/ui-egui/src/panels/mixer.rs index 1d5046b38..0bf72e724 100644 --- a/crates/ui-egui/src/panels/mixer.rs +++ b/crates/ui-egui/src/panels/mixer.rs @@ -89,7 +89,7 @@ pub const INPUT_METER: u64 = u64::MAX - 1; /// strips), fed by the playing mix ([`filmcraft_render::mixer::LiveMix`]) with fast attack and a /// 20 dB/s fall. [`INPUT_METER`] holds the voice-over input. pub fn poll_meters(app: &mut FilmcraftApp, ui: &egui::Ui) -> HashMap> { - let id = egui::Id::new("mixer-meters"); + let id = egui::Id::unique("mixer-meters"); let frame = ui.ctx().cumulative_frame_nr(); let (last, mut st): (u64, HashMap>) = ui.data(|d| d.get_temp(id)).unwrap_or_default(); if last == frame && frame != 0 { @@ -223,7 +223,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let body = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 2.0), pos2(rect.max.x, footer.min.y)); // effects / sends disclosure at the left edge let arrow = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 4.0), vec2(14.0, 18.0)); - let aresp = ui.interact(arrow, egui::Id::new("mixer-fx-toggle"), Sense::click()).on_hover_text(tl!("Show/Hide Effects and Sends")); + let aresp = ui.interact(arrow, egui::Id::unique("mixer-fx-toggle"), Sense::click()).on_hover_text(tl!("Show/Hide Effects and Sends")); icons::paint(ui.painter(), arrow.shrink(2.0), if app.ui.mixer_fx_open { Icon::ChevronDown } else { Icon::ChevronRight }, t.icon); app.auto.add("mixer.showEffects", arrow, "Show/Hide Effects and Sends"); if aresp.clicked() { @@ -231,10 +231,10 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // panel menu: Show/Hide Tracks, Meter Input(s) Only let menu_r = Rect::from_min_size(pos2(body.min.x + 2.0, body.min.y + 26.0), vec2(14.0, 18.0)); - let mresp = ui.interact(menu_r, egui::Id::new("mixer-menu"), Sense::click()).on_hover_text(tl!("Audio Track Mixer menu")); + let mresp = ui.interact(menu_r, egui::Id::unique("mixer-menu"), Sense::click()).on_hover_text(tl!("Audio Track Mixer menu")); icons::paint(ui.painter(), menu_r.shrink(2.0), Icon::Hamburger, if mresp.hovered() { t.tab_text_active } else { t.icon }); app.auto.add("mixer.menu", menu_r, "Audio Track Mixer menu"); - let show_hide_id = egui::Id::new("mixer-show-hide-open"); + let show_hide_id = egui::Id::unique("mixer-show-hide-open"); let mut show_hide: bool = ui.data(|d| d.get_temp(show_hide_id)).unwrap_or(false); egui::Popup::menu(&mresp).show(|ui| { let r = ui.button(tl!("Show/Hide Tracks…")); @@ -252,7 +252,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if show_hide { let mut open = true; let mut close = false; - egui::Window::new(tl!("Show/Hide Tracks")).id(egui::Id::new("mixer-show-hide")).collapsible(false).resizable(false).open(&mut open).show( + egui::Window::new(tl!("Show/Hide Tracks")).id(egui::Id::unique("mixer-show-hide")).collapsible(false).resizable(false).open(&mut open).show( ui.ctx(), |ui| { for id in seq.audio_tracks.iter().chain(&seq.submix_tracks).map(|t| t.id) { @@ -294,7 +294,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // strips never get shorter than their controls plus a usable fader: scroll vertically instead let need = 4.0 + if app.ui.mixer_fx_open { 10.0 * SLOT_H + 10.0 } else { 0.0 } + 112.0 + 26.0 + 24.0 + 6.0 + 170.0 + 42.0; let clip = Rect::from_min_max(pos2(x0, body.min.y), pos2(body.max.x - 8.0, body.max.y - sb_h)); - let voff_id = egui::Id::new("mixer-vscroll"); + let voff_id = egui::Id::unique("mixer-vscroll"); let max_voff = (need - (clip.height() - 4.0)).max(0.0); let mut voff: f32 = ui.data(|d| d.get_temp(voff_id)).unwrap_or(0.0); if ui.rect_contains_pointer(body) && !ui.input(|i| i.modifiers.shift) { @@ -307,7 +307,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let kh = (bar.height() * clip.height() / (need + 4.0)).max(20.0); let ky = bar.min.y + (bar.height() - kh) * voff / max_voff; let knob = Rect::from_min_size(pos2(bar.min.x, ky), vec2(bar.width(), kh)); - let kr = ui.interact(knob, egui::Id::new("mixer-vscroll-knob"), Sense::drag()); + let kr = ui.interact(knob, egui::Id::unique("mixer-vscroll-knob"), Sense::drag()); ui.painter().rect_filled(knob, 3.0, if kr.dragged() { t.accent } else { t.fader_knob }); if kr.dragged() { voff = (voff + kr.drag_delta().y * (need + 4.0) / bar.height()).clamp(0.0, max_voff); @@ -343,7 +343,7 @@ pub fn track_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { fn strip_scroll(app: &mut FilmcraftApp, ui: &egui::Ui, body: Rect, x0: f32, total: f32, avail: f32, key: &str) -> (f32, f32) { let t = app.tokens; let max_off = (total - avail).max(0.0); - let off_id = egui::Id::new(format!("{key}-hscroll")); + let off_id = egui::Id::unique(format!("{key}-hscroll")); let mut off: f32 = ui.data(|d| d.get_temp(off_id)).unwrap_or(0.0); if ui.rect_contains_pointer(body) { let sd = ui.input(|i| i.smooth_scroll_delta); @@ -356,7 +356,7 @@ fn strip_scroll(app: &mut FilmcraftApp, ui: &egui::Ui, body: Rect, x0: f32, tota let kw = (bar.width() * avail / total).max(20.0); let kx = bar.min.x + (bar.width() - kw) * off / max_off; let knob = Rect::from_min_size(pos2(kx, bar.min.y), vec2(kw, bar.height())); - let kr = ui.interact(knob, egui::Id::new(format!("{key}-hscroll-knob")), Sense::drag()); + let kr = ui.interact(knob, egui::Id::unique(format!("{key}-hscroll-knob")), Sense::drag()); ui.painter().rect_filled(knob, 4.0, if kr.dragged() { t.accent } else { t.fader_knob }); if kr.dragged() { off = (off + kr.drag_delta().x * total / bar.width()).clamp(0.0, max_off); @@ -401,10 +401,10 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let r = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, SLOT_H - 2.0)); let fx = tr.effects.get(k); let text = match fx { - Some(e) => format!("{}{}", if e.post_fader { "▸ " } else { "" }, e.def().map(|d| d.name).unwrap_or(&e.effect)), + Some(e) => format!("{}{}", if e.post_fader { "> " } else { "" }, e.def().map(|d| d.name).unwrap_or(&e.effect)), None => String::new(), }; - let resp = slot_box(ui, r, &text, fx.is_some(), fx.is_some_and(|e| !e.enabled), &t, egui::Id::new((&ap, "fx", k))); + let resp = slot_box(ui, r, &text, fx.is_some(), fx.is_some_and(|e| !e.enabled), &t, egui::Id::unique((&ap, "fx", k))); app.auto.add(&format!("{ap}.fx.{k}"), r, &if text.is_empty() { format!("Effect slot {}", k + 1) } else { text.clone() }); let can_add = fx.is_none() && k == tr.effects.len(); egui::Popup::menu(&resp).show(|ui| { @@ -495,7 +495,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s y += SLOT_H; continue; } - let resp = slot_box(ui, r, &text, snd.is_some(), snd.is_some_and(|s| s.muted), &t, egui::Id::new((&ap, "send", k))); + let resp = slot_box(ui, r, &text, snd.is_some(), snd.is_some_and(|s| s.muted), &t, egui::Id::unique((&ap, "send", k))); app.auto.add(&format!("{ap}.send.{k}"), r, &if text.is_empty() { format!("Send slot {}", k + 1) } else { text.clone() }); let can_add = snd.is_none() && k == tr.mixer.sends.len(); let tg = targets(seq, id); @@ -558,7 +558,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s // ---- input / output if !is_master { let ir = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); - let iresp = crate::widgets::dropdown_text(ui, ir, crate::i18n::t(tr.mixer.input_map.label()), &t, egui::Id::new((&ap, "input"))); + let iresp = crate::widgets::dropdown_text(ui, ir, crate::i18n::t(tr.mixer.input_map.label()), &t, egui::Id::unique((&ap, "input"))); app.auto.add(&format!("{ap}.input"), ir, "Input channel mapping"); egui::Popup::menu(&iresp).show(|ui| { for m in InputMap::ALL { @@ -573,7 +573,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let or = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); let out_label = tr.mixer.output.map(|o| seq.mix_track(o).map(|s| s.name.clone()).unwrap_or_else(|| tl!("Mix").into())).unwrap_or_else(|| tl!("Mix").into()); - let oresp = crate::widgets::dropdown_text(ui, or, &out_label, &t, egui::Id::new((&ap, "output"))); + let oresp = crate::widgets::dropdown_text(ui, or, &out_label, &t, egui::Id::unique((&ap, "output"))); app.auto.add(&format!("{ap}.output"), or, "Track output assignment"); let tg = targets(seq, id); egui::Popup::menu(&oresp).show(|ui| { @@ -599,7 +599,8 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let pan = shown(app, &tr, LANE_PAN, cx.now); let kc = pos2(sr.center().x, y + 20.0); let kr = Rect::from_center_size(kc, vec2(38.0, 38.0)); - let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()).on_hover_text(tl!("Pan / balance (drag; double-click: centre)")); + let kresp = + ui.interact(kr, egui::Id::unique((&ap, "pan")), Sense::click_and_drag()).on_hover_text(tl!("Pan / balance (drag; double-click: centre)")); let ring = if kresp.hovered() || kresp.dragged() { t.tab_text_active } else { t.control_handle_dim }; ui.painter().circle_stroke(kc, 16.0, Stroke::new(2.0, ring)); let ang = (pan / 100.0) as f32 * 135f32.to_radians(); @@ -622,7 +623,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s } let vr = Rect::from_center_size(pos2(kc.x, y + 48.0), vec2(56.0, 16.0)); let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr).layout(egui::Layout::top_down(egui::Align::Center))); - let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "panValue")), pan, 1.0, (-100.0, 100.0), 1, "", &t); + let (vresp, nv) = crate::widgets::hot_number(&mut vui, tl!("Pan"), egui::Id::unique((&ap, "panValue")), pan, 1.0, (-100.0, 100.0), 1, "", &t); app.auto.add(&format!("{ap}.panValue"), vresp.rect, &format!("{pan:.1}")); if let Some(v) = nv { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN, "value": v})); @@ -636,7 +637,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s // ---- automation mode let mr = Rect::from_min_size(pos2(x + 4.0, y), vec2(w - 8.0, 20.0)); let mode = tr.mixer.mode; - let mresp = crate::widgets::dropdown_text(ui, mr, crate::i18n::t(mode.label()), &t, egui::Id::new((&ap, "mode"))); + let mresp = crate::widgets::dropdown_text(ui, mr, crate::i18n::t(mode.label()), &t, egui::Id::unique((&ap, "mode"))); if cx.recording && mode.writes() { ui.painter().rect_stroke(mr, 4.0, Stroke::new(1.0, RED), StrokeKind::Inside); } @@ -658,7 +659,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let bw = 18.0; let bx = sr.center().x - bw * 1.5 - 6.0; let b = |i: f32| Rect::from_min_size(pos2(bx + i * (bw + 6.0), y), vec2(bw, 16.0)); - let m_r = crate::widgets::letter_toggle(ui, b(0.0), "M", muted, MUTE_ON, &t, egui::Id::new((&ap, "M"))); + let m_r = crate::widgets::letter_toggle(ui, b(0.0), "M", muted, MUTE_ON, &t, egui::Id::unique((&ap, "M"))); app.auto.add(&format!("{ap}.mute"), b(0.0), "Mute Track"); if m_r.clicked() { if cx.recording && mode.writes() { @@ -669,21 +670,21 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s cx.cmd("mixer.setStrip", json!({"strip": sid, "muted": !tr.muted})); } } - let s_r = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::new((&ap, "S"))); + let s_r = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::unique((&ap, "S"))); app.auto.add(&format!("{ap}.solo"), b(1.0), "Solo Track"); if s_r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "solo": !tr.solo})); } let is_sub = seq.submix_tracks.iter().any(|s| s.id == id); if is_sub { - let r_r = crate::widgets::letter_toggle(ui, b(2.0), "◆", tr.mixer.solo_safe, Color32::from_gray(0xc8), &t, egui::Id::new((&ap, "safe"))) + let r_r = crate::widgets::letter_toggle(ui, b(2.0), "◆", tr.mixer.solo_safe, Color32::from_gray(0xc8), &t, egui::Id::unique((&ap, "safe"))) .on_hover_text(tl!("Solo safe")); app.auto.add(&format!("{ap}.soloSafe"), b(2.0), "Solo Safe"); if r_r.clicked() { cx.cmd("mixer.setStrip", json!({"strip": sid, "soloSafe": !tr.mixer.solo_safe})); } } else { - let r_r = crate::widgets::letter_toggle(ui, b(2.0), "R", tr.mixer.record_arm, RED, &t, egui::Id::new((&ap, "R"))) + let r_r = crate::widgets::letter_toggle(ui, b(2.0), "R", tr.mixer.record_arm, RED, &t, egui::Id::unique((&ap, "R"))) .on_hover_text(tl!("Enable track for recording")); app.auto.add(&format!("{ap}.record"), b(2.0), "Enable Track for Recording"); if r_r.clicked() { @@ -715,7 +716,7 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s let cy = ypos(vol); let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0)); let fresp = - ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()).on_hover_text(tl!("Volume (drag; double-click: 0 dB)")); + ui.interact(cap.expand(3.0), egui::Id::unique((&ap, "fader")), Sense::click_and_drag()).on_hover_text(tl!("Volume (drag; double-click: 0 dB)")); let held = app.session.previews.live.get(id, LANE_VOLUME).is_some(); let col = if fresp.dragged() || held { t.accent } else { t.control_handle }; ui.painter().rect_filled(cap, 3.0, t.control_handle_bg); @@ -758,7 +759,8 @@ fn strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, id: TrackId, s // fader value (hot text) let vr = Rect::from_min_size(pos2(x + 4.0, sr.max.y - name_h - value_h), vec2(56.0, value_h)); let mut vui = ui.new_child(egui::UiBuilder::new().max_rect(vr)); - let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); + let (vresp, nv) = + crate::widgets::hot_number(&mut vui, tl!("Volume"), egui::Id::unique((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); app.auto.add(&format!("{ap}.value"), vresp.rect, &db_text(vol)); if let Some(v) = nv { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_VOLUME, "value": v})); @@ -786,7 +788,7 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr let py = shown(app, tr, LANE_PAN51_Y, cx.now); let sq = Rect::from_min_size(pos2(r.min.x, r.min.y), vec2(46.0, 46.0)); let resp = - ui.interact(sq, egui::Id::new((ap, "pan51")), Sense::click_and_drag()).on_hover_text(tl!("5.1 panner (drag the puck; double-click: front centre)")); + ui.interact(sq, egui::Id::unique((ap, "pan51")), Sense::click_and_drag()).on_hover_text(tl!("5.1 panner (drag the puck; double-click: front centre)")); let p = ui.painter(); p.rect_filled(sq, 3.0, t.panner_bg); p.rect_stroke(sq, 3.0, Stroke::new(1.0, if resp.hovered() || resp.dragged() { t.tab_text_active } else { t.panner_border }), StrokeKind::Inside); @@ -826,7 +828,7 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr small_text(ui, pos2(col_x, r.min.y + 6.0), Align2::LEFT_CENTER, tl!("Center"), 9.0, t.text_dim); let cr = Rect::from_min_size(pos2(col_x, r.min.y + 12.0), vec2(r.max.x - col_x, 14.0)); let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(cr)); - let (cresp, nc) = crate::widgets::hot_number(&mut cui, egui::Id::new((ap, "pan51.center")), center, 1.0, (0.0, 100.0), 0, "%", &t); + let (cresp, nc) = crate::widgets::hot_number(&mut cui, tl!("Center"), egui::Id::unique((ap, "pan51.center")), center, 1.0, (0.0, 100.0), 0, "%", &t); app.auto.add(&format!("{ap}.pan51.center"), cresp.rect, &format!("Center {center:.0} %")); if let Some(v) = nc { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_CENTER, "value": v})); @@ -835,7 +837,7 @@ fn pan51_panner(app: &mut FilmcraftApp, ui: &mut egui::Ui, cx: &mut Ctx, tr: &Tr let lr = Rect::from_min_size(pos2(col_x, r.min.y + 38.0), vec2(r.max.x - col_x, 14.0)); let mut lui = ui.new_child(egui::UiBuilder::new().max_rect(lr)); let (lresp, nl) = - crate::widgets::hot_number(&mut lui, egui::Id::new((ap, "pan51.lfe")), lfe.max(FADER_MIN_DB), 0.5, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); + crate::widgets::hot_number(&mut lui, "LFE", egui::Id::unique((ap, "pan51.lfe")), lfe.max(FADER_MIN_DB), 0.5, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); app.auto.add(&format!("{ap}.pan51.lfe"), lresp.rect, &format!("LFE {}", db_text(lfe))); if let Some(v) = nl { cx.cmd("mixer.setValue", json!({"strip": sid, "lane": LANE_PAN51_LFE, "value": v})); @@ -863,7 +865,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, cx: &mut Ctx) let mut x = row.center().x - bw * buttons.len() as f32 / 2.0; for (icon, cmd, tip) in buttons { let r = Rect::from_min_size(pos2(x, row.min.y + 4.0), vec2(bw - 2.0, 26.0)); - let resp = ui.interact(r, egui::Id::new(("mixer-transport", cmd)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("mixer-transport", cmd)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("mixer.transport.{cmd}"), r, tip); if resp.hovered() { ui.painter().rect_filled(r, 4.0, t.hover); @@ -912,7 +914,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let meters = poll_meters(app, ui); let now = app.session.playhead(); let mut acts: Vec<(String, Value)> = Vec::new(); - let kf_id = egui::Id::new("clip-mixer-keyframes"); + let kf_id = egui::Id::unique("clip-mixer-keyframes"); let mut write_kf: HashMap = ui.data(|d| d.get_temp(kf_id)).unwrap_or_default(); // Like Premiere, the strips narrow so that every track fits the panel; below the narrowest // usable strip the row scrolls instead, so no track is out of reach (#1922). @@ -962,7 +964,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // pan knob let kc = pos2(sr.center().x, sr.min.y + 36.0); let kr = Rect::from_center_size(kc, vec2(30.0, 30.0)); - let kresp = ui.interact(kr, egui::Id::new((&ap, "pan")), Sense::click_and_drag()); + let kresp = ui.interact(kr, egui::Id::unique((&ap, "pan")), Sense::click_and_drag()); ui.painter().circle_stroke(kc, 14.0, Stroke::new(2.0, dimc(t.control_handle_dim))); let ang = (pan / 100.0) as f32 * 135f32.to_radians(); ui.painter().line_segment([kc, kc + vec2(ang.sin(), -ang.cos()) * 12.0], Stroke::new(2.0, dimc(t.control_handle_dim))); @@ -983,7 +985,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // automation mode (Clip Mixer) let mode = filmcraft_engine::mixer::clip_mode(&app.session, tr.id); let mdr = Rect::from_min_size(pos2(sr.min.x + 4.0, sr.min.y + 2.0), vec2(sr.width() - 8.0, 16.0)); - let mdresp = crate::widgets::dropdown_text(ui, mdr, crate::i18n::t(mode.label()), &t, egui::Id::new((&ap, "mode"))); + let mdresp = crate::widgets::dropdown_text(ui, mdr, crate::i18n::t(mode.label()), &t, egui::Id::unique((&ap, "mode"))); app.auto.add(&format!("{ap}.mode"), mdr, &format!("Clip automation mode: {}", mode.label())); egui::Popup::menu(&mdresp).show(|ui| { for m in AutomationMode::ALL { @@ -998,18 +1000,18 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let y = sr.min.y + 72.0; let step = ((sr.width() - 6.0 - 18.0) / 2.0).min(24.0); let b = |k: f32| Rect::from_min_size(pos2(sr.center().x - step - 9.0 + k * step, y), vec2(18.0, 16.0)); - let mr = crate::widgets::letter_toggle(ui, b(0.0), "M", tr.muted, MUTE_ON, &t, egui::Id::new((&ap, "M"))); + let mr = crate::widgets::letter_toggle(ui, b(0.0), "M", tr.muted, MUTE_ON, &t, egui::Id::unique((&ap, "M"))); app.auto.add(&format!("{ap}.mute"), b(0.0), "Mute Track"); if mr.clicked() { acts.push(("mixer.setStrip".into(), json!({"strip": tr.id.0, "muted": !tr.muted}))); } - let so = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::new((&ap, "S"))); + let so = crate::widgets::letter_toggle(ui, b(1.0), "S", tr.solo, SOLO_ON, &t, egui::Id::unique((&ap, "S"))); app.auto.add(&format!("{ap}.solo"), b(1.0), "Solo Track"); if so.clicked() { acts.push(("mixer.setStrip".into(), json!({"strip": tr.id.0, "solo": !tr.solo}))); } - let kresp = - crate::widgets::icon_toggle(ui, b(2.0), Icon::Keyframe, kf, &t, egui::Id::new((&ap, "kf")), Some(t.accent)).on_hover_text(tl!("Write keyframes")); + let kresp = crate::widgets::icon_toggle(ui, b(2.0), Icon::Keyframe, kf, &t, egui::Id::unique((&ap, "kf")), Some(t.accent)) + .on_hover_text(tl!("Write keyframes")); app.auto.add(&format!("{ap}.keyframe"), b(2.0), "Write Keyframes"); if kresp.clicked() { write_kf.insert(tr.id.0, !kf); @@ -1026,7 +1028,7 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().rect_filled(track, 2.0, t.control_well); let cy = ypos(vol); let cap = Rect::from_center_size(pos2(track.center().x, cy), vec2(18.0, 26.0)); - let fresp = ui.interact(cap.expand(3.0), egui::Id::new((&ap, "fader")), Sense::click_and_drag()); + let fresp = ui.interact(cap.expand(3.0), egui::Id::unique((&ap, "fader")), Sense::click_and_drag()); ui.painter().rect_filled(cap, 3.0, t.control_handle_bg); ui.painter().rect_stroke(cap, 3.0, Stroke::new(2.0, dimc(t.control_handle)), StrokeKind::Inside); ui.painter().line_segment([pos2(cap.min.x + 5.0, cy), pos2(cap.max.x - 5.0, cy)], Stroke::new(2.0, dimc(t.control_handle))); @@ -1050,7 +1052,8 @@ pub fn clip_mixer(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if !active { vui.set_opacity(0.4); } - let (vresp, nv) = crate::widgets::hot_number(&mut vui, egui::Id::new((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); + let (vresp, nv) = + crate::widgets::hot_number(&mut vui, tl!("Volume"), egui::Id::unique((&ap, "value")), vol.max(-96.0), 0.1, (FADER_MIN_DB, FADER_MAX_DB), 1, "", &t); app.auto.add(&format!("{ap}.value"), vresp.rect, &db_text(vol)); if let (Some(v), true) = (nv, active) { set(&mut acts, "volume", "level", v, !vresp.dragged() || vresp.drag_started()); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0f21158fc..babc3a7e7 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -124,7 +124,7 @@ enum DragPayload { } fn payload_id() -> egui::Id { - egui::Id::new("filmcraft-drag-payload") + egui::Id::unique("filmcraft-drag-payload") } pub fn start_drag_source(ui: &egui::Ui, source: source_drag::SourceDrag) { @@ -206,7 +206,7 @@ pub fn clear_drag(ui: &egui::Ui) { } fn cancelled_id() -> egui::Id { - egui::Id::new("filmcraft-drag-cancelled") + egui::Id::unique("filmcraft-drag-cancelled") } /// Abandon the drag in progress (Escape, #580): nothing is dropped, and nothing restarts it until @@ -255,7 +255,7 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { _ => filmcraft_project::find_effect(e).map(|d| d.name.to_string()).unwrap_or_default(), }, }; - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("drag-ghost"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("drag-ghost"))); let r = Rect::from_min_size(p + egui::vec2(12.0, 8.0), egui::vec2(label.len() as f32 * 7.0 + 16.0, 20.0)); painter.rect_filled(r, 4.0, Color32::from_black_alpha(200)); painter.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), Color32::WHITE); @@ -263,7 +263,7 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { if ctx.input(|i| i.pointer.any_released()) { // cleared next frame so drop targets see the release this frame let f = ctx.cumulative_frame_nr(); - let k = egui::Id::new("drag-release-frame"); + let k = egui::Id::unique("drag-release-frame"); match ctx.data(|d| d.get_temp::(k)) { Some(prev) if prev < f => { clear_drag(ui); @@ -284,7 +284,7 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { drag_ghost(app, ui); // bins opened in new windows (Project panel) project::floating(app, ui.ctx()); - let id = egui::Id::new("panel-menu"); + let id = egui::Id::unique("panel-menu"); let Some((p, pos)) = ui.ctx().data(|d| d.get_temp::<(PanelKind, egui::Pos2)>(id)) else { return }; let mut close = false; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { @@ -358,7 +358,7 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { }); // A click elsewhere or Escape closes the menu. The click that opened it is over the tab, not // the menu, and must not close it again in the same frame. - let fresh = ui.ctx().data(|d| d.get_temp::(egui::Id::new("panel-menu-opened"))) == Some(ui.ctx().cumulative_frame_nr()); + let fresh = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("panel-menu-opened"))) == Some(ui.ctx().cumulative_frame_nr()); if close || (!fresh && area.response.clicked_elsewhere()) || crate::widgets::escape_closes(ui.ctx()) { ui.ctx().data_mut(|d| d.remove::<(PanelKind, egui::Pos2)>(id)); } diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index e1f4d4766..4e62b3ed1 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -285,11 +285,11 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } // A click on the Program monitor around the picture lets go of what is selected on it (#683; // in the picture the overlays do that). Under the picture and the overlays, so they come first. - if which == Which::Program && show_picture && ui.interact(video_area, egui::Id::new((prefix, "background")), Sense::click()).clicked() { + if which == Which::Program && show_picture && ui.interact(video_area, egui::Id::unique((prefix, "background")), Sense::click()).clicked() { crate::panels::graphics::deselect_on_monitor(app, ui); } // Click/drag in the picture: the Hand tool pans a magnified picture, otherwise focus. - let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() }); + let pic_resp = ui.interact(pic, egui::Id::unique((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() }); app.auto.add(&format!("{prefix}.picture"), pic.intersect(video_area), "picture"); // (Comparison View always fits both frames) if show_picture && !compare { @@ -326,7 +326,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) let tc = format_time(time, rate, drop_frame, TimeDisplay::Timecode, 48000); let tc_rect = Rect::from_min_size(row1.min, vec2(110.0, row1.height())); let min = if which == Which::Program { Tick::ZERO } else { origin }; - match crate::widgets::timecode_field(ui, egui::Id::new((prefix, "timecode")), tc_rect, &tc, time, min, rate, drop_frame, t.timecode) { + match crate::widgets::timecode_field(ui, egui::Id::unique((prefix, "timecode")), tc_rect, &tc, time, min, rate, drop_frame, t.timecode) { Some(Ok(to)) => { let cmd = if which == Which::Program { "playhead.set" } else { "source.setPlayhead" }; if let Err(e) = app.session.execute(cmd, json!({"time": to.0})) { @@ -353,7 +353,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) None => tl!("Fit").to_string(), Some(z) => format!("{}%", (z * 100.0).round() as i32), }; - let zresp = crate::widgets::dropdown_text(ui, zr, &zoom_label, &t, egui::Id::new((prefix, "zoom"))); + let zresp = crate::widgets::dropdown_text(ui, zr, &zoom_label, &t, egui::Id::unique((prefix, "zoom"))); app.auto.add(&format!("{prefix}.zoom"), zr, "Select Zoom Level"); monitor_view::zoom_menu(app, &zresp, which); let res = mv.res; @@ -371,7 +371,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) if separate_source_drag { crate::panels::source_drag::controls(app, ui, Rect::from_min_size(pos2(row1.center().x - 52.0, settings_y + 26.0), vec2(104.0, 24.0))); } - let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::new((prefix, "res"))); + let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::unique((prefix, "res"))); app.auto.add(&format!("{prefix}.resolution"), rr, "Select Playback Resolution"); egui::Popup::menu(&rresp).show(|ui| { for r in PlaybackRes::ALL { @@ -381,7 +381,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } }); let wr = Rect::from_min_size(pos2(rr.max.x + 6.0, settings_y + 1.0), vec2(22.0, 22.0)); - let wresp = ui.interact(wr, egui::Id::new((prefix, "wrench")), Sense::click()); + let wresp = ui.interact(wr, egui::Id::unique((prefix, "wrench")), Sense::click()); icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.tab_text_active } else { t.icon }); app.auto.add(&format!("{prefix}.settings"), wr, "Settings"); egui::Popup::menu(&wresp).show(|ui| { @@ -527,7 +527,7 @@ const EYEDROPPER_SCALE: f32 = 0.5; fn eyedropper(app: &mut FilmcraftApp, ui: &mut egui::Ui, resp: &egui::Response, pic: Rect, target: Target, rate: filmcraft_time::FrameRate, time: Tick) { let Some(armed) = app.ui.eyedropper.clone() else { return }; let ctx = ui.ctx().clone(); - let pending_id = egui::Id::new("eyedropper-pending"); + let pending_id = egui::Id::unique("eyedropper-pending"); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.eyedropper = None; ctx.data_mut(|d| d.remove::(pending_id)); @@ -684,7 +684,7 @@ fn time_zoom_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Wh } app.auto.add(&format!("{prefix}.zoomBar"), thumb, "zoom scroll bar"); app.auto.add(&format!("{prefix}.zoomBar.track"), bar, "zoom scroll bar track"); - let resp = ui.interact(bar, egui::Id::new((prefix, "zoomBar")), Sense::click_and_drag()); + let resp = ui.interact(bar, egui::Id::unique((prefix, "zoomBar")), Sense::click_and_drag()); // the start that brings the thumb's middle under `x` let jump = |x: f32| ((x - bar.min.x - w / 2.0) as f64 * per_point).clamp(0.0, range); let mut set = None; @@ -696,7 +696,7 @@ fn time_zoom_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Wh } // (a drag is known to be one only after the pointer has moved: what was grabbed is what was // under the pointer when the button went down) - let drag_id = egui::Id::new((prefix, "zoomBar-drag")); + let drag_id = egui::Id::unique((prefix, "zoomBar-drag")); if resp.drag_started() && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) { @@ -763,7 +763,7 @@ fn mini_timeline( let xof = |tk: Tick| bar.min.x + (((tk - shown.0).0 as f64 / dur) as f32).clamp(0.0, 1.0) * bar.width(); // a time off a zoomed ruler is not drawn (past either end of it all, it sits at that end) let shows = |tk: Tick| (tk >= shown.0 || shown.0 <= origin) && (tk <= shown.1 || shown.1 >= end); - let resp = ui.interact(bar, egui::Id::new((which as u8, "scrub")), Sense::click_and_drag()); + let resp = ui.interact(bar, egui::Id::unique((which as u8, "scrub")), Sense::click_and_drag()); app.auto.add(if which == Which::Program { "program.scrubBar" } else { "source.scrubBar" }, bar, "scrub bar"); let interaction = crate::panels::source_range::interact(app, ui, bar, which, shown); let p = ui.painter(); @@ -945,7 +945,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) // Keep the existing button-editor affordance only when it has its own space. // Customize this monitor's transport separately from the other monitor. let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 5.0), vec2(22.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); + let resp = ui.interact(r, egui::Id::unique((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); app.auto.add(&format!("{prefix}.transport.editor"), r, "Button Editor"); icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim }); let mut edited_hidden = hidden.clone(); @@ -1009,8 +1009,8 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) } if !fit.overflow.is_empty() { let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(TRANSPORT_BUTTON, TRANSPORT_BUTTON)); - let response = ui.interact(rect, egui::Id::new((prefix, "transport-more")), Sense::click()).on_hover_text(tl!("More transport controls")); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("More transport controls"))); + let response = ui.interact(rect, egui::Id::unique((prefix, "transport-more")), Sense::click()).on_hover_text(tl!("More transport controls")); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("More transport controls"))); if response.hovered() { ui.painter().rect_filled(rect, 4.0, t.hover); } @@ -1043,7 +1043,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) fn transport_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, prefix: &str, icon: Icon, command: &str, label: &str) -> bool { let t = app.tokens; - let response = ui.interact(rect, egui::Id::new((prefix, command)), Sense::click()).on_hover_text(label); + let response = ui.interact(rect, egui::Id::unique((prefix, command)), Sense::click()).on_hover_text(label); app.auto.add_response(&format!("{prefix}.transport.{command}"), &response, label); if response.hovered() { ui.painter().rect_filled(rect, 4.0, t.hover); diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index 5afc7e635..b4fa596c8 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -385,7 +385,7 @@ pub fn zoom_pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: } let mut d = Vec2::ZERO; // a middle-button drag that began over the picture pans it, whatever the tool - let held = egui::Id::new((prefix(w), "middle-pan")); + let held = egui::Id::unique((prefix(w), "middle-pan")); let (pressed, down, moved) = ui.input(|i| (i.pointer.button_pressed(egui::PointerButton::Middle), i.pointer.middle_down(), i.pointer.delta())); let panning = down && (ui.data(|m| m.get_temp::(held)).unwrap_or(false) || (pressed && ui.rect_contains_pointer(area))); ui.data_mut(|m| m.insert_temp(held, panning)); @@ -394,7 +394,7 @@ pub fn zoom_pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: d += moved; } if app.ui.tool == Tool::Hand { - let resp = ui.interact(area, egui::Id::new((prefix(w), "pan")), Sense::drag()); + let resp = ui.interact(area, egui::Id::unique((prefix(w), "pan")), Sense::drag()); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); } @@ -506,9 +506,9 @@ pub fn rulers(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, top: Rect, le } } } - let drag_id = egui::Id::new((pfx, "new-guide")); + let drag_id = egui::Id::unique((pfx, "new-guide")); for (r, name, vertical) in [(top, "top", false), (left, "left", true)] { - let resp = ui.interact(r, egui::Id::new((pfx, "ruler", name)), Sense::drag()); + let resp = ui.interact(r, egui::Id::unique((pfx, "ruler", name)), Sense::drag()); app.auto.add(&format!("{pfx}.ruler.{name}"), r, if vertical { "left ruler" } else { "top ruler" }); if resp.hovered() { ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); @@ -560,13 +560,13 @@ pub fn guides(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, p if v.lock_guides { continue; } - let resp = ui.interact(hit, egui::Id::new((pfx, "guide", i)), Sense::drag()); + let resp = ui.interact(hit, egui::Id::unique((pfx, "guide", i)), Sense::drag()); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(if g.vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); } let left = egui::PointerButton::Primary; // egui ends a drag on Escape: that stop puts the guide back where it was (#580) - let before_id = egui::Id::new((pfx, "guide-before-drag", i)); + let before_id = egui::Id::unique((pfx, "guide-before-drag", i)); if resp.drag_started_by(left) { ui.data_mut(|d| d.insert_temp(before_id, g.position)); } @@ -651,7 +651,7 @@ pub fn waveform( } let x = xof(time); p.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, t.playhead)); - let resp = ui.interact(area, egui::Id::new("source-waveform"), Sense::click_and_drag()); + let resp = ui.interact(area, egui::Id::unique("source-waveform"), Sense::click_and_drag()); app.auto.add("source.waveform", area, "audio waveform"); // Horizontal waveform drags scrub; dragging vertically lifts the marked audio to the timeline. let delta = resp.drag_delta(); @@ -694,7 +694,7 @@ pub fn compare_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, rate: f ] { let r = Rect::from_min_max(pos2(x - 34.0, bar.min.y + 1.0), pos2(x - 2.0, bar.max.y - 1.0)); x -= 34.0; - let resp = ui.interact(r, egui::Id::new(("compare", key)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("compare", key)), Sense::click()).on_hover_text(tip); ui.painter().rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.field_bg }); ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(10.0), t.text); app.auto.add(&format!("program.compare.{key}"), r, tip); @@ -801,7 +801,7 @@ fn push(e: &mut Elems, id: &str, r: &egui::Response, label: &str) { /// Add Guide… / Save Guides as Template… / Manage Guides… dialogs. pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.guide_dialog.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("guide-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("guide-dialog-before-drag"), &mut d); let mut elems: Elems = Vec::new(); let mut close = crate::widgets::escape_closes(ctx); let mut act: Option<(String, Value)> = None; @@ -812,7 +812,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { GuideDialog::Manage { .. } => ("Manage Guides", tl!("Manage Guides")), }; egui::Window::new(shown) - .id(egui::Id::new(title)) + .id(egui::Id::unique(title)) .collapsible(false) .resizable(false) .pivot(Align2::CENTER_CENTER) diff --git a/crates/ui-egui/src/panels/multicam.rs b/crates/ui-egui/src/panels/multicam.rs index 34ffae02d..70f0ffe0d 100644 --- a/crates/ui-egui/src/panels/multicam.rs +++ b/crates/ui-egui/src/panels/multicam.rs @@ -95,7 +95,7 @@ fn methods() -> [(&'static str, &'static str); 5] { /// Draw the open Synchronize / Merge Clips / Create Multi-Camera dialog. pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.sync_dialog.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("sync-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("sync-dialog-before-drag"), &mut d); let (title, pre) = match d.kind.as_str() { "merge" => (tl!("Merge Clips"), "merge"), "multicam" => (tl!("Create Multi-Camera Source Sequence"), "mcam"), @@ -106,7 +106,7 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let accent = app.tokens.accent; let names: Vec = d.items.iter().filter_map(|i| app.session.project.item(ItemId(*i)).map(|it| it.name.clone())).collect(); egui::Window::new(title) - .id(egui::Id::new(("sync-dialog", pre))) + .id(egui::Id::unique(("sync-dialog", pre))) .collapsible(false) .resizable(false) .default_width(440.0) @@ -116,7 +116,7 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { if pre != "sync" { ui.horizontal(|ui| { ui.label(if pre == "merge" { tl!("Clip Name:") } else { tl!("Sequence Name:") }); - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(300.0)), "Sequence Name"); push(&mut elems, format!("{pre}.name"), &r, "name"); }); ui.label(RichText::new(tlf!("{n} clip(s): {names}", n = names.len(), names = names.join(", "))).weak().small()); @@ -141,9 +141,12 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { push(&mut elems, format!("{pre}.ignoreHours"), &r, "Ignore Hours"); } "marker" => { - let r = ui.add_enabled( - d.method == "marker", - egui::TextEdit::singleline(&mut d.marker).hint_text(tl!("first marker")).desired_width(140.0), + let r = crate::widgets::accessible_name( + ui.add_enabled( + d.method == "marker", + egui::TextEdit::singleline(&mut d.marker).hint_text(tl!("first marker")).desired_width(140.0), + ), + "first marker", ); push(&mut elems, format!("{pre}.marker"), &r, "marker name"); } @@ -161,7 +164,10 @@ pub fn show_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { "sync" => { ui.horizontal(|ui| { ui.label(tl!("Reference track:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.track).hint_text(tl!("lowest (V1…)")).desired_width(90.0)); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut d.track).hint_text(tl!("lowest (V1…)")).desired_width(90.0)), + "Reference track", + ); push(&mut elems, "sync.track", &r, "reference track"); }); } @@ -354,7 +360,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { let cell_i = k - lay.first; let cell = Rect::from_min_size(pic.min + vec2((cell_i % cols) as f32 * cw, (cell_i / cols) as f32 * ch), vec2(cw, ch)); let name = cams.cameras.get(angle).map(|c| c.name.clone()).unwrap_or_default(); - let resp = ui.interact(cell, egui::Id::new(("multicam-angle", k)), Sense::click()).on_hover_text(tlf!("Camera {n} — {name}", n = k + 1, name)); + let resp = ui.interact(cell, egui::Id::unique(("multicam-angle", k)), Sense::click()).on_hover_text(tlf!("Camera {n} — {name}", n = k + 1, name)); app.auto.add(&format!("program.multicam.angle.{}", k + 1), cell, &name); if resp.clicked() { // modifiers held now, or carried by the click event (synthetic input) @@ -385,7 +391,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { for (id, label, dx, cmd) in [("pagePrev", "◀", -70.0, "multicam.prevPage"), ("pageNext", "▶", 70.0, "multicam.nextPage")] { let r = Rect::from_center_size(mid + vec2(dx, 0.0), vec2(26.0, 18.0)); let enabled = if cmd == "multicam.prevPage" { lay.page > 0 } else { lay.page + 1 < lay.pages }; - let resp = ui.interact(r, egui::Id::new(("multicam-page", id)), Sense::click()).on_hover_text(if dx < 0.0 { + let resp = ui.interact(r, egui::Id::unique(("multicam-page", id)), Sense::click()).on_hover_text(if dx < 0.0 { tl!("Previous page") } else { tl!("Next page") @@ -401,7 +407,7 @@ pub fn grid(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect) { // record indicator let rr = Rect::from_min_size(area.min + vec2(8.0, 8.0), vec2(64.0, 18.0)); let rec_on = app.ui.multicam_record; - let resp = ui.interact(rr, egui::Id::new("multicam-record"), Sense::click()).on_hover_text(tl!("Multi-Camera Record On/Off (0)")); + let resp = ui.interact(rr, egui::Id::unique("multicam-record"), Sense::click()).on_hover_text(tl!("Multi-Camera Record On/Off (0)")); app.auto.add("program.multicam.record", rr, "Multi-Camera Record"); let dot = if recording { Color32::from_rgb(0xe0, 0x3a, 0x3a) @@ -455,7 +461,7 @@ pub fn show_edit_cameras(app: &mut FilmcraftApp, ctx: &egui::Context) { let th = thumb_w * q.settings.height.max(1) as f32 / q.settings.width.max(1) as f32; let tokens = app.tokens; egui::Window::new(tl!("Edit Cameras")) - .id(egui::Id::new("Edit Cameras")) + .id(egui::Id::unique("Edit Cameras")) .collapsible(false) .resizable(false) .default_width(420.0) @@ -481,7 +487,10 @@ pub fn show_edit_cameras(app: &mut FilmcraftApp, ctx: &egui::Context) { } elems.push((format!("editCameras.thumb.{a}"), tr, format!("Camera {}", a + 1))); ui.label(format!("{}", a + 1)); - let r = ui.add(egui::TextEdit::singleline(&mut d.names[a]).desired_width(180.0)); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut d.names[a]).desired_width(180.0)), + format!("Camera {}", a + 1), + ); push(&mut elems, format!("editCameras.name.{a}"), &r, "Camera name"); }); } diff --git a/crates/ui-egui/src/panels/notes.rs b/crates/ui-egui/src/panels/notes.rs index 740db1f46..c5dc51723 100644 --- a/crates/ui-egui/src/panels/notes.rs +++ b/crates/ui-egui/src/panels/notes.rs @@ -35,7 +35,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }) .inner; // each focus of the editor starts a new undo step; the edits while it keeps focus join it - let typing = egui::Id::new("project-notes-typing"); + let typing = egui::Id::unique("project-notes-typing"); if r.gained_focus() { ui.data_mut(|d| { let n = d.get_temp::(typing).unwrap_or(0).wrapping_add(1); diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 9867de393..a61f6c041 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -103,12 +103,12 @@ pub fn save_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut open = true; let mut close = false; let mut apply = false; - egui::Window::new(tl!("Save Preset")).id(egui::Id::new("Save Preset")).open(&mut open).collapsible(false).resizable(false).default_width(340.0).show( + egui::Window::new(tl!("Save Preset")).id(egui::Id::unique("Save Preset")).open(&mut open).collapsible(false).resizable(false).default_width(340.0).show( ctx, |ui| { ui.horizontal(|ui| { ui.label(tl!("Name:")); - let r = ui.text_edit_singleline(&mut d.name); + let r = crate::widgets::accessible_name(ui.text_edit_singleline(&mut d.name), tl!("Name")); app.auto.add("savePreset.name", r.rect, "Name"); }); ui.add_space(4.0); @@ -127,7 +127,7 @@ pub fn save_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { } ui.add_space(4.0); ui.label(tl!("Description:")); - let r = ui.text_edit_multiline(&mut d.description); + let r = crate::widgets::accessible_name(ui.text_edit_multiline(&mut d.description), tl!("Description")); app.auto.add("savePreset.description", r.rect, "Description"); ui.add_space(6.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 6e3487e8d..103c040f9 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -310,7 +310,7 @@ fn tab_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, strip: Rect, docked: &[u _ => false, }; let id = k.map_or("project.tab.project".to_string(), |k| format!("project.tab.{k}")); - let resp = ui.interact(r, egui::Id::new(&id), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(&id), Sense::click()); app.auto.add(&id, r, &label); if is_active || resp.hovered() { ui.painter().rect_filled(r, 3.0, if is_active { t.pressed } else { t.hover }); @@ -321,7 +321,7 @@ fn tab_strip(app: &mut FilmcraftApp, ui: &mut egui::Ui, strip: Rect, docked: &[u } if let Some(k) = k { let cr = Rect::from_center_size(pos2(r.max.x - 11.0, r.center().y), vec2(12.0, 12.0)); - let cresp = ui.interact(cr, egui::Id::new(("ptab-close", k)), Sense::click()).on_hover_text(tl!("Close")); + let cresp = ui.interact(cr, egui::Id::unique(("ptab-close", k)), Sense::click()).on_hover_text(tl!("Close")); app.auto.add(&format!("project.tab.{k}.close"), cr, "Close"); icons::paint(ui.painter(), cr.shrink(2.0), Icon::Close, if cresp.hovered() { t.text } else { t.text_dim }); if cresp.clicked() || resp.middle_clicked() { @@ -352,7 +352,7 @@ pub fn floating(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut open = true; let root = app.ui.project_panel.tabs[k].bin == app.session.project.root.id.0; egui::Window::new(if root { tlf!("Project: {name}", name = app.session.project.name) } else { tlf!("Bin: {name}", name) }) - .id(egui::Id::new(("bin-window", k))) + .id(egui::Id::unique(("bin-window", k))) .open(&mut open) .default_size(vec2(520.0, 360.0)) .min_size(vec2(320.0, 220.0)) @@ -397,7 +397,7 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions Inst::Tab(k) => BinId(app.ui.project_panel.tabs[k].bin), }; let up = Rect::from_min_size(pos2(head.min.x, head.center().y - 9.0), vec2(22.0, 18.0)); - let up_resp = ui.interact(up, egui::Id::new((&pre, "up")), Sense::click()).on_hover_text(tl!("Up one level")); + let up_resp = ui.interact(up, egui::Id::unique((&pre, "up")), Sense::click()).on_hover_text(tl!("Up one level")); app.auto.add(&format!("{pre}.up"), up, "Up one level"); let can_up = v.bin != top; if up_resp.hovered() && can_up { @@ -429,7 +429,7 @@ fn body(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View, actions app.ui.project_search = q; app.auto.add(&format!("{pre}.search"), search_rect, "Search"); let find = Rect::from_min_size(pos2(search_rect.max.x + 8.0, row.min.y), vec2(24.0, 22.0)); - let fr = ui.interact(find, egui::Id::new((&pre, "find")), Sense::click()).on_hover_text(tl!("Find…")); + let fr = ui.interact(find, egui::Id::unique((&pre, "find")), Sense::click()).on_hover_text(tl!("Find…")); app.auto.add(&format!("{pre}.find"), find, "Find…"); if fr.hovered() { ui.painter().rect_filled(find, 3.0, t.hover); @@ -562,7 +562,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action (Icon::Freeform, ViewMode::Freeform, tl!("Freeform View")), ] { let r = Rect::from_min_size(pos2(x, bar.min.y + 5.0), vec2(26.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((pre, "pv", tip)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique((pre, "pv", tip)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("{pre}.view.{mode:?}"), r, tip); if v.mode == mode { ui.painter().rect_filled(r, 4.0, t.pressed); @@ -579,10 +579,10 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action let sw = (bar.width() - 300.0).clamp(40.0, 120.0); let sr = Rect::from_min_size(pos2(x + 8.0, bar.min.y + 8.0), vec2(sw, 16.0)); let mut sz = v.icon_size; - let resp = ui.put(sr, egui::Slider::new(&mut sz, 48.0..=400.0).show_value(false)); + let resp = crate::widgets::accessible_name(ui.put(sr, egui::Slider::new(&mut sz, 48.0..=400.0).show_value(false)), tl!("Zoom")); app.auto.add(&format!("{pre}.iconSize"), sr, "Zoom"); // a drag ended by Escape goes back to the size it started from (#580) - let orig_id = egui::Id::new((pre, "icon-size-before-drag")); + let orig_id = egui::Id::unique((pre, "icon-size-before-drag")); if resp.drag_started() { ui.data_mut(|d| d.insert_temp(orig_id, v.icon_size)); } @@ -606,7 +606,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action // Sort Icons (Icon view) if v.mode == ViewMode::Icon { let r = Rect::from_min_size(pos2(x, bar.min.y + 5.0), vec2(24.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((pre, "sortIcons")), Sense::click()).on_hover_text(tl!("Sort Icons")); + let resp = ui.interact(r, egui::Id::unique((pre, "sortIcons")), Sense::click()).on_hover_text(tl!("Sort Icons")); app.auto.add(&format!("{pre}.sortIcons"), r, "Sort Icons"); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); @@ -623,7 +623,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action (Icon::Automate, "clip.automateToSequence", tl!("Automate to Sequence")), ] { let r = Rect::from_min_size(pos2(rx - 24.0, bar.min.y + 5.0), vec2(24.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((pre, "pb", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique((pre, "pb", id)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("{pre}.button.{id}"), r, tip); if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); diff --git a/crates/ui-egui/src/panels/project_dialogs.rs b/crates/ui-egui/src/panels/project_dialogs.rs index 933770387..13d5a134c 100644 --- a/crates/ui-egui/src/panels/project_dialogs.rs +++ b/crates/ui-egui/src/panels/project_dialogs.rs @@ -10,12 +10,13 @@ use crate::FilmcraftApp; use crate::panels::project::ProjectDialog; fn push(app: &mut FilmcraftApp, id: &str, r: &egui::Response, label: &str) { + crate::widgets::accessible_name(r.clone(), label); app.auto.add(id, r.rect, label); } pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.project_panel.dialog.clone() else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("project-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("project-dialog-before-drag"), &mut d); let mut close = false; let mut run: Option<(String, Value)> = None; let title = match &d { @@ -25,7 +26,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ProjectDialog::FreeformOptions { .. } => tl!("Freeform View Options"), ProjectDialog::SaveArrangement { .. } => tl!("Save Arrangement"), }; - let mut win = egui::Window::new(title).id(egui::Id::new("project-dialog")).collapsible(false).resizable(false).centered(ctx); + let mut win = egui::Window::new(title).id(egui::Id::unique("project-dialog")).collapsible(false).resizable(false).centered(ctx); // a fixed size keeps the two-list dialog from shifting while its lists settle if matches!(d, ProjectDialog::MetadataDisplay { .. }) { win = win.fixed_size(egui::vec2(480.0, 470.0)); @@ -36,7 +37,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ProjectDialog::SavePresetAs { name } => { ui.horizontal(|ui| { ui.label(tl!("Name:")); - let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name"); push(app, "projectDialog.name", &r, "Name"); }); if ui.input(|i| i.key_pressed(egui::Key::Enter)) { @@ -74,7 +75,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { ProjectDialog::SaveArrangement { name, bin } => { ui.horizontal(|ui| { ui.label(tl!("Name:")); - let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name"); push(app, "projectDialog.name", &r, "Name"); }); if ok_cancel(app, ui, &mut close) && !name.trim().is_empty() { @@ -124,7 +125,8 @@ fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec let all = filmcraft_engine::project_panel::all_columns(&app.session.project); ui.horizontal(|ui| { ui.label(tl!("Show:")); - let r = ui.add(egui::TextEdit::singleline(filter).hint_text(tl!("Search fields")).desired_width(220.0)); + let r = + crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(filter).hint_text(tl!("Search fields")).desired_width(220.0)), "Search fields"); push(app, "projectDialog.filter", &r, "Search fields"); }); ui.add_space(4.0); @@ -217,7 +219,7 @@ fn manage_presets(app: &mut FilmcraftApp, ui: &mut egui::Ui, selected: &mut usiz let filled = presets.get(*selected).is_some_and(Option::is_some); ui.horizontal(|ui| { ui.label(tl!("Name:")); - let r = ui.add_enabled(filled, egui::TextEdit::singleline(name).desired_width(200.0)); + let r = crate::widgets::accessible_name(ui.add_enabled(filled, egui::TextEdit::singleline(name).desired_width(200.0)), "Name"); push(app, "projectDialog.name", &r, "Name"); let b = ui.add_enabled(filled, egui::Button::new(tl!("Rename"))); push(app, "projectDialog.rename", &b, "Rename"); diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index d908725a1..5183f8269 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -414,11 +414,14 @@ fn rename_field(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, item: Option return false; } let mut text = rn.text; - let id = egui::Id::new(("rename-field", pre, item, bin)); - let resp = ui.put(r, egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(font(app))).margin(vec2(3.0, 1.0))); + let id = egui::Id::unique(("rename-field", pre, item, bin)); + let resp = crate::widgets::accessible_name( + ui.put(r, egui::TextEdit::singleline(&mut text).id(id).font(egui::FontId::proportional(font(app))).margin(vec2(3.0, 1.0))), + "Rename", + ); app.auto.add(&format!("{pre}.rename"), r, "Rename"); // take keyboard focus the first frame the field is shown - let shown = egui::Id::new("project-rename-shown"); + let shown = egui::Id::unique("project-rename-shown"); if ui.ctx().data(|d| d.get_temp::(shown)) != Some(id) { ui.ctx().data_mut(|d| d.insert_temp(shown, id)); resp.request_focus(); @@ -444,7 +447,7 @@ fn rename_field(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, item: Option /// A second click on the name of the only selected item starts renaming it (after the /// double-click interval, so a double-click still opens it). fn slow_click_rename(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Response, name_rect: Rect, id: ItemId, was_selected: bool) { - let key = egui::Id::new("project-rename-pending"); + let key = egui::Id::unique("project-rename-pending"); let now = ui.input(|i| i.time); if resp.double_clicked() { ui.ctx().data_mut(|d| d.remove::<(u64, f64)>(key)); @@ -513,13 +516,13 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View let hp = ui.painter().with_clip_rect(hr.intersect(ui.clip_rect())); hp.rect_filled(hr, 0.0, t.panel_bg); hp.line_segment([hr.left_bottom(), hr.right_bottom()], Stroke::new(1.0, t.separator)); - let mut x = rect.min.x + LEAD - out.state.offset.x; + let mut x = rect.min.x + LEAD - out.state.clamped_offset().x; let sort = app.session.prefs.project_panel.view.sort.clone(); for (i, c) in lc.cols.iter().enumerate() { let cr = Rect::from_min_size(pos2(x, hr.min.y), vec2(c.width, header_h)); let vis = cr.intersect(hr); if vis.width() > 1.0 { - let id = egui::Id::new((&v.prefix, "col", &c.name)); + let id = egui::Id::unique((&v.prefix, "col", &c.name)); let resp = ui.interact(vis, id, Sense::click()).on_hover_text(crate::i18n::t(&c.name)); app.auto.add(&format!("{}.list.header.{}", v.prefix, c.name), vis, &c.name); if resp.hovered() { @@ -551,7 +554,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View // resize handle on the column's right edge let hr2 = Rect::from_center_size(pos2(cr.max.x, hr.center().y), vec2(8.0, header_h)); if hr2.intersects(hr) { - let id = egui::Id::new((&v.prefix, "colsize", &c.name)); + let id = egui::Id::unique((&v.prefix, "colsize", &c.name)); let resp = ui.interact(hr2.intersect(hr), id, Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeColumn); app.auto.add(&format!("{}.list.resize.{}", v.prefix, c.name), hr2.intersect(hr), "Resize column"); hp.line_segment([pos2(cr.max.x, hr.min.y + 4.0), pos2(cr.max.x, hr.max.y - 4.0)], Stroke::new(1.0, t.separator)); @@ -574,7 +577,7 @@ pub fn list_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View /// sit on top). Starts the frame's [`note_hit`] list. fn background(ui: &egui::Ui, v: &View) -> egui::Response { ui.data_mut(|d| d.remove::>(hits_id(v))); - ui.interact(ui.clip_rect(), egui::Id::new((&v.prefix, "background")), Sense::click_and_drag()) + ui.interact(ui.clip_rect(), egui::Id::unique((&v.prefix, "background")), Sense::click_and_drag()) } /// Empty space under the rows / cards, and the gaps between them (`bg`): click deselects, @@ -598,7 +601,7 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, bg: &egui::R } fn hits_id(v: &View) -> egui::Id { - egui::Id::new((&v.prefix, "marquee-hits")) + egui::Id::unique((&v.prefix, "marquee-hits")) } /// Remember where an item's row or card was drawn this frame, for [`marquee`]. @@ -612,7 +615,7 @@ fn note_hit(ui: &egui::Ui, v: &View, id: ItemId, r: Rect) { fn marquee(app: &mut FilmcraftApp, ui: &egui::Ui, v: &View, bg: &egui::Response, origin: egui::Pos2, actions: &mut Actions) { // (press point relative to `origin`, selection when the drag began, adds to it) type Drag = (egui::Vec2, Vec, bool); - let key = egui::Id::new((&v.prefix, "marquee")); + let key = egui::Id::unique((&v.prefix, "marquee")); let hits: Vec<(u64, Rect)> = ui.data_mut(|d| d.remove_temp(hits_id(v))).unwrap_or_default(); let current: Vec = app.session.state.project_selection.iter().map(|i| i.0).collect(); // the left button only: right-click opens the menu, the middle button is not a selection @@ -824,7 +827,7 @@ fn hover_time(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Response, r: R fn card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, id: ItemId, pre: &str, show_name: bool, show_dur: bool, sense: Sense) -> Option { let t = app.tokens; let it = app.session.project.item(id).cloned()?; - let resp = ui.interact(r.expand(2.0), egui::Id::new((pre, "card", id.0)), sense); + let resp = ui.interact(r.expand(2.0), egui::Id::unique((pre, "card", id.0)), sense); let th = Rect::from_min_size(r.min, vec2(r.width(), r.width() * 9.0 / 16.0)); let selected = app.session.state.project_selection.contains(&id); let card_bg = Rect::from_min_max(th.min - vec2(6.0, 6.0), pos2(th.max.x + 6.0, r.max.y + 2.0)); @@ -912,7 +915,7 @@ fn card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, id: ItemId, pre: &st fn bin_card(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, b: &Bin, v: &View, actions: &mut Actions) { let t = app.tokens; let th = Rect::from_min_size(r.min, vec2(r.width(), r.width() * 9.0 / 16.0)); - let resp = ui.interact(r.expand(2.0), egui::Id::new((&v.prefix, "bincard", b.id.0)), Sense::click_and_drag()); + let resp = ui.interact(r.expand(2.0), egui::Id::unique((&v.prefix, "bincard", b.id.0)), Sense::click_and_drag()); let selected = app.ui.project_panel.selected_bin == Some(b.id.0); let bg = Rect::from_min_max(th.min - vec2(6.0, 6.0), pos2(th.max.x + 6.0, r.max.y + 2.0)); ui.painter().rect_filled(bg, 4.0, if selected { t.tl_header_bg } else { t.panel_bg }); @@ -1000,7 +1003,7 @@ pub fn freeform_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: & let opts = app.session.prefs.project_panel.freeform.clone(); let mut cards: Vec = pp::freeform_layout(&app.session.project, Some(v.bin), &opts, rect.width()); cards.retain(|c| matches_filter(app, ItemId(c.item), filter)); - let drag_key = egui::Id::new((&v.prefix, "ff-drag")); + let drag_key = egui::Id::unique((&v.prefix, "ff-drag")); // (dragged item, offset so far) let dragging: Option<(u64, egui::Vec2)> = ui.ctx().data(|d| d.get_temp(drag_key)); let sel: Vec = app.session.state.project_selection.iter().map(|i| i.0).collect(); diff --git a/crates/ui-egui/src/panels/reference.rs b/crates/ui-egui/src/panels/reference.rs index 4c5c70f68..07f2ded34 100644 --- a/crates/ui-egui/src/panels/reference.rs +++ b/crates/ui-egui/src/panels/reference.rs @@ -68,7 +68,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { None => crate::dock::placeholder(ui, area, &t, "…"), } elems.push(("reference.picture".into(), area, "reference scopes".into())); - let resp = ui.interact(area, egui::Id::new("reference-area"), Sense::click()); + let resp = ui.interact(area, egui::Id::unique("reference-area"), Sense::click()); egui::Popup::context_menu(&resp).show(|ui| { for k in ScopeKind::ALL { let on = st.scopes.contains(&k); @@ -101,7 +101,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let w = if icon.is_some() { 24.0 } else { galley_w + 16.0 }; let r = Rect::from_min_size(pos2(x, bar.center().y - 11.0), vec2(w, 22.0)); x = r.max.x + 4.0; - let resp = ui.interact(r, egui::Id::new(("reference-btn", id.to_string())), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("reference-btn", id.to_string())), Sense::click()); if on { ui.painter().rect_filled(r, 4.0, t.pressed); } else if resp.hovered() { diff --git a/crates/ui-egui/src/panels/remix.rs b/crates/ui-egui/src/panels/remix.rs index 185bf74cb..c1fe31932 100644 --- a/crates/ui-egui/src/panels/remix.rs +++ b/crates/ui-egui/src/panels/remix.rs @@ -20,7 +20,7 @@ struct Draft { } fn draft_id() -> egui::Id { - egui::Id::new("remix-properties-draft") + egui::Id::unique("remix-properties-draft") } /// Open the dialog for the selected remixed clip (the menu entry without parameters). @@ -44,12 +44,12 @@ pub fn is_open(ctx: &egui::Context) -> bool { pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("remix-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("remix-dialog-before-drag"), &mut d); let mut close = false; let mut apply = false; let mut elems: Vec<(String, egui::Rect, String)> = Vec::new(); egui::Window::new(tl!("Remix Properties")) - .id(egui::Id::new("Remix Properties")) + .id(egui::Id::unique("Remix Properties")) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) diff --git a/crates/ui-egui/src/panels/scopes.rs b/crates/ui-egui/src/panels/scopes.rs index f68b0575a..bf8747961 100644 --- a/crates/ui-egui/src/panels/scopes.rs +++ b/crates/ui-egui/src/panels/scopes.rs @@ -57,7 +57,7 @@ pub fn frame_signal(app: &mut FilmcraftApp, ctx: &egui::Context, slot: &str, fra let pq = hdr_seq && space == ColorSpace::Rec2100; let rate = q.settings.frame_rate; let rev = app.session.revision; - let id = egui::Id::new(("scope-signal", slot)); + let id = egui::Id::unique(("scope-signal", slot)); let cached: Option<(u64, Arc, bool)> = ctx.data(|d| d.get_temp(id)); let key = hash_of((seq_id.0, frame, rev, pq)); if let Some((k, s, p)) = &cached @@ -99,7 +99,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { None => crate::dock::placeholder(ui, area, &t, if app.session.state.active_sequence.is_some() { "…" } else { tl!("(no sequence)") }), } // right-click anywhere on the scopes: the settings menu - let resp = ui.interact(area, egui::Id::new("scopes-area"), Sense::click()); + let resp = ui.interact(area, egui::Id::unique("scopes-area"), Sense::click()); egui::Popup::context_menu(&resp).show(|ui| settings_menu(app, ui)); footer(app, ui, Rect::from_min_max(pos2(rect.min.x, area.max.y), rect.max), sf.as_ref().map(|s| s.space)); } @@ -134,7 +134,7 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, space: Option Arc { /// A texture computed from (frame, settings, scope), kept until either changes. fn cached_texture(ctx: &egui::Context, slot: String, key: u64, make: impl FnOnce() -> (usize, usize, Vec)) -> Option { - let id = egui::Id::new(("scope-tex", slot.clone())); + let id = egui::Id::unique(("scope-tex", slot.clone())); if let Some((k, tex)) = ctx.data(|d| d.get_temp::<(u64, TextureHandle)>(id)) && k == key { diff --git a/crates/ui-egui/src/panels/sequence_settings.rs b/crates/ui-egui/src/panels/sequence_settings.rs index 0bd2f5dec..d043286aa 100644 --- a/crates/ui-egui/src/panels/sequence_settings.rs +++ b/crates/ui-egui/src/panels/sequence_settings.rs @@ -299,7 +299,7 @@ fn general(ui: &mut egui::Ui, d: &mut SequenceSettingsDraft, cur: &SequenceSetti .fold(0.0, f32::max); let list_w = 260.0; row(ui, label_w, "Sequence Name:", true, |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(list_w)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(list_w)), "Sequence Name"); elems.push(("sequenceSettings.name".into(), r.rect, d.name.clone())); }); row(ui, label_w, "Editing Mode:", false, |ui| fixed_combo(ui, elems, "editingMode", "Custom", list_w, NOT_YET)); @@ -475,7 +475,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let audio_samples = app.session.project.settings.audio_display_samples; let accent = app.tokens.accent; let mut d = app.ui.sequence_settings.clone(); - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("sequence-settings-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("sequence-settings-before-drag"), &mut d); let mut keep = true; let mut apply = false; let mut elems: Elems = Vec::new(); @@ -487,7 +487,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { &[("general", "General"), ("color", "Color Management"), ("vr", "VR Properties")] }; egui::Window::new(title) - .id(egui::Id::new("Sequence Settings")) + .id(egui::Id::unique("Sequence Settings")) .collapsible(false) .resizable(false) .default_width(620.0) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b48f7d613..5838e3449 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -303,7 +303,7 @@ fn appearance_modes(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) let mode = choice_text(get(&d.values, key)); ui.horizontal(|ui| { field_label(ui, "Appearance Mode"); - if let Some(v) = combo(app, ui, key, &mode, &static_opts(settings::APPEARANCE_MODES), 200.0) { + if let Some(v) = combo(app, ui, key, "Appearance Mode", &mode, &static_opts(settings::APPEARANCE_MODES), 200.0) { put(&mut d.values, key, json!(v)); } }); @@ -428,7 +428,7 @@ fn choice_text(v: &Value) -> String { } /// A dropdown of (value, label) options; returns the chosen value. -fn combo(app: &mut FilmcraftApp, ui: &mut Ui, key: &str, current: &str, opts: &[(String, String)], width: f32) -> Option { +fn combo(app: &mut FilmcraftApp, ui: &mut Ui, key: &str, label: &str, current: &str, opts: &[(String, String)], width: f32) -> Option { let shown = opts.iter().find(|o| o.0 == current).map(|o| o.1.clone()).unwrap_or_else(|| current.to_string()); let mut chosen = None; let mut items: Vec<(String, Rect, String)> = Vec::new(); @@ -447,7 +447,10 @@ fn combo(app: &mut FilmcraftApp, ui: &mut Ui, key: &str, current: &str, opts: &[ } } }); - app.auto.add(&format!("settings.{key}"), r.response.rect, &shown); + // The accessible name is the field's label, while the automation label stays the selected + // value so `ui.elements` reports what the dropdown shows. + let response = crate::widgets::accessible_name(r.response, crate::i18n::t(label)); + app.auto.add(&format!("settings.{key}"), response.rect, &shown); for (id, rect, l) in items { app.auto.add(&id, rect, &l); } @@ -522,13 +525,13 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi if let Some(x) = number(app, ui, f.key, cur.as_f64().unwrap_or(1.0), range, decimals, f.label) { put(&mut d.values, f.key, json!(if u == "frames" { x.round() } else { x })); } - if let Some(v) = combo(app, ui, unit_key, &u, &static_opts(settings::DURATION_UNITS), 90.0) { + if let Some(v) = combo(app, ui, unit_key, f.label, &u, &static_opts(settings::DURATION_UNITS), 90.0) { put(&mut d.values, unit_key, json!(v)); } } Kind::Choice(opts) => { field_label(ui, f.label); - if let Some(v) = combo(app, ui, f.key, &choice_text(&cur), &static_opts(opts), 300.0) { + if let Some(v) = combo(app, ui, f.key, f.label, &choice_text(&cur), &static_opts(opts), 300.0) { let x = if cur.is_number() { v.parse::().map(Value::from).unwrap_or(json!(v)) } else { json!(v) }; put(&mut d.values, f.key, x); } @@ -536,7 +539,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi Kind::Device(list) => { field_label(ui, f.label); let opts = device_opts(app, d, list); - if let Some(v) = combo(app, ui, f.key, &choice_text(&cur), &opts, 300.0) { + if let Some(v) = combo(app, ui, f.key, f.label, &choice_text(&cur), &opts, 300.0) { put(&mut d.values, f.key, json!(v)); } } @@ -549,7 +552,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi let c = settings::parse_hex(&choice_text(get(&d.values, &format!("labels.colors.{}.color", settings::label_id(l))))).unwrap_or(l.rgb()); ui.painter().rect_filled(sw, 2.0, Color32::from_rgb(c[0], c[1], c[2])); } - if let Some(v) = combo(app, ui, f.key, &cur_s, &opts, 160.0) { + if let Some(v) = combo(app, ui, f.key, f.label, &cur_s, &opts, 160.0) { put(&mut d.values, f.key, json!(v)); } } @@ -570,7 +573,7 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi } else { ui.available_width().clamp(60.0, 220.0) }; - let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width).hint_text(hint)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut s).desired_width(width).hint_text(hint)), f.label); app.auto.add(&format!("settings.{}", f.key), r.rect, f.label); if r.changed() { put(&mut d.values, f.key, json!(s)); @@ -608,9 +611,12 @@ fn color_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key: } // While the field has focus it shows what is typed, even when that is not a colour yet (one // character into `#2f6bdf`); each valid colour is kept as it comes. Leaving shows the kept one. - let draft_id = egui::Id::new(("settings-hex", key)); + let draft_id = egui::Id::unique(("settings-hex", key)); let mut text = ui.data(|m| m.get_temp::(draft_id)).unwrap_or_else(|| choice_text(get(&d.values, key))); - let t = ui.add(egui::TextEdit::singleline(&mut text).desired_width(70.0).font(egui::TextStyle::Monospace)); + let t = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut text).desired_width(70.0).font(egui::TextStyle::Monospace)), + format!("{label} hex"), + ); app.auto.add(&format!("settings.{key}.hex"), t.rect, label); if t.has_focus() { ui.data_mut(|m| m.insert_temp(draft_id, text.clone())); @@ -639,7 +645,7 @@ fn label_presets(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft) { ui.horizontal(|ui| { let opts: Vec<(String, String)> = presets.iter().map(|p| (p.name.clone(), p.name.clone())).collect(); let shown = if modified { tlf!("{name} (Modified)", name = labels.preset) } else { labels.preset.clone() }; - if let Some(name) = combo(app, ui, "labels.preset", &shown, &opts, 150.0) { + if let Some(name) = combo(app, ui, "labels.preset", "Label Color Presets", &shown, &opts, 150.0) { pick = presets.iter().find(|p| p.name == name).cloned(); } if preset_button(app, ui, "revert", Icon::Reset, tl!("Revert to the preset's colors and names"), modified) { @@ -730,7 +736,10 @@ fn custom(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, name: &str let id = settings::label_id(*l); let nk = format!("labels.colors.{id}.name"); let mut name = choice_text(get(&d.values, &nk)); - let r = ui.add_sized(vec2(136.0, 22.0), egui::TextEdit::singleline(&mut name)); + let r = crate::widgets::accessible_name( + ui.add_sized(vec2(136.0, 22.0), egui::TextEdit::singleline(&mut name)), + format!("{} name", l.name()), + ); app.auto.add(&format!("settings.{nk}"), r.rect, l.name()); if r.changed() { put(&mut d.values, &nk, json!(name)); @@ -845,11 +854,11 @@ fn rows(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, list: &[Row] /// Draw the dialog; returns whether it stays open. pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let Some(mut d) = app.ui.settings.take() else { return false }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("settings-dialog-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("settings-dialog-before-drag"), &mut d); let t = app.tokens; let mut close = false; let mut apply = false; - let resp = egui::Modal::new(egui::Id::new("settings-modal")).frame(modal_frame(&t)).show(ctx, |ui| { + let resp = egui::Modal::new(egui::Id::unique("settings-modal")).frame(modal_frame(&t)).show(ctx, |ui| { ui.set_width(W); // title bar (Premiere keeps "Preferences" as the window title) let (bar, _) = ui.allocate_exact_size(vec2(W, 30.0), Sense::hover()); @@ -864,7 +873,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { ui.painter().rect(list, 2.0, t.field_bg, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); for (i, c) in settings::categories().iter().enumerate() { let row = Rect::from_min_size(list.min + vec2(1.0, 1.0 + i as f32 * ROW_H), vec2(LIST_W - 2.0, ROW_H)); - let r = ui.interact(row, egui::Id::new(("settings-cat", c.id)), Sense::click()); + let r = ui.interact(row, egui::Id::unique(("settings-cat", c.id)), Sense::click()); let sel = d.page == c.id; if sel { ui.painter().rect_filled(row, 0.0, t.row_alt); diff --git a/crates/ui-egui/src/panels/shortcuts_dialog.rs b/crates/ui-egui/src/panels/shortcuts_dialog.rs index 62c50ddf3..005326f04 100644 --- a/crates/ui-egui/src/panels/shortcuts_dialog.rs +++ b/crates/ui-egui/src/panels/shortcuts_dialog.rs @@ -150,8 +150,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { let mut close: Option = None; // Some(true) = OK, Some(false) = Cancel let mut open = true; egui::Window::new(tl!("Keyboard Shortcuts")) - .id(egui::Id::new("Keyboard Shortcuts")) - .id(egui::Id::new("keyboard-shortcuts")) + .id(egui::Id::unique("Keyboard Shortcuts")) + .id(egui::Id::unique("keyboard-shortcuts")) .open(&mut open) .collapsible(false) .resizable(false) @@ -262,7 +262,7 @@ fn header(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) { if let Some(mut name) = app.shortcut_editor.save_name.clone() { ui.horizontal(|ui| { ui.label(RichText::new(tl!("Save preset as:")).size(13.0).color(t.text_dim)); - let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)), "Preset name"); app.auto.add("shortcuts.saveAs.name", r.rect, "Preset name"); let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); if small_button(app, ui, "shortcuts.saveAs.ok", tl!("Save"), !name.trim().is_empty()) || enter { @@ -483,7 +483,7 @@ fn keyboard(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect, t: &Tokens) { (true, true) => format!("shortcuts.mod.{}.right", &name[1..]), _ => format!("shortcuts.key.{name}"), }; - let resp = ui.interact(r, egui::Id::new(&id), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(&id), Sense::click()); let p = ui.painter(); let base = if is_mod { if mod_on { MOD_KEY } else { FREE_KEY.gamma_multiply(0.8) } @@ -594,7 +594,10 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke let plat = Platform::current(); ui.horizontal(|ui| { let mut q = app.shortcut_editor.search.clone(); - let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search commands or keys")).desired_width(320.0)); + let r = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search commands or keys")).desired_width(320.0)), + "Search commands or keys", + ); app.auto.add("shortcuts.search", r.rect, "Search"); app.shortcut_editor.search = q; if let Some(sel) = app.shortcut_editor.selected.clone() { @@ -664,7 +667,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke .collect::>() .join(", ") }; - let cresp = ui.interact(cell, egui::Id::new(("shortcut-cell", &id)), Sense::click()); + let cresp = ui.interact(cell, egui::Id::unique(("shortcut-cell", &id)), Sense::click()); if recording { ui.painter().rect(cell, 3.0, t.field_bg, Stroke::new(1.5, t.accent), StrokeKind::Inside); } else if cresp.hovered() { @@ -677,7 +680,7 @@ fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Toke app.shortcut_editor.recording = Some((id.clone(), false)); } let plus = Rect::from_min_size(pos2(r.max.x - 30.0, r.min.y + 3.0), vec2(20.0, 20.0)); - let presp = ui.interact(plus, egui::Id::new(("shortcut-add", &id)), Sense::click()).on_hover_text(tl!("Add another shortcut")); + let presp = ui.interact(plus, egui::Id::unique(("shortcut-add", &id)), Sense::click()).on_hover_text(tl!("Add another shortcut")); ui.painter().text( plus.center(), Align2::CENTER_CENTER, diff --git a/crates/ui-egui/src/panels/source_drag.rs b/crates/ui-egui/src/panels/source_drag.rs index d705e0f2b..35f718118 100644 --- a/crates/ui-egui/src/panels/source_drag.rs +++ b/crates/ui-egui/src/panels/source_drag.rs @@ -42,8 +42,8 @@ pub fn controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect) { let left = row.center().x - (width * 3.0 + gap * 2.0) * 0.5; for (i, (id, label, video, audio, enabled)) in buttons.into_iter().enumerate() { let rect = Rect::from_min_size(pos2(left + i as f32 * (width + gap), row.min.y), vec2(width, row.height())); - let response = ui.add_enabled_ui(enabled, |ui| ui.interact(rect, egui::Id::new(id), Sense::drag())).inner; - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + let response = ui.add_enabled_ui(enabled, |ui| ui.interact(rect, egui::Id::unique(id), Sense::drag())).inner; + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); let t = app.tokens; if response.hovered() || response.dragged() { ui.painter().rect_filled(rect, 2.0, t.hover); diff --git a/crates/ui-egui/src/panels/source_range.rs b/crates/ui-egui/src/panels/source_range.rs index d7bc66e9e..8551bef9c 100644 --- a/crates/ui-egui/src/panels/source_range.rs +++ b/crates/ui-egui/src/panels/source_range.rs @@ -22,7 +22,7 @@ struct Drag { revision: u64, } fn key(which: Which) -> egui::Id { - egui::Id::new(("monitor-range-edit", which as u8)) + egui::Id::unique(("monitor-range-edit", which as u8)) } fn self_item(app: &FilmcraftApp, which: Which) -> Option { if which == Which::Source { app.session.state.source_item } else { app.session.state.active_sequence } @@ -103,7 +103,7 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Whi } app.auto.add(id, rect, label); let response = - ui.add_enabled_ui(enabled && rect.width() > 0.0, |ui| ui.interact(rect, egui::Id::new(id), Sense::click_and_drag())).inner.on_hover_text(label); + ui.add_enabled_ui(enabled && rect.width() > 0.0, |ui| ui.interact(rect, egui::Id::unique(id), Sense::click_and_drag())).inner.on_hover_text(label); let cursor = if edge == RangeEdge::Move { if cfg!(windows) { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Grab } } else { @@ -197,7 +197,7 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Whi let rect = Rect::from_min_max(pos2(x - 7.0, bar.max.y - 12.0), pos2(x + 7.0, bar.max.y)); let id = if which == Which::Source { "source.playhead" } else { "program.playhead" }; app.auto.add(id, rect, "Scrub playhead"); - let playhead = ui.interact(rect, egui::Id::new(id), Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let playhead = ui.interact(rect, egui::Id::unique(id), Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); if (playhead.dragged() || playhead.clicked()) && let Some(pos) = playhead.interact_pointer_pos() { diff --git a/crates/ui-egui/src/panels/text.rs b/crates/ui-egui/src/panels/text.rs index b6eaca3c9..1fcc611d6 100644 --- a/crates/ui-egui/src/panels/text.rs +++ b/crates/ui-egui/src/panels/text.rs @@ -37,7 +37,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let text_w = tab_text_width(ui.painter(), shown); let w = text_w + 16.0; let r = Rect::from_min_size(pos2(x, rect.min.y + 4.0), vec2(w, 24.0)); - let resp = ui.interact(r, egui::Id::new(("text-tab", tab)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("text-tab", tab)), Sense::click()); let active = app.ui.text_tab == tab; ui.painter().text( pos2(r.min.x, r.center().y), @@ -65,7 +65,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { fn tool_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, icon: Icon, id: &str, label: &str, enabled: bool) -> bool { let t = app.tokens; - let resp = ui.interact(r, egui::Id::new(("text-tool", id)), if enabled { Sense::click() } else { Sense::hover() }); + let resp = ui.interact(r, egui::Id::unique(("text-tool", id)), if enabled { Sense::click() } else { Sense::hover() }); if enabled && resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } @@ -95,7 +95,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .enumerate() { let r = Rect::from_center_size(c + vec2(0.0, 26.0 + i as f32 * 34.0), vec2(200.0, 26.0)); - let resp = ui.interact(r, egui::Id::new(id), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(id), Sense::click()); ui.painter().rect_filled(r, 13.0, if i == 0 { if resp.hovered() { t.accent_hover } else { t.accent } } else { t.field_bg }); ui.painter().text(r.center(), Align2::CENTER_CENTER, label, Tokens::semibold(12.0), if i == 0 { Color32::WHITE } else { t.text }); app.auto.add(id, r, label); @@ -112,7 +112,7 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // the track shown: the one holding the first selected caption, else C1 let sel = app.session.state.caption_selection.clone(); let track_idx = sel.first().and_then(|c| seq.caption_tracks.iter().position(|tr| tr.caption(*c).is_some())).unwrap_or(0); - let track_idx = ui.ctx().data(|d| d.get_temp::(egui::Id::new("text-cap-track"))).filter(|i| *i < seq.caption_tracks.len()).unwrap_or(track_idx); + let track_idx = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("text-cap-track"))).filter(|i| *i < seq.caption_tracks.len()).unwrap_or(track_idx); let track = &seq.caption_tracks[track_idx]; // ---- toolbar: search, track picker, add / split / merge / delete @@ -123,12 +123,12 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut x = bar.min.x + 180.0f32.min(bar.width() * 0.4 + 10.0); let picker = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(130.0, 22.0)); let label = format!("C{} · {}", track_idx + 1, track.name); - let presp = crate::widgets::dropdown_text(ui, picker, &label, &t, egui::Id::new("text-cap-track-picker")); + let presp = crate::widgets::dropdown_text(ui, picker, &label, &t, egui::Id::unique("text-cap-track-picker")); app.auto.add("text.captions.track", picker, "Caption track"); egui::Popup::menu(&presp).show(|ui| { for (i, tr) in seq.caption_tracks.iter().enumerate() { if ui.selectable_label(i == track_idx, format!("C{} · {} ({})", i + 1, tr.name, crate::i18n::t(tr.format.label()))).clicked() { - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("text-cap-track"), i)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("text-cap-track"), i)); } } ui.separator(); @@ -209,14 +209,17 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } for (edge, val) in [("in", c.start), ("out", c.end())] { - let key = egui::Id::new(("cap-tc", c.id.0, edge)); + let key = egui::Id::unique(("cap-tc", c.id.0, edge)); let mut buf = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| tc(val)); - let resp = ui.add( - egui::TextEdit::singleline(&mut buf) - .id(key.with("te")) - .desired_width(88.0) - .font(Tokens::mono(11.0)) - .text_color(if edge == "in" { t.hot_text } else { t.text_dim }), + let resp = crate::widgets::accessible_name( + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(key.with("te")) + .desired_width(88.0) + .font(Tokens::mono(11.0)) + .text_color(if edge == "in" { t.hot_text } else { t.text_dim }), + ), + if edge == "in" { "Caption in" } else { "Caption out" }, ); app.auto.add(&format!("text.captions.{}.{edge}", c.id.0), resp.rect, if edge == "in" { "Caption in" } else { "Caption out" }); if resp.has_focus() { @@ -234,18 +237,21 @@ fn captions(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(sp) = &c.speaker { ui.label(egui::RichText::new(sp).size(11.0).color(t.text_dim).strong()); } - let key = egui::Id::new(("cap-text", c.id.0)); + let key = egui::Id::unique(("cap-text", c.id.0)); let mut buf = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| c.text.clone()); let resp = if crate::rtl::has_rtl(&buf) && !ui.memory(|m| m.has_focus(key.with("te"))) { reading_order(ui, &buf, key.with("te")) } else { - ui.add( - egui::TextEdit::multiline(&mut buf) - .id(key.with("te")) - .desired_rows(1) - .desired_width(ui.available_width()) - .font(Tokens::ui(12.5)) - .frame(egui::Frame::NONE), + crate::widgets::accessible_name( + ui.add( + egui::TextEdit::multiline(&mut buf) + .id(key.with("te")) + .desired_rows(1) + .desired_width(ui.available_width()) + .font(Tokens::ui(12.5)) + .frame(egui::Frame::NONE), + ), + "Caption text", ) }; app.auto.add(&format!("text.captions.{}.text", c.id.0), resp.rect, "Caption text"); @@ -346,7 +352,7 @@ fn graphics(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut actions: Vec<(String, Value)> = Vec::new(); // search let bar = Rect::from_min_size(rect.min + vec2(10.0, 2.0), vec2(rect.width() - 20.0, 26.0)); - let skey = egui::Id::new("text-gfx-search"); + let skey = egui::Id::unique("text-gfx-search"); let mut search = ui.data(|d| d.get_temp::(skey)).unwrap_or_default(); let w = 220.0f32.min(bar.width()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(bar.min, vec2(w, 24.0)))); @@ -399,15 +405,18 @@ fn graphics(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.label(egui::RichText::new(label).size(11.0).color(t.text_dim)); }); ui.vertical(|ui| { - let tkey = egui::Id::new(("gfx-text", g.clip.0, g.layer)); + let tkey = egui::Id::unique(("gfx-text", g.clip.0, g.layer)); let mut buf = ui.data(|d| d.get_temp::(tkey)).unwrap_or_else(|| g.text.clone()); - let resp = ui.add( - egui::TextEdit::multiline(&mut buf) - .id(tkey.with("te")) - .desired_rows(1) - .desired_width(ui.available_width()) - .font(Tokens::ui(12.5)) - .frame(egui::Frame::NONE), + let resp = crate::widgets::accessible_name( + ui.add( + egui::TextEdit::multiline(&mut buf) + .id(tkey.with("te")) + .desired_rows(1) + .desired_width(ui.available_width()) + .font(Tokens::ui(12.5)) + .frame(egui::Frame::NONE), + ), + "Graphic text", ); app.auto.add(&format!("text.graphics.{key}.text"), resp.rect, "Graphic text"); if resp.has_focus() { @@ -463,9 +472,12 @@ pub fn caption_properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) let (hr, _) = bui.allocate_exact_size(vec2(bui.available_width(), 30.0), Sense::hover()); bui.painter().text(pos2(hr.min.x, hr.center().y), Align2::LEFT_CENTER, format!("C{} · {}", track_idx + 1, track_name), Tokens::semibold(12.5), t.text); bui.label(egui::RichText::new(tl!("Text")).size(12.0).color(t.text_dim)); - let key = egui::Id::new(("props-cap-text", c.id.0)); + let key = egui::Id::unique(("props-cap-text", c.id.0)); let mut buf = bui.data(|d| d.get_temp::(key)).unwrap_or_else(|| c.text.clone()); - let resp = bui.add(egui::TextEdit::multiline(&mut buf).id(key.with("te")).desired_rows(3).desired_width(bui.available_width()).font(Tokens::ui(12.5))); + let resp = crate::widgets::accessible_name( + bui.add(egui::TextEdit::multiline(&mut buf).id(key.with("te")).desired_rows(3).desired_width(bui.available_width()).font(Tokens::ui(12.5))), + "Caption text", + ); app.auto.add("properties.caption.text", resp.rect, &c.text); if resp.has_focus() { bui.data_mut(|d| d.insert_temp(key, buf.clone())); @@ -479,9 +491,12 @@ pub fn caption_properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) for (edge, label, val) in [("in", tl!("In"), c.start), ("out", tl!("Out"), c.end())] { bui.horizontal(|ui| { ui.add_sized(vec2(70.0, 22.0), egui::Label::new(egui::RichText::new(label).size(12.0).color(t.text_dim))); - let key = egui::Id::new(("props-cap-tc", c.id.0, edge)); + let key = egui::Id::unique(("props-cap-tc", c.id.0, edge)); let mut buf = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| tc(val)); - let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(key.with("te")).desired_width(100.0).font(Tokens::mono(11.5))); + let resp = crate::widgets::accessible_name( + ui.add(egui::TextEdit::singleline(&mut buf).id(key.with("te")).desired_width(100.0).font(Tokens::mono(11.5))), + if edge == "in" { "In" } else { "Out" }, + ); app.auto.add(&format!("properties.caption.{edge}"), resp.rect, &tc(val)); if resp.has_focus() { ui.data_mut(|d| d.insert_temp(key, buf.clone())); @@ -537,7 +552,7 @@ fn transcript(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { }; ui.painter().text(c - vec2(0.0, 8.0), Align2::CENTER_CENTER, note, Tokens::ui(12.0), t.text_dim); let r = Rect::from_center_size(c + vec2(0.0, 26.0), vec2(200.0, 26.0)); - let resp = ui.interact(r, egui::Id::new("text.transcript.generate"), Sense::click()); + let resp = ui.interact(r, egui::Id::unique("text.transcript.generate"), Sense::click()); ui.painter().rect_filled(r, 13.0, if resp.hovered() { t.accent_hover } else { t.accent }); ui.painter().text(r.center(), Align2::CENTER_CENTER, tl!("Transcribe"), Tokens::semibold(12.0), Color32::WHITE); app.auto.add("text.transcript.generate", r, "Transcribe"); diff --git a/crates/ui-egui/src/panels/timecode.rs b/crates/ui-egui/src/panels/timecode.rs index 1dcfccccd..a9a818f2f 100644 --- a/crates/ui-egui/src/panels/timecode.rs +++ b/crates/ui-egui/src/panels/timecode.rs @@ -121,7 +121,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if show_name { ui.painter().text(pos2(r.center().x, vr.max.y + 2.0), Align2::CENTER_TOP, &caption, Tokens::ui(11.0), t.text_dim); } - let resp = ui.interact(r, egui::Id::new(("timecode-row", i)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("timecode-row", i)), Sense::click()); elems.push((format!("timecode.row.{i}"), vr, value.clone())); egui::Popup::context_menu(&resp).show(|ui| { ui.set_min_width(200.0); @@ -169,7 +169,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // "+" adds a row (same settings as the last one, Duration by default) let ar = Rect::from_min_size(pos2(rect.max.x - 28.0, rect.max.y - 26.0), vec2(22.0, 22.0)); - let aresp = ui.interact(ar, egui::Id::new("timecode-add"), Sense::click()); + let aresp = ui.interact(ar, egui::Id::unique("timecode-add"), Sense::click()); ui.painter().text(ar.center(), Align2::CENTER_CENTER, "+", Tokens::ui(16.0), if aresp.hovered() { t.tab_text_active } else { t.icon }); elems.push(("timecode.addRow".into(), ar, "Add Row".into())); if aresp.on_hover_text(tl!("Add a timecode row")).clicked() { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 33c243762..e8583b803 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -644,7 +644,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // divider between video and audio let div = Rect::from_min_max(pos2(rect.min.x, split_y), pos2(content.max.x, split_y + DIVIDER_H)); painter.rect_filled(div, 0.0, t.app_bg); - let div_resp = ui.interact(div, egui::Id::new("tl-divider"), Sense::drag()); + let div_resp = ui.interact(div, egui::Id::unique("tl-divider"), Sense::drag()); if div_resp.hovered() || div_resp.dragged() { ctx.set_cursor_icon(CursorIcon::ResizeVertical); } @@ -738,7 +738,7 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().text(rect.center() + vec2(0.0, 50.0), Align2::CENTER_CENTER, hint, Tokens::ui(12.0), t.text_dim); } let b = Rect::from_center_size(rect.center() + vec2(0.0, 20.0), vec2(170.0, 26.0)); - let resp = ui.interact(b, egui::Id::new("tl-open-demo"), Sense::click()); + let resp = ui.interact(b, egui::Id::unique("tl-open-demo"), Sense::click()); app.auto.add("timeline.openDemo", b, "Open Demo Project"); ui.painter().rect_filled(b, 13.0, if resp.hovered() { t.accent_hover } else { t.accent }); ui.painter().text(b.center(), Align2::CENTER_CENTER, tl!("Open Demo Project"), Tokens::semibold(12.0), Color32::WHITE); @@ -1275,7 +1275,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: p.rect_filled(hrect, 0.0, c.gamma_multiply(TRACK_HEADER_TINT)); p.rect_filled(Rect::from_min_max(strip.min, pos2(strip.min.x + 5.0, strip.max.y)), 0.0, c); } - let cresp = ui.interact(strip.intersect(visible), egui::Id::new(("trackColor", r.track.0)), Sense::click()).on_hover_text(tl!("Track Color")); + let cresp = ui.interact(strip.intersect(visible), egui::Id::unique(("trackColor", r.track.0)), Sense::click()).on_hover_text(tl!("Track Color")); app.auto.add(&format!("timeline.track.{}{}.color", if r.kind == TrackKind::Video { "V" } else { "A" }, r.index + 1), strip, "Track Color"); if cresp.hovered() && tr.label.is_none() { p.rect_filled(Rect::from_min_max(strip.min, pos2(strip.min.x + 5.0, strip.max.y)), 0.0, t.hover); @@ -1307,7 +1307,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: let patched = if r.kind == TrackKind::Video { tg.video_dest == Some(r.track) } else { tg.audio_dest == Some(r.track) }; let pr = btn_rect(13.0); app.auto.add(&format!("timeline.track.{label}.sourcePatch"), pr, "Source patch"); - if patch_button(ui, pr, visible, &label, patched, false, egui::Id::new(("patch", r.track.0)), t).clicked() { + if patch_button(ui, pr, visible, &label, patched, false, egui::Id::unique(("patch", r.track.0)), t).clicked() { actions.push(("timeline.setTargeting".into(), json!({"track": r.track.0, "sourcePatch": !patched}))); } // 2. track lock @@ -1320,7 +1320,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: if tr.locked { Icon::Lock } else { Icon::Unlock }, true, t, - egui::Id::new(("locked", r.track.0)), + egui::Id::unique(("locked", r.track.0)), Some(if tr.locked { t.icon_active } else { t.text_dim }), ); app.auto.add(&format!("timeline.track.{label}.locked"), lock_r, "Toggle Track Lock"); @@ -1335,14 +1335,21 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: let targeted = tg.targeted.contains(&r.track); let trr = btn_rect(61.0); app.auto.add(&format!("timeline.track.{label}.target"), trr, "Toggle track targeting"); - if patch_button(ui, trr, visible, &label, targeted, true, egui::Id::new(("target", r.track.0)), t).clicked() { + if patch_button(ui, trr, visible, &label, targeted, true, egui::Id::unique(("target", r.track.0)), t).clicked() { actions.push(("timeline.setTargeting".into(), json!({"track": r.track.0, "targeted": !targeted}))); } // 4. upper line icons let mut x = 102.0; let sr = small(x, upper_y); - let sresp = - crate::widgets::icon_toggle(ui, sr.intersect(visible), Icon::SyncLock, tr.sync_lock, t, egui::Id::new(("syncLock", r.track.0)), Some(t.text_dim)); + let sresp = crate::widgets::icon_toggle( + ui, + sr.intersect(visible), + Icon::SyncLock, + tr.sync_lock, + t, + egui::Id::unique(("syncLock", r.track.0)), + Some(t.text_dim), + ); app.auto.add(&format!("timeline.track.{label}.syncLock"), sr, "Toggle Sync Lock"); if sresp.clicked() { actions.push(("timeline.setTrack".into(), json!({"track": r.track.0, "syncLock": !tr.sync_lock}))); @@ -1356,7 +1363,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: if tr.enabled { Icon::Eye } else { Icon::EyeOff }, true, t, - egui::Id::new(("enabled", r.track.0)), + egui::Id::unique(("enabled", r.track.0)), Some(t.text_dim), ); app.auto.add(&format!("timeline.track.{label}.enabled"), er, "Toggle Track Output"); @@ -1372,7 +1379,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: tr.muted, Color32::from_rgb(0x2d, 0x9d, 0x78), t, - egui::Id::new(("mute", r.track.0)), + egui::Id::unique(("mute", r.track.0)), ); app.auto.add(&format!("timeline.track.{label}.muted"), mr, "Mute Track"); if mresp.clicked() { @@ -1386,7 +1393,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: tr.solo, Color32::from_rgb(0xf0, 0xf0, 0x4f), t, - egui::Id::new(("solo", r.track.0)), + egui::Id::unique(("solo", r.track.0)), ); app.auto.add(&format!("timeline.track.{label}.solo"), s2, "Solo Track"); if so.clicked() { @@ -1415,7 +1422,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: continue; } app.auto.add(&format!("timeline.track.{label}.{name}"), edge, "Drag to change the track height"); - let eresp = ui.interact(edge, egui::Id::new(("trackh", name, r.track.0)), Sense::drag()); + let eresp = ui.interact(edge, egui::Id::unique(("trackh", name, r.track.0)), Sense::drag()); if eresp.hovered() || eresp.dragged() { ui.ctx().set_cursor_icon(CursorIcon::ResizeVertical); } @@ -1427,7 +1434,7 @@ fn draw_headers(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, rows: } let resp = ui.interact( Rect::from_min_max(pos2(hrect.min.x + 90.0, hrect.min.y + 26.0), hrect.max).intersect(visible), - egui::Id::new(("hdr", r.track.0)), + egui::Id::unique(("hdr", r.track.0)), Sense::click(), ); if resp.double_clicked() { @@ -1473,7 +1480,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc // current timecode: Premiere's big blue timecode let tc = format_time(app.session.playhead(), rate, seq.settings.drop_frame, TimeDisplay::Timecode, seq.settings.sample_rate as i64); let tc_rect = Rect::from_min_size(pos2(rect.min.x + 14.0, rect.min.y + 4.0), vec2(hw - 20.0, 20.0)); - let tc_id = egui::Id::new(("timeline-tc", seq_id)); + let tc_id = egui::Id::unique(("timeline-tc", seq_id)); match crate::widgets::timecode_field(ui, tc_id, tc_rect, &tc, app.session.playhead(), Tick::ZERO, rate, seq.settings.drop_frame, t.timecode) { Some(Ok(time)) => { if let Err(e) = app.session.execute("playhead.set", json!({"time": time.0})) { @@ -1497,7 +1504,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc ]; for (icon, key, on, toggle, tip) in toggles { let r = Rect::from_min_size(pos2(x, y), vec2(28.0, 28.0)); - let resp = ui.interact(r, egui::Id::new(("tl-toggle", key)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("tl-toggle", key)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("timeline.toggle.{key}"), r, tip); if toggle && on { p.rect_filled(r, 4.0, t.pressed); @@ -1615,7 +1622,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc let hit = r.expand2(vec2(0.0, 2.0)); app.auto.add(&format!("timeline.renderBar.{i}"), hit, what); if ui.rect_contains_pointer(hit) { - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new(("rb", i)), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique(("rb", i)), egui::PopupAnchor::Pointer).show(|ui| { ui.label(what); }); } @@ -1635,7 +1642,7 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc let mr = marker_rect(layout, m.start); app.auto.add(&format!("timeline.marker.{}", m.id.0), mr, &m.name); if ui.rect_contains_pointer(mr) && !m.name.is_empty() { - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new(("mk", m.id.0)), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique(("mk", m.id.0)), egui::PopupAnchor::Pointer).show(|ui| { ui.label(&m.name); }); } @@ -1677,7 +1684,7 @@ fn zoom_scrollbar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, seq_seco } app.auto.add("timeline.zoomBar", thumb, "zoom scroll bar"); app.auto.add("timeline.zoomBar.track", bar, "zoom scroll bar track"); - let resp = ui.interact(bar, egui::Id::new("tl-zoombar"), Sense::click_and_drag()); + let resp = ui.interact(bar, egui::Id::unique("tl-zoombar"), Sense::click_and_drag()); let range = (total - vis).max(0.0); // a press beside the thumb brings the thumb's middle under the pointer (and a drag goes on // from there) @@ -1767,7 +1774,7 @@ fn vertical_scrollbar(ui: &mut egui::Ui, bar: Rect, value: &mut f32, max: f32, i p.rect_filled(track, track.width() / 2.0, t.separator); let hover = ui.rect_contains_pointer(thumb); p.rect_filled(thumb, thumb.width() / 2.0, if hover { t.scroll_thumb_hover } else { t.scroll_thumb }); - let resp = ui.interact(bar, egui::Id::new(("tl-vs", id)), Sense::drag()); + let resp = ui.interact(bar, egui::Id::unique(("tl-vs", id)), Sense::drag()); if resp.dragged() { let d = resp.drag_delta().y / (bar.height() - h).max(1.0) * max; *value = (*value + if invert { -d } else { d }).clamp(0.0, max); @@ -2256,7 +2263,7 @@ fn scrub_playhead(app: &mut FilmcraftApp, seq: &Sequence, layout: &Layout, p: Po fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &Layout, rect: Rect) { let ctx = ui.ctx().clone(); let area = Rect::from_min_max(pos2(layout.content.min.x, layout.ruler.min.y), layout.content.max); - let resp = ui.interact(area, egui::Id::new("timeline-area"), Sense::click_and_drag()); + let resp = ui.interact(area, egui::Id::unique("timeline-area"), Sense::click_and_drag()); let pos = resp.hover_pos().or(resp.interact_pointer_pos()); let mods = ctx.input(|i| i.modifiers); let tool = app.ui.tool; @@ -2806,11 +2813,11 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // ---- context menu on clips (right-clicking an unselected clip selects it first); on a clip's // edge, the edit point menu instead (right-clicking an unselected edit point selects it first) - let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target"); + let edit_point_menu_id = egui::Id::unique("timeline.editPointMenu.target"); // the clip right-clicked, remembered while its menu is open: Unlink leaves only it selected - let menu_clip_id = egui::Id::new("timeline.clipMenu.clip"); + let menu_clip_id = egui::Id::unique("timeline.clipMenu.clip"); // right-clicking empty time between clips selects the gap and opens its menu (#648, #668) - let gap_menu_id = egui::Id::new("timeline.gapMenu.open"); + let gap_menu_id = egui::Id::unique("timeline.gapMenu.open"); if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() { @@ -2825,7 +2832,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } // on a marker of the ruler, the marker menu instead (right-clicking a marker selects it first) - let marker_menu_id = egui::Id::new("timeline.markerMenu.target"); + let marker_menu_id = egui::Id::unique("timeline.markerMenu.target"); if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() { diff --git a/crates/ui-egui/src/panels/timeline_automation.rs b/crates/ui-egui/src/panels/timeline_automation.rs index 4e4ecc086..8b3766692 100644 --- a/crates/ui-egui/src/panels/timeline_automation.rs +++ b/crates/ui-egui/src/panels/timeline_automation.rs @@ -97,9 +97,16 @@ fn band(r: &Row) -> Rect { pub fn header_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, r: &Row, rect: Rect, visible: Rect, label: &str, t: &Tokens) { let Some(tr) = seq.track(r.track) else { return }; let showing = app.ui.timeline.track_lanes.get(&r.track.0).cloned(); - let resp = - crate::widgets::icon_toggle(ui, rect.intersect(visible), Icon::Keyframe, showing.is_some(), t, egui::Id::new(("lanebtn", r.track.0)), Some(LANE_COL)) - .on_hover_text(tl!("Show Keyframes")); + let resp = crate::widgets::icon_toggle( + ui, + rect.intersect(visible), + Icon::Keyframe, + showing.is_some(), + t, + egui::Id::unique(("lanebtn", r.track.0)), + Some(LANE_COL), + ) + .on_hover_text(tl!("Show Keyframes")); app.auto.add(&format!("timeline.track.{label}.keyframes"), rect, "Show Keyframes"); let opts = lane_options(tr); egui::Popup::menu(&resp).show(|ui| { @@ -131,7 +138,7 @@ struct KfDrag { } fn drag_id() -> egui::Id { - egui::Id::new("tl-lane-kf-drag") + egui::Id::unique("tl-lane-kf-drag") } /// Draw the shown lanes (after clips) and handle their editing (after the timeline's own interaction, @@ -173,7 +180,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // add keyframes: Pen tool, or ⌘-click with any tool let adding = app.ui.tool == Tool::Pen || ui.input(|i| i.modifiers.command); if adding { - let bresp = ui.interact(vis, egui::Id::new(("lane-band", r.track.0)), Sense::click()); + let bresp = ui.interact(vis, egui::Id::unique(("lane-band", r.track.0)), Sense::click()); if bresp.hovered() { ui.ctx().set_cursor_icon(CursorIcon::Crosshair); } @@ -199,7 +206,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & continue; } let hr = Rect::from_center_size(c, vec2(11.0, 11.0)); - let resp = ui.interact(hr, egui::Id::new(("lane-kf", r.track.0, i)), Sense::click_and_drag()); + let resp = ui.interact(hr, egui::Id::unique(("lane-kf", r.track.0, i)), Sense::click_and_drag()); let hot = resp.hovered() || dragging.is_some(); icons::paint(&p, Rect::from_center_size(c, vec2(9.0, 9.0)), Icon::Keyframe, if hot { Color32::WHITE } else { LANE_COL }); app.auto.add(&format!("timeline.track.{label}.lane.kf.{i}"), hr, &format!("{name} keyframe {v:.1}")); @@ -221,7 +228,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } if dragging.is_some() { let nv = denorm(tr, &key, ((b.max.y - c.y) / b.height()).clamp(0.0, 1.0)); - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("lane-kf-tip"), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("lane-kf-tip"), egui::PopupAnchor::Pointer).show(|ui| { ui.label(if key == LANE_VOLUME { format!("{nv:.2} dB") } else { format!("{nv:.2}") }); }); } diff --git a/crates/ui-egui/src/panels/timeline_captions.rs b/crates/ui-egui/src/panels/timeline_captions.rs index 6b93456ae..643286adb 100644 --- a/crates/ui-egui/src/panels/timeline_captions.rs +++ b/crates/ui-egui/src/panels/timeline_captions.rs @@ -35,7 +35,7 @@ enum Gesture { } fn drag_id() -> egui::Id { - egui::Id::new("caption-drag") + egui::Id::unique("caption-drag") } /// Live drag state: (caption, gesture, delta ticks). @@ -85,7 +85,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re app.auto.add(&format!("timeline.captionTrack.{label}"), badge, &tr.name); // right-click the header: delete this track or every empty caption track (registered // before the lock / eye toggles so those stay on top) - let hresp = ui.interact(hrect, egui::Id::new(("cap-header", tr.id.0)), Sense::click()); + let hresp = ui.interact(hrect, egui::Id::unique(("cap-header", tr.id.0)), Sense::click()); hresp.context_menu(|ui| { for (key, text, cmd, p) in [ ("delete", tl!("Delete Caption Track"), "captions.deleteTrack", json!({"track": tr.id.0})), @@ -107,7 +107,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re if tr.locked { Icon::Lock } else { Icon::Unlock }, true, t, - egui::Id::new(("cap-lock", tr.id.0)), + egui::Id::unique(("cap-lock", tr.id.0)), Some(if tr.locked { t.icon_active } else { t.text_dim }), ); app.auto.add(&format!("timeline.captionTrack.{label}.locked"), lock_r, "Toggle Caption Track Lock"); @@ -121,7 +121,7 @@ pub fn paint(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, area: Re if tr.enabled { Icon::Eye } else { Icon::EyeOff }, true, t, - egui::Id::new(("cap-eye", tr.id.0)), + egui::Id::unique(("cap-eye", tr.id.0)), Some(t.text_dim), ); app.auto.add(&format!("timeline.captionTrack.{label}.enabled"), eye_r, "Toggle Caption Track Output"); @@ -198,7 +198,7 @@ fn interact_block( actions: &mut Vec<(String, Value)>, ) { let ctx = ui.ctx().clone(); - let resp = ui.interact(r, egui::Id::new(("caption-block", c.id.0)), Sense::click_and_drag()); + let resp = ui.interact(r, egui::Id::unique(("caption-block", c.id.0)), Sense::click_and_drag()); let edge = 5.0f32.min(r.width() / 4.0); let gesture_at = |x: f32| { if x - r.min.x < edge { @@ -240,7 +240,7 @@ fn interact_block( Gesture::TrimIn => 1, Gesture::TrimOut => 2, }; - ctx.data_mut(|d| d.insert_temp(egui::Id::new("caption-drag-origin"), p.x)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("caption-drag-origin"), p.x)); ctx.data_mut(|d| d.insert_temp(drag_id(), (c.id.0, g, 0i64))); if !app.session.state.caption_selection.contains(&c.id) { actions.push(("captions.select".into(), json!({"captions": [c.id.0]}))); @@ -250,7 +250,7 @@ fn interact_block( && let (Some((id, g, _)), Some(p)) = (drag_state(&ctx), resp.interact_pointer_pos()) && id == c.id.0 { - let x0 = ctx.data(|d| d.get_temp::(egui::Id::new("caption-drag-origin"))).unwrap_or(p.x); + let x0 = ctx.data(|d| d.get_temp::(egui::Id::unique("caption-drag-origin"))).unwrap_or(p.x); let pps = app.ui.timeline.pps; let d = rate.snap_nearest(Tick::from_seconds_f64((p.x - x0) as f64 / pps)); ctx.data_mut(|dd| dd.insert_temp(drag_id(), (id, g, d.0))); diff --git a/crates/ui-egui/src/panels/timeline_volume.rs b/crates/ui-egui/src/panels/timeline_volume.rs index 3b904ffdf..e0b486ed7 100644 --- a/crates/ui-egui/src/panels/timeline_volume.rs +++ b/crates/ui-egui/src/panels/timeline_volume.rs @@ -191,7 +191,7 @@ fn run(app: &mut FilmcraftApp, acts: Vec<(&str, Value)>) { /// The level next to the pointer while dragging: one line, always as wide, so it does not jump. fn tip(ui: &egui::Ui, db: f64) { let text = if db <= FADER_MIN_DB { format!("{:>7} dB", "-∞") } else { format!("{db:>+7.2} dB") }; - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("tl-volume-tip"), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("tl-volume-tip"), egui::PopupAnchor::Pointer).show(|ui| { ui.add(egui::Label::new(egui::RichText::new(text).font(Tokens::mono(12.0))).wrap_mode(egui::TextWrapMode::Extend)); }); } @@ -206,7 +206,7 @@ struct KeyDrag { } fn key_drag_id() -> egui::Id { - egui::Id::new("tl-volume-key-drag") + egui::Id::unique("tl-volume-key-drag") } /// The keyframes on the lines of the audio clips in view: drag one in time and level, right-click @@ -256,7 +256,7 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layou continue; } let kr = Rect::from_center_size(c, vec2(11.0, 11.0)); - let resp = ui.interact(kr, egui::Id::new(("tl-volume-kf", it.id.0, i)), Sense::click_and_drag()); + let resp = ui.interact(kr, egui::Id::unique(("tl-volume-kf", it.id.0, i)), Sense::click_and_drag()); app.auto.add(&format!("timeline.clip.{}.volume.kf.{i}", it.id.0), kr, "Volume keyframe"); let mine = held.filter(|h| h.clip == it.id && h.index == i); if resp.hovered() || mine.is_some() { diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 34106c6b9..d55fccf58 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -11,7 +11,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let mut y = rect.min.y + 4.0; let size = 32.0; - let held_id = egui::Id::new("tools-held"); + let held_id = egui::Id::unique("tools-held"); if ui.input(|i| i.pointer.any_pressed()) { ui.data_mut(|d| d.remove::(held_id)); } @@ -23,8 +23,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } else { format!("{} ({})", app.ui.language.tr(current.label()), current.shortcut()) }; - let resp = ui.interact(r, egui::Id::new(("tool", format!("{:?}", group[0]))), Sense::click_and_drag()).on_hover_text(tip); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), app.ui.tool == current, current.label())); + let resp = ui.interact(r, egui::Id::unique(("tool", format!("{:?}", group[0]))), Sense::click_and_drag()).on_hover_text(tip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, resp.enabled(), app.ui.tool == current, current.label())); for tl in &group { if *tl == current { app.auto.add_response(&format!("tools.{tl:?}"), &resp, tl.label()); diff --git a/crates/ui-egui/src/panels/transform_handles.rs b/crates/ui-egui/src/panels/transform_handles.rs index c767e3ed3..1100eeaa4 100644 --- a/crates/ui-egui/src/panels/transform_handles.rs +++ b/crates/ui-egui/src/panels/transform_handles.rs @@ -398,7 +398,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra return; } let mut actions: Vec<(String, Value)> = Vec::new(); - let id = egui::Id::new("program-transform"); + let id = egui::Id::unique("program-transform"); let drag_id = id.with("drag"); let double = ui.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary)); // the monitor around the picture as well as on it (its clip reaches past the picture area) diff --git a/crates/ui-egui/src/panels/transition_controls.rs b/crates/ui-egui/src/panels/transition_controls.rs index dcea6ad38..41cec06b7 100644 --- a/crates/ui-egui/src/panels/transition_controls.rs +++ b/crates/ui-egui/src/panels/transition_controls.rs @@ -179,7 +179,7 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id changes.push(c); } let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y)); - let dresp = ui.interact(divider, egui::Id::new("tr-ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + let dresp = ui.interact(divider, egui::Id::unique("tr-ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); if dresp.dragged() { app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list); } @@ -252,7 +252,7 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio let (_, cut, _) = trn::bounds(track, x)?; let clip_a = x.from.and_then(|c| track.item(c)).map(|it| (it.start, it.end(), it.name.clone())); let clip_b = x.to.and_then(|c| track.item(c)).map(|it| (it.start, it.end(), it.name.clone())); - let mem = egui::Id::new(("tr-ec-lane-drag", id.0)); + let mem = egui::Id::unique(("tr-ec-lane-drag", id.0)); let mut drag: Option = ui.data(|d| d.get_temp(mem)); let v = drag.map_or_else(|| view(x, cut, frame), |d| d.view); let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); @@ -311,7 +311,7 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio elems.push(("effectControls.transition.timeline.ruler".into(), ruler, "time ruler".into())); // the ruler moves the playhead (once the sequence is no longer borrowed, below) let mut seek = None; - let rresp = ui.interact(ruler, egui::Id::new(("tr-ec-ruler", id.0)), Sense::click_and_drag()); + let rresp = ui.interact(ruler, egui::Id::unique(("tr-ec-ruler", id.0)), Sense::click_and_drag()); if (rresp.dragged() || rresp.clicked()) && let Some(pos) = rresp.interact_pointer_pos() { @@ -327,7 +327,7 @@ fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: Transitio None } }; - let sresp = ui.interact(span, egui::Id::new(("tr-ec-span", id.0)), Sense::click_and_drag()); + let sresp = ui.interact(span, egui::Id::unique(("tr-ec-span", id.0)), Sense::click_and_drag()); if let Some(h) = sresp.hover_pos() { ui.ctx().set_cursor_icon(if grab(h.x).is_some() { egui::CursorIcon::ResizeColumn } else { egui::CursorIcon::Grab }); } @@ -426,7 +426,7 @@ fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKin } (ParamKind::Bool, ParamValue::Bool(b)) => { let mut b = *b; - let r = ui.checkbox(&mut b, ""); + let r = crate::widgets::accessible_name(ui.checkbox(&mut b, ""), eid.clone()); elems.push((eid, r.rect, b.to_string())); r.changed().then(|| json!({"params": { pid: b }})) } @@ -444,7 +444,8 @@ fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKin } }); elems.extend(options); - elems.push((eid, r.response.rect, shown.to_string())); + let response = crate::widgets::accessible_name(r.response, eid.clone()); + elems.push((eid, response.rect, shown.to_string())); picked.map(|i| json!({"params": { pid: i }})) } (ParamKind::Color, ParamValue::Color(c)) => { diff --git a/crates/ui-egui/src/panels/trim_monitor.rs b/crates/ui-egui/src/panels/trim_monitor.rs index ba65b9474..9522feca0 100644 --- a/crates/ui-egui/src/panels/trim_monitor.rs +++ b/crates/ui-egui/src/panels/trim_monitor.rs @@ -5,7 +5,7 @@ //! outgoing side, in the incoming picture the incoming side, between them rolls. //! //! J/K/L and Space are routed to dynamic trimming / loop playback in `menus::invoke`; the engine -//! state machine (`filmcraft_engine::trim`) is advanced here once per frame with the UI clock. +//! state machine (`filmcraft_engine::trim`) is advanced from logic with the monotonic host clock. //! //! Automation ids: `trimMonitor.outgoing`, `trimMonitor.incoming`, `trimMonitor.roll`, //! `trimMonitor.outTimecode`, `trimMonitor.inTimecode`, `trimMonitor.outShift`, @@ -35,21 +35,21 @@ pub fn trim_mode(app: &FilmcraftApp) -> bool { !app.session.state.edit_points.is_empty() && app.ui.focused != crate::dock::PanelKind::Source } -/// Advance dynamic trimming / loop playback to the current UI time (once per frame). -pub fn advance(app: &mut FilmcraftApp, ctx: &egui::Context) { +/// Advance dynamic trimming / loop playback to a monotonic host time. Called from logic; a +/// repeated call at the same time is idempotent because `trim.tick` receives an absolute clock. +pub fn advance_at(app: &mut FilmcraftApp, clock: f64) { if !app.session.trim_play.active() { return; } - let clock = ctx.input(|i| i.time); if let Err(e) = app.session.execute("trim.tick", json!({"clock": clock})) { app.ui.status = e.to_string(); } - ctx.request_repaint(); } /// Route a transport command in trim mode (J/K/L/Space/Shift+K). `None` = not handled. pub fn route_transport(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str) -> Option> { - let clock = ctx.input(|i| i.time); + let _ = ctx; + let clock = app.now(); let trimming = trim_mode(app); let (cmd, params) = match id { "playback.forward" if trimming => ("trim.shuttle", json!({"direction": "forward", "clock": clock})), @@ -239,7 +239,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // drag to trim: px → frames (6 px per frame), applied on release as one trim let fpp = 1.0 / 6.0; for (r, which) in [(lp, "out"), (rp, "in"), (mid, "roll")] { - let resp = ui.interact(r, egui::Id::new(("trim-monitor-drag", which)), Sense::drag()); + let resp = ui.interact(r, egui::Id::unique(("trim-monitor-drag", which)), Sense::drag()); if resp.dragged() || resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeColumn); } @@ -248,7 +248,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // the same one data_mut() holds, and that lock is not reentrant (deadlock) let dx = resp.drag_delta().x; let total = ui.ctx().data_mut(|d| { - let v = d.get_temp_mut_or_default::(egui::Id::new("trim-monitor-dx")); + let v = d.get_temp_mut_or_default::(egui::Id::unique("trim-monitor-dx")); *v += dx; *v }); @@ -256,7 +256,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().text(pos2(r.center().x, r.max.y - 14.0), Align2::CENTER_CENTER, shift_text(n), Tokens::semibold(14.0), col); } if resp.drag_stopped() { - let total = ui.ctx().data_mut(|d| d.remove_temp::(egui::Id::new("trim-monitor-dx")).unwrap_or(0.0)); + let total = ui.ctx().data_mut(|d| d.remove_temp::(egui::Id::unique("trim-monitor-dx")).unwrap_or(0.0)); let n = (total * fpp).round() as i64; // egui ends a drag on Escape: that stop abandons it (#580) if n != 0 && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { @@ -295,10 +295,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ]; let total: f32 = buttons.iter().map(|b| b.3 + 4.0).sum(); let mut x = bar.center().x - total / 2.0; - let ctx = ui.ctx().clone(); for (id, label, tip, w) in buttons { let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(w, bar.height())); - let resp = ui.interact(r, egui::Id::new(id), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(id), Sense::click()).on_hover_text(tip); let bg = if resp.is_pointer_button_down_on() { t.pressed } else if resp.hovered() { @@ -316,7 +315,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(id, r, tip); if resp.clicked() { let res = match id { - "trimMonitor.playAround" => app.session.execute("trim.playAround", json!({"clock": ctx.input(|i| i.time), "toggle": true})), + "trimMonitor.playAround" => app.session.execute("trim.playAround", json!({"clock": app.now(), "toggle": true})), "trimMonitor.backwardMany" => app.session.execute("trim.backwardMany", json!({})), "trimMonitor.backward" => app.session.execute("trim.backward", json!({})), "trimMonitor.forward" => app.session.execute("trim.forward", json!({})), @@ -331,7 +330,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } // exit trim mode let r = Rect::from_min_size(pos2(rect.max.x - 30.0, bar.min.y + 3.0), vec2(22.0, 22.0)); - let resp = ui.interact(r, egui::Id::new("trimMonitor.exit"), Sense::click()).on_hover_text(tl!("Exit Trim Mode")); + let resp = ui.interact(r, egui::Id::unique("trimMonitor.exit"), Sense::click()).on_hover_text(tl!("Exit Trim Mode")); let c = if resp.hovered() { t.tab_text_active } else { t.text_dim }; ui.painter().line_segment([r.min + vec2(6.0, 6.0), r.max - vec2(6.0, 6.0)], Stroke::new(1.5, c)); ui.painter().line_segment([pos2(r.max.x - 6.0, r.min.y + 6.0), pos2(r.min.x + 6.0, r.max.y - 6.0)], Stroke::new(1.5, c)); diff --git a/crates/ui-egui/src/panels/tts.rs b/crates/ui-egui/src/panels/tts.rs index 8bc12e7cb..8e00bcfba 100644 --- a/crates/ui-egui/src/panels/tts.rs +++ b/crates/ui-egui/src/panels/tts.rs @@ -329,7 +329,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(Action::Run("tts.preview", json!({"sample": true, "voice": d.voice, "pitch": d.pitch.id(), "pace": d.pace}), true)); } ui.add_space(8.0); - let (hr, open) = crate::widgets::section_header(ui, egui::Id::new("tts-advanced"), "Advanced", d.advanced_open, &t, true); + let (hr, open) = crate::widgets::section_header(ui, egui::Id::unique("tts-advanced"), "Advanced", d.advanced_open, &t, true); auto.add("tts.advanced", visible(ui, hr.rect), "Advanced"); d.advanced_open = open; if open { @@ -378,7 +378,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { .desired_width(w) .desired_rows(8) .char_limit(filmcraft_tts::MAX_TEXT_BYTES); - let r = ui.add(te); + let r = crate::widgets::accessible_name(ui.add(te), "Text"); auto.add("tts.text", visible(ui, r.rect), "Text"); }); @@ -445,7 +445,7 @@ fn label(ui: &mut egui::Ui, t: &Tokens, text: &str) { #[allow(clippy::too_many_arguments)] fn button(ui: &mut egui::Ui, t: &Tokens, r: Rect, icon: Icon, text: &str, primary: bool, active: bool, id: &str) -> egui::Response { - let resp = ui.interact(r, egui::Id::new(id), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(id), Sense::click()); let p = ui.painter(); let (bg, fg) = match (primary, active) { (true, true) => (if resp.hovered() { t.accent_hover } else { t.accent }, egui::Color32::WHITE), diff --git a/crates/ui-egui/src/panels/voiceover.rs b/crates/ui-egui/src/panels/voiceover.rs index 1a80990f4..deb3080e3 100644 --- a/crates/ui-egui/src/panels/voiceover.rs +++ b/crates/ui-egui/src/panels/voiceover.rs @@ -38,13 +38,13 @@ struct Draft { } fn draft_id() -> egui::Id { - egui::Id::new("voiceover-settings-draft") + egui::Id::unique("voiceover-settings-draft") } /// The microphone button of an audio track header. Returns the requested action. pub fn header_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, track: TrackId, r: Rect, visible: Rect, label: &str, t: &Tokens) -> Option { let recording_here = app.session.voiceover.rec.as_ref().is_some_and(|x| x.track == track); - let resp = ui.interact(r.intersect(visible), egui::Id::new(("vo", track.0)), Sense::click()); + let resp = ui.interact(r.intersect(visible), egui::Id::unique(("vo", track.0)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(r, 2.0, t.hover); } @@ -220,7 +220,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { tl!("● Recording").into() }; let r = Rect::from_center_size(pos2(screen.center().x, screen.min.y + 90.0), vec2(220.0, 64.0)); - egui::Area::new(egui::Id::new("voiceover-countdown")).order(egui::Order::Foreground).fixed_pos(r.min).interactable(false).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("voiceover-countdown")).order(egui::Order::Foreground).fixed_pos(r.min).interactable(false).show(ctx, |ui| { let p = ui.painter(); p.rect_filled(r, 8.0, Color32::from_black_alpha(180)); p.rect_stroke(r, 8.0, Stroke::new(2.0, RED), egui::StrokeKind::Inside); @@ -232,18 +232,18 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { fn settings_dialog(app: &mut FilmcraftApp, ctx: &egui::Context) { let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) else { return }; - crate::widgets::revert_drag_on_escape(ctx, egui::Id::new("voiceover-settings-before-drag"), &mut d); + crate::widgets::revert_drag_on_escape(ctx, egui::Id::unique("voiceover-settings-before-drag"), &mut d); let mut elems: Vec<(String, Rect, String)> = Vec::new(); let mut close = false; let mut apply = false; let mut discard = false; let accent = app.tokens.accent; // the title is translated; the window keeps one id whatever the interface language - let id = egui::Id::new("voiceover-settings"); + let id = egui::Id::unique("voiceover-settings"); egui::Window::new(tl!("Voice-Over Record Settings")).id(id).collapsible(false).resizable(false).centered(ctx).show(ctx, |ui| { egui::Grid::new("voiceover-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Name:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.prefs.name).desired_width(220.0)); + let r = crate::widgets::accessible_name(ui.add(egui::TextEdit::singleline(&mut d.prefs.name).desired_width(220.0)), "Name"); elems.push(("voiceover.name".into(), r.rect, d.prefs.name.clone())); ui.end_row(); ui.label(tl!("Source:")); diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index 5678bd171..9a9b031a6 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -181,7 +181,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { WorkspaceDialog::Edit { .. } => ("Edit Workspaces", tl!("Edit Workspaces")), }; egui::Window::new(shown) - .id(egui::Id::new(title)) + .id(egui::Id::unique(title)) .collapsible(false) .resizable(false) .pivot(Align2::CENTER_CENTER) @@ -218,7 +218,7 @@ pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) { let own = sel.as_deref().is_some_and(|s| !is_builtin(s)); ui.horizontal(|ui| { ui.label(tl!("Name:")); - let r = ui.add_enabled(own, egui::TextEdit::singleline(name)); + let r = crate::widgets::accessible_name(ui.add_enabled(own, egui::TextEdit::singleline(name)), "Name"); push("workspaces.edit.name", &r, "Name"); let r = ui.add_enabled(own && sel.as_deref() != Some(name.trim()), egui::Button::new(tl!("Rename"))); push("workspaces.edit.rename", &r, "Rename"); diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index 7b71769cf..cf927f8e2 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -4,21 +4,16 @@ //! last fallbacks of every UI font family. A build without them looks up one installed face per //! script once (Noto Sans Arabic, SF Arabic, Segoe UI…) and reuses it across theme changes. //! -//! Reading order: egui shapes each font run with harfrust, so a word's letters join and run right -//! to left, but it places the runs left to right in logical order with no bidi pass: in -//! "كلمة أخرى" the second word comes out first. [`Lines`] lays out document text (captions) in -//! reading order for display. Lines are broken on the logical text as egui wraps it, each line's -//! runs are put in visual order by the Unicode Bidirectional Algorithm (UAX #9, as the text engine -//! does for titles), and every right-to-left word becomes its own layout section, which egui's -//! shaper draws right to left. Text being edited keeps egui's logical order: `TextEdit` maps the -//! caret by character index, so a reordered galley would put it in the wrong place. +//! Reading order: the egui 0.37 fork performs UAX #9 shaping, run ordering and bracket mirroring. +//! [`Lines`] lays out document text (captions) with one logical `LayoutJob` and +//! `TextDirection::Auto`, so the text engine produces visual order itself. Keeping the old +//! word-by-word reversal would reverse Arabic/Hebrew twice. Text being edited keeps egui's logical +//! order: `TextEdit` maps the caret by character index. -use std::borrow::Cow; -use std::ops::Range; use std::sync::{Arc, OnceLock}; -use egui::epaint::text::{ByteIndex, LayoutJob, LayoutSection, TextFormat}; -use egui::{Color32, FontData, FontDefinitions, FontId, Galley, Painter, Rect, Vec2, pos2}; +use egui::epaint::text::{LayoutJob, TextDirection, TextFormat}; +use egui::{Align, Color32, FontData, FontDefinitions, FontId, Galley, Painter, Rect, Vec2, pos2}; use filmcraft_text::fonts::{CraftFont, Face}; use unicode_bidi::{BidiClass, BidiInfo, bidi_class}; @@ -89,7 +84,7 @@ fn system_fonts() -> &'static [(String, Arc)] { let Some(data) = face.data() else { continue; }; - let font = FontData { font: Cow::Owned(data), index: face.info.index, tweak: Default::default() }; + let font = FontData::from_blob(Arc::new(data), face.info.index); out.push((format!("system-{}", s.code.to_lowercase()), Arc::new(font))); chosen.push(face); } @@ -133,116 +128,7 @@ pub(crate) fn has_rtl(text: &str) -> bool { text.chars().any(|c| matches!(bidi_class(c), BidiClass::R | BidiClass::AL)) } -/// Characters that stay together, in logical order, in a right-to-left word: letters, marks and -/// joiners. egui shapes such a word as one right-to-left run. -fn in_word(c: char) -> bool { - matches!(bidi_class(c), BidiClass::R | BidiClass::AL | BidiClass::NSM | BidiClass::BN) -} - -/// A bracket shown right to left takes its mirrored glyph (UAX #9 rule L4). -fn mirror(c: char) -> char { - match c { - '(' => ')', - ')' => '(', - '[' => ']', - ']' => '[', - '{' => '}', - '}' => '{', - '<' => '>', - '>' => '<', - '«' => '»', - '»' => '«', - '‹' => '›', - '›' => '‹', - _ => c, - } -} - -/// The layout sections of one line of `bidi.text` (byte range `line`, inside one paragraph), left -/// to right. A left-to-right run stays whole, except that Arabic-Indic digits get a section each -/// so the shaper cannot run them right to left. A right-to-left run is split into words -/// ([`in_word`]) and single other characters (mirrored), listed in reverse: egui shapes each word -/// right to left itself. -fn line_sections(bidi: &BidiInfo<'_>, line: Range) -> Vec { - let text = bidi.text; - let Some(whole) = text.get(line.clone()).filter(|s| !s.is_empty()) else { - return Vec::new(); - }; - let Some(para) = bidi.paragraphs.iter().find(|p| p.range.start <= line.start && line.end <= p.range.end) else { - return vec![whole.to_owned()]; - }; - let (levels, runs) = bidi.visual_runs(para, line); - let mut out = Vec::new(); - for run in runs { - let Some(s) = text.get(run.clone()) else { - continue; - }; - if levels.get(run.start).is_some_and(|l| l.is_rtl()) { - let mut units = Vec::new(); - let mut word = String::new(); - for c in s.chars() { - if in_word(c) { - word.push(c); - continue; - } - if !word.is_empty() { - units.push(std::mem::take(&mut word)); - } - units.push(mirror(c).to_string()); - } - if !word.is_empty() { - units.push(word); - } - out.extend(units.into_iter().rev()); - } else { - let mut plain = String::new(); - for c in s.chars() { - if bidi_class(c) == BidiClass::AN { - if !plain.is_empty() { - out.push(std::mem::take(&mut plain)); - } - out.push(c.to_string()); - } else { - plain.push(c); - } - } - if !plain.is_empty() { - out.push(plain); - } - } - } - out -} - -/// Byte ranges of the lines egui broke `text` into in `galley` (newlines left out), or None when -/// the rows do not account for every character. -fn line_ranges(text: &str, galley: &Galley) -> Option>> { - let bounds: Vec = text.char_indices().map(|(i, _)| i).chain(std::iter::once(text.len())).collect(); - let mut out = Vec::with_capacity(galley.rows.len()); - let mut at = 0usize; - for row in &galley.rows { - let end = at.checked_add(row.glyphs.len())?; - out.push(*bounds.get(at)?..*bounds.get(end)?); - at = end.checked_add(usize::from(row.ends_with_newline))?; - } - (at == bounds.len().saturating_sub(1)).then_some(out) -} - -/// Byte ranges of the lines of `text` between newlines. -fn paragraph_ranges(text: &str) -> Vec> { - let mut out = Vec::new(); - let mut start = 0; - for (i, c) in text.char_indices() { - if c == '\n' { - out.push(start..i); - start = i + 1; - } - } - out.push(start..text.len()); - out -} - -/// Document text laid out for display in reading order, one galley per line. +/// Document text laid out for display in reading order by the fork's bidi pass. pub(crate) struct Lines { rows: Vec>, /// The text starts right to left (its first strong letter is Arabic or Hebrew): lines align right. @@ -254,33 +140,19 @@ impl Lines { /// Lay out `text` in `font`, wrapped at `wrap_width` (`f32::INFINITY`: only at `\n`). Text /// without right-to-left letters is laid out as egui always does, in one galley. pub fn new(painter: &Painter, text: &str, font: FontId, color: Color32, wrap_width: f32) -> Self { - let logical = painter.layout(text.to_owned(), font.clone(), color, wrap_width); - if !has_rtl(text) { - return Self { size: logical.size(), rows: vec![logical], rtl: false }; - } - let bidi = BidiInfo::new(text, None); - let rtl = bidi.paragraphs.first().is_some_and(|p| p.level.is_rtl()); - let lines = line_ranges(text, &logical).unwrap_or_else(|| paragraph_ranges(text)); - let rows: Vec> = lines - .into_iter() - .map(|line| { - let mut job = LayoutJob::default(); - for s in line_sections(&bidi, line) { - let start = job.text.len(); - job.text.push_str(&s); - // pushed, not appended: `append` merges sections of equal format, and each - // right-to-left word must be shaped on its own - job.sections.push(LayoutSection { - leading_space: 0.0, - byte_range: ByteIndex(start)..ByteIndex(job.text.len()), - format: TextFormat::simple(font.clone(), color), - }); - } - if job.sections.is_empty() { painter.layout_no_wrap(String::new(), font.clone(), color) } else { painter.layout_job(job) } - }) - .collect(); - let size = rows.iter().fold(Vec2::ZERO, |s, g| Vec2::new(s.x.max(g.size().x), s.y + g.size().y)); - Self { rows, rtl, size } + // The egui fork performs UAX #9 paragraph direction, bidi runs, visual ordering and + // bracket mirroring itself (`TextDirection::Auto`). Reversing words here as well would + // reverse them twice; document text keeps one logical section and one layout job. + let rtl = has_rtl(text) && BidiInfo::new(text, None).paragraphs.first().is_some_and(|p| p.level.is_rtl()); + let mut job = LayoutJob::default(); + job.wrap.max_width = wrap_width; + job.direction = TextDirection::Auto; + job.halign = if rtl { Align::RIGHT } else { Align::LEFT }; + job.halign_min_is_start = true; + job.append(text, 0.0, TextFormat::simple(font, color)); + let galley = painter.layout_job(job); + let size = galley.size(); + Self { rows: vec![galley], rtl, size } } /// Paint the lines down from the top of `rect`: from its left edge, or from its right edge when @@ -307,60 +179,43 @@ pub(crate) fn paint_line(painter: &Painter, rect: Rect, text: &str, font: FontId mod tests { use super::*; - /// The sections of each line of `text` (split at newlines), left to right. - fn visual(text: &str) -> Vec> { - let bidi = BidiInfo::new(text, None); - paragraph_ranges(text).into_iter().map(|l| line_sections(&bidi, l)).collect() + fn drawn_chars(row: &egui::epaint::text::Row) -> Vec { + row.visual_order().map(|i| row.glyphs[i].chr).filter(|c| !c.is_control()).collect() } #[test] - fn rtl_words_are_listed_last_word_first() { - assert_eq!(visual("كلمة أخرى"), [["أخرى", " ", "كلمة"]]); - assert_eq!(visual("שלום עולם"), [["עולם", " ", "שלום"]]); + fn rtl_paragraph_is_flagged_and_visually_ordered() { + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let painter = Painter::new(ctx.clone(), egui::LayerId::background(), Rect::EVERYTHING); + let lines = Lines::new(&painter, "كلمة أخرى", FontId::proportional(13.0), Color32::WHITE, f32::INFINITY); + assert!(lines.rtl); + let row = lines.rows.first().and_then(|g| g.rows.first()).expect("one row"); + assert!(row.rtl_paragraph); + let order: String = drawn_chars(row).into_iter().collect(); + // Glyphs retain logical characters; the visual iterator walks them left to right. + // Both Arabic words therefore appear reversed in this diagnostic string. + assert_eq!(order, "ىرخأ ةملك", "the second logical word must draw first"); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); } #[test] - fn marks_and_joiners_stay_in_their_word() { - // fatha, shadda and a zero-width non-joiner belong to the letters around them - assert_eq!(visual("كَتَبَ مُحَمَّد"), [["مُحَمَّد", " ", "كَتَبَ"]]); - assert_eq!(visual("می\u{200c}خواهم بود"), [["بود", " ", "می\u{200c}خواهم"]]); - } - - #[test] - fn latin_and_numbers_inside_rtl_keep_their_order() { - assert_eq!(visual("قال FilmCraft 0.6 جيد"), [["جيد", " ", "FilmCraft 0.6", " ", "قال"]]); - // Arabic-Indic digits read left to right, one section each so the shaper keeps them so - assert_eq!(visual("سنة ١٤٤٥"), [["١", "٤", "٤", "٥", " ", "سنة"]]); - } - - #[test] - fn rtl_punctuation_and_brackets() { - assert_eq!(visual("نعم، (نص)"), [["(", "نص", ")", " ", "،", "نعم"]]); - assert_eq!(visual("لماذا؟"), [["لماذا؟"]]); - } - - #[test] - fn ltr_paragraph_with_an_rtl_phrase() { - assert_eq!(visual("Lesson كلمة أخرى end"), [["Lesson ", "أخرى", " ", "كلمة", " end"]]); - assert!(!has_rtl("Lesson 3: (draft)")); + fn ltr_paragraph_with_an_rtl_phrase_keeps_its_direction() { + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let painter = Painter::new(ctx.clone(), egui::LayerId::background(), Rect::EVERYTHING); + let lines = Lines::new(&painter, "Lesson كلمة أخرى end", FontId::proportional(13.0), Color32::WHITE, f32::INFINITY); + assert!(!lines.rtl); assert!(has_rtl("Lesson كلمة")); + let row = lines.rows.first().and_then(|g| g.rows.first()).expect("one row"); + assert!(!row.rtl_paragraph); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); } #[test] - fn each_line_is_reordered_on_its_own() { - assert_eq!(visual("سطر أول\nسطر ثان"), [["أول", " ", "سطر"], ["ثان", " ", "سطر"]]); - assert_eq!(visual("نص\n\nEnglish"), [vec!["نص"], vec![], vec!["English"]]); - } - - #[test] - fn hostile_ranges_give_nothing() { - let text = "كلمة"; - let bidi = BidiInfo::new(text, None); - assert!(line_sections(&bidi, 1..3).is_empty(), "not on a char boundary"); - assert!(line_sections(&bidi, 0..99).is_empty(), "past the end"); - #[allow(clippy::reversed_empty_ranges)] - let backwards = 4..2; - assert!(line_sections(&bidi, backwards).is_empty()); + fn hostile_ranges_do_not_panic() { + assert!(!has_rtl("Lesson 3: (draft)")); + assert!(has_rtl("كلمة")); } /// With an Arabic face installed, captions lay out with real glyphs in reading order: in @@ -380,36 +235,16 @@ mod tests { let font = FontId::proportional(13.0); let lines = Lines::new(&painter, "كلمة أخرى", font.clone(), Color32::WHITE, f32::INFINITY); assert!(lines.rtl); - let row = lines.rows.first().and_then(|g| g.rows.first()).map(|r| r.glyphs.clone()).unwrap_or_default(); - // egui shapes each word on its own (letters joined, in visual order) and pads the row with - // empty glyphs so every char keeps a cursor position: only drawn glyphs count here - let drawn: Vec<_> = row.iter().filter(|g| !g.uv_rect.is_nothing()).collect(); - let order = drawn.iter().map(|g| g.chr).collect::(); + let row = lines.rows.first().and_then(|g| g.rows.first()).expect("one row"); + let drawn: Vec<_> = row.visual_order().map(|i| &row.glyphs[i]).filter(|g| !g.uv_rect.is_nothing()).collect(); + assert!(!drawn.is_empty(), "Arabic text must draw some glyphs"); let xs = |word: &str| drawn.iter().filter(|g| word.contains(g.chr)).map(|g| g.pos.x).collect::>(); let (first, second) = (xs("كلمة"), xs("أخرى")); - assert!(!first.is_empty() && !second.is_empty(), "{order:?}"); - assert!(second.iter().fold(f32::MIN, |a, &b| a.max(b)) < first.iter().fold(f32::MAX, |a, &b| a.min(b)), "{order:?}"); - let leftmost = drawn.iter().min_by(|a, b| a.pos.x.total_cmp(&b.pos.x)).map(|g| g.chr); - assert_eq!(leftmost, Some('ى'), "{order:?}"); + assert!(!first.is_empty() && !second.is_empty()); + assert!(second.iter().fold(f32::MIN, |a, &b| a.max(b)) < first.iter().fold(f32::MAX, |a, &b| a.min(b))); let missing = painter.layout_no_wrap("\u{fffd}".into(), font, Color32::WHITE); let missing_uv = missing.rows.first().and_then(|r| r.glyphs.first()).map(|g| g.uv_rect); assert!(drawn.iter().all(|g| Some(g.uv_rect) != missing_uv), "an Arabic letter drew as a missing glyph"); ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); } - - /// Lines broken by wrapping are each reordered: the first line holds the first words. - #[test] - fn line_ranges_follow_the_rows() { - let ctx = egui::Context::default(); - ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); - let painter = Painter::new(ctx.clone(), egui::LayerId::background(), Rect::EVERYTHING); - let text = "one two three\nfour"; - let galley = painter.layout(text.to_owned(), FontId::proportional(13.0), Color32::WHITE, 40.0); - let ranges = line_ranges(text, &galley).unwrap_or_default(); - assert!(ranges.len() >= 3, "{ranges:?}"); - let joined: Vec<&str> = ranges.iter().filter_map(|r| text.get(r.clone())).collect(); - assert_eq!(joined.concat().replace(' ', ""), "onetwothreefour"); - assert_eq!(ranges.last().and_then(|r| text.get(r.clone())), Some("four")); - ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); - } } diff --git a/crates/ui-egui/src/scrub.rs b/crates/ui-egui/src/scrub.rs index 392ce7866..714e9731c 100644 --- a/crates/ui-egui/src/scrub.rs +++ b/crates/ui-egui/src/scrub.rs @@ -51,7 +51,7 @@ impl ScrubAudio { impl FilmcraftApp { /// Once a frame: play a grain when the playhead moved under a held mouse button. pub(crate) fn scrub_audio(&mut self, ctx: &egui::Context) { - let (now, down) = ctx.input(|i| (i.time, i.pointer.any_down())); + let (now, down) = (self.now(), ctx.input(|i| i.pointer.any_down())); if self.scrub_tick(now, down) { ctx.request_repaint_after(std::time::Duration::from_secs_f64(LINGER_S)); } diff --git a/crates/ui-egui/src/source_playback.rs b/crates/ui-egui/src/source_playback.rs index df1054f69..e8d20b986 100644 --- a/crates/ui-egui/src/source_playback.rs +++ b/crates/ui-egui/src/source_playback.rs @@ -213,7 +213,7 @@ impl FilmcraftApp { self.ui.status = error; return; } - let now = ctx.input(|i| i.time); + let now = self.now(); let time = self.session.state.source_playhead; if time != self.source_playback.shown { self.source_playback.clock.anchor_tick = time; @@ -257,7 +257,7 @@ impl FilmcraftApp { if let Some(audio) = self.audio.as_mut() { audio.stop(); } - self.ui.status = "Source audio output stopped responding: playing without sound (check Settings ▸ Audio Hardware)".into(); + self.ui.status = "Source audio output stopped responding: playing without sound (check Settings > Audio Hardware)".into(); } let elapsed = match reading { Some((frames, sr)) if clock.audio_clock && sr > 0 => frames as f64 / f64::from(sr), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e0710236c..c45dc9785 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -942,6 +942,14 @@ pub struct GfxEdit { pub layer: usize, pub caret: usize, pub anchor: usize, + /// Stable layer uid (`0` = the project layer has none). + #[serde(default)] + pub uid: u64, + /// Compact identity of a uid-less layer, so the edit target follows its layer through a + /// reorder, a deletion below it, or undo/redo instead of editing whatever moves into the + /// numeric index. + #[serde(default)] + pub fingerprint: u64, } fn shown() -> bool { diff --git a/crates/ui-egui/src/theme_favorites.rs b/crates/ui-egui/src/theme_favorites.rs index e797df9fd..563e08f19 100644 --- a/crates/ui-egui/src/theme_favorites.rs +++ b/crates/ui-egui/src/theme_favorites.rs @@ -1,7 +1,7 @@ //! Original theme favorite star artwork, MIT OR Apache-2.0. pub(crate) fn star(ui: &mut egui::Ui, favorite: bool, visible: bool, accent: egui::Color32, label: &str) -> egui::Response { let (rect, response) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); if visible { let color = if favorite { accent } else { ui.visuals().weak_text_color() }; let center = rect.center(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f40ec0365..f9caa667b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -6,16 +6,45 @@ use filmcraft_time::{FrameRate, Tick, parse_timecode}; use crate::icons::{self, Icon}; use crate::theme::Tokens; +/// Give a widget an explicit accessible name without changing its visible hint text. +/// +/// `egui_kittest` 0.37 hard-fails visible input roles that have neither a label nor a placeholder. +/// Text fields that sit beside a drawn label (or that have no room for a placeholder) still need a +/// stable name for screen readers and `get_by_label`. +pub fn accessible_name(response: Response, name: impl Into) -> Response { + let name = name.into(); + if response.ctx.has_accesskit_node(response.id) { + response.ctx.accesskit_node_builder(response.id, |node| { + if node.label().is_none_or(|label| label.trim().is_empty()) { + node.set_label(name); + } + }); + } + response +} + /// Premiere "hot text": a blue number you drag horizontally to scrub, click to type a number or a -/// calculation ([`crate::calc`]). +/// calculation ([`crate::calc`]). `label` is the property's accessible name (for example +/// "Position X"), used when the field switches to a text editor so screen readers can tell the +/// value fields apart. /// Returns (response, Some(new value) when changed). -pub fn hot_number(ui: &mut Ui, id: egui::Id, value: f64, speed: f64, range: (f64, f64), decimals: usize, suffix: &str, t: &Tokens) -> (Response, Option) { +pub fn hot_number( + ui: &mut Ui, + label: &str, + id: egui::Id, + value: f64, + speed: f64, + range: (f64, f64), + decimals: usize, + suffix: &str, + t: &Tokens, +) -> (Response, Option) { let editing_id = id.with("editing"); let edit_id = id.with("edit"); let select_all_id = id.with("select-all"); let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); if let Some(buf) = editing.as_mut() { - let r = ui.add(egui::TextEdit::singleline(buf).id(edit_id).desired_width(56.0).font(Tokens::ui(12.0))); + let r = accessible_name(ui.add(egui::TextEdit::singleline(buf).id(edit_id).desired_width(56.0).font(Tokens::ui(12.0))), label); let mut out = None; if r.lost_focus() { // the field's own unit can be typed anywhere; calc ignores the common ones @@ -109,7 +138,8 @@ pub fn timecode_field( st.store(ui.ctx(), edit_id); } } - let r = ui.put(rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(Tokens::timecode()).desired_width(rect.width())); + let r = + accessible_name(ui.put(rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(Tokens::timecode()).desired_width(rect.width())), "Timecode"); if r.lost_focus() { ui.data_mut(|d| d.remove::(editing_id)); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { @@ -272,7 +302,10 @@ pub fn search_field(ui: &mut Ui, text: &mut String, hint: &str, width: f32, t: & icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(12.0, 12.0)), Icon::Search, t.text_dim); let inner = Rect::from_min_max(pos2(rect.min.x + 22.0, rect.min.y + 2.0), pos2(rect.max.x - 8.0, rect.max.y - 2.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner)); - let resp = child.add(egui::TextEdit::singleline(text).hint_text(hint).frame(egui::Frame::NONE).desired_width(inner.width()).font(Tokens::ui(12.0))); + let resp = accessible_name( + child.add(egui::TextEdit::singleline(text).hint_text(hint).frame(egui::Frame::NONE).desired_width(inner.width()).font(Tokens::ui(12.0))), + hint, + ); // Select Find Box (Shift+F) / Open Search (Cmd+Shift+F) crate::panels::keyboard::take_search_focus(ui, rect, &resp); resp @@ -364,16 +397,40 @@ mod tests { let input = egui::RawInput { events, ..Default::default() }; // no renderer takes the font atlas upload here; drop it explicitly (epaint panics otherwise) ctx.run_ui(input, |ui| { - hot_number(ui, id, 123.5, 1.0, (0.0, 1000.0), 1, "", &t); + hot_number(ui, "Value", id, 123.5, 1.0, (0.0, 1000.0), 1, "", &t); }) .textures_delta .clear(); } + /// The edit field behind a hot number uses the caller's property label, not the generic + /// "Value", so Effect Controls and graphics fields are distinguishable by screen readers. + #[test] + fn a_hot_number_edit_field_uses_the_parameter_label() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + let id = egui::Id::unique("hot-labelled"); + let mut harness = Harness::builder().with_size(egui::vec2(240.0, 60.0)).build_ui_state( + move |ui, _: &mut ()| { + let t = Tokens::for_kind(ThemeKind::Dark); + ui.data_mut(|d| { + d.insert_temp(id.with("editing"), "12.5".to_string()); + d.insert_temp(id.with("select-all"), false); + }); + hot_number(ui, "Position X", id, 12.5, 1.0, (-1000.0, 1000.0), 1, "", &t); + }, + (), + ); + harness.run_steps(2); + harness.check_accessibility(); + assert!(harness.query_by_label("Position X").is_some(), "the field is reachable by its parameter label"); + assert!(harness.query_by_label("Value").is_none(), "the generic label is gone"); + } + #[test] fn clicking_a_hot_number_selects_its_whole_value() { let ctx = egui::Context::default(); - let id = egui::Id::new("hot"); + let id = egui::Id::unique("hot"); let pos = egui::pos2(14.0, 12.0); let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; frame(&ctx, vec![egui::Event::PointerMoved(pos)], id); diff --git a/crates/ui-egui/tests/it/clipboard_paste_ui.rs b/crates/ui-egui/tests/it/clipboard_paste_ui.rs index c01d2e165..f5b09173c 100644 --- a/crates/ui-egui/tests/it/clipboard_paste_ui.rs +++ b/crates/ui-egui/tests/it/clipboard_paste_ui.rs @@ -3,7 +3,10 @@ use std::cell::Cell; use std::rc::Rc; -use std::sync::mpsc::{Sender, channel}; +use std::sync::{ + Arc, + mpsc::{Sender, channel}, +}; use egui_kittest::Harness; use filmcraft_engine::Session; @@ -140,6 +143,46 @@ fn ctrl_v_pastes_an_image_above_the_footage() { assert_eq!(d.clips(), before); } +/// A paste delivered by `Event::PasteImage` carries the image bytes: paste uses that payload even +/// with no clipboard-media hook (the web host installs none) and without re-reading the OS. +#[test] +fn paste_image_event_imports_its_own_payload_without_a_clipboard_hook() { + let mut d = Driver::demo("paste-image-event"); + d.ok("ui.set", json!({"focused": "Timeline"})); + assert!(d.app().hooks.clipboard_media.is_none()); + let before = d.clips(); + let image = egui::ColorImage::filled([8, 6], egui::Color32::from_rgb(10, 20, 30)); + d.harness.input_mut().events.extend([egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), egui::Event::PasteImage(Arc::new(image))]); + d.frames(1); + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + d.frames(2); + let new: Vec<_> = d.clips().into_iter().filter(|c| !before.contains(c)).collect(); + assert_eq!(new.iter().map(|c| c.3.as_str()).collect::>(), ["Pasted Image 1.png"]); + assert!(d.dir.join("Pasted Image 1.png").is_file(), "the event payload was written, not an empty clipboard"); +} + +/// The saved PNG stores straight alpha. `Color32` is premultiplied internally, so a +/// semi-transparent clipboard pixel must keep its colour through the paste. +#[test] +fn paste_image_event_writes_straight_alpha_rgba() { + let mut d = Driver::demo("paste-image-alpha"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let before = d.clips(); + // Four half-transparent red pixels; premultiplied storage would be (128, 0, 0, 128). + let rgba = [255, 0, 0, 128, 255, 0, 0, 128, 255, 0, 0, 128, 255, 0, 0, 128]; + let image = egui::ColorImage::from_rgba_unmultiplied([2, 2], &rgba); + d.harness.input_mut().events.extend([egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), egui::Event::PasteImage(Arc::new(image))]); + d.frames(1); + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + d.frames(2); + let new: Vec<_> = d.clips().into_iter().filter(|c| !before.contains(c)).collect(); + assert_eq!(new.iter().map(|c| c.3.as_str()).collect::>(), ["Pasted Image 1.png"]); + let png = std::fs::read(d.dir.join("Pasted Image 1.png")).expect("the pasted PNG"); + let decoded = image::load_from_memory(&png).expect("decode the pasted PNG").to_rgba8(); + assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 128], "straight alpha, not the premultiplied colour"); + assert_eq!(decoded.get_pixel(1, 1).0, [255, 0, 0, 128]); +} + /// Files copied in the file manager paste the same way, even when the clipboard also holds text /// (Ctrl+V then arrives as a paste event); Ctrl+Shift+V inserts them on the targeted track. #[test] diff --git a/crates/ui-egui/tests/it/deselect_ui.rs b/crates/ui-egui/tests/it/deselect_ui.rs index 66095e652..4c4de2a24 100644 --- a/crates/ui-egui/tests/it/deselect_ui.rs +++ b/crates/ui-egui/tests/it/deselect_ui.rs @@ -103,7 +103,7 @@ fn clicking_another_panel_ends_text_editing() { let clip = d.exec("graphics.newText", json!({"text": "Title", "time": at}))["clip"].as_u64().unwrap(); d.ok("ui.panel.show", json!({"panel": "Properties"})); let edit = |d: &mut Driver| { - d.app().ui.gfx_edit = Some(GfxEdit { clip, layer: 0, caret: 0, anchor: 0 }); + d.app().ui.gfx_edit = Some(GfxEdit { clip, layer: 0, caret: 0, anchor: 0, ..Default::default() }); d.app().ui.focused = filmcraft_ui_egui::dock::PanelKind::Program; d.frames(3); }; diff --git a/crates/ui-egui/tests/it/graphics_handles_ui.rs b/crates/ui-egui/tests/it/graphics_handles_ui.rs index f3c03fb51..b850368f4 100644 --- a/crates/ui-egui/tests/it/graphics_handles_ui.rs +++ b/crates/ui-egui/tests/it/graphics_handles_ui.rs @@ -75,6 +75,24 @@ impl Driver { std::fs::create_dir_all(&dir).unwrap(); img.save(dir.join(format!("{name}.png"))).unwrap(); } + + fn ime(&self) -> Option { + self.harness.output().platform_output.ime.clone() + } + + /// Start point-text editing on the Program picture and return its clip id. + fn start_point_text(&mut self) -> u64 { + self.ok("ui.set", json!({"tool": "Type"})); + self.frames(3); + let pic = self.rect("program.picture"); + self.ok("ui.click", json!({"x": pic[0] + pic[2] * 0.3, "y": pic[1] + pic[3] * 0.2})); + self.frames(3); + self.exec("graphics.list", json!({}))["clip"].as_u64().unwrap() + } +} + +fn ime_payload(ime: &egui::output::IMEOutput) -> &egui::output::IMESurroundingTextPayload { + ime.surrounding_text.available().expect("surrounding text is available") } impl Driver { @@ -357,6 +375,839 @@ fn text_committed_by_an_input_method_is_typed_on_the_monitor() { assert_eq!(list["layers"][0]["text"], "A你好"); } +/// The Program monitor's custom IME producer reports committed logical text, a reversed UTF-8 +/// selection, ignores preedit-only updates, and labels an accepted commit `InputMethod` while +/// ordinary typing remains `Other`. +#[test] +fn ime_output_reports_committed_text_selection_and_cause() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "café😀"})); + d.frames(3); + + let first = d.ime().expect("the focused text layer publishes an IME context"); + let first_payload = ime_payload(&first); + assert_eq!(first_payload.text, "café😀"); + assert_eq!(first_payload.cursor, "café😀".len()); + assert_eq!(first_payload.anchor, "café😀".len()); + assert_eq!(first.change_cause, egui::output::ImeChangeCause::Other); + let (owner, generation) = (first.owner, first.context_generation); + + // Primary caret 5 (after é), anchor 2: a reversed selection of "fé". + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 2}})); + d.frames(3); + let selected = d.ime().expect("selection keeps the context"); + assert_eq!(selected.owner, owner); + assert_eq!(selected.context_generation, generation, "ordinary caret/selection moves keep the generation"); + let p = ime_payload(&selected); + assert_eq!((p.cursor, p.anchor), (5, 2), "direction is preserved"); + + // A pre-edit alone is not committed text and must not be sent. + d.harness.input_mut().events.push(egui::Event::Ime(egui::ImeEvent::Preedit { text: "に".into(), active_range_chars: Some(0..1) })); + d.frames(2); + let preedit = d.ime().expect("preedit does not remove the context"); + let p = ime_payload(&preedit); + assert_eq!(p.text, "café😀"); + assert_eq!((p.cursor, p.anchor), (5, 2)); + + // The commit replaces the selected "fé"; only this accepted mutation is InputMethod. + d.harness.input_mut().events.push(egui::Event::Ime(egui::ImeEvent::Commit("語".into()))); + d.frames(1); + let committed = d.ime().expect("the commit is observed after its action"); + assert_eq!(committed.owner, owner); + assert_eq!(committed.context_generation, generation); + assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod); + let p = ime_payload(&committed); + assert_eq!(p.text, "ca語😀"); + assert_eq!((p.cursor, p.anchor), (5, 5)); + + // An empty (no-op) commit must not claim InputMethod for the unchanged snapshot. + d.harness.input_mut().events.push(egui::Event::Ime(egui::ImeEvent::Commit(String::new()))); + d.frames(1); + let no_op = d.ime().expect("a no-op commit leaves the context in place"); + assert_eq!(no_op.change_cause, egui::output::ImeChangeCause::Other); + + // Repeated passes rebuild the same post-mutation descriptor without resetting the context. + d.frames(2); + let repeated = d.ime().expect("the committed editor is still observed"); + assert_eq!(repeated.owner, owner); + assert_eq!(repeated.context_generation, generation); + let p = ime_payload(&repeated); + assert_eq!(p.text, "ca語😀"); + assert_eq!((p.cursor, p.anchor), (5, 5)); + + // Ordinary typing in a later frame is application-origin, never InputMethod. + d.ok("ui.type", json!({"text": "X"})); + d.frames(2); + let typed = d.ime().expect("typing keeps the context"); + assert_eq!(typed.context_generation, generation); + assert_eq!(typed.change_cause, egui::output::ImeChangeCause::Other); + assert_eq!(ime_payload(&typed).text, "ca語X😀", "typing inserts at the retained caret before the emoji"); + assert_eq!((ime_payload(&typed).cursor, ime_payload(&typed).anchor), (6, 6)); +} + +/// Delete-surrounding excludes the selection, rounds partial UTF-8 ranges inward, and keeps a +/// reversed selection's direction while the queued edit is accepted. +#[test] +fn ime_delete_surrounding_keeps_selection_direction_and_unicode() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "aé😀z"})); + d.frames(3); + // Select é (bytes 1..3), primary caret at its end (reversed). + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 1}})); + d.frames(3); + d.harness.input_mut().events.push(egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 4 })); + d.frames(1); + let ime = d.ime().expect("delete-surrounding publishes the edited context"); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + let p = ime_payload(&ime); + assert_eq!(p.text, "éz", "one byte before and the four-byte emoji after are deleted; é is kept"); + assert_eq!((p.cursor, p.anchor), (2, 0), "primary caret stays after the anchor"); + assert_eq!(d.exec("graphics.list", json!({}))["layers"][0]["text"], "éz"); +} + +/// An available empty layer is `Available("", 0, 0)`, while a selection over the 4000-byte +/// protocol limit is an explicit `Unavailable`, never a truncated or fabricated snapshot. +#[test] +fn ime_surrounding_text_distinguishes_empty_from_unavailable_and_is_bounded() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + let empty = d.ime().expect("an empty new text layer is still a focused IME owner"); + let p = ime_payload(&empty); + assert!(p.text.is_empty()); + assert_eq!((p.cursor, p.anchor), (0, 0)); + + d.exec("graphics.setText", json!({"clip": clip, "layer": 0, "text": "a".repeat(5001)})); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 4000, "anchor": 0}})); + d.frames(3); + let at_limit = d.ime().expect("the edit context remains focused"); + let p = ime_payload(&at_limit); + assert_eq!(p.text.len(), 4000); + assert_eq!((p.cursor, p.anchor), (4000, 0)); + + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 4001, "anchor": 0}})); + d.frames(3); + let over = d.ime().expect("an unavailable snapshot still has an owner and generation"); + assert!(over.surrounding_text.available().is_none(), "a too-wide selection is explicitly Unavailable"); + assert_eq!(over.owner, at_limit.owner); +} + +/// Losing focus ends the output; reopening the same layer keeps the stable owner but resets the +/// generation exactly once. A plain caret move does not reset it. +#[test] +fn ime_owner_is_stable_across_edits_and_resets_on_refocus() { + let mut d = Driver::new(); + d.start_point_text(); + d.ok("ui.type", json!({"text": "Title"})); + d.frames(3); + let first = d.ime().expect("text editing has an IME context"); + let (owner, generation) = (first.owner, first.context_generation); + + d.ok("ui.key", json!({"key": "ArrowLeft"})); + d.frames(2); + let moved = d.ime().expect("caret movement keeps the context"); + assert_eq!(moved.owner, owner); + assert_eq!(moved.context_generation, generation, "ordinary caret movement does not reset"); + + d.ok("ui.key", json!({"key": "Escape"})); + d.frames(2); + assert!(d.ime().is_none(), "an unfocused, closed editor publishes no descriptor"); + + let pic = d.rect("program.picture"); + d.ok("ui.click", json!({"x": pic[0] + pic[2] * 0.3, "y": pic[1] + pic[3] * 0.2})); + d.frames(3); + let reopened = d.ime().expect("the reopened editor publishes again"); + assert_eq!(reopened.owner, owner, "the sequence/clip/component owner is stable"); + assert_ne!(reopened.context_generation, generation, "a replacement session resets the generation once"); +} + +/// An ordinary text event in the same pass as an accepted commit makes the whole frame `Other`. +#[test] +fn ime_mixed_application_and_ime_mutation_is_other() { + let mut d = Driver::new(); + d.start_point_text(); + d.frames(2); + let before = d.ime().expect("the empty editor is focused"); + d.harness.input_mut().events.extend([egui::Event::Text("A".into()), egui::Event::Ime(egui::ImeEvent::Commit("B".into()))]); + d.frames(1); + let mixed = d.ime().expect("the mixed edit is observed"); + assert_eq!(ime_payload(&mixed).text, "AB"); + assert_eq!(mixed.change_cause, egui::output::ImeChangeCause::Other); + assert_eq!(mixed.context_generation, before.context_generation); + + // A toolbar/control edit of the same target is application-origin too, even after a commit. + let clip = d.exec("graphics.list", json!({}))["clip"].as_u64().unwrap(); + d.exec("graphics.setText", json!({"clip": clip, "layer": 0, "text": "toolbar"})); + d.frames(2); + let toolbar = d.ime().expect("the toolbar edit keeps the owner"); + assert_eq!(ime_payload(&toolbar).text, "toolbar"); + assert_eq!(toolbar.change_cause, egui::output::ImeChangeCause::Other); +} + +fn ime_preedit(text: &str) -> egui::Event { + egui::Event::Ime(egui::ImeEvent::Preedit { text: text.into(), active_range_chars: None }) +} + +fn ime_commit(text: &str) -> egui::Event { + egui::Event::Ime(egui::ImeEvent::Commit(text.into())) +} + +fn key_press(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE } +} + +fn layer_texts(d: &mut Driver, clip: u64) -> Vec { + d.exec("graphics.list", json!({"clip": clip}))["layers"] + .as_array() + .map(|layers| layers.iter().map(|layer| layer["text"].as_str().unwrap_or("").to_string()).collect()) + .unwrap_or_default() +} + +fn new_text_layer(d: &mut Driver, clip: Option, text: &str, x: f64) -> u64 { + let mut p = json!({"text": text, "position": [x, 400], "size": 100}); + if let Some(clip) = clip { + p["clip"] = json!(clip); + } + d.exec("graphics.newText", p)["clip"].as_u64().unwrap() +} + +/// A commit from a composition whose target was replaced (`ui.set.gfxEdit`) must be dropped: the +/// text belongs to the old layer, not to whatever layer the new edit target names. +#[test] +fn ime_commit_after_target_replacement_is_dropped() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + // The edit target moves to the other layer while the IME is still composing. + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + d.harness.input_mut().events.push(ime_commit("x")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "beta".to_string()]); +} + +/// A composition whose target was replaced can emit another non-empty pre-edit before its final +/// commit. That late pre-edit must not revive the old composition on the replacement layer, while +/// a genuinely new composition in a later raw batch must still work. +#[test] +fn ime_late_preedit_after_target_replacement_cannot_retarget() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + // The edit target moves to the other layer while the old composition is still live. + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + // A late pre-edit from the replaced composition. The input method commits it in a later raw + // batch, so the tombstone must survive the pre-edit batch. + d.harness.input_mut().events.push(ime_preedit("にに")); + d.frames(1); + d.harness.input_mut().events.push(ime_commit("x")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "beta".to_string()], "the late pre-edit and paired commit stayed on the old target"); + assert!( + d.ime().is_none_or(|ime| ime.change_cause != egui::output::ImeChangeCause::InputMethod), + "a rejected stale composition is not an InputMethod receipt" + ); + + // The rejected composition's commit ended the tombstone: a fresh composition on the + // replacement layer is legitimate and must be accepted. + d.harness.input_mut().events.extend([ime_preedit("よ"), ime_commit("y")]); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "betay".to_string()], "a new composition on the current target still works"); +} + +/// An empty pre-edit clears the visible composition but not ownership of its pending terminal +/// commit. Switching targets before that commit arrives must still reject it. +#[test] +fn ime_empty_preedit_keeps_pending_commit_from_retargeting() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + // The empty pre-edit dismisses the visible composition. Its commit is a separate event. + d.harness.input_mut().events.push(ime_preedit("")); + d.frames(1); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + d.harness.input_mut().events.push(ime_commit("x")); + d.frames(2); + assert_eq!( + layer_texts(&mut d, clip), + vec!["alpha".to_string(), "beta".to_string()], + "the pending commit of the dismissed composition stayed on the old target" + ); + + // The rejected commit ended that composition; a direct commit on the current target works. + d.harness.input_mut().events.push(ime_commit("y")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "betay".to_string()], "a direct commit after the terminal boundary is accepted"); +} + +/// A rejected old commit is a terminal boundary even when the same raw batch immediately starts a +/// new pre-edit. That new composition must survive to its later commit. +#[test] +fn ime_preedit_after_stale_commit_in_one_batch_is_accepted() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + // Wayland can coalesce the old terminal events and a new pre-edit into one raw batch. + d.harness.input_mut().events.extend([ime_preedit(""), ime_commit("x"), ime_preedit("よ")]); + d.frames(1); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "beta".to_string()], "the old commit was still rejected"); + d.harness.input_mut().events.push(ime_commit("y")); + d.frames(2); + assert_eq!( + layer_texts(&mut d, clip), + vec!["alpha".to_string(), "betay".to_string()], + "the pre-edit after the old terminal commit started a new composition" + ); +} + +/// A same-pass control text mutation replaces the text the composition belonged to. The old +/// descriptor must not be published (existing behaviour) and the composition's next commit must +/// not be inserted into the replacement text. +#[test] +fn ime_same_pass_control_text_mutation_invalidates_composition() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "one"})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + // `ui.menu.invoke` is drained after the overlay drew and changes the committed text. + d.ok("ui.menu.invoke", json!({"id": "graphics.setText", "params": {"clip": clip, "layer": 0, "text": "changed"}})); + assert!(d.ime().is_none(), "the old cursor rectangle must not be published for the changed text"); + + d.harness.input_mut().events.push(ime_commit("x")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["changed".to_string()], "the old composition's commit must not edit the replacement text"); + assert!(d.ime().is_none_or(|ime| ime.change_cause != egui::output::ImeChangeCause::InputMethod), "a rejected stale commit is not an InputMethod receipt"); + // The control replacement keeps the editor's existing numeric byte offsets (the editor only + // clamps them into the new text, as it did before this migration), so place the caret at the + // end explicitly: this regression tests that the rejected composition's tombstone retires, + // not the unrelated caret policy. + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 7, "anchor": 7}})); + d.frames(1); + + // The rejected commit ended the tombstone: the next composition on the same target is + // legitimate and must still be accepted. + d.harness.input_mut().events.extend([ime_preedit("よ"), ime_commit("z")]); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["changedz".to_string()], "a new composition still starts after the text mutation"); +} + +/// A same-pass control request can change layout (font size, style, alignment, spacing, box, +/// transform) without changing the committed text or the layer identity. The pass must not +/// publish the cursor rectangle of the previous layout. +#[test] +fn ime_control_layout_change_does_not_publish_stale_cursor_rect() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "one"})); + d.frames(3); + let before = d.ime().expect("the editor is observed before the layout change"); + d.ok("ui.menu.invoke", json!({"id": "graphics.set", "params": {"clip": clip, "layer": 0, "props": {"tracking": 200.0}}})); + assert!(d.ime().is_none(), "the old cursor rectangle must not be published with the new layout"); + d.frames(1); + let after = d.ime().expect("the next pass observes the new layout"); + assert_ne!(after.cursor_rect, before.cursor_rect, "the candidate rectangle moved with the wider tracking"); +} + +/// An explicit `ui.set.gfxEdit` names the layer at that numeric index now. A uid belonging to a +/// layer that a reorder moved elsewhere must not silently retarget the request. +#[test] +fn explicit_gfx_edit_index_selects_the_current_layer_not_a_stale_uid() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "one", 400.0); + new_text_layer(&mut d, Some(clip), "two", 900.0); + let uids: Vec = d.exec("graphics.list", json!({"clip": clip}))["layers"].as_array().unwrap().iter().map(|l| l["uid"].as_u64().unwrap()).collect(); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 3}})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((0, uids[0]))); + + d.exec("graphics.arrangeLayer", json!({"clip": clip, "layer": 0, "to": "front"})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((1, uids[0])), "the edit follows the reordered layer"); + + // Index 0 now holds "two", but the request still carries the old "one" uid. The explicit + // index wins: the current layer at index 0 is selected. + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 3, "uid": uids[0]}})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((0, uids[1])), "the stale uid must not retarget the request"); + d.ok("ui.type", json!({"text": "X"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["twoX".to_string(), "one".to_string()]); +} + +/// The fork sends `DeleteSurroundingBytes` immediately before the `Commit` it belongs to. A stale +/// delete must not lift the tombstone, or the paired commit would be inserted into the replacement +/// layer. The tombstone is bounded to this input batch: a later direct commit (no pre-edit) on the +/// new target is legitimate and must still be accepted. +#[test] +fn stale_delete_and_commit_pair_cannot_retarget_the_replacement_layer() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + d.harness.input_mut().events.extend([egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 0, after_bytes: 0 }), ime_commit("x")]); + d.frames(2); + assert_eq!( + layer_texts(&mut d, clip), + vec!["alpha".to_string(), "beta".to_string()], + "neither the delete nor its paired commit touched the replacement layer" + ); + assert!(d.ime().is_none_or(|ime| ime.change_cause != egui::output::ImeChangeCause::InputMethod), "a rejected stale pair is not an InputMethod receipt"); + + // The tombstone ends with that batch: a later commit-only composition is accepted on the + // layer the user is editing now. + d.harness.input_mut().events.push(ime_commit("y")); + // One pass: the receipt belongs to the frame that observed the change. A later unchanged + // frame reports `Other`, per the tracker contract. + d.frames(1); + // Reading through the control channel advances a frame; capture the receipt first. + let receipt = d.ime().expect("the accepted direct commit is observed"); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "betay".to_string()]); + assert_eq!(receipt.change_cause, egui::output::ImeChangeCause::InputMethod); +} + +/// An empty pre-edit is the input method dismissing its composition. When it arrives after the +/// target was replaced it must not clear the tombstone before the paired commit, but it also must +/// not poison the next legitimate commit-only composition. +#[test] +fn stale_empty_preedit_and_commit_pair_cannot_retarget_the_replacement_layer() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 4, "anchor": 4}})); + d.frames(2); + d.harness.input_mut().events.extend([ime_preedit(""), ime_commit("x")]); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "beta".to_string()]); + assert!(d.ime().is_none_or(|ime| ime.change_cause != egui::output::ImeChangeCause::InputMethod)); + + d.harness.input_mut().events.push(ime_commit("y")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "betay".to_string()], "later commit-only input still works"); +} + +/// A composition belongs to its layer's uid, not to the numeric index it was started at: a real +/// reorder moves the editor (and the commit) with the same layer. +#[test] +fn composition_follows_a_reordered_layer_by_uid() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "one", 400.0); + new_text_layer(&mut d, Some(clip), "two", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 3}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + d.exec("graphics.arrangeLayer", json!({"clip": clip, "layer": 0, "to": "front"})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| e.layer), Some(1)); + d.harness.input_mut().events.push(ime_commit("x")); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["two".to_string(), "onex".to_string()], "the composition stayed on the layer it began on"); +} + +/// `DeleteSurroundingBytes { 0, 0 }` followed by an empty commit is the IME's way of replacing a +/// selection with nothing. The selection must be deleted and the mutation labelled `InputMethod`. +#[test] +fn empty_ime_commit_replaces_the_selection() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "abc"})); + d.frames(2); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 0, "anchor": 3}})); + d.frames(2); + d.harness.input_mut().events.extend([egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 0, after_bytes: 0 }), ime_commit("")]); + d.frames(1); + // Capture the receipt before graphics.list pumps the next control-channel frame. + let ime = d.ime().expect("the accepted empty commit is observed"); + assert_eq!(layer_texts(&mut d, clip), vec![String::new()], "the selected text is replaced by the empty commit"); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + assert_eq!(ime_payload(&ime).text, ""); +} + +/// Escape in the same raw batch as a commit cancels the edit; the later commit must not be typed. +#[test] +fn escape_before_a_commit_in_the_same_batch_cancels_it() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "a"})); + d.frames(2); + d.harness.input_mut().events.extend([key_press(egui::Key::Escape), ime_commit("x")]); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["a".to_string()]); + assert!(d.harness.state().ui.gfx_edit.is_none(), "Escape ended the edit"); + assert!(d.ime().is_none(), "the closed editor publishes no descriptor"); +} + +/// A pointer press later in the batch is handled after `text_edit`; a commit after that press +/// belongs to the new target and must not be inserted into the old one. +#[test] +fn pointer_press_before_a_commit_does_not_edit_the_old_layer() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + new_text_layer(&mut d, Some(clip), "beta", 900.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + let rect = d.rect(&format!("program.layer.{clip}.1")); + d.harness.input_mut().events.extend([ + egui::Event::PointerButton { + pos: egui::pos2((rect[0] + rect[2] / 2.0) as f32, (rect[1] + rect[3] / 2.0) as f32), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ime_commit("x"), + ]); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string(), "beta".to_string()]); +} + +/// Moving within the same layer keeps its owner, but a known composition belongs to the old +/// caret. Real press/release input moves it; the late replacement arrives in a separate batch. +#[test] +fn ime_pointer_caret_move_rejects_next_batch_old_composition() { + for terminal_pending in [false, true] { + for bytes in [false, true] { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + d.ok("ui.set", json!({"tool": "Type", "gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + let rect = d.rect(&format!("program.layer.{clip}.0")); + let pos = egui::pos2((rect[0] + 1.0) as f32, (rect[1] + rect[3] / 2.0) as f32); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + if terminal_pending { + d.harness.input_mut().events.push(ime_preedit("")); + d.frames(1); + } + let before = d.ime().expect("a live graphics owner"); + d.harness.input_mut().events.extend([ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + ]); + d.frames(1); + // A focus loss already disables the platform context; otherwise record its + // one-shot interruption even when the release frame no longer repeats it. + let press_interrupted = d.ime().is_none_or(|ime| ime.should_interrupt_composition); + d.harness.input_mut().events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + d.frames(1); + let edit = d.harness.state().ui.gfx_edit.as_ref().expect("same-layer edit remains active"); + let caret = edit.caret; + assert!(caret < 5 && edit.anchor == caret, "the real click must actually move the caret"); + let moved = d.ime().expect("same owner after pointer movement"); + assert_eq!(moved.owner, before.owner, "moving the caret does not replace the layer owner"); + assert!(press_interrupted || moved.should_interrupt_composition, "the platform cancels the known old composition"); + let undo = d.harness.state().session.history.undo.len(); + let delete = if bytes { + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 0, after_bytes: 1 }) + } else { + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 0, after_chars: 1 }) + }; + d.harness.input_mut().events.extend([delete, ime_commit("stale")]); + d.frames(1); + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string()], "old composition cannot edit the clicked caret"); + assert_eq!(d.harness.state().session.history.undo.len(), undo, "rejection creates no Undo transaction"); + // A fresh composition after the rejected terminal commit still applies to that caret. + d.harness.input_mut().events.extend([ime_preedit("fresh"), ime_commit("X")]); + d.frames(1); + assert_eq!(layer_texts(&mut d, clip), vec![format!("{}X{}", &"alpha"[..caret], &"alpha"[caret..])]); + } + } +} + +/// Control and keyboard caret moves are application-origin too, without changing stable owner +/// identity. A direct commit remains valid when no old composition was interrupted. +#[test] +fn ime_same_owner_selection_move_rejects_only_a_known_composition() { + for control_move in [false, true] { + for composing in [false, true] { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + let before = d.ime().expect("initial owner"); + if composing { + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + } + if control_move { + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 0, "anchor": 0}})); + } else { + d.harness.input_mut().events.push(key_press(egui::Key::Home)); + d.frames(1); + } + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.caret, e.anchor)), Some((0, 0))); + let moved = d.ime().expect("same owner after caret movement"); + assert_eq!((moved.owner, moved.context_generation), (before.owner, before.context_generation)); + assert_eq!(moved.should_interrupt_composition, composing); + let undo = d.harness.state().session.history.undo.len(); + d.harness.input_mut().events.push(ime_commit("X")); + d.frames(1); + assert_eq!(layer_texts(&mut d, clip), vec![if composing { "alpha" } else { "Xalpha" }.to_string()]); + if composing { + assert_eq!(d.harness.state().session.history.undo.len(), undo); + } + } + } +} + +/// A focused custom text widget consumes raw Ctrl/Cmd+A itself, rather than the menu helper. +/// Its new selection must not receive the terminal event of an older composition next batch. +#[test] +fn ime_raw_select_all_rejects_old_terminal_input_and_preserves_direct_and_fresh_commits() { + for phase in ["none", "active", "terminal_pending"] { + for bytes in [false, true] { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + if phase != "none" { + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + if phase == "terminal_pending" { + d.harness.input_mut().events.push(ime_preedit("")); + d.frames(1); + } + } + let before = d.ime().expect("the custom graphics editor is focused"); + assert_eq!(d.harness.ctx.memory(|m| m.focused()), Some(egui::Id::unique("gfx-text-edit"))); + assert!(d.harness.ctx.egui_wants_keyboard_input(), "raw Select All must reach the focused-widget path"); + let command = egui::Modifiers { ctrl: !cfg!(target_os = "macos"), mac_cmd: cfg!(target_os = "macos"), command: true, ..egui::Modifiers::NONE }; + d.harness.input_mut().events.extend([ + egui::Event::ModifiersChanged(command), + egui::Event::Key { key: egui::Key::A, physical_key: Some(egui::Key::A), pressed: true, repeat: false, modifiers: command }, + ]); + d.frames(1); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((0, 5)), "the raw key really selects all text"); + let selected = d.ime().expect("the same custom owner reports its selection"); + assert_eq!((selected.owner, selected.context_generation), (before.owner, before.context_generation)); + assert_eq!(selected.should_interrupt_composition, phase != "none", "only the known old composition is interrupted"); + let undo = d.harness.state().session.history.undo.len(); + let deletion = if bytes { + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }) + } else { + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 0 }) + }; + // Surrounding deletion excludes the selected range; the terminal commit would replace + // the whole selection if the old composition were not rejected. Release is in this + // later batch too, so no control/menu invocation can stand in for the actual key. + d.harness.input_mut().events.extend([ + egui::Event::Key { key: egui::Key::A, physical_key: Some(egui::Key::A), pressed: false, repeat: false, modifiers: command }, + egui::Event::ModifiersChanged(egui::Modifiers::NONE), + deletion, + ime_commit("X"), + ]); + d.frames(1); + if phase == "none" { + assert_eq!(layer_texts(&mut d, clip), vec!["X".to_string()], "without old preedit a direct commit replaces the selection"); + assert_eq!(d.harness.state().session.history.undo.len(), undo + 1); + } else { + assert_eq!(layer_texts(&mut d, clip), vec!["alpha".to_string()], "old terminal input cannot replace the new selection"); + assert_eq!(d.harness.state().session.history.undo.len(), undo, "rejected input creates no Undo transaction"); + assert!(!d.ime().expect("the unchanged selected editor remains focused").should_interrupt_composition, "the interruption is one-shot"); + d.harness.input_mut().events.extend([ime_preedit("fresh"), ime_commit("Y")]); + d.frames(1); + assert_eq!(layer_texts(&mut d, clip), vec!["Y".to_string()], "a fresh composition replaces the selection normally"); + assert_eq!(d.harness.state().session.history.undo.len(), undo + 1); + } + } + } +} + +/// A surrounding replacement at the original composing caret is still accepted and classified +/// as the input method's own change; the interruption guard is not a blanket commit filter. +#[test] +fn ime_uninterrupted_same_owner_replacement_still_applies() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "alpha", 500.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 5, "anchor": 5}})); + d.frames(3); + d.harness.input_mut().events.push(ime_preedit("に")); + d.frames(1); + d.harness.input_mut().events.extend([egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), ime_commit("X")]); + d.frames(1); + let ime = d.ime().expect("accepted replacement observation"); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + assert!(!ime.should_interrupt_composition); + assert_eq!(layer_texts(&mut d, clip), vec!["alphX".to_string()]); +} + +/// The concrete collision the uid-less fingerprint missed: two layers with identical static +/// geometry (position, size, no name) but different text. Reordering, deleting and undoing must +/// keep typing in the layer the user started editing, not in whatever now occupies its index. +#[test] +fn identical_layers_with_different_text_keep_the_edit_on_the_started_layer() { + let mut d = Driver::new(); + let clip = d.exec("graphics.newText", json!({"text": "one", "position": [960, 540], "size": 100}))["clip"].as_u64().unwrap(); + d.exec("graphics.newText", json!({"text": "two", "position": [960, 540], "size": 100, "clip": clip})); + let uids: Vec = d.exec("graphics.list", json!({"clip": clip}))["layers"].as_array().unwrap().iter().map(|l| l["uid"].as_u64().unwrap()).collect(); + assert_eq!(uids.len(), 2); + assert!(uids.iter().all(|uid| *uid != 0)); + assert_ne!(uids[0], uids[1], "identical geometry and different text get distinct identities"); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 3}})); + d.frames(3); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((0, uids[0]))); + + // Move the edited layer to the front. The other layer now sits at index 0 (same fingerprint). + d.exec("graphics.arrangeLayer", json!({"clip": clip, "layer": 0, "to": "front"})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((1, uids[0]))); + d.ok("ui.type", json!({"text": "X"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["two".to_string(), "oneX".to_string()], "typing followed the uid, not index 0"); + + // Delete the layer below it; the edit stays on "oneX" and keeps typing there. + d.exec("graphics.deleteLayer", json!({"clip": clip, "layer": 0})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((0, uids[0]))); + d.ok("ui.type", json!({"text": "Y"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["oneXY".to_string()]); + + // Undo the typing and then the deletion: the same uid is edited at its restored index. + d.exec("edit.undo", json!({})); + d.frames(2); + d.exec("edit.undo", json!({})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| (e.layer, e.uid)), Some((1, uids[0]))); + d.ok("ui.type", json!({"text": "Z"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["two".to_string(), "oneXZ".to_string()]); +} + +/// Every graphic layer created by `graphics.newText` has a stable uid. The edit target follows +/// that uid through a real reorder, a real deletion below it, and undo/redo. +#[test] +fn edit_target_follows_reorder_delete_and_undo() { + let mut d = Driver::new(); + let clip = new_text_layer(&mut d, None, "one", 400.0); + new_text_layer(&mut d, Some(clip), "two", 800.0); + new_text_layer(&mut d, Some(clip), "three", 1200.0); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 3, "anchor": 3}})); + d.frames(3); + let uid = d.harness.state().ui.gfx_edit.as_ref().map(|e| e.uid).unwrap_or(0); + assert_ne!(uid, 0, "the created layer has a stable uid"); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| e.layer), Some(0)); + + // Reorder layer 0 (the edited "one") to the front, then type: the text must follow it. + d.exec("graphics.arrangeLayer", json!({"clip": clip, "layer": 0, "to": "front"})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| e.layer), Some(2), "the edit follows the reordered layer"); + d.ok("ui.type", json!({"text": "A"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["two".to_string(), "three".to_string(), "oneA".to_string()]); + + // Undo the typing and then the arrange: the edit follows the same layer back to index 0. + d.exec("edit.undo", json!({})); + d.frames(2); + d.exec("edit.undo", json!({})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| e.layer), Some(0)); + d.ok("ui.type", json!({"text": "B"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["oneB".to_string(), "two".to_string(), "three".to_string()]); + + // Edit "two" (index 1), delete the layer below it, and keep typing into "two". + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 1, "caret": 3, "anchor": 3}})); + d.frames(2); + d.exec("graphics.deleteLayer", json!({"clip": clip, "layer": 0})); + d.frames(2); + assert_eq!(d.harness.state().ui.gfx_edit.as_ref().map(|e| e.layer), Some(0)); + d.ok("ui.type", json!({"text": "D"})); + d.frames(2); + assert_eq!(layer_texts(&mut d, clip), vec!["twoD".to_string(), "three".to_string()]); +} + +/// Replacing the project clears the edit and the IME owner even though `file.newProject` emits +/// only `ProjectChanged`, and a project with no sequence has no editor to observe. +#[test] +fn new_project_without_a_sequence_clears_the_ime_edit() { + let mut d = Driver::new(); + d.start_point_text(); + d.ok("ui.type", json!({"text": "x"})); + d.frames(2); + assert!(d.ime().is_some(), "the text editor owns the IME before the replacement"); + d.exec("file.newProject", json!({"name": "Fresh"})); + d.frames(3); + assert!(d.harness.state().ui.gfx_edit.is_none(), "the replaced document has no edit target"); + assert!(d.ime().is_none(), "no sequence means no visible editor and no descriptor"); +} + +/// `engine.execute`/`ui.menu.invoke` run after the UI pass. A same-pass text or caret change +/// leaves the drawn cursor rectangle stale, so that pass publishes Unavailable; the next pass +/// draws and observes the new text at the new caret geometry. +#[test] +fn ime_observation_follows_control_text_mutations_in_the_same_pass() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "one"})); + d.frames(3); + let before = d.ime().expect("the editor is observed"); + assert_eq!(ime_payload(&before).text, "one"); + // `ui.menu.invoke` is one of the requests drained after the frame, unlike `engine.execute`. + d.ok("ui.menu.invoke", json!({"id": "graphics.setText", "params": {"clip": clip, "layer": 0, "text": "a much longer line"}})); + assert!(d.ime().is_none(), "the old cursor rectangle must not be published with the changed text"); + d.frames(1); + let after = d.ime().expect("the next pass observes the new layout"); + assert_eq!(ime_payload(&after).text, "a much longer line", "the final observation runs after the control request"); + assert_ne!(after.cursor_rect, before.cursor_rect, "the candidate window moved with the longer line"); + d.frames(1); + let stable = d.ime().expect("a settled pass observes again"); + assert_eq!(stable.cursor_rect, after.cursor_rect, "the published geometry matches the next pass"); +} + +/// A hostile `ui.set.gfxEdit` offset is reported as the explicit `Unavailable` snapshot before +/// the next pass clamps it, rather than being silently floored before observation. +#[test] +fn invalid_gfx_edit_offsets_are_unavailable_before_clamping() { + let mut d = Driver::new(); + let clip = d.start_point_text(); + d.ok("ui.type", json!({"text": "é😀"})); + d.frames(3); + d.ok("ui.set", json!({"gfxEdit": {"clip": clip, "layer": 0, "caret": 1, "anchor": 2}})); + let ime = d.ime().expect("the editor is still observed"); + assert!(ime.surrounding_text.available().is_none(), "an offset inside a code point is Unavailable"); + d.frames(1); + let clamped = d.ime().expect("the clamped editor is observed"); + assert!(clamped.surrounding_text.available().is_some(), "the editor clamps on its next pass"); + assert_eq!(layer_texts(&mut d, clip), vec!["é😀".to_string()]); +} + /// #2430: a click (no drag) in the text being edited on the monitor puts the caret there; before, /// only a press that moved a little did, and typing went on at the end. #[test] diff --git a/crates/ui-egui/tests/it/masks_ui.rs b/crates/ui-egui/tests/it/masks_ui.rs index 4d90bfca3..c4ea443f8 100644 --- a/crates/ui-egui/tests/it/masks_ui.rs +++ b/crates/ui-egui/tests/it/masks_ui.rs @@ -387,10 +387,13 @@ fn presets_bin_drag_and_save_dialog() { d.ok("ui.click", json!({"id": format!("effectControls.effect.{fx}.twirl")})); d.frames(2); } + // Effect Controls: property fields must be reachable by name as well. + d.harness.check_accessibility(); d.ok("ui.click", json!({"id": "effectControls.effect.brightness_contrast", "button": "right"})); d.frames(2); d.ok("ui.click", json!({"id": "effectControls.effect.brightness_contrast.savePreset"})); d.frames(2); + d.harness.check_accessibility(); d.rect("savePreset.name"); d.ok("ui.click", json!({"id": "savePreset.name"})); d.ok("ui.key", json!({"key": "Cmd+A"})); diff --git a/crates/ui-egui/tests/it/mixer_ui.rs b/crates/ui-egui/tests/it/mixer_ui.rs index 603578392..da3127d79 100644 --- a/crates/ui-egui/tests/it/mixer_ui.rs +++ b/crates/ui-egui/tests/it/mixer_ui.rs @@ -483,7 +483,7 @@ fn paused_meters_repaint_without_input_and_settle_after_release() { // Inspect both values that drive the bars: a silent level alone must not strand its // slower peak hold. No new frames or pointer events are injected to read this state. let (_, meters): (u64, std::collections::HashMap>) = - d.harness.ctx.data(|data| data.get_temp(egui::Id::new("mixer-meters"))).unwrap(); + d.harness.ctx.data(|data| data.get_temp(egui::Id::unique("mixer-meters"))).unwrap(); assert!(!meters.is_empty()); assert!(meters.values().flatten().all(|meter| *meter == [-90.0; 2]), "{workspace}: meters stopped after {steps} requested frames: {meters:?}"); assert_ne!( diff --git a/crates/ui-egui/tests/it/monitor_geometry.rs b/crates/ui-egui/tests/it/monitor_geometry.rs index 700657e19..d8329278a 100644 --- a/crates/ui-egui/tests/it/monitor_geometry.rs +++ b/crates/ui-egui/tests/it/monitor_geometry.rs @@ -34,7 +34,7 @@ fn harness_at_scale(width: f32, kind: ThemeKind, which: Which, scale: f32) -> Ha monitor::show(app, ui, group.content, which); } let source = filmcraft_ui_egui::panels::dragged_source(ui); - ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("observed-source-drag"), source)); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("observed-source-drag"), source)); }, FilmcraftApp::new(session), ); @@ -175,6 +175,7 @@ fn transport_editor_keeps_its_own_space_and_customizes_compact_controls() { h.event(Event::PointerMoved(scroll.center())); h.event(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Wheel, delta: vec2(0.0, -1200.0), modifiers: Modifiers::NONE, phase: egui::TouchPhase::Move, @@ -232,7 +233,7 @@ fn source_drag_choices(width: f32) { h.run_steps(2); let source = h .ctx - .data(|data| data.get_temp::>(egui::Id::new("observed-source-drag"))) + .data(|data| data.get_temp::>(egui::Id::unique("observed-source-drag"))) .flatten() .expect("drag must create the production Source payload"); assert_eq!((source.video, source.audio), (video, audio)); diff --git a/crates/ui-egui/tests/it/panels_ui.rs b/crates/ui-egui/tests/it/panels_ui.rs index 150f4d0bf..3c246cb6c 100644 --- a/crates/ui-egui/tests/it/panels_ui.rs +++ b/crates/ui-egui/tests/it/panels_ui.rs @@ -248,6 +248,7 @@ fn wheel_over_a_tab_strip_switches_tabs() { for step in 0..5 { d.harness.input_mut().events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Wheel, delta: egui::vec2(0.0, -10.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, diff --git a/crates/ui-egui/tests/it/shared_docking_ui.rs b/crates/ui-egui/tests/it/shared_docking_ui.rs index 7d087bcb4..edb35c083 100644 --- a/crates/ui-egui/tests/it/shared_docking_ui.rs +++ b/crates/ui-egui/tests/it/shared_docking_ui.rs @@ -206,7 +206,7 @@ fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() 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] { @@ -502,7 +502,7 @@ fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_pro step(&mut h); } } - let layer = |panel: PanelKind| egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", panel.id()))); + let layer = |panel: PanelKind| egui::LayerId::new(egui::Order::Middle, egui::Id::unique(("floating-panel", panel.id()))); let covered = rect(&h, "panel.TextToSpeech").center(); assert!(rect(&h, "panel.Info").contains(covered)); ctx.move_to_top(layer(PanelKind::Info)); diff --git a/crates/ui-egui/tests/it/ui_control_input.rs b/crates/ui-egui/tests/it/ui_control_input.rs index 83b3c054e..b8aae493c 100644 --- a/crates/ui-egui/tests/it/ui_control_input.rs +++ b/crates/ui-egui/tests/it/ui_control_input.rs @@ -108,7 +108,7 @@ fn background_divider(isolated: bool) { d.frames(3, true); let before_project = d.h.state().session.project.clone(); let before_playheads = d.h.state().session.state.playheads.clone(); - let divider = Id::new(("dock-gutter", String::new())); + let divider = Id::unique(("dock-gutter", String::new())); d.h.ctx.memory_mut(|memory| memory.request_focus(divider)); d.frames(2, true); d.h.input_mut().events.push(Event::WindowFocused(false)); @@ -156,15 +156,7 @@ fn isolation_preserves_close_and_screenshot_delivery_without_claiming_os_focus() let viewport = raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap(); viewport.focused = Some(false); viewport.events.push(egui::ViewportEvent::Close); - raw.events = vec![ - Event::Text("unrelated typing".into()), - Event::WindowFocused(false), - Event::Screenshot { - viewport_id: egui::ViewportId::ROOT, - user_data: Default::default(), - image: Arc::new(egui::ColorImage::new([1, 1], vec![egui::Color32::WHITE])), - }, - ]; + raw.events = vec![Event::Text("unrelated typing".into()), Event::WindowFocused(false)]; raw.hovered_files.push(Default::default()); #[derive(Debug)] struct UnrelatedFile; @@ -187,7 +179,10 @@ fn isolation_preserves_close_and_screenshot_delivery_without_claiming_os_focus() assert!(raw.focused); assert_eq!(raw.viewports[&egui::ViewportId::ROOT].focused, Some(false)); assert_eq!(raw.viewports[&egui::ViewportId::ROOT].events, vec![egui::ViewportEvent::Close]); - assert!(matches!(raw.events.as_slice(), [Event::Screenshot { .. }, Event::ModifiersChanged(Modifiers::NONE)])); + assert!( + matches!(raw.events.as_slice(), [Event::ModifiersChanged(Modifiers::NONE)]), + "native input is dropped; screenshot results use callbacks, not raw events" + ); assert!(raw.hovered_files.is_empty() && raw.dropped_files.is_empty()); let state = filmcraft_ui_egui::control::inspect(d.h.state(), &ctx); assert_eq!(state["input"]["nativeWindowFocused"], false); diff --git a/crates/ui-egui/tests/it/ui_dock_resize.rs b/crates/ui-egui/tests/it/ui_dock_resize.rs index 2057ce262..4e434c594 100644 --- a/crates/ui-egui/tests/it/ui_dock_resize.rs +++ b/crates/ui-egui/tests/it/ui_dock_resize.rs @@ -64,7 +64,7 @@ fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() { frames(&mut h, 3); let before_project = h.state().session.project.clone(); let before_playheads = h.state().session.state.playheads.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)); frames(&mut h, 2); assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id)); @@ -131,7 +131,7 @@ fn space_plays_after_a_pane_is_resized_with_the_pointer() { frames(&mut h, 3); assert_eq!(first_size(&h), 420.0); // The divider keeps keyboard focus so arrows can nudge it; that must not cost the editor its shortcuts. - assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(Id::new(("dock-gutter", String::new())))); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(Id::unique(("dock-gutter", String::new())))); press(&mut h, Key::Space); assert!(h.state().playback.playing, "Space must start playback after resizing a pane (#1482)"); press(&mut h, Key::Space); diff --git a/crates/ui-egui/tests/it/ui_keyboard_focus.rs b/crates/ui-egui/tests/it/ui_keyboard_focus.rs index 8685d4933..f03b01ca6 100644 --- a/crates/ui-egui/tests/it/ui_keyboard_focus.rs +++ b/crates/ui-egui/tests/it/ui_keyboard_focus.rs @@ -32,9 +32,9 @@ fn tab_traversal_and_space_select_only_the_focused_tool_with_visible_focus() { h.run_steps(4); h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Tools], active: 0 }; h.run_steps(3); - let first = Id::new(("tool", "Selection".to_string())); - let second = Id::new(("tool", "TrackSelectForward".to_string())); - let third = Id::new(("tool", "Ripple".to_string())); + let first = Id::unique(("tool", "Selection".to_string())); + let second = Id::unique(("tool", "TrackSelectForward".to_string())); + let third = Id::unique(("tool", "Ripple".to_string())); for _ in 0..128 { key(&mut h, Key::Tab, Modifiers::NONE); if h.ctx.memory(|m| m.focused()) == Some(first) { @@ -76,8 +76,8 @@ fn dock_tab_traversal_and_enter_change_the_visible_panel_without_editing() { h.state_mut().ui.show_status_bar = false; h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Effects], active: 0 }; h.run_steps(3); - let first = Id::new(("tab", String::new(), 0usize)); - let second = Id::new(("tab", String::new(), 1usize)); + let first = Id::unique(("tab", String::new(), 0usize)); + let second = Id::unique(("tab", String::new(), 1usize)); for _ in 0..128 { key(&mut h, Key::Tab, Modifiers::NONE); if h.ctx.memory(|m| m.focused()) == Some(second) { diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 000000000..7e2b2f923 --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1563 @@ +# FilmCraft — egui 0.37 migration plan + +Status: local implementation revision after the independent adversarial reviews of +`plan-reviews/filmcraft-v1.md` and `filmcraft-v2.md`. The owned checkout now contains the +0.37 migration work; commits, pushes, publications, and final immutable pins/lock remain +gated. This document is the implementation contract, not publication acceptance. Every +statement about runtime behavior below is source inspection at the recorded revisions unless +it is explicitly labelled a proposed test or a proposed command. + +This revision resolves the review findings inline, including the dependency DAG. Section 17 +is a disposition index only; it does not override any section. + +## 1. Scope and exact baseline + +| Item | Value | +|---|---| +| Repository | `storytold/filmcraft` | +| Assigned checkout | `/home/codex/work/CraftApps/egui037-migration-20261022/filmcraft-implementation` | +| Current branch HEAD | `fa550ce2d07bdfee6b1d7b6835e8abc8c6913d00` (2026-10-24; migration work is uncommitted on this base) | +| Planning baseline (historical) | `abf44fda9015f4b7a96dc338f6042499bf8e9d9f` (2026-10-22, "Reject non-hex characters in colour hex parsing (#2600)") | +| `Cargo.toml` SHA-256 (current checkout) | `b41244b83b0aff71ab6e122826d20a49a2d59c35e87da57c8dfd228a962ec2cf` | +| `Cargo.lock` SHA-256 (current checkout) | `67528fa8486c963dadbaf04e487ab5a8af59ac8f98dac97e1d51f40b953b5bb5` | +| Workspace | resolver 3, edition 2024, Rust 1.95, 50 crates plus 3 apps and `xtask` | +| Current UI stack | `egui`, `eframe`, `egui-wgpu`, `egui-winit`, `egui_glow`, `egui_kittest`, `emath`, `epaint`, `epaint_default_fonts` at `0.37.0`; `winit 0.30.13`; `wgpu 30.0.1`; `accesskit 0.24.1`; `arboard 3.6.1` | +| Shared UI dependency | Current manifest: `craft-ui` at `5c874d51eeb3de70e735c8ce87a59a7dedd47675`, `craft-media` at `f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4`, `craft-docpath` at `82a24b006eefa036b43c09a67fb9a3fbd17ffe63`. Reconciled PR #199 base: `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`; final immutable pins and lock remain G-PINS/G-CL2F/G-CRAFT-LIBS gates. | +| Target release | `egui 0.37.0` = upstream commit `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` | +| Research candidate | `aiongg/egui` `craft/0.37` = `a4f9a17f64a6bae61350fb85804c9df0d9865916`; `aiongg/winit` `craft/0.30.13-lang` = `8b35388bd719e8cc86a95971ba0bc026c9a8f058` | +| Production fork pins | **Unknown and deliberately not invented.** They become valid only after reviewed, published immutable `storytold/egui` and `storytold/winit` commits exist. See §3.2 and §15 gate G-PINS. | +| Evidence directory | `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022` | +| Historical diagnostics | `/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/filmcraft.errors` — older checkout (FilmCraft manifest 0.10.0), clues only, never acceptance | + +The existing `App::logic` / `App::ui` split and root-`Ui` architecture are **not** introduced +by 0.37 and must not be rewritten as if they were. This plan changes where frame side effects +run and how input is handled across layout passes; it does not change the command, undo, +project or persistence model. Do not add a FilmCraft-specific `egui` compatibility crate. Use +the new APIs directly, and add a helper to `craft-ui` only for semantics that at least two +consumers need with the same contract. + +This repository has: + +- one root workspace (`Cargo.toml`) with `members = ["crates/*", "apps/*", "xtask"]`; +- one workspace lockfile (`Cargo.lock`, 708 packages); +- no `[patch]` section, no `vendor/` directory, no source replacement and no local crate copy; + `.cargo/config.toml` contains only the `xtask` alias; +- exactly four manifests that mention `egui`: the workspace, `crates/ui-egui/Cargo.toml`, + `apps/filmcraft/Cargo.toml` and `apps/filmcraft-web/Cargo.toml`; +- a native entry point (`apps/filmcraft/src/main.rs`), a wasm entry point + (`apps/filmcraft-web/src/lib.rs`), a CLI app and a web app; +- no `egui_plot`, `egui_extras` or `egui_dock`; all other crate manifests scanned for `egui`, + `eframe`, `winit`, `rfd` and `egui_*` contain none; +- `crates/ui-egui/Cargo.toml` has the only relevant target cfgs: `rustix` for Unix + thread/process clocks and `cpu-time` for Windows; +- `craft-libs` vendors `krilla`/`hayro` for PDF work, unrelated to the windowing stack; this + plan does not touch them. + +### 1.1 Reverse dependency: EffectCraft + +EffectCraft (`9581b53c3f8945683252af0eb5fac79e0e43aed4` in the owned checkout) pins fifteen +`filmcraft-*` crates to `2c5ba7a72619935f0481f99d59409e0d1844b234` in `effectcraft/Cargo.toml`. +That revision is not an ancestor of FilmCraft baseline `abf44fda`; it is reachable from +`origin/effectcraft/webm-alpha`. None of those crates (`media`, `codecs`, `frame`, `color`, +`time`, `project`, `interchange`, `geom`, the encoders/decoders and the muxer) contains +`egui`, and EffectCraft does not depend on `filmcraft-ui-egui`. + +Decision: EffectCraft needs no change for this migration. Retain its separate branch pin. +Re-audit only if (a) the pin moves to FilmCraft main or another revision that changes a +shared public type, or (b) EffectCraft starts consuming `filmcraft-ui-egui` (directly or +through another crate). The re-audit must check the actual Cargo identities, not just a +version string. This is the required explicit cross-repository check; it is not a silent +deferral of FilmCraft's own compatibility work. + +## 2. Current architecture and lifecycle + +### 2.1 Entry points + +| Target | Entry point / symbol | Notes | +|---|---|---| +| Native desktop | `apps/filmcraft/src/main.rs:80` `main`, `:139` `eframe::run_native`, `:168` `native_options`, `:190` `app_creator` | wgpu only; features `default_fonts`, `wgpu`, `accesskit`, `wayland`, `x11`; no `system_fonts`, no `links` | +| macOS native | `apps/filmcraft/Cargo.toml` target deps `muda`, `objc2-*`, `winit`; `main.rs:430` `agent_event_loop`; `native_menu.rs` | custom titlebar and menu; `--control` must not activate the app | +| Linux native | `apps/filmcraft/Cargo.toml` `zbus`; `main.rs` `run_dialog_off_thread` | X11 and Wayland at runtime; XDG portal dialogs | +| Windows native | `apps/filmcraft/Cargo.toml` `winresource`; `graphics.rs` DX12/default/software ladder | DX12 FXC shader compilation is relevant to startup measurement | +| Browser | `apps/filmcraft-web/src/lib.rs:125` `start`, `:156` `WebRunner::start`, `:92` `impl eframe::App for WebApp` | wasm32 only; WebGPU with WebGL2 fallback; WebCodecs; OPFS | +| Headless UI | `crates/ui-egui/tests/scripted.rs` and the other `tests/*_ui.rs` `Harness::builder().build_eframe(...)` | `egui_kittest` drives the same `FilmcraftApp` | + +### 2.2 The app object + +`FilmcraftApp` is in `crates/ui-egui/src/lib.rs`. The UI code is already split: + +- `crates/ui-egui/src/lib.rs:2133` `impl eframe::App for FilmcraftApp::logic`; +- `crates/ui-egui/src/lib.rs:2193` `impl eframe::App for FilmcraftApp::ui`; +- `crates/ui-egui/src/lib.rs:1807` private `FilmcraftApp::frame(&mut self, ui: &mut egui::Ui)`, + called from `App::ui` and wrapped in `catch_unwind`; +- `cargo xtask` only builds/tests; FilmCraft does not implement a second `update` path; +- the main workspace is custom-drawn: `App::ui` receives the root `Ui`, then calls + `header::show`, `dock_area`, `panels::*` and dialog functions. It does not use + `egui::TopBottomPanel`, `SidePanel` or `CentralPanel`; +- floating panels are `egui::Area`s managed by `crates/ui-egui/src/dock.rs` and `dock_ui.rs`, + not native viewports. There is no `show_viewport_deferred` and no child-window dock; +- dialogs use `egui::Window::new(...).show(ctx, ...)`, valid only inside a pass. + +### 2.3 Where side effects live (exhaustive inventory) + +These are the behaviours that must survive hidden logic-only calls, forced hidden paint and +multi-pass layout without duplication. All clock-driven entries use a monotonic host clock +after migration; no hidden work may read `ctx.input(|i| i.time)`. + +| Effect | Current location | Required migration decision | +|---|---|---| +| Theme/font installation and language fallback | `lib.rs:2133` `logic` (`theme::install`, `i18n::install_japanese_font`, `chinese_font_available`, `korean_font_available`) | Keep in logic; once-per-process state, not per pass; never call `Context::root_ui` | +| Control channel, deferred requests, input waiters | `lib.rs:1672` `drain_control`, retry deadlines `:1682`, `now - last_ui_time > 0.25` raise heuristic `:1679` | Logic; consumed `try_recv` queue; monotonic `now`; `ui.*` requests that need composition send `ViewportCommand::RequestPaintWhileHidden` (see §4.5) | +| Screenshot settle / issue / collect | `lib.rs:1702` settle timestamp, `:1714` `issue_screenshots`, `:1744` `collect_screenshots`; `queued_screenshots` `*frames += 1` (`lib.rs:1717-1720`), `monitor_inexact` `mem::take` (`:2182`), `timeline_still` increment (`:2163`) | Logic; monotonic deadline; `ScreenshotCallback` plus the CL-2 completion slot. Settle counters advance once per rendered frame/pass identity, not per logic call; a queued-but-not-yet-issued hidden request repeats `RequestPaintWhileHidden` until issued, after which the screenshot command itself forces the paint. | +| FPS, frame time, close interception | `lib.rs:2133` `logic` (`ctx.input(i.time)`, `visible()`, `close_requested()`) | Logic; monotonic host clock; viewport metadata is fresh in `run_logic`, input time is not | +| Frame worker pump | `lib.rs:2193` `ui` calls `self.frames.pump(...)` | Logic/background tick; do not rely on paint | +| Persistence polling | `lib.rs:1825` `frame` calls `session.poll_persistence()` | Logic; once per state transition, not per pass | +| Engine event drain | `lib.rs:1826` `session.drain_events()` | Logic; drain the one-shot queue; an event is consumed exactly once regardless of pass count | +| Toast timestamp | `lib.rs:1840` `Event::Toast` stamps `ctx.input(i.time)` while `drain_events` moves to logic | Stamp with the monotonic host clock; repeated logic calls must not re-time or duplicate the toast | +| Media refresh and pool sync | `lib.rs:1822` `sync_pool`, `:1825` `tick_media_refresh` (`:1777`) | Logic; monotonic deadlines and `request_repaint_after`; consumed scan state | +| Preferences application | `lib.rs:717` `apply_prefs`, called from `frame` | Logic or known UI pass 0; idempotent; font/visual side effects once | +| Drop import | `lib.rs:1473` `handle_drops` | `App::ui` pass 0 only; uses `RawInput` events; never `run_logic` | +| Native menu / command inbox | `lib.rs:1846` command inbox and `menus::invoke`; `:1860` `sync_native_menu` | Command inbox in logic (consumed channel); native menu sync in UI pass 0; `menus::invoke` must not run twice per frame | +| Keyboard shortcuts and focus/IME | `lib.rs:1546` `handle_shortcuts` | `App::ui` pass 0 only; `consume_key` remains the anti-duplication mechanism; fork E19 Latin-layout handling | +| Playback/source playback/scrub audio | `lib.rs:1128` `advance_playback`, `advance_source_playback`, `scrub_audio` | Logic; monotonic host clock; set playhead from anchor + elapsed, never accumulate per call | +| Undo press transaction | `lib.rs:2000` `begin_press` | `App::ui` pass 0 before UI; one press transaction per user gesture | +| Escape drag rollback | `lib.rs:2011` `cancel_value_drag` | Latch Escape and the press mark in UI pass 0; apply rollback exactly once after the final UI pass (§4.4) | +| TTS selection following | `lib.rs:1865` `panels::tts::follow_selection` | Logic or UI pass 0; idempotent selection identity | +| Job watch / auto-play after render | `lib.rs:1916` `watch_jobs` | Logic; consumed job results; status-bar drawing remains in UI | +| Auto-save dirty poll | `lib.rs:1818` `session.is_dirty()` and `request_repaint_after` | Logic; monotonic deadlines; worker stays alive while hidden | +| Dynamic trim / loop playback (`trim.tick`) | `lib.rs:1817` `panels::trim_monitor::advance`; `panels/trim_monitor.rs:39-48` executes `session.execute("trim.tick", {"clock": ctx.input(i.time)})` on every frame | Move to logic; monotonic host clock; compute an idempotent target time (same target called twice is a no-op), not one tick per pass; keep `request_repaint` | +| Metadata selection follow | `lib.rs:1814` `panels::metadata::follow_selection` | UI pass 0 or idempotent logic state; follows selection identity | +| Auto/undo frame bookkeeping | `lib.rs:1813` `self.auto.begin_frame()` | UI pass 0 once; no second call in a discarded pass | +| Timeline view sync | `lib.rs:1844` `self.sync_timeline_view()` | UI pass 0; idempotent | +| Native menu sync | `lib.rs:1860` `self.sync_native_menu()` | UI pass 0; idempotent | +| `last_ui_time` update | `lib.rs:2213` `self.last_ui_time = ctx.input(i.time)` | UI pass 0; monotonic host clock; drives the control raise heuristic and screenshot settle heuristics | +| Input waiters drain | `lib.rs:2193` `App::ui` tail and synthetic queue | UI pass; consumed waiter vector; respond once | +| Synthetic input / control input | `raw_input_hook` `lib.rs:2092`, synthetic queue | `raw_input_hook` runs before every `run_ui` frame and before `update_logic_only` ticks. Keep one unapplied synthetic gesture step in app state across repeated hidden logic ticks until a full UI pass consumes it; never collapse a press into its release or inject the same step twice. | + +Additional deadline/side-effect calls found by the review and covered by this table: +`drain_control`'s `now + 3.0` retry deadline, its `now - last_ui_time > 0.25` raise decision, +`issue_screenshots`'s `settle` and timeout, `tick_media_refresh`'s persisted `last` timestamp, +the auto-save `request_repaint_after(1s)`, TTS `request_repaint_after(100ms)`, the synthetic +input repaint, and the `monitor_inexact`/`timeline_still` settle counters. None may use stale +`ctx.input(i.time)` after the lifecycle split; the toast stamp and settle counters have explicit +rows above. + +### 2.4 Actual multi-pass exposure + +- FilmCraft does not call `Context::request_discard` (verified by grep). +- egui 0.37 components used by FilmCraft can request it: `Grid` on first show + (`crates/egui/src/grid.rs`, "new Grid" path), `Aligned` on size change + (`containers/aligned.rs`), and `Resize` on a sizing pass (`containers/resize.rs`). FilmCraft + uses `Grid` in many dialogs, `ResizeHandle` through `craft_ui::layout`, and `Aligned` only if + a dependency introduces it. +- Both native `crates/eframe/src/native/epi_integration.rs::update` and web + `crates/eframe/src/web/app_runner.rs` call `app.logic` **and** `app.ui` inside the + `Context::run_ui` closure. Both call logic once per layout pass, not once per frame. + When the root window/tab is hidden and no paint is requested, native + `update_logic_only` and the web runner call `Context::run_logic` with no UI pass. +- `run_ui` may run a second pass after a discard. `RawInput::take()` moves `events` out, so + pass 1+ receives an empty event list; `InputState` recomputes pointer clicks and + `key_pressed` only from `new.events`. A click or key seen in pass 0 is therefore not + re-derived in pass 1. +- After a normally completed `run_ui`, `current_pass_index()` is **zero**, including after + multiple passes: `end_pass` takes the output. Do not claim or test a stale nonzero index. + A pass-index first-pass guard is valid only for genuinely once-per-layout-frame UI state + under this pinned integration, and needs a regression proving hidden calls still run. It + must not suppress fresh asynchronous results, control commands or widget actions merely + because a later pass has a nonzero index. +- `InputState::viewport().visible() == Some(false)` does **not** prove that the current + callback has no fresh input. A pending screenshot or `RequestPaintWhileHidden` can force a + real UI pass while minimized/occluded, and that pass can carry fresh input. Do not infer a + "logic-only" phase from visibility. + +## 3. Dependency and pin strategy + +### 3.1 Shared `craft-libs` baseline — corrected facts + +- The reconciled shared-library base is the merged PR #199 squash + `3832c47ef68a0e6214f2c1e85f3526b6adcb686e` on `storytold/craft-libs`. The implementation + checkouts use that base; `49190884278f16c9a9f5823d7698a8ffb5deb062` and its verification + below are historical planning evidence. +- GitHub `repos/storytold/craft-libs/branches/main` returned + `49190884278f16c9a9f5823d7698a8ffb5deb062` at approximately 06:25 UTC on 2026-10-23. This was + the shared-library baseline at planning time. +- The local `origin/main` ref (`b1401e3`) is stale. A stale remote-tracking ref **does not + prove** that `4919088` is unpublished. The review's "unpublished local/audit branch" claim + is rejected; the corrected evidence is the GitHub main verification above. +- FilmCraft's current manifest pins `craft-ui` at + `5c874d51eeb3de70e735c8ce87a59a7dedd47675`, `craft-media` at + `f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4`, and `craft-docpath` separately at + `82a24b006eefa036b43c09a67fb9a3fbd17ffe63`. The old `5c874d51` commit was fetched exactly + into the owned `craft-libs` checkout for inspection without changing HEAD or any source. + These manifest pins are not the final publication pins. +- Between `5c874d51` and `4919088`, `crates/ui` changes seven files, **607 insertions and two + deletions**: `README`, `docking/model.rs`, `docking/surface.rs`, `docking/tests.rs`, + `lib.rs`, new `printing.rs` and `printing/tests.rs`. `Cargo.toml` is unchanged. + - The docking change is the protected-group fix: `MoveFloating` now requires every + floating-group member to be movable, including the resize grip. Preserve the tests from + `d66e345`, `fd9cf54` and `c67b1e1`; this intentional security behavior must not be + reverted to match the old FilmCraft baseline. + - Printing is additive and lazy. Enabling the updated crate must not start discovery at + startup. Relevant print commits include `b700ae7`, `d27646a`, `4cae45d`, `2bbef53` and + `4f85efb`. +- `craft-docpath` has no `egui` dependency. Keep it at `82a24b0` unless a later actual + cross-type or feature reason requires the move; do not fold it into the `craft-ui` bump. +- PR 199 is **not** mergeable as a tree. Its head `b68de8c` and historical audit tree + `ec8840a` are stale relative to `4919088`, omit current `craft-platform`, and revert + security, settings, docking and printer fixes. Reuse only the API port hunks from + `8614e4a04fae516fba16c22b31ff1008cb6be10b` and the relevant README changes from + `1764bca`, applied onto current `4919088`. Preserve intervening fixes and provenance. + Never merge `b68de8c` or `ec8840a` as a tree. +- The shared 0.37 port and the CL-2 capture helper will produce a later published + `craft-libs` revision. FilmCraft's final compatibility review must compare its actual old + API/use sites against that final published revision, not against `b68de8c` or a branch + point. + +### 3.2 One coherent production graph, not a stock-0.37 stage + +Stock 0.37 is a **local comparison** and CI control only. It is not a landable production +stage. The production landing uses one coherent graph: the final reviewed immutable +`storytold/egui` family, the final reviewed immutable `storytold/winit`, and the matching +published `craft-libs` revision. The previous coherent 0.36.2 graph remains the rollback +target. + +The exact production pins are **not known yet** and must not be invented. `a4f9a17f` and +`8b35388b` are aiongg research candidates and comparison sources, not approved production +pins. Gate G-PINS (§15) blocks the entire landing stage until: + +1. the complete reviewed/published `storytold/egui` P8 commit is immutable: E1-E21, + P2b bounded async W15P routing, P7 held-key pairing, P7b/E21b, P7c selected #8763/#8748 + fixes and justified performance fixes; +2. the completed reviewed/published `storytold/winit` commit contains the bounded W15P + transfer path, the W27 owner/support lifecycle and the required W1-W27 behavior; +3. the matching published `storytold/craft-libs` commit contains CL-1 (the PR-199 API hunks + and one coherent fork graph) and the frozen CL-2F helper; +4. the exact SHAs, feature sets, provenance and test mappings are recorded in the suite pin + manifest and the ledger. + +Until then, no `[patch]` block in FilmCraft names a fabricated or guessed revision. The +eventual block must name the same immutable revision for every egui-family package that can +resolve, plus `winit`; a patch section inside `craft-libs` does not propagate into this root. +`egui_extras` is unused. `egui_plot` does not exist in FilmCraft; do not add it because its +version numbering differs. `winit` stays exactly `0.30.13`. + +The final production manifest will look like the following shape, with `REPLACE_WITH_*` +resolved only after G-PINS: + +```toml +[patch.crates-io] +egui = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +eframe = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +egui-winit = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +egui-wgpu = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +egui_glow = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +egui_kittest = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +epaint = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +emath = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +ecolor = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +epaint_default_fonts = { git = "https://github.com/storytold/egui", rev = "REPLACE_WITH_APPROVED_EGUI_SHA" } +winit = { git = "https://github.com/storytold/winit", rev = "REPLACE_WITH_APPROVED_WINIT_SHA" } +``` + +If a stock-0.37 comparison is needed, it lives in a separate local/CI branch or harness with +its own lockfile and evidence. It is never merged to main as an intermediate production +state, and it does not change the production pin policy. + +### 3.3 Capture-helper sequence (CL-2 -> adapters -> CL-2F -> consumers) + +The suite capture contract is one completion slot per admitted request: the app holds it +strongly and the callback holds it weakly. + +1. **CL-2** in `craft-libs` (`crates/ui/src/capture.rs`): provisional bounded completion-slot + prototype and tests. The app owns the request ID, path, deadline, settling policy, + capture-frame geometry, DPI/scale identity and reply schema. +2. **DesignCraft and GridCraft adapters** against that exact provisional revision. DesignCraft + exercises multiple viewports; GridCraft exercises a single window. Their tokens, paths, + settle delays, deadlines and replies remain app-owned. +3. **CL-2F** freezes the helper API after joint review of those adapters. +4. **FilmCraft and the other consumers** pin the published revision containing CL-1 and + CL-2F in one atomic dependency stage, not a branch head and not a path dependency. + +All fourteen capture consumers adopt this contract. The eleven apps that already declare +`craft-ui` (CADCraft, DesignCraft, EffectCraft, FilmCraft, GridCraft, LightCraft, PdfCraft, +PhotoCraft, SoundCraft, VectorCraft and WordCraft) import the helper from the published +revision that contains both CL-1 and CL-2F in the same atomic dependency stage. DesignCraft +and GridCraft are the pilot adapters against the reviewed CL-2 provisional revision before +CL-2F freezes the API. DeckCraft, FigCraft and ArtCraft Native do not declare `craft-ui`; +they use contract-compatible local adapters unless they choose to add the dependency. Any +consumer that already carries an incompatible `craft-ui` bump must reconcile it in that same +atomic dependency stage, not a separate registry-only state. The earlier "thirteen `craft-ui` +consumers" wording was based on substring matches and is superseded by the exact TOML-key +inventory. + +The helper contract, which every adapter must satisfy: + +- the app's live request record owns the completion slot and a strong viewport waker; the + callback holds only a `Weak` reference to that slot, never a strong `Context` or app + reference and never a cycle back into the owning context. `ScreenshotCallback::new` does + not request a new frame when data arrives (`egui/src/data/screenshot_callback.rs:22-24`), + so the live slot's waker must request the repaint after the slot lock is released; +- the request record owns one slot; correlation is by ownership, not callback order or a + second global token map; +- admission uses the shared defaults of **four outstanding requests, 128 MiB per image and + 256 MiB aggregate reserved in-flight/completed bytes**; a stricter documented app cap is + permitted (the shared SoundCraft example is 64 MiB/image and 192 MiB aggregate with the + same four-request count). Check the count and aggregate budget before issue, reserve + checked `width * height * 4` bytes, and do not evict an earlier admitted request; +- record the target-UI logical bounds and a geometry epoch at issuance and bind the returned + pixels to that epoch. Cancel/retry on any intervening resize, DPI, zoom or surface-epoch + change before save, **even a change back**; with an unchanged epoch derive pixel scale + from the returned image dimensions and verify aspect/rounding. A callback carries pixels + only, not capture metadata; +- cancel, close, drop and late callbacks release the image and send no reply/file; release + the slot lock before waking the owning viewport. The callback has no repaint request of its + own; the live slot's waker is the only wake path and runs after unlocking; +- the app keeps the request ID, output path, serialization, timeout, settling and reply + schema; the callback does no PNG encoding, file I/O, command execution or long locking; +- retained image bytes return to zero after drain/shutdown; a post-hoc size check does not + substitute for pre-issue accounting. + +### 3.4 Lockfile and reachability checks + +Before publication, run these against a clean checkout and a fresh `CARGO_HOME` (or with +`--locked` after fetching). These are proposed checks, not executed here: + +```sh +cargo metadata --locked --format-version 1 > /dev/null +cargo tree -i egui@0.36.2 # must report no match +cargo tree -i egui # exactly one 0.37.0 source +cargo tree -i winit # exactly one pinned source +cargo tree -d -i winit # no duplicate winit +cargo tree -d -i wgpu # one resolved wgpu source/version +cargo tree -e features -i egui | grep -E 'default_fonts|monochrome_emoji_fonts|clipboard' +``` + +Add a CI/`xtask` check that fails if: + +- an egui-family package has a source other than the chosen immutable fork; +- `winit` has more than one source or a version other than 0.30.13; +- a published manifest contains `path = "/home/`, `path = "/Users/` or an uncommitted local + checkout; +- a git pin changes revision without the same change in the suite pin record; +- the required `clipboard` or `monochrome_emoji_fonts` feature is absent from the selected + native/egui feature set. + +The lockfile is committed. A fresh clone with `cargo fetch --locked` is the publication +reachability test. + +### 3.5 Feature policy + +The current feature lists are explicit. Migrate to 0.37 with these decisions: + +- workspace `egui`: `default-features = false` and features `["default_fonts", + "monochrome_emoji_fonts"]`; +- native `apps/filmcraft`: keep `default_fonts`, `wgpu`, `accesskit`, `wayland`, `x11`, and + **add `clipboard`**. Without `clipboard`, eframe 0.37's `egui-winit` uses the in-app + fallback clipboard, so cross-application text copy/paste silently stops working; +- `crates/ui-egui`: keep `default_fonts`, `wgpu`, `accesskit`; the native app root is + authoritative for OS clipboard; +- web `apps/filmcraft-web`: keep `default_fonts`, `wgpu`; the eframe `clipboard` feature is + native (`egui-winit`) and is not added to wasm. Verify browser clipboard behavior in the + browser workflow instead; +- do **not** add `system_fonts`; do **not** add `links` or `web_screen_reader` by accident. + Record them as deliberate opt-outs in the PR (`links` is not used; `web_screen_reader` is + a separate speech/accessibility decision, not part of this migration). Revisit only with + measured evidence. + +`monochrome_emoji_fonts` is not implied by `default_fonts` in 0.37. It must be explicit; +otherwise the bundled `NotoEmoji-Regular`/`emoji-icon-font` faces disappear and symbols such +as `✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑` can become tofu or switch to a platform face. + +These feature decisions apply to production roots. They do **not** flow through +`egui_kittest`: its normal `egui` dependency is `default-features = false`, and only its own +dev-dependencies enable fonts, which do not propagate. Test roots must enable the features +explicitly or install controlled fonts (§11.1). + +## 4. Lifecycle and root-Ui design + +### 4.1 Rule: logic is idempotent background; UI is composition; input is pass-0 only + +Introduce private `FilmcraftApp::run_logic_frame(&mut self, ctx: &egui::Context)` and +`FilmcraftApp::run_ui_frame(&mut self, ui: &mut egui::Ui)`: + +- `App::logic` runs `run_logic_frame` on **every** call. Under this pinned eframe/egui + integration, native and web call `App::logic` once per `run_ui` layout pass, and also call + it through `run_logic` for hidden ticks. Therefore `run_logic_frame` must be safe to call + more than once per frame: + - background queues are consumed (`try_recv`, `drain`, `Option::take`), so a second call + sees no work; + - playback/trim/autosave/deadline work compares a monotonic host time and computes an + idempotent target; calling it twice at the same instant does not advance twice; + - engine events are drained once and processed as values, not replayed; + - no logic path reads UI input events, `consume_key`, `Response`, or stale input time. +- `App::ui` runs only `run_ui_frame`. Fresh-input handling (drops, shortcuts, `begin_press`, + Escape, IME/focus, synthetic queue, screenshot request dispatch) runs in **pass 0** of a + UI pass, where events are fresh. Pass 1+ has no events and must not re-derive them. +- `raw_input_hook` runs before each UI frame **and** before each `update_logic_only` tick + (eframe `prepare_raw_input`). It must therefore advance at most one synthetic gesture step + per call and leave that step in app state until a full UI pass consumes it; repeated hidden + logic ticks must not drain a whole press/release sequence or inject it twice. +- `run_logic_frame` is wrapped in `catch_unwind` with a latch: an unwinding logic pass sets + `ui_error`, stops playback and does not re-run the same panicking code every tick or + re-log it every pass. `run_ui_frame` keeps its catch around `frame(ui)` and the graphics + notice. +- `on_exit` stays in `FilmcraftApp` and calls `session.shutdown()` exactly once. + +No `ui_pass`/`hidden_tick` predicate based on `viewport.visible()` is used. Visibility is +used only for presentation policy (for example whether a hidden window should be raised), +never to infer that the current pass has no input or that background work is complete. + +### 4.2 Hidden work and monotonic time + +`Context::run_logic` leaves `InputState` events and time at the previous pass. Therefore: + +- all background code uses a monotonic host clock (`web_time::Instant` on all targets, or a + small `Clock` wrapper around it); no hidden work uses `ctx.input(|i| i.time)`; +- background code never reads `i.raw.events`, never calls `consume_key`, and never polls + `Response`/`read_response`; +- input-dependent work is in `App::ui` pass 0, not in logic and not inferred from a + visibility flag; +- `Window::show`, `Area::show`, and any other widget construction run only inside a UI pass; + they are never called from `run_logic_frame`; +- control `ui.*` requests and gesture stages that need composition while hidden use + `ViewportCommand::RequestPaintWhileHidden` (see §4.5). A screenshot request already causes + the needed paint. + +A regression test exercises visible single/multipass, repeated hidden logic ticks, a forced +hidden UI paint, restore with buffered events, a background completion between passes, and +one gesture yielding one command/undo. + +### 4.3 Do not use `Context::root_ui` recursively + +`Context::root_ui` panics if called while `App::ui`/`Context::run_ui` already holds the root +`Ui` (upstream #8535). FilmCraft does not need it. Keep passing the existing `&mut egui::Ui` +to `header::show`, `dock_area`, `panels::*` and dialogs. Where a helper takes +`&egui::Context` only to add widgets, change it to take `&mut egui::Ui` (or return an +action/shape), not a `Context` bridge. + +### 4.4 Multi-pass actions: no clearing on a no-event pass + +Immediate mode plus `request_discard` means a UI handler can run in a pass whose output is +discarded. FilmCraft's project mutations must run once, and must not be lost. + +Design: + +- Add a frame-scoped `pending_ui_actions` queue and an `input_generation` counter in + `crates/ui-egui/src/lib.rs`. Each action carries a stable `ActionKey` (command, target + identity, payload digest) and the generation in which it was queued. +- Input-driven handlers queue only in UI pass 0. Pass 1+ receives no events, so it cannot + re-derive a click or key. +- **Never clear the queue at the start of pass 1+.** There is no reliable way to predict a + future discard from inside pass 0; `ctx.will_discard()` describes the current pass's + output, not the next pass. +- Dispatch policy: + - input-driven actions execute at the end of pass 0 (the pass that saw the events); the + empty-event pass cannot recreate them, so this is exactly once; + - actions that must observe final layout (dialog OK/Apply/close, dock reflow, actions that + close a panel) stay queued across discarded passes and are drained at the end of the + first pass whose `ctx.will_discard()` is false; + - a duplicate enqueue in a later pass coalesces by `ActionKey`; it is never used as a + reason to drop the original; + - if a frame ends with deferred actions still pending (for example an abnormal maximum-pass + exit or a caught panic), do not silently drop them: keep them, surface a visible status + error, and dispatch once at the next safe pass. A test forces this path. +- `cancel_value_drag` (Escape drag rollback) is also pass-0-aware. In pass 0, if + `pointer.any_down() && key_pressed(Escape)`, latch `escape_cancel` together with the undo + mark. Apply the rollback once after the final UI pass (or after pass 0 when no discard + occurred), then clear the latch. The final pass has no key event, so it must use the latch, + not re-read `ctx.input`. The latch is scoped to the active press transaction; it does not + cancel unrelated queued actions. +- `begin_press` runs before UI in pass 0; `cancel_value_drag` sees the corresponding mark and + runs once. + +Tests: click + forced discard = exactly one command/undo step; click on a `Grid` first-show +frame; key shortcut + forced discard; Escape drag + forced discard; two queued actions with +the same key coalesce; an abnormal pending queue is surfaced and dispatched exactly once. + +This replaces the reviewed design that cleared the queue on `pass_index > 0`; that design +would have lost the user's click on every discarded frame. + +### 4.5 Screenshot callback, hidden paint and CL-2 + +0.37 removes `egui::UserData` and `Event::Screenshot`; `ViewportCommand::Screenshot` takes a +`ScreenshotCallback` (`crates/egui/src/data/screenshot_callback.rs`). + +Replace `lib.rs:1714 issue_screenshots` and `lib.rs:1744 collect_screenshots` with: + +- one CL-2 completion slot per request, owned by the request record; the live slot holds the + strong viewport waker (`Context` clone plus `ViewportId`, or `request_repaint` callback), + and the callback holds only a `Weak` slot; +- `ViewportCommand::Screenshot(ScreenshotCallback::new(move |image| { /* complete the weak slot once, unlock, then wake */ }))`; +- `ScreenshotCallback::new` does not request a frame when the data arrives. Completion must + unlock the slot and then call the stored waker; the callback has no repaint path of its own + and does not hold a strong `Context` or app reference; +- `collect_screenshots` drains the result in logic and calls `control::save_screenshot(ctx, + &image, path, crop)` as today; after timeout/close/cancel, release the slot and ignore a + late image; +- token/correlation still required: two queued screenshots may complete out of order; use + ownership plus the request ID, not callback order; +- admission and memory limits from §3.3: four outstanding requests, 128 MiB per image, 256 + MiB aggregate (a stricter documented cap is allowed), checked `width * height * 4` + reservation before issue, and revalidation/rejection after any resize/DPI/zoom epoch + change; +- the issuance record binds the target-UI logical bounds and geometry epoch to the returned + pixels; a change back to the old geometry does not restore validity. + +Hidden-window semantics are the 0.37 behavior, not the old assumption: + +- `ViewportCommand::Screenshot` maps to `ActionRequested::Screenshot`/`PaintWhileHidden` and + makes the integration run a UI pass even when hidden; the callback is then completed from + that forced paint. A hidden pending screenshot is therefore expected to deliver an image. +- A request that is still queued and not yet issued — settling frames or a hidden logic tick + — has no screenshot command to force its paint. Logic must send + `ViewportCommand::RequestPaintWhileHidden` while such a request is pending so the UI pass + that advances settling can run; once issued, the screenshot command itself forces paint. +- `ViewportCommand::RequestPaintWhileHidden` is also the supported way to force a UI pass for + other stages that need composition/gesture handling while hidden (for example control + `ui.*` requests that need fresh layout or settling without a screenshot). +- The screenshot timeout (`SCREENSHOT_TIMEOUT_S`) is a renderer/readback failure guard, not a + "hidden never presents" path. A test must not encode "hidden returns timeout" as normal + behavior. Test both branches: hidden + pending screenshot → image delivered, including the + queued-but-not-yet-issued settling case; hidden + no capture request → no capture (and, + where a request is admitted but the renderer cannot complete, the documented timeout + error). +- `raw_input_hook` (`lib.rs:2092`) no longer preserves `Event::Screenshot`; it clears input + events as it already clears hovered/dropped files. Update + `crates/ui-egui/tests/ui_control_input.rs:153`: assert close/viewport metadata survive and + no screenshot event is expected; add a native/VM check that the callback still delivers. +- `raise_for_control` (`lib.rs:1665-1673`) remains for occluded macOS raising without focus; + pair it with `RequestPaintWhileHidden` where a UI pass is required, rather than assuming a + hidden window never runs UI. + +### 4.6 Playback, audio, autosave, jobs, trim and deadlines + +- `advance_playback` (`lib.rs:1128`) sets the playhead from `anchor_tick + elapsed`; use a + monotonic host clock and do not accumulate dt. Repeated calls at the same time are + idempotent. +- `playback_mix` remains the live project snapshot; audio callbacks are outside egui. Logic + keeps polling `audio.played_frames()` while hidden. +- `FilmcraftApp::frames.pump` moves from `App::ui` to logic. The web runner's `WebApp::logic` + also pumps jobs/audio; its per-frame work must be idempotent because web calls logic per + pass. +- Auto-save: `Session::poll_persistence` and `session.is_dirty` move to logic; the recovery + journal worker must not depend on painted frames. +- `watch_jobs` splits: `poll_jobs` in logic handles completion and `play_after_render`; the + status-bar `job_status` remains in UI. +- `trim_monitor::advance` (`lib.rs:1817`; `panels/trim_monitor.rs:39-48`) moves to logic and + uses a monotonic clock. `trim.tick` receives an idempotent target time/step, so a second + logic call for the same pass does not advance edits twice; it still requests repaint. +- Metadata follow, `auto.begin_frame`, `sync_timeline_view` and `sync_native_menu` run once + in UI pass 0 (or are made idempotent if moved to logic). +- Screenshot settling still waits for `monitor_inexact` and `timeline_still`, both updated in + logic/UI with the monotonic clock. +- Add a CI grep that rejects direct `session.execute`, `menus::invoke` and `trim.tick` + execution from UI-only modules outside the queue/dispatch module and an explicit + allow-list. Until that check is green, the one-action guarantee is not claimed. + +## 5. Concrete API migration catalogue + +These are the source-grounded incompatibilities. Line numbers are baseline line numbers. + +### 5.1 `FontData` is a shared blob + +0.37 `epaint` has `pub type Blob = Arc + Send + Sync>` and +`FontData::from_blob(Blob, index)` (`crates/epaint/src/text/font_data.rs`). Replace literal +`FontData { font: Cow::... }` in: + +| File:symbol | Current code | Migration | +|---|---|---| +| `crates/ui-egui/src/cjk.rs:53` `find_system_font` | `FontData { font: Cow::Owned(face.data()?), index: face.info.index, ... }` | `FontData::from_blob(Arc::new(face.data()?), face.info.index)` | +| `crates/ui-egui/src/i18n/mod.rs:292` `system_japanese_font` | `FontData { font: Cow::Borrowed(bytes), index: face.info.index, ... }` | `FontData::from_static(bytes)` (bytes are already leaked and shared) | +| `crates/ui-egui/src/rtl.rs:92` `system_fonts` | `FontData { font: Cow::Owned(data), index: face.info.index, ... }` | `FontData::from_blob(Arc::new(data), face.info.index)` | + +Keep `Arc` in `font_data` maps; `FontData` is cheap to clone now. Add a regression +that a TTC face index is preserved (non-zero `info.index`), because the literal construction +was the only place that carried it. + +### 5.2 `MouseWheel` gained `source` + +0.37 `Event::MouseWheel` has a required `source: MouseWheelSource`. Add +`source: MouseWheelSource::Wheel` for the control-channel synthetic wheel in +`crates/ui-egui/src/control.rs:370`, and to the two tests that construct it: +`crates/ui-egui/tests/monitor_geometry.rs:176` and `crates/ui-egui/tests/panels_ui.rs:249`. +Use `Wheel` rather than `Unknown` so ScrollArea behavior and kinetic-scroll policies match a +real mouse wheel. Add a test that the timeline wheel path (`panels/timeline.rs:2221`) still +steps one tab per notch and does not start kinetic scrolling in headless tests. + +### 5.3 `WidgetType` -> `Role`, and one role per control + +`WidgetType` is a deprecated alias of `accesskit::Role`; `SelectableLabel` no longer exists +and `TextEdit` is `TextInput`. Replace the ten sites: + +| File:line | Current role | New role | +|---|---|---| +| `crates/ui-egui/src/dock.rs:673` | `WidgetType::Button` | `Role::Button` | +| `crates/ui-egui/src/dock.rs:725` | `WidgetType::SelectableLabel` | `Role::Tab` with `selected(active)`; the code already overrides the node role to `Tab` | +| `crates/ui-egui/src/dock.rs:765`, `:779` | `Button` | `Button` | +| `crates/ui-egui/src/dock_ui.rs:326` | `Button` (floating-panel resize grip) | `Role::Button` with the accessible name "Resize panel". Do **not** expose `Splitter` here; a floating-window grip is not a pane divider. Dock split dividers use `Role::Splitter`; update `crates/ui-egui/tests/shared_docking_ui.rs:201` to the chosen role in the same PR | +| `crates/ui-egui/src/panels/graphics.rs:1028` | `TextEdit` | `Role::TextInput` with a name | +| `crates/ui-egui/src/panels/tools.rs:27` | `SelectableLabel` | `Role::Button` with `selected`; a radio-group model is a later accessibility change | +| `crates/ui-egui/src/panels/monitor.rs:1013` | `Button` | `Button` | +| `crates/ui-egui/src/panels/source_drag.rs:46` | `Button` | `Button` | +| `crates/ui-egui/src/theme_favorites.rs:4` | `Button` | `Button` (favorite toggle; `CheckBox` is a later semantic change) | + +The previous `Button` vs `Splitter` contradiction is resolved: floating grip = `Button`; +dock pane divider = `Splitter`. The test is updated once, not left ambiguous. + +### 5.4 `ScrollArea::State::offset` is private + +0.37 provides `clamped_offset()` and `unclamped_offset()`; the old `offset` field is private. +Replace `crates/ui-egui/src/panels/media_browser.rs:576` and +`crates/ui-egui/src/panels/project_views.rs:516` (`out.state.offset.x`) with +`clamped_offset().x` for header alignment. Add a test that scrolls the project list +horizontally to the end and asserts the header’s first column x matches the last visible +row’s column x within one point. + +### 5.5 `Context::run_ui` callback is `&mut Ui` + +`crates/ui-egui/src/lib.rs:2411` currently writes: + +```rust +let mut out = ctx.run_ui(raw, |ctx| secondary = ctx.input(|i| i.pointer.secondary_clicked())); +``` + +Change to: + +```rust +let mut out = ctx.run_ui(raw, |ui| secondary = ui.ctx().input(|i| i.pointer.secondary_clicked())); +``` + +The other `run_ui` call sites already use `|ui|`. Do not call `Context::root_ui` inside these +closures. + +### 5.6 `Id::new` and `Ui::id` + +0.37 deprecates `Id::new` and `Ui::id` (`Id::unique` / `Ui::scope_id`). FilmCraft has 403 +`Id::new` occurrences across `crates/ui-egui/src` and tests. Under `clippy -- -D warnings`, +deprecation is a hard failure. + +- `egui::Id::new(x)` -> `egui::Id::unique(x)` for every root id; same hash, same identity. +- `Id::new(parent).with(child)` -> `Id::unique(parent).with(child)`. +- `UiBuilder::id(x)` -> `UiBuilder::scope_id(x)`; FilmCraft uses `id_salt` and has no + `UiBuilder::id` (verified), but `craft-ui` PR #199 performs its own conversion. +- `Ui::id()` -> `Ui::scope_id()`; FilmCraft has no `ui.id()` call (verified). +- Keep `id_salt` for child UIs; do not replace every `id_salt` with a global id. + +Add a grep-based CI check that no `Id::new(` remains in FilmCraft or its tests. + +### 5.7 `Event::PasteImage` + +0.37 emits `Event::PasteImage(Arc)` when the clipboard has no text. Update +`crates/ui-egui/src/lib.rs:2299` `clipboard_events_as_keys` to map `PasteImage` with +`command` to `Key::V`, with `shift` to `Key::Insert`, and plain to `Key::Paste`, exactly as +`Event::Paste`. Add tests alongside `ctrl_c_x_v_become_key_presses`. + +The native app’s `clipboard_media` hook (arboard) still supplies files/images to the Paste +command. On wasm, either keep the existing “FilmCraft clips only” path or extend +`ClipboardMedia` with the `PasteImage` bytes; that is a separate feature decision. Do not +double-read the clipboard if the event already carries an image. + +### 5.8 `Event::Screenshot` / `UserData` + +Covered in §4.5. Remove `egui::UserData::new` at `lib.rs:1721` and the `Event::Screenshot` +matches at `lib.rs:1753` and `lib.rs:2100`. + +### 5.9 Other 0.37 changes that are compile-compatible but visible + +- `default_fonts` off by default: explicit feature; `monochrome_emoji_fonts` explicit (§3.5, + §6.1). +- `clipboard` opt-in in eframe/egui-winit: native feature added (§3.5). +- `Id` deprecations (§5.6). +- `Panel` constructors take `impl AsIdSalt`: FilmCraft does not use `egui::Panel`, but + `craft-ui` and any new panel code must use the new form. +- `UiBuilder::scope_id`: see PR #199. +- `ComboBox` is built from atoms and takes `IntoAtoms`; existing `selected_text`/`show_ui` + calls remain source-compatible, but styling/measurement changes must be reviewed. +- `WidgetType::TextEdit` renamed to `Role::TextInput`; `TextEdit` itself uses + `Role::TextInput`. +- `Context::root_ui`/`run_pass`: not adopted; no recursive root UI. +- `ViewportCommand::Screenshot` callback and `RequestPaintWhileHidden`: adopted as above. +- `egui::Event::PasteImage`, `MouseWheelSource`, PRIMARY selection: adopted as above. +- kittest 0.37 hard-fails input widgets without accessible names (#8589/#8611). The first + green compatibility stage must name all input widgets, including all 56 `TextEdit` + construction sites; do not disable strict naming globally. + +## 6. Fonts, glyph coverage, language and RTL + +### 6.1 Font installation and bundled coverage + +`crates/ui-egui/src/theme.rs:420` `install` builds `FontDefinitions`, inserts Inter/JetBrains +Mono, then appends Japanese craft-fonts (`add_craft_fonts`), Chinese (`cjk::install`) and RTL +(`rtl::install`). Keep that order. + +The explicit `default_fonts` feature preserves egui’s built-in fallback, but in 0.37 the +monochrome emoji faces moved behind `monochrome_emoji_fonts`. Enable it explicitly. FilmCraft +starts from `FontDefinitions::default()` and uses `✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑` (20 occurrences across +10 files); with `system_fonts` off, those symbols can otherwise become tofu or switch to a +platform face. Add a glyph-coverage assertion for exactly these characters plus a screenshot +diff. + +`FontData` construction is §5.1. Keep the process-wide caches (`OnceLock`) and the single +system scan; do not call `filmcraft_text::fonts::scan_system()` in a UI pass. + +`eframe` 0.37 adds an optional `system_fonts` feature (fontique provider, #8512) and a +`system_font_fallback` option. **Do not enable it in the migration.** It changes startup cost +and the fallback face chosen for a missing glyph; the current explicit CJK/RTL fallback is +part of FilmCraft’s visual contract. Record it as an opt-in follow-up with measured startup +and glyph coverage, especially for color emoji; do not accept clamp-only as a substitute for +texture wrap/filter semantics. + +Pinned test fixtures are the exact set from the egui plan §10.6, fetched into the task’s +evidence fixture directory with their licence files. **Do not copy font binaries into this +repository.** The fixture set is: + +| Fixture | Source | SHA-256 | License | +|---|---|---|---| +| Arabic | craft-fonts `2481a8a324b986a45954e90045ed1f8f2efaaf3c` `fonts/noto-sans-arabic/NotoSansArabic.ttf` | `63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e` | OFL-1.1, adjacent `OFL.txt` | +| Thai | same craft-fonts `fonts/noto-sans-thai/NotoSansThai.ttf` | `5a1c559bb539583c8a1fd99d1c5b9491e5e14478c9cd2bd0970d5c3096cc9ef8` | OFL-1.1 | +| CJK SC | same craft-fonts `fonts/noto-sans-cjk-sc/NotoSansCJKsc-Regular.otf` | `2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b` | OFL-1.1 | +| Japanese | same craft-fonts `fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf` | `258d7156c165f2ff774b6efee637c22c3b950de0d8a10e501137061bc8085d01` | OFL-1.1 | +| Hebrew | google/fonts `8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5` `ofl/notosanshebrew/NotoSansHebrew[wdth,wght].ttf` | `7ef36a2c3593758cdb622e1bdef4f84523e92fbc3ccc667438dd80ff54c2de88` | OFL-1.1; fix axes weight 400/width 100 | +| Color emoji | googlefonts/noto-emoji `22e564626297b4df0a40570ad81d6c05cc7c38bd` `fonts/NotoColorEmoji.ttf` | `39ee3c587e10e89669b9ff32703261d10d5f9c4dd5ad147b6b5a1c5200591817` | OFL-1.1 | +| Bundled Latin/emoji | candidate `epaint_default_fonts` `Ubuntu-Light.ttf` | `80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70` | Ubuntu Font License, adjacent `UFL.txt` | +| Bundled mono emoji | candidate `epaint_default_fonts` `NotoEmoji-Regular.ttf` | `415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5` | OFL-1.1 | + +The fixture bytes and licence hashes enter the evidence manifest. A controlled comparison +uses a fixed common set; a separate default-policy visual comparison records each +baseline/candidate policy. System-provider tests are a nondeterministic integration row with +OS/font inventory recorded, not pixel-golden input. + +### 6.2 UI language and AccessKit language + +FilmCraft’s `i18n::Language` is a private list (`crates/ui-egui/src/i18n/mod.rs`, +`Language::ALL`; 12 languages, no Arabic/Hebrew UI). Add `Language::bcp47()` returning proper +tags (`en`, `ja`, `es`, `pt-BR`, `uk`, `zh-CN`, `de`, `ru`, `fr`, `id`, `it`, `ko`). Call +`ctx.set_language(Some(tag))` when the language changes and `frame.set_ui_language(Some(tag))` +in `App::ui` (fork E18) so the AccessKit root and the web hidden IME input use the same tag. +In kittest, `Frame::set_ui_language` is a no-op; the `ctx.set_language` call is the tested +path. + +Do not enable `Style::right_to_left` (fork E9) yet: FilmCraft offers no RTL interface +language, and mirroring the custom-drawn workspace without a full RTL audit would be worse +than leaving it LTR. Document RTL UI as a future feature gated on real translated layouts. + +### 6.3 Document RTL and the fork’s bidi work + +`crates/ui-egui/src/rtl.rs` currently compensates for 0.36’s lack of bidi: `line_sections` +splits RTL runs into words, reverses them, mirrors brackets and gives each word its own +`LayoutSection`. The fork (E5/E12) performs UAX #9 shaping and visual ordering itself. Keeping +both is a double-reversal bug. + +In P1, in the same stage that pins the fork, change `rtl::Lines`: + +- keep `has_rtl` and the `rtl` alignment flag; +- build one `LayoutJob` with `direction: TextDirection::Auto`, + `halign: if rtl { Align::RIGHT } else { Align::LEFT }`, `halign_min_is_start: true`, one + section, and the requested wrap width; +- delete `line_sections`, `mirror`, `in_word`, `line_ranges` and their unit tests once the + fork is pinned; replace them with tests on `Row::rtl_paragraph`, `Row::visual_order`, glyph + order and right alignment; +- keep `paint`/`paint_line` API so `timeline_captions.rs:152` and `text.rs:239` do not change + contracts. + +The fork's E5/E12 path and the removal of the manual reversal must ship in the same P1 +commit set; there is no intermediate state with both active. A negative regression first +demonstrates the double ordering (Arabic caption with the fork produces reversed words), then +P1 removes it. Test `"كلمة أخرى"`, `"قال FilmCraft 0.6 جيد"`, Arabic-Indic digits, brackets +and an LTR paragraph containing an RTL phrase; assert the leftmost drawn glyph of the first +visual word, not just “some Arabic glyph exists”. + +`TextEdit` keeps logical order; fork E13/E16 add grapheme/bidi-safe caret movement. Test +caption text editing (logical caret, Delete/Backspace, Home/End) and selection rendering. + +## 7. Input, IME, clipboard and PRIMARY + +### 7.1 Keyboard + +`handle_shortcuts` (`lib.rs:1546`) already uses `i.consume_key` and a panel/app-wide +specificity sort. Fork E19 adds `RawInput::keyboard_layout_is_latin` and +`latin_layout_chars`; `handle_shortcuts` must use the Latin-layout character for letter +shortcuts when the active layout is not Latin, so `Ctrl+Z` still undoes on a +Russian/Arabic/Hebrew layout and an unmodified `Z` does not become a tool shortcut. P7 +held-key pairing keeps a key's emitted egui identity for its release when the layout or +modifiers change, clears it on focus loss/IME/destruction, and does not redefine winit's +live `logical_key`. Add a kittest that constructs `RawInput` with +`keyboard_layout_is_latin: Some(false)` and +`latin_layout_chars: vec![(Key::Q, 'q'), ...]`, sends a physical key, and asserts the command +fires only for a Latin-letter key. Do not hard-code a platform. + +Input handling remains in `App::ui` pass 0, where the event list is fresh; pass 1+ must not +re-run it. + +### 7.2 IME and outgoing surrounding text (E21b/P7b) + +Fork E6 (request_focus does not interrupt composition), E8 (TextEdit glyph clipping), E21 +(`ImeEvent::DeleteSurroundingBytes`) and winit W18-W27 cover Windows/X11/Wayland/macOS IME. +The candidate has a confirmed gap: winit W27 exposes +`WindowExtWayland::set_ime_surrounding_text`, but egui-winit never calls it, and +`egui::IMEOutput` carries no logical text. E21 alone carries incoming deletion/commit only. + +This migration therefore requires **E21b/P7b** before acceptance. The authoritative +contract is the complete E21b section of `egui/docs/egui-037-migration-plan.md` plus +`winit-review-triage.md`; these bullets are FilmCraft's adoption summary, not a weaker +restatement: + +- `IMEOutput` carries one atomic descriptor: owner/context-generation identity plus the + surrounding-text state and payload. The owner generation is present **even when the state + is `Unavailable`**; an available empty document is distinct from unavailable support and + is sent as an explicit empty payload, not omitted. +- add a bounded transient payload of at most **4000 UTF-8 bytes**, extracted at valid UTF-8 + boundaries **without allocating a full document copy**, with indices remapped after + excluding preedit. If the selection cannot fit, emit explicit `Unavailable` rather than + presenting a truncated selection as complete. Keep `Clone`/`Eq`, update `Copy`-dependent + callers, and keep transient text out of Debug, serialization and persistence. +- built-in `TextEdit` produces committed logical text after applying deletion/commit/cursor + updates, excluding preedit, with UTF-8 byte cursor/anchor indices; password fields emit no + text. Normal-to-password, no-IME, focus-loss/reset, owner-change and unavailable + transitions clear old backend text; each payload belongs to the current IME-owning + widget/viewport. +- W27 gains an explicit owner/support transition: clear its cache and commit a + disable/enable boundary before publishing a new owner or support state. Merely changing + purpose or sending empty text does not clear protocol state. Available-empty maps to + `set_surrounding_text("", 0, 0)` plus commit; unavailable omits `set_surrounding_text` and + uses the committed clear boundary; when support returns, enable plus the full payload + precedes commit. Cancel the old preedit without inserting it into the new owner. +- egui-winit appends the latest completed pass's IME descriptor, owner and payload together, + including an authoritative `None`; a previous payload must not survive a later + non-text/password/`None` pass. The same finalized output and payload are appended + together across passes. +- on a current-epoch text-input-v3 `done` with a mismatched serial, apply the incoming edits + **once** and defer outgoing state until a matching serial. Obsolete owner/focus epochs + invalidate their pending batch; a current-generation serial mismatch is not a reason to + drop valid edits. +- the egui-winit Wayland adapter calls W27 with the transient payload on text, selection or + IME-owner change, per window and owner; macOS NSRange conversion remains a separate W26 + path, not a claim of a new macOS API. + +FilmCraft’s own custom `IMEOutput` producer is at +`crates/ui-egui/src/panels/graphics.rs:933-940`. It must supply the logical model text, the +preedit, and UTF-8 byte cursor/anchor ranges from its own scalar/grapheme coordinates; it must +not pass display-bidi text or a `None` placeholder. Its integration test must reject a +split-UTF-8 range and verify the model range maps to the emitted byte range. Built-in +`TextEdit` consumers in FilmCraft (caption, notes, search, metadata) inherit the producer and +need tests rather than custom adapters; the same inheritance applies to LightCraft’s built-in +consumers. The suite inventory of custom producers is WordCraft `canvas.rs` (two), +VectorCraft `canvas.rs`, FigCraft `shortcuts.rs`, EffectCraft `viewer_text.rs`, FilmCraft +`graphics.rs`, DesignCraft `canvas.rs`, DeckCraft `canvas.rs` and PhotoCraft `type_tool.rs`; +each needs its own adapter and test. + +Required tests: outgoing text changes immediately after delete+commit and deletion-only +batches; scalar/grapheme vs byte indices; supplementary emoji/combining marks/RTL logical +order; preedit exclusion and caret movement; long selection/window limits; same-window field +switch; two windows; focus loss/reset/late serial; initial empty, available-empty → non-empty +and unavailable/password → available transitions; multi-pass final-output selection where +pass 1 is text and pass 2 is password, non-text or `None`. Native Wayland Vietnamese/ +predictive IME verifies the protocol trace and editor content/undo. Headless tests cannot +prove native IME focus/composition. + +#### Film graphics IME ownership lifecycle + +Incoming `ImeEvent` carries no owner or epoch, so the custom Program-monitor editor keeps a +local causal lifecycle: `Active(owner)`, `TerminalPending(owner)` and `Stale`. A non-empty +preedit records `Active`; an empty preedit clears the visible composition but records +`TerminalPending`, because its commit/delete is a separate event. A target change or editor +close turns either state into `Stale`, so that pending terminal event cannot edit the +replacement target. A rejected stale commit is itself the terminal boundary and consumes the +tombstone immediately, allowing a preedit later in the same raw batch to start a new +composition. A stale empty preedit or delete is only tentatively terminal: the tombstone is +kept through the batch so a paired old delete+commit is rejected, and is consumed at batch end +when no commit follows. A lone stale non-empty preedit keeps the tombstone across batches, so a +preedit/commit pair split across batches cannot retarget either. This uses observed event order +only, never frame/time heuristics. Regressions cover an empty preedit followed by a target +switch before its commit, and a coalesced stale commit followed by a new preedit in the same raw +batch. An entirely unobserved platform reset remains indistinguishable from a new composition; +the first ambiguous composition after it may be dropped rather than claiming provenance. + +Platform rows: + +- Windows: Japanese and Korean IME in a caption/notes/search `TextEdit`; caret follows + composition; a key the IME consumes is not also a tool shortcut; commit adds exactly one + undo step. +- X11: Vietnamese Telex; one input context per window; no duplicate letters. +- Wayland: text-input-v3 surrounding text and delete-surrounding; no double commit; outgoing + bytes observed after commit/delete. +- macOS: press-and-hold accents, dictation, ideographic space, first Korean key; real + hardware required. +- Web: named keys are not typed as text; `lang` on the hidden input; IME commit in a text + field. + +### 7.3 Clipboard and PRIMARY + +0.37 adds PRIMARY selection (#8428). The production graph must integrate **both** E4 and +W15P: + +- W15P adds a winit-owned PRIMARY path on the existing Wayland connection/event queue and + per-seat lifecycle, using SCTK 0.19.2’s existing `PrimarySelectionManagerState`, device, + source and offer support. PRIMARY is a distinct protocol object; do not bind a second + regular `wl_data_device`. +- Egui 0.37 `get_primary`/`set_primary` routes through that path. E4 preserves password + exclusion and does not reclaim ownership for an unchanged selection. +- FilmCraft also uses `arboard` directly for files/images (`apps/filmcraft/src/main.rs` + `clipboard_media`). The arboard/smithay PRIMARY path is a measured fallback only if the + winit path cannot meet a proven requirement; it is not the planned end state and it is not + accepted merely by documenting a GNOME limitation. + +Required native matrix, on X11 and Wayland, under wlroots (Hyprland/Sway), KWin and Mutter +with supported capabilities recorded: + +- keyboard- and pointer-triggered selection plus middle-click paste at the clicked position. + A PRIMARY request uses a fresh serial from the event that triggered it; the protocol + explicitly allows pointer-driven selection and `set_selection` takes the triggering + event's serial. Do not impose a keyboard-only serial rule; +- serial freshness, owner/seat changes, empty/absent offers, self-paste; +- password selection never offered; +- unchanged selection does not reclaim ownership; +- bounded asynchronous transfer: total deadline **2 s**, **16 MiB** cap, at most **4 read + workers** and **2 writer threads** with queue depth **8**. Copy/PRIMARY ownership claims + stay synchronous on the fresh triggering input turn (validate serial/seat, reserve bytes, + create the source, call `set_selection`); only pipe serving and potentially blocking reads + are asynchronous; +- a caller-owned live completion handle records the target window, input generation and an + owner-specific waker, wakes only that viewport, and is held weakly by workers. Canceled, + stale or superseded results are discarded and release their resources; no arbitrary + first-seat fallback; +- slow, trickling, oversized, canceled and failed transfers release/complete exactly once; +- text copy/cut/paste and Paste/Paste Insert/Paste Attributes; +- file list and image paste through arboard, not duplicated by egui’s image paste; +- Windows `Ctrl+Insert`, `Shift+Insert`, `Shift+Delete` still map through + `clipboard_events_as_keys`; +- `Event::PasteImage` maps to `Key::V` and does not produce two Paste commands. + +## 8. Panels, geometry, docking, viewports and GPU resources + +- `dock_ui.rs:190` `dock_area` calls `dock::layout_with_actions` for a manual rect tree, then + `panel_body` (`:144`) creates `ui.new_child(... id_salt(...))` and sets clip rects. 0.37’s + `Panel` API is not used; keep the manual layout, but verify that `Ui::new_child`/`scope_id` + and the improved panel clip rect (#8562) do not change body rects. +- Floating groups use `egui::Area` with `Order::Middle`; layer order is read through + `ctx.memory(|m| m.layer_ids())` (`dock_ui.rs:173` `floating_order`). Check the 0.37 layer + iteration API and z-order after the bump; `ui_dock_resize.rs` and `shared_docking_ui.rs` + cover focus, resize, detach, close/reopen and tab reorder. +- `dock.rs` draws custom tabs and registers automation ids. Keep stable ids + (`panel.tab.*`, `timeline.tab.*`, `panel.menu.*`, `panel.float.*.resize`) and use + `Role::Tab`/`Role::Button` semantics. The `Tabs` tab strip is not an `egui::TabBar`; do not + replace it. +- `ViewportCommand` usage remains root-only: `Close`, `InnerSize`, `Focus`, `CancelClose`, + `Maximized`, `Fullscreen`, `StartDrag`, `CursorPosition`, `Screenshot`, and the new + `RequestPaintWhileHidden` for hidden composition stages. `ui.resize` and `ui.focus` + control methods keep their behavior; `control_input_only` must still preserve close and + viewport lifecycle metadata. +- GPU: `FilmcraftApp::set_wgpu` receives `CreationContext::wgpu_render_state`; + `TextureHandle` and `GpuState`/`uploadedBytes` remain. `egui-wgpu` fork E2/E3 add surface + fitting and error scopes; they are relevant to large/odd windows. Do not change the + FilmCraft `wgpu 30.0.1` pin or the GPU compositor in the migration PR. The candidate’s own + lock resolves `wgpu 30.0.0`; FilmCraft’s lock is authoritative. Record the actual resolved + version and prove there is one source; do not claim the candidate lock’s version. + `perf.stats` `gpu.uploadedBytes`, transitions, `submitMs` and frame-cache counters are the + upload/GPU observables. + +## 9. Commands, undo, save and reopen + +The public contracts are: + +- every interactive action is a command registered in `crates/engine`; UI, CLI, MCP and the + control channel use the same ids; +- `menus::invoke` dispatches UI-only commands and engine commands; +- drag gestures are one undo step via `begin_press` and `cancel_value_drag`; +- `file.save`, `file.saveAs`, `file.open`, `file.closeProject`, `file.revert` and recovery are + unchanged; +- UI state (tool, workspace, dock layout, zoom, scroll, monitor settings) is serde in + `crates/ui-egui/src/state.rs`; project state is not UI state. + +Migration rules: + +- `begin_press` runs in UI pass 0 before any UI; `cancel_value_drag` runs once after the + final UI pass using the pass-0 Escape latch (§4.4). +- Input-driven UI mutations dispatch once in pass 0; actions that must see final layout are + queued and drained on the first non-discarded pass. Engine commands from control/MCP/CLI + remain one-shot channel work in logic. +- A forced two-pass click adds exactly one undo step and one project revision. A forced + two-pass click on a first-show `Grid` does the same. A forced discard during a drag with + Escape cancels once. +- Save/reopen preserves an edit, an effect, a caption, and the dock/tab layout. Existing + `timeline_tabs_ui.rs:258` and `scripted.rs:548` patterns are the starting point; add an + effect/trim/save/reopen assertion and compare `sequence.inspect` and `project.inspect`. + +## 10. Fork and vendor patch provenance + +FilmCraft has no vendor directory. The suite forks carry behavior that FilmCraft either uses +or must consciously not use. Keep the pins; do not delete unrelated fixes. The tables below +name the aiongg research candidate for provenance only; production pins are G-PINS. + +### 10.1 `aiongg/egui` `craft/0.37` (`a4f9a17f`, research) + +| Patch | Decision in this plan | Validation | +|---|---|---| +| E2+E3 egui-wgpu surface fit/error scopes | Keep; use for large windows and odd surfaces | Windows/Linux resize/VM; `ui.resize` to a large size; no `Wgpu` error | +| E4 Wayland clipboard/PRIMARY | Keep; must be paired with W15P; the filmcraft#1115 problem is exactly this | Hyprland/KWin/Mutter copy/paste, PRIMARY matrix, file/image paste | +| E5, E8, E12, E13, E14, E15, E16, E17, E20 | Keep; use them; remove FilmCraft’s manual bidi | Arabic/Hebrew captions, editing, alignment, screenshots | +| E6, E21 IME | Keep; E21 alone is incoming only; E21b is mandatory | native IME rows plus outgoing-state tests | +| E7, E19 non-Latin shortcuts and P7 held-key pairing | Keep; adapt `handle_shortcuts` to Latin-layout chars; retain the emitted egui key identity for a held physical key across layout/modifier changes and clear it on focus loss/IME/destruction | kittest + native layouts, repeat/unidentified keys | +| E9 RTL UI mirroring | Keep in fork but do not enable `Style::right_to_left` | no RTL UI language yet | +| E10 web key/lang fixes | Keep; test browser IME and named keys | `smoke.mjs` + manual browser | +| E11 vello_cpu multithreading | Not exercised by FilmCraft; no local feature change | none | +| E1 viewport hooks | Not used by FilmCraft; keep the fork patch | no FilmCraft test | +| E18 `Frame::set_ui_language` | Use with `ctx.set_language` | language switch + AccessKit root | +| E15 `LineBreaker`/`LayoutJob::language` | Optional; use only if document line breaking needs it | caption wrapping tests | +| E21b/P7b outgoing IME surrounding text (P7b) | **Required new prerequisite**: the complete shared owner/support/serial contract, 4000-byte bounded payload without a full-document copy, TextEdit producer, Film graphics adapter and Wayland delivery | §7.2 tests; native Wayland trace | +| P7c selected #8763/#8748 fixes | P7c owns the selected, tested #8763 selection-action and #8748 hidden parent/child fixes. #8764 remains an unmerged proposal at `6eb460a2a6d537d312a80579cb801895509deaed`, not a release fix | selection/pointer/keyboard and hidden-parent regressions | + +There is no E22. Do not invent fork APIs. A local workaround for #8763 needs a specific +reason and removal condition. + +### 10.2 `aiongg/winit` `craft/0.30.13-lang` (`8b35388b`, research) + +Keep the whole pinned branch because E4/E19/E21/E21b require W15/W15P/W25/W26/W27, +including the bounded W15P transfer path and the W27 owner/support lifecycle. For FilmCraft, +exercise at least: + +- W1 mixed-DPI window rect; W3/W4 Windows IME/hidden maximized; W5/W20/W21 X11 IME; + W6-W10 macOS IME; W11 X11 DPI; W12 macOS titlebar drag; W13 Wayland scroll phase; + W15 Wayland drops/clipboard; W15P PRIMARY; W18/W19 Windows IME; W22-W27 language/IME. +- W15P is required with E4 as described in §7.3, including per-seat lifecycle, password + exclusion, unchanged-owner behavior and slow/canceled transfer tests. +- W14 macOS cancelable termination is opt-in; FilmCraft does not call that platform extension, + and its own close interception remains. +- Do not enable W16 pinch/rotation or tablet input; no `craft-tablet` dependency exists. + +If the suite later replaces these forks with upstream releases containing the fixes, drop the +corresponding patch rows only after the same tests pass; do not remove a patch just because a +release number moved. + +### 10.3 `craft-libs` port + +Port only the selected PR-199 API hunks (`8614e4a`) and README changes (`1764bca`) onto the +current published `4919088` tree. Preserve newer security/settings/docking/printing fixes, +including the protected-group docking tests. Never merge `b68de8c` or `ec8840a` as trees. The +CL-2/CL-2F helper is a separate reviewed addition, not part of the stale PR tree. + +## 11. Tests and observable assertions + +### 11.1 Required repository gates (managed, proposed) + +Every new host build/test and owned-VM command must use +`maintenance/task.py run/status/finish` with its own task, job ID, source and evidence. The +commands below are proposals for the parent to submit; they were not executed during this +planning pass. Capacity must be inspected first; the suite snapshot had less than one default +64 GiB build reservation available after existing reservations. + +```sh +python3 /home/codex/work/maintenance/task.py run \ + --job-id filmcraft-egui037-ci-01 --kind build \ + --task filmcraft-egui037-migration \ + --source /home/codex/work/CraftApps/egui037-migration-20261022/filmcraft-implementation \ + --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/filmcraft \ + -- cargo xtask ci +python3 /home/codex/work/maintenance/task.py status filmcraft-egui037-ci-01 --tail-bytes 8192 +python3 /home/codex/work/maintenance/task.py finish filmcraft-egui037-ci-01 --artifact-manifest /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/filmcraft/artifacts.json +# after inspecting the plan and preserving evidence: +python3 /home/codex/work/maintenance/task.py finish filmcraft-egui037-ci-01 \ + --artifact-manifest /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/filmcraft/artifacts.json \ + --apply --owner-confirmed --checks-complete --evidence-preserved +python3 /home/codex/work/maintenance/task.py status filmcraft-egui037-ci-01 --tail-bytes 8192 +``` + +Every job uses one unique resource task/fresh job ID/`--source`/`--evidence`; poll `status` +until both the command and the finish outcome are known, and never treat `ok: true` alone as +success. + +`cargo xtask ci` already runs fmt, clippy, tests, layers, assets and wasm; keep it as the +authoritative gate. Also run, as separate managed jobs where a selected feature is not in +default CI: + +- `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, + `cargo test --workspace`, `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm`; +- with and without `CRAFT_FONTS_DIR` (pinned craft-fonts `abb83316d96aa59c1cf64784289e378fe9fa5695`); +- `FILMCRAFT_REQUIRE_ORACLES=1` when ffmpeg is available; +- `cargo metadata --locked`, the graph checks from §3.4, and the native `clipboard` + + `monochrome_emoji_fonts` feature assertions. + +Strict kittest naming and glyph behavior is part of the first green compatibility stage. +Do not add a global `allow`/disable for missing names or missing glyphs. If a name/glyph is +missing, fix the widget or fixture. + +**Font features do not propagate through kittest.** `egui_kittest`'s normal `[dependencies] +egui` uses `default-features = false`; only kittest's own `[dev-dependencies]` enable +`default_fonts` and `monochrome_emoji_fonts`, and a dependency's dev-dependencies do not +propagate into a consumer. Every kittest/FilmCraft test root that needs the bundled faces must +enable those egui features explicitly or install controlled fonts, and each actual graph must +be inspected. The production feature policy in §3.5 does not reach the harness by itself. + +Windows uses a uniquely owned clone and the VM run/status/finish workflow, with the clone’s +`Build-DesignCraft.ps1` equivalent for FilmCraft or the repository’s documented build helper. +The baseline VM is not adopted or reused across tasks. + +### 11.2 UI, lifecycle and workflow tests + +Existing anchors to extend or keep: + +- `crates/ui-egui/tests/scripted.rs` `Driver::demo`, `frames`, `call`, `ok`, `exec`; add + multi-pass and save/reopen cases. +- `crates/ui-egui/tests/ui_control_input.rs:153` screenshot/control isolation. +- `crates/ui-egui/tests/ui_dock_resize.rs`, `shared_docking_ui.rs`, `timeline_tabs_ui.rs`. +- `crates/ui-egui/tests/interface_zoom_ui.rs`, `settings_ui.rs`, `panels_ui.rs`, + `monitor_geometry.rs`, `timeline_trim_ui.rs`, `clipboard_paste_ui.rs`, `project_panel_ui.rs`. +- `crates/ui-egui/tests/perf_ui.rs` monitor lag. +- `crates/ui-egui/tests/robustness_ui.rs` hostile UI state. + +New/extended assertions: + +1. **Lifecycle:** visible single/multipass; logic called per pass but background effects are + idempotent; hidden `run_logic` ticks advance playback/autosave/workers/jobs without reading + input; forced hidden paint with a screenshot delivers an image; buffered input survives a + hidden tick and is handled once on the next visible pass; repeated hidden logic ticks keep + one synthetic gesture step pending and do not collapse press/release; a forced two-pass + click (including a first-show `Grid`) adds exactly one undo step; Escape drag cancels once. +2. **Root UI/geometry:** at 1600×980 and 1920×1080, `ui.elements` rects for + `panel.Timeline`, `panel.Program`, `panel.Project`, `tools.Razor`, dock split handles and + floating grips match the baseline within 1 point; panel bodies never overlap the + header/status bar; tab overflow still reaches every tab. +3. **Screenshot callback:** two queued `ui.screenshot` requests with different panel crops + return matching images/paths; a hidden screenshot delivers both when already issued and + when still settling (queued-but-not-issued), using `RequestPaintWhileHidden` in the latter + case; the weak callback wakes the live slot's waker after unlocking and never wakes itself; + a callback after timeout/close is ignored; the completion slot is dropped exactly once; any + resize/DPI/zoom change, including a change back, either retries within the deadline or + errors, never pairs old pixels with new geometry. Native VM test; headless test of the + correlation helper. +4. **Scroll header sync:** after horizontal scroll, media-browser/project-list headers align + with rows (≤1 point); after rubber-band overshoot, the header uses clamped offset. +5. **Accessibility:** for the demo workspace assert `Role::Tab`/selected on panel tabs, + `Role::Button`/selected on tool buttons, `Role::Button` "Resize panel" on the floating grip, + `Role::Splitter` on dock split dividers, and `Role::TextInput` with a non-empty accessible + name on caption/metadata/search fields. Use `Harness::get_by_label`, + `accesskit_node().role()`, `label()`, `toggled()`. All 56 `TextEdit` sites must be named; + add a negative control that removes a name and fails the strict kittest gate. +6. **Fonts:** with `CRAFT_FONTS_DIR` and without, assert Japanese/Chinese/Korean/Arabic/Hebrew + sample text produces no missing-glyph UV and preserves the expected family/weight; assert + `✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑` coverage; assert a non-zero TTC face index survives `FontData` + construction. Fixtures and licences are pinned in §6.1. +7. **RTL:** Arabic/Hebrew caption visual order, right alignment, bracket mirroring, digits and + a mixed LTR paragraph; editing caret/Home/End/Delete; compare glyph order, not just “text + present”. +8. **IME/clipboard:** outgoing surrounding text after commit/deletion, preedit exclusion, + grapheme/byte boundaries, password clearing, same-window switch, two windows, late serial, + owner generation present when unavailable, available-empty vs unavailable and a + pass-1-text/pass-2-`None` final pass; synthetic `PasteImage` maps to Paste once; native + PRIMARY (keyboard and pointer serials, bounded transfer) and text/file/image paste matrix + from §7.3. +9. **Save/reopen:** edit + effect + caption + tab layout, `file.saveAs`, `file.open`, equal + `sequence.inspect`/`project.inspect` and reopened tab order; undo/redo count exactly. +10. **Mutation/negative probes:** a deliberately broken test-only flag that repeats a queued + action must fail the one-undo-step assertion; a screenshot with a wrong token/slot must not + be delivered to the wrong request; a scroll offset beyond content must clamp; a font face + with the wrong index must produce a distinct glyph or missing glyph and fail the coverage + test; removing an accessible name must fail strict kittest. + +### 11.3 Visual comparisons and tolerances + +Do not add committed screenshots unless they are needed; if added, they are original +FilmCraft captures and need `.attribution` sidecars and `ATTRIBUTION.md` rows +(`cargo xtask assets`). Prefer writing review PNGs to the durable evidence directory. + +For before/after review: + +- workspace chrome is custom-painted: mask only volatile monitor/video regions; use + PSNR ≥ 45 dB, max channel difference ≤ 12, p99 ≤ 2 for unchanged regions, and exact layout + rects within 1 point. These thresholds are provisional until the baseline report records + the noise floor of an unchanged build; record the reference measurement that sets them. +- egui-drawn dialogs may move/resize because 0.37 changes button spacing and corner radius + (#8617, #8445): mask the dialog region or use p99 ≤ 8/max ≤ 32, and require human visual + review; do not auto-bless a changed golden. +- Arabic/Hebrew captions get both a pixel check and a glyph-order assertion; pixel-only + comparison would hide double reversal. +- A changed golden is never accepted merely to make CI pass; re-run and review. + +### 11.4 Native and browser workflows + +Native Linux (proposed, through the managed adapter; no raw `cargo run` outside task.py): + +```sh +python3 /home/codex/work/maintenance/task.py run \ + --job-id filmcraft-egui037-linux-ui-01 --kind build \ + --task filmcraft-egui037-migration \ + --source /home/codex/work/CraftApps/egui037-migration-20261022/filmcraft-implementation \ + --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/filmcraft \ + -- cargo run --release -p filmcraft -- --control 9876 +python3 /home/codex/work/maintenance/task.py status filmcraft-egui037-linux-ui-01 --tail-bytes 8192 +# after the UI workflow, evidence capture and review: +python3 /home/codex/work/maintenance/task.py finish filmcraft-egui037-linux-ui-01 \ + --apply --owner-confirmed --checks-complete --evidence-preserved +python3 /home/codex/work/maintenance/task.py status filmcraft-egui037-linux-ui-01 --tail-bytes 8192 +``` + +Drive `ui.inspect`, `ui.screenshot`, `ui.resize`, dock actions, clipboard/PRIMARY, and review +every PNG. Repeat under X11 and Wayland (and Hyprland for E4/W15P). Native Windows through a +uniquely owned clone: build the task checkout, run `filmcraft-ui-egui` UI tests, launch with +`--control`, capture screenshots, exercise IME and DX12/software graphics modes. macOS: real +hardware for Cmd shortcuts, titlebar drag, Japanese/Korean IME and header drag; if no Mac is +available, record macOS as unresolved, not passed. + +Browser (proposed; `cargo xtask web --serve` is a long-running server, so all steps run in +one managed `task.py run` job with a trap that stops the server, then `status` and an attested +`finish` after evidence review): + +```sh +# payload inside the managed job, with exact --task/--source/--evidence and a fresh job ID: +cargo xtask web --serve 8765 & +SERVER=$! +trap 'kill "$SERVER" 2>/dev/null' EXIT +ffmpeg -f lavfi -i testsrc2=size=640x360:rate=30 -f lavfi -i sine -t 3 -c:v libx264 -pix_fmt yuv420p -c:a aac target/web/dist/web-test.mp4 +node apps/filmcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --media web-test.mp4 --out target/web/smoke +``` + +Run WebGPU and `?webgl`; inspect `report.json` and steps `01-demo` … `05-modes`; check +`filmcraft.info()` for backend/compositor. A wasm compile is not runtime evidence; headless +browser tests do not prove native focus/IME/file pickers. + +### 11.5 Environment provisioning gates + +No current runtime acceptance exists. The following are gates, not passes: + +| Environment | Current evidence | Required before claiming a pass | +|---|---|---| +| Linux X11 | No inherited `DISPLAY`; no Xvfb/window manager observed | Provision an isolated display/window manager, record versions, qualify native focus/clipboard/PRIMARY | +| Linux Wayland | No inherited `WAYLAND_DISPLAY`; no compositor/input-method observed | Provision Weston/Sway/Hyprland/KWin/Mutter and an IME, record capabilities; run text-input and PRIMARY matrix | +| Linux GPU | Parent inspection found i915 PCI `8086:3E96` and readable/writable `/dev/dri/renderD128`; worker sandbox may hide it; **do not claim the server has no GPU** | Managed renderer/device probe must identify the actual driver/backend; access alone is not GPU acceptance | +| Windows | Sealed baseline uses Microsoft Basic Render Driver; a full clone may not fit current capacity | Allocate a unique full clone or a baseline-backed overlay with an adequate reservation; run visible edited/saved/reopened workflows, IME, DX12/software | +| Browser | Wasm compile is not runtime evidence | Pin browser and adapter; run interactive WebGPU and WebGL2 fixtures; record raw/compressed wasm sizes and backend identity | +| macOS / physical Windows audio/video | No qualified environment in this task | Real hardware access and owned evidence; software VM results do not qualify these rows | + +These rows remain explicit rollout blockers where unavailable. Do not report a GPU absent, +a current runtime pass, or a platform gate as satisfied from source inspection alone. + +## 12. Performance: baseline, paired runs, thresholds + +### 12.1 Baseline before production edits + +Record before changing production code, on the exact baseline SHA and lockfile, as managed +`task.py run` jobs (fresh job ID, exact `--source`/`--evidence`, `status`, then attested +`finish --apply`). Submit each payload as +`task.py run --job-id --kind build --task filmcraft-egui037-migration --source + --evidence -- `; the commands below are payloads, not a +raw-command exception: + +- `cargo xtask bench` with `--sections timeline,playback,scrub`, `--label + filmcraft-egui036-baseline`; GPU and `--cpu` runs. The timeline harness currently defaults + to 120 frames per view, 8 warmups (the `--repeat` value is a **run count**, not a sample + count) and emits only p50/p95, so extend `crates/ui-egui/examples/bench/sections.rs` to + accept the frame/warmup counts and emit p99 with exact sample counts **before** using it as + acceptance evidence. Run at least 10 warmups, 30 timed iterations per steady configuration + and at least 1,000 actual captured frames per view for any p99; +- `cargo xtask bench-playback` for `demo`, `stack3`, `stack3-blend`, `h264-1080`, `h264-2160`, + `after-preview`, `seek-storm`, `--res full,half`, GPU and `--cpu`, with 10 warmups / 30 + timed iterations per configuration where the harness supports them; p99 requires at least + 1,000 frames; +- native idle: no playback, no input, 60 s; record repaint count, process CPU, RSS; +- startup: first present and `Renderer::new` timing on Windows DX12 and Linux Vulkan, from + 20 fresh processes; +- `perf.stats` before/after a fixed operation script (open demo, razor, undo, apply effect, + play 5 s, screenshot); +- wasm raw and compressed sizes for the exact feature set. + +Record: exact SHA, `Cargo.lock` hash, feature set, `CRAFT_FONTS_DIR` revision, fixture names +and hashes, OS/driver, GPU adapter/backend, DPI/scale factor, viewport size, profile (release), +thread count, theme, warmups and repetitions, raw samples, median/p95/p99, peak RSS, idle +repaint/CPU, operation latency, `gpu.uploadedBytes`, frame-cache entries, shapes/vertices, and +load average. For allocation counts use `heaptrack` or `valgrind --tool=massif` on the Linux +bench process; record peak/total allocations separately from product metrics. GPU uploads are +reported by `perf.stats` (`gpu.uploadedBytes`) and frame-cache counters. + +### 12.2 Paired candidate runs + +Run the same commands against the candidate on the same machine and environment, alternating +baseline/candidate where possible. Separate software-renderer (`--cpu`, llvmpipe/WARP) from +real-GPU results; do not mix rows. Never benchmark concurrently with builds or another noisy +workload and claim a small improvement. Preserve raw samples and the exact source/lock +identity for both sides. + +Where the operation is equivalent, include a **stock 0.37 control** alongside 0.36 and the +selected fork. Fork-only app code need not compile unchanged on stock; use the same operation +in a pinned minimal adapter/harness and explain remaining semantic differences. Record all +three source/lock identities; unavailable equivalence is an attribution limitation, not proof +that an upstream regression is unavoidable. + +### 12.3 Predefined thresholds and noise policy + +These are the shared suite minimums; FilmCraft may tighten them, never silently loosen them. +The stock-0.37 equivalent control and the first baseline report are required before any +regression claim; thresholds and goldens are never auto-widened or auto-blessed to make a run +pass. + +- **Sampling:** at least 10 warmups and 30 timed iterations for steady operations; at least 20 + fresh processes for startup; at least **1,000 actual captured frames** per configuration for + any frame-distribution p99. A p99 from 20/30/50 samples is not a tail estimate. Report the + exact frame count and sample count in the report schema. +- **Timeline UI** (headless kittest, 1000 clips/20 tracks, 1920×1080): median update/tessellation + ≤ +5%, p95 ≤ +10%, p99 reported and reviewed. Vertices/shapes must not grow without + explanation. +- **Playback CPU** (`bench-playback` CPU ms/frame, worker/source columns): ≤ +3% median; + shown/dropped may vary with load and is not a gate alone. +- **Peak RSS:** FilmCraft gate is ≤ +5% for the same scenario after subtracting the measured + noise floor; investigate any repeatable increase above the floor. Report absolute MB. +- **Idle:** no extra repaint requests and no extra CPU when nothing is playing; a hidden window + keeps auto-save/playback semantics without busy-looping. +- **Startup:** report first-present and `Renderer::new`; on Windows DX12 a +20 ms median or + more is a regression to investigate against #8736; do not silently accept it. +- **Wasm:** record raw and compressed sizes; a size regression is reviewed, not hidden by a + compile-only check. + +If a threshold is exceeded, re-run alternating; if still exceeded, profile (`perf`, `tracy`, +`heaptrack`/massif) and report. Never auto-accept a changed golden or call compile time UI +performance. + +### 12.4 Separate workload rows + +Do not merge these into one number: + +- **#8731 text tessellation:** plain-text timeline/fixture workload (1000 clips/20 tracks, + 1920×1080), compare unchanged 0.36, stock 0.37 and the fork; measure tessellation time and + text-heavy frame time separately. If confirmed, evaluate a row-level fast path and cached + color-glyph metadata; test mixed/color rows. Do not bless a regression. +- **#8736 DX12 FXC shader compile:** Windows VM, measure `Renderer::new`/pipeline creation and + first present separately from steady frames, with predictable filtering disabled. Compare an + override-constant design with semantically equivalent wrap arithmetic only if needed; + clamp-only is not a valid Repeat/MirroredRepeat fix. +- **#8512 system-font startup:** measure discovery/startup separately. The migration keeps + `system_fonts` off, so this is a controlled opt-in experiment, not a default change; preserve + glyph coverage and record provider inventory. +- **Wasm/browser:** raw and compressed wasm size plus WebGPU/WebGL2 runtime frame timings from + the pinned browser environment; compile time is not UI performance. + +## 13. Implementation PR stages and dependency DAG + +No production stock-0.37 stage exists. The first production stage is the coherent fork graph. + +| Stage | Contents | Depends on | Rollback | +|---|---|---|---| +| P0 | This plan only; no code | — | delete document | +| **P1** | **Atomic compatibility landing on the final fork graph.** Manifests/lockfile: final approved `storytold/egui` P8 + `storytold/winit` pins, final `storytold/craft-libs` revision (CL-1 + CL-2F), explicit `default_fonts`, `monochrome_emoji_fonts`, native `clipboard`, no `system_fonts`/`links`. API migration: `FontData`, `Role`/names, `ScrollArea`, `MouseWheelSource`, `run_ui` signature, `Id::unique`, screenshot callback + CL-2 helper, `PasteImage`, E21b custom IME adapter, bounded E4/W15P clipboard/PRIMARY routing, lifecycle split/multi-pass queue, strict kittest names/glyphs, and **removal of the manual `rtl::Lines` reversal and its unit tests**. Tests from §11. | P0; **G-PINS**, **G-CL2F**, published/reachable pins; capacity for gates | revert to the coherent 0.36.2 graph (`Cargo.toml`/`Cargo.lock` and source); keep old craft-libs `5c874d51` and `craft-docpath 82a24b0` | +| P2 | Language and the full native input matrix: Latin-layout shortcuts, `ctx.set_language`/`set_ui_language`, and the full W15P/E4 PRIMARY and E21b native rows against the P1 integration. Fork bidi is already active in P1; P2 does not introduce a second bidi state. | P1 (fork already pinned) | revert P2; restore prior language/input behavior without changing the P1 dependency graph or re-introducing manual reversal | +| P3 | Accessibility semantics beyond first-green naming: radio-group/tool semantics, richer role/value/label assertions, AccessKit language screenshots. | P1 | revert P3 | +| P4 | Evidence hardening: visual/geometry/screenshot regression harness, mutation probes, performance baseline/candidate and stock control, wasm sizes, environment provisioning. | P1–P3 | revert P4; keep earlier behavior | +| P5 | Documentation/rollout notes and optional measured experiments (`system_fonts`, color emoji, macOS). | P4 | revert P5 | + +Publication is not part of P0. Each future push/new PR requires fresh direct human approval +after the parent shows the exact contents and validation. Do not include agent/model/tool +signatures in PR text or commits. + +### Rollback set + +Keep the old coherent set recoverable: + +- `egui/eframe/egui_kittest = 0.36.2`; +- `craft-ui` rev `5c874d51eeb3de70e735c8ce87a59a7dedd47675`; +- `craft-docpath` rev `82a24b006eefa036b43c09a67fb9a3fbd17ffe63`; +- registry `winit 0.30.13`, `wgpu 30.0.1`. + +A rollback is `git revert` of the stage commits plus restoring the baseline +`Cargo.toml`/`Cargo.lock` (or `git checkout abf44fda -- Cargo.toml Cargo.lock` after reverting +source changes). Do not keep a machine-local path patch as a fallback. A rollback rehearsal +uses the preserved baseline executable/source and a separate managed allocation. + +## 14. CI, compatibility and publication checks + +- `.github/workflows/ci.yml` runs `cargo xtask ci` with pinned `CRAFT_FONTS_REF`; add the pin + checks from §3.4 and a `cargo metadata --locked` step. +- Keep `freebsd.yml`’s skip-tests policy and `windows-arm64.yml` as-is; do not weaken them. +- `cargo xtask wasm` must still check `filmcraft-ui-egui` and `filmcraft-web`; `egui_kittest` + is a dev dependency and remains native-only. +- The web crate’s `wasm-bindgen` exact pin (`=0.2.129`) must match the installed CLI. The + candidate’s own lock uses `wasm-bindgen 0.2.126` and `web-sys 0.3.103`; FilmCraft currently + pins `web-sys 0.3.106` and `wasm-bindgen 0.2.129`. The `0.2`/`0.3` ranges are compatible, + but the final FilmCraft lock is authoritative: record the resolved versions and run + `wasm-bindgen --version` before `cargo xtask web`. +- `rfd 0.17`, `arboard 3.6.1`, `wgpu 30.0.1`, `accesskit 0.25`/`0.24` transitive and `winit + 0.30.13` compatibility were checked from manifests; a fresh `cargo metadata --locked` and a + clean lockfile resolution are still required. +- The candidate’s own lock resolves `wgpu 30.0.0`; FilmCraft pins 30.0.1. Record the actual + resolved candidate/FilmCraft version in the evidence and ensure one source; do not claim the + candidate lock is FilmCraft’s. +- New 0.37 dependencies include `wl-clipboard-rs`, `tree_magic_mini`, `petgraph`, `nom`, + `send_wrapper`, `unicode-properties`, `vello_cpu 0.2`, `glifo 0.3`, `kittest 0.5` and updated + `accesskit*`/`phf`. Review their licences (all expected MIT/Apache-2.0/BSD/ISC); no GPL/LGPL + may enter. +- Do not commit local screenshots as assets without sidecars/index rows. Keep review PNGs + under the task evidence directory. +- No agent/model/tool attribution in PR titles, descriptions, commits or comments. Contributor + credit stays username-only (`@skylartaylor`); do not add a name entry. + +## 15. Blocking unknowns and acceptance criteria + +These are gates, not future nice-to-haves. A failed or unavailable gate is an explicit blocker, +not a passing result. + +1. **G-PINS — production fork pins.** The reviewed, published, immutable `storytold/egui` + commit (E1-E21 + E21b/P7b + selected #8763/#8748 fixes) and `storytold/winit` commit + (W15P + W27 + required W1-W27 behavior) are not known at planning time. Do not invent a SHA, + do not use an aiongg branch as a production pin, and do not land a stock-0.37 intermediate. + The exact SHAs, feature sets and provenance must be recorded before P1. +2. **G-CL2F — shared capture helper.** `craft-libs` must publish the CL-2 helper, DesignCraft + and GridCraft adapters must be reviewed against it, and CL-2F must freeze the API before + FilmCraft pins the revision. A branch head or path dependency is not acceptable. +3. **G-CRAFT-LIBS — shared baseline and port.** The final published `craft-libs` revision must + build on the merged PR #199 base `3832c47ef68a0e6214f2c1e85f3526b6adcb686e` with the + protected-group docking fix and lazy printing preserved; the PR-199 hunks + (`8614e4a`/README `1764bca`) are historical provenance. `5c874d51` is the old FilmCraft + manifest pin; `4919088` is historical GitHub main. `craft-docpath` stays at `82a24b0` + unless a concrete cross-type reason appears. +4. **G-IME — native IME and E21b.** E21b/P7b must exist in the pinned fork with the complete + shared contract: owner generation present even when `Unavailable`, available-empty distinct + and explicitly sent, committed W27 disable/enable transitions with cache clear, + authoritative completed-pass `None`, current-epoch serial-mismatch edits applied once with + outgoing state deferred, and a 4000-byte payload extracted without a full-document copy. + The FilmCraft graphics adapter must supply logical model/preedit/UTF-8 bytes, and Windows + Japanese/Korean, X11 Vietnamese, Wayland text-input-v3 and macOS rows must run on real + environments. No headless test is a substitute. macOS without hardware is explicitly + unresolved. +5. **G-PRIMARY — W15P + E4.** The winit-owned PRIMARY path must be integrated, with native + per-seat keyboard- **and pointer-triggered** selection using a fresh triggering serial, + owner/seat changes, empty/absent offers, password exclusion, unchanged-owner non-reclaim, + self-paste, and slow/trickling/oversized/canceled transfers under wlroots, KWin and Mutter. + The transfer path is bounded (2 s total deadline, 16 MiB, 4 read workers, 2 writers with + queue depth 8), claims ownership synchronously on the fresh input turn, serves + asynchronously, and discards stale/canceled completions. The arboard/smithay path is a + measured fallback only. +6. **G-SEL — P7c #8763/#8748.** Reproduce selection loss with a caption/metadata field and a + custom toolbar action; P7c owns the selected, tested #8763 and #8748 fixes. #8764 is an + unmerged proposal, not a release fix. A local workaround needs a reason and removal + condition. +7. **G-PERF — performance and stock control.** Baseline and candidate runs must meet §12 + sampling and thresholds, with separate #8731 text, #8736 DX12, #8512 font and wasm rows, and + a stock-0.37 control where equivalent. Compile duration is not UI performance. +8. **G-ENV — real environments.** Linux X11/Wayland compositors and IME, a browser with a + pinned adapter, a unique Windows clone, and macOS hardware must be provisioned. The i915 + device’s presence is availability evidence only; the worker sandbox may hide it. No current + runtime pass is claimed. +9. **G-ACCESS — accessibility names and glyphs.** All 56 `TextEdit` sites and other input + widgets have names; strict kittest naming/glyph behavior is green without a global disable; + monochrome emoji coverage is asserted. +10. **G-LIFECYCLE — multi-pass audit.** The input-action design, Escape latch, monotonic + deadlines, one-step synthetic gesture retention across hidden logic ticks, idempotent + toast/settle counters and grep allow-list are implemented and tested; until then the + one-action and hidden-work guarantees are not met. +11. **G-SCREENSHOT — capture matrix.** The callback is delivered for a visible capture and + for a hidden capture, including queued-but-not-yet-issued settling; timeout/close/ + late-callback behavior is correct. The CL-2 slot holds the strong waker, the callback is + weak and does not wake itself, and admission bounds four outstanding requests, 128 MiB per + image and 256 MiB aggregate with checked `width * height * 4` before issue and after any + resize/DPI/zoom epoch change (including a change back). +12. **G-RTL — bidi.** P1 must land the fork's E5/E12 together with the simplified + `rtl::Lines`, and validate visual order/alignment/editing. Do not ship both the manual + reorder and fork bidi, and do not leave the removal to P2. + +## 16. Limitations of this planning pass + +- No build, test, benchmark, screenshot or app launch was run. All behavior statements are + source review and upstream documentation, not fresh validation. No command in §11 was + executed. +- The historical compiler diagnostics are from an earlier checkout and only identify likely + sites; current line numbers and new errors must be discovered by implementation gates. +- The production fork pins and the final `craft-libs` revision are unknown until the fork and + shared helper are published; no SHA is invented. +- The i915 device was observed on the host but no display session is inherited and the worker + sandbox may hide it. Windows baseline is software-rendered. macOS, browser runtime and real + Wayland compositor behavior remain unproven until the environment gates run. +- The exact stock-0.37 control harness may need small adapters for fork-only operations; such + differences are recorded as attribution limitations, not ignored. + +## 17. Review finding dispositions + +This table is an index; the body sections above are authoritative. + +| Finding | Disposition | Where resolved | +|---|---|---| +| F1 deferred-action clearing loses clicks | **Accepted.** No clear on pass 1+; input actions dispatch once in the pass with events; deferred actions persist and drain on the first non-discarded pass; duplicates coalesce; no silent drop. | §4.4, §9, §11.2 | +| F2 Escape rollback cannot fire on a multi-pass frame | **Accepted.** Latch Escape and the undo mark in pass 0; apply once after the final pass. | §2.3, §4.4, §9 | +| F3 hidden window never presents | **Accepted.** The screenshot command already requests paint while hidden; `RequestPaintWhileHidden` also covers queued-but-not-issued settling and other composition stages; timeout is not the normal hidden path; no visibility-based phase inference. | §4.2, §4.5, §11.2, §11.5 | +| F4 OS clipboard feature lost | **Accepted.** Add native `clipboard`; cross-application paste tests on X11/Wayland/Windows; web checked separately. | §3.5, §7.3, §11.1 | +| F5 stock 0.37 landable stage | **Accepted.** Stock is a local/CI comparison only; one coherent fork landing; final pins gated. | §3.2, §13 | +| F6 `default_fonts` not monochrome emoji | **Accepted.** Explicit `monochrome_emoji_fonts`, exact glyph set, screenshot diff. | §3.5, §6.1, §11.2 | +| F7 E21b/P7b IME gap | **Accepted.** Full owner/support/serial contract, 4000-byte no-full-copy payload, TextEdit producer, Film graphics adapter, tests; native rows required. | §7.2, §10.1, §11.2, §15 G-IME | +| F8 PRIMARY downgraded | **Accepted.** W15P + E4 required; keyboard/pointer serials, bounded transfer, native per-seat/password/unchanged-owner/slow-cancel matrix; arboard fallback measured only. | §7.3, §10.1-10.2, §15 G-PRIMARY | +| F9 effects catalogue incomplete | **Accepted.** Enumerates `trim.tick`, native menus, metadata/auto/sync sites, all monotonic deadline calls; trim idempotent; grep guard. | §2.3, §4.1, §4.6 | +| F10 performance protocol weak | **Accepted.** 10 warmups, 30 steady, 20 startup, ≥1,000 actual frames for p99; shared 5%/10%; stock control; separate #8731/#8736/#8512 rows; wasm sizes. | §12 | +| F11 unmanaged commands | **Accepted.** All validation uses `task.py run/status/finish` with task/source/evidence; proposals only. | §11.1, §11.4 | +| F12 EffectCraft reverse dependency absent | **Accepted.** Exact `2c5ba7a` pin, UI-free proof, separate branch retained, re-audit triggers. | §1.1 | +| F13 4919088 unpublished / PR 199 premise | **Premise rejected.** GitHub main verified `4919088`; stale `origin/main` `b1401e3` does not prove otherwise. Old `5c874d51` fetched; seven-file +607/−2 diff audited; printing and protected-group docking preserved; `craft-docpath` stays separate; PR 199 only hunks `8614e4a`/README `1764bca`. The actionable audit requirements are accepted. | §3.1, §10.3, §15 | +| F14 accessibility role contradiction | **Accepted.** Floating grip = `Button` "Resize panel"; dock split divider = `Splitter`; test updated once. | §5.3, §11.2 | +| F15 minor accuracy/robustness | **Accepted.** Catch-unwind latch; no `Window::show` in logic; wgpu/candidate versions stated; thresholds require a noise reference; strict kittest names are an ordering constraint. | §4.1, §4.2, §11.1, §11.3, §12.1, §14 | + +### 17.1 Fresh v2 review findings + +| Finding | Disposition | Where resolved | +|---|---|---| +| v2-1 P1 lands manual bidi reversal with fork bidi | **Accepted.** P1 now pins the fork, removes `rtl::Lines` reversal and its tests, and validates visual order; P2 no longer ships a second bidi state. | §6.3, §13, §15 G-RTL | +| v2-2 E21b contract incomplete | **Accepted.** Full owner/context identity, available-empty vs unavailable, committed W27 transitions, authoritative `None`, serial-mismatch-once/deferred-outgoing, no full-document copy. | §7.2, §10.1, §15 G-IME | +| v2-3 keyboard-only PRIMARY and no bounded transfer | **Accepted.** Keyboard and pointer triggering serials; 2 s/16 MiB/4 readers/2 writers/queue 8; synchronous claim, async serving, owner-specific waker, stale discard. | §7.3, §15 G-PRIMARY | +| v2-4 no screenshot wakeup/collection path | **Accepted.** Strong waker in the live slot, weak callback, unlock before wake, callback has no repaint path of its own; queued hidden requests use `RequestPaintWhileHidden`. | §3.3, §4.5, §15 G-SCREENSHOT | +| v2-5 performance commands contradict sampling rule | **Accepted.** Harness must emit p99 and exact counts, 1,000+ frames/10 warmups/30 iterations; `--repeat` is not a sample count. | §12.1, §12.3 | +| v2-6 validation mixes managed and raw commands | **Accepted.** Bench and browser payloads run inside `task.py run/status/finish`; the Linux UI job has an attested finish and the long-running server has a stop step. | §11.1, §11.4, §12.1 | +| v2-7 effect catalogue misses Toast and settle counters | **Accepted.** Toast uses monotonic time; settle counters are per rendered frame/pass identity; synthetic hook retains one step across hidden logic ticks. | §2.3, §4.1, §4.5, §15 G-LIFECYCLE | +| v2-8 consumer count and P7c traceability | **Accepted.** Fourteen capture consumers (eleven craft-ui + three local adapters); P7c explicitly owns #8763/#8748. | §3.3, §8, §10.1, §15 G-SEL | + +## 18. Primary sources reviewed + +- Upstream `egui 0.37.0` changelog and release commit `841a328b`; source inspected through + `git -C /home/codex/work/CraftApps/egui037-migration-20261022/egui show 0.37.0:…`. +- Upstream API changes: emilk/egui#8489 (screenshot callback), #8555 (`WidgetType` -> `Role`), + #8583 (`default_fonts` off), #8534 (Id deprecations), #8535 (`root_ui`), #8428 (PRIMARY), + #8512 (system fonts), #8631/#8748 (logic-only/overdue windows), #8577 (bidi), #8731 (text + tessellation), #8736 (DX12 shader compile), #8763 (TextEdit click-away) and proposed #8764 + (`6eb460a2a6d537d312a80579cb801895509deaed`, open/unmerged). +- Lifecycle source audit: `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lifecycle-source-audit.md`. +- IME gap: `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/ime-surrounding-gap.md`, + `.../winit-review-triage.md` for the W15P/W27/E21b serial and owner/support corrections, and + egui plan §10.6 for pinned font fixtures. +- Shared baseline audit: `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/filmcraft-shared-baseline-audit.md`. +- Suite rollout: `craft-libs/docs/egui-037-suite-rollout.md` and + `craft-libs/docs/egui-037-migration-plan.md` (CL-2/CL-2F, §7.8, §10.6). +- Fork provenance: `aiongg/egui` `craft/0.37` `PATCHES.md` (E1-E21) and `aiongg/winit` + `craft/0.30.13-lang` `PATCHES.md` (W1-W27), inspected read-only in + `/home/codex/work/CraftApps/egui037-migration-20261022/{egui,winit}`. +- Shared migration: `storytold/craft-libs#199` (port hunks `8614e4a`, README `1764bca`; + stale heads `b68de8c`/`ec8840a` are not mergeable trees), current main `4919088`, old + FilmCraft pin `5c874d51`, language work `storytold/craft-libs#198`. +- Local evidence: `/home/codex/work/ssd2/task-evidence/craft-fork-plan-20261020/PLAN.md`, + `/home/codex/work/ssd2/task-evidence/craft-fork-ai-lines-20261022/SURVEY.md`, + `/home/codex/work/ssd2/task-evidence/craft-fork-egui037-20261021/RESULTS.md`, + `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/upstream-discussions.json`, + `upstream-8764-fresh.json`, `craft-ui-consumers-exact.json`. +- Repository rules and commands: `AGENTS.md`, `CLAUDE.md`, `docs/architecture.md`, + `docs/testing.md`, `docs/web.md`, `docs/agents.md`, `.github/workflows/ci.yml`, + `xtask/src/main.rs`. + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +The owned checkout's current branch HEAD is `fa550ce2d07bdfee6b1d7b6835e8abc8c6913d00`; the migration work is uncommitted on that base. The 2026-10-23 note that the checkout was fast-forwarded to `c5c16939896277ab332280a6d3f1d7283d0d0c4e` and earlier baseline SHAs above are retained as historical review evidence. Use the current HEAD and its exact manifest/lock hashes (table in §1) for local integration and paired measurements; remote main is not asserted here. The original refs remain reachable. + +Preserve isolated MCP screenshot temporary directories and caller-path desktop captures; rejected synthetic gestures enqueue nothing (click 0–10, drag 2–1024). Retain the new typography/contrast/clip regressions and random benchmark output directories. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + + +## Final source review repair — 2026-10-24 + +The reviewed cumulative source identity and two findings are preserved in the external +`final-landing/filmcraft-final-review.md/json` evidence. Same-owner application caret/selection +moves (pointer, keyboard, Select All and explicit gfxEdit offsets) and another field taking +focus now invalidate only a known active/terminal-pending graphics composition before its late +replacement can mutate the moved selection. Stable owner/generation identity is preserved; +commit-only input without an old composition and uninterrupted replacement chains stay valid. +A private one-shot interruption asks the platform to cancel the old composition and survives +discarded passes until a final observation. No public IME protocol was added. + +Automation lookup now uses the current registry whenever it is populated, so a removed control +cannot resolve to its previous-pass coordinates. The pre-draw previous-registry fallback stays. +New regressions cover removed IDs, real pointer press/release plus next-batch char/byte deletion +and commit, terminal-pending composition, same-owner control/keyboard moves, direct-commit and +valid-chain positive controls. These checks and fresh independent review are pending managed +validation; the earlier18-test result predates this repair. Canonical manifests/lock are unchanged. diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 47f743d4f..bd5d67e6b 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -120,7 +120,6 @@ if has appimage; then esac APPDIR="$WORK/FilmCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" ln -s usr/bin/filmcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" diff --git a/packaging/windows/filmcraft.wxs b/packaging/windows/filmcraft.wxs index 91eb71de6..44114a4b3 100644 --- a/packaging/windows/filmcraft.wxs +++ b/packaging/windows/filmcraft.wxs @@ -7,6 +7,9 @@ Per-machine install into Program Files and a Start Menu shortcut. No file associations. --> + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index faa4b8638..786fea80e 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -90,11 +90,25 @@ Copy-Item (Join-Path $Bin 'filmcraft.exe'), (Join-Path $Bin 'filmcraft-cli.exe') & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'filmcraft.exe') (Join-Path $Stage 'filmcraft-cli.exe') +# ---- font notices for the MSI ------------------------------------------------------------------ +# The portable ZIP already carries these notices; stage the complete files before MSI creation too. +$Docs = Join-Path $Stage 'font-licences' +New-Item -ItemType Directory -Force -Path $Docs | Out-Null +$HasCraftFontLicences = 0 +if ($env:CRAFT_FONTS_DIR) { + foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "filmcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { wix build (Join-Path $PSScriptRoot 'filmcraft.wxs') -arch $Arch ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\filmcraft.ico')" ` + -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.