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