diff --git a/crates/gpui_widgets/Cargo.toml b/crates/gpui_widgets/Cargo.toml index cb7e489500..e484b42884 100644 --- a/crates/gpui_widgets/Cargo.toml +++ b/crates/gpui_widgets/Cargo.toml @@ -38,3 +38,7 @@ path = "examples/viewer.rs" [[example]] name = "project_explorer" path = "examples/project_explorer.rs" + +[[example]] +name = "scopes" +path = "examples/scopes.rs" diff --git a/crates/gpui_widgets/examples/scopes.rs b/crates/gpui_widgets/examples/scopes.rs new file mode 100644 index 0000000000..18613edae0 --- /dev/null +++ b/crates/gpui_widgets/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 { + // 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 { + 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, + chroma: Entity, + audio: Entity, + histogram: Entity>, + waveform: Entity>, + vectorscope: Entity>, + meter: Entity>, +} + +impl Example { + fn new(window: &mut Window, cx: &mut Context) -> 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.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) -> 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(); + }); +} diff --git a/crates/gpui_widgets/src/audio_meter.rs b/crates/gpui_widgets/src/audio_meter.rs new file mode 100644 index 0000000000..837633e976 --- /dev/null +++ b/crates/gpui_widgets/src/audio_meter.rs @@ -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; +} + +/// An audio level meter. +pub struct AudioLevelMeter { + data: Entity, + focus_handle: FocusHandle, + peak: Vec, +} + +impl AudioLevelMeter { + /// Create a meter over `data`. + pub fn new( + _control: usize, + data: Entity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + data, + focus_handle: cx.focus_handle(), + peak: Vec::new(), + } + } + + /// The current per-channel levels. + pub fn levels(&self, cx: &Context) -> Vec { + 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) { + 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 Focusable for AudioLevelMeter { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for AudioLevelMeter { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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 = 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); + } +} diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index 065c4382b8..d90ec0cea1 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -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; diff --git a/crates/gpui_widgets/src/scopes/math.rs b/crates/gpui_widgets/src/scopes/math.rs new file mode 100644 index 0000000000..47d6954d32 --- /dev/null +++ b/crates/gpui_widgets/src/scopes/math.rs @@ -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 { + 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::::new()); + } + + #[test] + fn waveform_envelope_slices() { + let samples: Vec = (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); + } +} diff --git a/crates/gpui_widgets/src/scopes/mod.rs b/crates/gpui_widgets/src/scopes/mod.rs new file mode 100644 index 0000000000..7cde8b4ef0 --- /dev/null +++ b/crates/gpui_widgets/src/scopes/mod.rs @@ -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; +} + +/// 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 { + data: Entity, + focus_handle: FocusHandle, +} + +impl Histogram { + /// Create a histogram over `data`. + pub fn new( + _control: usize, + data: Entity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + data, + focus_handle: cx.focus_handle(), + } + } + + /// The current histogram bins (for tests and hosts). + pub fn bins(&self, cx: &Context) -> Vec { + histogram_bins(&self.data.read(cx).luma_samples(), 64) + } +} + +impl Focusable for Histogram { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for Histogram { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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 { + data: Entity, + focus_handle: FocusHandle, +} + +impl Waveform { + /// Create a waveform scope. + pub fn new( + _control: usize, + data: Entity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + data, + focus_handle: cx.focus_handle(), + } + } + + /// The current envelope columns. + pub fn envelope(&self, cx: &Context) -> Vec<(f32, f32)> { + waveform_envelope(&self.data.read(cx).luma_samples(), 128) + } +} + +impl Focusable for Waveform { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for Waveform { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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 { + data: Entity, + focus_handle: FocusHandle, +} + +impl Vectorscope { + /// Create a vectorscope. + pub fn new( + _control: usize, + data: Entity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + data, + focus_handle: cx.focus_handle(), + } + } + + /// The projected chroma points. + pub fn points(&self, cx: &Context) -> Vec<(f32, f32)> { + vectorscope_points(&self.data.read(cx).chroma_samples()) + } +} + +impl Focusable for Vectorscope { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for Vectorscope { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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() + } +} diff --git a/docs/zh/oak-app-rewrite.md b/docs/zh/oak-app-rewrite.md index e172f74873..74110246fe 100644 --- a/docs/zh/oak-app-rewrite.md +++ b/docs/zh/oak-app-rewrite.md @@ -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. 主题系统