From 4164fa2e7c75f650ff8fbca42f6ff2b677fef2af Mon Sep 17 00:00:00 2001 From: justjohn12345 <11649414+justjohn12345@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:24:41 -0400 Subject: [PATCH] NEST6: Reveal in Project and Reveal Sequence in Project clip.revealInProject (clip context menu) selects the clip's source in the Project panel and shows it there: the panel comes forward with the search cleared, the list opens the bins on the way, and the icon and freeform views go into the bin that holds the item. sequence.revealInProject (Timeline tab menu) does the same for the sequence being edited. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 28 +++++++++++++++ crates/engine/src/lib.rs | 11 ++++-- crates/engine/src/nest_editing_tests.rs | 27 +++++++++++++- crates/ui-egui/src/lib.rs | 43 ++++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 4 +++ crates/ui-egui/src/panels/timeline.rs | 2 +- crates/ui-egui/tests/nest_editing_ui.rs | 47 ++++++++++++++++++++++++- 7 files changed, 157 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 621dc049d..589ce012c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1240,6 +1240,26 @@ fn build() -> Vec { crate::clip_ops::add_frame_hold(s, p) }), cmd!("clip.nest", "Nest…", ["Clip"], None, r#"{"name":str}"#, has_selection, |s, p| nest(s, p)), + // Reveal in Project (clip context menu) and Reveal Sequence in Project (Timeline tab menu): + // select the item in the Project panel and show it there + cmd!("clip.revealInProject", "Reveal in Project", [], None, r#"{"clip":id?}"#, has_seq, |s, p| { + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let t = s.playhead(); + let tg = s.targeting().targeted; + // the named or first selected clip, else the topmost clip under the playhead on a targeted track + let item = clips_p(s, p) + .iter() + .find_map(|c| seq.find_item(*c).map(|(_, i)| i.item)) + .or_else(|| { + seq.video_tracks.iter().rev().chain(seq.audio_tracks.iter()).filter(|tr| tg.contains(&tr.id)).find_map(|tr| tr.item_at(t).map(|i| i.item)) + }) + .ok_or_else(|| EngineError::Other("select a clip to reveal".into()))?; + reveal_in_project(s, item) + }), + cmd!("sequence.revealInProject", "Reveal Sequence in Project", [], None, r#"{"item":id?}"#, has_seq, |s, p| { + let item = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?; + reveal_in_project(s, item) + }), // ================= Sequence ================= cmd!("sequence.open", "Open in Timeline", [], None, r#"{"item":id}"#, always, |s, p| { let id = item_p(p, "item").ok_or_else(|| bad("sequence.open", "need `item`"))?; @@ -2576,6 +2596,14 @@ fn paste(s: &mut Session, insert: bool) -> Result { Ok(Value::Null) } +/// Select `item` in the Project panel and ask the UI to show it there. +fn reveal_in_project(s: &mut Session, item: ItemId) -> Result { + s.project.item(item).ok_or_else(|| EngineError::Other("that clip's source is not in the project".into()))?; + s.state.project_selection = vec![item]; + s.events.push(crate::Event::RevealInProject(item)); + Ok(json!({"item": item.0})) +} + /// The name Nest… offers: the first unused "Nested Sequence NN". pub fn next_nested_name(s: &Session) -> String { (1..=s.project.items.len().saturating_add(1)) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d86ec8895..157ec85e0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -299,10 +299,17 @@ pub struct EditorState { /// Events for frontends (drained each frame). #[derive(Clone, Debug, PartialEq, Serialize)] pub enum Event { - ProjectChanged { revision: u64 }, - Toast { message: String, error: bool }, + ProjectChanged { + revision: u64, + }, + Toast { + message: String, + error: bool, + }, OpenSequence(ItemId), OpenSource(ItemId), + /// Show this item in the Project panel (Reveal in Project). + RevealInProject(ItemId), } pub struct Session { diff --git a/crates/engine/src/nest_editing_tests.rs b/crates/engine/src/nest_editing_tests.rs index ebbb4182b..350644743 100644 --- a/crates/engine/src/nest_editing_tests.rs +++ b/crates/engine/src/nest_editing_tests.rs @@ -1,5 +1,5 @@ //! Editing with nested sequences: the nest toggle ("Insert and overwrite sequences as nests or -//! individual clips"), a sequence as a source, and Match Frame on a nest. +//! individual clips"), a sequence as a source, Match Frame on a nest, and Reveal in Project. //! Premiere's behaviour was observed in Premiere Pro 26.5.2. use super::*; @@ -187,6 +187,31 @@ fn match_frame_on_a_nest_loads_the_nested_sequence_at_the_frame_it_shows() { assert_eq!((s.state.source_item, s.state.source_playhead), (Some(nested), rate.tick_of(5 + 8))); } +#[test] +fn reveal_in_project_selects_the_clips_item_and_asks_the_ui_to_show_it() { + let mut s = demo(); + let v1 = clips(&s, TrackKind::Video, 0); + s.drain_events(); + // the selected clip + s.execute("timeline.select", json!({"clips": [v1[2].id.0]})).unwrap(); + assert_eq!(s.execute("clip.revealInProject", json!({})).unwrap()["item"], v1[2].item.0); + assert_eq!(s.state.project_selection, vec![v1[2].item]); + assert!(s.drain_events().iter().any(|e| matches!(e, Event::RevealInProject(i) if *i == v1[2].item))); + // a named clip + s.execute("clip.revealInProject", json!({"clip": v1[0].id.0})).unwrap(); + assert_eq!(s.state.project_selection, vec![v1[0].item]); + // nothing selected: the clip under the playhead on a targeted track + s.execute("timeline.select", json!({"clips": []})).unwrap(); + s.set_playhead(v1[3].start + Tick(v1[3].duration.0 / 2)); + s.execute("clip.revealInProject", json!({})).unwrap(); + assert_eq!(s.state.project_selection, vec![v1[3].item]); + // the sequence in the Timeline + let main = s.state.active_sequence.unwrap(); + s.execute("sequence.revealInProject", json!({})).unwrap(); + assert_eq!(s.state.project_selection, vec![main]); + assert!(s.drain_events().iter().any(|e| matches!(e, Event::RevealInProject(i) if *i == main))); +} + #[test] fn mark_in_and_out_in_the_source_monitor_mark_a_sequence_like_a_clip() { // marks on a sequence loaded in the Source Monitor used to be dropped silently diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c5102c491..1bf8feb12 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -472,6 +472,48 @@ impl FilmcraftApp { self.ui.focused = p; } + /// Reveal in Project: bring the Project panel forward, showing the bin that holds `item` with + /// the search cleared, so the (already selected) item is on show. + fn reveal_in_project(&mut self, item: filmcraft_project::ItemId) { + // the bins on the way down to the item (bounded: a project file can nest bins arbitrarily deep) + fn path(b: &filmcraft_project::Bin, item: filmcraft_project::ItemId, depth: usize, out: &mut Vec) -> bool { + if depth > 256 { + return false; + } + for c in &b.children { + match c { + filmcraft_project::BinEntry::Item(i) if *i == item => return true, + filmcraft_project::BinEntry::Bin(inner) => { + out.push(inner.id.0); + if path(inner, item, depth + 1, out) { + return true; + } + out.pop(); + } + _ => {} + } + } + false + } + let mut bins = Vec::new(); + let found = path(&self.session.project.root, item, 0, &mut bins); + // the list shows the whole tree: open the bins on the way. Icons and freeform show one bin + // at a time: go into the one that holds the item. + let list = self.session.prefs.project_panel.view.mode == filmcraft_engine::project_panel::ViewMode::List; + self.ui.project_panel.bin = if list || !found { None } else { bins.last().copied() }; + if list { + for b in bins { + if !self.ui.expanded_bins.contains(&b) { + self.ui.expanded_bins.push(b); + } + } + } + self.ui.project_panel.active_tab = None; + self.ui.project_panel.selected_bin = None; + self.ui.project_search.clear(); + self.show_panel(PanelKind::Project); + } + pub fn status(&mut self, s: impl Into) { self.ui.status = s.into(); } @@ -1032,6 +1074,7 @@ impl FilmcraftApp { filmcraft_engine::Event::OpenSource(_) => { self.ui.dock.activate(PanelKind::Source); } + filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item), filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))), filmcraft_engine::Event::ProjectChanged { .. } => {} } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2f0bb3def..1cf8c14d5 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -206,6 +206,10 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { } if p == PanelKind::Timeline { ui.separator(); + if ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new("Reveal Sequence in Project")).clicked() { + let _ = app.session.execute("sequence.revealInProject", serde_json::json!({})); + close = true; + } ui.checkbox(&mut app.ui.timeline.show_thumbnails, "Video Thumbnails"); ui.checkbox(&mut app.ui.timeline.show_waveforms, "Audio Waveforms"); } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 250fff772..74a42ba59 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1439,7 +1439,7 @@ const CLIP_MENU: &[&[(&str, &str)]] = &[ ("Field Options…", "clip.fieldOptions"), ("Scale to Frame Size", "clip.scaleToFrameSize"), ], - &[("Join Through Edits", "sequence.joinThroughEdits")], + &[("Reveal in Project", "clip.revealInProject"), ("Join Through Edits", "sequence.joinThroughEdits")], ]; fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &Layout, rect: Rect) { diff --git a/crates/ui-egui/tests/nest_editing_ui.rs b/crates/ui-egui/tests/nest_editing_ui.rs index d93fd9e0d..647ac36eb 100644 --- a/crates/ui-egui/tests/nest_editing_ui.rs +++ b/crates/ui-egui/tests/nest_editing_ui.rs @@ -1,4 +1,5 @@ -//! Headless UI tests of editing with nested sequences: the nest toggle button. +//! Headless UI tests of editing with nested sequences: the nest toggle button and Reveal in +//! Project. use std::sync::mpsc::{Sender, channel}; @@ -6,6 +7,7 @@ use egui_kittest::Harness; use filmcraft_engine::Session; use filmcraft_ui_egui::FilmcraftApp; use filmcraft_ui_egui::control::ControlRequest; +use filmcraft_ui_egui::dock::PanelKind; use serde_json::{Value, json}; struct Driver { @@ -54,11 +56,20 @@ impl Driver { v["result"].clone() } + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + fn click(&mut self, id: &str) { self.ok("ui.click", json!({"id": id})); self.frames(3); } + fn has(&mut self, id: &str) -> bool { + let v = self.ok("ui.elements", json!({"prefix": id})); + v.as_array().unwrap().iter().any(|e| e["id"] == json!(id)) + } + fn app(&mut self) -> &mut FilmcraftApp { self.harness.state_mut() } @@ -73,3 +84,37 @@ fn the_nest_button_toggles_how_sequences_are_edited_in() { d.click("timeline.toggle.nest"); assert!(!d.app().session.state.sequences_as_clips); } + +#[test] +fn reveal_in_project_shows_the_clips_item_in_the_project_panel() { + use filmcraft_engine::project_panel::ViewMode; + for mode in [ViewMode::List, ViewMode::Icon] { + let mut d = Driver::new(); + d.app().session.prefs.project_panel.view.mode = mode; + let (clip, item) = { + let it = &d.app().session.active_sequence().unwrap().video_tracks[0].items[2]; + (it.id.0, it.item) + }; + let bin = d.app().session.project.root.parent_of(item).expect("the demo keeps its footage in a bin").0; + // the Project panel is closed, its bins are collapsed and a search hides everything + d.app().ui.dock.close(PanelKind::Project); + d.app().ui.expanded_bins.clear(); + d.app().ui.project_search = "no such thing".into(); + d.frames(2); + assert!(!d.has(&format!("project.item.{}", item.0))); + d.exec("clip.revealInProject", json!({"clip": clip})); + d.frames(3); + assert!(d.app().ui.dock.is_visible(PanelKind::Project), "{mode:?}"); + assert!(d.app().ui.project_search.is_empty()); + assert_eq!(d.app().session.state.project_selection, vec![item]); + if mode == ViewMode::List { + // the list shows the tree with the item's bin opened + assert!(d.app().ui.expanded_bins.contains(&bin)); + assert_eq!(d.app().ui.project_panel.bin, None); + } else { + // icons show one bin at a time: the panel is inside the item's bin + assert_eq!(d.app().ui.project_panel.bin, Some(bin)); + } + assert!(d.has(&format!("project.item.{}", item.0)), "{mode:?}: the item is on show"); + } +}