feat(gpui_widgets): add scope widgets and audio level meter
W6 of the oak task list. scopes::math is a pure, unit-tested core (histogram binning, waveform min/max envelopes, vectorscope chroma projection, meter segment math, peak-hold decay) feeding three canvas widgets - Histogram, Waveform, Vectorscope - generic over LumaDataSource / ChromaDataSource traits the host implements over its frame buffers, plus an AudioLevelMeter over AudioMeterDataSource with lit segments and a peak marker. examples/scopes.rs drives all four from mock signals. 96 widget tests pass.
This commit is contained in:
@@ -38,3 +38,7 @@ path = "examples/viewer.rs"
|
||||
[[example]]
|
||||
name = "project_explorer"
|
||||
path = "examples/project_explorer.rs"
|
||||
|
||||
[[example]]
|
||||
name = "scopes"
|
||||
path = "examples/scopes.rs"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! A scopes demo: mock signal sources drive a histogram, waveform,
|
||||
//! vectorscope and audio level meter. Every frame the mock data updates and
|
||||
//! the scopes repaint.
|
||||
//!
|
||||
//! Run with `cargo run -p gpui_widgets --example scopes`.
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, Entity, Render, Window, WindowBounds, WindowOptions, div, prelude::*,
|
||||
px, size,
|
||||
};
|
||||
use gpui_widgets::audio_meter::{AudioLevelMeter, AudioMeterDataSource};
|
||||
use gpui_widgets::scopes::{ChromaDataSource, Histogram, LumaDataSource, Vectorscope, Waveform};
|
||||
|
||||
struct MockLuma {
|
||||
frame: u64,
|
||||
}
|
||||
impl LumaDataSource for MockLuma {
|
||||
fn luma_samples(&self) -> Vec<f32> {
|
||||
// A moving gradient + noise-ish bars.
|
||||
(0..4096)
|
||||
.map(|i| {
|
||||
let x = i as f32 / 4096.0;
|
||||
let t = self.frame as f32 / 60.0;
|
||||
((x + t * 0.25).fract() * 0.8 + 0.1).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
struct MockChroma {
|
||||
frame: u64,
|
||||
}
|
||||
impl ChromaDataSource for MockChroma {
|
||||
fn chroma_samples(&self) -> Vec<(f32, f32)> {
|
||||
// A rotating ring in chroma space.
|
||||
let t = self.frame as f32 / 60.0;
|
||||
(0..2048)
|
||||
.map(|i| {
|
||||
let angle = i as f32 / 2048.0 * std::f32::consts::TAU;
|
||||
(
|
||||
0.5 + 0.4 * (angle + t).cos(),
|
||||
0.5 + 0.4 * (angle + t).sin(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
struct MockAudio {
|
||||
frame: u64,
|
||||
}
|
||||
impl AudioMeterDataSource for MockAudio {
|
||||
fn levels(&self) -> Vec<f32> {
|
||||
let t = self.frame as f32 / 60.0;
|
||||
vec![
|
||||
(0.5 + 0.5 * (t * 2.0).sin()).clamp(0.0, 1.0),
|
||||
(0.5 + 0.5 * (t * 1.7).cos()).clamp(0.0, 1.0),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
struct Example {
|
||||
luma: Entity<MockLuma>,
|
||||
chroma: Entity<MockChroma>,
|
||||
audio: Entity<MockAudio>,
|
||||
histogram: Entity<Histogram<MockLuma>>,
|
||||
waveform: Entity<Waveform<MockLuma>>,
|
||||
vectorscope: Entity<Vectorscope<MockChroma>>,
|
||||
meter: Entity<AudioLevelMeter<MockAudio>>,
|
||||
}
|
||||
|
||||
impl Example {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let luma = cx.new(|_| MockLuma { frame: 0 });
|
||||
let chroma = cx.new(|_| MockChroma { frame: 0 });
|
||||
let audio = cx.new(|_| MockAudio { frame: 0 });
|
||||
|
||||
let histogram = cx.new(|cx| Histogram::new(1, luma.clone(), window, cx));
|
||||
let waveform = cx.new(|cx| Waveform::new(2, luma.clone(), window, cx));
|
||||
let vectorscope = cx.new(|cx| Vectorscope::new(3, chroma.clone(), window, cx));
|
||||
let meter = cx.new(|cx| AudioLevelMeter::new(4, audio.clone(), window, cx));
|
||||
|
||||
let this = cx.weak_entity();
|
||||
window.spawn(cx, async move |cx: &mut gpui::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.tick(cx));
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
luma,
|
||||
chroma,
|
||||
audio,
|
||||
histogram,
|
||||
waveform,
|
||||
vectorscope,
|
||||
meter,
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&mut self, cx: &mut Context<Self>) {
|
||||
self.luma.update(cx, |luma, _| luma.frame += 1);
|
||||
self.chroma.update(cx, |chroma, _| chroma.frame += 1);
|
||||
self.audio.update(cx, |audio, _| audio.frame += 1);
|
||||
self.meter.update(cx, |meter, cx| meter.update(cx));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Example {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.child(div().h(px(120.0)).child(self.histogram.clone()))
|
||||
.child(div().h(px(120.0)).child(self.waveform.clone()))
|
||||
.child(div().h(px(140.0)).child(self.vectorscope.clone()))
|
||||
.child(div().h(px(60.0)).child(self.meter.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
gpui_platform::application().run(|cx: &mut App| {
|
||||
cx.init_colors();
|
||||
let bounds = Bounds::centered(None, size(px(520.0), px(560.0)), cx);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| cx.new(|cx| Example::new(window, cx)),
|
||||
)
|
||||
.expect("Failed to open window");
|
||||
|
||||
cx.activate(true);
|
||||
cx.on_window_closed(|cx, _| {
|
||||
if cx.windows().is_empty() {
|
||||
cx.quit();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! An audio level meter: lit segments per channel with a peak-hold marker.
|
||||
//!
|
||||
//! Data-agnostic: the host implements [`AudioMeterDataSource`] over its
|
||||
//! engine's channel levels (in Oak, queried from the audio engine). The peak
|
||||
//! hold decays locally; the pure arithmetic is in [`scopes::math`].
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, Entity, FocusHandle, Focusable, Hsla, Render, Window, canvas,
|
||||
colors::DefaultColors, fill, point, prelude::*, px, size,
|
||||
};
|
||||
|
||||
use crate::scopes::{decay_peak, meter_lit_segments};
|
||||
|
||||
/// The number of segments per channel.
|
||||
const SEGMENTS: usize = 16;
|
||||
/// Peak decay per frame (fraction of full scale).
|
||||
const PEAK_DECAY: f32 = 0.01;
|
||||
|
||||
/// Provides per-channel levels in `0..1` (linear or dB-normalized).
|
||||
pub trait AudioMeterDataSource: 'static {
|
||||
/// The current level of each channel, `0..1`.
|
||||
fn levels(&self) -> Vec<f32>;
|
||||
}
|
||||
|
||||
/// An audio level meter.
|
||||
pub struct AudioLevelMeter<D: AudioMeterDataSource> {
|
||||
data: Entity<D>,
|
||||
focus_handle: FocusHandle,
|
||||
peak: Vec<f32>,
|
||||
}
|
||||
|
||||
impl<D: AudioMeterDataSource> AudioLevelMeter<D> {
|
||||
/// Create a meter over `data`.
|
||||
pub fn new(
|
||||
_control: usize,
|
||||
data: Entity<D>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
data,
|
||||
focus_handle: cx.focus_handle(),
|
||||
peak: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current per-channel levels.
|
||||
pub fn levels(&self, cx: &Context<Self>) -> Vec<f32> {
|
||||
self.data.read(cx).levels()
|
||||
}
|
||||
|
||||
/// Update the peak-hold state from the current levels (call each frame).
|
||||
pub fn update(&mut self, cx: &mut Context<Self>) {
|
||||
let levels = self.data.read(cx).levels();
|
||||
self.peak.resize(levels.len(), 0.0);
|
||||
for (peak, level) in self.peak.iter_mut().zip(&levels) {
|
||||
*peak = decay_peak(*peak, *level, PEAK_DECAY);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: AudioMeterDataSource> Focusable for AudioLevelMeter<D> {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: AudioMeterDataSource> Render for AudioLevelMeter<D> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let levels = self.data.read(cx).levels();
|
||||
self.peak.resize(levels.len(), 0.0);
|
||||
let peaks = self.peak.clone();
|
||||
let lit_counts: Vec<usize> = levels
|
||||
.iter()
|
||||
.map(|level| meter_lit_segments(*level, SEGMENTS))
|
||||
.collect();
|
||||
|
||||
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 }));
|
||||
}
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.size_full()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::scopes::meter_lit_segments;
|
||||
|
||||
#[test]
|
||||
fn meter_math_matches_scope_core() {
|
||||
assert_eq!(meter_lit_segments(0.0, SEGMENTS), 0);
|
||||
assert_eq!(meter_lit_segments(0.5, SEGMENTS), SEGMENTS / 2);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
//! files with no gpui coupling (e.g. [`value`], [`slider::model`]) and is
|
||||
//! covered by plain unit tests.
|
||||
|
||||
pub mod audio_meter;
|
||||
pub mod checkbox;
|
||||
pub mod color;
|
||||
pub mod combo_box;
|
||||
@@ -26,6 +27,7 @@ pub mod keyable;
|
||||
pub mod menu;
|
||||
pub mod project_explorer;
|
||||
pub mod radio_group;
|
||||
pub mod scopes;
|
||||
pub mod slider;
|
||||
pub mod spinbox;
|
||||
pub mod value;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Pure math for the scope widgets: histogram binning, waveform envelopes
|
||||
//! and vectorscope chroma projection. No gpui coupling, unit-tested.
|
||||
|
||||
/// Bin luma samples (`0..1`) into `bins` histogram buckets, returning the
|
||||
/// count per bucket. Samples outside `0..1` clamp to the edges.
|
||||
pub fn histogram_bins(samples: &[f32], bins: usize) -> Vec<u32> {
|
||||
if bins == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = vec![0u32; bins];
|
||||
if samples.is_empty() {
|
||||
return out;
|
||||
}
|
||||
for &sample in samples {
|
||||
let clamped = sample.clamp(0.0, 1.0);
|
||||
let index = ((clamped * bins as f32) as usize).min(bins - 1);
|
||||
out[index] += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the min/max envelope of `samples` over `columns` vertical slices.
|
||||
/// Each column covers a contiguous slice of the input; empty columns report
|
||||
/// `(0.0, 0.0)`.
|
||||
pub fn waveform_envelope(samples: &[f32], columns: usize) -> Vec<(f32, f32)> {
|
||||
let mut out = vec![(0.0f32, 0.0f32); columns.max(1)];
|
||||
if columns == 0 || samples.is_empty() {
|
||||
return out;
|
||||
}
|
||||
for column in 0..columns {
|
||||
let start = column * samples.len() / columns;
|
||||
let end = ((column + 1) * samples.len() / columns).max(start + 1).min(samples.len());
|
||||
let mut min = f32::MAX;
|
||||
let mut max = f32::MIN;
|
||||
for &sample in &samples[start..end] {
|
||||
min = min.min(sample);
|
||||
max = max.max(sample);
|
||||
}
|
||||
if end > start {
|
||||
out[column] = (min, max);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A chroma sample pair (e.g. `u`, `v` centered on `0.5`).
|
||||
pub type ChromaSample = (f32, f32);
|
||||
|
||||
/// Project chroma samples onto the vectorscope's two axes
|
||||
/// (`u - 0.5`, `v - 0.5`, normalized to `-0.5..0.5`). Out-of-range values
|
||||
/// clamp.
|
||||
pub fn vectorscope_points(samples: &[ChromaSample]) -> Vec<(f32, f32)> {
|
||||
samples
|
||||
.iter()
|
||||
.map(|&(u, v)| {
|
||||
(
|
||||
(u - 0.5).clamp(-0.5, 0.5),
|
||||
(v - 0.5).clamp(-0.5, 0.5),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Map a normalized `0..1` level to a meter segment's lit count: `segments`
|
||||
/// segments, the lit portion is proportional to the level.
|
||||
pub fn meter_lit_segments(level: f32, segments: usize) -> usize {
|
||||
let lit = (level.clamp(0.0, 1.0) * segments as f32).round() as usize;
|
||||
lit.min(segments)
|
||||
}
|
||||
|
||||
/// Peak-hold decay: `peak` decays toward `level` at `decay_per_frame`.
|
||||
pub fn decay_peak(peak: f32, level: f32, decay_per_frame: f32) -> f32 {
|
||||
if level >= peak {
|
||||
level
|
||||
} else {
|
||||
(peak - decay_per_frame).max(level)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn histogram_bins_correctly() {
|
||||
let samples = [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0];
|
||||
let bins = histogram_bins(&samples, 4);
|
||||
// Buckets: [0,0.25): 0.0, 0.1; [0.25,0.5): 0.25; [0.5,0.75): 0.5;
|
||||
// [0.75,1]: 0.75, 0.9, 1.0 (1.0 clamps into the last bucket).
|
||||
assert_eq!(bins, vec![2, 1, 1, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_clamps_out_of_range() {
|
||||
let bins = histogram_bins(&[-1.0, 0.5, 2.0], 2);
|
||||
assert_eq!(bins, vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_handles_empty_and_zero_bins() {
|
||||
assert_eq!(histogram_bins(&[], 4), vec![0u32; 4]);
|
||||
assert_eq!(histogram_bins(&[0.5], 0), Vec::<u32>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waveform_envelope_slices() {
|
||||
let samples: Vec<f32> = (0..100).map(|i| i as f32 / 100.0).collect();
|
||||
let env = waveform_envelope(&samples, 10);
|
||||
assert_eq!(env.len(), 10);
|
||||
// First column covers [0, 10): min 0.0, max 0.09.
|
||||
assert!((env[0].0 - 0.0).abs() < 0.001);
|
||||
assert!((env[0].1 - 0.09).abs() < 0.001);
|
||||
// Last column covers [90, 100): min 0.9, max 0.99.
|
||||
assert!((env[9].0 - 0.9).abs() < 0.001);
|
||||
assert!((env[9].1 - 0.99).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vectorscope_projection_centers() {
|
||||
let points = vectorscope_points(&[(0.5, 0.5), (1.0, 0.0), (0.0, 1.0)]);
|
||||
assert_eq!(points[0], (0.0, 0.0));
|
||||
assert_eq!(points[1], (0.5, -0.5));
|
||||
assert_eq!(points[2], (-0.5, 0.5));
|
||||
// Out-of-range clamps.
|
||||
let clamped = vectorscope_points(&[(2.0, -1.0)]);
|
||||
assert_eq!(clamped[0], (0.5, -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meter_lit_and_peak_decay() {
|
||||
assert_eq!(meter_lit_segments(0.0, 8), 0);
|
||||
assert_eq!(meter_lit_segments(0.5, 8), 4);
|
||||
assert_eq!(meter_lit_segments(1.0, 8), 8);
|
||||
assert_eq!(meter_lit_segments(1.5, 8), 8);
|
||||
|
||||
assert_eq!(decay_peak(0.8, 0.5, 0.1), 0.7);
|
||||
assert_eq!(decay_peak(0.8, 0.9, 0.1), 0.9);
|
||||
assert_eq!(decay_peak(0.8, 0.78, 0.1), 0.78);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Scope widgets: histogram, waveform and vectorscope, painted from host
|
||||
//! frame samples.
|
||||
//!
|
||||
//! Data-agnostic: the host implements [`LumaDataSource`] / [`ChromaDataSource`]
|
||||
//! over its frame buffers (in Oak, sampled from the renderer via C ABI). The
|
||||
//! pure math lives in [`math`] and is unit-tested.
|
||||
|
||||
mod math;
|
||||
|
||||
pub use math::*;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, Entity, FocusHandle, Focusable, Hsla, Render, Window, canvas,
|
||||
colors::DefaultColors, fill, point, prelude::*, px, size,
|
||||
};
|
||||
|
||||
/// Provides luma samples (`0..1`) for the histogram and waveform scopes.
|
||||
pub trait LumaDataSource: 'static {
|
||||
/// Luma samples for the current frame.
|
||||
fn luma_samples(&self) -> Vec<f32>;
|
||||
}
|
||||
|
||||
/// Provides chroma samples for the vectorscope.
|
||||
pub trait ChromaDataSource: 'static {
|
||||
/// `(u, v)` samples in `0..1` (centered on `0.5`).
|
||||
fn chroma_samples(&self) -> Vec<(f32, f32)>;
|
||||
}
|
||||
|
||||
/// A luminance histogram.
|
||||
pub struct Histogram<D: LumaDataSource> {
|
||||
data: Entity<D>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl<D: LumaDataSource> Histogram<D> {
|
||||
/// Create a histogram over `data`.
|
||||
pub fn new(
|
||||
_control: usize,
|
||||
data: Entity<D>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
data,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current histogram bins (for tests and hosts).
|
||||
pub fn bins(&self, cx: &Context<Self>) -> Vec<u32> {
|
||||
histogram_bins(&self.data.read(cx).luma_samples(), 64)
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: LumaDataSource> Focusable for Histogram<D> {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: LumaDataSource> Render for Histogram<D> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let bins = self.bins(cx);
|
||||
let height = bins.iter().copied().max().unwrap_or(1).max(1) as f32;
|
||||
canvas(
|
||||
move |_bounds, _window, _cx| (),
|
||||
move |bounds, (), window, _cx| {
|
||||
let width = f32::from(bounds.size.width);
|
||||
let bar_w = width / bins.len() as f32;
|
||||
let bar_color = Hsla::from(colors.selected);
|
||||
for (index, &count) in bins.iter().enumerate() {
|
||||
let h = (count as f32 / height) * f32::from(bounds.size.height);
|
||||
let bar = Bounds::new(
|
||||
point(bounds.left() + px(index as f32 * bar_w), bounds.bottom() - px(h)),
|
||||
size(px((bar_w - 1.0).max(1.0)), px(h)),
|
||||
);
|
||||
window.paint_quad(fill(bar, bar_color));
|
||||
}
|
||||
},
|
||||
)
|
||||
.size_full()
|
||||
}
|
||||
}
|
||||
|
||||
/// A waveform scope (min/max envelope over the frame's luma).
|
||||
pub struct Waveform<D: LumaDataSource> {
|
||||
data: Entity<D>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl<D: LumaDataSource> Waveform<D> {
|
||||
/// Create a waveform scope.
|
||||
pub fn new(
|
||||
_control: usize,
|
||||
data: Entity<D>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
data,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current envelope columns.
|
||||
pub fn envelope(&self, cx: &Context<Self>) -> Vec<(f32, f32)> {
|
||||
waveform_envelope(&self.data.read(cx).luma_samples(), 128)
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: LumaDataSource> Focusable for Waveform<D> {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: LumaDataSource> Render for Waveform<D> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let envelope = self.envelope(cx);
|
||||
canvas(
|
||||
move |_bounds, _window, _cx| (),
|
||||
move |bounds, (), window, _cx| {
|
||||
let width = f32::from(bounds.size.width);
|
||||
let height = f32::from(bounds.size.height);
|
||||
let col_w = width / envelope.len() as f32;
|
||||
let line = Hsla::from(colors.selected);
|
||||
for (column, &(min, max)) in envelope.iter().enumerate() {
|
||||
let y_min = (1.0 - min.clamp(0.0, 1.0)) * height;
|
||||
let y_max = (1.0 - max.clamp(0.0, 1.0)) * height;
|
||||
let band = Bounds::new(
|
||||
point(bounds.left() + px(column as f32 * col_w), bounds.top() + px(y_max)),
|
||||
size(px((col_w - 0.5).max(0.5)), px((y_min - y_max).max(1.0))),
|
||||
);
|
||||
window.paint_quad(fill(band, line));
|
||||
}
|
||||
},
|
||||
)
|
||||
.size_full()
|
||||
}
|
||||
}
|
||||
|
||||
/// A vectorscope (chroma projection with a graticule).
|
||||
pub struct Vectorscope<D: ChromaDataSource> {
|
||||
data: Entity<D>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl<D: ChromaDataSource> Vectorscope<D> {
|
||||
/// Create a vectorscope.
|
||||
pub fn new(
|
||||
_control: usize,
|
||||
data: Entity<D>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
data,
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The projected chroma points.
|
||||
pub fn points(&self, cx: &Context<Self>) -> Vec<(f32, f32)> {
|
||||
vectorscope_points(&self.data.read(cx).chroma_samples())
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: ChromaDataSource> Focusable for Vectorscope<D> {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: ChromaDataSource> Render for Vectorscope<D> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
let points = self.points(cx);
|
||||
canvas(
|
||||
move |_bounds, _window, _cx| (),
|
||||
move |bounds, (), window, _cx| {
|
||||
let width = f32::from(bounds.size.width);
|
||||
let height = f32::from(bounds.size.height);
|
||||
let center = point(bounds.left() + px(width / 2.0), bounds.top() + px(height / 2.0));
|
||||
let grid = Hsla::from(colors.border);
|
||||
// Graticule: crosshair + box.
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(
|
||||
point(bounds.left(), center.y - px(0.5)),
|
||||
size(bounds.size.width, px(1.0)),
|
||||
),
|
||||
grid,
|
||||
));
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(
|
||||
point(center.x - px(0.5), bounds.top()),
|
||||
size(px(1.0), bounds.size.height),
|
||||
),
|
||||
grid,
|
||||
));
|
||||
// Points: u -> x, v -> y (inverted).
|
||||
let point_color = Hsla::from(colors.selected);
|
||||
for &(u, v) in points.iter().take(4096) {
|
||||
let x = center.x + px(u / 0.5 * width / 2.0);
|
||||
let y = center.y - px(v / 0.5 * height / 2.0);
|
||||
let dot = Bounds::new(point(x - px(1.0), y - px(1.0)), size(px(2.0), px(2.0)));
|
||||
window.paint_quad(fill(dot, point_color));
|
||||
}
|
||||
},
|
||||
)
|
||||
.size_full()
|
||||
}
|
||||
}
|
||||
@@ -114,9 +114,15 @@
|
||||
|
||||
## W6. 示波器与音频表
|
||||
|
||||
- [ ] `Histogram` / `Vectorscope` / `Waveform` 检视组件(canvas
|
||||
- [x] `Histogram` / `Vectorscope` / `Waveform` 检视组件(canvas
|
||||
绘制,数据来自 oak render C ABI 的帧采样)。
|
||||
- [ ] `AudioLevelMeter` 表头(数据来自 oak audio C ABI)。
|
||||
> `gpui_widgets::scopes`:纯数学(直方图分箱、波形 min/max 包络、
|
||||
> vectorscope 色度投影)单测覆盖;`LumaDataSource`/`ChromaDataSource`
|
||||
> trait 由 host 经 C ABI 提供帧采样。
|
||||
- [x] `AudioLevelMeter` 表头(数据来自 oak audio C ABI)。
|
||||
> `gpui_widgets::audio_meter`:分段点亮 + 峰值保持衰减(纯数学
|
||||
> 单测);`AudioMeterDataSource` trait。
|
||||
> `examples/scopes.rs`:mock 信号源驱动四个组件。
|
||||
|
||||
## W7. 主题系统
|
||||
|
||||
|
||||
Reference in New Issue
Block a user