From 0559464747f7cfea0725d33dd30a09a93f4f145a Mon Sep 17 00:00:00 2001 From: Amjad Alsharafi <26300843+Amjad50@users.noreply.github.com> Date: Sat, 19 Oct 2024 18:08:23 +0800 Subject: [PATCH] Add menu and file explorer to TUI A lot of edits, added widgets for menu and file explorer. This now behave exactly like the full fledged UI we have. Signed-off-by: Amjad Alsharafi <26300843+Amjad50@users.noreply.github.com> --- Cargo.lock | 34 ++++ plastic_tui/Cargo.toml | 5 +- plastic_tui/src/main.rs | 25 ++- plastic_tui/src/ui.rs | 395 +++++++++++++++++++++++++++++++++------- 4 files changed, 392 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef313f9..8821cf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -968,6 +968,17 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "digest" version = "0.10.7" @@ -2580,10 +2591,13 @@ name = "plastic_tui" version = "0.3.1" dependencies = [ "crossterm", + "directories", "dynwave", "gilrs", "plastic_core", "ratatui", + "ratatui-explorer", + "tui-menu", ] [[package]] @@ -2747,6 +2761,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "ratatui-explorer" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde42516edac6d3f0b92bacc47644adda0d1c6cd0aefc4ac1f4ffb80c094999e" +dependencies = [ + "crossterm", + "derivative", + "ratatui", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3319,6 +3344,15 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5902c5d130972a0000f60860bfbf46f7ca3db5391eddfedd1b8728bd9dc96c0e" +[[package]] +name = "tui-menu" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6891512c027359f83904cf84fc7071303167e3e9718e454c3bd4d69eae62022" +dependencies = [ + "ratatui", +] + [[package]] name = "type-map" version = "0.5.0" diff --git a/plastic_tui/Cargo.toml b/plastic_tui/Cargo.toml index 0be80ec..f770278 100644 --- a/plastic_tui/Cargo.toml +++ b/plastic_tui/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "plastic_tui" -version = "0.3.1" +version = "0.3.2" authors = ["Amjad Alsharafi "] edition = "2021" description = "An accurate NES emulator. Front-end terminal interface (TUI) for plastic-core" @@ -17,4 +17,7 @@ crossterm = "0.28" gilrs = "0.11" dynwave = "0.1.0" ratatui = "0.28.1" +tui-menu = "0.2.4" +directories = "5.0" +ratatui-explorer = "0.1.2" diff --git a/plastic_tui/src/main.rs b/plastic_tui/src/main.rs index 8509c89..7a0847e 100644 --- a/plastic_tui/src/main.rs +++ b/plastic_tui/src/main.rs @@ -5,12 +5,29 @@ use std::env::args; fn main() { let args = args().collect::>(); - if args.len() < 2 { - eprintln!("USAGE: {} [-a]\n-a: remove audio", args[0]); + let mut file = args.get(1).map(|s| s.as_str()); + + if file == Some("-h") || file == Some("--help") { + eprintln!("USAGE: {} [rom-file] [-a]\n-a: remove audio", args[0]); return; } - let nes = match NES::new(&args[1]) { + let mut has_audio = true; + + if file == Some("-a") { + file = None; + has_audio = false; + } + + if has_audio && args.get(2).map(|s| s.as_str()) == Some("-a") { + has_audio = false; + } + + let nes = match file { + Some(f) => NES::new(f), + None => Ok(NES::new_without_file()), + }; + let nes = match nes { Ok(nes) => nes, Err(e) => { eprintln!("Error: {}", e); @@ -18,7 +35,5 @@ fn main() { } }; - let has_audio = !(args.len() == 3 && args[2] == "-a"); - ui::Ui::new(nes, has_audio).run(); } diff --git a/plastic_tui/src/ui.rs b/plastic_tui/src/ui.rs index 37a3b2e..37bbcb6 100644 --- a/plastic_tui/src/ui.rs +++ b/plastic_tui/src/ui.rs @@ -1,4 +1,6 @@ +use directories::ProjectDirs; use dynwave::AudioPlayer; +use layout::Flex; use plastic_core::{ misc::{process_audio, Fps}, nes_audio::SAMPLE_RATE, @@ -9,14 +11,16 @@ use ratatui::{ prelude::*, style::Color, widgets::{ - block::Title, + block::{Position, Title}, canvas::{Canvas, Painter, Shape}, - Block, Borders, + Block, Borders, Clear, Padding, Paragraph, }, }; -use std::{collections::HashMap, thread}; +use ratatui_explorer::{FileExplorer, Theme}; +use std::{collections::HashMap, fs, path::PathBuf, thread}; use std::{io, time::Duration}; use symbols::Marker; +use tui_menu::{Menu, MenuEvent as tuiMenuEvent, MenuItem, MenuState}; use gilrs::{Button, Event as GilrsEvent, EventType, Gilrs}; @@ -30,6 +34,21 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +fn base_save_state_folder() -> Option { + if let Some(proj_dirs) = ProjectDirs::from("Amjad50", "Plastic", "Plastic") { + let base_saved_states_dir = proj_dirs.data_local_dir().join("saved_states"); + // Linux: /home/../.local/share/plastic/saved_states + // Windows: C:\Users\..\AppData\Local\Plastic\Plastic\data\saved_states + // macOS: /Users/../Library/Application Support/Amjad50.Plastic.Plastic/saved_states + + fs::create_dir_all(&base_saved_states_dir).ok()?; + + Some(base_saved_states_dir) + } else { + None + } +} + struct ImageView<'a> { image: &'a [u8], } @@ -50,9 +69,27 @@ impl Shape for ImageView<'_> { } } +#[derive(Debug, Clone, Copy)] +enum MenuEvent { + FileOpen, + FileReset, + FilePause, + FileClose, + FileExit, + + SaveState(u8), + LoadState(u8), +} + pub struct Ui { pub nes: NES, + paused: bool, + error: Option, + file_explorer: FileExplorer, + is_file_explorer_open: bool, + + menu: MenuState, audio_player: Option>, gilrs: Gilrs, active_gamepad: Option, @@ -64,11 +101,36 @@ pub struct Ui { impl Ui { pub fn new(nes: NES, has_audio: bool) -> Self { + let theme = Theme::default() + .with_block( + Block::default() + .borders(Borders::ALL) + .style(Style::reset().fg(Color::White).bg(Color::Black)), + ) + .with_dir_style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .with_highlight_dir_style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) + .bg(Color::DarkGray), + ) + .add_default_title() + .with_title_bottom(|_| "Select .nes file".into()); + Ui { nes, + paused: false, + error: None, + file_explorer: FileExplorer::with_theme(theme).unwrap(), + is_file_explorer_open: false, + menu: MenuState::new(vec![]), audio_player: if has_audio { - Some(AudioPlayer::new(SAMPLE_RATE, dynwave::BufferSize::QuarterSecond).unwrap()) + AudioPlayer::new(SAMPLE_RATE, dynwave::BufferSize::QuarterSecond).ok() } else { None }, @@ -78,10 +140,101 @@ impl Ui { } } + fn get_present_save_states(&self) -> Option> { + const MIN_STATE_SLOT: u8 = 0; + const MAX_STATE_SLOT: u8 = 9; + if self.nes.is_empty() { + return None; + } + + let base_saved_states_dir = base_save_state_folder()?; + + Some( + (MIN_STATE_SLOT..=MAX_STATE_SLOT) + .map(|i| { + let filename = self.nes.save_state_file_name(i).unwrap(); + + (i, base_saved_states_dir.join(&filename).exists()) + }) + .collect(), + ) + } + + fn save_state(&mut self, slot: u8) { + if self.nes.is_empty() { + return; + } + + let base_saved_states_dir = base_save_state_folder().unwrap(); + let filename = self.nes.save_state_file_name(slot).unwrap(); + let path = base_saved_states_dir.join(&filename); + + let file = fs::File::create(&path).unwrap(); + + self.nes.save_state(&file).unwrap(); + } + + fn load_state(&mut self, slot: u8) { + if self.nes.is_empty() { + return; + } + + let base_saved_states_dir = base_save_state_folder().unwrap(); + let filename = self.nes.save_state_file_name(slot).unwrap(); + let path = base_saved_states_dir.join(&filename); + + let file = fs::File::open(&path).unwrap(); + + self.nes.load_state(&file).unwrap(); + } + + fn reset_menu(&mut self) { + let mut save_state_items = Vec::with_capacity(10); + let mut load_state_items = Vec::with_capacity(10); + + if let Some(slots) = self.get_present_save_states() { + for slot in slots { + save_state_items.push(MenuItem::item( + format!( + "Slot {} - {}", + slot.0, + if slot.1 { "Overwrite" } else { "Save" } + ), + MenuEvent::SaveState(slot.0), + )); + load_state_items.push(MenuItem::item( + format!("Slot {}{}", slot.0, if slot.1 { " - Present" } else { "" }), + MenuEvent::LoadState(slot.0), + )); + } + } + + self.menu = MenuState::new(vec![ + MenuItem::group( + "File", + vec![ + MenuItem::item("Open", MenuEvent::FileOpen), + MenuItem::item("Reset", MenuEvent::FileReset), + MenuItem::item( + if self.paused { "Resume" } else { "Pause" }, + MenuEvent::FilePause, + ), + MenuItem::item("Close", MenuEvent::FileClose), + MenuItem::item("Exit", MenuEvent::FileExit), + ], + ), + MenuItem::group("Save State", save_state_items), + MenuItem::group("Load State", load_state_items), + ]); + } + fn display(&mut self, terminal: &mut Terminal, fps: &Fps) { terminal .draw(move |f| { - let block = Block::default() + let [top, main] = + Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(f.area()); + + let mut block = Block::default() .borders(Borders::ALL) .title(Title::from("Plastic").alignment(Alignment::Center)) .title( @@ -96,17 +249,56 @@ impl Ui { .alignment(Alignment::Right), ) .title_style(Style::default().bold().fg(Color::Yellow)); - let canvas = Canvas::default() - .block(block) - .x_bounds([0., TV_WIDTH as f64]) - .y_bounds([0., TV_HEIGHT as f64]) - .marker(Marker::HalfBlock) - .paint(|ctx| { - ctx.draw(&ImageView { - image: self.nes.pixel_buffer(), + if self.paused { + block = block.title(Title::from("[Paused]").alignment(Alignment::Center)); + } + if let Some(error) = &self.error { + block = block + .title( + Title::from("Error:".red().bold()) + .position(Position::Bottom) + .alignment(Alignment::Center), + ) + .title( + Title::from(error.as_str().red().bold()) + .position(Position::Bottom) + .alignment(Alignment::Center), + ); + } + + if self.nes.is_empty() { + let paragraph = Paragraph::new("No ROM loaded") + .block(block.padding(Padding::top(main.height / 2))) + .alignment(Alignment::Center); + f.render_widget(paragraph, main); + } else { + let canvas = Canvas::default() + .block(block) + .x_bounds([0., TV_WIDTH as f64]) + .y_bounds([0., TV_HEIGHT as f64]) + .marker(Marker::HalfBlock) + .paint(|ctx| { + ctx.draw(&ImageView { + image: self.nes.pixel_buffer(), + }); }); - }); - f.render_widget(canvas, f.area()); + + f.render_widget(canvas, main); + } + + if self.is_file_explorer_open { + // draw in a center of the screen + let horizontal = + Layout::horizontal([Constraint::Percentage(50)]).flex(Flex::Center); + let vertical = + Layout::vertical([Constraint::Percentage(70)]).flex(Flex::Center); + let [area] = vertical.areas(main); + let [file_exp_area] = horizontal.areas(area); + f.render_widget(Clear, file_exp_area); + f.render_widget(&self.file_explorer.widget(), file_exp_area); + } + + f.render_stateful_widget(Menu::new(), top, &mut self.menu); }) .unwrap(); } @@ -120,52 +312,103 @@ impl Ui { let Ok(event) = crossterm::event::read() else { break; }; - match event { - Event::Key(input) => { - let modifiers = input.modifiers; - let code = input.code; - let possible_button = match code { - KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => return true, - - KeyCode::Char('C') | KeyCode::Char('c') - if modifiers.intersects(KeyModifiers::CONTROL) => - { - return true - } - KeyCode::Char('R') | KeyCode::Char('r') - if modifiers.intersects(KeyModifiers::CONTROL) => - { - self.nes.reset(); - None - } - KeyCode::Char('J') | KeyCode::Char('j') => Some(NESKey::B), - KeyCode::Char('K') | KeyCode::Char('k') => Some(NESKey::A), - KeyCode::Char('U') | KeyCode::Char('u') => Some(NESKey::Select), - KeyCode::Char('I') | KeyCode::Char('i') => Some(NESKey::Start), - KeyCode::Char('W') | KeyCode::Char('w') => Some(NESKey::Up), - KeyCode::Char('S') | KeyCode::Char('s') => Some(NESKey::Down), - KeyCode::Char('A') | KeyCode::Char('a') => Some(NESKey::Left), - KeyCode::Char('D') | KeyCode::Char('d') => Some(NESKey::Right), - _ => None, - }; - if let Some(button) = possible_button { - match input.kind { - KeyEventKind::Press | KeyEventKind::Repeat => { - self.nes.set_controller_state(button, true); - if !has_keyboard_enhancement { - // 20 frames - // TODO: very arbitrary, but it works on some of the games - // tested - self.keyboard_event_counter.insert(button, 20); + if let Event::Key(input) = event { + let modifiers = input.modifiers; + let code = input.code; + let possible_button = match code { + KeyCode::Char('q') | KeyCode::Char('Q') => return true, + KeyCode::Char('C') | KeyCode::Char('c') + if modifiers.intersects(KeyModifiers::CONTROL) => + { + return true + } + KeyCode::Char('R') | KeyCode::Char('r') + if modifiers.intersects(KeyModifiers::CONTROL) => + { + self.nes.reset(); + None + } + KeyCode::Char('J') | KeyCode::Char('j') => Some(NESKey::B), + KeyCode::Char('K') | KeyCode::Char('k') => Some(NESKey::A), + KeyCode::Char('U') | KeyCode::Char('u') => Some(NESKey::Select), + KeyCode::Char('I') | KeyCode::Char('i') => Some(NESKey::Start), + KeyCode::Char('W') | KeyCode::Char('w') => Some(NESKey::Up), + KeyCode::Char('S') | KeyCode::Char('s') => Some(NESKey::Down), + KeyCode::Char('A') | KeyCode::Char('a') => Some(NESKey::Left), + KeyCode::Char('D') | KeyCode::Char('d') => Some(NESKey::Right), + KeyCode::Char('P') | KeyCode::Char('p') => { + self.paused = !self.paused; + None + } + KeyCode::Enter => { + if self.is_file_explorer_open { + let file = self.file_explorer.current(); + if !file.is_dir() { + if file.path().extension().map(|e| e == "nes").unwrap_or(false) { + let new_nes = NES::new(file.path()); + match new_nes { + Ok(nes) => { + self.nes = nes; + self.is_file_explorer_open = false; + } + Err(e) => { + self.error = Some(format!("Opening NES: {}", e)); + } + } + + self.error = None; + } else { + self.error = Some("Invalid file".to_string()); } } - KeyEventKind::Release => { - self.nes.set_controller_state(button, false); + } else { + self.menu.select(); + } + None + } + KeyCode::Esc => { + self.is_file_explorer_open = false; + self.reset_menu(); + None + } + KeyCode::Left if !self.is_file_explorer_open => { + self.menu.left(); + None + } + KeyCode::Right if !self.is_file_explorer_open => { + self.menu.right(); + None + } + KeyCode::Up if !self.is_file_explorer_open => { + self.menu.up(); + None + } + KeyCode::Down if !self.is_file_explorer_open => { + self.menu.down(); + None + } + _ => None, + }; + if let Some(button) = possible_button { + match input.kind { + KeyEventKind::Press | KeyEventKind::Repeat => { + self.nes.set_controller_state(button, true); + if !has_keyboard_enhancement { + // 20 frames + // TODO: very arbitrary, but it works on some of the games + // tested + self.keyboard_event_counter.insert(button, 20); } } + KeyEventKind::Release => { + self.nes.set_controller_state(button, false); + } } } - _ => {} + } + + if self.is_file_explorer_open { + self.file_explorer.handle(&event).unwrap(); } } @@ -190,6 +433,26 @@ impl Ui { false } + fn handle_menu(&mut self) -> bool { + for e in self.menu.drain_events() { + match e { + tuiMenuEvent::Selected(event) => match event { + MenuEvent::FileOpen => { + self.is_file_explorer_open = true; + } + MenuEvent::FileReset => self.nes.reset(), + MenuEvent::FilePause => self.paused = !self.paused, + MenuEvent::FileClose => self.nes = NES::new_without_file(), + MenuEvent::FileExit => return true, + MenuEvent::SaveState(i) => self.save_state(i), + MenuEvent::LoadState(i) => self.load_state(i), + }, + } + self.reset_menu(); + } + false + } + fn handle_gamepad(&mut self) { // set events in the cache and check if gamepad is still active while let Some(GilrsEvent { id, event, .. }) = self.gilrs.next_event() { @@ -220,6 +483,8 @@ impl Ui { } pub fn run(&mut self) { + self.reset_menu(); + let mut stdout = io::stdout(); execute!( @@ -238,19 +503,27 @@ impl Ui { let mut terminal = Terminal::new(backend).unwrap(); let mut fps = Fps::new(61.0); - if let Some(ref mut player) = self.audio_player { - player.play().unwrap(); - } - loop { + if let Some(ref mut player) = self.audio_player { + if self.paused { + player.pause().unwrap(); + } else { + player.play().unwrap(); + } + } + fps.start_frame(); if self.handle_keyboard(has_keyboard_enhancement) { break; } - + if self.handle_menu() { + break; + } self.handle_gamepad(); - self.nes.clock_for_frame(); + if !self.paused { + self.nes.clock_for_frame(); + } self.display(&mut terminal, &fps); // take the buffer in all cases, otherwise the audio will keep accumulating in memory