Skip to main content

maolan_widgets/
drum.rs

1use crate::midi::PianoNote;
2use iced::{
3    Color, Event, Point, Rectangle, Renderer, Size, Theme, mouse,
4    widget::canvas::{Action as CanvasAction, Frame, Geometry, Path, Program},
5};
6use std::collections::HashSet;
7
8#[derive(Debug, Clone, PartialEq)]
9pub enum DrumMessage {
10    NoteSelected(usize),
11    ClearSelection,
12    NoteCreate {
13        start_sample: usize,
14        end_sample: usize,
15        pitch: u8,
16        repeat: bool,
17    },
18    NoteDelete(usize),
19    NoteMove {
20        note_index: usize,
21        delta_samples: i64,
22    },
23    AdjustVelocity {
24        note_index: usize,
25        delta: i8,
26    },
27    SelectRectStart {
28        position: Point,
29    },
30    SelectRectDrag {
31        position: Point,
32    },
33    SelectRectEnd,
34}
35
36#[derive(Default, Debug, Clone, Copy, PartialEq)]
37pub enum DraggingMode {
38    #[default]
39    None,
40    SelectingRect,
41    DraggingNote,
42    CreatingNote,
43}
44
45#[derive(Debug)]
46pub struct DrumRollInteraction {
47    pub notes: Vec<PianoNote>,
48    pub pixels_per_sample: f32,
49    pub zoom_x: f32,
50    pub drum_rows: Vec<u8>,
51    pub row_height: f32,
52    pub selecting_rect: Option<(Point, Point)>,
53    pub selected_notes: HashSet<usize>,
54    pub repeat_create: bool,
55}
56
57#[derive(Default, Debug)]
58pub struct DrumRollInteractionState {
59    pub dragging_mode: DraggingMode,
60    pub drag_start: Option<Point>,
61    pub drag_current: Option<Point>,
62    pub drag_note_index: Option<usize>,
63    pub hover_note_index: Option<usize>,
64    pub creating_dragged: bool,
65    pub creating_start_sample: Option<usize>,
66    pub creating_pitch: Option<u8>,
67}
68
69impl DrumRollInteraction {
70    pub fn new(
71        notes: Vec<PianoNote>,
72        pixels_per_sample: f32,
73        zoom_x: f32,
74        drum_rows: Vec<u8>,
75        row_height: f32,
76        selecting_rect: Option<(Point, Point)>,
77        selected_notes: HashSet<usize>,
78    ) -> Self {
79        Self {
80            notes,
81            pixels_per_sample,
82            zoom_x,
83            drum_rows,
84            row_height,
85            selecting_rect,
86            selected_notes,
87            repeat_create: false,
88        }
89    }
90
91    fn note_at_position(&self, position: Point, pps: f32, notes: &[PianoNote]) -> Option<usize> {
92        for (idx, note) in notes.iter().enumerate() {
93            let Some(row_idx) = self.drum_rows.iter().position(|&p| p == note.pitch) else {
94                continue;
95            };
96            let y = row_idx as f32 * self.row_height + 1.0;
97            let x = note.start_sample as f32 * pps;
98            let w = (note.length_samples as f32 * pps).max(2.0);
99            let h = (self.row_height - 2.0).max(2.0);
100            if position.x >= x && position.x <= x + w && position.y >= y && position.y <= y + h {
101                return Some(idx);
102            }
103        }
104        None
105    }
106
107    fn pitch_at_y(&self, y: f32) -> u8 {
108        let row_idx = (y / self.row_height)
109            .floor()
110            .clamp(0.0, (self.drum_rows.len().saturating_sub(1)) as f32)
111            as usize;
112        self.drum_rows.get(row_idx).copied().unwrap_or(60)
113    }
114
115    fn sample_at_x(&self, x: f32, pps: f32) -> usize {
116        (x / pps).max(0.0) as usize
117    }
118
119    fn local_position(bounds: Rectangle, position: Point) -> Point {
120        Point::new(position.x - bounds.x, position.y - bounds.y)
121    }
122}
123
124impl Program<DrumMessage> for DrumRollInteraction {
125    type State = DrumRollInteractionState;
126
127    fn update(
128        &self,
129        state: &mut Self::State,
130        event: &Event,
131        bounds: Rectangle,
132        cursor: mouse::Cursor,
133    ) -> Option<CanvasAction<DrumMessage>> {
134        let pps = (self.pixels_per_sample * self.zoom_x).max(0.0001);
135        let notes = &self.notes;
136
137        match event {
138            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
139                if let Some(position) = cursor.position_in(bounds) {
140                    if let Some(note_idx) = self.note_at_position(position, pps, notes) {
141                        state.drag_start = Some(position);
142                        state.drag_current = Some(position);
143                        state.drag_note_index = Some(note_idx);
144                        state.dragging_mode = DraggingMode::DraggingNote;
145                        return Some(
146                            CanvasAction::publish(DrumMessage::NoteSelected(note_idx))
147                                .and_capture(),
148                        );
149                    } else {
150                        state.drag_start = Some(position);
151                        state.drag_current = Some(position);
152                        state.drag_note_index = None;
153                        state.dragging_mode = DraggingMode::SelectingRect;
154                        return Some(
155                            CanvasAction::publish(DrumMessage::SelectRectStart { position })
156                                .and_capture(),
157                        );
158                    }
159                }
160            }
161            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
162                if let Some(position) = cursor.position_in(bounds) {
163                    state.drag_start = Some(position);
164                    state.drag_current = Some(position);
165                    state.drag_note_index = None;
166                    state.creating_dragged = false;
167                    state.creating_start_sample = Some(self.sample_at_x(position.x, pps));
168                    state.creating_pitch = Some(self.pitch_at_y(position.y));
169                    state.dragging_mode = DraggingMode::CreatingNote;
170                    return Some(CanvasAction::request_redraw().and_capture());
171                }
172            }
173            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) => {
174                if let Some(position) = cursor.position_in(bounds)
175                    && let Some(note_idx) = self.note_at_position(position, pps, notes)
176                {
177                    return Some(
178                        CanvasAction::publish(DrumMessage::NoteDelete(note_idx)).and_capture(),
179                    );
180                }
181            }
182            Event::Mouse(mouse::Event::CursorMoved { position }) => {
183                let position = Self::local_position(bounds, *position);
184                if state.drag_start.is_some() {
185                    state.drag_current = Some(position);
186                }
187                match state.dragging_mode {
188                    DraggingMode::SelectingRect => {
189                        return Some(CanvasAction::publish(DrumMessage::SelectRectDrag {
190                            position,
191                        }));
192                    }
193                    DraggingMode::DraggingNote => {
194                        return Some(CanvasAction::request_redraw());
195                    }
196                    DraggingMode::CreatingNote => {
197                        if let Some(drag_start) = state.drag_start
198                            && (position.x - drag_start.x).hypot(position.y - drag_start.y) < 3.0
199                        {
200                            return Some(CanvasAction::request_redraw().and_capture());
201                        }
202                        state.creating_dragged = true;
203                        return Some(CanvasAction::request_redraw().and_capture());
204                    }
205                    DraggingMode::None => {}
206                }
207                state.hover_note_index = cursor
208                    .position_in(bounds)
209                    .and_then(|position| self.note_at_position(position, pps, notes));
210            }
211            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
212                let mode = state.dragging_mode;
213
214                match mode {
215                    DraggingMode::SelectingRect => {
216                        state.drag_start = None;
217                        state.drag_current = None;
218                        state.drag_note_index = None;
219                        state.dragging_mode = DraggingMode::None;
220                        return Some(CanvasAction::publish(DrumMessage::SelectRectEnd));
221                    }
222                    DraggingMode::DraggingNote => {
223                        if let (Some(drag_start), Some(note_idx)) =
224                            (state.drag_start.take(), state.drag_note_index.take())
225                        {
226                            state.drag_current = None;
227                            state.dragging_mode = DraggingMode::None;
228                            if let Some(position) = cursor.position_in(bounds) {
229                                let delta_x = position.x - drag_start.x;
230                                let delta_samples = (delta_x / pps) as i64;
231                                if delta_samples != 0 {
232                                    return Some(
233                                        CanvasAction::publish(DrumMessage::NoteMove {
234                                            note_index: note_idx,
235                                            delta_samples,
236                                        })
237                                        .and_capture(),
238                                    );
239                                }
240                            }
241                        }
242                    }
243                    DraggingMode::CreatingNote => {}
244                    DraggingMode::None => {}
245                }
246            }
247            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => {
248                if state.dragging_mode == DraggingMode::CreatingNote {
249                    let start = state.drag_start.take();
250                    let current = state.drag_current.take();
251                    let start_sample = state.creating_start_sample;
252                    let pitch = state.creating_pitch;
253                    state.drag_note_index = None;
254                    state.creating_dragged = false;
255                    state.creating_start_sample = None;
256                    state.creating_pitch = None;
257                    state.dragging_mode = DraggingMode::None;
258                    if let Some(position) = cursor.position_in(bounds).or(current).or(start) {
259                        let Some(start_sample) = start_sample else {
260                            return Some(CanvasAction::request_redraw().and_capture());
261                        };
262                        let Some(pitch) = pitch else {
263                            return Some(CanvasAction::request_redraw().and_capture());
264                        };
265                        let end_sample = self.sample_at_x(position.x, pps);
266                        return Some(
267                            CanvasAction::publish(DrumMessage::NoteCreate {
268                                start_sample,
269                                end_sample,
270                                pitch,
271                                repeat: self.repeat_create,
272                            })
273                            .and_capture(),
274                        );
275                    }
276                    return Some(CanvasAction::request_redraw().and_capture());
277                }
278            }
279            Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
280                if let Some(position) = cursor.position_in(bounds) {
281                    let raw = match delta {
282                        mouse::ScrollDelta::Lines { y, .. } => *y,
283                        mouse::ScrollDelta::Pixels { y, .. } => *y / 16.0,
284                    };
285                    let steps = raw.round() as i32;
286                    if steps != 0
287                        && let Some(note_idx) = self.note_at_position(position, pps, notes)
288                    {
289                        let delta = steps.clamp(-24, 24) as i8;
290                        return Some(
291                            CanvasAction::publish(DrumMessage::AdjustVelocity {
292                                note_index: note_idx,
293                                delta,
294                            })
295                            .and_capture(),
296                        );
297                    }
298                }
299            }
300            _ => {}
301        }
302        None
303    }
304
305    fn draw(
306        &self,
307        state: &Self::State,
308        renderer: &Renderer,
309        _theme: &Theme,
310        bounds: Rectangle,
311        cursor: mouse::Cursor,
312    ) -> Vec<Geometry> {
313        let mut frame = Frame::new(renderer, bounds.size());
314
315        if state.dragging_mode == DraggingMode::DraggingNote
316            && let (Some(drag_start), Some(cursor_pos)) =
317                (state.drag_start, cursor.position_in(bounds))
318        {
319            let pps = (self.pixels_per_sample * self.zoom_x).max(0.0001);
320            let delta_x = cursor_pos.x - drag_start.x;
321            for &note_idx in &self.selected_notes {
322                if let Some(note) = self.notes.get(note_idx)
323                    && let Some(row_idx) = self.drum_rows.iter().position(|&p| p == note.pitch)
324                {
325                    let x = note.start_sample as f32 * pps + delta_x;
326                    let y = row_idx as f32 * self.row_height + 1.0;
327                    let w = (note.length_samples as f32 * pps).max(2.0);
328                    let h = (self.row_height - 2.0).max(2.0);
329                    frame.fill(
330                        &Path::rectangle(Point::new(x, y), Size::new(w, h)),
331                        Color::from_rgba(0.9, 0.9, 0.95, 0.35),
332                    );
333                }
334            }
335        }
336
337        if self.repeat_create
338            && state.dragging_mode == DraggingMode::CreatingNote
339            && let (Some(start), Some(current), Some(pitch)) =
340                (state.drag_start, state.drag_current, state.creating_pitch)
341            && let Some(row_idx) = self
342                .drum_rows
343                .iter()
344                .position(|&row_pitch| row_pitch == pitch)
345        {
346            let x0 = start.x.min(current.x).max(0.0);
347            let x1 = start.x.max(current.x).max(0.0);
348            let y = row_idx as f32 * self.row_height + 1.0;
349            let w = (x1 - x0).max(2.0);
350            let h = (self.row_height - 2.0).max(2.0);
351            let path = Path::rectangle(Point::new(x0, y), Size::new(w, h));
352            frame.fill(&path, Color::from_rgba(0.3, 0.55, 0.95, 0.28));
353            frame.stroke(
354                &path,
355                iced::widget::canvas::Stroke::default()
356                    .with_color(Color::from_rgba(0.5, 0.75, 1.0, 0.9))
357                    .with_width(1.5),
358            );
359        }
360
361        if let Some(note_idx) = state.hover_note_index
362            && let Some(note) = self.notes.get(note_idx)
363            && let Some(row_idx) = self.drum_rows.iter().position(|&p| p == note.pitch)
364        {
365            let pps = (self.pixels_per_sample * self.zoom_x).max(0.0001);
366            let x = note.start_sample as f32 * pps;
367            let y = row_idx as f32 * self.row_height + 1.0;
368            let w = (note.length_samples as f32 * pps).max(2.0);
369            let h = (self.row_height - 2.0).max(2.0);
370            frame.stroke(
371                &Path::rectangle(Point::new(x, y), Size::new(w, h)),
372                iced::widget::canvas::Stroke::default()
373                    .with_color(Color::from_rgba(1.0, 1.0, 1.0, 0.6))
374                    .with_width(1.5),
375            );
376        }
377
378        if let Some((start, end)) = self.selecting_rect {
379            let min_x = start.x.min(end.x);
380            let min_y = start.y.min(end.y);
381            let max_x = start.x.max(end.x);
382            let max_y = start.y.max(end.y);
383
384            let rect = Rectangle {
385                x: min_x,
386                y: min_y,
387                width: max_x - min_x,
388                height: max_y - min_y,
389            };
390
391            frame.fill(
392                &Path::rectangle(
393                    Point::new(rect.x, rect.y),
394                    Size::new(rect.width, rect.height),
395                ),
396                Color {
397                    r: 0.3,
398                    g: 0.5,
399                    b: 0.8,
400                    a: 0.2,
401                },
402            );
403            frame.stroke(
404                &Path::rectangle(
405                    Point::new(rect.x, rect.y),
406                    Size::new(rect.width, rect.height),
407                ),
408                iced::widget::canvas::Stroke::default()
409                    .with_color(Color::from_rgb(0.4, 0.6, 0.9))
410                    .with_width(1.5),
411            );
412        }
413
414        vec![frame.into_geometry()]
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::midi::PianoNote;
422    use iced::widget::canvas::Program;
423    use iced::{Event, Point, Rectangle, Size, event, mouse};
424    use std::collections::HashSet;
425
426    fn action_message(action: CanvasAction<DrumMessage>) -> (Option<DrumMessage>, event::Status) {
427        let (message, _redraw, status) = action.into_inner();
428        (message, status)
429    }
430
431    fn drum_note(start_sample: usize, pitch: u8) -> PianoNote {
432        PianoNote {
433            start_sample,
434            length_samples: 20,
435            pitch,
436            velocity: 100,
437            channel: 0,
438            mpe: Default::default(),
439        }
440    }
441
442    #[test]
443    fn drum_roll_click_on_note_selects_and_starts_drag() {
444        let interaction = DrumRollInteraction::new(
445            vec![drum_note(10, 38)],
446            1.0,
447            1.0,
448            vec![36, 38],
449            20.0,
450            None,
451            HashSet::new(),
452        );
453        let mut state = DrumRollInteractionState::default();
454        let bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 100.0));
455        let cursor = mouse::Cursor::Available(Point::new(15.0, 22.0));
456
457        let action = interaction
458            .update(
459                &mut state,
460                &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
461                bounds,
462                cursor,
463            )
464            .expect("action");
465
466        let (message, status) = action_message(action);
467        assert_eq!(message, Some(DrumMessage::NoteSelected(0)));
468        assert_eq!(status, event::Status::Captured);
469        assert_eq!(state.dragging_mode, DraggingMode::DraggingNote);
470        assert_eq!(state.drag_note_index, Some(0));
471    }
472
473    #[test]
474    fn drum_roll_drag_release_publishes_move_with_delta() {
475        let interaction = DrumRollInteraction::new(
476            vec![drum_note(10, 38)],
477            1.0,
478            1.0,
479            vec![36, 38],
480            20.0,
481            None,
482            HashSet::new(),
483        );
484        let mut state = DrumRollInteractionState::default();
485        let bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 100.0));
486        let press_cursor = mouse::Cursor::Available(Point::new(15.0, 22.0));
487        let release_cursor = mouse::Cursor::Available(Point::new(35.0, 22.0));
488
489        let _ = interaction.update(
490            &mut state,
491            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
492            bounds,
493            press_cursor,
494        );
495
496        let action = interaction
497            .update(
498                &mut state,
499                &Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
500                bounds,
501                release_cursor,
502            )
503            .expect("release action");
504
505        let (message, status) = action_message(action);
506        assert_eq!(
507            message,
508            Some(DrumMessage::NoteMove {
509                note_index: 0,
510                delta_samples: 20,
511            })
512        );
513        assert_eq!(status, event::Status::Captured);
514    }
515
516    #[test]
517    fn drum_roll_cursor_moved_while_dragging_requests_redraw() {
518        let interaction = DrumRollInteraction::new(
519            vec![drum_note(10, 38)],
520            1.0,
521            1.0,
522            vec![36, 38],
523            20.0,
524            None,
525            HashSet::new(),
526        );
527        let mut state = DrumRollInteractionState::default();
528        let bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 100.0));
529        let press_cursor = mouse::Cursor::Available(Point::new(15.0, 22.0));
530        let drag_cursor = mouse::Cursor::Available(Point::new(35.0, 22.0));
531
532        let _ = interaction.update(
533            &mut state,
534            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
535            bounds,
536            press_cursor,
537        );
538
539        let action = interaction
540            .update(
541                &mut state,
542                &Event::Mouse(mouse::Event::CursorMoved {
543                    position: Point::new(35.0, 22.0),
544                }),
545                bounds,
546                drag_cursor,
547            )
548            .expect("drag action");
549
550        let (message, _status) = action_message(action);
551        assert!(message.is_none());
552    }
553
554    #[test]
555    fn drum_roll_right_drag_publishes_paint_create_messages() {
556        let interaction = DrumRollInteraction::new(
557            Vec::new(),
558            1.0,
559            1.0,
560            vec![36, 38],
561            20.0,
562            None,
563            HashSet::new(),
564        );
565        let mut state = DrumRollInteractionState::default();
566        let bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 100.0));
567        let press_cursor = mouse::Cursor::Available(Point::new(12.0, 2.0));
568        let drag_cursor = mouse::Cursor::Unavailable;
569
570        let press = interaction
571            .update(
572                &mut state,
573                &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)),
574                bounds,
575                press_cursor,
576            )
577            .expect("press action");
578        let (message, status) = action_message(press);
579        assert_eq!(message, None);
580        assert_eq!(status, event::Status::Captured);
581        assert_eq!(state.dragging_mode, DraggingMode::CreatingNote);
582
583        let drag = interaction
584            .update(
585                &mut state,
586                &Event::Mouse(mouse::Event::CursorMoved {
587                    position: Point::new(32.0, 22.0),
588                }),
589                bounds,
590                drag_cursor,
591            )
592            .expect("drag action");
593        let (message, _status) = action_message(drag);
594        assert_eq!(message, None);
595
596        let release_cursor = mouse::Cursor::Available(Point::new(32.0, 22.0));
597        let release = interaction
598            .update(
599                &mut state,
600                &Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)),
601                bounds,
602                release_cursor,
603            )
604            .expect("release action");
605        let (message, status) = action_message(release);
606        assert_eq!(
607            message,
608            Some(DrumMessage::NoteCreate {
609                start_sample: 12,
610                end_sample: 32,
611                pitch: 36,
612                repeat: false,
613            })
614        );
615        assert_eq!(status, event::Status::Captured);
616    }
617
618    #[test]
619    fn drum_roll_right_click_creates_note_on_release() {
620        let interaction = DrumRollInteraction::new(
621            Vec::new(),
622            1.0,
623            1.0,
624            vec![36, 38],
625            20.0,
626            None,
627            HashSet::new(),
628        );
629        let mut state = DrumRollInteractionState::default();
630        let bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 100.0));
631        let cursor = mouse::Cursor::Available(Point::new(12.0, 2.0));
632
633        let _ = interaction.update(
634            &mut state,
635            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)),
636            bounds,
637            cursor,
638        );
639
640        let release = interaction
641            .update(
642                &mut state,
643                &Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)),
644                bounds,
645                cursor,
646            )
647            .expect("release action");
648        let (message, status) = action_message(release);
649        assert_eq!(
650            message,
651            Some(DrumMessage::NoteCreate {
652                start_sample: 12,
653                end_sample: 12,
654                pitch: 36,
655                repeat: false,
656            })
657        );
658        assert_eq!(status, event::Status::Captured);
659        assert_eq!(state.dragging_mode, DraggingMode::None);
660    }
661
662    #[test]
663    fn drum_roll_shift_right_drag_marks_create_as_repeat() {
664        let mut interaction = DrumRollInteraction::new(
665            Vec::new(),
666            1.0,
667            1.0,
668            vec![36, 38],
669            20.0,
670            None,
671            HashSet::new(),
672        );
673        interaction.repeat_create = true;
674        let mut state = DrumRollInteractionState::default();
675        let bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 100.0));
676        let press_cursor = mouse::Cursor::Available(Point::new(12.0, 2.0));
677        let release_cursor = mouse::Cursor::Available(Point::new(32.0, 22.0));
678
679        let _ = interaction.update(
680            &mut state,
681            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)),
682            bounds,
683            press_cursor,
684        );
685
686        let release = interaction
687            .update(
688                &mut state,
689                &Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)),
690                bounds,
691                release_cursor,
692            )
693            .expect("release action");
694        let (message, status) = action_message(release);
695        assert_eq!(
696            message,
697            Some(DrumMessage::NoteCreate {
698                start_sample: 12,
699                end_sample: 32,
700                pitch: 36,
701                repeat: true,
702            })
703        );
704        assert_eq!(status, event::Status::Captured);
705    }
706}