gpui_widgets defect fixes: menu checkmarks, dock ratios, meter orientation, CPU frames, drop-frame timecode
- menu: add runtime set-checked path (MenuItem::set_checked/clear_checked, Menu::set_item_checked recursive, MenuBar::set_item_checked) - dock: Split now stores per-child ratios (sum 1.0) so 3+ panels on one axis keep distinct sizes; resize_split_child adjusts a single boundary, resize_split keeps its two-arg whole-share semantics; one handle per boundary; DockLayoutState VERSION bumped to 2 for the new ratios field - audio_meter: MeterOrientation::Vertical for the 26px transport strip, segments lit bottom to top - viewer: ViewerFrameSource::CpuFrame + set_cpu_frame (BGRA8 RenderImage via the sprite atlas) for platforms without CVPixelBuffer - timeline: TimeDisplay::TimecodeDropFrame (SMPTE drop-frame for NTSC rates, non-drop fallback otherwise) - gpui_widgets: regression test pinning the fractional spacing helpers (py_0p5/py_1p5/py_2p5/py_3p5 already exist; no _0_5 aliases added)
This commit is contained in:
@@ -19,6 +19,8 @@ thiserror.workspace = true
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] }
|
||||
image.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
core-video.workspace = true
|
||||
|
||||
@@ -16,6 +16,20 @@ const SEGMENTS: usize = 16;
|
||||
/// Peak decay per frame (fraction of full scale).
|
||||
const PEAK_DECAY: f32 = 0.01;
|
||||
|
||||
/// The orientation of an [`AudioLevelMeter`].
|
||||
///
|
||||
/// A horizontal meter stacks channels vertically and lights segments left to
|
||||
/// right; a vertical meter (e.g. the 26px strip in the Oak transport bar)
|
||||
/// places channels side by side and lights segments bottom to top.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum MeterOrientation {
|
||||
/// Channels stacked vertically, segments lit left to right.
|
||||
#[default]
|
||||
Horizontal,
|
||||
/// Channels side by side, segments lit bottom to top.
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// Provides per-channel levels in `0..1` (linear or dB-normalized).
|
||||
pub trait AudioMeterDataSource: 'static {
|
||||
/// The current level of each channel, `0..1`.
|
||||
@@ -27,6 +41,7 @@ pub struct AudioLevelMeter<D: AudioMeterDataSource> {
|
||||
data: Entity<D>,
|
||||
focus_handle: FocusHandle,
|
||||
peak: Vec<f32>,
|
||||
orientation: MeterOrientation,
|
||||
}
|
||||
|
||||
impl<D: AudioMeterDataSource> AudioLevelMeter<D> {
|
||||
@@ -41,9 +56,21 @@ impl<D: AudioMeterDataSource> AudioLevelMeter<D> {
|
||||
data,
|
||||
focus_handle: cx.focus_handle(),
|
||||
peak: Vec::new(),
|
||||
orientation: MeterOrientation::Horizontal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the orientation (builder-style, callable after `new`).
|
||||
pub fn with_orientation(mut self, orientation: MeterOrientation) -> Self {
|
||||
self.orientation = orientation;
|
||||
self
|
||||
}
|
||||
|
||||
/// The current orientation.
|
||||
pub fn orientation(&self) -> MeterOrientation {
|
||||
self.orientation
|
||||
}
|
||||
|
||||
/// The current per-channel levels.
|
||||
pub fn levels(&self, cx: &App) -> Vec<f32> {
|
||||
self.data.read(cx).levels()
|
||||
@@ -76,36 +103,68 @@ impl<D: AudioMeterDataSource> Render for AudioLevelMeter<D> {
|
||||
.iter()
|
||||
.map(|level| meter_lit_segments(*level, SEGMENTS))
|
||||
.collect();
|
||||
let orientation = self.orientation;
|
||||
|
||||
canvas(
|
||||
move |_bounds, _window, _cx| (),
|
||||
move |bounds, (), window, _cx| {
|
||||
let width = f32::from(bounds.size.width);
|
||||
let height = f32::from(bounds.size.height);
|
||||
let channel_h = if lit_counts.is_empty() {
|
||||
height
|
||||
} else {
|
||||
height / lit_counts.len() as f32
|
||||
};
|
||||
let seg_w = width / SEGMENTS as f32;
|
||||
let lit_color = Hsla::from(colors.selected);
|
||||
let dim_color = Hsla::from(colors.border);
|
||||
let peak_color = Hsla::from(colors.text);
|
||||
|
||||
for (channel, &lit) in lit_counts.iter().enumerate() {
|
||||
let y = bounds.top() + px(channel as f32 * channel_h);
|
||||
for segment in 0..SEGMENTS {
|
||||
let seg = Bounds::new(
|
||||
point(bounds.left() + px(segment as f32 * seg_w), y),
|
||||
size(px((seg_w - 1.0).max(1.0)), px((channel_h - 2.0).max(2.0))),
|
||||
);
|
||||
window.paint_quad(fill(seg, if segment < lit { lit_color } else { dim_color }));
|
||||
match orientation {
|
||||
MeterOrientation::Horizontal => {
|
||||
let channel_h = if lit_counts.is_empty() {
|
||||
height
|
||||
} else {
|
||||
height / lit_counts.len() as f32
|
||||
};
|
||||
let seg_w = width / SEGMENTS as f32;
|
||||
for (channel, &lit) in lit_counts.iter().enumerate() {
|
||||
let y = bounds.top() + px(channel as f32 * channel_h);
|
||||
for segment in 0..SEGMENTS {
|
||||
let seg = Bounds::new(
|
||||
point(bounds.left() + px(segment as f32 * seg_w), y),
|
||||
size(px((seg_w - 1.0).max(1.0)), px((channel_h - 2.0).max(2.0))),
|
||||
);
|
||||
window.paint_quad(fill(seg, if segment < lit { lit_color } else { dim_color }));
|
||||
}
|
||||
// Peak marker.
|
||||
if let Some(peak) = peaks.get(channel) {
|
||||
let x = bounds.left() + px((peak.clamp(0.0, 1.0) * width) - 1.0);
|
||||
let marker = Bounds::new(point(x, y), size(px(2.0), px((channel_h - 2.0).max(2.0))));
|
||||
window.paint_quad(fill(marker, peak_color));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Peak marker.
|
||||
if let Some(peak) = peaks.get(channel) {
|
||||
let x = bounds.left() + px((peak.clamp(0.0, 1.0) * width) - 1.0);
|
||||
let marker = Bounds::new(point(x, y), size(px(2.0), px((channel_h - 2.0).max(2.0))));
|
||||
window.paint_quad(fill(marker, peak_color));
|
||||
MeterOrientation::Vertical => {
|
||||
// Channels side by side; segments stack bottom to top,
|
||||
// lit from the bottom like an equalizer column.
|
||||
let channel_w = if lit_counts.is_empty() {
|
||||
width
|
||||
} else {
|
||||
width / lit_counts.len() as f32
|
||||
};
|
||||
let seg_h = height / SEGMENTS as f32;
|
||||
for (channel, &lit) in lit_counts.iter().enumerate() {
|
||||
let x = bounds.left() + px(channel as f32 * channel_w);
|
||||
for segment in 0..SEGMENTS {
|
||||
let y = bounds.bottom() - px((segment + 1) as f32 * seg_h);
|
||||
let seg = Bounds::new(
|
||||
point(x, y),
|
||||
size(px((channel_w - 2.0).max(2.0)), px((seg_h - 1.0).max(1.0))),
|
||||
);
|
||||
window.paint_quad(fill(seg, if segment < lit { lit_color } else { dim_color }));
|
||||
}
|
||||
// Peak marker.
|
||||
if let Some(peak) = peaks.get(channel) {
|
||||
let y = bounds.bottom() - px(peak.clamp(0.0, 1.0) * height);
|
||||
let marker = Bounds::new(point(x, y), size(px((channel_w - 2.0).max(2.0)), px(2.0)));
|
||||
window.paint_quad(fill(marker, peak_color));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -166,4 +225,50 @@ mod tests {
|
||||
// Peaks track the levels on the first update.
|
||||
assert!((peaks[0] - 0.8).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn vertical_meter_renders_in_a_narrow_strip(cx: &mut TestAppContext) {
|
||||
// The Oak transport design needs a 26px-wide vertical strip: channels
|
||||
// side by side, segments lit bottom to top. Render one at that exact
|
||||
// size and exercise the paint path (the orientation default is
|
||||
// horizontal, so this also covers the builder).
|
||||
struct StripHost {
|
||||
meter: Entity<AudioLevelMeter<MockAudio>>,
|
||||
}
|
||||
impl Render for StripHost {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.child(self.meter.clone())
|
||||
}
|
||||
}
|
||||
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(26.0), px(200.0)), |window, cx| {
|
||||
let audio = cx.new(|_| MockAudio(vec![0.7]));
|
||||
let meter = cx.new(|cx| {
|
||||
AudioLevelMeter::new(5, audio, window, cx)
|
||||
.with_orientation(MeterOrientation::Vertical)
|
||||
});
|
||||
assert_eq!(meter.read(cx).orientation(), MeterOrientation::Vertical);
|
||||
StripHost { meter }
|
||||
});
|
||||
cx.run_until_parked();
|
||||
window
|
||||
.update(cx, |host, _, cx| {
|
||||
host.meter.update(cx, |meter, cx| meter.update(cx));
|
||||
})
|
||||
.unwrap();
|
||||
// Still valid after a vertical render + peak update.
|
||||
assert!(window
|
||||
.update(cx, |host, _, cx| host.meter.read(cx).orientation())
|
||||
.unwrap()
|
||||
== MeterOrientation::Vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_orientation_defaults_to_horizontal() {
|
||||
assert_eq!(MeterOrientation::default(), MeterOrientation::Horizontal);
|
||||
assert_ne!(MeterOrientation::Horizontal, MeterOrientation::Vertical);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,3 +33,48 @@ pub mod spinbox;
|
||||
pub mod theme;
|
||||
pub mod value;
|
||||
pub mod viewer;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AbsoluteLength, DefiniteLength, div, rems};
|
||||
|
||||
/// Fractional spacing helpers exist on the [`Styled`](gpui::Styled) trait
|
||||
/// (via `gpui_macros::padding_style_methods!` / `margin_style_methods!`),
|
||||
/// named after Tailwind's fractional scale: `0p5`, `1p5`, `2p5`, `3p5`
|
||||
/// (i.e. 0.5/1.5/2.5/3.5 units of 4px = 2/6/10/14px).
|
||||
///
|
||||
/// We deliberately do **not** add `_0_5`-style aliases (`py_0_5`,
|
||||
/// `px_1_5`, ...): every fractional value they would name already exists
|
||||
/// under the established `0p5`/`1p5`/`2p5`/`3p5` convention used
|
||||
/// throughout gpui and gpui_widgets, and a second naming scheme for the
|
||||
/// same helpers would only fragment the API surface. This test pins the
|
||||
/// helpers (and their values) so a future refactor of the style macros
|
||||
/// cannot silently drop them.
|
||||
#[test]
|
||||
fn fractional_spacing_helpers_exist() {
|
||||
let mut padding = div().py_0p5().px_1p5();
|
||||
assert_eq!(
|
||||
padding.style().padding.top,
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.125))))
|
||||
);
|
||||
assert_eq!(
|
||||
padding.style().padding.left,
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.375))))
|
||||
);
|
||||
assert_eq!(
|
||||
padding.style().padding.right,
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.375))))
|
||||
);
|
||||
|
||||
let mut margin = div().my_2p5().pt_3p5();
|
||||
assert_eq!(
|
||||
margin.style().margin.top,
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.625))).into())
|
||||
);
|
||||
assert_eq!(
|
||||
margin.style().padding.top,
|
||||
Some(DefiniteLength::Absolute(AbsoluteLength::Rems(rems(0.875))).into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,21 @@ impl MenuBar {
|
||||
self.open.is_some()
|
||||
}
|
||||
|
||||
/// Sets the checked state of the menu item with `id` across all entries
|
||||
/// (searching submenus recursively), so a host can toggle a checkmark at
|
||||
/// runtime without rebuilding the [`MenuBar`].
|
||||
///
|
||||
/// The checkmark appears on the next repaint (the renderer reads
|
||||
/// `checked` per frame); pair with a `cx.notify()` after the call.
|
||||
/// Returns whether an item with that id was found.
|
||||
pub fn set_item_checked(&mut self, id: usize, checked: bool) -> bool {
|
||||
let mut found = false;
|
||||
for entry in &mut self.entries {
|
||||
found |= entry.menu.set_item_checked(id, checked);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn open_menu(&mut self, index: usize, position: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
if self.open != Some(index) {
|
||||
self.open = Some(index);
|
||||
@@ -745,4 +760,30 @@ mod tests {
|
||||
});
|
||||
assert!(closed);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn runtime_set_item_checked_flips_the_checkmark(cx: &mut TestAppContext) {
|
||||
let (cx, host) = make_bar(cx);
|
||||
// Toggle item 11 ("Save") at runtime, by id.
|
||||
let changed = cx.update(|_window, app| {
|
||||
let bar = host.read(app).menu_bar.clone();
|
||||
bar.update(app, |bar, _cx| bar.set_item_checked(11, true))
|
||||
});
|
||||
assert!(changed, "item 11 exists and should be updated");
|
||||
let checked = cx.update(|_window, app| {
|
||||
host.read(app).menu_bar.read(app).entries[0].menu.items[1].checked
|
||||
});
|
||||
assert_eq!(checked, Some(true));
|
||||
|
||||
// Unknown ids are reported as not found and change nothing.
|
||||
let changed = cx.update(|_window, app| {
|
||||
let bar = host.read(app).menu_bar.clone();
|
||||
bar.update(app, |bar, _cx| bar.set_item_checked(12345, true))
|
||||
});
|
||||
assert!(!changed);
|
||||
let checked = cx.update(|_window, app| {
|
||||
host.read(app).menu_bar.read(app).entries[0].menu.items[0].checked
|
||||
});
|
||||
assert_eq!(checked, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,25 @@ impl MenuItem {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the checked state of an already-built item (runtime mutation).
|
||||
///
|
||||
/// Unlike the construction-only [`with_checked`](Self::with_checked), this
|
||||
/// lets a host flip a menu checkmark after the [`Menu`] has been handed
|
||||
/// to a view — e.g. through [`Menu::set_item_checked`] on the menu held
|
||||
/// by a [`MenuBar`](super::MenuBar) — without rebuilding the menu. The
|
||||
/// renderers read `checked` on every frame, so the change shows up on the
|
||||
/// next repaint.
|
||||
pub fn set_checked(&mut self, checked: bool) -> &mut Self {
|
||||
self.checked = Some(checked);
|
||||
self
|
||||
}
|
||||
|
||||
/// Remove the checkmark from an already-built item (runtime mutation).
|
||||
pub fn clear_checked(&mut self) -> &mut Self {
|
||||
self.checked = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a submenu.
|
||||
pub fn with_submenu(mut self, submenu: Menu) -> Self {
|
||||
self.submenu = Some(Box::new(submenu));
|
||||
@@ -85,6 +104,28 @@ impl Menu {
|
||||
item.label.is_empty()
|
||||
}
|
||||
|
||||
/// Sets the checked state of the item with `id`, searching top-level
|
||||
/// items and their submenus recursively. Returns whether an item with
|
||||
/// that id was found and updated.
|
||||
///
|
||||
/// Runtime counterpart to [`MenuItem::with_checked`]: hosts that hold a
|
||||
/// live [`Menu`] (e.g. in a [`MenuBar`](super::MenuBar)) can toggle a
|
||||
/// checkmark without rebuilding the menu.
|
||||
pub fn set_item_checked(&mut self, id: usize, checked: bool) -> bool {
|
||||
for item in &mut self.items {
|
||||
if item.id == id {
|
||||
item.set_checked(checked);
|
||||
return true;
|
||||
}
|
||||
if let Some(submenu) = item.submenu.as_mut() {
|
||||
if submenu.set_item_checked(id, checked) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The next selectable index from `current`, moving `delta` steps
|
||||
/// (skipping separators and disabled items). `None` returns the first
|
||||
/// (or last) selectable item. Returns `None` if nothing is selectable.
|
||||
@@ -158,6 +199,34 @@ mod tests {
|
||||
assert_eq!(menu.items[4].checked, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_item_checked_mutates_in_place() {
|
||||
let mut menu = sample_menu();
|
||||
// Runtime toggle on a built item, found by id.
|
||||
assert!(menu.set_item_checked(5, false));
|
||||
assert_eq!(menu.items[4].checked, Some(false));
|
||||
assert!(menu.set_item_checked(5, true));
|
||||
assert_eq!(menu.items[4].checked, Some(true));
|
||||
// Clear the checkmark entirely.
|
||||
assert!(menu.items[4].clear_checked().checked.is_none());
|
||||
// Unknown ids report failure and change nothing.
|
||||
assert!(!menu.set_item_checked(999, true));
|
||||
assert_eq!(menu.items[0].checked, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_item_checked_reaches_nested_submenus() {
|
||||
let sub = Menu::new(vec![MenuItem::new(10, "A"), MenuItem::new(11, "B")]);
|
||||
let mut menu = Menu::new(vec![
|
||||
MenuItem::new(5, "Nested").with_submenu(sub),
|
||||
MenuItem::new(6, "Top"),
|
||||
]);
|
||||
assert!(menu.set_item_checked(11, true));
|
||||
assert_eq!(menu.items[0].submenu.as_ref().unwrap().items[1].checked, Some(true));
|
||||
// The top-level item is untouched.
|
||||
assert_eq!(menu.items[1].checked, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_nesting() {
|
||||
let sub = Menu::new(vec![MenuItem::new(10, "A"), MenuItem::new(11, "B")]);
|
||||
|
||||
@@ -14,9 +14,11 @@ 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,
|
||||
AnyElement, App, AsyncWindowContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, ObjectFit, Render, RenderImage, SurfaceSource, Window, colors::DefaultColors, div,
|
||||
img, prelude::*, px, surface,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A request emitted by the viewer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -65,13 +67,29 @@ pub enum ViewerEvent {
|
||||
},
|
||||
}
|
||||
|
||||
/// The picture source of a [`ViewerWidget`].
|
||||
///
|
||||
/// On macOS the fast path is a CoreVideo [`SurfaceSource`]; on platforms
|
||||
/// without CVPixelBuffer (or when the engine only produces CPU frames) use
|
||||
/// [`ViewerFrameSource::CpuFrame`].
|
||||
#[derive(Clone)]
|
||||
pub enum ViewerFrameSource {
|
||||
/// A platform surface: a CoreVideo pixel buffer on macOS, or a GPU
|
||||
/// texture handle on Linux/FreeBSD.
|
||||
Surface(SurfaceSource),
|
||||
/// A CPU-side frame as raw bytes in a [`RenderImage`] (BGRA8, row-major,
|
||||
/// top-to-bottom), uploaded through gpui's sprite atlas on every
|
||||
/// platform — the path to use when no platform surface is available.
|
||||
CpuFrame(Arc<RenderImage>),
|
||||
}
|
||||
|
||||
/// The viewer widget.
|
||||
pub struct ViewerWidget<C: PlaybackClock> {
|
||||
control: usize,
|
||||
clock: Entity<C>,
|
||||
frame_rate: FrameRate,
|
||||
transport: TransportState,
|
||||
frame_source: Option<SurfaceSource>,
|
||||
frame_source: Option<ViewerFrameSource>,
|
||||
focus_handle: FocusHandle,
|
||||
show_safe_frames: bool,
|
||||
zoom: bool,
|
||||
@@ -122,7 +140,19 @@ impl<C: PlaybackClock> ViewerWidget<C> {
|
||||
|
||||
/// 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;
|
||||
self.frame_source = source.map(ViewerFrameSource::Surface);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Set the picture source to a CPU-side frame and repaint.
|
||||
///
|
||||
/// This is the path for non-macOS platforms and engines that decode to
|
||||
/// raw pixels instead of platform surfaces: hand in a
|
||||
/// [`RenderImage`](gpui::RenderImage) whose bytes are BGRA8 (the same
|
||||
/// format gpui's `img` element uses) and the viewer uploads it through
|
||||
/// the sprite atlas. `None` clears the picture (showing the placeholder).
|
||||
pub fn set_cpu_frame(&mut self, frame: Option<Arc<RenderImage>>, cx: &mut Context<Self>) {
|
||||
self.frame_source = frame.map(ViewerFrameSource::CpuFrame);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -174,11 +204,15 @@ impl<C: PlaybackClock> Render for ViewerWidget<C> {
|
||||
|
||||
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),
|
||||
);
|
||||
let picture_element: AnyElement = match source {
|
||||
ViewerFrameSource::Surface(surface_source) => {
|
||||
surface(surface_source.clone()).size_full().object_fit(fit).into_any()
|
||||
}
|
||||
ViewerFrameSource::CpuFrame(image) => {
|
||||
img(image.clone()).size_full().object_fit(fit).into_any()
|
||||
}
|
||||
};
|
||||
picture = picture.child(picture_element);
|
||||
} else {
|
||||
picture = picture.child(
|
||||
div()
|
||||
@@ -389,6 +423,55 @@ mod tests {
|
||||
assert_eq!(text, "00:01:40:00");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn cpu_frame_source_renders_without_a_platform_surface(cx: &mut TestAppContext) {
|
||||
// The CPU-frame path (for non-macOS platforms without CVPixelBuffer)
|
||||
// accepts raw BGRA8 bytes in a RenderImage and renders through the
|
||||
// sprite atlas — no SurfaceSource involved.
|
||||
use gpui::RenderImage;
|
||||
use image::{Frame, RgbaImage};
|
||||
|
||||
// A 2x2 opaque red frame, converted RGBA -> BGRA as gpui expects.
|
||||
let mut rgba = RgbaImage::from_pixel(2, 2, image::Rgba([255, 0, 0, 255]));
|
||||
for pixel in rgba.chunks_exact_mut(4) {
|
||||
pixel.swap(0, 2);
|
||||
}
|
||||
let frame = Arc::new(RenderImage::new(smallvec::SmallVec::from_elem(
|
||||
Frame::new(rgba),
|
||||
1,
|
||||
)));
|
||||
|
||||
let (cx, host) = make_host(cx);
|
||||
cx.update(|window, app| {
|
||||
host.read(app)
|
||||
.viewer
|
||||
.clone()
|
||||
.update(app, |viewer, cx| viewer.set_cpu_frame(Some(frame), cx));
|
||||
window.draw(app);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let is_cpu = cx.read(|app| {
|
||||
matches!(
|
||||
host.read(app).viewer.read(app).frame_source,
|
||||
Some(ViewerFrameSource::CpuFrame(_))
|
||||
)
|
||||
});
|
||||
assert!(is_cpu, "the frame source should be the CPU-frame variant");
|
||||
|
||||
// Clearing the CPU frame falls back to the placeholder.
|
||||
cx.update(|window, app| {
|
||||
host.read(app)
|
||||
.viewer
|
||||
.clone()
|
||||
.update(app, |viewer, cx| viewer.set_cpu_frame(None, cx));
|
||||
window.draw(app);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let is_none = cx.read(|app| host.read(app).viewer.read(app).frame_source.is_none());
|
||||
assert!(is_none);
|
||||
}
|
||||
|
||||
fn make_host(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity<Host>) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(640.0), px(420.0)), |window, cx| {
|
||||
|
||||
Reference in New Issue
Block a user