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}
375
376impl WaveformCanvas {
377    fn shape_hash(&self, bounds: Rectangle) -> u64 {
378        let mut hasher = std::collections::hash_map::DefaultHasher::new();
379        bounds.width.to_bits().hash(&mut hasher);
380        bounds.height.to_bits().hash(&mut hasher);
381        self.clip_offset.hash(&mut hasher);
382        self.clip_length.hash(&mut hasher);
383        self.max_length.hash(&mut hasher);
384        self.source_length.hash(&mut hasher);
385        self.stretch_ratio.to_bits().hash(&mut hasher);
386        self.reversed.hash(&mut hasher);
387        self.peaks.len().hash(&mut hasher);
388        for channel in self.peaks.iter() {
389            channel.len().hash(&mut hasher);
390            if channel.is_empty() {
391                continue;
392            }
393            for i in 0..CHECKPOINTS {
394                let idx = (i * channel.len()) / CHECKPOINTS;
395                let sample = channel[idx.min(channel.len() - 1)];
396                sample[0].to_bits().hash(&mut hasher);
397                sample[1].to_bits().hash(&mut hasher);
398            }
399        }
400        hasher.finish()
401    }
402
403    fn aggregate_column_peak(
404        channel_peaks: &[[f32; 2]],
405        src_start: usize,
406        src_end: usize,
407    ) -> Option<(f32, f32)> {
408        if src_start >= src_end || src_end > channel_peaks.len() {
409            return None;
410        }
411        let mut min_val = 1.0_f32;
412        let mut max_val = -1.0_f32;
413        for pair in &channel_peaks[src_start..src_end] {
414            min_val = min_val.min(pair[0].clamp(-1.0, 1.0));
415            max_val = max_val.max(pair[1].clamp(-1.0, 1.0));
416        }
417        Some((min_val, max_val))
418    }
419
420    fn source_column_peaks(
421        source_wav_path: &std::path::Path,
422        channel_count: usize,
423        source_start_sample: usize,
424        source_end_sample: usize,
425        total_columns: usize,
426    ) -> Option<Vec<Vec<[f32; 2]>>> {
427        if total_columns == 0 || source_end_sample <= source_start_sample || channel_count == 0 {
428            return None;
429        }
430        let (samples, wav_channels, _sample_rate) = read_wav_f32(source_wav_path).ok()?;
431        let use_channels = channel_count.min(wav_channels).max(1);
432        let total_frames = samples.len() / wav_channels;
433        if source_start_sample >= total_frames {
434            return None;
435        }
436        let read_end = source_end_sample.min(total_frames);
437        let read_frames = read_end.saturating_sub(source_start_sample);
438        if read_frames == 0 {
439            return None;
440        }
441
442        let start_sample = source_start_sample.saturating_mul(wav_channels);
443        let end_sample = read_end.saturating_mul(wav_channels);
444        let chunk = &samples[start_sample..end_sample];
445        if chunk.is_empty() {
446            return None;
447        }
448
449        let mut out = vec![vec![[0.0_f32, 0.0_f32]; total_columns]; channel_count];
450        for col in 0..total_columns {
451            let frame_start = (col * read_frames) / total_columns;
452            let mut frame_end = ((col + 1) * read_frames) / total_columns;
453            if frame_end <= frame_start {
454                frame_end = (frame_start + 1).min(read_frames);
455            }
456            if frame_start >= frame_end {
457                continue;
458            }
459            for (ch, out_channel) in out.iter_mut().enumerate().take(use_channels) {
460                let mut min_val = 1.0_f32;
461                let mut max_val = -1.0_f32;
462                for frame_idx in frame_start..frame_end {
463                    let sample_idx = frame_idx.saturating_mul(wav_channels).saturating_add(ch);
464                    let s = chunk
465                        .get(sample_idx)
466                        .copied()
467                        .unwrap_or(0.0)
468                        .clamp(-1.0, 1.0);
469                    min_val = min_val.min(s);
470                    max_val = max_val.max(s);
471                }
472                out_channel[col] = [min_val, max_val];
473            }
474        }
475
476        Some(out)
477    }
478}
479
480impl<Message> canvas::Program<Message> for WaveformCanvas {
481    type State = WaveformCanvasState;
482
483    fn draw(
484        &self,
485        state: &Self::State,
486        renderer: &Renderer,
487        _theme: &Theme,
488        bounds: Rectangle,
489        _cursor: mouse::Cursor,
490    ) -> Vec<Geometry> {
491        if self.peaks.is_empty() || bounds.width <= 0.0 || bounds.height <= 0.0 {
492            return vec![];
493        }
494
495        let hash = self.shape_hash(bounds);
496        if state.last_hash.get() != hash {
497            state.cache.clear();
498            state.last_hash.set(hash);
499        }
500
501        let geom = state
502            .cache
503            .draw(renderer, bounds.size(), |frame: &mut Frame| {
504                let inner_w = bounds.width.max(4.0);
505                let inner_h = bounds.height.max(4.0);
506                let channel_count = self.peaks.len().max(1);
507                let channel_h = inner_h / channel_count as f32;
508                let waveform_fill = Color::from_rgba(0.86, 0.94, 1.0, 0.34);
509                let waveform_edge = Color::from_rgba(0.96, 0.98, 1.0, 0.62);
510                let zero_line = Color::from_rgba(0.74, 0.86, 1.0, 0.28);
511                let clip_color = Color::from_rgba(1.0, 0.42, 0.30, 0.78);
512                let clip_level = 0.90_f32;
513                let edge_shade = darken(waveform_fill, 0.08);
514
515                for (channel_idx, channel_peaks) in self.peaks.iter().enumerate() {
516                    if channel_peaks.is_empty() {
517                        continue;
518                    }
519                    let channel_top = channel_h * channel_idx as f32;
520                    let center_y = channel_top + channel_h * 0.5;
521                    let half_span = (channel_h * 0.45).max(1.0);
522                    let total_peaks = channel_peaks.len();
523                    let max_len = if self.source_length > 0 {
524                        self.source_length
525                    } else {
526                        self.max_length
527                    }
528                    .max(1);
529                    let start_idx = ((self.clip_offset * total_peaks) / max_len)
530                        .min(total_peaks.saturating_sub(1));
531                    let effective_length = if self.stretch_ratio > 0.0 && self.stretch_ratio != 1.0
532                    {
533                        ((self.clip_length as f32 / self.stretch_ratio).ceil() as usize).max(1)
534                    } else {
535                        self.clip_length
536                    };
537                    let clip_end_sample = self
538                        .clip_offset
539                        .saturating_add(effective_length)
540                        .min(max_len);
541                    let mut end_idx = ((clip_end_sample * total_peaks) / max_len).min(total_peaks);
542                    if end_idx <= start_idx {
543                        end_idx = (start_idx + 1).min(total_peaks);
544                    }
545                    let visible_bins = end_idx.saturating_sub(start_idx).max(1);
546                    // Column counts are decimated (one column per
547                    // COLUMN_WIDTH_PX pixels), but zoom-dependent
548                    // trace/source-mode thresholds stay expressed in screen
549                    // pixels so rendering-mode decisions are unchanged.
550                    let pixel_columns = inner_w.ceil().max(1.0) as usize;
551                    let visible_columns = (inner_w / COLUMN_WIDTH_PX)
552                        .ceil()
553                        .max(1.0)
554                        .min(MAX_RENDER_COLUMNS as f32)
555                        as usize;
556                    let x_step = inner_w / visible_columns as f32;
557                    let margin_columns = RENDER_MARGIN_COLUMNS;
558                    let total_columns = visible_columns + (margin_columns * 2);
559                    let margin_bins = ((visible_bins * margin_columns * COLUMN_WIDTH_PX as usize)
560                        / pixel_columns)
561                        .max(1);
562                    let render_start_idx = start_idx.saturating_sub(margin_bins);
563                    let render_end_idx = end_idx.saturating_add(margin_bins).min(total_peaks);
564                    let render_bins = render_end_idx.saturating_sub(render_start_idx).max(1);
565                    let stored_samples_per_bin = max_len as f32 / total_peaks.max(1) as f32;
566                    let visible_source_samples =
567                        clip_end_sample.saturating_sub(self.clip_offset).max(1);
568                    let required_samples_per_px =
569                        visible_source_samples as f32 / pixel_columns.max(1) as f32;
570                    let high_zoom_source_mode = required_samples_per_px < 1.0;
571                    let trace_mode = high_zoom_source_mode
572                        || required_samples_per_px <= 4.0
573                        || visible_bins <= pixel_columns.saturating_mul(2);
574                    let use_source_columns = self.source_wav_path.is_some()
575                        && required_samples_per_px + f32::EPSILON < stored_samples_per_bin;
576                    let mut source_mode_columns = total_columns;
577                    let mut source_mode_margin = margin_columns;
578                    let mut source_mode_x_step = x_step;
579                    let mut source_mode_bin_w = x_step.max(1.0);
580                    let source_columns = if use_source_columns {
581                        let source_margin_samples = if high_zoom_source_mode {
582                            margin_columns * COLUMN_WIDTH_PX as usize
583                        } else {
584                            ((visible_source_samples * margin_columns * COLUMN_WIDTH_PX as usize)
585                                / pixel_columns)
586                                .max(1)
587                        };
588                        if high_zoom_source_mode {
589                            source_mode_columns =
590                                visible_source_samples + (source_margin_samples * 2);
591                            source_mode_margin = source_margin_samples;
592                            source_mode_x_step = inner_w / visible_source_samples.max(1) as f32;
593                            source_mode_bin_w = 1.0;
594                        }
595                        let source_start = self.clip_offset.saturating_sub(source_margin_samples);
596                        let source_end = clip_end_sample.saturating_add(source_margin_samples).min(
597                            if self.source_length > 0 {
598                                self.source_length
599                            } else {
600                                self.max_length
601                            }
602                            .max(1),
603                        );
604                        self.source_wav_path.as_ref().and_then(|path| {
605                            Self::source_column_peaks(
606                                path,
607                                self.peaks.len(),
608                                source_start,
609                                source_end,
610                                source_mode_columns,
611                            )
612                        })
613                    } else {
614                        None
615                    };
616
617                    frame.fill(
618                        &Path::rectangle(Point::new(0.0, center_y), iced::Size::new(inner_w, 1.0)),
619                        zero_line,
620                    );
621
622                    let draw_columns = if source_columns.is_some() {
623                        source_mode_columns
624                    } else {
625                        total_columns
626                    };
627                    if trace_mode {
628                        let trace = Path::new(|builder| {
629                            let mut started = false;
630                            for col in 0..draw_columns {
631                                let pair = if let Some(columns) = source_columns.as_ref() {
632                                    columns
633                                        .get(channel_idx)
634                                        .and_then(|ch| ch.get(col))
635                                        .copied()
636                                        .unwrap_or([0.0, 0.0])
637                                } else {
638                                    let visual_col = if self.reversed {
639                                        draw_columns.saturating_sub(col + 1)
640                                    } else {
641                                        col
642                                    };
643                                    let src_start = render_start_idx
644                                        + ((visual_col * render_bins) / draw_columns)
645                                            .min(render_bins);
646                                    let mut src_end = render_start_idx
647                                        + (((visual_col + 1) * render_bins) / draw_columns)
648                                            .min(render_bins);
649                                    if src_end <= src_start {
650                                        src_end = (src_start + 1).min(total_peaks);
651                                    }
652                                    let pair = Self::aggregate_column_peak(
653                                        channel_peaks,
654                                        src_start,
655                                        src_end,
656                                    )
657                                    .unwrap_or((0.0, 0.0));
658                                    [pair.0, pair.1]
659                                };
660                                let sample = ((pair[0] + pair[1]) * 0.5).clamp(-1.0, 1.0);
661                                let x = if source_columns.is_some() {
662                                    (col as f32 - source_mode_margin as f32) * source_mode_x_step
663                                } else {
664                                    (col as f32 - margin_columns as f32) * x_step
665                                };
666                                let y = (center_y - (sample * half_span))
667                                    .clamp(channel_top, channel_top + channel_h);
668                                if !started {
669                                    builder.move_to(Point::new(x, y));
670                                    started = true;
671                                } else {
672                                    builder.line_to(Point::new(x, y));
673                                }
674                            }
675                        });
676                        frame.stroke(
677                            &trace,
678                            canvas::Stroke::default()
679                                .with_color(waveform_edge)
680                                .with_width(1.0),
681                        );
682                        continue;
683                    }
684
685                    for col in 0..draw_columns {
686                        let visual_col = if self.reversed {
687                            draw_columns.saturating_sub(col + 1)
688                        } else {
689                            col
690                        };
691                        let (min_val, max_val) = if let Some(columns) = source_columns.as_ref() {
692                            let pair = columns
693                                .get(channel_idx)
694                                .and_then(|ch| ch.get(visual_col))
695                                .copied()
696                                .unwrap_or([0.0, 0.0]);
697                            (pair[0], pair[1])
698                        } else {
699                            let src_start = render_start_idx
700                                + ((visual_col * render_bins) / total_columns).min(render_bins);
701                            let mut src_end = render_start_idx
702                                + (((visual_col + 1) * render_bins) / total_columns)
703                                    .min(render_bins);
704                            if src_end <= src_start {
705                                src_end = (src_start + 1).min(total_peaks);
706                            }
707                            let Some(pair) =
708                                Self::aggregate_column_peak(channel_peaks, src_start, src_end)
709                            else {
710                                continue;
711                            };
712                            pair
713                        };
714                        let top = (center_y - (max_val * half_span))
715                            .clamp(channel_top, channel_top + channel_h);
716                        let bottom = (center_y - (min_val * half_span))
717                            .clamp(channel_top, channel_top + channel_h);
718                        let y = top.min(bottom);
719                        let h = (bottom - top).abs().max(1.0);
720                        let (x, bin_w) = if source_columns.is_some() {
721                            (
722                                (col as f32 - source_mode_margin as f32) * source_mode_x_step,
723                                source_mode_bin_w,
724                            )
725                        } else {
726                            (
727                                (col as f32 - margin_columns as f32) * x_step,
728                                x_step.max(1.0),
729                            )
730                        };
731
732                        frame.fill(
733                            &Path::rectangle(Point::new(x, y), iced::Size::new(bin_w, h)),
734                            waveform_fill,
735                        );
736                        let edge_h = (h * 0.2).clamp(1.0, 3.0);
737                        let shade_path = Path::new(|builder| {
738                            builder.rectangle(Point::new(x, y), iced::Size::new(bin_w, edge_h));
739                            builder.rectangle(
740                                Point::new(x, y + h - edge_h),
741                                iced::Size::new(bin_w, edge_h),
742                            );
743                        });
744                        frame.fill(&shade_path, edge_shade);
745
746                        if h >= 3.0 {
747                            let edge_path = Path::new(|builder| {
748                                builder.rectangle(Point::new(x, y), iced::Size::new(bin_w, 1.0));
749                                builder.rectangle(
750                                    Point::new(x, y + h - 1.0),
751                                    iced::Size::new(bin_w, 1.0),
752                                );
753                            });
754                            frame.fill(&edge_path, waveform_edge);
755                        }
756
757                        if max_val >= clip_level || -min_val >= clip_level {
758                            let clip_h = h.clamp(1.0, 3.0);
759                            let clip_path = Path::new(|builder| {
760                                if max_val >= clip_level {
761                                    builder.rectangle(
762                                        Point::new(x, y),
763                                        iced::Size::new(bin_w, clip_h),
764                                    );
765                                }
766                                if -min_val >= clip_level {
767                                    builder.rectangle(
768                                        Point::new(x, y + h - clip_h),
769                                        iced::Size::new(bin_w, clip_h),
770                                    );
771                                }
772                            });
773                            frame.fill(&clip_path, clip_color);
774                        }
775                    }
776                }
777            });
778        vec![geom]
779    }
780}
781
782#[derive(Default)]
783struct MidiClipNotesCanvasState {
784    cache: canvas::Cache,
785    last_hash: Cell<u64>,
786}
787
788#[derive(Clone)]
789struct MidiClipNotesCanvas {
790    notes: Arc<Vec<PianoNote>>,
791    clip_offset_samples: usize,
792    clip_visible_length_samples: usize,
793    reversed: bool,
794}
795
796impl MidiClipNotesCanvas {
797    fn shape_hash(&self, bounds: Rectangle) -> u64 {
798        let mut hasher = std::collections::hash_map::DefaultHasher::new();
799        bounds.width.to_bits().hash(&mut hasher);
800        bounds.height.to_bits().hash(&mut hasher);
801        self.clip_offset_samples.hash(&mut hasher);
802        self.clip_visible_length_samples.hash(&mut hasher);
803        self.reversed.hash(&mut hasher);
804        self.notes.len().hash(&mut hasher);
805        if let Some(first) = self.notes.first() {
806            first.start_sample.hash(&mut hasher);
807            first.length_samples.hash(&mut hasher);
808            first.pitch.hash(&mut hasher);
809            first.velocity.hash(&mut hasher);
810        }
811        if let Some(last) = self.notes.last() {
812            last.start_sample.hash(&mut hasher);
813            last.length_samples.hash(&mut hasher);
814            last.pitch.hash(&mut hasher);
815            last.velocity.hash(&mut hasher);
816        }
817        hasher.finish()
818    }
819}
820
821impl<Message> canvas::Program<Message> for MidiClipNotesCanvas {
822    type State = MidiClipNotesCanvasState;
823
824    fn draw(
825        &self,
826        state: &Self::State,
827        renderer: &Renderer,
828        _theme: &Theme,
829        bounds: Rectangle,
830        _cursor: mouse::Cursor,
831    ) -> Vec<Geometry> {
832        if self.notes.is_empty() || bounds.width <= 0.0 || bounds.height <= 0.0 {
833            return vec![];
834        }
835
836        let hash = self.shape_hash(bounds);
837        if state.last_hash.get() != hash {
838            state.cache.clear();
839            state.last_hash.set(hash);
840        }
841
842        let geom = state
843            .cache
844            .draw(renderer, bounds.size(), |frame: &mut Frame| {
845                let inner_w = bounds.width.max(1.0);
846                let inner_h = bounds.height.max(1.0);
847                let visible_start = self.clip_offset_samples;
848                let visible_len = self.clip_visible_length_samples.max(1);
849                let visible_end = visible_start.saturating_add(visible_len);
850                let clip_len = visible_len as f32;
851                let pitch_span = f32::from(PITCH_MAX) + 1.0;
852                let note_color = Color::from_rgba(0.68, 0.92, 0.40, 0.82);
853                let note_edge = Color::from_rgba(0.86, 0.98, 0.62, 0.95);
854                let grid_major = Color::from_rgba(0.74, 0.95, 0.58, 0.14);
855                let grid_minor = Color::from_rgba(0.62, 0.86, 0.48, 0.07);
856                let horizon = Color::from_rgba(0.88, 0.98, 0.72, 0.22);
857
858                for step in 0..=16 {
859                    let x = (step as f32 / 16.0) * inner_w;
860                    let color = if step % 4 == 0 {
861                        grid_major
862                    } else {
863                        grid_minor
864                    };
865                    frame.stroke(
866                        &Path::line(Point::new(x, 0.0), Point::new(x, inner_h)),
867                        canvas::Stroke::default().with_color(color).with_width(1.0),
868                    );
869                }
870
871                for row in 0..=10 {
872                    let y = (row as f32 / 10.0) * inner_h;
873                    frame.stroke(
874                        &Path::line(Point::new(0.0, y), Point::new(inner_w, y)),
875                        canvas::Stroke::default()
876                            .with_color(if row % 2 == 0 { grid_minor } else { grid_major })
877                            .with_width(0.5),
878                    );
879                }
880                let horizon_y = inner_h * 0.84;
881                frame.stroke(
882                    &Path::line(Point::new(0.0, horizon_y), Point::new(inner_w, horizon_y)),
883                    canvas::Stroke::default()
884                        .with_color(horizon)
885                        .with_width(1.0),
886                );
887
888                for note in self.notes.iter() {
889                    let note_start = note.start_sample;
890                    let note_end = note.start_sample.saturating_add(note.length_samples.max(1));
891                    if note_end <= visible_start || note_start >= visible_end {
892                        continue;
893                    }
894                    let pitch = note.pitch.min(PITCH_MAX);
895                    let clipped_start = note_start.max(visible_start);
896                    let clipped_end = note_end.min(visible_end);
897                    let rel_start = if self.reversed {
898                        visible_end.saturating_sub(clipped_end)
899                    } else {
900                        clipped_start.saturating_sub(visible_start)
901                    };
902                    let rel_len = clipped_end.saturating_sub(clipped_start).max(1);
903                    let x = (rel_start as f32 / clip_len) * inner_w;
904                    let w = ((rel_len as f32 / clip_len) * inner_w).max(1.0);
905                    let pitch_pos = (i16::from(PITCH_MAX) - i16::from(pitch)) as f32 / pitch_span;
906                    let y = pitch_pos * inner_h;
907                    let h = (inner_h / pitch_span).clamp(1.0, 8.0);
908                    let rect = Path::rectangle(Point::new(x, y), iced::Size::new(w, h));
909                    frame.fill(&rect, note_color);
910                    frame.stroke(
911                        &rect,
912                        canvas::Stroke::default()
913                            .with_color(note_edge)
914                            .with_width(0.5),
915                    );
916                }
917            });
918
919        vec![geom]
920    }
921}
922
923fn midi_clip_notes_overlay<Message: 'static>(
924    notes: Arc<Vec<PianoNote>>,
925    clip_offset_samples: usize,
926    clip_visible_length_samples: usize,
927    reversed: bool,
928) -> Element<'static, Message> {
929    canvas(MidiClipNotesCanvas {
930        notes,
931        clip_offset_samples,
932        clip_visible_length_samples,
933        reversed,
934    })
935    .width(Length::Fill)
936    .height(Length::Fill)
937    .into()
938}
939
940fn audio_waveform_overlay<Message: 'static>(waveform: WaveformCanvas) -> Element<'static, Message> {
941    canvas(waveform)
942        .width(Length::Fill)
943        .height(Length::Fill)
944        .into()
945}
946
947fn resolve_audio_clip_path(session_root: Option<&PathBuf>, clip_name: &str) -> Option<PathBuf> {
948    let path = PathBuf::from(clip_name);
949    if path.is_absolute() {
950        Some(path)
951    } else {
952        session_root.map(|root| root.join(path))
953    }
954}
955
956fn grouped_audio_waveform_overlay<Message: 'static>(
957    clip: &AudioClipData,
958    session_root: Option<&PathBuf>,
959    pixels_per_sample: f32,
960    clip_height: f32,
961) -> Element<'static, Message> {
962    let mut stack = Stack::new();
963    for child in &clip.grouped_clips {
964        let child_width = (child.length as f32 * pixels_per_sample).max(12.0);
965        let child_overlay = if child.is_group() {
966            grouped_audio_waveform_overlay(child, session_root, pixels_per_sample, clip_height)
967        } else {
968            audio_waveform_overlay(WaveformCanvas {
969                peaks: child.peaks.clone(),
970                source_wav_path: resolve_audio_clip_path(session_root, &child.name),
971                clip_offset: child.offset,
972                clip_length: child.length,
973                max_length: child.max_length_samples,
974                source_length: child.source_length_samples,
975                stretch_ratio: child.stretch_ratio,
976                reversed: child.reversed,
977            })
978        };
979        stack = stack.push(
980            pin(container(child_overlay)
981                .width(Length::Fixed(child_width))
982                .height(Length::Fixed(clip_height)))
983            .position(Point::new(child.start as f32 * pixels_per_sample, 0.0)),
984        );
985    }
986    container(stack)
987        .width(Length::Fill)
988        .height(Length::Fill)
989        .into()
990}
991
992#[derive(Clone, Copy)]
993enum AudioClipMode {
994    Widget,
995    Preview,
996}
997
998pub struct AudioClip<Message> {
999    clip: AudioClipData,
1000    session_root: Option<PathBuf>,
1001    pixels_per_sample: f32,
1002    clip_width: f32,
1003    clip_height: f32,
1004    label: String,
1005    is_selected: bool,
1006    left_handle_hovered: bool,
1007    right_handle_hovered: bool,
1008    interaction: Option<AudioClipInteraction<Message>>,
1009    background: Option<Background>,
1010    border_color: Option<Color>,
1011    radius: f32,
1012    mode: AudioClipMode,
1013    base_color: Color,
1014    selected_base_color: Color,
1015    border: Color,
1016    selected_border: Color,
1017    resize_handle_width: f32,
1018}
1019
1020impl<Message> AudioClip<Message> {
1021    pub fn clean_name(name: &str) -> String {
1022        clean_clip_name(name)
1023    }
1024
1025    pub fn label_for_width(label: &str, width_px: f32) -> String {
1026        trim_label_to_width(label, width_px)
1027    }
1028
1029    pub fn two_edge_gradient(
1030        base: Color,
1031        muted_alpha: f32,
1032        normal_alpha: f32,
1033        reverse: bool,
1034    ) -> Background {
1035        clip_two_edge_gradient(base, muted_alpha, normal_alpha, reverse)
1036    }
1037
1038    pub fn waveform_overlay(
1039        peaks: ClipPeaks,
1040        source_wav_path: Option<PathBuf>,
1041        clip_offset: usize,
1042        clip_length: usize,
1043        max_length: usize,
1044        source_length: usize,
1045    ) -> Element<'static, Message>
1046    where
1047        Message: 'static,
1048    {
1049        audio_waveform_overlay(WaveformCanvas {
1050            peaks,
1051            source_wav_path,
1052            clip_offset,
1053            clip_length,
1054            max_length,
1055            source_length,
1056            stretch_ratio: 1.0,
1057            reversed: false,
1058        })
1059    }
1060}
1061
1062impl<Message: Clone + 'static> AudioClip<Message> {
1063    pub fn new(clip: AudioClipData) -> Self {
1064        Self {
1065            clip,
1066            session_root: None,
1067            pixels_per_sample: 1.0,
1068            clip_width: 12.0,
1069            clip_height: 8.0,
1070            label: String::new(),
1071            is_selected: false,
1072            left_handle_hovered: false,
1073            right_handle_hovered: false,
1074            interaction: None,
1075            background: None,
1076            border_color: None,
1077            radius: 8.0,
1078            mode: AudioClipMode::Widget,
1079            base_color: Color::from_rgb8(68, 88, 132),
1080            selected_base_color: Color::from_rgb8(96, 126, 186),
1081            border: Color::from_rgb8(78, 93, 130),
1082            selected_border: Color::from_rgb8(176, 218, 255),
1083            resize_handle_width: CLIP_RESIZE_HANDLE_WIDTH,
1084        }
1085    }
1086
1087    pub fn with_colors(
1088        mut self,
1089        base_color: Color,
1090        selected_base_color: Color,
1091        border: Color,
1092        selected_border: Color,
1093    ) -> Self {
1094        self.base_color = base_color;
1095        self.selected_base_color = selected_base_color;
1096        self.border = border;
1097        self.selected_border = selected_border;
1098        self
1099    }
1100
1101    pub fn with_session_root(mut self, session_root: Option<&PathBuf>) -> Self {
1102        self.session_root = session_root.cloned();
1103        self
1104    }
1105
1106    pub fn with_pixels_per_sample(mut self, pixels_per_sample: f32) -> Self {
1107        self.pixels_per_sample = pixels_per_sample;
1108        self
1109    }
1110
1111    pub fn with_size(mut self, clip_width: f32, clip_height: f32) -> Self {
1112        self.clip_width = clip_width;
1113        self.clip_height = clip_height;
1114        self
1115    }
1116
1117    pub fn with_label(mut self, label: String) -> Self {
1118        self.label = label;
1119        self
1120    }
1121
1122    pub fn selected(mut self, is_selected: bool) -> Self {
1123        self.is_selected = is_selected;
1124        self
1125    }
1126
1127    pub fn hovered_handles(mut self, left: bool, right: bool) -> Self {
1128        self.left_handle_hovered = left;
1129        self.right_handle_hovered = right;
1130        self
1131    }
1132
1133    pub fn interactive(mut self, interaction: AudioClipInteraction<Message>) -> Self {
1134        self.interaction = Some(interaction);
1135        self.mode = AudioClipMode::Widget;
1136        self
1137    }
1138
1139    pub fn preview(mut self, background: Background, border_color: Color) -> Self {
1140        self.background = Some(background);
1141        self.border_color = Some(border_color);
1142        self.mode = AudioClipMode::Preview;
1143        self
1144    }
1145
1146    pub fn into_element(self) -> Element<'static, Message> {
1147        match self.mode {
1148            AudioClipMode::Preview => {
1149                let preview_content = container(Stack::with_children(vec![
1150                    audio_waveform_overlay(WaveformCanvas {
1151                        peaks: self.clip.peaks.clone(),
1152                        source_wav_path: resolve_audio_clip_path(
1153                            self.session_root.as_ref(),
1154                            &self.clip.name,
1155                        ),
1156                        clip_offset: self.clip.offset,
1157                        clip_length: self.clip.length,
1158                        max_length: self.clip.max_length_samples,
1159                        source_length: self.clip.source_length_samples,
1160                        stretch_ratio: self.clip.stretch_ratio,
1161                        reversed: self.clip.reversed,
1162                    }),
1163                    audio_clip_label_overlay(
1164                        self.label,
1165                        self.clip_width,
1166                        self.clip_height,
1167                        self.radius,
1168                        self.clip.peaks.len(),
1169                    ),
1170                ]))
1171                .width(Length::Fill)
1172                .height(Length::Fill)
1173                .padding(0)
1174                .style(move |_theme| container::Style {
1175                    background: self.background,
1176                    ..container::Style::default()
1177                });
1178                container(preview_content)
1179                    .width(Length::Fixed(self.clip_width))
1180                    .height(Length::Fixed(self.clip_height))
1181                    .style(move |_theme| container::Style {
1182                        background: None,
1183                        border: Border {
1184                            color: self.border_color.unwrap_or(Color::TRANSPARENT),
1185                            width: 2.0,
1186                            radius: self.radius.into(),
1187                        },
1188                        ..container::Style::default()
1189                    })
1190                    .into()
1191            }
1192            AudioClipMode::Widget => {
1193                let interaction = self.interaction.expect("audio clip interaction");
1194                let clip_muted = self.clip.muted;
1195                // Keep a grabbable strip in the middle of very narrow clips.
1196                let resize_handle_width = self.resize_handle_width.min(self.clip_width / 2.0);
1197                let left_edge_zone = mouse_area(
1198                    Space::new()
1199                        .width(Length::Fixed(resize_handle_width))
1200                        .height(Length::Fill),
1201                )
1202                .interaction(mouse::Interaction::Pointer)
1203                .on_enter(interaction.edges.left_hover_enter.clone())
1204                .on_exit(interaction.edges.left_hover_exit.clone())
1205                .on_press(interaction.edges.left_press.clone());
1206                let right_edge_zone = mouse_area(
1207                    Space::new()
1208                        .width(Length::Fixed(resize_handle_width))
1209                        .height(Length::Fill),
1210                )
1211                .interaction(mouse::Interaction::Pointer)
1212                .on_enter(interaction.edges.right_hover_enter.clone())
1213                .on_exit(interaction.edges.right_hover_exit.clone())
1214                .on_press(interaction.edges.right_press.clone());
1215
1216                let clip_content = container(Stack::with_children(vec![
1217                    if self.clip.is_group() {
1218                        grouped_audio_waveform_overlay(
1219                            &self.clip,
1220                            self.session_root.as_ref(),
1221                            self.pixels_per_sample,
1222                            self.clip_height,
1223                        )
1224                    } else {
1225                        audio_waveform_overlay(WaveformCanvas {
1226                            peaks: self.clip.peaks.clone(),
1227                            source_wav_path: resolve_audio_clip_path(
1228                                self.session_root.as_ref(),
1229                                &self.clip.name,
1230                            ),
1231                            clip_offset: self.clip.offset,
1232                            clip_length: self.clip.length,
1233                            max_length: self.clip.max_length_samples,
1234                            source_length: self.clip.source_length_samples,
1235                            stretch_ratio: self.clip.stretch_ratio,
1236                            reversed: self.clip.reversed,
1237                        })
1238                    },
1239                    audio_clip_label_overlay(
1240                        self.label,
1241                        self.clip_width,
1242                        self.clip_height,
1243                        self.radius,
1244                        self.clip.peaks.len(),
1245                    ),
1246                ]))
1247                .width(Length::Fill)
1248                .height(Length::Fill)
1249                .padding(0)
1250                .style(move |_theme| {
1251                    let base = if self.is_selected {
1252                        self.selected_base_color
1253                    } else {
1254                        self.base_color
1255                    };
1256                    let normal_alpha = if self.is_selected {
1257                        CLIP_SELECTED_ALPHA
1258                    } else {
1259                        CLIP_NORMAL_ALPHA
1260                    };
1261                    let muted_alpha = if clip_muted {
1262                        CLIP_MUTED_ALPHA
1263                    } else {
1264                        normal_alpha
1265                    };
1266                    container::Style {
1267                        background: Some(clip_two_edge_gradient(
1268                            base,
1269                            muted_alpha,
1270                            normal_alpha,
1271                            self.clip.reversed,
1272                        )),
1273                        border: Border {
1274                            radius: 8.0.into(),
1275                            ..Default::default()
1276                        },
1277                        ..container::Style::default()
1278                    }
1279                });
1280
1281                let clip_widget = container(clip_content)
1282                    .width(Length::Fixed(self.clip_width))
1283                    .height(Length::Fixed(self.clip_height))
1284                    .style(move |_theme| container::Style {
1285                        background: None,
1286                        border: Border {
1287                            color: if self.is_selected {
1288                                self.selected_border
1289                            } else {
1290                                self.border
1291                            },
1292                            width: if self.is_selected { 2.0 } else { 1.0 },
1293                            radius: 8.0.into(),
1294                        },
1295                        ..container::Style::default()
1296                    });
1297
1298                let clip_with_fades: Element<'static, Message> = if self.clip.fade_enabled {
1299                    let fade_in_width = visible_fade_overlay_width(
1300                        self.clip.fade_in_samples,
1301                        self.pixels_per_sample,
1302                    );
1303                    let fade_out_width = visible_fade_overlay_width(
1304                        self.clip.fade_out_samples,
1305                        self.pixels_per_sample,
1306                    );
1307                    let mut stack = Stack::new().push(clip_widget);
1308                    if should_draw_fade_overlay(self.clip.fade_in_samples, self.pixels_per_sample) {
1309                        if let Some(message) = interaction.fade_in_press.clone() {
1310                            let fade_in_handle = mouse_area(
1311                                container("")
1312                                    .width(Length::Fixed(6.0))
1313                                    .height(Length::Fixed(6.0))
1314                                    .style(|_theme| container::Style {
1315                                        background: Some(Background::Color(Color::from_rgba(
1316                                            1.0, 1.0, 1.0, 0.9,
1317                                        ))),
1318                                        border: Border {
1319                                            color: Color::from_rgba(0.3, 0.3, 0.3, 1.0),
1320                                            width: 1.0,
1321                                            radius: 8.0.into(),
1322                                        },
1323                                        ..container::Style::default()
1324                                    }),
1325                            )
1326                            .on_press(message);
1327                            stack = stack.push(
1328                                pin(fade_in_handle).position(Point::new(fade_in_width - 3.0, -3.0)),
1329                            );
1330                        }
1331                        stack = stack.push(
1332                            pin(fade_bezier_overlay(
1333                                fade_in_width,
1334                                self.clip_height,
1335                                Color::from_rgba(0.0, 0.0, 0.0, 0.3),
1336                                false,
1337                            ))
1338                            .position(Point::new(0.0, 0.0)),
1339                        );
1340                    }
1341                    if should_draw_fade_overlay(self.clip.fade_out_samples, self.pixels_per_sample)
1342                    {
1343                        if let Some(message) = interaction.fade_out_press.clone() {
1344                            let fade_out_handle = mouse_area(
1345                                container("")
1346                                    .width(Length::Fixed(6.0))
1347                                    .height(Length::Fixed(6.0))
1348                                    .style(|_theme| container::Style {
1349                                        background: Some(Background::Color(Color::from_rgba(
1350                                            1.0, 1.0, 1.0, 0.9,
1351                                        ))),
1352                                        border: Border {
1353                                            color: Color::from_rgba(0.3, 0.3, 0.3, 1.0),
1354                                            width: 1.0,
1355                                            radius: 8.0.into(),
1356                                        },
1357                                        ..container::Style::default()
1358                                    }),
1359                            )
1360                            .on_press(message);
1361                            stack = stack.push(pin(fade_out_handle).position(Point::new(
1362                                self.clip_width - fade_out_width - 3.0,
1363                                -3.0,
1364                            )));
1365                        }
1366                        stack = stack.push(
1367                            pin(fade_bezier_overlay(
1368                                fade_out_width,
1369                                self.clip_height,
1370                                Color::from_rgba(0.0, 0.0, 0.0, 0.3),
1371                                true,
1372                            ))
1373                            .position(Point::new(self.clip_width - fade_out_width, 0.0)),
1374                        );
1375                    }
1376                    stack.into()
1377                } else {
1378                    clip_widget.into()
1379                };
1380
1381                // Keep resize zones above waveform, label, and fade overlays so
1382                // their cursor and press handling cannot be shadowed by a
1383                // visual layer.
1384                let interactive_clip = Stack::with_children(vec![
1385                    clip_with_fades,
1386                    pin(left_edge_zone).position(Point::new(0.0, 0.0)).into(),
1387                    pin(right_edge_zone)
1388                        .position(Point::new(self.clip_width - resize_handle_width, 0.0))
1389                        .into(),
1390                ]);
1391                let base = mouse_area(interactive_clip);
1392                let base = if self.left_handle_hovered || self.right_handle_hovered {
1393                    base.interaction(mouse::Interaction::Pointer)
1394                } else {
1395                    base
1396                };
1397                let base = base
1398                    .on_press(interaction.on_select)
1399                    .on_double_click(interaction.on_open);
1400                if let Some(on_drag) = interaction.on_drag {
1401                    base.on_move(move |point| on_drag(point)).into()
1402                } else {
1403                    base.into()
1404                }
1405            }
1406        }
1407    }
1408}
1409
1410#[derive(Clone, Copy)]
1411enum MIDIClipMode {
1412    Widget,
1413    Preview,
1414}
1415
1416pub struct MIDIClip<Message> {
1417    clip: MIDIClipData,
1418    clip_width: f32,
1419    clip_height: f32,
1420    label: String,
1421    is_selected: bool,
1422    left_handle_hovered: bool,
1423    right_handle_hovered: bool,
1424    midi_notes: Option<Arc<Vec<PianoNote>>>,
1425    interaction: Option<MIDIClipInteraction<Message>>,
1426    background: Option<Background>,
1427    border_color: Option<Color>,
1428    radius: f32,
1429    mode: MIDIClipMode,
1430    base_color: Color,
1431    selected_base_color: Color,
1432    border: Color,
1433    selected_border: Color,
1434    resize_handle_width: f32,
1435}
1436
1437impl<Message> MIDIClip<Message> {
1438    pub fn clean_name(name: &str) -> String {
1439        clean_clip_name(name)
1440    }
1441
1442    pub fn label_for_width(label: &str, width_px: f32) -> String {
1443        trim_label_to_width(label, width_px)
1444    }
1445
1446    pub fn two_edge_gradient(
1447        base: Color,
1448        muted_alpha: f32,
1449        normal_alpha: f32,
1450        reverse: bool,
1451    ) -> Background {
1452        clip_two_edge_gradient(base, muted_alpha, normal_alpha, reverse)
1453    }
1454}
1455
1456impl<Message: Clone + 'static> MIDIClip<Message> {
1457    pub fn new(clip: MIDIClipData) -> Self {
1458        Self {
1459            clip,
1460            clip_width: 12.0,
1461            clip_height: 8.0,
1462            label: String::new(),
1463            is_selected: false,
1464            left_handle_hovered: false,
1465            right_handle_hovered: false,
1466            midi_notes: None,
1467            interaction: None,
1468            background: None,
1469            border_color: None,
1470            radius: 8.0,
1471            mode: MIDIClipMode::Widget,
1472            base_color: Color::from_rgb8(55, 90, 50),
1473            selected_base_color: Color::from_rgb8(84, 133, 72),
1474            border: Color::from_rgb8(148, 215, 118),
1475            selected_border: Color::from_rgb8(196, 255, 151),
1476            resize_handle_width: CLIP_RESIZE_HANDLE_WIDTH,
1477        }
1478    }
1479
1480    pub fn with_colors(
1481        mut self,
1482        base_color: Color,
1483        selected_base_color: Color,
1484        border: Color,
1485        selected_border: Color,
1486    ) -> Self {
1487        self.base_color = base_color;
1488        self.selected_base_color = selected_base_color;
1489        self.border = border;
1490        self.selected_border = selected_border;
1491        self
1492    }
1493
1494    pub fn with_size(mut self, clip_width: f32, clip_height: f32) -> Self {
1495        self.clip_width = clip_width;
1496        self.clip_height = clip_height;
1497        self
1498    }
1499
1500    pub fn with_label(mut self, label: String) -> Self {
1501        self.label = label;
1502        self
1503    }
1504
1505    pub fn selected(mut self, is_selected: bool) -> Self {
1506        self.is_selected = is_selected;
1507        self
1508    }
1509
1510    pub fn hovered_handles(mut self, left: bool, right: bool) -> Self {
1511        self.left_handle_hovered = left;
1512        self.right_handle_hovered = right;
1513        self
1514    }
1515
1516    pub fn with_notes(mut self, midi_notes: Option<Arc<Vec<PianoNote>>>) -> Self {
1517        self.midi_notes = midi_notes;
1518        self
1519    }
1520
1521    pub fn interactive(mut self, interaction: MIDIClipInteraction<Message>) -> Self {
1522        self.interaction = Some(interaction);
1523        self.mode = MIDIClipMode::Widget;
1524        self
1525    }
1526
1527    pub fn preview(mut self, background: Background, border_color: Color, radius: f32) -> Self {
1528        self.background = Some(background);
1529        self.border_color = Some(border_color);
1530        self.radius = radius;
1531        self.mode = MIDIClipMode::Preview;
1532        self
1533    }
1534
1535    pub fn into_element(self) -> Element<'static, Message> {
1536        match self.mode {
1537            MIDIClipMode::Preview => {
1538                let mut preview_layers = Vec::with_capacity(2);
1539                if let Some(notes) = self.midi_notes {
1540                    preview_layers.push(midi_clip_notes_overlay(
1541                        notes,
1542                        self.clip.offset,
1543                        self.clip.length.max(1),
1544                        self.clip.reversed,
1545                    ));
1546                }
1547                preview_layers.push(midi_clip_label_overlay(
1548                    self.label,
1549                    self.clip_width,
1550                    self.clip_height,
1551                    self.radius,
1552                ));
1553                let preview_content = container(Stack::with_children(preview_layers))
1554                    .width(Length::Fill)
1555                    .height(Length::Fill)
1556                    .padding(0)
1557                    .style(move |_theme| container::Style {
1558                        background: self.background,
1559                        ..container::Style::default()
1560                    });
1561                container(preview_content)
1562                    .width(Length::Fixed(self.clip_width))
1563                    .height(Length::Fixed(self.clip_height))
1564                    .style(move |_theme| container::Style {
1565                        background: None,
1566                        border: Border {
1567                            color: self.border_color.unwrap_or(Color::TRANSPARENT),
1568                            width: 2.0,
1569                            radius: self.radius.into(),
1570                        },
1571                        ..container::Style::default()
1572                    })
1573                    .into()
1574            }
1575            MIDIClipMode::Widget => {
1576                let interaction = self.interaction.expect("midi clip interaction");
1577                // Keep a grabbable strip in the middle of very narrow clips.
1578                let resize_handle_width = self.resize_handle_width.min(self.clip_width / 2.0);
1579                let left_edge_zone = mouse_area(
1580                    Space::new()
1581                        .width(Length::Fixed(resize_handle_width))
1582                        .height(Length::Fill),
1583                )
1584                .interaction(mouse::Interaction::Pointer)
1585                .on_enter(interaction.edges.left_hover_enter.clone())
1586                .on_exit(interaction.edges.left_hover_exit.clone())
1587                .on_press(interaction.edges.left_press.clone());
1588                let right_edge_zone = mouse_area(
1589                    Space::new()
1590                        .width(Length::Fixed(resize_handle_width))
1591                        .height(Length::Fill),
1592                )
1593                .interaction(mouse::Interaction::Pointer)
1594                .on_enter(interaction.edges.right_hover_enter.clone())
1595                .on_exit(interaction.edges.right_hover_exit.clone())
1596                .on_press(interaction.edges.right_press.clone());
1597
1598                let mut clip_layers = Vec::with_capacity(2);
1599                if let Some(notes) = self.midi_notes {
1600                    clip_layers.push(midi_clip_notes_overlay(
1601                        notes,
1602                        self.clip.offset,
1603                        self.clip.length.max(1),
1604                        self.clip.reversed,
1605                    ));
1606                }
1607                clip_layers.push(midi_clip_label_overlay(
1608                    self.label,
1609                    self.clip_width,
1610                    self.clip_height,
1611                    self.radius,
1612                ));
1613
1614                let clip_muted = self.clip.muted;
1615                let clip_widget = container(
1616                    container(Stack::with_children(clip_layers))
1617                        .width(Length::Fill)
1618                        .height(Length::Fill)
1619                        .padding(0)
1620                        .style(move |_theme| {
1621                            let base = if self.is_selected {
1622                                self.selected_base_color
1623                            } else {
1624                                self.base_color
1625                            };
1626                            let normal_alpha = if self.is_selected {
1627                                CLIP_SELECTED_ALPHA
1628                            } else {
1629                                CLIP_NORMAL_ALPHA
1630                            };
1631                            let muted_alpha = if clip_muted {
1632                                CLIP_MUTED_ALPHA
1633                            } else {
1634                                normal_alpha
1635                            };
1636                            container::Style {
1637                                background: Some(clip_two_edge_gradient(
1638                                    base,
1639                                    muted_alpha,
1640                                    normal_alpha,
1641                                    self.clip.reversed,
1642                                )),
1643                                border: Border {
1644                                    radius: 8.0.into(),
1645                                    ..Default::default()
1646                                },
1647                                ..container::Style::default()
1648                            }
1649                        }),
1650                )
1651                .width(Length::Fixed(self.clip_width))
1652                .height(Length::Fixed(self.clip_height))
1653                .style(move |_theme| container::Style {
1654                    background: None,
1655                    border: Border {
1656                        color: if self.is_selected {
1657                            self.selected_border
1658                        } else {
1659                            self.border
1660                        },
1661                        width: if self.is_selected { 2.2 } else { 1.4 },
1662                        radius: 8.0.into(),
1663                    },
1664                    ..container::Style::default()
1665                });
1666
1667                let interactive_clip = Stack::with_children(vec![
1668                    clip_widget.into(),
1669                    pin(left_edge_zone).position(Point::new(0.0, 0.0)).into(),
1670                    pin(right_edge_zone)
1671                        .position(Point::new(self.clip_width - resize_handle_width, 0.0))
1672                        .into(),
1673                ]);
1674                let base = mouse_area(interactive_clip);
1675                let base = if self.left_handle_hovered || self.right_handle_hovered {
1676                    base.interaction(mouse::Interaction::Pointer)
1677                } else {
1678                    base
1679                };
1680                let base = base
1681                    .on_press(interaction.on_select)
1682                    .on_double_click(interaction.on_open);
1683                if let Some(on_drag) = interaction.on_drag {
1684                    base.on_move(move |point| on_drag(point)).into()
1685                } else {
1686                    base.into()
1687                }
1688            }
1689        }
1690    }
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695    use super::{should_draw_fade_overlay, visible_fade_overlay_width};
1696
1697    #[test]
1698    fn visible_fade_overlay_width_grows_with_zoom_below_full_size() {
1699        let low_zoom = visible_fade_overlay_width(240, 0.01);
1700        let higher_zoom = visible_fade_overlay_width(240, 0.02);
1701
1702        assert!(higher_zoom > low_zoom);
1703        assert!((low_zoom - 2.4).abs() < 1.0e-5);
1704    }
1705
1706    #[test]
1707    fn visible_fade_overlay_width_matches_actual_size_once_large_enough() {
1708        let width = visible_fade_overlay_width(240, 0.1);
1709        assert_eq!(width, 24.0);
1710    }
1711
1712    #[test]
1713    fn should_draw_fade_overlay_hides_tiny_fades() {
1714        assert!(!should_draw_fade_overlay(240, 0.0125));
1715        assert!(should_draw_fade_overlay(240, 0.0126));
1716    }
1717}