From 548cfa8e36dc065633c124bc92b3ca59309cd631 Mon Sep 17 00:00:00 2001 From: Liam <33645555+lj3954@users.noreply.github.com> Date: Fri, 9 Aug 2024 02:22:19 -0700 Subject: [PATCH] feat: Implement tabs --- src/float.rs | 57 ++---- src/floating_text.rs | 36 +++- src/list.rs | 444 ----------------------------------------- src/main.rs | 111 +---------- src/running_command.rs | 7 +- src/state.rs | 323 +++++++++++++++++++++++++++++- src/tabs.rs | 162 +++++++++++++++ src/theme.rs | 6 + 8 files changed, 545 insertions(+), 601 deletions(-) delete mode 100644 src/list.rs create mode 100644 src/tabs.rs diff --git a/src/float.rs b/src/float.rs index cd0bd9f8..3b063cae 100644 --- a/src/float.rs +++ b/src/float.rs @@ -10,16 +10,16 @@ pub trait FloatContent { fn is_finished(&self) -> bool; } -pub struct Float { - content: Option, +pub struct Float { + content: Box, width_percent: u16, height_percent: u16, } -impl Float { - pub fn new(width_percent: u16, height_percent: u16) -> Self { +impl Float { + pub fn new(content: Box, width_percent: u16, height_percent: u16) -> Self { Self { - content: None, + content, width_percent, height_percent, } @@ -48,44 +48,25 @@ impl Float { pub fn draw(&mut self, frame: &mut Frame, parent_area: Rect) { let popup_area = self.floating_window(parent_area); - if let Some(content) = &mut self.content { - let content_area = Rect { - x: popup_area.x, - y: popup_area.y, - width: popup_area.width, - height: popup_area.height, - }; + let content_area = Rect { + x: popup_area.x, + y: popup_area.y, + width: popup_area.width, + height: popup_area.height, + }; - content.draw(frame, content_area); - } + self.content.draw(frame, content_area); } - // Returns true if the key was processed by this Float. + // Returns true if the floating window is finished. pub fn handle_key_event(&mut self, key: &KeyEvent) -> bool { - if let Some(content) = &mut self.content { - match key.code { - KeyCode::Enter | KeyCode::Char('p') | KeyCode::Esc | KeyCode::Char('q') => { - if content.is_finished() { - self.content = None; - } else { - content.handle_key_event(key); - } - } - _ => { - content.handle_key_event(key); - } + match key.code { + KeyCode::Enter | KeyCode::Char('p') | KeyCode::Esc | KeyCode::Char('q') + if self.content.is_finished() => + { + true } - true - } else { - false + _ => self.content.handle_key_event(key), } } - - pub fn get_content(&self) -> &Option { - &self.content - } - - pub fn set_content(&mut self, content: Option) { - self.content = content; - } } diff --git a/src/floating_text.rs b/src/floating_text.rs index 195ac24c..467ec18f 100644 --- a/src/floating_text.rs +++ b/src/floating_text.rs @@ -1,4 +1,4 @@ -use crate::float::FloatContent; +use crate::{float::FloatContent, running_command::Command}; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ layout::Rect, @@ -7,6 +7,7 @@ use ratatui::{ widgets::{Block, Borders, List}, Frame, }; +use std::path::PathBuf; pub struct FloatingText { text: Vec, @@ -18,6 +19,26 @@ impl FloatingText { Self { text, scroll: 0 } } + pub fn from_command(command: &Command, mut full_path: PathBuf) -> Option { + let lines = match command { + Command::Raw(cmd) => { + // Reconstruct the line breaks and file formatting after the + // 'include_str!()' call in the node + cmd.lines().map(|line| line.to_string()).collect() + } + Command::LocalFile(file_path) => { + full_path.push(file_path); + let file_contents = std::fs::read_to_string(&full_path) + .map_err(|_| format!("File not found: {:?}", &full_path)) + .unwrap(); + file_contents.lines().map(|line| line.to_string()).collect() + } + // If command is a folder, we don't display a preview + Command::None => return None, + }; + Some(Self::new(lines)) + } + fn scroll_down(&mut self) { if self.scroll + 1 < self.text.len() { self.scroll += 1; @@ -64,16 +85,11 @@ impl FloatContent for FloatingText { fn handle_key_event(&mut self, key: &KeyEvent) -> bool { match key.code { - KeyCode::Down | KeyCode::Char('j') => { - self.scroll_down(); - true - } - KeyCode::Up | KeyCode::Char('k') => { - self.scroll_up(); - true - } - _ => false, + KeyCode::Down | KeyCode::Char('j') => self.scroll_down(), + KeyCode::Up | KeyCode::Char('k') => self.scroll_up(), + _ => {} } + false } fn is_finished(&self) -> bool { diff --git a/src/list.rs b/src/list.rs deleted file mode 100644 index e85bd28d..00000000 --- a/src/list.rs +++ /dev/null @@ -1,444 +0,0 @@ -use crate::{float::Float, floating_text::FloatingText, running_command::Command, state::AppState}; -use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; -use ego_tree::{tree, NodeId}; -use ratatui::{ - layout::Rect, - style::{Style, Stylize}, - text::Line, - widgets::{Block, Borders, List, ListState}, - Frame, -}; - -#[derive(Clone)] -struct ListNode { - name: &'static str, - command: Command, -} - -/// This is a data structure that has everything necessary to draw and manage a menu of commands -pub struct CustomList { - /// The tree data structure, to represent regular items - /// and "directories" - inner_tree: ego_tree::Tree, - /// This stack keeps track of our "current dirrectory". You can think of it as `pwd`. but not - /// just the current directory, all paths that took us here, so we can "cd .." - visit_stack: Vec, - /// This is the state asociated with the list widget, used to display the selection in the - /// widget - list_state: ListState, - // This stores the current search query - filter_query: String, - // This stores the filtered tree - filtered_items: Vec, - // This is the preview window for the commands - preview_float: Float, -} - -impl CustomList { - pub fn new() -> Self { - // When a function call ends with an exclamation mark, it means it's a macro, like in this - // case the tree! macro expands to `ego-tree::tree` data structure - let tree = tree!(ListNode { - name: "root", - command: Command::None, - } => { - ListNode { - name: "Applications Setup", - command: Command::None - } => { - ListNode { - name: "Alacritty", - command: Command::LocalFile("applications-setup/alacritty-setup.sh"), - }, - ListNode { - name: "Bash Prompt", - command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/mybash/main/setup.sh)\""), - }, - ListNode { - name: "DWM-Titus", - command: Command::LocalFile("applications-setup/dwmtitus-setup.sh") - }, - ListNode { - name: "Kitty", - command: Command::LocalFile("applications-setup/kitty-setup.sh") - }, - ListNode { - name: "Neovim", - command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/neovim/main/setup.sh)\""), - }, - ListNode { - name: "Rofi", - command: Command::LocalFile("applications-setup/rofi-setup.sh"), - }, - ListNode { - name: "ZSH Prompt", - command: Command::LocalFile("applications-setup/zsh-setup.sh"), - } - - }, - ListNode { - name: "Security", - command: Command::None - } => { - ListNode { - name: "Firewall Baselines (CTT)", - command: Command::LocalFile("security/firewall-baselines.sh"), - } - }, - ListNode { - name: "System Setup", - command: Command::None, - } => { - ListNode { - name: "Build Prerequisites", - command: Command::LocalFile("system-setup/1-compile-setup.sh"), - }, - ListNode { - name: "Gaming Dependencies", - command: Command::LocalFile("system-setup/2-gaming-setup.sh"), - }, - ListNode { - name: "Global Theme", - command: Command::LocalFile("system-setup/3-global-theme.sh"), - }, - ListNode { - name: "Remove Snaps", - command: Command::LocalFile("system-setup/4-remove-snaps.sh"), - }, - }, - ListNode { - name: "Utilities", - command: Command::None - } => { - ListNode { - name: "Wifi Manager", - command: Command::LocalFile("utils/wifi-control.sh"), - }, - ListNode { - name: "Bluetooth Manager", - command: Command::LocalFile("utils/bluetooth-control.sh"), - }, - ListNode { - name: "MonitorControl(xorg)", - command: Command::None, - } => { - ListNode { - name: "Set Resolution", - command: Command::LocalFile("utils/monitor-control/set_resolutions.sh"), - }, - ListNode { - name: "Duplicate Displays", - command: Command::LocalFile("utils/monitor-control/duplicate_displays.sh"), - }, - ListNode { - name: "Extend Displays", - command: Command::LocalFile("utils/monitor-control/extend_displays.sh"), - }, - ListNode { - name: "Auto Detect Displays", - command: Command::LocalFile("utils/monitor-control/auto_detect_displays.sh"), - }, - ListNode { - name: "Enable Monitor", - command: Command::LocalFile("utils/monitor-control/enable_monitor.sh"), - }, - ListNode { - name: "Disable Monitor", - command: Command::LocalFile("utils/monitor-control/disable_monitor.sh"), - }, - ListNode { - name: "Set Primary Monitor", - command: Command::LocalFile("utils/monitor-control/set_primary_monitor.sh"), - }, - ListNode { - name: "Change Orientation", - command: Command::LocalFile("utils/monitor-control/change_orientation.sh"), - }, - ListNode { - name: "Manage Arrangement", - command: Command::LocalFile("utils/monitor-control/manage_arrangement.sh"), - }, - ListNode { - name: "Scale Monitors", - command: Command::LocalFile("utils/monitor-control/scale_monitor.sh"), - }, - ListNode { - name: "Reset Scaling", - command: Command::LocalFile("utils/monitor-control/reset_scaling.sh"), - }, - }, - }, - ListNode { - name: "Full System Update", - command: Command::LocalFile("system-update.sh"), - }, - }); - // We don't get a reference, but rather an id, because references are siginficantly more - // paintfull to manage - let root_id = tree.root().id(); - Self { - inner_tree: tree, - visit_stack: vec![root_id], - list_state: ListState::default().with_selected(Some(0)), - filter_query: String::new(), - filtered_items: vec![], - preview_float: Float::new(80, 80), - } - } - - /// Draw our custom widget to the frame - pub fn draw(&mut self, frame: &mut Frame, area: Rect, state: &AppState) { - let item_list: Vec = if self.filter_query.is_empty() { - let mut items: Vec = vec![]; - // If we are not at the root of our filesystem tree, we need to add `..` path, to be able - // to go up the tree - // icons:   - if !self.at_root() { - items.push( - Line::from(format!("{} ..", state.theme.dir_icon)) - .style(state.theme.dir_color), - ); - } - - // Get the last element in the `visit_stack` vec - let curr = self - .inner_tree - .get(*self.visit_stack.last().unwrap()) - .unwrap(); - - // Iterate through all the children - for node in curr.children() { - // The difference between a "directory" and a "command" is simple: if it has children, - // it's a directory and will be handled as such - if node.has_children() { - items.push( - Line::from(format!("{} {}", state.theme.dir_icon, node.value().name)) - .style(state.theme.dir_color), - ); - } else { - items.push( - Line::from(format!("{} {}", state.theme.cmd_icon, node.value().name)) - .style(state.theme.cmd_color), - ); - } - } - items - } else { - self.filtered_items - .iter() - .map(|node| { - Line::from(format!("{} {}", state.theme.cmd_icon, node.name)) - .style(state.theme.cmd_color) - }) - .collect() - }; - - // create the normal list widget containing only item in our "working directory" / tree - // node - let list = List::new(item_list) - .highlight_style(Style::default().reversed()) - .block(Block::default().borders(Borders::ALL).title(format!( - "Linux Toolbox - {}", - chrono::Local::now().format("%Y-%m-%d") - ))) - .scroll_padding(1); - - // Render it - frame.render_stateful_widget(list, area, &mut self.list_state); - - //Render the preview window - self.preview_float.draw(frame, area); - } - - pub fn filter(&mut self, query: String) { - self.filter_query.clone_from(&query); - self.filtered_items.clear(); - - let query_lower = query.to_lowercase(); - - let mut stack = vec![self.inner_tree.root().id()]; - - while let Some(node_id) = stack.pop() { - let node = self.inner_tree.get(node_id).unwrap(); - - if node.value().name.to_lowercase().contains(&query_lower) && !node.has_children() { - self.filtered_items.push(node.value().clone()); - } - - for child in node.children() { - stack.push(child.id()); - } - } - self.filtered_items.sort_by(|a, b| a.name.cmp(b.name)); - } - - /// Resets the selection to the first item - pub fn reset_selection(&mut self) { - if !self.filtered_items.is_empty() { - self.list_state.select(Some(0)); - } else { - self.list_state.select(None); - } - } - - /// Handle key events, we are only interested in `Press` and `Repeat` events - pub fn handle_key(&mut self, event: KeyEvent, state: &AppState) -> Option { - if event.kind == KeyEventKind::Release { - return None; - } - - if self.preview_float.handle_key_event(&event) { - return None; // If the key event was handled by the preview, don't propagate it further - } - - match event.code { - // Damm you Up arrow, use vim lol - KeyCode::Char('j') | KeyCode::Down => { - self.list_state.select_next(); - None - } - KeyCode::Char('k') | KeyCode::Up => { - self.list_state.select_previous(); - None - } - KeyCode::Char('p') => { - self.toggle_preview_window(state); - None - } - - KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => { - if self.preview_float.get_content().is_none() { - self.handle_enter() - } else { - None - } - } - KeyCode::Left | KeyCode::Char('h') if !self.at_root() => self.enter_parent_directory(), - _ => None, - } - } - - fn get_selected_command(&self) -> Option { - let selected_index = self.list_state.selected().unwrap_or(0); - - if self.filter_query.is_empty() { - // No filter query, use the regular tree navigation - let curr = self - .inner_tree - .get(*self.visit_stack.last().unwrap()) - .unwrap(); - - if !self.at_root() && selected_index == 0 { - return None; - } - - let mut actual_index = selected_index; - if !self.at_root() { - actual_index -= 1; // Adjust for the ".." item if not at root - } - - for (idx, node) in curr.children().enumerate() { - if idx == actual_index { - return Some(node.value().command.clone()); - } - } - } else { - // Filter query is active, use the filtered items - if let Some(filtered_node) = self.filtered_items.get(selected_index) { - return Some(filtered_node.command.clone()); - } - } - - None - } - - fn enter_parent_directory(&mut self) -> Option { - self.visit_stack.pop(); - self.list_state.select(Some(0)); - None - } - - /// Handles the key. This key can do 3 things: - /// - Run a command, if it is the currently selected item, - /// - Go up a directory - /// - Go down into a directory - /// - /// Returns `Some(command)` when command is selected, othervise we returns `None` - fn handle_enter(&mut self) -> Option { - let selected_index = self.list_state.selected().unwrap_or(0); - - if self.filter_query.is_empty() { - // No filter query, use the regular tree navigation - let curr = self - .inner_tree - .get(*self.visit_stack.last().unwrap()) - .unwrap(); - - if !self.at_root() && selected_index == 0 { - return self.enter_parent_directory(); - } - - let mut actual_index = selected_index; - if !self.at_root() { - actual_index -= 1; // Adjust for the ".." item if not at root - } - - for (idx, node) in curr.children().enumerate() { - if idx == actual_index { - if node.has_children() { - self.visit_stack.push(node.id()); - self.list_state.select(Some(0)); - return None; - } else { - return Some(node.value().command.clone()); - } - } - } - } else { - // Filter query is active, use the filtered items - if let Some(filtered_node) = self.filtered_items.get(selected_index) { - return Some(filtered_node.command.clone()); - } - } - - None - } - - fn toggle_preview_window(&mut self, state: &AppState) { - if self.preview_float.get_content().is_some() { - // If the preview window is active, disable it - self.preview_float.set_content(None); - } else { - // If the preview window is not active, show it - - // Get the selected command - if let Some(selected_command) = self.get_selected_command() { - let lines = match selected_command { - Command::Raw(cmd) => cmd.lines().map(|line| line.to_string()).collect(), - Command::LocalFile(file_path) => { - if file_path.is_empty() { - return; - } - let mut full_path = state.temp_path.clone(); - full_path.push(file_path); - let file_contents = std::fs::read_to_string(&full_path) - .map_err(|_| format!("File not found: {:?}", &full_path)) - .unwrap(); - file_contents.lines().map(|line| line.to_string()).collect() - } - Command::None => return, - }; - - self.preview_float - .set_content(Some(FloatingText::new(lines))); - } - } - } - - /// Checks weather the current tree node is the root node (can we go up the tree or no) - /// Returns `true` if we can't go up the tree (we are at the tree root) - /// else returns `false` - fn at_root(&self) -> bool { - self.visit_stack.len() == 1 - } -} diff --git a/src/main.rs b/src/main.rs index f7ae28ca..7502b61b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ mod float; mod floating_text; -mod list; mod running_command; pub mod state; +mod tabs; mod theme; use std::{ @@ -13,23 +13,16 @@ use std::{ use clap::Parser; use crossterm::{ cursor::RestorePosition, - event::{self, DisableMouseCapture, Event, KeyCode, KeyEventKind}, + event::{self, DisableMouseCapture, Event, KeyEventKind}, style::ResetColor, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; -use float::Float; use include_dir::include_dir; -use list::CustomList; use ratatui::{ backend::{Backend, CrosstermBackend}, - layout::{Constraint, Direction, Layout}, - style::{Color, Style}, - text::Span, - widgets::{Block, Borders, Paragraph}, Terminal, }; -use running_command::RunningCommand; use state::AppState; use tempdir::TempDir; use theme::THEMES; @@ -56,17 +49,14 @@ fn main() -> std::io::Result<()> { .extract(temp_dir.path()) .expect("Failed to extract the saved directory"); - let state = AppState { - theme, - temp_path: temp_dir.path().to_owned(), - }; + let mut state = AppState::new(theme, temp_dir.path().to_owned()); stdout().execute(EnterAlternateScreen)?; enable_raw_mode()?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; terminal.clear()?; - run(&mut terminal, &state)?; + run(&mut terminal, &mut state)?; // restore terminal disable_raw_mode()?; @@ -78,55 +68,9 @@ fn main() -> std::io::Result<()> { Ok(()) } -fn run(terminal: &mut Terminal, state: &AppState) -> io::Result<()> { - //Create the search field - let mut search_input = String::new(); - //Create the command list - let mut custom_list = CustomList::new(); - //Create the float to hold command output - let mut command_float = Float::new(60, 60); - let mut in_search_mode = false; - +fn run(terminal: &mut Terminal, state: &mut AppState) -> io::Result<()> { loop { - // Always redraw - terminal - .draw(|frame| { - //Split the terminal into 2 vertical chunks - //One for the search bar and one for the command list - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(1)].as_ref()) - .split(frame.size()); - - //Set the search bar text (If empty use the placeholder) - let display_text = if search_input.is_empty() { - if in_search_mode { - Span::raw("") - } else { - Span::raw("Press / to search") - } - } else { - Span::raw(&search_input) - }; - - //Create the search bar widget - let mut search_bar = Paragraph::new(display_text) - .block(Block::default().borders(Borders::ALL).title("Search")) - .style(Style::default().fg(Color::DarkGray)); - - //Change the color if in search mode - if in_search_mode { - search_bar = search_bar.clone().style(Style::default().fg(Color::Blue)); - } - - //Render the search bar (First chunk of the screen) - frame.render_widget(search_bar, chunks[0]); - //Render the command list (Second chunk of the screen) - custom_list.draw(frame, chunks[1], state); - //Render the command float in the custom_list chunk - command_float.draw(frame, chunks[1]); - }) - .unwrap(); + terminal.draw(|frame| state.draw(frame)).unwrap(); // Wait for an event if !event::poll(Duration::from_millis(10))? { @@ -141,47 +85,8 @@ fn run(terminal: &mut Terminal, state: &AppState) -> io::Result<( continue; } - //Send the key to the float - //If we receive true, then the float processed the input - //If that's the case, don't propagate input to other widgets - if !command_float.handle_key_event(&key) { - //Insert user input into the search bar - if in_search_mode { - match key.code { - KeyCode::Char(c) => { - search_input.push(c); - custom_list.filter(search_input.clone()); - } - KeyCode::Backspace => { - search_input.pop(); - custom_list.filter(search_input.clone()); - } - KeyCode::Esc => { - search_input = String::new(); - custom_list.filter(search_input.clone()); - in_search_mode = false - } - KeyCode::Enter => { - in_search_mode = false; - custom_list.reset_selection(); - } - _ => {} - } - } else if let Some(cmd) = custom_list.handle_key(key, state) { - command_float.set_content(Some(RunningCommand::new(cmd, state))); - } else { - // Handle keys while not in search mode - match key.code { - // Exit the program - KeyCode::Char('q') => return Ok(()), - //Activate search mode if the forward slash key gets pressed - KeyCode::Char('/') => { - in_search_mode = true; - continue; - } - _ => {} - } - } + if !state.handle_key(&key) { + return Ok(()); } } } diff --git a/src/running_command.rs b/src/running_command.rs index 06aa5b91..d07c0428 100644 --- a/src/running_command.rs +++ b/src/running_command.rs @@ -1,4 +1,4 @@ -use crate::{float::FloatContent, state::AppState}; +use crate::float::FloatContent; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use oneshot::{channel, Receiver}; use portable_pty::{ @@ -13,6 +13,7 @@ use ratatui::{ }; use std::{ io::Write, + path::Path, sync::{Arc, Mutex}, thread::JoinHandle, }; @@ -125,7 +126,7 @@ impl FloatContent for RunningCommand { } impl RunningCommand { - pub fn new(command: Command, state: &AppState) -> Self { + pub fn new(command: Command, temp_path: &Path) -> Self { let pty_system = NativePtySystem::default(); // Build the command based on the provided Command enum variant @@ -141,7 +142,7 @@ impl RunningCommand { Command::None => panic!("Command::None was treated as a command"), } - cmd.cwd(&state.temp_path); + cmd.cwd(temp_path); // Open a pseudo-terminal with initial size let pair = pty_system diff --git a/src/state.rs b/src/state.rs index 459e7d30..5f8ef1a6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,9 +1,326 @@ -use crate::theme::Theme; +use crate::{ + float::{Float, FloatContent}, + floating_text::FloatingText, + running_command::{Command, RunningCommand}, + tabs::{ListNode, TABS}, + theme::Theme, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; +use ego_tree::NodeId; +use ratatui::{ + layout::{Constraint, Direction, Layout}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListState, Paragraph}, + Frame, +}; use std::path::PathBuf; pub struct AppState { /// Selected theme - pub theme: Theme, + theme: Theme, /// Path to the root of the unpacked files in /tmp - pub temp_path: PathBuf, + temp_path: PathBuf, + /// Currently focused area + focus: Focus, + /// Current tab + current_tab: ListState, + /// Current search query + search_query: String, + /// Current items + items: Vec, + /// This stack keeps track of our "current dirrectory". You can think of it as `pwd`. but not + /// just the current directory, all paths that took us here, so we can "cd .." + visit_stack: Vec, + /// This is the state asociated with the list widget, used to display the selection in the + /// widget + selection: ListState, +} + +pub enum Focus { + Search, + TabList, + List, + FloatingWindow(Float), +} + +struct ListEntry { + node: ListNode, + id: NodeId, + has_children: bool, +} + +impl AppState { + pub fn new(theme: Theme, temp_path: PathBuf) -> Self { + let root_id = TABS[0].tree.root().id(); + let mut state = Self { + theme, + temp_path, + focus: Focus::List, + current_tab: ListState::default().with_selected(Some(0)), + search_query: String::new(), + items: vec![], + visit_stack: vec![root_id], + selection: ListState::default().with_selected(Some(0)), + }; + state.update_items(); + state + } + pub fn draw(&mut self, frame: &mut Frame) { + let longest_tab_display_len = TABS + .iter() + .map(|tab| tab.name.len() + self.theme.tab_icon.len()) + .max() + .unwrap_or(0); + + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(longest_tab_display_len as u16 + 5), + Constraint::Percentage(100), + ]) + .split(frame.size()); + let left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(1)]) + .split(horizontal[0]); + + let tabs = TABS.iter().map(|tab| tab.name).collect::>(); + + let tab_hl_style = if let Focus::TabList = self.focus { + Style::default().reversed().fg(self.theme.tab_color) + } else { + Style::new().fg(self.theme.tab_color) + }; + + let list = List::new(tabs) + .block(Block::default().borders(Borders::ALL)) + .highlight_style(tab_hl_style) + .highlight_symbol(self.theme.tab_icon); + frame.render_stateful_widget(list, left_chunks[1], &mut self.current_tab); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(1)].as_ref()) + .split(horizontal[1]); + + // Render search bar + let search_text = match self.focus { + Focus::Search => Span::raw(&self.search_query), + _ if !self.search_query.is_empty() => Span::raw(&self.search_query), + _ => Span::raw("Press / to search"), + }; + let search_bar = Paragraph::new(search_text) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(if let Focus::Search = self.focus { + Color::Blue + } else { + Color::DarkGray + })); + frame.render_widget(search_bar, chunks[0]); + + let mut items: Vec = Vec::new(); + if !self.at_root() { + items.push( + Line::from(format!("{} ..", self.theme.dir_icon)).style(self.theme.dir_color), + ); + } + + items.extend(self.items.iter().map( + |ListEntry { + node, has_children, .. + }| { + if *has_children { + Line::from(format!("{} {}", self.theme.dir_icon, node.name)) + .style(self.theme.dir_color) + } else { + Line::from(format!("{} {}", self.theme.cmd_icon, node.name)) + .style(self.theme.cmd_color) + } + }, + )); + + // Create the list widget with items + let list = List::new(items) + .highlight_style(if let Focus::List = self.focus { + Style::default().reversed() + } else { + Style::new() + }) + .block(Block::default().borders(Borders::ALL).title(format!( + "Linux Toolbox - {}", + chrono::Local::now().format("%Y-%m-%d") + ))) + .scroll_padding(1); + frame.render_stateful_widget(list, chunks[1], &mut self.selection); + + if let Focus::FloatingWindow(float) = &mut self.focus { + float.draw(frame, chunks[1]); + } + } + pub fn handle_key(&mut self, key: &KeyEvent) -> bool { + match &mut self.focus { + Focus::FloatingWindow(command) => { + if command.handle_key_event(key) { + self.focus = Focus::List; + } + } + Focus::Search => { + match key.code { + KeyCode::Char(c) => self.search_query.push(c), + KeyCode::Backspace => { + self.search_query.pop(); + } + KeyCode::Esc => { + self.search_query = String::new(); + self.exit_search(); + } + KeyCode::Enter => self.exit_search(), + _ => return true, + } + self.update_items(); + } + _ if key.code == KeyCode::Char('q') => return false, + Focus::TabList => match key.code { + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => { + self.focus = Focus::List + } + KeyCode::Char('j') | KeyCode::Down + if self.current_tab.selected().unwrap() + 1 < TABS.len() => + { + self.current_tab.select_next(); + self.refresh_tab(); + } + KeyCode::Char('k') | KeyCode::Up => { + self.current_tab.select_previous(); + self.refresh_tab(); + } + KeyCode::Char('/') => self.enter_search(), + _ => {} + }, + Focus::List if key.kind != KeyEventKind::Release => match key.code { + KeyCode::Char('j') | KeyCode::Down => self.selection.select_next(), + KeyCode::Char('k') | KeyCode::Up => self.selection.select_previous(), + KeyCode::Char('p') => self.enable_preview(), + KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => self.handle_enter(), + KeyCode::Char('h') | KeyCode::Left => { + if self.at_root() { + self.focus = Focus::TabList; + } else { + self.enter_parent_directory(); + } + } + KeyCode::Char('/') => self.enter_search(), + KeyCode::Tab => self.focus = Focus::TabList, + _ => {} + }, + _ => {} + }; + true + } + pub fn update_items(&mut self) { + if self.search_query.is_empty() { + let curr = TABS[self.current_tab.selected().unwrap()] + .tree + .get(*self.visit_stack.last().unwrap()) + .unwrap(); + + self.items = curr + .children() + .map(|node| ListEntry { + node: node.value().clone(), + id: node.id(), + has_children: node.has_children(), + }) + .collect(); + } else { + self.items.clear(); + + let query_lower = self.search_query.to_lowercase(); + for tab in TABS.iter() { + let mut stack = vec![tab.tree.root().id()]; + while let Some(node_id) = stack.pop() { + let node = tab.tree.get(node_id).unwrap(); + + if node.value().name.to_lowercase().contains(&query_lower) + && !node.has_children() + { + self.items.push(ListEntry { + node: node.value().clone(), + id: node.id(), + has_children: false, + }); + } + + stack.extend(node.children().map(|child| child.id())); + } + } + self.items.sort_by(|a, b| a.node.name.cmp(b.node.name)); + } + } + /// Checks ehther the current tree node is the root node (can we go up the tree or no) + /// Returns `true` if we can't go up the tree (we are at the tree root) + /// else returns `false` + fn at_root(&self) -> bool { + self.visit_stack.len() == 1 + } + fn enter_parent_directory(&mut self) { + self.visit_stack.pop(); + self.selection.select(Some(0)); + self.update_items(); + } + fn get_selected_command(&mut self, change_directory: bool) -> Option { + let mut selected_index = self.selection.selected().unwrap_or(0); + + if !self.at_root() && selected_index == 0 { + if change_directory { + self.enter_parent_directory(); + } + return None; + } + if !self.at_root() { + selected_index = selected_index.saturating_sub(1); + } + + if let Some(item) = self.items.get(selected_index) { + if !item.has_children { + return Some(item.node.command.clone()); + } else if change_directory { + self.visit_stack.push(item.id); + self.selection.select(Some(0)); + self.update_items(); + } + } + None + } + fn enable_preview(&mut self) { + if let Some(command) = self.get_selected_command(false) { + if let Some(preview) = FloatingText::from_command(&command, self.temp_path.clone()) { + self.spawn_float(preview, 80, 80); + } + } + } + fn handle_enter(&mut self) { + if let Some(cmd) = self.get_selected_command(true) { + let command = RunningCommand::new(cmd, &self.temp_path); + self.spawn_float(command, 80, 80); + } + } + fn spawn_float(&mut self, float: T, width: u16, height: u16) { + self.focus = Focus::FloatingWindow(Float::new(Box::new(float), width, height)); + } + fn enter_search(&mut self) { + self.focus = Focus::Search; + self.selection.select(None); + } + fn exit_search(&mut self) { + self.selection.select(Some(0)); + self.focus = Focus::List; + self.update_items(); + } + fn refresh_tab(&mut self) { + self.visit_stack = vec![TABS[self.current_tab.selected().unwrap()].tree.root().id()]; + self.selection.select(Some(0)); + self.update_items(); + } } diff --git a/src/tabs.rs b/src/tabs.rs new file mode 100644 index 00000000..e7ef5d35 --- /dev/null +++ b/src/tabs.rs @@ -0,0 +1,162 @@ +use std::sync::LazyLock; + +use ego_tree::{tree, Tree}; + +use crate::running_command::Command; + +pub struct Tab { + pub name: &'static str, + pub tree: Tree, +} + +#[derive(Clone)] +pub struct ListNode { + pub name: &'static str, + pub command: Command, +} + +pub static TABS: LazyLock> = LazyLock::new(|| { + vec![ + Tab { + name: "System Setup", + tree: tree!(ListNode { + name: "root", + command: Command::None, + } => { + ListNode { + name: "Full System Update", + command: Command::LocalFile("system-update.sh"), + }, + ListNode { + name: "Build Prerequisites", + command: Command::LocalFile("system-setup/1-compile-setup.sh"), + }, + ListNode { + name: "Gaming Dependencies", + command: Command::LocalFile("system-setup/2-gaming-setup.sh"), + }, + ListNode { + name: "Global Theme", + command: Command::LocalFile("system-setup/3-global-theme.sh"), + }, + ListNode { + name: "Remove Snaps", + command: Command::LocalFile("system-setup/4-remove-snaps.sh"), + } + }), + }, + Tab { + name: "Applications Setup", + tree: tree!(ListNode { + name: "root", + command: Command::None, + } => { + ListNode { + name: "Alacritty", + command: Command::LocalFile("applications-setup/alacritty-setup.sh"), + }, + ListNode { + name: "Bash Prompt", + command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/mybash/main/setup.sh)\""), + }, + ListNode { + name: "DWM-Titus", + command: Command::LocalFile("applications-setup/dwmtitus-setup.sh") + }, + ListNode { + name: "Kitty", + command: Command::LocalFile("applications-setup/kitty-setup.sh") + }, + ListNode { + name: "Neovim", + command: Command::Raw("bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/neovim/main/setup.sh)\""), + }, + ListNode { + name: "Rofi", + command: Command::LocalFile("applications-setup/rofi-setup.sh"), + }, + ListNode { + name: "ZSH Prompt", + command: Command::LocalFile("applications-setup/zsh-setup.sh"), + } + }), + }, + Tab { + name: "Security", + tree: tree!(ListNode { + name: "root", + command: Command::None, + } => { + ListNode { + name: "Firewall Baselines (CTT)", + command: Command::LocalFile("security/firewall-baselines.sh"), + } + }), + }, + Tab { + name: "Utilities", + tree: tree!(ListNode { + name: "root", + command: Command::None, + } => { + ListNode { + name: "Wifi Manager", + command: Command::LocalFile("utils/wifi-control.sh"), + }, + ListNode { + name: "Bluetooth Manager", + command: Command::LocalFile("utils/bluetooth-control.sh"), + }, + ListNode { + name: "MonitorControl(xorg)", + command: Command::None, + } => { + ListNode { + name: "Set Resolution", + command: Command::LocalFile("utils/monitor-control/set_resolutions.sh"), + }, + ListNode { + name: "Duplicate Displays", + command: Command::LocalFile("utils/monitor-control/duplicate_displays.sh"), + }, + ListNode { + name: "Extend Displays", + command: Command::LocalFile("utils/monitor-control/extend_displays.sh"), + }, + ListNode { + name: "Auto Detect Displays", + command: Command::LocalFile("utils/monitor-control/auto_detect_displays.sh"), + }, + ListNode { + name: "Enable Monitor", + command: Command::LocalFile("utils/monitor-control/enable_monitor.sh"), + }, + ListNode { + name: "Disable Monitor", + command: Command::LocalFile("utils/monitor-control/disable_monitor.sh"), + }, + ListNode { + name: "Set Primary Monitor", + command: Command::LocalFile("utils/monitor-control/set_primary_monitor.sh"), + }, + ListNode { + name: "Change Orientation", + command: Command::LocalFile("utils/monitor-control/change_orientation.sh"), + }, + ListNode { + name: "Manage Arrangement", + command: Command::LocalFile("utils/monitor-control/manage_arrangement.sh"), + }, + ListNode { + name: "Scale Monitors", + command: Command::LocalFile("utils/monitor-control/scale_monitor.sh"), + }, + ListNode { + name: "Reset Scaling", + command: Command::LocalFile("utils/monitor-control/reset_scaling.sh"), + } + }, + }), + }, + ] +}); diff --git a/src/theme.rs b/src/theme.rs index 4792cddc..149dbece 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -4,8 +4,10 @@ use ratatui::style::Color; pub struct Theme { pub dir_color: Color, pub cmd_color: Color, + pub tab_color: Color, pub dir_icon: &'static str, pub cmd_icon: &'static str, + pub tab_icon: &'static str, pub success_color: Color, pub fail_color: Color, } @@ -14,16 +16,20 @@ pub const THEMES: [Theme; 2] = [ Theme { dir_color: Color::Blue, cmd_color: Color::LightGreen, + tab_color: Color::Yellow, dir_icon: "[DIR]", cmd_icon: "[CMD]", + tab_icon: ">> ", success_color: Color::Green, fail_color: Color::Red, }, Theme { dir_color: Color::Blue, cmd_color: Color::Rgb(204, 224, 208), + tab_color: Color::Rgb(255, 255, 85), dir_icon: "  ", cmd_icon: "  ", + tab_icon: " ", fail_color: Color::Rgb(199, 55, 44), success_color: Color::Rgb(5, 255, 55), },