Add keyboard support to virtual piano

This commit is contained in:
Skyler Lehmkuhl 2025-11-30 11:26:14 -05:00
parent 8f1934ab59
commit 98c2880b45
1 changed files with 356 additions and 47 deletions

View File

@ -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<u8>,
/// 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<String, u8>,
/// 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<String, u8>,
/// Reverse mapping for displaying labels (MIDI note -> key label)
note_to_key_map: HashMap<u8, String>,
}
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(&note);
// 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(&note) ||
(!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,15 +232,7 @@ 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))
});
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);
@ -176,7 +248,7 @@ impl VirtualPianoPane {
}
// 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 pointer_over_key && pointer_down {
if self.dragging_note != Some(note) {
// Stop previous note
if let Some(prev_note) = self.dragging_note {
@ -188,6 +260,7 @@ impl VirtualPianoPane {
}
}
}
}
// Draw black keys on top
for note in visible_start..=visible_end {
@ -210,7 +283,17 @@ impl VirtualPianoPane {
egui::vec2(black_key_width, black_key_height),
);
let is_pressed = self.pressed_notes.contains(&note);
// 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(&note) ||
(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<u8> = 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(&note);
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(&note);
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 {