diff --git a/crates/oak-app/src/app.rs b/crates/oak-app/src/app.rs index 2cadd894b..45592f8c8 100644 --- a/crates/oak-app/src/app.rs +++ b/crates/oak-app/src/app.rs @@ -523,6 +523,25 @@ impl OakApp { ) .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 OakApp { 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 OakApp { 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) { + 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) { + 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) { diff --git a/crates/oak-app/src/oakui/component/menu.rs b/crates/oak-app/src/oakui/component/menu.rs index a71a060c6..664b4cdec 100644 --- a/crates/oak-app/src/oakui/component/menu.rs +++ b/crates/oak-app/src/oakui/component/menu.rs @@ -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 { + 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"); + } } diff --git a/crates/oak-app/src/oakui/engine.rs b/crates/oak-app/src/oakui/engine.rs index ca86d22da..7b75f8be4 100644 --- a/crates/oak-app/src/oakui/engine.rs +++ b/crates/oak-app/src/oakui/engine.rs @@ -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) { + 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) { + 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 { + 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. diff --git a/crates/oak-app/src/oakui/mock.rs b/crates/oak-app/src/oakui/mock.rs index 2bcb0c7e0..5638d365f 100644 --- a/crates/oak-app/src/oakui/mock.rs +++ b/crates/oak-app/src/oakui/mock.rs @@ -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) { 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| { diff --git a/crates/oak-app/src/oakui/real.rs b/crates/oak-app/src/oakui/real.rs index 96b7dd418..c37cb30cb 100644 --- a/crates/oak-app/src/oakui/real.rs +++ b/crates/oak-app/src/oakui/real.rs @@ -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(); }); } diff --git a/crates/oak-app/src/panels/program_viewer.rs b/crates/oak-app/src/panels/program_viewer.rs index a4b3c30d5..688d027bd 100644 --- a/crates/oak-app/src/panels/program_viewer.rs +++ b/crates/oak-app/src/panels/program_viewer.rs @@ -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 ProgramViewerPanel { 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 ProgramViewerPanel { } } - /// 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) { - 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) { + 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) { + 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 Render for ProgramViewerPanel { // 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 PanelCommandHandler for ProgramViewerPanel { impl EventEmitter for ProgramViewerPanel {} +impl EventEmitter for ProgramViewerPanel {} + impl EventEmitter for ProgramViewerPanel {} impl DockPanel for ProgramViewerPanel { diff --git a/crates/oak-app/src/panels/source_viewer.rs b/crates/oak-app/src/panels/source_viewer.rs index c01c049d2..dee8365e7 100644 --- a/crates/oak-app/src/panels/source_viewer.rs +++ b/crates/oak-app/src/panels/source_viewer.rs @@ -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 SourceViewerPanel { cx: &mut Context, ) -> 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 SourceViewerPanel { } } - /// 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) { - 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) { + 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) { + 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 Render for SourceViewerPanel { // 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 PanelCommandHandler for SourceViewerPanel { impl EventEmitter for SourceViewerPanel {} +impl EventEmitter for SourceViewerPanel {} + impl EventEmitter for SourceViewerPanel {} impl DockPanel for SourceViewerPanel { diff --git a/gpui b/gpui index 50f78b57c..fb8d726fa 160000 --- a/gpui +++ b/gpui @@ -1 +1 @@ -Subproject commit 50f78b57c8c80949c92a9acfe06c096477e798ee +Subproject commit fb8d726fa386530121c914c7aff78d6f23e83795