M13.19: the Timeline's rename field and twirl arrows work — the name field took the keyboard focus every frame (Enter and clicking away could never leave it, which also silenced every single-key shortcut); it now focuses once with the name selected, Enter or a click elsewhere commits, Escape cancels; the layer row's click area sat on top of the twirl arrow and the name field (it was registered after them) and now sits under them; Ctrl-click a layer's arrow opens every group inside

This commit is contained in:
bflatastic 2026-10-04 01:09:20 +07:00
parent ba3748f79e
commit aef72378f8
2 changed files with 120 additions and 15 deletions

View File

@ -363,13 +363,25 @@ pub fn locate(app: &EffectcraftApp, layer: u64, time: Option<f64>) -> 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::<bool>(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<u64> =
@ -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);

View File

@ -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");
}