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 ¬e_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}