From c979f585ac4cb3c456394dda151889af5a9e5846 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 13 Oct 2026 13:47:22 -0400 Subject: [PATCH 01/21] Timeline: focus newly added expressions after Alt-click (refs #1072) Alt-clicking a stopwatch added an expression, but its editor needed another click before typing went into it. In After Effects the new expression's field takes the keyboard at once, its text selected. The pick whip's one-shot refocus (the editor takes the keyboard back after a reference goes in) is now an ExprFocus request for any property: which text to select, counted from the end, and the pass it was asked for in. An Alt-click that adds an expression asks for it with the whole text selected, so typing replaces it; the expression row is opened if it was closed and scrolled into view (it is added below the stopwatch's row, which can be the last one shown). The request is taken once, when the editor is drawn, and dropped if its expression doesn't appear or it isn't taken within a few passes, so it never takes the keyboard later; an Alt-click that removes the expression clears it. Tests (ui_inline_edit): typing straight after the Alt-click replaces the new expression; a field clicked right after keeps the keyboard; after removing it nothing has the keyboard; an expression added at the Timeline's bottom brings its editor into view with the keyboard. Both fail on main. --- crates/ui-egui/src/panels/timeline.rs | 58 +++++++++++++--- crates/ui-egui/tests/ui_inline_edit.rs | 92 ++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..72c07110 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -265,10 +265,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)] @@ -1888,6 +1903,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::(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::(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; @@ -2630,12 +2658,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::(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::(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::(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)); } @@ -2733,6 +2764,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::(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::(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}))); @@ -3057,7 +3097,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)); } diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index dd20e55b..7bc5505e 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -539,3 +539,95 @@ fn arrow_keys_move_the_caret_in_rename_fields() { let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone(); 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 { + 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")); +} From 09dad1a51f14a8e80d986e2fb0ee4521986c2a05 Mon Sep 17 00:00:00 2001 From: Egon5000 <210198019+elZanco@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:49:37 +0200 Subject: [PATCH 02/21] Timeline: a shape group's inner Contents draws no row, as After Effects elides it --- crates/ui-egui/src/panels/timeline.rs | 24 ++++++++++++++++++++++ crates/ui-egui/tests/ui_timeline_shapes.rs | 23 +++++++++++++++++++++ docs/ui-parity.md | 5 +++-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index aff4ef2c..a51644a3 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -756,6 +756,8 @@ fn search_rows(l: &Layer, q: &str) -> Vec { fn all(l: &Layer, g: &PropGroup, depth: usize, out: &mut Vec) { 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); @@ -768,6 +770,8 @@ fn search_rows(l: &Layer, q: &str) -> Vec { fn rec(l: &Layer, g: &PropGroup, depth: usize, q: &str, out: &mut Vec) { 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. @@ -1178,6 +1182,11 @@ fn push_group(rows: &mut Vec, 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); @@ -4370,6 +4379,21 @@ 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: a search + /// finds the group's items directly, and the only "Contents" row is the layer's. + #[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(); + let rows = labels(&app); + assert!(rows.iter().any(|r| r.trim() == ">Rectangle 1"), "the shape group matches: {rows:?}"); + assert!(rows.iter().any(|r| r.trim() == ">Rectangle Path 1"), "its path lists directly under it: {rows:?}"); + assert_eq!(rows.iter().filter(|r| r.trim() == ">Contents").count(), 1, "only the layer's Contents: {rows:?}"); + } + #[test] fn columns_toggle_and_layout() { let mut app = app(); diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 3993a6f9..bc42b446 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -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(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5173e769..63442260 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (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 (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) | +| 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 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 | @@ -44,6 +44,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-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 | | 2026-10-10 | major | First UI parity checklist | From 80158bb69bafa94f7b63c27cb79d4b9cbe8c0a9e Mon Sep 17 00:00:00 2001 From: dorfee Date: Mon, 19 Oct 2026 16:45:39 +0300 Subject: [PATCH 03/21] ScriptUI: install scheduled task runner in host sessions --- crates/host/src/lib.rs | 22 ++++++++++++++++++---- crates/ui-egui/src/lib.rs | 4 ++++ crates/ui-egui/tests/ui_scriptui.rs | 15 +++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index bdb4e2bc..577492a5 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -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 { 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] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 72016cc5..c330bcc3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1775,7 +1775,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()) { diff --git a/crates/ui-egui/tests/ui_scriptui.rs b/crates/ui-egui/tests/ui_scriptui.rs index 6cfc2db7..31fb555d 100644 --- a/crates/ui-egui/tests/ui_scriptui.rs +++ b/crates/ui-egui/tests/ui_scriptui.rs @@ -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(); +} From e244d642350787aaa7028470855368165012d029 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:25:20 +0200 Subject: [PATCH 04/21] Reject non-finite numbers in CSS colour parsing Found while testing crates/svg/src/color.rs:182-185 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/color.rs | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/svg/src/color.rs b/crates/svg/src/color.rs index ba4183e7..9ef5faa0 100644 --- a/crates/svg/src/color.rs +++ b/crates/svg/src/color.rs @@ -179,7 +179,11 @@ pub fn parse_color(s: &str) -> Option<[f64; 4]> { return None; } let comp = |t: &str| -> Option { - Some(if let Some(p) = t.strip_suffix('%') { p.trim().parse::().ok()? / 100.0 } else { t.parse::().ok()? / 255.0 }) + Some(if let Some(p) = t.strip_suffix('%') { + p.trim().parse::().ok().filter(|v| v.is_finite())? / 100.0 + } else { + t.parse::().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::().ok()?.rem_euclid(360.0) / 360.0; - let s = a[1].trim_end_matches('%').parse::().ok()?.clamp(0.0, 100.0) / 100.0; - let l = a[2].trim_end_matches('%').parse::().ok()?.clamp(0.0, 100.0) / 100.0; + let h = a[0].trim_end_matches("deg").parse::().ok().filter(|v| v.is_finite())?.rem_euclid(360.0) / 360.0; + let s = a[1].trim_end_matches('%').parse::().ok().filter(|v| v.is_finite())?.clamp(0.0, 100.0) / 100.0; + let l = a[2].trim_end_matches('%').parse::().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 { - Some(if let Some(p) = t.strip_suffix('%') { p.parse::().ok()? / 100.0 } else { t.parse::().ok()? }.clamp(0.0, 1.0)) + Some( + if let Some(p) = t.strip_suffix('%') { + p.parse::().ok().filter(|v| v.is_finite())? / 100.0 + } else { + t.parse::().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])); From 0a78eb28708513cf01dbae9cff17151ac2a3c4a0 Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 12:56:33 -0400 Subject: [PATCH 05/21] fix(script): bound and cancel socket writes (#1248) --- Cargo.lock | 11 +++ crates/script/Cargo.toml | 3 + crates/script/src/socket.rs | 135 ++++++++++++++++++++++++++++++++---- 3 files changed, 134 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 412fb667..e2daffce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1591,6 +1591,7 @@ dependencies = [ "effectcraft-render", "effectcraft-time", "serde_json", + "socket2", "web-time", ] @@ -4815,6 +4816,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" diff --git a/crates/script/Cargo.toml b/crates/script/Cargo.toml index 10c635eb..6d03d1bd 100644 --- a/crates/script/Cargo.toml +++ b/crates/script/Cargo.toml @@ -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" diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 4030332d..3dee94de 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -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,10 @@ mod imp { /// socket blocked on a slow peer doesn't hold up the others (#1248). struct Entry { sock: Mutex, - /// 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, + /// Writes poll this flag because shutdown alone may not interrupt a Windows send. + closed: AtomicBool, } struct Table { @@ -85,7 +87,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 +103,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,6 +164,36 @@ mod imp { Ok(()) } + /// 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 before another operation. + fn write(s: &mut TcpStream, data: &[u8], limit: Duration, closed: &AtomicBool) -> std::io::Result<()> { + s.set_nonblocking(true)?; + let end = Instant::now() + limit; + let result = (|| { + let mut pending = data; + while !pending.is_empty() { + if 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(()) + })(); + let restored = s.set_nonblocking(false); + result.and(restored) + } + pub(super) fn run(op: &str, id: u32, p: &J) -> 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 { @@ -265,11 +309,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.closed) { 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 +330,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 +343,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 || { @@ -315,9 +358,26 @@ mod tests { 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`. @@ -334,11 +394,12 @@ mod tests { // #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); } @@ -348,12 +409,14 @@ mod tests { 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 +426,46 @@ mod tests { assert!(r.get("error").is_some(), "{r}"); run("close", b, &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(); + } + } } From 4a84b4da0814cd8021f9d4ac96628394320f631a Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 18:26:34 -0400 Subject: [PATCH 06/21] docs(script): explain socket dispatch and regression cases --- crates/script/src/socket.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 3dee94de..15d6bdb4 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -194,6 +194,7 @@ mod imp { result.and(restored) } + /// Dispatch an authorized operation without holding the socket table during I/O. pub(super) fn run(op: &str, id: u32, p: &J) -> 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 { @@ -353,6 +354,7 @@ 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 @@ -389,6 +391,7 @@ 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. @@ -403,6 +406,7 @@ mod tests { 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(); From f667b6e11ec73c4bf3126a050de7b8c828b23e0b Mon Sep 17 00:00:00 2001 From: TimotheusG Date: Mon, 19 Oct 2026 19:19:20 -0400 Subject: [PATCH 07/21] fix(script): retire streams after failed mode transitions --- crates/script/src/socket.rs | 92 +++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 10 deletions(-) diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 15d6bdb4..9d1f8798 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -68,7 +68,8 @@ mod imp { sock: Mutex, /// A second handle on a stream, so `close` can wake a blocked read. wake: Option, - /// Writes poll this flag because shutdown alone may not interrupt a Windows send. + /// 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, } @@ -164,16 +165,26 @@ 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 before another operation. - fn write(s: &mut TcpStream, data: &[u8], limit: Duration, closed: &AtomicBool) -> std::io::Result<()> { - s.set_nonblocking(true)?; + /// 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 closed.load(Ordering::Acquire) { + 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()); @@ -190,12 +201,20 @@ mod imp { } Ok(()) })(); - let restored = s.set_nonblocking(false); - result.and(restored) + 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" => { @@ -243,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)) => { @@ -257,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())}) } @@ -311,7 +343,7 @@ mod imp { // instead of blocking the script for good (#1248). let limit = timeout(p).max(Duration::from_millis(1)); let data = p.get("data").and_then(J::as_str).unwrap_or(""); - match write(s, &encode(data, utf8), limit, &entry.closed) { + 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)", @@ -431,6 +463,46 @@ mod tests { 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() { From 14453386ac201793756d9fad0486dbfc0e6bb080 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Tue, 20 Oct 2026 14:46:05 +0200 Subject: [PATCH 08/21] Viewer: keep hidden AV layers' controls visible (#1769) --- crates/ui-egui/src/panels/viewer.rs | 9 ++-- crates/ui-egui/tests/ui_viewer.rs | 68 +++++++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 984ad696..b8d8061b 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 2a9162a5..14464d18 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -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(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ccce70e0..d76e573f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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) | +| 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) | | 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 | From b24438f9bbd5d05f96a35913681bf7fd76647d32 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:40:59 -0400 Subject: [PATCH 09/21] CLI: template renders keep the template composition's name (#1937) --- apps/effectcraft-cli/src/template.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/effectcraft-cli/src/template.rs b/apps/effectcraft-cli/src/template.rs index 844c978b..8cf5c124 100644 --- a/apps/effectcraft-cli/src/template.rs +++ b/apps/effectcraft-cli/src/template.rs @@ -150,8 +150,11 @@ fn instantiate(s: &mut Session, source: u64, values: &Map, 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()))?; From 5fb5666d78c3fb66983dafed6efccd0f3f271ea4 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:56:25 -0400 Subject: [PATCH 10/21] Tests: template render outputs keep the template composition's name (#1937) --- apps/effectcraft-cli/tests/qa_template.rs | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/qa_template.rs index 8b877755..0618c0d6 100644 --- a/apps/effectcraft-cli/tests/qa_template.rs +++ b/apps/effectcraft-cli/tests/qa_template.rs @@ -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); +} From c839ae7598ae19481ab6e4a40fdb4813b0f4e92a Mon Sep 17 00:00:00 2001 From: Nikolai Nyegaard Date: Wed, 21 Oct 2026 12:01:47 +0200 Subject: [PATCH 11/21] Settings: list the languages alphabetically by their own name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones - The list used the registry order, which is the order the languages were added --- crates/engine/src/prefs.rs | 16 +++++++++------- crates/ui-egui/src/i18n.rs | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index a2ce579e..a6978135 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -26,16 +26,18 @@ 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, 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"), ("日本語", "ja"), ("简体中文", "zh-hans"), ("繁體中文", "zh-hant"), - ("Українська", "uk"), - ("Español", "es"), - ("Português (Brasil)", "pt-br"), - ("Русский", "ru"), - ("Français", "fr"), - ("Deutsch", "de"), - ("Čeština", "cs"), ]; /// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value). diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 8b8b01b5..ba7d73ad 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -13,7 +13,7 @@ use effectcraft_engine::menus::{MenuEntry, MenuNode}; 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; 10] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs"]; +const CATALOG_CODES: [&str; 10] = ["cs", "de", "es", "fr", "pt-br", "ru", "uk", "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). From 5668196288e34f7ef1969ba51b2c38950ccbd634 Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Wed, 21 Oct 2026 20:57:08 +0530 Subject: [PATCH 12/21] docs: escape the backtick in the twirlSelected shortcut (#2025) --- docs/control-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4d01ef2e..6cfbcd8d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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.` / `timeline.revealAdd.` (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.` (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.` / `timeline.revealAdd.` (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.` (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: ""}` and writes no file. | From a59e7ad67f0c31159a1b4c029e218ccb78535e28 Mon Sep 17 00:00:00 2001 From: Egon 5000 <210198019+elZanco@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:58:38 +0200 Subject: [PATCH 13/21] Timeline: assert the full search-row hierarchy for the elided Contents, add docstrings The search test now pins every row (layer, Contents, group, path/fill/ Transform) so a future regression that re-adds the inner Contents row or reorders the flattening fails loudly. Docstrings cover the functions the diff touches (group_row, all, rec, push_group) for CodeRabbit's docstring-coverage pre-merge check. --- crates/ui-egui/src/panels/timeline.rs | 49 +++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index fc09d30d..d122f503 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -747,12 +747,17 @@ 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 { + /// 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) { for c in &g.children { match c { @@ -767,6 +772,9 @@ fn search_rows(l: &Layer, q: &str) -> Vec { } } } + /// 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) { for c in &g.children { match c { @@ -1175,6 +1183,10 @@ fn collect_props(g: &PropGroup, out: &mut Vec, 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, l: &Layer, g: &PropGroup, depth: usize, open: &std::collections::BTreeSet) { for c in &g.children { match c { @@ -4428,8 +4440,9 @@ 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: a search - /// finds the group's items directly, and the only "Contents" row is the layer's. + /// 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(); @@ -4437,10 +4450,34 @@ mod tests { s.execute("layer.newShape", json!({"kind": "rect"})).unwrap(); let mut app = EffectcraftApp::new(s); app.ui.timeline.search = "rect".into(); - let rows = labels(&app); - assert!(rows.iter().any(|r| r.trim() == ">Rectangle 1"), "the shape group matches: {rows:?}"); - assert!(rows.iter().any(|r| r.trim() == ">Rectangle Path 1"), "its path lists directly under it: {rows:?}"); - assert_eq!(rows.iter().filter(|r| r.trim() == ">Contents").count(), 1, "only the layer's Contents: {rows:?}"); + 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::>() + ); } #[test] From ffe3c913e1aca5548cdfa0c952bc01b7c3ec336d Mon Sep 17 00:00:00 2001 From: Madhav Agarwal Date: Thu, 22 Oct 2026 19:05:30 +0530 Subject: [PATCH 14/21] ui: scrub hot text from the slider range (#2097) --- crates/ui-egui/src/panels/effect_controls.rs | 5 +---- crates/ui-egui/src/panels/timeline.rs | 2 +- crates/ui-egui/src/widgets.rs | 21 ++++++++++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 03b3a1ac..79f414af 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -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), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d..af4f7e28 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4105,7 +4105,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), }; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c005224d..d3d59e2c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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). @@ -666,6 +678,15 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { 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 From 52bfb9c4bbca6879e196039dbb419c2c4351a872 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:39:10 +0200 Subject: [PATCH 15/21] Reject non-hex characters such as '+' in Rgba::from_hex Found while testing crates/color/src/lib.rs:40-47 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 5545f390..a8957bcb 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -39,7 +39,13 @@ impl Rgba { /// `#rrggbb` or `#rrggbbaa`. pub fn from_hex(s: &str) -> Option { 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)?) }), @@ -262,6 +268,14 @@ mod tests { assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}"); } + #[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 { From fdfa286079da535e5b8480fcd9a3b66efdd513b4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:46:14 +0000 Subject: [PATCH 16/21] Keep rgb_to_hsv and rgb_to_hsl hue within 0..1 Found while testing crates/color/src/lib.rs:133 (rgb_to_hsv) and :96 (rgb_to_hsl) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 5545f390..4a280627 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -93,7 +93,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 +130,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 +262,14 @@ 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 srgb_roundtrip() { for i in 0..=100 { From ed532afe72d40a09ac4d736143ca5ef9d296cf13 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:12:38 +0200 Subject: [PATCH 17/21] Return None from Mat4::inverse for non-finite input Found while testing crates/geom/src/lib.rs:362-390 (pivot check at line 367) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/geom/src/lib.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index a03c9d46..d86784f1 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -360,6 +360,9 @@ impl Mat4 { } /// General inverse (Gauss-Jordan with partial pivoting). pub fn inverse(&self) -> Option { + 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); From 58d00e78d5ccb6847d013110ca3c562d824eb763 Mon Sep 17 00:00:00 2001 From: Markus Ang Date: Thu, 22 Oct 2026 19:41:49 -0400 Subject: [PATCH 18/21] #2034: fit translated Settings page names --- crates/ui-egui/src/panels/settings.rs | 15 +++++++++++---- crates/ui-egui/tests/ui_scrolling.rs | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 28a7c014..a386367d 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -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) { 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,33 @@ 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(tr(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, tr(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(tr(p.title)).clicked() { page = p.id.to_string(); } } diff --git a/crates/ui-egui/tests/ui_scrolling.rs b/crates/ui-egui/tests/ui_scrolling.rs index 5f681623..85cca07f 100644 --- a/crates/ui-egui/tests/ui_scrolling.rs +++ b/crates/ui-egui/tests/ui_scrolling.rs @@ -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])) @@ -163,3 +165,22 @@ 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); + + let name = "スクリプトとエクスプレッション"; + let text_width = h.ctx.fonts_mut(|fonts| fonts.layout_no_wrap(name.into(), 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:?}"); +} From 0558c9427c3f8ffb885121534a44da5a8c7108b0 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Fri, 23 Oct 2026 23:23:59 +0800 Subject: [PATCH 19/21] fix: end an open text edit when Deselect All runs (#2096) While a text layer was being edited, Edit > Deselect All and its shortcuts selected every character (or did nothing) instead of ending the edit and deselecting the layer. - deselect_all now commits the open edit first, through the same route as text.endEdit: a created-but-empty type-tool layer is discarded, anything typed is kept, and no extra undo step is introduced. - While the in-canvas editor holds the keyboard, the editor forwards the command's currently assigned shortcut (and any function-key chord) so Deselect All reaches the command from the comp; bare-letter bindings stay typing, and Select All still selects characters. - The Project-panel focus redirects (invocation and menu enabled-ness) stand down while a text edit is open, so the menu and shortcut end the edit even when focus stayed on Project after Layer > New > Text. Tests: three engine cases (commit + discard semantics), six UI kittest cases (chords, F2, rebinding, menu enabled-ness, Project focus), RED at base. --- crates/engine/src/commands/edit.rs | 4 + crates/engine/src/commands/text_edit.rs | 2 +- crates/engine/src/tests_text_edit.rs | 35 +++++ crates/ui-egui/src/menus.rs | 30 ++++- crates/ui-egui/src/panels/viewer_text.rs | 14 +- crates/ui-egui/tests/ui_text_edit.rs | 156 +++++++++++++++++++++-- 6 files changed, 225 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 019d56d1..5fa353aa 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -73,6 +73,10 @@ fn select_all(s: &mut Session, _: &Value) -> Result { Ok(json!(s.state.selected_layers.len())) } fn deselect_all(s: &mut Session, _: &Value) -> Result { + // 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(); diff --git a/crates/engine/src/commands/text_edit.rs b/crates/engine/src/commands/text_edit.rs index 5dcb6d09..9d12e458 100644 --- a/crates/engine/src/commands/text_edit.rs +++ b/crates/engine/src/commands/text_edit.rs @@ -159,7 +159,7 @@ fn edit(s: &mut Session, p: &Value) -> Result { } /// `text.endEdit`: stop editing (a Type-tool layer left empty is deleted). -fn end_edit(s: &mut Session, _: &Value) -> Result { +pub(crate) fn end_edit(s: &mut Session, _: &Value) -> Result { 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)?; diff --git a/crates/engine/src/tests_text_edit.rs b/crates/engine/src/tests_text_edit.rs index 243a9c67..b4d2ff8c 100644 --- a/crates/engine/src/tests_text_edit.rs +++ b/crates/engine/src/tests_text_edit.rs @@ -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"); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 92893dcb..755f5a03 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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" => { @@ -1606,7 +1610,10 @@ pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option } 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() @@ -1774,6 +1781,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. diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index 57b34b29..23e938f3 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -486,7 +486,9 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) { Key::Escape => { exec(app, "text.endEdit", json!({})); } - Key::A if cmd => { + // Cmd/Ctrl alone is Select All; with Shift it belongs to Deselect All, + // which the binding match below handles, so don't eat the chord here. + Key::A if cmd && !m.shift => { exec(app, "text.setSelection", json!({"select": "all"})); } Key::Z if cmd => { @@ -499,6 +501,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) { diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index d147529b..da78aa46 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -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,130 @@ 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 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"); +} From 0635649e42dd49d24d93d8fb9672dec99bede46e Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Sat, 24 Oct 2026 00:03:10 +0800 Subject: [PATCH 20/21] fix: let a rebound Deselect All use Alt variants of Cmd+A and Cmd+Z (#2096) The text editor's Select All and Undo arms ignored Alt, so a Deselect All rebound to Cmd+Alt+A or Cmd+Alt+Z was taken as Select All or Undo while a text edit was open. The arms now match exactly the chords the editor owns (Cmd/Ctrl+A, and Cmd/Ctrl+Z with or without Shift); their Alt variants fall through to the Deselect All binding match. Chords the editor owns still win over a rebound Deselect All, so Undo keeps working while typing. Test: a UI kittest rebinds Deselect All to each Alt variant and checks that Cmd+A and Cmd+Z still edit the text while the rebound chord ends the edit. --- crates/ui-egui/src/panels/viewer_text.rs | 10 ++++++---- crates/ui-egui/tests/ui_text_edit.rs | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index 23e938f3..c7169f41 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -486,12 +486,14 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) { Key::Escape => { exec(app, "text.endEdit", json!({})); } - // Cmd/Ctrl alone is Select All; with Shift it belongs to Deselect All, - // which the binding match below handles, so don't eat the chord here. - Key::A if cmd && !m.shift => { + // 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 => { diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index da78aa46..ed5baca2 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -634,6 +634,28 @@ fn rebound_deselect_all_chord_forwards_from_inside_the_edit() { 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(); From e3e9776c2583eed230aedd1cda6df0b0b3fab641 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 02:12:45 +0000 Subject: [PATCH 21/21] Batch fix: settings: size the page column from the titles it draws (#2171) --- crates/ui-egui/src/panels/settings.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b84d65d7..8edc40b4 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -663,8 +663,10 @@ 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(tr(p.title).to_owned(), Tokens::ui(12.5), t.text).size().x).fold(0.0_f32, f32::max); + 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(page_width + 4.0); @@ -689,7 +691,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if sel { Color32::WHITE } else { t.text }, ); app.auto.add(&format!("settings.page.{}", p.id), r, p.title); - if resp.on_hover_text(tr(p.title)).clicked() { + if resp.on_hover_text(crate::i18n::settings_page_title(p.title)).clicked() { page = p.id.to_string(); } }