1use gpui::{
2 App, Bounds, ContentMask, Entity, IntoElement, ParentElement as _, Path, PathBuilder, Pixels,
3 RenderOnce, StyleRefinement, Styled, Window, canvas, div, point, px, size,
4};
5
6use crate::{ActiveTheme as _, Sizable, Size, StyledExt as _};
7
8use instant::Instant;
9
10use super::SpeechState;
11
12const DEFAULT_WIDTH: Pixels = px(96.);
14
15const GAP_RATIO: f32 = 2.5;
17
18#[derive(IntoElement)]
30pub struct SpeechWaveform {
31 state: Entity<SpeechState>,
32 size: Size,
33 style: StyleRefinement,
34}
35
36impl SpeechWaveform {
37 pub fn new(state: &Entity<SpeechState>) -> Self {
39 Self {
40 state: state.clone(),
41 size: Size::default(),
42 style: StyleRefinement::default(),
43 }
44 }
45
46 fn height(&self) -> Pixels {
47 match self.size {
48 Size::Size(height) => height,
49 Size::XSmall => px(12.),
50 Size::Small => px(16.),
51 Size::Medium => px(20.),
52 Size::Large => px(24.),
53 }
54 }
55}
56
57impl Sizable for SpeechWaveform {
58 fn with_size(mut self, size: impl Into<Size>) -> Self {
59 self.size = size.into();
60 self
61 }
62}
63
64impl Styled for SpeechWaveform {
65 fn style(&mut self) -> &mut StyleRefinement {
66 &mut self.style
67 }
68}
69
70impl RenderOnce for SpeechWaveform {
71 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
72 let height = self.height();
73 let bar = (height * 0.125).round().clamp(px(2.), px(4.));
76 let gap = (bar * GAP_RATIO).round();
77 let state = self.state.read(cx);
78 let capturing = state.status().is_capturing();
79 let levels: Vec<f32> = state.levels().collect();
80 let animate = capturing && !cx.reduce_motion();
81 let scroll = state
86 .level_lead_at(Instant::now())
87 .filter(|_| animate)
88 .map_or(0., |lead| -lead);
89 if animate {
90 window.request_animation_frame();
91 }
92 let color = if capturing {
93 cx.theme().primary
94 } else {
95 cx.theme().muted_foreground
96 };
97
98 div()
99 .h(height)
100 .w(DEFAULT_WIDTH)
101 .flex_shrink_0()
102 .refine_style(&self.style)
103 .child(
104 canvas(
105 |_, _, _| {},
106 move |bounds, _, window, _| {
107 window.with_content_mask(Some(ContentMask { bounds }), |window| {
112 for rect in bar_rects(bounds, &levels, scroll, bar, gap) {
113 if let Some(path) = capsule(rect) {
114 window.paint_path(path, color);
115 }
116 }
117 });
118 },
119 )
120 .size_full(),
121 )
122 }
123}
124
125fn capsule(rect: Bounds<Pixels>) -> Option<Path<Pixels>> {
127 let radius = rect.size.width.min(rect.size.height) / 2.;
128 let radii = point(radius, radius);
129 let (left, right) = (rect.left(), rect.right());
130 let (top, bottom) = (rect.top() + radius, rect.bottom() - radius);
131 let mut path = PathBuilder::fill();
132 path.move_to(point(left, top));
133 path.arc_to(radii, px(0.), false, true, point(right, top));
134 path.line_to(point(right, bottom));
135 path.arc_to(radii, px(0.), false, true, point(left, bottom));
136 path.close();
137 path.build().ok()
138}
139
140fn bar_rects(
146 bounds: Bounds<Pixels>,
147 levels: &[f32],
148 scroll: f32,
149 bar: Pixels,
150 gap: Pixels,
151) -> Vec<Bounds<Pixels>> {
152 let pitch = bar + gap;
153 let height = bounds.size.height;
154 let middle = bounds.origin.y + height / 2.;
155 let count = ((bounds.size.width + gap) / pitch - scroll.min(0.))
157 .ceil()
158 .max(0.) as usize
159 + 1;
160 (0..count)
161 .filter_map(|from_end| {
162 let right = bounds.right() - pitch * (from_end as f32 + scroll);
163 let left = right - bar;
164 if left < bounds.left() - px(0.5) || left >= bounds.right() {
165 return None;
166 }
167 let level = levels[levels.len().checked_sub(from_end + 1)?];
168 let bar_height = (height * level.clamp(0., 1.)).max(bar);
169 Some(Bounds::new(
170 point(left, middle - bar_height / 2.),
171 size(bar, bar_height),
172 ))
173 })
174 .collect()
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 fn bounds(width: f32, height: f32) -> Bounds<Pixels> {
182 Bounds::new(point(px(10.), px(20.)), size(px(width), px(height)))
183 }
184
185 #[test]
186 fn a_short_history_grows_from_the_trailing_edge() {
187 let bounds = bounds(98., 20.);
188 let rects = bar_rects(bounds, &[0.2, 0.9], 0., px(2.), px(5.));
189 assert_eq!(rects.len(), 2);
190 assert_eq!(rects[0].right(), bounds.right());
191 assert_eq!(rects[0].size.height, px(18.));
192 assert_eq!(rects[1].right(), bounds.right() - px(7.));
193 assert_eq!(rects[1].size.height, px(4.));
194 }
195
196 #[test]
197 fn a_long_history_fills_the_width() {
198 let bounds = bounds(96., 20.);
199 let rects = bar_rects(bounds, &[0.; 64], 0., px(2.), px(5.));
200 assert_eq!(rects.len(), 14);
202 assert!(
203 rects
204 .iter()
205 .all(|rect| rect.left() >= bounds.left() - px(0.5))
206 );
207 }
208
209 #[test]
210 fn nothing_is_drawn_before_any_audio() {
211 assert!(bar_rects(bounds(96., 20.), &[], 0., px(2.), px(5.)).is_empty());
212 }
213
214 #[test]
215 fn bars_grow_from_the_midline_and_silence_is_a_dot() {
216 let bounds = bounds(40., 20.);
217 for rect in bar_rects(bounds, &[0.5, 0.], 0., px(2.), px(5.)) {
218 let middle = rect.origin.y + rect.size.height / 2.;
219 assert_eq!(middle, px(30.));
220 assert!(rect.size.height >= px(2.));
221 }
222 }
223
224 #[test]
225 fn scrolling_moves_bars_toward_the_leading_edge() {
226 let bounds = bounds(40., 20.);
227 let still = bar_rects(bounds, &[1.; 8], 0., px(2.), px(5.));
228 let moving = bar_rects(bounds, &[1.; 8], 0.5, px(2.), px(5.));
229 assert_eq!(still[0].left() - moving[0].left(), px(3.5));
230 assert!(
231 moving
232 .iter()
233 .all(|rect| rect.left() >= bounds.left() - px(0.5))
234 );
235 }
236
237 #[test]
238 fn a_bar_past_the_trailing_edge_is_left_out_until_it_slides_in() {
239 let bounds = bounds(40., 20.);
240 let out = bar_rects(bounds, &[1., 0.5], -1., px(2.), px(5.));
242 assert_eq!(out.len(), 1);
243 assert_eq!(out[0].right(), bounds.right());
244 let entering = bar_rects(bounds, &[1., 0.5], -0.2, px(2.), px(5.));
247 assert_eq!(entering.len(), 2);
248 assert!(entering[0].left() < bounds.right());
249 }
250}