From 47bf08a15e9ff676a62280943965986d31a195e6 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 19:56:09 +0100 Subject: [PATCH 1/5] M-egui037: document migration behavior and guard atomic batch rollback --- apps/effectcraft-web/src/app.rs | 5 ++ apps/effectcraft/src/main.rs | 5 ++ crates/engine/src/autosave.rs | 2 +- crates/engine/src/camera_track.rs | 2 +- crates/engine/src/commands/app_more.rs | 5 +- crates/engine/src/commands/batch.rs | 37 ++++++++++ crates/engine/src/commands/edit.rs | 10 +++ crates/engine/src/commands/layer.rs | 18 +++++ crates/engine/src/commands/scripts.rs | 4 +- crates/engine/src/commands/text_edit.rs | 10 +++ crates/engine/src/doc_path.rs | 2 +- crates/engine/src/history.rs | 3 + crates/engine/src/lib.rs | 27 ++++++++ crates/engine/src/remote.rs | 4 +- crates/engine/src/roto.rs | 2 +- crates/engine/src/sequence.rs | 4 +- crates/engine/src/vector.rs | 2 +- crates/engine/src/warp.rs | 2 +- crates/ui-egui/src/color_picker.rs | 8 +++ crates/ui-egui/src/control.rs | 68 ++++++++++++++++++- crates/ui-egui/src/dock.rs | 2 + crates/ui-egui/src/dock_ui.rs | 6 ++ crates/ui-egui/src/header.rs | 4 ++ crates/ui-egui/src/i18n/effects.rs | 2 +- crates/ui-egui/src/i18n/ui.rs | 2 +- crates/ui-egui/src/lib.rs | 25 ++++++- crates/ui-egui/src/menu_keys.rs | 9 +++ crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/panels/anim_tools.rs | 6 ++ crates/ui-egui/src/panels/comp_settings.rs | 2 + crates/ui-egui/src/panels/dialogs.rs | 4 ++ crates/ui-egui/src/panels/effect_controls.rs | 11 +++ crates/ui-egui/src/panels/effects_presets.rs | 7 ++ crates/ui-egui/src/panels/essential.rs | 5 ++ crates/ui-egui/src/panels/expr_bar.rs | 7 ++ crates/ui-egui/src/panels/footage_panel.rs | 5 ++ crates/ui-egui/src/panels/forms.rs | 2 +- crates/ui-egui/src/panels/fx_editors.rs | 25 +++++++ crates/ui-egui/src/panels/gradient_editor.rs | 6 ++ crates/ui-egui/src/panels/graph.rs | 13 ++++ crates/ui-egui/src/panels/graph_tools.rs | 2 + crates/ui-egui/src/panels/info.rs | 5 ++ crates/ui-egui/src/panels/layer_panel.rs | 11 +++ .../ui-egui/src/panels/layer_styles_dialog.rs | 2 +- crates/ui-egui/src/panels/markers_ui.rs | 2 + crates/ui-egui/src/panels/media_panels.rs | 6 ++ crates/ui-egui/src/panels/misc.rs | 7 ++ crates/ui-egui/src/panels/paint_panels.rs | 11 +++ crates/ui-egui/src/panels/project.rs | 7 ++ crates/ui-egui/src/panels/properties.rs | 6 ++ crates/ui-egui/src/panels/render_queue.rs | 5 ++ crates/ui-egui/src/panels/rq_templates.rs | 2 + crates/ui-egui/src/panels/scopes_panel.rs | 4 ++ crates/ui-egui/src/panels/script_console.rs | 3 + crates/ui-egui/src/panels/scriptui_view.rs | 10 ++- crates/ui-egui/src/panels/shortcut_editor.rs | 6 ++ crates/ui-egui/src/panels/text_panels.rs | 13 ++++ crates/ui-egui/src/panels/timeline.rs | 29 ++++++++ crates/ui-egui/src/panels/tracker.rs | 7 ++ crates/ui-egui/src/panels/viewer.rs | 14 ++++ crates/ui-egui/src/panels/viewer_overlays.rs | 1 + crates/ui-egui/src/panels/viewer_text.rs | 55 +++++++++++++++ crates/ui-egui/src/panels/viewer_tools.rs | 10 ++- crates/ui-egui/src/pointer_wrap.rs | 1 + crates/ui-egui/src/theme.rs | 4 +- crates/ui-egui/src/widgets.rs | 7 ++ 66 files changed, 574 insertions(+), 23 deletions(-) diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index f09bf28a1..169529e5d 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -86,6 +86,8 @@ pub struct WebApp { } impl eframe::App for WebApp { + /// Take dropped files out of `raw_input` before eframe can replay them on a hidden tab, read them asynchronously + /// through `open_or_import`, then run the shared app's input hook. fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { // Files dropped on the page are input: read them here, where the browser's drop event is // still the current pass's, not from a later (possibly logic-only) tick. @@ -101,6 +103,9 @@ impl eframe::App for WebApp { self.app.raw_input_hook(ctx, raw_input); } + /// The browser host's logic tick: run queued UI actions, tick the shared app, poll API replies, flush Render + /// Queue downloads, persist the session snapshot when enabled, publish GPU/frame-worker/disk-cache info and close + /// the IME pass. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut())); for f in actions { diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index a95e79a49..ead52c099 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -138,6 +138,10 @@ fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) { let _ = (app, ctx); } +/// Run the desktop app: parse arguments (`--version` exits before any file is written), install logging and the panic +/// hook, attach the session log, scan fonts, honor the launch guard's backend choice and open the eframe window. +/// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then +/// reports startup failure through `startup_failed`. Returns the event loop's result. fn main() -> eframe::Result { // First, so the panic hook and every start-up warning are recorded. Records go to stderr // and the session log; Help ▸ Enable Logging writes through this logger too, and warnings @@ -432,6 +436,7 @@ struct Desktop { } impl eframe::App for Desktop { + /// The desktop host's logic tick: update the macOS menu bar, run the shared app tick and close the IME pass. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] self.menu.update(&mut self.app, ctx); diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 37127ac56..e70e819fd 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -143,7 +143,7 @@ pub fn write(prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last write_in(&StdFiles, prefs, project, fallback, last_slot, json) } -/// [`write`] through `fs`. +/// [`write()`] through `fs`. pub fn write_in( fs: &dyn FileOps, prefs: &Prefs, diff --git a/crates/engine/src/camera_track.rs b/crates/engine/src/camera_track.rs index adc5aee69..29961f533 100644 --- a/crates/engine/src/camera_track.rs +++ b/crates/engine/src/camera_track.rs @@ -7,7 +7,7 @@ //! camera"). When only the solve settings changed (Shot Type, Angle of View, Solve Method, //! deleted points) the stored tracks are re-solved without re-tracking. Results are written into //! the effect's hidden parameters as one undo step; edits that change the layer's frames clear -//! them and queue the effect again ([`invalidate`], run after every edit), like After Effects. +//! them and queue the effect again (`invalidate`, run after every edit), like After Effects. //! //! Composition space: the solve's canonical frame maps to comp world by //! [`CameraSolve::world`] with the zoom of the first solved frame (focal length × the layer's diff --git a/crates/engine/src/commands/app_more.rs b/crates/engine/src/commands/app_more.rs index b95dd4375..f607c8288 100644 --- a/crates/engine/src/commands/app_more.rs +++ b/crates/engine/src/commands/app_more.rs @@ -354,7 +354,7 @@ fn assign_view_shortcut(s: &mut Session, p: &Value) -> Result { reassign(s, view_command(cur), &slot, "view.assign3dShortcut") } -/// Window ▸ Assign Shortcut to "" Workspace: Shift+F10, Shift+F11 or Shift+F12. +/// Window ▸ Assign Shortcut to `""` Workspace: Shift+F10, Shift+F11 or Shift+F12. /// The frontend fills in the current workspace. fn assign_workspace_shortcut(s: &mut Session, p: &Value) -> Result { let slot = slot_p(p, "window.assignWorkspaceShortcut", true)?; @@ -668,6 +668,9 @@ fn extract_cubemap(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// The specs for the commands in this module: history steps, recent footage and presets, Save +/// a Copy As XML, footage replacement and Execute File, 3D-view and workspace shortcuts, +/// logging and the system report, VR environment/cubemap creation and System Information. pub fn specs() -> Vec { vec![ cmd!("edit.history", "History", [], None, "{steps?: n (undo n steps), redo?: n}", has_history, history), diff --git a/crates/engine/src/commands/batch.rs b/crates/engine/src/commands/batch.rs index e1fc37140..f6e6901cc 100644 --- a/crates/engine/src/commands/batch.rs +++ b/crates/engine/src/commands/batch.rs @@ -51,6 +51,17 @@ fn substitute(v: &Value, results: &[Value]) -> Result { }) } +/// `engine.batch`: runs `steps` (`[{command|id, params?}]`) as engine commands, substituting +/// `$N` / `$N.key.0` strings in each step's params with earlier results, and returns +/// `{steps, results}`. +/// +/// `engine.batch`, `file.runScript` and `script.run` are refused inside a batch. Successful +/// steps are folded into one undo step named `label` (default "Batch"). With the default +/// `atomic: true`, a failing step restores the project, history and editor state, drops the +/// events the batch published, sanitizes the editor against the failing intermediate state (so +/// no pre-batch IME token is revived) and keeps the higher process IME revision; with +/// `atomic: false` the steps before the failure stay applied. Either way the error names the +/// failing step and command. fn batch(s: &mut Session, p: &Value) -> Result { let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("engine.batch", "missing `steps`: [{command, params?}, …]"))?.clone(); let label = str_p(p, "label").unwrap_or("Batch").to_string(); @@ -138,6 +149,32 @@ mod tests { use crate::Session; + /// Failed atomic steps discard their UI events while preserving events queued beforehand. + #[test] + fn atomic_batch_event_rollback_preserves_prior_events() { + for atomic in [true, false] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "First", "width": 100, "height": 100})).unwrap(); + let first = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "Second", "width": 100, "height": 100})).unwrap(); + let second = s.active_comp_id().unwrap(); + s.drain_events(); + s.execute("comp.open", json!({"comp": first.0})).unwrap(); + let result = s.execute_checked( + "engine.batch", + json!({"atomic": atomic, "steps": [ + {"command": "comp.open", "params": {"comp": second.0}}, + {"command": "no.suchCommand", "params": {}} + ]}), + ); + assert!(result.is_err()); + assert_eq!(s.active_comp_id(), Some(if atomic { first } else { second })); + let opened: Vec<_> = + s.drain_events().into_iter().filter_map(|event| if let crate::Event::OpenComp(id) = event { Some(id) } else { None }).collect(); + assert_eq!(opened, if atomic { vec![first] } else { vec![first, second] }); + } + } + #[test] fn batch_refs_one_undo_step_and_rollback() { let mut s = Session::default(); diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 05015e6b4..a7de48652 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -149,6 +149,16 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { Ok(json!(new.iter().map(|l| l.0).collect::>())) } +/// `edit.clear`: delegates to `text.delete` while editing text (deleting the selection or +/// preceding character/word), then handles selected Time Remap (turning remapping off), keyframes, loose mask +/// vertices, puppet pins, effects, masks/shape items/text animators/trackers, and finally the +/// selected (or given) unlocked layers. Layer and selected-property-group paths return the number +/// removed; text, Time Remap, keyframe, loose-vertex, puppet-pin and effect paths return their +/// delegated command's result. +/// +/// Layer removal also unparents surviving children in place, clears track mattes that pointed +/// at a removed layer and rewrites layer-reference values and keys to `None` in the same undo +/// step; the text-edit session is then sanitized so a removed layer's editor is not reused. fn delete(s: &mut Session, p: &Value) -> Result { if s.state.text_edit.is_some() && p.get("layers").is_none() { return s.execute("text.delete", json!({})); diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 8ff43f32e..3b76100f4 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -162,6 +162,15 @@ fn text_range_p(p: &Value) -> Option> { Some(x.min(y)..x.max(y)) } +/// `layer.newText`: adds a Text layer to the comp at the CTI and returns `{layer}`. +/// +/// The document starts as centre-justified "Text", with the optional text parameters applied. +/// With `box` `[x, y, w, h]`, `(x, y)` is the paragraph box's top-left in comp space. Its width +/// and height are `w.abs().max(1.0)` and `h.abs().max(1.0)`; the layer position is the box centre. +/// It is left justified unless `justify` is given. The layer is named by `name`, else the first 40 +/// characters of the first text line, else "Text". `edit: true` also opens a new editing +/// session with the caret after the text and `created: true`, so leaving it empty removes the +/// layer. fn new_text(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); @@ -1454,6 +1463,15 @@ fn shape_item_target(proj: &mut Project, cid: ItemId, lid: LayerId, group_uid: O }) } +/// `layer.setText`: sets a text layer's Source Text and/or character attributes at the CTI. +/// +/// A `font` name is also added to the recent fonts and the preferences saved. With an empty +/// `range` and no `text`, while that layer is being edited and its document is non-empty, +/// recognised character attributes are also staged in the pending insertion style without +/// changing existing characters. Paragraph/document attributes still apply through the edit; +/// the session is bumped whenever this branch runs. Document changes use [`Session::edit`], +/// merged by `merge_p`. With `text` given, a layer whose name is empty or starts with `Text` +/// takes the new first line (up to 40 characters) when that line is non-empty. Replies `null`. fn set_text(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.setText")?; // The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display). diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 24049459c..d352e636c 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -234,7 +234,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result { if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } -/// Run an installed or bundled script by name (File ▸ Scripts ▸ ). A ScriptUI panel run +/// Run an installed or bundled script by name (File ▸ Scripts ▸ ``). A ScriptUI panel run /// this way gets `this` = a floating palette-like panel window. pub(crate) fn run_named(s: &mut Session, name: &str) -> Result { let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?; @@ -308,7 +308,7 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result { Ok(json!({"opened": opened, "failed": failed})) } -/// Window ▸ : run the panel script (or bring its panel forward). +/// Window ▸ ``: run the panel script (or bring its panel forward). fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string(); diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 9de40c588..0d9b25aec 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -14,6 +14,8 @@ use serde_json::{Value, json}; /// never reuses a live IME context). Exhaustion wraps; it cannot panic. static NEXT_EDIT_SESSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +/// Takes the next session token: returns the counter's current value and increments it, so +/// successive tokens differ until the process-wide `u64` counter wraps. pub(super) fn next_edit_session() -> u64 { NEXT_EDIT_SESSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) } @@ -27,10 +29,14 @@ pub(super) fn next_edit_session() -> u64 { /// receipt rather than claiming a mutation with a reused revision. static NEXT_IME_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +/// Allocates a fresh process-wide IME revision, or `None` once the counter has nowhere left +/// to advance; callers keep their existing stamp rather than reuse one. pub(crate) fn next_ime_revision() -> Option { allocate_ime_revision(&NEXT_IME_REVISION) } +/// Takes the next value of `counter` with a compare-exchange loop: `Some(current + 1)` after +/// storing it, or `None` without storing when the counter is already `u64::MAX`. fn allocate_ime_revision(counter: &std::sync::atomic::AtomicU64) -> Option { use std::sync::atomic::Ordering::Relaxed; let mut current = counter.load(Relaxed); @@ -85,6 +91,8 @@ pub struct TextEdit { } impl Default for TextEdit { + /// An empty, uncreated editing session on layer `0` with no selection, pending style or goal + /// column, and a fresh process-wide session token. fn default() -> Self { Self { layer: LayerId(0), anchor: 0, caret: 0, pending: None, created: false, goal_x: None, session: next_edit_session() } } @@ -909,6 +917,8 @@ fn editing(s: &Session) -> std::result::Result<(), String> { if s.state.text_edit.is_some() { Ok(()) } else { Err("not editing text".into()) } } +/// The command specs of this module: the `text.*` editing, selection and caret commands and +/// the two `edit.pasteText*` commands, each with its parameter shape and enablement predicate. pub fn specs() -> Vec { vec![ cmd!( diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs index 00d6c33f1..7161b6177 100644 --- a/crates/engine/src/doc_path.rs +++ b/crates/engine/src/doc_path.rs @@ -3,7 +3,7 @@ //! document's folder, and only local files, or files on the network share the document itself is //! on, are followed (see `craft_docpath`). //! -//! Loading a document resolves its footage paths once ([`resolve_footage`]): followed paths are +//! Loading a document resolves its footage paths once (`resolve_footage`): followed paths are //! replaced by the resolved path, and an item with a refused path is marked missing, so the media //! layer never reads it. The session then remembers which paths came from which document //! ([`StoredPaths`]); the footage check, the changed-on-disk scan, Reload Footage, Relink, Edit diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index d9577285b..6e3d928c5 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -314,6 +314,9 @@ impl Session { } // Every state off the new line goes into branches: a chain per first child, the other // children starting branches of their own. + /// Adds to `out` one [`Branch`] for the off-line subtree at `start`: it follows the first + /// child that is not on the new history line, and records every other such child as a further + /// branch from the state it forked at (`parent`). fn chain(a: &Arena, start: usize, parent: usize, in_new: &[bool], out: &mut Vec) { let mut steps = vec![]; let mut n = start; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 83024dd81..3c3cb868d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -564,6 +564,10 @@ pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value; pub type PluginLoader = fn(&[u8], &str) -> std::result::Result; impl Default for Session { + /// A new unsaved session: a default project that is both current and saved, no path, no + /// active jobs, the plain `FsServices`/`NoFootage` defaults, default preferences and keymaps, + /// empty history, events and journal, and a fresh process-wide IME stamp (`u64::MAX` when the + /// allocator is exhausted). fn default() -> Self { let project = Arc::new(Project::default()); Session { @@ -692,6 +696,8 @@ impl Session { self.script_calls.0 > 0 } + /// Whether `id` names a known command whose `enabled` predicate returns `Ok` for this session; + /// an unknown id or a predicate error reads as disabled. pub fn is_enabled(&self, id: &str) -> bool { commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok()) } @@ -715,6 +721,15 @@ impl Session { self.edit_inner(label, merge, f) } + /// The shared body of [`Session::edit`] and [`Session::edit_text_doc`]. + /// + /// Runs `f` on a clone of the project and editor state, then synchronizes derived data (global + /// light and Essential Graphics), queues invalidated Warp Stabilizer/camera-tracker work and + /// Roto Brush instances needing re-propagation in the `*_pending` lists. If the clone has the same + /// content the project and history are left as they were (only editor-state changes from `f` + /// are kept) and no revision is bumped; otherwise the previous project is recorded as an + /// undo step under `label`, unless it merges with the current merge key (then the redo branch + /// is dropped). An `Err` from `f` propagates with the session unchanged. 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(); @@ -845,6 +860,10 @@ impl Session { } } + /// Undoes one step: the current project goes onto the redo stack, per-comp Resolution is kept + /// as it was, the merge key is cleared, selections are sanitized for the replaced state (the + /// on-canvas editor gets a new IME epoch only when its own document changed) and the session is + /// bumped. Returns `false`, changing nothing, when there is nothing to undo. pub fn undo(&mut self) -> bool { let Some((label, p)) = self.history.undo.pop() else { return false }; let edited = commands::text_edit::active_editor_snapshot(self); @@ -857,6 +876,10 @@ impl Session { true } + /// Redoes one step: the current project goes back onto the undo stack, per-comp Resolution is + /// kept, the merge key is cleared, selections are sanitized for the replaced state (a changed + /// text document starts a new IME epoch) and the session is bumped. Returns `false`, changing + /// nothing, when there is nothing to redo. pub fn redo(&mut self) -> bool { let Some((label, p)) = self.history.redo.pop() else { return false }; let edited = commands::text_edit::active_editor_snapshot(self); @@ -996,6 +1019,10 @@ impl Session { commands::text_edit::after_external_change(self, edited.as_ref()); } + /// Sets the time of every comp related to `comp` through precomp layers: down into the comps + /// it nests (through each layer's timing) and up into the comps nesting it. A seen-set keeps + /// cycles and diamonds finite; each found time is clamped to its comp's last frame, snapped to + /// the nearest frame and stored in `state.times`. The caller owns the text-editor boundary. 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)]; diff --git a/crates/engine/src/remote.rs b/crates/engine/src/remote.rs index 7d592abe2..66417ccee 100644 --- a/crates/engine/src/remote.rs +++ b/crates/engine/src/remote.rs @@ -9,7 +9,7 @@ //! added, changed or removed; arrays element-wise when their length is unchanged, else //! replaced). Lossless (unlike JSON Merge Patch, `null` values survive). //! - [`Mirror`] (page side): what the worker holds; turns the next project revision into a -//! [`FrameMsg::Sync`] (a full project the first time, a diff afterwards). +//! [`FrameMsg::Project`] the first time and [`FrameMsg::Patch`] for subsequent diffs. //! - [`FrameServer`] (worker side): applies syncs, relayed Roto Brush segmentations and render //! requests, and renders frames as premultiplied RGBA8 ([`rgba8_premultiplied`]). //! @@ -301,7 +301,7 @@ impl FrameServer { } /// Identity of virtual footage files, used when native metadata cannot describe the path. - /// Called from [`FrameServer::load`], not from the UI thread's cache lookup. + /// Called from `FrameServer::load`, not from the UI thread's cache lookup. pub fn set_virtual_id(&mut self, id: Option Option<(u64, u64)> + Send + Sync>>) { self.virtual_id = id; } diff --git a/crates/engine/src/roto.rs b/crates/engine/src/roto.rs index 49f53d39b..d9ce6e5af 100644 --- a/crates/engine/src/roto.rs +++ b/crates/engine/src/roto.rs @@ -6,7 +6,7 @@ //! `wait`; in the browser the background is a Web Worker whose segmentations stream back, //! [`crate::offload`]), frame by frame outward from the base frame, rendering the effect's input //! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same -//! pass and stores the final mattes in the effect (one undo step). [`sync`], run after every +//! pass and stores the final mattes in the effect (one undo step). `sync`, run after every //! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made //! of (the layer's source and timing, its masks and the effects above it) and drops frozen //! mattes made from other frames. diff --git a/crates/engine/src/sequence.rs b/crates/engine/src/sequence.rs index c98aba1d3..46dca154c 100644 --- a/crates/engine/src/sequence.rs +++ b/crates/engine/src/sequence.rs @@ -20,7 +20,7 @@ use crate::{Importer, Services}; /// How an import treats numbered stills. #[derive(Clone, Copy, Debug)] pub struct SequenceOptions { - /// The " Sequence" checkbox: picked numbered stills of one run import as a sequence. + /// The `" Sequence"` checkbox: picked numbered stills of one run import as a sequence. pub sequence: bool, /// The numbered stills inside a picked or dropped folder import as sequences. pub folders: bool, @@ -44,7 +44,7 @@ impl SequenceOptions { /// `file.import`'s options. Dropped files (`drag: true`) import as stills, as in After /// Effects, while a folder's numbered stills import as sequences unless `sequence: false` - /// (Alt-drag of a folder). The Import dialog passes its " Sequence" checkbox (off + /// (Alt-drag of a folder). The Import dialog passes its `" Sequence"` checkbox (off /// unless ticked) as `sequence`. Without `sequence` or `drag` (scripts, agents) a numbered /// still brings its run, as it always has. pub fn for_import(p: &serde_json::Value) -> SequenceOptions { diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 3c349a7df..324d3a82a 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -160,7 +160,7 @@ fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[ /// Blend Mode). Top of the paint order first. /// /// Clipping groups (PDF / Illustrator clipping masks): shapes inside them are intersected with -/// the clip paths through Merge Paths ▸ Intersect (see [`clipped_items`]). Clips enclosing the +/// the clip paths through Merge Paths ▸ Intersect (see `clipped_items`). Clips enclosing the /// whole document are layer masks instead ([`vector_clip_masks`], used by /// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see /// [`split_at_images`]); soft masks are not converted. diff --git a/crates/engine/src/warp.rs b/crates/engine/src/warp.rs index 7928a5cab..5472d4e8c 100644 --- a/crates/engine/src/warp.rs +++ b/crates/engine/src/warp.rs @@ -7,7 +7,7 @@ //! stabilization plan once to validate the result. When the job finishes, the analysis is //! written into the effect's hidden Analysis parameter as one undo step, together with a key of //! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that -//! change those clear the analysis ([`invalidate`], run after every edit) and queue the effect +//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect //! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses //! automatically too). diff --git a/crates/ui-egui/src/color_picker.rs b/crates/ui-egui/src/color_picker.rs index 847e1e541..acc01cc65 100644 --- a/crates/ui-egui/src/color_picker.rs +++ b/crates/ui-egui/src/color_picker.rs @@ -77,6 +77,8 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) out.changed } +/// The `egui::Id` of the pass number in which a colour picker last stayed open, which +/// [`open_recently`] uses to keep Escape for the picker rather than the dialog under it. fn open_pass_id() -> egui::Id { egui::Id::unique("color-picker-open-pass") } @@ -175,6 +177,12 @@ impl Pick { const SQUARE: f32 = 200.0; const SLIDER_W: f32 = 16.0; +/// The colour picker popup's contents: saturation/value square, channel slider, number fields for +/// H/S/B and R/G/B, a hex field and an original-colour swatch, plus OK and Cancel. `*c` is updated +/// as the UI changes and `original` is what Cancel (or the swatch) restores. The chosen channel +/// and the HSV/RGB pair are remembered in egui temp data so reopening keeps the hue when +/// saturation or value reaches zero. Returns what the picker did this frame; it never runs a +/// command itself. fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3]) -> Outcome { let state_id = id.with("pick"); let mut pick = match ui.data(|d| d.get_temp::<([f32; 3], [f32; 3])>(state_id)) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a481d96ac..29fe4e661 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -39,6 +39,8 @@ pub struct ControlRequest { } impl ControlRequest { + /// Creates a request for `method` with `params`, returning it with the channel that will + /// receive the UI thread's reply. pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { let (tx, rx) = std::sync::mpsc::channel(); (Self { method: method.into(), params, reply: tx }, rx) @@ -170,6 +172,10 @@ fn point(app: &EffectcraftApp, p: &Value) -> Result { Ok(egui::pos2(x as f32, y as f32)) } +/// Dispatches one control request on the UI thread. Replies are `{"ok": true, "result": …}` +/// or `{"ok": false, "error": …}`; an unknown method is an error. Some methods answer with +/// [`Outcome::Retry`], [`Outcome::AfterInput`], [`Outcome::Screenshot`] or +/// [`Outcome::AwaitJobs`] instead of replying in this call. pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlRequest, now: f64) -> Outcome { let p = &req.params; match req.method.as_str() { @@ -198,6 +204,10 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques } } +/// Runs `engine.execute` or `ui.menu.invoke`. `engine.execute` with a known engine command id +/// goes through [`execute_engine`] (so a `wait: true` job can be offloaded); every other id, +/// and every `ui.menu.invoke`, goes through the menu dispatcher. A failure is copied to the +/// status line. 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`") }; @@ -219,6 +229,7 @@ fn execute_command(app: &mut EffectcraftApp, ctx: &egui::Context, method: &str, } } +/// Replies with `command.list`'s result, forwarding `filter` and `enabledOnly`. 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), @@ -226,6 +237,8 @@ fn engine_commands(app: &mut EffectcraftApp, p: &Value) -> Outcome { } } +/// Replies with the automation elements whose id starts with `prefix` (all when empty), as +/// `{id, label, rect}` records. 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()) @@ -237,6 +250,9 @@ fn focus(ctx: &egui::Context) -> Outcome { ok(Value::Null) } +/// Applies `ui.set` in order: tool, workspace, theme, focused panel, viewer settings, then the +/// layout fields ([`set_ui_layout`]). An unknown tool, workspace or theme name, or an invalid +/// viewer value, replies with an error after the earlier fields were already applied. 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") { @@ -297,6 +313,9 @@ fn set_ui_layout(app: &mut EffectcraftApp, p: &Value) -> Outcome { ok(Value::Null) } +/// Applies viewer `zoom`, the active comp's `res` (stored in the project), `pan` and the +/// `grid`, `rulers`, `safeMargins` and `transparencyGrid` toggles. An unknown resolution name +/// is an error; a resolution change without an active comp is ignored. 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); @@ -328,6 +347,8 @@ fn apply_viewer(app: &mut EffectcraftApp, v: &Value) -> Result<(), String> { Ok(()) } +/// Applies timeline `pps`, `start`, `graphEditor`, `showModes`, `openLayers` and `openGroups` +/// to the UI state, leaving absent fields unchanged. fn apply_timeline(app: &mut EffectcraftApp, tl: &Value) { if let Some(v) = tl.get("pps") { app.ui.timeline.pps = v.as_f64(); @@ -349,6 +370,8 @@ fn apply_timeline(app: &mut EffectcraftApp, tl: &Value) { } } +/// Replaces the Project panel's optional columns with the names in `a`, in the canonical +/// `COLUMNS` order; unknown names are kept, sorted after the known ones. fn apply_project_columns(app: &mut EffectcraftApp, a: &[Value]) { let mut cols = vec![]; for k in a.iter().filter_map(Value::as_str) { @@ -357,6 +380,8 @@ fn apply_project_columns(app: &mut EffectcraftApp, a: &[Value]) { app.ui.project_columns = cols; } +/// Merges `m` over the current Wiggler/Smoother/Motion Sketch settings by round-tripping them +/// through JSON. A value of the wrong type is an error and leaves the settings unchanged. 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 { @@ -371,6 +396,8 @@ fn apply_anim_tools(app: &mut EffectcraftApp, m: &serde_json::Map } } +/// Shows (`ui.panel.show`) or closes (`ui.panel.close`) the named panel; an unknown panel name +/// is an error. 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`") }; @@ -382,6 +409,8 @@ fn panel(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome { ok(Value::Null) } +/// The number of synthetic events a gesture queues: `2 * count + 3` for `ui.click` (`count` +/// 1..=512), or 3 for `ui.move`. An out-of-range `count` or an overflow is an error. fn click_gesture_events(method: &str, p: &Value) -> Result { if method != "ui.click" { return Ok(3); @@ -394,6 +423,10 @@ fn click_gesture_events(method: &str, p: &Value) -> Result { 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()) } +/// Queues a click or a plain move at `{id}`'s centre (`fx`/`fy` within its rect, default 0.5) +/// or at `{x, y}`, holding the request's modifiers across the whole gesture. An element that is +/// not on screen returns [`Outcome::Retry`]; the gesture is size-checked against the 4096-event +/// queue limit before anything is pushed. Replies [`Outcome::AfterInput`]. 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) { @@ -433,6 +466,10 @@ fn click_or_move(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome { Outcome::AfterInput } +/// Queues a primary-button drag from `from` to `to` over `steps` interpolated moves (default +/// 12, at least 2, at most 2048), with the request's modifiers. Both endpoints are resolved +/// first (an unseen element returns [`Outcome::Retry`]) and the event count is checked against +/// the 4096-event queue limit. Replies [`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)) { @@ -463,6 +500,8 @@ fn drag(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } +/// Queues a move to the target point, a wheel event of `dx`, `dy` points with the request's +/// modifiers, and a released Extra1 button. Replies [`Outcome::AfterInput`]. fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { let pos = match point(app, p) { Ok(p) => p, @@ -487,6 +526,9 @@ fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } +/// Queues a press and release of `key`, whose name may carry a shortcut (`Cmd+Shift+Z`); the +/// separate `modifiers` flags are added. A `Physical+` prefix makes the key positional. An +/// unknown key name is an error. 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`") }; @@ -499,6 +541,8 @@ fn key(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } +/// Queues a text event with `text`, followed by a released F35 key. Missing `text` is an +/// error. 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())); @@ -506,6 +550,8 @@ fn type_text(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } +/// Converts comp pixel `x`, `y` to a screen point with the viewer's current transform, or +/// returns [`Outcome::Retry`] while the viewer is not visible. 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)"); @@ -516,6 +562,8 @@ fn viewer_locate(ctx: &egui::Context, p: &Value) -> Outcome { } } +/// Converts a point (`{id}`, or `{x, y}`) to comp pixels; replies with an error when the +/// viewer is not visible. fn viewer_hit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome { let pos = match point(app, p) { Ok(p) => p, @@ -527,6 +575,9 @@ fn viewer_hit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outco } } +/// Returns the screen point at `time` seconds on the ruler row of layer `layer`'s bar; +/// without a numeric `time`, returns the bar's centre. Returns [`Outcome::Retry`] when the bar +/// is unavailable, or a numeric `time` is supplied but the ruler is unavailable or malformed. 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)) { @@ -535,6 +586,9 @@ fn timeline_locate(app: &EffectcraftApp, p: &Value) -> Outcome { } } +/// Applies `action` (`play`, `stop`, `status`, else toggle) at `now` and replies with the +/// playing state, current time in seconds, whether audio is present and the meter's level and +/// peak in dB. 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 { @@ -548,6 +602,11 @@ fn playback(app: &mut EffectcraftApp, p: &Value, now: f64) -> Outcome { ) } +/// Resolves the capture target — a named panel, an element id, or the whole window — and +/// returns [`Outcome::Retry`] while a requested panel or element is not registered, so a +/// missing target is never silently captured as the window. Requests a repaint without raising +/// or focusing the window and returns [`Outcome::Screenshot`]; the target is resolved again +/// when the capture is issued. 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 @@ -574,11 +633,12 @@ fn screenshot(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outco 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. + // Request a repaint without raising the window or taking keyboard focus. app.raise_for_control(ctx); Outcome::Screenshot { path: s("path").map(str::to_string), target } } +/// Asks the viewport for an inner size of `width`×`height` pixels (default 1600×1000). 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; @@ -586,6 +646,8 @@ fn resize(ctx: &egui::Context, p: &Value) -> Outcome { ok(Value::Null) } +/// Sends the viewport close request. `force: true` marks the unsaved-changes state as +/// quitting, so a modified project closes without the save prompt. 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) { @@ -653,6 +715,10 @@ pub fn base64(data: &[u8]) -> String { s } +/// Builds the `ui.inspect` reply: window size and pixels per point, fps, UI state with the +/// active comp's viewer resolution, playback and time, active comp, selected layer/key counts, +/// registered element count, keyboard focus (egui id, rect, automation ids and rect +/// candidates), fresh window focus, workspace storage error, open dialog and last frame time. pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value { // A logic-only tick refreshes the viewport information but not `InputState`'s screen rect. let size = ctx.input(|i| i.viewport().inner_rect).map_or_else(|| ctx.content_rect().size(), |r| r.size()); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 3dbc83714..268ed9416 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1053,6 +1053,8 @@ fn gutter(ui: &mut egui::Ui, gap: Rect, vertical: bool, path: &str, t: &Tokens, (response.delta != 0.0).then_some(response.delta) } +/// Build the split-divider handle for `gap`: `vertical` selects the axis, `path` gives its stable id, and the theme +/// supplies hover/drag/focus colours and the localized size label. Returns the handle for the caller to show. fn resize_handle(gap: Rect, vertical: bool, path: &str, t: &Tokens) -> ResizeHandle<'static> { let axis = if vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }; let style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus }; diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 559cb2191..05bcd1c8e 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -12,6 +12,7 @@ use crate::dock::{self, DockAction, DockNode, Group, Layout, PanelKind, PanelWin use crate::{EffectcraftApp, panels}; use craft_ui::docking::{Action, DropTarget, resolve_drop}; +/// The `egui::Id` under which the panel whose tab is being dragged is stored. fn drag_id() -> egui::Id { egui::Id::unique("dock-tab-drag") } @@ -161,6 +162,8 @@ impl EffectcraftApp { pos.and_then(|p| self.dock_rects.iter().rev().find(|(_, r)| r.contains(p)).map(|(k, _)| *k)).unwrap_or(self.ui.focused) } + /// Draw panel `p`'s body in `rect`: register its automation region, put the Composition Navigator above an active + /// viewer when a nested-comp flow exists, then call the panel's `show` and (for a viewer) the sketch overlay. fn panel_body(&mut self, ui: &mut egui::Ui, p: PanelKind, rect: Rect) { self.auto.add(&format!("panel.{}", p.id()), rect, p.title()); let mut content = rect.shrink(1.0); @@ -228,6 +231,9 @@ impl EffectcraftApp { } } + /// Lay out and draw the workspace in `body`: the maximized panel or the dock tree, group chrome, active bodies + /// and focus outline; then floating groups, their native panel windows, layout/action commands, tab drag-and-drop + /// and the panel menu. Draws the Home screen instead when it is showing. pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) { let t = self.tokens; let ctx = ui.ctx().clone(); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 2ad3dd20f..861d6fd91 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -15,6 +15,10 @@ use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint}; use effectcraft_engine::project::{LayerId, LayerSource, PropGroup}; use effectcraft_engine::viewer as vw; +/// Draws the Tools bar into `rect`: brand (About), Home, the tool slots with their fly-out +/// menus and press-and-hold picks, options for the active tool (shape paint, puppet, roto), +/// Snapping, then Discord, GitHub, website, appearance and workspace controls. Clicks apply +/// tool, dialog, theme and workspace changes to `app` or run their commands. pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().clone(); diff --git a/crates/ui-egui/src/i18n/effects.rs b/crates/ui-egui/src/i18n/effects.rs index b41430bed..162300f9c 100644 --- a/crates/ui-egui/src/i18n/effects.rs +++ b/crates/ui-egui/src/i18n/effects.rs @@ -14,7 +14,7 @@ //! the id is stable, so a translation never becomes an orphan. //! //! Each row repeats the registry's English name so that a rename upstream fails -//! [`tests::every_row_names_a_registry_effect`] instead of silently pointing a translation at +//! `tests::every_row_names_a_registry_effect` instead of silently pointing a translation at //! another effect. An effect with no row draws the registry's English name; the //! `missing_translations_report` test (run by `cargo xtask ci`) lists those per language. //! diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index a9c45bb99..1b75ff3ee 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -6,7 +6,7 @@ //! inventing an id, and a missing row falls back to English instead of to an empty label. //! //! The language in force is set once per frame, from the same resolved language the fonts are -//! installed for ([`super::language`], [`set_current`]), so a panel does not have to thread it +//! installed for (`crate::i18n::language`, [`set_current`]), so a panel does not have to thread it //! through every function. It is thread-local: the render threads never draw interface text. //! //! A row that carries values keeps one `{}` per value and is read with [`tr_args`]; the tests below diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7f8e02683..2a39ea8ed 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -111,6 +111,7 @@ impl AppClock { } impl Default for AppClock { + /// The real monotonic clock (`Self::real()`). fn default() -> Self { Self::real() } @@ -555,6 +556,10 @@ struct PlaybackPass<'a> { } impl EffectcraftApp { + /// Build the app around `session`. + /// Loads and applies the workspaces saved in the session's config directory (a failed load is reported in + /// `ui.status`), initializes the default UI, playback and render state, and registers the frontend's commands as + /// shortcutable `UiCommand`s on the session. pub fn new(session: Session) -> Self { let mut ui = UiState::default(); let workspace_store = workspace_state::WorkspaceStore::load(session.config.as_deref(), &mut ui); @@ -848,7 +853,7 @@ impl EffectcraftApp { } /// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and - /// give it the focus: Window ▸ . + /// give it the focus: Window ▸ ``. pub fn show_panel(&mut self, p: PanelKind) { if !self.ui.floating.iter().any(|f| f.panels.contains(&p)) && self.ui.maximized.is_some_and(|m| m != p) { self.ui.maximized = None; @@ -1570,6 +1575,8 @@ impl EffectcraftApp { } } + /// Advance the audio or scrub output and feed its latest peaks to the level meter; an idle scrub is closed after + /// one second. With no source but an active meter, drive the meter to silence and request a repaint. fn pump_meters(&mut self, ctx: &egui::Context, now: f64) { if let Some(a) = &mut self.audio { a.pump(); @@ -1794,6 +1801,10 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// Handle the control channel for this tick: drain queued requests plus previously deferred ones, run each + /// through `control::handle`, answer `Done` immediately, retry `Retry` until its three-second deadline, hold + /// input/job/screenshot replies until their event happens and finish job waiters. Retry timeouts and refused + /// screenshots are answered as `{"ok": false, "error": ...}`; the receiver is kept for the next tick. fn drain_control(&mut self, ctx: &egui::Context, now: f64) { let Some(rx) = self.control_rx.take() else { return }; let mut reqs: Vec<(ControlRequest, f64)> = std::mem::take(&mut self.deferred); @@ -2149,6 +2160,8 @@ impl EffectcraftApp { // ---------------------------------------------------------------- frame + /// Apply the engine events drained this tick: open a comp, show a toast, open a URL, run a frontend command, + /// purge frame caches or copy an image; command and clipboard failures go to `app.ui.status`. fn handle_events(&mut self, ctx: &egui::Context) { for ev in self.session.drain_events() { match ev { @@ -2359,6 +2372,10 @@ impl EffectcraftApp { panels::viewer_text::finish_logic(self, ctx); } + /// One UI pass after the IME begin hook: close on an applied GPU failure, run `ui_setup`, skip drawing until + /// fonts are ready, draw the frame, finish pointer wrapping, pump queued wasm frames, record input activity, + /// publish the completed pass's geometry and pass sequence, acknowledge applied synthetic input and answer + /// `AfterInput` replies only after a non-discarded pass. Requests repaints while frames are in flight. fn draw_inner(&mut self, ui: &mut egui::Ui, frame: &eframe::Frame) { let ctx = ui.ctx().clone(); if self.gpu_failure_applied == Some(true) { @@ -2619,6 +2636,9 @@ impl eframe::App for EffectcraftApp { } } + /// Feed pending synthetic input into `raw_input` only once fonts are ready (so a non-drawing pass never consumes + /// a batch), then prepare pointer wrapping; ending a numeric scrub clears the history merge key so the next edit + /// starts a new undo step. fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { let injected = self.synthetic_in_flight || !self.synthetic.is_empty(); // Only a pass that can draw may receive synthetic input: `Context::run_ui` applies @@ -2633,6 +2653,8 @@ impl eframe::App for EffectcraftApp { } } + /// The logic-only tick: run `tick` and close the IME pass with `finish_ime_logic`. It does not read pass-time + /// input state (events, pointer and time are stale here), only the viewport information eframe refreshed. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { // Never `ctx.input(|i| i.time)` here: `Context::run_logic` leaves events, pointer state and // time at the previous full pass's values (only the viewport information is refreshed). @@ -2640,6 +2662,7 @@ impl eframe::App for EffectcraftApp { self.finish_ime_logic(ctx); } + /// Draw one UI pass through `draw`. fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.draw(ui, frame); } diff --git a/crates/ui-egui/src/menu_keys.rs b/crates/ui-egui/src/menu_keys.rs index 71e3b6425..49d07301c 100644 --- a/crates/ui-egui/src/menu_keys.rs +++ b/crates/ui-egui/src/menu_keys.rs @@ -60,15 +60,24 @@ struct KeyOpened { pointer: Option, } +/// The temp-data id of the [`FirstEntry`] depth to highlight after a keyboard-opened menu is +/// first drawn. fn pending_id() -> Id { Id::unique("menu-keys-first") } + +/// The temp-data id of the [`KeyOpened`] record: submenus opened from the keyboard and the +/// pointer position they were opened at. fn opened_id() -> Id { Id::unique("menu-keys-opened") } + +/// The temp-data id of the flag saying the menu bar currently holds the keyboard. fn active_id() -> Id { Id::unique("menu-keys-active") } + +/// The temp-data id of the `(armed, held)` Alt-key state used to detect an Alt tap. fn alt_id() -> Id { Id::unique("menu-keys-alt") } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 851f6fb07..9364630af 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1580,7 +1580,7 @@ pub(crate) fn panel_shown(app: &EffectcraftApp, p: PanelKind) -> bool { } } -/// The command a menu entry runs when chosen from a menu: Window ▸ closes a panel whose +/// The command a menu entry runs when chosen from a menu: Window ▸ `` closes a panel whose /// entry is checked, as in After Effects (#1630). Shortcuts and agents calling `window.panel` /// still only show the panel. pub(crate) fn menu_choice(app: &EffectcraftApp, id: String, params: Value) -> (String, Value) { @@ -1823,6 +1823,8 @@ fn system_paste_id() -> egui::Id { egui::Id::unique("menus-system-paste") } +/// The temp-data id of the flag set by a plain Space press and taken on its release, so a tap +/// runs the Space shortcut while holding Space stays the Hand tool. fn space_tap_id() -> egui::Id { egui::Id::unique("spacebar-tap") } diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index 17a69b807..fe4c67895 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -31,6 +31,8 @@ fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, opti widgets::popup_menu(ui, id, r.left_bottom(), &opts, Some(cur)).filter(|i| *i != cur) } +/// A drag-to-change number field for an anim-tool setting: `v` clamped to `range`, `speed` points +/// per pixel, `dec` decimals and a `suffix`; returns the new value, or `v` when it did not change. fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 { let t = app.tokens; let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("animtool-n", auto)), v, speed, range, dec, suffix, &t); @@ -38,6 +40,8 @@ fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &st nv.unwrap_or(v) } +/// A checkbox at `at` with its `text` label drawn to its right; returns the state to store, i.e. +/// `!on` when it was clicked and `on` otherwise. fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool { let t = app.tokens; let r = Rect::from_min_size(at, vec2(16.0, 16.0)); @@ -218,6 +222,8 @@ struct Capture { points: Vec<[f64; 3]>, } +/// The `egui::Id` of the Motion Sketch capture in progress ([`Capture`]): its start time and the +/// recorded points. fn capture_id() -> egui::Id { egui::Id::unique("motion-sketch-capture") } diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 6aff37823..1dc6fad37 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -181,6 +181,8 @@ fn timecode_field(ui: &mut egui::Ui, buf: &mut Option, secs: &mut f64, r r } +/// The temp-data id of the one-shot flag that makes the Save Preset name field take focus when +/// it is next drawn. fn name_focus_id() -> egui::Id { egui::Id::unique("comp-preset-name-focus") } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 99496a218..b59af5721 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -273,6 +273,10 @@ const ABOUT_TABS: [(&str, &str); 3] = [("About", "about"), ("Contributors", "con /// Height of the Contributors / Models tab bodies (they scroll inside it). const CREDITS_HEIGHT: f32 = 380.0; +/// The About dialog: tabs for the name/version/blurb, the contributors and the models (the last +/// two scroll in a fixed-height body), the licence and bundled-font line, and Close. The tab is +/// kept in egui temp data. A command the About body asked for is run once the modal has been +/// built; Close clears `app.dialog`. fn about(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut close = false; let mut cmd: Option<&str> = None; diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index f0c023448..a8fc02127 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -670,6 +670,11 @@ fn toggle_closed(app: &mut EffectcraftApp, g: &PropGroup, open: bool, all: bool) } } +/// Lays out one property group's rows below `*y` (which it advances), recursing into open +/// subgroups: one row per visible property with its controls, plus the dial or slider row when its +/// slider is open, and an inline editor for groups that have one. Rows scrolled out of `rect` are +/// skipped without being drawn or registered, though their height is still counted. Pick-whip +/// drops are registered for each visible property; edits go on `actions`. #[allow(clippy::too_many_arguments)] fn group_rows( app: &mut EffectcraftApp, @@ -1121,6 +1126,12 @@ pub fn start_rename(ctx: &egui::Context, uid: u64, name: &str) { ctx.memory_mut(|m| m.request_focus(id.with("field"))); } +/// The Effect Controls panel for the selected layer: a header with the comp and layer names, then +/// one header per effect (Fx toggle, twirl, rename, Reset, About, label bar, reorder drag and its +/// right-click menu) followed by that effect's custom editor and its property rows. Dragging +/// effects or presets onto the panel applies them; a right-click on empty space opens the Effect +/// menu. The rows read from a snapshot of the project, and every edit is queued and run through +/// [`crate::menus::invoke`], with failures reported in `app.ui.status`. pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().with_clip_rect(rect); diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 3e4bd9275..fa7d2ac40 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -264,6 +264,13 @@ fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec
 (Actions, Actions) {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
diff --git a/crates/ui-egui/src/panels/expr_bar.rs b/crates/ui-egui/src/panels/expr_bar.rs
index e40d0a2d0..ce098efa0 100644
--- a/crates/ui-egui/src/panels/expr_bar.rs
+++ b/crates/ui-egui/src/panels/expr_bar.rs
@@ -23,6 +23,8 @@ struct Cache {
     current: usize,
 }
 
+/// The `egui::Id` under which the error bar's cache for composition `cid` is kept: this pass's
+/// failing expressions and which one is shown.
 fn cache_id(cid: ItemId) -> egui::Id {
     egui::Id::unique(("expr-error-bar", cid.0))
 }
@@ -46,6 +48,11 @@ pub fn height(app: &mut EffectcraftApp, ctx: &egui::Context, cid: ItemId) -> f32
     if errors(app, ctx, cid).errors.is_empty() { 0.0 } else { HEIGHT }
 }
 
+/// Draws the expression error strip for composition `cid` in `rect`: a warning, the shown error's
+/// description and ◀ n/m ▶ buttons to step through the failing expressions. Clicking the message
+/// selects that layer and property, reveals it in the Timeline and opens its expression. Does
+/// nothing when every expression evaluates. Stepping and clicking only change UI state (the
+/// cached index, selection and Timeline state), never the document.
 pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId) {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs
index 31d9beeda..bc74ca69e 100644
--- a/crates/ui-egui/src/panels/footage_panel.rs
+++ b/crates/ui-egui/src/panels/footage_panel.rs
@@ -13,6 +13,11 @@ use super::panel_kit as kit;
 use crate::EffectcraftApp;
 use crate::theme::Tokens;
 
+/// The Footage panel for the item opened into it: the frame at the panel's current time (cached
+/// per item, time and session revision), a ruler with the in/out span and CTI that scrubs on click
+/// or drag, Play/Pause, set In/Out, and Ripple Insert / Overlay Edit into the active comp. A solid
+/// shows its colour, footage without video says so, and an empty panel explains how to open one.
+/// Playback and time changes run `footage.*` commands, so the panel and agents share the state.
 pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs
index 942bd9fe0..15d9100f8 100644
--- a/crates/ui-egui/src/panels/forms.rs
+++ b/crates/ui-egui/src/panels/forms.rs
@@ -152,7 +152,7 @@ fn is_pdf(p: &Value) -> bool {
 }
 
 /// File ▸ Import ▸ File… picked numbered stills that import as image sequences: ask, as After
-/// Effects' " Sequence" checkbox in its Import dialog does (rfd's native dialog can't
+/// Effects' ` Sequence` checkbox in its Import dialog does (rfd's native dialog can't
 /// carry a checkbox), with Force Alphabetical Order and the frame rate. `p`: the import's
 /// parameters. Returns whether the dialog opened. The checkbox starts off, as in After Effects:
 /// unticked, the picked files import as stills. Dropped files and agents' `file.import` don't
diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs
index 7ee11b714..56b748782 100644
--- a/crates/ui-egui/src/panels/fx_editors.rs
+++ b/crates/ui-egui/src/panels/fx_editors.rs
@@ -111,6 +111,8 @@ fn tabs(
     out
 }
 
+/// A small "Reset" link centred in `r`, registered under the automation id `id`; returns whether
+/// it was clicked.
 fn reset_link(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str) -> bool {
     let t = app.tokens;
     let resp = ui.interact(r, egui::Id::unique(id), Sense::click());
@@ -162,6 +164,10 @@ pub fn inline_editor(
 
 const RGB_CURVES: [(&str, &str); 4] = [("master", "Master"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")];
 
+/// The Lumetri RGB Curves editor for effect group `g`: channel tabs (Master, Red, Green, Blue),
+/// the selected channel's curve graph with its Reset, and the point positions registered for
+/// automation. A curve edit or Reset is queued on `actions` as a `prop.set` of that channel's
+/// curve string; the visible channel is remembered in `app.ui.fx_curve_channel` per group.
 #[allow(clippy::too_many_arguments)]
 fn rgb_curves(
     app: &mut EffectcraftApp,
@@ -269,6 +275,11 @@ fn hue_color(h: f32) -> Color32 {
     Color32::from(c)
 }
 
+/// The Lumetri Hue/Saturation Curves editor for effect group `g`: tabs for the Hue vs Sat,
+/// Hue vs Hue, Hue vs Luma, Luma vs Sat and Sat vs Sat curves, drawn as a neutral-relative offset
+/// curve over the axis' colour band, with point drag/add/remove gestures and a Reset. Changes are
+/// queued on `actions` as a `prop.set` of the selected curve's point list; the visible tab is kept
+/// in `app.ui.fx_curve_channel` per group.
 #[allow(clippy::too_many_arguments)]
 fn huesat_curves(
     app: &mut EffectcraftApp,
@@ -441,6 +452,12 @@ fn wheel_point(c: Pos2, radius: f32, phase: f32) -> Pos2 {
     pos2(c.x + radius * a.sin(), c.y - radius * a.cos())
 }
 
+/// Colorama's Output Cycle wheel for effect group `g`: the palette drawn as a ring with its stops.
+/// Clicking the ring adds a stop with the colour there, dragging moves one, and Alt-clicking or
+/// dragging one off removes it while another stop remains; the selected stop's colour can be
+/// edited below. A change queues
+/// the rewritten palette on `actions`, and for a preset palette also switches the effect to the
+/// custom palette in the same undo step.
 #[allow(clippy::too_many_arguments)]
 fn colorama_wheel(
     app: &mut EffectcraftApp,
@@ -657,6 +674,10 @@ pub fn join_map(m: &[fw::CurvePoints; 3]) -> String {
     m.iter().map(|c| c.format()).collect::>().join(" | ")
 }
 
+/// Glow's Colour Map for effect group `g`: a strip showing the gradient the glow uses (A/B colours,
+/// or the arbitrary map sampled through the loop settings). In arbitrary-map mode it also draws the
+/// RGB curve tabs and the selected channel's graph with a Reset; edits queue a `prop.set` of
+/// `arbitraryMap` on `actions`.
 #[allow(clippy::too_many_arguments)]
 fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, ectx: &EvalCtx, r: Rect, actions: &mut Actions) {
     let t = app.tokens;
@@ -769,6 +790,10 @@ pub fn add_pair(v: &[(f64, f64)]) -> (f64, f64) {
     ((a.0 + ds * 0.5).rem_euclid(1.0), (a.1 + gap * 0.5).rem_euclid(1.0))
 }
 
+/// Reshape's correspondence buttons: Add Point appends a pair at the widest destination-fraction
+/// gap (or `(0, 0)` for an empty list), Remove Point drops the last stored pair, and Clear removes
+/// all pairs. A status line says whether points are automatic. Each change
+/// queues a `prop.set` of the `correspondencePoints` string on `actions`.
 #[allow(clippy::too_many_arguments)]
 fn reshape_buttons(
     app: &mut EffectcraftApp,
diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs
index 9db4d6163..fe10f4ddb 100644
--- a/crates/ui-egui/src/panels/gradient_editor.rs
+++ b/crates/ui-egui/src/panels/gradient_editor.rs
@@ -246,6 +246,12 @@ fn to_color32(c: [f32; 4]) -> Color32 {
     Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3]))
 }
 
+/// The Gradient Editor dialog for the gradient in `app.dialog_state.gradient`: a stop track for
+/// the opacity and colour tracks over a checkerboard preview, the selected stop's location,
+/// opacity or colour and a Delete button (when its track has more than one stop), the
+/// mismatched-stop-count warning, and OK/Cancel. Dragging moves stops, Delete/Backspace removes the
+/// selected one unless a widget wants keyboard input. OK runs the commit path; Cancel closes the dialog and drops the
+/// draft. With no gradient target it closes the dialog.
 pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
     if app.dialog_state.gradient.target.is_none() {
         app.dialog = None;
diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs
index fa082040c..66ba31470 100644
--- a/crates/ui-egui/src/panels/graph.rs
+++ b/crates/ui-egui/src/panels/graph.rs
@@ -189,6 +189,9 @@ const MORE_POPUP: &str = "graph-more";
 impl ButtonBar {
     const MORE_W: f32 = 24.0;
 
+    /// A bar starting 8 points into `bar`, stopping short of its right end by the "»" button's
+    /// width. It takes the id picked from the "»" menu in the previous frame, so the button whose
+    /// id that was reports a click on the frame after the pick.
     fn new(ctx: &egui::Context, bar: Rect) -> Self {
         let chosen = ctx.data_mut(|d| d.remove_temp::(egui::Id::unique(MORE_POPUP).with("chosen")));
         ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen }
@@ -233,6 +236,10 @@ impl ButtonBar {
     }
 }
 
+/// One text button of the Graph Editor's button bar at the bar's current `x`, in the toolbar row
+/// at `y`: draws it, registers it for automation and advances the bar. Once a button does not fit
+/// (or a previous one overflowed), the button goes to the "»" menu instead and only the id the
+/// menu picked reports a click. Returns whether this button was chosen.
 fn text_button(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
@@ -627,6 +634,8 @@ fn fit_all(app: &mut EffectcraftApp) {
     app.ui.timeline.pps = None;
 }
 
+/// The `egui::Id` of the one-shot flag [`track_open`] sets when the Graph Editor is switched on,
+/// so [`show`] frames all graphs on its next pass, once the plot rectangle is known.
 fn fit_pending_id() -> egui::Id {
     egui::Id::unique("graph-fit-pending")
 }
@@ -739,6 +748,8 @@ struct KeyGrab {
     t: f64,
 }
 
+/// The `egui::Id` of the key drag in progress ([`KeyGrab`]): where the button went down and the
+/// grabbed key's comp time when it did.
 fn key_grab_id() -> egui::Id {
     egui::Id::unique("graph-key-grab")
 }
@@ -786,6 +797,8 @@ impl HandleGrab {
 /// A handle drag: where the button went down, and the handles that move.
 type HandleDrag = (Pos2, Vec);
 
+/// The `egui::Id` of the Bezier handle drag in progress ([`HandleDrag`]): the press position and
+/// the handles the drag moves.
 fn handle_drag_id() -> egui::Id {
     egui::Id::unique("graph-handle-drag")
 }
diff --git a/crates/ui-egui/src/panels/graph_tools.rs b/crates/ui-egui/src/panels/graph_tools.rs
index ee5d7543f..7950d4a66 100644
--- a/crates/ui-egui/src/panels/graph_tools.rs
+++ b/crates/ui-egui/src/panels/graph_tools.rs
@@ -34,6 +34,7 @@ struct BoxDrag {
     last: [f64; 2],
 }
 
+/// The `egui::Id` of the transform-box drag in progress ([`BoxDrag`]).
 fn box_id() -> egui::Id {
     egui::Id::unique("graph-transform-box")
 }
@@ -254,6 +255,7 @@ struct AltScale {
     span: Option,
 }
 
+/// The `egui::Id` of the Timeline Alt-drag group scale in progress ([`AltScale`]).
 fn alt_id() -> egui::Id {
     egui::Id::unique("timeline-alt-scale")
 }
diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs
index a075087d3..86a1e6444 100644
--- a/crates/ui-egui/src/panels/info.rs
+++ b/crates/ui-egui/src/panels/info.rs
@@ -6,6 +6,11 @@ use egui::{Align2, Color32, Rect, pos2, vec2};
 use crate::theme::Tokens;
 use crate::{EffectcraftApp, widgets};
 
+/// The Info panel: the colour and alpha under the pointer (sampled from the shown viewer frame,
+/// read back for GPU frames) with its position and a swatch, then the selected layers, the current
+/// time, the duration/In/Out of a single selected layer, the achieved preview frame rate, the
+/// render time and queue state (when the preference asks for it) and the selected keyframe's
+/// property, time and value. Read-only: it draws and scrolls but changes no document state.
 pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let t = app.tokens;
     let p = ui.painter().with_clip_rect(rect);
diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs
index 2ebb2dee0..f6157da47 100644
--- a/crates/ui-egui/src/panels/layer_panel.rs
+++ b/crates/ui-egui/src/panels/layer_panel.rs
@@ -40,10 +40,14 @@ struct Tex {
     rect: [f64; 4],
 }
 
+/// The `egui::Id` of the cached render of the layer shown in this panel ([`Tex`]), with the key
+/// the image was made from.
 fn tex_id() -> egui::Id {
     egui::Id::unique("layer-panel-tex")
 }
 
+/// The `egui::Id` under which the panel's last Fit magnification is kept, so View ▸ Zoom In/Out
+/// can start from it (see [`last_fit`]).
 fn fit_id() -> egui::Id {
     egui::Id::unique("layer-panel-fit")
 }
@@ -83,6 +87,13 @@ fn default_view(layer: &Layer, tool: Tool) -> usize {
     }
 }
 
+/// The Layer panel: one layer in its own space with its masks and effects up to the View menu's
+/// choice, rendered synchronously at a zoom/pan the panel keeps (the last Fit magnification is
+/// published for the View menu). Wheel or pinch zooms about the pointer, the Hand tool or
+/// Space-drag pans, and the Zoom tool clicks zoom. The paint tools record a stroke's layer-space
+/// points while dragging and run `paint.stroke` on release (or on a click); Alt-click with the
+/// Clone Stamp sets its source. With a Roto Brush effect it hands the canvas to
+/// [`super::roto_tool`]. Without a current layer it shows how to open one.
 pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs
index e687dffa1..a4af4dae2 100644
--- a/crates/ui-egui/src/panels/layer_styles_dialog.rs
+++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs
@@ -61,7 +61,7 @@ pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> {
     Ok(())
 }
 
-/// Menu routing: choosing one of the Layer ▸ Layer Styles ▸