From 223b21b06acd091c30e9b79fadf7037229f8fd1e Mon Sep 17 00:00:00 2001 From: Elias Date: Fri, 9 Oct 2026 23:36:22 +0100 Subject: [PATCH 1/7] timeline: trim zones straddle layer bar ends (AE), trim wins over slip The In/Out trim zones were 6 pt strips inside the bar only: at or just outside an end the pointer got the default cursor, and on footage/precomp layers just outside the end it hit the slip zone, which shows the same arrow but slips the source instead of trimming. Zones now reach 4 pt outside and up to 5 pt inside each end (short bars keep their middle third as body), registered after the slip zones so trimming wins there. The trim zones show the system resize cursor (upstream port: the fork also offers an owner-local cursor, left out here). Test: ui_timeline_trim. (cherry picked from commit e2490f801067365addc03858bd555731cb188ad1) (cherry picked from commit b704fc52c4fdb43816721e95d13f132a11062aa1) (cherry picked from commit 17faf9cebf9e347534ceab37386e79979da8f786) (cherry picked from commit 9b9d21aacc4d025e36e13a05e4416a49f448bdf1) --- crates/ui-egui/src/panels/timeline.rs | 27 ++++- crates/ui-egui/tests/ui_timeline_trim.rs | 121 +++++++++++++++++++++++ 2 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/ui_timeline_trim.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f10429..0fc8eec1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -69,6 +69,25 @@ pub(crate) fn bar_color(label: Color32, selected: bool) -> Color32 { Color32::from_rgb(mix(label.r()), mix(label.g()), mix(label.b())) } +/// How far a layer bar's trim zones reach past its ends and into it, in points. After Effects' +/// zones straddle each end, so the pointer needn't land inside the bar. +const TRIM_OUTSIDE: f32 = 4.0; +const TRIM_INSIDE: f32 = 5.0; + +/// A layer bar's body (drag to move) and its In/Out trim zones. Short bars keep their middle +/// third as body; a bar too short for that is all trim zones (the Out one on top). +fn trim_zones(bar: Rect) -> (Rect, Rect, Rect) { + let inside = TRIM_INSIDE.min(bar.width() / 3.0).max(0.0); + let in_r = Rect::from_min_max(pos2(bar.min.x - TRIM_OUTSIDE, bar.min.y), pos2(bar.min.x + inside, bar.max.y)); + let out_r = Rect::from_min_max(pos2(bar.max.x - inside, bar.min.y), pos2(bar.max.x + TRIM_OUTSIDE, bar.max.y)); + (bar.shrink2(vec2(inside, 0.0)), in_r, out_r) +} + +/// The trim cursor over a layer bar's ends: the horizontal resize arrow. +fn trim_cursor(ctx: &egui::Context) { + ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); +} + /// Darken part `r` of a layer bar and stripe it diagonally in its label colour (where the layer /// runs past its source). Only the visible part is drawn. fn stripes(p: &egui::Painter, r: Rect, label: Color32) { @@ -2095,16 +2114,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name); // Interactions: move body, trim edges. - let edge_w = 6.0; - let body = ui.interact(bar.shrink2(vec2(edge_w, 0.0)), egui::Id::new(("bar", layer.id.0)), Sense::click_and_drag()); - let (in_r, out_r) = - (Rect::from_min_max(bar.min, pos2(bar.min.x + edge_w, bar.max.y)), Rect::from_min_max(pos2(bar.max.x - edge_w, bar.min.y), bar.max)); + let (body_r, in_r, out_r) = trim_zones(bar); + let body = ui.interact(body_r, egui::Id::new(("bar", layer.id.0)), Sense::click_and_drag()); let lin = ui.interact(in_r, egui::Id::new(("bar-in", layer.id.0)), Sense::drag()); let lout = ui.interact(out_r, egui::Id::new(("bar-out", layer.id.0)), Sense::drag()); app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In"); app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out"); if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() { - ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + trim_cursor(&ctx); } if body.double_clicked() && let Some(a) = double_click_action(layer) diff --git a/crates/ui-egui/tests/ui_timeline_trim.rs b/crates/ui-egui/tests/ui_timeline_trim.rs new file mode 100644 index 00000000..d66bf5b7 --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_trim.rs @@ -0,0 +1,121 @@ +//! Trimming layers in the Timeline as in After Effects: the pointer over either end of a layer +//! bar (a few points inside it or just outside) shows the trim cursor, and dragging that end +//! moves the layer's In or Out point. +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{CursorIcon, Event, Modifiers, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// A 10 s comp at 30 fps with a solid trimmed to 2–6 s and a 20 s precomp trimmed to 3–7 s (its +/// source runs past both ends, so the slip zones sit right beside its bar's ends). +fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 320, "height": 180, "frameRate": 30, "duration": 20})).unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [solid], "in": 2.0, "out": 6.0})).unwrap(); + let pre = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [pre], "in": 3.0, "out": 7.0})).unwrap(); + s.execute("layer.select", json!({"layers": []})).unwrap(); + let app = EffectcraftApp::new(s); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + (h, solid, pre) +} + +fn bar(h: &Harness<'_, EffectcraftApp>, layer: u64) -> egui::Rect { + let e = h.state().auto.find(&format!("timeline.layer.{layer}.bar")).expect("layer bar").clone(); + egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// The cursor shown with the pointer resting at `p`. +fn cursor_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) -> CursorIcon { + h.event(Event::PointerMoved(p)); + h.run_steps(3); + h.output().platform_output.cursor_icon +} + +/// (in, out, start) of `layer` in seconds. +fn timing(h: &Harness<'_, EffectcraftApp>, layer: u64) -> (f64, f64, f64) { + let comp = h.state().session.active_comp().unwrap(); + let l = comp.layer(LayerId(layer)).unwrap(); + (l.in_point.seconds(), l.out_point.seconds(), l.start_time.seconds()) +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.step(); + h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=10 { + h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))); + h.step(); + } + h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Both ends of every bar show the trim cursor, just inside and just outside the end; the middle +/// doesn't. The same holds for a selected layer, a twirled-open layer and a short bar. +fn assert_trim_cursor_at_ends(h: &mut Harness<'_, EffectcraftApp>, layer: u64, case: &str) { + let b = bar(h, layer); + let y = b.center().y; + for (what, x) in [("in, outside", b.min.x - 3.0), ("in, inside", b.min.x + 2.0), ("out, inside", b.max.x - 2.0), ("out, outside", b.max.x + 3.0)] { + assert_eq!(cursor_at(h, pos2(x, y)), CursorIcon::ResizeHorizontal, "{case}: layer {layer} {what} (bar {b:?})"); + } + if b.width() > 40.0 { + assert_ne!(cursor_at(h, b.center()), CursorIcon::ResizeHorizontal, "{case}: the middle of the bar moves, it doesn't trim"); + } +} + +#[test] +fn bar_ends_show_the_trim_cursor() { + let (mut h, solid, pre) = harness(); + assert_trim_cursor_at_ends(&mut h, solid, "unselected solid"); + assert_trim_cursor_at_ends(&mut h, pre, "precomp with slip zones beside its ends"); + h.state_mut().session.execute("layer.select", json!({"layers": [solid]})).unwrap(); + h.run_steps(2); + assert_trim_cursor_at_ends(&mut h, solid, "selected"); + h.state_mut().ui.timeline.open_layers.insert(solid); + h.run_steps(2); + assert_trim_cursor_at_ends(&mut h, solid, "twirled open"); + assert_trim_cursor_at_ends(&mut h, pre, "below a twirled-open layer"); + // Three frames long: still trims from both ends. + h.state_mut().session.execute("layer.timing", json!({"layers": [solid], "in": 2.0, "out": 2.1})).unwrap(); + h.run_steps(2); + assert_trim_cursor_at_ends(&mut h, solid, "short bar"); +} + +/// The trim zones show the horizontal resize cursor; the bar body doesn't. +#[test] +fn trim_zones_show_the_resize_cursor() { + let (mut h, solid, _) = harness(); + let b = bar(&h, solid); + assert_eq!(cursor_at(&mut h, pos2(b.max.x, b.center().y)), CursorIcon::ResizeHorizontal); + assert_ne!(cursor_at(&mut h, b.center()), CursorIcon::ResizeHorizontal, "trim cursor leaked onto the bar body"); +} + +#[test] +fn dragging_bar_ends_trims_in_and_out() { + let (mut h, solid, pre) = harness(); + let b = bar(&h, solid); + let pps = b.width() as f64 / 4.0; + let y = b.center().y; + // Out end, grabbed just inside, one second to the left. + drag(&mut h, pos2(b.max.x - 2.0, y), pos2(b.max.x - 2.0 - pps as f32, y)); + let (i, o, s) = timing(&h, solid); + assert!((i - 2.0).abs() < 1e-6 && (o - 5.0).abs() < 1.5 / 30.0 && s.abs() < 1e-6, "out trimmed to ~5 s: in {i} out {o} start {s}"); + // In end, grabbed just inside, one second to the right. + let b = bar(&h, solid); + drag(&mut h, pos2(b.min.x + 2.0, y), pos2(b.min.x + 2.0 + pps as f32, y)); + let (i, o2, s) = timing(&h, solid); + assert!((i - 3.0).abs() < 1.5 / 30.0 && (o2 - o).abs() < 1e-6 && s.abs() < 1e-6, "in trimmed to ~3 s: in {i} out {o2} start {s}"); + // The precomp's Out end grabbed just outside the bar (over its slip zone) extends it. + let b = bar(&h, pre); + let y = b.center().y; + drag(&mut h, pos2(b.max.x + 3.0, y), pos2(b.max.x + 3.0 + pps as f32, y)); + let (i, o, s) = timing(&h, pre); + assert!((i - 3.0).abs() < 1e-6 && (o - 8.0).abs() < 1.5 / 30.0 && s.abs() < 1e-6, "out extended to ~8 s, not slipped: in {i} out {o} start {s}"); +} From def8b6f3b448af720f6bbc24eeb0159d22245b04 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 13:28:09 +0100 Subject: [PATCH 2/7] Timeline: a click on empty track area deselects layers (as in After Effects) (cherry picked from commit 9d6352faea454ebcd1a575056c4d5f0bafcc80ea) (cherry picked from commit d6110a23e9c459eb4c677b439d811b7438b1a999) (cherry picked from commit f46595d119d253c9298eb045ad521420a155b422) (cherry picked from commit fff43e6f6edc00bf60aa031643f3356b9e7ccfad) --- crates/ui-egui/src/panels/timeline.rs | 27 +++++++----- crates/ui-egui/tests/ui_workflow_selection.rs | 44 +++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 0fc8eec1..29fd5b19 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2086,8 +2086,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } let gresp = ui - .interact(gr, egui::Id::new(("ghost", side, layer.id.0)), Sense::drag()) + .interact(gr, egui::Id::new(("ghost", side, layer.id.0)), Sense::click_and_drag()) .on_hover_text(crate::i18n::tr("Drag to slip the source")); + // Outside the bar it is empty track area: a plain click deselects. + if gresp.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) { + actions.push(("layer.select".into(), json!({"layers": []}))); + } app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip"); if gresp.hovered() || gresp.dragged() { ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); @@ -2825,14 +2829,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if graph_on { super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions); } - // Empty-area click in the graph: deselect keys (below the last row, the layers too, as in - // After Effects); drag: box-select keys. - if empty.clicked() { - actions.push(("keys.select".into(), json!({"keys": []}))); - let below = empty.interact_pointer_pos().is_some_and(|p| hit_rows.iter().all(|(r, _)| p.y > r.max.y)); - if below && !ui.input(|i| i.modifiers.shift || i.modifiers.command) { - actions.push(("layer.select".into(), json!({"layers": []}))); - } + // A plain click on empty track area (beside a layer's bar, on a property row's empty span or + // below the last row) deselects the layers and their keys and properties, as in After + // Effects; Shift / Cmd keep the selection. Drag: box-select keys. + if empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) { + actions.push(("layer.select".into(), json!({"layers": []}))); } if let (true, Some(origin), Some(cur)) = (empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos()) @@ -2938,7 +2939,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift), "selectProperties": true}))); + let mods = ui.input(|i| i.modifiers); + if keys.is_empty() && !(mods.shift || mods.command) { + // A marquee that catches nothing leaves nothing selected (layers included). + actions.push(("layer.select".into(), json!({"layers": []}))); + } else { + actions.push(("keys.select".into(), json!({"keys": keys, "add": mods.shift, "selectProperties": true}))); + } } // CTI. diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index 697ff2a2..0510fdae 100644 --- a/crates/ui-egui/tests/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -88,6 +88,50 @@ fn timeline_empty_outline_marquee_selects_layers() { drag(&mut h, pos2(area.min.x + 210.0, last.max.y + 25.0), pos2(area.min.x + 215.0, top.min.y + 2.0), Modifiers::NONE); assert_eq!(h.state().session.state.selected_layers, ids); } +/// A plain click on empty track area (beside a layer's bar or below the last layer) deselects +/// the layers, as in After Effects; Shift / Cmd keep the selection, a click on a bar selects +/// its layer and a key marquee that catches nothing leaves nothing selected. +#[test] +fn timeline_click_on_empty_track_area_deselects_layers() { + let mut h = harness(); + let ids: Vec = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect(); + let select = |h: &mut Harness<'_, EffectcraftApp>, layers: &[LayerId]| { + let layers: Vec = layers.iter().map(|l| l.0).collect(); + h.state_mut().session.execute("layer.select", json!({"layers": layers})).unwrap(); + h.run_steps(2); + }; + h.state_mut().session.execute("layer.timing", json!({"layers": [ids[1].0], "in": 1.5, "out": 2.5})).unwrap(); + select(&mut h, &ids[..2]); + let bar = rect(&h, &format!("timeline.layer.{}.bar", ids[1].0)); + let before = pos2(bar.min.x - 30.0, bar.center().y); + let after = pos2(bar.max.x + 30.0, bar.center().y); + click(&mut h, before, Modifiers::NONE); + assert!(h.state().session.state.selected_layers.is_empty(), "a click beside the bar deselects"); + click(&mut h, bar.center(), Modifiers::NONE); + assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "a click on the bar selects its layer"); + click(&mut h, after, Modifiers::SHIFT); + assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "Shift-click on empty keeps the selection"); + click(&mut h, after, Modifiers::COMMAND); + assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "Cmd-click on empty keeps the selection"); + click(&mut h, after, Modifiers::NONE); + assert!(h.state().session.state.selected_layers.is_empty()); + // Below the last layer, in the track area and in the outline. + let last = rect(&h, &format!("timeline.layer.{}.bar", ids[3].0)); + select(&mut h, &ids); + click(&mut h, pos2(last.center().x, last.max.y + 30.0), Modifiers::NONE); + assert!(h.state().session.state.selected_layers.is_empty(), "a click below the last layer deselects"); + let outline = rect(&h, "timeline.layerMarquee"); + select(&mut h, &ids); + click(&mut h, pos2(outline.min.x + 210.0, last.max.y + 30.0), Modifiers::NONE); + assert!(h.state().session.state.selected_layers.is_empty(), "so does one below the layers in the outline"); + // A key marquee over empty track area that catches nothing. + select(&mut h, &ids[1..2]); + drag(&mut h, after, pos2(after.x + 60.0, after.y + 4.0), Modifiers::NONE); + assert!(h.state().session.state.selected_layers.is_empty(), "an empty marquee leaves nothing selected"); + select(&mut h, &ids[1..2]); + drag(&mut h, after, pos2(after.x + 60.0, after.y + 4.0), Modifiers::SHIFT); + assert_eq!(h.state().session.state.selected_layers, vec![ids[1]], "a Shift marquee keeps the selection"); +} #[test] fn project_clear_deletes_items_and_undo_restores_them() { let mut h = harness(); From 66812324c940bb08e806d22026a9414ee7f2c093 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 23:44:44 +0100 Subject: [PATCH 3/7] UI: reproduce overlapping duplicated text editing with pointer and Type tool (cherry picked from commit 0acb03da5f0d09d726047013e931e5eeff5504ce) (cherry picked from commit 66427c0149c5f2fd46931945a4f11a9530c12269) (cherry picked from commit d5fadffc1ac9f072a985a918cd6a62e9763bc2bf) (cherry picked from commit adec1c33542c2566183c6698cb0b1ad8e8afa0e0) --- crates/ui-egui/tests/ui_text_edit.rs | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index d3be6a44..d4dc71ee 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -464,3 +464,68 @@ fn timeline_double_click_on_a_text_layer_edits_its_text() { assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "gamma", "typing replaced the text ({target})"); } } + +#[test] +fn duplicated_text_boxes_both_remain_editable() { + let mut a = app(); + let original = a + .session + .execute("layer.newText", json!({"text": "alpha beta", "size": 40, "position": [40, 100], "justify": "left", "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); + h.run_steps(3); + h.state_mut().ui.focused = PanelKind::Composition; + key(&mut h, Key::D, Modifiers::COMMAND); + let duplicate = h.state().session.state.selected_layers[0].0; + assert_ne!(original, duplicate); + h.state_mut().session.execute("prop.set", json!({"layer": duplicate, "path": "transform/position", "value": [465, 110]})).unwrap(); + h.run_steps(3); + for (layer, x, text) in [(original, 80.0, "original edited"), (duplicate, 380.0, "duplicate edited")] { + h.run_steps(40); + let comp = rect(&h, "viewer.comp"); + let k = comp.width() / 640.0; + let p = comp.min + egui::vec2(x * k, 85.0 * k); + click(&mut h, p, Modifiers::NONE); + click(&mut h, p, Modifiers::NONE); + assert_eq!(edited(&h).0, layer); + type_text(&mut h, text); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(layer_doc(&h.state().session, LayerId(layer)).unwrap().text, text); + } +} + +#[test] +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); + h.run_steps(3); + h.state_mut().ui.focused = PanelKind::Composition; + key(&mut h, Key::D, Modifiers::COMMAND); + let duplicate = h.state().session.state.selected_layers[0].0; + assert_ne!(original, duplicate); + let comp = rect(&h, "viewer.comp"); + let p = comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0); + click(&mut h, p, Modifiers::NONE); + click(&mut h, p, Modifiers::NONE); + assert_eq!(edited(&h).0, duplicate, "double-click edits the topmost overlapping copy"); + type_text(&mut h, "copy edited"); + assert_eq!(edited(&h).1, "copy edited"); + key(&mut h, Key::Z, Modifiers::COMMAND); + assert_eq!(edited(&h).1, "alpha beta", "undo restores the duplicate's text"); + key(&mut h, Key::Escape, Modifiers::NONE); + h.state_mut().session.execute("layer.select", json!({"layers": [original]})).unwrap(); + h.state_mut().ui.focused = PanelKind::Composition; + key(&mut h, Key::T, Modifiers::COMMAND); + h.run_steps(40); + click(&mut h, p, Modifiers::NONE); + assert_eq!(edited(&h).0, original, "the Type tool must honor the explicitly selected overlapping text layer"); + key(&mut h, Key::A, Modifiers::COMMAND); + type_text(&mut h, "original edited"); + assert_eq!(edited(&h).1, "original edited"); + assert_eq!(layer_doc(&h.state().session, LayerId(duplicate)).unwrap().text, "alpha beta", "editing the original leaves the duplicate unchanged"); + key(&mut h, Key::Z, Modifiers::COMMAND); + assert_eq!(edited(&h).1, "alpha beta"); +} From 9af5560c1f234fb7e4bd9fa2b6479fb46fab1549 Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 23:45:45 +0100 Subject: [PATCH 4/7] UI: let Type tool edit selected overlapping text while respecting visibility (cherry picked from commit 9fd5b1e5618a6ad19638494b480460e9527debde) (cherry picked from commit 812e34fd7834e828bebd3f79440f831d188a1a99) (cherry picked from commit 283adb1a30e6b51ab6603488e57b9997a0ca83f0) (cherry picked from commit cb2e659baad5e4fe853c124dafe32491335b8232) --- crates/ui-egui/src/panels/viewer_text.rs | 23 +++++++++---- crates/ui-egui/tests/ui_text_edit.rs | 41 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index f4604f2e..57b34b29 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -118,12 +118,23 @@ fn edited(app: &EffectcraftApp, ectx: &EvalCtx, lid: LayerId) -> Option Some(Edited { layer, doc, lay, m, inv, carets }) } -/// The topmost text layer whose text is under screen point `s`. -fn text_layer_at(app: &EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, s: Pos2) -> Option { - super::viewer::selectable_layers(ectx.comp, ectx.time) +/// Type-tool clicks prefer a selected text layer, as viewer dragging does (#230). +/// Double-clicks retain topmost picking. Both use the same visibility/lock/time/solo filter. +fn text_layer_at(app: &EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, s: Pos2, prefer_selected: bool) -> Option { + let mut topmost = None; + for e in super::viewer::selectable_layers(ectx.comp, ectx.time) .filter(|l| matches!(l.source, LayerSource::Text)) .filter_map(|l| edited(app, ectx, l.id)) - .find(|e| e.contains(map, s)) + .filter(|e| e.contains(map, s)) + { + if !prefer_selected || app.session.state.selected_layers.contains(&e.layer.id) { + return Some(e); + } + if topmost.is_none() { + topmost = Some(e); + } + } + topmost } fn exec(app: &mut EffectcraftApp, id: &str, p: Value) -> Option { @@ -223,7 +234,7 @@ pub fn hook( } } if !handled && type_tool { - if let Some(e) = text_layer_at(app, ectx, map, pos) { + if let Some(e) = text_layer_at(app, ectx, map, pos, true) { let ci = e.hit(map, pos); exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci})); drag = Some(Drag::Select { layer: e.layer.id }); @@ -325,7 +336,7 @@ pub fn hook( owns = true; } _ if !matches!(tool, Tool::Hand | Tool::Zoom) && !tool.is_pen() && !type_tool => { - if let Some(e) = text_layer_at(app, ectx, map, p) { + if let Some(e) = text_layer_at(app, ectx, map, p, false) { exec(app, "text.edit", json!({"layer": e.layer.id.0})); ui.memory_mut(|m| m.request_focus(focus_id())); owns = true; diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index d4dc71ee..d147529b 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -529,3 +529,44 @@ fn overlapping_duplicate_text_remains_editable_by_double_click_and_selected_type key(&mut h, Key::Z, Modifiers::COMMAND); assert_eq!(edited(&h).1, "alpha beta"); } + +#[test] +fn type_tool_selected_text_preference_skips_ineligible_layers_and_falls_back_to_topmost() { + for exclusion in ["hidden", "locked", "inactive", "unsoloed", "outside", "unselected"] { + let mut a = app(); + let mut ids = vec![]; + for _ in 0..2 { + ids.push( + a.session.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "box": [40, 60, 250, 100]})).unwrap()["layer"].as_u64().unwrap(), + ); + } + let original = ids[0]; + let topmost = ids[1]; + match exclusion { + "hidden" | "locked" => { + let switch = if exclusion == "hidden" { "video" } else { "locked" }; + a.session.execute("layer.setSwitch", json!({"layers": [original], "switch": switch, "value": exclusion == "locked"})).unwrap(); + } + "inactive" => { + let cid = a.session.active_comp_id().unwrap(); + std::sync::Arc::make_mut(&mut a.session.project).comp_mut(cid).unwrap().layer_mut(LayerId(original)).unwrap().out_point = + effectcraft_engine::time::Tick::ZERO; + } + "unsoloed" => { + a.session.execute("layer.setSwitch", json!({"layers": [topmost], "switch": "solo", "value": true})).unwrap(); + } + "outside" => { + a.session.execute("prop.set", json!({"layer": original, "path": "transform/position", "value": [500, 300]})).unwrap(); + } + _ => {} + } + 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); + h.run_steps(3); + let comp = rect(&h, "viewer.comp"); + let p = comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0); + click(&mut h, p, Modifiers::NONE); + assert_eq!(edited(&h).0, topmost, "{exclusion} selected text must not override the eligible hit"); + } +} From 5b2b99464dd2ce4f814505b89668bb17e530572a Mon Sep 17 00:00:00 2001 From: Elias Date: Fri, 9 Oct 2026 18:04:31 +0100 Subject: [PATCH 5/7] ui: skip timeline ruler labels that would run under the marker bin The comp-end label was clipped to a stray "1" against the marker bin. (cherry picked from commit f62afa0364f1bf38f0b0b5eda5ccc07f33174790) (cherry picked from commit 2e3d08dad7d73a14688bf6d8bfb1712ab51443a2) (cherry picked from commit 773471c3a39bef51f4763d21d007534cfd95c0b1) (cherry picked from commit 176508a82145eb7063c776f6cd44ba925a8edd99) --- crates/ui-egui/src/panels/timeline.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 29fd5b19..36fdecc1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1567,7 +1567,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { pr.line_segment([pos2(x, ruler.max.y - 10.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick)); let fno = (s / fd).round() as i64; let label = ruler_label(fno, fps_i, &app.session.project.settings); - pr.text(pos2(x + 3.0, ruler.max.y - 16.0), Align2::LEFT_CENTER, label, Tokens::ui(10.0), t.tl_ruler_text); + // A label that would run under the marker bin is left out rather than cut to a stray digit. + let g = pr.layout_no_wrap(label, Tokens::ui(10.0), t.tl_ruler_text); + if x + 3.0 + g.size().x <= bin.min.x { + pr.galley(pos2(x + 3.0, ruler.max.y - 16.0 - g.size().y / 2.0), g, t.tl_ruler_text); + } for k in 1..5 { let xx = tm.x(s + secs_per_label * k as f64 / 5.0); pr.line_segment([pos2(xx, ruler.max.y - 4.0), pos2(xx, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick.gamma_multiply(0.6))); From 5bcfc0a4a560d568dd7b51250dd9fd27b80590ce Mon Sep 17 00:00:00 2001 From: Elias Date: Fri, 9 Oct 2026 18:04:31 +0100 Subject: [PATCH 6/7] ui: elide long Project panel names with an ellipsis Names were hard-clipped mid-glyph against the label column ("EffectCraft Intrc"); use widgets::text_fit like the other text columns. (cherry picked from commit 341c924eff265f08e8b04b5a6a99d0d173a13e24) (cherry picked from commit 60086aa4b5c735cd2307d773b22d8189e211f40f) (cherry picked from commit c318f8438f60d5ecb8bbebb8857bad1fe6d9834d) (cherry picked from commit eaebbe7ce0b9d88970f435e9327f7b04612c1a9d) --- crates/ui-egui/src/panels/project.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index e18f76bc..076be4f1 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -662,11 +662,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cell = Rect::from_min_max(pos2(x0 + 27.0, r.min.y + 1.0), pos2((x0 + 33.0 + g.size().x).min(name_clip.max.x), r.max.y - 1.0)); lp.with_clip_rect(name_clip.expand2(vec2(3.0, 0.0))).rect_filled(cell, 0.0, Color32::from_rgb(0xa6, 0xa6, 0xa6)); } - lp.with_clip_rect(name_clip).text( + // Long names end in "…" before the label column instead of being cut mid-glyph. + widgets::text_fit( + &lp.with_clip_rect(name_clip), pos2(x0 + 30.0, r.center().y), Align2::LEFT_CENTER, &it.name, Tokens::ui(12.0), + name_clip.max.x - (x0 + 30.0) - 2.0, if selected { Color32::from_gray(0x16) } else { t.text }, ); } From b00c7f582a6f92d8ffb13cc33adc83345f40aa8a Mon Sep 17 00:00:00 2001 From: Elias Date: Fri, 9 Oct 2026 18:04:31 +0100 Subject: [PATCH 7/7] ui: centre Reset / About... on the effect header row in Effect Controls The buttons were offset 2pt below the 22pt header's centre, so their text sat below the effect name's baseline. (cherry picked from commit acacef9f42e992c535397a55ae03bb3f3684a5e0) (cherry picked from commit dc9ff1b5aad75c4f4233d89ed33813844e980487) (cherry picked from commit 1e599b6e1d2e3402a10477ccccd1fd52724dc95c) (cherry picked from commit 7deda99fabe57bb40c6ea9f02f18c6c79d19d792) --- crates/ui-egui/src/panels/effect_controls.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index d8de61d8..73c85d8e 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1123,7 +1123,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("effectControls.effect.{}.twirl", g.uid), tw, &g.name); let rename_id = egui::Id::new(("ec-rename", g.uid)); let editing: Option = ctx.data(|d| d.get_temp(rename_id)); - let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.min.y + 4.0), vec2(40.0, 18.0)); + let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.center().y - 9.0), vec2(40.0, 18.0)); if let Some(mut value) = editing.as_ref().cloned() { let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0)); let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0))); @@ -1157,7 +1157,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("effect.reset".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } // About…: the effect's name and category (AE's About dialog). - let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.min.y + 4.0), vec2(44.0, 18.0)); + let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.center().y - 9.0), vec2(44.0, 18.0)); let aresp = ui.interact(about, egui::Id::new(("ec-about", g.uid)), Sense::click()); bp.text(about.center(), Align2::CENTER_CENTER, crate::i18n::tr("About..."), Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim }); app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About");