//! Floating MIDI/instrument window: device picker, activity display, mode selector, //! clickable piano keyboard, and ADSR envelope controls. use std::time::Instant; use egui; use audiofiles_core::instrument::{InstrumentMode, note_name}; use crate::state::{BrowserState, MidiAction}; use super::theme; use super::widgets; /// Drag payload for dropping samples onto the keyboard to create zones. #[derive(Clone)] pub struct DragPayload { pub hash: String, pub name: String, } /// Draw the floating MIDI/instrument window. Call from the overlay layer. pub fn draw_midi_window(ctx: &egui::Context, state: &mut BrowserState) { let mut open = state.show_midi_window; widgets::tool_window(ctx, "MIDI / Instrument", &mut open, 420.0, 340.0, |ui| { // Empty state hint when no sample is loaded let has_sample = state.shared.instrument.try_lock() .map(|g| !g.zone_buffers.is_empty()) .unwrap_or(false); if !has_sample { ui.vertical_centered(|ui| { ui.add_space(theme::space::MD); ui.label( egui::RichText::new("No sample loaded") .color(theme::text_secondary()), ); ui.label( egui::RichText::new("Right-click a sample \u{2192} \"Play as Instrument\", or drag samples onto the keyboard below") .small() .color(theme::text_muted()), ); ui.add_space(theme::space::MD); }); ui.separator(); } // Section 1: MIDI device picker draw_midi_device_picker(ui, state); ui.separator(); // Section 2: Activity display draw_activity_display(ui, state); ui.separator(); // Section 3: Mode + root + lock draw_mode_controls(ui, state); ui.separator(); // Section 4: Piano keyboard (clickable) draw_piano_keyboard(ui, state); ui.separator(); // Section 5: ADSR controls draw_adsr_controls(ui, state); }); state.show_midi_window = open; } /// MIDI device picker: port dropdown, refresh, disconnect. /// Only shown when ports are available or a device is connected. fn draw_midi_device_picker(ui: &mut egui::Ui, state: &mut BrowserState) { // Auto-scan on first display so the user doesn't see an empty picker if state.midi_state.available_ports.is_empty() && state.midi_state.connected_port.is_none() && state.midi_pending_action.is_none() { state.midi_pending_action = Some(MidiAction::RefreshPorts); } // Empty state: instead of hiding the picker entirely (which leaves the user // with no way to ask the app to look again from inside the panel), render a // muted line + Refresh button so plugging a controller mid-session is // recoverable without closing the window. if state.midi_state.available_ports.is_empty() && state.midi_state.connected_port.is_none() { ui.horizontal(|ui| { ui.label( egui::RichText::new("No MIDI inputs detected") .color(theme::text_muted()), ); if ui.small_button("Refresh").clicked() { state.midi_pending_action = Some(MidiAction::RefreshPorts); } }); return; } ui.horizontal(|ui| { ui.label(egui::RichText::new("MIDI Input").color(theme::text_primary())); if ui.small_button("Refresh").clicked() { state.midi_pending_action = Some(MidiAction::RefreshPorts); } }); ui.horizontal(|ui| { let ports = &state.midi_state.available_ports; let selected_text = state .midi_state .connected_port_name .as_deref() .unwrap_or("(none)"); egui::ComboBox::from_id_salt("midi_port") .selected_text(selected_text) .width(220.0) .show_ui(ui, |ui| { for (i, name) in ports.iter().enumerate() { let is_current = state.midi_state.connected_port == Some(i); if ui.selectable_label(is_current, name).clicked() && !is_current { state.midi_pending_action = Some(MidiAction::Connect(i)); } } }); if state.midi_state.connected_port.is_some() && ui.small_button("Disconnect").clicked() { state.midi_pending_action = Some(MidiAction::Disconnect); } }); } /// Recent note activity with fading alpha. fn draw_activity_display(ui: &mut egui::Ui, state: &mut BrowserState) { let now = Instant::now(); // Expire notes older than 2 seconds state.midi_state.recent_notes.retain(|n| now.duration_since(n.timestamp).as_secs_f32() < 2.0); ui.horizontal(|ui| { if state.midi_state.recent_notes.is_empty() { // Idle copy depends on connection state so the user always has a // ground truth on whether MIDI is wired up (m-7). The dash was // ambiguous against the M-10 empty picker. let idle_text = match state.midi_state.connected_port_name.as_deref() { Some(port) => format!("Connected to {port} \u{00B7} listening"), None => "Not connected".to_string(), }; ui.label( egui::RichText::new(idle_text) .small() .color(theme::text_muted()), ); } else { for note in state.midi_state.recent_notes.iter().rev().take(8) { let age = now.duration_since(note.timestamp).as_secs_f32(); let alpha = ((2.0 - age) / 2.0).clamp(0.0, 1.0); let color = theme::text_primary().linear_multiply(alpha); ui.label( egui::RichText::new(format!("{} v{}", note.note_name, note.velocity)) .small() .color(color), ); } } }); // Request repaint while notes are fading if !state.midi_state.recent_notes.is_empty() { ui.ctx().request_repaint(); } } /// Mode selector, root note label, lock checkbox. fn draw_mode_controls(ui: &mut egui::Ui, state: &mut BrowserState) { ui.horizontal(|ui| { let mut mode = state.shared.instrument.lock().config.mode; let was_chromatic = mode == InstrumentMode::Chromatic; ui.radio_value(&mut mode, InstrumentMode::Chromatic, "Chromatic") .on_hover_text("Pitch one sample up and down across the keyboard"); ui.add_enabled(false, egui::RadioButton::new( mode == InstrumentMode::MultiSample, "Multi-sample", )) .on_hover_text("Drop two or more samples onto the keyboard to enable multi-sample mode"); if (mode == InstrumentMode::Chromatic) != was_chromatic { state.shared.instrument.lock().config.mode = mode; } ui.separator(); ui.label( egui::RichText::new(format!("Root: {}", note_name(state.instrument_root_note))) .color(theme::text_secondary()), ); ui.separator(); ui.checkbox(&mut state.instrument_locked, "Lock sample") .on_hover_text("Keep the current sample loaded as the table selection changes"); }); // Surface the Multi-sample precondition inline (not hover-only) while in // Chromatic mode, so the permanently-disabled radio doesn't read as a // dead-end. The mode flips automatically once a second sample is dropped. if state.shared.instrument.lock().config.mode == InstrumentMode::Chromatic { ui.label( egui::RichText::new("Multi-sample activates after you drop a second sample onto the keyboard.") .small() .color(theme::text_muted()), ); } } /// Draw a 3-octave piano keyboard with click-to-play, active voice highlighting, and zone overlays. fn draw_piano_keyboard(ui: &mut egui::Ui, state: &mut BrowserState) { let base_octave = (state.instrument_root_note / 12).saturating_sub(1) as i32; let num_octaves = 3; // Octave navigation: regular ui.button (≈28px) instead of small_button // (≈16px) so the targets clear the Fitts floor for a control users hit // repeatedly. `[` / `]` shortcuts match DAW convention. let mut octave_down = false; let mut octave_up = false; ui.horizontal(|ui| { if ui .button("-") .on_hover_text("Octave down ([)") .clicked() { octave_down = true; } ui.label( egui::RichText::new(format!("Oct {}", base_octave)) .small() .color(theme::text_secondary()), ); if ui .button("+") .on_hover_text("Octave up (])") .clicked() { octave_up = true; } }); // Keyboard shortcuts. Only consume keys when no text input is focused, // otherwise typing `[` into a tag field would scroll the octave. let no_focus = ui.ctx().memory(|m| m.focused().is_none()); if no_focus { if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::OpenBracket)) { octave_down = true; } if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::CloseBracket)) { octave_up = true; } } if octave_down && state.instrument_root_note >= 12 { state.instrument_root_note -= 12; if let Some(zone) = state.shared.instrument.lock().zone_buffers.first_mut() { zone.root_note = state.instrument_root_note; } } if octave_up && state.instrument_root_note <= 115 { state.instrument_root_note += 12; if let Some(zone) = state.shared.instrument.lock().zone_buffers.first_mut() { zone.root_note = state.instrument_root_note; } } // Piano keys let key_width = 16.0_f32; let white_height = 70.0_f32; let black_height = 42.0_f32; let black_width = 10.0_f32; let zone_bar_height = 8.0_f32; let start_note = (base_octave * 12 + 12) as u8; let end_note = start_note + (num_octaves * 12) as u8; let white_notes = white_keys_in_range(start_note, end_note); let total_width = white_notes.len() as f32 * key_width; // Snapshot zone info let zone_ranges: Vec<(u8, u8, u8)> = state .shared .instrument .try_lock() .map(|guard| { guard .zone_buffers .iter() .map(|z| (z.low_note, z.high_note, z.root_note)) .collect() }) .unwrap_or_default(); let num_zones = zone_ranges.len(); let total_height = white_height + if num_zones > 1 { zone_bar_height * num_zones as f32 + 2.0 } else { 0.0 }; let (response, painter) = ui.allocate_painter( egui::vec2(total_width, total_height), egui::Sense::click_and_drag(), ); let rect = response.rect; // Get active voices for highlighting let active_notes: Vec = state .shared .instrument .try_lock() .map(|guard| { guard .voices .iter() .filter(|v| v.active) .map(|v| v.note) .collect() }) .unwrap_or_default(); let note_to_x = |note: u8| -> f32 { let white_count = white_keys_in_range(start_note, note).len() as f32; rect.min.x + white_count * key_width }; // Build a list of all black key rects for hit testing (black keys take priority) let mut black_key_rects: Vec<(egui::Rect, u8)> = Vec::new(); { let mut white_x = 0.0_f32; for ¬e in &white_notes { let pitch_class = note % 12; if matches!(pitch_class, 0 | 2 | 5 | 7 | 9) { let black_note = note + 1; if black_note < end_note { let bx = rect.min.x + white_x + key_width - black_width / 2.0; let kr = egui::Rect::from_min_size( egui::pos2(bx, rect.min.y), egui::vec2(black_width, black_height), ); black_key_rects.push((kr, black_note)); } } white_x += key_width; } } // Precompute zone-removal chip rects. M-8 moves zone removal off of the // shared secondary-click (which previously did two different things on the // same response) onto an explicit chip per bar; right-click is now // reserved for "set root note" on keys. let chip_size = (zone_bar_height - 2.0).max(6.0); let chip_rects: Vec = if num_zones > 1 { zone_ranges .iter() .enumerate() .map(|(i, (_, high, _))| { let x_end = note_to_x((*high).min(end_note.saturating_sub(1)) + 1); let y = rect.min.y + white_height + 2.0 + i as f32 * zone_bar_height; let chip_x = (x_end - chip_size).max(rect.min.x); egui::Rect::from_min_size( egui::pos2(chip_x, y), egui::vec2(chip_size, chip_size), ) }) .collect() } else { Vec::new() }; // Determine which note the pointer is over (for click-to-play). Suppress // the lookup when the pointer is below the keys (zone-bar area) or over a // removal chip — previously a click in the zone-bar area silently played // whichever white key sat directly above. let pointer_note: Option = response.interact_pointer_pos().and_then(|pos| { if chip_rects.iter().any(|r| r.contains(pos)) { return None; } if pos.y >= rect.min.y + white_height { return None; } // Check black keys first (they overlay white keys) for &(kr, note) in &black_key_rects { if kr.contains(pos) { return Some(note); } } // Then white keys let rel_x = pos.x - rect.min.x; let white_idx = (rel_x / key_width) as usize; white_notes.get(white_idx).copied() }); // Primary-click on a chip removes that zone. Hit-tested here (before key // play handling has run for the click) so the click doesn't double as a // note play. let chip_clicked_index: Option = if response.clicked() { response.interact_pointer_pos().and_then(|pos| { chip_rects.iter().position(|r| r.contains(pos)) }) } else { None }; // Handle pointer-down: note_on for newly pressed notes (left-click only) if response.is_pointer_button_down_on() && !ui.input(|i| i.pointer.secondary_down()) && let Some(note) = pointer_note { if !state.piano_held_notes.contains(¬e) { state.shared.instrument.lock().note_on(note, 100); state.piano_held_notes.push(note); } // Release notes that are no longer under the pointer (drag across keys) let to_release: Vec = state.piano_held_notes.iter() .filter(|&&n| n != note) .copied() .collect(); for n in to_release { state.shared.instrument.lock().note_off(n); state.piano_held_notes.retain(|&held| held != n); } } // Handle pointer-up: release all held notes if response.drag_stopped() || (!response.is_pointer_button_down_on() && !state.piano_held_notes.is_empty()) { let held = std::mem::take(&mut state.piano_held_notes); for n in held { state.shared.instrument.lock().note_off(n); } } // Tooltip: right-click hint response.clone().on_hover_text("Click to play \u{2022} Right-click to set root note \u{2022} Drag samples here to load \u{2022} Click the X on a zone bar to remove it"); // Right-click to set root note if response.secondary_clicked() && let Some(note) = pointer_note { state.instrument_root_note = note; if let Some(zone) = state.shared.instrument.lock().zone_buffers.first_mut() { zone.root_note = note; } } // Draw white keys let mut white_x = 0.0_f32; for ¬e in &white_notes { let key_rect = egui::Rect::from_min_size( rect.min + egui::vec2(white_x, 0.0), egui::vec2(key_width - 1.0, white_height), ); let is_active = active_notes.contains(¬e) || state.piano_held_notes.contains(¬e); let is_root = note == state.instrument_root_note; let fill = if is_active { theme::accent_blue() } else { theme::piano_white_key() }; painter.rect_filled(key_rect, 2.0, fill); painter.rect_stroke(key_rect, 2.0, egui::Stroke::new(1.0, theme::border_default()), egui::StrokeKind::Outside); if is_root { let dot_center = key_rect.center_bottom() - egui::vec2(0.0, 8.0); painter.circle_filled(dot_center, 3.0, theme::accent_purple()); } white_x += key_width; } // Draw black keys on top for &(key_rect, black_note) in &black_key_rects { let is_active = active_notes.contains(&black_note) || state.piano_held_notes.contains(&black_note); let is_root = black_note == state.instrument_root_note; let fill = if is_active { theme::accent_blue() } else { theme::piano_black_key() }; painter.rect_filled(key_rect, 2.0, fill); if is_root { let dot_center = key_rect.center_bottom() - egui::vec2(0.0, 5.0); painter.circle_filled(dot_center, 3.0, theme::accent_purple()); } } // Draw zone bars (multi-sample mode). Each bar gets a small X chip at its // right edge for removal — see chip_rects above for hit-testing and // chip_clicked_index for the click consumption. if num_zones > 1 { let zone_colors = [ theme::accent_blue(), theme::accent_green(), theme::accent_yellow(), theme::accent_purple(), theme::accent_cyan(), theme::accent_red(), ]; for (i, (low, high, _root)) in zone_ranges.iter().enumerate() { let x_start = note_to_x(*low); let x_end = note_to_x((*high).min(end_note.saturating_sub(1)) + 1); let y = rect.min.y + white_height + 2.0 + i as f32 * zone_bar_height; let bar_rect = egui::Rect::from_min_max( egui::pos2(x_start, y), egui::pos2(x_end, y + zone_bar_height - 1.0), ); let color = zone_colors[i % zone_colors.len()]; painter.rect_filled(bar_rect, 2.0, color.linear_multiply(0.6)); // Paint the X chip at the right end of the bar. Background is a // slightly darker overlay so the X reads against the bar fill. if let Some(chip_rect) = chip_rects.get(i) { painter.rect_filled(*chip_rect, 1.0, color.linear_multiply(0.3)); let pad = 2.0; let p1 = chip_rect.min + egui::vec2(pad, pad); let p2 = chip_rect.max - egui::vec2(pad, pad); let p3 = egui::pos2(chip_rect.min.x + pad, chip_rect.max.y - pad); let p4 = egui::pos2(chip_rect.max.x - pad, chip_rect.min.y + pad); let stroke = egui::Stroke::new(1.2, theme::text_primary()); painter.line_segment([p1, p2], stroke); painter.line_segment([p3, p4], stroke); } } if let Some(idx) = chip_clicked_index { state.remove_instrument_zone(idx); } } // Drop-hover feedback: while a sample is being dragged over the keyboard, // paint a translucent accent overlay on the white key under the cursor and // show a tooltip naming the target note (m-6). Without this the drop // interaction is invisible until the user commits. let dragged_payload = egui::DragAndDrop::payload::(ui.ctx()); if dragged_payload.is_some() && response.hovered() && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) && pos.y < rect.min.y + white_height { let rel_x = (pos.x - rect.min.x).max(0.0); let white_idx = (rel_x / key_width) as usize; if let Some(&root_note) = white_notes.get(white_idx) { let key_x = rect.min.x + white_idx as f32 * key_width; let hover_rect = egui::Rect::from_min_size( egui::pos2(key_x, rect.min.y), egui::vec2(key_width - 1.0, white_height), ); painter.rect_filled( hover_rect, 2.0, theme::accent_blue().linear_multiply(0.3), ); egui::Tooltip::always_open( ui.ctx().clone(), ui.layer_id(), egui::Id::new("piano_drop_hint"), egui::PopupAnchor::Pointer, ) .show(|ui| { ui.label(format!( "Drop to create a zone centered on {}", note_name(root_note), )); }); } } // Handle drop: create zone at the dropped note if let Some(payload) = response.dnd_release_payload::() && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) { let rel_x = pos.x - rect.min.x; let white_idx = (rel_x / key_width) as usize; if let Some(&root_note) = white_notes.get(white_idx) { let low = root_note.saturating_sub(6); let high = (root_note + 6).min(127); state.add_instrument_zone(&payload.hash, &payload.name, low, high, root_note); } } } /// Return the white key MIDI note numbers in a range. fn white_keys_in_range(start: u8, end: u8) -> Vec { (start..end) .filter(|n| matches!(n % 12, 0 | 2 | 4 | 5 | 7 | 9 | 11)) .collect() } /// Draw ADSR envelope sliders, with a live envelope-shape preview above so the /// effect of each parameter is visible before the user releases the slider. fn draw_adsr_controls(ui: &mut egui::Ui, state: &mut BrowserState) { let mut envelope = state.shared.instrument.lock().config.envelope; // Presets row. Each tuple is (label, A, D, S, R). Loaded values are // playback-tested and meant as shortcuts, not authoritative — the sliders // remain editable after a preset click (p-3). let presets: &[(&str, f32, f32, f32, f32)] = &[ ("Default", 0.005, 0.05, 0.8, 0.10), ("Pluck", 0.001, 0.20, 0.0, 0.20), ("Pad", 0.80, 0.30, 0.8, 1.50), ("Stab", 0.001, 0.05, 0.0, 0.05), ]; ui.horizontal(|ui| { ui.label(egui::RichText::new("Preset").small().color(theme::text_secondary())); for (label, a, d, s, r) in presets { // Highlight a preset when the envelope matches it exactly. Float // compare is intentional: any user-driven slider edit drops the // highlight, which is the correct signal. let active = (envelope.attack - *a).abs() < 1e-4 && (envelope.decay - *d).abs() < 1e-4 && (envelope.sustain - *s).abs() < 1e-4 && (envelope.release - *r).abs() < 1e-4; if ui.selectable_label(active, *label).clicked() { envelope.attack = *a; envelope.decay = *d; envelope.sustain = *s; envelope.release = *r; } } }); draw_adsr_envelope_shape(ui, envelope.attack, envelope.decay, envelope.sustain, envelope.release); ui.horizontal(|ui| { ui.label(egui::RichText::new("A").small().color(theme::text_secondary())) .on_hover_text("Attack — time to reach full volume after key press"); let slider = egui::Slider::new(&mut envelope.attack, 0.001..=5.0) .logarithmic(true) .max_decimals(3) .suffix("s"); ui.add(slider); ui.label(egui::RichText::new("D").small().color(theme::text_secondary())) .on_hover_text("Decay — time to fall from peak to sustain level"); let slider = egui::Slider::new(&mut envelope.decay, 0.001..=5.0) .logarithmic(true) .max_decimals(3) .suffix("s"); ui.add(slider); }); ui.horizontal(|ui| { ui.label(egui::RichText::new("S").small().color(theme::text_secondary())) .on_hover_text("Sustain — held volume level while the key is down (0 to 1)"); let slider = egui::Slider::new(&mut envelope.sustain, 0.0..=1.0) .max_decimals(2); ui.add(slider); ui.label(egui::RichText::new("R").small().color(theme::text_secondary())) .on_hover_text("Release — time to fade to silence after key release"); let slider = egui::Slider::new(&mut envelope.release, 0.001..=10.0) .logarithmic(true) .max_decimals(3) .suffix("s"); ui.add(slider); }); state.shared.instrument.lock().config.envelope = envelope; } /// Paint a tiny ADSR envelope diagram (~40px tall) above the sliders so the /// shape changes are visible live. Times are mapped log-ish so very short /// attacks/releases still register visually. fn draw_adsr_envelope_shape(ui: &mut egui::Ui, attack: f32, decay: f32, sustain: f32, release: f32) { let avail = ui.available_width().min(240.0); let (rect, _) = ui.allocate_exact_size(egui::vec2(avail, 40.0), egui::Sense::hover()); let painter = ui.painter_at(rect); // Background frame painter.rect_filled(rect, 2.0, theme::bg_tertiary()); // Map each phase to a horizontal slice. Use a soft log so 0.001s isn't a // single pixel: weight = (1 + t).ln() with t in seconds, clamped. let w = |t: f32| (1.0 + t.max(0.0)).ln(); let wa = w(attack); let wd = w(decay); // Treat sustain as a fixed visual width so the user can always see the // hold segment — it's volume-axis, not time-axis. let ws: f32 = 0.6; let wr = w(release); let total = (wa + wd + ws + wr).max(0.001); let usable = rect.width() - 4.0; let x0 = rect.left() + 2.0; let x_a = x0 + (wa / total) * usable; let x_d = x_a + (wd / total) * usable; let x_s = x_d + (ws / total) * usable; let x_r = x_s + (wr / total) * usable; let y_peak = rect.top() + 4.0; let y_base = rect.bottom() - 4.0; let y_sustain = y_base + (y_peak - y_base) * sustain.clamp(0.0, 1.0); let stroke = egui::Stroke::new(1.5, theme::accent_blue()); let p0 = egui::pos2(x0, y_base); let p_a = egui::pos2(x_a, y_peak); let p_d = egui::pos2(x_d, y_sustain); let p_s = egui::pos2(x_s, y_sustain); let p_r = egui::pos2(x_r, y_base); painter.line_segment([p0, p_a], stroke); painter.line_segment([p_a, p_d], stroke); painter.line_segment([p_d, p_s], stroke); painter.line_segment([p_s, p_r], stroke); }