diff --git a/Cargo.lock b/Cargo.lock index 142e1b61..dc59c8e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -971,6 +971,7 @@ dependencies = [ "tempdir", "toml", "tui-term", + "unicode-width", "which", ] diff --git a/Cargo.toml b/Cargo.toml index 03a8205c..8c06863d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ tempdir = "0.3.7" serde = { version = "1.0.205", features = ["derive"] } toml = "0.8.19" which = "6.0.2" +unicode-width = "0.1.13" [[bin]] name = "linutil" diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 00000000..0a17056d --- /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 FilterInstance { + search_input: Vec, + in_search_mode: bool, + input_position: usize, + items: Vec, +} + +impl FilterInstance { + 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(&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/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 66f99972..bbfbcbd0 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,4 +1,5 @@ use crate::{ + filter::{FilterInstance, 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: FilterInstance, } 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: FilterInstance::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(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, .. }| { @@ -171,21 +155,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 => { @@ -228,45 +202,12 @@ impl AppState { }; 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) @@ -292,7 +233,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 { @@ -321,11 +262,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) {