diff --git a/src/app.rs b/src/app.rs index 8030986..8dfa533 100644 --- a/src/app.rs +++ b/src/app.rs @@ -18,12 +18,12 @@ use crate::{ controller::ControllerManager, emulator::{EmulatorClient, EmulatorCommand, SimId}, images::ImageProcessor, - input::MappingProvider, + input::{MappingProvider, ShortcutProvider}, memory::MemoryClient, persistence::Persistence, window::{ AboutWindow, AppWindow, BgMapWindow, CharacterDataWindow, FrameBufferWindow, GameWindow, - GdbServerWindow, InputWindow, ObjectWindow, RegisterWindow, WorldWindow, + GdbServerWindow, InputWindow, ObjectWindow, RegisterWindow, ShortcutsWindow, WorldWindow, }, }; @@ -44,6 +44,7 @@ pub struct Application { client: EmulatorClient, proxy: EventLoopProxy, mappings: MappingProvider, + shortcuts: ShortcutProvider, controllers: ControllerManager, memory: Arc, images: ImageProcessor, @@ -63,6 +64,7 @@ impl Application { let icon = load_icon().ok().map(Arc::new); let persistence = Persistence::new(); let mappings = MappingProvider::new(persistence.clone()); + let shortcuts = ShortcutProvider::new(persistence.clone()); let controllers = ControllerManager::new(client.clone(), &mappings); let memory = Arc::new(MemoryClient::new(client.clone())); let images = ImageProcessor::new(); @@ -77,6 +79,7 @@ impl Application { client, proxy, mappings, + shortcuts, memory, images, controllers, @@ -111,6 +114,7 @@ impl ApplicationHandler for Application { self.client.clone(), self.proxy.clone(), self.persistence.clone(), + self.shortcuts.clone(), SimId::Player1, ); self.open(event_loop, Box::new(app)); @@ -246,11 +250,16 @@ impl ApplicationHandler for Application { let input = InputWindow::new(self.mappings.clone()); self.open(event_loop, Box::new(input)); } + UserEvent::OpenShortcuts => { + let shortcuts = ShortcutsWindow::new(self.shortcuts.clone()); + self.open(event_loop, Box::new(shortcuts)); + } UserEvent::OpenPlayer2 => { let p2 = GameWindow::new( self.client.clone(), self.proxy.clone(), self.persistence.clone(), + self.shortcuts.clone(), SimId::Player2, ); self.open(event_loop, Box::new(p2)); @@ -503,6 +512,7 @@ pub enum UserEvent { OpenRegisters(SimId), OpenDebugger(SimId), OpenInput, + OpenShortcuts, OpenPlayer2, Quit(SimId), } diff --git a/src/input.rs b/src/input.rs index 252645b..d74c866 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,11 +1,13 @@ use std::{ - collections::{hash_map::Entry, HashMap}, + cmp::Ordering, + collections::{hash_map::Entry, HashMap, HashSet}, fmt::Display, str::FromStr, - sync::{Arc, RwLock}, + sync::{Arc, Mutex, RwLock}, }; use anyhow::anyhow; +use egui::{Key, KeyboardShortcut, Modifiers}; use gilrs::{ev::Code, Axis, Button, Gamepad, GamepadId}; use serde::{Deserialize, Serialize}; use winit::keyboard::{KeyCode, PhysicalKey}; @@ -454,3 +456,205 @@ struct PersistedGamepadMapping { default_buttons: Vec<(Code, VBKey)>, default_axes: Vec<(Code, (VBKey, VBKey))>, } + +#[derive(Serialize, Deserialize)] +pub struct Shortcut { + pub shortcut: KeyboardShortcut, + pub command: Command, +} + +#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum Command { + OpenRom, + Quit, + FrameAdvance, + Reset, + PauseResume, + // if you update this, update Command::all and add a default +} + +impl Command { + pub fn all() -> [Self; 5] { + [ + Self::OpenRom, + Self::Quit, + Self::PauseResume, + Self::Reset, + Self::FrameAdvance, + ] + } + + pub fn name(self) -> &'static str { + match self { + Self::OpenRom => "Open ROM", + Self::Quit => "Exit", + Self::PauseResume => "Pause/Resume", + Self::Reset => "Reset", + Self::FrameAdvance => "Frame Advance", + } + } +} + +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +struct Shortcuts { + all: Vec<(Command, KeyboardShortcut)>, + by_command: HashMap, +} + +impl Default for Shortcuts { + fn default() -> Self { + let mut shortcuts = Shortcuts { + all: vec![], + by_command: HashMap::new(), + }; + shortcuts.set( + Command::OpenRom, + KeyboardShortcut::new(Modifiers::COMMAND, Key::O), + ); + shortcuts.set( + Command::Quit, + KeyboardShortcut::new(Modifiers::COMMAND, Key::Q), + ); + shortcuts.set( + Command::PauseResume, + KeyboardShortcut::new(Modifiers::NONE, Key::F5), + ); + shortcuts.set( + Command::Reset, + KeyboardShortcut::new(Modifiers::SHIFT, Key::F5), + ); + shortcuts.set( + Command::FrameAdvance, + KeyboardShortcut::new(Modifiers::NONE, Key::F6), + ); + shortcuts + } +} + +impl Shortcuts { + fn set(&mut self, command: Command, shortcut: KeyboardShortcut) { + if self.by_command.insert(command, shortcut).is_some() { + for (cmd, sht) in &mut self.all { + if *cmd == command { + *sht = shortcut; + break; + } + } + } else { + self.all.push((command, shortcut)); + } + self.all.sort_by(|l, r| order_shortcut(l.1, r.1)); + } + + fn unset(&mut self, command: Command) { + if self.by_command.remove(&command).is_some() { + self.all.retain(|(c, _)| *c != command); + } + } + + fn save(&self) -> PersistedShortcuts { + let mut shortcuts = PersistedShortcuts { shortcuts: vec![] }; + for command in Command::all() { + let shortcut = self.by_command.get(&command).copied(); + shortcuts.shortcuts.push((command, shortcut)); + } + shortcuts + } +} + +fn order_shortcut(left: KeyboardShortcut, right: KeyboardShortcut) -> Ordering { + left.logical_key.cmp(&right.logical_key).then_with(|| { + specificity(left.modifiers) + .cmp(&specificity(right.modifiers)) + .reverse() + }) +} + +fn specificity(modifiers: egui::Modifiers) -> usize { + let mut mods = 0; + if modifiers.alt { + mods += 1; + } + if modifiers.command || modifiers.ctrl { + mods += 1; + } + if modifiers.shift { + mods += 1; + } + mods +} + +#[derive(Serialize, Deserialize)] +struct PersistedShortcuts { + shortcuts: Vec<(Command, Option)>, +} + +#[derive(Clone)] +pub struct ShortcutProvider { + persistence: Persistence, + shortcuts: Arc>, +} + +impl ShortcutProvider { + pub fn new(persistence: Persistence) -> Self { + let mut shortcuts = Shortcuts::default(); + if let Ok(saved) = persistence.load_config::("shortcuts") { + for (command, shortcut) in saved.shortcuts { + if let Some(shortcut) = shortcut { + shortcuts.set(command, shortcut); + } else { + shortcuts.unset(command); + } + } + } + Self { + persistence, + shortcuts: Arc::new(Mutex::new(shortcuts)), + } + } + + pub fn shortcut_for(&self, command: Command) -> Option { + let lock = self.shortcuts.lock().unwrap(); + lock.by_command.get(&command).copied() + } + + pub fn consume_all(&self, input: &mut egui::InputState) -> HashSet { + let lock = self.shortcuts.lock().unwrap(); + lock.all + .iter() + .filter_map(|(command, shortcut)| input.consume_shortcut(shortcut).then_some(*command)) + .collect() + } + + pub fn set(&self, command: Command, shortcut: KeyboardShortcut) { + let updated = { + let mut lock = self.shortcuts.lock().unwrap(); + lock.set(command, shortcut); + lock.save() + }; + let _ = self.persistence.save_config("shortcuts", &updated); + } + + pub fn unset(&self, command: Command) { + let updated = { + let mut lock = self.shortcuts.lock().unwrap(); + lock.unset(command); + lock.save() + }; + let _ = self.persistence.save_config("shortcuts", &updated); + } + + pub fn reset(&self) { + let updated = { + let mut lock = self.shortcuts.lock().unwrap(); + *lock = Shortcuts::default(); + lock.save() + }; + let _ = self.persistence.save_config("shortcuts", &updated); + } +} diff --git a/src/window.rs b/src/window.rs index 8ad5146..ff8bb6d 100644 --- a/src/window.rs +++ b/src/window.rs @@ -3,6 +3,7 @@ use egui::{Context, ViewportBuilder, ViewportId}; pub use game::GameWindow; pub use gdb::GdbServerWindow; pub use input::InputWindow; +pub use shortcuts::ShortcutsWindow; pub use vip::{ BgMapWindow, CharacterDataWindow, FrameBufferWindow, ObjectWindow, RegisterWindow, WorldWindow, }; @@ -15,6 +16,7 @@ mod game; mod game_screen; mod gdb; mod input; +mod shortcuts; mod utils; mod vip; diff --git a/src/window/game.rs b/src/window/game.rs index ce78bd2..aa09099 100644 --- a/src/window/game.rs +++ b/src/window/game.rs @@ -3,6 +3,7 @@ use std::sync::mpsc; use crate::{ app::UserEvent, emulator::{EmulatorClient, EmulatorCommand, EmulatorState, SimId, SimState}, + input::{Command, ShortcutProvider}, persistence::Persistence, }; use egui::{ @@ -38,6 +39,7 @@ pub struct GameWindow { client: EmulatorClient, proxy: EventLoopProxy, persistence: Persistence, + shortcuts: ShortcutProvider, sim_id: SimId, config: GameConfig, screen: Option, @@ -50,6 +52,7 @@ impl GameWindow { client: EmulatorClient, proxy: EventLoopProxy, persistence: Persistence, + shortcuts: ShortcutProvider, sim_id: SimId, ) -> Self { let config = load_config(&persistence, sim_id); @@ -57,6 +60,7 @@ impl GameWindow { client, proxy, persistence, + shortcuts, sim_id, config, screen: None, @@ -66,8 +70,53 @@ impl GameWindow { } fn show_menu(&mut self, ctx: &Context, ui: &mut Ui) { + let state = self.client.emulator_state(); + let is_ready = self.client.sim_state(self.sim_id) == SimState::Ready; + let can_pause = is_ready && state == EmulatorState::Running; + let can_resume = is_ready && state == EmulatorState::Paused; + let can_frame_advance = is_ready && state != EmulatorState::Debugging; + + for command in ui.input_mut(|input| self.shortcuts.consume_all(input)) { + match command { + Command::OpenRom => { + let rom = rfd::FileDialog::new() + .add_filter("Virtual Boy ROMs", &["vb", "vbrom"]) + .pick_file(); + if let Some(path) = rom { + self.client + .send_command(EmulatorCommand::LoadGame(self.sim_id, path)); + } + } + Command::Quit => { + let _ = self.proxy.send_event(UserEvent::Quit(self.sim_id)); + } + Command::PauseResume => { + if state == EmulatorState::Paused && can_resume { + self.client.send_command(EmulatorCommand::Resume); + } + if state == EmulatorState::Running && can_pause { + self.client.send_command(EmulatorCommand::Pause); + } + } + Command::Reset => { + if is_ready { + self.client + .send_command(EmulatorCommand::Reset(self.sim_id)); + } + } + Command::FrameAdvance => { + if can_frame_advance { + self.client.send_command(EmulatorCommand::FrameAdvance); + } + } + } + } + ui.menu_button("ROM", |ui| { - if ui.button("Open ROM").clicked() { + if ui + .add(self.button_for(ui.ctx(), "Open ROM", Command::OpenRom)) + .clicked() + { let rom = rfd::FileDialog::new() .add_filter("Virtual Boy ROMs", &["vb", "vbrom"]) .pick_file(); @@ -77,33 +126,49 @@ impl GameWindow { } ui.close_menu(); } - if ui.button("Quit").clicked() { + if ui + .add(self.button_for(ui.ctx(), "Quit", Command::Quit)) + .clicked() + { let _ = self.proxy.send_event(UserEvent::Quit(self.sim_id)); } }); ui.menu_button("Emulation", |ui| { - let state = self.client.emulator_state(); - let is_ready = self.client.sim_state(self.sim_id) == SimState::Ready; - let can_pause = is_ready && state == EmulatorState::Running; - let can_resume = is_ready && state == EmulatorState::Paused; - let can_frame_advance = is_ready && state != EmulatorState::Debugging; if state == EmulatorState::Running { - if ui.add_enabled(can_pause, Button::new("Pause")).clicked() { + if ui + .add_enabled( + can_pause, + self.button_for(ui.ctx(), "Pause", Command::PauseResume), + ) + .clicked() + { self.client.send_command(EmulatorCommand::Pause); ui.close_menu(); } - } else if ui.add_enabled(can_resume, Button::new("Resume")).clicked() { + } else if ui + .add_enabled( + can_resume, + self.button_for(ui.ctx(), "Resume", Command::PauseResume), + ) + .clicked() + { self.client.send_command(EmulatorCommand::Resume); ui.close_menu(); } - if ui.add_enabled(is_ready, Button::new("Reset")).clicked() { + if ui + .add_enabled(is_ready, self.button_for(ui.ctx(), "Reset", Command::Reset)) + .clicked() + { self.client .send_command(EmulatorCommand::Reset(self.sim_id)); ui.close_menu(); } ui.separator(); if ui - .add_enabled(can_frame_advance, Button::new("Frame Advance")) + .add_enabled( + can_frame_advance, + self.button_for(ui.ctx(), "Frame Advance", Command::FrameAdvance), + ) .clicked() { self.client.send_command(EmulatorCommand::FrameAdvance); @@ -293,6 +358,10 @@ impl GameWindow { ui.close_menu(); } }); + if ui.button("Key Shortcuts").clicked() { + self.proxy.send_event(UserEvent::OpenShortcuts).unwrap(); + ui.close_menu(); + } } fn show_color_picker(&mut self, ui: &mut Ui) { @@ -334,6 +403,14 @@ impl GameWindow { } self.config = new_config; } + + fn button_for(&self, ctx: &Context, text: &str, command: Command) -> Button { + let button = Button::new(text); + match self.shortcuts.shortcut_for(command) { + Some(shortcut) => button.shortcut_text(ctx.format_shortcut(&shortcut)), + None => button, + } + } } fn config_filename(sim_id: SimId) -> &'static str { diff --git a/src/window/shortcuts.rs b/src/window/shortcuts.rs new file mode 100644 index 0000000..ad7742f --- /dev/null +++ b/src/window/shortcuts.rs @@ -0,0 +1,103 @@ +use egui::{ + Button, CentralPanel, Context, Event, KeyboardShortcut, Label, Layout, Ui, ViewportBuilder, + ViewportId, +}; +use egui_extras::{Column, TableBuilder}; + +use crate::input::{Command, ShortcutProvider}; + +use super::AppWindow; + +pub struct ShortcutsWindow { + shortcuts: ShortcutProvider, + now_binding: Option, +} + +impl ShortcutsWindow { + pub fn new(shortcuts: ShortcutProvider) -> Self { + Self { + shortcuts, + now_binding: None, + } + } + + fn show_shortcuts(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + if ui.button("Use defaults").clicked() { + self.shortcuts.reset(); + } + }); + ui.separator(); + let row_height = ui.spacing().interact_size.y; + let width = ui.available_width() - 20.0; + TableBuilder::new(ui) + .column(Column::exact(width * 0.3)) + .column(Column::exact(width * 0.5)) + .column(Column::exact(width * 0.2)) + .cell_layout(Layout::left_to_right(egui::Align::Center)) + .body(|mut body| { + for command in Command::all() { + body.row(row_height, |mut row| { + row.col(|ui| { + ui.add_sized(ui.available_size(), Label::new(command.name())); + }); + row.col(|ui| { + let button = if self.now_binding == Some(command) { + Button::new("Binding...") + } else if let Some(shortcut) = self.shortcuts.shortcut_for(command) { + Button::new(ui.ctx().format_shortcut(&shortcut)) + } else { + Button::new("") + }; + if ui.add_sized(ui.available_size(), button).clicked() { + self.now_binding = Some(command); + } + }); + row.col(|ui| { + if ui + .add_sized(ui.available_size(), Button::new("Clear")) + .clicked() + { + self.shortcuts.unset(command); + self.now_binding = None; + } + }); + }); + } + }); + if let Some(command) = self.now_binding { + if let Some(shortcut) = ui.input_mut(|i| { + i.events.iter().find_map(|event| match event { + Event::Key { + key, + pressed: true, + modifiers, + .. + } => Some(KeyboardShortcut::new(*modifiers, *key)), + _ => None, + }) + }) { + self.shortcuts.set(command, shortcut); + self.now_binding = None; + } + } + } +} + +impl AppWindow for ShortcutsWindow { + fn viewport_id(&self) -> ViewportId { + ViewportId::from_hash_of("shortcuts") + } + + fn initial_viewport(&self) -> ViewportBuilder { + ViewportBuilder::default() + .with_title("Keyboard Shortcuts") + .with_inner_size((400.0, 400.0)) + } + + fn show(&mut self, ctx: &Context) { + CentralPanel::default().show(ctx, |ui| { + self.show_shortcuts(ui); + }); + } +}