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