diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 49732bc1..87218934 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -363,13 +363,25 @@ pub fn locate(app: &EffectcraftApp, layer: u64, time: Option) -> Option<(f3 } } -/// Start renaming the first selected layer. +/// Start renaming the first selected layer (Enter in the Timeline). pub fn begin_rename(app: &mut EffectcraftApp, ctx: &egui::Context) { if let Some(l) = app.session.state.selected_layers.first().and_then(|id| app.session.active_comp().and_then(|c| c.layer(*id))) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-rename"), (l.id.0, l.name.clone()))); + start_rename(ctx, l.id.0, &l.name); } } +/// 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| { + d.insert_temp(egui::Id::new("tl-rename"), (id, name.to_string())); + d.insert_temp(rename_focus_id(), true); + }); +} + +fn rename_focus_id() -> egui::Id { + egui::Id::new("tl-rename-focus") +} + fn group_visible(g: &PropGroup, layer: &Layer) -> bool { match g.match_id.as_str() { "masks" | "effects" => !g.children.is_empty(), @@ -1182,11 +1194,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", idx_of(layer.id)), Tokens::ui(11.5), t.text_dim); } layer_cells(app, ui, &lp, &cw, &comp, layer, r, cy, &mut actions); + // Row click → select; double-click → rename; drag → reorder. Registered before the + // twirl and the name field so those sit on top and get their clicks. + let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 }; + let row_resp = ui.interact( + Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)), + egui::Id::new(("row", layer.id.0)), + Sense::click_and_drag(), + ); // Twirl + icon + name. let tw = Rect::from_center_size(pos2(cw.name + 8.0, cy), vec2(14.0, 14.0)); let open = app.ui.timeline.open_layers.contains(&layer.id.0); if widgets::twirl(ui, tw, open, egui::Id::new(("twirl", layer.id.0)), &t).clicked() { - ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt))); + ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt || i.modifiers.command))); } app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl"); icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim); @@ -1195,8 +1215,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id)); if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0)))); - let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width())); - er.request_focus(); + let out = egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()).show(&mut child); + let er = out.response; + if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) { + // Just started: focus the field with the whole name selected. + er.request_focus(); + let mut state = out.state; + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(&ctx, er.id); + } if er.lost_focus() { if !ui.input(|i| i.key_pressed(egui::Key::Escape)) { actions.push(("layer.rename".into(), json!({"layer": rl, "name": buf}))); @@ -1219,13 +1247,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect())); lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col); } - // Row click → select; double-click → rename; drag → reorder. - let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 }; - let row_resp = ui.interact( - Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)), - egui::Id::new(("row", layer.id.0)), - Sense::click_and_drag(), - ); if row_resp.drag_started() { // The selected layers move together; an unselected row moves alone. let moving: Vec = @@ -1247,9 +1268,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { LayerSource::Footage { .. } | LayerSource::Solid { .. } => { actions.push(("layer.openLayer".into(), json!({"layer": layer.id.0}))); } - _ => { - ctx.data_mut(|d| d.insert_temp(rename_id, (layer.id.0, layer.name.clone()))); - } + _ => start_rename(&ctx, layer.id.0, &layer.name), } } layer_context_menu(&row_resp, layer, &mut actions); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index c70bbfd4..f7b1792c 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -560,3 +560,89 @@ fn timeline_rows_drag_to_reorder_layers() { drag(&mut h, from, top.center() - vec2(0.0, top.height() * 0.4)); assert_eq!(names(&h), ["Box", "Plate"]); } + +fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 { + h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0 +} + +/// Uid of a layer's property by match path (`transform/position`). +fn prop_uid(h: &Harness<'_, EffectcraftApp>, layer: &str, path: &str) -> u64 { + let id = layer_id(h, layer); + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop(path).unwrap().uid +} + +/// Is the property's row on screen in the Timeline? +fn shown(h: &Harness<'_, EffectcraftApp>, uid: u64) -> bool { + h.state().auto.find(&format!("timeline.prop.{uid}.stopwatch")).is_some() +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: egui::Key, modifiers: egui::Modifiers) { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.run_steps(2); +} + +#[test] +fn timeline_twirl_arrows_open_layers_and_groups() { + let mut h = harness(); + let id = layer_id(&h, "Box"); + let transform = { + let comp = h.state().session.active_comp().unwrap().clone(); + comp.layer(LayerId(id)).unwrap().props.sub("transform").unwrap().uid + }; + let at = rect(&h, &format!("timeline.layer.{id}.twirl")).center(); + click(&mut h, at); + assert!(h.state().ui.timeline.open_layers.contains(&id), "the layer twirls open"); + let at = rect(&h, &format!("timeline.group.{transform}.twirl")).center(); + click(&mut h, at); + for p in ["anchor", "position", "scale", "rotation", "opacity"] { + assert!(shown(&h, prop_uid(&h, "Box", &format!("transform/{p}"))), "{p} row"); + } + // And closed again. + let at = rect(&h, &format!("timeline.layer.{id}.twirl")).center(); + click(&mut h, at); + h.run_steps(2); + assert!(!h.state().ui.timeline.open_layers.contains(&id)); + assert!(!shown(&h, prop_uid(&h, "Box", "transform/position"))); +} + +#[test] +fn timeline_rename_commits_on_enter_and_on_click_away() { + // The rename field (double-click a name, or Enter on a selected layer) used to grab the + // keyboard every frame: clicking away or Enter could never leave it. (The harness can't + // double-click — its frames are longer than a double-click — so this opens it with Enter.) + let mut h = harness(); + h.state_mut().session.execute("layer.newNull", json!({"name": "Ctrl"})).unwrap(); + let id = layer_id(&h, "Ctrl"); + let name = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().name.clone(); + let rename = |h: &mut Harness<'_, EffectcraftApp>| { + h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap(); + h.run_steps(2); + key(h, egui::Key::Enter, Default::default()); + h.run_steps(1); + assert!(h.ctx.memory(|m| m.focused().is_some()), "the name field has focus"); + }; + // The whole name is selected: typing replaces it; Enter commits and frees the keyboard. + rename(&mut h); + h.input_mut().events.push(Event::Text("Rig".into())); + h.step(); + key(&mut h, egui::Key::Enter, Default::default()); + assert_eq!(name(&h), "Rig"); + assert!(h.ctx.memory(|m| m.focused().is_none()), "the field let go of the keyboard"); + key(&mut h, egui::Key::P, Default::default()); + assert_eq!(h.state().ui.timeline.reveal, vec!["position"], "P works after renaming"); + // Clicking away commits too. + rename(&mut h); + h.input_mut().events.push(Event::Text("Hand".into())); + h.step(); + let away = rect(&h, "viewer.comp").center(); + click(&mut h, away); + assert_eq!(name(&h), "Hand"); + assert!(h.ctx.memory(|m| m.focused().is_none())); + // Escape cancels. + rename(&mut h); + h.input_mut().events.push(Event::Text("Nope".into())); + h.step(); + key(&mut h, egui::Key::Escape, Default::default()); + assert_eq!(name(&h), "Hand"); +}