feat(gpui): support BGRA surfaces on macOS; add W4 viewer widget

Two changes landing W4 of the oak task list:

gpui_macos: the Surface renderer only accepted biplanar YUV 4:2:0 pixel
buffers, so engine BGRA frames could not be displayed. SurfaceBounds gains an
is_bgra flag and draw_surfaces now creates a single BGRA8Unorm texture (and
the fragment shader swizzles it to RGBA) for 32BGRA buffers, keeping the
YUV path unchanged. Verified by running the W3 surface_bridge demo.

gpui_widgets::viewer: ViewerWidget with a pure TransportState machine
(advance/loop/step/in-out, unit-tested), a PlaybackClock trait the host
implements over the engine, a ~60Hz polling ticker, a transport bar emitting
ViewerEvent requests, timecode via gpui::timeline::time, and safe-frame/zoom
toggles. examples/viewer.rs shows a mock clock with generated macOS test
frames. 87 widget + 182 gpui tests pass.
This commit is contained in:
2026-08-09 06:05:54 +08:00
parent 66b05b0ebe
commit d97e3abd20
9 changed files with 851 additions and 23 deletions
+1
View File
@@ -28,3 +28,4 @@ pub mod radio_group;
pub mod slider;
pub mod spinbox;
pub mod value;
pub mod viewer;
+18
View File
@@ -0,0 +1,18 @@
//! The playback clock a [`ViewerWidget`](super::ViewerWidget) reads its
//! position from.
//!
//! The host implements this trait over its engine (in Oak: the audio engine
//! clock queried through the C ABI). The widget polls it on a timer and only
//! *requests* transport changes; the engine is the single source of truth.
use gpui::timeline::{Frame, FrameRate};
/// A read-only view of the engine's playback clock.
pub trait PlaybackClock: 'static {
/// The current playhead frame.
fn current_frame(&self) -> Frame;
/// Whether playback is running.
fn is_playing(&self) -> bool;
/// The sequence's frame rate.
fn frame_rate(&self) -> FrameRate;
}
+417
View File
@@ -0,0 +1,417 @@
//! The viewer widget: a fullscreen preview with a transport bar.
//!
//! The picture comes from a [`SurfaceSource`] (in Oak, the W3 bridge's
//! IOSurface-backed pixel buffer); the playhead position is polled from the
//! host's [`PlaybackClock`] on a ~60 Hz timer, and every transport action is
//! emitted as a [`ViewerEvent`] request (the engine applies it and the clock
//! reflects it). Timecode formatting reuses `gpui::timeline::time`.
pub mod clock;
pub mod transport;
pub use clock::*;
pub use transport::*;
use gpui::timeline::{FrameRate, TimeDisplay, format_timecode};
use gpui::{
App, AsyncWindowContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
ObjectFit, Render, SurfaceSource, Window, colors::DefaultColors, div, prelude::*, px, surface,
};
/// A request emitted by the viewer.
#[derive(Debug, Clone, PartialEq)]
pub enum ViewerEvent {
/// Start playback.
PlayRequested {
/// The viewer's stable id.
control: usize,
},
/// Pause playback.
PauseRequested {
/// The viewer's stable id.
control: usize,
},
/// Step the playhead by `delta` frames.
StepRequested {
/// The viewer's stable id.
control: usize,
/// Frames to step (negative steps backward).
delta: i64,
},
/// Set the loop-in point at the playhead.
InPointRequested {
/// The viewer's stable id.
control: usize,
},
/// Set the loop-out point at the playhead.
OutPointRequested {
/// The viewer's stable id.
control: usize,
},
/// Clear the loop range.
ClearRangeRequested {
/// The viewer's stable id.
control: usize,
},
/// Toggle the safe-frame overlay.
ToggleSafeFramesRequested {
/// The viewer's stable id.
control: usize,
},
/// Toggle the zoom (contain vs cover).
ToggleZoomRequested {
/// The viewer's stable id.
control: usize,
},
}
/// The viewer widget.
pub struct ViewerWidget<C: PlaybackClock> {
control: usize,
clock: Entity<C>,
frame_rate: FrameRate,
transport: TransportState,
frame_source: Option<SurfaceSource>,
focus_handle: FocusHandle,
show_safe_frames: bool,
zoom: bool,
}
impl<C: PlaybackClock> ViewerWidget<C> {
/// Create a viewer driven by `clock`.
pub fn new(
control: usize,
clock: Entity<C>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let frame_rate = clock.read(cx).frame_rate();
// Poll the engine clock on a timer and reflect it locally.
let this = cx.weak_entity();
window.spawn(cx, async move |cx: &mut AsyncWindowContext| {
loop {
cx.background_executor()
.timer(std::time::Duration::from_millis(16))
.await;
let _ = cx.update(|_window, app| {
if let Some(this) = this.upgrade() {
this.update(app, |this, cx| this.poll_clock(cx));
}
});
}
})
.detach();
Self {
control,
clock,
frame_rate,
transport: TransportState::new(),
frame_source: None,
focus_handle: cx.focus_handle(),
show_safe_frames: false,
zoom: false,
}
}
/// The current transport state.
pub fn transport(&self) -> TransportState {
self.transport
}
/// Set the picture source (the bridge's pixel buffer) and repaint.
pub fn set_frame_source(&mut self, source: Option<SurfaceSource>, cx: &mut Context<Self>) {
self.frame_source = source;
cx.notify();
}
fn poll_clock(&mut self, cx: &mut Context<Self>) {
let clock = self.clock.read(cx);
let frame = clock.current_frame();
let playing = clock.is_playing();
if frame != self.transport.frame || playing != self.transport.playing {
self.transport.frame = frame;
self.transport.playing = playing;
cx.notify();
}
}
fn emit(&mut self, event: ViewerEvent, cx: &mut Context<Self>) {
cx.emit(event);
cx.notify();
}
}
impl<C: PlaybackClock> EventEmitter<ViewerEvent> for ViewerWidget<C> {}
impl<C: PlaybackClock> Focusable for ViewerWidget<C> {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl<C: PlaybackClock> Render for ViewerWidget<C> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let timecode = format_timecode(
self.transport.frame,
self.frame_rate,
TimeDisplay::Timecode,
);
// The picture area: surface (or placeholder), safe frames and zoom.
let mut picture = div()
.id("gpui-widgets-viewer-picture")
.flex_1()
.relative()
.bg(gpui::Hsla {
h: 0.0,
s: 0.0,
l: 0.0,
a: 1.0,
});
if let Some(source) = &self.frame_source {
let fit = if self.zoom { ObjectFit::Cover } else { ObjectFit::Contain };
picture = picture.child(
surface(source.clone())
.size_full()
.object_fit(fit),
);
} else {
picture = picture.child(
div()
.size_full()
.flex()
.items_center()
.justify_center()
.text_color(colors.disabled)
.child("No frame source"),
);
}
if self.show_safe_frames {
picture = picture.child(
div()
.absolute()
.left_0()
.right_0()
.top_0()
.bottom_0()
.flex()
.items_center()
.justify_center()
.child(
div()
.w(px(560.0))
.h(px(315.0))
.border_1()
.border_color(colors.selected),
),
);
}
// Transport bar.
let playing = self.transport.playing;
let play_label = if playing { "⏸" } else { "▶" };
let transport_bar = div()
.flex()
.items_center()
.gap_1()
.px_2()
.py_1()
.bg(colors.container)
.child(button(
"gpui-widgets-viewer-in",
"⏮",
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit(ViewerEvent::InPointRequested { control: this.control }, cx);
}),
))
.child(button(
"gpui-widgets-viewer-step-back",
"⏪",
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit(
ViewerEvent::StepRequested {
control: this.control,
delta: -1,
},
cx,
);
}),
))
.child(button(
"gpui-widgets-viewer-play",
play_label,
cx.listener(|this, _event: &ClickEvent, _window, cx| {
let event = if this.transport.playing {
ViewerEvent::PauseRequested { control: this.control }
} else {
ViewerEvent::PlayRequested { control: this.control }
};
this.emit(event, cx);
}),
))
.child(button(
"gpui-widgets-viewer-step-forward",
"⏩",
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit(
ViewerEvent::StepRequested {
control: this.control,
delta: 1,
},
cx,
);
}),
))
.child(button(
"gpui-widgets-viewer-out",
"⏭",
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit(ViewerEvent::OutPointRequested { control: this.control }, cx);
}),
))
.child(button(
"gpui-widgets-viewer-clear-range",
"✕",
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.emit(ViewerEvent::ClearRangeRequested { control: this.control }, cx);
}),
))
.child(
div()
.px_2()
.text_color(colors.text)
.child(timecode),
)
.child(
div().flex_1(),
)
.child(button(
"gpui-widgets-viewer-safe",
"安全框",
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.show_safe_frames = !this.show_safe_frames;
this.emit(
ViewerEvent::ToggleSafeFramesRequested { control: this.control },
cx,
);
}),
))
.child(button(
"gpui-widgets-viewer-zoom",
"缩放",
cx.listener(|this, _event: &ClickEvent, _window, cx| {
this.zoom = !this.zoom;
this.emit(ViewerEvent::ToggleZoomRequested { control: this.control }, cx);
}),
));
div().size_full().flex().flex_col().child(picture).child(transport_bar)
}
}
/// A small labeled button.
fn button(
id: &'static str,
label: &'static str,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> impl IntoElement {
div()
.id(id)
.debug_selector(move || id.into())
.px_2()
.py_1()
.rounded_md()
.cursor_pointer()
.hover(|style| style.bg(gpui::colors::Colors::dark().selected))
.on_click(on_click)
.child(label)
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::timeline::{Frame, FrameRate};
use gpui::{Modifiers, TestAppContext, VisualTestContext, px, size};
struct MockClock {
frame: Frame,
playing: bool,
}
impl PlaybackClock for MockClock {
fn current_frame(&self) -> Frame {
self.frame
}
fn is_playing(&self) -> bool {
self.playing
}
fn frame_rate(&self) -> FrameRate {
FrameRate::new(30, 1)
}
}
#[gpui::test]
async fn play_button_emits_play_request(cx: &mut TestAppContext) {
struct Host {
viewer: Entity<ViewerWidget<MockClock>>,
events: Vec<ViewerEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.viewer.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(640.0), px(420.0)), |window, cx| {
let clock = cx.new(|_| MockClock {
frame: Frame(0),
playing: false,
});
let viewer = cx.new(|cx| ViewerWidget::new(1, clock, window, cx));
let host = Host {
viewer,
events: Vec::new(),
};
cx.subscribe(
&host.viewer,
|host: &mut Host,
_v: Entity<ViewerWidget<MockClock>>,
event: &ViewerEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// The play button sits on the left of the transport bar at the bottom.
let play = cx
.debug_bounds("gpui-widgets-viewer-play")
.expect("play button rendered");
cx.simulate_click(play.center(), Modifiers::none());
cx.run_until_parked();
let requested = cx.read(|app| {
host.read(app).events.iter().any(|e| {
matches!(e, ViewerEvent::PlayRequested { control: 1 })
})
});
assert!(requested, "expected a PlayRequested event");
}
#[test]
fn timecode_formatting_reuses_timeline() {
let frame = Frame(3000);
let text = format_timecode(frame, FrameRate::new(30, 1), TimeDisplay::Timecode);
assert_eq!(text, "00:01:40:00");
}
}
+156
View File
@@ -0,0 +1,156 @@
//! Pure transport state machine for the [`ViewerWidget`](super::ViewerWidget):
//! playback position, play/pause, in/out points and loop-range stepping.
//! No gpui coupling, unit-tested.
use gpui::timeline::Frame;
/// The playback state of a viewer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TransportState {
/// The current playhead frame.
pub frame: Frame,
/// Whether playback is running (the engine drives the position; the
/// view only reflects it).
pub playing: bool,
/// The loop-in point, if set.
pub in_point: Option<Frame>,
/// The loop-out point, if set.
pub out_point: Option<Frame>,
/// Whether playback loops between the in/out points.
pub loop_range: bool,
}
impl Default for TransportState {
fn default() -> Self {
Self::new()
}
}
impl TransportState {
/// Create a stopped transport at frame 0.
pub fn new() -> Self {
Self {
frame: Frame(0),
playing: false,
in_point: None,
out_point: None,
loop_range: false,
}
}
/// Toggle play/pause; returns the new state.
pub fn toggle_play(&mut self) -> bool {
self.playing = !self.playing;
self.playing
}
/// Advance one frame. Clamps at `length` (exclusive); when a loop range
/// is active the position wraps to `in_point` instead of stopping at
/// `out_point`.
pub fn advance(&mut self, length: Frame) -> Frame {
let next = self.frame.0 + 1;
self.frame = if let Some(out) = self.out_point {
if self.loop_range && next >= out.0 {
Frame(self.in_point.unwrap_or(Frame(0)).0)
} else {
Frame(next.min(length.0 - 1))
}
} else {
Frame(next.min(length.0 - 1))
};
self.frame
}
/// Step the playhead by `delta` frames, clamped to `[0, length)`.
pub fn step(&mut self, delta: i64, length: Frame) -> Frame {
self.frame = Frame((self.frame.0 + delta).clamp(0, length.0 - 1));
self.frame
}
/// Set the loop-in point at the current frame.
pub fn set_in_point(&mut self, length: Frame) {
self.in_point = Some(Frame(self.frame.0.min(length.0 - 1)));
self.loop_range = true;
}
/// Set the loop-out point at the current frame.
pub fn set_out_point(&mut self, length: Frame) {
self.out_point = Some(Frame(self.frame.0.min(length.0 - 1)));
self.loop_range = true;
}
/// Clear the loop range.
pub fn clear_range(&mut self) {
self.in_point = None;
self.out_point = None;
self.loop_range = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggle_play_flips() {
let mut t = TransportState::new();
assert!(!t.playing);
assert!(t.toggle_play());
assert!(!t.toggle_play());
}
#[test]
fn advance_clamps_at_length() {
let mut t = TransportState::new();
t.frame = Frame(9);
assert_eq!(t.advance(Frame(10)), Frame(9));
assert_eq!(t.advance(Frame(10)), Frame(9));
}
#[test]
fn advance_loops_within_range() {
let mut t = TransportState::new();
t.frame = Frame(5);
t.in_point = Some(Frame(4));
t.out_point = Some(Frame(8));
t.loop_range = true;
assert_eq!(t.advance(Frame(100)), Frame(6));
t.frame = Frame(7);
assert_eq!(t.advance(Frame(100)), Frame(4));
}
#[test]
fn advance_ignores_out_point_without_loop() {
let mut t = TransportState::new();
t.out_point = Some(Frame(8));
t.loop_range = false;
t.frame = Frame(7);
assert_eq!(t.advance(Frame(100)), Frame(8));
}
#[test]
fn step_clamps_to_range() {
let mut t = TransportState::new();
t.frame = Frame(2);
assert_eq!(t.step(-5, Frame(10)), Frame(0));
assert_eq!(t.step(100, Frame(10)), Frame(9));
// 9 + 1 -> 10, clamped to the last index 9.
assert_eq!(t.step(1, Frame(10)), Frame(9));
}
#[test]
fn in_out_points_respect_length() {
let mut t = TransportState::new();
t.frame = Frame(50);
t.set_in_point(Frame(10));
assert_eq!(t.in_point, Some(Frame(9)));
t.frame = Frame(50);
t.set_out_point(Frame(10));
assert_eq!(t.out_point, Some(Frame(9)));
assert!(t.loop_range);
t.clear_range();
assert_eq!(t.in_point, None);
assert_eq!(t.out_point, None);
assert!(!t.loop_range);
}
}