diff --git a/Cargo.lock b/Cargo.lock index 71b9463..b63888c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,12 +2024,20 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" name = "pe-menu" version = "0.2.0" dependencies = [ + "anyhow", + "clap", + "color-eyre", + "core", "crossterm", "futures", "ratatui", "serde", "tokio", "toml", + "tracing", + "tracing-error", + "tracing-subscriber", + "vergen-gix", ] [[package]] diff --git a/config/config.json5 b/config/config.json5 index f4bd9e2..f255593 100644 --- a/config/config.json5 +++ b/config/config.json5 @@ -3,19 +3,19 @@ "clone_app_path": "C:/Program Files/Some Clone Tool/app.exe", "keybindings": { "ScanDisks": { - "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" }, "InstallDrivers": { "": "Process", "": "KeyUp", "": "KeyDown", - "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" }, "SelectDisks": { "": "InstallDriver", @@ -23,70 +23,79 @@ "": "Process", "": "KeyUp", "": "KeyDown", - "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" }, "SelectTableType": { "": "PrevScreen", "": "Process", "": "KeyUp", "": "KeyDown", - "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" }, "Confirm": { "": "PrevScreen", "": "Process", - "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" }, "PreClone": { - "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" }, "Clone": { - "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" }, "SelectParts": { "": "Process", "": "KeyUp", "": "KeyDown", - "": "ScanDisks", // Start over - "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "ScanDisks", + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" }, "PostClone": { - "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" }, "Done": { "": "Quit", - "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" }, "Failed": { "": "Quit", - "": "Quit", // Quit the application - "": "Quit", // Another way to quit - "": "Quit", // Yet another way to quit - "": "Suspend" // Suspend the application + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" }, - } + "PEMenu": { + "": "Process", + "": "KeyUp", + "": "KeyDown", + "": "Quit", + "": "Quit", + "": "Quit", + "": "Suspend" + }, + }, } diff --git a/core/src/state.rs b/core/src/state.rs index b3219b0..790efe9 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -25,6 +25,7 @@ use crate::system::{ #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Mode { + // Clone #[default] ScanDisks, InstallDrivers, @@ -35,8 +36,11 @@ pub enum Mode { Clone, SelectParts, PostClone, + // Core Done, Failed, + // WinPE + PEMenu, } #[derive(Debug, Default)] diff --git a/deja_vu/src/app.rs b/deja_vu/src/app.rs index c235f3a..99d1767 100644 --- a/deja_vu/src/app.rs +++ b/deja_vu/src/app.rs @@ -107,12 +107,14 @@ impl App { Mode::Confirm => Some(Mode::SelectTableType), Mode::SelectTableType => Some(Mode::SelectDisks), Mode::SelectDisks => Some(Mode::ScanDisks), - // + // Disallowed moves Mode::InstallDrivers | Mode::ScanDisks | Mode::PreClone | Mode::Clone | Mode::PostClone => None, + // Invalid states + Mode::PEMenu => panic!("This shouldn't happen?"), }; new_mode } @@ -129,6 +131,8 @@ impl App { Mode::SelectParts => Mode::PostClone, Mode::PostClone | Mode::Done => Mode::Done, Mode::Failed => Mode::Failed, + // Invalid states + Mode::PEMenu => panic!("This shouldn't happen?"), }; if new_mode == self.cur_mode { @@ -570,8 +574,11 @@ fn build_footer_string(cur_mode: Mode) -> String { Mode::SelectTableType => String::from("(Enter) to select / (b) to go back / (q) to quit"), Mode::Confirm => String::from("(Enter) to confirm / (b) to go back / (q) to quit"), Mode::Done | Mode::Failed | Mode::InstallDrivers => String::from("(Enter) or (q) to quit"), + // Invalid states + Mode::PEMenu => panic!("This shouldn't happen?"), } } + fn build_left_items(app: &App, cur_mode: Mode) -> (String, Vec, Vec, bool) { let title: String; let mut items = Vec::new(); @@ -604,7 +611,7 @@ fn build_left_items(app: &App, cur_mode: Mode) -> (String, Vec, Vec { title = String::from("Confirm Selections"); } - Mode::PreClone | Mode::Clone | Mode::PostClone | Mode::ScanDisks => { + Mode::ScanDisks | Mode::PreClone | Mode::Clone | Mode::PostClone => { title = String::from("Processing"); } Mode::SelectParts => { @@ -621,6 +628,8 @@ fn build_left_items(app: &App, cur_mode: Mode) -> (String, Vec, Vec title = String::from("Done"), + // Invalid states + Mode::PEMenu => panic!("This shouldn't happen?"), }; (title, labels, items, select_one) } diff --git a/include/menu_entries/01_deja-vu.toml b/include/menu_entries/01_deja-vu.toml index 6899622..2f33c07 100644 --- a/include/menu_entries/01_deja-vu.toml +++ b/include/menu_entries/01_deja-vu.toml @@ -1,4 +1,5 @@ name = 'Deja-Vu' command = 'X:\tools\deja-vu.exe' +description = "Windows clone assistant tool" use_conemu = true separator = false diff --git a/include/menu_entries/02_separator.toml b/include/menu_entries/02_separator.toml index ed50f1c..cfd668c 100644 --- a/include/menu_entries/02_separator.toml +++ b/include/menu_entries/02_separator.toml @@ -1,4 +1,5 @@ name = '' command = '' +description = '' use_conemu = false separator = true diff --git a/include/menu_entries/03_ntpwedit.toml b/include/menu_entries/03_ntpwedit.toml index 9b07df9..d8124dd 100644 --- a/include/menu_entries/03_ntpwedit.toml +++ b/include/menu_entries/03_ntpwedit.toml @@ -1,4 +1,5 @@ name = 'NTPWEdit' command = 'X:\Program Files\NTPWEdit\ntpwedit.exe' +description = 'Mostly used to unlock the built-in admin account' use_conemu = false separator = false diff --git a/include/menu_entries/04_clone-tool.toml b/include/menu_entries/04_clone-tool.toml index 1fe2dd3..530ddd3 100644 --- a/include/menu_entries/04_clone-tool.toml +++ b/include/menu_entries/04_clone-tool.toml @@ -1,4 +1,5 @@ name = 'Some Clone Tool' command = 'X:\Program Files\Some\Tool.exe' +description = 'Run Some Clone tool' use_conemu = false separator = false diff --git a/include/menu_entries/05_taskmgr.toml b/include/menu_entries/05_taskmgr.toml index c33d623..f4484fc 100644 --- a/include/menu_entries/05_taskmgr.toml +++ b/include/menu_entries/05_taskmgr.toml @@ -1,4 +1,5 @@ name = 'Task Manager' command = 'X:\Windows\System32\taskmgr.exe' +description = 'Manage those tasks' use_conemu = false separator = false diff --git a/pe_menu/Cargo.toml b/pe_menu/Cargo.toml index b1fdbd0..cf7bcfb 100644 --- a/pe_menu/Cargo.toml +++ b/pe_menu/Cargo.toml @@ -21,9 +21,26 @@ license = "GPL" version = "0.2.0" [dependencies] +core = { path = "../core" } +clap = { version = "4.4.5", features = [ + "derive", + "cargo", + "wrap_help", + "unicode", + "string", + "unstable-styles", + ] } +color-eyre = "0.6.3" crossterm = { version = "0.28.1", features = ["event-stream"] } futures = "0.3.30" ratatui = "0.29.0" serde = { version = "1.0.217", features = ["derive"] } tokio = { version = "1.43.0", features = ["full"] } toml = "0.8.13" +tracing = "0.1.41" +tracing-error = "0.2.0" +tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } + +[build-dependencies] +anyhow = "1.0.86" +vergen-gix = { version = "1.0.0", features = ["build", "cargo"] } diff --git a/pe_menu/build.rs b/pe_menu/build.rs new file mode 100644 index 0000000..c3ced6b --- /dev/null +++ b/pe_menu/build.rs @@ -0,0 +1,28 @@ +// This file is part of Deja-vu. +// +// Deja-vu is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Deja-vu is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Deja-vu. If not, see . +// +use anyhow::Result; +use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder}; + +fn main() -> Result<()> { + let build = BuildBuilder::all_build()?; + let gix = GixBuilder::all_git()?; + let cargo = CargoBuilder::all_cargo()?; + Emitter::default() + .add_instructions(&build)? + .add_instructions(&gix)? + .add_instructions(&cargo)? + .emit() +} diff --git a/pe_menu/src/app.rs b/pe_menu/src/app.rs index fbe5c23..fbe63b1 100644 --- a/pe_menu/src/app.rs +++ b/pe_menu/src/app.rs @@ -13,46 +13,50 @@ // You should have received a copy of the GNU General Public License // along with Deja-vu. If not, see . // -use ratatui::widgets::ListState; -use serde::Deserialize; +use core::{ + action::Action, + components::{ + footer::Footer, fps::FpsCounter, left::Left, popup, right::Right, state::StatefulList, + title::Title, Component, + }, + config::Config, + line::DVLine, + state::Mode, + tasks::{Task, Tasks}, + tui::{Event, Tui}, +}; use std::{ - env, error, fs, io, + env, fs, + iter::zip, path::PathBuf, - process::{Command, Output}, - thread::{self, JoinHandle}, + sync::{Arc, Mutex}, }; -/// Application result type. -#[allow(clippy::module_name_repetitions)] -pub type AppResult = std::result::Result>; +use color_eyre::Result; +use ratatui::{ + crossterm::event::KeyEvent, + layout::{Constraint, Direction, Layout}, + prelude::Rect, + style::Color, +}; +use serde::Deserialize; +use tokio::sync::mpsc; +use tracing::{debug, info}; -/// Application exit reasons -#[derive(Debug, Default)] -pub enum QuitReason { - #[default] - Exit, - Poweroff, - Restart, -} - -/// Config #[derive(Debug, Deserialize)] -pub struct Config { +pub struct Menu { con_emu: String, tools: Vec, } -impl Config { - /// # Panics - /// - /// Will panic for many reasons +impl Menu { #[must_use] - pub fn load() -> Option { + pub fn load() -> Menu { // Main config let exe_path = env::current_exe().expect("Failed to find main executable"); let contents = fs::read_to_string(exe_path.with_file_name("pe-menu.toml")) .expect("Failed to load config file"); - let mut new_config: Config = + let mut menu_entries: Menu = toml::from_str(&contents).expect("Failed to parse config file"); // Tools @@ -66,301 +70,305 @@ impl Config { for entry in entries { let contents = fs::read_to_string(&entry).expect("Failed to read tool config file"); let tool: Tool = toml::from_str(&contents).expect("Failed to parse tool config file"); - new_config.tools.push(tool); + menu_entries.tools.push(tool); } // Done - Some(new_config) + menu_entries } } -/// `PopUp` -#[derive(Debug, Clone, PartialEq)] -pub struct PopUp { - pub title: String, - pub body: String, -} - -impl PopUp { - #[must_use] - pub fn new(title: &str, body: &str) -> PopUp { - PopUp { - title: String::from(title), - body: String::from(body), - } - } -} - -/// `Tool` #[derive(Debug, Deserialize)] pub struct Tool { name: String, command: String, + description: String, args: Option>, use_conemu: bool, separator: bool, } -/// `MenuEntry` -#[derive(Default, Debug, Clone, PartialEq)] -pub struct MenuEntry { - pub name: String, - pub command: String, - pub args: Vec, - pub use_conemu: bool, - pub separator: bool, -} - -impl MenuEntry { - #[must_use] - pub fn new( - name: &str, - command: &str, - args: Option>, - use_conemu: bool, - separator: bool, - ) -> MenuEntry { - let mut my_args = Vec::new(); - if let Some(a) = args { - my_args.clone_from(&a); - } - MenuEntry { - name: String::from(name), - command: String::from(command), - args: my_args, - use_conemu, - separator, - } - } -} - -/// `StatefulList` -#[derive(Default, Debug, Clone, PartialEq)] -pub struct StatefulList { - pub state: ListState, - pub items: Vec, - pub last_selected: Option, -} - -impl StatefulList { - #[must_use] - pub fn new() -> StatefulList { - StatefulList { - state: ListState::default(), - items: Vec::new(), - last_selected: None, - } - } - - #[must_use] - pub fn get_selected(&self) -> Option<&T> { - if let Some(i) = self.state.selected() { - self.items.get(i) - } else { - None - } - } - - pub fn pop_selected(&mut self) -> Option { - if let Some(i) = self.state.selected() { - Some(self.items[i].clone()) - } else { - None - } - } - - fn select_first_item(&mut self) { - if self.items.is_empty() { - self.state.select(None); - } else { - self.state.select(Some(0)); - } - self.last_selected = None; - } - - pub fn set_items(&mut self, items: Vec) { - // Clear list and rebuild with provided items - self.items.clear(); - for item in items { - self.items.push(item); - } - - // Reset state and select first item (if available) - self.state = ListState::default(); - self.select_first_item(); - } - - pub fn next(&mut self) { - if self.items.is_empty() { - return; - } - let i = match self.state.selected() { - Some(i) => { - if i >= self.items.len() - 1 { - 0 - } else { - i + 1 - } - } - None => self.last_selected.unwrap_or(0), - }; - self.state.select(Some(i)); - } - - pub fn previous(&mut self) { - if self.items.is_empty() { - return; - } - let i = match self.state.selected() { - Some(i) => { - if i == 0 { - self.items.len() - 1 - } else { - i - 1 - } - } - None => self.last_selected.unwrap_or(0), - }; - self.state.select(Some(i)); - } -} - -/// Application. -#[derive(Debug)] pub struct App { - pub config: Config, - pub main_menu: StatefulList, - pub popup: Option, - pub quit_reason: QuitReason, - pub running: bool, - pub thread_pool: Vec>>, -} - -impl Default for App { - fn default() -> Self { - let config = Config::load(); - Self { - config: config.unwrap(), - running: true, - quit_reason: QuitReason::Exit, - main_menu: StatefulList::new(), - popup: None, - thread_pool: Vec::new(), - } - } + // TUI + action_rx: mpsc::UnboundedReceiver, + action_tx: mpsc::UnboundedSender, + components: Vec>, + config: Config, + frame_rate: f64, + last_tick_key_events: Vec, + should_quit: bool, + should_suspend: bool, + tick_rate: f64, + // App + list: StatefulList, + menu: Menu, + mode: Mode, + selections: Vec>, } impl App { - /// Constructs a new instance of [`App`]. - #[must_use] - pub fn new() -> Self { - let mut app = Self::default(); - - // Add MenuEntries - for tool in &app.config.tools { - app.main_menu.items.push(MenuEntry::new( - &tool.name, - &tool.command, - tool.args.clone(), - tool.use_conemu, - tool.separator, - )); - } - app.main_menu.select_first_item(); - - // Done - app + pub fn new(tick_rate: f64, frame_rate: f64) -> Result { + let (action_tx, action_rx) = mpsc::unbounded_channel(); + let disk_list_arc = Arc::new(Mutex::new(Vec::new())); + let mut tasks = Tasks::new(action_tx.clone(), disk_list_arc.clone()); + tasks.add(Task::ScanDisks); + Ok(Self { + // TUI + action_rx, + action_tx, + components: vec![ + Box::new(Title::new("PE Menu")), + Box::new(FpsCounter::new()), + Box::new(Left::new()), + Box::new(Right::new()), + Box::new(Footer::new()), + Box::new(popup::Popup::new()), + ], + config: Config::new()?, + frame_rate, + last_tick_key_events: Vec::new(), + should_quit: false, + should_suspend: false, + tick_rate, + // App + list: StatefulList::default(), + menu: Menu::load(), + mode: Mode::PEMenu, + selections: vec![None, None], + }) } - /// Handles the tick event of the terminal. - pub fn tick(&self) {} + pub async fn run(&mut self) -> Result<()> { + let mut tui = Tui::new()? + // .mouse(true) // uncomment this line to enable mouse support + .tick_rate(self.tick_rate) + .frame_rate(self.frame_rate); + tui.enter()?; - /// Actually exit application - /// - /// # Errors - /// # Panics - /// - /// Will panic if wpeutil fails to reboot or shutdown - pub fn exit(&self) -> Result<(), &'static str> { - let mut argument: Option = None; - match self.quit_reason { - QuitReason::Exit => {} - QuitReason::Poweroff => argument = Some(String::from("shutdown")), - QuitReason::Restart => argument = Some(String::from("reboot")), + for component in &mut self.components { + component.register_action_handler(self.action_tx.clone())?; } - if let Some(a) = argument { - Command::new("wpeutil") - .arg(a) - .output() - .expect("Failed to run exit command"); + for component in &mut self.components { + component.register_config_handler(self.config.clone())?; + } + for component in &mut self.components { + component.init(tui.size()?)?; + } + + let action_tx = self.action_tx.clone(); + loop { + self.handle_events(&mut tui).await?; + self.handle_actions(&mut tui)?; + if self.should_suspend { + tui.suspend()?; + action_tx.send(Action::Resume)?; + action_tx.send(Action::ClearScreen)?; + // tui.mouse(true); + tui.enter()?; + } else if self.should_quit { + tui.stop()?; + break; + } + } + tui.exit()?; + Ok(()) + } + + async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { + let Some(event) = tui.next_event().await else { + return Ok(()); + }; + let action_tx = self.action_tx.clone(); + match event { + Event::Quit => action_tx.send(Action::Quit)?, + Event::Tick => action_tx.send(Action::Tick)?, + Event::Render => action_tx.send(Action::Render)?, + Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, + Event::Key(key) => self.handle_key_event(key)?, + _ => {} + } + for component in &mut self.components { + if let Some(action) = component.handle_events(Some(event.clone()))? { + action_tx.send(action)?; + } } Ok(()) } - /// Set running to false to quit the application. - pub fn quit(&mut self, reason: QuitReason) { - self.running = false; - self.quit_reason = reason; - } - - /// # Panics - /// - /// Will panic if command fails to run - pub fn open_terminal(&mut self) { - Command::new("cmd.exe") - .arg("-new_console:n") - .output() - .expect("Failed to run command"); - } - - /// # Panics - /// - /// Will panic if menu entry isn't found - pub fn run_tool(&mut self) { - // Command - let tool: &MenuEntry; - if let Some(index) = self.main_menu.state.selected() { - tool = &self.main_menu.items[index]; - } else { - self.popup = Some(PopUp::new( - "Failed to find menu entry", - "Check for an updated version of Deja-Vu", - )); - return; - } - let command = if tool.use_conemu { - self.config.con_emu.clone() - } else { - tool.command.clone() + fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { + let action_tx = self.action_tx.clone(); + let Some(keymap) = self.config.keybindings.get(&self.mode) else { + return Ok(()); }; - - // Separators - if tool.separator { - return; - } - - // Args - let mut args = tool.args.clone(); - if tool.use_conemu { - args.insert(0, tool.command.clone()); - args.push(String::from("-new_console:n")); - } - - // Check path - let command_path = PathBuf::from(&command); - if let Ok(true) = command_path.try_exists() { - // File path exists + if let Some(action) = keymap.get(&vec![key]) { + info!("Got action: {action:?}"); + action_tx.send(action.clone())?; } else { - // File path doesn't exist or is a broken symlink/etc - // The latter case would be Ok(false) rather than Err(_) - self.popup = Some(PopUp::new("Tool Missing", &format!("Tool path: {command}"))); - return; - } + // If the key was not handled as a single key action, + // then consider it for multi-key combinations. + self.last_tick_key_events.push(key); - // Run - // TODO: This really needs refactored to use channels so we can properly check if the - // command fails. - let new_thread = thread::spawn(move || Command::new(command_path).args(args).output()); - self.thread_pool.push(new_thread); + // Check for multi-key combinations + if let Some(action) = keymap.get(&self.last_tick_key_events) { + info!("Got action: {action:?}"); + action_tx.send(action.clone())?; + } + } + Ok(()) + } + + fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> { + while let Ok(action) = self.action_rx.try_recv() { + if action != Action::Tick && action != Action::Render { + debug!("{action:?}"); + } + match action { + Action::Tick => { + self.last_tick_key_events.drain(..); + } + Action::Quit => self.should_quit = true, + Action::Suspend => self.should_suspend = true, + Action::Resume => self.should_suspend = false, + Action::ClearScreen => tui.terminal.clear()?, + Action::KeyUp => self.list.previous(), + Action::KeyDown => self.list.next(), + Action::Error(ref msg) => { + self.action_tx + .send(Action::DisplayPopup(popup::Type::Error, msg.clone()))?; + self.action_tx.send(Action::SetMode(Mode::Failed))?; + } + Action::Process => { + if let Some(index) = self.list.selected() { + //TODO: Run selected tool? + } + } + Action::Resize(w, h) => self.handle_resize(tui, w, h)?, + Action::Render => self.render(tui)?, + Action::SetMode(_) => { + self.action_tx + .send(Action::UpdateFooter(String::from("(Enter) to select")))?; + let (title, labels, items, select_one) = build_left_items(self); + self.action_tx + .send(Action::UpdateLeft(title, labels, items, select_one))?; + let (labels, start, items) = build_right_items(self); + self.action_tx + .send(Action::UpdateRight(labels, start, items))?; + } + _ => {} + } + for component in &mut self.components { + if let Some(action) = component.update(action.clone())? { + self.action_tx.send(action)?; + }; + } + } + Ok(()) + } + + fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> { + tui.resize(Rect::new(0, 0, w, h))?; + self.render(tui)?; + Ok(()) + } + + fn render(&mut self, tui: &mut Tui) -> Result<()> { + tui.draw(|frame| { + if let [header, _body, footer, left, right, popup] = get_chunks(frame.area())[..] { + let component_areas = vec![ + header, // Title Bar + header, // FPS Counter + left, right, footer, popup, + ]; + for (component, area) in zip(self.components.iter_mut(), component_areas) { + if let Err(err) = component.draw(frame, area) { + let _ = self + .action_tx + .send(Action::Error(format!("Failed to draw: {err:?}"))); + } + } + }; + })?; + Ok(()) } } + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + // Cut the given rectangle into three vertical pieces + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + // Then cut the middle vertical piece into three width-wise pieces + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] // Return the middle chunk +} + +fn get_chunks(r: Rect) -> Vec { + let mut chunks: Vec = Vec::with_capacity(6); + + // Main sections + chunks.extend( + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(r) + .to_vec(), + ); + + // Left/Right + chunks.extend( + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(centered_rect(90, 90, chunks[1])) + .to_vec(), + ); + + // Popup + chunks.push(centered_rect(60, 25, r)); + + // Done + chunks +} + +fn build_left_items(app: &App) -> (String, Vec, Vec, bool) { + let title = String::from("Tools"); + let items = app + .menu + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect(); + let labels: Vec = Vec::new(); + (title, labels, items, true) +} + +fn build_right_items(app: &App) -> (Vec>, usize, Vec>) { + let items = vec![app + .menu + .tools + .iter() + .map(|item| DVLine { + line_parts: vec![item.description.clone()], + line_colors: vec![Color::Reset], + }) + .collect()]; + let labels: Vec> = Vec::new(); + let start_index = 0; + (labels, start_index, items) +} diff --git a/pe_menu/src/main.rs b/pe_menu/src/main.rs index b7fab7a..8bcfda9 100644 --- a/pe_menu/src/main.rs +++ b/pe_menu/src/main.rs @@ -13,40 +13,21 @@ // You should have received a copy of the GNU General Public License // along with Deja-vu. If not, see . // -use pe_menu::app::{App, AppResult}; -use pe_menu::event::{Event, Handler}; -use pe_menu::handler::handle_key_events; -use pe_menu::tui::Tui; -use ratatui::backend::CrosstermBackend; -use ratatui::Terminal; -use std::io; +use clap::Parser; +use color_eyre::Result; +use core; + +use crate::app::App; + +mod app; #[tokio::main] -async fn main() -> AppResult<()> { - // Create an application. - let mut app = App::new(); +async fn main() -> Result<()> { + core::errors::init()?; + core::logging::init()?; - // Initialize the terminal user interface. - let backend = CrosstermBackend::new(io::stderr()); - let terminal = Terminal::new(backend)?; - let events = Handler::new(250); - let mut tui = Tui::new(terminal, events); - tui.init()?; - - // Start the main loop. - while app.running { - // Render the user interface. - tui.draw(&mut app)?; - // Handle events. - match tui.events.next().await? { - Event::Tick => app.tick(), - Event::Key(key_event) => handle_key_events(key_event, &mut app), - Event::Mouse(_) | Event::Resize(_, _) => {} - } - } - - // Exit the user interface. - tui.exit()?; - app.exit()?; + let args = core::cli::Cli::parse(); + let mut app = App::new(args.tick_rate, args.frame_rate)?; + app.run().await?; Ok(()) } diff --git a/pe_menu/src/old/app.rs b/pe_menu/src/old/app.rs new file mode 100644 index 0000000..234cc1d --- /dev/null +++ b/pe_menu/src/old/app.rs @@ -0,0 +1,366 @@ +// This file is part of Deja-vu. +// +// Deja-vu is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Deja-vu is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Deja-vu. If not, see . + +use ratatui::widgets::ListState; +use serde::Deserialize; +use std::{ + env, error, fs, io, + path::PathBuf, + process::{Command, Output}, + thread::{self, JoinHandle}, +}; + +/// Application result type. +#[allow(clippy::module_name_repetitions)] +pub type AppResult = std::result::Result>; + +/// Application exit reasons +#[derive(Debug, Default)] +pub enum QuitReason { + #[default] + Exit, + Poweroff, + Restart, +} + +/// Config +#[derive(Debug, Deserialize)] +pub struct Config { + con_emu: String, + tools: Vec, +} + +impl Config { + /// # Panics + /// + /// Will panic for many reasons + #[must_use] + pub fn load() -> Option { + // Main config + let exe_path = env::current_exe().expect("Failed to find main executable"); + let contents = fs::read_to_string(exe_path.with_file_name("pe-menu.toml")) + .expect("Failed to load config file"); + let mut new_config: Config = + toml::from_str(&contents).expect("Failed to parse config file"); + + // Tools + let tool_config_path = exe_path.parent().unwrap().join("menu_entries"); + let mut entries: Vec = std::fs::read_dir(tool_config_path) + .expect("Failed to find any tool configs") + .map(|res| res.map(|e| e.path())) + .filter_map(Result::ok) + .collect(); + entries.sort(); + for entry in entries { + let contents = fs::read_to_string(&entry).expect("Failed to read tool config file"); + let tool: Tool = toml::from_str(&contents).expect("Failed to parse tool config file"); + new_config.tools.push(tool); + } + + // Done + Some(new_config) + } +} + +/// `PopUp` +#[derive(Debug, Clone, PartialEq)] +pub struct PopUp { + pub title: String, + pub body: String, +} + +impl PopUp { + #[must_use] + pub fn new(title: &str, body: &str) -> PopUp { + PopUp { + title: String::from(title), + body: String::from(body), + } + } +} + +/// `Tool` +#[derive(Debug, Deserialize)] +pub struct Tool { + name: String, + command: String, + args: Option>, + use_conemu: bool, + separator: bool, +} + +/// `MenuEntry` +#[derive(Default, Debug, Clone, PartialEq)] +pub struct MenuEntry { + pub name: String, + pub command: String, + pub args: Vec, + pub use_conemu: bool, + pub separator: bool, +} + +impl MenuEntry { + #[must_use] + pub fn new( + name: &str, + command: &str, + args: Option>, + use_conemu: bool, + separator: bool, + ) -> MenuEntry { + let mut my_args = Vec::new(); + if let Some(a) = args { + my_args.clone_from(&a); + } + MenuEntry { + name: String::from(name), + command: String::from(command), + args: my_args, + use_conemu, + separator, + } + } +} + +/// `StatefulList` +#[derive(Default, Debug, Clone, PartialEq)] +pub struct StatefulList { + pub state: ListState, + pub items: Vec, + pub last_selected: Option, +} + +impl StatefulList { + #[must_use] + pub fn new() -> StatefulList { + StatefulList { + state: ListState::default(), + items: Vec::new(), + last_selected: None, + } + } + + #[must_use] + pub fn get_selected(&self) -> Option<&T> { + if let Some(i) = self.state.selected() { + self.items.get(i) + } else { + None + } + } + + pub fn pop_selected(&mut self) -> Option { + if let Some(i) = self.state.selected() { + Some(self.items[i].clone()) + } else { + None + } + } + + fn select_first_item(&mut self) { + if self.items.is_empty() { + self.state.select(None); + } else { + self.state.select(Some(0)); + } + self.last_selected = None; + } + + pub fn set_items(&mut self, items: Vec) { + // Clear list and rebuild with provided items + self.items.clear(); + for item in items { + self.items.push(item); + } + + // Reset state and select first item (if available) + self.state = ListState::default(); + self.select_first_item(); + } + + pub fn next(&mut self) { + if self.items.is_empty() { + return; + } + let i = match self.state.selected() { + Some(i) => { + if i >= self.items.len() - 1 { + 0 + } else { + i + 1 + } + } + None => self.last_selected.unwrap_or(0), + }; + self.state.select(Some(i)); + } + + pub fn previous(&mut self) { + if self.items.is_empty() { + return; + } + let i = match self.state.selected() { + Some(i) => { + if i == 0 { + self.items.len() - 1 + } else { + i - 1 + } + } + None => self.last_selected.unwrap_or(0), + }; + self.state.select(Some(i)); + } +} + +/// Application. +#[derive(Debug)] +pub struct App { + pub config: Config, + pub main_menu: StatefulList, + pub popup: Option, + pub quit_reason: QuitReason, + pub running: bool, + pub thread_pool: Vec>>, +} + +impl Default for App { + fn default() -> Self { + let config = Config::load(); + Self { + config: config.unwrap(), + running: true, + quit_reason: QuitReason::Exit, + main_menu: StatefulList::new(), + popup: None, + thread_pool: Vec::new(), + } + } +} + +impl App { + /// Constructs a new instance of [`App`]. + #[must_use] + pub fn new() -> Self { + let mut app = Self::default(); + + // Add MenuEntries + for tool in &app.config.tools { + app.main_menu.items.push(MenuEntry::new( + &tool.name, + &tool.command, + tool.args.clone(), + tool.use_conemu, + tool.separator, + )); + } + app.main_menu.select_first_item(); + + // Done + app + } + + /// Handles the tick event of the terminal. + pub fn tick(&self) {} + + /// Actually exit application + /// + /// # Errors + /// # Panics + /// + /// Will panic if wpeutil fails to reboot or shutdown + pub fn exit(&self) -> Result<(), &'static str> { + let mut argument: Option = None; + match self.quit_reason { + QuitReason::Exit => {} + QuitReason::Poweroff => argument = Some(String::from("shutdown")), + QuitReason::Restart => argument = Some(String::from("reboot")), + } + if let Some(a) = argument { + Command::new("wpeutil") + .arg(a) + .output() + .expect("Failed to run exit command"); + } + Ok(()) + } + + /// Set running to false to quit the application. + pub fn quit(&mut self, reason: QuitReason) { + self.running = false; + self.quit_reason = reason; + } + + /// # Panics + /// + /// Will panic if command fails to run + pub fn open_terminal(&mut self) { + Command::new("cmd.exe") + .arg("-new_console:n") + .output() + .expect("Failed to run command"); + } + + /// # Panics + /// + /// Will panic if menu entry isn't found + pub fn run_tool(&mut self) { + // Command + let tool: &MenuEntry; + if let Some(index) = self.main_menu.state.selected() { + tool = &self.main_menu.items[index]; + } else { + self.popup = Some(PopUp::new( + "Failed to find menu entry", + "Check for an updated version of Deja-Vu", + )); + return; + } + let command = if tool.use_conemu { + self.config.con_emu.clone() + } else { + tool.command.clone() + }; + + // Separators + if tool.separator { + return; + } + + // Args + let mut args = tool.args.clone(); + if tool.use_conemu { + args.insert(0, tool.command.clone()); + args.push(String::from("-new_console:n")); + } + + // Check path + let command_path = PathBuf::from(&command); + if let Ok(true) = command_path.try_exists() { + // File path exists + } else { + // File path doesn't exist or is a broken symlink/etc + // The latter case would be Ok(false) rather than Err(_) + self.popup = Some(PopUp::new("Tool Missing", &format!("Tool path: {command}"))); + return; + } + + // Run + // TODO: This really needs refactored to use channels so we can properly check if the + // command fails. + let new_thread = thread::spawn(move || Command::new(command_path).args(args).output()); + self.thread_pool.push(new_thread); + } +} diff --git a/pe_menu/src/event.rs b/pe_menu/src/old/event.rs similarity index 100% rename from pe_menu/src/event.rs rename to pe_menu/src/old/event.rs diff --git a/pe_menu/src/handler.rs b/pe_menu/src/old/handler.rs similarity index 100% rename from pe_menu/src/handler.rs rename to pe_menu/src/old/handler.rs diff --git a/pe_menu/src/lib.rs b/pe_menu/src/old/lib.rs similarity index 100% rename from pe_menu/src/lib.rs rename to pe_menu/src/old/lib.rs diff --git a/pe_menu/src/old/main.rs b/pe_menu/src/old/main.rs new file mode 100644 index 0000000..b7fab7a --- /dev/null +++ b/pe_menu/src/old/main.rs @@ -0,0 +1,52 @@ +// This file is part of Deja-vu. +// +// Deja-vu is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Deja-vu is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +// See the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Deja-vu. If not, see . +// +use pe_menu::app::{App, AppResult}; +use pe_menu::event::{Event, Handler}; +use pe_menu::handler::handle_key_events; +use pe_menu::tui::Tui; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use std::io; + +#[tokio::main] +async fn main() -> AppResult<()> { + // Create an application. + let mut app = App::new(); + + // Initialize the terminal user interface. + let backend = CrosstermBackend::new(io::stderr()); + let terminal = Terminal::new(backend)?; + let events = Handler::new(250); + let mut tui = Tui::new(terminal, events); + tui.init()?; + + // Start the main loop. + while app.running { + // Render the user interface. + tui.draw(&mut app)?; + // Handle events. + match tui.events.next().await? { + Event::Tick => app.tick(), + Event::Key(key_event) => handle_key_events(key_event, &mut app), + Event::Mouse(_) | Event::Resize(_, _) => {} + } + } + + // Exit the user interface. + tui.exit()?; + app.exit()?; + Ok(()) +} diff --git a/pe_menu/src/tui.rs b/pe_menu/src/old/tui.rs similarity index 100% rename from pe_menu/src/tui.rs rename to pe_menu/src/old/tui.rs diff --git a/pe_menu/src/ui.rs b/pe_menu/src/old/ui.rs similarity index 100% rename from pe_menu/src/ui.rs rename to pe_menu/src/old/ui.rs