use std::{ collections::{HashMap, VecDeque}, sync::{ atomic::{AtomicBool, Ordering}, Arc, }, thread::JoinHandle, }; use crossbeam::{channel::Receiver, select}; use egui::{mutex::Mutex, Color32, Frame, Grid, RichText, Rounding, ScrollArea, SelectableLabel}; use midir::{MidiInput, MidiInputPort}; use crate::midi::{daemon::CTM, Message, ParsedMessage, VoiceCategory, VoiceMessage}; /// State used to display the UI. It's intended to be shared between the /// renderer and the daemon which updates the state. #[derive(Clone)] pub struct DisplayState { pub midi_input_ports: Arc>>, pub midi_messages: Arc>>, pub selected_ports: HashMap, pub max_messages: usize, pub only_note_on_off: Arc, pub show_raw: Arc, } impl DisplayState { pub fn new() -> DisplayState { DisplayState { midi_input_ports: Arc::new(Mutex::new(vec![])), midi_messages: Arc::new(Mutex::new(VecDeque::new())), selected_ports: HashMap::new(), max_messages: 10_000_usize, only_note_on_off: Arc::new(AtomicBool::new(true)), show_raw: Arc::new(AtomicBool::new(true)), } } pub fn set_ports(&self, updated_ports: Vec) { let mut ports = self.midi_input_ports.lock(); *ports = updated_ports; } pub fn store_message(&self, message: CTM) { let mut messages = self.midi_messages.lock(); messages.push_back(message); if messages.len() > self.max_messages { messages.pop_front(); } } } impl Default for DisplayState { fn default() -> Self { DisplayState::new() } } /// Queues we receive from to refresh and update the UI. #[derive(Debug, Clone)] pub struct DisplayQueues { pub messages: Receiver, pub ports: Receiver>, // TODO: conn mapping } impl DisplayQueues { pub fn new(messages: Receiver, ports: Receiver>) -> DisplayQueues { DisplayQueues { messages, ports } } } pub fn display_state_daemon(queues: DisplayQueues, state: DisplayState) -> JoinHandle<()> { std::thread::spawn(move || loop { select! { recv(queues.messages) -> m => match m { Ok(ctm) => { state.store_message(ctm); } Err(e) => { println!("borken {e:?}"); } }, recv(queues.ports) -> ports => match ports { Ok(ports) => { state.set_ports(ports) } Err(e) => { println!("borken {e:?}"); } } } }) } /// Launches the UI and runs it until it's done executing. /// /// Accepts a VecDeque as input, which is used as shared state to provide the /// messages pub fn run(state: DisplayState) { let native_options = eframe::NativeOptions::default(); // TODO: don't ignore result let _ = eframe::run_native( "Midi Keys", native_options, Box::new(|cc| Ok(Box::new(MidiKeysApp::new(cc, state.clone())))), ); } struct MidiKeysApp { midi_in: MidiInput, state: DisplayState, } impl MidiKeysApp { fn new(_cc: &eframe::CreationContext<'_>, state: DisplayState) -> Self { // this is where to hook in for customizing eguji, like fonts and visuals. let midi_in: MidiInput = MidiInput::new("midi-keys").expect("could not connect to system MIDI"); MidiKeysApp { midi_in, state } } } impl eframe::App for MidiKeysApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { ctx.set_theme(egui::Theme::Light); let ports = self.state.midi_input_ports.lock().clone(); let messages = self.state.midi_messages.lock().clone(); egui::TopBottomPanel::top("menu_bar_panel").show(ctx, |ui| { egui::menu::bar(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("Quit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } }); ui.menu_button("Help", |ui| { if ui.button("About").clicked() { // TODO: implement something } }); }); }); egui::SidePanel::left("instrument_panel").show(ctx, |ui| { ui.heading("Connections"); for port in ports.iter() { let port_name = self .midi_in .port_name(port) .unwrap_or("unknown".to_string()); let conn_id = port.id(); let selected = self.state.selected_ports.get(&conn_id).unwrap_or(&true); if ui .add(SelectableLabel::new(*selected, &port_name)) .clicked() { self.state.selected_ports.insert(conn_id, !selected); } } }); let mut only_note_on_off = self.state.only_note_on_off.load(Ordering::Relaxed); let mut show_raw = self.state.show_raw.load(Ordering::Relaxed); egui::TopBottomPanel::top("filter_panel").show(ctx, |ui| { ui.horizontal_wrapped(|ui| { ui.checkbox(&mut only_note_on_off, "Only note on/off"); ui.checkbox(&mut show_raw, "Display raw bytes"); }); }); self.state .only_note_on_off .store(only_note_on_off, Ordering::Relaxed); self.state.show_raw.store(show_raw, Ordering::Relaxed); egui::CentralPanel::default().show(ctx, |ui| { ScrollArea::vertical() .max_width(f32::INFINITY) .show(ui, |ui| { ui.vertical_centered_justified(|ui| { for (idx, (conn, _ts, msg)) in messages.iter().rev().enumerate() { if only_note_on_off && !matches!( &msg.parsed, ParsedMessage::Voice(VoiceMessage { category: VoiceCategory::NoteOn { .. } | VoiceCategory::NoteOff { .. }, .. }) ) { continue; } if !self .state .selected_ports .get(conn.as_str()) .unwrap_or(&true) { continue; } let port = match ports.iter().find(|p| &p.id() == conn) { Some(p) => p, None => continue, }; ui.set_width(ui.available_width()); let mut frame = Frame::default() .inner_margin(4.0) .outer_margin(4.0) .stroke((1.0, Color32::BLACK)) .rounding(Rounding::same(2.0)) .begin(ui); let port_name = self .midi_in .port_name(port) .unwrap_or("(disconnected_device)".into()); frame.content_ui.label(RichText::new(port_name).strong()); display_midi_message(idx, msg, &mut frame.content_ui, show_raw); frame.end(ui); } }); ctx.request_repaint(); }); }); } } fn display_midi_message(idx: usize, msg: &Message, ui: &mut egui::Ui, raw: bool) { match &msg.parsed { ParsedMessage::Voice(vm) => { Grid::new(format!("message_grid_{idx}")).show(ui, |ui| { let (name, fields) = match vm.category { VoiceCategory::NoteOff { note, velocity } => ( "NoteOff", vec![ ("note", note_name(note)), ("velocity", format!("{}", velocity)), ], ), VoiceCategory::NoteOn { note, velocity } => ( "NoteOn", vec![ ("note", note_name(note)), ("velocity", format!("{}", velocity)), ], ), VoiceCategory::AfterTouch { note, pressure } => ( "AfterTouch", vec![ ("note", note_name(note)), ("pressure", format!("{}", pressure)), ], ), VoiceCategory::ControlChange { controller, value } => ( "ControlChange", vec![ ("controller", format!("{}", controller)), ("value", format!("{}", value)), ], ), VoiceCategory::ProgramChange { value } => { ("ProgramChange", vec![("value", format!("{}", value))]) } VoiceCategory::ChannelPressure { pressure } => ( "ChannelPressure", vec![("pressure", format!("{}", pressure))], ), VoiceCategory::PitchWheel { value } => { ("PitchWheel", vec![("value", format!("{}", value))]) } VoiceCategory::Unknown => ("Unknown", vec![]), }; let voice_label = format!("Voice:{}", name); ui.label(RichText::new(voice_label).italics()); ui.label(format!("channel = {}", vm.channel)); for (name, value) in fields { ui.label(format!("{name} = {value}")); } }); } ParsedMessage::System(_system_common) => {} ParsedMessage::Realtime(_system_realtime) => {} } if raw { ui.horizontal_top(|ui| { ui.label("Bytes:"); Frame::none().show(ui, |ui| { ui.spacing_mut().item_spacing = (0.0, 0.0).into(); for byte in &msg.raw { Frame::none() .inner_margin(2.0) .stroke((0.5, Color32::GRAY)) .outer_margin(0.0) .show(ui, |ui| { ui.label(format!("{:02X}", byte)); }); } }); }); } } fn note_name(midi_note: u8) -> String { let octave = ((midi_note as i32) - 12) / 12; let note = match midi_note % 12 { 0 => "C", 1 => "C#", 2 => "D", 3 => "Eb", 4 => "E", 5 => "F#", 6 => "F#", 7 => "G", 8 => "Ab", 9 => "A", 10 => "Bb", 11 => "B", _ => panic!("somehow we broke modular arithmetic rules i guess"), }; format!("{note}{octave}") } #[cfg(test)] mod tests { use crate::ui::note_name; #[test] pub fn names_of_notes() { assert_eq!(note_name(60), "C4"); assert_eq!(note_name(36), "C2"); assert_eq!(note_name(57), "A3"); assert_eq!(note_name(35), "B1"); } }