Merge pull request #2264 from aiongg/contrib-batch-4

Integration batch 4: 15 contributor PRs (interface, scripting, hardening, docs)
This commit is contained in:
aiongg
2026-10-23 22:57:03 -04:00
committed by GitHub
30 changed files with 991 additions and 80 deletions
Generated
+11
View File
@@ -1602,6 +1602,7 @@ dependencies = [
"effectcraft-render",
"effectcraft-time",
"serde_json",
"socket2",
"web-time",
]
@@ -4827,6 +4828,16 @@ dependencies = [
"serde",
]
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "spin"
version = "0.9.9"
+4 -1
View File
@@ -150,8 +150,11 @@ fn instantiate(s: &mut Session, source: u64, values: &Map<String, Value>, import
}
let added = execute(s, "layer.addItem", json!({"comp": parent, "item": source, "time": 0}))?;
let instance = added.get("layer").and_then(Value::as_u64).ok_or_else(|| Failure::Error("cannot create template instance".into()))?;
// Keep a partial final frame: layer.addItem snaps its default end to the frame grid.
// Keep a partial final frame: layer.addItem snaps its default end to the frame grid. Give the
// wrapper the source's exact name, not Duplicate's numbered one, for `[compName]` outputs.
s.edit("Template Duration", None, |project, _| {
let name = project.item(ItemId(source)).ok_or_else(|| EngineError::Other("template composition is missing".into()))?.name.clone();
project.item_mut(ItemId(parent)).ok_or_else(|| EngineError::Other("template render composition is missing".into()))?.name = name;
let comp = project.comp_mut(ItemId(parent)).ok_or(EngineError::NoComp)?;
let duration = comp.duration;
let layer = comp.layer_mut(LayerId(instance)).ok_or_else(|| EngineError::Other("template instance is missing".into()))?;
@@ -767,3 +767,45 @@ fn template_media_control_reports_a_path_that_cannot_be_imported() {
assert!(!dir.join("missing-out.png").exists(), "{reply}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Renders with `--template` name their outputs after the template's composition (#1937), not
/// the numbered name a duplicate would get. The name ends in a number and differs from the
/// archive's file name; an expression that looks the composition up by that name still finds it.
#[test]
fn template_render_outputs_keep_the_template_composition_name() {
let dir = std::env::temp_dir().join(format!("ec-qa-template-comp-name-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let build = BUILD.replace(r#"addComp("Lower Third""#, r#"addComp("Title Card 2""#).replace(
"var fill =",
"bar.property(\"ADBE Transform Group\").property(\"ADBE Position\").expression = '[comp(\"Title Card 2\").numLayers * 80, 65]';\nvar fill =",
);
std::fs::write(dir.join("build.jsx"), build).unwrap();
let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]);
assert_eq!(built["output"], "controls: 3", "{built}");
ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Title Card 2","path":"card.ectemplate"}"#, "t.ecproj"]);
let template_before = std::fs::read(dir.join("card.ectemplate")).unwrap();
std::fs::write(dir.join("values.json"), r#"{"Title":"John Smith"}"#).unwrap();
for (case, values) in [("defaults", None), ("values", Some("values.json"))] {
let out = dir.join(case).join("[compName]_[#####].png");
let mut args =
vec!["render", "--template", "card.ectemplate", "--out", out.to_str().unwrap(), "--format", "png", "--start", "1", "--end", "1.0833333333333333"];
if let Some(values) = values {
args.extend(["--values", values]);
}
let rendered = ok(&dir, &args);
let rendered = rendered["rendered"].as_array().unwrap();
assert_eq!(rendered.len(), 1, "{case}: {rendered:?}");
assert_eq!(rendered[0]["comp"], "Title Card 2", "{case}: {rendered:?}");
let frames: Vec<_> = std::fs::read_dir(dir.join(case)).unwrap().map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert_eq!(frames.len(), 1, "{case}: {frames:?}");
assert!(frames[0].starts_with("Title Card 2_") && frames[0].ends_with(".png"), "{case}: {frames:?}");
// The template's own two layers place the bar; the one-layer wrapper would move it left.
let frame = image::open(dir.join(case).join(&frames[0])).unwrap().to_rgba8();
let bar = frame.get_pixel(300, 75).0;
assert!(bar[0] > 240 && bar[1] < 15, "{case}: comp(\"Title Card 2\") is the template's composition: {bar:?}");
}
assert_eq!(std::fs::read(dir.join("card.ectemplate")).unwrap(), template_before);
let _ = std::fs::remove_dir_all(&dir);
}
+25 -3
View File
@@ -39,7 +39,13 @@ impl Rgba {
/// `#rrggbb` or `#rrggbbaa`.
pub fn from_hex(s: &str) -> Option<Rgba> {
let s = s.trim().trim_start_matches('#');
let p = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok();
let p = |i: usize| {
let pair = s.get(i..i + 2)?;
if !pair.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
u8::from_str_radix(pair, 16).ok()
};
match s.len() {
6 => Some(Rgba::from_u8(p(0)?, p(2)?, p(4)?)),
8 => Some(Rgba { a: p(6)? as f32 / 255.0, ..Rgba::from_u8(p(0)?, p(2)?, p(4)?) }),
@@ -93,7 +99,7 @@ pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == r {
((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
(((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0).rem_euclid(1.0)
} else if max == g {
((b - r) / d + 2.0) / 6.0
} else {
@@ -130,7 +136,7 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
let h = if d < 1e-7 {
0.0
} else if max == r {
((g - b) / d).rem_euclid(6.0) / 6.0
(((g - b) / d).rem_euclid(6.0) / 6.0).rem_euclid(1.0)
} else if max == g {
((b - r) / d + 2.0) / 6.0
} else {
@@ -262,6 +268,22 @@ mod tests {
assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}");
}
#[test]
fn hue_stays_below_one_for_tiny_blue() {
let (h, _, _) = rgb_to_hsv(1.0, 0.0, 1e-9);
assert!((0.0..1.0).contains(&h), "rgb_to_hsv hue {h} not in 0..1");
let (h, _, _) = rgb_to_hsl(1.0, 0.0, 1e-9);
assert!((0.0..1.0).contains(&h), "rgb_to_hsl hue {h} not in 0..1");
}
#[test]
fn rejects_signed_hex_pairs() {
assert!(Rgba::from_hex("#+1+2+3").is_none());
assert!(Rgba::from_hex("#1+2+3+").is_none());
assert!(Rgba::from_hex("#ff00+0ff").is_none());
assert!(Rgba::from_hex("#a1B2c3").is_some());
}
#[test]
fn srgb_roundtrip() {
for i in 0..=100 {
+4
View File
@@ -74,6 +74,10 @@ fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!(s.state.selected_layers.len()))
}
fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
// A deselected layer never keeps an open text edit: commit the edit first, exactly as
// `text.endEdit` (an empty Type-tool layer included), so every route — menu, shortcut,
// script, MCP — leaves one final state.
super::text_edit::end_edit(s, &Value::Null)?;
s.state.selected_layers.clear();
s.state.selected_props.clear();
s.state.selected_keys.clear();
+1 -1
View File
@@ -159,7 +159,7 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
}
/// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted).
fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result<Value> {
let Some(e) = s.state.text_edit.take() else { return Ok(Value::Null) };
if e.created && layer_doc(s, e.layer).is_some_and(|d| d.text.is_empty()) {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
+10 -8
View File
@@ -26,17 +26,19 @@ pub const PREFS_FILE: &str = "prefs.json";
pub const LANGUAGES: &[(&str, &str)] = &[
("Match System", "system"),
("English", "en"),
// Alphabetical by the language's own name after English; Cyrillic, Georgian, then CJK names
// follow the Latin ones.
("Čeština", "cs"),
("Deutsch", "de"),
("Español", "es"),
("Français", "fr"),
("Português (Brasil)", "pt-br"),
("Русский", "ru"),
("Українська", "uk"),
("ქართული", "ka"),
("日本語", "ja"),
("简体中文", "zh-hans"),
("繁體中文", "zh-hant"),
("Українська", "uk"),
("Español", "es"),
("Português (Brasil)", "pt-br"),
("Русский", "ru"),
("Français", "fr"),
("Deutsch", "de"),
("Čeština", "cs"),
("ქართული", "ka"),
];
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
+35
View File
@@ -392,3 +392,38 @@ fn set_text_schema_accepts_every_opentype_key_and_requires_none() {
s.execute_checked("layer.setText", json!({"layer": t, "discretionaryLigatures": true, "ss02": true, "ss20": true})).unwrap();
assert!(doc(&s, t).style_at(0).opentype.stylistic_set(20));
}
#[test]
fn deselect_all_ends_the_open_edit() {
let (mut s, t) = session("Hello");
s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap();
s.execute("text.insert", json!({"text": "!"})).unwrap();
assert_eq!(doc(&s, t).text, "Hello!");
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none(), "Deselect All ends the open text edit on every route");
assert!(s.state.selected_layers.is_empty(), "and leaves nothing selected");
assert_eq!(doc(&s, t).text, "Hello!", "the text stays committed");
// Ending an edit is no undoable step of its own (as with text.endEdit): undo takes
// the typed text, it does not reopen the edit.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(&s, t).text, "Hello", "undo reverts the typed text, not the deselection");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
}
#[test]
fn deselect_all_discards_an_empty_type_tool_layer() {
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
"the Type tool opens the edit on its new, empty layer"
);
let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len());
let before = count(&s);
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none());
assert!(s.state.selected_layers.is_empty());
assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
}
+13
View File
@@ -360,6 +360,9 @@ impl Mat4 {
}
/// General inverse (Gauss-Jordan with partial pivoting).
pub fn inverse(&self) -> Option<Mat4> {
if !self.0.iter().flatten().all(|v| v.is_finite()) {
return None;
}
let mut a = self.0;
let mut inv = Mat4::IDENTITY.0;
for col in 0..4 {
@@ -450,6 +453,16 @@ mod tests {
(a - b).length() < 1e-9
}
#[test]
fn mat4_inverse_rejects_non_finite() {
for (r, c, v) in [(0, 0, f64::NAN), (2, 1, f64::NAN), (1, 3, f64::INFINITY)] {
let mut m = Mat4::IDENTITY;
m.0[r][c] = v;
assert!(m.inverse().is_none(), "{r},{c},{v}");
}
assert!(Mat4::IDENTITY.inverse().is_some());
}
#[test]
fn layer_2d_maps_anchor_to_position() {
let m = Mat3::layer_2d(vec2(50.0, 25.0), vec2(960.0, 540.0), vec2(200.0, 50.0), 30.0);
+18 -4
View File
@@ -82,7 +82,7 @@ impl Exporter for FileExporter {
/// A new session with media, import, expressions, scripting and export enabled.
pub fn session() -> Session {
Session {
let mut s = Session {
// The desktop app, the CLI and the MCP server share installed Roto Brush models.
models_dir: config_dir().map(|d| d.join("models")),
exporter: Some(Arc::new(FileExporter::default())),
@@ -90,11 +90,11 @@ pub fn session() -> Session {
importer: Some(Arc::new(MediaImporter)),
expr: Some(Arc::new(effectcraft_expr::Expressions)),
expr_check: Some(effectcraft_expr::check_syntax),
script: Some(effectcraft_script::runner),
script_ui: effectcraft_engine::scriptui::ScriptUi { dispatch: Some(effectcraft_script::dispatch_ui), ..Default::default() },
plugin_loader: effectcraft_plugin::wasm_available().then_some(effectcraft_plugin::loader as effectcraft_engine::PluginLoader),
..Default::default()
}
};
effectcraft_script::install(&mut s);
s
}
/// The platform config directory for EffectCraft (`EFFECTCRAFT_CONFIG_DIR` overrides):
@@ -109,6 +109,20 @@ pub fn config_dir() -> Option<std::path::PathBuf> {
mod tests {
use serde_json::json;
#[test]
fn host_runs_tasks_scheduled_by_panel_handlers() {
let mut s = super::session();
let result = s.execute("script.run", json!({"code": "var w=new Window('palette','Timer regression'); var b=w.add('button',undefined,'Import'); b.onClick=function(){app.scheduleTask(\"app.project.items.addComp('Scheduled',64,64,1,1,25)\",0,false);};w.show();"})).unwrap();
assert_eq!(result["ok"], true, "{result}");
s.execute("scriptui.click", json!({"window":"Timer regression","widget":"Import"})).unwrap();
assert!(s.project.items.is_empty());
let tick = s.execute("script.runTasks", json!({})).unwrap();
assert_eq!(tick["ran"], 1, "{tick}");
assert_eq!(tick["errors"], json!([]), "{tick}");
assert!(s.project.items.values().any(|item| item.name == "Scheduled"));
s.execute("scriptui.close", json!({"window":"Timer regression"})).unwrap();
}
/// Layer ▸ Camera ▸ Link Focus Distance to Point of Interest / to Layer: the expressions
/// evaluate to the camera's focus on the target.
#[test]
+3
View File
@@ -27,3 +27,6 @@ boa_engine = { workspace = true, features = ["js"] }
[dev-dependencies]
effectcraft-expr = { workspace = true }
effectcraft-psd = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
socket2 = "0.6"
+198 -17
View File
@@ -51,6 +51,7 @@ mod imp {
use std::collections::BTreeMap;
use std::io::{ErrorKind, Read, Write};
use std::net::{Shutdown, TcpListener, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -65,9 +66,11 @@ mod imp {
/// socket blocked on a slow peer doesn't hold up the others (#1248).
struct Entry {
sock: Mutex<Sock>,
/// A second handle on a stream, so `close` can shut it down while another call is
/// blocked reading or writing it.
/// A second handle on a stream, so `close` can wake a blocked read.
wake: Option<TcpStream>,
/// Closed or unusable after a mode error. Writes poll it because Windows sends may not
/// be interrupted by shutdown alone; queued operations recheck it after locking.
closed: AtomicBool,
}
struct Table {
@@ -85,7 +88,7 @@ mod imp {
Sock::Stream { s, .. } => s.try_clone().ok(),
Sock::Listener(_) => None,
};
let entry = Arc::new(Entry { sock: Mutex::new(s), wake });
let entry = Arc::new(Entry { sock: Mutex::new(s), wake, closed: AtomicBool::new(false) });
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
let id = t.next;
t.next += 1;
@@ -101,11 +104,23 @@ mod imp {
/// Forget socket `id`; a call blocked on it fails at once.
fn close(id: u32) {
let entry = table().lock().unwrap_or_else(|e| e.into_inner()).socks.remove(&id);
if let Some(w) = entry.as_ref().and_then(|e| e.wake.as_ref()) {
let _ = w.shutdown(Shutdown::Both);
if let Some(entry) = entry {
entry.closed.store(true, Ordering::Release);
if let Some(w) = &entry.wake {
let _ = w.shutdown(Shutdown::Both);
}
}
}
/// A test-only handle for establishing backpressure before exercising the public operations.
#[cfg(test)]
pub(super) fn test_stream(id: u32) -> TcpStream {
let entry = get(id).unwrap();
let sock = entry.sock.lock().unwrap();
let Sock::Stream { s, .. } = &*sock else { panic!("expected a stream") };
s.try_clone().unwrap()
}
fn timeout(p: &J) -> Duration {
Duration::from_secs_f64(p.get("timeout").and_then(J::as_f64).unwrap_or(10.0).clamp(0.0, 3600.0))
}
@@ -150,7 +165,56 @@ mod imp {
Ok(())
}
/// A failed mode transition leaves the stream unusable; reject queued operations and wake I/O.
fn change_mode(s: &TcpStream, entry: &Entry, nonblocking: bool, mode: &impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> std::io::Result<()> {
mode(s, nonblocking).map_err(|e| {
entry.closed.store(true, Ordering::Release);
let _ = s.shutdown(Shutdown::Both);
let action = if nonblocking { "enable nonblocking mode" } else { "restore blocking mode" };
std::io::Error::other(format!("socket is unusable: cannot {action}: {e}"))
})
}
/// Write with one deadline and an explicit close signal. On Windows, shutdown alone does
/// not reliably interrupt a blocking send, so never leave a send blocked inside the OS.
/// The caller holds this socket's lock; restore blocking mode or retire the connection.
fn write(s: &mut TcpStream, data: &[u8], limit: Duration, entry: &Entry, mode: &impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> std::io::Result<()> {
change_mode(s, entry, true, mode)?;
let end = Instant::now() + limit;
let result = (|| {
let mut pending = data;
while !pending.is_empty() {
if entry.closed.load(Ordering::Acquire) {
return Err(std::io::Error::new(ErrorKind::ConnectionAborted, "the socket was closed"));
}
let left = end.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(std::io::Error::from(ErrorKind::TimedOut));
}
match s.write(pending) {
Ok(0) => return Err(std::io::Error::from(ErrorKind::WriteZero)),
Ok(n) => pending = pending.get(n..).ok_or_else(|| std::io::Error::from(ErrorKind::InvalidData))?,
Err(e) if e.kind() == ErrorKind::WouldBlock => std::thread::sleep(left.min(Duration::from_millis(5))),
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(())
})();
match (result, change_mode(s, entry, false, mode)) {
(Err(write), Err(restore)) => Err(std::io::Error::other(format!("{restore}; write also failed: {write}"))),
(result, Ok(())) => result,
(Ok(()), Err(restore)) => Err(restore),
}
}
/// Dispatch an authorized operation without holding the socket table during I/O.
pub(super) fn run(op: &str, id: u32, p: &J) -> J {
run_with_mode(op, id, p, TcpStream::set_nonblocking)
}
/// Dispatch with a mode setter so exceptional OS failures can be injected per operation.
pub(super) fn run_with_mode(op: &str, id: u32, p: &J, mode: impl Fn(&TcpStream, bool) -> std::io::Result<()>) -> J {
let utf8 = p.get("encoding").and_then(J::as_str).is_some_and(|e| e.eq_ignore_ascii_case("utf-8") || e.eq_ignore_ascii_case("utf8"));
match op {
"open" => {
@@ -198,6 +262,15 @@ mod imp {
};
};
let mut sock = entry.sock.lock().unwrap_or_else(|e| e.into_inner());
// Recheck after locking: close or a failed mode transition may have retired it
// while this caller waited with an Arc obtained from the table.
if entry.closed.load(Ordering::Acquire) {
return match op {
"connected" => json!({"value": false}),
"eof" => json!({"value": true}),
_ => err("the socket is closed or unusable"),
};
}
match (op, &mut *sock) {
("poll", Sock::Listener(l)) => match l.accept() {
Ok((s, peer)) => {
@@ -212,14 +285,18 @@ mod imp {
("connected", Sock::Stream { s, buf, eof }) => {
if !*eof && buf.is_empty() {
// Peek without blocking to notice a closed peer.
let _ = s.set_nonblocking(true);
if let Err(e) = change_mode(s, &entry, true, &mode) {
return err(e);
}
let mut b = [0u8; 1];
match s.peek(&mut b) {
Ok(0) => *eof = true,
Err(e) if e.kind() != ErrorKind::WouldBlock => *eof = true,
_ => {}
}
let _ = s.set_nonblocking(false);
if let Err(e) = change_mode(s, &entry, false, &mode) {
return err(e);
}
}
json!({"value": !(*eof && buf.is_empty())})
}
@@ -265,11 +342,8 @@ mod imp {
// A peer that stops reading fills the buffers: give up after `timeout`
// instead of blocking the script for good (#1248).
let limit = timeout(p).max(Duration::from_millis(1));
if let Err(e) = s.set_write_timeout(Some(limit)) {
return err(format!("cannot set the write timeout: {e}"));
}
let data = p.get("data").and_then(J::as_str).unwrap_or("");
match s.write_all(&encode(data, utf8)).and_then(|_| s.flush()) {
match write(s, &encode(data, utf8), limit, &entry, &mode) {
Ok(()) => json!({"ok": true}),
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => err(format!(
"write timed out after {} s: the other side isn't reading (some of the data may have been sent)",
@@ -289,6 +363,7 @@ mod imp {
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
use std::io::{ErrorKind, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc;
use std::time::{Duration, Instant};
@@ -301,6 +376,7 @@ mod tests {
/// `host:port` and the accepted stream's keeper (drop it to let the connection go).
fn stalled_peer() -> (String, mpsc::Sender<()>) {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
socket2::SockRef::from(&l).set_recv_buffer_size(4096).unwrap();
let host = l.local_addr().unwrap().to_string();
let (stop, wait) = mpsc::channel::<()>();
std::thread::spawn(move || {
@@ -310,14 +386,32 @@ mod tests {
(host, stop)
}
/// Open a test connection through the production socket dispatcher.
fn open(host: &str) -> u32 {
let r = run("open", 0, &json!({"host": host, "timeout": 5}));
r["id"].as_u64().unwrap_or_else(|| panic!("open {host}: {r}")) as u32
}
/// More than the loopback buffers hold, so a peer that doesn't read stops the write.
fn big() -> String {
"x".repeat(64 * 1024 * 1024)
/// Fill bounded socket buffers nonblocking, rather than assuming a 64 MiB write stalls.
/// In particular, Windows may accept that entire write with its default send buffering.
fn backpressure(id: u32) {
let mut stream = super::imp::test_stream(id);
socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap();
stream.set_nonblocking(true).unwrap();
let start = Instant::now();
let chunk = [b'x'; 4096];
let mut sent = 0;
loop {
assert!(sent < 16 * 1024 * 1024 && start.elapsed() < Duration::from_secs(5), "could not establish backpressure");
match stream.write(&chunk) {
Ok(0) => panic!("peer closed while establishing backpressure"),
Ok(n) => sent += n,
Err(e) if e.kind() == ErrorKind::WouldBlock => break,
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => panic!("establishing backpressure: {e}"),
}
}
stream.set_nonblocking(false).unwrap();
}
/// Run `f` on a thread; its result, or `None` if it takes longer than `limit`.
@@ -329,31 +423,36 @@ mod tests {
rx.recv_timeout(limit).ok()
}
/// A bounded write times out even when the peer never drains its receive buffer.
#[test]
fn a_write_to_a_peer_that_stops_reading_fails_after_the_timeout() {
// #1248: it used to block for good.
let (host, _keep) = stalled_peer();
let id = open(&host);
let data = big();
backpressure(id);
let data = "x".repeat(64 * 1024);
let t0 = Instant::now();
let r = within(Duration::from_secs(20), move || run("write", id, &json!({"data": data, "timeout": 1}))).expect("the write gave up");
assert!(r["error"].as_str().is_some_and(|e| e.contains("timed out")), "{r}");
assert!(t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed());
assert!(t0.elapsed() >= Duration::from_millis(500) && t0.elapsed() < Duration::from_secs(15), "{:?}", t0.elapsed());
run("close", id, &J::Null);
}
/// Closing a stalled stream interrupts its writer without blocking another socket.
#[test]
fn a_blocked_socket_doesnt_hold_up_the_others_and_close_interrupts_it() {
let (host_a, _keep_a) = stalled_peer();
let (host_b, _keep_b) = stalled_peer();
let (a, b) = (open(&host_a), open(&host_b));
// A write on A that would wait a minute.
let data = big();
backpressure(a);
let data = "x".repeat(64 * 1024);
let (tx, blocked) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(run("write", a, &json!({"data": data, "timeout": 60})));
});
std::thread::sleep(Duration::from_millis(500));
assert!(matches!(blocked.try_recv(), Err(mpsc::TryRecvError::Empty)), "A must still be writing before close");
// Socket B answers meanwhile (the table isn't locked by A's write).
let r = within(Duration::from_secs(3), move || run("connected", b, &json!({}))).expect("socket B answered while A was blocked");
assert_eq!(r["value"], true, "{r}");
@@ -363,4 +462,86 @@ mod tests {
assert!(r.get("error").is_some(), "{r}");
run("close", b, &J::Null);
}
/// Failed mode changes retire the stream; subsequent I/O fails instead of using it.
#[test]
fn mode_restoration_failure_retires_streams() {
for (op, fail_nonblocking) in [("write", false), ("connected", false), ("write", true), ("connected", true)] {
let (host, _keep) = stalled_peer();
let id = open(&host);
let result = super::imp::run_with_mode(op, id, &json!({"data": "hello"}), |s, nonblocking| {
if nonblocking == fail_nonblocking { Err(std::io::Error::other("injected mode failure")) } else { s.set_nonblocking(nonblocking) }
});
assert!(result["error"].as_str().is_some_and(|e| e.contains("injected mode failure")), "{op}: {result}");
assert_eq!(run("connected", id, &J::Null)["value"], false, "{op}: broken socket remains connected");
assert_eq!(run("eof", id, &J::Null)["value"], true);
for operation in ["read", "readln", "write"] {
let r = run(operation, id, &json!({"count": 1, "timeout": 0.01, "data": "x"}));
assert!(r.get("error").is_some(), "{operation} must reject the unusable stream: {r}");
}
run("close", id, &J::Null);
run("close", id, &J::Null);
}
}
/// A preceding write error cannot hide the failure that makes the stream unusable.
#[test]
fn restoration_failure_preserves_the_write_error_too() {
let (host, _keep) = stalled_peer();
let id = open(&host);
let result = super::imp::run_with_mode("write", id, &json!({"data": "hello"}), |s, nonblocking| {
if nonblocking {
s.set_nonblocking(true)?;
s.shutdown(std::net::Shutdown::Write)
} else {
Err(std::io::Error::other("injected restore failure"))
}
});
let error = result["error"].as_str().unwrap();
assert!(error.contains("injected restore failure") && error.contains("write also failed"), "{error}");
assert_eq!(run("connected", id, &J::Null)["value"], false);
run("close", id, &J::Null);
}
/// Both outgoing and accepted sockets deliver complete data, then return to blocking reads.
#[test]
fn writes_deliver_all_data_and_restore_blocking_reads() {
for accepted in [false, true] {
let (id, mut peer) = if accepted {
let listener = run("listen", 0, &json!({"port": 0}));
let listener_id = listener["id"].as_u64().unwrap() as u32;
let peer = TcpStream::connect(("127.0.0.1", listener["port"].as_u64().unwrap() as u16)).unwrap();
let start = Instant::now();
let id = loop {
if let Some(id) = run("poll", listener_id, &J::Null)["id"].as_u64() {
break id as u32;
}
assert!(start.elapsed() < Duration::from_secs(5));
std::thread::sleep(Duration::from_millis(1));
};
run("close", listener_id, &J::Null);
(id, peer)
} else {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let id = open(&listener.local_addr().unwrap().to_string());
(id, listener.accept().unwrap().0)
};
socket2::SockRef::from(&super::imp::test_stream(id)).set_send_buffer_size(4096).unwrap();
peer.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let reader = std::thread::spawn(move || {
let mut data = vec![0; 256 * 1024];
peer.read_exact(&mut data).unwrap();
assert!(data.iter().all(|b| *b == b'x'));
// A read on a socket accidentally left nonblocking would return before this.
std::thread::sleep(Duration::from_millis(100));
peer.write_all(b"ack").unwrap();
});
let r = within(Duration::from_secs(10), move || run("write", id, &json!({"data": "x".repeat(256 * 1024), "timeout": 5}))).unwrap();
assert_eq!(r["ok"], true, "{r}");
let r = run("read", id, &json!({"count": 3, "timeout": 2}));
assert_eq!(r["data"], "ack", "{r}");
run("close", id, &J::Null);
reader.join().unwrap();
}
}
}
+32 -5
View File
@@ -179,7 +179,11 @@ pub fn parse_color(s: &str) -> Option<[f64; 4]> {
return None;
}
let comp = |t: &str| -> Option<f64> {
Some(if let Some(p) = t.strip_suffix('%') { p.trim().parse::<f64>().ok()? / 100.0 } else { t.parse::<f64>().ok()? / 255.0 })
Some(if let Some(p) = t.strip_suffix('%') {
p.trim().parse::<f64>().ok().filter(|v| v.is_finite())? / 100.0
} else {
t.parse::<f64>().ok().filter(|v| v.is_finite())? / 255.0
})
};
let alpha = a.get(3).map_or(Some(1.0), |t| alpha(t))?;
return Some([comp(a[0])?.clamp(0.0, 1.0), comp(a[1])?.clamp(0.0, 1.0), comp(a[2])?.clamp(0.0, 1.0), alpha]);
@@ -189,9 +193,9 @@ pub fn parse_color(s: &str) -> Option<[f64; 4]> {
if a.len() < 3 {
return None;
}
let h = a[0].trim_end_matches("deg").parse::<f64>().ok()?.rem_euclid(360.0) / 360.0;
let s = a[1].trim_end_matches('%').parse::<f64>().ok()?.clamp(0.0, 100.0) / 100.0;
let l = a[2].trim_end_matches('%').parse::<f64>().ok()?.clamp(0.0, 100.0) / 100.0;
let h = a[0].trim_end_matches("deg").parse::<f64>().ok().filter(|v| v.is_finite())?.rem_euclid(360.0) / 360.0;
let s = a[1].trim_end_matches('%').parse::<f64>().ok().filter(|v| v.is_finite())?.clamp(0.0, 100.0) / 100.0;
let l = a[2].trim_end_matches('%').parse::<f64>().ok().filter(|v| v.is_finite())?.clamp(0.0, 100.0) / 100.0;
let al = a.get(3).map_or(Some(1.0), |t| alpha(t))?;
let [r, g, b] = hsl(h, s, l);
return Some([r, g, b, al]);
@@ -208,7 +212,14 @@ fn split_args(s: &str) -> Vec<&str> {
}
fn alpha(t: &str) -> Option<f64> {
Some(if let Some(p) = t.strip_suffix('%') { p.parse::<f64>().ok()? / 100.0 } else { t.parse::<f64>().ok()? }.clamp(0.0, 1.0))
Some(
if let Some(p) = t.strip_suffix('%') {
p.parse::<f64>().ok().filter(|v| v.is_finite())? / 100.0
} else {
t.parse::<f64>().ok().filter(|v| v.is_finite())?
}
.clamp(0.0, 1.0),
)
}
/// CSS HSL → RGB (CSS Color 3 §4.2.4).
@@ -239,6 +250,22 @@ fn hsl(h: f64, s: f64, l: f64) -> [f64; 3] {
mod tests {
use super::parse_color;
#[test]
fn non_finite_rejected() {
for s in [
"rgb(nan,0,0)",
"rgb(inf,0,0)",
"rgba(0,0,0,nan)",
"rgba(0,0,0,inf)",
"hsl(nan,100%,50%)",
"hsl(120,nan%,50%)",
"hsl(120,100%,inf%)",
"hsl(120,100%,50%,nan)",
] {
assert_eq!(parse_color(s), None, "{s}");
}
}
#[test]
fn colors() {
assert_eq!(parse_color("#f00"), Some([1.0, 0.0, 0.0, 1.0]));
+1 -1
View File
@@ -15,7 +15,7 @@ pub(crate) use ui::catalog_characters;
pub(crate) use ui::{set_current, tr, tr_args};
/// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them.
const CATALOG_CODES: [&str; 11] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs", "ka"];
const CATALOG_CODES: [&str; 11] = ["cs", "de", "es", "fr", "pt-br", "ru", "uk", "ka", "ja", "zh-hans", "zh-hant"];
/// The active interface language: the stored preference when it names a catalog, otherwise `en`
/// (English is the source of every label and needs no lookup).
+4
View File
@@ -1784,7 +1784,11 @@ impl EffectcraftApp {
if self.session.script_ui.task_hosts.is_empty() {
return;
}
let previous_comp = self.session.active_comp_id();
let Some(r) = effectcraft_engine::scriptui::ScriptUi::run_tasks(&mut self.session) else { return };
if self.session.active_comp_id().is_some() && self.session.active_comp_id() != previous_comp {
self.ui.start_screen = false;
}
if let Some(msg) = r["errors"].as_array().and_then(|e| e.first()).and_then(|e| e["message"].as_str()) {
self.toast = Some((format!("Scheduled script task: {msg}"), ctx.input(|i| i.time)));
} else if let Some(line) = r["output"].as_array().and_then(|o| o.last()).and_then(|l| l.as_str()) {
+28 -2
View File
@@ -330,7 +330,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
}
return r;
}
if no_params(&params) && app.ui.focused == PanelKind::Project {
// The Project-panel focus redirects apply only while no text edit is open: creating a text
// layer leaves focus on Project while the insertion point lives in the comp, so during an
// edit these commands address the text context — redirected, Deselect All would clear a
// project selection the user is not looking at and silently swallow the edit's own end.
if no_params(&params) && app.ui.focused == PanelKind::Project && app.session.state.text_edit.is_none() {
match id {
"edit.duplicate" => return run_engine(app, ctx, "project.duplicate", json!({})),
"edit.selectAll" => {
@@ -1622,7 +1626,10 @@ pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool>
}
pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
if app.ui.focused == PanelKind::Project {
// The Project-panel enabled rules mirror the invoke redirects, so they stand down for the
// same reason (see the redirect block): while a text edit is open the menu items must be
// enabled by what their routed command can do, or the menu grays out commands that work.
if app.ui.focused == PanelKind::Project && app.session.state.text_edit.is_none() {
match e.command.as_str() {
"edit.duplicate" => {
return !app.session.state.project_selection.is_empty()
@@ -1790,6 +1797,25 @@ fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool {
want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.command && cfg!(not(target_os = "macos")))
}
/// Whether the `key`+`mods` chord is the current binding of `command` in the active preset
/// (so callers that must match a shortcut themselves — like the viewer's text editor, which
/// holds the keyboard while the shortcut dispatcher is skipped — follow rebinds).
/// `Physical+`-only bindings never match here, exactly as for the app-wide dispatcher.
pub fn chord_matches(session: &effectcraft_engine::Session, command: &str, key: egui::Key, mods: egui::Modifiers) -> bool {
bindings(session).into_iter().any(|(m, k, id, _)| id == command && k == key && mods_match(m, mods))
}
/// Whether a Deselect All chord matched by [`chord_matches`] may run while a text editor holds
/// the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as
/// such for undo and the rest), and function keys never type, so those forward from the editor;
/// a chord that could instead produce a character — a bare or Shift-only letter, digit or
/// punctuation key, as from a pathological bare-letter rebinding — stays with the editor so
/// typing keeps working, the same invariant the app-wide dispatcher honors by skipping
/// focused text fields.
pub fn forwardable_during_text_edit(mods: egui::Modifiers, key: egui::Key) -> bool {
mods.command || mods.ctrl || mods.alt || (egui::Key::F1..=egui::Key::F35).contains(&key)
}
/// Set (egui temp data) from a plain Spacebar press until its release while a tap would still
/// run the Spacebar shortcut.
/// The system clipboard's text of this frame's paste event, for the Edit ▸ Paste it runs.
+1 -4
View File
@@ -337,10 +337,7 @@ fn prop_row(
ParamUi::Percent => "%",
_ => "",
};
let speed = match &prop.ui {
ParamUi::Slider { slider_min, slider_max, .. } => ((slider_max - slider_min) / 300.0).clamp(0.001, 50.0),
_ => 0.5,
};
let speed = widgets::slider_scrub_speed(&prop.ui);
let (vr, nv, _) = widgets::hot_number_at(
ui,
pos2(vx, cy - 9.0),
+13 -4
View File
@@ -589,6 +589,7 @@ fn browser_storage_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, a
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
}
/// Draw the editable label colors and names in Settings.
fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mut Vec<Act>) {
ui.label(tr("Label Colors and Names:"));
ui.add_space(4.0);
@@ -622,6 +623,7 @@ fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mu
}
}
/// Draw the Settings dialog, including its translated page list and the selected page's controls.
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let all = pages();
let mut page = app.dialog_state.settings_page.clone();
@@ -661,28 +663,35 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
{
let ui = &mut body;
// Page list (left, like After Effects).
let widest_page = all
.iter()
.map(|p| ui.painter().layout_no_wrap(crate::i18n::settings_page_title(p.title).to_owned(), Tokens::ui(12.5), t.text).size().x)
.fold(0.0_f32, f32::max);
let page_width = (widest_page + 20.0).max(176.0).min((ui.available_width() * 0.42).min(300.0));
ui.vertical(|ui| {
ui.set_width(180.0);
ui.set_width(page_width + 4.0);
egui::ScrollArea::vertical().id_salt("settings-pages").max_height(body_h).auto_shrink([false, false]).show(ui, |ui| {
// The rows touch, so the pointer never falls between two of them.
ui.spacing_mut().item_spacing.y = 0.0;
for p in &all {
let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(page_width, 24.0), egui::Sense::click());
let sel = page == p.id;
if sel {
ui.painter().rect_filled(r, 3.0, t.accent);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
ui.painter().text(
crate::widgets::text_fit(
ui.painter(),
r.left_center() + vec2(10.0, 0.0),
egui::Align2::LEFT_CENTER,
crate::i18n::settings_page_title(p.title),
Tokens::ui(12.5),
(page_width - 20.0).max(0.0),
if sel { Color32::WHITE } else { t.text },
);
app.auto.add(&format!("settings.page.{}", p.id), r, p.title);
if resp.clicked() {
if resp.on_hover_text(crate::i18n::settings_page_title(p.title)).clicked() {
page = p.id.to_string();
}
}
+111 -10
View File
@@ -267,10 +267,25 @@ fn whip_into_id(prop: u64) -> egui::Id {
egui::Id::new(("tl-pickwhip-into", prop))
}
/// After a pick whip into the expression being edited: its editor takes the focus back, the
/// cursor this many characters before the end (just after the reference).
fn expr_refocus_id(prop: u64) -> egui::Id {
egui::Id::new(("tl-expr-refocus", prop))
/// An expression editor to take the keyboard when it is next drawn: its property, the text to
/// select as counts of characters before the end (so it fits the expression as it will be then)
/// and the pass it was asked for in. After a pick whip into the expression being edited, the
/// cursor goes just after the reference; after an Alt-click on a stopwatch adds an expression,
/// its whole text is selected, so typing replaces it.
#[derive(Clone, Copy, Debug)]
struct ExprFocus {
prop: u64,
select: [usize; 2],
pass: u64,
}
fn expr_focus_id() -> egui::Id {
egui::Id::new("tl-expr-focus")
}
fn focus_expression(ctx: &egui::Context, prop: u64, select: [usize; 2]) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(expr_focus_id(), ExprFocus { prop, select, pass }));
}
#[derive(Clone, Debug)]
@@ -762,15 +777,22 @@ fn display_name(app: &EffectcraftApp, l: &Layer) -> String {
/// layer whose names contain `q` (lowercase), each with its enclosing groups. A matching group
/// brings its whole contents.
fn search_rows(l: &Layer, q: &str) -> Vec<Row> {
/// The row for a group in search results: depth, its effect's enabled flag, and the eye
/// switch for layer-style groups (not Blending Options).
fn group_row(l: &Layer, g: &PropGroup, depth: usize, parent: &PropGroup) -> Row {
let fx = matches!(g.kind, GroupKind::Effect { .. }).then_some(g.enabled);
let eye =
(parent.match_id == effectcraft_engine::project::styles::GROUP && g.match_id != effectcraft_engine::project::styles::BLENDING).then_some(g.enabled);
Row { layer: l.id, depth, kind: RowKind::Group { uid: g.uid, name: g.name.clone(), open: true, has_children: !g.children.is_empty(), fx, eye } }
}
/// Every visible child of `g` at `depth`, groups recursed into whole — a matching group
/// brings its contents. A shape group's inner Contents is elided (as in AE): no row, and
/// its items list at the same depth as the group itself.
fn all(l: &Layer, g: &PropGroup, depth: usize, out: &mut Vec<Row>) {
for c in &g.children {
match c {
// A shape group's inner Contents is elided (as in AE): no row, items at the same depth.
Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => all(l, sg, depth, out),
Node::Group(sg) => {
out.push(group_row(l, sg, depth, g));
all(l, sg, depth + 1, out);
@@ -780,9 +802,14 @@ fn search_rows(l: &Layer, q: &str) -> Vec<Row> {
}
}
}
/// The children of `g` that match `q`: a name-matching group brings its whole contents
/// (`all`); otherwise the search recurses and keeps only subtrees with hits. A shape
/// group's inner Contents is searched through without a row of its own (as in AE).
fn rec(l: &Layer, g: &PropGroup, depth: usize, q: &str, out: &mut Vec<Row>) {
for c in &g.children {
match c {
// A shape group's inner Contents is elided (as in AE): search through it, no row.
Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => rec(l, sg, depth, q, out),
Node::Group(sg) if depth > 1 || group_visible(sg, l) => {
// An effect group also matches the name the row draws, so a search finds
// `高斯模糊` in a Chinese interface and `Gaussian Blur` in an English one.
@@ -1186,6 +1213,10 @@ fn collect_props(g: &PropGroup, out: &mut Vec<u64>, f: &dyn Fn(&Property) -> boo
}
}
/// The rows for the children of `g` at `depth`: groups recurse into when their twirl-down is
/// in `open`, text animators list their selectors and properties directly (as in AE), and a
/// shape group's inner Contents (AE's elided `ADBE Vectors Group`) is skipped so its items
/// list directly under the group.
fn push_group(rows: &mut Vec<Row>, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet<u64>) {
for c in &g.children {
match c {
@@ -1193,6 +1224,11 @@ fn push_group(rows: &mut Vec<Row>, l: &Layer, g: &PropGroup, depth: usize, open:
Node::Group(sg) if g.match_id == "animator" && matches!(sg.match_id.as_str(), "selectors" | "properties") => {
push_group(rows, l, sg, depth, open);
}
// A shape group's inner Contents (AE's elided `ADBE Vectors Group`): the group's items
// (path, fill/stroke, Transform) list directly under it, with no "Contents" row.
Node::Group(sg) if g.match_id == "group" && sg.match_id == "contents" => {
push_group(rows, l, sg, depth, open);
}
Node::Group(sg) => {
let o = open.contains(&sg.uid);
let fx = matches!(sg.kind, GroupKind::Effect { .. }).then_some(sg.enabled);
@@ -1968,6 +2004,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let rh = t.row_h;
let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum();
let max_scroll = (total_h - rows_rect.height() + rh).max(0.0);
// An expression editor asked to take the keyboard comes into view; a request whose
// expression didn't appear, or that wasn't taken within a few passes, is dropped (it never
// takes the keyboard later).
if let Some(f) = ctx.data(|d| d.get_temp::<ExprFocus>(expr_focus_id())) {
match rows.iter().position(|r| matches!(r.kind, RowKind::Expr { uid, .. } if uid == f.prop)) {
Some(i) if ctx.cumulative_pass_nr() <= f.pass.saturating_add(8) => {
let top: f32 = rows.iter().take(i).map(|r| row_height(r, rh)).sum();
let fits = (top + rows.get(i).map_or(rh, |r| row_height(r, rh)) - rows_rect.height()).max(0.0).min(top);
app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(top).max(fits);
}
_ => ctx.data_mut(|d| d.remove::<ExprFocus>(expr_focus_id())),
}
}
if scroll_to == Some(crate::state::ScrollTo::Layer) {
// The topmost selected layer, in stack order.
let sel = &app.session.state.selected_layers;
@@ -2781,12 +2830,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg);
let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0));
let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
if let Some(after) = ctx.data_mut(|d| d.remove_temp::<usize>(expr_refocus_id(*uid))) {
// A pick whip just put a reference in: editing goes on in the new text,
// the cursor after the reference.
if let Some(f) = ctx.data(|d| d.get_temp::<ExprFocus>(expr_focus_id())).filter(|f| f.prop == *uid) {
// Asked for once (a pick whip put a reference in, or an Alt-click added the
// expression): editing starts in the expression as it is now.
ctx.data_mut(|d| d.remove::<ExprFocus>(expr_focus_id()));
buf = ex.text.clone();
let n = buf.chars().count();
let [a, b] = f.select.map(|before_end| egui::text::CCursor::new(n.saturating_sub(before_end)));
let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default();
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(buf.chars().count().saturating_sub(after)))));
st.cursor.set_char_range(Some(egui::text::CCursorRange::two(a, b)));
st.store(&ctx, edit_id);
ctx.memory_mut(|m| m.request_focus(edit_id));
}
@@ -2884,6 +2936,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if ui.input(|i| i.modifiers.alt) {
if resp.clicked() {
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
if prop.expr.is_none() {
// Adding one: its editor opens, comes into view and takes the
// keyboard with the new expression selected (#1072).
app.ui.timeline.expr_closed.remove(uid);
focus_expression(&ctx, *uid, [usize::MAX, 0]);
} else if ctx.data(|d| d.get_temp::<ExprFocus>(expr_focus_id())).is_some_and(|f| f.prop == *uid) {
// Removing it: nothing is left to take the keyboard.
ctx.data_mut(|d| d.remove::<ExprFocus>(expr_focus_id()));
}
}
} else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge})));
@@ -3208,7 +3269,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
params["expression"] = json!(text);
params["range"] = json!([a, b]);
let after = text.chars().count().saturating_sub(b);
ctx.data_mut(|d| d.insert_temp(expr_refocus_id(src_prop), after));
focus_expression(&ctx, src_prop, [after, after]);
}
actions.push(("prop.pickWhip".into(), params));
}
@@ -4273,7 +4334,7 @@ fn value_editor(
match value {
Value::Scalar(v) => {
let (min, max, dec, speed) = match &prop.ui {
ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, ((max - min) / 400.0).clamp(0.01, 10.0)),
ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)),
ParamUi::Percent => (-1e6, 1e6, 1, 0.5),
_ => (-1e9, 1e9, 1, 1.0),
};
@@ -4612,6 +4673,46 @@ mod tests {
assert!(labels(&app).iter().any(|r| r.trim() == "Scale"));
}
/// A shape group's inner Contents (AE's elided `ADBE Vectors Group`) draws no row: the
/// search shows the full row hierarchy with the group's items — path, fill and Transform —
/// listing directly under the group, and the only "Contents" row the layer's own.
#[test]
fn shape_group_inner_contents_is_elided() {
let mut s = effectcraft_engine::Session::default();
s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 4})).unwrap();
s.execute("layer.newShape", json!({"kind": "rect"})).unwrap();
let mut app = EffectcraftApp::new(s);
app.ui.timeline.search = "rect".into();
assert_eq!(
labels(&app),
[
"Shape Layer 1",
" >Contents",
" >Rectangle 1",
" >Rectangle Path 1",
" Size",
" Position",
" Roundness",
" >Fill 1",
" Composite",
" Fill Rule",
" Color",
" Opacity",
" >Transform",
" Anchor Point",
" Position",
" Scale",
" Skew",
" Skew Axis",
" Rotation",
" Opacity",
]
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
);
}
#[test]
fn columns_toggle_and_layout() {
let mut app = app();
+6 -3
View File
@@ -1011,9 +1011,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.add(egui::Shape::closed_line(outline.into_iter().map(|p| map.to_screen(p)).collect(), Stroke::new(1.0, col)));
}
}
// A layer whose Video switch is off has no box, handles or paths in the viewer (a hidden
// null disappears from the comp, as in After Effects), even while it is selected.
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && l.switches.video) {
// The eye hides a layer's pixels, not its controls: a selected AV layer keeps its box,
// handles, anchor, motion path and mask/shape paths so it stays editable over the visible
// artwork (#1769). Nulls, cameras and lights have no pixels and disappear from the viewer
// when hidden (#1332/#1642). Picking still uses `selectable_layers`, so a hidden layer is
// selected from the Timeline; its controls are interactive once selected.
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && (l.switches.video || l.source.is_av())) {
let col = if label_handles { t.label(l.label) } else { t.accent };
// Motion path of animated position (keyframes and spatial tangents are draggable).
ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits, &mut motion_frames);
+16 -2
View File
@@ -486,10 +486,14 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
Key::Escape => {
exec(app, "text.endEdit", json!({}));
}
Key::A if cmd => {
// The editor owns exactly Cmd/Ctrl+A (Select All) and Cmd/Ctrl+Z with or
// without Shift (Undo, Redo). Other modifiers on those keys aren't editing
// chords, so they fall through to the binding match below: Shift+A is
// Deselect All's default, and Alt variants are free for a rebound one.
Key::A if cmd && !m.shift && !m.alt => {
exec(app, "text.setSelection", json!({"select": "all"}));
}
Key::Z if cmd => {
Key::Z if cmd && !m.alt => {
let _ = crate::menus::invoke(app, &ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({}));
}
Key::Comma | Key::Period if cmd && m.shift => {
@@ -499,6 +503,16 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
}
// The app-wide shortcut dispatcher is skipped while this editor holds the
// keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
// must be matched here and run through the command dispatcher like undo
// above. Matching the command's current binding, not a fixed key, keeps
// rebound shortcuts working and gives every route one final state; the
// forwarding test also refuses chords a focused editor must never surrender
// (see `forwardable_during_text_edit`).
_ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
let _ = crate::menus::invoke(app, &ctx, "edit.deselectAll", json!({}));
}
_ => {}
}
if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
+21
View File
@@ -2,6 +2,8 @@
use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use effectcraft_project::ParamUi;
use crate::automation::Registry;
use crate::icons::{self, Icon};
use crate::theme::Tokens;
@@ -145,6 +147,16 @@ pub fn proportional_scrub(m: egui::Modifiers) -> bool {
m.command && m.alt
}
/// How far one pointer pixel scrubs a hot-text field: the property's slider (soft) range over
/// 300 px. The Timeline and Effect Controls share this so they cannot drift; a large hard range
/// only allows typing big values, it must not make dragging coarse (#2097).
pub fn slider_scrub_speed(ui: &ParamUi) -> f64 {
match ui {
ParamUi::Slider { slider_min, slider_max, .. } => ((slider_max - slider_min) / 300.0).clamp(0.001, 50.0),
_ => 0.5,
}
}
/// [`hot_number_at`] for a whole number (an angle's revolutions): a drag steps it once every
/// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames),
/// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished).
@@ -773,6 +785,15 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
mod tests {
use super::*;
/// #2097: the drag speed comes from the slider range, not a much wider hard range.
#[test]
fn slider_scrub_speed_uses_the_slider_range() {
let slider = |min, max, slider_min, slider_max| ParamUi::Slider { min, max, slider_min, slider_max, decimals: 1 };
assert!((slider_scrub_speed(&slider(0.0, 10_000.0, 0.0, 1_000.0)) - 1_000.0 / 300.0).abs() < 1e-9);
assert!((slider_scrub_speed(&slider(0.0, 1.0e9, 0.0, 1.0)) - 1.0 / 300.0).abs() < 1e-9);
assert_eq!(slider_scrub_speed(&ParamUi::Number), 0.5);
}
/// #488: an inline editor selects its whole text only as it opens. Should it be without the
/// keyboard for a frame later (an input method, another handler), it takes the keyboard back
/// with the caret where the user put it instead of selecting everything again, which made
+92
View File
@@ -540,6 +540,98 @@ fn arrow_keys_move_the_caret_in_rename_fields() {
assert_eq!(layer_name, "Pla-te");
}
/// A solid named `name` with its Transform group open in the Timeline; returns the layer, its
/// Opacity uid and its Rotation uid.
fn open_transform(h: &mut Harness<'_, EffectcraftApp>, name: &str) -> (u64, u64, u64) {
let (layer, opacity, rotation, transform) = {
let s = &mut h.state_mut().session;
let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone();
(l, tr.get("opacity").unwrap().uid, tr.get("rotation").unwrap().uid, tr.uid)
};
h.state_mut().ui.timeline.open_layers.insert(layer);
h.state_mut().ui.timeline.open_groups.insert(transform);
h.run_steps(3);
(layer, opacity, rotation)
}
fn expression(h: &Harness<'_, EffectcraftApp>, layer: u64, prop: u64) -> Option<String> {
let comp = h.state().session.active_comp().unwrap();
comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(prop).unwrap().expr.as_ref().map(|e| e.text.clone())
}
fn commit_expression(h: &mut Harness<'_, EffectcraftApp>) {
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND));
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
h.step();
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
/// #1072: Alt-clicking a stopwatch adds an expression and its editor takes the keyboard at once,
/// the new expression selected, so typing replaces it (it took another click). That happens
/// once: a field clicked next keeps the keyboard, and removing the expression with another
/// Alt-click leaves nothing to take it.
#[test]
fn alt_clicking_a_stopwatch_focuses_the_new_expression() {
let (mut h, _) = harness();
let (layer, opacity, rotation) = open_transform(&mut h, "Plate");
let alt = Modifiers { alt: true, ..Modifiers::NONE };
let stopwatch = |h: &Harness<'_, EffectcraftApp>, prop: u64| center(h, &format!("timeline.prop.{prop}.stopwatch"));
let at = stopwatch(&h, opacity);
click_with(&mut h, at, alt);
assert!(expression(&h, layer, opacity).is_some(), "an expression was added");
type_text(&mut h, "50");
commit_expression(&mut h);
assert_eq!(expression(&h, layer, opacity).as_deref(), Some("50"), "typing replaced the new expression");
// A field clicked right after an Alt-click keeps the keyboard.
let at = stopwatch(&h, rotation);
click_with(&mut h, at, alt);
let added = expression(&h, layer, rotation).expect("an expression on Rotation");
let p = center(&h, "timeline.timecode");
click(&mut h, p, 2);
type_text(&mut h, "0:00:01:00");
key(&mut h, Key::Enter);
let frame = h.state().session.active_comp().unwrap().frame_rate.frame_at(h.state().session.time());
assert_eq!((frame, expression(&h, layer, rotation)), (24, Some(added)), "the time took the typing, not the expression");
// Alt-click again removes the expression; nothing takes the keyboard after it.
let at = stopwatch(&h, opacity);
click_with(&mut h, at, alt);
h.run_steps(5);
assert_eq!(expression(&h, layer, opacity), None);
assert!(!h.ctx.egui_wants_keyboard_input(), "nothing has the keyboard");
}
/// #1072: an expression added at the bottom of the Timeline brings its editor into view to take
/// the keyboard (its row is added below the last one shown).
#[test]
fn an_expression_added_at_the_bottom_of_the_timeline_comes_into_view() {
let (mut h, _) = harness();
let (layer, opacity, _) = open_transform(&mut h, "Bottom");
for i in 0..40 {
h.state_mut().session.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap();
}
h.run_steps(3);
// Scroll to the end (the layer is at the bottom of the stack), then until Opacity is the
// last row shown.
h.state_mut().ui.timeline.scroll_y = 1.0e6;
h.run_steps(3);
let rows = h.state().auto.find("timeline.scroll").unwrap().rect;
let bottom = rows[1] + rows[3];
let row = h.state().auto.find(&format!("timeline.prop.{opacity}.row")).unwrap().rect;
h.state_mut().ui.timeline.scroll_y += row[1] + row[3] - bottom;
h.run_steps(3);
let row = h.state().auto.find(&format!("timeline.prop.{opacity}.row")).unwrap().rect;
assert!((row[1] + row[3] - bottom).abs() < 1.0, "Opacity is at the bottom: {row:?}");
let at = center(&h, &format!("timeline.prop.{opacity}.stopwatch"));
click_with(&mut h, at, Modifiers { alt: true, ..Modifiers::NONE });
let editor = h.state().auto.find(&format!("timeline.prop.{opacity}.expression")).expect("the editor is drawn").rect;
assert!(editor[1] >= rows[1] - 0.5 && editor[1] + editor[3] <= bottom + 0.5, "the editor is in view: {editor:?} in {rows:?}");
type_text(&mut h, "25");
commit_expression(&mut h);
assert_eq!(expression(&h, layer, opacity).as_deref(), Some("25"));
}
/// #1434: with several 3D layers selected, typing a Z Position sets Z on each of them; their X
/// and Y stay their own. Every selected layer used to take the typed layer's whole Position, so
/// they stacked on top of it and seemed to disappear.
+15
View File
@@ -271,3 +271,18 @@ fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) {
}
h.run_steps(3);
}
#[test]
fn scheduled_import_reveals_its_composition_without_manual_clicks() {
let mut s = effectcraft_host::session();
let result = s.execute("script.run", json!({"code": "var w=new Window('palette','Automatic import');var b=w.add('button',undefined,'Open');b.onClick=function(){app.scheduleTask(\"var c=app.project.items.addComp('Imported',64,64,1,1,25);c.openInViewer();\",0,false);};w.show();"})).unwrap();
assert_eq!(result["ok"], true, "{result}");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
h.state_mut().ui.start_screen = true;
h.run_steps(2);
h.state_mut().session.execute("scriptui.click", json!({"window":"Automatic import","widget":"Open"})).unwrap();
h.run_steps(4);
let active = h.state().session.active_comp_id().unwrap();
assert_eq!(h.state().session.project.items.get(&active).unwrap().name, "Imported");
assert!(!h.state().ui.start_screen, "Home must not hide a composition opened by the scheduled import");
h.state_mut().session.execute("scriptui.close", json!({"window":"Automatic import"})).unwrap();
}
+26
View File
@@ -8,6 +8,7 @@ use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
/// Open the demo project in a UI test harness with the requested window size.
fn harness(w: f32, h: f32) -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
@@ -16,6 +17,7 @@ fn harness(w: f32, h: f32) -> Harness<'static, EffectcraftApp> {
h
}
/// Return a registered widget's rectangle so UI tests can inspect its layout.
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
@@ -236,3 +238,27 @@ fn settings_dialog_does_not_scroll_sideways() {
h.run_steps(20);
assert_eq!((rect(&h, "settings.ok"), rect(&h, "settings.page.general")), (ok, page), "nothing moved sideways");
}
/// #2034: the Settings sidebar uses the translated page names when deciding its width.
#[test]
fn settings_page_column_fits_japanese_names() {
use effectcraft_ui_egui::theme::Tokens;
let mut session = Session::default();
session.prefs.general.language = "ja".into();
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session));
h.run_steps(3);
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "scripting"})).unwrap();
h.run_steps(3);
// The panel titles a page with its Settings menu row, minus the ellipsis.
let name = effectcraft_ui_egui::menus::menu_items(h.state())
.into_iter()
.find(|m| m.id == "app.settings" && m.params["page"] == "scripting")
.and_then(|m| m.label.strip_suffix("...").map(str::to_owned))
.expect("the Settings menu lists the Scripting & Expressions page");
let text_width = h.ctx.fonts_mut(|fonts| fonts.layout_no_wrap(name.clone(), Tokens::ui(12.5), egui::Color32::WHITE).size().x);
let row = rect(&h, "settings.page.scripting");
assert!(row.width() >= text_width + 20.0, "{name} needs {text_width} points; row is {row:?}");
}
+166 -12
View File
@@ -16,6 +16,11 @@ fn app() -> EffectcraftApp {
EffectcraftApp::new(s)
}
/// A headless window at the size and frame step the viewer tests here are written for.
fn harness(a: EffectcraftApp) -> Harness<'static, EffectcraftApp> {
Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
@@ -103,7 +108,7 @@ fn modal_blocks_background_text_events() {
#[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());
let mut h = harness(app());
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
@@ -172,7 +177,7 @@ fn clicking_another_panel_stops_typing_into_text() {
let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let title =
s.execute("layer.newText", json!({"text": "Title", "size": 80, "position": [100, 120], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
let layers = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.len();
@@ -231,7 +236,7 @@ fn opening_a_formatting_tab_keeps_the_text_selection() {
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid =
s.execute("layer.newText", json!({"text": "Hello world", "size": 80, "position": [60, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.run_steps(3);
let ctx = h.ctx.clone();
// Effects & Presets shown, Properties, Character and Paragraph behind it.
@@ -261,7 +266,7 @@ fn keys_after_a_click_away_in_the_same_frame_skip_the_text() {
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let lid = s.execute("layer.newText", json!({"text": "Hello", "size": 80, "position": [60, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().session.execute("text.edit", json!({"layer": lid, "caret": 5})).unwrap();
@@ -293,7 +298,7 @@ fn keys_after_a_click_away_in_the_same_frame_skip_the_text() {
#[test]
fn switching_from_type_to_selection_commits_and_moves_text_layer() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
let mut h = harness(app());
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
@@ -327,7 +332,7 @@ fn double_click_enters_editing_and_selects_words() {
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid =
s.execute("layer.newText", json!({"text": "alpha beta", "size": 80, "position": [100, 200], "justify": "left"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
// A point inside "beta" (comp space → window).
@@ -368,7 +373,7 @@ fn double_click_enters_editing_and_selects_words() {
#[test]
fn type_tool_drag_makes_paragraph_text_with_box_handles() {
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app());
let mut h = harness(app());
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
@@ -418,7 +423,7 @@ fn layer_new_text_menu_and_shortcut_start_typing_into_source_text() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
if via_shortcut {
@@ -447,7 +452,7 @@ fn timeline_double_click_on_a_text_layer_edits_its_text() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "alpha beta"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| EffectcraftApp::new(s));
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Composition);
h.run_steps(3);
let p = rect(&h, &format!("timeline.layer.{lid}.{target}")).center();
@@ -474,7 +479,7 @@ fn duplicated_text_boxes_both_remain_editable() {
.unwrap()["layer"]
.as_u64()
.unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
let mut h = harness(a);
h.run_steps(3);
h.state_mut().ui.focused = PanelKind::Composition;
key(&mut h, Key::D, Modifiers::COMMAND);
@@ -500,7 +505,7 @@ fn duplicated_text_boxes_both_remain_editable() {
fn overlapping_duplicate_text_remains_editable_by_double_click_and_selected_type_tool() {
let mut a = app();
let original = a.session.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "box": [40, 60, 250, 100]})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
let mut h = harness(a);
h.run_steps(3);
h.state_mut().ui.focused = PanelKind::Composition;
key(&mut h, Key::D, Modifiers::COMMAND);
@@ -562,7 +567,7 @@ fn type_tool_selected_text_preference_skips_ineligible_layers_and_falls_back_to_
}
a.session.state.selected_layers = if exclusion == "unselected" { vec![] } else { vec![LayerId(original)] };
a.ui.tool = effectcraft_ui_egui::state::Tool::Type;
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_| a);
let mut h = harness(a);
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
let p = comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0);
@@ -570,3 +575,152 @@ fn type_tool_selected_text_preference_skips_ineligible_layers_and_falls_back_to_
assert_eq!(edited(&h).0, topmost, "{exclusion} selected text must not override the eligible hit");
}
}
/// Opens a fresh Type-tool edit holding `text` and returns the harness with the new layer id,
/// so the Deselect All tests start from the same state.
fn type_tool_edit(a: EffectcraftApp, text: &str) -> (Harness<'static, EffectcraftApp>, u64) {
let mut h = harness(a);
h.run_steps(3);
h.state_mut().ui.tool = effectcraft_ui_egui::state::Tool::Type;
h.run_steps(1);
let comp = rect(&h, "viewer.comp");
let p = comp.min + egui::vec2(180.0, 160.0) * (comp.width() / 640.0);
click(&mut h, p, Modifiers::NONE);
type_text(&mut h, text);
let (lid, _, _, _) = edited(&h);
(h, lid)
}
#[test]
fn deselect_all_chords_end_a_type_tool_edit() {
for (name, k, mods) in [("Cmd/Ctrl+Shift+A", Key::A, Modifiers { shift: true, ..Modifiers::COMMAND }), ("F2", Key::F2, Modifiers::NONE)] {
let (mut h, lid) = type_tool_edit(app(), "Hello");
let (_, text, _, _) = edited(&h);
assert_eq!(text, "Hello", "{name}: the Type tool click and typing made a layer");
key(&mut h, k, mods);
assert!(h.state().session.state.text_edit.is_none(), "{name}: Deselect All ends the edit");
assert!(h.state().session.state.selected_layers.is_empty(), "{name}: and deselects the layer");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Hello", "{name}: the typed text is kept");
}
}
#[test]
fn cmd_a_in_a_text_edit_still_selects_all_characters() {
let (mut h, _lid) = type_tool_edit(app(), "Hello");
key(&mut h, Key::A, Modifiers::COMMAND);
let (_, _, a, c) = edited(&h);
assert_eq!((a, c), (0, 5), "Select All keeps selecting every character");
assert!(h.state().session.state.text_edit.is_some(), "the edit stays open");
assert_eq!(h.state().session.state.selected_layers.len(), 1, "and the layer stays selected");
}
#[test]
fn rebound_deselect_all_chord_forwards_from_inside_the_edit() {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": ["Cmd+Alt+D"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
// The chord belongs to the shortcut, not to a hard-coded key: the old defaults do
// nothing special while the editor holds the keyboard…
key(&mut h, Key::A, Modifiers { shift: true, ..Modifiers::COMMAND });
assert!(h.state().session.state.text_edit.is_some(), "Cmd+Shift+A was rebound away");
let (_, _, a, c) = edited(&h);
assert_eq!((a, c), (5, 5), "and Cmd+Shift+A no longer selects all characters either");
key(&mut h, Key::F2, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_some(), "nor does F2");
// …and the current binding is what ends the edit.
key(&mut h, Key::D, Modifiers { alt: true, ..Modifiers::COMMAND });
assert!(h.state().session.state.text_edit.is_none(), "the rebound chord ends the edit");
assert!(h.state().session.state.selected_layers.is_empty(), "and deselects");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Hello", "the text is kept");
}
#[test]
fn deselect_all_rebound_onto_an_alt_variant_of_an_editor_chord_forwards() {
let alt_cmd = |k| (k, Modifiers { alt: true, ..Modifiers::COMMAND });
for (bound, (k, mods)) in [("Cmd+Alt+Z", alt_cmd(Key::Z)), ("Cmd+Alt+A", alt_cmd(Key::A))] {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": [bound]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
// The editor owns Cmd+A and Cmd+Z themselves, whatever Deselect All is bound to…
key(&mut h, Key::A, Modifiers::COMMAND);
let (_, _, a0, c0) = edited(&h);
assert_eq!((a0, c0), (0, 5), "{bound}: Cmd+A still selects every character");
type_text(&mut h, "Hi");
key(&mut h, Key::Z, Modifiers::COMMAND);
assert_eq!(edited(&h).1, "Hello", "{bound}: Cmd+Z still undoes the typing");
// …but not their Alt variants, which belong to the rebound Deselect All.
key(&mut h, k, mods);
assert!(h.state().session.state.text_edit.is_none(), "{bound}: the rebound chord ends the edit");
assert!(h.state().session.state.selected_layers.is_empty(), "{bound}: and deselects");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Hello", "{bound}: without undoing or selecting text");
}
}
#[test]
fn bare_letter_rebinding_stays_typing_but_a_function_key_rebinding_forwards() {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": ["A"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
// A binding without a command modifier is text as far as the editor is concerned: the
// keystroke belongs to the text, since a command may not steal a key that types
// (the app-wide dispatcher honors the same invariant by skipping focused text fields).
key(&mut h, Key::A, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_some(), "a bare-letter Deselect All binding does not end the edit");
type_text(&mut h, "a");
let (lid2, _, a0, c0) = edited(&h);
assert_eq!(lid2, lid, "the same edit on the same layer continues");
assert_eq!((a0, c0), (6, 6), "the letter typed at the caret");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Helloa", "and landed in the text");
// A function key types nothing, so a modifier-free function-key binding is forwarded
// (the default F2 relies on this).
h.state_mut().session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": ["F7"]})).unwrap();
key(&mut h, Key::F7, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_none(), "a bare function-key binding ends the edit");
assert!(h.state().session.state.selected_layers.is_empty(), "and deselects");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Helloa", "the text is kept");
}
/// Opens a text edit the way a user does from the Project panel — Layer ▸ New ▸ Text's shortcut
/// while that panel is focused — so the insertion point lives in the comp while focus stays on
/// Project. Returns the harness and the new layer id.
fn project_focused_text_edit(text: &str) -> (Harness<'static, EffectcraftApp>, u64) {
let mut h = harness(app());
h.run_steps(3);
h.state_mut().ui.focused = PanelKind::Project;
h.run_steps(1);
key(&mut h, Key::T, Modifiers { alt: true, shift: true, ..Modifiers::COMMAND });
type_text(&mut h, text);
let (lid, typed, _, _) = edited(&h);
assert_eq!(typed, text, "typing went into the new layer");
assert_eq!(h.state().ui.focused, PanelKind::Project, "the Project panel is still the focused panel");
(h, lid)
}
#[test]
fn deselect_all_chords_end_an_edit_started_while_the_project_panel_is_focused() {
for (name, k, mods) in [("Cmd/Ctrl+Shift+A", Key::A, Modifiers { shift: true, ..Modifiers::COMMAND }), ("F2", Key::F2, Modifiers::NONE)] {
let (mut h, lid) = project_focused_text_edit("Hi");
key(&mut h, k, mods);
assert!(h.state().session.state.text_edit.is_none(), "{name}: Deselect All ends the edit");
assert!(h.state().session.state.selected_layers.is_empty(), "{name}: and deselects the layer");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Hi", "{name}: the typed text is kept");
}
}
#[test]
fn deselect_all_menu_item_follows_the_edit_while_the_project_panel_is_focused() {
let enabled = |h: &Harness<'_, EffectcraftApp>| {
effectcraft_ui_egui::menus::menu_items(h.state()).into_iter().find(|m| m.id == "edit.deselectAll").is_some_and(|m| m.enabled)
};
let (mut h, _) = project_focused_text_edit("Hi");
h.state_mut().session.execute("project.select", json!({"items": []})).unwrap();
h.run_steps(1);
assert!(h.state().session.state.text_edit.is_some(), "the edit is still open");
// The item ends the edit, so an empty Project selection must not gray it out.
assert!(enabled(&h), "Deselect All is enabled during the edit");
key(&mut h, Key::Escape, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_none(), "Escape ended the edit");
assert_eq!(h.state().ui.focused, PanelKind::Project, "the Project panel is still the focused panel");
// With no edit, the item deselects Project items again: nothing selected there, nothing to do.
assert!(!enabled(&h), "Deselect All follows the empty Project selection once the edit is over");
}
@@ -75,6 +75,29 @@ fn shape_contents_add_menu_adds_path_operations() {
assert_eq!(items, ["group", "trim"]);
}
/// A shape group's inner Contents is elided in After Effects: opening the group lists its items
/// (here the path) directly, with no "Contents" row between them.
#[test]
fn shape_group_contents_has_no_inner_contents_row() {
let mut s = session();
let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let contents = layer.props.sub("contents").unwrap().clone();
let group = contents.groups().next().unwrap().clone();
let inner = group.sub("contents").unwrap().clone();
let item = inner.groups().next().unwrap().clone();
let mut h = harness(s);
h.state_mut().ui.timeline.open_layers.insert(l);
h.state_mut().ui.timeline.open_groups.insert(contents.uid);
h.state_mut().ui.timeline.open_groups.insert(group.uid);
h.run_steps(2);
let name = |uid: u64| format!("timeline.group.{uid}.name");
assert!(h.state().auto.find(&name(contents.uid)).is_some(), "the layer's Contents row");
assert!(h.state().auto.find(&name(group.uid)).is_some(), "the shape group row");
assert!(h.state().auto.find(&name(inner.uid)).is_none(), "no inner Contents row");
assert!(h.state().auto.find(&name(item.uid)).is_some(), "the path lists directly under the group");
}
#[test]
fn classic_3d_shape_layers_offer_change_renderer() {
let mut s = session();
+68
View File
@@ -759,6 +759,74 @@ fn path_editing_hides_layer_bounds_and_keeps_corner_vertices_editable() {
}
}
/// #1769: the eye hides a layer's pixels, not the controls of a selected AV layer: a hidden shape
/// layer keeps its transform box, anchor and shape path, and its handles still scale it.
#[test]
fn hidden_shape_layer_keeps_its_controls_and_can_still_scale() {
let mut h = harness();
let (layer, path) = {
let r = h
.state_mut()
.session
.execute(
"shape.newPath",
json!({
"vertices": [[200.0, 100.0], [400.0, 100.0], [400.0, 260.0], [200.0, 260.0]],
"closed": true,
"fill": false,
"stroke": "#ffffff",
"strokeWidth": 4
}),
)
.unwrap();
(r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap())
};
h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap();
h.state_mut().session.state.selected_vertices.clear();
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [layer], "switch": "video", "value": false})).unwrap();
h.run_steps(2);
let shown = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().clone();
assert!(!shown.switches.video);
assert!(shown.source.is_av());
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(layer)]);
assert!(h.state().auto.find(&format!("viewer.handle.{layer}.0")).is_some(), "a hidden shape keeps its transform box");
assert!(h.state().auto.find(&format!("viewer.anchor.{layer}")).is_some(), "and its anchor");
assert!(h.state().auto.find(&format!("viewer.shapePath.{path}.vertex.0")).is_some(), "and its shape path");
let scale = |h: &Harness<'_, EffectcraftApp>| {
h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/scale").unwrap().value.as_vec3()
};
let before = scale(&h);
let from = rect(&h, &format!("viewer.handle.{layer}.5")).center();
drag(&mut h, from, from + vec2(24.0, 0.0));
assert_ne!(scale(&h), before, "a hidden layer's handle still scales it");
}
/// #1769: a hidden masked layer keeps its mask path and vertices, and they remain editable.
#[test]
fn hidden_masked_layer_keeps_its_mask_path_editable() {
let mut h = harness();
let plate = layer_id(&h, "Plate");
let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]];
let mask = {
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [plate]})).unwrap();
let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap();
s.state.selected_vertices.clear();
s.execute("layer.setSwitch", json!({"layers": [plate], "switch": "video", "value": false})).unwrap();
m
};
h.run_steps(2);
assert!(h.state().auto.find(&format!("viewer.handle.{plate}.0")).is_some(), "a hidden masked layer keeps its transform box");
let vertex = format!("viewer.mask.{mask}.vertex.0");
assert!(h.state().auto.find(&vertex).is_some(), "and its mask path");
let before = mask_path(&h, "Plate");
let from = rect(&h, &vertex).center();
drag(&mut h, from, from + vec2(15.0, 12.0));
let after = mask_path(&h, "Plate");
assert_ne!(after.vertices[0], before.vertices[0], "the hidden layer's mask vertex can still be moved");
assert_eq!(after.vertices[1..], before.vertices[1..]);
}
#[test]
fn path_tools_keep_bounds_on_layers_without_visible_paths() {
let mut h = harness();
+1 -1
View File
@@ -38,7 +38,7 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params
| `engine.execute` | `{command, params?}` | The command's result. Every engine command (`effectcraft-cli commands`) runs exactly as it does headless, so it never opens a dialog: `comp.new {}` creates a default comp. UI-only ids (`tool.*`, `view.*`, `window.*`, `playback.*`, `timeline.*`, `app.*`) fall through to the menu dispatcher. Unknown top-level params are rejected, and the error lists the accepted keys. |
| `ui.menu.invoke` | `{id, params?}` | Like a menu click: commands with empty params may open their dialog (New Composition, Solid Settings, file pickers). |
| Copy a Media Browser source path | `ui.menu.invoke {id: "mediaBrowser.copySourcePath", params: {path}}` | Copies the exact non-empty path string to the clipboard and returns `{path}`. Supports file/folder paths and virtual source identifiers. Missing, empty or non-string paths return an error. |
| UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.reveal.<kind>` / `timeline.revealAdd.<kind>` (the property shortcuts and Shift+shortcut: position, scale, rotation, opacity, anchor, effects, masks, feather, levels, animated, and the double-press sets maskPath, maskOpacity, timeRemap, expressions, material, paint, selected, missingEffects, waveform), `timeline.keyAt.<anchor\|position\|scale\|rotation\|opacity>` (Alt+Shift+A/P/S/R/T), `timeline.twirlSelected` (Ctrl/Cmd+`), `timeline.collapseAll`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). |
| UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.reveal.<kind>` / `timeline.revealAdd.<kind>` (the property shortcuts and Shift+shortcut: position, scale, rotation, opacity, anchor, effects, masks, feather, levels, animated, and the double-press sets maskPath, maskOpacity, timeRemap, expressions, material, paint, selected, missingEffects, waveform), `timeline.keyAt.<anchor\|position\|scale\|rotation\|opacity>` (Alt+Shift+A/P/S/R/T), `timeline.twirlSelected` ``(Ctrl/Cmd+`)``, `timeline.collapseAll`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). |
| `engine.commands` | `{filter?, enabledOnly?}` | `[{id, label, menu, shortcut, params, enabled, why}]` |
| `ui.menu.list` | `{}` | The menu tree with enablement. |
| `render.frame` | `{comp?, time?, max_side?, path?, base64?}` | Renders a comp frame through the session, independent of the viewer zoom or resolution. `comp` is an id or name (default: the active comp), `time` is in comp seconds (default: the CTI), `max_side` caps the longest side (0 = full size). Writes a PNG to `path` (default: a temp file) and returns `{comp, time, width, height, path}`. With `base64: true` it returns `{…, png: "<base64>"}` and writes no file. |
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (shape group Contents row elided; path-editing layer bounds #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026
How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging,
modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence
@@ -17,7 +17,7 @@ feel and platform differences, and the 26.x interface changes are not in yet.
|---|---|---:|---|
| Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) |
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed |
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849) |
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a hidden null, camera or light has no box or handles in the viewer and isn't picked there; other selected hidden layers keep their layer controls and paths, #1769); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849); a shape group's inner Contents (After Effects' elided Vectors group) draws no row — the group's path, fill/stroke and Transform list directly under it |
| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu |
| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed |
| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed |
@@ -50,6 +50,7 @@ Effects running; most of the rest parallelises.
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | minor | A shape group's inner Contents (After Effects' elided Vectors group) draws no row: the group's path, fill/stroke and Transform list directly under the group |
| 2026-10-13 | patch | Layer bounds are suppressed during path-point editing (#1220); shape/mask interaction regressions |
| 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) |
| 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated |