diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 538a9ad..b2ae5b4 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -15,15 +15,18 @@ pub mod update; use crate::config::ResolvedConfig; use crate::indexer::IndexedTable; -use crate::simplefrontend::{launch, TableOption}; +use crate::simplefrontend::{launch, prompt, TableOption}; +use crate::{info_diff, info_edit, write_info_json}; use anyhow::Result; +use colored::Colorize; use event::{Event, EventHandler}; use ratatui::backend::CrosstermBackend; use state::State; use std::collections::HashSet; -use std::io::stdin; +use std::io::{stdin, Cursor, Write}; use tui::Tui; use update::update; +use vpin::vpx::{extractvbs, ExtractResult}; type Terminal = ratatui::Terminal>; @@ -92,6 +95,86 @@ fn run_action(state: &mut State, tui: &mut Tui, action: Action) -> Result launch(selected_path, vpinball_executable, Some(false)); Ok(()) }), + TableOption::InfoShow => run_external(tui, || { + // echo pipe to less + let mut memory_file = Cursor::new(Vec::new()); + write_info_json(selected_path, &mut memory_file)?; + let output = memory_file.into_inner(); + // execute less with the data piped in + let mut less = std::process::Command::new("less") + .stdin(std::process::Stdio::piped()) + .spawn()?; + let mut stdin = less.stdin.take().unwrap(); + stdin.write_all(&output)?; + // wait for less to finish + less.wait()?; + Ok(()) + }), + TableOption::InfoEdit => run_external(tui, || { + let config = Some(&state.config); + match info_edit(selected_path, config) { + Ok(path) => { + println!("Launched editor for {}", path.display()); + } + Err(err) => { + let msg = format!("Unable to edit table info: {}", err); + prompt(msg.truecolor(255, 125, 0).to_string()); + } + } + Ok(()) + }), + TableOption::InfoDiff => run_external(tui, || { + match info_diff(selected_path) { + Ok(diff) => { + prompt(diff); + } + Err(err) => { + let msg = format!("Unable to diff info: {}", err); + prompt(msg.truecolor(255, 125, 0).to_string()); + } + }; + Ok(()) + }), + TableOption::ExtractVBS => { + // TODO is this guard thing a good idea? I prefer the closure approach + // but we got some issues with the borrow checker + let _guard = TuiGuard::new(tui)?; + match extractvbs(selected_path, false, None) { + Ok(ExtractResult::Extracted(path)) => { + state.prompt_info(format!( + "VBS extracted to {}", + path.to_string_lossy() + )); + } + Ok(ExtractResult::Existed(path)) => { + let msg = + format!("VBS already exists at {}", path.to_string_lossy()); + state.prompt_warning(msg); + } + Err(err) => { + let msg = format!("Unable to extract VBS: {}", err); + state.prompt_error(msg); + } + } + Ok(()) + } + TableOption::EditVBS => run_external(tui, || { + let config = Some(&state.config); + match info_edit(selected_path, config) { + Ok(path) => { + println!("Launched editor for {}", path.display()); + } + Err(err) => { + let msg = format!("Unable to edit table info: {}", err); + prompt(msg.truecolor(255, 125, 0).to_string()); + } + } + Ok(()) + }), + TableOption::PatchVBS => run_external(tui, || Ok(())), + TableOption::UnifyLineEndings => run_external(tui, || Ok(())), + TableOption::ShowVBSDiff => run_external(tui, || Ok(())), + TableOption::CreateVBSPatch => run_external(tui, || Ok(())), not_implemented => run_external(tui, || { eprintln!( "Action not implemented: {:?}. Press enter to continue.", @@ -112,8 +195,26 @@ fn run_action(state: &mut State, tui: &mut Tui, action: Action) -> Result } } +struct TuiGuard<'a> { + tui: &'a mut Tui, +} + +impl<'a> TuiGuard<'a> { + fn new(tui: &'a mut Tui) -> Result { + tui.disable()?; + Ok(Self { tui }) + } +} + +impl<'a> Drop for TuiGuard<'a> { + fn drop(&mut self) { + if let Err(err) = self.tui.enable() { + eprintln!("Failed to re-enable TUI: {}", err); + } + } +} + fn run_external(tui: &mut Tui, run: impl Fn() -> Result) -> Result { - // TODO most of this stuff is duplicated in Tui tui.disable()?; let result = run(); tui.enable()?; diff --git a/src/frontend/state.rs b/src/frontend/state.rs index 168c480..880b152 100644 --- a/src/frontend/state.rs +++ b/src/frontend/state.rs @@ -9,7 +9,20 @@ pub struct State { pub roms: HashSet, pub tables: TableList, pub table_dialog: Option, + /// Dialog to display messages to the user. + /// It can be a warning or an error. + /// It will be displayed until the user dismisses it. + /// It will be displayed on top of everything else. + pub message_dialog: Option, } + +#[derive(Debug)] +pub enum MessageDialog { + Info(String), + Warning(String), + Error(String), +} + #[derive(Debug, Default)] pub enum TablesSort { #[default] @@ -209,6 +222,7 @@ impl State { roms, tables, table_dialog: None, + message_dialog: None, } } @@ -217,6 +231,9 @@ impl State { /// Returns the key bindings. pub fn get_key_bindings(&self) -> Vec<(&str, &str)> { + if let Some(_) = self.message_dialog { + return vec![("⏎", "Dismiss")]; + } match self.table_dialog { Some(_) => vec![("⏎", "Select"), ("↑↓", "Navigate"), ("q/esc", "Back")], None => vec![ @@ -237,6 +254,22 @@ impl State { self.table_dialog = Some(dialog); } } + + pub(crate) fn close_dialog(&mut self) { + self.table_dialog = None; + } + + pub(crate) fn prompt_info(&mut self, message: String) { + self.message_dialog = Some(MessageDialog::Info(message)); + } + + pub(crate) fn prompt_warning(&mut self, message: String) { + self.message_dialog = Some(MessageDialog::Warning(message)); + } + + pub(crate) fn prompt_error(&mut self, message: String) { + self.message_dialog = Some(MessageDialog::Error(message)); + } } #[cfg(test)] diff --git a/src/frontend/ui.rs b/src/frontend/ui.rs index 4bd5f1c..ce63b8c 100644 --- a/src/frontend/ui.rs +++ b/src/frontend/ui.rs @@ -1,4 +1,4 @@ -use crate::frontend::state::{FilterState, State, TableActionsDialog, TablesSort}; +use crate::frontend::state::{FilterState, MessageDialog, State, TableActionsDialog, TablesSort}; use crate::indexer::IndexedTable; use crate::simplefrontend::{capitalize_first_letter, TableOption}; use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect}; @@ -29,7 +29,7 @@ const KEY_BINDING_STYLE: Style = Style::new().fg(AMBER.c500); pub fn render(state: &mut State, f: &mut Frame) { let mut main_enabled = true; - if state.table_dialog.is_some() { + if state.table_dialog.is_some() || state.message_dialog.is_some() { main_enabled = false; } @@ -48,9 +48,37 @@ pub fn render(state: &mut State, f: &mut Frame) { render_action_dialog(table_dialog, f, table); } + // render the message dialog on top + if let Some(dialog) = &state.message_dialog { + render_message_dialog(f, dialog); + } + render_key_bindings(state, f, chunks[1]); } +fn render_message_dialog(frame: &mut Frame, dialog: &MessageDialog) { + let (msg, style, title) = match dialog { + MessageDialog::Error(message) => (message, Style::default().fg(Color::Red), "Error"), + MessageDialog::Warning(message) => { + (message, Style::default().fg(Color::Yellow), "⚠ Warning") + } + MessageDialog::Info(message) => (message, Style::default(), "Info"), + }; + + let text = Text::styled(msg.clone(), style); + let area = frame.area(); + let block = Block::bordered() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(style) + .title(title) + .style(style); + let area = popup_area_percent(area, 80, 10); + frame.render_widget(Clear, area); //this clears out the background + let paragraph = Paragraph::new(text).wrap(Wrap { trim: true }).block(block); + frame.render_widget(paragraph, area); +} + fn render_main(state: &mut State, f: &mut Frame, enabled: bool, area: Rect) { let [list_filter_aea, info_area] = Layout::default() .direction(Direction::Horizontal) @@ -297,3 +325,16 @@ fn popup_area(area: Rect, width: u16, /*percent_x: u16,*/ height: u16) -> Rect { let [area] = horizontal.areas(area); area } + +fn popup_area_percent(area: Rect, percent_x: u16, max_height: u16) -> Rect { + let vertical = Layout::vertical([ + Constraint::Length(2), + Constraint::Max(max_height), + Constraint::Length(3), + ]) + .flex(Flex::Center); + let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center); + let [_, area, _] = vertical.areas(area); + let [area] = horizontal.areas(area); + area +} diff --git a/src/frontend/update.rs b/src/frontend/update.rs index 499c8f0..1eeb38d 100644 --- a/src/frontend/update.rs +++ b/src/frontend/update.rs @@ -1,7 +1,16 @@ use crate::frontend::state::State; use crate::frontend::Action; +use crate::indexer::IndexedTable; +use crate::info_gather; use crate::simplefrontend::TableOption; +use colored::Colorize; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::prelude::{Color, Text}; +use ratatui::style::Style; +use ratatui::text::Span; +use ratatui::widgets::{ListItem, Paragraph}; +use vpin::vpx::tableinfo::TableInfo; +use vpin::vpx::version::Version; pub fn update(state: &mut State, key_event: KeyEvent) -> Action { // always allow ctrl-c to quit @@ -10,10 +19,23 @@ pub fn update(state: &mut State, key_event: KeyEvent) -> Action { { return Action::Quit; } + + // give priority to the message dialog + if let Some(_dialog) = &mut state.message_dialog { + return match key_event.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter => { + state.message_dialog = None; + Action::None + } + _ => Action::None, + }; + } + + // handle the table dialog match &mut state.table_dialog { Some(dialog) => match key_event.code { KeyCode::Esc | KeyCode::Char('q') => { - state.table_dialog = None; + state.close_dialog(); Action::None } KeyCode::Up => { diff --git a/src/lib.rs b/src/lib.rs index c89ff2a..56e0ce3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,8 @@ use std::process::{exit, ExitCode}; use vpin::directb2s::read; use vpin::vpx; use vpin::vpx::jsonmodel::{game_data_to_json, info_to_json}; +use vpin::vpx::tableinfo::TableInfo; +use vpin::vpx::version::Version; use vpin::vpx::{expanded, extractvbs, importvbs, tableinfo, verify, ExtractResult, VerifyResult}; pub mod config; @@ -112,8 +114,9 @@ fn handle_command(matches: ArgMatches) -> io::Result { let path = path.unwrap_or(""); let expanded_path = expand_path(path)?; println!("showing info for {}", expanded_path.display())?; - let info = info_gather(&expanded_path)?; - println!("{}", info)?; + let (version, info) = info_gather(&expanded_path)?; + let txt = info_format(&version, info); + println!("{}", txt)?; Ok(ExitCode::SUCCESS) } Some((CMD_INFO_EXTRACT, sub_matches)) => { @@ -1166,13 +1169,17 @@ fn expand_path(path: &str) -> io::Result { } } -fn info_gather(vpx_file_path: &PathBuf) -> io::Result { +fn info_gather(vpx_file_path: &PathBuf) -> io::Result<(Version, TableInfo)> { let mut vpx_file = vpx::open(vpx_file_path)?; let version = vpx_file.read_version()?; // GameData also has a name field that we might want to display here // where is this shown in the UI? let table_info = vpx_file.read_tableinfo()?; + Ok((version, table_info)) +} + +fn info_format(version: &Version, table_info: TableInfo) -> String { let mut buffer = String::new(); buffer.push_str(&format!("{:>18} {}\n", "VPX Version:".green(), version)); @@ -1243,8 +1250,7 @@ fn info_gather(vpx_file_path: &PathBuf) -> io::Result { for (prop, value) in &table_info.properties { buffer.push_str(&format!("{:>18}: {}\n", prop.green(), value)); } - - Ok(buffer) + buffer } fn info_extract(vpx_file_path: &PathBuf) -> io::Result { @@ -1259,25 +1265,29 @@ fn info_extract(vpx_file_path: &PathBuf) -> io::Result { return Ok(ExitCode::SUCCESS); } } - write_info_json(vpx_file_path, &info_file_path)?; + write_info_json_file(vpx_file_path, &info_file_path)?; println!("Extracted table info to {}", info_file_path.display())?; Ok(ExitCode::SUCCESS) } -fn write_info_json(vpx_file_path: &PathBuf, info_file_path: &PathBuf) -> io::Result<()> { +fn write_info_json_file(vpx_file_path: &PathBuf, info_file_path: &PathBuf) -> io::Result<()> { + let mut info_file = File::create(info_file_path)?; + write_info_json(vpx_file_path, &mut info_file) +} + +fn write_info_json(vpx_file_path: &PathBuf, writer: &mut W) -> io::Result<()> { let mut vpx_file = vpx::open(vpx_file_path)?; let table_info = vpx_file.read_tableinfo()?; let custom_info_tags = vpx_file.read_custominfotags()?; let table_info_json = info_to_json(&table_info, &custom_info_tags); - let info_file = File::create(info_file_path)?; - serde_json::to_writer_pretty(info_file, &table_info_json)?; + serde_json::to_writer_pretty(writer, &table_info_json)?; Ok(()) } fn info_edit(vpx_file_path: &PathBuf, config: Option<&ResolvedConfig>) -> io::Result { let info_file_path = vpx_file_path.with_extension("info.json"); if !info_file_path.exists() { - write_info_json(vpx_file_path, &info_file_path)?; + write_info_json_file(vpx_file_path, &info_file_path)?; } open_editor(&info_file_path, config)?; Ok(info_file_path) @@ -1394,7 +1404,7 @@ pub fn info_diff>(vpx_file_path: P) -> io::Result { let info_file_path = expanded_vpx_path.with_extension("info.json"); let original_info_path = vpx_file_path.as_ref().with_extension("info.original.tmp"); if info_file_path.exists() { - write_info_json(&expanded_vpx_path, &original_info_path)?; + write_info_json_file(&expanded_vpx_path, &original_info_path)?; let diff_color = if colored::control::SHOULD_COLORIZE.should_colorize() { DiffColor::Always } else { diff --git a/src/simplefrontend.rs b/src/simplefrontend.rs index 4aca953..17919e1 100644 --- a/src/simplefrontend.rs +++ b/src/simplefrontend.rs @@ -7,22 +7,23 @@ use std::{ process::{exit, ExitStatus}, }; -use colored::Colorize; -use console::Emoji; -use dialoguer::theme::ColorfulTheme; -use dialoguer::{FuzzySelect, Input, Select}; -use indicatif::{ProgressBar, ProgressStyle}; -use is_executable::IsExecutable; - use crate::config::ResolvedConfig; use crate::indexer::{IndexError, IndexedTable, Progress}; use crate::patcher::LineEndingsResult::{NoChanges, Unified}; use crate::patcher::{patch_vbs_file, unify_line_endings_vbs_file}; use crate::{ - indexer, info_diff, info_edit, info_gather, open_editor, run_diff, script_diff, + indexer, info_diff, info_edit, info_format, info_gather, open_editor, run_diff, script_diff, vpx::{extractvbs, vbs_path_for, ExtractResult}, DiffColor, ProgressBarProgress, }; +use colored::Colorize; +use console::Emoji; +use dialoguer::theme::ColorfulTheme; +use dialoguer::{FuzzySelect, Input, Select}; +use indicatif::{ProgressBar, ProgressStyle}; +use is_executable::IsExecutable; +use vpin::vpx::tableinfo::TableInfo; +use vpin::vpx::version::Version; const LAUNCH: Emoji = Emoji("🚀", "[launch]"); const CRASH: Emoji = Emoji("💥", "[crash]"); @@ -386,8 +387,9 @@ fn table_menu( } } Some(TableOption::InfoShow) => match info_gather(selected_path) { - Ok(info) => { - prompt(info); + Ok((version, info)) => { + let txt = info_format(&version, info); + prompt(txt); } Err(err) => { let msg = format!("Unable to gather table info: {}", err); @@ -416,7 +418,7 @@ fn table_menu( } } -fn prompt>(msg: S) { +pub fn prompt>(msg: S) { Input::::new() .with_prompt(format!("{} - Press enter to continue.", msg.into())) .default("".to_string())