From 0a570f7ddfeed9a92601fba486ab94a5ae77df2f Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 08:51:03 +0100 Subject: [PATCH 1/4] M0.37: include complete bundled font notices --- ATTRIBUTION.md | 21 +++- NOTICE | 269 +++++++++++++++++++++++++++++++++++++++++-- docs/architecture.md | 2 +- 3 files changed, 276 insertions(+), 16 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c9faa57b1..77a9cc432 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -1,19 +1,28 @@ # Attribution -Every non-code asset in EffectCraft, with its author, source and licence. Each file listed here also has +Every non-code asset in EffectCraft, with its author, source and licence. Each repository file listed here also has a `.attribution` sidecar with full details. The rules are in [AGENTS.md](AGENTS.md) §1: no Adobe iconography, images or other Adobe assets; open licences only; every asset attributed. `cargo xtask assets` checks that this index and the sidecars cover every asset in the repository. +The complete licence texts and copyright notices for the bundled fonts, including the fonts embedded +by the pinned egui dependency, are reproduced in [`NOTICE`](NOTICE), which every package ships; the +`assets/fonts/OFL-*.txt` files are the upstream copies kept beside the fonts. -## Bundled egui font icons +## Bundled dependency fonts -These dependency fonts are embedded by the pinned egui font definitions; no font file is added -here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts. +These fonts are embedded by the pinned egui dependency (epaint_default_fonts 0.37 at +`6d518fc799681571963842f95b55a6b68431b15a`); no font file is added to this repository. +egui-icons is always embedded, `default_fonts` adds Ubuntu Light and Hack, and +`monochrome_emoji_fonts` adds Noto Emoji and emoji-icon-font. The complete copyright notices +and licence texts are in [`NOTICE`](NOTICE). | Asset | Author | Source | Licence | |---|---|---|---| -| `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in NOTICE) | -| `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in NOTICE) | +| `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) | +| `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (complete copyright and permission text in `NOTICE`) | +| `epaint_default_fonts/fonts/NotoEmoji-Regular.ttf` | Google Inc. (The Noto Project Authors) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/OFL.txt | OFL-1.1 (complete text in `NOTICE`) | +| `epaint_default_fonts/fonts/Ubuntu-Light.ttf` | Canonical Ltd. and Dalton Maag | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/UFL.txt | Ubuntu Font Licence 1.0 (complete text in `NOTICE`) | +| `epaint_default_fonts/fonts/Hack-Regular.ttf` | Source Foundry Authors; Bitstream Inc. | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/Hack-Regular.txt; original https://github.com/source-foundry/Hack | MIT AND Bitstream-Vera (complete texts in `NOTICE`) | ## Asset files diff --git a/NOTICE b/NOTICE index 61d96e48e..5cf18a553 100644 --- a/NOTICE +++ b/NOTICE @@ -12,22 +12,273 @@ Third-party material This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in ATTRIBUTION.md. -Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. -Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt. -JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt. +Bundled fonts + +EffectCraft embeds Inter, Noto Serif, JetBrains Mono and Noto Sans Symbols 2 from +assets/fonts/. The pinned egui dependency (epaint_default_fonts, revision +6d518fc799681571963842f95b55a6b68431b15a) additionally embeds Ubuntu Light, Hack, +Noto Emoji, egui-icons and emoji-icon-font through EffectCraft's enabled `default_fonts` +and `monochrome_emoji_fonts` features; no font file from that dependency is added to +this repository. The complete copyright notices and licence texts required for all of +these fonts follow. The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR Apache-2.0. The shared patched syntax source is centralized in storytold/craft-libs, transferred from PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters. -Bundled egui font icons +SIL Open Font License 1.1 -The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' -emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is -included through monochrome_emoji_fonts. Their upstream project is -https://github.com/jslegers/emoji-icon-font; both use the same exact copyright -and permission notice below. +Applies to Inter (assets/fonts/Inter-*.ttf), Noto Serif (assets/fonts/NotoSerif-Regular.ttf), +JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf), Noto Sans Symbols 2 +(assets/fonts/NotoSansSymbols2-Regular.ttf) and Noto Emoji (NotoEmoji-Regular.ttf). +Copyright notices: + +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols) +Copyright 2013 Google Inc. All Rights Reserved. + +The fonts in assets/fonts/ also keep their upstream licence files at +assets/fonts/OFL-.txt. The complete licence text follows. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Ubuntu Font Licence 1.0 + +Applies to Ubuntu Light (Ubuntu-Light.ttf). Copyright notice: + +Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0 + +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + +Hack: MIT License and Bitstream Vera License + +Applies to Hack (Hack-Regular.ttf). Upstream notice and both licence texts: + +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. + +MIT License (egui-icons and emoji-icon-font) + +Applies to egui-icons.ttf, a five-glyph subset of emoji-icon-font, and the parent +emoji-icon-font.ttf, both by John Slegers, https://github.com/jslegers/emoji-icon-font. MIT License diff --git a/docs/architecture.md b/docs/architecture.md index 72daec328..d3dd4db69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -356,7 +356,7 @@ to the preview cache. Scoped allocation failures retain the adaptive budget/CPU **Recovery boundary:** the host installs handlers through eframe's CreationContext, after egui's presentation pipelines but before EffectCraft's pipelines. This does not cover that earlier egui -startup window. eframe 0.36.2 supports lost-surface recreation, not replacing its live device and +startup window. eframe 0.37 supports lost-surface recreation, not replacing its live device and all presentation resources. Device loss therefore requires restarting the presentation backend; CPU compositing alone cannot restore that display. No automatic restart or persistent safe-mode sentinel is implemented here. Browser scope futures are not synchronously awaited; the browser page From 57220d4d6ad244a5533cec41dd8498714a958a07 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 10:18:26 +0100 Subject: [PATCH 2/4] Fix egui 0.37 text ownership and simplify UI lifecycle handlers --- crates/engine/src/commands/app_more.rs | 2 +- crates/engine/src/commands/batch.rs | 2 +- crates/engine/src/commands/edit.rs | 4 +- crates/engine/src/commands/layer.rs | 7 +- crates/engine/src/commands/text_edit.rs | 68 +- crates/engine/src/history.rs | 2 +- crates/engine/src/lib.rs | 49 +- crates/engine/src/tests_text_edit.rs | 254 ++++++ crates/ui-egui/src/control.rs | 754 ++++++++++-------- crates/ui-egui/src/lib.rs | 329 +++++--- .../ui-egui/src/panels/layer_styles_dialog.rs | 4 +- 11 files changed, 999 insertions(+), 476 deletions(-) diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index 1bb94952f..0758b9bc7 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -38,7 +38,7 @@ pub(crate) fn grouped(s: &mut Session, label: &str, f: impl FnOnce(&mut Sessi Err(e) => { if changed { s.project = before; - s.sanitize_state(); + s.sanitize_after_replacement(); s.bump(); } Err(e) diff --git a/crates/engine/src/commands/batch.rs b/crates/engine/src/commands/batch.rs index 4bfd2b2b1..fd6f4f49b 100644 --- a/crates/engine/src/commands/batch.rs +++ b/crates/engine/src/commands/batch.rs @@ -78,7 +78,7 @@ fn batch(s: &mut Session, p: &Value) -> Result { s.project = project; s.history = history; s.state = state; - s.sanitize_state(); + s.sanitize_after_replacement(); s.bump(); "; the batch was rolled back (nothing changed)".to_string() } else { diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index cde77b1e8..05015e6b4 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -227,8 +227,8 @@ fn delete(s: &mut Session, p: &Value) -> Result { st.selected_keys.clear(); Ok(()) })?; - // A removed layer may have been the edited one (possibly with the same id restored by an - // immediate undo): end or re-epoch that session before another command can reuse it. + // A removed layer may have been the edited one: end that session before another command + // can reuse it. Undo restores a layer as a new editor, never by reviving the old IME session. s.sanitize_state(); Ok(json!(ids.len())) } diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index e220e7d93..8ff43f32e 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1481,7 +1481,9 @@ fn set_text(s: &mut Session, p: &Value) -> Result { } s.bump(); } - let replacing_text = p.get("text").is_some() && s.state.text_edit.as_ref().is_some_and(|e| e.layer == lid); + // A changed Source Text is handled by `Session::edit`: its boundary clamps the selection and + // starts a new IME epoch only when the CTI document actually changed, so an identical text + // write stays a no-op and keeps the editor's owner. s.edit("Edit Text", merge_p(p), |proj, _| { let l = layer_mut(proj, cid, lid)?; let lt = l.layer_time(t); @@ -1502,9 +1504,6 @@ fn set_text(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - if replacing_text { - super::text_edit::invalidate_session(s); - } Ok(Value::Null) } diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 4f47d3bad..5550ee68c 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -248,7 +248,7 @@ pub(crate) fn edit_doc( ) -> Result<()> { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let t = s.time(); - s.edit(label, merge, |proj, st| { + s.edit_text_doc(label, merge, |proj, st| { let l = layer_mut(proj, cid, lid)?; let lt = l.layer_time(t); let pr = l.props.prop_mut("text/sourceText").ok_or_else(|| bad("text", "not a text layer"))?; @@ -257,13 +257,19 @@ pub(crate) fn edit_doc( KV::Str(t) => TextDoc::plain(&t), _ => TextDoc::default(), }; - let before = first_line_name(&doc.text); + let original = doc.clone(); f(&mut doc, st)?; - let after = first_line_name(&doc.text); - pr.set_value_at(lt, KV::Text(Box::new(doc))); - // Text layers are named after their text until renamed. - if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) { - l.name = after; + // A no-op (an IME deletion with nothing around the selection, a set to the value it + // already has) must not turn a static Str into Text or add a keyframe at the CTI: that + // would change the project and record an empty undo step. + if doc != original { + let before = first_line_name(&original.text); + let after = first_line_name(&doc.text); + pr.set_value_at(lt, KV::Text(Box::new(doc))); + // Text layers are named after their text until renamed. + if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) { + l.name = after; + } } Ok(()) }) @@ -674,12 +680,51 @@ pub(crate) fn invalidate_session(s: &mut Session) { } } -/// Keep the session valid after undo / external edits: clamp the selection, drop it when the +/// Clamp the editor selection and give it a fresh IME epoch after its document was replaced. +/// +/// This is the one replacement path for document changes outside `edit_doc`: +/// [`crate::Session::edit`] calls [`after_external_change`] after every edit, [`crate::Session::set_time`] +/// and [`crate::Session::sync_related_times`] call it after moving the CTI, and +/// [`crate::Session::sanitize_after_replacement`] calls it when it restores a whole document. +/// Clamping first means a shortened document leaves the caret in range, so the next Backspace +/// deletes instead of being swallowed by an out-of-range selection. +pub(crate) fn document_replaced(s: &mut Session) { + sanitize(s); + invalidate_session(s); +} + +/// The document the active on-canvas editor is showing, with its layer. +/// +/// [`crate::Session::edit`] and the time setters snapshot this before a change and compare it +/// afterwards: only a change that actually replaces this document starts a new epoch, while a +/// write to another property, layer or composition, a move to a time showing the same text, or a +/// write that leaves the text as it was, keeps the editor's owner. With no editor open this is a +/// single `Option` check, with no document copy. +pub(crate) fn active_editor_doc(s: &Session) -> Option<(LayerId, TextDoc)> { + let e = s.state.text_edit.as_ref()?; + Some((e.layer, layer_doc(s, e.layer)?)) +} + +/// Finish a change outside the editor: if it replaced the document the editor owned, clamp the +/// selection and start a new IME epoch. +pub(crate) fn after_external_change(s: &mut Session, before: Option<&(LayerId, TextDoc)>) { + let Some((lid, before)) = before else { return }; + // The change ended the editor or moved it to another layer: that path owns its own session. + if !s.state.text_edit.as_ref().is_some_and(|e| e.layer == *lid) { + return; + } + if layer_doc(s, *lid).as_ref() != Some(before) { + document_replaced(s); + } +} + +/// Keep the session valid after routine state cleanup: clamp the selection, drop it when the /// layer is gone or not in the active comp. /// -/// Any call to this function follows an undo/redo, a project-level deletion or a rolled-back -/// batch. The document may have been replaced even when the same layer id exists again, so the -/// composition epoch is advanced before the session is kept. +/// This is also called after project-item cleanup that leaves the edited document alone, so it +/// must not advance [`TextEdit::session`]: that would make the viewer treat unrelated cleanup as +/// a document replacement and interrupt the active IME composition. Replacement paths call +/// [`document_replaced`] instead. pub(crate) fn sanitize(s: &mut Session) { let Some(e) = s.state.text_edit.clone() else { return }; match layer_doc(s, e.layer) { @@ -688,7 +733,6 @@ pub(crate) fn sanitize(s: &mut Session) { if let Some(ed) = s.state.text_edit.as_mut() { ed.anchor = ed.anchor.min(n); ed.caret = ed.caret.min(n); - ed.session = next_edit_session(); } } None => s.state.text_edit = None, diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 8b178a05c..2c49e182b 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -347,7 +347,7 @@ impl Session { self.history.redo = redo; self.history.branches = branches; self.history.merge_key = None; - self.sanitize_state(); + self.sanitize_after_replacement(); self.bump(); true } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d6be19fd8..65ac57bfa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -686,7 +686,25 @@ impl Session { } /// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step. + /// + /// Every edit is an external boundary for the on-canvas text editor: if it replaced the + /// document the editor is showing, the selection is clamped and the editor gets a fresh IME + /// epoch. The editor's own typing goes through `edit_text_doc`, which owns that + /// document and must not treat its own keystrokes as a replacement. pub fn edit(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { + let edited = commands::text_edit::active_editor_doc(self); + let out = self.edit_inner(label, merge, f)?; + commands::text_edit::after_external_change(self, edited.as_ref()); + Ok(out) + } + + /// [`Session::edit`] for the on-canvas editor's own text changes. Its document is the one + /// the editor owns, so a change here is typing, not a replacement behind the IME's back. + pub(crate) fn edit_text_doc(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { + self.edit_inner(label, merge, f) + } + + fn edit_inner(&mut self, label: &str, merge: Option<&str>, f: impl FnOnce(&mut Project, &mut EditorState) -> Result) -> Result { let before = self.project.clone(); let mut p = (*self.project).clone(); let mut st = self.state.clone(); @@ -809,7 +827,7 @@ impl Session { self.keep_view_settings(&cur); self.history.redo.push((label, cur)); self.history.merge_key = None; - self.sanitize_state(); + self.sanitize_after_replacement(); self.bump(); true } @@ -820,11 +838,19 @@ impl Session { self.keep_view_settings(&cur); self.history.undo.push((label, cur)); self.history.merge_key = None; - self.sanitize_state(); + self.sanitize_after_replacement(); self.bump(); true } + /// Keep selections valid after the project was replaced by another state (undo, redo, a + /// history jump, a rollback): the same layer id can name a different document, so the + /// on-canvas editor gets a fresh IME epoch. + pub fn sanitize_after_replacement(&mut self) { + self.sanitize_state(); + commands::text_edit::document_replaced(self); + } + /// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id /// counts only inside the comp that owns it, so the active comp never sees another comp's layers. pub fn sanitize_state(&mut self) { @@ -900,13 +926,22 @@ impl Session { self.state.active_comp.and_then(|c| self.state.times.get(&c).copied()).unwrap_or(Tick::ZERO) } + /// Move the active comp's current time. + /// + /// Time is an external boundary for the on-canvas text editor too: an animated Source Text + /// can show a different document at another time. The document is snapshotted only while an + /// editor is open, so playback with no text editor does no text copying. pub fn set_time(&mut self, t: Tick) { if let Some(c) = self.state.active_comp { let t = self.project.comp(c).map_or(t, |comp| comp_frame_at(comp, t)); + let edited = commands::text_edit::active_editor_doc(self); self.state.times.insert(c, t); if self.prefs.general.sync_time_related_items { - self.sync_related_times(c, t); + // Direct call: this `set_time` owns the editor snapshot for both time changes, + // so a synced move does not take a second snapshot or start a second epoch. + self.sync_related_times_inner(c, t); } + commands::text_edit::after_external_change(self, edited.as_ref()); } } @@ -914,6 +949,14 @@ impl Session { /// `comp` moves the current time of the comps nested in it (through their precomp layers' /// timing) and of the comps it is nested in, so every related viewer shows the same moment. pub fn sync_related_times(&mut self, comp: ItemId, t: Tick) { + // Moving a related comp can move the active one, so the editor's document is checked + // here too; the snapshot is skipped entirely when no text editor is open. + let edited = commands::text_edit::active_editor_doc(self); + self.sync_related_times_inner(comp, t); + commands::text_edit::after_external_change(self, edited.as_ref()); + } + + fn sync_related_times_inner(&mut self, comp: ItemId, t: Tick) { let mut seen = std::collections::BTreeSet::from([comp]); let mut todo = vec![(comp, t)]; let mut found = vec![]; diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index a664ca369..1b2fcc955 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -235,6 +235,260 @@ fn ime_session_epoch_tracks_replaced_text_and_layer_restoration() { assert_ne!(s.state.text_edit.as_ref().unwrap().session, first); } +#[test] +fn no_op_ime_deletion_keeps_a_static_plain_text_value_and_records_no_step() { + use effectcraft_keyframe::Value as KV; + let (mut s, t) = session("abc"); + // Legacy/imported text can still be stored as a plain Str rather than a TextDoc. + let cid = s.active_comp_id().unwrap(); + let pr = std::sync::Arc::make_mut(&mut s.project) + .comp_mut(cid) + .unwrap() + .layer_mut(effectcraft_project::LayerId(t)) + .unwrap() + .props + .prop_mut("text/sourceText") + .unwrap(); + pr.value = KV::Str("abc".into()); + s.execute("text.edit", json!({"layer": t, "caret": 0})).unwrap(); + let steps = s.history.undo.len(); + + // A non-zero before count at the document start deletes nothing. + s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap(); + + let pr = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().props.prop("text/sourceText").unwrap(); + assert!(matches!(&pr.value, KV::Str(_)), "a no-op keeps the static Str variant"); + assert!(pr.keys.is_empty()); + assert_eq!(s.history.undo.len(), steps, "a no-op adds no undo step"); +} + +#[test] +fn no_op_ime_deletion_does_not_add_a_keyframe_to_animated_source_text() { + let (mut s, t) = session("abc"); + s.execute("text.edit", json!({"layer": t, "caret": 0})).unwrap(); + s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); + let lid = effectcraft_project::LayerId(t); + let keys = |s: &Session| s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap().keys.len(); + assert_eq!(keys(&s), 1, "one key from the stopwatch"); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + let steps = s.history.undo.len(); + + // A non-zero before count at the document start deletes nothing, at a time with no key. + s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap(); + + assert_eq!(keys(&s), 1, "a no-op does not key Source Text at the CTI"); + assert_eq!(s.history.undo.len(), steps, "a no-op adds no undo step"); +} + +#[test] +fn property_writes_replace_the_edited_document_only_when_the_text_changes() { + let (mut s, t) = session("abc"); + let other = s.execute("layer.newText", json!({"text": "other"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); + let owner = s.state.text_edit.as_ref().unwrap().session; + let steps = s.history.undo.len(); + + // Writing the text the document already has is a no-op: same document, same owner, no step. + s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": "abc"})).unwrap(); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "a no-op write keeps the IME owner"); + assert_eq!(s.history.undo.len(), steps, "a no-op write records no undo step"); + s.execute("layer.setText", json!({"layer": t, "text": "abc"})).unwrap(); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "a no-op layer.setText keeps the IME owner"); + assert_eq!(s.history.undo.len(), steps, "a no-op layer.setText records no undo step"); + + // Another property of the edited layer leaves its document alone. + s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap(); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "an unrelated property keeps the IME owner"); + + // Source Text of another layer is not the editor's document either. + s.execute("prop.set", json!({"layer": other, "path": "text/sourceText", "value": "changed"})).unwrap(); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "another layer's text keeps the IME owner"); + + // Replacing the edited layer's Source Text is a document replacement: a new IME epoch. + s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": "replaced"})).unwrap(); + assert_eq!(doc(&s, t).text, "replaced"); + assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "replacing the document changes the IME owner"); +} + +#[test] +fn a_replacing_property_write_clamps_the_selection_so_the_next_backspace_deletes() { + let (mut s, t) = session("abcdef"); + s.execute("text.edit", json!({"layer": t, "caret": 6})).unwrap(); + let owner = s.state.text_edit.as_ref().unwrap().session; + + s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": "a"})).unwrap(); + assert_eq!(doc(&s, t).text, "a"); + assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range"); + assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner); + + // Backspace at the clamped caret removes the character instead of no-opping on 5..6. + s.execute("text.delete", json!({})).unwrap(); + assert_eq!(doc(&s, t).text, ""); + assert_eq!(sel(&s), (0, 0)); +} + +#[test] +fn keyframe_writes_on_source_text_replace_the_edited_document_only_when_the_cti_value_changes() { + let (mut s, t) = session("abc"); + s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); + // Keying the text the CTI already shows leaves the document equal. + s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); + let owner = s.state.text_edit.as_ref().unwrap().session; + + s.execute("keys.set", json!({"layer": t, "path": "text/sourceText", "time": 0.0, "value": "abc"})).unwrap(); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "a no-op key write keeps the IME owner"); + + // `keys.set` changes the key at the CTI: a document replacement. + s.execute("keys.set", json!({"layer": t, "path": "text/sourceText", "time": 0.0, "value": "replaced"})).unwrap(); + assert_eq!(doc(&s, t).text, "replaced"); + assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a changed keyframe changes the IME owner"); +} + +#[test] +fn deleting_the_cti_keyframe_first_replaces_the_document_then_the_last_key_is_a_noop() { + let (mut s, t) = session("abc"); + s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); + s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); + s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": 1.0, "value": "later"})).unwrap(); + let owner = s.state.text_edit.as_ref().unwrap().session; + let lid = effectcraft_project::LayerId(t); + let pr = s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap(); + let uid = pr.uid; + let times: Vec<_> = pr.keys.iter().map(|k| k.time).collect(); + assert_eq!(times.len(), 2); + + // Deleting the key at the CTI (0s) while the 1s key remains shows the later text: replacing + // the document starts a new epoch. + s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[0] }]; + assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1)); + assert_eq!(doc(&s, t).text, "later"); + let replaced = s.state.text_edit.as_ref().unwrap().session; + assert_ne!(replaced, owner, "deleting the CTI key changed the document"); + + // Removing the last key turns its value into the static CTI value. That value is what the + // CTI already showed, so the document does not change and the new owner stays. + s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[1] }]; + assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1)); + assert_eq!(doc(&s, t).text, "later"); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, replaced, "removing the last key is a document no-op"); +} + +#[test] +fn moving_the_cti_to_a_shorter_animated_key_clamps_and_starts_a_new_epoch() { + let (mut s, t) = session("abcdef"); + s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); + s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": 1.0, "value": "a"})).unwrap(); + s.execute("text.edit", json!({"layer": t, "caret": 6})).unwrap(); + let owner = s.state.text_edit.as_ref().unwrap().session; + + // The same time still shows the same document: no new epoch. + s.execute("time.set", json!({"time": 0.0})).unwrap(); + assert_eq!(doc(&s, t).text, "abcdef"); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "an unchanged CTI document keeps the IME owner"); + + // The key at 1s holds shorter text: the document changes, the selection is clamped and the + // editor gets a new epoch. + s.execute("time.set", json!({"time": 1.0})).unwrap(); + assert_eq!(doc(&s, t).text, "a"); + assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range"); + let replaced = s.state.text_edit.as_ref().unwrap().session; + assert_ne!(replaced, owner, "a changed CTI document starts a new IME epoch"); + + // Backspace at the clamped caret removes the character instead of no-opping on 5..6. + s.execute("text.delete", json!({})).unwrap(); + assert_eq!(doc(&s, t).text, ""); + assert_eq!(sel(&s), (0, 0)); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, replaced, "typing after the replacement keeps its owner"); + + // Staying at the same time after the edit is a no-op for the document too. + s.execute("time.set", json!({"time": 1.0})).unwrap(); + assert_eq!(doc(&s, t).text, ""); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, replaced, "a no-op time move keeps the IME owner"); +} + +#[test] +fn sync_related_times_replaces_the_edited_document_when_it_moves_the_active_comp() { + let (mut s, t) = session("abcdef"); + s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); + s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": 1.0, "value": "a"})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let r = s.execute("layer.precompose", json!({"layers": [t], "name": "Inner", "open": true})).unwrap(); + let inner = effectcraft_project::ItemId(r["comp"].as_u64().unwrap()); + assert_eq!(s.active_comp_id(), Some(inner)); + + s.execute("text.edit", json!({"layer": t, "caret": 6})).unwrap(); + let owner = s.state.text_edit.as_ref().unwrap().session; + s.sync_related_times(outer, effectcraft_time::Tick::from_seconds_f64(1.0)); + + assert_eq!(doc(&s, t).text, "a"); + assert_eq!(sel(&s), (1, 1), "the related time move clamps the selection"); + assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "the related time move changed the document"); +} + +#[test] +fn applying_a_preset_that_writes_source_text_replaces_the_edited_document() { + let (mut s, t) = session("abc"); + s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); + let owner = s.state.text_edit.as_ref().unwrap().session; + let preset = json!({ + "effectcraftAnimationPreset": 1, + "properties": [{"path": "text/sourceText", "value": {"t": "Text", "v": {"text": "replaced"}}, "keys": []}], + }); + s.execute("anim.applyPreset", json!({"preset": preset, "layers": [t]})).unwrap(); + assert_eq!(doc(&s, t).text, "replaced"); + assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a preset that replaces source text changes the IME owner"); +} + +#[test] +fn a_failed_property_write_keeps_the_edited_document_and_its_owner() { + let (mut s, t) = session("abc"); + s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); + let owner = s.state.text_edit.as_ref().unwrap().session; + let steps = s.history.undo.len(); + + // null cannot coerce to a Text value: the edit fails before anything is committed. + assert!(s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": null})).is_err()); + assert_eq!(doc(&s, t).text, "abc"); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "a failed write keeps the IME owner"); + assert_eq!(s.history.undo.len(), steps, "a failed write records no undo step"); +} + +#[test] +fn property_writes_off_the_cti_keep_the_owner_until_they_change_the_cti_document() { + let (mut s, t) = session("abc"); + s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); + s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); + s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": 1.0, "value": "later"})).unwrap(); + let owner = s.state.text_edit.as_ref().unwrap().session; + + // Writing a key at 1s leaves the CTI (0s) value as it was. + s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "time": 1.0, "value": "changed"})).unwrap(); + assert_eq!(doc(&s, t).text, "abc"); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "an off-CTI write keeps the IME owner"); + + // Writing the CTI value itself replaces the document. + s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": "cti"})).unwrap(); + assert_eq!(doc(&s, t).text, "cti"); + assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a CTI write changes the IME owner"); +} + +#[test] +fn unrelated_project_cleanup_keeps_the_ime_owner_but_replacement_changes_it() { + let (mut s, t) = session("abc"); + s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); + let owner = s.state.text_edit.as_ref().unwrap().session; + + // file.removeUnusedFootage calls sanitize_state while the edited document is untouched. + s.execute("file.importSolid", json!({"name": "Unused"})).unwrap(); + let removed = s.execute("file.removeUnusedFootage", json!({})).unwrap(); + assert_eq!(removed["removed"], json!(1), "the unused solid was actually removed"); + assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "unrelated cleanup keeps the IME owner"); + + // Replacing the Source Text is a document replacement: a new owner. + s.execute("layer.setText", json!({"layer": t, "text": "replaced"})).unwrap(); + assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "replacing the document changes the owner"); +} + #[test] fn ime_session_does_not_cross_a_composition_switch() { let (mut s, t) = session("abc"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fa31886cc..a481d96ac 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -172,353 +172,429 @@ fn point(app: &EffectcraftApp, p: &Value) -> Result { pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlRequest, now: f64) -> Outcome { let p = &req.params; - let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { - "engine.execute" | "ui.menu.invoke" => { - let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; - let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); - // `engine.execute` runs engine commands exactly as headless (never opens a dialog, e.g. - // `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`, - // `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click. - let r = if req.method == "engine.execute" && effectcraft_engine::find_command(id).is_some() { - execute_engine(app, id, params) - } else { - crate::menus::invoke(app, ctx, id, params).map(ok) - }; - match r { - Ok(o) => o, - Err(e) => { - app.ui.status = e.clone(); - err(e) - } - } - } - "engine.commands" => match app.session.execute("command.list", json!({"filter": p.get("filter"), "enabledOnly": p.get("enabledOnly")})) { - Ok(v) => ok(v), - Err(e) => err(e), - }, + "engine.execute" | "ui.menu.invoke" => execute_command(app, ctx, &req.method, p), + "engine.commands" => engine_commands(app, p), "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_items(app)).unwrap_or_default()), "ui.inspect" => ok(inspect(app, ctx)), - "ui.elements" => { - let prefix = s("prefix").unwrap_or(""); - ok(serde_json::to_value(app.auto.query(prefix)).unwrap_or_default()) - } - "ui.set" => { - if let Some(t) = s("tool") { - match Tool::from_name(t) { - Some(t) => app.ui.tool = t, - None => return err(format!("unknown tool `{t}`")), - } - } - if let Some(w) = s("workspace") { - match crate::dock::WORKSPACES.iter().find(|x| x.eq_ignore_ascii_case(w)) { - Some(w) => app.set_workspace(w), - None => return err(format!("unknown workspace `{w}`")), - } - } - if let Some(th) = s("theme") { - match crate::theme::ThemeKind::from_name(th) { - Some(k) => app.set_theme(ctx, k), - None => return err("unknown theme (dark, darker, light)"), - } - } - if let Some(f) = s("focused").and_then(PanelKind::from_name) { - app.ui.focused = f; - } - if let Some(v) = p.get("viewer") { - if let Some(z) = v.get("zoom") { - app.ui.viewer.zoom = z.as_f64().map(|z| z as f32); - } - // The active comp's Resolution (`view.res.*`), saved with the project. - if let Some(r) = v.get("res").and_then(Value::as_str) { - let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else { - return err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)")); - }; - if let Some(cid) = app.session.active_comp_id() - && let Err(e) = app.session.set_resolution(cid, res) - { - return err(e); - } - } - if let Some(Value::Array(a)) = v.get("pan") { - app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32]; - } - for (k, slot) in [ - ("grid", &mut app.ui.viewer.grid), - ("rulers", &mut app.ui.viewer.rulers), - ("safeMargins", &mut app.ui.viewer.safe_margins), - ("transparencyGrid", &mut app.ui.viewer.transparency_grid), - ] { - if let Some(b) = v.get(k).and_then(Value::as_bool) { - *slot = b; - } - } - } - if let Some(tl) = p.get("timeline") { - if let Some(v) = tl.get("pps") { - app.ui.timeline.pps = v.as_f64(); - } - if let Some(v) = tl.get("start").and_then(Value::as_f64) { - app.ui.timeline.start = v; - } - if let Some(v) = tl.get("graphEditor").and_then(Value::as_bool) { - app.ui.timeline.graph_editor = v; - } - if let Some(v) = tl.get("showModes").and_then(Value::as_bool) { - app.ui.timeline.show_modes = v; - } - if let Some(Value::Array(a)) = tl.get("openLayers") { - app.ui.timeline.open_layers = a.iter().filter_map(Value::as_u64).collect(); - } - if let Some(Value::Array(a)) = tl.get("openGroups") { - app.ui.timeline.open_groups = a.iter().filter_map(Value::as_u64).collect(); - } - } - if let Some(b) = p.get("menuBar").and_then(Value::as_bool) { - app.ui.show_menu_bar = b; - } - // Project panel optional columns, e.g. ["type", "duration", "path", "comment"]. - if let Some(Value::Array(a)) = p.get("projectColumns") { - let mut cols = vec![]; - for k in a.iter().filter_map(Value::as_str) { - crate::panels::project::set_column(&mut cols, k, true); - } - app.ui.project_columns = cols; - } - // Wiggler / Smoother / Motion Sketch settings (fields of `AnimToolsState`). - if let Some(Value::Object(m)) = p.get("animTools") { - let mut cur = serde_json::to_value(&app.ui.anim_tools).unwrap_or_default(); - for (k, v) in m { - cur[k] = v.clone(); - } - match serde_json::from_value(cur) { - Ok(v) => app.ui.anim_tools = v, - Err(e) => return err(format!("animTools: {e}")), - } - } - if let Some(b) = p.get("home").and_then(Value::as_bool) { - app.ui.start_screen = b; - } - // Home screen tab: "home", "templates" or "learn". - if let Some(tab) = p.get("homeTab").and_then(Value::as_str) { - app.ui.home_learn = tab == "learn"; - app.ui.home_templates = tab == "templates"; - } - ok(Value::Null) - } - "ui.panel.show" | "ui.panel.close" => { - let Some(panel) = s("panel").and_then(PanelKind::from_name) else { return err("unknown `panel`") }; - if req.method == "ui.panel.show" { - app.show_panel(panel); - } else { - app.ui.dock.close(panel); - } - ok(Value::Null) - } - "ui.click" | "ui.move" => { - let pos = match point(app, p) { - Ok(p) => p, - Err(e) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()), - Err(e) => return err(e), - }; - let m = modifiers(p); - // Validate the complete gesture before mutating the queue; the loopback control - // channel is externally reachable and must not amplify tiny requests into huge work. - const MAX_PENDING_EVENTS: usize = 4096; - const MAX_CLICK_COUNT: u64 = 512; - let pending = app.synthetic.len(); - let gesture_events = if req.method == "ui.click" { - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); - if count == 0 || count > MAX_CLICK_COUNT { - return err(format!("`count` must be between 1 and {MAX_CLICK_COUNT}")); - } - let Some(events) = usize::try_from(count).ok().and_then(|n| n.checked_mul(2)).and_then(|n| n.checked_add(3)) else { - return err("click event count overflow"); - }; - events - } else { - 3 - }; - if pending.checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) { - return err("synthetic input queue is full"); - } - // egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event) - // and the synthetic events arrive one per frame: hold them for the whole gesture. - app.synthetic.push(egui::Event::ModifiersChanged(m)); - app.synthetic.push(egui::Event::PointerMoved(pos)); - if req.method == "ui.click" { - let button = match s("button") { - Some("right") => egui::PointerButton::Secondary, - Some("middle") => egui::PointerButton::Middle, - _ => egui::PointerButton::Primary, - }; - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); - for _ in 0..count { - app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); - app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); - } - } - app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); - Outcome::AfterInput - } - "ui.drag" => { - let (Some(from), Some(to)) = (p.get("from"), p.get("to")) else { return err("need `from` and `to`") }; - let (a, b) = match (point(app, from), point(app, to)) { - (Ok(a), Ok(b)) => (a, b), - (Err(e), _) | (_, Err(e)) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()), - (Err(e), _) | (_, Err(e)) => return err(e), - }; - let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); - const MAX_PENDING_EVENTS: usize = 4096; - const MAX_DRAG_STEPS: u64 = 2048; - if steps > MAX_DRAG_STEPS { - return err(format!("`steps` must be at most {MAX_DRAG_STEPS}")); - } - let Some(gesture_events) = usize::try_from(steps).ok().and_then(|n| n.checked_add(5)) else { return err("drag event count overflow") }; - if app.synthetic.len().checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) { - return err("synthetic input queue is full"); - } - let m = modifiers(p); - app.synthetic.push(egui::Event::ModifiersChanged(m)); - app.synthetic.push(egui::Event::PointerMoved(a)); - app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); - for i in 1..=steps { - let f = i as f32 / steps as f32; - app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f)); - } - app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); - app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); - Outcome::AfterInput - } - "ui.scroll" => { - let pos = match point(app, p) { - Ok(p) => p, - Err(e) => return err(e), - }; - let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32; - let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32; - app.synthetic.push(egui::Event::PointerMoved(pos)); - app.synthetic.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(dx, dy), - modifiers: modifiers(p), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - }); - app.synthetic.push(egui::Event::PointerButton { - pos: egui::pos2(-100.0, -100.0), - button: egui::PointerButton::Extra1, - pressed: false, - modifiers: Default::default(), - }); - Outcome::AfterInput - } - "ui.key" => { - let Some(name) = s("key") else { return err("missing `key`") }; - let Some((mut m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) }; - let extra = modifiers(p); - m |= extra; - let physical_key = name.starts_with("Physical+").then_some(key); - app.synthetic.push(egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers: m }); - app.synthetic.push(egui::Event::Key { key, physical_key, pressed: false, repeat: false, modifiers: m }); - Outcome::AfterInput - } - "ui.type" => { - let Some(text) = s("text") else { return err("missing `text`") }; - app.synthetic.push(egui::Event::Text(text.to_string())); - app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }); - Outcome::AfterInput - } - "ui.viewer.locate" => { - let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else { - return err("need `x`, `y` (comp pixels)"); - }; - match crate::panels::viewer::comp_to_screen(ctx, [x as f32, y as f32]) { - Some(pt) => ok(json!({"x": pt.x, "y": pt.y})), - None => Outcome::Retry("the composition viewer is not visible".into()), - } - } - "ui.viewer.hit" => { - let pos = match point(app, p) { - Ok(p) => p, - Err(e) => return err(e), - }; - match crate::panels::viewer::screen_to_comp(ctx, pos) { - Some(c) => ok(json!({"x": c[0], "y": c[1]})), - None => err("the composition viewer is not visible"), - } - } - "ui.timeline.locate" => { - let Some(l) = p.get("layer").and_then(Value::as_u64) else { return err("need `layer` (id)") }; - match crate::panels::timeline::locate(app, l, p.get("time").and_then(Value::as_f64)) { - Some((x, y)) => ok(json!({"x": x, "y": y})), - None => Outcome::Retry("layer not visible in the timeline".into()), - } - } - "ui.playback" => { - match s("action").unwrap_or("toggle") { - "play" => app.play(now), - "stop" => app.stop(), - "status" => {} - _ => app.toggle_play(now), - } - ok( - json!({"playing": app.playback.playing, "time": app.session.time().seconds(), "audio": app.audio.is_some(), "levelsDb": app.meter.level_db, "peaksDb": app.meter.peak_db}), - ) - } - "ui.screenshot" => { - // The panel/element is an identity, not a rectangle: it is resolved when the capture - // is issued (after the UI settles), so a panel that moves meanwhile is not cropped - // with its old rectangle. - let target = match s("panel") { - Some(name) => match PanelKind::from_name(name) { - Some(pk) => crate::ShotTarget::Panel(pk), - None => return err(format!("unknown panel `{name}`")), - }, - None => s("id").map_or(crate::ShotTarget::Window, |id| crate::ShotTarget::Id(id.to_string())), - }; - // Asking for one element and silently capturing the whole window would hide a missing - // panel: wait for it like any other not-yet-laid-out control. - let missing = match &target { - crate::ShotTarget::Panel(pk) => app.auto.find(&format!("panel.{}", pk.id())).is_none(), - crate::ShotTarget::Id(id) => app.auto.find(id).is_none(), - crate::ShotTarget::Window | crate::ShotTarget::Rect(_) => false, - }; - if missing { - return Outcome::Retry(match &target { - crate::ShotTarget::Panel(pk) => format!("the {} panel is not on screen", pk.title()), - crate::ShotTarget::Id(id) => format!("no element `{id}` is on screen"), - crate::ShotTarget::Window | crate::ShotTarget::Rect(_) => "the element is not on screen".into(), - }); - } - // Bring the window on screen so a frame is presented, without taking keyboard focus. - app.raise_for_control(ctx); - Outcome::Screenshot { path: s("path").map(str::to_string), target } - } + "ui.elements" => elements(app, p), + "ui.set" => set_ui(app, ctx, p), + "ui.panel.show" | "ui.panel.close" => panel(app, &req.method, p), + "ui.click" | "ui.move" => click_or_move(app, &req.method, p), + "ui.drag" => drag(app, p), + "ui.scroll" => scroll(app, p), + "ui.key" => key(app, p), + "ui.type" => type_text(app, p), + "ui.viewer.locate" => viewer_locate(ctx, p), + "ui.viewer.hit" => viewer_hit(app, ctx, p), + "ui.timeline.locate" => timeline_locate(app, p), + "ui.playback" => playback(app, p, now), + "ui.screenshot" => screenshot(app, ctx, p), "render.frame" => render_frame(app, p), - "ui.resize" => { - let w = p.get("width").and_then(Value::as_f64).unwrap_or(1600.0) as f32; - let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0) as f32; - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); - ok(Value::Null) - } - "ui.focus" => { - // Explicit request: activate the app and take keyboard focus. - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); - ok(Value::Null) - } - "app.quit" => { - // A modified project asks to save first unless `force: true`. - if p.get("force").and_then(Value::as_bool).unwrap_or(false) { - app.dialog_state.unsaved.quitting = true; - } - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - ok(Value::Null) - } + "ui.resize" => resize(ctx, p), + "ui.focus" => focus(ctx), + "app.quit" => quit(app, ctx, p), m => err(format!("unknown method `{m}`")), } } +fn execute_command(app: &mut EffectcraftApp, ctx: &egui::Context, method: &str, p: &Value) -> Outcome { + let s = |k: &str| p.get(k).and_then(Value::as_str); + let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; + let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + // `engine.execute` runs engine commands exactly as headless (never opens a dialog, e.g. + // `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`, + // `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click. + let r = if method == "engine.execute" && effectcraft_engine::find_command(id).is_some() { + execute_engine(app, id, params) + } else { + crate::menus::invoke(app, ctx, id, params).map(ok) + }; + match r { + Ok(o) => o, + Err(e) => { + app.ui.status = e.clone(); + err(e) + } + } +} + +fn engine_commands(app: &mut EffectcraftApp, p: &Value) -> Outcome { + match app.session.execute("command.list", json!({"filter": p.get("filter"), "enabledOnly": p.get("enabledOnly")})) { + Ok(v) => ok(v), + Err(e) => err(e), + } +} + +fn elements(app: &EffectcraftApp, p: &Value) -> Outcome { + let prefix = p.get("prefix").and_then(Value::as_str).unwrap_or(""); + ok(serde_json::to_value(app.auto.query(prefix)).unwrap_or_default()) +} + +/// Explicit request: activate the app and take keyboard focus. +fn focus(ctx: &egui::Context) -> Outcome { + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + ok(Value::Null) +} + +fn set_ui(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome { + let s = |k: &str| p.get(k).and_then(Value::as_str); + if let Some(t) = s("tool") { + match Tool::from_name(t) { + Some(t) => app.ui.tool = t, + None => return err(format!("unknown tool `{t}`")), + } + } + if let Some(w) = s("workspace") { + match crate::dock::WORKSPACES.iter().find(|x| x.eq_ignore_ascii_case(w)) { + Some(w) => app.set_workspace(w), + None => return err(format!("unknown workspace `{w}`")), + } + } + if let Some(th) = s("theme") { + match crate::theme::ThemeKind::from_name(th) { + Some(k) => app.set_theme(ctx, k), + None => return err("unknown theme (dark, darker, light)"), + } + } + if let Some(f) = s("focused").and_then(PanelKind::from_name) { + app.ui.focused = f; + } + if let Some(v) = p.get("viewer") + && let Err(e) = apply_viewer(app, v) + { + return err(e); + } + set_ui_layout(app, p) +} + +/// The remaining `ui.set` fields, applied in the same order after the viewer settings. +fn set_ui_layout(app: &mut EffectcraftApp, p: &Value) -> Outcome { + if let Some(tl) = p.get("timeline") { + apply_timeline(app, tl); + } + if let Some(b) = p.get("menuBar").and_then(Value::as_bool) { + app.ui.show_menu_bar = b; + } + // Project panel optional columns, e.g. ["type", "duration", "path", "comment"]. + if let Some(Value::Array(a)) = p.get("projectColumns") { + apply_project_columns(app, a); + } + // Wiggler / Smoother / Motion Sketch settings (fields of `AnimToolsState`). + if let Some(Value::Object(m)) = p.get("animTools") + && let Err(e) = apply_anim_tools(app, m) + { + return err(e); + } + if let Some(b) = p.get("home").and_then(Value::as_bool) { + app.ui.start_screen = b; + } + // Home screen tab: "home", "templates" or "learn". + if let Some(tab) = p.get("homeTab").and_then(Value::as_str) { + app.ui.home_learn = tab == "learn"; + app.ui.home_templates = tab == "templates"; + } + ok(Value::Null) +} + +fn apply_viewer(app: &mut EffectcraftApp, v: &Value) -> Result<(), String> { + if let Some(z) = v.get("zoom") { + app.ui.viewer.zoom = z.as_f64().map(|z| z as f32); + } + // The active comp's Resolution (`view.res.*`), saved with the project. + if let Some(r) = v.get("res").and_then(Value::as_str) { + let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else { + return Err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)")); + }; + if let Some(cid) = app.session.active_comp_id() + && let Err(e) = app.session.set_resolution(cid, res) + { + return Err(e.to_string()); + } + } + if let Some(Value::Array(a)) = v.get("pan") { + app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32]; + } + for (k, slot) in [ + ("grid", &mut app.ui.viewer.grid), + ("rulers", &mut app.ui.viewer.rulers), + ("safeMargins", &mut app.ui.viewer.safe_margins), + ("transparencyGrid", &mut app.ui.viewer.transparency_grid), + ] { + if let Some(b) = v.get(k).and_then(Value::as_bool) { + *slot = b; + } + } + Ok(()) +} + +fn apply_timeline(app: &mut EffectcraftApp, tl: &Value) { + if let Some(v) = tl.get("pps") { + app.ui.timeline.pps = v.as_f64(); + } + if let Some(v) = tl.get("start").and_then(Value::as_f64) { + app.ui.timeline.start = v; + } + if let Some(v) = tl.get("graphEditor").and_then(Value::as_bool) { + app.ui.timeline.graph_editor = v; + } + if let Some(v) = tl.get("showModes").and_then(Value::as_bool) { + app.ui.timeline.show_modes = v; + } + if let Some(Value::Array(a)) = tl.get("openLayers") { + app.ui.timeline.open_layers = a.iter().filter_map(Value::as_u64).collect(); + } + if let Some(Value::Array(a)) = tl.get("openGroups") { + app.ui.timeline.open_groups = a.iter().filter_map(Value::as_u64).collect(); + } +} + +fn apply_project_columns(app: &mut EffectcraftApp, a: &[Value]) { + let mut cols = vec![]; + for k in a.iter().filter_map(Value::as_str) { + crate::panels::project::set_column(&mut cols, k, true); + } + app.ui.project_columns = cols; +} + +fn apply_anim_tools(app: &mut EffectcraftApp, m: &serde_json::Map) -> Result<(), String> { + let mut cur = serde_json::to_value(&app.ui.anim_tools).unwrap_or_default(); + for (k, v) in m { + cur[k] = v.clone(); + } + match serde_json::from_value(cur) { + Ok(v) => { + app.ui.anim_tools = v; + Ok(()) + } + Err(e) => Err(format!("animTools: {e}")), + } +} + +fn panel(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome { + let s = |k: &str| p.get(k).and_then(Value::as_str); + let Some(panel) = s("panel").and_then(PanelKind::from_name) else { return err("unknown `panel`") }; + if method == "ui.panel.show" { + app.show_panel(panel); + } else { + app.ui.dock.close(panel); + } + ok(Value::Null) +} + +fn click_gesture_events(method: &str, p: &Value) -> Result { + if method != "ui.click" { + return Ok(3); + } + const MAX_CLICK_COUNT: u64 = 512; + let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); + if count == 0 || count > MAX_CLICK_COUNT { + return Err(format!("`count` must be between 1 and {MAX_CLICK_COUNT}")); + } + usize::try_from(count).ok().and_then(|n| n.checked_mul(2)).and_then(|n| n.checked_add(3)).ok_or_else(|| "click event count overflow".to_string()) +} + +fn click_or_move(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome { + let s = |k: &str| p.get(k).and_then(Value::as_str); + let pos = match point(app, p) { + Ok(p) => p, + Err(e) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()), + Err(e) => return err(e), + }; + let m = modifiers(p); + // Validate the complete gesture before mutating the queue; the loopback control + // channel is externally reachable and must not amplify tiny requests into huge work. + const MAX_PENDING_EVENTS: usize = 4096; + let pending = app.synthetic.len(); + let gesture_events = match click_gesture_events(method, p) { + Ok(events) => events, + Err(e) => return err(e), + }; + if pending.checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) { + return err("synthetic input queue is full"); + } + // egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event) + // and the synthetic events arrive one per frame: hold them for the whole gesture. + app.synthetic.push(egui::Event::ModifiersChanged(m)); + app.synthetic.push(egui::Event::PointerMoved(pos)); + if method == "ui.click" { + let button = match s("button") { + Some("right") => egui::PointerButton::Secondary, + Some("middle") => egui::PointerButton::Middle, + _ => egui::PointerButton::Primary, + }; + let count = p.get("count").and_then(Value::as_u64).unwrap_or(1); + for _ in 0..count { + app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); + app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); + } + } + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + Outcome::AfterInput +} + +fn drag(app: &mut EffectcraftApp, p: &Value) -> Outcome { + let (Some(from), Some(to)) = (p.get("from"), p.get("to")) else { return err("need `from` and `to`") }; + let (a, b) = match (point(app, from), point(app, to)) { + (Ok(a), Ok(b)) => (a, b), + (Err(e), _) | (_, Err(e)) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()), + (Err(e), _) | (_, Err(e)) => return err(e), + }; + let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); + const MAX_PENDING_EVENTS: usize = 4096; + const MAX_DRAG_STEPS: u64 = 2048; + if steps > MAX_DRAG_STEPS { + return err(format!("`steps` must be at most {MAX_DRAG_STEPS}")); + } + let Some(gesture_events) = usize::try_from(steps).ok().and_then(|n| n.checked_add(5)) else { return err("drag event count overflow") }; + if app.synthetic.len().checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) { + return err("synthetic input queue is full"); + } + let m = modifiers(p); + app.synthetic.push(egui::Event::ModifiersChanged(m)); + app.synthetic.push(egui::Event::PointerMoved(a)); + app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); + for i in 1..=steps { + let f = i as f32 / steps as f32; + app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f)); + } + app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + Outcome::AfterInput +} + +fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { + let pos = match point(app, p) { + Ok(p) => p, + Err(e) => return err(e), + }; + let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32; + let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32; + app.synthetic.push(egui::Event::PointerMoved(pos)); + app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(dx, dy), + modifiers: modifiers(p), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + app.synthetic.push(egui::Event::PointerButton { + pos: egui::pos2(-100.0, -100.0), + button: egui::PointerButton::Extra1, + pressed: false, + modifiers: Default::default(), + }); + Outcome::AfterInput +} + +fn key(app: &mut EffectcraftApp, p: &Value) -> Outcome { + let s = |k: &str| p.get(k).and_then(Value::as_str); + let Some(name) = s("key") else { return err("missing `key`") }; + let Some((mut m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) }; + let extra = modifiers(p); + m |= extra; + let physical_key = name.starts_with("Physical+").then_some(key); + app.synthetic.push(egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers: m }); + app.synthetic.push(egui::Event::Key { key, physical_key, pressed: false, repeat: false, modifiers: m }); + Outcome::AfterInput +} + +fn type_text(app: &mut EffectcraftApp, p: &Value) -> Outcome { + let Some(text) = p.get("text").and_then(Value::as_str) else { return err("missing `text`") }; + app.synthetic.push(egui::Event::Text(text.to_string())); + app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }); + Outcome::AfterInput +} + +fn viewer_locate(ctx: &egui::Context, p: &Value) -> Outcome { + let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else { + return err("need `x`, `y` (comp pixels)"); + }; + match crate::panels::viewer::comp_to_screen(ctx, [x as f32, y as f32]) { + Some(pt) => ok(json!({"x": pt.x, "y": pt.y})), + None => Outcome::Retry("the composition viewer is not visible".into()), + } +} + +fn viewer_hit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome { + let pos = match point(app, p) { + Ok(p) => p, + Err(e) => return err(e), + }; + match crate::panels::viewer::screen_to_comp(ctx, pos) { + Some(c) => ok(json!({"x": c[0], "y": c[1]})), + None => err("the composition viewer is not visible"), + } +} + +fn timeline_locate(app: &EffectcraftApp, p: &Value) -> Outcome { + let Some(l) = p.get("layer").and_then(Value::as_u64) else { return err("need `layer` (id)") }; + match crate::panels::timeline::locate(app, l, p.get("time").and_then(Value::as_f64)) { + Some((x, y)) => ok(json!({"x": x, "y": y})), + None => Outcome::Retry("layer not visible in the timeline".into()), + } +} + +fn playback(app: &mut EffectcraftApp, p: &Value, now: f64) -> Outcome { + let action = p.get("action").and_then(Value::as_str).unwrap_or("toggle"); + match action { + "play" => app.play(now), + "stop" => app.stop(), + "status" => {} + _ => app.toggle_play(now), + } + ok( + json!({"playing": app.playback.playing, "time": app.session.time().seconds(), "audio": app.audio.is_some(), "levelsDb": app.meter.level_db, "peaksDb": app.meter.peak_db}), + ) +} + +fn screenshot(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome { + let s = |k: &str| p.get(k).and_then(Value::as_str); + // The panel/element is an identity, not a rectangle: it is resolved when the capture + // is issued (after the UI settles), so a panel that moves meanwhile is not cropped + // with its old rectangle. + let target = match s("panel") { + Some(name) => match PanelKind::from_name(name) { + Some(pk) => crate::ShotTarget::Panel(pk), + None => return err(format!("unknown panel `{name}`")), + }, + None => s("id").map_or(crate::ShotTarget::Window, |id| crate::ShotTarget::Id(id.to_string())), + }; + // Asking for one element and silently capturing the whole window would hide a missing + // panel: wait for it like any other not-yet-laid-out control. + let missing = match &target { + crate::ShotTarget::Panel(pk) => app.auto.find(&format!("panel.{}", pk.id())).is_none(), + crate::ShotTarget::Id(id) => app.auto.find(id).is_none(), + crate::ShotTarget::Window | crate::ShotTarget::Rect(_) => false, + }; + if missing { + return Outcome::Retry(match &target { + crate::ShotTarget::Panel(pk) => format!("the {} panel is not on screen", pk.title()), + crate::ShotTarget::Id(id) => format!("no element `{id}` is on screen"), + crate::ShotTarget::Window | crate::ShotTarget::Rect(_) => "the element is not on screen".into(), + }); + } + // Bring the window on screen so a frame is presented, without taking keyboard focus. + app.raise_for_control(ctx); + Outcome::Screenshot { path: s("path").map(str::to_string), target } +} + +fn resize(ctx: &egui::Context, p: &Value) -> Outcome { + let w = p.get("width").and_then(Value::as_f64).unwrap_or(1600.0) as f32; + let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0) as f32; + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); + ok(Value::Null) +} + +fn quit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome { + // A modified project asks to save first unless `force: true`. + if p.get("force").and_then(Value::as_bool).unwrap_or(false) { + app.dialog_state.unsaved.quitting = true; + } + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + ok(Value::Null) +} + /// `render.frame {comp?, time?, max_side?, path?, base64?}`: render a comp frame through the session /// (independent of the viewer zoom/resolution) and return it as PNG, written to `path` (default a /// temp file) or inline as base64 when `base64: true`. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 531d7dd6e..2eed3678c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1402,11 +1402,37 @@ impl EffectcraftApp { let video = plan.is_none_or(|p| p.video); // Prefetch ahead. let cur = fr.frame_at(self.session.time()); - // While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few - // frames, only once it is done, and not while edits keep coming (a layer dragged in the - // viewer): those frames would be stale at the next step and would hold the CPU and GPU - // the next viewer frame needs. - let ahead = if self.playback.playing { + let ahead = self.prefetch_ahead(ctx, now); + let queued = self.frames.inflight(); + let series = self.frame_series(cid, scale); + // The preview's position orders prefetch and eviction (#595). + let playhead = plan.filter(|p| p.video).map(|p| frames::Playhead { series, cur, start: p.start, end: p.end, step: p.step, looping: p.looping }); + self.frames.set_playhead(playhead); + if self.cache_preview(ctx, &c, cid, scale, now, fr, series, plan, ahead, queued) { + return; + } + let step = plan.map_or(1, |p| p.step); + let fit_ahead = self.frames_that_fit(&c, scale) - 1; + self.prefetch_playback(cid, scale, plan, cur, wa, wb, ahead, fit_ahead, series, queued, video); + self.pump_meters(ctx, now); + if !self.playback.playing { + self.cache_when_idle(ctx, &c, &series, now, input_active, (wa, wb)); + return; + } + let fit = self.frames_that_fit(&c, scale); + self.start_held_audio(now, plan, series, cur, ahead, fit, fr); + if self.advance_audio_clock(ctx, now, series, wa, wb, video, fr, cur, step) { + return; + } + self.advance_frame_clock(ctx, now, plan, video, looping, wa, wb, fr, cur, step, series); + } + + /// While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few + /// frames, only once it is done, and not while edits keep coming (a layer dragged in the + /// viewer): those frames would be stale at the next step and would hold the CPU and GPU + /// the next viewer frame needs. + fn prefetch_ahead(&mut self, ctx: &egui::Context, now: f64) -> i64 { + if self.playback.playing { (self.frames_parallelism() * 2).max(4) as i64 } else if self.frames.urgent_pending() { 0 @@ -1417,59 +1443,83 @@ impl EffectcraftApp { // Keep workers available for a newly edited viewer frame. Running obsolete // background renders cannot be preempted by the priority queue. self.frames_parallelism().saturating_sub(1).min(2) as i64 - }; - let mut queued = self.frames.inflight(); - let series = self.frame_series(cid, scale); - // The preview's position orders prefetch and eviction (#595). - let playhead = plan.filter(|p| p.video).map(|p| frames::Playhead { series, cur, start: p.start, end: p.end, step: p.step, looping: p.looping }); - self.frames.set_playhead(playhead); - if self.playback.caching - && let Some(p) = plan - { - // Cache Before Playback: the range in play order, then start the clock. A range - // larger than the RAM preview budget is cut to the frames that fit (caching more - // would evict the first ones and never finish). - let mut all = true; - let n = (p.end - p.start) / p.step + 1; - let fit = self.frames_that_fit(&c, scale); - if n > fit { - let mut cut = p; - (cut.start, cut.end) = (p.first, (p.first + (fit - 1) * p.step).min(p.end)); - self.playback.plan = Some(cut); - self.toast = Some((format!("Preview cut to {fit} of {n} frames: no more fit in the RAM preview (Settings ▸ Memory & CPU)"), now)); - ctx.request_repaint(); - return; - } - let order: Vec = (0..n).filter_map(|k| p.frame_after(p.first, k)).collect(); - for f in order { - if self.frames.is_cached(&FrameKey { frame: f, ..series }) { - continue; - } - all = false; - if queued < ahead as usize { - self.request_frame(cid, f, scale); - queued += 1; - } - } - self.playback.waiting = !all; - if all { - self.playback.caching = false; - self.playback.start_wall = now; - self.playback.start_frame = p.first; - self.session.set_time(fr.tick_of(p.first)); - self.start_audio(fr.tick_of(p.first)); - } - ctx.request_repaint(); - return; } - let step = plan.map_or(1, |p| p.step); - // Never further ahead than the RAM preview holds: those frames would evict the ones the - // preview shows first, or be evicted before it gets there (#595). The window starts at the - // first uncached frame ahead: after a loop or a restart the frames just ahead are cached, - // and a window from the playhead left the workers idle until it came near the end of the - // cached run. It stays ahead × 3 frames wide: workers spread over the whole preview render - // frames with different layers at once (on the GPU, an atlas each: #1812). - let fit_ahead = self.frames_that_fit(&c, scale) - 1; + } + + /// Cache Before Playback: the range in play order, then start the clock. A range larger than + /// the RAM preview budget is cut to the frames that fit (caching more would evict the first + /// ones and never finish). Returns true when this pass handled the preview. + fn cache_preview( + &mut self, + ctx: &egui::Context, + comp: &effectcraft_engine::project::Comp, + cid: ItemId, + scale: f64, + now: f64, + fr: effectcraft_engine::time::FrameRate, + series: FrameKey, + plan: Option, + ahead: i64, + mut queued: usize, + ) -> bool { + if !self.playback.caching { + return false; + } + let Some(p) = plan else { return false }; + let mut all = true; + let n = (p.end - p.start) / p.step + 1; + let fit = self.frames_that_fit(comp, scale); + if n > fit { + let mut cut = p; + (cut.start, cut.end) = (p.first, (p.first + (fit - 1) * p.step).min(p.end)); + self.playback.plan = Some(cut); + self.toast = Some((format!("Preview cut to {fit} of {n} frames: no more fit in the RAM preview (Settings ▸ Memory & CPU)"), now)); + ctx.request_repaint(); + return true; + } + let order: Vec = (0..n).filter_map(|k| p.frame_after(p.first, k)).collect(); + for f in order { + if self.frames.is_cached(&FrameKey { frame: f, ..series }) { + continue; + } + all = false; + if queued < ahead as usize { + self.request_frame(cid, f, scale); + queued += 1; + } + } + self.playback.waiting = !all; + if all { + self.playback.caching = false; + self.playback.start_wall = now; + self.playback.start_frame = p.first; + self.session.set_time(fr.tick_of(p.first)); + self.start_audio(fr.tick_of(p.first)); + } + ctx.request_repaint(); + true + } + + /// Never further ahead than the RAM preview holds: those frames would evict the ones the + /// preview shows first, or be evicted before it gets there (#595). The window starts at the + /// first uncached frame ahead: after a loop or a restart the frames just ahead are cached, + /// and a window from the playhead left the workers idle until it came near the end of the + /// cached run. It stays ahead × 3 frames wide: workers spread over the whole preview render + /// frames with different layers at once (on the GPU, an atlas each: #1812). + fn prefetch_playback( + &self, + cid: ItemId, + scale: f64, + plan: Option, + cur: i64, + wa: i64, + wb: i64, + ahead: i64, + fit_ahead: i64, + series: FrameKey, + mut queued: usize, + video: bool, + ) { let frame = |k: i64| match plan { Some(p) => p.frame_after(cur, k), None => { @@ -1496,6 +1546,9 @@ impl EffectcraftApp { queued += 1; } } + } + + fn pump_meters(&mut self, ctx: &egui::Context, now: f64) { if let Some(a) = &mut self.audio { a.pump(); self.meter.update(a.feed.take_peaks(), now); @@ -1511,57 +1564,97 @@ impl EffectcraftApp { self.meter.update([0.0; 2], now); ctx.request_repaint(); } - if !self.playback.playing { - self.cache_when_idle(ctx, &c, &series, now, input_active, (wa, wb)); + } + + /// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the + /// prefetch window when the silent preview keeps real time, else the rest of the preview. + fn start_held_audio( + &mut self, + now: f64, + plan: Option, + series: FrameKey, + cur: i64, + ahead: i64, + fit: i64, + fr: effectcraft_engine::time::FrameRate, + ) { + if !self.playback.audio_held { return; } - let snap = |f: i64| wa + (f - wa).div_euclid(step) * step; - // Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the - // prefetch window when the silent preview keeps real time, else the rest of the preview. - let fit = self.frames_that_fit(&c, scale); - if self.playback.audio_held - && let Some(p) = plan - && ((self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, ahead.min(fit))) || self.cached_ahead(&p, &series, cur, fit)) - { + let Some(p) = plan else { return }; + if (self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, ahead.min(fit))) || self.cached_ahead(&p, &series, cur, fit) { self.playback.audio_held = false; self.playback.start_wall = now; self.playback.start_frame = cur; self.start_audio(fr.tick_of(cur)); } - // Audio clock drives playback: show the frame under the audible sample. - if let Some(a) = &self.audio { - if a.finished() { - if video { - self.session.set_time(fr.tick_of(wb)); - } else { - self.playback.audio_frame = Some(wb); - } - self.stop(); - return; - } - let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb)); - if !video { - self.playback.audio_frame = Some(target); - } else if self.frames.is_cached(&FrameKey { frame: target, ..series }) { - self.playback.waiting = false; - if target != cur { - self.session.set_time(fr.tick_of(target)); - self.playback.frame_shown(now); - } + } + + /// Audio clock drives playback: show the frame under the audible sample. Returns true when + /// the audio path handled this pass (including stopping at the end). + fn advance_audio_clock( + &mut self, + ctx: &egui::Context, + now: f64, + series: FrameKey, + wa: i64, + wb: i64, + video: bool, + fr: effectcraft_engine::time::FrameRate, + cur: i64, + step: i64, + ) -> bool { + let Some(a) = &self.audio else { return false }; + if a.finished() { + if video { + self.session.set_time(fr.tick_of(wb)); } else { - // Rendering can't keep up: rather than skip frames, the sound stops and every - // frame shows as it renders until the frames ahead are cached again. - self.audio = None; - self.playback.audio_held = true; - self.playback.waiting = true; - // The rate measured before the stall no longer says it keeps up. - self.playback.shown_at.clear(); - self.playback.start_wall = now; - self.playback.start_frame = cur; + self.playback.audio_frame = Some(wb); } - ctx.request_repaint(); - return; + self.stop(); + return true; } + let snap = |f: i64| wa + (f - wa).div_euclid(step) * step; + let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb)); + if !video { + self.playback.audio_frame = Some(target); + } else if self.frames.is_cached(&FrameKey { frame: target, ..series }) { + self.playback.waiting = false; + if target != cur { + self.session.set_time(fr.tick_of(target)); + self.playback.frame_shown(now); + } + } else { + // Rendering can't keep up: rather than skip frames, the sound stops and every + // frame shows as it renders until the frames ahead are cached again. + self.audio = None; + self.playback.audio_held = true; + self.playback.waiting = true; + // The rate measured before the stall no longer says it keeps up. + self.playback.shown_at.clear(); + self.playback.start_wall = now; + self.playback.start_frame = cur; + } + ctx.request_repaint(); + true + } + + /// The wall clock advances the preview when no audio device drives it, and shows the target + /// frame (or holds when it is not cached yet). + fn advance_frame_clock( + &mut self, + ctx: &egui::Context, + now: f64, + plan: Option, + video: bool, + looping: bool, + wa: i64, + wb: i64, + fr: effectcraft_engine::time::FrameRate, + cur: i64, + step: i64, + series: FrameKey, + ) { let elapsed = now - self.playback.start_wall; // The plan carries the rate the preview was started with (and `play_with` copies it to // `Playback::fps`). Deriving the frame from the plan keeps a logic-only tick correct even @@ -1919,9 +2012,23 @@ impl EffectcraftApp { /// viewport, and everything that can write a file or reply happens here, on the app thread. fn tick_screenshots(&mut self, ctx: &egui::Context) { let Some(geom) = self.current_geometry(ctx) else { return }; - // 1. Drain completed slots. A completion whose surface epoch changed since issuance is - // discarded even if the geometry changed back: it was cropped with another frame's - // pixels-per-point. + self.drain_completed_screenshots(geom); + self.timeout_screenshots(); + self.issue_screenshots(ctx, geom); + // 4. Wake up for the earliest deadline even when no further pass is otherwise needed: an + // issued capture whose callback never arrives (hidden window, display asleep, renderer + // failure) would otherwise never reach its timeout. This is a timer, not a paint + // request: the issued screenshot command already forces the frame it needs. + if let Some(next) = self.pending_screenshots.iter().map(|s| s.deadline).reduce(f64::min) { + let wait = (next - self.now).max(0.001); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + } + + /// 1. Drain completed slots. A completion whose surface epoch changed since issuance is + /// discarded even if the geometry changed back: it was cropped with another frame's + /// pixels-per-point. + fn drain_completed_screenshots(&mut self, geom: DrawGeometry) { let mut i = 0; while i < self.pending_screenshots.len() { let taken = self.pending_screenshots.get(i).and_then(|s| s.slot.as_ref()).and_then(craft_ui::capture::CaptureSlot::take); @@ -1977,7 +2084,10 @@ impl EffectcraftApp { } } } - // 2. Timeouts: a late callback finds its slot cancelled and writes nothing. + } + + /// 2. Timeouts: a late callback finds its slot cancelled and writes nothing. + fn timeout_screenshots(&mut self) { let mut i = 0; while i < self.pending_screenshots.len() { if self.now > self.pending_screenshots[i].deadline { @@ -1986,8 +2096,11 @@ impl EffectcraftApp { i += 1; } } - // 3. Issue settled requests. The command itself requests the paint; a request still - // waiting for its passes needs `want_paint` so a hidden window keeps painting. + } + + /// 3. Issue settled requests. The command itself requests the paint; a request still + /// waiting for its passes needs `want_paint` so a hidden window keeps painting. + fn issue_screenshots(&mut self, ctx: &egui::Context, geom: DrawGeometry) { let mut i = 0; while i < self.pending_screenshots.len() { if self.pending_screenshots[i].slot.is_some() { @@ -2038,14 +2151,6 @@ impl EffectcraftApp { s.crop = crop; i += 1; } - // 4. Wake up for the earliest deadline even when no further pass is otherwise needed: an - // issued capture whose callback never arrives (hidden window, display asleep, renderer - // failure) would otherwise never reach its timeout. This is a timer, not a paint - // request: the issued screenshot command already forces the frame it needs. - if let Some(next) = self.pending_screenshots.iter().map(|s| s.deadline).reduce(f64::min) { - let wait = (next - self.now).max(0.001); - ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); - } } // ---------------------------------------------------------------- frame diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index c0b03f77e..c615bc4f4 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -260,9 +260,11 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool) { let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); app.session.project = checkpoint.project; app.session.history = checkpoint.history; - app.session.sanitize_state(); if changed { + app.session.sanitize_after_replacement(); app.session.bump(); + } else { + app.session.sanitize_state(); } } app.session.history.merge_key = None; From 18206c10145e1fdf2dfa088563c72a528d2d680a Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 11:40:05 +0100 Subject: [PATCH 3/4] Fix text ownership after composition removal and simplify playback helpers --- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_text_edit.rs | 446 +++++++++++++++--------- crates/ui-egui/src/lib.rs | 130 ++++--- crates/ui-egui/tests/it/ui_menus.rs | 8 +- crates/ui-egui/tests/it/ui_scrolling.rs | 72 ++-- crates/ui-egui/tests/it/ui_tail.rs | 2 +- 6 files changed, 364 insertions(+), 296 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 65ac57bfa..a9c585209 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -867,6 +867,8 @@ impl Session { }); if self.state.active_comp.is_some_and(|c| p.comp(c).is_none()) { self.clear_live_selection(); + // A matching layer id in the fallback comp is not the document being edited. + self.state.text_edit = None; self.state.active_comp = self.state.open_comps.first().copied(); if let Some(id) = self.state.active_comp { self.recall_selection(id); diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 1b2fcc955..db92b28be 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -24,6 +24,98 @@ fn sel(s: &Session) -> (usize, usize) { (e.anchor, e.caret) } +/// Start editing the text layer with everything selected (no caret given). +fn begin_edit(s: &mut Session, t: u64) { + s.execute("text.edit", json!({"layer": t})).unwrap(); +} + +/// Start editing the text layer with the caret at `caret`. +fn begin_edit_at(s: &mut Session, t: u64, caret: usize) { + s.execute("text.edit", json!({"layer": t, "caret": caret})).unwrap(); +} + +/// A session with one text layer already open in the editor at `caret`. +fn editing(text: &str, caret: usize) -> (Session, u64) { + let (mut s, t) = session(text); + begin_edit_at(&mut s, t, caret); + (s, t) +} + +/// A session with one text layer already open in the editor with everything selected. +fn editing_all(text: &str) -> (Session, u64) { + let (mut s, t) = session(text); + begin_edit(&mut s, t); + (s, t) +} + +/// The IME owner token of the open editor. +fn ime_owner(s: &Session) -> u64 { + s.state.text_edit.as_ref().unwrap().session +} + +fn undo_steps(s: &Session) -> usize { + s.history.undo.len() +} + +/// The editor kept its document (same IME owner). +fn assert_ime_owner(s: &Session, expected: u64, why: &str) { + assert_eq!(ime_owner(s), expected, "{why}"); +} + +/// The editor's document was replaced (new IME owner/epoch). +fn assert_ime_epoch_changed(s: &Session, previous: u64, why: &str) { + assert_ne!(ime_owner(s), previous, "{why}"); +} + +fn assert_text(s: &Session, t: u64, expected: &str) { + assert_eq!(doc(s, t).text, expected); +} + +/// A new, empty composition called "Other" (the standard second comp). +fn new_other_comp(s: &mut Session) -> u64 { + s.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap()["comp"].as_u64().unwrap() +} + +/// Stopwatch the layer's Source Text (one key at the CTI). +fn animate_source(s: &mut Session, t: u64) { + s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); +} + +/// Add a Source Text key at `time`. +fn add_source_key(s: &mut Session, t: u64, time: f64, value: &str) { + s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap(); +} + +/// Write Source Text as a static value (a replacement when it differs from the shown text). +fn set_source(s: &mut Session, t: u64, value: &str) { + s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": value})).unwrap(); +} + +/// Write the Source Text key at `time`. +fn set_source_key(s: &mut Session, t: u64, time: f64, value: &str) { + s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap(); +} + +/// Set the Source Text key at the CTI (`keys.set`). +fn set_source_cti_key(s: &mut Session, t: u64, time: f64, value: &str) { + s.execute("keys.set", json!({"layer": t, "path": "text/sourceText", "time": time, "value": value})).unwrap(); +} + +/// Replace the whole Source Text of the layer (`layer.setText`). +fn replace_text(s: &mut Session, t: u64, text: &str) { + s.execute("layer.setText", json!({"layer": t, "text": text})).unwrap(); +} + +/// Select the character range `start..end` in the open editor. +fn select(s: &mut Session, start: usize, end: usize) { + s.execute("text.setSelection", json!({"start": start, "end": end})).unwrap(); +} + +/// Set both selection ends, keeping their direction (`anchor` is the other end). +fn select_anchor(s: &mut Session, anchor: usize, caret: usize) { + s.execute("text.setSelection", json!({"anchor": anchor, "caret": caret})).unwrap(); +} + #[test] fn set_text_with_a_range_creates_runs_and_undoes() { let (mut s, t) = session("Hello World"); @@ -209,30 +301,29 @@ fn edit_state_round_trips_through_serde() { #[test] fn ime_session_epoch_tracks_replaced_text_and_layer_restoration() { - let (mut s, t) = session("abc"); - s.execute("text.edit", json!({"layer": t})).unwrap(); - let first = s.state.text_edit.as_ref().unwrap().session; + let (mut s, t) = editing_all("abc"); + let first = ime_owner(&s); // Replacing Source Text is a new document even though the layer id stays. - s.execute("layer.setText", json!({"layer": t, "text": "replaced"})).unwrap(); - let replaced = s.state.text_edit.as_ref().unwrap().session; + replace_text(&mut s, t, "replaced"); + let replaced = ime_owner(&s); assert_ne!(replaced, first, "layer.setText starts a new IME epoch"); // Undo restores another document: it must not revive the token from before either edit. s.execute("edit.undo", json!({})).unwrap(); - let undone = s.state.text_edit.as_ref().unwrap().session; + let undone = ime_owner(&s); assert_ne!(undone, replaced); assert_ne!(undone, first); // Clear + immediate undo in the same command sequence: the layer is gone long enough to // end the session, and the restored layer cannot inherit a composition. - s.execute("text.edit", json!({"layer": t})).unwrap(); + begin_edit(&mut s, t); s.execute("edit.clear", json!({"layers": [t]})).unwrap(); assert!(s.state.text_edit.is_none(), "clearing the edited layer ends the session"); s.execute("edit.undo", json!({})).unwrap(); assert!(s.state.text_edit.is_none(), "undo does not recreate the old composition session"); - s.execute("text.edit", json!({"layer": t})).unwrap(); - assert_ne!(s.state.text_edit.as_ref().unwrap().session, first); + begin_edit(&mut s, t); + assert_ne!(ime_owner(&s), first); } #[test] @@ -250,8 +341,8 @@ fn no_op_ime_deletion_keeps_a_static_plain_text_value_and_records_no_step() { .prop_mut("text/sourceText") .unwrap(); pr.value = KV::Str("abc".into()); - s.execute("text.edit", json!({"layer": t, "caret": 0})).unwrap(); - let steps = s.history.undo.len(); + begin_edit_at(&mut s, t, 0); + let steps = undo_steps(&s); // A non-zero before count at the document start deletes nothing. s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap(); @@ -259,98 +350,94 @@ fn no_op_ime_deletion_keeps_a_static_plain_text_value_and_records_no_step() { let pr = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().props.prop("text/sourceText").unwrap(); assert!(matches!(&pr.value, KV::Str(_)), "a no-op keeps the static Str variant"); assert!(pr.keys.is_empty()); - assert_eq!(s.history.undo.len(), steps, "a no-op adds no undo step"); + assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step"); } #[test] fn no_op_ime_deletion_does_not_add_a_keyframe_to_animated_source_text() { - let (mut s, t) = session("abc"); - s.execute("text.edit", json!({"layer": t, "caret": 0})).unwrap(); - s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); + let (mut s, t) = editing("abc", 0); + animate_source(&mut s, t); let lid = effectcraft_project::LayerId(t); let keys = |s: &Session| s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap().keys.len(); assert_eq!(keys(&s), 1, "one key from the stopwatch"); s.execute("time.set", json!({"time": 1.0})).unwrap(); - let steps = s.history.undo.len(); + let steps = undo_steps(&s); // A non-zero before count at the document start deletes nothing, at a time with no key. s.execute("text.deleteSurrounding", json!({"before": 1, "after": 0})).unwrap(); assert_eq!(keys(&s), 1, "a no-op does not key Source Text at the CTI"); - assert_eq!(s.history.undo.len(), steps, "a no-op adds no undo step"); + assert_eq!(undo_steps(&s), steps, "a no-op adds no undo step"); } #[test] fn property_writes_replace_the_edited_document_only_when_the_text_changes() { let (mut s, t) = session("abc"); let other = s.execute("layer.newText", json!({"text": "other"})).unwrap()["layer"].as_u64().unwrap(); - s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); - let owner = s.state.text_edit.as_ref().unwrap().session; - let steps = s.history.undo.len(); + begin_edit_at(&mut s, t, 1); + let owner = ime_owner(&s); + let steps = undo_steps(&s); // Writing the text the document already has is a no-op: same document, same owner, no step. - s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": "abc"})).unwrap(); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "a no-op write keeps the IME owner"); - assert_eq!(s.history.undo.len(), steps, "a no-op write records no undo step"); - s.execute("layer.setText", json!({"layer": t, "text": "abc"})).unwrap(); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "a no-op layer.setText keeps the IME owner"); - assert_eq!(s.history.undo.len(), steps, "a no-op layer.setText records no undo step"); + set_source(&mut s, t, "abc"); + assert_ime_owner(&s, owner, "a no-op write keeps the IME owner"); + assert_eq!(undo_steps(&s), steps, "a no-op write records no undo step"); + replace_text(&mut s, t, "abc"); + assert_ime_owner(&s, owner, "a no-op layer.setText keeps the IME owner"); + assert_eq!(undo_steps(&s), steps, "a no-op layer.setText records no undo step"); // Another property of the edited layer leaves its document alone. s.execute("prop.set", json!({"layer": t, "path": "transform/opacity", "value": 50})).unwrap(); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "an unrelated property keeps the IME owner"); + assert_ime_owner(&s, owner, "an unrelated property keeps the IME owner"); // Source Text of another layer is not the editor's document either. - s.execute("prop.set", json!({"layer": other, "path": "text/sourceText", "value": "changed"})).unwrap(); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "another layer's text keeps the IME owner"); + set_source(&mut s, other, "changed"); + assert_ime_owner(&s, owner, "another layer's text keeps the IME owner"); // Replacing the edited layer's Source Text is a document replacement: a new IME epoch. - s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": "replaced"})).unwrap(); - assert_eq!(doc(&s, t).text, "replaced"); - assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "replacing the document changes the IME owner"); + set_source(&mut s, t, "replaced"); + assert_text(&s, t, "replaced"); + assert_ime_epoch_changed(&s, owner, "replacing the document changes the IME owner"); } #[test] fn a_replacing_property_write_clamps_the_selection_so_the_next_backspace_deletes() { - let (mut s, t) = session("abcdef"); - s.execute("text.edit", json!({"layer": t, "caret": 6})).unwrap(); - let owner = s.state.text_edit.as_ref().unwrap().session; + let (mut s, t) = editing("abcdef", 6); + let owner = ime_owner(&s); - s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": "a"})).unwrap(); - assert_eq!(doc(&s, t).text, "a"); + set_source(&mut s, t, "a"); + assert_text(&s, t, "a"); assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range"); - assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner); + assert_ne!(ime_owner(&s), owner); // Backspace at the clamped caret removes the character instead of no-opping on 5..6. s.execute("text.delete", json!({})).unwrap(); - assert_eq!(doc(&s, t).text, ""); + assert_text(&s, t, ""); assert_eq!(sel(&s), (0, 0)); } #[test] fn keyframe_writes_on_source_text_replace_the_edited_document_only_when_the_cti_value_changes() { - let (mut s, t) = session("abc"); - s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); + let (mut s, t) = editing("abc", 1); // Keying the text the CTI already shows leaves the document equal. - s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); - let owner = s.state.text_edit.as_ref().unwrap().session; + animate_source(&mut s, t); + let owner = ime_owner(&s); - s.execute("keys.set", json!({"layer": t, "path": "text/sourceText", "time": 0.0, "value": "abc"})).unwrap(); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "a no-op key write keeps the IME owner"); + set_source_cti_key(&mut s, t, 0.0, "abc"); + assert_ime_owner(&s, owner, "a no-op key write keeps the IME owner"); // `keys.set` changes the key at the CTI: a document replacement. - s.execute("keys.set", json!({"layer": t, "path": "text/sourceText", "time": 0.0, "value": "replaced"})).unwrap(); - assert_eq!(doc(&s, t).text, "replaced"); - assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a changed keyframe changes the IME owner"); + set_source_cti_key(&mut s, t, 0.0, "replaced"); + assert_text(&s, t, "replaced"); + assert_ime_epoch_changed(&s, owner, "a changed keyframe changes the IME owner"); } #[test] fn deleting_the_cti_keyframe_first_replaces_the_document_then_the_last_key_is_a_noop() { - let (mut s, t) = session("abc"); - s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); - s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); - s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": 1.0, "value": "later"})).unwrap(); - let owner = s.state.text_edit.as_ref().unwrap().session; + let (mut s, t) = editing("abc", 1); + animate_source(&mut s, t); + add_source_key(&mut s, t, 1.0, "later"); + let owner = ime_owner(&s); let lid = effectcraft_project::LayerId(t); let pr = s.active_comp().unwrap().layer(lid).unwrap().props.prop("text/sourceText").unwrap(); let uid = pr.uid; @@ -361,143 +448,167 @@ fn deleting_the_cti_keyframe_first_replaces_the_document_then_the_last_key_is_a_ // the document starts a new epoch. s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[0] }]; assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1)); - assert_eq!(doc(&s, t).text, "later"); - let replaced = s.state.text_edit.as_ref().unwrap().session; + assert_text(&s, t, "later"); + let replaced = ime_owner(&s); assert_ne!(replaced, owner, "deleting the CTI key changed the document"); // Removing the last key turns its value into the static CTI value. That value is what the // CTI already showed, so the document does not change and the new owner stays. s.state.selected_keys = vec![crate::KeyRef { layer: lid, prop: uid, time: times[1] }]; assert_eq!(s.execute("keys.delete", json!({})).unwrap(), json!(1)); - assert_eq!(doc(&s, t).text, "later"); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, replaced, "removing the last key is a document no-op"); + assert_text(&s, t, "later"); + assert_ime_owner(&s, replaced, "removing the last key is a document no-op"); } #[test] fn moving_the_cti_to_a_shorter_animated_key_clamps_and_starts_a_new_epoch() { let (mut s, t) = session("abcdef"); - s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); - s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": 1.0, "value": "a"})).unwrap(); - s.execute("text.edit", json!({"layer": t, "caret": 6})).unwrap(); - let owner = s.state.text_edit.as_ref().unwrap().session; + animate_source(&mut s, t); + add_source_key(&mut s, t, 1.0, "a"); + begin_edit_at(&mut s, t, 6); + let owner = ime_owner(&s); // The same time still shows the same document: no new epoch. s.execute("time.set", json!({"time": 0.0})).unwrap(); - assert_eq!(doc(&s, t).text, "abcdef"); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "an unchanged CTI document keeps the IME owner"); + assert_text(&s, t, "abcdef"); + assert_ime_owner(&s, owner, "an unchanged CTI document keeps the IME owner"); // The key at 1s holds shorter text: the document changes, the selection is clamped and the // editor gets a new epoch. s.execute("time.set", json!({"time": 1.0})).unwrap(); - assert_eq!(doc(&s, t).text, "a"); + assert_text(&s, t, "a"); assert_eq!(sel(&s), (1, 1), "the shortened document leaves the caret in range"); - let replaced = s.state.text_edit.as_ref().unwrap().session; + let replaced = ime_owner(&s); assert_ne!(replaced, owner, "a changed CTI document starts a new IME epoch"); // Backspace at the clamped caret removes the character instead of no-opping on 5..6. s.execute("text.delete", json!({})).unwrap(); - assert_eq!(doc(&s, t).text, ""); + assert_text(&s, t, ""); assert_eq!(sel(&s), (0, 0)); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, replaced, "typing after the replacement keeps its owner"); + assert_ime_owner(&s, replaced, "typing after the replacement keeps its owner"); // Staying at the same time after the edit is a no-op for the document too. s.execute("time.set", json!({"time": 1.0})).unwrap(); - assert_eq!(doc(&s, t).text, ""); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, replaced, "a no-op time move keeps the IME owner"); + assert_text(&s, t, ""); + assert_ime_owner(&s, replaced, "a no-op time move keeps the IME owner"); } #[test] fn sync_related_times_replaces_the_edited_document_when_it_moves_the_active_comp() { let (mut s, t) = session("abcdef"); - s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); - s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": 1.0, "value": "a"})).unwrap(); + animate_source(&mut s, t); + add_source_key(&mut s, t, 1.0, "a"); let outer = s.active_comp_id().unwrap(); let r = s.execute("layer.precompose", json!({"layers": [t], "name": "Inner", "open": true})).unwrap(); let inner = effectcraft_project::ItemId(r["comp"].as_u64().unwrap()); assert_eq!(s.active_comp_id(), Some(inner)); - s.execute("text.edit", json!({"layer": t, "caret": 6})).unwrap(); - let owner = s.state.text_edit.as_ref().unwrap().session; + begin_edit_at(&mut s, t, 6); + let owner = ime_owner(&s); s.sync_related_times(outer, effectcraft_time::Tick::from_seconds_f64(1.0)); - assert_eq!(doc(&s, t).text, "a"); + assert_text(&s, t, "a"); assert_eq!(sel(&s), (1, 1), "the related time move clamps the selection"); - assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "the related time move changed the document"); + assert_ime_epoch_changed(&s, owner, "the related time move changed the document"); } #[test] fn applying_a_preset_that_writes_source_text_replaces_the_edited_document() { - let (mut s, t) = session("abc"); - s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); - let owner = s.state.text_edit.as_ref().unwrap().session; + let (mut s, t) = editing("abc", 1); + let owner = ime_owner(&s); let preset = json!({ "effectcraftAnimationPreset": 1, "properties": [{"path": "text/sourceText", "value": {"t": "Text", "v": {"text": "replaced"}}, "keys": []}], }); s.execute("anim.applyPreset", json!({"preset": preset, "layers": [t]})).unwrap(); - assert_eq!(doc(&s, t).text, "replaced"); - assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a preset that replaces source text changes the IME owner"); + assert_text(&s, t, "replaced"); + assert_ime_epoch_changed(&s, owner, "a preset that replaces source text changes the IME owner"); } #[test] fn a_failed_property_write_keeps_the_edited_document_and_its_owner() { - let (mut s, t) = session("abc"); - s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); - let owner = s.state.text_edit.as_ref().unwrap().session; - let steps = s.history.undo.len(); + let (mut s, t) = editing("abc", 1); + let owner = ime_owner(&s); + let steps = undo_steps(&s); // null cannot coerce to a Text value: the edit fails before anything is committed. assert!(s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": null})).is_err()); - assert_eq!(doc(&s, t).text, "abc"); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "a failed write keeps the IME owner"); - assert_eq!(s.history.undo.len(), steps, "a failed write records no undo step"); + assert_text(&s, t, "abc"); + assert_ime_owner(&s, owner, "a failed write keeps the IME owner"); + assert_eq!(undo_steps(&s), steps, "a failed write records no undo step"); } #[test] fn property_writes_off_the_cti_keep_the_owner_until_they_change_the_cti_document() { - let (mut s, t) = session("abc"); - s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); - s.execute("prop.toggleAnimation", json!({"layer": t, "path": "text/sourceText"})).unwrap(); - s.execute("prop.addKey", json!({"layer": t, "path": "text/sourceText", "time": 1.0, "value": "later"})).unwrap(); - let owner = s.state.text_edit.as_ref().unwrap().session; + let (mut s, t) = editing("abc", 1); + animate_source(&mut s, t); + add_source_key(&mut s, t, 1.0, "later"); + let owner = ime_owner(&s); // Writing a key at 1s leaves the CTI (0s) value as it was. - s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "time": 1.0, "value": "changed"})).unwrap(); - assert_eq!(doc(&s, t).text, "abc"); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "an off-CTI write keeps the IME owner"); + set_source_key(&mut s, t, 1.0, "changed"); + assert_text(&s, t, "abc"); + assert_ime_owner(&s, owner, "an off-CTI write keeps the IME owner"); // Writing the CTI value itself replaces the document. - s.execute("prop.set", json!({"layer": t, "path": "text/sourceText", "value": "cti"})).unwrap(); - assert_eq!(doc(&s, t).text, "cti"); - assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "a CTI write changes the IME owner"); + set_source(&mut s, t, "cti"); + assert_text(&s, t, "cti"); + assert_ime_epoch_changed(&s, owner, "a CTI write changes the IME owner"); } #[test] fn unrelated_project_cleanup_keeps_the_ime_owner_but_replacement_changes_it() { - let (mut s, t) = session("abc"); - s.execute("text.edit", json!({"layer": t, "caret": 1})).unwrap(); - let owner = s.state.text_edit.as_ref().unwrap().session; + let (mut s, t) = editing("abc", 1); + let owner = ime_owner(&s); // file.removeUnusedFootage calls sanitize_state while the edited document is untouched. s.execute("file.importSolid", json!({"name": "Unused"})).unwrap(); let removed = s.execute("file.removeUnusedFootage", json!({})).unwrap(); assert_eq!(removed["removed"], json!(1), "the unused solid was actually removed"); - assert_eq!(s.state.text_edit.as_ref().unwrap().session, owner, "unrelated cleanup keeps the IME owner"); + assert_ime_owner(&s, owner, "unrelated cleanup keeps the IME owner"); // Replacing the Source Text is a document replacement: a new owner. - s.execute("layer.setText", json!({"layer": t, "text": "replaced"})).unwrap(); - assert_ne!(s.state.text_edit.as_ref().unwrap().session, owner, "replacing the document changes the owner"); + replace_text(&mut s, t, "replaced"); + assert_ime_epoch_changed(&s, owner, "replacing the document changes the owner"); } #[test] fn ime_session_does_not_cross_a_composition_switch() { - let (mut s, t) = session("abc"); - s.execute("text.edit", json!({"layer": t})).unwrap(); - let other = s.execute("comp.new", json!({"name": "Other", "width": 320, "height": 180, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let (mut s, _) = editing_all("abc"); + let other = new_other_comp(&mut s); s.execute("comp.open", json!({"comp": other})).unwrap(); assert!(s.state.text_edit.is_none(), "the old comp's editor is not reused in another comp"); } +#[test] +fn removing_the_active_comp_does_not_retarget_its_text_editor() { + for replacement in [false, true] { + let (mut s, t) = session("original"); + let original = s.active_comp_id().unwrap(); + let layer = s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().clone(); + let other = new_other_comp(&mut s); + let other_id = effectcraft_project::ItemId(other); + std::sync::Arc::make_mut(&mut s.project).comp_mut(other_id).unwrap().layers.push(layer); + s.execute("comp.open", json!({"comp": other})).unwrap(); + s.execute("comp.open", json!({"comp": original.0})).unwrap(); + begin_edit_at(&mut s, t, 1); + assert!(s.state.text_edit.is_some()); + + // Reproduce cleanup after a project replacement, bypassing Session::edit's earlier + // document guard so this specifically exercises sanitize_state's fallback-comp branch. + std::sync::Arc::make_mut(&mut s.project).items.remove(&original); + if replacement { + s.sanitize_after_replacement(); + } else { + s.sanitize_state(); + } + assert_eq!(s.active_comp_id(), Some(other_id)); + assert!(s.state.text_edit.is_none(), "the same-numbered layer belongs to a different comp"); + assert!(s.execute("text.insert", json!({"text": "wrong document"})).is_err()); + assert_eq!(doc(&s, t).text, "original", "the fallback comp was not edited"); + } +} + #[test] fn opentype_features_per_range_render_and_query() { let (mut s, t) = session("Office 1/2"); @@ -555,26 +666,32 @@ fn fonts_lists_bundled_and_installed_families() { assert_eq!(crate::font_styles("No Such Family").len(), 5); } -/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at -/// 400 (so the helpers that look for an installed font with a real axis range never pick it). -fn pinned_variable_font(family: &str) -> Vec { - let u16s = |v: &[u16]| -> Vec { v.iter().flat_map(|x| x.to_be_bytes()).collect() }; - let strings: Vec> = [family, "Regular"].iter().map(|s| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect(); - let mut name = u16s(&[0, 2, 30]); +/// A big-endian `u16` array, as sfnt tables store them. +fn u16s(v: &[u16]) -> Vec { + v.iter().flat_map(|x| x.to_be_bytes()).collect() +} + +/// A `name` table with one Windows/Unicode English record per `(id, text)`. +fn name_table(strings: &[(u16, String)]) -> Vec { + let enc: Vec<(u16, Vec)> = strings.iter().map(|(id, s)| (*id, s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect())).collect(); + let mut name = u16s(&[0, enc.len() as u16, 6 + 12 * enc.len() as u16]); let mut off = 0; - for (id, st) in strings.iter().enumerate() { - name.extend(u16s(&[3, 1, 0x409, id as u16 + 1, st.len() as u16, off])); + for (id, st) in &enc { + name.extend(u16s(&[3, 1, 0x409, *id, st.len() as u16, off])); off += st.len() as u16; } - strings.iter().for_each(|st| name.extend(st)); - let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]); - fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256]))); - let mut out = u16s(&[1, 0, 2, 0, 0, 0]); + enc.iter().for_each(|(_, st)| name.extend(st)); + name +} + +/// A single-face font with `tables` in order (zero checksums, offsets after the directory). +fn sfnt(tables: &[(&[u8; 4], Vec)]) -> Vec { + let mut out = u16s(&[1, 0, tables.len() as u16, 0, 0, 0]); let mut body: Vec = vec![]; - for (tag, data) in [(b"fvar", fvar), (b"name", name)] { - out.extend(tag); + for (tag, data) in tables { + out.extend(*tag); out.extend([0u8; 4]); - out.extend(((12 + 32 + body.len()) as u32).to_be_bytes()); + out.extend(((12 + 16 * tables.len() + body.len()) as u32).to_be_bytes()); out.extend((data.len() as u32).to_be_bytes()); body.extend(data); } @@ -582,6 +699,15 @@ fn pinned_variable_font(family: &str) -> Vec { out } +/// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at +/// 400 (so the helpers that look for an installed font with a real axis range never pick it). +fn pinned_variable_font(family: &str) -> Vec { + let mut fvar = u16s(&[1, 0, 16, 2, 1, 20, 0, 8]); + fvar.extend(b"wght".iter().copied().chain(u16s(&[0x190, 0, 0x190, 0, 0x190, 0, 0, 256]))); + let name = name_table(&[(1, family.to_owned()), (2, "Regular".to_owned())]); + sfnt(&[(b"fvar", fvar), (b"name", name)]) +} + /// The font menu's Variable Fonts filter (After Effects 26.3): `text.fonts {variable: true}` /// lists only families with variation axes, every listing says which families are variable, and /// the font menu's rows carry the flag the filter uses. @@ -616,7 +742,6 @@ fn fonts_filter_variable_families() { /// `Light`) and the named `instances` (style, `wght`), each with a PostScript name /// `-