Files
oak-editor/crates/oak-app/src/panels/commands.rs
T
Mike-Solar 4cc30d8711 test(oak-app): UI panels, engines, dialogs and widgets
Mock/real engine boundaries, shell modals and menus, timeline/
inspector/node-editor/project-explorer panels, dialogs, and the
editor controls, including the review remediation assertions.
2026-09-22 20:54:04 +08:00

591 lines
19 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! Focused-panel command routing: the Rust counterpart of the C++
//! `PanelWidget` virtual command interface (`app/panel/panel.h`'s ~39
//! `play_pause` / `set_in` / `delete_selected` / … overrides). Menu clicks
//! and key presses whose registry entry carries [`Route::FocusedPanel`]
//! land here first: the currently focused panel gets the command, and when
//! it does not implement it (the default methods return `false`) the app
//! shell's global handler runs instead — the
//! `PanelManager::currently_focused()` pattern.
//!
//! Panels override only the subset they implement; [`dispatch_to`] maps an
//! [`ActionId`] onto the matching trait method so panel implementations and
//! the action registry stay in one place.
use gpui::timeline::Frame;
use gpui::{App, Context, Entity};
use gpui_widgets::viewer::PlaybackClock;
use crate::actions::ActionId;
use crate::oakui::{AppEngine, Monitor};
/// The commands a dock panel can handle when it is focused. Every method
/// defaults to "not handled" (`false`), so a panel overrides only its
/// subset; returning `true` stops the shell's global fallback from running.
pub trait PanelCommandHandler: Sized {
// --- transport (the panel's monitor) ----------------------------------
fn play_pause(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn prev_frame(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn next_frame(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn go_to_start(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn go_to_end(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn play_in_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn go_to_prev_cut(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn go_to_next_cut(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn go_to_in(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn go_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn shuttle_left(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn shuttle_stop(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn shuttle_right(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
// --- in / out points ----------------------------------------------------
fn set_in(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn set_out(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn reset_in(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn reset_out(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn clear_in_out(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
// --- selection ----------------------------------------------------------
fn select_all(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn deselect_all(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
// --- editing -------------------------------------------------------------
fn cut_selected(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn copy_selected(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn paste(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn paste_insert(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn duplicate(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn rename_selected(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn delete_selected(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn ripple_delete(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn split_at_playhead(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn speed_duration(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
/// The clip menu's "Default Transition" item / the Ctrl+Shift+D action:
/// add a transition of the configured default length at every seam
/// around the selected clips.
fn default_transition(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn toggle_links(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn toggle_selected_enabled(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn insert(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn overwrite(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn ripple_to_in(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn ripple_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn edit_to_in(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn edit_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn nudge_left(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn nudge_right(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn move_in_to_playhead(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn move_out_to_playhead(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn delete_in_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn ripple_delete_in_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn set_marker(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
// --- synchronization (the timeline clip menu's Synchronize group) ------
fn sync_by_source_time(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn sync_by_waveform(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn sync_by_waveform_speed(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
// --- multi-camera (the Multicam panel's source-switch hotkeys) ---------
/// Switches the panel's multicam to source index `source`. `split_clip`
/// = the change applies from the playhead forward (the clip is split
/// first). The Multicam panel handles it; every other panel falls
/// through to the shell's no-op handler.
fn multicam_switch(&mut self, source: i32, split_clip: bool, cx: &mut Context<Self>) -> bool {
let _ = (source, split_clip, cx);
false
}
// --- view ----------------------------------------------------------------
fn zoom_in(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn zoom_out(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn increase_track_height(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn decrease_track_height(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
fn toggle_show_all(&mut self, _cx: &mut Context<Self>) -> bool {
false
}
}
/// Routes `action` to the matching [`PanelCommandHandler`] method; whether
/// the panel handled it. Actions without a panel command (file ops, tools,
/// …) return `false` and fall through to the shell's global handler.
pub fn dispatch_to<P: PanelCommandHandler>(
panel: &mut P,
action: ActionId,
cx: &mut Context<P>,
) -> bool {
match action {
ActionId::PlayPause => panel.play_pause(cx),
ActionId::PrevFrame => panel.prev_frame(cx),
ActionId::NextFrame => panel.next_frame(cx),
ActionId::GoToStart => panel.go_to_start(cx),
ActionId::GoToEnd => panel.go_to_end(cx),
ActionId::PlayInToOut => panel.play_in_to_out(cx),
ActionId::GoToPrevCut => panel.go_to_prev_cut(cx),
ActionId::GoToNextCut => panel.go_to_next_cut(cx),
ActionId::GoToIn => panel.go_to_in(cx),
ActionId::GoToOut => panel.go_to_out(cx),
ActionId::ShuttleLeft => panel.shuttle_left(cx),
ActionId::ShuttleStop => panel.shuttle_stop(cx),
ActionId::ShuttleRight => panel.shuttle_right(cx),
ActionId::SetInPoint => panel.set_in(cx),
ActionId::SetOutPoint => panel.set_out(cx),
ActionId::ResetIn => panel.reset_in(cx),
ActionId::ResetOut => panel.reset_out(cx),
ActionId::ClearInOut => panel.clear_in_out(cx),
ActionId::SelectAll => panel.select_all(cx),
ActionId::DeselectAll => panel.deselect_all(cx),
ActionId::Cut => panel.cut_selected(cx),
ActionId::Copy => panel.copy_selected(cx),
ActionId::Paste => panel.paste(cx),
ActionId::PasteInsert => panel.paste_insert(cx),
ActionId::Duplicate => panel.duplicate(cx),
ActionId::Rename => panel.rename_selected(cx),
ActionId::Delete => panel.delete_selected(cx),
ActionId::RippleDelete => panel.ripple_delete(cx),
ActionId::SplitAtPlayhead => panel.split_at_playhead(cx),
ActionId::SpeedDuration => panel.speed_duration(cx),
ActionId::DefaultTransition => panel.default_transition(cx),
ActionId::LinkUnlink => panel.toggle_links(cx),
ActionId::EnableDisable => panel.toggle_selected_enabled(cx),
ActionId::Insert => panel.insert(cx),
ActionId::Overwrite => panel.overwrite(cx),
ActionId::RippleToIn => panel.ripple_to_in(cx),
ActionId::RippleToOut => panel.ripple_to_out(cx),
ActionId::EditToIn => panel.edit_to_in(cx),
ActionId::EditToOut => panel.edit_to_out(cx),
ActionId::NudgeLeft => panel.nudge_left(cx),
ActionId::NudgeRight => panel.nudge_right(cx),
ActionId::MoveInToPlayhead => panel.move_in_to_playhead(cx),
ActionId::MoveOutToPlayhead => panel.move_out_to_playhead(cx),
ActionId::DeleteInOut => panel.delete_in_to_out(cx),
ActionId::RippleDeleteInOut => panel.ripple_delete_in_to_out(cx),
ActionId::Marker => panel.set_marker(cx),
ActionId::SyncBySourceTime => panel.sync_by_source_time(cx),
ActionId::SyncByWaveform => panel.sync_by_waveform(cx),
ActionId::SyncByWaveformSpeed => panel.sync_by_waveform_speed(cx),
ActionId::MulticamSwitch1 => panel.multicam_switch(0, true, cx),
ActionId::MulticamSwitch2 => panel.multicam_switch(1, true, cx),
ActionId::MulticamSwitch3 => panel.multicam_switch(2, true, cx),
ActionId::MulticamSwitch4 => panel.multicam_switch(3, true, cx),
ActionId::MulticamSwitch5 => panel.multicam_switch(4, true, cx),
ActionId::MulticamSwitch6 => panel.multicam_switch(5, true, cx),
ActionId::MulticamSwitch7 => panel.multicam_switch(6, true, cx),
ActionId::MulticamSwitch8 => panel.multicam_switch(7, true, cx),
ActionId::MulticamSwitch9 => panel.multicam_switch(8, true, cx),
ActionId::MulticamSwitchNoSplit1 => panel.multicam_switch(0, false, cx),
ActionId::MulticamSwitchNoSplit2 => panel.multicam_switch(1, false, cx),
ActionId::MulticamSwitchNoSplit3 => panel.multicam_switch(2, false, cx),
ActionId::MulticamSwitchNoSplit4 => panel.multicam_switch(3, false, cx),
ActionId::MulticamSwitchNoSplit5 => panel.multicam_switch(4, false, cx),
ActionId::MulticamSwitchNoSplit6 => panel.multicam_switch(5, false, cx),
ActionId::MulticamSwitchNoSplit7 => panel.multicam_switch(6, false, cx),
ActionId::MulticamSwitchNoSplit8 => panel.multicam_switch(7, false, cx),
ActionId::MulticamSwitchNoSplit9 => panel.multicam_switch(8, false, cx),
ActionId::ZoomIn => panel.zoom_in(cx),
ActionId::ZoomOut => panel.zoom_out(cx),
ActionId::IncreaseTrackHeight => panel.increase_track_height(cx),
ActionId::DecreaseTrackHeight => panel.decrease_track_height(cx),
ActionId::ToggleShowAll => panel.toggle_show_all(cx),
_ => false,
}
}
/// The transport commands a viewer panel routes to one of the engine's
/// monitors (the program viewer drives [`Monitor::Program`], the source
/// viewer [`Monitor::Source`]). Shuttle left steps back one frame — true
/// reverse playback is an engine transport gap (the C++ `decspeed`
/// behavior approximated like the old shortcut table did). Cut navigation
/// and loop are not implemented by the viewers and fall through.
pub fn viewer_transport<E: AppEngine>(
engine: &Entity<E>,
clock: &Entity<E::Clock>,
monitor: Monitor,
action: ActionId,
cx: &mut App,
) -> bool {
match action {
ActionId::PlayPause => {
let playing = clock.read(cx).is_playing();
engine.update(cx, |engine, cx| {
if playing {
engine.pause(monitor, cx);
} else {
engine.play(monitor, cx);
}
});
true
}
ActionId::PrevFrame => {
engine.update(cx, |engine, cx| engine.step(monitor, -1, cx));
true
}
ActionId::NextFrame => {
engine.update(cx, |engine, cx| engine.step(monitor, 1, cx));
true
}
ActionId::GoToStart => {
engine.update(cx, |engine, cx| engine.request_frame(monitor, Frame::ZERO, cx));
true
}
ActionId::GoToEnd => {
let length = engine.read(cx).sequence_length();
engine.update(cx, |engine, cx| engine.request_frame(monitor, length, cx));
true
}
ActionId::ShuttleLeft => {
engine.update(cx, |engine, cx| engine.step(monitor, -1, cx));
true
}
ActionId::ShuttleStop => {
engine.update(cx, |engine, cx| engine.pause(monitor, cx));
true
}
ActionId::ShuttleRight => {
engine.update(cx, |engine, cx| engine.play(monitor, cx));
true
}
ActionId::GoToIn => {
if let Some((start, _)) = engine.read(cx).workarea() {
engine.update(cx, |engine, cx| engine.request_frame(monitor, start, cx));
}
true
}
ActionId::GoToOut => {
if let Some((_, end)) = engine.read(cx).workarea() {
engine.update(cx, |engine, cx| engine.request_frame(monitor, end, cx));
}
true
}
ActionId::PlayInToOut => {
// Seek to the work area's start, then play (the engine stops at
// the sequence end; out-point stopping is a transport gap).
if let Some((start, _)) = engine.read(cx).workarea() {
engine.update(cx, |engine, cx| engine.request_frame(monitor, start, cx));
}
engine.update(cx, |engine, cx| engine.play(monitor, cx));
true
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::{ActionId, REGISTRY};
use crate::oakui::MockEngine;
use gpui::timeline::{Frame, TimelineDataSource as _};
use gpui::{AppContext as _, TestAppContext};
/// A panel that overrides nothing: every command falls through.
struct DefaultPanel;
impl PanelCommandHandler for DefaultPanel {}
/// A panel that overrides a couple of commands and records the
/// multi-camera routing arguments.
#[derive(Default)]
struct RecordingPanel {
value: bool,
multicam: Option<(i32, bool)>,
}
impl PanelCommandHandler for RecordingPanel {
fn play_pause(&mut self, _cx: &mut Context<Self>) -> bool {
self.value = true;
self.value
}
fn multicam_switch(&mut self, source: i32, split_clip: bool, _cx: &mut Context<Self>) -> bool {
self.multicam = Some((source, split_clip));
true
}
}
/// Every registry action dispatches onto the matching trait method,
/// and a panel that implements none of them declines all of them —
/// which also exercises every default body.
#[gpui::test]
async fn default_panel_declines_every_registered_action(cx: &mut TestAppContext) {
cx.update(|app| {
let panel = app.new(|_| DefaultPanel);
for entry in REGISTRY {
let handled =
panel.update(app, |panel, cx| dispatch_to(panel, entry.action, cx));
assert!(
!handled,
"the default panel must decline {:?}",
entry.action
);
}
});
}
/// A panel that handles `PlayPause` stops the fall-through for that
/// action only; the multicam actions map their numbered ids onto
/// `(source, split_clip)` pairs.
#[gpui::test]
async fn dispatch_propagates_handled_actions_and_multicam_arguments(
cx: &mut TestAppContext,
) {
cx.update(|app| {
let panel = app.new(|_| RecordingPanel::default());
let handled = panel.update(app, |panel, cx| dispatch_to(panel, ActionId::PlayPause, cx));
assert!(handled, "the overridden command handles the action");
assert!(panel.read(app).value);
// Everything else still falls through to the shell.
let handled = panel.update(app, |panel, cx| dispatch_to(panel, ActionId::Cut, cx));
assert!(!handled);
let handled =
panel.update(app, |panel, cx| dispatch_to(panel, ActionId::MulticamSwitch3, cx));
assert!(handled);
assert_eq!(panel.read(app).multicam, Some((2, true)));
let handled = panel.update(app, |panel, cx| {
dispatch_to(panel, ActionId::MulticamSwitchNoSplit7, cx)
});
assert!(handled);
assert_eq!(panel.read(app).multicam, Some((6, false)));
// Actions with no panel command at all also decline.
let handled = panel.update(app, |panel, cx| dispatch_to(panel, ActionId::NewProject, cx));
assert!(!handled);
});
}
/// The viewer transport maps actions onto the monitor's clock: play /
/// pause toggling, stepping, jumps to start/end and the shuttle keys.
#[gpui::test]
async fn viewer_transport_drives_the_monitor_clock(cx: &mut TestAppContext) {
cx.update(|app| {
let engine = app.new(MockEngine::create);
let clock = engine.read(app).program_clock().clone();
let monitor = Monitor::Program;
// Commands the viewers do not implement fall through.
assert!(!viewer_transport(
&engine,
&clock,
monitor,
ActionId::Cut,
app
));
// PlayPause toggles, and ShuttleStop forces the paused state.
assert!(viewer_transport(
&engine,
&clock,
monitor,
ActionId::PlayPause,
app
));
assert!(clock.read(app).is_playing());
assert!(viewer_transport(
&engine,
&clock,
monitor,
ActionId::PlayPause,
app
));
assert!(!clock.read(app).is_playing());
assert!(viewer_transport(
&engine,
&clock,
monitor,
ActionId::ShuttleRight,
app
));
assert!(clock.read(app).is_playing());
assert!(viewer_transport(
&engine,
&clock,
monitor,
ActionId::ShuttleStop,
app
));
assert!(!clock.read(app).is_playing());
// Frame stepping clamps at the sequence bounds.
assert!(viewer_transport(
&engine,
&clock,
monitor,
ActionId::GoToStart,
app
));
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame::ZERO);
assert!(viewer_transport(
&engine,
&clock,
monitor,
ActionId::NextFrame,
app
));
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(1));
assert!(viewer_transport(
&engine,
&clock,
monitor,
ActionId::PrevFrame,
app
));
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(0));
assert!(viewer_transport(
&engine,
&clock,
monitor,
ActionId::ShuttleLeft,
app
));
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(0));
// GoToEnd requests one past the end; the seek clamps to the
// last frame.
let length = engine.read(app).sequence_length();
assert!(viewer_transport(
&engine,
&clock,
monitor,
ActionId::GoToEnd,
app
));
assert_eq!(engine.read(app).clock_frame(monitor, app), Frame(length.0 - 1));
// Work-area commands still claim the action even when the
// engine has no work area to jump to.
for action in [
ActionId::GoToIn,
ActionId::GoToOut,
ActionId::PlayInToOut,
] {
assert!(
viewer_transport(&engine, &clock, monitor, action, app),
"{action:?} is handled"
);
}
});
}
}