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
+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> {