diff --git a/Cargo.lock b/Cargo.lock index 58f92065..93f132fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1744,6 +1744,7 @@ dependencies = [ "raw-window-handle", "smithay-clipboard", "web-time", + "webbrowser", "winit", ] @@ -2325,6 +2326,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-concurrency" version = "7.7.1" @@ -2731,6 +2741,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.10" @@ -5230,6 +5261,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf16_iter" version = "1.0.5" @@ -5618,6 +5661,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml index 3d5e1bb1..5ca52d69 100644 --- a/apps/effectcraft/Cargo.toml +++ b/apps/effectcraft/Cargo.toml @@ -14,7 +14,8 @@ effectcraft-engine = { workspace = true } effectcraft-host = { workspace = true } effectcraft-ui-egui = { workspace = true } egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +# `links`: links (Help menu, Discord, Settings) open in the system browser. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] } serde_json = { workspace = true } rfd = { workspace = true } cpal = { workspace = true } diff --git a/apps/effectcraft/src/appimage.rs b/apps/effectcraft/src/appimage.rs new file mode 100644 index 00000000..f353a751 --- /dev/null +++ b/apps/effectcraft/src/appimage.rs @@ -0,0 +1,132 @@ +//! Desktop integration for the AppImage. Wayland compositors (GNOME, KDE…) show a window's icon +//! from the desktop entry named by its app ID (`ai.storyteller.effectcraft.desktop`). The .deb, +//! .rpm and Flatpak packages install one; an AppImage installs nothing, so its window got the +//! generic icon. Started from an AppImage (`$APPIMAGE`), the app adds a hidden entry +//! (`NoDisplay=true`: no menu item) pointing at it and its icon under the user's data folder +//! (`$XDG_DATA_HOME`, else `~/.local/share`). Nothing is written when another entry already +//! provides the ID (an installed package, or the user's own), and an entry it wrote is kept up to +//! date when the AppImage moves. Failures are only logged: the icon is cosmetic. + +use std::path::{Path, PathBuf}; + +pub const APP_ID: &str = "ai.storyteller.effectcraft"; + +/// Marks entries this module wrote (it never touches others). +const MARKER: &str = "X-EffectCraft-AppImage=true"; + +/// A path as one `Exec` argument (desktop entry spec: quoted, with `"`, `` ` ``, `$` and `\` +/// escaped; `%` doubled so it isn't a field code). +pub fn exec_arg(path: &str) -> String { + let mut s = String::with_capacity(path.len() + 2); + s.push('"'); + for c in path.chars() { + // The quoting escape is a backslash, which the key file's string syntax escapes again: + // `"` is written `\\"`, a backslash `\\\\`. + match c { + '"' | '`' | '$' => { + s.push_str("\\\\"); + s.push(c); + } + '\\' => s.push_str("\\\\\\\\"), + '%' => s.push_str("%%"), + '\n' | '\r' => {} + c => s.push(c), + } + } + s.push('"'); + s +} + +/// The hidden desktop entry for the AppImage at `appimage`. +pub fn entry(appimage: &str) -> String { + format!( + "[Desktop Entry]\nType=Application\nName=EffectCraft\nComment=Motion graphics and visual effects compositor\nExec={} %F\nIcon={APP_ID}\nTerminal=false\nStartupWMClass={APP_ID}\nNoDisplay=true\n{MARKER}\n", + exec_arg(appimage) + ) +} + +/// Write the entry and `icon` (a 256 px PNG) under `data_home`, unless one of `data_dirs` (or +/// `data_home` itself, with an entry we didn't write) already has the ID's entry. Returns whether +/// anything was written. +pub fn integrate(appimage: &str, data_home: &Path, data_dirs: &[PathBuf], icon: &[u8]) -> std::io::Result { + let name = format!("{APP_ID}.desktop"); + if data_dirs.iter().any(|d| d.join("applications").join(&name).exists()) { + return Ok(false); + } + let path = data_home.join("applications").join(&name); + let text = entry(appimage); + let icon_path = data_home.join("icons/hicolor/256x256/apps").join(format!("{APP_ID}.png")); + match std::fs::read_to_string(&path) { + Ok(old) if !old.contains(MARKER) => return Ok(false), + Ok(old) if old == text && icon_path.exists() => return Ok(false), + _ => {} + } + write_atomic(&icon_path, icon)?; + write_atomic(&path, text.as_bytes())?; + Ok(true) +} + +fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("part"); + std::fs::write(&tmp, bytes)?; + std::fs::rename(&tmp, path) +} + +/// [`integrate`] for this process: when started from an AppImage, with the XDG data folders of +/// the environment. +pub fn integrate_from_env(icon: &[u8]) { + let Some(appimage) = std::env::var_os("APPIMAGE").map(|a| a.to_string_lossy().into_owned()) else { return }; + let env_path = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from); + let Some(data_home) = env_path("XDG_DATA_HOME").or_else(|| env_path("HOME").map(|h| h.join(".local/share"))) else { return }; + let dirs = std::env::var("XDG_DATA_DIRS").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + let data_dirs: Vec = dirs.split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect(); + match integrate(&appimage, &data_home, &data_dirs, icon) { + Ok(true) => log::info!("AppImage: desktop entry and icon written to {}", data_home.display()), + Ok(false) => {} + Err(e) => log::warn!("AppImage: could not write the desktop entry: {e}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exec_arguments_are_quoted_and_escaped() { + assert_eq!(exec_arg("/home/a/EffectCraft.AppImage"), "\"/home/a/EffectCraft.AppImage\""); + assert_eq!(exec_arg("/a b/$x\"y`z\\w%"), "\"/a b/\\\\$x\\\\\"y\\\\`z\\\\\\\\w%%\""); + let e = entry("/opt/EffectCraft.AppImage"); + assert!(e.contains("Exec=\"/opt/EffectCraft.AppImage\" %F\n") && e.contains("NoDisplay=true\n") && e.contains("Icon=ai.storyteller.effectcraft\n")); + } + + #[test] + fn writes_once_follows_moves_and_leaves_other_entries_alone() { + let root = std::env::temp_dir().join(format!("ec-appimage-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let (home, system) = (root.join("home"), root.join("system")); + let entry_path = home.join("applications/ai.storyteller.effectcraft.desktop"); + let icon_path = home.join("icons/hicolor/256x256/apps/ai.storyteller.effectcraft.png"); + // First start: entry and icon written; again: nothing to do. + assert!(integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap()); + assert_eq!(std::fs::read(&icon_path).unwrap(), b"png"); + assert!(std::fs::read_to_string(&entry_path).unwrap().contains("Exec=\"/a/EffectCraft.AppImage\"")); + assert!(!integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap()); + // The AppImage moved: the entry follows it. + assert!(integrate("/b/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap()); + assert!(std::fs::read_to_string(&entry_path).unwrap().contains("Exec=\"/b/EffectCraft.AppImage\"")); + // The user's own entry is never replaced. + std::fs::write(&entry_path, "[Desktop Entry]\nName=Mine\n").unwrap(); + assert!(!integrate("/c/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap()); + assert_eq!(std::fs::read_to_string(&entry_path).unwrap(), "[Desktop Entry]\nName=Mine\n"); + // An installed package provides the entry: nothing is written. + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(system.join("applications")).unwrap(); + std::fs::write(system.join("applications/ai.storyteller.effectcraft.desktop"), "x").unwrap(); + assert!(!integrate("/a/EffectCraft.AppImage", &home, std::slice::from_ref(&system), b"png").unwrap()); + assert!(!entry_path.exists()); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 7a56944e..3f310846 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -7,6 +7,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +// Built everywhere so its tests run on every platform; only Linux AppImages use it. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +mod appimage; mod audio_out; mod control_server; #[cfg(target_os = "macos")] @@ -17,11 +20,15 @@ use serde_json::json; /// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by /// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock. +/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too). +#[cfg(not(target_os = "macos"))] +const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png"); + fn app_icon() -> egui::IconData { #[cfg(target_os = "macos")] let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png"); #[cfg(not(target_os = "macos"))] - let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png"); + let png: &[u8] = ICON_PNG; eframe::icon_data::from_png_bytes(png).unwrap_or_default() } @@ -47,6 +54,9 @@ fn main() -> eframe::Result { _ => files.push(a), } } + // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. + #[cfg(target_os = "linux")] + appimage::integrate_from_env(ICON_PNG); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("EffectCraft") @@ -57,7 +67,7 @@ fn main() -> eframe::Result { .with_active(control_port.is_none()) .with_icon(app_icon()) // Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID. - .with_app_id("ai.storyteller.effectcraft"), + .with_app_id(appimage::APP_ID), event_loop_builder: agent_event_loop(control_port.is_some()), ..Default::default() }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3704dfbe..63d1db37 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -39,6 +39,9 @@ use state::UiState; use theme::Tokens; const SCREENSHOT_TIMEOUT_S: f64 = 4.0; +/// Seconds without a project change before paused prefetch resumes (see +/// [`EffectcraftApp::editing`]). +const EDIT_QUIET: f64 = 0.3; /// Modal dialogs. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -184,6 +187,8 @@ pub struct EffectcraftApp { /// When the user last did something (input or an edit): Cache Frames When Idle waits for /// a second of quiet. (time, project revision then) last_activity: (f64, u64), + /// When the project last changed, and its revision then (see [`Self::editing`]). + last_edit: (f64, u64), pub(crate) toast: Option<(String, f64)>, /// Texture of the last CPU frame shown in the viewer and the key it came from. pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey)>, @@ -263,6 +268,7 @@ impl EffectcraftApp { next_token: 1, last_ui_time: 0.0, last_activity: (0.0, 0), + last_edit: (0.0, 0), toast: None, viewer_tex: None, passive_tex: std::collections::HashMap::new(), @@ -797,11 +803,16 @@ impl EffectcraftApp { // 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, and only once it is done, so prefetch never delays what is on screen. + // 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 { (self.frames_parallelism() * 2).max(4) as i64 } else if self.frames.urgent_pending() { 0 + } else if let Some(wait) = self.editing(now) { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + 0 } else { (self.frames_parallelism() / 2).max(2) as i64 }; @@ -963,6 +974,16 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// While the project keeps changing (an edit within [`EDIT_QUIET`] seconds), how long until + /// it counts as settled. + fn editing(&mut self, now: f64) -> Option { + if self.last_edit.1 != self.session.revision { + self.last_edit = (now, self.session.revision); + } + let left = EDIT_QUIET - (now - self.last_edit.0); + (left > 0.0).then_some(left) + } + /// Composition ▸ Preview ▸ Cache Frames When Idle: after a second without input or edits, /// render the work area's frames (from the current time on, then from its start) in the /// background, a few at a time, until it is cached or the RAM preview budget is full. diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 2f58b87b..959d9c60 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -212,8 +212,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }, } app.auto.add("project.thumbnail", thumb, &it.name); + // The details stay inside the panel (a margin on the right), cut short with "…". let tx = thumb.max.x + 10.0; - p.text(pos2(tx, thumb.min.y + 6.0), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), t.text); + let max_w = head.max.x - 10.0 - tx; + let r = widgets::text_fit(&p, pos2(tx, thumb.min.y + 6.0), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), max_w, t.text); + app.auto.add("project.details.name", r, &it.name); let mut lines = vec![]; if let Some((w, h)) = it.dimensions() { lines.push(format!("{w} x {h} (1.00)")); @@ -226,10 +229,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { lines.push(f.codec.clone()); } for (i, l) in lines.iter().enumerate() { - p.text(pos2(tx, thumb.min.y + 22.0 + 13.0 * i as f32), Align2::LEFT_CENTER, l, Tokens::ui(11.0), t.text_dim); + let r = widgets::text_fit(&p, pos2(tx, thumb.min.y + 22.0 + 13.0 * i as f32), Align2::LEFT_CENTER, l, Tokens::ui(11.0), max_w, t.text_dim); + app.auto.add(&format!("project.details.{i}"), r, l); } } else { - p.text(head.center(), Align2::CENTER_CENTER, "Select an item to see its details", Tokens::ui(11.0), t.text_faint); + let hint = "Select an item to see its details"; + let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.0), head.width() - 20.0, t.text_faint); + app.auto.add("project.details.hint", r, hint); } // Search. let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0)); @@ -297,6 +303,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let footer_h = 28.0; let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h)); let lp = p.with_clip_rect(list); + // The list's empty area (under the rows and the scroll bar, which take their own clicks): + // a double-click imports, as in After Effects (File ▸ Import ▸ File...). + let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click()); + app.auto.add("project.empty", list, "Double-click to import files"); if overflow > 0.0 && ui.rect_contains_pointer(list) { let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift)); let d = if dx.abs() > 0.0 { @@ -680,6 +690,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("project.delete".into(), json!({}))); } app.auto.add("project.delete", tr, "Delete"); + if empty.double_clicked() { + actions.push(("file.import".into(), json!({}))); + } for (id, params) in actions { if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { app.ui.status = e; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index c1d90dd2..0c80fb20 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -6,7 +6,7 @@ use effectcraft_engine::effects::puppet::PinKind; use effectcraft_engine::geom::{Mat3, vec2 as gv2}; -use effectcraft_engine::project::{Layer, LayerId}; +use effectcraft_engine::project::{Comp, Layer, LayerId}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::three_d::{self, CameraState, View3D}; use effectcraft_engine::time::Tick; @@ -68,10 +68,16 @@ enum Gesture { Camera { tool: Tool, }, + /// A bounding-box handle: scales about the anchor point so the grabbed point follows the + /// pointer (corners both axes, edges one), relative to the layer as the drag began. Scale { layer: LayerId, start_scale: [f64; 3], + /// The grabbed point relative to the anchor, in layer pixels. start_local: [f64; 2], + anchor: [f64; 2], + axes: [bool; 2], + /// Comp → layer pixels as the drag began. inv: Mat3, }, Rotate { @@ -242,6 +248,32 @@ fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64 Some((m, pts, b)) } +/// Which axes a bounding-box handle scales: corners (0–3) both; the top and bottom edges (4, 6) +/// y; the right and left edges (5, 7) x. +fn handle_axes(handle: usize) -> [bool; 2] { + match handle { + 4 | 6 => [false, true], + 5 | 7 => [true, false], + _ => [true, true], + } +} + +/// The scale that puts the point grabbed at `start` (relative to the anchor, layer pixels) +/// under the pointer at `cur`, for a layer whose scale was `start_scale` when the drag began. +/// `proportional` (Shift on a corner) keeps the aspect ratio, following the pointer along the +/// handle's diagonal. Dragging past the anchor flips the layer, as in After Effects; an axis +/// whose grabbed point sits on the anchor keeps its scale. +fn drag_scale(start_scale: [f64; 3], start: [f64; 2], cur: [f64; 2], axes: [bool; 2], proportional: bool) -> [f64; 3] { + let ratio = |k: usize| if axes[k] && start[k].abs() > 1e-3 { cur[k] / start[k] } else { 1.0 }; + let (mut fx, mut fy) = (ratio(0), ratio(1)); + if proportional && axes == [true, true] { + let d2 = start[0] * start[0] + start[1] * start[1]; + let f = if d2 > 1e-6 { (cur[0] * start[0] + cur[1] * start[1]) / d2 } else { 1.0 }; + (fx, fy) = (f, f); + } + [start_scale[0] * fx, start_scale[1] * fy, start_scale[2]] +} + fn point_in_quad(p: [f64; 2], q: &[[f64; 2]; 4]) -> bool { let mut sign = 0.0; for i in 0..4 { @@ -621,23 +653,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add("viewer.regionOfInterest", r, "Region of interest"); } - // 3D Reference Axes (grid and guide options): the world axes as seen by the view's camera. - if app.session.prefs.three_d.show_reference_axes && comp.layers.iter().any(|l| l.is_3d()) { - let cam = cam_state(&ectx); - let o = pos2(area.min.x + 40.0, area.max.y - 40.0); - for (axis, col, name) in [ - (effectcraft_engine::geom::vec3(1.0, 0.0, 0.0), Color32::from_rgb(0xe0, 0x40, 0x40), "X"), - (effectcraft_engine::geom::vec3(0.0, 1.0, 0.0), Color32::from_rgb(0x50, 0xd0, 0x50), "Y"), - (effectcraft_engine::geom::vec3(0.0, 0.0, 1.0), Color32::from_rgb(0x50, 0x80, 0xf0), "Z"), - ] { - let v = cam.view.apply_vec(axis); - let tip = o + vec2(v.x as f32, v.y as f32) * 26.0; - painter.line_segment([o, tip], Stroke::new(2.0, col)); - painter.text(tip + (tip - o).normalized() * 7.0, Align2::CENTER_CENTER, name, Tokens::ui(10.0), col); - } - app.auto.add("viewer.referenceAxes", Rect::from_center_size(o, vec2(64.0, 64.0)), "3D Reference Axes"); - } - // Overlays + interaction. let selected = app.session.state.selected_layers.clone(); let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new(); @@ -1043,8 +1058,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { layer: h.layer, } }), + // Everything starts where the button went down: by the time the pointer has moved + // far enough to count as a drag it may have left a small handle. Tool::Selection => { - if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() { + let cpt = map.to_comp(press); + if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(press) < HANDLE + 2.0).cloned() { let layer = comp.layer(lid).cloned(); layer.and_then(|layer| { let tr = layer.transform()?; @@ -1052,8 +1070,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let inv = l2c.inverse()?; let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]); let lp = inv.apply(gv2(cpt[0], cpt[1])); - let _ = hi; - Some(Gesture::Scale { layer: lid, start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]), start_local: [lp.x - a[0], lp.y - a[1]], inv }) + Some(Gesture::Scale { + layer: lid, + start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]), + start_local: [lp.x - a[0], lp.y - a[1]], + anchor: [a[0], a[1]], + axes: handle_axes(hi), + inv, + }) }) } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) { let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app @@ -1085,7 +1109,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if !vertex_hits.iter().any(|h| h.tangent.is_none()) { let _ = app.session.execute("edit.deselectAll", json!({})); } - Some(Gesture::Marquee { start: pos }) + Some(Gesture::Marquee { start: press }) } } _ => None, @@ -1121,52 +1145,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); } } - Gesture::Scale { layer, start_scale, start_local, inv, .. } => { + Gesture::Scale { layer, start_scale, start_local, anchor, axes, inv } => { let lp = inv.apply(gv2(cpt[0], cpt[1])); - // Local point relative to the anchor in *unscaled* layer space. - let a = comp.layer(layer).and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "anchor", [0.0; 3]))).unwrap_or([0.0; 3]); - let cur = [lp.x - a[0], lp.y - a[1]]; - let fx = if start_local[0].abs() > 1e-6 { cur[0] / start_local[0] } else { 1.0 }; - let fy = if start_local[1].abs() > 1e-6 { cur[1] / start_local[1] } else { 1.0 }; - let (fx, fy) = if mods.shift { - let f = (fx + fy) / 2.0; - (f, f) - } else { - (if start_local[0].abs() > 1e-3 { fx } else { 1.0 }, if start_local[1].abs() > 1e-3 { fy } else { 1.0 }) - }; - // The local point moved within the *current* scale; compose with the start scale. - let cs = comp.layer(layer).and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "scale", [100.0; 3]))).unwrap_or([100.0; 3]); - let nx = cs[0] * fx; - let ny = cs[1] * fy; - let _ = start_scale; - let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": [nx, ny, cs[2]], "merge": merge})); - ui.data_mut(|d| { - if let Some(Gesture::Scale { start_local, .. }) = d.get_temp_mut_or_default::>(egui::Id::new("unused")).as_mut() { - let _ = start_local; - } - }); - // Re-baseline so the next frame's ratios are relative to the new scale. - let lp_unscaled = [cur[0] / fx.max(1e-9) * fx, cur[1] / fy.max(1e-9) * fy]; - let mut g2 = gesture.clone(); - if let Some(Gesture::Scale { start_local, inv: i2, .. }) = g2.as_mut() { - *start_local = lp_unscaled; - if let Some(l) = app.session.active_comp().and_then(|c| c.layer(layer)).cloned() { - let e2 = EvalCtx { - project: &app.session.project, - comp_id: cid, - comp: app.session.project.comp(cid).unwrap_or(&comp), - time, - expr: app.session.expr.as_deref(), - footage: None, - }; - if let Some(m) = l2c(&e2, &l).0.inverse() { - *i2 = m; - } - } - } - if let Some(g2) = g2 { - ui.data_mut(|d| d.insert_temp(gid, g2)); - } + let v = drag_scale(start_scale, start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, mods.shift); + let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge})); } Gesture::Rotate { layer, center, start_angle, start_rot } => { let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(); @@ -1373,10 +1355,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let b = map.to_comp(pos); let (x0, x1) = (a[0].min(b[0]), a[0].max(b[0])); let (y0, y1) = (a[1].min(b[1]), a[1].max(b[1])); - let hits: Vec = comp - .layers - .iter() - .filter(|l| l.is_active_at(time) && !l.switches.locked) + let hits: Vec = selectable_layers(&comp, time) .filter(|l| layer_quad(&ectx, l).is_some_and(|(_, q, _)| q.iter().any(|p| p[0] >= x0 && p[0] <= x1 && p[1] >= y0 && p[1] <= y1))) .map(|l| l.id.0) .collect(); @@ -1683,15 +1662,18 @@ fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: & }); } +/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. +/// Solo scope matches the renderer: only active AV layers activate it, even when hidden or locked. +pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { + let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time)); + comp.layers + .iter() + .filter(move |l| l.is_active_at(time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) +} + /// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer. pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option { - let hit = ectx - .comp - .layers - .iter() - .filter(|l| l.is_active_at(ectx.time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light()) - .find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q))) - .map(|l| l.id)?; + let hit = selectable_layers(ectx.comp, ectx.time).find(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| point_in_quad(cpt, &q))).map(|l| l.id)?; if toggle { let _ = app.session.execute("layer.select", json!({"layers": [hit.0], "toggle": true})); } else if !app.session.state.selected_layers.contains(&hit) { diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index dd0a28a5..37f7d082 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -119,10 +119,8 @@ fn edited(app: &EffectcraftApp, ectx: &EvalCtx, lid: LayerId) -> Option /// The topmost text layer whose text is under screen point `s`. fn text_layer_at(app: &EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, s: Pos2) -> Option { - ectx.comp - .layers - .iter() - .filter(|l| matches!(l.source, LayerSource::Text) && l.is_active_at(ectx.time) && l.switches.video && !l.switches.locked) + super::viewer::selectable_layers(ectx.comp, ectx.time) + .filter(|l| matches!(l.source, LayerSource::Text)) .filter_map(|l| edited(app, ectx, l.id)) .find(|e| e.contains(map, s)) } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 99787b4e..e483f12b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -143,6 +143,17 @@ pub fn icon_button(ui: &mut Ui, rect: Rect, icon: Icon, active: bool, t: &Tokens resp } +/// One line of `text` at `pos` (placed by `align`), cut short with "…" past `max_w` points so it +/// never runs out of its panel. Returns where it was painted. +pub fn text_fit(p: &egui::Painter, pos: egui::Pos2, align: Align2, text: &str, font: egui::FontId, max_w: f32, color: Color32) -> Rect { + let mut job = egui::text::LayoutJob::simple_singleline(text.to_owned(), font, color); + job.wrap = egui::text::TextWrapping { max_width: max_w.max(0.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') }; + let galley = p.layout_job(job); + let rect = align.anchor_size(pos, galley.size()); + p.galley(rect.min, galley, color); + rect +} + /// Rounded search field with a magnifier icon. pub fn search_field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens) -> Response { let h = rect.height(); diff --git a/crates/ui-egui/tests/ui_agent_audit.rs b/crates/ui-egui/tests/ui_agent_audit.rs index 246a0039..a14f7783 100644 --- a/crates/ui-egui/tests/ui_agent_audit.rs +++ b/crates/ui-egui/tests/ui_agent_audit.rs @@ -123,6 +123,7 @@ fn gaps_of(h: &mut Harness<'_, EffectcraftApp>, p: PanelKind) -> Vec { } #[test] +#[cfg_attr(not(debug_assertions), ignore = "egui records widget types only in debug builds")] fn every_panel_registers_automation_ids_for_its_widgets() { let mut s = session(); // Content for the selection-driven panels: the title (text with effects), a Render Queue @@ -133,7 +134,9 @@ fn every_panel_registers_automation_ids_for_its_widgets() { let title = s.active_comp().unwrap().layers.iter().find(|l| l.name == "EFFECTCRAFT").map(|l| l.id.0).unwrap(); let mut h = harness(s); h.state_mut().ui.layer_panel = Some(title); - // egui records widget types (to tell labels from controls) with this debug option. + // egui records widget types (to tell labels from controls) with this debug option, which + // only debug builds have. + #[cfg(debug_assertions)] h.ctx.all_styles_mut(|st| st.debug.show_interactive_widgets = true); let mut gaps = vec![]; for p in PanelKind::ALL { diff --git a/crates/ui-egui/tests/ui_ram_preview.rs b/crates/ui-egui/tests/ui_ram_preview.rs index c384607a..df62c028 100644 --- a/crates/ui-egui/tests/ui_ram_preview.rs +++ b/crates/ui-egui/tests/ui_ram_preview.rs @@ -51,3 +51,47 @@ fn an_edit_in_another_comp_keeps_the_cached_frames_and_undo_finds_them_again() { // B's frame is still there too. assert!(h.state().frames.is_cached(&effectcraft_ui_egui::frames::FrameKey { frame: 0, ..b_frame })); } + +/// Issue #65: while edits keep coming (a layer dragged in the viewer), only the viewer's frame +/// renders; prefetching the frames around it waits until the edits pause (they would be stale at +/// the next step, and would hold the CPU and GPU the next viewer frame needs), then resumes. +#[test] +fn prefetch_waits_while_edits_keep_coming() { + let mut s = Session::default(); + let c = s.execute("comp.new", json!({"name": "Drag", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let solid = s.execute("layer.newSolid", json!({"color": "#3080ff", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap(); + // Short steps: input time advances 50 ms per frame. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.05).build_eframe(|_| EffectcraftApp::new(s)); + settle(&mut h); + let frame = |h: &Harness<'_, EffectcraftApp>| { + let app = h.state(); + let comp = app.session.project.comp(ItemId(c)).unwrap(); + comp.frame_rate.frame_at(app.session.time()) + }; + for i in 1..=8 { + let v = json!([20.0 + 2.0 * i as f64, 18.0, 0.0]); + h.state_mut().session.execute("prop.set", json!({"layer": solid, "path": "transform/position", "value": v, "merge": "drag"})).unwrap(); + h.step(); + // The viewer's frame renders... + for _ in 0..500 { + if !h.state().frames.urgent_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + h.step(); + // ...and nothing else is queued or cached for this state of the comp. + let series = h.state().shown_series(ItemId(c)); + assert_eq!(h.state().frames.cached_frames(&series), vec![frame(&h)], "edit {i}"); + assert_eq!(h.state().frames.inflight(), 0, "edit {i}"); + } + // The edits pause: the frames around the current one fill in. + settle(&mut h); + for _ in 0..20 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + settle(&mut h); + let series = h.state().shown_series(ItemId(c)); + assert!(h.state().frames.cached_frames(&series).len() > 1, "prefetch resumed"); +} diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index d1d55a58..c1b3e73e 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -166,3 +166,100 @@ fn nested_comp_markers_show_on_the_precomp_bar() { // (29.97 fps: the marker's frame is at 1.001 s) assert!((h.state().session.time().seconds() - 1.0).abs() < 0.034, "{}", h.state().session.time().seconds()); } + +/// Issue #46: a panel dragged by its panel-menu (hamburger) button onto the left edge of another +/// group docks there as its own group; dragging the gap between panels resizes them. +#[test] +fn panels_dock_beside_others_and_gutters_resize_them() { + let (mut h, _, _) = harness(); + let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone(); + // Show Effect Controls in its group so its menu button is on screen, then drag by it. + let tab = rect(&h, "panel.tab.EffectControls"); + click_n(&mut h, tab.center(), 1); + let grip = rect(&h, "panel.menu.EffectControls").center(); + let tl = rect(&h, "panel.Timeline"); + drag(&mut h, grip, pos2(tl.min.x + tl.width() * 0.08, tl.center().y)); + assert_eq!(group_of(&dock(&h), PanelKind::EffectControls), Some(vec![PanelKind::EffectControls])); + assert!(!group_of(&dock(&h), PanelKind::Timeline).unwrap().contains(&PanelKind::EffectControls)); + let (fx, tl) = (rect(&h, "panel.EffectControls"), rect(&h, "panel.Timeline")); + assert!(fx.max.x <= tl.min.x + 1.0 && (fx.center().y - tl.center().y).abs() < tl.height(), "{fx:?} {tl:?}"); + // The gutter on the Project panel's right edge: dragging it 60 px widens the panel by 60. + let project = rect(&h, "panel.Project"); + let gutter = h + .state() + .auto + .previous + .iter() + .filter(|e| e.id.starts_with("dock.gutter.") && e.rect[3] > e.rect[2]) + .map(|e| egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))) + .filter(|g| g.y_range().contains(project.center().y)) + .min_by(|a, b| (a.center().x - project.max.x).abs().total_cmp(&(b.center().x - project.max.x).abs())) + .expect("a gutter beside the Project panel"); + drag(&mut h, gutter.center(), gutter.center() + egui::vec2(60.0, 0.0)); + let wider = rect(&h, "panel.Project"); + assert!((wider.width() - project.width() - 60.0).abs() < 2.0, "{} → {}", project.width(), wider.width()); +} + +/// Issue #45: the Project panel's details (name, size, duration) stay inside the panel with a +/// margin, cut short with "…" when they don't fit; so does the hint shown with nothing selected. +#[test] +fn project_details_stay_inside_the_panel() { + let (mut h, pre, _) = harness(); + let long = "A composition with a very long name that could never fit beside the thumbnail".to_string(); + h.state_mut().session.execute("project.rename", json!({"item": pre, "name": long})).unwrap(); + h.state_mut().session.state.project_selection = vec![ItemId(pre)]; + h.run_steps(3); + let panel = rect(&h, "panel.Project"); + for id in ["project.details.name", "project.details.0", "project.details.1"] { + let r = rect(&h, id); + assert!(r.max.x <= panel.max.x - 9.0 && r.width() > 10.0, "{id}: {r:?} in {panel:?}"); + } + h.state_mut().session.state.project_selection.clear(); + h.run_steps(3); + let r = rect(&h, "project.details.hint"); + assert!(r.min.x >= panel.min.x + 9.0 && r.max.x <= panel.max.x - 9.0, "{r:?} in {panel:?}"); +} + +/// A file dropped on the window (what the windowing layer hands egui). +#[derive(Debug)] +struct Dropped(std::path::PathBuf); + +impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + &self.0 + } + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } +} + +/// Issue #44: double-clicking the Project panel's empty area asks for files to import (as After +/// Effects' File ▸ Import ▸ File... does), and files dropped on the window are imported. (Tests +/// have no media decoders: what is checked is that the import runs with those files.) +#[test] +fn project_empty_area_double_click_and_dropped_files_import() { + let (mut h, _, _) = harness(); + let png = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/app-icon/hicolor/16x16/apps/ai.storyteller.effectcraft.png"); + let png = std::fs::canonicalize(png).unwrap().to_string_lossy().to_string(); + let asked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (a, p) = (asked.clone(), png.clone()); + h.state_mut().hooks.pick_files = Some(Box::new(move |_| { + a.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + vec![p.clone()] + })); + let imports = |h: &Harness<'_, EffectcraftApp>| h.state().session.journal.iter().filter(|(c, p)| c == "file.import" && p["paths"] == json!([png])).count(); + // Below the last row. + let last = rect(&h, "project.item.2"); + let empty = rect(&h, "project.empty"); + click_n(&mut h, pos2(empty.center().x, (last.max.y + empty.max.y) / 2.0), 2); + assert_eq!(asked.load(std::sync::atomic::Ordering::Relaxed), 1, "the file dialog opened"); + assert_eq!(imports(&h), 1, "the picked file was imported"); + // A double-click on a row opens that item instead. + let row = rect(&h, "project.item.2.name"); + click_n(&mut h, row.center(), 2); + assert_eq!(asked.load(std::sync::atomic::Ordering::Relaxed), 1); + // Dropping a file on the window imports it. + h.input_mut().dropped_files.push(std::sync::Arc::new(Dropped(png.clone().into()))); + h.run_steps(2); + assert_eq!(imports(&h), 2, "the dropped file was imported"); +} diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index 2c9af608..86d966db 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -46,6 +46,31 @@ fn edited(h: &Harness<'_, EffectcraftApp>) -> (u64, String, usize, usize) { (e.layer.0, layer_doc(s, e.layer).unwrap().text, e.anchor, e.caret) } +#[test] +fn type_tool_skips_hidden_and_unsoloed_text_layers() { + for switch in ["video", "solo"] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap(); + let mut ids = vec![]; + for _ in 0..2 { + let lid = s.execute("layer.newText", json!({"text": "alpha beta", "size": 80, "position": [100, 200], "justify": "left"})).unwrap()["layer"] + .as_u64() + .unwrap(); + ids.push(lid); + } + let (target, value) = if switch == "video" { (ids[1], false) } else { (ids[0], true) }; + s.execute("layer.setSwitch", json!({"layers": [target], "switch": switch, "value": value})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type; + h.run_steps(3); + let comp = rect(&h, "viewer.comp"); + let k = comp.width() / 640.0; + click(&mut h, comp.min + egui::vec2(330.0 * k, 180.0 * k), Modifiers::NONE); + assert_eq!(edited(&h).0, ids[0], "{switch}"); + } +} + #[test] fn type_tool_click_type_edit_and_commit() { let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app()); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 835536bb..70d2e7a4 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -58,6 +58,82 @@ fn screen(h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> Pos2 { c.min + vec2(p[0] * z, p[1] * z) } +fn selection_harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Selection", "width": 640, "height": 360, "duration": 4})).unwrap(); + for name in ["Back", "Front"] { + s.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 80, "height": 80})).unwrap(); + } + s.execute("edit.deselectAll", json!({})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +#[test] +fn viewer_click_skips_hidden_and_unsoloed_layers() { + for switch in ["video", "solo"] { + let mut h = selection_harness(); + let comp = h.state().session.active_comp().unwrap(); + let (front, back) = (comp.layers[0].id, comp.layers[1].id); + let at = screen(&h, [320.0, 180.0]); + click(&mut h, at); + assert_eq!(h.state().session.state.selected_layers, vec![front]); + let s = &mut h.state_mut().session; + let (target, value) = if switch == "video" { (front, false) } else { (back, true) }; + s.execute("layer.setSwitch", json!({"layers": [target.0], "switch": switch, "value": value})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(40); // Start a new click rather than a double-click. + click(&mut h, at); + assert_eq!(h.state().session.state.selected_layers, vec![back], "{switch}"); + } +} + +#[test] +fn viewer_marquee_skips_hidden_and_unsoloed_layers() { + for switch in ["video", "solo"] { + let mut h = selection_harness(); + let comp = h.state().session.active_comp().unwrap(); + let (front, back) = (comp.layers[0].id, comp.layers[1].id); + let (target, value) = if switch == "video" { (front, false) } else { (back, true) }; + h.state_mut().session.execute("layer.setSwitch", json!({"layers": [target.0], "switch": switch, "value": value})).unwrap(); + h.run_steps(3); + let (from, to) = (screen(&h, [250.0, 110.0]), screen(&h, [390.0, 250.0])); + drag(&mut h, from, to); + assert_eq!(h.state().session.state.selected_layers, vec![back], "{switch}"); + } +} + +#[test] +fn viewer_click_does_not_pick_through_hidden_or_locked_solo_layers() { + for switch in ["video", "lock"] { + let mut h = selection_harness(); + let front = h.state().session.active_comp().unwrap().layers[0].id; + let s = &mut h.state_mut().session; + s.execute("layer.setSwitch", json!({"layers": [front.0], "switch": "solo", "value": true})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [front.0], "switch": switch, "value": switch == "lock"})).unwrap(); + h.run_steps(3); + let at = screen(&h, [320.0, 180.0]); + click(&mut h, at); + assert!(h.state().session.state.selected_layers.is_empty(), "{switch}"); + } +} + +#[test] +fn viewer_click_ignores_inactive_solo_layers() { + let mut h = selection_harness(); + let comp = h.state().session.active_comp().unwrap(); + let (front, back) = (comp.layers[0].id, comp.layers[1].id); + let s = &mut h.state_mut().session; + s.execute("layer.setSwitch", json!({"layers": [front.0], "switch": "solo", "value": true})).unwrap(); + let cid = s.active_comp_id().unwrap(); + std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(front).unwrap().in_point = effectcraft_engine::time::Tick::from_seconds_f64(1.0); + h.run_steps(3); + let at = screen(&h, [320.0, 180.0]); + click(&mut h, at); + assert_eq!(h.state().session.state.selected_layers, vec![back]); +} + #[test] fn bottom_bar_in_after_effects_order() { let h = harness(); @@ -391,6 +467,8 @@ fn reference_axes_toggle_from_the_grid_menu() { h.state_mut().session.execute("layer.setSwitch", json!({"layers": [box_id.0], "switch": "threeD", "value": true})).unwrap(); h.run_steps(3); assert!(h.state().auto.find("viewer.referenceAxes").is_some(), "on by default (Settings ▸ 3D)"); + // Issue #64: one compass, not two. + assert_eq!(h.state().auto.query("viewer.referenceAxes").len(), 1); let g = rect(&h, "viewer.grid").center(); click(&mut h, g); h.run_steps(2); @@ -743,3 +821,79 @@ fn double_press_shortcuts_reveal_their_second_set() { h.run_steps(40); } } + +/// Drag through `path` with `modifiers` held (press at the first point, release at the last). +fn drag_path(h: &mut Harness<'_, EffectcraftApp>, path: &[Pos2], modifiers: egui::Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerMoved(path[0])); + h.input_mut().events.push(Event::PointerButton { pos: path[0], button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + for w in path.windows(2) { + for i in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(w[0] + (w[1] - w[0]) * (i as f32 / 6.0))); + h.step(); + } + } + let end = *path.last().unwrap(); + h.input_mut().events.push(Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(2); + // The keys come up after the button, as a hand does. + h.input_mut().events.push(Event::ModifiersChanged(Default::default())); + h.step(); +} + +/// Issue #63: a bounding-box handle scales about the anchor point so the grabbed corner follows +/// the pointer, relative to the scale the drag began with: dragging through the anchor and back +/// out recovers (it used to stick at 0), edges scale one axis, Shift keeps the proportions. +#[test] +fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() { + let mut h = harness(); + let box_id = h.state().session.active_comp().unwrap().layers[0].id; + let set = |h: &mut Harness<'_, EffectcraftApp>, path: &str, v: serde_json::Value| { + h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": path, "value": v})).unwrap(); + }; + let scale = |h: &Harness<'_, EffectcraftApp>| { + let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone(); + l.props.prop("transform/scale").unwrap().value.as_vec3() + }; + let close = |a: [f64; 3], b: [f64; 2]| (a[0] - b[0]).abs() < 1.0 && (a[1] - b[1]).abs() < 1.0; + h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + // The 80×80 box is centred at (320, 180) with its anchor in the middle. + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + h.run_steps(2); + let undo0 = h.state().session.history.undo.len(); + // Bottom-right corner (360, 220) to (400, 260): twice as far from the anchor. + let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center(); + let path = [corner, screen(&h, [400.0, 260.0])]; + drag_path(&mut h, &path, Default::default()); + assert!(close(scale(&h), [200.0, 200.0]), "{:?}", scale(&h)); + assert_eq!(h.state().session.history.undo.len(), undo0 + 1, "one undo step per drag"); + // Through the anchor (scale ≈ 0) and back out to 1.5×: the layer follows, nothing sticks. + set(&mut h, "transform/scale", json!([100, 100, 100])); + h.run_steps(2); + let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center(); + let path = [corner, screen(&h, [320.0, 180.0]), screen(&h, [300.0, 170.0]), screen(&h, [380.0, 240.0])]; + drag_path(&mut h, &path, Default::default()); + assert!(close(scale(&h), [150.0, 150.0]), "{:?}", scale(&h)); + // Past the anchor the layer flips, as in After Effects. + set(&mut h, "transform/scale", json!([100, 100, 100])); + h.run_steps(2); + let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center(); + let path = [corner, screen(&h, [280.0, 140.0])]; + drag_path(&mut h, &path, Default::default()); + assert!(close(scale(&h), [-100.0, -100.0]), "{:?}", scale(&h)); + // The right edge (handle 5) scales x only. + set(&mut h, "transform/scale", json!([100, 100, 100])); + h.run_steps(2); + let edge = rect(&h, &format!("viewer.handle.{}.5", box_id.0)).center(); + let path = [edge, screen(&h, [380.0, 200.0])]; + drag_path(&mut h, &path, Default::default()); + assert!(close(scale(&h), [150.0, 100.0]), "{:?}", scale(&h)); + // Shift on a corner keeps the proportions: the pointer's place along the diagonal. + set(&mut h, "transform/scale", json!([100, 100, 100])); + h.run_steps(2); + let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center(); + let path = [corner, screen(&h, [400.0, 230.0])]; + drag_path(&mut h, &path, egui::Modifiers::SHIFT); + assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h)); +} diff --git a/docs/gaps.md b/docs/gaps.md index 1f4124c9..ac97446a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -34,7 +34,7 @@ of them have been reconciled. | Behaves like After Effects | unmeasured, ≈ 60–80% | One outside contributor found four bugs in features marked done within a day (PRs #6–#10): Hold keyframes eased the motion into them, `keyInSpatialTangent` / `keyOutSpatialTangent` had the wrong names, a zero frame rate crashed, Find and Enter Full Screen shared Ctrl+F off macOS. There are about 10 After Effects reference captures in total. No test compares our renders or property values with After Effects itself; the tests check what the agents believed After Effects does | | Opening existing After Effects work | ≈ 0% | `.aep` / `.aepx` projects cannot be opened. Third-party After Effects plug-ins cannot run. Expressions and the scripting object model are strong, so scripts and expressions carry over | | Stability | improving | 23 never-crash PRs landed on 4 October; [AGENTS.md](../AGENTS.md) "Never crash" now binds every crate. On 5 October `cargo xtask ci` failed on main under Rust 1.99's clippy (a fix is in progress) | -| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. parity.md counts docking as done. Most checking happens on macOS. No localisation, no accessibility work | +| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. Most checking happens on macOS. No localisation, no accessibility work | | Performance | unknown against After Effects | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU, an M4 Pro under load). Nothing benchmarked against After Effects; no large real projects (4K footage, hundreds of layers) tested | | Media formats | ≈ 80% | H.264, ProRes, HEVC, AV1, image sequences and audio exist. The new HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified | | AI-assisted tools | ≈ 50% | Both tools can use trained models (pure-Rust inference, optional downloads), but nothing compares them with After Effects yet. Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on average on a test portrait; on our synthetic clip the eyes and chin stay within 4% of the face height | @@ -63,10 +63,15 @@ The most important missing piece: it turns every other estimate here into a meas ### G2. Real-user reliability on every platform -- Fix the open user issues (#41–#47), each with a regression test. +- Fix user issues as they come in, each with a regression test, and answer the reporter (#41–#47 + and #63–#68 are handled; Wayland file drops wait on winit). - Run the headless snapshot and control-channel checks on Linux (X11 and Wayland) and Windows, not only macOS. Interactions that only fail with real input (docking drags, viewer pan, drag-and-drop import, inline rename) need scripted input tests. +- Regression evidence for [#66](https://github.com/storytold/effectcraft/issues/66): scripted + pointer tests in `ui_viewer` and `ui_text_edit`, checked headlessly on Linux, cover viewer clicks, + marquee selection and text picking when layers are hidden or excluded by solo. They also check + hidden/locked solo layers and solo layers outside their active time range. - Regression evidence for [#67](https://github.com/storytold/effectcraft/issues/67): scripted input tests in `ui_project_delete`, checked headlessly on Linux, cover Delete/Backspace on Project items, multiple selection and undo. They check that an unrelated Timeline layer stays,