diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 1563d392..29bf154b 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1154,7 +1154,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))); @@ -1191,7 +1191,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"); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index fd93b991..465971a4 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -673,11 +673,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 }, ); } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 535a8dc2..4f7bb40e 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) { @@ -1668,7 +1687,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))); @@ -2217,8 +2240,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); @@ -2245,16 +2272,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) @@ -3012,14 +3037,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()) @@ -3147,7 +3169,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/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 d3be6a44..d147529b 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -464,3 +464,109 @@ 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"); +} + +#[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"); + } +} 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}"); +} diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index 831d04bb..6e264dd3 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();