diff --git a/crates/oak-app/src/actions.rs b/crates/oak-app/src/actions.rs index 39faba81a..3a3644ff6 100644 --- a/crates/oak-app/src/actions.rs +++ b/crates/oak-app/src/actions.rs @@ -356,8 +356,7 @@ pub fn key_bindings() -> Vec { pub fn display_shortcut(action: ActionId) -> Option { let keys = effective_keys(action.entry()); let key = keys.first()?; - let keystroke = - gpui::Keystroke::parse(key).expect("effective keys parse (tests enforce it)"); + let keystroke = gpui::Keystroke::parse(key).expect("effective keys parse (tests enforce it)"); // `secondary-` parses to `platform` on macOS and `control` elsewhere; // the modifier renderers below follow the same split. @@ -568,14 +567,18 @@ pub fn has_custom_shortcuts() -> bool { /// compare keys regardless of the `secondary-` vs `cmd-`/`super-`/`win-` /// spelling (the parser maps all of them to the same modifier bits). fn canonical_key(key: &str) -> Option { - gpui::Keystroke::parse(key).ok().map(|keystroke| keystroke.unparse()) + gpui::Keystroke::parse(key) + .ok() + .map(|keystroke| keystroke.unparse()) } /// The canonical form of a key list (accepts both `&str` and `String` /// slices, so the `&'static [&str]` registry defaults and the `Vec` /// overrides share one code path). fn canonical_keys>(keys: &[K]) -> Vec { - keys.iter().filter_map(|key| canonical_key(key.as_ref())).collect() + keys.iter() + .filter_map(|key| canonical_key(key.as_ref())) + .collect() } /// The action's effective key list: the override when one is set, else the @@ -584,7 +587,11 @@ pub fn effective_keys(entry: &ActionEntry) -> Vec { match overrides().lock().unwrap().get(entry.cpp_id) { Some(ShortcutOverride::Unbound) => Vec::new(), Some(ShortcutOverride::Keys(keys)) => keys.clone(), - None => entry.default_keys.iter().map(|key| key.to_string()).collect(), + None => entry + .default_keys + .iter() + .map(|key| key.to_string()) + .collect(), } } @@ -748,6 +755,47 @@ impl Tool { _ => None, } } + + /// The timeline-widget tool this app tool drives, if the widget models it. + /// + /// `Edit`/`Transition`/`Add`/`Record`/`Hand` have no widget counterpart + /// (the widget's pointer tool covers them); those map to `None` and the + /// timeline keeps its previous tool. + pub const fn timeline_tool(self) -> Option { + match self { + Tool::Pointer => Some(gpui::timeline::TimelineTool::Select), + Tool::TrackSelect => Some(gpui::timeline::TimelineTool::TrackSelect), + Tool::Ripple => Some(gpui::timeline::TimelineTool::Ripple), + Tool::Rolling => Some(gpui::timeline::TimelineTool::Roll), + Tool::Razor => Some(gpui::timeline::TimelineTool::Razor), + Tool::Slip => Some(gpui::timeline::TimelineTool::Slip), + Tool::Slide => Some(gpui::timeline::TimelineTool::Slide), + Tool::Zoom => Some(gpui::timeline::TimelineTool::Zoom), + _ => None, + } + } +} + +/// Extension trait mapping the timeline widget's tool back to the app tool +/// (for the toolbar highlight). Unmodeled tools fall back to the pointer. +pub trait TimelineToolExt { + fn app_tool(self) -> Tool; +} + +impl TimelineToolExt for gpui::timeline::TimelineTool { + fn app_tool(self) -> Tool { + match self { + gpui::timeline::TimelineTool::Select => Tool::Pointer, + gpui::timeline::TimelineTool::TrackSelect => Tool::TrackSelect, + gpui::timeline::TimelineTool::Ripple => Tool::Ripple, + gpui::timeline::TimelineTool::Roll => Tool::Rolling, + gpui::timeline::TimelineTool::Razor => Tool::Razor, + gpui::timeline::TimelineTool::Slip => Tool::Slip, + gpui::timeline::TimelineTool::Slide => Tool::Slide, + gpui::timeline::TimelineTool::Zoom => Tool::Zoom, + _ => Tool::Pointer, + } + } } #[cfg(test)] @@ -759,7 +807,11 @@ mod tests { fn registry_ids_are_unique() { let mut seen = std::collections::HashSet::new(); for entry in REGISTRY { - assert!(seen.insert(entry.cpp_id), "duplicate cpp id {}", entry.cpp_id); + assert!( + seen.insert(entry.cpp_id), + "duplicate cpp id {}", + entry.cpp_id + ); } } @@ -823,7 +875,9 @@ mod tests { /// is meant to drive the menus). #[test] fn every_action_appears_in_the_menu_tree() { - let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::i18n::lang_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); crate::i18n::set_language_code("en-US"); use crate::oakui::component::menu; @@ -871,7 +925,9 @@ mod tests { fn every_i18n_key_exists_in_both_languages() { for entry in REGISTRY { for code in ["en-US", "zh-CN"] { - let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::i18n::lang_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); crate::i18n::set_language_code(code); let value = crate::i18n::tr(entry.i18n_key); assert_ne!( @@ -884,7 +940,9 @@ mod tests { } // The loop leaves zh-CN active; restore the default so lock-free // tests building English menus are not fooled mid-assert. - let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let _guard = crate::i18n::lang_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); crate::i18n::set_language_code("en-US"); } @@ -895,27 +953,20 @@ mod tests { #[test] #[cfg(target_os = "macos")] fn display_shortcut_formats_labels() { - let _guard = shortcuts_test_lock().lock().unwrap_or_else(|e| e.into_inner()); - assert_eq!( - display_shortcut(ActionId::Redo).as_deref(), - Some("⇧⌘Z") - ); + let _guard = shortcuts_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); + assert_eq!(display_shortcut(ActionId::Redo).as_deref(), Some("⇧⌘Z")); assert_eq!( display_shortcut(ActionId::SplitAtPlayhead).as_deref(), Some("⌘K") ); - assert_eq!( - display_shortcut(ActionId::NudgeLeft).as_deref(), - Some("⌥←") - ); + assert_eq!(display_shortcut(ActionId::NudgeLeft).as_deref(), Some("⌥←")); assert_eq!( display_shortcut(ActionId::FullScreen).as_deref(), Some("F11") ); - assert_eq!( - display_shortcut(ActionId::Insert).as_deref(), - Some(",") - ); + assert_eq!(display_shortcut(ActionId::Insert).as_deref(), Some(",")); assert!(display_shortcut(ActionId::About).is_none()); } @@ -926,8 +977,7 @@ mod tests { /// A unique temporary directory for one test (the `shortcuts` file /// round-trip), so parallel tests never collide on the same path. fn temp_dir(label: &str) -> String { - static COUNTER: std::sync::atomic::AtomicUsize = - std::sync::atomic::AtomicUsize::new(0); + static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let path = std::env::temp_dir().join(format!( "oak-shortcuts-test-{label}-{}-{n}", @@ -959,7 +1009,9 @@ mod tests { /// only carries the entries that differ from the registry defaults. #[test] fn shortcuts_file_round_trips_through_the_override_table() { - let _guard = shortcuts_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let _guard = shortcuts_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); reset_all_custom_shortcuts(); let dir = temp_dir("roundtrip"); let path = format!("{dir}/shortcuts"); @@ -984,10 +1036,7 @@ mod tests { effective_keys(ActionId::SaveProject.entry()), vec!["cmd-alt-s".to_string()] ); - assert_eq!( - effective_keys(ActionId::Undo.entry()), - Vec::::new() - ); + assert_eq!(effective_keys(ActionId::Undo.entry()), Vec::::new()); // The un-overridden action keeps its registry default. assert_eq!( effective_keys(ActionId::NewProject.entry()), @@ -999,7 +1048,9 @@ mod tests { /// an all-default table removes the file entirely (the C++ behavior). #[test] fn save_writes_only_entries_that_differ_from_default() { - let _guard = shortcuts_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let _guard = shortcuts_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); reset_all_custom_shortcuts(); let dir = temp_dir("diff"); let path = format!("{dir}/shortcuts"); @@ -1022,7 +1073,9 @@ mod tests { /// defaults stay intact until overridden. #[test] fn effective_keys_fall_back_to_defaults() { - let _guard = shortcuts_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let _guard = shortcuts_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); reset_all_custom_shortcuts(); // Delete defaults to ["delete", "backspace"]. assert_eq!( @@ -1046,7 +1099,9 @@ mod tests { /// none). #[test] fn stealing_a_key_moves_the_binding_away() { - let _guard = shortcuts_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let _guard = shortcuts_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); reset_all_custom_shortcuts(); let canon = canonical_key("secondary-c").expect("copy's key parses"); assert_eq!(owner_of_shortcut(&canon), Some(ActionId::Copy)); @@ -1072,7 +1127,9 @@ mod tests { #[test] #[cfg(target_os = "macos")] fn display_shortcut_uses_the_effective_key() { - let _guard = shortcuts_test_lock().lock().unwrap_or_else(|e| e.into_inner()); + let _guard = shortcuts_test_lock() + .lock() + .unwrap_or_else(|e| e.into_inner()); set_custom_shortcut("saveproj", vec!["cmd-alt-s".to_string()]); assert_eq!( display_shortcut(ActionId::SaveProject).as_deref(), diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index 45592f8c8..3ee9be338 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -56,7 +56,7 @@ use gpui_widgets::dialog::{DialogButton, Modal, ModalEvent, ModalOptions}; use gpui_widgets::theme::{apply_theme, OakTheme}; use gpui_widgets::viewer::PlaybackClock; -use crate::actions::{ActionId, Tool}; +use crate::actions::{ActionId, TimelineToolExt, Tool}; use crate::dialogs::{ExportDialogContent, PreferencesDialogContent}; use crate::oakui::{AppEngine, ExportSession, MockEngine, Monitor, RealEngine}; use crate::panels::commands as panel_commands; @@ -195,7 +195,9 @@ enum ModalState { content: Entity>, }, /// The about dialog (Help > About Oak…; static content). - About { modal: Entity }, + About { + modal: Entity, + }, /// The new-sequence dialog (File > New > Sequence…). NewSequence { modal: Entity, @@ -370,6 +372,9 @@ pub struct OakApp { /// The active timeline tool (the Tools menu's exclusive group; the /// timeline toolbar keeps its own visual selection for now). active_tool: Tool, + /// The tool the timeline widget last reported; app tool follows it via + /// the observe hook on [`Self::timeline`] (toolbar ↔ menu stay in sync). + last_timeline_tool: gpui::timeline::TimelineTool, /// 回放 → 循环播放 is on (the C++ `Loop` config flag; playback looping /// itself is a transport gap, so this only drives the checkmark). loop_playback: bool, @@ -411,6 +416,17 @@ impl OakApp { let source_clock = engine.read(cx).source_clock().clone(); let program_clock = engine.read(cx).program_clock().clone(); let timeline = cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0)); + // The toolbar and the Tools menu both drive the widget's tool; the + // widget is the single source of truth, and any change (from either + // side) is mirrored into the app tool + menu checkmark here. + cx.observe(&timeline, |this, timeline, cx| { + let tool = timeline.read(cx).tool(); + if tool != this.last_timeline_tool { + this.last_timeline_tool = tool; + this.active_tool = tool.app_tool(); + this.rebuild_menu_bar(cx); + } + }); // M12 P4: install the waveform decorator when the engine provides // a waveform cache. if let Some(cache) = engine.read(cx).waveform_cache() { @@ -516,7 +532,9 @@ impl OakApp { // properties dialog. cx.subscribe( &panels.project, - |this, _panel, event: &crate::panels::project_explorer::SequencePropertiesRequested, + |this, + _panel, + event: &crate::panels::project_explorer::SequencePropertiesRequested, cx| { this.open_sequence_properties(event.0, cx); }, @@ -735,6 +753,7 @@ impl OakApp { focused_panel: None, panels, active_tool: Tool::Pointer, + last_timeline_tool: gpui::timeline::TimelineTool::Select, loop_playback: false, show_all: false, full_screen: false, @@ -1094,7 +1113,15 @@ impl OakApp { tool_action if Tool::from_action(tool_action).is_some() => { let tool = Tool::from_action(tool_action).unwrap(); self.active_tool = tool; - println!("[tools] selected: {tool:?} (placeholder behavior)"); + println!("[tools] selected: {tool:?}"); + // Push the app tool into the timeline widget when it models + // one; unmodeled tools (edit/transition/add/record/hand) keep + // the widget's current tool. + if let Some(tt) = tool.timeline_tool() { + self.timeline.update(cx, |timeline, cx| { + timeline.set_tool(tt, cx); + }); + } self.rebuild_menu_bar(cx); } // --- Proxy (Tools) --------------------------------------------- @@ -1436,7 +1463,8 @@ impl OakApp { menu::ViewerPanelEvent::SetInPoint => self.set_point_at_playhead(true, cx), menu::ViewerPanelEvent::SetOutPoint => self.set_point_at_playhead(false, cx), menu::ViewerPanelEvent::ClearRange => { - self.engine.update(cx, |engine, cx| engine.clear_workarea(cx)); + self.engine + .update(cx, |engine, cx| engine.clear_workarea(cx)); } } } @@ -2193,8 +2221,9 @@ impl OakApp { .map(|p| p.name) .unwrap_or_default(); self.spawn_modal(cx, move |window, app| { - let content = - app.new(|cx| crate::dialogs::SequencePropertiesContent::new(engine, sequence_id, window, cx)); + let content = app.new(|cx| { + crate::dialogs::SequencePropertiesContent::new(engine, sequence_id, window, cx) + }); let modal = app.new(|cx| { Modal::new( modal_ids::SEQUENCE_PROPERTIES, diff --git a/crates/oak-app/src/oakui/graphops.rs b/crates/oak-app/src/oakui/graphops.rs index c64189ec8..e759b6451 100644 --- a/crates/oak-app/src/oakui/graphops.rs +++ b/crates/oak-app/src/oakui/graphops.rs @@ -43,7 +43,10 @@ use oak_node::sequence::SequenceBehavior; use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType}; use oak_node::value::VideoParams; use oak_timeline::handle::CHandle; -use oak_timeline::util::NodeRef; +use oak_timeline::util::{ + block_in, block_length, block_out, block_set_in, block_set_length_and_media_in, + block_set_length_and_media_out, clip_set_media_in, NodeRef, +}; use oak_storage::backend::StorageBackend; @@ -142,8 +145,7 @@ pub fn create_project() -> ProjectRef { pub fn load_ove(path: &Path) -> Result { let xml = std::fs::read_to_string(path) .map_err(|e| format!("failed to read \"{}\": {e}", path.display()))?; - let project = - oak_node::serializer::load(&xml).map_err(|e| format!("failed to parse: {e}"))?; + let project = oak_node::serializer::load(&xml).map_err(|e| format!("failed to parse: {e}"))?; let abs = if path.is_absolute() { path.to_path_buf() } else { @@ -166,7 +168,8 @@ pub fn save_ove(project: &ProjectRef, path: &Path) -> Result<(), String> { let guard = lock(project); oak_node::serializer::save(&guard).map_err(|e| format!("failed to serialize: {e}"))? }; - std::fs::write(path, &xml).map_err(|e| format!("failed to write \"{}\": {e}", path.display()))?; + std::fs::write(path, &xml) + .map_err(|e| format!("failed to write \"{}\": {e}", path.display()))?; let mut guard = lock(project); guard.set_filename(&path.to_string_lossy()); guard.set_modified(false); @@ -250,17 +253,19 @@ pub fn create_sequence_with_params( // Mount the sequence under the root folder. Not pushed to the undo // stack: like the default track layout below, it is part of the // sequence's creation, not an undoable edit. - oak_task::nodeops::folder_add_child_command( - (project.clone(), root), - (project.clone(), seq), - ) - .redo_now(); + oak_task::nodeops::folder_add_child_command((project.clone(), root), (project.clone(), seq)) + .redo_now(); // A new sequence starts with the default 2 video + 2 audio track // layout (user-mandated NLE default: V1, V2 on top, A1, A2 below). // Driven directly through the add-track commands' redo — NOT pushed // to the undo stack, a sequence's default layout is part of its // creation, not an undoable edit. - for kind in [TrackType::Video, TrackType::Video, TrackType::Audio, TrackType::Audio] { + for kind in [ + TrackType::Video, + TrackType::Video, + TrackType::Audio, + TrackType::Audio, + ] { let Some(list) = find_or_create_track_list(project, seq, kind) else { continue; }; @@ -285,10 +290,8 @@ pub fn create_folder(project: &ProjectRef, name: &str) -> Result let mut guard = lock(project); guard.graph.add_node(core, behavior) }; - let cmd = oak_task::nodeops::folder_add_child_command( - (project.clone(), root), - (project.clone(), id), - ); + let cmd = + oak_task::nodeops::folder_add_child_command((project.clone(), root), (project.clone(), id)); oak_undo::global::push(cmd, "New Folder").map_err(|e| e.to_string())?; Ok(id) } @@ -324,11 +327,8 @@ pub fn ensure_sequences_mounted(project: &ProjectRef) { (root, orphans) }; for id in orphans { - oak_task::nodeops::folder_add_child_command( - (project.clone(), root), - (project.clone(), id), - ) - .redo_now(); + oak_task::nodeops::folder_add_child_command((project.clone(), root), (project.clone(), id)) + .redo_now(); } } @@ -715,12 +715,18 @@ pub fn import_footage(project: &ProjectRef, path: &Path) -> Result()) else { - return Err("internal error: footage node created without footage behavior".to_string()); + return Err( + "internal error: footage node created without footage behavior".to_string(), + ); }; f.filename = filename.clone(); // Probe before the node enters the graph so a failed probe leaves @@ -737,10 +743,8 @@ pub fn import_footage(project: &ProjectRef, path: &Path) -> Result Vec<(Rational, String, i32)> { // SAFETY: `list` boxes a `TimelineMarkerList` (created by // `marker_list_create`); the read is shared and brief. let Some(l) = (unsafe { - oak_timeline::handle::get::>>( - list, - ) + oak_timeline::handle::get::< + std::sync::Arc>, + >(list) }) else { return Vec::new(); }; @@ -804,9 +808,9 @@ pub fn marker_index_at(list: &CHandle, time: Rational) -> Option { } // SAFETY: as `markers_of`. let l = unsafe { - oak_timeline::handle::get::>>( - list, - ) + oak_timeline::handle::get::< + std::sync::Arc>, + >(list) }?; let l = l.lock().unwrap_or_else(|e| e.into_inner()); (0..l.size()).find(|&i| l.at(i).map(|m| m.time().in_()) == Some(time)) @@ -820,9 +824,9 @@ pub fn workarea_state(wa: &CHandle) -> Option<(bool, TimeRange)> { // SAFETY: `wa` boxes a `TimelineWorkArea` (created by // `workarea_create`); the read is shared and brief. let w = unsafe { - oak_timeline::handle::get::>>( - wa, - ) + oak_timeline::handle::get::< + std::sync::Arc>, + >(wa) }?; let w = w.lock().unwrap_or_else(|e| e.into_inner()); Some((w.enabled(), *w.range())) @@ -836,9 +840,9 @@ pub fn workarea_set(wa: &CHandle, enabled: bool, range: TimeRange) { // SAFETY: `wa` boxes a `TimelineWorkArea`; the engine writes it only // from the UI thread. if let Some(w) = unsafe { - oak_timeline::handle::get_mut::>>( - wa, - ) + oak_timeline::handle::get_mut::< + std::sync::Arc>, + >(wa) } { let mut w = w.lock().unwrap_or_else(|e| e.into_inner()); w.set_enabled(enabled); @@ -959,8 +963,8 @@ pub fn library_duplicate(uuid: &str) -> Result { /// Import a `.ove` / `.otio` / `.fcpxml` project file as a new library /// row; returns the new row's uuid. pub fn library_import(path: &Path) -> Result { - let file_uri = oak_storage::uri::StorageUri::parse(&path.to_string_lossy()) - .map_err(|e| e.to_string())?; + let file_uri = + oak_storage::uri::StorageUri::parse(&path.to_string_lossy()).map_err(|e| e.to_string())?; oak_storage::writethrough::backend() .import_from_file(&library()?, &file_uri) .map_err(|e| e.to_string()) @@ -972,8 +976,8 @@ pub fn library_export(uuid: &str, path: &Path) -> Result<(), String> { if uuid.is_empty() { return Err("invalid uuid".to_string()); } - let file_uri = oak_storage::uri::StorageUri::parse(&path.to_string_lossy()) - .map_err(|e| e.to_string())?; + let file_uri = + oak_storage::uri::StorageUri::parse(&path.to_string_lossy()).map_err(|e| e.to_string())?; oak_storage::writethrough::backend() .export_to_file(&library()?, uuid, &file_uri) .map_err(|e| e.to_string()) @@ -996,8 +1000,8 @@ pub fn library_open(uuid: &str) -> Result { )); } let handle = result.project; - let project = unsafe { oak_storage::nodeutil::project_arc(&handle) } - .map_err(|e| e.to_string())?; + let project = + unsafe { oak_storage::nodeutil::project_arc(&handle) }.map_err(|e| e.to_string())?; lock(&project).set_modified(false); Ok(project) } @@ -1105,7 +1109,9 @@ pub fn connect_command( return Err(format!("connect: input \"{input_id}\" is not connectable")); } if g.graph.connected_output(to, input_id, -1).is_some() { - return Err(format!("connect: input \"{input_id}\" is already connected")); + return Err(format!( + "connect: input \"{input_id}\" is already connected" + )); } } let (p1, p2) = (p.clone(), p.clone()); @@ -1177,15 +1183,13 @@ pub fn set_context_position_command( if !g.graph.is_valid(node) || !g.graph.is_valid(context) { return Err("set position: node not found".to_string()); }; - g.graph - .get(node) - .and_then(|e| { - e.core - .context_positions - .iter() - .find(|(c, _, _)| *c == context) - .map(|(_, pos, expanded)| (*pos, *expanded)) - }) + g.graph.get(node).and_then(|e| { + e.core + .context_positions + .iter() + .find(|(c, _, _)| *c == context) + .map(|(_, pos, expanded)| (*pos, *expanded)) + }) }; let (p1, p2) = (p.clone(), p.clone()); Ok(oak_undo::undocommand::UndoCommand::from_closures( @@ -1546,7 +1550,12 @@ pub fn place_footage_clips_linked( ) .to_command(), ); - commands.push(connect_command(p, footage, clip, oak_node::block::clip_input::TEXTURE_INPUT)?); + commands.push(connect_command( + p, + footage, + clip, + oak_node::block::clip_input::TEXTURE_INPUT, + )?); } // Link the group both ways. Each ordered pair is its own command whose // undo removes ONLY the direction it added (incremental undo), so a @@ -1583,7 +1592,8 @@ pub fn place_footage_clips_linked( /// Split `clip` at `time_ts` (a frame timestamp strictly inside the /// clip's range), undoable "Split Clip" (the module's /// `BlockSplitCommand`). -pub fn split_clip(p: &ProjectRef, clip: NodeId, time_ts: i64) -> Result<(), String> { let (tb, in_r, out_r) = { +pub fn split_clip(p: &ProjectRef, clip: NodeId, time_ts: i64) -> Result<(), String> { + let (tb, in_r, out_r) = { let g = lock(p); let tb = clip_track(&g.graph, clip) .and_then(|t| track_behavior(&g.graph, t)) @@ -1592,13 +1602,15 @@ pub fn split_clip(p: &ProjectRef, clip: NodeId, time_ts: i64) -> Result<(), Stri .and_then(|l| l.sequence) .and_then(|s| sequence_time_base(&g.graph, s)) .ok_or_else(|| "the clip's sequence has no valid frame rate".to_string())?; - let (in_r, out_r, _) = clip_range(&g.graph, clip) - .ok_or_else(|| "the node is not a clip".to_string())?; + let (in_r, out_r, _) = + clip_range(&g.graph, clip).ok_or_else(|| "the node is not a clip".to_string())?; (tb, in_r, out_r) }; let point = ts_to_rational(time_ts, tb); if point <= in_r || point >= out_r { - return Err(format!("split time {time_ts} is not strictly inside the clip")); + return Err(format!( + "split time {time_ts} is not strictly inside the clip" + )); } push( oak_timeline::undosplit::BlockSplitCommand::new(node_ref(p, clip), point).to_command(), @@ -1646,8 +1658,64 @@ pub fn split_clips_preserving_links( /// a time, trim-in anchors the OUT, trim-out anchors the IN — the /// module's own `BlockTrimCommand` applies its length setters with /// inverted semantics, so the closures carry the correct mapping). -pub fn trim_clip(p: &ProjectRef, clip: NodeId, new_in_ts: i64, new_out_ts: i64) -> Result<(), String> { +/// An undoable length change for `clip` (trim semantics: `out_anchored` +/// keeps the out point — trim-in — and shifts the in; otherwise the in +/// stays — trim-out). Shared by [`trim_clip`] and [`ripple_trim_clip`]; +/// the closure applies the new length on redo and restores `old` on undo. +pub(crate) fn trim_command( + p: &ProjectRef, + clip: NodeId, + out_anchored: bool, + old: Rational, + new: Rational, +) -> oak_undo::undocommand::UndoCommand { use oak_node::block::BlockCore; + let (p1, p2) = (p.clone(), p.clone()); + oak_undo::undocommand::UndoCommand::from_closures( + move || { + let mut g = lock(&p1); + if let Some(c) = g + .graph + .get_mut(clip) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + if out_anchored { + BlockCore::set_length_and_media_out(&mut c.core, new); + } else { + BlockCore::set_length_and_media_in(&mut c.core, new); + } + } + }, + move || { + let mut g = lock(&p2); + if let Some(c) = g + .graph + .get_mut(clip) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + { + if out_anchored { + BlockCore::set_length_and_media_out(&mut c.core, old); + } else { + BlockCore::set_length_and_media_in(&mut c.core, old); + } + } + }, + ) +} + +/// Trim `clip`'s timeline range to `[new_in_ts, new_out_ts)` (undoable +/// "Trim Clip"; the facade's `oakengine_clip_trim` semantics: one end at +/// a time, trim-in anchors the OUT, trim-out anchors the IN — the +/// module's own `BlockTrimCommand` applies its length setters with +/// inverted semantics, so the closures carry the correct mapping). +pub fn trim_clip( + p: &ProjectRef, + clip: NodeId, + new_in_ts: i64, + new_out_ts: i64, +) -> Result<(), String> { if new_in_ts < 0 || new_out_ts <= new_in_ts { return Err("invalid trim range (need 0 <= new_in < new_out)".to_string()); } @@ -1671,65 +1739,251 @@ pub fn trim_clip(p: &ProjectRef, clip: NodeId, new_in_ts: i64, new_out_ts: i64) let new_in = ts_to_rational(new_in_ts, tb); let new_out = ts_to_rational(new_out_ts, tb); - /// A trim closure command: `set` applies the length (in anchored or - /// out anchored) for both redo and undo with the captured values. - fn trim_cmd( - p: &ProjectRef, - clip: NodeId, - out_anchored: bool, - old: Rational, - new: Rational, - ) -> oak_undo::undocommand::UndoCommand { - let (p1, p2) = (p.clone(), p.clone()); - oak_undo::undocommand::UndoCommand::from_closures( - move || { - let mut g = lock(&p1); - if let Some(c) = g - .graph - .get_mut(clip) - .and_then(|e| e.behavior.as_any_mut()) - .and_then(|a| a.downcast_mut::()) - { - if out_anchored { - BlockCore::set_length_and_media_out(&mut c.core, new); - } else { - BlockCore::set_length_and_media_in(&mut c.core, new); - } - } - }, - move || { - let mut g = lock(&p2); - if let Some(c) = g - .graph - .get_mut(clip) - .and_then(|e| e.behavior.as_any_mut()) - .and_then(|a| a.downcast_mut::()) - { - if out_anchored { - BlockCore::set_length_and_media_out(&mut c.core, old); - } else { - BlockCore::set_length_and_media_in(&mut c.core, old); - } - } - }, - ) - } - let mut children = Vec::new(); if new_in != old_in { // in-trim: length = block out - new in (out anchored). - children.push(trim_cmd(p, clip, true, old_length, old_out - new_in)); + children.push(trim_command(p, clip, true, old_length, old_out - new_in)); } if new_out != old_out { // out-trim: length = new out - new in (in anchored); the old // length is the post-in-trim length (out - new in) when both ends // move. let post_in_trim = old_out - new_in; - children.push(trim_cmd(p, clip, false, post_in_trim, new_out - new_in)); + children.push(trim_command(p, clip, false, post_in_trim, new_out - new_in)); } push_multi(children, "Trim Clip") } +/// Ripple-trim `clip` so its `start_edge` lands on `new_frame` (undoable +/// "Ripple Trim Clip"): the trimmed clip's media anchor is kept (trim-in +/// anchors the OUT, trim-out anchors the IN, exactly like [`trim_clip`]), +/// and every block after it on the same track shifts rigidly by the same +/// delta — no gap opens behind the trimmed edge, the tail just follows. +pub fn ripple_trim_clip( + p: &ProjectRef, + clip: NodeId, + start_edge: bool, + new_frame: i64, +) -> Result<(), String> { + let (tb, old_in, old_out, tail) = { + let g = lock(p); + let tb = clip_track(&g.graph, clip) + .and_then(|t| track_behavior(&g.graph, t)) + .and_then(|t| t.track_list) + .and_then(|l| track_list_behavior(&g.graph, l)) + .and_then(|l| l.sequence) + .and_then(|s| sequence_time_base(&g.graph, s)) + .ok_or_else(|| "the clip's sequence has no valid frame rate".to_string())?; + let (in_r, out_r, _) = + clip_range(&g.graph, clip).ok_or_else(|| "the node is not a clip".to_string())?; + let track = + clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())?; + let blocks = track_behavior(&g.graph, track) + .map(|t| t.blocks.clone()) + .unwrap_or_default(); + let index = blocks + .iter() + .position(|&b| b == clip) + .ok_or_else(|| "the clip is not on its track".to_string())?; + (tb, in_r, out_r, blocks[index + 1..].to_vec()) + }; + let new = ts_to_rational(new_frame, tb); + let old_length = old_out - old_in; + // start_edge (trim-in): the out stays anchored, length = out - new; + // the tail shifts by (new - in). Otherwise (trim-out): the in stays + // anchored, length = new - in; the tail shifts by (new - out). + let (trim_len, anchored) = if start_edge { + (old_out - new, true) + } else { + (new - old_in, false) + }; + let delta = if start_edge { + new - old_in + } else { + new - old_out + }; + if delta.is_null() { + return Ok(()); + } + let mut children = Vec::new(); + children.push(trim_command(p, clip, anchored, old_length, trim_len)); + for b in tail { + let b_ref = node_ref(p, b); + let b_old_in = block_in(&b_ref); + let (r1, r2) = (b_ref.clone(), b_ref); + children.push(oak_undo::undocommand::UndoCommand::from_closures( + move || block_set_in(&r1, b_old_in + delta), + move || block_set_in(&r2, b_old_in), + )); + } + push_multi(children, "Ripple Trim Clip") +} + +/// Roll-edit the shared boundary of adjacent clips `a`/`b` (both on +/// `track`) to `new_frame` (undoable "Roll Edit"): the boundary moves +/// without disturbing anything else on the track — the module's own +/// `BlockTrimCommand` in roll-edit mode trims `a` out-anchored and +/// compensates `b` in-anchored, so both media anchors are preserved. +pub fn roll_edit( + p: &ProjectRef, + track: NodeId, + a: NodeId, + b: NodeId, + new_frame: i64, +) -> Result<(), String> { + let (tb, a_in, a_out, b_in, b_out) = { + let g = lock(p); + let tb = clip_track(&g.graph, a) + .and_then(|t| track_behavior(&g.graph, t)) + .and_then(|t| t.track_list) + .and_then(|l| track_list_behavior(&g.graph, l)) + .and_then(|l| l.sequence) + .and_then(|s| sequence_time_base(&g.graph, s)) + .ok_or_else(|| "the clip's sequence has no valid frame rate".to_string())?; + let (a_in, a_out, _) = + clip_range(&g.graph, a).ok_or_else(|| "node a is not a clip".to_string())?; + let (b_in, b_out, _) = + clip_range(&g.graph, b).ok_or_else(|| "node b is not a clip".to_string())?; + (tb, a_in, a_out, b_in, b_out) + }; + if a_out != b_in { + return Err("roll edit: the clips do not share a boundary".to_string()); + } + let new = ts_to_rational(new_frame, tb); + if new <= a_in || new >= b_out { + return Err("roll edit: boundary would escape the adjacent clips".to_string()); + } + if new == a_out { + // Boundary didn't move — nothing to do. + return Ok(()); + } + let mut cmd = oak_timeline::undopointer::BlockTrimCommand::new( + node_ref(p, track), + node_ref(p, a), + new - a_in, + oak_timeline::common::MovementMode::TrimOut, + ); + cmd.set_trim_is_a_roll_edit(true); + cmd.prepare(); + push(cmd.to_command(), "Roll Edit") +} + +/// Slide `clip` so its in point becomes `new_start` (undoable "Slide +/// Clip"): the clip's own media window and length are untouched — the +/// in point moves and the out follows — while the adjacent blocks are +/// shortened/lengthened to fill the gap or absorb the overlap (the left +/// neighbor's out moves to `new_start`, the right neighbor's in follows +/// the clip's new out). A neighbor that would collapse to a negative +/// length is left alone, leaving the hole open. +pub fn slide_clip(p: &ProjectRef, clip: NodeId, new_start: i64) -> Result<(), String> { + let (tb, old_in, old_out, left, right) = { + let g = lock(p); + let tb = clip_track(&g.graph, clip) + .and_then(|t| track_behavior(&g.graph, t)) + .and_then(|t| t.track_list) + .and_then(|l| track_list_behavior(&g.graph, l)) + .and_then(|l| l.sequence) + .and_then(|s| sequence_time_base(&g.graph, s)) + .ok_or_else(|| "the clip's sequence has no valid frame rate".to_string())?; + let (in_r, out_r, _) = + clip_range(&g.graph, clip).ok_or_else(|| "the node is not a clip".to_string())?; + let track = + clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())?; + let blocks = track_behavior(&g.graph, track) + .map(|t| t.blocks.clone()) + .unwrap_or_default(); + let index = blocks + .iter() + .position(|&b| b == clip) + .ok_or_else(|| "the clip is not on its track".to_string())?; + ( + tb, + in_r, + out_r, + blocks.get(index.wrapping_sub(1)).copied(), + blocks.get(index + 1).copied(), + ) + }; + let new = ts_to_rational(new_start.max(0), tb); + let delta = new - old_in; + if delta.is_null() { + return Ok(()); + } + let clip_len = old_out - old_in; + let mut children = Vec::new(); + // The clip itself slides: in moves, length and media window stay put + // (`set_in` keeps the length and never touches the media in-point). + { + let c_ref = node_ref(p, clip); + let (r1, r2) = (c_ref.clone(), c_ref); + children.push(oak_undo::undocommand::UndoCommand::from_closures( + move || block_set_in(&r1, new), + move || block_set_in(&r2, old_in), + )); + } + // Left neighbor: its out follows the clip's new in (in anchored, + // media untouched). A neighbor that would collapse to a negative + // length is left alone, leaving the hole open. + if let Some(l) = left { + let l_ref = node_ref(p, l); + let l_len = block_length(&l_ref); + if new > block_in(&l_ref) { + let (r1, r2) = (l_ref.clone(), l_ref); + children.push(oak_undo::undocommand::UndoCommand::from_closures( + move || block_set_length_and_media_in(&r1, l_len + delta), + move || block_set_length_and_media_in(&r2, l_len), + )); + } + } + // Right neighbor: its in follows the clip's new out (out anchored, + // media untouched); skipped when it would collapse to a negative + // length. + if let Some(r) = right { + let r_ref = node_ref(p, r); + let r_len = block_length(&r_ref); + if new + clip_len < block_out(&r_ref) { + let (r1, r2) = (r_ref.clone(), r_ref); + children.push(oak_undo::undocommand::UndoCommand::from_closures( + move || block_set_length_and_media_out(&r1, r_len - delta), + move || block_set_length_and_media_out(&r2, r_len), + )); + } + } + push_multi(children, "Slide Clip") +} + +/// Slip `clip` so its media in-point becomes `new_media_in` (undoable +/// "Slip Clip"): the timeline range and length stay put, only the media +/// window slides inside the clip (clamped to frame 0). +pub fn slip_clip(p: &ProjectRef, clip: NodeId, new_media_in: i64) -> Result<(), String> { + let (tb, old_media_in) = { + let g = lock(p); + let tb = clip_track(&g.graph, clip) + .and_then(|t| track_behavior(&g.graph, t)) + .and_then(|t| t.track_list) + .and_then(|l| track_list_behavior(&g.graph, l)) + .and_then(|l| l.sequence) + .and_then(|s| sequence_time_base(&g.graph, s)) + .ok_or_else(|| "the clip's sequence has no valid frame rate".to_string())?; + let (_, _, media_in) = + clip_range(&g.graph, clip).ok_or_else(|| "the node is not a clip".to_string())?; + (tb, media_in) + }; + let new = ts_to_rational(new_media_in.max(0), tb); + if new == old_media_in { + return Ok(()); + } + let c_ref = node_ref(p, clip); + let (r1, r2) = (c_ref.clone(), c_ref); + push( + oak_undo::undocommand::UndoCommand::from_closures( + move || clip_set_media_in(&r1, new), + move || clip_set_media_in(&r2, old_media_in), + ), + "Slip Clip", + ) +} + /// The undoable same-track move command for one clip (its in point becomes /// `new_in_ts`; the module's `TrackMoveBlockCommand` — the old spot becomes /// a gap, length and media-in are preserved). @@ -1740,11 +1994,14 @@ fn move_clip_command( ) -> Result { let (tb, list, track_index) = { let g = lock(p); - let track = clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())?; + let track = + clip_track(&g.graph, clip).ok_or_else(|| "the clip is not on a track".to_string())?; let list = track_behavior(&g.graph, track) .and_then(|t| t.track_list) .ok_or_else(|| "the clip's track has no list".to_string())?; - let track_index = track_behavior(&g.graph, track).map(|t| t.index).unwrap_or(0); + let track_index = track_behavior(&g.graph, track) + .map(|t| t.index) + .unwrap_or(0); let tb = track_list_behavior(&g.graph, list) .and_then(|l| l.sequence) .and_then(|s| sequence_time_base(&g.graph, s)) @@ -1886,8 +2143,8 @@ pub fn move_clip_with_links( .and_then(|l| l.sequence) .and_then(|s| sequence_time_base(&g.graph, s)) .ok_or_else(|| "the clip's sequence has no valid frame rate".to_string())?; - let (in_r, _, _) = clip_range(&g.graph, clip) - .ok_or_else(|| "the node is not a clip".to_string())?; + let (in_r, _, _) = + clip_range(&g.graph, clip).ok_or_else(|| "the node is not a clip".to_string())?; rational_to_ts(in_r, tb) }; // The linked clips' current in points (each stays on its own track; @@ -2162,7 +2419,9 @@ pub fn copy_clips(p: &ProjectRef, clips: &[NodeId]) -> Vec { seq.unwrap_or((1, 25)) }; let to_ts = |r: Rational| { - (r.numerator() * tb_den).checked_div(r.denominator() * tb_num).unwrap_or(0) + (r.numerator() * tb_den) + .checked_div(r.denominator() * tb_num) + .unwrap_or(0) }; let speed = clip_behavior(&g.graph, clip) .map(|c| c.core.speed) @@ -2377,7 +2636,11 @@ mod tests { for kind in [TrackType::Video, TrackType::Audio] { let blocks: Vec = track_ids(&g.graph, seq, kind) .iter() - .map(|t| track_behavior(&g.graph, *t).map(|t| t.blocks.len()).unwrap_or(0)) + .map(|t| { + track_behavior(&g.graph, *t) + .map(|t| t.blocks.len()) + .unwrap_or(0) + }) .collect(); tracks.push((kind, blocks)); } @@ -2392,13 +2655,20 @@ mod tests { let g = lock(&project); let video_blocks: usize = track_ids(&g.graph, seq, TrackType::Video) .iter() - .map(|t| track_behavior(&g.graph, *t).map(|t| t.blocks.len()).unwrap_or(0)) + .map(|t| { + track_behavior(&g.graph, *t) + .map(|t| t.blocks.len()) + .unwrap_or(0) + }) .sum(); assert_eq!(video_blocks, 0, "cycle {cycle}: undo removes the clips"); drop(g); oak_undo::global::redo().expect("redo"); let state = snapshot(&project); - assert_eq!(state, before, "cycle {cycle}: redo must restore the exact state"); + assert_eq!( + state, before, + "cycle {cycle}: redo must restore the exact state" + ); } oak_undo::global::clear().unwrap(); let _ = std::fs::remove_file(&media); @@ -2432,7 +2702,11 @@ mod tests { let color_of = |p: &ProjectRef, id: NodeId| -> i32 { let g = lock(p); - g.graph.get(id).expect("the clip node exists").core.override_color + g.graph + .get(id) + .expect("the clip node exists") + .core + .override_color }; // Label = the footage name; the color is locked in at creation. @@ -2489,7 +2763,8 @@ mod tests { oak_undo::global::clear().unwrap(); let project = create_project(); let seq = create_sequence(&project, "Paste Links"); - let media = std::env::temp_dir().join(format!("oak_paste_links_{}.mp4", std::process::id())); + let media = + std::env::temp_dir().join(format!("oak_paste_links_{}.mp4", std::process::id())); oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media"); let footage = import_footage(&project, &media).expect("import"); let dropped = place_footage_clips_linked( @@ -2520,12 +2795,18 @@ mod tests { oak_undo::global::undo().expect("undo paste"); { let g = lock(&project); - assert!(!g.graph.are_linked(pasted[0], pasted[1]), "undo unlinks the pair"); + assert!( + !g.graph.are_linked(pasted[0], pasted[1]), + "undo unlinks the pair" + ); } oak_undo::global::redo().expect("redo paste"); { let g = lock(&project); - assert!(g.graph.are_linked(pasted[0], pasted[1]), "redo relinks the pair"); + assert!( + g.graph.are_linked(pasted[0], pasted[1]), + "redo relinks the pair" + ); } oak_undo::global::clear().unwrap(); let _ = std::fs::remove_file(&media); @@ -2541,7 +2822,8 @@ mod tests { oak_undo::global::clear().unwrap(); let project = create_project(); let seq = create_sequence(&project, "Delete Linked"); - let media = std::env::temp_dir().join(format!("oak_delete_linked_{}.mp4", std::process::id())); + let media = + std::env::temp_dir().join(format!("oak_delete_linked_{}.mp4", std::process::id())); oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media"); let footage = import_footage(&project, &media).expect("import"); let dropped = place_footage_clips_linked( @@ -2670,7 +2952,10 @@ mod tests { ); { let g = lock(&project); - assert!(clip_track(&g.graph, audio).is_none(), "audio is off-track now"); + assert!( + clip_track(&g.graph, audio).is_none(), + "audio is off-track now" + ); assert!(g.graph.are_linked(video, audio), "the link itself survives"); } @@ -2706,7 +2991,11 @@ mod undo_cycle_track_tests { let g = lock(p); track_ids(&g.graph, seq, kind).len() }; - assert_eq!(count_of(&project, TrackType::Video), 2, "default 2 video tracks"); + assert_eq!( + count_of(&project, TrackType::Video), + 2, + "default 2 video tracks" + ); let index = add_track(&project, seq, TrackType::Video).expect("add a track"); assert_eq!(index, 2, "the new track is the third video track"); @@ -2763,8 +3052,10 @@ mod undo_cycle_ops_tests { let (in_r, out_r, _) = clip_range(&g.graph, b).unwrap_or_default(); ( b, - in_r.numerator() as i128 * 1_000_000 / in_r.denominator().max(1) as i128, - out_r.numerator() as i128 * 1_000_000 / out_r.denominator().max(1) as i128, + in_r.numerator() as i128 * 1_000_000 + / in_r.denominator().max(1) as i128, + out_r.numerator() as i128 * 1_000_000 + / out_r.denominator().max(1) as i128, ) }) .collect() @@ -2776,7 +3067,12 @@ mod undo_cycle_ops_tests { (g.graph.node_count(), tracks) } - fn cycle_assert(p: &ProjectRef, seq: NodeId, post: &(usize, Vec<(NodeId, Vec<(NodeId, i128, i128)>)>), what: &str) { + fn cycle_assert( + p: &ProjectRef, + seq: NodeId, + post: &(usize, Vec<(NodeId, Vec<(NodeId, i128, i128)>)>), + what: &str, + ) { for cycle in 0..3 { oak_undo::global::undo().unwrap_or_else(|e| panic!("{what}: undo failed: {e:?}")); oak_undo::global::redo().unwrap_or_else(|e| panic!("{what}: redo failed: {e:?}")); diff --git a/crates/oak-app/src/oakui/mock.rs b/crates/oak-app/src/oakui/mock.rs index 5638d365f..b3a432885 100644 --- a/crates/oak-app/src/oakui/mock.rs +++ b/crates/oak-app/src/oakui/mock.rs @@ -1020,10 +1020,7 @@ impl MockEngine { /// matching a selected graph node. fn effect_for_node(&self, node: NodeId) -> Option { let title = self.nodes.iter().find(|n| n.id() == node)?.title(); - self.effects - .iter() - .find(|e| e.title == title) - .map(|e| e.id) + self.effects.iter().find(|e| e.title == title).map(|e| e.id) } /// Looks up a node by id (test helper). @@ -1486,6 +1483,103 @@ impl AppEngine for MockEngine { } cx.notify(); } + TimelineEvent::ClipSplitRequested { clip, time } => { + self.split_clip(*clip, *time, cx); + } + TimelineEvent::ClipRippleTrimRequested { + clip, + edge, + new_frame, + } => { + if let Some((track, index)) = self.mock_clip_position(*clip) { + if self.tracks[track].locked { + return; + } + // Trim the clip itself (same as a plain trim), then shift + // every later clip on the track by the same delta — the + // mock's stand-in for the real ripple-close of the gap. + let delta = { + let clip = &self.tracks[track].clips[index]; + match edge { + gpui::timeline::TrimEdge::Start => new_frame.0 - clip.range.start.0, + gpui::timeline::TrimEdge::End => new_frame.0 - clip.range.end.0, + } + }; + { + let clip = &mut self.tracks[track].clips[index]; + match edge { + gpui::timeline::TrimEdge::Start => { + clip.range.start = *new_frame; + clip.media_in = Frame(clip.media_in.0 + delta); + } + gpui::timeline::TrimEdge::End => { + clip.range.end = *new_frame; + } + } + } + for later in self.tracks[track].clips.iter_mut().skip(index + 1) { + later.range = FrameRange::new( + Frame(later.range.start.0 + delta), + Frame(later.range.end.0 + delta), + ); + } + } + cx.notify(); + } + TimelineEvent::ClipRollRequested { + clip_a, + clip_b, + new_frame, + } => { + let (Some((ta, ia)), Some((tb, ib))) = ( + self.mock_clip_position(*clip_a), + self.mock_clip_position(*clip_b), + ) else { + return; + }; + if ta != tb || self.tracks[ta].locked { + return; + } + // The boundary must stay inside the two clips' combined span. + let (a_start, b_end) = { + let a = &self.tracks[ta].clips[ia]; + let b = &self.tracks[ta].clips[ib]; + (a.range.start.0, b.range.end.0) + }; + if new_frame.0 <= a_start || new_frame.0 >= b_end { + return; + } + let (a_idx, b_idx) = if ia < ib { (ia, ib) } else { (ib, ia) }; + self.tracks[ta].clips[a_idx].range.end = *new_frame; + self.tracks[ta].clips[b_idx].range.start = *new_frame; + cx.notify(); + } + TimelineEvent::ClipSlideRequested { clip, new_start } => { + if let Some((track, index)) = self.mock_clip_position(*clip) { + if self.tracks[track].locked { + return; + } + // The clip slides in time; its media offset is unchanged. + let length = { + let clip = &self.tracks[track].clips[index]; + clip.range.end.0 - clip.range.start.0 + }; + let clip = &mut self.tracks[track].clips[index]; + clip.range = FrameRange::new(*new_start, Frame(new_start.0 + length)); + } + cx.notify(); + } + TimelineEvent::ClipSlipRequested { clip, new_media_in } => { + if let Some((track, index)) = self.mock_clip_position(*clip) { + if self.tracks[track].locked { + return; + } + // The clip's position in time is fixed; only the media + // offset under it changes. + self.tracks[track].clips[index].media_in = *new_media_in; + } + cx.notify(); + } TimelineEvent::ClipMoveRequested { clip, new_track, @@ -1495,7 +1589,11 @@ impl AppEngine for MockEngine { return; }; if self.tracks[track].locked - || self.tracks.get(*new_track).map(|t| t.locked).unwrap_or(true) + || self + .tracks + .get(*new_track) + .map(|t| t.locked) + .unwrap_or(true) { return; } @@ -1542,17 +1640,17 @@ impl AppEngine for MockEngine { } cx.notify(); } - TimelineEvent::WorkAreaPreview { start, end } => { - self.set_workarea_preview(*start, *end, cx); - } - TimelineEvent::WorkAreaCommitted { - start, - end, - old_start, - old_end, - } => { - self.commit_workarea(*old_start, *old_end, *start, *end, cx); - } + TimelineEvent::WorkAreaPreview { start, end } => { + self.set_workarea_preview(*start, *end, cx); + } + TimelineEvent::WorkAreaCommitted { + start, + end, + old_start, + old_end, + } => { + self.commit_workarea(*old_start, *old_end, *start, *end, cx); + } } } @@ -1706,7 +1804,8 @@ impl AppEngine for MockEngine { // The footage's media type, inferred from its entry name (the mock // never probes media). Entries the explorer does not list are // rejected. - let Some(name) = self.footage_entry_name(id) else { println!("[mock engine] drop footage: entry {id} not in the project"); + let Some(name) = self.footage_entry_name(id) else { + println!("[mock engine] drop footage: entry {id} not in the project"); cx.notify(); return; }; @@ -1734,7 +1833,9 @@ impl AppEngine for MockEngine { // A 10-second demo clip (the mock has no media durations). let fps = self.frame_rate(); let length = Frame( - (10.0 * fps.num as f64 / fps.den.max(1) as f64).round().max(1.0) as i64, + (10.0 * fps.num as f64 / fps.den.max(1) as f64) + .round() + .max(1.0) as i64, ); let clip = MockClip { id: ClipId(self.next_mock_clip_id()), @@ -1780,10 +1881,18 @@ impl AppEngine for MockEngine { fn footage_length_frames(&self, id: u64) -> Option { self.footage_entry_name(id)?; let fps = self.frame_rate(); - Some((10.0 * fps.num as f64 / fps.den.max(1) as f64).round().max(1.0) as i64) + Some( + (10.0 * fps.num as f64 / fps.den.max(1) as f64) + .round() + .max(1.0) as i64, + ) } - fn export_project_path(&mut self, _path: PathBuf, cx: &mut Context) -> Result<(), String> { + fn export_project_path( + &mut self, + _path: PathBuf, + cx: &mut Context, + ) -> Result<(), String> { println!("[mock engine] export: no persistence in mock mode"); cx.notify(); Ok(()) @@ -1935,7 +2044,11 @@ impl AppEngine for MockEngine { self.ocio_config.clone() } - fn set_project_ocio_config(&mut self, path: String, cx: &mut Context) -> Result<(), String> { + fn set_project_ocio_config( + &mut self, + path: String, + cx: &mut Context, + ) -> Result<(), String> { let trimmed = path.trim().to_string(); // Validate like the real engine (a bogus path keeps the dialog open). if !trimmed.is_empty() { @@ -1951,7 +2064,12 @@ impl AppEngine for MockEngine { self.cache_location.clone() } - fn set_project_cache_location(&mut self, setting: i32, custom_path: String, cx: &mut Context) { + fn set_project_cache_location( + &mut self, + setting: i32, + custom_path: String, + cx: &mut Context, + ) { let setting = setting.clamp(0, 2); self.cache_location = ( setting, @@ -2137,7 +2255,11 @@ impl AppEngine for MockEngine { self.mock_multicam_state() } - fn multicam_angle_frame(&mut self, source: i32, cx: &mut Context) -> Option> { + fn multicam_angle_frame( + &mut self, + source: i32, + cx: &mut Context, + ) -> Option> { let playhead = self.clock_frame(Monitor::Program, cx).0; self.mock_multicam_angle_frame(source, playhead) } @@ -2152,7 +2274,12 @@ impl AppEngine for MockEngine { self.mock_multicam_state().is_some() } - fn multicam_enable_selected(&mut self, _clips: Vec, enabled: bool, cx: &mut Context) { + fn multicam_enable_selected( + &mut self, + _clips: Vec, + enabled: bool, + cx: &mut Context, + ) { // Run the real enable/disable commands on the demo graph (one undo // entry each, like the real engine). let mut guard = self.ensure_demo_multicam(); @@ -2188,7 +2315,8 @@ impl AppEngine for MockEngine { let Some(demo) = guard.as_ref() else { return; }; - let Some(state) = crate::oakui::multicam::multicam_state_for_clip(&demo.project, demo.clip.id) + let Some(state) = + crate::oakui::multicam::multicam_state_for_clip(&demo.project, demo.clip.id) else { return; }; @@ -2203,9 +2331,7 @@ impl AppEngine for MockEngine { split_clip, playhead, ); - if let Err(e) = - super::graphops::push_command(cmd, oak_timeline::multicam::SWITCH_LABEL) - { + if let Err(e) = super::graphops::push_command(cmd, oak_timeline::multicam::SWITCH_LABEL) { println!("[mock] multicam switch failed: {e}"); } drop(guard); @@ -2393,8 +2519,10 @@ impl DemoMulticamGraph { list.sequence = Some(sequence); let list_id = g.graph.add_node(core, behavior); for _ in 0..4 { - let (core, behavior) = - (NodeCore::new(), Box::new(TrackBehavior::new(TrackType::Video))); + let (core, behavior) = ( + NodeCore::new(), + Box::new(TrackBehavior::new(TrackType::Video)), + ); let track_id = g.graph.add_node(core, behavior); let t = g .graph @@ -2724,7 +2852,11 @@ mod tests { clock.started = Some((Instant::now() - Duration::from_secs(10), Frame(0))); clock.tick(Frame(5), true); - assert_eq!(clock.transport.frame(), Frame(4), "pinned to the last frame"); + assert_eq!( + clock.transport.frame(), + Frame(4), + "pinned to the last frame" + ); assert!(!clock.transport.is_playing(), "playback stopped"); // A later tick is a no-op: the anchor is cleared. clock.tick(Frame(5), true); @@ -2799,7 +2931,12 @@ mod tests { assert!(is_expanded(app), "the demo card starts expanded"); toggle(app, false); engine.update(app, |engine, cx| { - engine.apply_effect_event(&EffectStackEvent::CardSelected { effect: EffectId(1) }, cx); + engine.apply_effect_event( + &EffectStackEvent::CardSelected { + effect: EffectId(1), + }, + cx, + ); }); engine.update(app, |engine, cx| { engine.apply_node_graph_event( @@ -3002,7 +3139,8 @@ mod tests { } #[gpui::test] - async fn mock_split_at_playhead_and_ripple_delete(cx: &mut TestAppContext) { cx.update(|app| { + async fn mock_split_at_playhead_and_ripple_delete(cx: &mut TestAppContext) { + cx.update(|app| { let engine = demo_engine(app); // Park the program playhead inside 开场 (0–240) and split there. diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index c37cb30cb..991a7122f 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -203,21 +203,14 @@ impl MonitorFrameCache { // Proxy stays primary while playing (a full-res render would fight // the moving playhead); a cached fill or an in-flight job mean no // new job (the drain re-schedules once the job lands). - !playing - && self.pending.is_none() - && !self.full.as_ref().is_some_and(|f| f.frame == frame) + !playing && self.pending.is_none() && !self.full.as_ref().is_some_and(|f| f.frame == frame) } /// Installs a completed full-res frame. Returns false — and keeps the /// cache untouched — when the completion is stale (the pending job it /// belongs to no longer matches, i.e. an edit, a selection change or a /// project drop happened while it was in flight). - fn install_full_res( - &mut self, - frame: i64, - generation: u64, - image: Arc, - ) -> bool { + fn install_full_res(&mut self, frame: i64, generation: u64, image: Arc) -> bool { if self.pending != Some((frame, generation)) { return false; } @@ -1102,7 +1095,10 @@ impl RealEngine { // audio per frame advance regardless of the rate. let chunk: i64 = 1; - let mut st = self.audio_prefetch.lock().unwrap_or_else(|e| e.into_inner()); + let mut st = self + .audio_prefetch + .lock() + .unwrap_or_else(|e| e.into_inner()); // Reset on seek / (re)start: the playhead must lie inside the // submitted window [front_ts, next_submit). if !st.covers(frame) { @@ -1111,7 +1107,11 @@ impl RealEngine { // Submit chunks to cover [next_submit, frame + PREFETCH ahead). In // steady state the window moves by one chunk per tick, so exactly // one new ticket is posted; the rest are already buffered. - let tx = self.audio_tx.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let tx = self + .audio_tx + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); let target = frame + chunk * AUDIO_PREFETCH_CHUNKS; while st.next_submit < target { let ts = st.next_submit; @@ -1458,8 +1458,12 @@ impl RealEngine { let Some(request) = self.build_full_res_request(monitor, frame) else { return; }; - self.cpu_frame_cache.lock().unwrap().entry(monitor).or_default().pending = - Some((frame, request.generation)); + self.cpu_frame_cache + .lock() + .unwrap() + .entry(monitor) + .or_default() + .pending = Some((frame, request.generation)); let tx = self.full_res_tx.lock().unwrap().clone(); std::thread::spawn(move || Self::full_res_worker(request, tx)); } @@ -1471,10 +1475,11 @@ impl RealEngine { let mut cache = self.cpu_frame_cache.lock().unwrap(); let rx = self.full_res_rx.lock().unwrap(); while let Ok(event) = rx.try_recv() { - cache - .entry(event.monitor) - .or_default() - .install_full_res(event.frame, event.generation, event.image); + cache.entry(event.monitor).or_default().install_full_res( + event.frame, + event.generation, + event.image, + ); } } @@ -1486,7 +1491,9 @@ impl RealEngine { /// time; 0 without a sequence). The sequence's stored playhead is /// mirrored from the program clock on every seek/tick. fn program_playhead_ts(&self) -> i64 { - let Some(project) = self.project_ref() else { return 0 }; + let Some(project) = self.project_ref() else { + return 0; + }; let Some(seq) = self.sequence else { return 0 }; let Some(tb) = self.time_base() else { return 0 }; let time = graphops::sequence_playhead(&graphops::lock(project).graph, seq); @@ -1556,16 +1563,22 @@ impl RealEngine { if source < 0 || source >= state.source_count { return None; } - let Some(project) = self.project.clone() else { return None }; - let Some(seq) = self.sequence else { return None }; - let Some(tb) = self.time_base() else { return None }; + let Some(project) = self.project.clone() else { + return None; + }; + let Some(seq) = self.sequence else { + return None; + }; + let Some(tb) = self.time_base() else { + return None; + }; let playhead = self.program_playhead_ts(); // Exact-playhead cache hit. - if let Some(img) = self - .multicam_frames - .lock() - .unwrap() - .lookup(state.node_id, source, playhead) + if let Some(img) = + self.multicam_frames + .lock() + .unwrap() + .lookup(state.node_id, source, playhead) { return Some(img); } @@ -1585,7 +1598,10 @@ impl RealEngine { let info = self.sequence_info.as_ref()?; let (w, h) = (info.format.width.max(1), info.format.height.max(1)); let scale = MULTICAM_ANGLE_LONG_EDGE as f64 / w.max(h) as f64; - (((w as f64 * scale).round() as u32).max(2) as i32, ((h as f64 * scale).round() as u32).max(2) as i32) + ( + ((w as f64 * scale).round() as u32).max(2) as i32, + ((h as f64 * scale).round() as u32).max(2) as i32, + ) }; cache.pending.insert((state.node_id, source)); let request = MulticamAngleRequest { @@ -1646,9 +1662,13 @@ impl RealEngine { if !playing { return; } - let Some(project) = self.project.clone() else { return }; + let Some(project) = self.project.clone() else { + return; + }; let Some(tb) = self.time_base() else { return }; - let Some((width, height)) = self.proxy_render_size() else { return }; + let Some((width, height)) = self.proxy_render_size() else { + return; + }; let Some(node) = (match monitor { Monitor::Program => self.sequence, Monitor::Source => self.selected_footage_node(), @@ -1664,7 +1684,9 @@ impl RealEngine { Monitor::Program => self.sequence_length().0, Monitor::Source => self.source_length().0, }; - let Some(m) = RenderManager::global() else { return }; + let Some(m) = RenderManager::global() else { + return; + }; let forward = config_get_int(CONFIG_KEY_PREVIEW_WINDOW, DEFAULT_PREVIEW_WINDOW_FORWARD) .clamp(8, 1200); @@ -1681,7 +1703,10 @@ impl RealEngine { // Reset / rebuild when the node changed or an invalidation bumped the // generation (the old pending/claimed requests are cancelled). - let mut windows = self.preview_windows.lock().unwrap_or_else(|e| e.into_inner()); + let mut windows = self + .preview_windows + .lock() + .unwrap_or_else(|e| e.into_inner()); let window = windows.entry(monitor).or_default(); // Cancel/release calls fire completions synchronously and those // completions lock `preview_windows`, so they must run AFTER this @@ -1752,12 +1777,7 @@ impl RealEngine { for frame in new_frames { let params = match monitor { Monitor::Program => super::renderops::sequence_frame_params( - &project, - node, - frame, - tb, - width, - height, + &project, node, frame, tb, width, height, ), Monitor::Source => { super::renderops::footage_frame_params(&project, node, frame, tb, width, height) @@ -1775,8 +1795,7 @@ impl RealEngine { // request. The stale slot's credit goes back instead. let mut stale_slot = None; { - let mut windows = - preview_windows.lock().unwrap_or_else(|e| e.into_inner()); + let mut windows = preview_windows.lock().unwrap_or_else(|e| e.into_inner()); let window = windows.entry(monitor).or_default(); if window.sequence == node_id && window.generation == version { match result { @@ -1801,7 +1820,8 @@ impl RealEngine { } } }); - m.tickets.submit_playback(params, frame, distance, version, done); + m.tickets + .submit_playback(params, frame, distance, version, done); self.preview_windows .lock() .unwrap_or_else(|e| e.into_inner()) @@ -1826,7 +1846,10 @@ impl RealEngine { Monitor::Source => self.selected_footage_node()?, }; let node_id = node.identity(); - let mut windows = self.preview_windows.lock().unwrap_or_else(|e| e.into_inner()); + let mut windows = self + .preview_windows + .lock() + .unwrap_or_else(|e| e.into_inner()); let window = windows.get_mut(&monitor)?; if window.sequence != node_id || window.generation != self.preview_generation { return None; @@ -1858,7 +1881,10 @@ impl RealEngine { // completions lock `preview_windows` (self-deadlock otherwise). let mut pending: Vec<(u64, Vec)> = Vec::new(); { - let mut windows = self.preview_windows.lock().unwrap_or_else(|e| e.into_inner()); + let mut windows = self + .preview_windows + .lock() + .unwrap_or_else(|e| e.into_inner()); for window in windows.values_mut() { let slots: Vec = std::mem::take(&mut window.slots).into_values().collect(); @@ -1883,12 +1909,14 @@ impl RealEngine { // Same lock-order rule as `cancel_preview_windows`: run the cancel // and releases outside the `preview_windows` guard. let pending = { - let mut windows = self.preview_windows.lock().unwrap_or_else(|e| e.into_inner()); + let mut windows = self + .preview_windows + .lock() + .unwrap_or_else(|e| e.into_inner()); let Some(window) = windows.get_mut(&monitor) else { return; }; - let slots: Vec = - std::mem::take(&mut window.slots).into_values().collect(); + let slots: Vec = std::mem::take(&mut window.slots).into_values().collect(); window.submitted.clear(); (window.sequence, slots) }; @@ -2124,9 +2152,8 @@ impl RealEngine { } } } - self.proxy_runs.retain(|run| { - !finished.iter().any(|(footage, _)| *footage == run.footage) - }); + self.proxy_runs + .retain(|run| !finished.iter().any(|(footage, _)| *footage == run.footage)); if let Some(project) = self.project.clone() { for (footage, ok) in &finished { let mut guard = graphops::lock(&project); @@ -2237,7 +2264,6 @@ impl RealEngine { self.pump_proxy_queue(cx); } - /// The proxy lifecycle state of one footage row: an in-flight run /// wins, then the disk state of the recorded proxy path, with a /// recorded failure (state 3) preserved while no file is on disk. @@ -2296,8 +2322,7 @@ impl RealEngine { let Some(node) = graphops::id_of(clip.0) else { continue; }; - let Some((block_in, _, media_in)) = graphops::clip_range(&guard.graph, node) - else { + let Some((block_in, _, media_in)) = graphops::clip_range(&guard.graph, node) else { continue; }; let Some(track) = graphops::clip_track(&guard.graph, node) else { @@ -2349,8 +2374,7 @@ impl RealEngine { // (node, track, list, track index, placement) for every valid // placement (the reference itself lands on the anchor). - let mut placements: Vec<(NodeId, NodeId, NodeId, i32, oak_core::Rational)> = - Vec::new(); + let mut placements: Vec<(NodeId, NodeId, NodeId, i32, oak_core::Rational)> = Vec::new(); for target in &targets { let placement = place_by_source_time(&reference.source, &target.source, anchor_in); if placement.valid { @@ -2400,7 +2424,9 @@ impl RealEngine { /// speed (one multi-undo). fn sync_clips_by_waveform_internal(&mut self, clips: &[ClipId], allow_speed: bool) { use oak_audio::synchronizer::place_by_waveform_offset; - use oak_audio::waveformsync::{estimate_envelope_offset_valid, estimate_stretch_and_offset}; + use oak_audio::waveformsync::{ + estimate_envelope_offset_valid, estimate_stretch_and_offset, + }; let Some(cache) = self.waveform_cache() else { return; @@ -2494,23 +2520,16 @@ impl RealEngine { ); // (node, track, list, track index, placement, speed, old speed). - let mut placements: Vec<( - NodeId, - NodeId, - NodeId, - i32, - oak_core::Rational, - f64, - f64, - )> = vec![( - reference.node, - reference.track, - reference.list, - reference.track_index, - reference.block_in, - 1.0, - reference.speed, - )]; + let mut placements: Vec<(NodeId, NodeId, NodeId, i32, oak_core::Rational, f64, f64)> = + vec![( + reference.node, + reference.track, + reference.list, + reference.track_index, + reference.block_in, + 1.0, + reference.speed, + )]; for (i, target) in targets.iter().enumerate() { if i == ref_index { @@ -2534,9 +2553,8 @@ impl RealEngine { if allow_speed && (!offset.valid || offset.confidence < 0.6) { // Inconclusive: the clips may run at different speeds — // search a rate range with a tighter offset radius. - let radius = max_offset_windows.min( - (i64::from(sample_rate) * 30) / window_samples as i64, - ); + let radius = + max_offset_windows.min((i64::from(sample_rate) * 30) / window_samples as i64); let stretch = estimate_stretch_and_offset( &ref_envelope, &cand_envelope, @@ -2640,7 +2658,9 @@ impl RealEngine { /// pool (snapshot serialized once per undo-stack revision; the worker /// renders node-graph tickets from it). fn push_graph_snapshot(&self) { - let Some(project) = self.project.clone() else { return }; + let Some(project) = self.project.clone() else { + return; + }; if let Some(m) = RenderManager::global() { let revision = oak_undo::global::index().unwrap_or(0).max(0) as u64; let _ = m.set_graph_snapshot(&project, revision); @@ -2688,8 +2708,8 @@ impl RealEngine { graphops::ensure_sequences_mounted(&project); // The sequence: the project's first, or a blank default. - let seq = first_sequence - .unwrap_or_else(|| graphops::create_sequence(&project, "Sequence 1")); + let seq = + first_sequence.unwrap_or_else(|| graphops::create_sequence(&project, "Sequence 1")); self.sequence = Some(seq); self.markers = Some(AuxHandle(graphops::marker_list_create())); self.workarea = Some(AuxHandle(graphops::workarea_create())); @@ -2851,7 +2871,13 @@ impl RealEngine { _ => Box::new(tracks.iter().enumerate().rev()), }; for (track_index, &track_id) in ordered { - out.push(Self::snapshot_track(&guard.graph, track_id, kind, track_index, tb)); + out.push(Self::snapshot_track( + &guard.graph, + track_id, + kind, + track_index, + tb, + )); } } } @@ -2895,16 +2921,16 @@ impl RealEngine { }; // Height in internal units → pixels. let height = graphops::track_behavior(graph, track) - .map(|t| { - px(oak_node::track::internal_height_to_pixel_height(t.height).max(24) as f32) - }) + .map(|t| px(oak_node::track::internal_height_to_pixel_height(t.height).max(24) as f32)) .unwrap_or(px(64.0)); let clips = graphops::clip_ids(graph, track) .iter() .enumerate() .filter_map(|(clip_index, &block)| { let (in_r, out_r, media_r) = graphops::clip_range(graph, block)?; - let to_ts = |r: oak_core::Rational| tb.map(|tb| graphops::rational_to_ts(r, tb)).unwrap_or(0); + let to_ts = |r: oak_core::Rational| { + tb.map(|tb| graphops::rational_to_ts(r, tb)).unwrap_or(0) + }; // The clip's color is locked in at creation time (its // `override_color`); clips without one (older projects) // fall back to the track-relative palette so their color @@ -3035,7 +3061,10 @@ impl RealEngine { let guard = graphops::lock(project); // A clip block node on the current timeline: its stack is the target. if graphops::clip_behavior(&guard.graph, node).is_some() - && self.tracks.iter().any(|t| t.clips.iter().any(|c| c.id.0 == ident)) + && self + .tracks + .iter() + .any(|t| t.clips.iter().any(|c| c.id.0 == ident)) { return (Some(ClipId(ident)), None); } @@ -3105,8 +3134,7 @@ impl RealEngine { // The OpenFX plugin badge: the persistent-message count (the // simplified 徽标/计数 of stage 6b). Built-in effects show none. let badge = plugin_handle.and_then(|handle| { - let count = - oak_plugin::suites::message::persistent_message_count(handle as usize); + let count = oak_plugin::suites::message::persistent_message_count(handle as usize); (count > 0).then_some(count) }); let subtitle = plugin_handle.map(|_| { @@ -3414,7 +3442,10 @@ impl AudioMeterDataSource for RealEngine { if n <= 0 { return vec![0.0, 0.0]; } - peaks[..n as usize].iter().map(|p| p.clamp(0.0, 1.0)).collect() + peaks[..n as usize] + .iter() + .map(|p| p.clamp(0.0, 1.0)) + .collect() } } @@ -3554,7 +3585,11 @@ impl AppEngine for RealEngine { let Some(project) = self.project.clone() else { return; }; - self.apply_edit(graphops::remove_track(&project, track_id), "remove track", cx); + self.apply_edit( + graphops::remove_track(&project, track_id), + "remove track", + cx, + ); } fn set_track_height(&mut self, height: Pixels, cx: &mut Context) { @@ -3589,7 +3624,10 @@ impl AppEngine for RealEngine { // the old selection is stale. The source pre-render window (M15 // S2) rebuilds against the new footage. *self.source_renderer.lock().unwrap() = RendererSlot::Untried; - self.cpu_frame_cache.lock().unwrap().remove(&Monitor::Source); + self.cpu_frame_cache + .lock() + .unwrap() + .remove(&Monitor::Source); self.full_res_generation = self.full_res_generation.wrapping_add(1); self.cancel_preview_window(Monitor::Source); } @@ -3880,6 +3918,85 @@ impl AppEngine for RealEngine { let result = graphops::trim_clip(&project, block, new_in, new_out); self.apply_edit(result, "trim clip", cx); } + TimelineEvent::ClipSplitRequested { clip, time } => { + self.split_clip(*clip, *time, cx); + } + TimelineEvent::ClipRippleTrimRequested { + clip, + edge, + new_frame, + } => { + let Some(block) = self.clip_block(*clip) else { + return; + }; + if self.clip_track_locked(block) { + return; + } + let Some(project) = self.project.clone() else { + return; + }; + let result = graphops::ripple_trim_clip( + &project, + block, + matches!(edge, TrimEdge::Start), + new_frame.0, + ); + self.apply_edit(result, "ripple trim clip", cx); + } + TimelineEvent::ClipRollRequested { + clip_a, + clip_b, + new_frame, + } => { + let (Some(block_a), Some(project)) = + (self.clip_block(*clip_a), self.project.clone()) + else { + return; + }; + if self.clip_track_locked(block_a) { + return; + } + let Some(block_b) = self.clip_block(*clip_b) else { + return; + }; + // The roll pair lives on one track; locate that track's node. + let Some(track) = self + .tracks + .iter() + .find(|t| t.clips.iter().any(|c| c.block == block_a)) + .map(|t| t.track) + else { + return; + }; + let result = graphops::roll_edit(&project, track, block_a, block_b, new_frame.0); + self.apply_edit(result, "roll edit", cx); + } + TimelineEvent::ClipSlideRequested { clip, new_start } => { + let Some(block) = self.clip_block(*clip) else { + return; + }; + if self.clip_track_locked(block) { + return; + } + let Some(project) = self.project.clone() else { + return; + }; + let result = graphops::slide_clip(&project, block, new_start.0); + self.apply_edit(result, "slide clip", cx); + } + TimelineEvent::ClipSlipRequested { clip, new_media_in } => { + let Some(block) = self.clip_block(*clip) else { + return; + }; + if self.clip_track_locked(block) { + return; + } + let Some(project) = self.project.clone() else { + return; + }; + let result = graphops::slip_clip(&project, block, new_media_in.0); + self.apply_edit(result, "slip clip", cx); + } TimelineEvent::ClipMoveRequested { clip, new_track, @@ -3969,8 +4086,7 @@ impl AppEngine for RealEngine { // setters. The muted flag doubles as the video/subtitle // visibility toggle (Olive parity); the model has no solo // flag yet, so solo requests are inert. - let (Some(t), Some(project)) = - (self.tracks.get(*track), self.project.clone()) + let (Some(t), Some(project)) = (self.tracks.get(*track), self.project.clone()) else { return; }; @@ -4271,11 +4387,7 @@ impl AppEngine for RealEngine { } } - fn export_project_path( - &mut self, - path: PathBuf, - cx: &mut Context, - ) -> Result<(), String> { + fn export_project_path(&mut self, path: PathBuf, cx: &mut Context) -> Result<(), String> { if self.project.is_none() { return Err("no project open".into()); } @@ -4477,7 +4589,11 @@ impl AppEngine for RealEngine { self.apply_edit(result, "drop footage", cx); return; } else { - let footage_kind = if has_video { TrackKind::Video } else { TrackKind::Audio }; + let footage_kind = if has_video { + TrackKind::Video + } else { + TrackKind::Audio + }; let Some(target) = ensure_track(self, footage_kind, cx) else { return; }; @@ -4551,8 +4667,7 @@ impl AppEngine for RealEngine { } fn library_delete_project(&mut self, uuid: &str) -> Result<(), String> { - graphops::library_delete(uuid) - .map_err(|e| format!("failed to delete the project: {e}")) + graphops::library_delete(uuid).map_err(|e| format!("failed to delete the project: {e}")) } fn library_rename_project(&mut self, uuid: &str, name: &str) -> Result<(), String> { @@ -4572,8 +4687,12 @@ impl AppEngine for RealEngine { } fn library_export_project(&mut self, uuid: &str, path: PathBuf) -> Result<(), String> { - graphops::library_export(uuid, &path) - .map_err(|e| format!("failed to export the project to \"{}\": {e}", path.display())) + graphops::library_export(uuid, &path).map_err(|e| { + format!( + "failed to export the project to \"{}\": {e}", + path.display() + ) + }) } fn set_use_proxy_media(&mut self, enabled: bool, cx: &mut Context) { @@ -4613,7 +4732,11 @@ impl AppEngine for RealEngine { .unwrap_or_default() } - fn set_project_ocio_config(&mut self, path: String, cx: &mut Context) -> Result<(), String> { + fn set_project_ocio_config( + &mut self, + path: String, + cx: &mut Context, + ) -> Result<(), String> { let Some(project) = self.project.clone() else { return Err("no project open".to_string()); }; @@ -4649,12 +4772,20 @@ impl AppEngine for RealEngine { .map(|p| { let g = graphops::lock(p); // Clamp: the dialog's combo indexes by this value. - (g.cache_location_setting.clamp(0, 2), g.custom_cache_path.clone()) + ( + g.cache_location_setting.clamp(0, 2), + g.custom_cache_path.clone(), + ) }) .unwrap_or((0, String::new())) } - fn set_project_cache_location(&mut self, setting: i32, custom_path: String, cx: &mut Context) { + fn set_project_cache_location( + &mut self, + setting: i32, + custom_path: String, + cx: &mut Context, + ) { let Some(project) = self.project.clone() else { return; }; @@ -4704,7 +4835,10 @@ impl AppEngine for RealEngine { format: VideoFormat { width, height, - rate: FrameRate::new(rate.numerator().max(1) as u32, rate.denominator().max(1) as u32), + rate: FrameRate::new( + rate.numerator().max(1) as u32, + rate.denominator().max(1) as u32, + ), }, interlaced, }) @@ -4828,8 +4962,8 @@ impl AppEngine for RealEngine { let (filename, stream_index, params) = { let project = self.project.as_ref().ok_or("no project open")?; let guard = graphops::lock(project); - let f = graphops::footage_behavior(&guard.graph, footage) - .ok_or("entry is not footage")?; + let f = + graphops::footage_behavior(&guard.graph, footage).ok_or("entry is not footage")?; if f.filename.is_empty() { return Err("the footage has no media file".into()); } @@ -4974,7 +5108,8 @@ impl AppEngine for RealEngine { } }; let _ = std::fs::remove_file(&proxy_path); - if let Ok(working) = oak_codec::proxymanager::ProxyManager::get_working_filename(&proxy_path) + if let Ok(working) = + oak_codec::proxymanager::ProxyManager::get_working_filename(&proxy_path) { let _ = std::fs::remove_file(&working); } @@ -5212,7 +5347,15 @@ impl AppEngine for RealEngine { }) }; let result = graphops::set_clips_linked(&project, &blocks, !all_linked); - self.apply_edit(result, if all_linked { "unlink clips" } else { "link clips" }, cx); + self.apply_edit( + result, + if all_linked { + "unlink clips" + } else { + "link clips" + }, + cx, + ); } fn start_export(&mut self, format: i32, path: PathBuf) -> Result { @@ -5235,7 +5378,11 @@ impl AppEngine for RealEngine { self.multicam_state_internal() } - fn multicam_angle_frame(&mut self, source: i32, _cx: &mut Context) -> Option> { + fn multicam_angle_frame( + &mut self, + source: i32, + _cx: &mut Context, + ) -> Option> { self.multicam_angle_frame_internal(source) } @@ -5277,7 +5424,12 @@ impl AppEngine for RealEngine { false } - fn multicam_enable_selected(&mut self, clips: Vec, enabled: bool, cx: &mut Context) { + fn multicam_enable_selected( + &mut self, + clips: Vec, + enabled: bool, + cx: &mut Context, + ) { let Some(project) = self.project.clone() else { return; }; @@ -5361,8 +5513,7 @@ impl AppEngine for RealEngine { split_clip, playhead, ); - let result = - graphops::push_command(cmd, oak_timeline::multicam::SWITCH_LABEL); + let result = graphops::push_command(cmd, oak_timeline::multicam::SWITCH_LABEL); self.apply_edit(result, "multicam switch", cx); } @@ -5531,7 +5682,11 @@ impl RealEngine { } /// Exports as `.otio` / `.fcpxml` through the oaktask save task. - fn export_interchange(&mut self, path: &PathBuf, _cx: &mut Context) -> Result<(), String> { + fn export_interchange( + &mut self, + path: &PathBuf, + _cx: &mut Context, + ) -> Result<(), String> { let Some(project) = self.project.clone() else { return Err("no project open".into()); }; @@ -5974,10 +6129,7 @@ mod tests { let _transition = ConfigRestore::of(CONFIG_KEY_DEFAULT_TRANSITION_SEC); config_set_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC, "1.5"); - assert_eq!( - config_get_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC), - "1.5" - ); + assert_eq!(config_get_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC), "1.5"); let _output = ConfigRestore::of(CONFIG_KEY_AUDIO_OUTPUT); config_set_string(CONFIG_KEY_AUDIO_OUTPUT, "Test Speakers"); @@ -6031,7 +6183,10 @@ mod tests { /// writethrough.rs) — a rename here would silently disconnect the dialog. #[test] fn snapshot_interval_key_matches_the_storage_module() { - assert_eq!(CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, "Storage/SnapshotIntervalSec"); + assert_eq!( + CONFIG_KEY_SNAPSHOT_INTERVAL_SEC, + "Storage/SnapshotIntervalSec" + ); } /// End-to-end through the module crates: a project the engine itself @@ -6065,7 +6220,10 @@ mod tests { let loaded = graphops::load_ove(&save_path).expect("load"); let (loaded_name, sequences) = { let guard = graphops::lock(&loaded); - (graphops::project_name(&guard), graphops::sequence_ids(&guard)) + ( + graphops::project_name(&guard), + graphops::sequence_ids(&guard), + ) }; assert!(!loaded_name.is_empty(), "the loaded project has a name"); assert_eq!(sequences.len(), 1, "the sequence survives the round-trip"); @@ -6097,8 +6255,8 @@ mod tests { #[test] fn legacy_footage_is_reprobed_after_load() { let _media = media_lock(); - let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/project_with_footage.ove"); + let fixture = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/project_with_footage.ove"); let project = graphops::load_ove(&fixture).expect("the C++ fixture loads"); let ids = graphops::footage_ids(&graphops::lock(&project)); @@ -6120,7 +6278,10 @@ mod tests { for &id in &ids { let secs = graphops::footage_duration_seconds(&guard.graph, id) .expect("the reprobe restores a duration"); - assert!(secs > 0.5, "the fixture's demo.mp4 has a real duration: {secs}"); + assert!( + secs > 0.5, + "the fixture's demo.mp4 has a real duration: {secs}" + ); } } @@ -6168,10 +6329,8 @@ mod tests { // M12 P0: with a clip of real media on the video track, the same // render must produce the decoded footage (known content, non // black). The media is program-generated. - let media = std::env::temp_dir().join(format!( - "oakapp_e2e_media_{}.mp4", - std::process::id() - )); + let media = + std::env::temp_dir().join(format!("oakapp_e2e_media_{}.mp4", std::process::id())); oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10) .expect("generate e2e test media"); let footage = graphops::import_footage(&project, &media).expect("import_footage"); @@ -6183,7 +6342,10 @@ mod tests { .expect("render_frame with a clip must produce a frame"); let (image, _scope) = frame.to_display().expect("display image from the slot"); let bytes = image.as_bytes(0).expect("one frame"); - let nonzero = bytes.chunks(4).filter(|px| px[..3].iter().any(|&c| c != 0)).count(); + let nonzero = bytes + .chunks(4) + .filter(|px| px[..3].iter().any(|&c| c != 0)) + .count(); assert!( nonzero > 0, "the sequence with a footage clip must render non-black pixels" @@ -6199,10 +6361,14 @@ mod tests { release_rendered_frame(&frame); // A second frame at a later timestamp renders too. - assert!(crate::oakui::renderops::render_sequence_frame(&project, seq, 30, tb, 480, 270).is_ok()); + assert!( + crate::oakui::renderops::render_sequence_frame(&project, seq, 30, tb, 480, 270).is_ok() + ); // Invalid geometry is rejected. - assert!(crate::oakui::renderops::render_sequence_frame(&project, seq, 0, tb, 0, 270).is_err()); + assert!( + crate::oakui::renderops::render_sequence_frame(&project, seq, 0, tb, 0, 270).is_err() + ); oak_undo::global::clear().unwrap(); let _ = std::fs::remove_file(&media); @@ -6284,12 +6450,8 @@ mod tests { let project = graphops::create_project(); // Generate a real media file, import it. - let media = std::env::temp_dir().join(format!( - "oakapp_browser_{}.mp4", - std::process::id() - )); - oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10) - .expect("generate test media"); + let media = std::env::temp_dir().join(format!("oakapp_browser_{}.mp4", std::process::id())); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media"); graphops::import_footage(&project, &media).expect("import must succeed"); // The project browser (ProjectDataSource) must list it. @@ -6326,15 +6488,17 @@ mod tests { let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); - let media = std::env::temp_dir().join(format!( - "oakapp_engine_import_{}.mp4", - std::process::id() - )); + let media = + std::env::temp_dir().join(format!("oakapp_engine_import_{}.mp4", std::process::id())); oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10) .expect("generate e2e test media"); - let imported = cx - .update(|app| engine.update(app, |engine, cx| engine.import_footage(media.clone(), cx))); - assert!(imported.is_ok(), "import through the seam succeeds: {imported:?}"); + let imported = cx.update(|app| { + engine.update(app, |engine, cx| engine.import_footage(media.clone(), cx)) + }); + assert!( + imported.is_ok(), + "import through the seam succeeds: {imported:?}" + ); // The project browser (ProjectDataSource) lists the file at the root. let name = media.file_name().unwrap().to_string_lossy().into_owned(); @@ -6373,27 +6537,26 @@ mod tests { cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); let media = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/demo.mp4"); - let imported = cx - .update(|app| engine.update(app, |engine, cx| engine.import_footage(media.clone(), cx))); + let imported = cx.update(|app| { + engine.update(app, |engine, cx| engine.import_footage(media.clone(), cx)) + }); assert!(imported.is_ok(), "import tests/demo.mp4: {imported:?}"); let name = media.file_name().unwrap().to_string_lossy().into_owned(); // The core render step runs synchronously: the first frame decodes to // a PNG in the shared thumbnail directory. - let entry_id = cx - .read(|app| { - engine - .read(app) - .roots() - .into_iter() - .find(|e| e.name.as_ref() == name) - .expect("the imported footage is listed") - .id - }); + let entry_id = cx.read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + .expect("the imported footage is listed") + .id + }); let project = cx.read(|app| engine.read(app).project_ref().cloned().unwrap()); let rendered = RealEngine::render_thumbnail(&project, entry_id); - let rendered = rendered - .expect("render_thumbnail produces a PNG path for the real media"); + let rendered = rendered.expect("render_thumbnail produces a PNG path for the real media"); assert!( rendered.exists(), "the rendered PNG exists: {}", @@ -6405,12 +6568,8 @@ mod tests { // path into the cache so the entry re-reads with the thumbnail. let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); - cx.update(|app| { - engine.update(app, |engine, cx| { - engine.import_footage(media.clone(), cx) - }) - }) - .expect("re-import the footage"); + cx.update(|app| engine.update(app, |engine, cx| engine.import_footage(media.clone(), cx))) + .expect("re-import the footage"); // Progress criterion, not wall time: the worker's async thumbnail // installs after a bounded number of engine pumps (a wall-clock // cap would fail on slow machines for machine speed). @@ -6515,13 +6674,19 @@ mod tests { entries[base].name.is_empty() == false && entries[base + 1].name.is_empty() == false, "edit rows carry their command labels" ); - assert_eq!(cx.read(|app| engine.read(app).history_index()), (base + 2) as i64); + assert_eq!( + cx.read(|app| engine.read(app).history_index()), + (base + 2) as i64 + ); // Undo grays the newest row (it joins the redoable tail). cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx))); let entries = cx.read(|app| engine.read(app).history_entries()); assert!(!entries.last().unwrap().done, "undone row stays listed"); - assert_eq!(cx.read(|app| engine.read(app).history_index()), (base + 1) as i64); + assert_eq!( + cx.read(|app| engine.read(app).history_index()), + (base + 1) as i64 + ); // A jump to the bottom undoes everything below the base command; // the rows stay listed (gray), matching the C++ jump semantics. @@ -6536,9 +6701,15 @@ mod tests { cx.update(|app| { engine.update(app, |engine, cx| engine.jump_history((base + 2) as i64, cx)) }); - assert_eq!(cx.read(|app| engine.read(app).history_index()), (base + 2) as i64); + assert_eq!( + cx.read(|app| engine.read(app).history_index()), + (base + 2) as i64 + ); let entries = cx.read(|app| engine.read(app).history_entries()); - assert!(entries.iter().all(|e| e.done), "the redo restored every row"); + assert!( + entries.iter().all(|e| e.done), + "the redo restored every row" + ); oak_undo::global::clear().unwrap(); } @@ -6549,9 +6720,7 @@ mod tests { /// grid clicks all run this exact path. Also covers the timeline menu's /// eligibility/checked state and the enable/disable detection. #[gpui::test] - async fn real_engine_multicam_switch_round_trips_through_undo( - cx: &mut gpui::TestAppContext, - ) { + async fn real_engine_multicam_switch_round_trips_through_undo(cx: &mut gpui::TestAppContext) { use oak_node::block::clip_input::TEXTURE_INPUT; let _media = media_lock(); let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); @@ -6621,14 +6790,15 @@ mod tests { let state = cx .read(|app| engine.read(app).multicam_state()) .expect("a selected multicam clip is detected"); - assert_eq!(state.source_count, 4, "default 2 video tracks + 2 added = four angles"); + assert_eq!( + state.source_count, 4, + "default 2 video tracks + 2 added = four angles" + ); assert_eq!(state.current_source, 0); // Switch through the UI path (no split: the playhead sits at the // clip's in point, so the switch is a plain current_in write). - cx.update(|app| { - engine.update(app, |engine, cx| engine.multicam_switch_to(1, false, cx)) - }); + cx.update(|app| engine.update(app, |engine, cx| engine.multicam_switch_to(1, false, cx))); let state = cx .read(|app| engine.read(app).multicam_state()) .expect("still detected after the switch"); @@ -6637,13 +6807,17 @@ mod tests { // ONE undo entry restores the previous source; redo re-applies it. cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx))); assert_eq!( - cx.read(|app| engine.read(app).multicam_state()).unwrap().current_source, + cx.read(|app| engine.read(app).multicam_state()) + .unwrap() + .current_source, 0, "undo restores the pre-switch source" ); cx.update(|app| engine.update(app, |engine, cx| engine.redo(cx))); assert_eq!( - cx.read(|app| engine.read(app).multicam_state()).unwrap().current_source, + cx.read(|app| engine.read(app).multicam_state()) + .unwrap() + .current_source, 1, "redo re-applies the switched source" ); @@ -6675,12 +6849,9 @@ mod tests { let seq = graphops::create_sequence(&project, "Node Editor"); graphops::add_track(&project, seq, TrackType::Video).expect("add a video track"); - let media = std::env::temp_dir().join(format!( - "oakapp_nodegraph_{}.mp4", - std::process::id() - )); - oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10) - .expect("generate test media"); + let media = + std::env::temp_dir().join(format!("oakapp_nodegraph_{}.mp4", std::process::id())); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media"); let footage = graphops::import_footage(&project, &media).expect("import must succeed"); graphops::place_footage_clip(&project, seq, footage, TrackType::Video, 0, 0, 10, 0) .expect("clip placement"); @@ -6756,10 +6927,8 @@ mod tests { .type_id; let effect = crate::oakui::effectchain::insert(&project, clip.id, 0, &ty).expect("chain it"); - let media = std::env::temp_dir().join(format!( - "oakapp_sel_link_{}.mp4", - std::process::id() - )); + let media = std::env::temp_dir() + .join(format!("oakapp_sel_link_{}.mp4", std::process::id())); oak_codec::testmedia::write_test_clip(&media, 32, 32, 10, 10) .expect("generate test media"); let footage = graphops::import_footage(&project, &media).expect("import the media"); @@ -6786,26 +6955,51 @@ mod tests { ); let _ = std::fs::remove_file(&media); engine.adopt_project(project, cx); - (ClipId(clip.id.identity()), effect.identity(), footage.identity()) + ( + ClipId(clip.id.identity()), + effect.identity(), + footage.identity(), + ) }) }); // A single clip selection narrows the node graph to the chain and // mirrors the block node as the graph selection. cx.update(|app| { - engine.update(app, |engine, cx| engine.set_selected_clips(vec![clip_id], cx)) + engine.update(app, |engine, cx| { + engine.set_selected_clips(vec![clip_id], cx) + }) }); assert_eq!( cx.read(|app| engine.read(app).selected_graph_node()), Some(clip_id.0), "a clip selection highlights its block node" ); - let ids: Vec = cx - .read(|app| engine.read(app).nodes().into_iter().map(|n| n.id.0).collect()); - assert_eq!(ids.len(), 3, "only the clip's context chain is shown (got {ids:?})"); - assert!(ids.contains(&clip_id.0), "the clip node is part of the chain"); - assert!(ids.contains(&effect_ident), "the effect is part of the chain"); - assert!(ids.contains(&footage_ident), "the footage is part of the chain"); + let ids: Vec = cx.read(|app| { + engine + .read(app) + .nodes() + .into_iter() + .map(|n| n.id.0) + .collect() + }); + assert_eq!( + ids.len(), + 3, + "only the clip's context chain is shown (got {ids:?})" + ); + assert!( + ids.contains(&clip_id.0), + "the clip node is part of the chain" + ); + assert!( + ids.contains(&effect_ident), + "the effect is part of the chain" + ); + assert!( + ids.contains(&footage_ident), + "the footage is part of the chain" + ); // Selecting the effect node in the graph retargets the inspector to // the owning clip and highlights the matching card. @@ -6875,12 +7069,9 @@ mod tests { let project = graphops::create_project(); let seq = graphops::create_sequence(&project, "Full Res Source"); - let media = std::env::temp_dir().join(format!( - "oakapp_fullres_src_{}.mp4", - std::process::id() - )); - oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10) - .expect("generate test media"); + let media = + std::env::temp_dir().join(format!("oakapp_fullres_src_{}.mp4", std::process::id())); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media"); let footage = graphops::import_footage(&project, &media).expect("import must succeed"); let tb = graphops::sequence_time_base(&graphops::lock(&project).graph, seq).unwrap(); @@ -7049,12 +7240,8 @@ mod tests { let seq = graphops::create_sequence(&project, "Full Res E2E"); graphops::add_track(&project, seq, TrackType::Video).expect("add a video track"); - let media = std::env::temp_dir().join(format!( - "oakapp_fullres_{}.mp4", - std::process::id() - )); - oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10) - .expect("generate test media"); + let media = std::env::temp_dir().join(format!("oakapp_fullres_{}.mp4", std::process::id())); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media"); let footage = graphops::import_footage(&project, &media).expect("import must succeed"); graphops::place_footage_clip(&project, seq, footage, TrackType::Video, 0, 0, 10, 0) .expect("clip placement"); @@ -7190,9 +7377,7 @@ mod tests { let _media = media_lock(); let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); - cx.update(|app| { - engine.update(app, |engine, cx| engine.set_track_height(px(96.0), cx)) - }); + cx.update(|app| engine.update(app, |engine, cx| engine.set_track_height(px(96.0), cx))); let height = cx.read(|app| engine.read(app).tracks[0].height()); assert_eq!(height, px(96.0), "the new height is applied"); } @@ -7214,14 +7399,15 @@ mod tests { }) }); let name = media.file_name().unwrap().to_string_lossy().into_owned(); - let entry = cx.read(|app| { - engine - .read(app) - .roots() - .into_iter() - .find(|e| e.name.as_ref() == name) - }) - .expect("imported footage is listed"); + let entry = cx + .read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + }) + .expect("imported footage is listed"); cx.update(|app| { engine.update(app, |engine, cx| { // Drop at a non-zero frame: the placement must land where @@ -7250,9 +7436,16 @@ mod tests { }; let (video_clips, audio_clips) = cx.read(|app| { let engine = engine.read(app); - (clip_count(engine, TrackKind::Video), clip_count(engine, TrackKind::Audio)) + ( + clip_count(engine, TrackKind::Video), + clip_count(engine, TrackKind::Audio), + ) }); - assert_eq!((video_clips, audio_clips), (1, 1), "one video clip + one audio clip"); + assert_eq!( + (video_clips, audio_clips), + (1, 1), + "one video clip + one audio clip" + ); // The two clips are linked (grouped edits apply to both). let (video_block, audio_block) = cx.read(|app| { @@ -7285,15 +7478,29 @@ mod tests { cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx))); let (video_clips, audio_clips) = cx.read(|app| { let engine = engine.read(app); - (clip_count(engine, TrackKind::Video), clip_count(engine, TrackKind::Audio)) + ( + clip_count(engine, TrackKind::Video), + clip_count(engine, TrackKind::Audio), + ) }); - assert_eq!((video_clips, audio_clips), (0, 0), "one undo removes both clips"); + assert_eq!( + (video_clips, audio_clips), + (0, 0), + "one undo removes both clips" + ); cx.update(|app| engine.update(app, |engine, cx| engine.redo(cx))); let (video_clips, audio_clips) = cx.read(|app| { let engine = engine.read(app); - (clip_count(engine, TrackKind::Video), clip_count(engine, TrackKind::Audio)) + ( + clip_count(engine, TrackKind::Video), + clip_count(engine, TrackKind::Audio), + ) }); - assert_eq!((video_clips, audio_clips), (1, 1), "one redo restores both clips"); + assert_eq!( + (video_clips, audio_clips), + (1, 1), + "one redo restores both clips" + ); } /// Dragging a clip of a linked A/V pair drags its partner in lockstep: @@ -7313,14 +7520,15 @@ mod tests { }) }); let name = media.file_name().unwrap().to_string_lossy().into_owned(); - let entry = cx.read(|app| { - engine - .read(app) - .roots() - .into_iter() - .find(|e| e.name.as_ref() == name) - }) - .expect("imported footage is listed"); + let entry = cx + .read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + }) + .expect("imported footage is listed"); cx.update(|app| { engine.update(app, |engine, cx| { engine.drop_footage(entry.id, TrackKind::Video, 0, Frame(40), cx) @@ -7442,14 +7650,15 @@ mod tests { }) }); let name = media.file_name().unwrap().to_string_lossy().into_owned(); - let entry = cx.read(|app| { - engine - .read(app) - .roots() - .into_iter() - .find(|e| e.name.as_ref() == name) - }) - .expect("imported footage is listed"); + let entry = cx + .read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + }) + .expect("imported footage is listed"); cx.update(|app| { engine.update(app, |engine, cx| { engine.drop_footage(entry.id, TrackKind::Video, 0, Frame(40), cx) @@ -7599,7 +7808,10 @@ mod tests { guard.graph.links_of(na).contains(&nb) }) }; - assert!(linked(cx, video_id, audio_id), "the dropped A/V pair starts linked"); + assert!( + linked(cx, video_id, audio_id), + "the dropped A/V pair starts linked" + ); // The toggle unlinks the pair; ONE undo restores the link; the next // toggle unlinks it again (the undo left the pair linked, so the @@ -7609,7 +7821,10 @@ mod tests { engine.toggle_clip_links(vec![video_id, audio_id], cx) }) }); - assert!(!linked(cx, video_id, audio_id), "the toggle unlinks the pair"); + assert!( + !linked(cx, video_id, audio_id), + "the toggle unlinks the pair" + ); cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx))); assert!(linked(cx, video_id, audio_id), "one undo restores the link"); cx.update(|app| { @@ -7626,7 +7841,9 @@ mod tests { // Split the video clip at frame 20: the two halves are NOT linked by // default; selecting both and toggling links them. cx.update(|app| { - engine.update(app, |engine, cx| engine.request_frame(Monitor::Program, Frame(20), cx)) + engine.update(app, |engine, cx| { + engine.request_frame(Monitor::Program, Frame(20), cx) + }) }); cx.update(|app| engine.update(app, |engine, cx| engine.split_at_playhead(cx))); let halves: Vec = cx.read(|app| { @@ -7686,7 +7903,9 @@ mod tests { }) }); cx.update(|app| { - engine.update(app, |engine, cx| engine.request_frame(Monitor::Program, Frame(20), cx)) + engine.update(app, |engine, cx| { + engine.request_frame(Monitor::Program, Frame(20), cx) + }) }); cx.update(|app| engine.update(app, |engine, cx| engine.split_at_playhead(cx))); @@ -7730,8 +7949,14 @@ mod tests { }) }; let _ = clips_of; - let (vf, vr) = (by_start(TrackKind::Video, 0, cx), by_start(TrackKind::Video, 20, cx)); - let (af, ar) = (by_start(TrackKind::Audio, 0, cx), by_start(TrackKind::Audio, 20, cx)); + let (vf, vr) = ( + by_start(TrackKind::Video, 0, cx), + by_start(TrackKind::Video, 20, cx), + ); + let (af, ar) = ( + by_start(TrackKind::Audio, 0, cx), + by_start(TrackKind::Audio, 20, cx), + ); assert!(linked(vf, af, cx), "the front halves stay linked"); assert!(linked(vr, ar, cx), "the rear halves are linked too"); } @@ -7749,10 +7974,8 @@ mod tests { let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); - let media = std::env::temp_dir().join(format!( - "oakapp_playback_window_{}.mp4", - std::process::id() - )); + let media = + std::env::temp_dir().join(format!("oakapp_playback_window_{}.mp4", std::process::id())); oak_codec::testmedia::write_test_clip(&media, 64, 64, 250, 25) .expect("generate playback test media"); cx.update(|app| { @@ -7761,14 +7984,15 @@ mod tests { }) }); let name = media.file_name().unwrap().to_string_lossy().into_owned(); - let entry = cx.read(|app| { - engine - .read(app) - .roots() - .into_iter() - .find(|e| e.name.as_ref() == name) - }) - .expect("imported footage is listed"); + let entry = cx + .read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + }) + .expect("imported footage is listed"); cx.update(|app| { engine.update(app, |engine, cx| { engine.drop_footage(entry.id, TrackKind::Video, 0, Frame(0), cx) @@ -7796,7 +8020,11 @@ mod tests { let playhead = cx.read(|app| engine.read(app).clock_frame(Monitor::Program, app)); hit = hit || cx - .update(|app| engine.update(app, |engine, _cx| engine.preview_slot_frame(Monitor::Program, playhead))) + .update(|app| { + engine.update(app, |engine, _cx| { + engine.preview_slot_frame(Monitor::Program, playhead) + }) + }) .is_some(); if hit { break; @@ -7827,9 +8055,15 @@ mod tests { }) }); let name = media.file_name().unwrap().to_string_lossy().into_owned(); - let entry = cx.read(|app| { - engine.read(app).roots().into_iter().find(|e| e.name.as_ref() == name) - }).expect("imported footage is listed"); + let entry = cx + .read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + }) + .expect("imported footage is listed"); cx.update(|app| { engine.update(app, |engine, cx| { engine.drop_footage(entry.id, TrackKind::Video, 0, Frame(0), cx) @@ -7838,7 +8072,9 @@ mod tests { // Interactive seek (not playing): a single synchronous render of the // target frame. If this hangs, the seek render path deadlocks. cx.update(|app| { - engine.update(app, |engine, cx| engine.request_frame(Monitor::Program, Frame(24), cx)) + engine.update(app, |engine, cx| { + engine.request_frame(Monitor::Program, Frame(24), cx) + }) }); let img = cx.read(|app| engine.read(app).cpu_frame(Monitor::Program, app)); let bytes = img.as_bytes(0).expect("frame bytes"); @@ -7854,7 +8090,9 @@ mod tests { } cx.update(|app| engine.update(app, |engine, cx| engine.pause(Monitor::Program, cx))); cx.update(|app| { - engine.update(app, |engine, cx| engine.request_frame(Monitor::Program, Frame(48), cx)) + engine.update(app, |engine, cx| { + engine.request_frame(Monitor::Program, Frame(48), cx) + }) }); let img = cx.read(|app| engine.read(app).cpu_frame(Monitor::Program, app)); let bytes = img.as_bytes(0).expect("seek-after-play frame bytes"); @@ -7876,9 +8114,15 @@ mod tests { }) }); let name = media.file_name().unwrap().to_string_lossy().into_owned(); - let entry = cx.read(|app| { - engine.read(app).roots().into_iter().find(|e| e.name.as_ref() == name) - }).expect("imported footage is listed"); + let entry = cx + .read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + }) + .expect("imported footage is listed"); cx.update(|app| { engine.update(app, |engine, cx| { engine.drop_footage(entry.id, TrackKind::Video, 0, Frame(0), cx) @@ -7900,11 +8144,18 @@ mod tests { let (roots, tracks) = cx.read(|app| { let engine = engine.read(app); ( - engine.roots().iter().map(|e| e.name.to_string()).collect::>(), + engine + .roots() + .iter() + .map(|e| e.name.to_string()) + .collect::>(), engine.tracks.iter().map(|t| t.clips.len()).sum::(), ) }); - assert!(roots.iter().any(|n| n == &name), "footage survives: {roots:?}"); + assert!( + roots.iter().any(|n| n == &name), + "footage survives: {roots:?}" + ); assert!(tracks > 0, "the timeline clip survives the roundtrip"); } @@ -7927,14 +8178,15 @@ mod tests { }) }); let name = media.file_name().unwrap().to_string_lossy().into_owned(); - let entry = cx.read(|app| { - engine - .read(app) - .roots() - .into_iter() - .find(|e| e.name.as_ref() == name) - }) - .expect("imported footage is listed"); + let entry = cx + .read(|app| { + engine + .read(app) + .roots() + .into_iter() + .find(|e| e.name.as_ref() == name) + }) + .expect("imported footage is listed"); cx.update(|app| { engine.update(app, |engine, cx| { engine.drop_footage(entry.id, TrackKind::Video, 0, Frame(0), cx) @@ -8090,25 +8342,26 @@ mod tests { let folder = cx.update(|app| { engine - .update(app, |engine, cx| engine.create_folder("Folder 1".to_string(), cx)) + .update(app, |engine, cx| { + engine.create_folder("Folder 1".to_string(), cx) + }) .expect("create folder") }); let seq = cx.update(|app| { - engine - .update(app, |engine, cx| { - engine - .create_sequence_with_params( - "Seq 4K".to_string(), - VideoFormat { - width: 3840, - height: 2160, - rate: FrameRate::new(24000, 1001), - }, - true, - cx, - ) - .expect("create sequence") - }) + engine.update(app, |engine, cx| { + engine + .create_sequence_with_params( + "Seq 4K".to_string(), + VideoFormat { + width: 3840, + height: 2160, + rate: FrameRate::new(24000, 1001), + }, + true, + cx, + ) + .expect("create sequence") + }) }); // Both mount under the root folder, like the explorer expects. @@ -8127,8 +8380,14 @@ mod tests { .map(|f| f.children.clone()) .unwrap_or_default() }; - assert!(children.contains(&folder_node), "the folder mounts under root"); - assert!(children.contains(&seq_node), "the sequence mounts under root"); + assert!( + children.contains(&folder_node), + "the folder mounts under root" + ); + assert!( + children.contains(&seq_node), + "the sequence mounts under root" + ); let is_seq = cx.read(|app| engine.read(app).entry_is_sequence(seq)); assert!(is_seq, "the created entry is a sequence"); @@ -8141,7 +8400,10 @@ mod tests { .expect("sequence parameters"); assert_eq!(params.name, "Seq 4K"); assert_eq!((params.format.width, params.format.height), (3840, 2160)); - assert_eq!((params.format.rate.num, params.format.rate.den), (24000, 1001)); + assert_eq!( + (params.format.rate.num, params.format.rate.den), + (24000, 1001) + ); assert!(params.interlaced, "the interlaced flag round-trips"); } @@ -8155,39 +8417,37 @@ mod tests { cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); let seq = cx.update(|app| { - engine - .update(app, |engine, cx| { - engine - .create_sequence_with_params( - "Before".to_string(), - VideoFormat { - width: 1280, - height: 720, - rate: FrameRate::new(25, 1), - }, - false, - cx, - ) - .expect("create sequence") - }) + engine.update(app, |engine, cx| { + engine + .create_sequence_with_params( + "Before".to_string(), + VideoFormat { + width: 1280, + height: 720, + rate: FrameRate::new(25, 1), + }, + false, + cx, + ) + .expect("create sequence") + }) }); cx.update(|app| { - engine - .update(app, |engine, cx| { - engine - .update_sequence_parameters( - seq, - "After".to_string(), - VideoFormat { - width: 1920, - height: 1080, - rate: FrameRate::new(30000, 1001), - }, - true, - cx, - ) - .expect("update parameters") - }) + engine.update(app, |engine, cx| { + engine + .update_sequence_parameters( + seq, + "After".to_string(), + VideoFormat { + width: 1920, + height: 1080, + rate: FrameRate::new(30000, 1001), + }, + true, + cx, + ) + .expect("update parameters") + }) }); let params = cx @@ -8195,7 +8455,10 @@ mod tests { .expect("sequence parameters"); assert_eq!(params.name, "After"); assert_eq!((params.format.width, params.format.height), (1920, 1080)); - assert_eq!((params.format.rate.num, params.format.rate.den), (30000, 1001)); + assert_eq!( + (params.format.rate.num, params.format.rate.den), + (30000, 1001) + ); assert!(params.interlaced, "the interlace flag updates too"); } @@ -8236,7 +8499,12 @@ mod tests { }); let seq = cx - .read(|app| engine.read(app).sequence.expect("a sequence was auto-created")) + .read(|app| { + engine + .read(app) + .sequence + .expect("a sequence was auto-created") + }) .identity(); let params = cx .read(|app| engine.read(app).sequence_parameters(seq)) @@ -8261,7 +8529,10 @@ mod tests { ) }; assert_eq!((params.format.width, params.format.height), (width, height)); - assert_eq!((params.format.rate.num, params.format.rate.den), (rate_num, rate_den)); + assert_eq!( + (params.format.rate.num, params.format.rate.den), + (rate_num, rate_den) + ); assert_eq!(params.interlaced, interlaced); // The drop placed a clip on the auto-created sequence's timeline. diff --git a/crates/oak-app/src/panels/timeline.rs b/crates/oak-app/src/panels/timeline.rs index 8ba61d042..fc97033db 100644 --- a/crates/oak-app/src/panels/timeline.rs +++ b/crates/oak-app/src/panels/timeline.rs @@ -39,28 +39,26 @@ //! timeline wrapper is `min_w_0` so the ruler always keeps the remaining //! space — no overlap at 1600×900 or down to ~1100px wide. +use crate::oakui::component::controls::ValueKind; +use crate::oakui::component::controls::{CheckBox, CheckBoxEvent, CheckState}; +use crate::oakui::component::controls::{Slider, SliderEvent, SliderModel}; +use crate::oakui::component::menu::{Menu, MenuItem}; use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::timeline::{ - ClipData, ClipId, Frame, TimelineEvent, TimelineHit, TimelineView, TrackData, TrackKind, - HEADER_WIDTH, MIN_TRACK_HEIGHT, RULER_HEIGHT, -}; -use gpui::{ - div, img, prelude::*, px, Context, Entity, MouseButton, Pixels, Point, Window, + ClipData, ClipId, Frame, TimelineEvent, TimelineHit, TimelineTool, TimelineView, TrackData, + TrackKind, HEADER_WIDTH, MIN_TRACK_HEIGHT, RULER_HEIGHT, }; +use gpui::{div, img, prelude::*, px, Context, Entity, MouseButton, Pixels, Point, Window}; use gpui::{AnyElement, App, ClickEvent, DragMoveEvent, EventEmitter, Render, SharedString}; -use crate::oakui::component::controls::{CheckBox, CheckBoxEvent, CheckState}; -use crate::oakui::component::menu::{Menu, MenuItem}; -use gpui_widgets::viewer::PlaybackClock; use gpui_widgets::project_explorer::FootageDrag; -use crate::oakui::component::controls::{Slider, SliderEvent, SliderModel}; use gpui_widgets::tooltip::tooltip_view; -use crate::oakui::component::controls::ValueKind; +use gpui_widgets::viewer::PlaybackClock; use crate::actions::ActionId; use crate::i18n; -use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered}; use crate::oakui::component::menu; +use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered}; use crate::oakui::icons; use crate::oakui::{AppEngine, Monitor}; use crate::panels::commands::{self as panel_commands, PanelCommandHandler}; @@ -95,8 +93,6 @@ pub struct TimelinePanel { zoom: Entity, height: Entity, snap: Entity, - /// The currently selected tool (visual only). - selected_tool: usize, /// The drop point of an in-flight footage drag: the display track under /// the cursor plus the start frame. `None` outside the clip area or while /// no footage drag is active. @@ -185,16 +181,12 @@ impl TimelinePanel { // The right-click menu: the view reports what was hit // (`ContextMenuRequested`), the panel assembles the matching menu // and opens the popup at the click position. - let context_menu = - ContextMenuHandle::new(Self::on_local_menu_item, window, cx); - cx.subscribe( - &timeline, - |this, _view, event: &TimelineEvent, cx| { - if let TimelineEvent::ContextMenuRequested { position, hit } = event { - this.open_context_menu(*position, hit.clone(), cx); - } - }, - ) + let context_menu = ContextMenuHandle::new(Self::on_local_menu_item, window, cx); + cx.subscribe(&timeline, |this, _view, event: &TimelineEvent, cx| { + if let TimelineEvent::ContextMenuRequested { position, hit } = event { + this.open_context_menu(*position, hit.clone(), cx); + } + }) .detach(); Self { @@ -203,7 +195,6 @@ impl TimelinePanel { zoom, height, snap, - selected_tool: 0, footage_drop: None, context_menu, context_track: None, @@ -235,8 +226,7 @@ impl TimelinePanel { cx.notify(); }); } - let ids: Vec = - self.timeline.read(cx).selection().iter().copied().collect(); + let ids: Vec = self.timeline.read(cx).selection().iter().copied().collect(); let (sync, proxy, multicam) = { let engine = self.engine.read(cx); ( @@ -275,35 +265,36 @@ impl TimelinePanel { } match item { LOCAL_ADD_VIDEO_TRACK => { - self.engine.update(cx, |engine, cx| engine.add_track(TrackKind::Video, cx)); + self.engine + .update(cx, |engine, cx| engine.add_track(TrackKind::Video, cx)); } LOCAL_ADD_AUDIO_TRACK => { - self.engine.update(cx, |engine, cx| engine.add_track(TrackKind::Audio, cx)); + self.engine + .update(cx, |engine, cx| engine.add_track(TrackKind::Audio, cx)); } LOCAL_DELETE_TRACK => { if let Some(track) = self.context_track { - self.engine.update(cx, |engine, cx| engine.remove_track(track, cx)); + self.engine + .update(cx, |engine, cx| engine.remove_track(track, cx)); } } LOCAL_DELETE_ALL_EMPTY => { - self.engine.update(cx, |engine, cx| engine.delete_empty_tracks(cx)); + self.engine + .update(cx, |engine, cx| engine.delete_empty_tracks(cx)); } LOCAL_CACHE_ALL | LOCAL_CACHE_IN_OUT | LOCAL_CACHE_DISCARD => { println!("[timeline] cache action {item} (not implemented yet)"); } - LOCAL_PROXY_GENERATE - | LOCAL_PROXY_USE - | LOCAL_PROXY_REVEAL - | LOCAL_PROXY_DELETE => { - let ids: Vec = - self.timeline.read(cx).selection().iter().copied().collect(); + LOCAL_PROXY_GENERATE | LOCAL_PROXY_USE | LOCAL_PROXY_REVEAL | LOCAL_PROXY_DELETE => { + let ids: Vec = self.timeline.read(cx).selection().iter().copied().collect(); let rows = self.engine.read(cx).clip_footage_entries(&ids); match item { LOCAL_PROXY_GENERATE => { for row in rows.into_iter().filter(|row| row.can_generate) { - if let Err(err) = self.engine.update(cx, |engine, cx| { - engine.proxy_generate(row.id, cx) - }) { + if let Err(err) = self + .engine + .update(cx, |engine, cx| engine.proxy_generate(row.id, cx)) + { println!("[timeline] proxy generate failed: {err}"); } } @@ -326,9 +317,8 @@ impl TimelinePanel { } _ => { for row in rows.into_iter().filter(|row| row.has_proxy) { - self.engine.update(cx, |engine, cx| { - engine.proxy_delete(row.id, cx) - }); + self.engine + .update(cx, |engine, cx| engine.proxy_delete(row.id, cx)); } } } @@ -343,8 +333,7 @@ impl TimelinePanel { LOCAL_MULTICAM => { // The C++ `multicam_enabled_triggered` flip: checked clips // disable, unchecked ones enable. - let ids: Vec = - self.timeline.read(cx).selection().iter().copied().collect(); + let ids: Vec = self.timeline.read(cx).selection().iter().copied().collect(); let enable = !self.engine.read(cx).multicam_enabled_on_selection(&ids); self.engine.update(cx, |engine, cx| { engine.multicam_enable_selected(ids, enable, cx) @@ -596,7 +585,8 @@ impl PanelCommandHandler for TimelinePanel { true } fn clear_in_out(&mut self, cx: &mut Context) -> bool { - self.engine.update(cx, |engine, cx| engine.clear_workarea(cx)); + self.engine + .update(cx, |engine, cx| engine.clear_workarea(cx)); true } @@ -633,16 +623,19 @@ impl PanelCommandHandler for TimelinePanel { // --- editing --- fn cut_selected(&mut self, cx: &mut Context) -> bool { let ids: Vec = self.timeline.read(cx).selection().iter().copied().collect(); - self.engine.update(cx, |engine, cx| engine.clipboard_cut(ids, cx)); + self.engine + .update(cx, |engine, cx| engine.clipboard_cut(ids, cx)); true } fn copy_selected(&mut self, cx: &mut Context) -> bool { let ids: Vec = self.timeline.read(cx).selection().iter().copied().collect(); - self.engine.update(cx, |engine, cx| engine.clipboard_copy(ids, cx)); + self.engine + .update(cx, |engine, cx| engine.clipboard_copy(ids, cx)); true } fn paste(&mut self, cx: &mut Context) -> bool { - self.engine.update(cx, |engine, cx| engine.clipboard_paste(cx)); + self.engine + .update(cx, |engine, cx| engine.clipboard_paste(cx)); true } fn delete_selected(&mut self, cx: &mut Context) -> bool { @@ -673,14 +666,16 @@ impl PanelCommandHandler for TimelinePanel { } fn sync_by_waveform(&mut self, cx: &mut Context) -> bool { let ids: Vec = self.timeline.read(cx).selection().iter().copied().collect(); - self.engine - .update(cx, |engine, cx| engine.sync_clips_by_waveform(ids, false, cx)); + self.engine.update(cx, |engine, cx| { + engine.sync_clips_by_waveform(ids, false, cx) + }); true } fn sync_by_waveform_speed(&mut self, cx: &mut Context) -> bool { let ids: Vec = self.timeline.read(cx).selection().iter().copied().collect(); - self.engine - .update(cx, |engine, cx| engine.sync_clips_by_waveform(ids, true, cx)); + self.engine.update(cx, |engine, cx| { + engine.sync_clips_by_waveform(ids, true, cx) + }); true } /// 编辑 → 链接/重新链接: toggles the graph links among the selected @@ -730,11 +725,12 @@ impl Render for TimelinePanel { .bg(colors.container); // A tool button: a 16px icon on a 24px hit target with a localized - // tooltip; the selected tool is highlighted. + // tooltip; the selected tool is highlighted (driven by the widget's + // current tool, so the toolbar and the Tools menu stay in sync). let tool_button = |index: usize, icon_name: &'static str, key: &'static str, cx: &mut Context| { let tool = i18n::tr(key); - let selected = self.selected_tool == index; + let selected = self.timeline.read(cx).tool().index() == index; let background = if selected { colors.selected } else { @@ -753,9 +749,10 @@ impl Render for TimelinePanel { .bg(background) .hover(move |style| style.bg(hover_bg)) .tooltip(move |window, cx| tooltip_view(tool.into(), window, cx)) - .on_click(cx.listener(move |this, _event: &ClickEvent, _window, _cx| { - println!("[timeline] tool: {tool} (placeholder)"); - this.selected_tool = index; + .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { + if let Some(tt) = TimelineTool::from_index(index) { + this.timeline.update(cx, |view, cx| view.set_tool(tt, cx)); + } })) .child(img(path).size(px(16.0))) }; @@ -766,29 +763,28 @@ impl Render for TimelinePanel { // Add-track buttons (the convenient way to create tracks; the track // header context menu offers the same two entries). - let add_track_btn = |id: &'static str, - key: &'static str, - kind: TrackKind, - cx: &mut Context| { - let label = i18n::tr(key); - let hover_bg = colors.selected; - div() - .id(id) - .h(px(24.0)) - .px_2() - .flex() - .items_center() - .rounded_sm() - .cursor_pointer() - .text_color(colors.text) - .text_xs() - .hover(move |style| style.bg(hover_bg)) - .tooltip(move |window, cx| tooltip_view(label.into(), window, cx)) - .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { - this.engine.update(cx, |engine, cx| engine.add_track(kind, cx)); - })) - .child(label) - }; + let add_track_btn = + |id: &'static str, key: &'static str, kind: TrackKind, cx: &mut Context| { + let label = i18n::tr(key); + let hover_bg = colors.selected; + div() + .id(id) + .h(px(24.0)) + .px_2() + .flex() + .items_center() + .rounded_sm() + .cursor_pointer() + .text_color(colors.text) + .text_xs() + .hover(move |style| style.bg(hover_bg)) + .tooltip(move |window, cx| tooltip_view(label.into(), window, cx)) + .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { + this.engine + .update(cx, |engine, cx| engine.add_track(kind, cx)); + })) + .child(label) + }; toolbar = toolbar .child(add_track_btn( "toolbar-add-video-track", @@ -803,10 +799,12 @@ impl Render for TimelinePanel { cx, )); - // A plain icon button (no selection state), e.g. zoom in/out. + // A plain icon button (no selection state), e.g. zoom in/out. The + // zoom buttons scale around the playhead (the design's fixed anchor). let icon_btn = |id: &'static str, icon_name: &'static str, key: &'static str, + factor: f32, cx: &mut Context| { let label = i18n::tr(key); let hover_bg = colors.container; @@ -822,12 +820,21 @@ impl Render for TimelinePanel { .text_color(colors.text) .hover(move |style| style.bg(hover_bg)) .tooltip(move |window, cx| tooltip_view(label.into(), window, cx)) + .on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| { + this.timeline.update(cx, |view, cx| { + let anchor = view.state.point_at_frame(view.state.playhead); + let zoom = view.state.zoom * factor; + view.state.set_zoom(zoom, anchor); + cx.emit(TimelineEvent::ZoomChanged(zoom)); + cx.notify(); + }); + })) .child(img(path).size(px(16.0))) }; // The snap toggle: the magnet icon next to the checkbox box. The icon - // is decorative (the box itself is clickable, as in the widget's - // default row). + // is a real toggle too — clicking it flips snap state and mirrors it + // into the checkbox, keeping the two in sync. let snap_row = div() .flex() .items_center() @@ -844,6 +851,23 @@ impl Render for TimelinePanel { .tooltip(move |window, cx| { tooltip_view(i18n::tr("timeline.snap").into(), window, cx) }) + .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { + let enabled = !this.timeline.read(cx).state.snap_enabled; + this.timeline.update(cx, |timeline, cx| { + timeline.state.snap_enabled = enabled; + cx.notify(); + }); + this.snap.update(cx, |snap, cx| { + snap.set_state( + if enabled { + CheckState::Checked + } else { + CheckState::Unchecked + }, + cx, + ); + }); + })) .child(img(icons::icon_path(icons::ICON_SNAP, cx)).size(px(16.0))), ) .child(self.snap.clone()); @@ -853,12 +877,14 @@ impl Render for TimelinePanel { "toolbar-zoom-in", icons::ICON_ZOOM_IN, "timeline.zoom_in", + 1.25, cx, )) .child(icon_btn( "toolbar-zoom-out", icons::ICON_ZOOM_OUT, "timeline.zoom_out", + 0.8, cx, )) .child( @@ -1119,8 +1145,14 @@ pub(crate) fn clip_menu( .with_checked(false) .separated(), MenuItem::new(LOCAL_CACHE_ALL, i18n::tr("timeline.context.cache_all")), - MenuItem::new(LOCAL_CACHE_IN_OUT, i18n::tr("timeline.context.cache_in_out")), - MenuItem::new(LOCAL_CACHE_DISCARD, i18n::tr("timeline.context.cache_discard")), + MenuItem::new( + LOCAL_CACHE_IN_OUT, + i18n::tr("timeline.context.cache_in_out"), + ), + MenuItem::new( + LOCAL_CACHE_DISCARD, + i18n::tr("timeline.context.cache_discard"), + ), ]); items.push(MenuItem::new(0, i18n::tr("timeline.context.cache")).with_submenu(cache_menu)); // Proxy group: the enable state mirrors the C++ @@ -1130,7 +1162,10 @@ pub(crate) fn clip_menu( let can_generate = proxy.iter().any(|row| row.can_generate); let any_proxy = proxy.iter().any(|row| row.has_proxy); let all_enabled = has_footage && proxy.iter().all(|row| row.enabled); - let mut generate = MenuItem::new(LOCAL_PROXY_GENERATE, i18n::tr("timeline.context.generate_proxy")); + let mut generate = MenuItem::new( + LOCAL_PROXY_GENERATE, + i18n::tr("timeline.context.generate_proxy"), + ); if !can_generate { generate = generate.disabled(); } @@ -1139,11 +1174,17 @@ pub(crate) fn clip_menu( if !has_footage { use_proxy = use_proxy.disabled(); } - let mut reveal = MenuItem::new(LOCAL_PROXY_REVEAL, i18n::tr("timeline.context.reveal_proxy")); + let mut reveal = MenuItem::new( + LOCAL_PROXY_REVEAL, + i18n::tr("timeline.context.reveal_proxy"), + ); if !any_proxy { reveal = reveal.disabled(); } - let mut delete = MenuItem::new(LOCAL_PROXY_DELETE, i18n::tr("timeline.context.delete_proxy")); + let mut delete = MenuItem::new( + LOCAL_PROXY_DELETE, + i18n::tr("timeline.context.delete_proxy"), + ); if !any_proxy { delete = delete.disabled(); } @@ -1166,8 +1207,11 @@ pub(crate) fn clip_menu( .disabled(), ); items.push( - MenuItem::new(LOCAL_REVEAL_PROJECT, i18n::tr("timeline.context.reveal_in_project")) - .disabled(), + MenuItem::new( + LOCAL_REVEAL_PROJECT, + i18n::tr("timeline.context.reveal_in_project"), + ) + .disabled(), ); // Multi-Cam (checkable): enabled when any selected clip's connected // viewer is a sequence, checked when that clip already has a multicam — @@ -1187,15 +1231,21 @@ pub(crate) fn clip_menu( /// sequence "Properties" entry. pub(crate) fn empty_area_menu() -> Menu { let thumbnails = Menu::new(vec![ - MenuItem::new(LOCAL_THUMBNAIL_OFF, i18n::tr("timeline.context.thumbnails_off")) - .with_checked(false), + MenuItem::new( + LOCAL_THUMBNAIL_OFF, + i18n::tr("timeline.context.thumbnails_off"), + ) + .with_checked(false), MenuItem::new( LOCAL_THUMBNAIL_IN_OUT, i18n::tr("timeline.context.thumbnails_at_in_points"), ) .with_checked(false), - MenuItem::new(LOCAL_THUMBNAIL_ON, i18n::tr("timeline.context.thumbnails_on")) - .with_checked(false), + MenuItem::new( + LOCAL_THUMBNAIL_ON, + i18n::tr("timeline.context.thumbnails_on"), + ) + .with_checked(false), ]); Menu::new(vec![ MenuItem::new( @@ -1203,11 +1253,13 @@ pub(crate) fn empty_area_menu() -> Menu { i18n::tr("timeline.context.use_audio_time_units"), ) .with_checked(false), - MenuItem::new(0, i18n::tr("timeline.context.show_thumbnails")) - .with_submenu(thumbnails), - MenuItem::new(LOCAL_SHOW_WAVEFORMS, i18n::tr("timeline.context.show_waveforms")) - .with_checked(false) - .separated(), + MenuItem::new(0, i18n::tr("timeline.context.show_thumbnails")).with_submenu(thumbnails), + MenuItem::new( + LOCAL_SHOW_WAVEFORMS, + i18n::tr("timeline.context.show_waveforms"), + ) + .with_checked(false) + .separated(), properties_item(ActionId::SequenceSettings), ]) } @@ -1216,10 +1268,23 @@ pub(crate) fn empty_area_menu() -> Menu { /// every empty track. pub(crate) fn track_head_menu() -> Menu { Menu::new(vec![ - MenuItem::new(LOCAL_ADD_VIDEO_TRACK, i18n::tr("timeline.context.add_video_track")), - MenuItem::new(LOCAL_ADD_AUDIO_TRACK, i18n::tr("timeline.context.add_audio_track")), - MenuItem::new(LOCAL_DELETE_TRACK, i18n::tr("timeline.context.delete_track")).separated(), - MenuItem::new(LOCAL_DELETE_ALL_EMPTY, i18n::tr("timeline.context.delete_all_empty")), + MenuItem::new( + LOCAL_ADD_VIDEO_TRACK, + i18n::tr("timeline.context.add_video_track"), + ), + MenuItem::new( + LOCAL_ADD_AUDIO_TRACK, + i18n::tr("timeline.context.add_audio_track"), + ), + MenuItem::new( + LOCAL_DELETE_TRACK, + i18n::tr("timeline.context.delete_track"), + ) + .separated(), + MenuItem::new( + LOCAL_DELETE_ALL_EMPTY, + i18n::tr("timeline.context.delete_all_empty"), + ), ]) } @@ -1253,10 +1318,16 @@ pub(crate) fn ruler_menu() -> Menu { i18n::tr("timeline.context.timecode_non_drop_frame"), ) .with_checked(false), - MenuItem::new(LOCAL_TIMECODE_SECONDS, i18n::tr("timeline.context.timecode_seconds")) - .with_checked(false), - MenuItem::new(LOCAL_TIMECODE_FRAMES, i18n::tr("timeline.context.timecode_frames")) - .with_checked(false), + MenuItem::new( + LOCAL_TIMECODE_SECONDS, + i18n::tr("timeline.context.timecode_seconds"), + ) + .with_checked(false), + MenuItem::new( + LOCAL_TIMECODE_FRAMES, + i18n::tr("timeline.context.timecode_frames"), + ) + .with_checked(false), MenuItem::new( LOCAL_TIMECODE_MILLISECONDS, i18n::tr("timeline.context.timecode_milliseconds"), @@ -1385,7 +1456,10 @@ mod tests { cx.update(|window, cx| { window.draw(cx).clear(); }); - assert!(cx.debug_bounds("menu-popup").is_none(), "menu starts hidden"); + assert!( + cx.debug_bounds("menu-popup").is_none(), + "menu starts hidden" + ); let canvas = cx .debug_bounds("timeline-canvas") @@ -1412,11 +1486,7 @@ mod tests { /// entries and a registry-backed "Properties". #[test] fn clip_menu_keeps_the_cpp_shape() { - let menu = clip_menu( - crate::oakui::engine::SyncEligibility::default(), - &[], - None, - ); + let menu = clip_menu(crate::oakui::engine::SyncEligibility::default(), &[], None); // Color label item sits right after the edit section and carries a // submenu of all 16 labels. let color = menu @@ -1437,9 +1507,11 @@ mod tests { ActionId::SyncByWaveformSpeed, ] { let id = action.entry().menu_id(); - let item = menu.items.iter().find(|item| item.id == id).unwrap_or_else(|| { - panic!("clip menu missing synchronize entry {id}") - }); + let item = menu + .items + .iter() + .find(|item| item.id == id) + .unwrap_or_else(|| panic!("clip menu missing synchronize entry {id}")); assert!(!item.enabled, "synchronize {id} should be disabled"); } @@ -1448,9 +1520,11 @@ mod tests { LOCAL_REVEAL_PROJECT, LOCAL_MULTICAM, ] { - let item = menu.items.iter().find(|item| item.id == id).unwrap_or_else(|| { - panic!("clip menu missing disabled placeholder id {id}") - }); + let item = menu + .items + .iter() + .find(|item| item.id == id) + .unwrap_or_else(|| panic!("clip menu missing disabled placeholder id {id}")); assert!(!item.enabled, "placeholder {id} should be disabled"); } @@ -1474,10 +1548,7 @@ mod tests { // "Properties" dispatches through the speed/duration registry entry. let properties = menu.items.last().expect("properties is the clip menu tail"); - assert_eq!( - properties.id, - ActionId::SpeedDuration.entry().menu_id() - ); + assert_eq!(properties.id, ActionId::SpeedDuration.entry().menu_id()); } /// The synchronize / proxy enable state follows the selection (the C++ @@ -1580,7 +1651,10 @@ mod tests { .find(|item| item.id == LOCAL_MULTICAM) .expect("multi-cam item"); assert!(item.enabled, "a sequence-fed clip enables Multi-Cam"); - assert!(item.checked.unwrap_or(false), "checked when multicam present"); + assert!( + item.checked.unwrap_or(false), + "checked when multicam present" + ); // Eligible but not enabled: enabled, unchecked. let menu = clip_menu( @@ -1614,25 +1688,22 @@ mod tests { let ids: Vec = sub.iter().map(|item| item.id).collect(); assert_eq!( ids, - vec![LOCAL_THUMBNAIL_OFF, LOCAL_THUMBNAIL_IN_OUT, LOCAL_THUMBNAIL_ON] + vec![ + LOCAL_THUMBNAIL_OFF, + LOCAL_THUMBNAIL_IN_OUT, + LOCAL_THUMBNAIL_ON + ] ); assert!(sub.iter().all(|item| item.checked == Some(false))); let properties = menu.items.last().expect("properties tail"); - assert_eq!( - properties.id, - ActionId::SequenceSettings.entry().menu_id() - ); + assert_eq!(properties.id, ActionId::SequenceSettings.entry().menu_id()); } /// The track-header menu is exactly the two delete entries. #[test] fn track_head_menu_offers_add_then_delete_entries() { - let ids: Vec = track_head_menu() - .items - .iter() - .map(|item| item.id) - .collect(); + let ids: Vec = track_head_menu().items.iter().map(|item| item.id).collect(); assert_eq!( ids, vec![ diff --git a/gpui b/gpui index fb8d726fa..31f3838c5 160000 --- a/gpui +++ b/gpui @@ -1 +1 @@ -Subproject commit fb8d726fa386530121c914c7aff78d6f23e83795 +Subproject commit 31f3838c52992884835b470353370fa5c5887812