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