Skip to main content

maolan_widgets/
waveform.rs

1use iced::{
2    Color, Element, Event, Length, Point, Rectangle, Renderer, Theme, keyboard, mouse,
3    widget::{
4        canvas,
5        canvas::{Action as CanvasAction, Frame, Geometry, Path, Program, Stroke},
6    },
7};
8use std::time::Instant;
9
10use crate::consts::DOUBLE_CLICK;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SelectionEdge {
14    Start,
15    End,
16}
17
18type RatioMessageFn<'a, Message> = Box<dyn Fn(f32) -> Message + 'a>;
19type RatioPairMessageFn<'a, Message> = Box<dyn Fn((f32, f32)) -> Message + 'a>;
20
21pub struct SampleWaveform<'a, Message> {
22    channels: Vec<&'a [f32]>,
23    peak: f32,
24    empty_label: &'static str,
25    playhead_ratio: Option<f32>,
26    selection_ratio: Option<(f32, f32)>,
27    xfade_ratio: Option<(f32, f32)>,
28    markers: Vec<(usize, String)>,
29    on_click: Option<RatioMessageFn<'a, Message>>,
30    on_double_click: Option<RatioMessageFn<'a, Message>>,
31    on_selection_start: Option<RatioMessageFn<'a, Message>>,
32    on_selection_drag: Option<RatioMessageFn<'a, Message>>,
33    on_selection_finish: Option<RatioMessageFn<'a, Message>>,
34    on_xfade: Option<RatioPairMessageFn<'a, Message>>,
35    on_right_click: Option<RatioMessageFn<'a, Message>>,
36    on_middle_click: Option<RatioMessageFn<'a, Message>>,
37    on_middle_click_away: Option<RatioMessageFn<'a, Message>>,
38}
39
40impl<'a, Message> SampleWaveform<'a, Message> {
41    pub fn new(channels: impl IntoIterator<Item = &'a [f32]>, peak: f32) -> Self {
42        Self {
43            channels: channels.into_iter().collect(),
44            peak,
45            empty_label: "No sample loaded",
46            playhead_ratio: None,
47            selection_ratio: None,
48            xfade_ratio: None,
49            markers: Vec::new(),
50            on_click: None,
51            on_double_click: None,
52            on_selection_start: None,
53            on_selection_drag: None,
54            on_selection_finish: None,
55            on_xfade: None,
56            on_right_click: None,
57            on_middle_click: None,
58            on_middle_click_away: None,
59        }
60    }
61
62    pub fn playhead_ratio(mut self, playhead_ratio: Option<f32>) -> Self {
63        self.playhead_ratio = playhead_ratio.map(|ratio| ratio.clamp(0.0, 1.0));
64        self
65    }
66
67    pub fn selection_ratio(mut self, selection_ratio: Option<(f32, f32)>) -> Self {
68        self.selection_ratio = selection_ratio.map(|(start, end)| {
69            let start = start.clamp(0.0, 1.0);
70            let end = end.clamp(0.0, 1.0);
71            (start.min(end), start.max(end))
72        });
73        self
74    }
75
76    pub fn xfade_ratio(mut self, xfade_ratio: Option<(f32, f32)>) -> Self {
77        self.xfade_ratio = xfade_ratio.map(|(start, end)| {
78            let start = start.clamp(0.0, 1.0);
79            let end = end.clamp(0.0, 1.0);
80            (start.min(end), start.max(end))
81        });
82        self
83    }
84
85    pub fn on_xfade(mut self, on_xfade: impl Fn((f32, f32)) -> Message + 'a) -> Self {
86        self.on_xfade = Some(Box::new(on_xfade));
87        self
88    }
89
90    pub fn on_selection_start(mut self, on_selection_start: impl Fn(f32) -> Message + 'a) -> Self {
91        self.on_selection_start = Some(Box::new(on_selection_start));
92        self
93    }
94
95    pub fn on_selection_drag(mut self, on_selection_drag: impl Fn(f32) -> Message + 'a) -> Self {
96        self.on_selection_drag = Some(Box::new(on_selection_drag));
97        self
98    }
99
100    pub fn on_selection_finish(
101        mut self,
102        on_selection_finish: impl Fn(f32) -> Message + 'a,
103    ) -> Self {
104        self.on_selection_finish = Some(Box::new(on_selection_finish));
105        self
106    }
107
108    pub fn markers(mut self, markers: impl IntoIterator<Item = (usize, String)>) -> Self {
109        self.markers = markers.into_iter().collect();
110        self.markers.sort_unstable_by_key(|(sample, _)| *sample);
111        self
112    }
113
114    pub fn on_click(mut self, on_click: impl Fn(f32) -> Message + 'a) -> Self {
115        self.on_click = Some(Box::new(on_click));
116        self
117    }
118
119    pub fn on_double_click(mut self, on_double_click: impl Fn(f32) -> Message + 'a) -> Self {
120        self.on_double_click = Some(Box::new(on_double_click));
121        self
122    }
123
124    pub fn on_right_click(mut self, on_right_click: impl Fn(f32) -> Message + 'a) -> Self {
125        self.on_right_click = Some(Box::new(on_right_click));
126        self
127    }
128
129    pub fn on_middle_click(mut self, on_middle_click: impl Fn(f32) -> Message + 'a) -> Self {
130        self.on_middle_click = Some(Box::new(on_middle_click));
131        self
132    }
133
134    pub fn on_middle_click_away(
135        mut self,
136        on_middle_click_away: impl Fn(f32) -> Message + 'a,
137    ) -> Self {
138        self.on_middle_click_away = Some(Box::new(on_middle_click_away));
139        self
140    }
141
142    pub fn empty_label(mut self, label: &'static str) -> Self {
143        self.empty_label = label;
144        self
145    }
146
147    pub fn view(self) -> Element<'a, Message>
148    where
149        Message: 'a,
150    {
151        canvas(self).width(Length::Fill).height(Length::Fill).into()
152    }
153
154    fn frame_count(&self) -> usize {
155        self.channels
156            .iter()
157            .map(|channel| channel.len())
158            .min()
159            .unwrap_or(0)
160    }
161
162    fn near_marker(&self, ratio: f32, width: f32) -> bool {
163        if self.markers.is_empty() {
164            return false;
165        }
166        let frames = self.frame_count();
167        if frames == 0 {
168            return false;
169        }
170        let x = width * ratio;
171        self.markers
172            .iter()
173            .map(|(sample, _)| width * (*sample as f32 / frames as f32))
174            .map(|marker_x| (marker_x - x).abs())
175            .any(|distance| distance <= 8.0)
176    }
177
178    fn selection_edge_at(&self, ratio: f32, width: f32) -> Option<SelectionEdge> {
179        let (start, end) = self.selection_ratio?;
180        let x = width * ratio;
181        let start_distance = (width * start - x).abs();
182        let end_distance = (width * end - x).abs();
183        const EDGE_TOLERANCE: f32 = 6.0;
184        match (
185            start_distance <= EDGE_TOLERANCE,
186            end_distance <= EDGE_TOLERANCE,
187        ) {
188            (true, true) => {
189                if start_distance <= end_distance {
190                    Some(SelectionEdge::Start)
191                } else {
192                    Some(SelectionEdge::End)
193                }
194            }
195            (true, false) => Some(SelectionEdge::Start),
196            (false, true) => Some(SelectionEdge::End),
197            (false, false) => None,
198        }
199    }
200
201    fn publish_xfade(&self, edge: SelectionEdge, ratio: f32) -> Option<CanvasAction<Message>> {
202        let selection = self.selection_ratio?;
203        let region = xfade_inner_region(selection, edge, ratio);
204        self.on_xfade
205            .as_ref()
206            .map(|message| CanvasAction::publish(message(region)).and_capture())
207    }
208}
209
210/// Computes the symmetric inner ("xfade") region for a ctrl+drag starting at
211/// one edge of the selection: the distance from the selection start to the
212/// inner start always equals the distance from the inner end to the
213/// selection end.
214fn xfade_inner_region(selection: (f32, f32), edge: SelectionEdge, ratio: f32) -> (f32, f32) {
215    let (sel_start, sel_end) = selection;
216    let (inner_start, inner_end) = match edge {
217        SelectionEdge::Start => (ratio, sel_end - (ratio - sel_start)),
218        SelectionEdge::End => (sel_start + (sel_end - ratio), ratio),
219    };
220    let inner_start = inner_start.clamp(sel_start, sel_end);
221    let inner_end = inner_end.clamp(sel_start, sel_end);
222    if inner_start > inner_end {
223        let mid = (sel_start + sel_end) / 2.0;
224        (mid, mid)
225    } else {
226        (inner_start, inner_end)
227    }
228}
229
230#[derive(Debug, Default)]
231pub struct SampleWaveformState {
232    selecting: bool,
233    drag_start: Option<Point>,
234    last_click_at: Option<Instant>,
235    modifiers: keyboard::Modifiers,
236    xfade_edge: Option<SelectionEdge>,
237}
238
239impl<Message> Program<Message> for SampleWaveform<'_, Message> {
240    type State = SampleWaveformState;
241
242    fn update(
243        &self,
244        state: &mut Self::State,
245        event: &Event,
246        bounds: Rectangle,
247        cursor: mouse::Cursor,
248    ) -> Option<CanvasAction<Message>> {
249        match event {
250            Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
251                state.modifiers = *modifiers;
252                None
253            }
254            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
255                let ratio = cursor_ratio(cursor, bounds)?;
256                let position = cursor.position_in(bounds)?;
257                let now = Instant::now();
258                let is_double_click = state
259                    .last_click_at
260                    .is_some_and(|last| now.duration_since(last) <= DOUBLE_CLICK);
261                state.last_click_at = Some(now);
262
263                if is_double_click {
264                    state.drag_start = None;
265                    state.selecting = false;
266                    state.xfade_edge = None;
267                    return self
268                        .on_double_click
269                        .as_ref()
270                        .map(|message| CanvasAction::publish(message(ratio)).and_capture());
271                }
272
273                if state.modifiers.control()
274                    && let Some(edge) = self.selection_edge_at(ratio, bounds.width)
275                {
276                    state.drag_start = None;
277                    state.selecting = false;
278                    state.xfade_edge = Some(edge);
279                    return self.publish_xfade(edge, ratio);
280                }
281
282                state.drag_start = Some(position);
283                state.selecting = false;
284                state.xfade_edge = None;
285                None
286            }
287            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
288                let position = cursor.position_in(bounds)?;
289                if let Some(edge) = state.xfade_edge {
290                    let ratio = cursor_ratio(cursor, bounds)?;
291                    return self.publish_xfade(edge, ratio);
292                }
293                if let Some(drag_start) = state.drag_start
294                    && !state.selecting
295                    && (position.x - drag_start.x).abs() > 2.0
296                {
297                    state.selecting = true;
298                    let ratio = cursor_ratio(cursor, bounds)?;
299                    return self
300                        .on_selection_start
301                        .as_ref()
302                        .map(|message| CanvasAction::publish(message(ratio)).and_capture());
303                }
304                if state.selecting {
305                    let ratio = cursor_ratio(cursor, bounds)?;
306                    return self
307                        .on_selection_drag
308                        .as_ref()
309                        .map(|message| CanvasAction::publish(message(ratio)).and_capture());
310                }
311                None
312            }
313            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
314                let ratio = cursor_ratio(cursor, bounds)?;
315                if let Some(edge) = state.xfade_edge.take() {
316                    return self.publish_xfade(edge, ratio);
317                }
318                if state.selecting {
319                    state.selecting = false;
320                    state.drag_start = None;
321                    return self
322                        .on_selection_finish
323                        .as_ref()
324                        .map(|message| CanvasAction::publish(message(ratio)).and_capture());
325                }
326                if state.drag_start.take().is_some() {
327                    return self
328                        .on_click
329                        .as_ref()
330                        .map(|message| CanvasAction::publish(message(ratio)).and_capture());
331                }
332                None
333            }
334            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
335                let ratio = cursor_ratio(cursor, bounds)?;
336                self.on_right_click
337                    .as_ref()
338                    .map(|message| CanvasAction::publish(message(ratio)).and_capture())
339            }
340            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) => {
341                let ratio = cursor_ratio(cursor, bounds)?;
342                if self.near_marker(ratio, bounds.width) {
343                    self.on_middle_click
344                        .as_ref()
345                        .map(|message| CanvasAction::publish(message(ratio)).and_capture())
346                } else {
347                    self.on_middle_click_away
348                        .as_ref()
349                        .map(|message| CanvasAction::publish(message(ratio)).and_capture())
350                }
351            }
352            _ => None,
353        }
354    }
355
356    fn draw(
357        &self,
358        _state: &Self::State,
359        renderer: &Renderer,
360        _theme: &Theme,
361        bounds: Rectangle,
362        _cursor: iced::mouse::Cursor,
363    ) -> Vec<Geometry> {
364        let mut frame = Frame::new(renderer, bounds.size());
365
366        let background = Path::rectangle(Point::ORIGIN, bounds.size());
367        frame.fill(&background, Color::from_rgb(0.075, 0.078, 0.095));
368
369        let frames = self.frame_count();
370        if frames == 0 {
371            draw_empty_label(&mut frame, self.empty_label);
372            return vec![frame.into_geometry()];
373        }
374
375        draw_waveform(&mut frame, bounds, &self.channels, frames, self.peak);
376        if let Some(selection) = self.selection_ratio {
377            draw_selection(&mut frame, bounds, selection);
378        }
379        if let Some(xfade) = self.xfade_ratio {
380            draw_xfade(&mut frame, bounds, xfade);
381        }
382        if let Some(ratio) = self.playhead_ratio {
383            draw_playhead(&mut frame, bounds, ratio);
384        }
385        draw_markers(&mut frame, bounds, frames, &self.markers);
386
387        vec![frame.into_geometry()]
388    }
389}
390
391fn cursor_ratio(cursor: mouse::Cursor, bounds: Rectangle) -> Option<f32> {
392    let position = cursor.position_in(bounds)?;
393    Some((position.x / bounds.width.max(1.0)).clamp(0.0, 1.0))
394}
395
396fn draw_empty_label(frame: &mut Frame, label: &'static str) {
397    frame.fill_text(canvas::Text {
398        content: String::from(label),
399        position: Point::new(8.0, 18.0),
400        color: Color::from_rgb(0.50, 0.52, 0.58),
401        size: iced::Pixels(12.0),
402        ..canvas::Text::default()
403    });
404}
405
406fn draw_waveform(
407    frame: &mut Frame,
408    bounds: Rectangle,
409    channels: &[&[f32]],
410    frames: usize,
411    peak: f32,
412) {
413    let width = bounds.width.max(1.0) as usize;
414    let height = bounds.height.max(1.0);
415    let channel_count = channels.len().max(1);
416    let lane_height = height / channel_count as f32;
417    let peak = peak.max(1e-10);
418    let samples_per_pixel = frames as f32 / width as f32;
419
420    for (channel_index, channel) in channels.iter().enumerate() {
421        let top = channel_index as f32 * lane_height;
422        let center = top + lane_height / 2.0;
423        let scale = (lane_height / 2.0 - 2.0).max(1.0);
424        let mut top_points = Vec::with_capacity(width);
425        let mut bottom_points = Vec::with_capacity(width);
426        for x in 0..width {
427            let start = ((x as f32 * samples_per_pixel) as usize).min(frames);
428            let end = (((x + 1) as f32 * samples_per_pixel) as usize).min(frames);
429
430            let mut min_sample = 0.0f32;
431            let mut max_sample = 0.0f32;
432            if start < end {
433                for &sample in &channel[start..end] {
434                    min_sample = min_sample.min(sample);
435                    max_sample = max_sample.max(sample);
436                }
437            }
438
439            let y_top = (center - (max_sample / peak) * scale).clamp(top, top + lane_height);
440            let y_bottom = (center - (min_sample / peak) * scale).clamp(top, top + lane_height);
441            top_points.push(Point::new(x as f32, y_top));
442            bottom_points.push(Point::new(x as f32, y_bottom));
443        }
444
445        let waveform_path = Path::new(|builder| {
446            if let Some(first) = top_points.first() {
447                builder.move_to(*first);
448                for point in &top_points[1..] {
449                    builder.line_to(*point);
450                }
451                for point in bottom_points.iter().rev() {
452                    builder.line_to(*point);
453                }
454                builder.close();
455            }
456        });
457
458        frame.fill(&waveform_path, Color::from_rgba(0.35, 0.55, 0.85, 0.35));
459        frame.stroke(
460            &waveform_path,
461            Stroke::default()
462                .with_color(Color::from_rgb(0.45, 0.72, 1.0))
463                .with_width(1.0),
464        );
465
466        if channel_index > 0 {
467            let separator = Path::line(Point::new(0.0, top), Point::new(width as f32, top));
468            frame.stroke(
469                &separator,
470                Stroke::default()
471                    .with_color(Color::from_rgb(0.12, 0.13, 0.16))
472                    .with_width(1.0),
473            );
474        }
475    }
476}
477
478fn draw_playhead(frame: &mut Frame, bounds: Rectangle, ratio: f32) {
479    let x = (bounds.width * ratio.clamp(0.0, 1.0)).clamp(0.0, bounds.width);
480    let path = Path::line(Point::new(x, 0.0), Point::new(x, bounds.height));
481    frame.stroke(
482        &path,
483        Stroke::default()
484            .with_color(Color::from_rgb(1.0, 0.82, 0.25))
485            .with_width(2.0),
486    );
487}
488
489fn draw_selection(frame: &mut Frame, bounds: Rectangle, selection: (f32, f32)) {
490    let x0 = bounds.width * selection.0.clamp(0.0, 1.0);
491    let x1 = bounds.width * selection.1.clamp(0.0, 1.0);
492    let width = (x1 - x0).abs().max(1.0);
493    let path = Path::rectangle(
494        Point::new(x0.min(x1), 0.0),
495        iced::Size::new(width, bounds.height),
496    );
497    frame.fill(&path, Color::from_rgba(1.0, 0.82, 0.25, 0.22));
498    frame.stroke(
499        &path,
500        Stroke::default()
501            .with_color(Color::from_rgba(1.0, 0.82, 0.25, 0.70))
502            .with_width(1.0),
503    );
504}
505
506fn draw_xfade(frame: &mut Frame, bounds: Rectangle, region: (f32, f32)) {
507    let x0 = bounds.width * region.0.clamp(0.0, 1.0);
508    let x1 = bounds.width * region.1.clamp(0.0, 1.0);
509    if x1 - x0 < 1.0 {
510        return;
511    }
512    let path = Path::rectangle(Point::new(x0, 0.0), iced::Size::new(x1 - x0, bounds.height));
513    frame.fill(&path, Color::from_rgba(0.35, 0.85, 0.55, 0.16));
514    frame.stroke(
515        &path,
516        Stroke::default()
517            .with_color(Color::from_rgba(0.35, 0.85, 0.55, 0.65))
518            .with_width(1.0),
519    );
520}
521
522fn draw_markers(frame: &mut Frame, bounds: Rectangle, frames: usize, markers: &[(usize, String)]) {
523    if frames == 0 || markers.is_empty() {
524        return;
525    }
526    let marker_color = Color::from_rgba(0.96, 0.72, 0.18, 0.95);
527    let marker_border = Color::from_rgba(0.2, 0.16, 0.04, 0.95);
528    let label_bg = Color::from_rgba(0.28, 0.20, 0.06, 0.92);
529    let label_border = Color::from_rgba(0.78, 0.62, 0.18, 0.85);
530    let text_color = Color::from_rgba(0.98, 0.92, 0.72, 0.96);
531
532    for (sample, name) in markers {
533        let ratio = (*sample as f32 / frames as f32).clamp(0.0, 1.0);
534        let x = bounds.width * ratio;
535
536        frame.stroke(
537            &Path::line(Point::new(x, 3.0), Point::new(x, bounds.height - 3.0)),
538            Stroke::default().with_width(2.0).with_color(marker_color),
539        );
540
541        let handle_size = 6.0;
542        frame.fill(
543            &Path::rectangle(
544                Point::new(x - handle_size / 2.0, 0.0),
545                iced::Size::new(handle_size, handle_size),
546            ),
547            marker_color,
548        );
549        frame.stroke(
550            &Path::rectangle(
551                Point::new(x - handle_size / 2.0, 0.0),
552                iced::Size::new(handle_size, handle_size),
553            ),
554            Stroke::default().with_width(1.0).with_color(marker_border),
555        );
556
557        let trimmed = name.trim();
558        if !trimmed.is_empty() {
559            let text_x = x + 5.0;
560            let text_y = 1.0;
561            let approx_width = trimmed.len() as f32 * 5.5 + 8.0;
562            frame.fill(
563                &Path::rectangle(
564                    Point::new(text_x, text_y),
565                    iced::Size::new(approx_width, 12.0),
566                ),
567                label_bg,
568            );
569            frame.stroke(
570                &Path::rectangle(
571                    Point::new(text_x, text_y),
572                    iced::Size::new(approx_width, 12.0),
573                ),
574                Stroke::default().with_width(1.0).with_color(label_border),
575            );
576            frame.fill_text(canvas::Text {
577                content: trimmed.to_string(),
578                position: Point::new(text_x + 3.0, text_y + 1.0),
579                color: text_color,
580                size: iced::Pixels(9.0),
581                ..Default::default()
582            });
583        }
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::{SelectionEdge, xfade_inner_region};
590
591    #[test]
592    fn xfade_inner_region_from_start_edge_is_symmetric() {
593        let (start, end) = xfade_inner_region((0.2, 0.8), SelectionEdge::Start, 0.3);
594        assert!((start - 0.3).abs() < 1e-6);
595        assert!((end - 0.7).abs() < 1e-6);
596    }
597
598    #[test]
599    fn xfade_inner_region_from_end_edge_is_symmetric() {
600        let (start, end) = xfade_inner_region((0.2, 0.8), SelectionEdge::End, 0.7);
601        assert!((start - 0.3).abs() < 1e-6);
602        assert!((end - 0.7).abs() < 1e-6);
603    }
604
605    #[test]
606    fn xfade_inner_region_clamps_inside_selection() {
607        let (start, end) = xfade_inner_region((0.2, 0.8), SelectionEdge::Start, 0.05);
608        assert!((start - 0.2).abs() < 1e-6);
609        assert!((end - 0.8).abs() < 1e-6);
610        let (start, end) = xfade_inner_region((0.2, 0.8), SelectionEdge::End, 0.95);
611        assert!((start - 0.2).abs() < 1e-6);
612        assert!((end - 0.8).abs() < 1e-6);
613    }
614
615    #[test]
616    fn xfade_inner_region_collapses_to_middle_when_edges_cross() {
617        let (start, end) = xfade_inner_region((0.2, 0.8), SelectionEdge::Start, 0.6);
618        assert!((start - 0.5).abs() < 1e-6);
619        assert!((end - 0.5).abs() < 1e-6);
620    }
621}