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
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(&note_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(&note_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(&note_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(&note_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}