Skip to main content

maolan_widgets/
clip.rs

1use crate::audio::read_wav_f32;
2use crate::midi::{PITCH_MAX, PianoNote};
3use iced::{
4    Background, Border, Color, Element, Length, Point, Rectangle, Renderer, Theme, gradient, mouse,
5    widget::{
6        Space, Stack, canvas,
7        canvas::{Frame, Geometry, Path},
8        container, mouse_area, pin, text,
9    },
10};
11use std::{
12    cell::Cell,
13    hash::{Hash, Hasher},
14    path::PathBuf,
15    sync::Arc,
16};
17pub type PeakPair = [f32; 2];
18pub type ClipPeaksData = Vec<Vec<PeakPair>>;
19pub type ClipPeaks = Arc<ClipPeaksData>;
20
21const CHECKPOINTS: usize = 16;
22const MAX_RENDER_COLUMNS: usize = 32_767;
23const RENDER_MARGIN_COLUMNS: usize = 2;
24/// Horizontal on-screen footprint of one waveform bar-mode column, in
25/// pixels. One column covers multiple screen pixels so the number of
26/// primitives per clip stays bounded.
27const COLUMN_WIDTH_PX: f32 = 2.0;
28/// Horizontal hit width of a clip's resize edges, in pixels. Shared with
29/// the editor's cursor hit-test so the pointer shape and the press behavior
30/// cover the same zone.
31pub const CLIP_RESIZE_HANDLE_WIDTH: f32 = 8.0;
32const CLIP_NORMAL_ALPHA: f32 = 0.68;
33const CLIP_SELECTED_ALPHA: f32 = 0.78;
34const CLIP_MUTED_ALPHA: f32 = 0.34;
35
36#[derive(Debug, Clone, Default)]
37pub struct AudioClipData {
38    pub name: String,
39    pub start: usize,
40    pub length: usize,
41    pub offset: usize,
42    pub muted: bool,
43    pub reversed: bool,
44    pub max_length_samples: usize,
45    pub source_length_samples: usize,
46    pub peaks: ClipPeaks,
47    pub fade_enabled: bool,
48    pub fade_in_samples: usize,
49    pub fade_out_samples: usize,
50    pub grouped_clips: Vec<AudioClipData>,
51    pub stretch_ratio: f32,
52}
53
54impl AudioClipData {
55    pub fn is_group(&self) -> bool {
56        !self.grouped_clips.is_empty()
57    }
58}
59
60#[derive(Debug, Clone, Default)]
61pub struct MIDIClipData {
62    pub name: String,
63    pub start: usize,
64    pub length: usize,
65    pub offset: usize,
66    pub input_channel: usize,
67    pub muted: bool,
68    pub reversed: bool,
69    pub max_length_samples: usize,
70    pub grouped_clips: Vec<MIDIClipData>,
71}
72
73impl MIDIClipData {
74    pub fn is_group(&self) -> bool {
75        !self.grouped_clips.is_empty()
76    }
77}
78
79#[derive(Clone)]
80pub struct ClipEdgeMessages<Message> {
81    pub left_hover_enter: Message,
82    pub left_hover_exit: Message,
83    pub left_press: Message,
84    pub right_hover_enter: Message,
85    pub right_hover_exit: Message,
86    pub right_press: Message,
87}
88
89pub struct AudioClipInteraction<Message> {
90    pub on_select: Message,
91    pub on_open: Message,
92    pub on_drag: Option<Arc<dyn Fn(Point) -> Message + Send + Sync + 'static>>,
93    pub edges: ClipEdgeMessages<Message>,
94    pub fade_in_press: Option<Message>,
95    pub fade_out_press: Option<Message>,
96}
97
98pub struct MIDIClipInteraction<Message> {
99    pub on_select: Message,
100    pub on_open: Message,
101    pub on_drag: Option<Arc<dyn Fn(Point) -> Message + Send + Sync + 'static>>,
102    pub edges: ClipEdgeMessages<Message>,
103}
104
105fn clean_clip_name(name: &str) -> String {
106    let mut cleaned = name.to_string();
107    if let Some(stripped) = cleaned.strip_prefix("audio/") {
108        cleaned = stripped.to_string();
109    }
110    if let Some(stripped) = cleaned.strip_prefix("midi/") {
111        cleaned = stripped.to_string();
112    }
113    if let Some(stripped) = cleaned.strip_suffix(".wav") {
114        cleaned = stripped.to_string();
115    }
116    if let Some(stripped) = cleaned.strip_suffix(".midi") {
117        cleaned = stripped.to_string();
118    } else if let Some(stripped) = cleaned.strip_suffix(".mid") {
119        cleaned = stripped.to_string();
120    }
121    cleaned
122}
123
124fn trim_label_to_width(label: &str, width_px: f32) -> String {
125    let max_chars = ((width_px - 10.0) / 7.0).floor() as i32;
126    if max_chars <= 0 {
127        return String::new();
128    }
129    let max_chars = max_chars as usize;
130    if label.chars().count() <= max_chars {
131        return label.to_string();
132    }
133    label.chars().take(max_chars).collect()
134}
135
136fn clip_label_overlay<Message: 'static>(
137    label: String,
138    clip_width: f32,
139    clip_height: f32,
140    corner_radius: f32,
141    vertical_alignment: iced::alignment::Vertical,
142) -> Element<'static, Message> {
143    let padding = corner_radius.max(0.0);
144    let text_area_width = (clip_width - (padding * 2.0)).max(1.0);
145    let (text_area_height, y) = match vertical_alignment {
146        iced::alignment::Vertical::Top => ((clip_height - (padding * 2.0)).max(1.0), padding),
147        iced::alignment::Vertical::Center => (clip_height.max(1.0), 0.0),
148        iced::alignment::Vertical::Bottom => ((clip_height - (padding * 2.0)).max(1.0), padding),
149    };
150
151    pin(container(
152        text(label)
153            .size(12)
154            .width(Length::Fill)
155            .align_x(iced::alignment::Horizontal::Left),
156    )
157    .width(Length::Fixed(text_area_width))
158    .height(Length::Fixed(text_area_height))
159    .align_x(iced::alignment::Horizontal::Left)
160    .align_y(vertical_alignment))
161    .position(Point::new(padding, y))
162    .into()
163}
164
165fn audio_clip_label_overlay<Message: 'static>(
166    label: String,
167    clip_width: f32,
168    clip_height: f32,
169    corner_radius: f32,
170    channels: usize,
171) -> Element<'static, Message> {
172    let vertical_alignment = if channels.max(1).is_multiple_of(2) {
173        iced::alignment::Vertical::Center
174    } else {
175        iced::alignment::Vertical::Top
176    };
177    clip_label_overlay(
178        label,
179        clip_width,
180        clip_height,
181        corner_radius,
182        vertical_alignment,
183    )
184}
185
186fn midi_clip_label_overlay<Message: 'static>(
187    label: String,
188    clip_width: f32,
189    clip_height: f32,
190    corner_radius: f32,
191) -> Element<'static, Message> {
192    clip_label_overlay(
193        label,
194        clip_width,
195        clip_height,
196        corner_radius,
197        iced::alignment::Vertical::Top,
198    )
199}
200
201fn brighten(color: Color, amount: f32) -> Color {
202    Color {
203        r: (color.r + amount).min(1.0),
204        g: (color.g + amount).min(1.0),
205        b: (color.b + amount).min(1.0),
206        a: color.a,
207    }
208}
209
210fn darken(color: Color, amount: f32) -> Color {
211    Color {
212        r: (color.r - amount).max(0.0),
213        g: (color.g - amount).max(0.0),
214        b: (color.b - amount).max(0.0),
215        a: color.a,
216    }
217}
218
219fn clip_two_edge_gradient(
220    base: Color,
221    muted_alpha: f32,
222    normal_alpha: f32,
223    reverse: bool,
224) -> Background {
225    let alpha = normal_alpha;
226    let (edge, center) = if reverse {
227        (
228            Color {
229                a: alpha,
230                ..darken(base, 0.05)
231            },
232            Color {
233                a: alpha,
234                ..brighten(base, 0.06)
235            },
236        )
237    } else {
238        (
239            Color {
240                a: alpha,
241                ..brighten(base, 0.06)
242            },
243            Color {
244                a: alpha,
245                ..darken(base, 0.05)
246            },
247        )
248    };
249    let edge_muted = Color {
250        a: muted_alpha,
251        ..edge
252    };
253    let center_muted = Color {
254        a: muted_alpha,
255        ..center
256    };
257
258    let (top_bottom, middle) = if muted_alpha < normal_alpha {
259        (edge_muted, center_muted)
260    } else {
261        (edge, center)
262    };
263    Background::Gradient(
264        gradient::Linear::new(0.0)
265            .add_stop(0.0, top_bottom)
266            .add_stop(0.5, middle)
267            .add_stop(1.0, top_bottom)
268            .into(),
269    )
270}
271
272fn visible_fade_overlay_width(fade_samples: usize, pixels_per_sample: f32) -> f32 {
273    fade_samples as f32 * pixels_per_sample
274}
275
276fn should_draw_fade_overlay(fade_samples: usize, pixels_per_sample: f32) -> bool {
277    fade_samples as f32 * pixels_per_sample > 3.0
278}
279
280#[derive(Debug, Clone, Copy)]
281struct FadeBezierCanvas {
282    color: Color,
283    fade_out: bool,
284}
285
286impl<Message> canvas::Program<Message> for FadeBezierCanvas {
287    type State = ();
288
289    fn draw(
290        &self,
291        _state: &Self::State,
292        renderer: &Renderer,
293        _theme: &Theme,
294        bounds: Rectangle,
295        _cursor: mouse::Cursor,
296    ) -> Vec<Geometry> {
297        let mut frame = Frame::new(renderer, bounds.size());
298        let start = if self.fade_out {
299            Point::new(0.0, 0.0)
300        } else {
301            Point::new(0.0, bounds.height)
302        };
303        let end = if self.fade_out {
304            Point::new(bounds.width, bounds.height)
305        } else {
306            Point::new(bounds.width, 0.0)
307        };
308        let c1 = if self.fade_out {
309            Point::new(bounds.width * 0.2, 0.0)
310        } else {
311            Point::new(bounds.width * 0.2, bounds.height)
312        };
313        let c2 = if self.fade_out {
314            Point::new(bounds.width * 0.8, bounds.height)
315        } else {
316            Point::new(bounds.width * 0.8, 0.0)
317        };
318        let fill = Path::new(|builder| {
319            if self.fade_out {
320                builder.move_to(Point::new(0.0, 0.0));
321                builder.line_to(Point::new(bounds.width, 0.0));
322                builder.line_to(end);
323            } else {
324                builder.move_to(Point::new(0.0, 0.0));
325                builder.line_to(end);
326            }
327            builder.bezier_curve_to(c2, c1, start);
328            builder.line_to(Point::new(0.0, 0.0));
329        });
330        frame.fill(&fill, Color::from_rgba(0.0, 0.0, 0.0, 0.22));
331
332        let path = Path::new(|builder| {
333            builder.move_to(start);
334            builder.bezier_curve_to(c1, c2, end);
335        });
336        frame.stroke(
337            &path,
338            canvas::Stroke::default()
339                .with_width(1.0)
340                .with_color(self.color),
341        );
342        vec![frame.into_geometry()]
343    }
344}
345
346fn fade_bezier_overlay<Message: 'static>(
347    width: f32,
348    height: f32,
349    color: Color,
350    fade_out: bool,
351) -> Element<'static, Message> {
352    canvas(FadeBezierCanvas { color, fade_out })
353        .width(Length::Fixed(width.max(0.0)))
354        .height(Length::Fixed(height.max(0.0)))
355        .into()
356}
357
358#[derive(Default)]
359struct WaveformCanvasState {
360    cache: canvas::Cache,
361    last_hash: Cell<u64>,
362}
363
364#[derive(Clone)]
365struct WaveformCanvas {
366    peaks: ClipPeaks,
367    source_wav_path: Option<PathBuf>,
368    clip_offset: usize,
369    clip_length: usize,
370    max_length: usize,
371    source_length: usize,
372    stretch_ratio: f32,
373    reversed: bool,
374    log_scaled: bool,
375}
376
377impl WaveformCanvas {
378    /// Ardour-style log waveform shaping: amplitude is converted to dB and
379    /// shaped with `alt_log_meter`, preserving sign. 0 dBFS maps to ±1.0 and
380    /// -18 dBFS maps to roughly ±0.75.
381    fn log_scale_sample(v: f32) -> f32 {
382        if v == 0.0 {
383            return 0.0;
384        }
385        let db = 20.0 * v.abs().log10();
386        const MIN_DB: f32 = -192.0;
387        let shaped = if db < MIN_DB {
388            0.0
389        } else {
390            ((db - MIN_DB) / (0.0 - MIN_DB)).powf(8.0)
391        };
392        (v.signum() * shaped).clamp(-1.0, 1.0)
393    }
394
395    fn shape_hash(&self, bounds: Rectangle) -> u64 {
396        let mut hasher = std::collections::hash_map::DefaultHasher::new();
397        bounds.width.to_bits().hash(&mut hasher);
398        bounds.height.to_bits().hash(&mut hasher);
399        self.log_scaled.hash(&mut hasher);
400        self.clip_offset.hash(&mut hasher);
401        self.clip_length.hash(&mut hasher);
402        self.max_length.hash(&mut hasher);
403        self.source_length.hash(&mut hasher);
404        self.stretch_ratio.to_bits().hash(&mut hasher);
405        self.reversed.hash(&mut hasher);
406        self.peaks.len().hash(&mut hasher);
407        for channel in self.peaks.iter() {
408            channel.len().hash(&mut hasher);
409            if channel.is_empty() {
410                continue;
411            }
412            for i in 0..CHECKPOINTS {
413                let idx = (i * channel.len()) / CHECKPOINTS;
414                let sample = channel[idx.min(channel.len() - 1)];
415                sample[0].to_bits().hash(&mut hasher);
416                sample[1].to_bits().hash(&mut hasher);
417            }
418        }
419        hasher.finish()
420    }
421
422    fn aggregate_column_peak(
423        channel_peaks: &[[f32; 2]],
424        src_start: usize,
425        src_end: usize,
426    ) -> Option<(f32, f32)> {
427        if src_start >= src_end || src_end > channel_peaks.len() {
428            return None;
429        }
430        let mut min_val = 1.0_f32;
431        let mut max_val = -1.0_f32;
432        for pair in &channel_peaks[src_start..src_end] {
433            min_val = min_val.min(pair[0].clamp(-1.0, 1.0));
434            max_val = max_val.max(pair[1].clamp(-1.0, 1.0));
435        }
436        Some((min_val, max_val))
437    }
438
439    fn source_column_peaks(
440        source_wav_path: &std::path::Path,
441        channel_count: usize,
442        source_start_sample: usize,
443        source_end_sample: usize,
444        total_columns: usize,
445    ) -> Option<Vec<Vec<[f32; 2]>>> {
446        if total_columns == 0 || source_end_sample <= source_start_sample || channel_count == 0 {
447            return None;
448        }
449        let (samples, wav_channels, _sample_rate) = read_wav_f32(source_wav_path).ok()?;
450        let use_channels = channel_count.min(wav_channels).max(1);
451        let total_frames = samples.len() / wav_channels;
452        if source_start_sample >= total_frames {
453            return None;
454        }
455        let read_end = source_end_sample.min(total_frames);
456        let read_frames = read_end.saturating_sub(source_start_sample);
457        if read_frames == 0 {
458            return None;
459        }
460
461        let start_sample = source_start_sample.saturating_mul(wav_channels);
462        let end_sample = read_end.saturating_mul(wav_channels);
463        let chunk = &samples[start_sample..end_sample];
464        if chunk.is_empty() {
465            return None;
466        }
467
468        let mut out = vec![vec![[0.0_f32, 0.0_f32]; total_columns]; channel_count];
469        for col in 0..total_columns {
470            let frame_start = (col * read_frames) / total_columns;
471            let mut frame_end = ((col + 1) * read_frames) / total_columns;
472            if frame_end <= frame_start {
473                frame_end = (frame_start + 1).min(read_frames);
474            }
475            if frame_start >= frame_end {
476                continue;
477            }
478            for (ch, out_channel) in out.iter_mut().enumerate().take(use_channels) {
479                let mut min_val = 1.0_f32;
480                let mut max_val = -1.0_f32;
481                for frame_idx in frame_start..frame_end {
482                    let sample_idx = frame_idx.saturating_mul(wav_channels).saturating_add(ch);
483                    let s = chunk
484                        .get(sample_idx)
485                        .copied()
486                        .unwrap_or(0.0)
487                        .clamp(-1.0, 1.0);
488                    min_val = min_val.min(s);
489                    max_val = max_val.max(s);
490                }
491                out_channel[col] = [min_val, max_val];
492            }
493        }
494
495        Some(out)
496    }
497}
498
499impl<Message> canvas::Program<Message> for WaveformCanvas {
500    type State = WaveformCanvasState;
501
502    fn draw(
503        &self,
504        state: &Self::State,
505        renderer: &Renderer,
506        _theme: &Theme,
507        bounds: Rectangle,
508        _cursor: mouse::Cursor,
509    ) -> Vec<Geometry> {
510        if self.peaks.is_empty() || bounds.width <= 0.0 || bounds.height <= 0.0 {
511            return vec![];
512        }
513
514        let hash = self.shape_hash(bounds);
515        if state.last_hash.get() != hash {
516            state.cache.clear();
517            state.last_hash.set(hash);
518        }
519
520        let geom = state
521            .cache
522            .draw(renderer, bounds.size(), |frame: &mut Frame| {
523                let inner_w = bounds.width.max(4.0);
524                let inner_h = bounds.height.max(4.0);
525                let channel_count = self.peaks.len().max(1);
526                let channel_h = inner_h / channel_count as f32;
527                let waveform_fill = Color::from_rgba(0.86, 0.94, 1.0, 0.34);
528                let waveform_edge = Color::from_rgba(0.96, 0.98, 1.0, 0.62);
529                let zero_line = Color::from_rgba(0.74, 0.86, 1.0, 0.28);
530                let clip_color = Color::from_rgba(1.0, 0.42, 0.30, 0.78);
531                let clip_level = 0.90_f32;
532                let edge_shade = darken(waveform_fill, 0.08);
533
534                for (channel_idx, channel_peaks) in self.peaks.iter().enumerate() {
535                    if channel_peaks.is_empty() {
536                        continue;
537                    }
538                    let channel_top = channel_h * channel_idx as f32;
539                    let center_y = channel_top + channel_h * 0.5;
540                    let half_span = (channel_h * 0.45).max(1.0);
541                    let total_peaks = channel_peaks.len();
542                    let max_len = if self.source_length > 0 {
543                        self.source_length
544                    } else {
545                        self.max_length
546                    }
547                    .max(1);
548                    let start_idx = ((self.clip_offset * total_peaks) / max_len)
549                        .min(total_peaks.saturating_sub(1));
550                    let effective_length = if self.stretch_ratio > 0.0 && self.stretch_ratio != 1.0
551                    {
552                        ((self.clip_length as f32 / self.stretch_ratio).ceil() as usize).max(1)
553                    } else {
554                        self.clip_length
555                    };
556                    let clip_end_sample = self
557                        .clip_offset
558                        .saturating_add(effective_length)
559                        .min(max_len);
560                    let mut end_idx = ((clip_end_sample * total_peaks) / max_len).min(total_peaks);
561                    if end_idx <= start_idx {
562                        end_idx = (start_idx + 1).min(total_peaks);
563                    }
564                    let visible_bins = end_idx.saturating_sub(start_idx).max(1);
565                    // Column counts are decimated (one column per
566                    // COLUMN_WIDTH_PX pixels), but zoom-dependent
567                    // trace/source-mode thresholds stay expressed in screen
568                    // pixels so rendering-mode decisions are unchanged.
569                    let pixel_columns = inner_w.ceil().max(1.0) as usize;
570                    let visible_columns = (inner_w / COLUMN_WIDTH_PX)
571                        .ceil()
572                        .max(1.0)
573                        .min(MAX_RENDER_COLUMNS as f32)
574                        as usize;
575                    let x_step = inner_w / visible_columns as f32;
576                    let margin_columns = RENDER_MARGIN_COLUMNS;
577                    let total_columns = visible_columns + (margin_columns * 2);
578                    let margin_bins = ((visible_bins * margin_columns * COLUMN_WIDTH_PX as usize)
579                        / pixel_columns)
580                        .max(1);
581                    let render_start_idx = start_idx.saturating_sub(margin_bins);
582                    let render_end_idx = end_idx.saturating_add(margin_bins).min(total_peaks);
583                    let render_bins = render_end_idx.saturating_sub(render_start_idx).max(1);
584                    let stored_samples_per_bin = max_len as f32 / total_peaks.max(1) as f32;
585                    let visible_source_samples =
586                        clip_end_sample.saturating_sub(self.clip_offset).max(1);
587                    let required_samples_per_px =
588                        visible_source_samples as f32 / pixel_columns.max(1) as f32;
589                    let high_zoom_source_mode = required_samples_per_px < 1.0;
590                    let trace_mode = high_zoom_source_mode
591                        || required_samples_per_px <= 4.0
592                        || visible_bins <= pixel_columns.saturating_mul(2);
593                    let use_source_columns = self.source_wav_path.is_some()
594                        && required_samples_per_px + f32::EPSILON < stored_samples_per_bin;
595                    let mut source_mode_columns = total_columns;
596                    let mut source_mode_margin = margin_columns;
597                    let mut source_mode_x_step = x_step;
598                    let mut source_mode_bin_w = x_step.max(1.0);
599                    let source_columns = if use_source_columns {
600                        let source_margin_samples = if high_zoom_source_mode {
601                            margin_columns * COLUMN_WIDTH_PX as usize
602                        } else {
603                            ((visible_source_samples * margin_columns * COLUMN_WIDTH_PX as usize)
604                                / pixel_columns)
605                                .max(1)
606                        };
607                        if high_zoom_source_mode {
608                            source_mode_columns =
609                                visible_source_samples + (source_margin_samples * 2);
610                            source_mode_margin = source_margin_samples;
611                            source_mode_x_step = inner_w / visible_source_samples.max(1) as f32;
612                            source_mode_bin_w = 1.0;
613                        }
614                        let source_start = self.clip_offset.saturating_sub(source_margin_samples);
615                        let source_end = clip_end_sample.saturating_add(source_margin_samples).min(
616                            if self.source_length > 0 {
617                                self.source_length
618                            } else {
619                                self.max_length
620                            }
621                            .max(1),
622                        );
623                        self.source_wav_path.as_ref().and_then(|path| {
624                            Self::source_column_peaks(
625                                path,
626                                self.peaks.len(),
627                                source_start,
628                                source_end,
629                                source_mode_columns,
630                            )
631                        })
632                    } else {
633                        None
634                    };
635
636                    frame.fill(
637                        &Path::rectangle(Point::new(0.0, center_y), iced::Size::new(inner_w, 1.0)),
638                        zero_line,
639                    );
640
641                    let draw_columns = if source_columns.is_some() {
642                        source_mode_columns
643                    } else {
644                        total_columns
645                    };
646                    if trace_mode {
647                        let trace = Path::new(|builder| {
648                            let mut started = false;
649                            for col in 0..draw_columns {
650                                let pair = if let Some(columns) = source_columns.as_ref() {
651                                    columns
652                                        .get(channel_idx)
653                                        .and_then(|ch| ch.get(col))
654                                        .copied()
655                                        .unwrap_or([0.0, 0.0])
656                                } else {
657                                    let visual_col = if self.reversed {
658                                        draw_columns.saturating_sub(col + 1)
659                                    } else {
660                                        col
661                                    };
662                                    let src_start = render_start_idx
663                                        + ((visual_col * render_bins) / draw_columns)
664                                            .min(render_bins);
665                                    let mut src_end = render_start_idx
666                                        + (((visual_col + 1) * render_bins) / draw_columns)
667                                            .min(render_bins);
668                                    if src_end <= src_start {
669                                        src_end = (src_start + 1).min(total_peaks);
670                                    }
671                                    let pair = Self::aggregate_column_peak(
672                                        channel_peaks,
673                                        src_start,
674                                        src_end,
675                                    )
676                                    .unwrap_or((0.0, 0.0));
677                                    [pair.0, pair.1]
678                                };
679                                let sample = ((pair[0] + pair[1]) * 0.5).clamp(-1.0, 1.0);
680                                let sample = if self.log_scaled {
681                                    Self::log_scale_sample(sample)
682                                } else {
683                                    sample
684                                };
685                                let x = if source_columns.is_some() {
686                                    (col as f32 - source_mode_margin as f32) * source_mode_x_step
687                                } else {
688                                    (col as f32 - margin_columns as f32) * x_step
689                                };
690                                let y = (center_y - (sample * half_span))
691                                    .clamp(channel_top, channel_top + channel_h);
692                                if !started {
693                                    builder.move_to(Point::new(x, y));
694                                    started = true;
695                                } else {
696                                    builder.line_to(Point::new(x, y));
697                                }
698                            }
699                        });
700                        frame.stroke(
701                            &trace,
702                            canvas::Stroke::default()
703                                .with_color(waveform_edge)
704                                .with_width(1.0),
705                        );
706                        continue;
707                    }
708
709                    for col in 0..draw_columns {
710                        let visual_col = if self.reversed {
711                            draw_columns.saturating_sub(col + 1)
712                        } else {
713                            col
714                        };
715                        let (min_val, max_val) = if let Some(columns) = source_columns.as_ref() {
716                            let pair = columns
717                                .get(channel_idx)
718                                .and_then(|ch| ch.get(visual_col))
719                                .copied()
720                                .unwrap_or([0.0, 0.0]);
721                            (pair[0], pair[1])
722                        } else {
723                            let src_start = render_start_idx
724                                + ((visual_col * render_bins) / total_columns).min(render_bins);
725                            let mut src_end = render_start_idx
726                                + (((visual_col + 1) * render_bins) / total_columns)
727                                    .min(render_bins);
728                            if src_end <= src_start {
729                                src_end = (src_start + 1).min(total_peaks);
730                            }
731                            let Some(pair) =
732                                Self::aggregate_column_peak(channel_peaks, src_start, src_end)
733                            else {
734                                continue;
735                            };
736                            pair
737                        };
738                        let (disp_min, disp_max) = if self.log_scaled {
739                            (
740                                Self::log_scale_sample(min_val),
741                                Self::log_scale_sample(max_val),
742                            )
743                        } else {
744                            (min_val, max_val)
745                        };
746                        let top = (center_y - (disp_max * half_span))
747                            .clamp(channel_top, channel_top + channel_h);
748                        let bottom = (center_y - (disp_min * half_span))
749                            .clamp(channel_top, channel_top + channel_h);
750                        let y = top.min(bottom);
751                        let h = (bottom - top).abs().max(1.0);
752                        let (x, bin_w) = if source_columns.is_some() {
753                            (
754                                (col as f32 - source_mode_margin as f32) * source_mode_x_step,
755                                source_mode_bin_w,
756                            )
757                        } else {
758                            (
759                                (col as f32 - margin_columns as f32) * x_step,
760                                x_step.max(1.0),
761                            )
762                        };
763
764                        frame.fill(
765                            &Path::rectangle(Point::new(x, y), iced::Size::new(bin_w, h)),
766                            waveform_fill,
767                        );
768                        let edge_h = (h * 0.2).clamp(1.0, 3.0);
769                        let shade_path = Path::new(|builder| {
770                            builder.rectangle(Point::new(x, y), iced::Size::new(bin_w, edge_h));
771                            builder.rectangle(
772                                Point::new(x, y + h - edge_h),
773                                iced::Size::new(bin_w, edge_h),
774                            );
775                        });
776                        frame.fill(&shade_path, edge_shade);
777
778                        if h >= 3.0 {
779                            let edge_path = Path::new(|builder| {
780                                builder.rectangle(Point::new(x, y), iced::Size::new(bin_w, 1.0));
781                                builder.rectangle(
782                                    Point::new(x, y + h - 1.0),
783                                    iced::Size::new(bin_w, 1.0),
784                                );
785                            });
786                            frame.fill(&edge_path, waveform_edge);
787                        }
788
789                        if max_val >= clip_level || -min_val >= clip_level {
790                            let clip_h = h.clamp(1.0, 3.0);
791                            let clip_path = Path::new(|builder| {
792                                if max_val >= clip_level {
793                                    builder.rectangle(
794                                        Point::new(x, y),
795                                        iced::Size::new(bin_w, clip_h),
796                                    );
797                                }
798                                if -min_val >= clip_level {
799                                    builder.rectangle(
800                                        Point::new(x, y + h - clip_h),
801                                        iced::Size::new(bin_w, clip_h),
802                                    );
803                                }
804                            });
805                            frame.fill(&clip_path, clip_color);
806                        }
807                    }
808                }
809            });
810        vec![geom]
811    }
812}
813
814#[derive(Default)]
815struct MidiClipNotesCanvasState {
816    cache: canvas::Cache,
817    last_hash: Cell<u64>,
818}
819
820#[derive(Clone)]
821struct MidiClipNotesCanvas {
822    notes: Arc<Vec<PianoNote>>,
823    clip_offset_samples: usize,
824    clip_visible_length_samples: usize,
825    reversed: bool,
826}
827
828impl MidiClipNotesCanvas {
829    fn shape_hash(&self, bounds: Rectangle) -> u64 {
830        let mut hasher = std::collections::hash_map::DefaultHasher::new();
831        bounds.width.to_bits().hash(&mut hasher);
832        bounds.height.to_bits().hash(&mut hasher);
833        self.clip_offset_samples.hash(&mut hasher);
834        self.clip_visible_length_samples.hash(&mut hasher);
835        self.reversed.hash(&mut hasher);
836        self.notes.len().hash(&mut hasher);
837        if let Some(first) = self.notes.first() {
838            first.start_sample.hash(&mut hasher);
839            first.length_samples.hash(&mut hasher);
840            first.pitch.hash(&mut hasher);
841            first.velocity.hash(&mut hasher);
842        }
843        if let Some(last) = self.notes.last() {
844            last.start_sample.hash(&mut hasher);
845            last.length_samples.hash(&mut hasher);
846            last.pitch.hash(&mut hasher);
847            last.velocity.hash(&mut hasher);
848        }
849        hasher.finish()
850    }
851}
852
853impl<Message> canvas::Program<Message> for MidiClipNotesCanvas {
854    type State = MidiClipNotesCanvasState;
855
856    fn draw(
857        &self,
858        state: &Self::State,
859        renderer: &Renderer,
860        _theme: &Theme,
861        bounds: Rectangle,
862        _cursor: mouse::Cursor,
863    ) -> Vec<Geometry> {
864        if self.notes.is_empty() || bounds.width <= 0.0 || bounds.height <= 0.0 {
865            return vec![];
866        }
867
868        let hash = self.shape_hash(bounds);
869        if state.last_hash.get() != hash {
870            state.cache.clear();
871            state.last_hash.set(hash);
872        }
873
874        let geom = state
875            .cache
876            .draw(renderer, bounds.size(), |frame: &mut Frame| {
877                let inner_w = bounds.width.max(1.0);
878                let inner_h = bounds.height.max(1.0);
879                let visible_start = self.clip_offset_samples;
880                let visible_len = self.clip_visible_length_samples.max(1);
881                let visible_end = visible_start.saturating_add(visible_len);
882                let clip_len = visible_len as f32;
883                let pitch_span = f32::from(PITCH_MAX) + 1.0;
884                let note_color = Color::from_rgba(0.68, 0.92, 0.40, 0.82);
885                let note_edge = Color::from_rgba(0.86, 0.98, 0.62, 0.95);
886                let grid_major = Color::from_rgba(0.74, 0.95, 0.58, 0.14);
887                let grid_minor = Color::from_rgba(0.62, 0.86, 0.48, 0.07);
888                let horizon = Color::from_rgba(0.88, 0.98, 0.72, 0.22);
889
890                for step in 0..=16 {
891                    let x = (step as f32 / 16.0) * inner_w;
892                    let color = if step % 4 == 0 {
893                        grid_major
894                    } else {
895                        grid_minor
896                    };
897                    frame.stroke(
898                        &Path::line(Point::new(x, 0.0), Point::new(x, inner_h)),
899                        canvas::Stroke::default().with_color(color).with_width(1.0),
900                    );
901                }
902
903                for row in 0..=10 {
904                    let y = (row as f32 / 10.0) * inner_h;
905                    frame.stroke(
906                        &Path::line(Point::new(0.0, y), Point::new(inner_w, y)),
907                        canvas::Stroke::default()
908                            .with_color(if row % 2 == 0 { grid_minor } else { grid_major })
909                            .with_width(0.5),
910                    );
911                }
912                let horizon_y = inner_h * 0.84;
913                frame.stroke(
914                    &Path::line(Point::new(0.0, horizon_y), Point::new(inner_w, horizon_y)),
915                    canvas::Stroke::default()
916                        .with_color(horizon)
917                        .with_width(1.0),
918                );
919
920                for note in self.notes.iter() {
921                    let note_start = note.start_sample;
922                    let note_end = note.start_sample.saturating_add(note.length_samples.max(1));
923                    if note_end <= visible_start || note_start >= visible_end {
924                        continue;
925                    }
926                    let pitch = note.pitch.min(PITCH_MAX);
927                    let clipped_start = note_start.max(visible_start);
928                    let clipped_end = note_end.min(visible_end);
929                    let rel_start = if self.reversed {
930                        visible_end.saturating_sub(clipped_end)
931                    } else {
932                        clipped_start.saturating_sub(visible_start)
933                    };
934                    let rel_len = clipped_end.saturating_sub(clipped_start).max(1);
935                    let x = (rel_start as f32 / clip_len) * inner_w;
936                    let w = ((rel_len as f32 / clip_len) * inner_w).max(1.0);
937                    let pitch_pos = (i16::from(PITCH_MAX) - i16::from(pitch)) as f32 / pitch_span;
938                    let y = pitch_pos * inner_h;
939                    let h = (inner_h / pitch_span).clamp(1.0, 8.0);
940                    let rect = Path::rectangle(Point::new(x, y), iced::Size::new(w, h));
941                    frame.fill(&rect, note_color);
942                    frame.stroke(
943                        &rect,
944                        canvas::Stroke::default()
945                            .with_color(note_edge)
946                            .with_width(0.5),
947                    );
948                }
949            });
950
951        vec![geom]
952    }
953}
954
955fn midi_clip_notes_overlay<Message: 'static>(
956    notes: Arc<Vec<PianoNote>>,
957    clip_offset_samples: usize,
958    clip_visible_length_samples: usize,
959    reversed: bool,
960) -> Element<'static, Message> {
961    canvas(MidiClipNotesCanvas {
962        notes,
963        clip_offset_samples,
964        clip_visible_length_samples,
965        reversed,
966    })
967    .width(Length::Fill)
968    .height(Length::Fill)
969    .into()
970}
971
972fn audio_waveform_overlay<Message: 'static>(waveform: WaveformCanvas) -> Element<'static, Message> {
973    canvas(waveform)
974        .width(Length::Fill)
975        .height(Length::Fill)
976        .into()
977}
978
979fn resolve_audio_clip_path(session_root: Option<&PathBuf>, clip_name: &str) -> Option<PathBuf> {
980    let path = PathBuf::from(clip_name);
981    if path.is_absolute() {
982        Some(path)
983    } else {
984        session_root.map(|root| root.join(path))
985    }
986}
987
988fn grouped_audio_waveform_overlay<Message: 'static>(
989    clip: &AudioClipData,
990    session_root: Option<&PathBuf>,
991    pixels_per_sample: f32,
992    clip_height: f32,
993    log_scaled: bool,
994) -> Element<'static, Message> {
995    let mut stack = Stack::new();
996    for child in &clip.grouped_clips {
997        let child_width = (child.length as f32 * pixels_per_sample).max(12.0);
998        let child_overlay = if child.is_group() {
999            grouped_audio_waveform_overlay(
1000                child,
1001                session_root,
1002                pixels_per_sample,
1003                clip_height,
1004                log_scaled,
1005            )
1006        } else {
1007            audio_waveform_overlay(WaveformCanvas {
1008                peaks: child.peaks.clone(),
1009                source_wav_path: resolve_audio_clip_path(session_root, &child.name),
1010                clip_offset: child.offset,
1011                clip_length: child.length,
1012                max_length: child.max_length_samples,
1013                source_length: child.source_length_samples,
1014                stretch_ratio: child.stretch_ratio,
1015                reversed: child.reversed,
1016                log_scaled,
1017            })
1018        };
1019        stack = stack.push(
1020            pin(container(child_overlay)
1021                .width(Length::Fixed(child_width))
1022                .height(Length::Fixed(clip_height)))
1023            .position(Point::new(child.start as f32 * pixels_per_sample, 0.0)),
1024        );
1025    }
1026    container(stack)
1027        .width(Length::Fill)
1028        .height(Length::Fill)
1029        .into()
1030}
1031
1032#[derive(Clone, Copy)]
1033enum AudioClipMode {
1034    Widget,
1035    Preview,
1036}
1037
1038pub struct AudioClip<Message> {
1039    clip: AudioClipData,
1040    session_root: Option<PathBuf>,
1041    pixels_per_sample: f32,
1042    clip_width: f32,
1043    clip_height: f32,
1044    label: String,
1045    is_selected: bool,
1046    left_handle_hovered: bool,
1047    right_handle_hovered: bool,
1048    interaction: Option<AudioClipInteraction<Message>>,
1049    background: Option<Background>,
1050    border_color: Option<Color>,
1051    radius: f32,
1052    mode: AudioClipMode,
1053    base_color: Color,
1054    selected_base_color: Color,
1055    border: Color,
1056    selected_border: Color,
1057    resize_handle_width: f32,
1058    log_scaled: bool,
1059}
1060
1061impl<Message> AudioClip<Message> {
1062    pub fn clean_name(name: &str) -> String {
1063        clean_clip_name(name)
1064    }
1065
1066    pub fn label_for_width(label: &str, width_px: f32) -> String {
1067        trim_label_to_width(label, width_px)
1068    }
1069
1070    pub fn two_edge_gradient(
1071        base: Color,
1072        muted_alpha: f32,
1073        normal_alpha: f32,
1074        reverse: bool,
1075    ) -> Background {
1076        clip_two_edge_gradient(base, muted_alpha, normal_alpha, reverse)
1077    }
1078
1079    pub fn waveform_overlay(
1080        peaks: ClipPeaks,
1081        source_wav_path: Option<PathBuf>,
1082        clip_offset: usize,
1083        clip_length: usize,
1084        max_length: usize,
1085        source_length: usize,
1086    ) -> Element<'static, Message>
1087    where
1088        Message: 'static,
1089    {
1090        Self::waveform_overlay_scaled(
1091            peaks,
1092            source_wav_path,
1093            clip_offset,
1094            clip_length,
1095            max_length,
1096            source_length,
1097            false,
1098        )
1099    }
1100
1101    pub fn waveform_overlay_scaled(
1102        peaks: ClipPeaks,
1103        source_wav_path: Option<PathBuf>,
1104        clip_offset: usize,
1105        clip_length: usize,
1106        max_length: usize,
1107        source_length: usize,
1108        log_scaled: bool,
1109    ) -> Element<'static, Message>
1110    where
1111        Message: 'static,
1112    {
1113        audio_waveform_overlay(WaveformCanvas {
1114            peaks,
1115            source_wav_path,
1116            clip_offset,
1117            clip_length,
1118            max_length,
1119            source_length,
1120            stretch_ratio: 1.0,
1121            reversed: false,
1122            log_scaled,
1123        })
1124    }
1125}
1126
1127impl<Message: Clone + 'static> AudioClip<Message> {
1128    pub fn new(clip: AudioClipData) -> Self {
1129        Self {
1130            clip,
1131            session_root: None,
1132            pixels_per_sample: 1.0,
1133            clip_width: 12.0,
1134            clip_height: 8.0,
1135            label: String::new(),
1136            is_selected: false,
1137            left_handle_hovered: false,
1138            right_handle_hovered: false,
1139            interaction: None,
1140            background: None,
1141            border_color: None,
1142            radius: 8.0,
1143            mode: AudioClipMode::Widget,
1144            base_color: Color::from_rgb8(68, 88, 132),
1145            selected_base_color: Color::from_rgb8(96, 126, 186),
1146            border: Color::from_rgb8(78, 93, 130),
1147            selected_border: Color::from_rgb8(176, 218, 255),
1148            resize_handle_width: CLIP_RESIZE_HANDLE_WIDTH,
1149            log_scaled: false,
1150        }
1151    }
1152
1153    pub fn with_colors(
1154        mut self,
1155        base_color: Color,
1156        selected_base_color: Color,
1157        border: Color,
1158        selected_border: Color,
1159    ) -> Self {
1160        self.base_color = base_color;
1161        self.selected_base_color = selected_base_color;
1162        self.border = border;
1163        self.selected_border = selected_border;
1164        self
1165    }
1166
1167    pub fn with_session_root(mut self, session_root: Option<&PathBuf>) -> Self {
1168        self.session_root = session_root.cloned();
1169        self
1170    }
1171
1172    pub fn with_pixels_per_sample(mut self, pixels_per_sample: f32) -> Self {
1173        self.pixels_per_sample = pixels_per_sample;
1174        self
1175    }
1176
1177    pub fn with_size(mut self, clip_width: f32, clip_height: f32) -> Self {
1178        self.clip_width = clip_width;
1179        self.clip_height = clip_height;
1180        self
1181    }
1182
1183    pub fn with_label(mut self, label: String) -> Self {
1184        self.label = label;
1185        self
1186    }
1187
1188    pub fn selected(mut self, is_selected: bool) -> Self {
1189        self.is_selected = is_selected;
1190        self
1191    }
1192
1193    pub fn hovered_handles(mut self, left: bool, right: bool) -> Self {
1194        self.left_handle_hovered = left;
1195        self.right_handle_hovered = right;
1196        self
1197    }
1198
1199    pub fn interactive(mut self, interaction: AudioClipInteraction<Message>) -> Self {
1200        self.interaction = Some(interaction);
1201        self.mode = AudioClipMode::Widget;
1202        self
1203    }
1204
1205    pub fn preview(mut self, background: Background, border_color: Color) -> Self {
1206        self.background = Some(background);
1207        self.border_color = Some(border_color);
1208        self.mode = AudioClipMode::Preview;
1209        self
1210    }
1211
1212    pub fn log_scaled(mut self, log_scaled: bool) -> Self {
1213        self.log_scaled = log_scaled;
1214        self
1215    }
1216
1217    pub fn into_element(self) -> Element<'static, Message> {
1218        match self.mode {
1219            AudioClipMode::Preview => {
1220                let preview_content = container(Stack::with_children(vec![
1221                    audio_waveform_overlay(WaveformCanvas {
1222                        peaks: self.clip.peaks.clone(),
1223                        source_wav_path: resolve_audio_clip_path(
1224                            self.session_root.as_ref(),
1225                            &self.clip.name,
1226                        ),
1227                        clip_offset: self.clip.offset,
1228                        clip_length: self.clip.length,
1229                        max_length: self.clip.max_length_samples,
1230                        source_length: self.clip.source_length_samples,
1231                        stretch_ratio: self.clip.stretch_ratio,
1232                        reversed: self.clip.reversed,
1233                        log_scaled: self.log_scaled,
1234                    }),
1235                    audio_clip_label_overlay(
1236                        self.label,
1237                        self.clip_width,
1238                        self.clip_height,
1239                        self.radius,
1240                        self.clip.peaks.len(),
1241                    ),
1242                ]))
1243                .width(Length::Fill)
1244                .height(Length::Fill)
1245                .padding(0)
1246                .style(move |_theme| container::Style {
1247                    background: self.background,
1248                    ..container::Style::default()
1249                });
1250                container(preview_content)
1251                    .width(Length::Fixed(self.clip_width))
1252                    .height(Length::Fixed(self.clip_height))
1253                    .style(move |_theme| container::Style {
1254                        background: None,
1255                        border: Border {
1256                            color: self.border_color.unwrap_or(Color::TRANSPARENT),
1257                            width: 2.0,
1258                            radius: self.radius.into(),
1259                        },
1260                        ..container::Style::default()
1261                    })
1262                    .into()
1263            }
1264            AudioClipMode::Widget => {
1265                let interaction = self.interaction.expect("audio clip interaction");
1266                let clip_muted = self.clip.muted;
1267                // Keep a grabbable strip in the middle of very narrow clips.
1268                let resize_handle_width = self.resize_handle_width.min(self.clip_width / 2.0);
1269                let left_edge_zone = mouse_area(
1270                    Space::new()
1271                        .width(Length::Fixed(resize_handle_width))
1272                        .height(Length::Fill),
1273                )
1274                .interaction(mouse::Interaction::Pointer)
1275                .on_enter(interaction.edges.left_hover_enter.clone())
1276                .on_exit(interaction.edges.left_hover_exit.clone())
1277                .on_press(interaction.edges.left_press.clone());
1278                let right_edge_zone = mouse_area(
1279                    Space::new()
1280                        .width(Length::Fixed(resize_handle_width))
1281                        .height(Length::Fill),
1282                )
1283                .interaction(mouse::Interaction::Pointer)
1284                .on_enter(interaction.edges.right_hover_enter.clone())
1285                .on_exit(interaction.edges.right_hover_exit.clone())
1286                .on_press(interaction.edges.right_press.clone());
1287
1288                let clip_content = container(Stack::with_children(vec![
1289                    if self.clip.is_group() {
1290                        grouped_audio_waveform_overlay(
1291                            &self.clip,
1292                            self.session_root.as_ref(),
1293                            self.pixels_per_sample,
1294                            self.clip_height,
1295                            self.log_scaled,
1296                        )
1297                    } else {
1298                        audio_waveform_overlay(WaveformCanvas {
1299                            peaks: self.clip.peaks.clone(),
1300                            source_wav_path: resolve_audio_clip_path(
1301                                self.session_root.as_ref(),
1302                                &self.clip.name,
1303                            ),
1304                            clip_offset: self.clip.offset,
1305                            clip_length: self.clip.length,
1306                            max_length: self.clip.max_length_samples,
1307                            source_length: self.clip.source_length_samples,
1308                            stretch_ratio: self.clip.stretch_ratio,
1309                            reversed: self.clip.reversed,
1310                            log_scaled: self.log_scaled,
1311                        })
1312                    },
1313                    audio_clip_label_overlay(
1314                        self.label,
1315                        self.clip_width,
1316                        self.clip_height,
1317                        self.radius,
1318                        self.clip.peaks.len(),
1319                    ),
1320                ]))
1321                .width(Length::Fill)
1322                .height(Length::Fill)
1323                .padding(0)
1324                .style(move |_theme| {
1325                    let base = if self.is_selected {
1326                        self.selected_base_color
1327                    } else {
1328                        self.base_color
1329                    };
1330                    let normal_alpha = if self.is_selected {
1331                        CLIP_SELECTED_ALPHA
1332                    } else {
1333                        CLIP_NORMAL_ALPHA
1334                    };
1335                    let muted_alpha = if clip_muted {
1336                        CLIP_MUTED_ALPHA
1337                    } else {
1338                        normal_alpha
1339                    };
1340                    container::Style {
1341                        background: Some(clip_two_edge_gradient(
1342                            base,
1343                            muted_alpha,
1344                            normal_alpha,
1345                            self.clip.reversed,
1346                        )),
1347                        border: Border {
1348                            radius: 8.0.into(),
1349                            ..Default::default()
1350                        },
1351                        ..container::Style::default()
1352                    }
1353                });
1354
1355                let clip_widget = container(clip_content)
1356                    .width(Length::Fixed(self.clip_width))
1357                    .height(Length::Fixed(self.clip_height))
1358                    .style(move |_theme| container::Style {
1359                        background: None,
1360                        border: Border {
1361                            color: if self.is_selected {
1362                                self.selected_border
1363                            } else {
1364                                self.border
1365                            },
1366                            width: if self.is_selected { 2.0 } else { 1.0 },
1367                            radius: 8.0.into(),
1368                        },
1369                        ..container::Style::default()
1370                    });
1371
1372                let clip_with_fades: Element<'static, Message> = if self.clip.fade_enabled {
1373                    let fade_in_width = visible_fade_overlay_width(
1374                        self.clip.fade_in_samples,
1375                        self.pixels_per_sample,
1376                    );
1377                    let fade_out_width = visible_fade_overlay_width(
1378                        self.clip.fade_out_samples,
1379                        self.pixels_per_sample,
1380                    );
1381                    let mut stack = Stack::new().push(clip_widget);
1382                    if should_draw_fade_overlay(self.clip.fade_in_samples, self.pixels_per_sample) {
1383                        if let Some(message) = interaction.fade_in_press.clone() {
1384                            let fade_in_handle = mouse_area(
1385                                container("")
1386                                    .width(Length::Fixed(6.0))
1387                                    .height(Length::Fixed(6.0))
1388                                    .style(|_theme| container::Style {
1389                                        background: Some(Background::Color(Color::from_rgba(
1390                                            1.0, 1.0, 1.0, 0.9,
1391                                        ))),
1392                                        border: Border {
1393                                            color: Color::from_rgba(0.3, 0.3, 0.3, 1.0),
1394                                            width: 1.0,
1395                                            radius: 8.0.into(),
1396                                        },
1397                                        ..container::Style::default()
1398                                    }),
1399                            )
1400                            .on_press(message);
1401                            stack = stack.push(
1402                                pin(fade_in_handle).position(Point::new(fade_in_width - 3.0, -3.0)),
1403                            );
1404                        }
1405                        stack = stack.push(
1406                            pin(fade_bezier_overlay(
1407                                fade_in_width,
1408                                self.clip_height,
1409                                Color::from_rgba(0.0, 0.0, 0.0, 0.3),
1410                                false,
1411                            ))
1412                            .position(Point::new(0.0, 0.0)),
1413                        );
1414                    }
1415                    if should_draw_fade_overlay(self.clip.fade_out_samples, self.pixels_per_sample)
1416                    {
1417                        if let Some(message) = interaction.fade_out_press.clone() {
1418                            let fade_out_handle = mouse_area(
1419                                container("")
1420                                    .width(Length::Fixed(6.0))
1421                                    .height(Length::Fixed(6.0))
1422                                    .style(|_theme| container::Style {
1423                                        background: Some(Background::Color(Color::from_rgba(
1424                                            1.0, 1.0, 1.0, 0.9,
1425                                        ))),
1426                                        border: Border {
1427                                            color: Color::from_rgba(0.3, 0.3, 0.3, 1.0),
1428                                            width: 1.0,
1429                                            radius: 8.0.into(),
1430                                        },
1431                                        ..container::Style::default()
1432                                    }),
1433                            )
1434                            .on_press(message);
1435                            stack = stack.push(pin(fade_out_handle).position(Point::new(
1436                                self.clip_width - fade_out_width - 3.0,
1437                                -3.0,
1438                            )));
1439                        }
1440                        stack = stack.push(
1441                            pin(fade_bezier_overlay(
1442                                fade_out_width,
1443                                self.clip_height,
1444                                Color::from_rgba(0.0, 0.0, 0.0, 0.3),
1445                                true,
1446                            ))
1447                            .position(Point::new(self.clip_width - fade_out_width, 0.0)),
1448                        );
1449                    }
1450                    stack.into()
1451                } else {
1452                    clip_widget.into()
1453                };
1454
1455                // Keep resize zones above waveform, label, and fade overlays so
1456                // their cursor and press handling cannot be shadowed by a
1457                // visual layer.
1458                let interactive_clip = Stack::with_children(vec![
1459                    clip_with_fades,
1460                    pin(left_edge_zone).position(Point::new(0.0, 0.0)).into(),
1461                    pin(right_edge_zone)
1462                        .position(Point::new(self.clip_width - resize_handle_width, 0.0))
1463                        .into(),
1464                ]);
1465                let base = mouse_area(interactive_clip);
1466                let base = if self.left_handle_hovered || self.right_handle_hovered {
1467                    base.interaction(mouse::Interaction::Pointer)
1468                } else {
1469                    base
1470                };
1471                let base = base
1472                    .on_press(interaction.on_select)
1473                    .on_double_click(interaction.on_open);
1474                if let Some(on_drag) = interaction.on_drag {
1475                    base.on_move(move |point| on_drag(point)).into()
1476                } else {
1477                    base.into()
1478                }
1479            }
1480        }
1481    }
1482}
1483
1484#[derive(Clone, Copy)]
1485enum MIDIClipMode {
1486    Widget,
1487    Preview,
1488}
1489
1490pub struct MIDIClip<Message> {
1491    clip: MIDIClipData,
1492    clip_width: f32,
1493    clip_height: f32,
1494    label: String,
1495    is_selected: bool,
1496    left_handle_hovered: bool,
1497    right_handle_hovered: bool,
1498    midi_notes: Option<Arc<Vec<PianoNote>>>,
1499    interaction: Option<MIDIClipInteraction<Message>>,
1500    background: Option<Background>,
1501    border_color: Option<Color>,
1502    radius: f32,
1503    mode: MIDIClipMode,
1504    base_color: Color,
1505    selected_base_color: Color,
1506    border: Color,
1507    selected_border: Color,
1508    resize_handle_width: f32,
1509}
1510
1511impl<Message> MIDIClip<Message> {
1512    pub fn clean_name(name: &str) -> String {
1513        clean_clip_name(name)
1514    }
1515
1516    pub fn label_for_width(label: &str, width_px: f32) -> String {
1517        trim_label_to_width(label, width_px)
1518    }
1519
1520    pub fn two_edge_gradient(
1521        base: Color,
1522        muted_alpha: f32,
1523        normal_alpha: f32,
1524        reverse: bool,
1525    ) -> Background {
1526        clip_two_edge_gradient(base, muted_alpha, normal_alpha, reverse)
1527    }
1528}
1529
1530impl<Message: Clone + 'static> MIDIClip<Message> {
1531    pub fn new(clip: MIDIClipData) -> Self {
1532        Self {
1533            clip,
1534            clip_width: 12.0,
1535            clip_height: 8.0,
1536            label: String::new(),
1537            is_selected: false,
1538            left_handle_hovered: false,
1539            right_handle_hovered: false,
1540            midi_notes: None,
1541            interaction: None,
1542            background: None,
1543            border_color: None,
1544            radius: 8.0,
1545            mode: MIDIClipMode::Widget,
1546            base_color: Color::from_rgb8(55, 90, 50),
1547            selected_base_color: Color::from_rgb8(84, 133, 72),
1548            border: Color::from_rgb8(148, 215, 118),
1549            selected_border: Color::from_rgb8(196, 255, 151),
1550            resize_handle_width: CLIP_RESIZE_HANDLE_WIDTH,
1551        }
1552    }
1553
1554    pub fn with_colors(
1555        mut self,
1556        base_color: Color,
1557        selected_base_color: Color,
1558        border: Color,
1559        selected_border: Color,
1560    ) -> Self {
1561        self.base_color = base_color;
1562        self.selected_base_color = selected_base_color;
1563        self.border = border;
1564        self.selected_border = selected_border;
1565        self
1566    }
1567
1568    pub fn with_size(mut self, clip_width: f32, clip_height: f32) -> Self {
1569        self.clip_width = clip_width;
1570        self.clip_height = clip_height;
1571        self
1572    }
1573
1574    pub fn with_label(mut self, label: String) -> Self {
1575        self.label = label;
1576        self
1577    }
1578
1579    pub fn selected(mut self, is_selected: bool) -> Self {
1580        self.is_selected = is_selected;
1581        self
1582    }
1583
1584    pub fn hovered_handles(mut self, left: bool, right: bool) -> Self {
1585        self.left_handle_hovered = left;
1586        self.right_handle_hovered = right;
1587        self
1588    }
1589
1590    pub fn with_notes(mut self, midi_notes: Option<Arc<Vec<PianoNote>>>) -> Self {
1591        self.midi_notes = midi_notes;
1592        self
1593    }
1594
1595    pub fn interactive(mut self, interaction: MIDIClipInteraction<Message>) -> Self {
1596        self.interaction = Some(interaction);
1597        self.mode = MIDIClipMode::Widget;
1598        self
1599    }
1600
1601    pub fn preview(mut self, background: Background, border_color: Color, radius: f32) -> Self {
1602        self.background = Some(background);
1603        self.border_color = Some(border_color);
1604        self.radius = radius;
1605        self.mode = MIDIClipMode::Preview;
1606        self
1607    }
1608
1609    pub fn into_element(self) -> Element<'static, Message> {
1610        match self.mode {
1611            MIDIClipMode::Preview => {
1612                let mut preview_layers = Vec::with_capacity(2);
1613                if let Some(notes) = self.midi_notes {
1614                    preview_layers.push(midi_clip_notes_overlay(
1615                        notes,
1616                        self.clip.offset,
1617                        self.clip.length.max(1),
1618                        self.clip.reversed,
1619                    ));
1620                }
1621                preview_layers.push(midi_clip_label_overlay(
1622                    self.label,
1623                    self.clip_width,
1624                    self.clip_height,
1625                    self.radius,
1626                ));
1627                let preview_content = container(Stack::with_children(preview_layers))
1628                    .width(Length::Fill)
1629                    .height(Length::Fill)
1630                    .padding(0)
1631                    .style(move |_theme| container::Style {
1632                        background: self.background,
1633                        ..container::Style::default()
1634                    });
1635                container(preview_content)
1636                    .width(Length::Fixed(self.clip_width))
1637                    .height(Length::Fixed(self.clip_height))
1638                    .style(move |_theme| container::Style {
1639                        background: None,
1640                        border: Border {
1641                            color: self.border_color.unwrap_or(Color::TRANSPARENT),
1642                            width: 2.0,
1643                            radius: self.radius.into(),
1644                        },
1645                        ..container::Style::default()
1646                    })
1647                    .into()
1648            }
1649            MIDIClipMode::Widget => {
1650                let interaction = self.interaction.expect("midi clip interaction");
1651                // Keep a grabbable strip in the middle of very narrow clips.
1652                let resize_handle_width = self.resize_handle_width.min(self.clip_width / 2.0);
1653                let left_edge_zone = mouse_area(
1654                    Space::new()
1655                        .width(Length::Fixed(resize_handle_width))
1656                        .height(Length::Fill),
1657                )
1658                .interaction(mouse::Interaction::Pointer)
1659                .on_enter(interaction.edges.left_hover_enter.clone())
1660                .on_exit(interaction.edges.left_hover_exit.clone())
1661                .on_press(interaction.edges.left_press.clone());
1662                let right_edge_zone = mouse_area(
1663                    Space::new()
1664                        .width(Length::Fixed(resize_handle_width))
1665                        .height(Length::Fill),
1666                )
1667                .interaction(mouse::Interaction::Pointer)
1668                .on_enter(interaction.edges.right_hover_enter.clone())
1669                .on_exit(interaction.edges.right_hover_exit.clone())
1670                .on_press(interaction.edges.right_press.clone());
1671
1672                let mut clip_layers = Vec::with_capacity(2);
1673                if let Some(notes) = self.midi_notes {
1674                    clip_layers.push(midi_clip_notes_overlay(
1675                        notes,
1676                        self.clip.offset,
1677                        self.clip.length.max(1),
1678                        self.clip.reversed,
1679                    ));
1680                }
1681                clip_layers.push(midi_clip_label_overlay(
1682                    self.label,
1683                    self.clip_width,
1684                    self.clip_height,
1685                    self.radius,
1686                ));
1687
1688                let clip_muted = self.clip.muted;
1689                let clip_widget = container(
1690                    container(Stack::with_children(clip_layers))
1691                        .width(Length::Fill)
1692                        .height(Length::Fill)
1693                        .padding(0)
1694                        .style(move |_theme| {
1695                            let base = if self.is_selected {
1696                                self.selected_base_color
1697                            } else {
1698                                self.base_color
1699                            };
1700                            let normal_alpha = if self.is_selected {
1701                                CLIP_SELECTED_ALPHA
1702                            } else {
1703                                CLIP_NORMAL_ALPHA
1704                            };
1705                            let muted_alpha = if clip_muted {
1706                                CLIP_MUTED_ALPHA
1707                            } else {
1708                                normal_alpha
1709                            };
1710                            container::Style {
1711                                background: Some(clip_two_edge_gradient(
1712                                    base,
1713                                    muted_alpha,
1714                                    normal_alpha,
1715                                    self.clip.reversed,
1716                                )),
1717                                border: Border {
1718                                    radius: 8.0.into(),
1719                                    ..Default::default()
1720                                },
1721                                ..container::Style::default()
1722                            }
1723                        }),
1724                )
1725                .width(Length::Fixed(self.clip_width))
1726                .height(Length::Fixed(self.clip_height))
1727                .style(move |_theme| container::Style {
1728                    background: None,
1729                    border: Border {
1730                        color: if self.is_selected {
1731                            self.selected_border
1732                        } else {
1733                            self.border
1734                        },
1735                        width: if self.is_selected { 2.2 } else { 1.4 },
1736                        radius: 8.0.into(),
1737                    },
1738                    ..container::Style::default()
1739                });
1740
1741                let interactive_clip = Stack::with_children(vec![
1742                    clip_widget.into(),
1743                    pin(left_edge_zone).position(Point::new(0.0, 0.0)).into(),
1744                    pin(right_edge_zone)
1745                        .position(Point::new(self.clip_width - resize_handle_width, 0.0))
1746                        .into(),
1747                ]);
1748                let base = mouse_area(interactive_clip);
1749                let base = if self.left_handle_hovered || self.right_handle_hovered {
1750                    base.interaction(mouse::Interaction::Pointer)
1751                } else {
1752                    base
1753                };
1754                let base = base
1755                    .on_press(interaction.on_select)
1756                    .on_double_click(interaction.on_open);
1757                if let Some(on_drag) = interaction.on_drag {
1758                    base.on_move(move |point| on_drag(point)).into()
1759                } else {
1760                    base.into()
1761                }
1762            }
1763        }
1764    }
1765}
1766
1767#[cfg(test)]
1768mod tests {
1769    use super::{WaveformCanvas, should_draw_fade_overlay, visible_fade_overlay_width};
1770
1771    #[test]
1772    fn log_scale_sample_maps_full_scale_to_unity() {
1773        assert_eq!(WaveformCanvas::log_scale_sample(1.0), 1.0);
1774        assert_eq!(WaveformCanvas::log_scale_sample(-1.0), -1.0);
1775        assert_eq!(WaveformCanvas::log_scale_sample(0.0), 0.0);
1776    }
1777
1778    #[test]
1779    fn log_scale_sample_boosts_quiet_material() {
1780        let minus_18_db = 10.0_f32.powf(-18.0 / 20.0);
1781        // (174/192)^8, per the alt_log_meter shape.
1782        let expected = (174.0_f32 / 192.0).powf(8.0);
1783        let scaled = WaveformCanvas::log_scale_sample(minus_18_db);
1784        assert!((scaled - expected).abs() < 1.0e-4, "scaled = {scaled}");
1785        assert!(scaled > minus_18_db, "log scale must boost quiet material");
1786        let scaled_neg = WaveformCanvas::log_scale_sample(-minus_18_db);
1787        assert!(
1788            (scaled_neg + expected).abs() < 1.0e-4,
1789            "scaled_neg = {scaled_neg}"
1790        );
1791    }
1792
1793    #[test]
1794    fn log_scale_sample_clamps_out_of_range_input() {
1795        assert!(WaveformCanvas::log_scale_sample(4.0) <= 1.0);
1796        assert!(WaveformCanvas::log_scale_sample(-4.0) >= -1.0);
1797        // Subnormal amplitudes below -192 dB floor render as silence.
1798        assert_eq!(WaveformCanvas::log_scale_sample(1.0e-12), 0.0);
1799    }
1800
1801    #[test]
1802    fn visible_fade_overlay_width_grows_with_zoom_below_full_size() {
1803        let low_zoom = visible_fade_overlay_width(240, 0.01);
1804        let higher_zoom = visible_fade_overlay_width(240, 0.02);
1805
1806        assert!(higher_zoom > low_zoom);
1807        assert!((low_zoom - 2.4).abs() < 1.0e-5);
1808    }
1809
1810    #[test]
1811    fn visible_fade_overlay_width_matches_actual_size_once_large_enough() {
1812        let width = visible_fade_overlay_width(240, 0.1);
1813        assert_eq!(width, 24.0);
1814    }
1815
1816    #[test]
1817    fn should_draw_fade_overlay_hides_tiny_fades() {
1818        assert!(!should_draw_fade_overlay(240, 0.0125));
1819        assert!(should_draw_fade_overlay(240, 0.0126));
1820    }
1821}