Add keyboard support to virtual piano
This commit is contained in:
parent
8f1934ab59
commit
98c2880b45
|
|
@ -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(¬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,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(¬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<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(¬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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue