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/ATTRIBUTION.md b/ATTRIBUTION.md index e8a3a0aa2..2801e9e29 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -172,3 +172,12 @@ SHA-256 (`crates/speech/src/models.rs`, `docs/transcripts.md`). | Asset | Author | Source | Licence | |---|---|---|---| | Whisper tiny / base / small speech-recognition weights (`model.safetensors`, `config.json`, `generation_config.json`, `tokenizer.json`) | OpenAI | `https://huggingface.co/openai/whisper-{tiny,base,small}` (revisions `169d4a43…`, `e37978b9…`, `973afd24…`); weights released with `https://github.com/openai/whisper` | MIT (weights and code, github.com/openai/whisper); the Hugging Face conversion is labelled Apache-2.0 | + +## egui dependency fonts + +| Asset | Author | Source | Licence | Use | +|---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. | +| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | Ubuntu Font Licence 1.0 (Copyright 2011 Canonical Ltd.; full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional fallback, included by `default_fonts`; `system_fonts` is not enabled. Dependency asset unmodified here; no font file copied into this repository. | +| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors (Google) | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | OFL-1.1 (Copyright 2013 Google Inc.; full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is the monochrome emoji fallback, included because the workspace enables `monochrome_emoji_fonts`. Dependency asset unmodified here; no font file copied into this repository. | +| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors; Bitstream Inc.; DejaVu contributors | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT (Copyright (c) 2018 Source Foundry Authors) and the Bitstream Vera License (Copyright (c) 2003 Bitstream, Inc.); the DejaVu work is public domain; full upstream notices in `NOTICE` | Hack-Regular.ttf is egui's default monospace fallback, included by `default_fonts`. Dependency asset unmodified here; no font file copied into this repository. | diff --git a/Cargo.lock b/Cargo.lock index a9f6190ca..74321a89e 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", ] @@ -1014,12 +1010,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "thiserror 2.0.21", ] @@ -1027,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "egui", "log", @@ -1299,9 +1295,8 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1333,9 +1328,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1357,6 +1351,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1368,9 +1363,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1388,9 +1382,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1408,30 +1401,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1443,9 +1439,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "eframe", "egui", @@ -1488,9 +1483,8 @@ dependencies = [ [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1550,9 +1544,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1568,16 +1561,17 @@ dependencies = [ "self_cell", "skrifa", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -2431,6 +2425,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -2460,6 +2460,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2947,12 +2953,12 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -3169,6 +3175,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -3177,7 +3192,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", "serde", "serde_core", ] @@ -3188,7 +3203,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3487,12 +3502,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3765,6 +3780,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -4318,6 +4342,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "p256" version = "0.13.2" @@ -4437,10 +4471,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.13.1" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -4449,9 +4494,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -4459,9 +4504,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -4472,9 +4517,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -5445,6 +5490,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "seq-macro" version = "0.3.6" @@ -6248,6 +6299,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -6310,6 +6372,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -6386,9 +6454,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -6401,9 +6469,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -7270,8 +7338,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -7352,6 +7419,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "x11-dl" version = "2.21.0" @@ -7703,3 +7788,23 @@ dependencies = [ "syn 3.0.6", "winnow 1.0.4", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 1de2139e4..afaaf5401 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,15 +72,18 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" proptest = "1" roxmltree = "0.21" -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" } -craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", default-features = false } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } +craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false } # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", features = ["serde", "native-windows"] } -egui = "0.36" -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde", "native-windows"] } +# egui 0.37: bundled fonts are opt-in, and the monochrome symbol/emoji faces moved to their own +# feature. FilmCraft paints ✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑ in panel chrome, so both features are part of the +# production font policy (system_fonts stays off). +egui = { version = "0.37", features = ["default_fonts", "monochrome_emoji_fonts"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" [workspace.lints.rust] @@ -153,3 +156,21 @@ opt-level = 3 [profile.release] lto = "thin" codegen-units = 1 + +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index d0d113091..92f248744 100644 --- a/NOTICE +++ b/NOTICE @@ -19,3 +19,268 @@ Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian) — OFL-1.1. Their licence texts ship with the packages as OFL-.txt. Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md. + + +egui icon font subset (dependency asset) +---------------------------------------- +epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of +emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback +uses the same MIT license. Upstream license text follows unchanged. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Noto Emoji font (dependency asset) +---------------------------------- +epaint_default_fonts 0.37, pinned with egui 0.37 at +https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts, +embeds NotoEmoji-Regular.ttf, the monochrome emoji fallback used when egui's +`monochrome_emoji_fonts` feature is enabled. The font's copyright notice and the +upstream SIL Open Font License 1.1 text follow unchanged. + +Copyright 2013 Google Inc. All Rights Reserved. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Ubuntu Light font (dependency asset) +------------------------------------ +epaint_default_fonts 0.37, pinned with egui 0.37 at +https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts, +embeds Ubuntu-Light.ttf as a default fallback. The font's copyright notice and the +upstream Ubuntu Font Licence 1.0 text follow unchanged. + +Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0 + +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + +Hack font (dependency asset) +---------------------------- +epaint_default_fonts 0.37, pinned with egui 0.37 at +https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts, +embeds Hack-Regular.ttf as the default monospace fallback. The upstream notice and +licences follow unchanged. + +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. diff --git a/apps/filmcraft-web/src/fs.rs b/apps/filmcraft-web/src/fs.rs index d7ec9072f..7f715b4da 100644 --- a/apps/filmcraft-web/src/fs.rs +++ b/apps/filmcraft-web/src/fs.rs @@ -424,6 +424,10 @@ impl Services for WebServices { } } +thread_local! { + static DOWNLOAD_CLOCK: web_time::Instant = web_time::Instant::now(); +} + /// Offer bytes as a browser download named `name`. Downloads are clicked at least /// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378). pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> { @@ -434,7 +438,8 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> { a.set_href(&url); a.set_download(file_name(name)); a.style().set_property("display", "none")?; - let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now())); + let now_ms = DOWNLOAD_CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0); + let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(now_ms)); let name = name.to_owned(); wasm_bindgen_futures::spawn_local(async move { if delay > 0.0 { diff --git a/apps/filmcraft-web/src/lib.rs b/apps/filmcraft-web/src/lib.rs index 21512ccc5..1110c64e4 100644 --- a/apps/filmcraft-web/src/lib.rs +++ b/apps/filmcraft-web/src/lib.rs @@ -94,12 +94,13 @@ impl eframe::App for WebApp { self.app.raw_input_hook(ctx, raw_input); } - fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + /// Hidden logic pass: host actions, app logic, API replies, jobs, audio, autosave, then IME reset; it does not draw UI. + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut())); for f in actions { f(self, ctx); } - self.app.logic(ctx, frame); + self.app.logic_inner(ctx); api::poll_replies(); // exports advance between frames (no threads) if self.app.session.pump_jobs(std::time::Duration::from_millis(30)) { @@ -109,6 +110,7 @@ impl eframe::App for WebApp { a.pump(); } self.autosave.tick(&self.app.session, ctx); + self.app.finish_ime_reset_logic(ctx); } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -159,6 +161,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { canvas, web_options, Box::new(move |cc| { + filmcraft_ui_egui::install_ime_reset_callbacks(&cc.egui_ctx); set_context(cc.egui_ctx.clone()); let ctx = cc.egui_ctx.clone(); fs::set_on_data(move || ctx.request_repaint()); diff --git a/apps/filmcraft-web/src/recovery.rs b/apps/filmcraft-web/src/recovery.rs index 6001531e8..fc556013a 100644 --- a/apps/filmcraft-web/src/recovery.rs +++ b/apps/filmcraft-web/src/recovery.rs @@ -28,10 +28,18 @@ 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 { + /// Default policy with a monotonic host clock, so hidden-tab logic never uses a stale egui input time. + fn default() -> Self { + Self { policy: Policy::default(), outcomes: Rc::default(), clock: web_time::Instant::now() } + } } impl Autosave { @@ -40,7 +48,7 @@ impl Autosave { if !opfs::available() { return; } - let now = ctx.input(|i| i.time); + let now = self.clock.elapsed().as_secs_f64(); for o in self.outcomes.borrow_mut().drain(..) { match o { Outcome::Snapshot { revision, ok } => self.policy.written(revision, ok, now), @@ -64,7 +72,9 @@ impl Autosave { } }) }; - let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()}); + // No wall-clock timestamp: recovery policy scheduling uses the monotonic clock + // above, and the snapshot schema needs no display time. + let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision}); let d1 = done(self.outcomes.clone(), failed.clone(), left.clone(), ctx.clone()); let d2 = done(self.outcomes.clone(), failed, left, ctx.clone()); opfs::write_then(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false), Some(d1)); diff --git a/apps/filmcraft-web/src/webcodecs.rs b/apps/filmcraft-web/src/webcodecs.rs index 48fa7a1c8..48b1d3bad 100644 --- a/apps/filmcraft-web/src/webcodecs.rs +++ b/apps/filmcraft-web/src/webcodecs.rs @@ -68,8 +68,15 @@ 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(); +} + +/// Milliseconds from a monotonic browser clock; codec idle and staleness accounting must never use wall time. fn now_ms() -> f64 { - js_sys::Date::now() + CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0) } fn get(o: &JsValue, k: &str) -> JsValue { 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..a29596f81 100644 --- a/apps/filmcraft/src/audio.rs +++ b/apps/filmcraft/src/audio.rs @@ -51,11 +51,12 @@ enum Note { } impl Note { + /// Localized status text for a fallback device (name interpolated through `fmt`) or for no output device. fn text(&self) -> String { use filmcraft_ui_egui::i18n::{fmt, t}; match self { Note::Fallback(device) => fmt(t("Audio hardware: the default output device cannot be opened; using '{device}'"), &[("device", device)]), - Note::NoDevice => t("No audio output device found: playing without sound (check Settings ▸ Audio Hardware)").to_string(), + Note::NoDevice => t("No audio output device found: playing without sound (check Settings > Audio Hardware)").to_string(), } } } diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 5ffec5aa2..2f2b6f818 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -190,6 +190,7 @@ fn native_options(agent: bool) -> eframe::NativeOptions { fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc) -> eframe::AppCreator<'static> { let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch; Box::new(move |cc| { + filmcraft_ui_egui::install_ime_reset_callbacks(&cc.egui_ctx); created.store(true, Ordering::Relaxed); // A fallback graphics mode says so in the window, with a button back to the default. let notice = graphics.degraded().then(|| { diff --git a/crates/engine/src/clip_ops.rs b/crates/engine/src/clip_ops.rs index c878255ff..efc7fb26c 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 fea450aee..5784fa823 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1201,6 +1201,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; @@ -1216,6 +1217,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; @@ -4661,6 +4663,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti let first = proj.sequences().next().map(|i| i.id); s.media.add_stored(&proj, path.as_deref()); s.project = std::sync::Arc::new(proj); + s.document_epoch = s.document_epoch.wrapping_add(1); s.history = Default::default(); s.history.limit = 200; s.state = crate::Session::default().state; 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 6b430e0ac..5b70393d8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -431,6 +431,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, @@ -549,6 +553,7 @@ impl Session { project: Arc::new(Project::new("Untitled")), history: History { limit: 200, ..Default::default() }, revision: 1, + document_epoch: 0, saved_revision: 1, path: None, state: EditorState { 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 ea19c6aa9..a7959bac0 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 aa19bb615..b77968dd1 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(); // Embedded LUT text is rendered on the hot path. Drop oversize entries at load time so a // hostile project cannot retain an unrenderable multi-megabyte source or repeatedly offer it // to renderers. Clips that refer to one simply have no LUT, like a deleted library entry. @@ -437,6 +440,32 @@ mod tests { assert_eq!(loaded.project.sequence(seq).unwrap().video_tracks[0].items.len(), 3); } + /// Opening a project written before graphic layers had uids assigns stable, unique identities + /// without a schema upgrade. The old file still opens, and its two byte-identical layers (same + /// geometry, different text) can no longer be confused after a reorder. + #[test] + fn opening_a_legacy_project_assigns_unique_graphic_layer_uids() { + use filmcraft_project::graphic::{layer_indices, new_text_layer}; + use filmcraft_project::{ItemKind, SequenceSettings, TrackKind}; + use filmcraft_time::{FrameRate, Tick, TimeRange}; + let mut p = Project::new("Legacy graphics"); + let rate = FrameRate::FPS_25; + let seq = p.new_sequence("Sequence 01", SequenceSettings::default(), 1, 1, None); + let graphic = p.add_item("Graphic", filmcraft_project::Label::Rose, ItemKind::Graphic { width: 1920, height: 1080, rate }, None); + let mut item = p.make_track_item(graphic, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(100)), rate).unwrap(); + item.effects = vec![new_text_layer("one", Default::default(), 100.0), new_text_layer("two", Default::default(), 100.0)]; + assert!(item.effects.iter().all(|e| filmcraft_project::gtemplate::layer_uid(e) == 0)); + p.sequence_mut(seq).unwrap().video_tracks[0].items = vec![item]; + let loaded = decode(&encode(&p, false)).expect("an old project still opens"); + let item = &loaded.project.sequence(seq).unwrap().video_tracks[0].items[0]; + let uids: Vec = 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 b37d82687..05693820f 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1750,6 +1750,32 @@ fn resolve_auto_points_sized(e: &mut EffectInstance, frame: (u32, u32), source: } impl Project { + /// Lazy upgrade for graphic layers written before they had stable uids: assign a unique + /// [`crate::graphic_design::LayerExtra::uid`] to every uid-less or duplicated graphic layer + /// in sequences and source graphics. + /// + /// Called when a project file is opened, before undo history starts, so every later undo + /// snapshot carries the same identity. The field is already optional in the file schema, so + /// this changes no schema version and older builds still read the file. Returns whether any + /// layer changed. + pub fn ensure_graphic_layer_uids(&mut self) -> bool { + let mut changed = false; + for item in self.items.values_mut() { + if let ItemKind::Sequence(seq) = &mut item.kind { + let seq = std::sync::Arc::make_mut(seq); + for track in seq.video_tracks.iter_mut().chain(seq.audio_tracks.iter_mut()) { + for it in &mut track.items { + changed |= crate::graphic::ensure_layer_uids(&mut it.effects); + } + } + } + } + for graphic in self.source_graphics.values_mut() { + changed |= crate::graphic::ensure_layer_uids(&mut graphic.layers); + } + changed + } + /// Available picture and sound, resolving a subclip to its parent media. Missing parents, /// cycles and overly deep subclip chains carry neither stream. pub fn source_streams(&self, item: ItemId) -> (bool, bool) { 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 474a86c96..70adc7637 100644 --- a/crates/ui-egui/src/automation.rs +++ b/crates/ui-egui/src/automation.rs @@ -61,6 +61,9 @@ impl Registry { } /// Observe the widget without changing its accessibility role or input behavior. pub fn add_response(&mut self, id: &str, response: &egui::Response, label: &str) { + // The automation label is also the name a screen reader and kittest use. Keep an + // existing visible label/placeholder; this only fills a missing name. + crate::widgets::accessible_name(response.clone(), label); self.add_in(response.ctx.viewport_id(), id, response.rect, label); if let Some(element) = self.elements.last_mut() { element.widget_id = Some(format!("{:016x}", response.id.value())); @@ -72,11 +75,78 @@ impl Registry { pub fn begin_frame(&mut self) { self.previous = std::mem::take(&mut self.elements); } - pub fn find(&self, id: &str) -> Option<&Element> { - self.previous.iter().chain(self.elements.iter()).find(|e| e.id == id) + /// The entries of the last accepted UI pass, or the pass before it while the current pass is + /// still being laid out (pass 0 clears `elements`; `drain_ui_requests` runs after the layout). + pub fn all(&self) -> &[Element] { + if self.elements.is_empty() { &self.previous } else { &self.elements } } - /// Elements whose id starts with a prefix. + /// A widget drawn in the current accepted pass. Unlike [`Self::find`] this never falls back to + /// the previous pass, for callers that need fresh geometry (e.g. a screenshot crop). + pub fn find_current(&self, id: &str) -> Option<&Element> { + self.elements.iter().find(|e| e.id == id) + } + /// Look up a widget, preferring the layout just drawn. The previous pass is only used before + /// this pass has registered anything (input handling runs before the panels draw). + pub fn find(&self, id: &str) -> Option<&Element> { + self.all().iter().find(|e| e.id == id) + } + /// Elements whose id starts with a prefix, from the current accepted pass when there is one. pub fn query(&self, prefix: &str) -> Vec<&Element> { - self.previous.iter().filter(|e| e.id.starts_with(prefix)).collect() + self.all().iter().filter(|e| e.id.starts_with(prefix)).collect() + } +} + +#[cfg(test)] +mod tests { + use egui::{Rect, pos2, vec2}; + + use super::Registry; + + /// Test helper: a 10×10 rect at `x` for pass-order fixtures. + fn rect(x: f32) -> Rect { + Rect::from_min_size(pos2(x, 0.0), vec2(10.0, 10.0)) + } + + /// Lookups and prefix queries must resolve the pass just drawn, not stale geometry. + #[test] + fn lookups_prefer_the_current_accepted_pass() { + let mut registry = Registry::default(); + registry.add("panel.project", rect(1.0), "old"); + registry.begin_frame(); + registry.add("panel.project", rect(200.0), "new"); + assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(200.0)); + assert_eq!(registry.query("panel.").iter().map(|e| e.rect[0]).collect::>(), vec![200.0]); + assert_eq!(registry.find_current("panel.project").map(|e| e.rect[0]), Some(200.0)); + } + + /// Once the current pass registers an element, a removed id must not resolve from the previous pass. + #[test] + fn removed_elements_do_not_resolve_from_a_populated_current_pass() { + let mut registry = Registry::default(); + registry.add("dialog.closed", rect(1.0), "old control"); + registry.begin_frame(); + assert!(registry.find("dialog.closed").is_some(), "before drawing, the previous pass remains usable"); + registry.add("panel.current", rect(200.0), "current control"); + assert!(registry.find("dialog.closed").is_none(), "a closed control cannot target stale coordinates"); + assert_eq!(registry.find("panel.current").map(|e| e.rect[0]), Some(200.0)); + assert!(registry.query("dialog.").is_empty()); + } + + /// The previous pass is usable only before the current pass draws; a discarded pass leaves exactly one entry. + #[test] + fn the_previous_pass_is_only_a_fallback_before_this_pass_draws() { + let mut registry = Registry::default(); + registry.add("panel.project", rect(1.0), "old"); + registry.begin_frame(); + assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(1.0)); + assert_eq!(registry.query("panel.").len(), 1); + assert!(registry.find_current("panel.project").is_none()); + + // A discarded pass is cleared and redrawn, so exactly one entry per widget remains. + registry.add("panel.project", rect(2.0), "discarded"); + registry.elements.clear(); + registry.add("panel.project", rect(3.0), "accepted"); + assert_eq!(registry.elements.len(), 1); + assert_eq!(registry.find("panel.project").map(|e| e.rect[0]), Some(3.0)); } } 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..617845643 --- /dev/null +++ b/crates/ui-egui/src/capture.rs @@ -0,0 +1,1113 @@ +//! 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::Arc; +use std::sync::mpsc::Sender; + +use craft_ui::capture::{CaptureError, CaptureSlot}; +use egui::{ColorImage, 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 { + /// Compares only the fields that change captured pixels: viewport size, pixels per point and + /// the GPU texture limit. The presentation-only minimized/occluded flags are deliberately + /// ignored, so minimizing, occluding or losing window focus does not invalidate an issued + /// capture. + 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 { + /// Snapshot the surface from a raw-input callback, reading the fresh `viewports`/`screen_rect` + /// geometry rather than the stale `InputState`. Missing or hostile fields fall back to + /// `previous` where possible; `None` means no usable zoom and pixels-per-point pair could be + /// established. + 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 }) + } + + /// Pixel size the backend will present: viewport points scaled by pixels per point and + /// rounded. If either axis exceeds the texture limit, both axes shrink by the same factor to + /// a positive limit, matching wgpu's `surface_fit`; per-axis clamping would predict the wrong + /// image. A zero/unknown texture limit disables shrinking. + /// `None` when the viewport is missing, non-finite, empty or the size overflows `usize`. + 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)]) + } + + /// Pixels per point of the delivered screenshot texture. + /// + /// egui-wgpu's `surface_fit` shrinks an oversized window's surface by one factor and draws it + /// at `pixels_per_point * render_scale`; a crop expressed in points must use that rendered + /// scale, not the native one. A window within the limit keeps the native factor exactly, so a + /// one-pixel rounding difference between `viewport * ppp` and the integer window size never + /// becomes a bogus sub-1 scale. + fn captured_pixels_per_point(self) -> f32 { + let native = self.pixels_per_point; + if !native.is_finite() || native <= 0.0 { + return 1.0; + } + let Some(viewport) = self.viewport else { return native }; + if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 || self.max_texture_side == 0 { + return native; + } + let axis = |points: f32| (f64::from(points) * f64::from(native)).round(); + let (width, height) = (axis(viewport.x), axis(viewport.y)); + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return native; + } + let longest = width.max(height); + let limit = self.max_texture_side as f64; + if longest <= limit { + return native; + } + // Match egui-wgpu exactly: it casts `surface_fit`'s scale to f32 and then multiplies the + // f32 pixels-per-point by it. + let render_scale = (limit / longest) as f32; + let effective = native * render_scale; + if effective.is_finite() && effective > 0.0 { effective } else { native } + } + + /// Raw RGBA bytes of [`Self::expected_size`], checked for multiplication overflow; `None` + /// when the surface has no capturable size. + 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 { + /// Pixel size implied by the bound surface geometry; `None` when it is not capturable. + fn expected_size(self) -> Option<[usize; 2]> { + self.surface.expected_size() + } + + /// Raw RGBA byte count implied by the bound surface, with the same overflow rule as + /// [`SurfaceGeometry::expected_bytes`]. + 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 { + /// Resolve the named element against the accepted pass's automation registry. Only an element + /// drawn in the current pass with a finite, non-empty rectangle is accepted; a stale + /// previous-pass rectangle or an unknown id is an error, never a fallback to the whole window. + 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 { + /// True when no request is queued, in flight or completed-but-not-yet-drained. + pub(crate) fn is_empty(&self) -> bool { + self.requests.is_empty() + } + + /// True while at least one admitted request has not been handed to a `Screenshot` callback. + fn has_unissued(&self) -> bool { + self.requests.iter().any(|shot| !shot.issued) + } + + /// Sum of every request's byte reservation, including completed-but-undrained captures; + /// saturates instead of overflowing. + fn reserved(&self) -> usize { + self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.reserved)) + } +} + +/// Allocate the next request token, wrapping after `u64::MAX` and skipping zero so a token is +/// always usable as a presence marker. +fn next_token(shots: &mut Shots) -> u64 { + let mut token = shots.token.wrapping_add(1); + if token == 0 { + token = 1; + } + shots.token = token; + token +} + +/// Build the JSON error reply carrying `token`, so the client can match a rejection to the +/// request it sent. +fn error_reply(token: u64, message: impl std::fmt::Display) -> Value { + json!({"ok": false, "error": message.to_string(), "request": token}) +} + +/// Send an error reply for `token`, ignoring a client that has already disconnected. +fn reject(reply: Sender, token: u64, message: impl std::fmt::Display) { + let _ = reply.send(error_reply(token, message)); +} + +/// The active sequence id (none when no sequence is open) and project revision that a capture +/// is bound to. +fn active_document(app: &FilmcraftApp) -> DocumentId { + DocumentId { sequence: app.session.state.active_sequence.map(|id| id.0), revision: app.session.revision } +} + +/// The widget currently holding keyboard focus, part of the capture identity. +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(); +} + +/// Why the request's bound document/revision or keyboard focus no longer matches. `None` +/// means identity is unchanged; a mismatch must reject rather than save pixels from another +/// document or focus. +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 + } +} + +/// Why the live surface snapshot or capture epoch differs from the one this request was +/// admitted or issued against. `None` means the geometry is still the one it reserved for. +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 + } +} + +/// Re-admit an unissued request against the current geometry: cancel its old slot, check the +/// per-image and aggregate budgets including every other request, and reset its settled +/// frames, crop misses and ready time. On failure nothing is changed and the caller rejects +/// it; on success the request is unissued again and must collect fresh settling passes. +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(()) +} + +/// Whether an unissued request has waited for enough accepted passes, including the bounded +/// maximum when the monitor is still showing an inexact frame or the timeline is animating. +fn shot_is_settled(now: Instant, monitor_inexact: bool, timeline_still: u32, shot: &Shot) -> bool { + if now < shot.ready_at || shot.frames < SETTLE_PASSES { + return false; + } + !((monitor_inexact || timeline_still < 2) && now < shot.ready_at + SETTLE_MAX) +} + +/// The result of trying to issue one settled request. Every variant keeps the original admission +/// order: identity failures reject, geometry changes retry, a missing crop target waits a bounded +/// number of passes, and everything else issues in the current accepted UI pass. +enum IssueOutcome { + Identity(String), + GeometryRetry, + CropDeferred, + Failed(String), + Issued, +} + +/// Decide one settled request: reject on identity change, ask for a geometry retry, defer a +/// not-yet-drawn crop target for a bounded number of accepted passes, or issue the screenshot +/// with the crop resolved from this pass. The callback is taken at most once and `issued` is +/// set only when the viewport command was sent. +fn try_issue_shot(app: &mut FilmcraftApp, ctx: &egui::Context, index: usize) -> IssueOutcome { + let shot = &app.shots.requests[index]; + if let Some(reason) = identity_reason(app, ctx, shot) { + return IssueOutcome::Identity(reason.to_string()); + } + if geometry_reason(app, shot).is_some() { + return IssueOutcome::GeometryRetry; + } + let crop_result = match shot.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[index]; + 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); + return IssueOutcome::CropDeferred; + } + return IssueOutcome::Failed(message); + } + }; + let shot = &mut app.shots.requests[index]; + let Some(callback) = shot.callback.take() else { + return IssueOutcome::Failed("screenshot failed: the capture callback was already consumed".into()); + }; + shot.crop = resolved_crop; + ctx.send_viewport_cmd_to(ctx.viewport_id(), ViewportCommand::Screenshot(callback)); + shot.issued = true; + IssueOutcome::Issued +} + +/// Reject failed requests from the highest index down, so an earlier removal cannot shift a later +/// index and remove the wrong shot. +fn reject_failures(app: &mut FilmcraftApp, mut failures: Vec<(usize, String)>) { + failures.sort_unstable_by_key(|(i, _)| std::cmp::Reverse(*i)); + for (i, message) in failures { + if i >= app.shots.requests.len() { + continue; + } + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, message); + } +} + +/// 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 !shot_is_settled(now, monitor_inexact, app.timeline_still, shot) { + continue; + } + match try_issue_shot(app, ctx, i) { + IssueOutcome::Identity(reason) => failures.push((i, reason)), + IssueOutcome::GeometryRetry => retries.push(i), + IssueOutcome::CropDeferred => ctx.request_repaint(), + IssueOutcome::Failed(message) => failures.push((i, message)), + IssueOutcome::Issued => {} + } + } + for i in retries.into_iter().rev() { + if let Err(message) = retry_geometry(app, ctx, i) { + failures.push((i, message)); + } + } + reject_failures(app, failures); + if !app.shots.is_empty() { + ctx.request_repaint_after(REPAINT_INTERVAL); + } +} + +/// The next step for one drain scan position. `Keep` advances to the next request; every removal +/// variant leaves the index unchanged so it sees the shot shifted into place. +enum DrainStep { + Keep, + Timeout, + Identity(String), + Geometry, + Complete(Result, CaptureError>), +} + +/// Classify one drain position before removing anything: check the deadline first, then +/// identity, then geometry (an unissued request keeps waiting for a retry, while in-flight +/// stale pixels are rejected), and finally take a completed slot. `Keep` leaves the request +/// in place and advances the scan. +fn drain_step(app: &FilmcraftApp, ctx: &egui::Context, index: usize, now: Instant) -> DrainStep { + let shot = &app.shots.requests[index]; + if now >= shot.deadline { + return DrainStep::Timeout; + } + if let Some(reason) = identity_reason(app, ctx, shot) { + return DrainStep::Identity(reason.to_string()); + } + if geometry_reason(app, shot).is_some() { + if !shot.issued { + // The next accepted UI pass re-creates the slot against the fresh geometry. + return DrainStep::Keep; + } + // Geometry changed while the callback was already issued: reject stale pixels rather + // than pairing them with the new surface. + return DrainStep::Geometry; + } + if !shot.issued { + return DrainStep::Keep; + } + let Some(result) = shot.slot.as_ref().and_then(CaptureSlot::take) else { + return DrainStep::Keep; + }; + DrainStep::Complete(result) +} + +/// Remove one request, cancel its slot and send its error reply; later requests shift down by +/// one index. +fn remove_and_reject(app: &mut FilmcraftApp, index: usize, message: impl std::fmt::Display) { + let shot = app.shots.requests.remove(index); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, message); +} + +/// Build the saved-image reply, using the rendered scale of the delivered texture for crops. +fn image_reply(shot: &Shot, image: &ColorImage) -> Value { + if image.size != shot.expected { + return 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] + ), + ); + } + let captured_ppp = shot.geometry.surface.captured_pixels_per_point(); + let mut reply = crate::control::save_screenshot_with_ppp(image, shot.path.as_deref(), shot.crop, captured_ppp); + if let Some(object) = reply.as_object_mut() { + object.insert("request".into(), json!(shot.token)); + } + reply +} + +/// Cancel any remaining callback, turn the captured image or error into the reply and send it +/// exactly once. A pixel size that differs from the reserved size becomes an error reply +/// instead of writing an image for the wrong surface. +fn complete_shot(mut shot: Shot, result: Result, CaptureError>) { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + let reply = match result { + Ok(image) => image_reply(&shot, &image), + Err(error) => error_reply(shot.token, error), + }; + let _ = shot.reply.send(reply); +} + +/// 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(); + match drain_step(app, ctx, i, now) { + DrainStep::Keep => i += 1, + DrainStep::Timeout => remove_and_reject(app, i, "no frame was presented before the screenshot timeout"), + DrainStep::Identity(reason) => remove_and_reject(app, i, format!("screenshot failed: {reason}")), + DrainStep::Geometry => remove_and_reject(app, i, "screenshot failed: the window geometry changed while the capture was in flight"), + DrainStep::Complete(result) => { + let shot = app.shots.requests.remove(i); + complete_shot(shot, result); + } + } + } + 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; + + /// App with a default session, for capture-state unit tests. + fn app() -> FilmcraftApp { + FilmcraftApp::new(Session::default()) + } + + /// A fixed 800×600, 1 px/pt capture identity for the app's current document with no focus. + 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, + } + } + + /// Raw input reporting an 800×600 root viewport. + 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() } + } + + /// Admit one uncropped in-memory capture at the default test surface; return its reply + /// receiver and token. + fn admit_one(app: &mut FilmcraftApp, ctx: &egui::Context) -> (Receiver, u64) { + admit_with_surface(app, ctx, geometry(app).surface) + } + + /// Bind `surface` as the current capture geometry, admit one uncropped in-memory capture and + /// return its reply receiver and token. + fn admit_with_surface(app: &mut FilmcraftApp, ctx: &egui::Context, surface: SurfaceGeometry) -> (Receiver, u64) { + app.capture_geometry = Some(CaptureGeometry { surface, document: active_document(app), focus: None }); + let (tx, rx) = channel(); + admit(app, ctx, None, None, tx); + let token = app.shots.requests.last().map(|shot| shot.token).unwrap_or_default(); + (rx, token) + } + + /// `note_raw_geometry` uses the fresh raw 2x viewport (1600×1200) rather than the previous + /// pass's input and bumps the capture epoch. + #[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); + } + + /// Oversized surfaces shrink both axes by one factor to the texture limit and crop at the + /// matching rendered scale; a surface within the limit keeps the exact native scale. + #[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"); + assert_eq!(surface.captured_pixels_per_point(), 0.5, "the fitted texture is drawn at the same factor"); + surface.viewport = Some(vec2(4_000.0, 16_384.0)); + assert_eq!(surface.expected_size(), Some([2000, 8192])); + assert_eq!(surface.captured_pixels_per_point(), 0.5); + surface.viewport = Some(vec2(1920.0, 1080.0)); + assert_eq!(surface.expected_size(), Some([1920, 1080]), "within the limit nothing shrinks"); + assert_eq!(surface.captured_pixels_per_point(), 1.0, "an uncapped surface keeps the native scale exactly"); + } + + /// Unusable viewport data preserves a valid native scale, while an invalid native scale falls + /// back to 1.0 so a crop can never divide by zero. + #[test] + fn captured_scale_falls_back_when_geometry_is_unusable() { + let app = app(); + let mut surface = geometry(&app).surface; + surface.pixels_per_point = 2.0; + surface.viewport = None; + assert_eq!(surface.captured_pixels_per_point(), 2.0, "no viewport preserves the native scale"); + surface.viewport = Some(vec2(0.0, 600.0)); + assert_eq!(surface.captured_pixels_per_point(), 2.0); + surface.viewport = Some(vec2(f32::NAN, 600.0)); + assert_eq!(surface.captured_pixels_per_point(), 2.0); + surface.viewport = Some(vec2(800.0, 600.0)); + surface.pixels_per_point = f32::INFINITY; + assert_eq!(surface.captured_pixels_per_point(), 1.0, "an invalid native scale falls back to 1, like save_screenshot_with_ppp"); + surface.pixels_per_point = 0.0; + assert_eq!(surface.captured_pixels_per_point(), 1.0); + } + + /// A crop in points selects the region where a uniformly downscaled surface actually drew it, + /// using the rendered rather than the native scale. + #[test] + fn downscaled_capture_crops_with_the_rendered_scale() { + let ctx = egui::Context::default(); + let mut app = app(); + let surface = SurfaceGeometry { + viewport: Some(vec2(16_384.0, 2_000.0)), + // A 2x native surface fits at 0.25 render scale: crop at 0.5 pixels per point. + // This also catches accidentally returning render_scale without the native factor. + pixels_per_point: 2.0, + max_texture_side: 8192, + minimized: false, + occluded: false, + }; + assert_eq!(surface.expected_size(), Some([8192, 1_000])); + assert_eq!(surface.captured_pixels_per_point(), 0.5); + let (rx, token) = admit_with_surface(&mut app, &ctx, surface); + let dir = tempfile::tempdir().expect("a temporary screenshot directory"); + let path = dir.path().join("downscaled-crop.png"); + // The crop is 400x200 points, which the fitted surface draws as 200x100 pixels. Paint a + // red rectangle at exactly that rendered position so a wrong (native) scale would crop + // black pixels or the wrong dimensions instead of this region. + let mut image = ColorImage::filled([8192, 1_000], Color32::BLACK); + for y in 25..125 { + for x in 50..250 { + image.pixels[y * 8192 + x] = Color32::RED; + } + } + let shot = &mut app.shots.requests[0]; + shot.path = Some(path.to_string_lossy().into_owned()); + shot.crop = Some([100.0, 50.0, 400.0, 200.0]); + shot.issued = true; + let callback = shot.callback.take().expect("a callback"); + callback.complete(Arc::new(image)); + drain(&mut app, &ctx); + let reply = rx.try_recv().expect("a screenshot reply"); + assert_eq!(reply["request"], token); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!([reply["result"]["width"].as_u64(), reply["result"]["height"].as_u64()], [Some(200), Some(100)]); + let decoded = image::load_from_memory(&std::fs::read(&path).expect("the saved crop")).expect("decode the saved crop").to_rgba8(); + assert_eq!(decoded.dimensions(), (200, 100)); + assert!( + decoded.pixels().all(|pixel| *pixel == image::Rgba([255, 0, 0, 255])), + "the crop must select the red region where the downscaled surface drew it" + ); + } + + /// A 1.5x surface whose point size rounds up by one window pixel is still uncapped, so its + /// crop keeps the exact 1.5 scale instead of a slightly shrunk one. + #[test] + fn uncapped_hidpi_capture_keeps_the_native_scale() { + let ctx = egui::Context::default(); + let mut app = app(); + let surface = SurfaceGeometry { + // 801 points at 1.5x is 1201.5 window pixels, which rounds to 1202. The fitted scale + // must still be exactly 1.0 rather than 1202/1201.5 from that one-pixel difference. + viewport: Some(vec2(801.0, 600.0)), + pixels_per_point: 1.5, + max_texture_side: 8192, + minimized: false, + occluded: false, + }; + assert_eq!(surface.expected_size(), Some([1202, 900])); + assert_eq!(surface.captured_pixels_per_point(), 1.5); + let (rx, token) = admit_with_surface(&mut app, &ctx, surface); + let dir = tempfile::tempdir().expect("a temporary screenshot directory"); + let path = dir.path().join("hidpi-crop.png"); + let mut image = ColorImage::filled([1202, 900], Color32::BLACK); + for y in 75..375 { + for x in 150..750 { + image.pixels[y * 1202 + x] = Color32::GREEN; + } + } + let shot = &mut app.shots.requests[0]; + shot.path = Some(path.to_string_lossy().into_owned()); + shot.crop = Some([100.0, 50.0, 400.0, 200.0]); + shot.issued = true; + let callback = shot.callback.take().expect("a callback"); + callback.complete(Arc::new(image)); + drain(&mut app, &ctx); + let reply = rx.try_recv().expect("a screenshot reply"); + assert_eq!(reply["request"], token); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!([reply["result"]["width"].as_u64(), reply["result"]["height"].as_u64()], [Some(600), Some(300)]); + let decoded = image::load_from_memory(&std::fs::read(&path).expect("the saved crop")).expect("decode the saved crop").to_rgba8(); + assert_eq!(decoded.dimensions(), (600, 300)); + assert!(decoded.pixels().all(|pixel| *pixel == image::Rgba([0, 255, 0, 255])), "the HiDPI crop must select the green region at the native scale"); + } + + /// Minimized, occluded and window-focus flags do not bump the epoch or reject an already + /// issued capture; its pixels are still delivered once. + #[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}"); + } + + /// Admission counts completed-but-undrained requests, so a new request over `MAX_REQUESTS` + /// is rejected instead of replacing a slot that still owes a 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}"); + } + + /// A timed-out request replies once; its late callback cannot produce a second 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"); + } + + /// A near-per-image-limit surface is rejected before any slot exists because its crop/encode + /// reservation alone exceeds the aggregate bound. + #[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)); + } + + /// A crop resolves only from the current pass's registry; a stale rect and a missing id both + /// error rather than using old coordinates. + #[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()); + } + + /// Issuing waits for the accepted final pass of a multipass layout, not the discarded first + /// pass. + #[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"); + } + + /// `issue` consumes `monitor_inexact`, so the signal cannot stay sticky and delay later + /// screenshots until the five-second settle maximum. + #[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"); + } + + /// An unissued request follows a geometry change: its expected size and budget are recomputed + /// and it waits for fresh settling passes before issuing. + #[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"); + } + + /// When a resize pushes both queued shots over the aggregate budget, each is rejected with + /// its own token and removal of the first cannot shift the second's index. + #[test] + fn mixed_order_geometry_failures_reject_every_shot_without_shifting_indices() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx0, token0) = admit_one(&mut app, &ctx); + let (rx1, token1) = admit_one(&mut app, &ctx); + for shot in &mut app.shots.requests { + shot.ready_at = Instant::now() - Duration::from_millis(1); + shot.frames = SETTLE_PASSES; + } + app.timeline_still = 2; + // A resize makes both queued shots need more crop/encode buffers than the aggregate + // budget, so the geometry retry fails for the last shot first. + 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, + }); + app.capture_epoch += 1; + issue(&mut app, &ctx); + assert!(app.shots.requests.is_empty(), "both failed shots were removed"); + for (rx, token) in [(rx0, token0), (rx1, token1)] { + let reply = rx.try_recv().expect("each failed shot gets its own reply"); + assert_eq!(reply["request"], token); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().unwrap_or_default().contains("aggregate"), "{reply}"); + } + } +} diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index b32b3f89d..0ead0b072 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -53,7 +53,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). @@ -103,3 +103,15 @@ fn install_chinese(fonts: &mut FontDefinitions) { stack.extend(order.iter().map(|f| name(f))); } } + +#[cfg(test)] +mod font_blob_tests { + use super::*; + + /// `FontData::from_blob` retains the TTC face index when constructed from a shared blob. + #[test] + fn a_ttc_face_index_survives_font_data_construction() { + let font = FontData::from_blob(Arc::new(vec![0u8; 4]), 7); + assert_eq!(font.index, 7, "the face index must travel with the shared blob"); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 374bea71c..f8bfc3944 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; /// Read a pointer repetition count, using `default` when `key` is absent or null. /// @@ -71,7 +73,8 @@ pub enum Outcome { AfterInput, Screenshot { path: Option, - crop: Option<[f32; 4]>, + /// Resolved to fresh geometry at issue time, not here. + crop: Option, }, } @@ -114,9 +117,14 @@ fn point(app: &FilmcraftApp, p: &Value) -> Result { Ok(egui::pos2(x as f32, y as f32)) } +/// Execute one control request. Synthetic input is capped by `MAX_SYNTHETIC_EVENTS` before queueing, screenshot crops stay symbolic until capture, and +/// `gfxEdit` layer identity is resolved from the document. pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); + if matches!(req.method.as_str(), "ui.click" | "ui.move" | "ui.drag" | "ui.scroll" | "ui.key" | "ui.type") && app.synthetic.len() >= MAX_SYNTHETIC_EVENTS { + return err("too many synthetic input events are queued; wait for the current input to be processed"); + } match req.method.as_str() { "engine.execute" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; @@ -305,8 +313,48 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) } // the Type tool's text editing state: {"clip", "layer", "caret", "anchor"} (byte offsets) or null if let Some(v) = p.get("gfxEdit") { - match serde_json::from_value(v.clone()) { - Ok(e) => app.ui.gfx_edit = e, + match serde_json::from_value::>(v.clone()) { + Ok(mut e) => { + // An explicit `ui.set.gfxEdit {clip, layer}` names the layer currently at + // that index. Resolve its identity from the document instead of inheriting + // a stale uid/fingerprint from a previous edit session: after a reorder, + // delete or undo the requested index may now hold a different layer. + if let Some(new) = e.as_mut() { + match crate::panels::graphics::current_layer_identity(&app.session, new.clip, new.layer) { + Some((uid, fingerprint)) => { + new.uid = uid; + new.fingerprint = fingerprint; + } + None => { + new.uid = 0; + new.fingerprint = 0; + } + } + } + let changed = match (&app.ui.gfx_edit, &e) { + (None, None) => false, + (Some(old), Some(new)) => { + // A stable uid is the owner key. The compact fingerprint only + // decides identity for a uid-less layer, and zero there means + // "the requested index" rather than a different owner. + old.clip != new.clip + || old.layer != new.layer + || old.uid != new.uid + || (new.uid == 0 && old.fingerprint != 0 && old.fingerprint != new.fingerprint) + } + _ => true, + }; + if changed { + app.restart_ime_target(); + } else if let (Some(old), Some(new)) = (&app.ui.gfx_edit, &e) + && (old.caret, old.anchor) != (new.caret, new.anchor) + { + // Caret is not part of the stable owner. Only a known composition + // becomes stale; commit-only input without one remains eligible. + app.invalidate_ime_composition(); + } + app.ui.gfx_edit = e; + } Err(e) => return err(format!("`gfxEdit`: {e}")), } } @@ -403,6 +451,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.synthetic.push(egui::Event::PointerMoved(pos)); app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Wheel, delta: egui::vec2(dx, dy), modifiers: modifiers(p), phase: egui::TouchPhase::Move, @@ -456,8 +505,14 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) ok(json!({"playing": app.playback.playing, "playhead": app.session.playhead().0})) } "ui.screenshot" => { - let crop = s("panel").and_then(PanelKind::from_name).and_then(|pk| app.auto.find(&format!("panel.{}", pk.id())).map(|e| e.rect)); - let crop = crop.or_else(|| s("id").and_then(|id| app.auto.find(id).map(|e| e.rect))); + // Keep the target symbolic: the crop must come from the layout that is actually + // captured, not from whatever the registry held when this control request arrived. + let crop = if let Some(panel) = s("panel") { + let Some(pk) = PanelKind::from_name(panel) else { return err(format!("unknown panel `{panel}`")) }; + Some(crate::capture::CropTarget::Element(format!("panel.{}", pk.id()))) + } else { + s("id").map(|id| crate::capture::CropTarget::Element(id.to_string())) + }; // Bring the window on screen so a frame is presented, without taking keyboard focus. app.raise_for_control(ctx); Outcome::Screenshot { path: s("path").map(str::to_string), crop } @@ -521,10 +576,11 @@ fn merge(v: &mut Value, patch: &Value) { } } +/// Read-only JSON snapshot; focus and element counts come from the current accepted registry pass. pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size(); let keyboard_focus = ctx.memory(|memory| memory.focused()).map(|id| { - let elements: Vec<_> = app.auto.previous.iter().filter(|element| element.response_id == Some(id)).collect(); + let elements: Vec<_> = app.auto.all().iter().filter(|element| element.response_id == Some(id)).collect(); json!({ "widgetId": format!("{:016x}", id.value()), "elementIds": elements.iter().map(|element| element.id.as_str()).collect::>(), @@ -545,24 +601,53 @@ pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value { "playhead": app.session.playhead().0, "activeSequence": app.session.state.active_sequence.map(|i| i.0), "selection": app.session.state.selection.iter().map(|c| c.0).collect::>(), - "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]; @@ -623,7 +708,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]); @@ -772,6 +862,31 @@ mod base64_tests { } } + /// Negative and overhanging panel origins clip to the visible image without shifting the requested region, at any scale. + #[test] + fn screenshot_crop_clips_negative_panel_origin() { + // A panel starting two points left of / one point above the window: only the visible + // 2×3 pixels are captured, not the requested 4×4 starting at (0, 0). + assert_eq!(super::crop_pixels([8, 8], Some([-2.0, -1.0, 4.0, 4.0]), 1.0).unwrap(), (0, 0, 2, 3)); + // The same crop at 2× scale clips the same logical region to the pixel grid. + assert_eq!(super::crop_pixels([16, 16], Some([-2.0, -1.0, 4.0, 4.0]), 2.0).unwrap(), (0, 0, 4, 6)); + // Partly outside on the right/bottom uses the visible remainder. + assert_eq!(super::crop_pixels([8, 8], Some([6.0, 5.0, 4.0, 4.0]), 1.0).unwrap(), (6, 5, 2, 3)); + } + + /// Crops with no overlap or a non-finite/non-positive size are refused, never rendered as a zero-sized image. + #[test] + fn screenshot_crop_outside_the_image_is_refused() { + for crop in [[-8.0, -8.0, 4.0, 4.0], [8.0, 0.0, 4.0, 4.0], [0.0, 9.0, 4.0, 4.0]] { + let err = super::crop_pixels([8, 8], Some(crop), 1.0).expect_err("a crop outside the image must be refused"); + assert!(err.contains("does not intersect"), "{err}"); + } + for crop in [[0.0, 0.0, 0.0, 4.0], [0.0, 0.0, -4.0, 4.0], [f32::NAN, 0.0, 4.0, 4.0], [0.0, 0.0, f32::INFINITY, 4.0]] { + let err = super::crop_pixels([8, 8], Some(crop), 1.0).expect_err("an invalid crop must be refused"); + assert!(err.contains("finite rectangle"), "{err}"); + } + } + #[test] fn synthetic_event_repetition_is_bounded() { assert_eq!(super::bounded_repetitions(&json!({}), "count", 1, 0, super::MAX_CLICK_COUNT).unwrap(), 1); 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 9a245dda3..5c94516fd 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -638,12 +638,12 @@ pub fn sequence_tabs(ui: &mut egui::Ui, strip: Rect, seqs: &SeqTabs, t: &Tokens, while last + 1 < seqs.open.len() && span(first, last + 1) <= room { last += 1; } - let list_id = egui::Id::new("timeline-tab-list"); + let list_id = egui::Id::unique("timeline-tab-list"); let more = (!all_fit).then(|| Rect::from_min_size(pos2(strip.max.x - 22.0, strip.center().y - 10.0), vec2(18.0, 20.0))); let mut list_opened = false; if let Some(r) = more { - let resp = ui.interact(r, egui::Id::new("timeline-tab-overflow"), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs")); + let resp = ui.interact(r, egui::Id::unique("timeline-tab-overflow"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), "more tabs")); reg.add_response("timeline.tabs.more", &resp, "more tabs"); let c = if resp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, c); @@ -657,7 +657,7 @@ pub fn sequence_tabs(ui: &mut egui::Ui, strip: Rect, seqs: &SeqTabs, t: &Tokens, } // The wheel over the strip steps through the tabs: down = next, up = previous, one tab per // notch (trackpad scrolling adds up to a notch first), stopping at the ends. - let wheel_id = egui::Id::new("timeline-tab-wheel"); + let wheel_id = egui::Id::unique("timeline-tab-wheel"); if ui.rect_contains_pointer(strip) && !seqs.open.is_empty() { const NOTCH: f32 = 40.0; let total = ui.ctx().data(|d| d.get_temp::(wheel_id)).unwrap_or(0.0) + crate::panels::timeline::wheel_input(ui.ctx()).delta.y; @@ -680,8 +680,8 @@ pub fn sequence_tabs(ui: &mut egui::Ui, strip: Rect, seqs: &SeqTabs, t: &Tokens, let w = widths.get(i).copied().unwrap_or(0.0); let tab_rect = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, strip.height())); // a sequence tab keeps its identity when the tabs are reordered under the pointer - let resp = ui.interact(tab_rect, egui::Id::new(("tab-seq", *id)), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, name)); + let resp = ui.interact(tab_rect, egui::Id::unique(("tab-seq", *id)), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Tab, resp.enabled(), is_active, name)); ui.ctx().accesskit_node_builder(resp.id, |node| { node.set_role(egui::accesskit::Role::Tab); node.set_selected(is_active); @@ -692,7 +692,7 @@ pub fn sequence_tabs(ui: &mut egui::Ui, strip: Rect, seqs: &SeqTabs, t: &Tokens, dragged = Some(*id); } // Escape puts a dragged tab back where it was (egui ends the drag on Escape, #580) - let home = egui::Id::new(("tab-seq-home", *id)); + let home = egui::Id::unique(("tab-seq-home", *id)); if resp.drag_started() { ui.data_mut(|d| d.insert_temp(home, i)); } @@ -711,8 +711,8 @@ pub fn sequence_tabs(ui: &mut egui::Ui, strip: Rect, seqs: &SeqTabs, t: &Tokens, if is_active { // × closes the sequence's tab (the sequence stays in the project) let cr = Rect::from_center_size(pos2(tab_rect.min.x + 10.0, text_y), vec2(8.0, 8.0)); - let cresp = ui.interact(cr.expand(4.0), egui::Id::new(("tab-close", *id)), Sense::click()); - cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, cresp.enabled(), "close sequence")); + let cresp = ui.interact(cr.expand(4.0), egui::Id::unique(("tab-close", *id)), Sense::click()); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, cresp.enabled(), "close sequence")); reg.add_response(&format!("timeline.tab.{id}.close"), &cresp, "close sequence"); let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; painter.line_segment([cr.left_top(), cr.right_bottom()], Stroke::new(1.2, cc)); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 55cef1dbf..82291617f 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -19,7 +19,7 @@ use crate::{FilmcraftApp, panels}; /// The dock area's egui id; `DockArea::hit_rects` and `float_space` read by it. pub fn area_id() -> egui::Id { - egui::Id::new("filmcraft-docking") + egui::Id::unique("filmcraft-docking") } /// Where each panel body was drawn last frame, and on which layer. @@ -311,14 +311,13 @@ impl FilmcraftApp { if let Some((panel, pos)) = menu { let frame = ctx.cumulative_frame_nr(); ctx.data_mut(|data| { - data.insert_temp(egui::Id::new("panel-menu"), (panel, pos)); - data.insert_temp(egui::Id::new("panel-menu-opened"), frame); + data.insert_temp(egui::Id::unique("panel-menu"), (panel, pos)); + data.insert_temp(egui::Id::unique("panel-menu-opened"), frame); }); } let hits = DockArea::::hit_rects(&ctx, area_id()); self.register_dock_hits(&hits); panels::panel_menu_popup(self, ui); - self.cancel_value_drag(ui.ctx()); } /// The Timeline's tabs stand for its open sequences: a close from the dock area (a tab's @@ -546,8 +545,8 @@ impl DockContent for FilmContent<'_> { self.seq_actions.extend(dock::sequence_tabs(ui, strip, &self.seqs, &t, &mut self.app.auto)); } let rect = Rect::from_center_size(menu.center(), vec2(18.0, 18.0)); - let response = ui.interact(rect, egui::Id::new(("panel-menu-button", active.id())), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), "panel menu")); + let response = ui.interact(rect, egui::Id::unique(("panel-menu-button", active.id())), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, response.enabled(), "panel menu")); self.app.auto.add_response(&format!("panel.menu.{}", active.id()), &response, "panel menu"); let color = if response.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::Hamburger, color); 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 9f08e2c8d..181de229f 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -13,12 +13,13 @@ use crate::theme::Tokens; /// Height of the header bar. With `integrated_titlebar` it sits in the macOS title-bar strip. pub const HEIGHT: f32 = 38.0; +/// Draw the header bar; dragging starts a native window move and double-click toggles maximized. pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().clone(); p.rect_filled(rect, 0.0, t.header_bg); p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, Color32::BLACK)); - let drag = ui.interact(rect, egui::Id::new("header-drag"), Sense::click_and_drag()); + let drag = ui.interact(rect, egui::Id::unique("header-drag"), Sense::click_and_drag()); if drag.drag_started() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } @@ -29,7 +30,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut x = rect.min.x + if app.integrated_titlebar { 104.0 } else { 14.0 }; // Home let home = Rect::from_center_size(pos2(x + 10.0, rect.center().y), vec2(26.0, 26.0)); - let hresp = ui.interact(home, egui::Id::new("hdr-home"), Sense::click()).on_hover_text(tl!("Home")); + let hresp = ui.interact(home, egui::Id::unique("hdr-home"), Sense::click()).on_hover_text(tl!("Home")); app.auto.add("header.home", home, "Home"); if hresp.hovered() { p.rect_filled(home, 4.0, t.hover); @@ -44,7 +45,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let active = app.ui.mode == m; let galley = p.layout_no_wrap(shown.to_string(), Tokens::ui(14.0), if active { t.tab_text_active } else { t.tab_text }); let r = Rect::from_min_size(pos2(x - 6.0, rect.min.y + 6.0), vec2(galley.size().x + 12.0, rect.height() - 12.0)); - let resp = ui.interact(r, egui::Id::new(("mode", label)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("mode", label)), Sense::click()); app.auto.add(&format!("header.mode.{}", label.to_ascii_lowercase()), r, label); let col = if active || resp.hovered() { t.tab_text_active } else { t.tab_text }; let gw = galley.size().x; @@ -75,7 +76,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut btn = |ui: &mut egui::Ui, icon: Icon, id: &str, tip: &str, app: &mut FilmcraftApp| -> egui::Response { let r = Rect::from_center_size(pos2(rx - 14.0, rect.center().y), vec2(28.0, 28.0)); rx -= 38.0; - let resp = ui.interact(r, egui::Id::new(("hdr", id)), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::unique(("hdr", id)), Sense::click()).on_hover_text(tip); app.auto.add(&format!("header.{id}"), r, tip); if resp.hovered() { ui.painter().rect_filled(r, 4.0, t.hover); @@ -100,7 +101,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let qx = rect.max.x - 14.0 - 4.0 * 38.0; // the fifth button from the right if btn(ui, Icon::Export, "quickExport", tl!("Quick Export"), app).clicked() { app.ui.export.quick_open = !app.ui.export.quick_open; - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("quick-export-toggled"), true)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("quick-export-toggled"), true)); } crate::panels::export_mode::quick_export(app, ui.ctx(), pos2(qx - 340.0, rect.max.y + 4.0)); // Appearance Mode: click for the next one (Auto, Light, Dark); the icon shows the current one. @@ -120,7 +121,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let ws = crate::i18n::t(&app.ui.workspace).to_uppercase(); let wg = p.layout_no_wrap(ws.clone(), Tokens::ui(11.0), t.text_dim); let wr = Rect::from_min_size(pos2(rx - wg.size().x + 10.0, rect.center().y - 10.0), vec2(wg.size().x + 8.0, 20.0)); - let wresp = ui.interact(wr, egui::Id::new("hdr-ws-name"), Sense::click()); + let wresp = ui.interact(wr, egui::Id::unique("hdr-ws-name"), Sense::click()); app.auto.add("header.workspaceName", wr, &ws); p.galley_with_override_text_color(pos2(wr.min.x + 4.0, rect.center().y - wg.size().y / 2.0), wg, if wresp.hovered() { t.text } else { t.text_dim }); // Community: a labelled Discord button, always one click away. @@ -129,7 +130,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let g = p.layout_no_wrap(label.to_string(), Tokens::ui(12.0), Color32::WHITE); let w = g.size().x + 34.0; let r = Rect::from_min_size(pos2(wr.min.x - w - 14.0, rect.center().y - 12.0), vec2(w, 24.0)); - let resp = ui.interact(r, egui::Id::new("hdr-discord"), Sense::click()).on_hover_text(tl!("Join the ArtCraft Discord (discord.gg/artcraft)")); + let resp = ui.interact(r, egui::Id::unique("hdr-discord"), Sense::click()).on_hover_text(tl!("Join the ArtCraft Discord (discord.gg/artcraft)")); app.auto.add("header.discord", r, "Join the ArtCraft Discord"); ui.painter().rect_filled(r, 12.0, if resp.hovered() { t.accent_hover } else { t.accent }); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); @@ -155,7 +156,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { crate::links::open(app, ui.ctx(), crate::links::DISCORD); } } - let popup_id = egui::Id::new("workspaces-popup"); + let popup_id = egui::Id::unique("workspaces-popup"); if ws_resp.clicked() || wresp.clicked() { ui.ctx().data_mut(|d| d.insert_temp(popup_id, true)); } diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5482b6a04..b97843aea 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3470,8 +3470,8 @@ Commits Commits % of all commits % aller Commits Lines +/− Zeilen +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Audioausgabe fehlgeschlagen: Wiedergabe ohne Ton (prüfe Voreinstellungen ▸ Audiohardware) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) Audioausgabe reagiert nicht: Wiedergabe ohne Ton (prüfe Voreinstellungen ▸ Audiohardware) + Audio output failed: playing without sound (check Settings > Audio Hardware) Audioausgabe fehlgeschlagen: Wiedergabe ohne Ton (prüfe Voreinstellungen > Audiohardware) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) Audioausgabe reagiert nicht: Wiedergabe ohne Ton (prüfe Voreinstellungen > Audiohardware) Clip Speed / Duration Clipgeschwindigkeit/-dauer Speed: Geschwindigkeit: Reverse Speed Rückwärts abspielen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 17f5f582a..14eddb4f2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3467,8 +3467,8 @@ Commits Commits % of all commits % de todos los commits Lines +/− Líneas +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) La salida de audio ha fallado: se reproduce sin sonido (revisa Ajustes ▸ Hardware de audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) La salida de audio no responde: se reproduce sin sonido (revisa Ajustes ▸ Hardware de audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) La salida de audio ha fallado: se reproduce sin sonido (revisa Ajustes > Hardware de audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) La salida de audio no responde: se reproduce sin sonido (revisa Ajustes > Hardware de audio) Clip Speed / Duration Velocidad/duración del clip Speed: Velocidad: Reverse Speed Invertir velocidad @@ -3620,7 +3620,7 @@ Shows the version, community links and credits in a FilmCraft window. Muestra la versión, los enlaces de la comunidad y los créditos en una ventana de FilmCraft. Couldn't open your web browser No se pudo abrir el navegador web Open this address in your browser yourself: Abre esta dirección tú mismo en el navegador: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Restablecer zoom de la interfaz Track Color Color de pista Open a sequence to search its graphics Abre una secuencia para buscar en sus gráficos diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e6a4b1c39..8d00deec3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3438,8 +3438,8 @@ Commits Commits % of all commits % de tous les commits Lines +/− Lignes +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Échec de la sortie audio : lecture sans son (vérifiez Préférences ▸ Matériel audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) La sortie audio ne répond pas : lecture sans son (vérifiez Préférences ▸ Matériel audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) Échec de la sortie audio : lecture sans son (vérifiez Préférences > Matériel audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) La sortie audio ne répond pas : lecture sans son (vérifiez Préférences > Matériel audio) Clip Speed / Duration Vitesse/durée du clip Speed: Vitesse : Reverse Speed Vitesse inversée @@ -3579,7 +3579,7 @@ Export the labels to a preset file… Exporter les étiquettes dans un fichier de paramètre prédéfini… Show the presets folder Afficher le dossier des paramètres prédéfinis Delete this preset Supprimer ce paramètre prédéfini -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Réinitialiser le zoom de l'interface Track Color Couleur de la piste Open a sequence to search its graphics Ouvrez une séquence pour rechercher dans ses graphiques diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 500db2c7a..3d383b4f0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3455,8 +3455,8 @@ Commits Commit % of all commits % dari semua commit Lines +/− Baris +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Output audio gagal: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) Output audio tidak merespons: memutar tanpa suara (periksa Pengaturan ▸ Perangkat Keras Audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) Output audio gagal: memutar tanpa suara (periksa Pengaturan > Perangkat Keras Audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) Output audio tidak merespons: memutar tanpa suara (periksa Pengaturan > Perangkat Keras Audio) Clip Speed / Duration Kecepatan / Durasi Klip Speed: Kecepatan: Reverse Speed Balik Kecepatan @@ -3577,7 +3577,7 @@ Appearance Settings… Pengaturan Tampilan… Add to favorites Tambahkan ke favorit Remove from favorites Hapus dari favorit -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Atur Ulang Zoom Antarmuka Track Color Warna Trek Open a sequence to search its graphics Buka sekuens untuk mencari di grafisnya diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index adfc59fb8..81fb5d627 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3448,8 +3448,8 @@ Commits Commit % of all commits % di tutti i commit Lines +/− Righe +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Uscita audio non riuscita: riproduzione senza audio (controlla Impostazioni ▸ Hardware audio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) L'uscita audio non risponde: riproduzione senza audio (controlla Impostazioni ▸ Hardware audio) + Audio output failed: playing without sound (check Settings > Audio Hardware) Uscita audio non riuscita: riproduzione senza audio (controlla Impostazioni > Hardware audio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) L'uscita audio non risponde: riproduzione senza audio (controlla Impostazioni > Hardware audio) Clip Speed / Duration Velocità / durata clip Speed: Velocità: Reverse Speed Velocità inversa @@ -3603,7 +3603,7 @@ Shows the version, community links and credits in a FilmCraft window. Mostra la versione, i link della community e i riconoscimenti in una finestra di FilmCraft. Couldn't open your web browser Impossibile aprire il browser web Open this address in your browser yourself: Apri tu questo indirizzo nel browser: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Reimposta zoom dell'interfaccia Track Color Colore traccia Open a sequence to search its graphics Apri una sequenza per cercare nelle sue grafiche diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 74d2dfcf5..e2b1c6702 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3452,8 +3452,8 @@ Commits コミット % of all commits 全コミットに占める割合 (%) Lines +/− 行数 +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力に失敗したため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) オーディオ出力が応答しないため、無音で再生しています (「環境設定 ▸ オーディオハードウェア」を確認してください) + Audio output failed: playing without sound (check Settings > Audio Hardware) オーディオ出力に失敗したため、無音で再生しています (「環境設定 > オーディオハードウェア」を確認してください) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) オーディオ出力が応答しないため、無音で再生しています (「環境設定 > オーディオハードウェア」を確認してください) Clip Speed / Duration クリップの速度 / 長さ Speed: 速度: Reverse Speed 逆再生する @@ -3619,7 +3619,7 @@ Shows the version, community links and credits in a FilmCraft window. バージョン、コミュニティへのリンク、クレジットを FilmCraft のウィンドウに表示します。 Couldn't open your web browser ウェブブラウザーを開けませんでした Open this address in your browser yourself: このアドレスをご自身でブラウザーで開いてください: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom インターフェイスのズームをリセット Track Color トラックカラー Open a sequence to search its graphics シーケンスを開くとグラフィックスを検索できます diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bcfe7f799..e795ac9f3 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 오디오 시간 단위 @@ -3598,7 +3598,7 @@ Shows the version, community links and credits in a FilmCraft window. FilmCraft 창에 버전, 커뮤니티 링크, 제작진을 보여 줍니다. Couldn't open your web browser 웹 브라우저를 열 수 없습니다 Open this address in your browser yourself: 이 주소를 브라우저에서 직접 여세요: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom 인터페이스 확대/축소 재설정 Track Color 트랙 색상 Open a sequence to search its graphics 그래픽을 검색하려면 시퀀스를 여세요 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 80f9dfe01..b84f93819 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", @@ -250,7 +269,7 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { const JAPANESE_SAMPLE: &str = "日本語ファイル編集あア"; /// Installed families preferred for Japanese interface text, best first (Gothic / sans-serif faces -/// read best at menu sizes). Any other installed face that covers [`JAPANESE_SAMPLE`] is used if +/// read best at menu sizes). Any other installed face that covers `JAPANESE_SAMPLE` is used if /// none of these is present. const PREFERRED_JAPANESE: &[&str] = &[ "Hiragino Sans", @@ -289,13 +308,13 @@ pub fn system_japanese_font() -> Option> { // kept for the life of the process (one font, read once) so installing it again after a // theme change shares the bytes instead of copying the whole file let bytes: &'static [u8] = Box::leak(face.data()?.into_boxed_slice()); - Some(Arc::new(egui::FontData { font: std::borrow::Cow::Borrowed(bytes), index: face.info.index, tweak: Default::default() })) + Some(Arc::new(egui::FontData::from_blob(Arc::new(bytes), face.info.index))) }) .clone() } /// Whether the craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`) supplies a -/// Japanese interface font: some craft-fonts face covers [`JAPANESE_SAMPLE`]. `theme::install` adds +/// Japanese interface font: some craft-fonts face covers `JAPANESE_SAMPLE`. `theme::install` adds /// these faces to every font family, so nothing else needs installing (and no system scan runs). pub fn craft_japanese_font() -> bool { filmcraft_text::fonts::craft_japanese().next().is_some() @@ -1104,3 +1123,20 @@ mod tests { }); } } + +#[cfg(test)] +mod bcp47_tests { + use super::Language; + + /// Protocol-facing BCP 47 tags use `pt-BR`/`zh-CN`; every tag is non-empty ASCII. + #[test] + fn accesskit_tags_use_protocol_spelling() { + assert_eq!(Language::En.bcp47(), "en"); + assert_eq!(Language::PtBr.bcp47(), "pt-BR"); + assert_eq!(Language::ZhCn.bcp47(), "zh-CN"); + for language in Language::ALL { + let tag = language.bcp47(); + assert!(!tag.is_empty() && tag.is_ascii(), "{tag:?}"); + } + } +} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d1bb7d835..ea7642c52 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3452,8 +3452,8 @@ Commits Commits % of all commits % de todos os commits Lines +/− Linhas +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio falhou: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio não está respondendo: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) + Audio output failed: playing without sound (check Settings > Audio Hardware) A saída de áudio falhou: reproduzindo sem som (verifique Preferências > Hardware de áudio) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) A saída de áudio não está respondendo: reproduzindo sem som (verifique Preferências > Hardware de áudio) Clip Speed / Duration Velocidade/duração do clipe Speed: Velocidade: Reverse Speed Inverter velocidade @@ -3580,7 +3580,7 @@ # The native macOS application menu (Settings…). Settings Preferências -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom Redefinir zoom da interface Track Color Cor da trilha Open a sequence to search its graphics Abra uma sequência para pesquisar nos gráficos dela diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 35b43dac2..dc3ee65ef 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3446,8 +3446,8 @@ Commits 提交 % of all commits 占全部提交的比例 Lines +/− 代码行 +/− - Audio output failed: playing without sound (check Settings ▸ Audio Hardware) 音频输出失败:无声播放(请检查设置 ▸ 音频硬件) - Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) 音频输出无响应:无声播放(请检查设置 ▸ 音频硬件) + Audio output failed: playing without sound (check Settings > Audio Hardware) 音频输出失败:无声播放(请检查设置 > 音频硬件) + Audio output is not responding: playing without sound (check Settings > Audio Hardware) 音频输出无响应:无声播放(请检查设置 > 音频硬件) Clip Speed / Duration 剪辑速度 / 时长 Speed: 速度: Reverse Speed 反转速度 @@ -3595,7 +3595,7 @@ Shows the version, community links and credits in a FilmCraft window. 在 FilmCraft 窗口中显示版本、社区链接和致谢。 Couldn't open your web browser 无法打开网页浏览器 Open this address in your browser yourself: 请自行在浏览器中打开此地址: -# View ▸ Reset Interface Zoom (#2253). +# View > Reset Interface Zoom (#2253). Reset Interface Zoom 重置界面缩放 Track Color 轨道颜色 Open a sequence to search its graphics 打开序列以搜索其中的图形 diff --git a/crates/ui-egui/src/ime_reset.rs b/crates/ui-egui/src/ime_reset.rs new file mode 100644 index 000000000..86a01a1fc --- /dev/null +++ b/crates/ui-egui/src/ime_reset.rs @@ -0,0 +1,490 @@ +//! Ordered admission for the Program monitor's interrupted input method. +//! +//! A reset request is not an acknowledgement. Geometry/focus/output loss retires the local +//! token, and a later actual custom descriptor requests a fresh fence even for the same caret. + +use std::sync::Arc; + +use egui::{Context, IMEPurpose, Id, ImeResetId, ImeTarget, ViewportId, ViewportIdMap}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Destination { + pub document: u64, + pub target: ImeTarget, + pub caret: usize, + pub anchor: usize, + pub text: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct OutputContext { + pub owner: Id, + pub generation: u64, + pub purpose: IMEPurpose, +} + +impl OutputContext { + /// Snapshot the owner/generation/purpose triple that identifies one published custom IME + /// output. + pub(crate) fn of(ime: &egui::output::IMEOutput) -> Self { + Self { owner: ime.owner, generation: ime.context_generation, purpose: ime.purpose } + } +} + +#[derive(Clone, Copy, Debug)] +enum Phase { + NeedsRequest, + Awaiting(ImeResetId), + Suspended, + AllocationFailed, +} + +#[derive(Clone, Copy, Debug)] +struct Pending { + // The retired composition is separate from the current destination (A -> B releases B). + origin: ImeTarget, + destination: Option, + output: Option, + phase: Phase, +} + +#[derive(Default)] +pub(crate) struct State { + pending: ViewportIdMap, + incoming: ViewportIdMap, + completed_origin: ViewportIdMap, + pub viewport: ViewportId, + custom_output: Option, +} + +impl State { + /// Start a pass for `ctx`'s viewport and clear the per-pass published-output record, so + /// `finish` cannot mistake a previous pass's descriptor for this pass's. + pub(crate) fn begin_pass(&mut self, ctx: &Context) { + self.viewport = ctx.viewport_id(); + self.custom_output = None; + } + + /// Whether a reset fence still guards `viewport`. While blocked, IME input for the custom + /// editor must not be admitted. + pub(crate) fn blocked(&self, viewport: ViewportId) -> bool { + self.pending.contains_key(&viewport) + } + + /// The composition target being retired for `viewport`: the pending origin, or the last + /// completed origin until `terminal` clears it. + pub(crate) fn origin(&self, viewport: ViewportId) -> Option { + self.pending.get(&viewport).map(|p| p.origin).or_else(|| self.completed_origin.get(&viewport).copied()) + } + + /// Whether `destination` differs from the last one remembered for `viewport`; false when + /// nothing has been remembered yet. + pub(crate) fn incoming_changed(&self, viewport: ViewportId, destination: Destination) -> bool { + self.incoming.get(&viewport).is_some_and(|old| *old != destination) + } + + /// Record the latest destination for `viewport`, so the next event can distinguish a real + /// target change from repeated observation of the same target. + pub(crate) fn remember(&mut self, viewport: ViewportId, destination: Destination) { + self.incoming.insert(viewport, destination); + } + + /// Acknowledge a terminal IME event for `viewport`: forget the incoming destination and + /// completed origin so stale targets cannot be reused. + pub(crate) fn terminal(&mut self, viewport: ViewportId) { + self.incoming.remove(&viewport); + self.completed_origin.remove(&viewport); + } + + /// Context-less commands retire an obsolete ID synchronously. Repeated unchanged + /// reconciliation is inert; the next live local destination flushes NeedsRequest. + pub(crate) fn interrupt(&mut self, origin: ImeTarget, destination: Option) { + let viewport = self.viewport; + if let Some(pending) = self.pending.get_mut(&viewport) { + if pending.destination != destination { + pending.destination = destination; + // An unavailable route cannot be rearmed by reconciliation or a control. + // Only the actual eligible final descriptor may leave Suspended. + if !matches!(pending.phase, Phase::Suspended) { + pending.phase = Phase::NeedsRequest; + } + } + } else { + self.pending.insert(viewport, Pending { origin, destination, output: None, phase: Phase::NeedsRequest }); + } + } + + /// Move a pending fence to `Suspended` and drop its old output binding. Only an actually + /// eligible final descriptor in `finish` may rearm it; `sync`/`interrupt` reconciliation cannot. + pub(crate) fn suspend(&mut self, viewport: ViewportId) { + if let Some(pending) = self.pending.get_mut(&viewport) { + pending.phase = Phase::Suspended; + pending.output = None; + } + } + + /// Whether the custom output published in this pass is the one recorded by `produced`. + /// False when no custom output was produced in this pass. + pub(crate) fn owns_output(&self, ctx: &Context) -> bool { + self.custom_output.is_some() && ctx.output(|out| out.ime.as_ref().map(OutputContext::of)) == self.custom_output + } + + /// Allocate a reset fence for the current viewport after clearing the previous fence's + /// output binding. Allocation failure leaves the phase `AllocationFailed`, which still + /// blocks input; a later real destination change can request again. + fn request(ctx: &Context, pending: &mut Pending) { + // A new fence invalidates any output context bound to the previous fence. Clearing it + // also lets `finish` recognize this request as already made in this pass, even when an + // earlier pass had bound a different descriptor to the old destination. + pending.output = None; + pending.phase = ctx.request_ime_reset(ctx.viewport_id()).map_or(Phase::AllocationFailed, Phase::Awaiting); + } + + /// Must run with the live local ed/text before EACH raw event, never a staged engine copy. + pub(crate) fn sync(&mut self, ctx: &Context, destination: Destination) { + let Some(pending) = self.pending.get_mut(&ctx.viewport_id()) else { return }; + let changed = pending.destination != Some(destination); + pending.destination = Some(destination); + // A suspended route cannot admit events until an actual eligible output rearms it. + if !matches!(pending.phase, Phase::Suspended) && (changed || matches!(pending.phase, Phase::NeedsRequest)) { + Self::request(ctx, pending); + } + } + + /// Validate a completion token and release the viewport's guard. The token must belong to + /// this viewport and equal the current `Awaiting` id, and `destination` must equal the + /// pending destination; `ctx.output` is deliberately not consulted because raw events run + /// before this pass publishes its descriptor. Any mismatch returns false and keeps the + /// guard. Success records the origin and destination for the next observation. + pub(crate) fn complete(&mut self, ctx: &Context, id: ImeResetId, destination: Destination) -> bool { + let viewport = ctx.viewport_id(); + let Some(pending) = self.pending.get(&viewport).copied() else { return false }; + // The opaque token is necessary but not sufficient: the destination carries the owner + // and replacement epoch, Film publishes only `IMEPurpose::Normal`, and a generation, + // purpose or focus transition supersedes or suspends this guard before it can be rearmed. Do not read + // `ctx.output` here: raw events are processed before this pass has published its custom + // descriptor, so that would compare against an empty or stale output. The reducer test + // `changed_output_context_supersedes_and_allocation_failure_stays_blocked` binds a token + // to one live owner/generation/purpose by rejecting it after each transition. + if id.viewport_id() != viewport || pending.destination != Some(destination) || !matches!(pending.phase, Phase::Awaiting(current) if current == id) { + return false; + } + self.pending.remove(&viewport); + self.completed_origin.insert(viewport, pending.origin); + self.remember(viewport, destination); + true + } + + /// Record the custom output descriptor published in this pass, for `finish` and + /// `owns_output` to compare against the actual output. + pub(crate) fn produced(&mut self, key: OutputContext) { + self.custom_output = Some(key); + } + + /// Actual custom eligibility and actual final app output are independent facts. Never + /// overwrite a stock field or infer native completion from a descriptor/publication. + pub(crate) fn finish(&mut self, ctx: &Context, destination: Option, eligible: bool) { + let viewport = ctx.viewport_id(); + let actual = ctx.output(|out| out.ime.as_ref().map(OutputContext::of)); + let custom = self.custom_output; + let Some(pending) = self.pending.get_mut(&viewport) else { return }; + let Some(destination) = destination.filter(|_| eligible && custom.is_some() && actual == custom) else { + self.suspend(viewport); + return; + }; + let changed = pending.destination != Some(destination) || pending.output != custom; + let activate = matches!(pending.phase, Phase::Suspended | Phase::NeedsRequest); + // `sync` may already have requested this pass, while `finish` is the first place the + // actual custom output context is known. Bind that same request to the descriptor instead + // of allocating a second, silently superseded ID for the same destination transition. + let requested_by_sync = matches!(pending.phase, Phase::Awaiting(_)) && pending.output.is_none(); + pending.destination = Some(destination); + if activate || (changed && !requested_by_sync) { + Self::request(ctx, pending); + } + // A request clears the previous fence's binding. Bind the confirmed descriptor only + // after requesting, so the next pass compares against this output rather than mistaking + // an earlier pass's unbound request for a same-pass sync request. + pending.output = custom; + } + + /// Forget all reset state for a closed viewport: pending fence, incoming destination and + /// completed origin. + pub(crate) fn end_viewport(&mut self, viewport: ViewportId) { + self.pending.remove(&viewport); + self.incoming.remove(&viewport); + self.completed_origin.remove(&viewport); + } +} + +/// Temp-data key marking whether `viewport` is inside a UI pass rather than a hidden logic +/// callback. +fn mode_id(viewport: ViewportId) -> Id { + Id::unique(("filmcraft-ime-reset-ui-pass", viewport)) +} + +/// Whether the current callback is a UI pass (between the installed begin/end-pass hooks) +/// for this viewport, rather than `run_logic`. Defaults to false when no hook ran. +pub(crate) fn in_ui_pass(ctx: &Context) -> bool { + ctx.data(|data| data.get_temp::(mode_id(ctx.viewport_id()))).unwrap_or(false) +} + +/// Install before the first run_ui. The callbacks receive Ui and capture no Context/app. +pub(crate) fn install(ctx: &Context) { + let installed = Id::unique("filmcraft-ime-reset-mode-installed"); + if ctx.data(|data| data.get_temp::(installed)).unwrap_or(false) { + return; + } + ctx.on_begin_pass( + "filmcraft IME reset UI entry", + Arc::new(|ui| { + let viewport = ui.ctx().viewport_id(); + ui.data_mut(|data| data.insert_temp(mode_id(viewport), true)); + }), + ); + ctx.on_end_pass( + "filmcraft IME reset UI exit", + Arc::new(|ui| { + let viewport = ui.ctx().viewport_id(); + ui.data_mut(|data| data.insert_temp(mode_id(viewport), false)); + }), + ); + ctx.data_mut(|data| data.insert_temp(installed, true)); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fixed destination for one editor owner with caret 3 and committed-text stamp 17. + fn destination() -> Destination { + Destination { document: 1, target: ImeTarget::new(Id::unique("film-model-editor"), 1), caret: 3, anchor: 3, text: 17 } + } + + /// Build the IME output a custom producer publishes for `destination` with the given + /// generation and purpose. + fn output(destination: Destination, generation: u64, purpose: IMEPurpose) -> egui::output::IMEOutput { + egui::output::IMEOutput::new(destination.target.owner, generation, purpose, egui::Rect::ZERO, egui::Rect::ZERO) + } + + // These reducer tests use real Context requests/output, not native acknowledgements. + /// Run one real UI pass that publishes a custom `output` and calls `finish` as eligible; + /// return the reset request the Context attached, if any. + fn custom_pass(ctx: &Context, state: &mut State, destination: Destination, generation: u64, purpose: IMEPurpose) -> Option { + let mut result = ctx.run_ui(egui::RawInput::default(), |ui| { + state.begin_pass(ui.ctx()); + let ime = output(destination, generation, purpose); + state.produced(OutputContext::of(&ime)); + ui.ctx().output_mut(|out| out.ime = Some(ime)); + state.finish(ui.ctx(), Some(destination), true); + }); + let id = result.platform_output.ime.as_ref().and_then(|ime| ime.reset_request); + result.textures_delta.clear(); + id + } + + /// A generation or purpose change supersedes the old fence, old tokens cannot complete, an + /// allocation failure stays blocked, and a later real destination change retries. + #[test] + fn changed_output_context_supersedes_and_allocation_failure_stays_blocked() { + let ctx = Context::default(); + install(&ctx); + let mut state = State::default(); + let destination = destination(); + state.interrupt(destination.target, Some(destination)); + let first = custom_pass(&ctx, &mut state, destination, 1, IMEPurpose::Normal).unwrap(); + assert!(custom_pass(&ctx, &mut state, destination, 1, IMEPurpose::Normal).is_none()); + let second = custom_pass(&ctx, &mut state, destination, 2, IMEPurpose::Normal).unwrap(); + let third = custom_pass(&ctx, &mut state, destination, 2, IMEPurpose::Password).unwrap(); + assert_ne!(first, second); + assert_ne!(second, third); + // The old opaque IDs are rejected even though document/target/caret/text are unchanged. + assert!(!state.complete(&ctx, first, destination)); + assert!(!state.complete(&ctx, second, destination)); + let other_owner = Destination { target: ImeTarget::new(Id::unique("film-other-owner"), 1), ..destination }; + assert!(!state.complete(&ctx, third, other_owner), "the live destination owner is part of the admission boundary"); + assert!(state.blocked(ViewportId::ROOT)); + // Model token exhaustion explicitly; do not attempt to forge an opaque ID. + state.pending.get_mut(&ViewportId::ROOT).unwrap().phase = Phase::AllocationFailed; + for _ in 0..3 { + assert!(custom_pass(&ctx, &mut state, destination, 2, IMEPurpose::Password).is_none()); + assert!(state.blocked(ViewportId::ROOT)); + } + let changed = Destination { caret: 1, anchor: 1, ..destination }; + state.sync(&ctx, changed); + let retry = custom_pass(&ctx, &mut state, changed, 2, IMEPurpose::Password).unwrap(); + assert!(state.complete(&ctx, retry, changed)); + assert!(!state.complete(&ctx, retry, changed)); + } + + /// `sync` followed by `finish` in the same pass allocates exactly one request and keeps it + /// as the awaiting boundary. + #[test] + fn same_pass_sync_and_finish_keep_one_request() { + let ctx = Context::default(); + install(&ctx); + let mut state = State::default(); + let destination = destination(); + state.interrupt(destination.target, Some(destination)); + let mut requested = None; + let mut result = ctx.run_ui(egui::RawInput::default(), |ui| { + state.begin_pass(ui.ctx()); + let ime = output(destination, 1, IMEPurpose::Normal); + state.produced(OutputContext::of(&ime)); + ui.ctx().output_mut(|out| out.ime = Some(ime)); + state.sync(ui.ctx(), destination); + if let Phase::Awaiting(id) = state.pending[&ViewportId::ROOT].phase { + requested = Some(id); + } + state.finish(ui.ctx(), Some(destination), true); + assert!(matches!( + state.pending[&ViewportId::ROOT].phase, + Phase::Awaiting(id) if Some(id) == requested + )); + }); + assert_eq!(result.platform_output.ime.as_ref().and_then(|ime| ime.reset_request), requested); + result.textures_delta.clear(); + } + + /// `finish` reuses the replacement request allocated by `sync` instead of allocating a + /// second one, and the stale token cannot release the guard. + #[test] + fn stale_output_binding_does_not_force_a_second_request_on_sync_then_finish() { + let ctx = Context::default(); + install(&ctx); + let mut state = State::default(); + let destination = destination(); + state.interrupt(destination.target, Some(destination)); + let stale = custom_pass(&ctx, &mut state, destination, 1, IMEPurpose::Normal).unwrap(); + assert!(matches!( + state.pending[&ViewportId::ROOT].phase, + Phase::Awaiting(id) if id == stale + )); + assert!(state.pending[&ViewportId::ROOT].output.is_some()); + + // A destination transition retires the fence at the admission boundary but leaves the + // previous output binding attached until the next actual request replaces it. + let changed = Destination { caret: 1, anchor: 1, ..destination }; + state.interrupt(destination.target, Some(changed)); + assert!(state.pending[&ViewportId::ROOT].output.is_some(), "an old output context remains bound"); + + let mut requested_by_sync = None; + let mut result = ctx.run_ui(egui::RawInput::default(), |ui| { + state.begin_pass(ui.ctx()); + let ime = output(changed, 2, IMEPurpose::Normal); + state.produced(OutputContext::of(&ime)); + ui.ctx().output_mut(|out| out.ime = Some(ime)); + state.sync(ui.ctx(), changed); + if let Phase::Awaiting(id) = state.pending[&ViewportId::ROOT].phase { + requested_by_sync = Some(id); + } + state.finish(ui.ctx(), Some(changed), true); + assert!( + matches!( + state.pending[&ViewportId::ROOT].phase, + Phase::Awaiting(id) if Some(id) == requested_by_sync + ), + "finish must reuse the request sync allocated for this destination transition" + ); + }); + let replacement = requested_by_sync.expect("sync allocated the replacement request"); + assert_ne!(replacement, stale); + assert_eq!(result.platform_output.ime.as_ref().and_then(|ime| ime.reset_request), Some(replacement)); + result.textures_delta.clear(); + + // The retired R1 is no longer the awaiting boundary and cannot release the guard. + assert!(!state.complete(&ctx, stale, changed)); + assert!(state.blocked(ViewportId::ROOT)); + assert!(state.complete(&ctx, replacement, changed)); + assert!(!state.blocked(ViewportId::ROOT)); + } + + /// If custom eligibility is lost after `sync`, stock IME may echo the queued token but it + /// cannot release the suspended guard; only a later eligible descriptor can. + #[test] + fn queued_before_absence_token_may_reach_stock_but_cannot_release_suspended_guard() { + let ctx = Context::default(); + install(&ctx); + let mut state = State::default(); + let destination = destination(); + state.interrupt(destination.target, Some(destination)); + let mut queued = None; + let mut absent = ctx.run_ui(egui::RawInput::default(), |ui| { + state.begin_pass(ui.ctx()); + state.sync(ui.ctx(), destination); + if let Phase::Awaiting(id) = state.pending[&ViewportId::ROOT].phase { + queued = Some(id); // actual Context request returned by the production reducer + } + // A later same-pass layout/control loss prevents any descriptor publication. + state.finish(ui.ctx(), Some(destination), false); + }); + assert!(absent.platform_output.ime.is_none()); + absent.textures_delta.clear(); + let queued = queued.unwrap(); + let stock_id = Id::unique("film-model-stock-field"); + let mut text = String::from("stock"); + let mut stock = ctx.run_ui(egui::RawInput::default(), |ui| { + state.begin_pass(ui.ctx()); + ui.memory_mut(|memory| memory.request_focus(stock_id)); + ui.add(egui::TextEdit::singleline(&mut text).id(stock_id)); + // Destination changes on an ineligible route cannot allocate a replacement. + state.interrupt(destination.target, Some(Destination { caret: 0, anchor: 0, ..destination })); + state.sync(ui.ctx(), destination); + assert!(!state.complete(ui.ctx(), queued, destination)); + state.finish(ui.ctx(), Some(destination), false); + }); + assert_eq!( + stock.platform_output.ime.as_ref().and_then(|ime| ime.reset_request), + Some(queued), + "core may attach ONLY the already queued R1; the public app API cannot cancel it" + ); + assert!(matches!(state.pending[&ViewportId::ROOT].phase, Phase::Suspended)); + stock.textures_delta.clear(); + ctx.memory_mut(|memory| memory.surrender_focus(stock_id)); + let replacement = custom_pass(&ctx, &mut state, destination, 1, IMEPurpose::Normal).unwrap(); + assert_ne!(replacement, queued); + assert!(!state.complete(&ctx, queued, destination)); + assert!(state.complete(&ctx, replacement, destination)); + } + + /// UI-pass mode is scoped per viewport, `install` is idempotent, and a child viewport's + /// completion cannot release ROOT's guard. + #[test] + fn pass_mode_is_per_viewport_and_foreign_completion_cannot_release_a_real_guard() { + let ctx = Context::default(); + install(&ctx); + install(&ctx); // creator reuse must not register another pair of callbacks + let mut state = State::default(); + let destination = destination(); + state.interrupt(destination.target, Some(destination)); + let root = custom_pass(&ctx, &mut state, destination, 1, IMEPurpose::Normal).unwrap(); + + let foreign_viewport = ViewportId::from_hash_of("film-other-window"); + // Register an actual child viewport; a request for an unlisted viewport would be dropped + // by `Memory::begin_pass` before it could exercise the viewport check at all. + let mut raw = egui::RawInput { viewport_id: foreign_viewport, ..Default::default() }; + raw.viewports.insert(foreign_viewport, egui::ViewportInfo { parent: Some(ViewportId::ROOT), ..Default::default() }); + let mut foreign = None; + let mut foreign_output = ctx.run_ui(raw, |ui| { + assert_eq!(ui.ctx().viewport_id(), foreign_viewport); + assert!(in_ui_pass(ui.ctx())); + foreign = ui.ctx().request_ime_reset(foreign_viewport); + }); + foreign_output.textures_delta.clear(); + let foreign = foreign.expect("the registered child viewport allocated a real request"); + + let mut full = ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(in_ui_pass(ui.ctx())); + assert!(!ui.data(|data| data.get_temp::(mode_id(foreign_viewport))).unwrap_or(false)); + state.begin_pass(ui.ctx()); + assert!(!state.complete(ui.ctx(), foreign, destination), "a child viewport's completion cannot release ROOT's guard"); + assert!(state.blocked(ViewportId::ROOT)); + assert!(state.complete(ui.ctx(), root, destination)); + assert!(!state.complete(ui.ctx(), foreign, destination)); + assert!(!state.blocked(ViewportId::ROOT)); + }); + full.textures_delta.clear(); + assert!(!in_ui_pass(&ctx)); + let _logic = ctx.run_logic(&egui::RawInput::default(), |ctx| assert!(!in_ui_pass(ctx))); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a6545f178..37872e071 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; @@ -47,6 +48,7 @@ pub mod graphics_notice; pub mod header; pub mod i18n; pub mod icons; +mod ime_reset; pub mod links; pub mod menus; pub mod native_dialogs; @@ -67,6 +69,7 @@ use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; +use egui::ime_context::{ImeContextTracker, ImeTarget}; use egui::{TextureHandle, TextureOptions}; use filmcraft_engine::{Services, Session}; use filmcraft_time::Tick; @@ -203,7 +206,7 @@ pub enum Dialog { NewSequence, /// Settings (Preferences window; page in `UiState::settings`). Preferences, - /// "Recover unsaved changes from