Files
oak-editor/crates/oak-app/src/panels/program_viewer.rs
T
Mike-Solar 54945e39af app: pick colors from the program viewer frame (PS-style eyedropper)
Replaces the project-thumbnail picker: the color popup's eyedropper
button arms the program viewer, the next click on the picture samples
that pixel (contain-fit mapped, BGRA from the current CPU frame) into
the picker's draft; Esc or the button cancels. Coordinated through a
global eyedropper mailbox on the engine.
2026-08-27 18:32:34 +08:00

833 lines
28 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The program viewer panel (序列查看器): the `ViewerWidget` over the
//! program monitor's clock, with a 26px audio level strip attached to its
//! right edge (the design's WP6 layout). A header tab row switches the body
//! between the picture and the scopes (histogram / waveform / vectorscope),
//! whose samples come from the same rendered frame the picture shows.
use gpui::colors::DefaultColors;
use gpui::dock::{DockPanel, PanelEvent};
use gpui::{
div, prelude::*, px, size, AnyElement, App, ClickEvent, Context, Entity, EventEmitter,
MouseButton, Point, Render, SharedString, Window,
};
use gpui_widgets::audio_meter::AudioLevelMeter;
use gpui_widgets::scopes::{ChromaDataSource, Histogram, LumaDataSource, Vectorscope, Waveform};
use gpui_widgets::viewer::{
InteractPointerKind, PlaybackClock, SafeMargins, ViewerEvent, ViewerWidget, ViewerZoom,
WaveformMode,
};
use crate::actions::ActionId;
use crate::oakui::component::menu;
use crate::oakui::component::menu::{ContextMenuHandle, ContextMenuTriggered};
use crate::oakui::ofx::InteractViewport;
use crate::oakui::timecode::{format_fps, format_resolution};
use crate::oakui::{AppEngine, Monitor};
use crate::panels::commands::{self as panel_commands, PanelCommandHandler};
use crate::panels::ids::PROGRAM_VIEWER;
use crate::panels::{chip, viewer_title};
/// Width of the audio level strip, per the design (26px).
const METER_WIDTH: f32 = 26.0;
/// The body tab of the program viewer: the picture or the scopes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProgramViewTab {
/// The rendered picture (the viewer widget plus the level strip).
Picture,
/// The scopes (histogram / waveform / vectorscope).
Scopes,
}
/// The program monitor's scope samples, refreshed from the engine whenever
/// the displayed frame changes. The scope widgets read this entity through
/// the [`LumaDataSource`] / [`ChromaDataSource`] traits.
struct ScopeState {
/// Per-pixel luma of the current frame (`0..=1`).
luma: Vec<f32>,
/// Per-pixel chroma `(Cb, Cr)` of the current frame (`0..=1`).
chroma: Vec<(f32, f32)>,
}
impl LumaDataSource for ScopeState {
fn luma_samples(&self) -> Vec<f32> {
self.luma.clone()
}
}
impl ChromaDataSource for ScopeState {
fn chroma_samples(&self) -> Vec<(f32, f32)> {
self.chroma.clone()
}
}
/// The cached overlay composite the viewer displays while an OFX interact
/// is active (redrawn only when the frame/time/viewport changed or the
/// plugin requested a repaint).
struct OverlayComposite {
/// The base frame the composite was built from (Arc identity compare).
frame: std::sync::Arc<gpui::RenderImage>,
/// The plugin instance the overlay came from.
instance: u64,
/// The viewport the overlay was drawn at.
viewport: InteractViewport,
/// The playhead seconds used for the draw.
time: f64,
/// The composited image shown in the viewer.
image: std::sync::Arc<gpui::RenderImage>,
}
/// The program viewer panel.
pub struct ProgramViewerPanel<E: AppEngine> {
viewer: Entity<ViewerWidget<E::Clock>>,
meter: Entity<AudioLevelMeter<E>>,
engine: Entity<E>,
/// The program monitor's clock (also owned by the viewer widget; kept
/// here so transport commands can read the playing state).
clock: Entity<E::Clock>,
/// The last CPU frame handed to the viewer (compared by `Arc` identity so
/// a paused playhead does not re-upload the picture every frame).
last_cpu_frame: Option<std::sync::Arc<gpui::RenderImage>>,
/// The last base frame the scope samples were analyzed from (the scopes
/// follow the raw frame, not the overlay composite).
scope_frame: Option<std::sync::Arc<gpui::RenderImage>>,
/// The cached overlay composite (active OFX interact only); `None` when
/// the viewer shows the raw engine frame.
overlay: Option<OverlayComposite>,
/// The active body tab.
tab: ProgramViewTab,
/// The scope samples backing the three scope widgets.
scope_state: Entity<ScopeState>,
/// The histogram scope.
histogram: Entity<Histogram<ScopeState>>,
/// The waveform scope.
waveform: Entity<Waveform<ScopeState>>,
/// The vectorscope.
vectorscope: Entity<Vectorscope<ScopeState>>,
/// The right-click context menu.
context_menu: ContextMenuHandle,
}
impl<E: AppEngine> ProgramViewerPanel<E> {
/// Builds a viewer over `clock` (the program monitor's clock) with the
/// level meter `meter` (updated on the app's tick timer).
pub fn new(
engine: Entity<E>,
clock: Entity<E::Clock>,
meter: Entity<AudioLevelMeter<E>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let viewer = cx.new(|cx| ViewerWidget::new(3, clock.clone(), window, cx));
// Route every transport request to the engine's program monitor, and
// forward the picture's pointer/key events to the active OFX interact
// (no-op when none is live).
cx.subscribe(&viewer, |this, _viewer, event: &ViewerEvent, cx| match event {
ViewerEvent::InteractPointer {
kind,
position,
button,
pressed,
} => this.forward_interact_pointer(*kind, *position, *button, *pressed, cx),
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),
// The OFX color picker armed the eyedropper and the user clicked
// the frame: hand the sampled colour back to the engine's mailbox.
ViewerEvent::EyedropperPick { color } => {
this.engine
.update(cx, |engine, cx| engine.eyedropper_picked(*color, cx))
}
event => {
let monitor = Monitor::Program;
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!("[program viewer] request: {other:?}"),
});
}
})
.detach();
// Re-render whenever the engine's global eyedropper arm state flips,
// so `sync_frame` mirrors it into the viewer widget.
cx.observe(&engine, |_this, _engine, cx| cx.notify()).detach();
// A throttled idle pump for the OFX interact: the plugin's UI work
// loop is served on the viewer's own timer (the app tick is
// shell-owned), so an active interact's `idle` action runs without
// coupling to the shell.
let this = cx.weak_entity();
window
.spawn(cx, async move |cx: &mut gpui::AsyncWindowContext| loop {
cx.background_executor()
.timer(std::time::Duration::from_millis(50))
.await;
let _ = cx.update(|_window, app| {
if let Some(this) = this.upgrade() {
this.update(app, |_this, _cx| {
crate::oakui::ofx::pump_interact_idle();
});
}
});
})
.detach();
let context_menu =
ContextMenuHandle::new(Self::on_local_menu_item, window, cx);
let scope_state = cx.new(|_cx| ScopeState {
luma: Vec::new(),
chroma: Vec::new(),
});
let histogram = cx.new(|cx| Histogram::new(41, scope_state.clone(), window, cx));
let waveform = cx.new(|cx| Waveform::new(42, scope_state.clone(), window, cx));
let vectorscope = cx.new(|cx| Vectorscope::new(43, scope_state.clone(), window, cx));
Self {
viewer,
meter,
engine,
clock,
last_cpu_frame: None,
scope_frame: None,
overlay: None,
tab: ProgramViewTab::Picture,
scope_state,
histogram,
waveform,
vectorscope,
context_menu,
}
}
/// 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>) {
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}"),
}
}
/// Routes a transport command to the engine's program monitor through
/// the shared viewer transport helper.
fn transport(&mut self, action: ActionId, cx: &mut Context<Self>) -> bool {
let engine = self.engine.clone();
let clock = self.clock.clone();
panel_commands::viewer_transport(&engine, &clock, Monitor::Program, action, cx)
}
/// Pushes the engine's current frame into the viewer and the scopes, but
/// only when it actually changed (the engine caches one image per
/// playhead frame, with the scope samples analyzed in the same pass).
///
/// When an OFX interact is live, the displayed picture is the engine
/// frame with the interact's GL-drawn overlay composited over it; the
/// composite is cached and only redrawn when the frame / playhead /
/// viewport changed or the plugin requested a repaint (so a paused
/// viewer stays inert).
fn sync_frame(&mut self, cx: &mut Context<Self>) {
// Mirror the OFX color picker's eyedropper arm state into the viewer
// widget; while armed, clicking the frame samples the pixel under the
// cursor (the setter no-ops when the state is unchanged, so a paused
// viewer stays inert).
let armed = self.engine.read(cx).eyedropper_armed(cx);
self.viewer.update(cx, |viewer, cx| viewer.set_eyedropper_armed(armed, cx));
// Keep the main-process interact in sync with the inspector's
// current selection (creates/destroys the interact as the target
// moves; a no-op while it is unchanged).
let target = self.engine.read(cx).ofx_interact_target(cx);
crate::oakui::ofx::sync_active_interact(target);
let frame = self.engine.read(cx).cpu_frame(Monitor::Program, cx);
// The scopes follow the *base* frame (the overlay is not part of the
// analysed picture).
if self
.scope_frame
.as_ref()
.is_none_or(|last| !std::sync::Arc::ptr_eq(last, &frame))
{
self.scope_frame = Some(frame.clone());
let scope = self.engine.read(cx).scope_data(Monitor::Program, cx);
self.scope_state.update(cx, |state, cx| {
state.luma = (*scope.luma).clone();
state.chroma = (*scope.chroma).clone();
cx.notify();
});
}
// The picture actually shown: the raw frame, or the overlay
// composite while an interact is live.
let displayed = self.displayed_frame(&frame, cx);
if self
.last_cpu_frame
.as_ref()
.is_none_or(|last| !std::sync::Arc::ptr_eq(last, &displayed))
{
self.last_cpu_frame = Some(displayed.clone());
let displayed = displayed.clone();
if std::env::var_os("OAK_DEBUG_VIEWER").is_some() {
let sz = displayed.size(0);
eprintln!("[viewer] push frame {}x{}", sz.width.0, sz.height.0);
// Dump the displayed pixels (BGRA -> PPM) for black-frame
// debugging: what the viewer RECEIVES, before the GPU path.
if let Some(bytes) = displayed.as_bytes(0) {
let (w, h) = (sz.width.0 as usize, sz.height.0 as usize);
let mut ppm = format!("P6\n{w} {h}\n255\n").into_bytes();
for px in bytes[..w * h * 4].chunks_exact(4) {
ppm.extend_from_slice(&[px[2], px[1], px[0]]);
}
let _ = std::fs::write("/tmp/oak_viewer_frame.ppm", ppm);
}
}
self.viewer
.update(cx, |viewer, cx| viewer.set_cpu_frame(Some(displayed), cx));
}
}
/// The picture for the current engine `frame`: the frame itself, or —
/// when an interact is live — the cached overlay composite, redrawing it
/// when any redraw condition changed.
fn displayed_frame(
&mut self,
frame: &std::sync::Arc<gpui::RenderImage>,
cx: &mut Context<Self>,
) -> std::sync::Arc<gpui::RenderImage> {
use std::sync::atomic::Ordering;
let Some((instance, interact, _)) = crate::oakui::ofx::active_interact() else {
self.overlay = None;
return frame.clone();
};
let frame_size = frame.size(0);
let viewport = InteractViewport::at_frame_size(
frame_size.width.0 as u32,
frame_size.height.0 as u32,
);
let time = self.playhead_seconds(cx);
// Redraw when the base frame, the target instance, the playhead, or
// the viewport changed, or when the plugin asked for a repaint via
// interactRedraw / interactSwapBuffers (polled and cleared here —
// outside the cache predicate so a redraw request during a cache-miss
// frame is not replayed).
let plugin_redraw = interact.redraw_requested.swap(false, Ordering::Relaxed)
|| interact.swap_requested.swap(false, Ordering::Relaxed);
let stale = plugin_redraw
|| self.overlay.as_ref().is_none_or(|o| {
!std::sync::Arc::ptr_eq(&o.frame, frame)
|| o.instance != instance
|| o.time != time
|| o.viewport != viewport
});
if stale {
if let Some(image) = crate::oakui::ofx::draw_interact_composite(
instance,
&interact,
&viewport,
time,
frame,
) {
self.overlay = Some(OverlayComposite {
frame: frame.clone(),
instance,
viewport,
time,
image: image.clone(),
});
return image;
}
// The interact is inert (no GL / the plugin failed to draw):
// show the base frame. The next frame re-probes (cheap when GL
// is unavailable).
self.overlay = None;
return frame.clone();
}
self.overlay.as_ref().unwrap().image.clone()
}
/// The program monitor's playhead in seconds (the interact's draw/pen
/// `time`).
fn playhead_seconds(&self, cx: &App) -> f64 {
let clock = self.clock.read(cx);
gpui::timeline::frame_to_seconds(clock.current_frame(), clock.frame_rate())
}
/// Forwards a picture pointer event to the active OFX interact, mapping
/// the picture-local position into the interact's viewport pixels (the
/// frame's pixel grid). Only the primary (left) button drives
/// pen_down/pen_up (the OFX pen is a two-state device); moves forward
/// pen_motion with the pen-down state reflecting whether a button is
/// held. No-op without an active interact or outside the frame rect.
fn forward_interact_pointer(
&mut self,
kind: InteractPointerKind,
position: Point<f32>,
button: Option<MouseButton>,
pressed: bool,
cx: &mut Context<Self>,
) {
let Some((_, interact, _)) = crate::oakui::ofx::active_interact() else {
return;
};
let Some(bounds) = self.viewer.read(cx).picture_bounds() else {
return;
};
let area = size(f32::from(bounds.size.width), f32::from(bounds.size.height));
let frame_size = self
.engine
.read(cx)
.cpu_frame(Monitor::Program, cx)
.size(0);
let frame = size(frame_size.width.0 as f32, frame_size.height.0 as f32);
let Some((px, py)) = crate::oakui::ofx::viewport_pixel_to_pen(position, area, frame)
else {
// The pointer is in the letterbox (outside the frame rect).
return;
};
let time = self.playhead_seconds(cx);
match kind {
InteractPointerKind::Move => {
let _ = interact.pen_motion((px, py), pressed, time);
}
InteractPointerKind::Down if button == Some(MouseButton::Left) => {
let _ = interact.pen_down((px, py), time);
}
InteractPointerKind::Up if button == Some(MouseButton::Left) => {
let _ = interact.pen_up((px, py), time);
}
_ => {}
}
}
/// Forwards a key event to the active OFX interact. No-op without an
/// active interact.
///
/// # Consumption semantics
///
/// gpui dispatches the global keybindings *before* the focused element's
/// key handlers, so a key consumed by the app's action system (space =
/// play/pause, J/K/L = shuttle, …) never reaches the picture's key
/// handler and therefore never reaches the interact — the existing
/// shortcut system keeps priority. A key that reaches here was not
/// consumed by any binding. The plugin's return status is deliberately
/// not acted on: the app does not steal key repeat or other listeners,
/// so the interact is a passive consumer of otherwise-unused keys.
fn forward_interact_key(&mut self, down: bool, keystroke: &gpui::Keystroke, cx: &mut Context<Self>) {
let Some((_, interact, _)) = crate::oakui::ofx::active_interact() else {
return;
};
let (sym, key_string) = crate::oakui::ofx::key_symbol(keystroke);
let time = self.playhead_seconds(cx);
if down {
let _ = interact.key_down(sym, &key_string, time);
} else {
let _ = interact.key_up(sym, &key_string, time);
}
}
/// One header tab button (picture / scopes), highlighted when active.
fn tab_button(
&self,
id: &'static str,
label: &'static str,
tab: ProgramViewTab,
colors: &gpui::colors::Colors,
cx: &mut Context<Self>,
) -> impl IntoElement {
let active = self.tab == tab;
div()
.id(id)
.px_2()
.py_1()
.rounded_sm()
.border_1()
.border_color(colors.border)
.bg(if active {
colors.selected
} else {
colors.container
})
.text_color(colors.text)
.cursor_pointer()
.child(label)
.on_click(cx.listener(move |this, _event: &ClickEvent, _window, cx| {
this.tab = tab;
cx.notify();
}))
}
}
impl<E: AppEngine> Render for ProgramViewerPanel<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.sync_frame(cx);
let colors = cx.default_colors().clone();
let format = self
.engine
.read(cx)
.current_sequence()
.map(|sequence| sequence.format)
.unwrap_or(crate::oakui::VideoFormat::hd_1080p25());
let sequence_name = self
.engine
.read(cx)
.current_sequence()
.map(|sequence| sequence.name.clone())
.unwrap_or_default();
let title = viewer_title("panel.program_viewer", &sequence_name);
let body = match self.tab {
ProgramViewTab::Picture => div()
.flex_1()
.flex()
.min_h_0()
.min_w_0()
.child(
div()
.flex_1()
.min_w_0()
.min_h_0()
.overflow_hidden()
.child(self.viewer.clone()),
)
.child(
div()
.w(px(METER_WIDTH))
.flex_shrink_0()
.border_l_1()
.border_color(colors.border)
.child(self.meter.clone()),
),
ProgramViewTab::Scopes => {
let cell = |label: &'static str, scope: AnyElement| {
div()
.flex_1()
.flex()
.flex_col()
.min_w_0()
.child(
div()
.px_2()
.py_1()
.text_xs()
.text_color(colors.disabled)
.child(label),
)
.child(div().flex_1().min_h_0().child(scope))
};
div()
.flex_1()
.flex()
.min_h_0()
.child(cell(
crate::i18n::tr("scope.histogram"),
self.histogram.clone().into_any_element(),
))
.child(cell(
crate::i18n::tr("scope.waveform"),
self.waveform.clone().into_any_element(),
))
.child(cell(
crate::i18n::tr("scope.vectorscope"),
self.vectorscope.clone().into_any_element(),
))
}
};
div()
.size_full()
.flex()
.flex_col()
.overflow_hidden()
// Any click inside the panel makes it the focused panel (the
// dock re-emits this as `DockEvent::PanelFocused`, which the
// shell uses to route focused-panel commands).
.on_mouse_down(MouseButton::Left, {
cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| {
cx.emit(PanelEvent::Focused);
})
})
// The viewer widget has no right-click handling of its own, so
// the panel opens the shared viewer menu here.
.on_mouse_down(MouseButton::Right, {
cx.listener(|this, event: &gpui::MouseDownEvent, _window, 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(
div()
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.border_b_1()
.border_color(colors.border)
.child(chip(&colors, title))
.child(chip(
&colors,
format_resolution(format.width, format.height),
))
.child(chip(&colors, format_fps(format.rate)))
.child(self.tab_button(
"program-tab-picture",
crate::i18n::tr("viewer.picture"),
ProgramViewTab::Picture,
&colors,
cx,
))
.child(self.tab_button(
"program-tab-scopes",
crate::i18n::tr("viewer.scopes"),
ProgramViewTab::Scopes,
&colors,
cx,
)),
)
.child(body)
// The right-click popup renders anchored above the panel.
.child(self.context_menu.widget())
}
}
impl<E: AppEngine> PanelCommandHandler for ProgramViewerPanel<E> {
fn play_pause(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::PlayPause, cx)
}
fn prev_frame(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::PrevFrame, cx)
}
fn next_frame(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::NextFrame, cx)
}
fn go_to_start(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::GoToStart, cx)
}
fn go_to_end(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::GoToEnd, cx)
}
fn play_in_to_out(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::PlayInToOut, cx)
}
fn go_to_in(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::GoToIn, cx)
}
fn go_to_out(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::GoToOut, cx)
}
fn shuttle_left(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::ShuttleLeft, cx)
}
fn shuttle_stop(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::ShuttleStop, cx)
}
fn shuttle_right(&mut self, cx: &mut Context<Self>) -> bool {
self.transport(ActionId::ShuttleRight, cx)
}
}
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> {
fn panel_id(&self) -> gpui::dock::PanelId {
PROGRAM_VIEWER
}
fn title(&self, cx: &App) -> SharedString {
let name = self
.engine
.read(cx)
.current_sequence()
.map(|sequence| sequence.name.clone())
.unwrap_or_default();
viewer_title("panel.program_viewer", &name).into()
}
fn tab_content(&self, cx: &App) -> AnyElement {
let name = self
.engine
.read(cx)
.current_sequence()
.map(|sequence| sequence.name.clone())
.unwrap_or_default();
div()
.child(viewer_title("panel.program_viewer", &name))
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oakui::MockEngine;
use gpui::{size, TestAppContext, VisualTestContext};
/// The scopes tab renders from the mock engine's synthetic frame without
/// crashing, and the scope state carries that frame's samples.
#[gpui::test]
async fn scopes_tab_renders_from_the_current_frame(cx: &mut TestAppContext) {
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(640.0), px(360.0)), |window, cx| {
let engine = cx.new(|cx| MockEngine::demo(cx));
let clock = engine.read(cx).program_clock().clone();
let meter = cx.new(|cx| AudioLevelMeter::new(30, engine.clone(), window, cx));
ProgramViewerPanel::new(engine, clock, meter, window, cx)
});
cx.run_until_parked();
let panel = window.root(cx).expect("program viewer panel root");
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Draw the picture tab once (fills the scope state from frame 0),
// then switch to the scopes tab and draw it.
cx.update(|window, cx| {
window.draw(cx).clear();
panel.update(cx, |panel, cx| {
panel.tab = ProgramViewTab::Scopes;
cx.notify();
});
});
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let (luma_len, chroma_len, bins, envelope) = cx.read(|app| {
let panel = panel.read(app);
(
panel.scope_state.read(app).luma.len(),
panel.scope_state.read(app).chroma.len(),
panel.histogram.read(app).bins(app),
panel.waveform.read(app).envelope(app),
)
});
let pixels = (crate::oakui::frames::SYNTH_FRAME_WIDTH
* crate::oakui::frames::SYNTH_FRAME_HEIGHT) as usize;
assert_eq!(luma_len, pixels);
assert_eq!(chroma_len, pixels);
assert_eq!(bins.iter().sum::<u32>() as usize, pixels);
assert_eq!(envelope.len(), 128);
}
}