app: make the viewer context menu real
CI / Build & test (Windows) (push) Canceled after 12m22s
CI / Build & test (Linux) (push) Canceled after 14m4s

Every item in the program/source viewer menu now works and reflects
real state: zoom levels, full-screen toggle, safe margins (off/on/
custom), stop-on-last (honoured by both playback clocks), waveform
mode, show-fps overlay, and save-frame (writes a PNG of the current
frame). The viewer widget's in/out/clear buttons are wired to the
shared program workarea.
This commit is contained in:
2026-08-27 17:39:03 +08:00
parent bad2f9552e
commit 4e619efb93
8 changed files with 650 additions and 98 deletions
+52 -5
View File
@@ -523,6 +523,25 @@ impl<E: AppEngine> OakApp<E> {
)
.detach();
// The viewer panels re-emit their monitor-level requests (full-screen
// and the loop in/out range) for the shell to apply: full-screen
// toggles the window, and the in/out/clear requests act on the shared
// program workarea (both monitors use it).
cx.subscribe(
&panels.source_viewer,
|this, _panel, event: &menu::ViewerPanelEvent, cx| {
this.apply_viewer_panel_event(*event, cx);
},
)
.detach();
cx.subscribe(
&panels.program_viewer,
|this, _panel, event: &menu::ViewerPanelEvent, cx| {
this.apply_viewer_panel_event(*event, cx);
},
)
.detach();
// Arrange the default workspace: the design's 素材查看器 | 序列查看器 |
// 检查器 row (project bin docked on the left), node editor + history
// as tabs, timeline full width at the bottom.
@@ -950,11 +969,7 @@ impl<E: AppEngine> OakApp<E> {
println!("[view] toggle show all: {} (placeholder)", self.show_all);
self.rebuild_menu_bar(cx);
}
A::FullScreen => {
self.full_screen = !self.full_screen;
println!("[view] full screen: {} (placeholder)", self.full_screen);
self.rebuild_menu_bar(cx);
}
A::FullScreen => self.toggle_full_screen(cx),
A::Preferences => self.open_preferences(cx),
// --- Playback (the program monitor) ----------------------------
A::PlayPause => {
@@ -1394,6 +1409,38 @@ impl<E: AppEngine> OakApp<E> {
cx.notify();
}
/// Toggles the window's full-screen state (the 视图 → 全屏 menu entry and
/// the viewer context menu's `FullScreenRequested`), then refreshes the
/// menu bar so the checkmark follows the real window state.
fn toggle_full_screen(&mut self, cx: &mut Context<Self>) {
let windows = cx.windows();
let Some(window) = windows.first() else {
return;
};
let Ok(full_screen) = cx.update_window(*window, |_root, window, _app| {
window.toggle_fullscreen();
window.is_fullscreen()
}) else {
return;
};
self.full_screen = full_screen;
self.rebuild_menu_bar(cx);
}
/// Applies a viewer panel's shell-level request: full-screen toggles the
/// window, and the loop-range requests move the shared program workarea
/// (both monitors act on it through the same undoable path as the menu).
fn apply_viewer_panel_event(&mut self, event: menu::ViewerPanelEvent, cx: &mut Context<Self>) {
match event {
menu::ViewerPanelEvent::FullScreenRequested => self.toggle_full_screen(cx),
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));
}
}
}
/// Replaces the `MenuBar` entity with one built from the current language
/// and the dynamic checkmark state, re-subscribing to its trigger events.
fn rebuild_menu_bar(&mut self, cx: &mut Context<Self>) {
+242 -33
View File
@@ -33,6 +33,7 @@
pub use gpui_widgets::menu::{
ContextMenu, ContextMenuEvent, Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem,
};
use gpui_widgets::viewer::{SafeMargins, ViewerZoom, WaveformMode, VIEWER_ZOOM_LEVELS};
// ---------------------------------------------------------------------------
// Context-menu plumbing
@@ -289,57 +290,87 @@ pub const LOCAL_VIEWER_WF_BOTH: usize = 2331;
pub const LOCAL_VIEWER_SHOW_FPS: usize = 2332;
pub const LOCAL_VIEWER_SAVE_FRAME: usize = 2333;
/// The zoom levels the viewer's Zoom submenu offers (the C++
/// `ViewerSizer::k_zoom_levels`).
pub const VIEWER_ZOOM_LEVELS: [f32; 10] =
[0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 4.0, 8.0];
/// The zoom submenu item id for `level` (one of [`VIEWER_ZOOM_LEVELS`]).
/// The zoom submenu item id for `level` (one of
/// [`gpui_widgets::viewer::VIEWER_ZOOM_LEVELS`]).
pub fn viewer_zoom_level_id(index: usize) -> usize {
LOCAL_VIEWER_ZOOM_LEVELS_BASE + index
}
/// The live state the viewer context menu reflects. The panels build one per
/// right-click from the engine config and the viewer widget, so every checked
/// entry is real: the radio groups mark the current zoom / resolution / safe
/// margins / waveform, and the toggles mark `StopOnLastFrame` and `ShowFPS`.
pub struct ViewerMenuState {
/// The current playback resolution divider (1/2/4/8).
pub playback_divider: i64,
/// The viewer widget's zoom state.
pub zoom: ViewerZoom,
/// The viewer widget's safe-margin overlay.
pub safe: SafeMargins,
/// The `StopOnLastFrame` config.
pub stop_on_last: bool,
/// The `ViewerWaveformMode` config.
pub waveform: WaveformMode,
/// Whether the frame-rate overlay is shown.
pub show_fps: bool,
}
impl Default for ViewerMenuState {
fn default() -> Self {
Self {
playback_divider: 1,
zoom: ViewerZoom::Fit,
safe: SafeMargins::Off,
stop_on_last: false,
waveform: WaveformMode::Automatic,
show_fps: false,
}
}
}
/// The context menu both viewer monitors show (the C++
/// `ViewerWidget::show_context_menu`, minus the OCIO color menus and the
/// subtitle block the engine does not surface yet). Zoom / playback
/// resolution / safe margins / waveform / FPS are placeholders until the
/// viewer widget grows those controls.
pub fn viewer_menu(playback_divider: i64) -> Menu {
/// subtitle block the engine does not surface yet).
pub fn viewer_menu(state: &ViewerMenuState) -> Menu {
use crate::i18n::tr;
// Zoom: Fit + one entry per zoom level.
let mut zoom_items = vec![MenuItem::new(LOCAL_VIEWER_ZOOM_FIT, tr("viewer.context.zoom_fit"))];
// Zoom: Fit + one entry per zoom level, checked against the live state.
let mut zoom_items = vec![MenuItem::new(LOCAL_VIEWER_ZOOM_FIT, tr("viewer.context.zoom_fit"))
.with_checked(state.zoom == ViewerZoom::Fit)];
for (index, level) in VIEWER_ZOOM_LEVELS.iter().enumerate() {
zoom_items.push(MenuItem::new(
viewer_zoom_level_id(index),
format!("{:.0}%", level * 100.0),
));
zoom_items.push(
MenuItem::new(viewer_zoom_level_id(index), format!("{:.0}%", level * 100.0))
.with_checked(state.zoom == ViewerZoom::Level(index)),
);
}
// Playback resolution radio group.
// Playback Resolution radio group (the C++ `PlaybackDivider` config):
// the checked entry reflects the current divider.
let resolution_menu = Menu::new(vec![
MenuItem::new(LOCAL_VIEWER_RES_FULL, tr("viewer.context.res_full"))
.with_checked(playback_divider <= 1),
.with_checked(state.playback_divider <= 1),
MenuItem::new(LOCAL_VIEWER_RES_HALF, tr("viewer.context.res_half"))
.with_checked(playback_divider == 2),
.with_checked(state.playback_divider == 2),
MenuItem::new(LOCAL_VIEWER_RES_QUARTER, tr("viewer.context.res_quarter"))
.with_checked(playback_divider == 4),
.with_checked(state.playback_divider == 4),
MenuItem::new(LOCAL_VIEWER_RES_EIGHTH, tr("viewer.context.res_eighth"))
.with_checked(playback_divider >= 8),
.with_checked(state.playback_divider >= 8),
]);
// Safe margins radio group.
let safe_menu = Menu::new(vec![
MenuItem::new(LOCAL_VIEWER_SAFE_OFF, tr("viewer.context.safe_off")).with_checked(true),
MenuItem::new(LOCAL_VIEWER_SAFE_ON, tr("viewer.context.safe_on")).with_checked(false),
MenuItem::new(LOCAL_VIEWER_SAFE_OFF, tr("viewer.context.safe_off"))
.with_checked(state.safe == SafeMargins::Off),
MenuItem::new(LOCAL_VIEWER_SAFE_ON, tr("viewer.context.safe_on"))
.with_checked(state.safe == SafeMargins::On),
MenuItem::new(LOCAL_VIEWER_SAFE_CUSTOM, tr("viewer.context.safe_custom"))
.with_checked(false),
.with_checked(matches!(state.safe, SafeMargins::Custom(_, _))),
]);
// Audio waveform radio group.
let waveform_menu = Menu::new(vec![
MenuItem::new(LOCAL_VIEWER_WF_AUTOMATIC, tr("viewer.context.wf_automatic"))
.with_checked(true),
MenuItem::new(LOCAL_VIEWER_WF_ONLY, tr("viewer.context.wf_only")).with_checked(false),
MenuItem::new(LOCAL_VIEWER_WF_BOTH, tr("viewer.context.wf_both")).with_checked(false),
.with_checked(state.waveform == WaveformMode::Automatic),
MenuItem::new(LOCAL_VIEWER_WF_ONLY, tr("viewer.context.wf_only"))
.with_checked(state.waveform == WaveformMode::Only),
MenuItem::new(LOCAL_VIEWER_WF_BOTH, tr("viewer.context.wf_both"))
.with_checked(state.waveform == WaveformMode::Both),
]);
Menu::new(vec![
@@ -349,14 +380,89 @@ pub fn viewer_menu(playback_divider: i64) -> Menu {
.with_submenu(resolution_menu),
MenuItem::new(0, tr("viewer.context.safe_margins")).with_submenu(safe_menu).separated(),
MenuItem::new(LOCAL_VIEWER_STOP_ON_LAST, tr("viewer.context.stop_on_last"))
.with_checked(false)
.with_checked(state.stop_on_last)
.separated(),
MenuItem::new(0, tr("viewer.context.audio_waveform")).with_submenu(waveform_menu),
MenuItem::new(LOCAL_VIEWER_SHOW_FPS, tr("viewer.context.show_fps")).with_checked(false),
MenuItem::new(LOCAL_VIEWER_SHOW_FPS, tr("viewer.context.show_fps"))
.with_checked(state.show_fps),
MenuItem::new(LOCAL_VIEWER_SAVE_FRAME, tr("viewer.context.save_frame")).separated(),
])
}
/// A viewer context-menu item the panels apply directly on the viewer widget
/// or the engine (unlike the registry items, which re-emit as
/// [`ContextMenuTriggered`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewerMenuAction {
/// Zoom out to fit the whole frame.
ZoomFit,
/// Zoom to `index` of [`VIEWER_ZOOM_LEVELS`].
ZoomLevel(usize),
/// Request full-screen mode.
FullScreen,
/// Set the playback resolution divider (1/2/4/8).
Resolution(i64),
/// Turn the safe-margin overlay off.
SafeOff,
/// Show the standard safe margins.
SafeOn,
/// Show custom safe margins (the app uses the standard 0.9 × 0.8).
SafeCustom,
/// Toggle the `StopOnLastFrame` config.
StopOnLast,
/// Set the audio-waveform overlay mode.
Waveform(WaveformMode),
/// Toggle the frame-rate overlay.
ShowFps,
/// Save the current frame to a PNG.
SaveFrame,
}
/// Resolve a triggered viewer context-menu item id into the action the
/// panels apply, `None` when the id belongs to another menu.
pub fn viewer_menu_action(item: usize) -> Option<ViewerMenuAction> {
if item == LOCAL_VIEWER_ZOOM_FIT {
Some(ViewerMenuAction::ZoomFit)
} else if item >= LOCAL_VIEWER_ZOOM_LEVELS_BASE
&& item < LOCAL_VIEWER_ZOOM_LEVELS_BASE + VIEWER_ZOOM_LEVELS.len()
{
Some(ViewerMenuAction::ZoomLevel(item - LOCAL_VIEWER_ZOOM_LEVELS_BASE))
} else {
match item {
LOCAL_VIEWER_FULL_SCREEN => Some(ViewerMenuAction::FullScreen),
LOCAL_VIEWER_RES_FULL => Some(ViewerMenuAction::Resolution(1)),
LOCAL_VIEWER_RES_HALF => Some(ViewerMenuAction::Resolution(2)),
LOCAL_VIEWER_RES_QUARTER => Some(ViewerMenuAction::Resolution(4)),
LOCAL_VIEWER_RES_EIGHTH => Some(ViewerMenuAction::Resolution(8)),
LOCAL_VIEWER_SAFE_OFF => Some(ViewerMenuAction::SafeOff),
LOCAL_VIEWER_SAFE_ON => Some(ViewerMenuAction::SafeOn),
LOCAL_VIEWER_SAFE_CUSTOM => Some(ViewerMenuAction::SafeCustom),
LOCAL_VIEWER_STOP_ON_LAST => Some(ViewerMenuAction::StopOnLast),
LOCAL_VIEWER_WF_AUTOMATIC => Some(ViewerMenuAction::Waveform(WaveformMode::Automatic)),
LOCAL_VIEWER_WF_ONLY => Some(ViewerMenuAction::Waveform(WaveformMode::Only)),
LOCAL_VIEWER_WF_BOTH => Some(ViewerMenuAction::Waveform(WaveformMode::Both)),
LOCAL_VIEWER_SHOW_FPS => Some(ViewerMenuAction::ShowFps),
LOCAL_VIEWER_SAVE_FRAME => Some(ViewerMenuAction::SaveFrame),
_ => None,
}
}
}
/// A viewer panel request the app shell applies: full-screen goes to the
/// window, the in/out/clear requests go to the program workarea (both
/// monitors share one workarea, so the source monitor's requests act on it).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewerPanelEvent {
/// Toggle the window's full-screen state.
FullScreenRequested,
/// Set the program workarea's in point at the playhead.
SetInPoint,
/// Set the program workarea's out point at the playhead.
SetOutPoint,
/// Clear the program workarea's in/out range.
ClearRange,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -474,14 +580,14 @@ mod tests {
}
/// The viewer menu carries the zoom levels with percentage labels and
/// defaults each radio group to its first entry.
/// checks Fit / the default radio entries.
#[test]
fn viewer_menu_offers_every_zoom_level() {
// The label lookups below race with tests that flip the process
// language: pin en-US under the shared lock.
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
crate::i18n::set_language_code("en-US");
let menu = viewer_menu(1);
let menu = viewer_menu(&ViewerMenuState::default());
let zoom = menu
.items
.iter()
@@ -490,9 +596,15 @@ mod tests {
let zoom_items = &zoom.submenu.as_ref().unwrap().items;
assert_eq!(zoom_items.len(), 1 + VIEWER_ZOOM_LEVELS.len());
assert_eq!(zoom_items[0].id, LOCAL_VIEWER_ZOOM_FIT);
assert_eq!(zoom_items[0].checked, Some(true), "Fit checked by default");
for (index, level) in VIEWER_ZOOM_LEVELS.iter().enumerate() {
assert_eq!(zoom_items[index + 1].id, viewer_zoom_level_id(index));
assert_eq!(zoom_items[index + 1].label, format!("{:.0}%", level * 100.0));
assert_eq!(
zoom_items[index + 1].checked,
Some(false),
"zoom level {index} unchecked by default"
);
}
}
@@ -504,7 +616,7 @@ mod tests {
// language: pin en-US under the shared lock.
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
crate::i18n::set_language_code("en-US");
let menu = viewer_menu(1);
let menu = viewer_menu(&ViewerMenuState::default());
for label_key in [
"viewer.context.playback_resolution",
"viewer.context.safe_margins",
@@ -530,7 +642,10 @@ mod tests {
// Label lookup: pin en-US under the shared language lock.
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
crate::i18n::set_language_code("en-US");
let menu = viewer_menu(4);
let menu = viewer_menu(&ViewerMenuState {
playback_divider: 4,
..ViewerMenuState::default()
});
let item = menu
.items
.iter()
@@ -542,4 +657,98 @@ mod tests {
assert_eq!(sub[2].checked, Some(true), "quarter checked at /4");
assert_eq!(sub[3].checked, Some(false), "eighth unchecked at /4");
}
/// Every checked entry reflects the live state the panels build.
#[test]
fn viewer_menu_reflects_live_state() {
// Label lookup: pin en-US under the shared language lock.
let _guard = crate::i18n::lang_test_lock().lock().unwrap_or_else(|e| e.into_inner());
crate::i18n::set_language_code("en-US");
let menu = viewer_menu(&ViewerMenuState {
playback_divider: 2,
zoom: ViewerZoom::Level(4),
safe: SafeMargins::On,
stop_on_last: true,
waveform: WaveformMode::Both,
show_fps: true,
});
let find_sub = |label: &'static str| {
menu.items
.iter()
.find(|item| item.label == crate::i18n::tr(label))
.unwrap_or_else(|| panic!("viewer menu missing {label}"))
.submenu
.as_ref()
.unwrap()
.items
.clone()
};
let zoom = find_sub("viewer.context.zoom");
assert_eq!(zoom[0].checked, Some(false), "Fit unchecked at 100%");
assert_eq!(zoom[5].checked, Some(true), "100% checked");
let resolution = find_sub("viewer.context.playback_resolution");
assert_eq!(resolution[1].checked, Some(true), "half checked");
let safe = find_sub("viewer.context.safe_margins");
assert_eq!(safe[1].checked, Some(true), "safe On checked");
let waveform = find_sub("viewer.context.audio_waveform");
assert_eq!(waveform[2].checked, Some(true), "waveform Both checked");
let stop = menu
.items
.iter()
.find(|item| item.label == crate::i18n::tr("viewer.context.stop_on_last"))
.expect("stop-on-last item");
assert_eq!(stop.checked, Some(true));
let fps = menu
.items
.iter()
.find(|item| item.label == crate::i18n::tr("viewer.context.show_fps"))
.expect("show-fps item");
assert_eq!(fps.checked, Some(true));
}
/// Every local viewer id resolves to its action (and non-viewer ids do
/// not).
#[test]
fn viewer_menu_action_parses_every_id() {
use ViewerMenuAction as V;
assert_eq!(viewer_menu_action(LOCAL_VIEWER_ZOOM_FIT), Some(V::ZoomFit));
assert_eq!(
viewer_menu_action(LOCAL_VIEWER_ZOOM_LEVELS_BASE + 4),
Some(V::ZoomLevel(4))
);
assert_eq!(
viewer_menu_action(LOCAL_VIEWER_ZOOM_LEVELS_BASE + VIEWER_ZOOM_LEVELS.len()),
None,
"zoom id past the last level"
);
assert_eq!(viewer_menu_action(LOCAL_VIEWER_FULL_SCREEN), Some(V::FullScreen));
for (id, divider) in [
(LOCAL_VIEWER_RES_FULL, 1),
(LOCAL_VIEWER_RES_HALF, 2),
(LOCAL_VIEWER_RES_QUARTER, 4),
(LOCAL_VIEWER_RES_EIGHTH, 8),
] {
assert_eq!(viewer_menu_action(id), Some(V::Resolution(divider)));
}
assert_eq!(viewer_menu_action(LOCAL_VIEWER_SAFE_OFF), Some(V::SafeOff));
assert_eq!(viewer_menu_action(LOCAL_VIEWER_SAFE_ON), Some(V::SafeOn));
assert_eq!(viewer_menu_action(LOCAL_VIEWER_SAFE_CUSTOM), Some(V::SafeCustom));
assert_eq!(viewer_menu_action(LOCAL_VIEWER_STOP_ON_LAST), Some(V::StopOnLast));
assert_eq!(
viewer_menu_action(LOCAL_VIEWER_WF_AUTOMATIC),
Some(V::Waveform(WaveformMode::Automatic))
);
assert_eq!(
viewer_menu_action(LOCAL_VIEWER_WF_ONLY),
Some(V::Waveform(WaveformMode::Only))
);
assert_eq!(
viewer_menu_action(LOCAL_VIEWER_WF_BOTH),
Some(V::Waveform(WaveformMode::Both))
);
assert_eq!(viewer_menu_action(LOCAL_VIEWER_SHOW_FPS), Some(V::ShowFps));
assert_eq!(viewer_menu_action(LOCAL_VIEWER_SAVE_FRAME), Some(V::SaveFrame));
assert_eq!(viewer_menu_action(0), None, "registry-range id is not local");
assert_eq!(viewer_menu_action(LOCAL_ID_BASE), None, "color-label id");
}
}
+70
View File
@@ -807,6 +807,76 @@ pub trait AppEngine:
let _ = cx;
}
// -------------------------------------------------------------------
// Viewer options (the C++ viewer context menu): stop-on-last-frame,
// the audio-waveform overlay mode and saving the current frame.
// Defaults persist to the app config store, so every engine shares
// them without an implementation.
// -------------------------------------------------------------------
/// Whether playback stops at the last frame instead of looping (the C++
/// viewer `Stop on Last` toggle / `StopOnLastFrame` config).
fn stop_on_last(&self) -> bool {
oak_common::configstore::ConfigStore::instance()
.get_bool(None, "StopOnLastFrame", 0)
!= 0
}
/// Sets the `StopOnLastFrame` config (the next tick past the end either
/// pauses at the last frame or wraps around).
fn set_stop_on_last(&mut self, enabled: bool, cx: &mut Context<Self>) {
oak_common::configstore::ConfigStore::instance().set(
None,
"StopOnLastFrame",
if enabled { "true" } else { "false" },
);
let _ = cx;
}
/// The audio-waveform overlay mode as the `ViewerWaveformMode` config
/// value (`0` automatic / `1` only / `2` both), clamped to the valid
/// range.
fn waveform_mode(&self) -> i32 {
oak_common::configstore::ConfigStore::instance()
.get_int(None, "ViewerWaveformMode", 0)
.clamp(0, 2)
}
/// Sets the `ViewerWaveformMode` config value (clamped to `0..=2`).
fn set_waveform_mode(&mut self, mode: i32, cx: &mut Context<Self>) {
oak_common::configstore::ConfigStore::instance().set(
None,
"ViewerWaveformMode",
&mode.clamp(0, 2).to_string(),
);
let _ = cx;
}
/// Saves `monitor`'s current CPU frame as a BGRA PNG at `path` and
/// returns the path on success. The viewer menu's `Save Frame` entry
/// falls back to this for engines without a dedicated capture path.
fn save_frame(
&self,
monitor: Monitor,
path: PathBuf,
cx: &App,
) -> Result<PathBuf, String> {
let image = self.cpu_frame(monitor, cx);
let size = image.size(0);
let bytes = image
.as_bytes(0)
.ok_or_else(|| "empty frame: no image data".to_string())?;
image::save_buffer(
&path,
bytes,
size.width.0 as u32,
size.height.0 as u32,
image::ExtendedColorType::Bgra8,
)
.map_err(|e| format!("save frame failed: {e}"))?;
Ok(path)
}
// -------------------------------------------------------------------
// Project properties (the C++ File > Project Properties dialog):
// the per-project OCIO config override and the disk-cache location.
+44 -3
View File
@@ -161,8 +161,9 @@ impl MockClock {
}
/// Advances the playhead from the wall clock while playing, looping at
/// `length`. No-op when stopped.
pub fn tick(&mut self, length: Frame) {
/// `length` — or pausing on the last frame when `stop_on_last` is set.
/// No-op when stopped.
pub fn tick(&mut self, length: Frame, stop_on_last: bool) {
let Some((started, anchored)) = self.started else {
return;
};
@@ -173,6 +174,15 @@ impl MockClock {
as i64,
);
if length.0 > 0 && frame.0 >= length.0 {
if stop_on_last {
// Stop on the last frame and pause playback (the C++ viewer's
// Stop on Last option): the playhead never wraps.
self.transport.pause();
self.started = None;
frame = Frame(length.0 - 1);
self.transport.seek(frame, length);
return;
}
// Loop back to the start of the sequence for the demo.
frame = Frame(frame.0 % length.0);
}
@@ -1285,10 +1295,11 @@ impl EngineGateway for MockEngine {
fn tick(&mut self, cx: &mut Context<Self>) {
let length = self.sequence_length();
let stop_on_last = self.stop_on_last();
for clock in [&self.source_clock, &self.program_clock] {
let clock = clock.clone();
clock.update(cx, |clock, cx| {
clock.tick(length);
clock.tick(length, stop_on_last);
cx.notify();
});
}
@@ -2703,6 +2714,36 @@ mod tests {
});
}
/// The stop-on-last tick pauses on the final frame instead of wrapping.
#[test]
fn clock_tick_stops_on_the_last_frame_when_asked() {
use std::time::{Duration, Instant};
let mut clock = MockClock::new(FrameRate::new(30, 1));
clock.play();
// 10 s at 30 fps = 300 frames into a 5-frame sequence: wrapped 60×.
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!(!clock.transport.is_playing(), "playback stopped");
// A later tick is a no-op: the anchor is cleared.
clock.tick(Frame(5), true);
assert_eq!(clock.transport.frame(), Frame(4));
}
/// Without stop-on-last the tick wraps modulo the sequence length.
#[test]
fn clock_tick_loops_when_not_stopping() {
use std::time::{Duration, Instant};
let mut clock = MockClock::new(FrameRate::new(30, 1));
clock.play();
clock.started = Some((Instant::now() - Duration::from_secs(10), Frame(0)));
clock.tick(Frame(5), false);
assert_eq!(clock.transport.frame(), Frame(0), "300 % 5 = 0");
assert!(clock.transport.is_playing(), "still playing while looping");
}
#[gpui::test]
async fn effect_stack_edit_applies_to_the_model(cx: &mut TestAppContext) {
cx.update(|app| {
+14 -3
View File
@@ -677,12 +677,13 @@ impl RealClock {
}
/// Advances the playhead from the wall clock while playing, looping at
/// `length`. No-op when stopped. The advance is clamped per tick: a
/// `length` — or pausing on the last frame when `stop_on_last` is set.
/// No-op when stopped. The advance is clamped per tick: a
/// long stall (the first render after pressing play, a disk stall)
/// must not teleport the playhead past the pre-render window — the
/// dropped time is re-anchored away instead (NLEs drop frames during
/// stalls; they never jump the playhead over rendered content).
pub fn tick(&mut self, length: Frame) {
pub fn tick(&mut self, length: Frame, stop_on_last: bool) {
let Some((started, anchored)) = self.started else {
return;
};
@@ -701,6 +702,15 @@ impl RealClock {
self.started = Some((Instant::now(), frame));
}
if length.0 > 0 && frame.0 >= length.0 {
if stop_on_last {
// Stop on the last frame and pause playback (the C++ viewer's
// Stop on Last option): the playhead never wraps.
self.transport.pause();
self.started = None;
frame = Frame(length.0 - 1);
self.transport.seek(frame, length);
return;
}
frame = Frame(frame.0 % length.0);
}
self.transport.seek(frame, length);
@@ -3224,13 +3234,14 @@ impl EngineGateway for RealEngine {
// project's length 0 used to freeze the source playhead at 0).
let length = self.sequence_length();
let source_length = self.source_length();
let stop_on_last = self.stop_on_last();
for (clock, len) in [
(&self.source_clock, source_length),
(&self.program_clock, length),
] {
let clock = clock.clone();
clock.update(cx, |clock, cx| {
clock.tick(len);
clock.tick(len, stop_on_last);
cx.notify();
});
}
+107 -20
View File
@@ -28,7 +28,10 @@ use gpui::{
};
use gpui_widgets::audio_meter::AudioLevelMeter;
use gpui_widgets::scopes::{ChromaDataSource, Histogram, LumaDataSource, Vectorscope, Waveform};
use gpui_widgets::viewer::{InteractPointerKind, PlaybackClock, ViewerEvent, ViewerWidget};
use gpui_widgets::viewer::{
InteractPointerKind, PlaybackClock, SafeMargins, ViewerEvent, ViewerWidget, ViewerZoom,
WaveformMode,
};
use crate::actions::ActionId;
use crate::oakui::component::menu;
@@ -145,6 +148,14 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
ViewerEvent::InteractKey { down, keystroke } => {
this.forward_interact_key(*down, keystroke, cx)
}
// The widget already toggled its own overlay state; nothing to
// forward to the engine.
ViewerEvent::ToggleSafeFramesRequested { .. } | ViewerEvent::ToggleZoomRequested { .. } => {}
// The loop in/out range is the shell-owned program workarea, so
// the panel re-emits the request.
ViewerEvent::InPointRequested { .. } => cx.emit(menu::ViewerPanelEvent::SetInPoint),
ViewerEvent::OutPointRequested { .. } => cx.emit(menu::ViewerPanelEvent::SetOutPoint),
ViewerEvent::ClearRangeRequested { .. } => cx.emit(menu::ViewerPanelEvent::ClearRange),
event => {
let monitor = Monitor::Program;
this.engine.update(cx, |engine, cx| match event {
@@ -205,22 +216,90 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
}
}
/// Handles the viewer's local (non-registry) context-menu items.
/// Handles the viewer's local (non-registry) context-menu items by
/// resolving them into [`menu::ViewerMenuAction`] and applying the action
/// (the id resolution and the menu construction are shared with the source
/// viewer, so both monitors behave identically).
fn on_local_menu_item(&mut self, item: usize, cx: &mut Context<Self>) {
use crate::oakui::component::menu as shared_menu;
let divider = match item {
shared_menu::LOCAL_VIEWER_RES_FULL => Some(1),
shared_menu::LOCAL_VIEWER_RES_HALF => Some(2),
shared_menu::LOCAL_VIEWER_RES_QUARTER => Some(4),
shared_menu::LOCAL_VIEWER_RES_EIGHTH => Some(8),
_ => None,
};
if let Some(divider) = divider {
let engine = self.engine.clone();
engine.update(cx, |engine, cx| engine.set_playback_divider(divider, cx));
let Some(action) = menu::viewer_menu_action(item) else {
println!("[program viewer] context-menu item {item} (not handled)");
return;
};
self.apply_viewer_action(action, cx);
}
/// Applies one viewer context-menu action. Zoom, safe margins and the FPS
/// overlay are viewer-widget state; the resolution divider, stop-on-last
/// and waveform mode are engine config; full-screen and the in/out range
/// requests are re-emitted for the app shell (the workarea is shell-owned).
fn apply_viewer_action(&mut self, action: menu::ViewerMenuAction, cx: &mut Context<Self>) {
match action {
menu::ViewerMenuAction::ZoomFit => {
let zoom = ViewerZoom::Fit;
self.viewer.update(cx, |viewer, cx| viewer.set_zoom(zoom, cx));
}
menu::ViewerMenuAction::ZoomLevel(index) => {
let zoom = ViewerZoom::Level(index);
self.viewer.update(cx, |viewer, cx| viewer.set_zoom(zoom, cx));
}
menu::ViewerMenuAction::Resolution(divider) => self
.engine
.update(cx, |engine, cx| engine.set_playback_divider(divider, cx)),
menu::ViewerMenuAction::SafeOff => {
let margins = SafeMargins::Off;
self.viewer
.update(cx, |viewer, cx| viewer.set_safe_margins(margins, cx));
}
menu::ViewerMenuAction::SafeOn => {
let margins = SafeMargins::On;
self.viewer
.update(cx, |viewer, cx| viewer.set_safe_margins(margins, cx));
}
menu::ViewerMenuAction::SafeCustom => {
let margins = SafeMargins::Custom(0.9, 0.8);
self.viewer
.update(cx, |viewer, cx| viewer.set_safe_margins(margins, cx));
}
menu::ViewerMenuAction::StopOnLast => {
let enabled = self.engine.read(cx).stop_on_last();
self.engine
.update(cx, |engine, cx| engine.set_stop_on_last(!enabled, cx));
}
menu::ViewerMenuAction::Waveform(mode) => self.engine.update(cx, |engine, cx| {
engine.set_waveform_mode(mode.config_value(), cx);
}),
menu::ViewerMenuAction::ShowFps => {
let show = self.viewer.read(cx).show_fps();
self.viewer.update(cx, |viewer, cx| viewer.set_show_fps(!show, cx));
}
menu::ViewerMenuAction::SaveFrame => self.save_frame(cx),
menu::ViewerMenuAction::FullScreen => {
cx.emit(menu::ViewerPanelEvent::FullScreenRequested)
}
}
}
/// Saves the current program-monitor frame to a PNG in `$HOME/Pictures`
/// (falling back to the working directory), named after the frame number
/// and a timestamp. The engine's shared capture path writes the BGRA CPU
/// frame; there is no save dialog yet.
fn save_frame(&mut self, cx: &mut Context<Self>) {
let frame = self.engine.read(cx).clock_frame(Monitor::Program, cx).0;
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
let name = format!("oak-frame-program-{frame}-{ts}.png");
let dir = std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.map(|home| home.join("Pictures"))
.filter(|dir| std::fs::create_dir_all(dir).is_ok())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let path = dir.join(name);
match self.engine.read(cx).save_frame(Monitor::Program, path.clone(), cx) {
Ok(path) => println!("[program viewer] saved frame to {}", path.display()),
Err(error) => println!("[program viewer] save frame failed: {error}"),
}
println!("[program viewer] context-menu item {item} (not implemented yet)");
}
/// Routes a transport command to the engine's program monitor through
@@ -562,12 +641,18 @@ impl<E: AppEngine> Render for ProgramViewerPanel<E> {
// the panel opens the shared viewer menu here.
.on_mouse_down(MouseButton::Right, {
cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| {
let divider = this.engine.read(cx).playback_divider();
this.context_menu.show(
event.position,
menu::viewer_menu(divider),
cx,
);
let state = menu::ViewerMenuState {
playback_divider: this.engine.read(cx).playback_divider(),
zoom: this.viewer.read(cx).zoom(),
safe: this.viewer.read(cx).safe_margins(),
stop_on_last: this.engine.read(cx).stop_on_last(),
waveform: WaveformMode::from_config_value(
this.engine.read(cx).waveform_mode(),
),
show_fps: this.viewer.read(cx).show_fps(),
};
this.context_menu
.show(event.position, menu::viewer_menu(&state), cx);
})
})
.child(
@@ -644,6 +729,8 @@ impl<E: AppEngine> PanelCommandHandler for ProgramViewerPanel<E> {
impl<E: AppEngine> EventEmitter<PanelEvent> for ProgramViewerPanel<E> {}
impl<E: AppEngine> EventEmitter<menu::ViewerPanelEvent> for ProgramViewerPanel<E> {}
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for ProgramViewerPanel<E> {}
impl<E: AppEngine> DockPanel for ProgramViewerPanel<E> {
+120 -33
View File
@@ -23,7 +23,9 @@ use gpui::{
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, MouseButton, Render,
SharedString, Window,
};
use gpui_widgets::viewer::{ViewerEvent, ViewerWidget};
use gpui_widgets::viewer::{
SafeMargins, ViewerEvent, ViewerWidget, ViewerZoom, WaveformMode,
};
use crate::actions::ActionId;
use crate::oakui::component::menu;
@@ -57,19 +59,28 @@ impl<E: AppEngine> SourceViewerPanel<E> {
cx: &mut Context<Self>,
) -> Self {
let viewer = cx.new(|cx| ViewerWidget::new(2, clock.clone(), window, cx));
// Route every transport request to the engine's source monitor.
cx.subscribe(&viewer, |this, _viewer, event: &ViewerEvent, cx| {
let monitor = Monitor::Source;
this.engine.update(cx, |engine, cx| match event {
ViewerEvent::PlayRequested { .. } => engine.play(monitor, cx),
ViewerEvent::PauseRequested { .. } => engine.pause(monitor, cx),
ViewerEvent::StepRequested { delta, .. } => engine.step(monitor, *delta, cx),
// The source monitor hosts no OFX interact: the picture's
// pointer/key events are not forwarded here (the program
// viewer's panel forwards them when an interact is live).
ViewerEvent::InteractPointer { .. } | ViewerEvent::InteractKey { .. } => {}
other => println!("[source viewer] request: {other:?}"),
});
// Route every transport request to the engine's source monitor, and
// re-emit the loop-range / overlay requests for the app shell.
cx.subscribe(&viewer, |this, _viewer, event: &ViewerEvent, cx| match event {
// The source monitor hosts no OFX interact: the picture's
// pointer/key events are not forwarded here (the program
// viewer's panel forwards them when an interact is live).
ViewerEvent::InteractPointer { .. } | ViewerEvent::InteractKey { .. } => {}
// The widget already toggled its own overlay state.
ViewerEvent::ToggleSafeFramesRequested { .. } | ViewerEvent::ToggleZoomRequested { .. } => {}
// Both monitors share the shell-owned program workarea.
ViewerEvent::InPointRequested { .. } => cx.emit(menu::ViewerPanelEvent::SetInPoint),
ViewerEvent::OutPointRequested { .. } => cx.emit(menu::ViewerPanelEvent::SetOutPoint),
ViewerEvent::ClearRangeRequested { .. } => cx.emit(menu::ViewerPanelEvent::ClearRange),
event => {
let monitor = Monitor::Source;
this.engine.update(cx, |engine, cx| match event {
ViewerEvent::PlayRequested { .. } => engine.play(monitor, cx),
ViewerEvent::PauseRequested { .. } => engine.pause(monitor, cx),
ViewerEvent::StepRequested { delta, .. } => engine.step(monitor, *delta, cx),
other => println!("[source viewer] request: {other:?}"),
});
}
})
.detach();
@@ -85,22 +96,90 @@ impl<E: AppEngine> SourceViewerPanel<E> {
}
}
/// Handles the viewer's local (non-registry) context-menu items.
/// Handles the viewer's local (non-registry) context-menu items by
/// resolving them into [`menu::ViewerMenuAction`] and applying the action
/// (the id resolution and the menu construction are shared with the program
/// viewer, so both monitors behave identically).
fn on_local_menu_item(&mut self, item: usize, cx: &mut Context<Self>) {
use crate::oakui::component::menu as shared_menu;
let divider = match item {
shared_menu::LOCAL_VIEWER_RES_FULL => Some(1),
shared_menu::LOCAL_VIEWER_RES_HALF => Some(2),
shared_menu::LOCAL_VIEWER_RES_QUARTER => Some(4),
shared_menu::LOCAL_VIEWER_RES_EIGHTH => Some(8),
_ => None,
};
if let Some(divider) = divider {
let engine = self.engine.clone();
engine.update(cx, |engine, cx| engine.set_playback_divider(divider, cx));
let Some(action) = menu::viewer_menu_action(item) else {
println!("[source viewer] context-menu item {item} (not handled)");
return;
};
self.apply_viewer_action(action, cx);
}
/// Applies one viewer context-menu action. Zoom, safe margins and the FPS
/// overlay are viewer-widget state; the resolution divider, stop-on-last
/// and waveform mode are engine config; full-screen and the in/out range
/// requests are re-emitted for the app shell (the workarea is shell-owned).
fn apply_viewer_action(&mut self, action: menu::ViewerMenuAction, cx: &mut Context<Self>) {
match action {
menu::ViewerMenuAction::ZoomFit => {
let zoom = ViewerZoom::Fit;
self.viewer.update(cx, |viewer, cx| viewer.set_zoom(zoom, cx));
}
menu::ViewerMenuAction::ZoomLevel(index) => {
let zoom = ViewerZoom::Level(index);
self.viewer.update(cx, |viewer, cx| viewer.set_zoom(zoom, cx));
}
menu::ViewerMenuAction::Resolution(divider) => self
.engine
.update(cx, |engine, cx| engine.set_playback_divider(divider, cx)),
menu::ViewerMenuAction::SafeOff => {
let margins = SafeMargins::Off;
self.viewer
.update(cx, |viewer, cx| viewer.set_safe_margins(margins, cx));
}
menu::ViewerMenuAction::SafeOn => {
let margins = SafeMargins::On;
self.viewer
.update(cx, |viewer, cx| viewer.set_safe_margins(margins, cx));
}
menu::ViewerMenuAction::SafeCustom => {
let margins = SafeMargins::Custom(0.9, 0.8);
self.viewer
.update(cx, |viewer, cx| viewer.set_safe_margins(margins, cx));
}
menu::ViewerMenuAction::StopOnLast => {
let enabled = self.engine.read(cx).stop_on_last();
self.engine
.update(cx, |engine, cx| engine.set_stop_on_last(!enabled, cx));
}
menu::ViewerMenuAction::Waveform(mode) => self.engine.update(cx, |engine, cx| {
engine.set_waveform_mode(mode.config_value(), cx);
}),
menu::ViewerMenuAction::ShowFps => {
let show = self.viewer.read(cx).show_fps();
self.viewer.update(cx, |viewer, cx| viewer.set_show_fps(!show, cx));
}
menu::ViewerMenuAction::SaveFrame => self.save_frame(cx),
menu::ViewerMenuAction::FullScreen => {
cx.emit(menu::ViewerPanelEvent::FullScreenRequested)
}
}
}
/// Saves the current source-monitor frame to a PNG in `$HOME/Pictures`
/// (falling back to the working directory), named after the frame number
/// and a timestamp. The engine's shared capture path writes the BGRA CPU
/// frame; there is no save dialog yet.
fn save_frame(&mut self, cx: &mut Context<Self>) {
let frame = self.engine.read(cx).clock_frame(Monitor::Source, cx).0;
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0);
let name = format!("oak-frame-source-{frame}-{ts}.png");
let dir = std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.map(|home| home.join("Pictures"))
.filter(|dir| std::fs::create_dir_all(dir).is_ok())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let path = dir.join(name);
match self.engine.read(cx).save_frame(Monitor::Source, path.clone(), cx) {
Ok(path) => println!("[source viewer] saved frame to {}", path.display()),
Err(error) => println!("[source viewer] save frame failed: {error}"),
}
println!("[source viewer] context-menu item {item} (not implemented yet)");
}
/// Routes a transport command to the engine's source monitor through
@@ -159,12 +238,18 @@ impl<E: AppEngine> Render for SourceViewerPanel<E> {
// the panel opens the shared viewer menu here.
.on_mouse_down(MouseButton::Right, {
cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| {
let divider = this.engine.read(cx).playback_divider();
this.context_menu.show(
event.position,
menu::viewer_menu(divider),
cx,
);
let state = menu::ViewerMenuState {
playback_divider: this.engine.read(cx).playback_divider(),
zoom: this.viewer.read(cx).zoom(),
safe: this.viewer.read(cx).safe_margins(),
stop_on_last: this.engine.read(cx).stop_on_last(),
waveform: WaveformMode::from_config_value(
this.engine.read(cx).waveform_mode(),
),
show_fps: this.viewer.read(cx).show_fps(),
};
this.context_menu
.show(event.position, menu::viewer_menu(&state), cx);
})
})
.child(
@@ -234,6 +319,8 @@ impl<E: AppEngine> PanelCommandHandler for SourceViewerPanel<E> {
impl<E: AppEngine> EventEmitter<PanelEvent> for SourceViewerPanel<E> {}
impl<E: AppEngine> EventEmitter<menu::ViewerPanelEvent> for SourceViewerPanel<E> {}
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for SourceViewerPanel<E> {}
impl<E: AppEngine> DockPanel for SourceViewerPanel<E> {