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