// 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 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, fs, iter::zip, path::PathBuf, sync::{Arc, Mutex}, }; 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}; #[derive(Clone, Debug, Default, Deserialize)] pub struct Tool { name: String, command: String, description: String, args: Option>, use_conemu: bool, separator: bool, } pub struct App { // 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, mode: Mode, tasks: Tasks, } impl 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 tasks = Tasks::new(action_tx.clone(), disk_list_arc.clone()); let mut list = StatefulList::default(); list.set_items(load_tools()); 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, mode: Mode::default(), tasks, }) } 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()?; for component in &mut self.components { component.register_action_handler(self.action_tx.clone())?; } 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(); action_tx.send(Action::SetMode(Mode::PEMenu))?; 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(()) } 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(()); }; if let Some(action) = keymap.get(&vec![key]) { info!("Got action: {action:?}"); action_tx.send(action.clone())?; } else { // 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); // 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(..); // Check background task(s) self.tasks.poll()?; } 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(); if let Some(tool) = self.list.get_selected() { if tool.separator { // Skip over separator self.list.previous(); if let Some(index) = self.list.selected() { self.action_tx.send(Action::Highlight(index))?; } } } } Action::KeyDown => { self.list.next(); if let Some(tool) = self.list.get_selected() { if tool.separator { // Skip over separator self.list.next(); if let Some(index) = self.list.selected() { self.action_tx.send(Action::Highlight(index))?; } } } } 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 => { // Run selected tool if let Some(tool) = self.list.get_selected() { info!("Run tool: {:?}", &tool); self.tasks.add(build_command(&self, &tool)); } } Action::Resize(w, h) => self.handle_resize(tui, w, h)?, Action::Render => self.render(tui)?, Action::SetMode(mode) => { self.mode = mode; self.action_tx .send(Action::UpdateFooter(String::from("(Enter) to select")))?; self.action_tx.send(build_left_items(self))?; self.action_tx.send(build_right_items(self))?; self.action_tx.send(Action::Select(None, None))?; } Action::OpenTerminal => { self.tasks.add(Task::Command( PathBuf::from("cmd.exe"), vec![String::from("-new_console:n")], )); } _ => {} } 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 } pub fn build_command(app: &App, tool: &Tool) -> Task { let cmd_path: PathBuf; let mut cmd_args: Vec = Vec::new(); let start_index: usize; if tool.use_conemu { cmd_path = app.config.conemu_path.clone(); cmd_args.push(String::from("-new_console:n")); cmd_args.push(tool.command.clone()); start_index = 1; } else { cmd_path = PathBuf::from(tool.command.clone()); start_index = 0; } if let Some(args) = &tool.args { if args.len() > start_index { args[start_index..].iter().for_each(|a| { cmd_args.push(a.clone()); }); } } Task::Command(cmd_path, cmd_args) } fn build_left_items(app: &App) -> Action { let title = String::from("Tools"); let labels = vec![String::new(), String::new()]; let items = app .list .items .iter() .map(|tool| { if tool.separator { String::from("──────────────") } else { tool.name.clone() } }) // ─ .collect(); Action::UpdateLeft(title, labels, items, 0) } fn build_right_items(app: &App) -> Action { let labels: Vec> = Vec::new(); let items = app .list .items .iter() .map(|entry| { vec![ DVLine { line_parts: vec![entry.name.clone()], line_colors: vec![Color::Cyan], }, DVLine { line_parts: vec![String::new()], line_colors: vec![Color::Reset], }, DVLine { line_parts: vec![entry.description.clone()], line_colors: vec![Color::Reset], }, ] }) .collect(); let start_index = 0; Action::UpdateRight(labels, start_index, items) } pub fn load_tools() -> Vec { let exe_path = env::current_exe().expect("Failed to find main executable"); 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(); entries .iter() .map(|entry| { 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"); tool }) .collect() }