Skip to main content

maolan_widgets/
piano.rs

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
62/// Orientation of a piano-style keyboard within its bounding rectangle.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum Orientation {
65    /// Horizontal, white keys run left-to-right, black keys protrude to the top.
66    #[default]
67    Degree0,
68    /// Vertical, white keys run top-to-bottom, black keys protrude to the right.
69    Degree90,
70    /// Horizontal, white keys run left-to-right, black keys protrude to the bottom.
71    Degree180,
72    /// Vertical, white keys run bottom-to-top, black keys protrude to the left.
73    Degree270,
74}
75
76/// Ratio of the keyboard's cross-axis depth that black keys occupy.
77///
78/// At 0.5 the black-key region and the visible white-key front are equally
79/// wide, so white keys are exactly twice as wide as black keys.
80const BLACK_KEY_DEPTH_RATIO: f32 = 0.5;
81
82/// Ratio of a white key's length that a black key occupies along the note axis.
83const BLACK_KEY_LENGTH_RATIO: f32 = 0.6;
84
85fn velocity_from_v(v: f32, depth: f32) -> u8 {
86    let normalized = (v / depth).clamp(0.0, 1.0);
87    (normalized * 126.0).round() as u8 + 1
88}
89
90/// Hit-test a point inside a keyboard range drawn by `draw_keyboard_range_into`.
91///
92/// `cursor` and `bounds` must be in the same canvas coordinate space. The returned
93/// tuple is `(note_class, velocity)` where `note_class` is the MIDI note class
94/// within the octave (0..11) and `velocity` is in the range 1..=127.
95pub fn note_at_in_range(
96    cursor: Point,
97    bounds: Rectangle,
98    orientation: Orientation,
99    note_count: u8,
100) -> Option<(u8, u8)> {
101    if note_count == 0 {
102        return None;
103    }
104    if orientation == Orientation::Degree270 {
105        return note_at_in_range_degree270(cursor, bounds, note_count);
106    }
107    let natural_cursor = to_natural_cursor(orientation, bounds, cursor);
108    let natural = natural_bounds(orientation, bounds);
109    let height = natural.height;
110    let width = natural.width;
111    let white_notes: &[u8] = if note_count >= 12 {
112        &[0, 2, 4, 5, 7, 9, 11]
113    } else {
114        &[0, 2, 4, 5, 7]
115    };
116    let white_key_count = white_notes.iter().take_while(|&n| *n < note_count).count() as f32;
117    if white_key_count == 0.0 {
118        return None;
119    }
120    let white_key_size = height / white_key_count;
121    let black_key_width = width * BLACK_KEY_DEPTH_RATIO;
122    let black_key_length = white_key_size * BLACK_KEY_LENGTH_RATIO;
123    let u = natural_cursor.x.clamp(0.0, height);
124    let v = natural_cursor.y;
125
126    let (black_offsets, black_notes): (&[u8], &[u8]) = if note_count >= 12 {
127        (&[1, 2, 4, 5, 6], &[1, 3, 6, 8, 10])
128    } else {
129        (&[1, 2, 4], &[1, 3, 6])
130    };
131
132    if v <= black_key_width {
133        for (idx, &note_id) in black_notes
134            .iter()
135            .enumerate()
136            .take_while(|&(_, n)| *n < note_count)
137        {
138            let center = black_offsets[idx] as f32 * white_key_size;
139            if (u - center).abs() <= black_key_length * 0.5 {
140                let velocity = velocity_from_v(v, black_key_width);
141                return Some((note_id, velocity));
142            }
143        }
144    }
145
146    let index = (u / white_key_size)
147        .clamp(0.0, white_key_count - 1.0)
148        .floor() as usize;
149    let note_id = white_notes.get(index).copied()?;
150    let velocity = velocity_from_v(v, width);
151    Some((note_id, velocity))
152}
153
154fn note_at_in_range_degree270(
155    cursor: Point,
156    bounds: Rectangle,
157    note_count: u8,
158) -> Option<(u8, u8)> {
159    if note_count < NOTES_PER_OCTAVE as u8 {
160        let white_note_ids = [0_u8, 2, 4, 5, 7];
161        let black_key_offsets = [1_u8, 2, 4];
162        let black_note_ids = [1_u8, 3, 6];
163        let white_key_height = bounds.height / white_note_ids.len() as f32;
164        let black_key_width = bounds.width * BLACK_KEY_DEPTH_RATIO;
165        let black_key_height = white_key_height * BLACK_KEY_LENGTH_RATIO;
166
167        if cursor.x <= black_key_width {
168            for (idx, offset) in black_key_offsets.iter().enumerate() {
169                let note_id = black_note_ids[idx];
170                if note_id >= note_count {
171                    continue;
172                }
173                let y_pos_black = bounds.height
174                    - (f32::from(*offset) * white_key_height)
175                    - (black_key_height * 0.5);
176                if cursor.y >= y_pos_black && cursor.y <= y_pos_black + black_key_height {
177                    let velocity = velocity_from_v(cursor.x, black_key_width);
178                    return Some((note_id, velocity));
179                }
180            }
181        }
182
183        for (i, note_id) in white_note_ids.iter().enumerate() {
184            let y_pos = bounds.height - ((i + 1) as f32 * white_key_height);
185            if cursor.y >= y_pos && cursor.y <= y_pos + white_key_height {
186                let velocity = velocity_from_v(cursor.x, bounds.width);
187                return Some((*note_id, velocity));
188            }
189        }
190        return None;
191    }
192    let white_key_height = bounds.height / 7.0;
193    let black_key_offsets = [1, 2, 4, 5, 6];
194    let black_note_ids = [1, 3, 6, 8, 10];
195    let black_key_width = bounds.width * BLACK_KEY_DEPTH_RATIO;
196    let black_key_height = white_key_height * BLACK_KEY_LENGTH_RATIO;
197
198    if cursor.x <= black_key_width {
199        for (idx, offset) in black_key_offsets.iter().enumerate() {
200            let y_pos_black =
201                bounds.height - (*offset as f32 * white_key_height) - (black_key_height * 0.5);
202            if cursor.y >= y_pos_black && cursor.y <= y_pos_black + black_key_height {
203                let velocity = velocity_from_v(cursor.x, black_key_width);
204                return Some((black_note_ids[idx], velocity));
205            }
206        }
207    }
208
209    for i in 0..7 {
210        let note_id = match i {
211            0 => 0,
212            1 => 2,
213            2 => 4,
214            3 => 5,
215            4 => 7,
216            5 => 9,
217            6 => 11,
218            _ => 0,
219        };
220        let y_pos = bounds.height - ((i + 1) as f32 * white_key_height);
221        if cursor.y >= y_pos && cursor.y <= y_pos + white_key_height {
222            let velocity = velocity_from_v(cursor.x, bounds.width);
223            return Some((note_id, velocity));
224        }
225    }
226    None
227}
228
229/// Map a point from the keyboard's natural coordinate system to canvas space.
230///
231/// In the natural coordinate system `u` runs along the note axis from the
232/// lowest note (0) to the highest note (length), and `v` runs across the
233/// front of the keys from the black-key side (0) to the white-key front (width).
234pub fn map_point(orientation: Orientation, bounds: Rectangle, u: f32, v: f32) -> Point {
235    let x = bounds.x;
236    let y = bounds.y;
237    let w = bounds.width;
238    let h = bounds.height;
239    match orientation {
240        Orientation::Degree0 => Point::new(x + u, y + v),
241        Orientation::Degree90 => Point::new(x + w - v, y + u),
242        Orientation::Degree180 => Point::new(x + u, y + h - v),
243        Orientation::Degree270 => Point::new(x + v, y + h - u),
244    }
245}
246
247/// Map a natural-coordinate rectangle `[u_low, u_high] x [v_low, v_high]` to a
248/// canvas-space origin and size suitable for `Path::rectangle`.
249fn map_rect(
250    orientation: Orientation,
251    bounds: Rectangle,
252    u_low: f32,
253    u_high: f32,
254    v_low: f32,
255    v_high: f32,
256) -> (Point, Size) {
257    let x = bounds.x;
258    let y = bounds.y;
259    match orientation {
260        Orientation::Degree0 => (
261            Point::new(x + u_low, y + v_low),
262            Size::new(u_high - u_low, v_high - v_low),
263        ),
264        Orientation::Degree90 => (
265            Point::new(x + bounds.width - v_high, y + u_low),
266            Size::new(v_high - v_low, u_high - u_low),
267        ),
268        Orientation::Degree180 => (
269            Point::new(x + u_low, y + bounds.height - v_high),
270            Size::new(u_high - u_low, v_high - v_low),
271        ),
272        Orientation::Degree270 => (
273            Point::new(x + v_low, y + bounds.height - u_high),
274            Size::new(v_high - v_low, u_high - u_low),
275        ),
276    }
277}
278
279/// Bounds in the natural coordinate system used by the drawing code.
280fn natural_bounds(orientation: Orientation, bounds: Rectangle) -> Rectangle {
281    match orientation {
282        Orientation::Degree90 | Orientation::Degree270 => bounds,
283        Orientation::Degree0 | Orientation::Degree180 => Rectangle {
284            x: bounds.x,
285            y: bounds.y,
286            width: bounds.height,
287            height: bounds.width,
288        },
289    }
290}
291
292/// Convert a cursor position into the keyboard's natural `(u, v)` coordinates.
293fn to_natural_cursor(orientation: Orientation, bounds: Rectangle, cursor: Point) -> Point {
294    let w = bounds.width;
295    let h = bounds.height;
296    match orientation {
297        Orientation::Degree0 => Point::new(cursor.x, cursor.y),
298        Orientation::Degree90 => Point::new(cursor.y, w - cursor.x),
299        Orientation::Degree180 => Point::new(cursor.x, h - cursor.y),
300        Orientation::Degree270 => Point::new(h - cursor.y, cursor.x),
301    }
302}
303
304/// Choose text alignment for a key label based on orientation.
305fn text_align_x(orientation: Orientation) -> iced::advanced::text::Alignment {
306    use iced::advanced::text::Alignment;
307    match orientation {
308        Orientation::Degree0 => Alignment::Center,
309        Orientation::Degree90 => Alignment::Left,
310        Orientation::Degree180 => Alignment::Center,
311        Orientation::Degree270 => Alignment::Right,
312    }
313}
314
315fn text_align_y(orientation: Orientation) -> iced::alignment::Vertical {
316    use iced::alignment::Vertical;
317    match orientation {
318        Orientation::Degree0 => Vertical::Top,
319        Orientation::Degree90 => Vertical::Center,
320        Orientation::Degree180 => Vertical::Bottom,
321        Orientation::Degree270 => Vertical::Center,
322    }
323}
324
325fn draw_keyboard_range_into(
326    frame: &mut Frame<Renderer>,
327    bounds: Rectangle,
328    pressed_notes: &HashSet<u8>,
329    octave: u8,
330    midnam_note_names: &HashMap<u8, String>,
331    orientation: Orientation,
332    note_count: u8,
333) {
334    if note_count == 0 {
335        return;
336    }
337    let natural = natural_bounds(orientation, bounds);
338    let height = natural.height;
339    let width = natural.width;
340    let white_notes: &[u8] = if note_count >= 12 {
341        &[0, 2, 4, 5, 7, 9, 11]
342    } else {
343        &[0, 2, 4, 5, 7]
344    };
345    let white_key_count = white_notes.iter().take_while(|&&n| n < note_count).count() as f32;
346    if white_key_count == 0.0 {
347        return;
348    }
349    let white_key_size = height / white_key_count;
350    let black_key_width = width * BLACK_KEY_DEPTH_RATIO;
351    let black_key_length = white_key_size * BLACK_KEY_LENGTH_RATIO;
352    let stroke = canvas::Stroke::default().with_width(1.0);
353    let h_align = text_align_x(orientation);
354    let v_align = text_align_y(orientation);
355
356    // White keys, drawn full depth; black keys are overlaid on the back half.
357    for (i, &note_id) in white_notes
358        .iter()
359        .enumerate()
360        .take_while(|&(_, n)| *n < note_count)
361    {
362        let midi_note = octave * 12 + note_id;
363        let is_pressed = pressed_notes.contains(&note_id);
364        let color = if is_pressed {
365            Color::from_rgb(0.0, 0.5, 1.0)
366        } else {
367            Color::WHITE
368        };
369        let u_low = i as f32 * white_key_size;
370        let u_high = u_low + white_key_size - 1.0;
371
372        let (origin, size) = map_rect(orientation, bounds, u_low, u_high, 0.0, width);
373        let rect = Path::rectangle(origin, size);
374        frame.fill(&rect, color);
375        frame.stroke(&rect, stroke);
376
377        if let Some(note_name) = midnam_note_names.get(&midi_note) {
378            use iced::widget::canvas::Text;
379            let text_pos = map_point(orientation, bounds, (u_low + u_high) * 0.5, width - 4.0);
380            frame.fill_text(Text {
381                content: note_name.clone(),
382                position: text_pos,
383                color: Color::BLACK,
384                size: 10.0.into(),
385                align_x: h_align,
386                align_y: v_align,
387                ..Text::default()
388            });
389        }
390    }
391
392    let (black_offsets, black_notes): (&[u8], &[u8]) = if note_count >= 12 {
393        (&[1, 2, 4, 5, 6], &[1, 3, 6, 8, 10])
394    } else {
395        (&[1, 2, 4], &[1, 3, 6])
396    };
397
398    for (idx, &note_id) in black_notes
399        .iter()
400        .enumerate()
401        .take_while(|&(_, n)| *n < note_count)
402    {
403        let is_pressed = pressed_notes.contains(&note_id);
404        let color = if is_pressed {
405            Color::from_rgb(0.0, 0.4, 0.8)
406        } else {
407            Color::BLACK
408        };
409        let center = black_offsets[idx] as f32 * white_key_size;
410        let (origin, size) = map_rect(
411            orientation,
412            bounds,
413            center - black_key_length * 0.5,
414            center + black_key_length * 0.5,
415            0.0,
416            black_key_width,
417        );
418        let rect = Path::rectangle(origin, size);
419        frame.fill(&rect, color);
420    }
421}
422
423pub fn draw_octave_into(
424    frame: &mut Frame<Renderer>,
425    bounds: Rectangle,
426    pressed_notes: &HashSet<u8>,
427    octave: u8,
428    midnam_note_names: &HashMap<u8, String>,
429    orientation: Orientation,
430) {
431    draw_keyboard_range_into(
432        frame,
433        bounds,
434        pressed_notes,
435        octave,
436        midnam_note_names,
437        orientation,
438        12,
439    );
440}
441
442pub fn draw_partial_octave_into(
443    frame: &mut Frame<Renderer>,
444    bounds: Rectangle,
445    pressed_notes: &HashSet<u8>,
446    octave: u8,
447    midnam_note_names: &HashMap<u8, String>,
448    orientation: Orientation,
449) {
450    draw_keyboard_range_into(
451        frame,
452        bounds,
453        pressed_notes,
454        octave,
455        midnam_note_names,
456        orientation,
457        octave_note_count(octave),
458    );
459}
460
461fn draw_octave_degree270(
462    renderer: &Renderer,
463    bounds: Rectangle,
464    pressed_notes: &HashSet<u8>,
465    octave: u8,
466    midnam_note_names: &HashMap<u8, String>,
467) -> Vec<canvas::Geometry> {
468    let mut frame = Frame::new(renderer, bounds.size());
469    let white_key_height = bounds.height / 7.0;
470
471    for i in 0..7 {
472        let note_id = match i {
473            0 => 0,
474            1 => 2,
475            2 => 4,
476            3 => 5,
477            4 => 7,
478            5 => 9,
479            6 => 11,
480            _ => 0,
481        };
482        let midi_note = octave * 12 + note_id;
483        let is_pressed = pressed_notes.contains(&note_id);
484        let y_pos = bounds.height - ((i + 1) as f32 * white_key_height);
485        let rect = Path::rectangle(
486            Point::new(0.0, y_pos),
487            Size::new(bounds.width, white_key_height - 1.0),
488        );
489
490        frame.fill(
491            &rect,
492            if is_pressed {
493                Color::from_rgb(0.0, 0.5, 1.0)
494            } else {
495                Color::WHITE
496            },
497        );
498        frame.stroke(&rect, canvas::Stroke::default().with_width(1.0));
499
500        if let Some(note_name) = midnam_note_names.get(&midi_note) {
501            use iced::widget::canvas::Text;
502            frame.fill_text(Text {
503                content: note_name.clone(),
504                position: Point::new(bounds.width - 25.0, y_pos + white_key_height * 0.5 - 6.0),
505                color: Color::BLACK,
506                size: 10.0.into(),
507                ..Text::default()
508            });
509        }
510    }
511
512    let black_key_offsets = [1, 2, 4, 5, 6];
513    let black_note_ids = [1, 3, 6, 8, 10];
514    let black_key_width = bounds.width * BLACK_KEY_DEPTH_RATIO;
515    let black_key_height = white_key_height * BLACK_KEY_LENGTH_RATIO;
516
517    for (idx, offset) in black_key_offsets.iter().enumerate() {
518        let note_id = black_note_ids[idx];
519        let is_pressed = pressed_notes.contains(&note_id);
520        let y_pos_black =
521            bounds.height - (*offset as f32 * white_key_height) - (black_key_height * 0.5);
522        let rect = Path::rectangle(
523            Point::new(0.0, y_pos_black),
524            Size::new(black_key_width, black_key_height),
525        );
526
527        frame.fill(
528            &rect,
529            if is_pressed {
530                Color::from_rgb(0.0, 0.4, 0.8)
531            } else {
532                Color::BLACK
533            },
534        );
535    }
536
537    vec![frame.into_geometry()]
538}
539
540fn draw_partial_octave_degree270(
541    renderer: &Renderer,
542    bounds: Rectangle,
543    pressed_notes: &HashSet<u8>,
544    octave: u8,
545    midnam_note_names: &HashMap<u8, String>,
546    note_count: u8,
547) -> Vec<canvas::Geometry> {
548    let mut frame = Frame::new(renderer, bounds.size());
549    let white_note_ids = [0_u8, 2, 4, 5, 7];
550    let black_key_offsets = [1_u8, 2, 4];
551    let black_note_ids = [1_u8, 3, 6];
552    let white_key_height = bounds.height / white_note_ids.len() as f32;
553    let black_key_height = white_key_height * BLACK_KEY_LENGTH_RATIO;
554    let black_key_width = bounds.width * BLACK_KEY_DEPTH_RATIO;
555
556    for (i, note_id) in white_note_ids.iter().enumerate() {
557        let midi_note = octave * 12 + *note_id;
558        let is_pressed = pressed_notes.contains(note_id);
559        let y_pos = bounds.height - ((i + 1) as f32 * white_key_height);
560        let rect = Path::rectangle(
561            Point::new(0.0, y_pos),
562            Size::new(bounds.width, white_key_height - 1.0),
563        );
564        frame.fill(
565            &rect,
566            if is_pressed {
567                Color::from_rgb(0.0, 0.5, 1.0)
568            } else {
569                Color::WHITE
570            },
571        );
572        frame.stroke(&rect, canvas::Stroke::default().with_width(1.0));
573        if let Some(note_name) = midnam_note_names.get(&midi_note) {
574            use iced::widget::canvas::Text;
575            frame.fill_text(Text {
576                content: note_name.clone(),
577                position: Point::new(bounds.width - 25.0, y_pos + white_key_height * 0.5 - 6.0),
578                color: Color::BLACK,
579                size: 10.0.into(),
580                ..Text::default()
581            });
582        }
583    }
584
585    for (idx, offset) in black_key_offsets.iter().enumerate() {
586        let note_id = black_note_ids[idx];
587        if note_id >= note_count {
588            continue;
589        }
590        let is_pressed = pressed_notes.contains(&note_id);
591        let y_pos_black =
592            bounds.height - (f32::from(*offset) * white_key_height) - (black_key_height * 0.5);
593        let rect = Path::rectangle(
594            Point::new(0.0, y_pos_black),
595            Size::new(black_key_width, black_key_height),
596        );
597        frame.fill(
598            &rect,
599            if is_pressed {
600                Color::from_rgb(0.0, 0.4, 0.8)
601            } else {
602                Color::BLACK
603            },
604        );
605    }
606
607    vec![frame.into_geometry()]
608}
609
610pub fn draw_octave(
611    renderer: &Renderer,
612    bounds: Rectangle,
613    pressed_notes: &HashSet<u8>,
614    octave: u8,
615    midnam_note_names: &HashMap<u8, String>,
616    orientation: Orientation,
617) -> Vec<canvas::Geometry> {
618    if orientation == Orientation::Degree270 {
619        return draw_octave_degree270(renderer, bounds, pressed_notes, octave, midnam_note_names);
620    }
621    let mut frame = Frame::new(renderer, bounds.size());
622    let local_bounds = Rectangle {
623        x: 0.0,
624        y: 0.0,
625        width: bounds.width,
626        height: bounds.height,
627    };
628    draw_octave_into(
629        &mut frame,
630        local_bounds,
631        pressed_notes,
632        octave,
633        midnam_note_names,
634        orientation,
635    );
636    vec![frame.into_geometry()]
637}
638
639pub fn draw_partial_octave(
640    renderer: &Renderer,
641    bounds: Rectangle,
642    pressed_notes: &HashSet<u8>,
643    octave: u8,
644    midnam_note_names: &HashMap<u8, String>,
645    orientation: Orientation,
646) -> Vec<canvas::Geometry> {
647    if orientation == Orientation::Degree270 {
648        return draw_partial_octave_degree270(
649            renderer,
650            bounds,
651            pressed_notes,
652            octave,
653            midnam_note_names,
654            octave_note_count(octave),
655        );
656    }
657    let mut frame = Frame::new(renderer, bounds.size());
658    let local_bounds = Rectangle {
659        x: 0.0,
660        y: 0.0,
661        width: bounds.width,
662        height: bounds.height,
663    };
664    draw_partial_octave_into(
665        &mut frame,
666        local_bounds,
667        pressed_notes,
668        octave,
669        midnam_note_names,
670        orientation,
671    );
672    vec![frame.into_geometry()]
673}
674
675#[derive(Debug, Clone)]
676pub struct OctaveKeyboard<Message, Press, Release>
677where
678    Press: Fn(u8, u8) -> Message + Clone,
679    Release: Fn(u8) -> Message + Clone,
680{
681    pub octave: u8,
682    pub note_count: u8,
683    pub midnam_note_names: HashMap<u8, String>,
684    pub orientation: Orientation,
685    /// Cached names count for widget identity
686    on_press: Press,
687    on_release: Release,
688}
689
690impl<Message, Press, Release> OctaveKeyboard<Message, Press, Release>
691where
692    Press: Fn(u8, u8) -> Message + Clone,
693    Release: Fn(u8) -> Message + Clone,
694{
695    pub fn new(
696        octave: u8,
697        midnam_note_names: HashMap<u8, String>,
698        on_press: Press,
699        on_release: Release,
700    ) -> Self {
701        Self {
702            octave,
703            note_count: octave_note_count(octave),
704            midnam_note_names,
705            orientation: Orientation::Degree0,
706            on_press,
707            on_release,
708        }
709    }
710
711    pub fn with_orientation(mut self, orientation: Orientation) -> Self {
712        self.orientation = orientation;
713        self
714    }
715
716    fn note_at(&self, cursor: Point, bounds: Rectangle) -> Option<(u8, u8)> {
717        note_at_in_range(cursor, bounds, self.orientation, self.note_count)
718    }
719
720    fn midi_note(&self, note_class: u8) -> u8 {
721        (usize::from(self.octave) * 12 + usize::from(note_class)) as u8
722    }
723}
724
725#[derive(Default, Debug)]
726pub struct OctaveKeyboardState {
727    pub pressed_notes: HashSet<u8>,
728    pub active_note_class: Option<u8>,
729}
730
731impl<Message, Press, Release> Program<Message> for OctaveKeyboard<Message, Press, Release>
732where
733    Message: 'static,
734    Press: Fn(u8, u8) -> Message + Clone,
735    Release: Fn(u8) -> Message + Clone,
736{
737    type State = OctaveKeyboardState;
738
739    fn update(
740        &self,
741        state: &mut Self::State,
742        event: &Event,
743        bounds: Rectangle,
744        cursor: mouse::Cursor,
745    ) -> Option<CanvasAction<Message>> {
746        match event {
747            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
748                if let Some(position) = cursor.position_in(bounds)
749                    && let Some((note_class, velocity)) = self.note_at(position, bounds)
750                {
751                    state.active_note_class = Some(note_class);
752                    state.pressed_notes.clear();
753                    state.pressed_notes.insert(note_class);
754                    return Some(
755                        CanvasAction::publish((self.on_press.clone())(
756                            self.midi_note(note_class),
757                            velocity,
758                        ))
759                        .and_capture(),
760                    );
761                }
762            }
763            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
764                if let Some(note_class) = state.active_note_class.take() {
765                    state.pressed_notes.clear();
766                    return Some(CanvasAction::publish((self.on_release.clone())(
767                        self.midi_note(note_class),
768                    )));
769                }
770            }
771            _ => {}
772        }
773        None
774    }
775
776    fn draw(
777        &self,
778        state: &Self::State,
779        renderer: &Renderer,
780        _theme: &Theme,
781        bounds: Rectangle,
782        _cursor: mouse::Cursor,
783    ) -> Vec<Geometry> {
784        if self.note_count == NOTES_PER_OCTAVE as u8 {
785            draw_octave(
786                renderer,
787                bounds,
788                &state.pressed_notes,
789                self.octave,
790                &self.midnam_note_names,
791                self.orientation,
792            )
793        } else {
794            draw_partial_octave(
795                renderer,
796                bounds,
797                &state.pressed_notes,
798                self.octave,
799                &self.midnam_note_names,
800                self.orientation,
801            )
802        }
803    }
804}
805
806pub fn row_height(zoom_y: f32) -> f32 {
807    ((WHITE_KEY_HEIGHT * WHITE_KEYS_PER_OCTAVE as f32 / NOTES_PER_OCTAVE as f32) * zoom_y).max(1.0)
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813    use iced::widget::canvas::Program;
814    use iced::{Point, Rectangle, Size, event, mouse};
815
816    #[derive(Debug, Clone, PartialEq, Eq)]
817    enum TestMessage {
818        Pressed(u8, u8),
819        Released(u8),
820    }
821
822    fn action_message(action: CanvasAction<TestMessage>) -> (Option<TestMessage>, event::Status) {
823        let (message, _redraw, status) = action.into_inner();
824        (message, status)
825    }
826
827    #[test]
828    fn octave_keyboard_update_publishes_pressed_and_released_notes() {
829        let keyboard = OctaveKeyboard::new(
830            4,
831            HashMap::new(),
832            TestMessage::Pressed,
833            TestMessage::Released,
834        )
835        .with_orientation(Orientation::Degree270);
836        let bounds = Rectangle::new(Point::ORIGIN, Size::new(20.0, 70.0));
837        let press_cursor = mouse::Cursor::Available(Point::new(15.0, 65.0));
838        let release_cursor = mouse::Cursor::Available(Point::new(15.0, 65.0));
839        let mut state = OctaveKeyboardState::default();
840
841        let press = keyboard
842            .update(
843                &mut state,
844                &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
845                bounds,
846                press_cursor,
847            )
848            .expect("press action");
849        let (message, status) = action_message(press);
850        assert_eq!(message, Some(TestMessage::Pressed(48, 96)));
851        assert_eq!(status, event::Status::Captured);
852        assert_eq!(state.active_note_class, Some(0));
853        assert!(state.pressed_notes.contains(&0));
854
855        let release = keyboard
856            .update(
857                &mut state,
858                &Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
859                bounds,
860                release_cursor,
861            )
862            .expect("release action");
863        let (message, status) = action_message(release);
864        assert_eq!(message, Some(TestMessage::Released(48)));
865        assert_eq!(status, event::Status::Ignored);
866        assert!(state.pressed_notes.is_empty());
867    }
868
869    #[test]
870    fn partial_octave_keyboard_maps_top_note_to_midi_127() {
871        let keyboard = OctaveKeyboard::new(
872            10,
873            HashMap::new(),
874            TestMessage::Pressed,
875            TestMessage::Released,
876        )
877        .with_orientation(Orientation::Degree270);
878        let bounds = Rectangle::new(Point::ORIGIN, Size::new(20.0, 80.0));
879        let cursor = mouse::Cursor::Available(Point::new(15.0, 5.0));
880        let mut state = OctaveKeyboardState::default();
881
882        let press = keyboard
883            .update(
884                &mut state,
885                &Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
886                bounds,
887                cursor,
888            )
889            .expect("press action");
890        let (message, status) = action_message(press);
891
892        assert_eq!(message, Some(TestMessage::Pressed(127, 96)));
893        assert_eq!(status, event::Status::Captured);
894        assert_eq!(state.active_note_class, Some(7));
895    }
896}