app: real timeline tools, live zoom buttons, clickable snap toggle
CI / Build & test (Linux) (push) Successful in 20m7s
CI / Build & test (Windows) (push) Successful in 41m55s

- the eight toolbar tools now drive real editing: razor splits at the
  click point, ripple/slip/roll/slide emit new undoable composite
  commands (ripple_trim_clip, roll_edit, slide_clip, slip_clip),
  zoom clicks zoom anchored at the cursor, track-select selects the
  track right of the click; panel tool state and the Tools menu stay
  in sync both ways
- the zoom-in/out toolbar icons now actually zoom (anchored at the
  playhead) and the snap magnet icon toggles snapping like its checkbox
This commit is contained in:
2026-08-27 17:59:27 +08:00
parent 4e619efb93
commit 1ae03c1b1e
7 changed files with 1521 additions and 659 deletions
+90 -33
View File
@@ -356,8 +356,7 @@ pub fn key_bindings() -> Vec<KeyBinding> {
pub fn display_shortcut(action: ActionId) -> Option<String> {
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<String> {
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<String>`
/// overrides share one code path).
fn canonical_keys<K: AsRef<str>>(keys: &[K]) -> Vec<String> {
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<String> {
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<gpui::timeline::TimelineTool> {
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::<String>::new()
);
assert_eq!(effective_keys(ActionId::Undo.entry()), Vec::<String>::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(),
+36 -7
View File
@@ -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<E: AppEngine> {
content: Entity<crate::dialogs::ProjectPropertiesContent<E>>,
},
/// The about dialog (Help > About Oak…; static content).
About { modal: Entity<Modal> },
About {
modal: Entity<Modal>,
},
/// The new-sequence dialog (File > New > Sequence…).
NewSequence {
modal: Entity<Modal>,
@@ -370,6 +372,9 @@ pub struct OakApp<E: AppEngine> {
/// 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<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
// 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<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
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<E: AppEngine> OakApp<E> {
.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,
+419 -123
View File
@@ -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<ProjectRef, String> {
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<NodeId, String>
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<NodeId, Strin
return Err("the project has no root folder".to_string());
}
let (mut core, mut behavior) = FootageBehavior::create();
core.set_standard_value("file_in", -1, oak_node::value::NodeValue::Text(filename.clone()));
core.set_standard_value(
"file_in",
-1,
oak_node::value::NodeValue::Text(filename.clone()),
);
let Some(f) = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<FootageBehavior>())
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<NodeId, Strin
}
(guard.root, id)
};
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, "Import Footage").map_err(|e| e.to_string())?;
Ok(id)
}
@@ -784,9 +788,9 @@ pub fn markers_of(list: &CHandle) -> 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::<std::sync::Arc<std::sync::Mutex<oak_timeline::marker::TimelineMarkerList>>>(
list,
)
oak_timeline::handle::get::<
std::sync::Arc<std::sync::Mutex<oak_timeline::marker::TimelineMarkerList>>,
>(list)
}) else {
return Vec::new();
};
@@ -804,9 +808,9 @@ pub fn marker_index_at(list: &CHandle, time: Rational) -> Option<usize> {
}
// SAFETY: as `markers_of`.
let l = unsafe {
oak_timeline::handle::get::<std::sync::Arc<std::sync::Mutex<oak_timeline::marker::TimelineMarkerList>>>(
list,
)
oak_timeline::handle::get::<
std::sync::Arc<std::sync::Mutex<oak_timeline::marker::TimelineMarkerList>>,
>(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::<std::sync::Arc<std::sync::Mutex<oak_timeline::workarea::TimelineWorkArea>>>(
wa,
)
oak_timeline::handle::get::<
std::sync::Arc<std::sync::Mutex<oak_timeline::workarea::TimelineWorkArea>>,
>(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::<std::sync::Arc<std::sync::Mutex<oak_timeline::workarea::TimelineWorkArea>>>(
wa,
)
oak_timeline::handle::get_mut::<
std::sync::Arc<std::sync::Mutex<oak_timeline::workarea::TimelineWorkArea>>,
>(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<String, String> {
/// 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<String, 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()
.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<ProjectRef, String> {
));
}
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::<ClipBlockBehavior>())
{
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::<ClipBlockBehavior>())
{
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::<ClipBlockBehavior>())
{
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::<ClipBlockBehavior>())
{
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<oak_undo::undocommand::UndoCommand, String> {
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<ClipboardClip> {
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<usize> = 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:?}"));
+171 -33
View File
@@ -1020,10 +1020,7 @@ impl MockEngine {
/// matching a selected graph node.
fn effect_for_node(&self, node: NodeId) -> Option<EffectId> {
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<i64> {
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<Self>) -> Result<(), String> {
fn export_project_path(
&mut self,
_path: PathBuf,
cx: &mut Context<Self>,
) -> 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<Self>) -> Result<(), String> {
fn set_project_ocio_config(
&mut self,
path: String,
cx: &mut Context<Self>,
) -> 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<Self>) {
fn set_project_cache_location(
&mut self,
setting: i32,
custom_path: String,
cx: &mut Context<Self>,
) {
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<Self>) -> Option<Arc<RenderImage>> {
fn multicam_angle_frame(
&mut self,
source: i32,
cx: &mut Context<Self>,
) -> Option<Arc<RenderImage>> {
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<ClipId>, enabled: bool, cx: &mut Context<Self>) {
fn multicam_enable_selected(
&mut self,
_clips: Vec<ClipId>,
enabled: bool,
cx: &mut Context<Self>,
) {
// 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 开场 (0240) and split there.
File diff suppressed because it is too large Load Diff
+205 -134
View File
@@ -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<E: AppEngine> {
zoom: Entity<Slider>,
height: Entity<Slider>,
snap: Entity<CheckBox>,
/// 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<E: AppEngine> TimelinePanel<E> {
// 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<E: AppEngine> TimelinePanel<E> {
zoom,
height,
snap,
selected_tool: 0,
footage_drop: None,
context_menu,
context_track: None,
@@ -235,8 +226,7 @@ impl<E: AppEngine> TimelinePanel<E> {
cx.notify();
});
}
let ids: Vec<ClipId> =
self.timeline.read(cx).selection().iter().copied().collect();
let ids: Vec<ClipId> = self.timeline.read(cx).selection().iter().copied().collect();
let (sync, proxy, multicam) = {
let engine = self.engine.read(cx);
(
@@ -275,35 +265,36 @@ impl<E: AppEngine> TimelinePanel<E> {
}
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<ClipId> =
self.timeline.read(cx).selection().iter().copied().collect();
LOCAL_PROXY_GENERATE | LOCAL_PROXY_USE | LOCAL_PROXY_REVEAL | LOCAL_PROXY_DELETE => {
let ids: Vec<ClipId> = 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<E: AppEngine> TimelinePanel<E> {
}
_ => {
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<E: AppEngine> TimelinePanel<E> {
LOCAL_MULTICAM => {
// The C++ `multicam_enabled_triggered` flip: checked clips
// disable, unchecked ones enable.
let ids: Vec<ClipId> =
self.timeline.read(cx).selection().iter().copied().collect();
let ids: Vec<ClipId> = 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<E: AppEngine> PanelCommandHandler for TimelinePanel<E> {
true
}
fn clear_in_out(&mut self, cx: &mut Context<Self>) -> 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<E: AppEngine> PanelCommandHandler for TimelinePanel<E> {
// --- editing ---
fn cut_selected(&mut self, cx: &mut Context<Self>) -> bool {
let ids: Vec<ClipId> = 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<Self>) -> bool {
let ids: Vec<ClipId> = 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<Self>) -> 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<Self>) -> bool {
@@ -673,14 +666,16 @@ impl<E: AppEngine> PanelCommandHandler for TimelinePanel<E> {
}
fn sync_by_waveform(&mut self, cx: &mut Context<Self>) -> bool {
let ids: Vec<ClipId> = 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<Self>) -> bool {
let ids: Vec<ClipId> = 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<E: AppEngine> Render for TimelinePanel<E> {
.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<Self>| {
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<E: AppEngine> Render for TimelinePanel<E> {
.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<E: AppEngine> Render for TimelinePanel<E> {
// 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<Self>| {
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<Self>| {
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<E: AppEngine> Render for TimelinePanel<E> {
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<Self>| {
let label = i18n::tr(key);
let hover_bg = colors.container;
@@ -822,12 +820,21 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
.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<E: AppEngine> Render for TimelinePanel<E> {
.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<E: AppEngine> Render for TimelinePanel<E> {
"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<usize> = 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<usize> = track_head_menu()
.items
.iter()
.map(|item| item.id)
.collect();
let ids: Vec<usize> = track_head_menu().items.iter().map(|item| item.id).collect();
assert_eq!(
ids,
vec![
+1 -1
Submodule gpui updated: fb8d726fa3...31f3838c52