mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Merge pull request #2264 from aiongg/contrib-batch-4
Integration batch 4: 15 contributor PRs (interface, scripting, hardening, docs)
This commit is contained in:
Generated
+11
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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]));
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -330,7 +330,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
}
|
||||
return r;
|
||||
}
|
||||
if no_params(¶ms) && 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(¶ms) && 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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user