diff --git a/Cargo.lock b/Cargo.lock index 40e82069..a87ca14f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -882,6 +882,7 @@ dependencies = [ "tempdir", "toml", "tui-term", + "unicode-width", "which", ] diff --git a/Cargo.toml b/Cargo.toml index 77ca0ab4..95704db2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,10 @@ tempdir = "0.3.7" serde = { version = "1.0.205", features = ["derive"] } toml = "0.8.19" which = "6.0.3" +unicode-width = "0.1.13" + +[build-dependencies] +chrono = "0.4.33" [[bin]] name = "linutil" diff --git a/build.rs b/build.rs index dc9c06dd..e6e6600f 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,9 @@ fn main() { // Rebuild program if any file in commands directory changes. println!("cargo:rerun-if-changed=src/commands"); + // Add current date as a variable to be displayed in the 'Linux Toolbox' text. + println!( + "cargo:rustc-env=BUILD_DATE={}", + chrono::Local::now().format("%Y-%m-%d") + ); } diff --git a/build/linutil b/build/linutil index 6ce6e242..1a346201 100755 Binary files a/build/linutil and b/build/linutil differ diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 00000000..3e90af8f --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,160 @@ +use crate::{state::ListEntry, tabs::Tab, theme::Theme}; +use crossterm::event::{KeyCode, KeyEvent}; +use ego_tree::NodeId; +use ratatui::{ + layout::Rect, + style::Style, + text::Span, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use unicode_width::UnicodeWidthChar; + +pub enum SearchAction { + None, + Exit, + Update, +} + +pub struct Filter { + search_input: Vec, + in_search_mode: bool, + input_position: usize, + items: Vec, +} + +impl Filter { + pub fn new() -> Self { + Self { + search_input: vec![], + in_search_mode: false, + input_position: 0, + items: vec![], + } + } + pub fn item_list(&self) -> &[ListEntry] { + &self.items + } + pub fn activate_search(&mut self) { + self.in_search_mode = true; + } + pub fn deactivate_search(&mut self) { + self.in_search_mode = false; + } + pub fn update_items(&mut self, tabs: &[Tab], current_tab: usize, node: NodeId) { + if self.search_input.is_empty() { + let curr = tabs[current_tab].tree.get(node).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_input.iter().collect::().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)); + } + } + pub fn draw_searchbar(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + //Set the search bar text (If empty use the placeholder) + let display_text = if !self.in_search_mode && self.search_input.is_empty() { + Span::raw("Press / to search") + } else { + Span::raw(self.search_input.iter().collect::()) + }; + + let search_color = if self.in_search_mode { + theme.focused_color() + } else { + theme.unfocused_color() + }; + + //Create the search bar widget + let search_bar = Paragraph::new(display_text) + .block(Block::default().borders(Borders::ALL).title("Search")) + .style(Style::default().fg(search_color)); + + //Render the search bar (First chunk of the screen) + frame.render_widget(search_bar, area); + + // Render cursor in search bar + if self.in_search_mode { + let cursor_position: usize = self.search_input[..self.input_position] + .iter() + .map(|c| c.width().unwrap_or(1)) + .sum(); + let x = area.x + cursor_position as u16 + 1; + let y = area.y + 1; + frame.set_cursor(x, y); + } + } + // Handles key events. Returns true if search must be exited + pub fn handle_key(&mut self, event: &KeyEvent) -> SearchAction { + //Insert user input into the search bar + match event.code { + KeyCode::Char(c) => self.insert_char(c), + KeyCode::Backspace => self.remove_previous(), + KeyCode::Delete => self.remove_next(), + KeyCode::Left => return self.cursor_left(), + KeyCode::Right => return self.cursor_right(), + KeyCode::Esc => { + self.input_position = 0; + self.search_input.clear(); + return SearchAction::Exit; + } + KeyCode::Enter => return SearchAction::Exit, + _ => return SearchAction::None, + }; + SearchAction::Update + } + fn cursor_left(&mut self) -> SearchAction { + self.input_position = self.input_position.saturating_sub(1); + SearchAction::None + } + fn cursor_right(&mut self) -> SearchAction { + if self.input_position < self.search_input.len() { + self.input_position += 1; + } + SearchAction::None + } + fn insert_char(&mut self, input: char) { + self.search_input.insert(self.input_position, input); + self.cursor_right(); + } + fn remove_previous(&mut self) { + let current = self.input_position; + if current > 0 { + self.search_input.remove(current - 1); + self.cursor_left(); + } + } + fn remove_next(&mut self) { + let current = self.input_position; + if current < self.search_input.len() { + self.search_input.remove(current); + } + } +} diff --git a/src/floating_text.rs b/src/floating_text.rs index 1dc8520e..482a8822 100644 --- a/src/floating_text.rs +++ b/src/floating_text.rs @@ -68,8 +68,18 @@ impl FloatContent for FloatingText { .text .iter() .skip(self.scroll) + .flat_map(|line| { + if line.is_empty() { + return vec![String::new()]; + } + line.chars() + .collect::>() + .chunks(inner_area.width as usize) + .map(|chunk| chunk.iter().collect()) + .collect::>() + }) .take(inner_area.height as usize) - .map(|line| Line::from(line.as_str())) + .map(Line::from) .collect(); // Create list widget diff --git a/src/main.rs b/src/main.rs index 0f3fb38f..85c0278f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod filter; mod float; mod floating_text; mod running_command; diff --git a/src/search.rs b/src/search.rs deleted file mode 100644 index 68827c24..00000000 --- a/src/search.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - layout::Rect, - style::Style, - text::Span, - widgets::{Block, Borders, Paragraph}, - Frame, -}; - -use crate::state::AppState; - -pub struct SearchBar { - search_input: String, - in_search_mode: bool, -} - -impl SearchBar { - pub fn new() -> Self { - SearchBar { - search_input: String::new(), - in_search_mode: false, - } - } - - pub fn activate_search(&mut self) { - self.in_search_mode = true; - } - - pub fn deactivate_search(&mut self) { - self.in_search_mode = false; - } - - pub fn is_search_active(&self) -> bool { - self.in_search_mode - } - - pub fn draw(&self, frame: &mut Frame, area: Rect, state: &AppState) { - //Set the search bar text (If empty use the placeholder) - let display_text = if !self.in_search_mode && self.search_input.is_empty() { - Span::raw("Press / to search") - } else { - Span::raw(&self.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(state.theme.unfocused_color)); - - //Change the color if in search mode - if self.in_search_mode { - search_bar = search_bar - .clone() - .style(Style::default().fg(state.theme.focused_color)); - } - - //Render the search bar (First chunk of the screen) - frame.render_widget(search_bar, area); - } - - pub fn handle_key(&mut self, event: KeyEvent) -> String { - //Insert user input into the search bar - match event.code { - KeyCode::Char(c) => { - self.search_input.push(c); - } - KeyCode::Backspace => { - self.search_input.pop(); - } - KeyCode::Esc => { - self.search_input = String::new(); - self.in_search_mode = false; - } - KeyCode::Enter => { - self.in_search_mode = false; - } - _ => {} - } - self.search_input.clone() - } -} diff --git a/src/state.rs b/src/state.rs index 6c597445..432a5af2 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,4 +1,5 @@ use crate::{ + filter::{Filter, SearchAction}, float::{Float, FloatContent}, floating_text::FloatingText, running_command::{Command, RunningCommand}, @@ -9,9 +10,9 @@ 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}, + style::{Style, Stylize}, + text::Line, + widgets::{Block, Borders, List, ListState}, Frame, }; use std::path::Path; @@ -25,16 +26,13 @@ pub struct AppState { tabs: Vec, /// 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, + filter: Filter, } pub enum Focus { @@ -44,10 +42,10 @@ pub enum Focus { FloatingWindow(Float), } -struct ListEntry { - node: ListNode, - id: NodeId, - has_children: bool, +pub struct ListEntry { + pub node: ListNode, + pub id: NodeId, + pub has_children: bool, } impl AppState { @@ -59,10 +57,9 @@ impl AppState { focus: Focus::List, tabs, 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)), + filter: Filter::new(), }; state.update_items(); state @@ -110,20 +107,7 @@ impl AppState { .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]); + self.filter.draw_searchbar(frame, chunks[0], &self.theme); let mut items: Vec = Vec::new(); if !self.at_root() { @@ -132,7 +116,7 @@ impl AppState { ); } - items.extend(self.items.iter().map( + items.extend(self.filter.item_list().iter().map( |ListEntry { node, has_children, .. }| { @@ -156,7 +140,7 @@ impl AppState { .block( Block::default() .borders(Borders::ALL) - .title(format!("Linux Toolbox")), + .title(format!("Linux Toolbox - {}", env!("BUILD_DATE"))), ) .scroll_padding(1); frame.render_stateful_widget(list, chunks[1], &mut self.selection); @@ -172,21 +156,11 @@ impl AppState { 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(); - } + Focus::Search => match self.filter.handle_key(key) { + SearchAction::Exit => self.exit_search(), + SearchAction::Update => 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 => { @@ -203,8 +177,8 @@ impl AppState { self.refresh_tab(); } KeyCode::Char('/') => self.enter_search(), - KeyCode::Char('t') => self.theme = self.theme.next(), - KeyCode::Char('T') => self.theme = self.theme.prev(), + KeyCode::Char('t') => self.theme.next(), + KeyCode::Char('T') => self.theme.prev(), _ => {} }, Focus::List if key.kind != KeyEventKind::Release => match key.code { @@ -221,53 +195,20 @@ impl AppState { } KeyCode::Char('/') => self.enter_search(), KeyCode::Tab => self.focus = Focus::TabList, - KeyCode::Char('t') => self.theme = self.theme.next(), - KeyCode::Char('T') => self.theme = self.theme.prev(), + KeyCode::Char('t') => self.theme.next(), + KeyCode::Char('T') => self.theme.prev(), _ => {} }, _ => {} }; true } - pub fn update_items(&mut self) { - if self.search_query.is_empty() { - let curr = self.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 self.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)); - } + fn update_items(&mut self) { + self.filter.update_items( + &self.tabs, + self.current_tab.selected().unwrap(), + *self.visit_stack.last().unwrap(), + ); } /// 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) @@ -293,7 +234,7 @@ impl AppState { selected_index = selected_index.saturating_sub(1); } - if let Some(item) = self.items.get(selected_index) { + if let Some(item) = self.filter.item_list().get(selected_index) { if !item.has_children { return Some(item.node.command.clone()); } else if change_directory { @@ -322,11 +263,13 @@ impl AppState { } fn enter_search(&mut self) { self.focus = Focus::Search; + self.filter.activate_search(); self.selection.select(None); } fn exit_search(&mut self) { self.selection.select(Some(0)); self.focus = Focus::List; + self.filter.deactivate_search(); self.update_items(); } fn refresh_tab(&mut self) { diff --git a/src/theme.rs b/src/theme.rs index bb5b6af2..84fa15b4 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -86,17 +86,15 @@ impl Theme { } impl Theme { - #[allow(unused)] - pub fn next(self) -> Self { - let position = self as usize; + pub fn next(&mut self) { + let position = *self as usize; let types = Theme::value_variants(); - types[(position + 1) % types.len()].into() + *self = types[(position + 1) % types.len()]; } - #[allow(unused)] - pub fn prev(self) -> Self { - let position = self as usize; + pub fn prev(&mut self) { + let position = *self as usize; let types = Theme::value_variants(); - types[(position + types.len() - 1) % types.len()].into() + *self = types[(position + types.len() - 1) % types.len()]; } }