diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs index 69b7599..6038ae8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/virtual_piano.rs @@ -5,7 +5,7 @@ use super::{NodePath, PaneRenderer, SharedPaneState}; use eframe::egui; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; /// Virtual piano pane state pub struct VirtualPianoPane { @@ -21,6 +21,14 @@ pub struct VirtualPianoPane { dragging_note: Option, /// Octave offset for keyboard mapping (default: 0 = C4) octave_offset: i8, + /// Tracks which computer keys are held and which MIDI notes they're playing + active_key_presses: HashMap, + /// Current velocity for keyboard input (adjustable with C/V keys) + keyboard_velocity: u8, + /// Base keyboard mapping (key string -> MIDI note, before octave offset) + keyboard_map: HashMap, + /// Reverse mapping for displaying labels (MIDI note -> key label) + note_to_key_map: HashMap, } impl Default for VirtualPianoPane { @@ -31,6 +39,33 @@ impl Default for VirtualPianoPane { impl VirtualPianoPane { pub fn new() -> Self { + // Create keyboard mapping (C4-F5 range, MIDI notes 60-77) + let mut keyboard_map = HashMap::new(); + keyboard_map.insert("a".to_string(), 60); // C4 + keyboard_map.insert("w".to_string(), 61); // C#4 + keyboard_map.insert("s".to_string(), 62); // D4 + keyboard_map.insert("e".to_string(), 63); // D#4 + keyboard_map.insert("d".to_string(), 64); // E4 + keyboard_map.insert("f".to_string(), 65); // F4 + keyboard_map.insert("t".to_string(), 66); // F#4 + keyboard_map.insert("g".to_string(), 67); // G4 + keyboard_map.insert("y".to_string(), 68); // G#4 + keyboard_map.insert("h".to_string(), 69); // A4 + keyboard_map.insert("u".to_string(), 70); // A#4 + keyboard_map.insert("j".to_string(), 71); // B4 + keyboard_map.insert("k".to_string(), 72); // C5 + keyboard_map.insert("o".to_string(), 73); // C#5 + keyboard_map.insert("l".to_string(), 74); // D5 + keyboard_map.insert("p".to_string(), 75); // D#5 + keyboard_map.insert(";".to_string(), 76); // E5 + keyboard_map.insert("'".to_string(), 77); // F5 + + // Create reverse mapping for labels (note -> uppercase key) + let mut note_to_key_map = HashMap::new(); + for (key, note) in &keyboard_map { + note_to_key_map.insert(*note, key.to_uppercase()); + } + Self { white_key_aspect_ratio: 6.0, black_key_width_ratio: 0.6, @@ -38,6 +73,10 @@ impl VirtualPianoPane { pressed_notes: HashSet::new(), dragging_note: None, octave_offset: 0, // Center on C4 (MIDI note 60) + active_key_presses: HashMap::new(), + keyboard_velocity: 100, // Default MIDI velocity + keyboard_map, + note_to_key_map, } } @@ -123,6 +162,38 @@ impl VirtualPianoPane { } } + // Check black keys for interaction first (since they render on top and overlap white keys) + // This prevents white keys underneath from also receiving the click + let mut black_key_interacted = false; + let pointer_pos = ui.input(|i| i.pointer.hover_pos()); + + if let Some(pos) = pointer_pos { + for note in visible_start..=visible_end { + if !Self::is_black_key(note) { + continue; + } + + // Calculate black key rect + let mut white_keys_before = 0; + for n in visible_start..note { + if Self::is_white_key(n) { + white_keys_before += 1; + } + } + + let x = rect.min.x + offset_x + (white_keys_before as f32 * white_key_width) - (black_key_width / 2.0); + let key_rect = egui::Rect::from_min_size( + egui::pos2(x, rect.min.y), + egui::vec2(black_key_width, black_key_height), + ); + + if key_rect.contains(pos) { + black_key_interacted = true; + break; + } + } + } + // Draw white keys first (so black keys render on top) for note in visible_start..=visible_end { if !Self::is_white_key(note) { @@ -136,8 +207,17 @@ impl VirtualPianoPane { egui::vec2(white_key_width - 1.0, white_key_height), ); - // Visual feedback for pressed keys - let is_pressed = self.pressed_notes.contains(¬e); + // Handle interaction (skip if a black key is being interacted with) + let key_id = ui.id().with(("white_key", note)); + let response = ui.interact(key_rect, key_id, egui::Sense::click_and_drag()); + + // Visual feedback for pressed keys (check both pressed_notes and current pointer state) + let pointer_over_key = ui.input(|i| { + i.pointer.hover_pos().map_or(false, |pos| key_rect.contains(pos)) + }); + let pointer_down = ui.input(|i| i.pointer.primary_down()); + let is_pressed = self.pressed_notes.contains(¬e) || + (!black_key_interacted && pointer_over_key && pointer_down); let color = if is_pressed { egui::Color32::from_rgb(100, 150, 255) // Blue when pressed } else { @@ -152,40 +232,33 @@ impl VirtualPianoPane { egui::StrokeKind::Middle, ); - // Handle interaction - let key_id = ui.id().with(("white_key", note)); - let response = ui.interact(key_rect, key_id, egui::Sense::click_and_drag()); - - // Check if pointer is currently over this key (works during drag too) - let pointer_over_key = ui.input(|i| { - i.pointer.hover_pos().map_or(false, |pos| key_rect.contains(pos)) - }); - - // Mouse down starts note (detect primary button pressed on this key) - if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { - self.send_note_on(note, 100, shared); - self.dragging_note = Some(note); - } - - // Mouse up stops note (detect primary button released) - if ui.input(|i| i.pointer.primary_released()) { - if self.dragging_note == Some(note) { - self.send_note_off(note, shared); - self.dragging_note = None; - } - } - - // Dragging over a new key (pointer is down and over a different key) - if pointer_over_key && ui.input(|i| i.pointer.primary_down()) { - if self.dragging_note != Some(note) { - // Stop previous note - if let Some(prev_note) = self.dragging_note { - self.send_note_off(prev_note, shared); - } - // Start new note + if !black_key_interacted { + // Mouse down starts note (detect primary button pressed on this key) + if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { self.send_note_on(note, 100, shared); self.dragging_note = Some(note); } + + // Mouse up stops note (detect primary button released) + if ui.input(|i| i.pointer.primary_released()) { + if self.dragging_note == Some(note) { + self.send_note_off(note, shared); + self.dragging_note = None; + } + } + + // Dragging over a new key (pointer is down and over a different key) + if pointer_over_key && pointer_down { + if self.dragging_note != Some(note) { + // Stop previous note + if let Some(prev_note) = self.dragging_note { + self.send_note_off(prev_note, shared); + } + // Start new note + self.send_note_on(note, 100, shared); + self.dragging_note = Some(note); + } + } } } @@ -210,7 +283,17 @@ impl VirtualPianoPane { egui::vec2(black_key_width, black_key_height), ); - let is_pressed = self.pressed_notes.contains(¬e); + // Handle interaction (same as white keys) + let key_id = ui.id().with(("black_key", note)); + let response = ui.interact(key_rect, key_id, egui::Sense::click_and_drag()); + + // Visual feedback for pressed keys (check both pressed_notes and current pointer state) + let pointer_over_key = ui.input(|i| { + i.pointer.hover_pos().map_or(false, |pos| key_rect.contains(pos)) + }); + let pointer_down = ui.input(|i| i.pointer.primary_down()); + let is_pressed = self.pressed_notes.contains(¬e) || + (pointer_over_key && pointer_down); let color = if is_pressed { egui::Color32::from_rgb(50, 100, 200) // Darker blue when pressed } else { @@ -219,15 +302,6 @@ impl VirtualPianoPane { ui.painter().rect_filled(key_rect, 2.0, color); - // Handle interaction (same as white keys) - let key_id = ui.id().with(("black_key", note)); - let response = ui.interact(key_rect, key_id, egui::Sense::click_and_drag()); - - // Check if pointer is currently over this key (works during drag too) - let pointer_over_key = ui.input(|i| { - i.pointer.hover_pos().map_or(false, |pos| key_rect.contains(pos)) - }); - // Mouse down starts note if pointer_over_key && ui.input(|i| i.pointer.primary_pressed()) { self.send_note_on(note, 100, shared); @@ -243,7 +317,7 @@ impl VirtualPianoPane { } // Dragging over a new key - if pointer_over_key && ui.input(|i| i.pointer.primary_down()) { + if pointer_over_key && pointer_down { if self.dragging_note != Some(note) { if let Some(prev_note) = self.dragging_note { self.send_note_off(prev_note, shared); @@ -282,21 +356,236 @@ impl VirtualPianoPane { } } } + + /// Process keyboard input for virtual piano + /// Returns true if the event was consumed + fn handle_keyboard_input(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { + // Check if we have an active MIDI layer - don't process input if not + let has_active_midi_layer = if let Some(active_layer_id) = *shared.active_layer_id { + shared.layer_to_track_map.contains_key(&active_layer_id) + } else { + false + }; + + if !has_active_midi_layer { + return false; + } + + let mut consumed = false; + + ui.input(|i| { + // Handle octave shift keys (Z/X) + if i.key_pressed(egui::Key::Z) { + if self.octave_offset > -2 { + self.octave_offset -= 1; + consumed = true; + } + } + if i.key_pressed(egui::Key::X) { + if self.octave_offset < 2 { + self.octave_offset += 1; + consumed = true; + } + } + + // Handle velocity adjustment (C/V) + if i.key_pressed(egui::Key::C) { + self.keyboard_velocity = self.keyboard_velocity.saturating_sub(10).max(1); + consumed = true; + } + if i.key_pressed(egui::Key::V) { + self.keyboard_velocity = self.keyboard_velocity.saturating_add(10).min(127); + consumed = true; + } + + // Process raw events for piano keys (need to track press/release separately) + for event in &i.events { + if let egui::Event::Key { key, pressed, repeat, .. } = event { + if *repeat { + continue; // Ignore key repeats + } + + // Convert egui::Key to string representation + let key_str = match key { + egui::Key::A => "a", + egui::Key::S => "s", + egui::Key::D => "d", + egui::Key::F => "f", + egui::Key::G => "g", + egui::Key::H => "h", + egui::Key::J => "j", + egui::Key::K => "k", + egui::Key::L => "l", + egui::Key::W => "w", + egui::Key::E => "e", + egui::Key::T => "t", + egui::Key::Y => "y", + egui::Key::U => "u", + egui::Key::O => "o", + egui::Key::P => "p", + egui::Key::Semicolon => ";", + egui::Key::Quote => "'", + _ => continue, + }; + + if let Some(&base_note) = self.keyboard_map.get(key_str) { + if *pressed { + // Key down - start note + if !self.active_key_presses.contains_key(key_str) { + let note = (base_note as i32 + self.octave_offset as i32 * 12) + .clamp(0, 127) as u8; + self.active_key_presses.insert(key_str.to_string(), note); + self.send_note_on(note, self.keyboard_velocity, shared); + consumed = true; + } + } else { + // Key up - stop note + if let Some(note) = self.active_key_presses.remove(key_str) { + self.send_note_off(note, shared); + consumed = true; + } + } + } + } + } + }); + + consumed + } + + /// Release all keyboard-held notes (call when losing focus or switching tracks) + fn release_all_keyboard_notes(&mut self, shared: &mut SharedPaneState) { + let notes_to_release: Vec = self.active_key_presses.values().copied().collect(); + for note in notes_to_release { + self.send_note_off(note, shared); + } + self.active_key_presses.clear(); + } + + /// Render keyboard letter labels on piano keys + fn render_key_labels( + &self, + ui: &mut egui::Ui, + rect: egui::Rect, + visible_start: u8, + visible_end: u8, + white_key_width: f32, + offset_x: f32, + ) { + let white_key_height = rect.height(); + let black_key_width = white_key_width * self.black_key_width_ratio; + let black_key_height = white_key_height * self.black_key_height_ratio; + + // Render labels on white keys + for note in visible_start..=visible_end { + if !Self::is_white_key(note) { + continue; + } + + // Calculate base note (subtract octave offset to get unmapped note) + let base_note = (note as i32 - self.octave_offset as i32 * 12).clamp(0, 127) as u8; + + // Check if this note has a keyboard mapping + if let Some(label) = self.note_to_key_map.get(&base_note) { + // Count white keys before this note for positioning + let mut white_keys_before = 0; + for n in visible_start..note { + if Self::is_white_key(n) { + white_keys_before += 1; + } + } + + let x = rect.min.x + offset_x + (white_keys_before as f32 * white_key_width); + let label_pos = egui::pos2( + x + white_key_width / 2.0, + rect.min.y + rect.height() - 30.0, + ); + + // Check if key is currently pressed + let is_pressed = self.pressed_notes.contains(¬e); + let color = if is_pressed { + egui::Color32::BLACK + } else { + egui::Color32::from_gray(51) // #333333 + }; + + ui.painter().text( + label_pos, + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(16.0), + color, + ); + } + } + + // Render labels on black keys + for note in visible_start..=visible_end { + if !Self::is_black_key(note) { + continue; + } + + let base_note = (note as i32 - self.octave_offset as i32 * 12).clamp(0, 127) as u8; + + if let Some(label) = self.note_to_key_map.get(&base_note) { + // Count white keys before this note for positioning + let mut white_keys_before = 0; + for n in visible_start..note { + if Self::is_white_key(n) { + white_keys_before += 1; + } + } + + let x = rect.min.x + offset_x + (white_keys_before as f32 * white_key_width) + - (black_key_width / 2.0); + let label_pos = egui::pos2( + x + black_key_width / 2.0, + rect.min.y + black_key_height - 20.0, + ); + + let is_pressed = self.pressed_notes.contains(¬e); + let color = if is_pressed { + egui::Color32::WHITE + } else { + egui::Color32::from_rgba_premultiplied(255, 255, 255, 178) // rgba(255,255,255,0.7) + }; + + ui.painter().text( + label_pos, + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(14.0), + color, + ); + } + } + } } impl PaneRenderer for VirtualPianoPane { fn render_header(&mut self, ui: &mut egui::Ui, _shared: &mut SharedPaneState) -> bool { ui.horizontal(|ui| { ui.label("Octave Shift:"); - if ui.button("-").clicked() && self.octave_offset > -3 { + if ui.button("-").clicked() && self.octave_offset > -2 { self.octave_offset -= 1; } let center_note = 60 + (self.octave_offset as i32 * 12); let octave_name = format!("C{}", center_note / 12); ui.label(octave_name); - if ui.button("+").clicked() && self.octave_offset < 3 { + if ui.button("+").clicked() && self.octave_offset < 2 { self.octave_offset += 1; } + + ui.separator(); + + ui.label("Velocity:"); + if ui.button("-").clicked() { + self.keyboard_velocity = self.keyboard_velocity.saturating_sub(10).max(1); + } + ui.label(format!("{}", self.keyboard_velocity)); + if ui.button("+").clicked() { + self.keyboard_velocity = self.keyboard_velocity.saturating_add(10).min(127); + } }); true // We rendered a header @@ -317,6 +606,11 @@ impl PaneRenderer for VirtualPianoPane { }; if !has_active_midi_layer { + // Release any held notes before showing error message + if !self.active_key_presses.is_empty() { + self.release_all_keyboard_notes(shared); + } + // Show message if no active MIDI track ui.centered_and_justified(|ui| { ui.label("No MIDI track selected. Create a MIDI track to use the virtual piano."); @@ -324,8 +618,23 @@ impl PaneRenderer for VirtualPianoPane { return; } + // Request keyboard focus to prevent tool shortcuts from firing + // This sets wants_keyboard_input() to true + let piano_id = ui.id().with("virtual_piano_keyboard"); + ui.memory_mut(|m| m.request_focus(piano_id)); + + // Handle keyboard input FIRST + self.handle_keyboard_input(ui, shared); + + // Calculate visible range (needed for both rendering and labels) + let (visible_start, visible_end, white_key_width, offset_x) = + self.calculate_visible_range(rect.width(), rect.height()); + // Render the keyboard self.render_keyboard(ui, rect, shared); + + // Render keyboard labels on top + self.render_key_labels(ui, rect, visible_start, visible_end, white_key_width, offset_x); } fn name(&self) -> &str {