1use crate::midi::{MIDI_NOTE_COUNT, NOTES_PER_OCTAVE, WHITE_KEY_HEIGHT, WHITE_KEYS_PER_OCTAVE};
2use iced::{
3 Background, Color, Event, Point, Rectangle, Renderer, Size, Theme, gradient, mouse,
4 widget::canvas::{self, Action as CanvasAction, Frame, Geometry, Path, Program},
5};
6use std::collections::{HashMap, HashSet};
7
8pub fn is_black_key(pitch: u8) -> bool {
9 matches!(pitch % 12, 1 | 3 | 6 | 8 | 10)
10}
11
12pub fn note_color(velocity: u8, channel: u8) -> Color {
13 let t = (velocity as f32 / 127.0).clamp(0.0, 1.0);
14 let c = (channel as f32 / 15.0).clamp(0.0, 1.0);
15 Color {
16 r: 0.25 + 0.45 * t,
17 g: 0.35 + 0.4 * (1.0 - c),
18 b: 0.65 + 0.3 * c,
19 a: 0.9,
20 }
21}
22
23pub fn brighten(color: Color, amount: f32) -> Color {
24 Color {
25 r: (color.r + amount).min(1.0),
26 g: (color.g + amount).min(1.0),
27 b: (color.b + amount).min(1.0),
28 a: color.a,
29 }
30}
31
32pub fn darken(color: Color, amount: f32) -> Color {
33 Color {
34 r: (color.r - amount).max(0.0),
35 g: (color.g - amount).max(0.0),
36 b: (color.b - amount).max(0.0),
37 a: color.a,
38 }
39}
40
41pub fn note_two_edge_gradient(base: Color) -> Background {
42 let edge = brighten(base, 0.08);
43 let middle = darken(base, 0.08);
44 Background::Gradient(
45 gradient::Linear::new(0.0)
46 .add_stop(0.0, edge)
47 .add_stop(0.5, middle)
48 .add_stop(1.0, edge)
49 .into(),
50 )
51}
52
53pub fn octave_note_count(octave: u8) -> u8 {
54 let start = usize::from(octave) * NOTES_PER_OCTAVE;
55 if start >= MIDI_NOTE_COUNT {
56 0
57 } else {
58 (MIDI_NOTE_COUNT - start).min(NOTES_PER_OCTAVE) as u8
59 }
60}
61
62fn draw_chromatic_rows(
63 renderer: &Renderer,
64 bounds: Rectangle,
65 pressed_notes: &HashSet<u8>,
66 octave: u8,
67 midnam_note_names: &HashMap<u8, String>,
68 note_count: u8,
69) -> Vec<canvas::Geometry> {
70 let mut frame = Frame::new(renderer, bounds.size());
71 let note_height = bounds.height / f32::from(note_count.max(1));
72
73 for i in 0..note_count {
74 let note_in_octave = note_count - 1 - i;
75 let midi_note = octave * 12 + note_in_octave;
76 let is_pressed = pressed_notes.contains(¬e_in_octave);
77 let y_pos = f32::from(i) * note_height;
78
79 let rect = Path::rectangle(
80 Point::new(0.0, y_pos),
81 Size::new(bounds.width, note_height - 1.0),
82 );
83 let is_black = is_black_key(note_in_octave);
84
85 frame.fill(
86 &rect,
87 if is_pressed {
88 Color::from_rgb(0.2, 0.6, 0.9)
89 } else if is_black {
90 Color::from_rgb(0.18, 0.18, 0.2)
91 } else {
92 Color::from_rgb(0.92, 0.92, 0.94)
93 },
94 );
95 frame.stroke(
96 &rect,
97 canvas::Stroke::default()
98 .with_width(1.0)
99 .with_color(Color::from_rgb(0.25, 0.25, 0.28)),
100 );
101
102 if let Some(note_name) = midnam_note_names.get(&midi_note) {
103 use iced::widget::canvas::Text;
104 frame.fill_text(Text {
105 content: note_name.clone(),
106 position: Point::new(4.0, y_pos + note_height * 0.5 - 6.0),
107 color: if is_black { Color::WHITE } else { Color::BLACK },
108 size: 11.0.into(),
109 ..Text::default()
110 });
111 }
112 }
113
114 vec![frame.into_geometry()]
115}
116
117fn draw_partial_octave(
118 renderer: &Renderer,
119 bounds: Rectangle,
120 pressed_notes: &HashSet<u8>,
121 octave: u8,
122 midnam_note_names: &HashMap<u8, String>,
123 note_count: u8,
124) -> Vec<canvas::Geometry> {
125 let mut frame = Frame::new(renderer, bounds.size());
126 let white_note_ids = [0_u8, 2, 4, 5, 7];
127 let black_key_offsets = [1_u8, 2, 4];
128 let black_note_ids = [1_u8, 3, 6];
129 let white_key_height = bounds.height / white_note_ids.len() as f32;
130 let black_key_height = white_key_height * 0.6;
131 let black_key_width = bounds.width * 0.6;
132
133 for (i, note_id) in white_note_ids.iter().enumerate() {
134 let midi_note = octave * 12 + *note_id;
135 let is_pressed = pressed_notes.contains(note_id);
136 let y_pos = bounds.height - ((i + 1) as f32 * white_key_height);
137 let rect = Path::rectangle(
138 Point::new(0.0, y_pos),
139 Size::new(bounds.width, white_key_height - 1.0),
140 );
141 frame.fill(
142 &rect,
143 if is_pressed {
144 Color::from_rgb(0.0, 0.5, 1.0)
145 } else {
146 Color::WHITE
147 },
148 );
149 frame.stroke(&rect, canvas::Stroke::default().with_width(1.0));
150 if let Some(note_name) = midnam_note_names.get(&midi_note) {
151 use iced::widget::canvas::Text;
152 frame.fill_text(Text {
153 content: note_name.clone(),
154 position: Point::new(bounds.width - 25.0, y_pos + white_key_height * 0.5 - 6.0),
155 color: Color::BLACK,
156 size: 10.0.into(),
157 ..Text::default()
158 });
159 }
160 }
161
162 for (idx, offset) in black_key_offsets.iter().enumerate() {
163 let note_id = black_note_ids[idx];
164 if note_id >= note_count {
165 continue;
166 }
167 let is_pressed = pressed_notes.contains(¬e_id);
168 let y_pos_black =
169 bounds.height - (f32::from(*offset) * white_key_height) - (black_key_height * 0.5);
170 let rect = Path::rectangle(
171 Point::new(0.0, y_pos_black),
172 Size::new(black_key_width, black_key_height),
173 );
174 frame.fill(
175 &rect,
176 if is_pressed {
177 Color::from_rgb(0.0, 0.4, 0.8)
178 } else {
179 Color::BLACK
180 },
181 );
182 }
183
184 vec![frame.into_geometry()]
185}
186
187pub fn draw_octave(
188 renderer: &Renderer,
189 bounds: Rectangle,
190 pressed_notes: &HashSet<u8>,
191 octave: u8,
192 midnam_note_names: &HashMap<u8, String>,
193) -> Vec<canvas::Geometry> {
194 let mut frame = Frame::new(renderer, bounds.size());
195 let white_key_height = bounds.height / 7.0;
196
197 for i in 0..7 {
198 let note_id = match i {
199 0 => 0,
200 1 => 2,
201 2 => 4,
202 3 => 5,
203 4 => 7,
204 5 => 9,
205 6 => 11,
206 _ => 0,
207 };
208 let midi_note = octave * 12 + note_id;
209 let is_pressed = pressed_notes.contains(¬e_id);
210 let y_pos = bounds.height - ((i + 1) as f32 * white_key_height);
211 let rect = Path::rectangle(
212 Point::new(0.0, y_pos),
213 Size::new(bounds.width, white_key_height - 1.0),
214 );
215
216 frame.fill(
217 &rect,
218 if is_pressed {
219 Color::from_rgb(0.0, 0.5, 1.0)
220 } else {
221 Color::WHITE
222 },
223 );
224 frame.stroke(&rect, canvas::Stroke::default().with_width(1.0));
225
226 if let Some(note_name) = midnam_note_names.get(&midi_note) {
227 use iced::widget::canvas::Text;
228 frame.fill_text(Text {
229 content: note_name.clone(),
230 position: Point::new(bounds.width - 25.0, y_pos + white_key_height * 0.5 - 6.0),
231 color: Color::BLACK,
232 size: 10.0.into(),
233 ..Text::default()
234 });
235 }
236 }
237
238 let black_key_offsets = [1, 2, 4, 5, 6];
239 let black_note_ids = [1, 3, 6, 8, 10];
240 let black_key_width = bounds.width * 0.6;
241 let black_key_height = white_key_height * 0.6;
242
243 for (idx, offset) in black_key_offsets.iter().enumerate() {
244 let note_id = black_note_ids[idx];
245 let is_pressed = pressed_notes.contains(¬e_id);
246 let y_pos_black =
247 bounds.height - (*offset as f32 * white_key_height) - (black_key_height * 0.5);
248 let rect = Path::rectangle(
249 Point::new(0.0, y_pos_black),
250 Size::new(black_key_width, black_key_height),
251 );
252
253 frame.fill(
254 &rect,
255 if is_pressed {
256 Color::from_rgb(0.0, 0.4, 0.8)
257 } else {
258 Color::BLACK
259 },
260 );
261 }
262
263 vec![frame.into_geometry()]
264}
265
266#[derive(Debug, Clone)]
267pub struct OctaveKeyboard<Message, Press, Release>
268where
269 Press: Fn(u8) -> Message + Clone,
270 Release: Fn(u8) -> Message + Clone,
271{
272 pub octave: u8,
273 pub note_count: u8,
274 pub midnam_note_names: HashMap<u8, String>,
275 on_press: Press,
276 on_release: Release,
277}
278
279impl<Message, Press, Release> OctaveKeyboard<Message, Press, Release>
280where
281 Press: Fn(u8) -> Message + Clone,
282 Release: Fn(u8) -> Message + Clone,
283{
284 pub fn new(
285 octave: u8,
286 midnam_note_names: HashMap<u8, String>,
287 on_press: Press,
288 on_release: Release,
289 ) -> Self {
290 Self {
291 octave,
292 note_count: octave_note_count(octave),
293 midnam_note_names,
294 on_press,
295 on_release,
296 }
297 }
298
299 fn note_class_at(&self, cursor: Point, bounds: Rectangle) -> Option<u8> {
300 if self.note_count == 0 {
301 return None;
302 }
303 if self.note_count < NOTES_PER_OCTAVE as u8 {
304 let white_note_ids = [0_u8, 2, 4, 5, 7];
305 let black_key_offsets = [1_u8, 2, 4];
306 let black_note_ids = [1_u8, 3, 6];
307 let white_key_height = bounds.height / white_note_ids.len() as f32;
308 let black_key_width = bounds.width * 0.6;
309 let black_key_height = white_key_height * 0.6;
310
311 if cursor.x <= black_key_width {
312 for (idx, offset) in black_key_offsets.iter().enumerate() {
313 let note_id = black_note_ids[idx];
314 if note_id >= self.note_count {
315 continue;
316 }
317 let y_pos_black = bounds.height
318 - (f32::from(*offset) * white_key_height)
319 - (black_key_height * 0.5);
320 if cursor.y >= y_pos_black && cursor.y <= y_pos_black + black_key_height {
321 return Some(note_id);
322 }
323 }
324 }
325
326 for (i, note_id) in white_note_ids.iter().enumerate() {
327 let y_pos = bounds.height - ((i + 1) as f32 * white_key_height);
328 if cursor.y >= y_pos && cursor.y <= y_pos + white_key_height {
329 return Some(*note_id);
330 }
331 }
332 return None;
333 }
334 let white_key_height = bounds.height / 7.0;
335 let black_key_offsets = [1, 2, 4, 5, 6];
336 let black_note_ids = [1, 3, 6, 8, 10];
337 let black_key_width = bounds.width * 0.6;
338 let black_key_height = white_key_height * 0.6;
339
340 if cursor.x <= black_key_width {
341 for (idx, offset) in black_key_offsets.iter().enumerate() {
342 let y_pos_black =
343 bounds.height - (*offset as f32 * white_key_height) - (black_key_height * 0.5);
344 if cursor.y >= y_pos_black && cursor.y <= y_pos_black + black_key_height {
345 return Some(black_note_ids[idx]);
346 }
347 }
348 }
349
350 for i in 0..7 {
351 let note_id = match i {
352 0 => 0,
353 1 => 2,
354 2 => 4,
355 3 => 5,
356 4 => 7,
357 5 => 9,
358 6 => 11,
359 _ => 0,
360 };
361 let y_pos = bounds.height - ((i + 1) as f32 * white_key_height);
362 if cursor.y >= y_pos && cursor.y <= y_pos + white_key_height {
363 return Some(note_id);
364 }
365 }
366 None
367 }
368
369 fn midi_note(&self, note_class: u8) -> u8 {
370 (usize::from(self.octave) * 12 + usize::from(note_class)) as u8
371 }
372}
373
374#[derive(Default, Debug)]
375pub struct OctaveKeyboardState {
376 pub pressed_notes: HashSet<u8>,
377 pub active_note_class: Option<u8>,
378}
379
380impl<Message, Press, Release> Program<Message> for OctaveKeyboard<Message, Press, Release>
381where
382 Message: 'static,
383 Press: Fn(u8) -> Message + Clone,
384 Release: Fn(u8) -> Message + Clone,
385{
386 type State = OctaveKeyboardState;
387
388 fn update(
389 &self,
390 state: &mut Self::State,
391 event: &Event,
392 bounds: Rectangle,
393 cursor: mouse::Cursor,
394 ) -> Option<CanvasAction<Message>> {
395 match event {
396 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
397 if let Some(position) = cursor.position_in(bounds)
398 && let Some(note_class) = self.note_class_at(position, bounds)
399 {
400 state.active_note_class = Some(note_class);
401 state.pressed_notes.clear();
402 state.pressed_notes.insert(note_class);
403 return Some(
404 CanvasAction::publish((self.on_press.clone())(self.midi_note(note_class)))
405 .and_capture(),
406 );
407 }
408 }
409 Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
410 if let Some(note_class) = state.active_note_class.take() {
411 state.pressed_notes.clear();
412 return Some(CanvasAction::publish((self.on_release.clone())(
413 self.midi_note(note_class),
414 )));
415 }
416 }
417 _ => {}
418 }
419 None
420 }
421
422 fn draw(
423 &self,
424 state: &Self::State,
425 renderer: &Renderer,
426 _theme: &Theme,
427 bounds: Rectangle,
428 _cursor: mouse::Cursor,
429 ) -> Vec<Geometry> {
430 let is_piano = self.midnam_note_names.is_empty()
431 || self.midnam_note_names.values().all(|name| {
432 matches!(
433 name.as_str(),
434 "C" | "C#" | "D" | "D#" | "E" | "F" | "F#" | "G" | "G#" | "A" | "A#" | "B"
435 ) || name.starts_with('C')
436 || name.starts_with('D')
437 || name.starts_with('E')
438 || name.starts_with('F')
439 || name.starts_with('G')
440 || name.starts_with('A')
441 || name.starts_with('B')
442 });
443
444 if is_piano {
445 if self.note_count == NOTES_PER_OCTAVE as u8 {
446 draw_octave(
447 renderer,
448 bounds,
449 &state.pressed_notes,
450 self.octave,
451 &self.midnam_note_names,
452 )
453 } else {
454 draw_partial_octave(
455 renderer,
456 bounds,
457 &state.pressed_notes,
458 self.octave,
459 &self.midnam_note_names,
460 self.note_count,
461 )
462 }
463 } else {
464 draw_chromatic_rows(
465 renderer,
466 bounds,
467 &state.pressed_notes,
468 self.octave,
469 &self.midnam_note_names,
470 self.note_count,
471 )
472 }
473 }
474}
475
476pub fn row_height(zoom_y: f32) -> f32 {
477 ((WHITE_KEY_HEIGHT * WHITE_KEYS_PER_OCTAVE as f32 / NOTES_PER_OCTAVE as f32) * zoom_y).max(1.0)
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use iced::widget::canvas::Program;
484 use iced::{Point, Rectangle, Size, event, mouse};
485
486 #[derive(Debug, Clone, PartialEq, Eq)]
487 enum TestMessage {
488 Pressed(u8),
489 Released(u8),
490 }
491
492 fn action_message(action: CanvasAction<TestMessage>) -> (Option<TestMessage>, event::Status) {
493 let (message, _redraw, status) = action.into_inner();
494 (message, status)
495 }
496
497 #[test]
498 fn octave_keyboard_update_publishes_pressed_and_released_notes() {
499 let keyboard = OctaveKeyboard::new(
500 4,
501 HashMap::new(),
502 TestMessage::Pressed,
503 TestMessage::Released,
504 );
505 let bounds = Rectangle::new(Point::ORIGIN, Size::new(20.0, 70.0));
506 let press_cursor = mouse::Cursor::Available(Point::new(15.0, 65.0));
507 let release_cursor = mouse::Cursor::Available(Point::new(15.0, 65.0));
508 let mut state = OctaveKeyboardState::default();
509
510 let press = keyboard
511 .update(
512 &mut state,
513 &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
514 bounds,
515 press_cursor,
516 )
517 .expect("press action");
518 let (message, status) = action_message(press);
519 assert_eq!(message, Some(TestMessage::Pressed(48)));
520 assert_eq!(status, event::Status::Captured);
521 assert_eq!(state.active_note_class, Some(0));
522 assert!(state.pressed_notes.contains(&0));
523
524 let release = keyboard
525 .update(
526 &mut state,
527 &Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
528 bounds,
529 release_cursor,
530 )
531 .expect("release action");
532 let (message, status) = action_message(release);
533 assert_eq!(message, Some(TestMessage::Released(48)));
534 assert_eq!(status, event::Status::Ignored);
535 assert!(state.pressed_notes.is_empty());
536 }
537
538 #[test]
539 fn partial_octave_keyboard_maps_top_note_to_midi_127() {
540 let keyboard = OctaveKeyboard::new(
541 10,
542 HashMap::new(),
543 TestMessage::Pressed,
544 TestMessage::Released,
545 );
546 let bounds = Rectangle::new(Point::ORIGIN, Size::new(20.0, 80.0));
547 let cursor = mouse::Cursor::Available(Point::new(15.0, 5.0));
548 let mut state = OctaveKeyboardState::default();
549
550 let press = keyboard
551 .update(
552 &mut state,
553 &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
554 bounds,
555 cursor,
556 )
557 .expect("press action");
558 let (message, status) = action_message(press);
559
560 assert_eq!(message, Some(TestMessage::Pressed(127)));
561 assert_eq!(status, event::Status::Captured);
562 assert_eq!(state.active_note_class, Some(7));
563 }
564}