From e108f460c4947051a2310d41c198df04ef1bbf8b Mon Sep 17 00:00:00 2001 From: Anton Iliashenko <183588867+aniliashenko@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:35:23 +0200 Subject: [PATCH] #637: double-clicking a layer's bar acts like its name; text layers edit their text Double-clicking a text layer in the Timeline (its name or its bar) edits its text in the Composition viewer with the whole text selected, as in After Effects; it used to open the rename field (Enter still renames). The layer bar had no double-click at all: it now opens a precomp, opens footage and solids in the Layer panel and edits text, like the name. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 27 ++++++++++++++++++++------- crates/ui-egui/tests/ui_tabs.rs | 12 ++++++++++++ crates/ui-egui/tests/ui_text_edit.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 61b2d22d..59504353 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -563,6 +563,18 @@ pub fn begin_rename(app: &mut EffectcraftApp, ctx: &egui::Context) { } } +/// What double-clicking a layer's name or bar does, as in After Effects: a precomp opens, footage +/// and solids open in the Layer panel (paint happens there), text is edited in the viewer with the +/// whole text selected. `None`: the name renames, the bar does nothing. +fn double_click_action(layer: &Layer) -> Option<(String, serde_json::Value)> { + match &layer.source { + LayerSource::Comp { item } => Some(("comp.open".into(), json!({"comp": item.0}))), + LayerSource::Footage { .. } | LayerSource::Solid { .. } => Some(("layer.openLayer".into(), json!({"layer": layer.id.0}))), + LayerSource::Text => Some(("text.edit".into(), json!({"layer": layer.id.0}))), + _ => None, + } +} + /// Open the rename field on layer `id`; it takes the keyboard focus on its first frame only. fn start_rename(ctx: &egui::Context, id: u64, name: &str) { ctx.data_mut(|d| { @@ -1870,13 +1882,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } if row_resp.double_clicked() { - match &layer.source { - LayerSource::Comp { item } => actions.push(("comp.open".into(), json!({"comp": item.0}))), - // Footage and solids open in the Layer panel (paint happens there). - LayerSource::Footage { .. } | LayerSource::Solid { .. } => { - actions.push(("layer.openLayer".into(), json!({"layer": layer.id.0}))); - } - _ => start_rename(&ctx, layer.id.0, &layer.name), + match double_click_action(layer) { + Some(a) => actions.push(a), + None => start_rename(&ctx, layer.id.0, &layer.name), } } layer_context_menu(&row_resp, layer, &mut actions); @@ -2084,6 +2092,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() { ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } + if body.double_clicked() + && let Some(a) = double_click_action(layer) + { + actions.push(a); + } let shift = ui.input(|i| i.modifiers.shift); if let Some(layers) = (body.clicked() && shift).then(|| shift_range(&ctx, cid, &comp, &rows, &selected, layer.id)).flatten() { // Shift-click: the layers from the last one clicked to this one, as in diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index 4d6777b6..7f4689bf 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -167,6 +167,18 @@ fn nested_comp_markers_show_on_the_precomp_bar() { assert!((h.state().session.time().seconds() - 1.0).abs() < 0.034, "{}", h.state().session.time().seconds()); } +/// #637: double-clicking a precomp layer's bar opens its comp, as double-clicking its name does. +#[test] +fn double_clicking_a_precomp_bar_opens_its_comp() { + let (mut h, pre, main) = harness(); + h.state_mut().session.execute("comp.open", json!({"comp": main})).unwrap(); + h.run_steps(4); + let l = h.state().session.project.comp(ItemId(main)).unwrap().layers[0].id.0; + let bar = rect(&h, &format!("timeline.layer.{l}.bar")); + click_n(&mut h, bar.center(), 2); + assert_eq!(h.state().session.active_comp_id(), Some(ItemId(pre))); +} + /// Issue #46: a panel dragged by its panel-menu (hamburger) button onto the left edge of another /// group docks there as its own group; dragging the gap between panels resizes them. #[test] diff --git a/crates/ui-egui/tests/ui_text_edit.rs b/crates/ui-egui/tests/ui_text_edit.rs index 1c2c7355..578f49d0 100644 --- a/crates/ui-egui/tests/ui_text_edit.rs +++ b/crates/ui-egui/tests/ui_text_edit.rs @@ -277,3 +277,29 @@ fn type_tool_drag_makes_paragraph_text_with_box_handles() { assert!(doc2.box_size.unwrap()[0] > bs[0] + 20.0, "{:?}", doc2.box_size); assert!(h.state().session.state.text_edit.is_some(), "still editing after resizing"); } + +/// Double-clicking a text layer in the Timeline, on its name or on its bar, edits its text in the +/// Composition viewer with the whole text selected, as in After Effects (Enter renames). +#[test] +fn timeline_double_click_on_a_text_layer_edits_its_text() { + for target in ["row", "bar"] { + 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)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(3); + let p = rect(&h, &format!("timeline.layer.{lid}.{target}")).center(); + click(&mut h, p, Modifiers::NONE); + click(&mut h, p, Modifiers::NONE); + let (layer, text, anchor, caret) = edited(&h); + assert_eq!(layer, lid, "{target}"); + assert_eq!((anchor.min(caret), anchor.max(caret)), (0, 10), "everything selected ({target})"); + assert!(h.state().auto.find(&format!("timeline.layer.{lid}.nameEdit")).is_none(), "no rename field ({target})"); + assert_eq!(text, "alpha beta"); + type_text(&mut h, "gamma"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().session.state.text_edit.is_none(), "Escape commits ({target})"); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "gamma", "typing replaced the text ({target})"); + } +}