From 75b75f8463f3f55e763012bc87846d6de2804616 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 15 Oct 2026 01:25:46 +0530 Subject: [PATCH] fix(properties): reveal transform rows on double-click (#1456) --- contributors/people.toml | 3 + crates/model/src/stl.rs | 6 +- crates/ui-egui/src/panels/properties.rs | 11 ++++ crates/ui-egui/src/panels/timeline.rs | 4 ++ crates/ui-egui/src/state.rs | 6 +- crates/ui-egui/tests/ui_panels.rs | 86 +++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 6 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 705feb25..dc25b08e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -78,3 +78,6 @@ sync_github_name = true [people.moecr7] real_name = "Moein Saharkhiz" display_name = "onlymoein" + +[people.arnav-hiwarkar] +sync_github_name = true diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858a..34e9a7f0 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2649a38e..84d8d408 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -244,6 +244,17 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, } p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text); let vx = r.min.x + (r.width() * 0.5).max(130.0); + let label = Rect::from_min_max(pos2(r.min.x + 42.0, r.min.y), pos2(vx - 20.0, r.max.y)); + let resp = ui.interact(label, egui::Id::new(("props-label", uid)), Sense::click()); + app.auto.add(&format!("{base}.label"), label, &pr.name); + if resp.double_clicked() { + app.ui.timeline.search.clear(); + app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Property(uid)); + if let Err(e) = crate::menus::frontend(app, ui.ctx(), "timeline.revealProps", json!({"props": [{"layer": lid, "prop": uid}]})) { + app.ui.status = e; + } + app.show_panel(PanelKind::Timeline); + } let merge = format!("props-{uid}"); // With several layers selected the edit applies to all: a scrub by the same change, a typed // value as it is. diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 163e6507..2de97a4b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1895,6 +1895,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Layer) && Some(r.layer) == top) { app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum(); } + } else if let Some(crate::state::ScrollTo::Property(uid)) = scroll_to + && let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Prop { uid: p } if p == uid)) + { + app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum(); } if ui.rect_contains_pointer(rows_rect) { let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt)); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b8ecb8c0..bbfe0c05 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -335,13 +335,15 @@ impl TimelineState { } } -/// Where a Timeline shortcut scrolls to. +/// Where the Timeline scrolls to. #[derive(Clone, Copy, Debug, PartialEq)] pub enum ScrollTo { /// D: the current time, centred in the time graph. Time, /// X: the topmost selected layer, at the top of the layers. Layer, + /// A revealed property, at the top of the rows. + Property(u64), } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -419,7 +421,7 @@ pub struct TimelineState { /// The name column shows Source Name instead of Layer Name (click its header). #[serde(default)] pub source_name: bool, - /// A scroll asked for by a shortcut (D, X), done when the Timeline is next drawn. + /// A pending scroll, applied when the Timeline is next drawn. #[serde(skip)] pub scroll_to: Option, } diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index 15439a02..0c54445b 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -125,6 +125,15 @@ fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.run_steps(2); } +fn double_click_at(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) { + h.input_mut().events.push(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false, true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + h.run_steps(3); +} + fn hover(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { h.input_mut().events.push(Event::PointerMoved(p)); h.run_steps(3); @@ -506,6 +515,83 @@ fn properties_scale_link_shares_the_timeline_state() { assert!(!h.state().ui.timeline.unlinked.contains(&uid), "re-linking clears the shared state"); } +#[test] +fn properties_label_double_click_reveals_without_editing() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id; + let other = h.state_mut().session.execute("layer.newSolid", json!({"name": "Other", "color": "#204060"})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("prop.addKey", json!({"layer": layer.0, "path": "transform/opacity", "time": 1.0, "value": 50.0})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [layer.0]})).unwrap(); + let comp = h.state().session.active_comp().unwrap(); + let opacity = comp.layer(layer).unwrap().props.prop("transform/opacity").unwrap().uid; + let other_scale = comp.layers.iter().find(|l| l.id.0 == other).unwrap().props.prop("transform/scale").unwrap().uid; + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::frontend(h.state_mut(), &ctx, "timeline.revealProps", json!({"props": [{"layer": other, "prop": other_scale}]})).unwrap(); + open(&mut h, "properties"); + open(&mut h, "renderQueue"); + h.state_mut().ui.timeline.search = "no matching property".into(); + h.run_steps(3); + let value = h.state().auto.find(&format!("properties.prop.{opacity}.value")).unwrap().rect; + let label = pos2(value[0] - 60.0, value[1] + value[3] / 2.0); + let row = format!("timeline.prop.{opacity}.row"); + click_at(&mut h, label); + assert_eq!(h.state().ui.timeline.search, "no matching property"); + assert!(h.state().auto.find(&row).is_none()); + h.run_steps(30); + let before = serde_json::to_value(h.state().session.project.as_ref()).unwrap(); + let undo = h.state().session.history.undo.len(); + let selected = h.state().session.state.selected_layers.clone(); + for _ in 0..2 { + double_click_at(&mut h, label); + assert!(h.state().auto.find(&row).is_some(), "Opacity is visible for keyframing"); + assert!(h.state().auto.find(&format!("timeline.prop.{other_scale}.row")).is_some(), "the other layer keeps its reveal"); + assert_eq!(h.state().ui.timeline.search, ""); + h.run_steps(30); + } + assert_eq!(serde_json::to_value(h.state().session.project.as_ref()).unwrap(), before, "revealing preserves values and keyframes"); + assert_eq!(h.state().session.history.undo.len(), undo); + assert_eq!(h.state().session.state.selected_layers, selected); +} + +#[test] +fn properties_reveal_scrolls_to_the_displayed_layer_in_a_multiple_selection() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id; + for _ in 0..40 { + h.state_mut().session.execute("layer.newSolid", json!({"name": "Spacer", "color": "#204060"})).unwrap(); + } + let top = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("layer.select", json!({"layers": [layer.0, top.0]})).unwrap(); + let uid = h.state().session.active_comp().unwrap().layer(layer).unwrap().props.prop("transform/opacity").unwrap().uid; + open(&mut h, "properties"); + h.state_mut().toggle_maximize(PanelKind::Properties); + h.run_steps(3); + let r = h.state().auto.find(&format!("properties.prop.{uid}.label")).unwrap().rect; + double_click_at(&mut h, pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)); + assert!(h.state().auto.find(&format!("timeline.prop.{uid}.row")).is_some(), "the displayed layer's property is in view"); + assert_eq!(h.state().session.state.selected_layers, [layer, top]); +} + +#[test] +fn properties_transform_labels_reveal_the_corresponding_property() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id; + h.state_mut().session.execute("layer.select", json!({"layers": [layer.0]})).unwrap(); + let props: Vec = + h.state().session.active_comp().unwrap().layer(layer).unwrap().transform().unwrap().props().filter(|p| !p.three_d_only).map(|p| p.uid).collect(); + open(&mut h, "properties"); + for uid in &props { + h.state_mut().ui.timeline.open_layers.remove(&layer.0); + h.run_steps(30); + assert!(h.state().auto.find(&format!("timeline.prop.{uid}.row")).is_none()); + let r = h.state().auto.find(&format!("properties.prop.{uid}.label")).unwrap().rect; + double_click_at(&mut h, pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)); + for candidate in &props { + assert_eq!(h.state().auto.find(&format!("timeline.prop.{candidate}.row")).is_some(), candidate == uid); + } + } +} + /// Gap 7: the Backgrounds category is listed under * Animation Presets and a preset applies on double-click. #[test] fn background_presets_are_listed_and_apply() {