From 42014998b4d149137893333783b9231a05c52aa4 Mon Sep 17 00:00:00 2001 From: afonsofrancof Date: Fri, 26 Jul 2024 16:04:34 +0100 Subject: [PATCH 1/5] Added a Search bar and organized command structure --- .../alacritty-setup.sh | 0 .../kitty-setup.sh | 0 .../rofi-setup.sh | 0 src/list.rs | 153 +++++++++++------- src/main.rs | 64 +++++++- 5 files changed, 159 insertions(+), 58 deletions(-) rename src/commands/{dotfiles => applications-setup}/alacritty-setup.sh (100%) rename src/commands/{dotfiles => applications-setup}/kitty-setup.sh (100%) rename src/commands/{dotfiles => applications-setup}/rofi-setup.sh (100%) diff --git a/src/commands/dotfiles/alacritty-setup.sh b/src/commands/applications-setup/alacritty-setup.sh similarity index 100% rename from src/commands/dotfiles/alacritty-setup.sh rename to src/commands/applications-setup/alacritty-setup.sh diff --git a/src/commands/dotfiles/kitty-setup.sh b/src/commands/applications-setup/kitty-setup.sh similarity index 100% rename from src/commands/dotfiles/kitty-setup.sh rename to src/commands/applications-setup/kitty-setup.sh diff --git a/src/commands/dotfiles/rofi-setup.sh b/src/commands/applications-setup/rofi-setup.sh similarity index 100% rename from src/commands/dotfiles/rofi-setup.sh rename to src/commands/applications-setup/rofi-setup.sh diff --git a/src/list.rs b/src/list.rs index b564bdea..7f61f053 100644 --- a/src/list.rs +++ b/src/list.rs @@ -18,6 +18,7 @@ macro_rules! with_common_script { }; } +#[derive(Clone)] struct ListNode { name: &'static str, command: &'static str, @@ -37,6 +38,10 @@ pub struct CustomList { /// This stores the preview windows state. If it is None, it will not be displayed. /// If it is Some, we show it with the content of the selected item preview_window_state: Option, + // This stores the current search query + filter_query: String, + // This stores the filtered tree + filtered_items: Vec, } /// This struct stores the preview window state @@ -62,18 +67,6 @@ impl CustomList { name: "root", command: "" } => { - ListNode { - name: "Full System Update", - command: with_common_script!("commands/system-update.sh"), - }, - ListNode { - name: "Setup Bash Prompt", - command: "bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/mybash/main/setup.sh)\"" - }, - ListNode { - name: "Setup Neovim", - command: "bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/neovim/main/setup.sh)\"" - }, // ListNode { // name: "Just ls, nothing special, trust me", // command: include_str!("commands/special_ls.sh"), @@ -100,22 +93,35 @@ impl CustomList { } }, ListNode { - name: "Titus Dotfiles", + name: "Applications Setup", command: "" } => { ListNode { name: "Alacritty Setup", - command: with_common_script!("commands/dotfiles/alacritty-setup.sh"), + command: with_common_script!("commands/applications-setup/alacritty-setup.sh"), + }, ListNode { name: "Kitty Setup", - command: with_common_script!("commands/dotfiles/kitty-setup.sh"), + command: with_common_script!("commands/applications-setup/kitty-setup.sh"), + }, + ListNode { + name: "Bash Prompt Setup", + command: "bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/mybash/main/setup.sh)\"" + }, + ListNode { + name: "Neovim Setup", + command: "bash -c \"$(curl -s https://raw.githubusercontent.com/ChrisTitusTech/neovim/main/setup.sh)\"" }, ListNode { name: "Rofi Setup", - command: with_common_script!("commands/dotfiles/rofi-setup.sh"), + command: with_common_script!("commands/applications-setup/rofi-setup.sh"), }, - } + }, + ListNode { + name: "Full System Update", + command: with_common_script!("commands/system-update.sh"), + }, }); // We don't get a reference, but rather an id, because references are siginficantly more // paintfull to manage @@ -126,46 +132,59 @@ impl CustomList { list_state: ListState::default().with_selected(Some(0)), // By default the PreviewWindowState is set to None, so it is not being shown preview_window_state: None, + filter_query: String::new(), + filtered_items: vec![], } } - + /// Draw our custom widget to the frame - pub fn draw(&mut self, frame: &mut Frame, area: Rect) { - // Get the last element in the `visit_stack` vec + pub fn draw(&mut self, frame: &mut Frame, area: Rect, filter: String) { let theme = get_theme(); - let curr = self - .inner_tree - .get(*self.visit_stack.last().unwrap()) - .unwrap(); - let mut items = vec![]; + self.filter(filter); - // 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!("{} ..", theme.dir_icon)).style(theme.dir_color)); - } - - // 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!("{} {}", theme.dir_icon, node.value().name)) - .style(theme.dir_color), - ); - } else { - items.push( - Line::from(format!("{} {}", theme.cmd_icon, node.value().name)) - .style(theme.cmd_color), - ); + 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!("{} ..", theme.dir_icon)).style(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!("{} {}", theme.dir_icon, node.value().name)) + .style(theme.dir_color), + ); + } else { + items.push( + Line::from(format!("{} {}", theme.cmd_icon, node.value().name)) + .style(theme.cmd_color), + ); + } + } + items + } else { + self.filtered_items + .iter() + .map(|node| { + Line::from(format!("{} {}", theme.cmd_icon, node.name)).style(theme.cmd_color) + }) + .collect() + }; // create the normal list widget containing only item in our "working directory" / tree // node - let list = List::new(items) + let list = List::new(item_list) .highlight_style(Style::default().reversed()) .block(Block::default().borders(Borders::ALL).title(format!( "Linux Toolbox - {}", @@ -204,6 +223,26 @@ impl CustomList { } } + 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()); + } + } + } + /// Handle key events, we are only interested in `Press` and `Repeat` events pub fn handle_key(&mut self, event: KeyEvent) -> Option<&'static str> { if event.kind == KeyEventKind::Release { @@ -268,18 +307,22 @@ impl CustomList { } } } + fn try_scroll_up(&mut self) { self.list_state .select(Some(self.list_state.selected().unwrap().saturating_sub(1))); } + fn try_scroll_down(&mut self) { - let curr = self - .inner_tree - .get(*self.visit_stack.last().unwrap()) - .unwrap(); - - let count = curr.children().count(); - + let count = if self.filter_query.is_empty() { + let curr = self + .inner_tree + .get(*self.visit_stack.last().unwrap()) + .unwrap(); + curr.children().count() + } else { + self.filtered_items.len() + }; let curr_selection = self.list_state.selected().unwrap(); if self.at_root() { self.list_state diff --git a/src/main.rs b/src/main.rs index 7acfcfb0..8f474e7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,10 @@ use crossterm::{ 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; @@ -57,13 +61,47 @@ fn main() -> std::io::Result<()> { fn run(terminal: &mut Terminal) -> io::Result<()> { let mut command_opt: Option = None; - let mut custom_list = CustomList::new(); + let mut search_input = String::new(); + let mut in_search_mode = false; + loop { // Always redraw terminal .draw(|frame| { - custom_list.draw(frame, frame.size()); + //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], search_input.clone()); + if let Some(ref mut command) = &mut command_opt { command.draw(frame); } @@ -90,7 +128,27 @@ fn run(terminal: &mut Terminal) -> io::Result<()> { if key.code == KeyCode::Char('q') { return Ok(()); } - if let Some(cmd) = custom_list.handle_key(key) { + //Activate search mode if the forward slash key gets pressed + if key.code == KeyCode::Char('/') { + // Enter search mode + in_search_mode = true; + continue; + } + //Insert user input into the search bar + if in_search_mode { + match key.code { + KeyCode::Char(c) => search_input.push(c), + KeyCode::Backspace => { + search_input.pop(); + } + KeyCode::Esc => { + search_input = String::new(); + in_search_mode = false + } + KeyCode::Enter => in_search_mode = false, + _ => {} + } + } else if let Some(cmd) = custom_list.handle_key(key) { command_opt = Some(RunningCommand::new(cmd)); } } From 3e8c81c92fbe1ea6d1b9a3c75c55076cdede05aa Mon Sep 17 00:00:00 2001 From: afonsofrancof Date: Fri, 26 Jul 2024 16:13:29 +0100 Subject: [PATCH 2/5] Fix one empty line (damn you cargo fmt check) --- src/list.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/list.rs b/src/list.rs index 7f61f053..26b761df 100644 --- a/src/list.rs +++ b/src/list.rs @@ -136,7 +136,7 @@ impl CustomList { filtered_items: vec![], } } - + /// Draw our custom widget to the frame pub fn draw(&mut self, frame: &mut Frame, area: Rect, filter: String) { let theme = get_theme(); From 0ca4f09dbc7981077d90dd7131a77972113ad193 Mon Sep 17 00:00:00 2001 From: afonsofrancof Date: Tue, 30 Jul 2024 21:52:12 +0100 Subject: [PATCH 3/5] Merged with #82, still need testing --- src/list.rs | 62 ++++++++++++++++++++++++++++++++--------------------- src/main.rs | 24 +++++++++++++++++++-- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/src/list.rs b/src/list.rs index 60564d83..3d7453d2 100644 --- a/src/list.rs +++ b/src/list.rs @@ -9,6 +9,7 @@ use ratatui::{ Frame, }; +#[derive(Clone)] struct ListNode { name: &'static str, command: Command, @@ -161,7 +162,8 @@ impl CustomList { self.filtered_items .iter() .map(|node| { - Line::from(format!("{} {}", state.theme.cmd_icon, node.value().name)).style(state.theme.cmd_color) + Line::from(format!("{} {}", state.theme.cmd_icon, node.name)) + .style(state.theme.cmd_color) }) .collect() }; @@ -313,6 +315,7 @@ impl CustomList { } else { self.filtered_items.len() }; + let curr_selection = self.list_state.selected().unwrap(); if self.at_root() { self.list_state @@ -378,35 +381,44 @@ impl CustomList { /// /// Returns `Some(command)` when command is selected, othervise we returns `None` fn handle_enter(&mut self) -> Option { - // Get the current node (current directory) - let curr = self - .inner_tree - .get(*self.visit_stack.last().unwrap()) - .unwrap(); + // Get the selected index let selected = self.list_state.selected().unwrap(); - // if we are not at the root, and the first element is selected, - // we can be sure it's '..', so we go up the directory - if !self.at_root() && selected == 0 { - self.visit_stack.pop(); - self.list_state.select(Some(0)); - return None; - } + 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(); - for (mut idx, node) in curr.children().enumerate() { - // at this point, we know that we are not on the .. item, and our indexes of the items never had .. - // item. so to balance it out, in case the selection index contains .., se add 1 to our node index - if !self.at_root() { - idx += 1; + // if we are not at the root, and the first element is selected, + // we can be sure it's '..', so we go up the directory + if !self.at_root() && selected == 0 { + self.visit_stack.pop(); + self.list_state.select(Some(0)); + return None; } - if idx == selected { - 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()); + + for (mut idx, node) in curr.children().enumerate() { + // at this point, we know that we are not on the .. item, and our indexes of the items never had .. + // item. so to balance it out, in case the selection index contains .., se add 1 to our node index + if !self.at_root() { + idx += 1; } + if idx == selected { + 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) { + return Some(filtered_node.command.clone()); } } None diff --git a/src/main.rs b/src/main.rs index 350c4584..c1122acb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -117,7 +117,7 @@ fn run(terminal: &mut Terminal, state: &AppState) -> io::Result<( //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], search_input.clone(),state); + custom_list.draw(frame, chunks[1], search_input.clone(), state); if let Some(ref mut command) = &mut command_opt { command.draw(frame, state); @@ -145,7 +145,27 @@ fn run(terminal: &mut Terminal, state: &AppState) -> io::Result<( if key.code == KeyCode::Char('q') { return Ok(()); } - if let Some(cmd) = custom_list.handle_key(key, state) { + //Activate search mode if the forward slash key gets pressed + if key.code == KeyCode::Char('/') { + // Enter search mode + in_search_mode = true; + continue; + } + //Insert user input into the search bar + if in_search_mode { + match key.code { + KeyCode::Char(c) => search_input.push(c), + KeyCode::Backspace => { + search_input.pop(); + } + KeyCode::Esc => { + search_input = String::new(); + in_search_mode = false + } + KeyCode::Enter => in_search_mode = false, + _ => {} + } + } else if let Some(cmd) = custom_list.handle_key(key, state) { command_opt = Some(RunningCommand::new(cmd, state)); } } From 71c81b23020204b433892c4a744c53b9305aee12 Mon Sep 17 00:00:00 2001 From: afonsofrancof Date: Tue, 30 Jul 2024 21:57:53 +0100 Subject: [PATCH 4/5] Merge with security folder PR --- src/list.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/list.rs b/src/list.rs index 73ca21a1..3eb200aa 100644 --- a/src/list.rs +++ b/src/list.rs @@ -77,15 +77,15 @@ impl CustomList { }, ListNode { name: "Security", - command: "" + command: Command::None } => { ListNode { name: "Firewall Baselines (CTT)", - command: with_common_script!("commands/security/firewall-baselines.sh"), + command: Command::LocalFile("security/firewall-baselines.sh"), } }, ListNode { - name: "Titus Dotfiles", + name: "Applications Setup", command: Command::None } => { ListNode { From 847e09b63753aeca27efeacff57736c62a889bb5 Mon Sep 17 00:00:00 2001 From: afonsofrancof Date: Tue, 30 Jul 2024 22:12:03 +0100 Subject: [PATCH 5/5] Fix some bugs --- src/list.rs | 65 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/src/list.rs b/src/list.rs index 3eb200aa..0f7b9bdd 100644 --- a/src/list.rs +++ b/src/list.rs @@ -325,15 +325,18 @@ impl CustomList { self.filtered_items.len() }; - let curr_selection = self.list_state.selected().unwrap(); - if self.at_root() { - self.list_state - .select(Some((curr_selection + 1).min(count - 1))); - } else { - // When we are not at the root, we have to account for 1 more "virtual" node, `..`. So - // the count is 1 bigger (select is 0 based, because it's an index) - self.list_state - .select(Some((curr_selection + 1).min(count))); + if let Some(curr_selection) = self.list_state.selected() { + if self.at_root() { + self.list_state + .select(Some((curr_selection + 1).min(count - 1))); + } else { + // When we are not at the root, we have to account for 1 more "virtual" node, `..`. So + // the count is 1 bigger (select is 0 based, because it's an index) + self.list_state + .select(Some((curr_selection + 1).min(count))); + } + } else if count > 0 { + self.list_state.select(Some(0)); } } @@ -355,29 +358,38 @@ impl CustomList { } } - /// This method return the currently selected command, or None if no command is selected. + /// This method returns the currently selected command, or None if no command is selected. /// It was extracted from the 'handle_enter()' /// - /// This could probably be integrated into the 'handle_enter()' method as to avoid code + /// This could probably be integrated into the 'handle_enter()' method to avoid code /// duplication, but I don't want to make too major changes to the codebase. fn get_selected_command(&self) -> Option { - let curr = self - .inner_tree - .get(*self.visit_stack.last().unwrap()) - .unwrap(); let selected = self.list_state.selected().unwrap(); - // If we are not at the root and the first item is selected, it's the `..` item - if !self.at_root() && selected == 0 { - return None; - } + 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(); - for (mut idx, node) in curr.children().enumerate() { - if !self.at_root() { - idx += 1; + // If we are not at the root and the first item is selected, it's the `..` item + if !self.at_root() && selected == 0 { + return None; } - if idx == selected { - return Some(node.value().command.clone()); + + for (mut idx, node) in curr.children().enumerate() { + if !self.at_root() { + idx += 1; + } + if idx == selected { + 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) { + return Some(filtered_node.command.clone()); } } None @@ -390,6 +402,11 @@ impl CustomList { /// /// Returns `Some(command)` when command is selected, othervise we returns `None` fn handle_enter(&mut self) -> Option { + // Ensure an item is selected if none is selected + if self.list_state.selected().is_none() { + self.list_state.select(Some(0)); + } + // Get the selected index let selected = self.list_state.selected().unwrap();