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        pitch: u8,
15    },
16    NoteDelete(usize),
17    NoteMove {
18        note_index: usize,
19        delta_samples: i64,
20    },
21    AdjustVelocity {
22        note_index: usize,
23        delta: i8,
24    },
25    SelectRectStart {
26        position: Point,
27    },
28    SelectRectDrag {
29        position: Point,
30    },
31    SelectRectEnd,
32}
33
34#[derive(Default, Debug, Clone, Copy, PartialEq)]
35pub enum DraggingMode {
36    #[default]
37    None,
38    SelectingRect,
39    DraggingNote,
40}
41
42#[derive(Debug)]
43pub struct DrumRollInteraction {
44    pub notes: Vec<PianoNote>,
45    pub pixels_per_sample: f32,
46    pub zoom_x: f32,
47    pub drum_rows: Vec<u8>,
48    pub row_height: f32,
49    pub selecting_rect: Option<(Point, Point)>,
50    pub selected_notes: HashSet<usize>,
51}
52
53#[derive(Default, Debug)]
54pub struct DrumRollInteractionState {
55    pub dragging_mode: DraggingMode,
56    pub drag_start: Option<Point>,
57    pub drag_note_index: Option<usize>,
58    pub hover_note_index: Option<usize>,
59}
60
61impl DrumRollInteraction {
62    pub fn new(
63        notes: Vec<PianoNote>,
64        pixels_per_sample: f32,
65        zoom_x: f32,
66        drum_rows: Vec<u8>,
67        row_height: f32,
68        selecting_rect: Option<(Point, Point)>,
69        selected_notes: HashSet<usize>,
70    ) -> Self {
71        Self {
72            notes,
73            pixels_per_sample,
74            zoom_x,
75            drum_rows,
76            row_height,
77            selecting_rect,
78            selected_notes,
79        }
80    }
81
82    fn note_at_position(&self, position: Point, pps: f32, notes: &[PianoNote]) -> Option<usize> {
83        for (idx, note) in notes.iter().enumerate() {
84            let Some(row_idx) = self.drum_rows.iter().position(|&p| p == note.pitch) else {
85                continue;
86            };
87            let y = row_idx as f32 * self.row_height + 1.0;
88            let x = note.start_sample as f32 * pps;
89            let w = (note.length_samples as f32 * pps).max(2.0);
90            let h = (self.row_height - 2.0).max(2.0);
91            if position.x >= x && position.x <= x + w && position.y >= y && position.y <= y + h {
92                return Some(idx);
93            }
94        }
95        None
96    }
97
98    fn pitch_at_y(&self, y: f32) -> u8 {
99        let row_idx = (y / self.row_height)
100            .floor()
101            .clamp(0.0, (self.drum_rows.len().saturating_sub(1)) as f32)
102            as usize;
103        self.drum_rows.get(row_idx).copied().unwrap_or(60)
104    }
105
106    fn sample_at_x(&self, x: f32, pps: f32) -> usize {
107        (x / pps).max(0.0) as usize
108    }
109}
110
111impl Program<DrumMessage> for DrumRollInteraction {
112    type State = DrumRollInteractionState;
113
114    fn update(
115        &self,
116        state: &mut Self::State,
117        event: &Event,
118        bounds: Rectangle,
119        cursor: mouse::Cursor,
120    ) -> Option<CanvasAction<DrumMessage>> {
121        let pps = (self.pixels_per_sample * self.zoom_x).max(0.0001);
122        let notes = &self.notes;
123
124        match event {
125            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
126                if let Some(position) = cursor.position_in(bounds) {
127                    if let Some(note_idx) = self.note_at_position(position, pps, notes) {
128                        state.drag_start = Some(position);
129                        state.drag_note_index = Some(note_idx);
130                        state.dragging_mode = DraggingMode::DraggingNote;
131                        return Some(
132                            CanvasAction::publish(DrumMessage::NoteSelected(note_idx))
133                                .and_capture(),
134                        );
135                    } else {
136                        state.drag_start = Some(position);
137                        state.drag_note_index = None;
138                        state.dragging_mode = DraggingMode::SelectingRect;
139                        return Some(
140                            CanvasAction::publish(DrumMessage::SelectRectStart { position })
141                                .and_capture(),
142                        );
143                    }
144                }
145            }
146            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
147                if let Some(position) = cursor.position_in(bounds) {
148                    let pitch = self.pitch_at_y(position.y);
149                    let start_sample = self.sample_at_x(position.x, pps);
150                    return Some(
151                        CanvasAction::publish(DrumMessage::NoteCreate {
152                            start_sample,
153                            pitch,
154                        })
155                        .and_capture(),
156                    );
157                }
158            }
159            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) => {
160                if let Some(position) = cursor.position_in(bounds)
161                    && let Some(note_idx) = self.note_at_position(position, pps, notes)
162                {
163                    return Some(
164                        CanvasAction::publish(DrumMessage::NoteDelete(note_idx)).and_capture(),
165                    );
166                }
167            }
168            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
169                if let Some(position) = cursor.position_in(bounds) {
170                    match state.dragging_mode {
171                        DraggingMode::SelectingRect => {
172                            return Some(CanvasAction::publish(DrumMessage::SelectRectDrag {
173                                position,
174                            }));
175                        }
176                        DraggingMode::DraggingNote => {
177                            return Some(CanvasAction::request_redraw());
178                        }
179                        DraggingMode::None => {}
180                    }
181                    state.hover_note_index = self.note_at_position(position, pps, notes);
182                }
183            }
184            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
185                let mode = state.dragging_mode;
186
187                match mode {
188                    DraggingMode::SelectingRect => {
189                        state.drag_start = None;
190                        state.drag_note_index = None;
191                        state.dragging_mode = DraggingMode::None;
192                        return Some(CanvasAction::publish(DrumMessage::SelectRectEnd));
193                    }
194                    DraggingMode::DraggingNote => {
195                        if let (Some(drag_start), Some(note_idx)) =
196                            (state.drag_start.take(), state.drag_note_index.take())
197                        {
198                            state.dragging_mode = DraggingMode::None;
199                            if let Some(position) = cursor.position_in(bounds) {
200                                let delta_x = position.x - drag_start.x;
201                                let delta_samples = (delta_x / pps) as i64;
202                                if delta_samples != 0 {
203                                    return Some(
204                                        CanvasAction::publish(DrumMessage::NoteMove {
205                                            note_index: note_idx,
206                                            delta_samples,
207                                        })
208                                        .and_capture(),
209                                    );
210                                }
211                            }
212                        }
213                    }
214                    DraggingMode::None => {}
215                }
216            }
217            Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
218                if let Some(position) = cursor.position_in(bounds) {
219                    let raw = match delta {
220                        mouse::ScrollDelta::Lines { y, .. } => *y,
221                        mouse::ScrollDelta::Pixels { y, .. } => *y / 16.0,
222                    };
223                    let steps = raw.round() as i32;
224                    if steps != 0
225                        && let Some(note_idx) = self.note_at_position(position, pps, notes)
226                    {
227                        let delta = steps.clamp(-24, 24) as i8;
228                        return Some(
229                            CanvasAction::publish(DrumMessage::AdjustVelocity {
230                                note_index: note_idx,
231                                delta,
232                            })
233                            .and_capture(),
234                        );
235                    }
236                }
237            }
238            _ => {}
239        }
240        None
241    }
242
243    fn draw(
244        &self,
245        state: &Self::State,
246        renderer: &Renderer,
247        _theme: &Theme,
248        bounds: Rectangle,
249        cursor: mouse::Cursor,
250    ) -> Vec<Geometry> {
251        let mut frame = Frame::new(renderer, bounds.size());
252
253        if state.dragging_mode == DraggingMode::DraggingNote
254            && let (Some(drag_start), Some(cursor_pos)) =
255                (state.drag_start, cursor.position_in(bounds))
256        {
257            let pps = (self.pixels_per_sample * self.zoom_x).max(0.0001);
258            let delta_x = cursor_pos.x - drag_start.x;
259            for &note_idx in &self.selected_notes {
260                if let Some(note) = self.notes.get(note_idx)
261                    && let Some(row_idx) = self.drum_rows.iter().position(|&p| p == note.pitch)
262                {
263                    let x = note.start_sample as f32 * pps + delta_x;
264                    let y = row_idx as f32 * self.row_height + 1.0;
265                    let w = (note.length_samples as f32 * pps).max(2.0);
266                    let h = (self.row_height - 2.0).max(2.0);
267                    frame.fill(
268                        &Path::rectangle(Point::new(x, y), Size::new(w, h)),
269                        Color::from_rgba(0.9, 0.9, 0.95, 0.35),
270                    );
271                }
272            }
273        }
274
275        if let Some(note_idx) = state.hover_note_index
276            && let Some(note) = self.notes.get(note_idx)
277            && let Some(row_idx) = self.drum_rows.iter().position(|&p| p == note.pitch)
278        {
279            let pps = (self.pixels_per_sample * self.zoom_x).max(0.0001);
280            let x = note.start_sample as f32 * pps;
281            let y = row_idx as f32 * self.row_height + 1.0;
282            let w = (note.length_samples as f32 * pps).max(2.0);
283            let h = (self.row_height - 2.0).max(2.0);
284            frame.stroke(
285                &Path::rectangle(Point::new(x, y), Size::new(w, h)),
286                iced::widget::canvas::Stroke::default()
287                    .with_color(Color::from_rgba(1.0, 1.0, 1.0, 0.6))
288                    .with_width(1.5),
289            );
290        }
291
292        if let Some((start, end)) = self.selecting_rect {
293            let min_x = start.x.min(end.x);
294            let min_y = start.y.min(end.y);
295            let max_x = start.x.max(end.x);
296            let max_y = start.y.max(end.y);
297
298            let rect = Rectangle {
299                x: min_x,
300                y: min_y,
301                width: max_x - min_x,
302                height: max_y - min_y,
303            };
304
305            frame.fill(
306                &Path::rectangle(
307                    Point::new(rect.x, rect.y),
308                    Size::new(rect.width, rect.height),
309                ),
310                Color {
311                    r: 0.3,
312                    g: 0.5,
313                    b: 0.8,
314                    a: 0.2,
315                },
316            );
317            frame.stroke(
318                &Path::rectangle(
319                    Point::new(rect.x, rect.y),
320                    Size::new(rect.width, rect.height),
321                ),
322                iced::widget::canvas::Stroke::default()
323                    .with_color(Color::from_rgb(0.4, 0.6, 0.9))
324                    .with_width(1.5),
325            );
326        }
327
328        vec![frame.into_geometry()]
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::midi::PianoNote;
336    use iced::widget::canvas::Program;
337    use iced::{Event, Point, Rectangle, Size, event, mouse};
338    use std::collections::HashSet;
339
340    fn action_message(action: CanvasAction<DrumMessage>) -> (Option<DrumMessage>, event::Status) {
341        let (message, _redraw, status) = action.into_inner();
342        (message, status)
343    }
344
345    fn drum_note(start_sample: usize, pitch: u8) -> PianoNote {
346        PianoNote {
347            start_sample,
348            length_samples: 20,
349            pitch,
350            velocity: 100,
351            channel: 0,
352            mpe: Default::default(),
353        }
354    }
355
356    #[test]
357    fn drum_roll_click_on_note_selects_and_starts_drag() {
358        let interaction = DrumRollInteraction::new(
359            vec![drum_note(10, 38)],
360            1.0,
361            1.0,
362            vec![36, 38],
363            20.0,
364            None,
365            HashSet::new(),
366        );
367        let mut state = DrumRollInteractionState::default();
368        let bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 100.0));
369        let cursor = mouse::Cursor::Available(Point::new(15.0, 22.0));
370
371        let action = interaction
372            .update(
373                &mut state,
374                &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
375                bounds,
376                cursor,
377            )
378            .expect("action");
379
380        let (message, status) = action_message(action);
381        assert_eq!(message, Some(DrumMessage::NoteSelected(0)));
382        assert_eq!(status, event::Status::Captured);
383        assert_eq!(state.dragging_mode, DraggingMode::DraggingNote);
384        assert_eq!(state.drag_note_index, Some(0));
385    }
386
387    #[test]
388    fn drum_roll_drag_release_publishes_move_with_delta() {
389        let interaction = DrumRollInteraction::new(
390            vec![drum_note(10, 38)],
391            1.0,
392            1.0,
393            vec![36, 38],
394            20.0,
395            None,
396            HashSet::new(),
397        );
398        let mut state = DrumRollInteractionState::default();
399        let bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 100.0));
400        let press_cursor = mouse::Cursor::Available(Point::new(15.0, 22.0));
401        let release_cursor = mouse::Cursor::Available(Point::new(35.0, 22.0));
402
403        let _ = interaction.update(
404            &mut state,
405            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
406            bounds,
407            press_cursor,
408        );
409
410        let action = interaction
411            .update(
412                &mut state,
413                &Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
414                bounds,
415                release_cursor,
416            )
417            .expect("release action");
418
419        let (message, status) = action_message(action);
420        assert_eq!(
421            message,
422            Some(DrumMessage::NoteMove {
423                note_index: 0,
424                delta_samples: 20,
425            })
426        );
427        assert_eq!(status, event::Status::Captured);
428    }
429
430    #[test]
431    fn drum_roll_cursor_moved_while_dragging_requests_redraw() {
432        let interaction = DrumRollInteraction::new(
433            vec![drum_note(10, 38)],
434            1.0,
435            1.0,
436            vec![36, 38],
437            20.0,
438            None,
439            HashSet::new(),
440        );
441        let mut state = DrumRollInteractionState::default();
442        let bounds = Rectangle::new(Point::ORIGIN, Size::new(200.0, 100.0));
443        let press_cursor = mouse::Cursor::Available(Point::new(15.0, 22.0));
444        let drag_cursor = mouse::Cursor::Available(Point::new(35.0, 22.0));
445
446        let _ = interaction.update(
447            &mut state,
448            &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
449            bounds,
450            press_cursor,
451        );
452
453        let action = interaction
454            .update(
455                &mut state,
456                &Event::Mouse(mouse::Event::CursorMoved {
457                    position: Point::new(35.0, 22.0),
458                }),
459                bounds,
460                drag_cursor,
461            )
462            .expect("drag action");
463
464        let (message, _status) = action_message(action);
465        assert!(message.is_none());
466    }
467}