app: make the viewer context menu real
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:
@@ -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>) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user