diff --git a/README.md b/README.md index 9502e16..63f4c06 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ NOTE: `ig` respects `ripgrep`'s [configuration file](https://github.com/BurntSus | `dw` | Filter out all matches in current file | | `v` | Toggle vertical context viewer | | `s` | Toggle horizontal context viewer | -| `F5` | Re-run search | +| `F5` | Open search pattern popup | ## Supported text editors `igrep` supports Vim, Neovim, nano, VS Code (stable and insiders), Emacs, EmacsClient, Helix, SublimeText, Micro, Intellij, Goland and Pycharm. If your beloved editor is missing on this list and you still want to use `igrep` please file an issue. diff --git a/src/ig/mod.rs b/src/ig/mod.rs index 13a67fe..ea8461c 100644 --- a/src/ig/mod.rs +++ b/src/ig/mod.rs @@ -7,7 +7,7 @@ use crate::{ ui::{editor::Editor, result_list::ResultList}, }; pub use search_config::SearchConfig; -use searcher::{Event, Searcher}; +use searcher::Event; use std::io; use std::process::ExitStatus; use std::sync::mpsc; @@ -22,20 +22,20 @@ pub enum State { } pub struct Ig { + tx: mpsc::Sender, rx: mpsc::Receiver, state: State, - searcher: Searcher, editor: Editor, } impl Ig { - pub fn new(config: SearchConfig, editor: Editor) -> Self { + pub fn new(editor: Editor) -> Self { let (tx, rx) = mpsc::channel(); Self { + tx, rx, state: State::Idle, - searcher: Searcher::new(config, tx), editor, } } @@ -73,11 +73,11 @@ impl Ig { None } - pub fn search(&mut self, result_list: &mut ResultList) { + pub fn search(&mut self, search_config: SearchConfig, result_list: &mut ResultList) { if self.state == State::Idle { *result_list = ResultList::default(); self.state = State::Searching; - self.searcher.search(); + searcher::search(search_config, self.tx.clone()); } } diff --git a/src/ig/searcher.rs b/src/ig/searcher.rs index a75a2ef..0136528 100644 --- a/src/ig/searcher.rs +++ b/src/ig/searcher.rs @@ -14,93 +14,81 @@ pub enum Event { Error, } -pub struct Searcher { - config: SearchConfig, - tx: mpsc::Sender, -} - -impl Searcher { - pub fn new(config: SearchConfig, tx: mpsc::Sender) -> Self { - Self { config, tx } - } - - pub fn search(&self) { - let tx = self.tx.clone(); - let config = self.config.clone(); - let paths = self.config.paths.clone(); - std::thread::spawn(move || { - let path_searchers = paths - .into_iter() - .map(|path| { - let config = config.clone(); - let tx = tx.clone(); - std::thread::spawn(move || Self::run(&path, config, tx)) - }) - .collect::>(); +pub fn search(config: SearchConfig, tx: mpsc::Sender) { + std::thread::spawn(move || { + let path_searchers = config + .paths + .clone() + .into_iter() + .map(|path| { + let config = config.clone(); + let tx = tx.clone(); + std::thread::spawn(move || run(&path, config, tx)) + }) + .collect::>(); - for searcher in path_searchers { - if searcher.join().is_err() { - tx.send(Event::Error).ok(); - return; - } + for searcher in path_searchers { + if searcher.join().is_err() { + tx.send(Event::Error).ok(); + return; } + } - tx.send(Event::SearchingFinished).ok(); - }); - } + tx.send(Event::SearchingFinished).ok(); + }); +} - fn run(path: &Path, config: SearchConfig, tx: mpsc::Sender) { - let grep_searcher = SearcherBuilder::new() - .binary_detection(BinaryDetection::quit(b'\x00')) - .line_terminator(LineTerminator::byte(b'\n')) - .line_number(true) - .multi_line(false) - .build(); +fn run(path: &Path, config: SearchConfig, tx: mpsc::Sender) { + let grep_searcher = SearcherBuilder::new() + .binary_detection(BinaryDetection::quit(b'\x00')) + .line_terminator(LineTerminator::byte(b'\n')) + .line_number(true) + .multi_line(false) + .build(); - let matcher = RegexMatcherBuilder::new() - .line_terminator(Some(b'\n')) - .case_insensitive(config.case_insensitive) - .case_smart(config.case_smart) - .build(&config.pattern) - .expect("Cannot build RegexMatcher"); - let mut builder = WalkBuilder::new(path); + let matcher = RegexMatcherBuilder::new() + .line_terminator(Some(b'\n')) + .case_insensitive(config.case_insensitive) + .case_smart(config.case_smart) + .build(&config.pattern) + .expect("Cannot build RegexMatcher"); + let mut builder = WalkBuilder::new(path); - let walk_parallel = builder - .overrides(config.overrides.clone()) - .types(config.types.clone()) - .hidden(!config.search_hidden) - .build_parallel(); - walk_parallel.run(move || { - let tx = tx.clone(); - let matcher = matcher.clone(); - let mut grep_searcher = grep_searcher.clone(); + let walk_parallel = builder + .overrides(config.overrides.clone()) + .types(config.types.clone()) + .hidden(!config.search_hidden) + .build_parallel(); + walk_parallel.run(move || { + let tx = tx.clone(); + let matcher = matcher.clone(); + let mut grep_searcher = grep_searcher.clone(); - Box::new(move |result| { - let dir_entry = match result { - Ok(entry) => { - if !entry.file_type().map_or(false, |ft| ft.is_file()) { - return ignore::WalkState::Continue; - } - entry + Box::new(move |result| { + let dir_entry = match result { + Ok(entry) => { + if !entry.file_type().map_or(false, |ft| ft.is_file()) { + return ignore::WalkState::Continue; } - Err(_) => return ignore::WalkState::Continue, - }; - let mut matches_in_entry = Vec::new(); - let sr = MatchesSink::new(&matcher, &mut matches_in_entry); - grep_searcher - .search_path(&matcher, dir_entry.path(), sr) - .ok(); - - if !matches_in_entry.is_empty() { - tx.send(Event::NewEntry(FileEntry::new( - dir_entry.path().to_string_lossy().into_owned(), - matches_in_entry, - ))) - .ok(); + entry } + Err(_) => return ignore::WalkState::Continue, + }; + let mut matches_in_entry = Vec::new(); + let sr = MatchesSink::new(&matcher, &mut matches_in_entry); + grep_searcher + .search_path(&matcher, dir_entry.path(), sr) + .ok(); - ignore::WalkState::Continue - }) - }); - } + if !matches_in_entry.is_empty() { + tx.send(Event::NewEntry(FileEntry::new( + dir_entry.path().to_string_lossy().into_owned(), + matches_in_entry, + ))) + .ok(); + } + + ignore::WalkState::Continue + }) + }); } diff --git a/src/ui/app.rs b/src/ui/app.rs index 0bdd12e..9f2cf1b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4,6 +4,7 @@ use super::{ input_handler::{InputHandler, InputState}, result_list::ResultList, scroll_offset_list::{List, ListItem, ListState, ScrollOffset}, + search_popup::SearchPopup, theme::Theme, }; @@ -29,27 +30,32 @@ use ratatui::{ use std::path::PathBuf; pub struct App { + search_config: SearchConfig, ig: Ig, result_list: ResultList, result_list_state: ListState, context_viewer_state: ContextViewerState, theme: Box, + search_popup: SearchPopup, } impl App { - pub fn new(config: SearchConfig, editor: Editor, theme: Box) -> Self { + pub fn new(search_config: SearchConfig, editor: Editor, theme: Box) -> Self { Self { - ig: Ig::new(config, editor), + search_config, + ig: Ig::new(editor), result_list: ResultList::default(), result_list_state: ListState::default(), context_viewer_state: ContextViewerState::default(), theme, + search_popup: SearchPopup::default(), } } pub fn run(&mut self) -> Result<()> { let mut input_handler = InputHandler::default(); - self.ig.search(&mut self.result_list); + self.ig + .search(self.search_config.clone(), &mut self.result_list); loop { let backend = CrosstermBackend::new(std::io::stdout()); @@ -133,11 +139,15 @@ impl App { }; Self::draw_list(frame, list_area, app); + if let Some(cv_area) = cv_area { Self::draw_context_viewer(frame, cv_area, app); } Self::draw_bottom_bar(frame, bottom_bar_area, app, input_handler); + + app.search_popup + .draw(frame, app.theme.search_popup_border()); } fn draw_list(frame: &mut Frame>, area: Rect, app: &mut App) { @@ -385,12 +395,29 @@ impl Application for App { } fn on_search(&mut self) { - self.ig.search(&mut self.result_list); + let pattern = self.search_popup.get_pattern(); + self.search_config.pattern = pattern; + self.ig + .search(self.search_config.clone(), &mut self.result_list); } fn on_exit(&mut self) { self.ig.exit(); } + + fn on_toggle_popup(&mut self) { + self.search_popup + .set_pattern(self.search_config.pattern.clone()); + self.search_popup.toggle(); + } + + fn on_char_inserted(&mut self, c: char) { + self.search_popup.insert_char(c); + } + + fn on_char_removed(&mut self) { + self.search_popup.remove_char(); + } } #[cfg_attr(test, mockall::automock)] @@ -409,4 +436,7 @@ pub trait Application { fn on_open_file(&mut self); fn on_search(&mut self); fn on_exit(&mut self); + fn on_toggle_popup(&mut self); + fn on_char_inserted(&mut self, c: char); + fn on_char_removed(&mut self); } diff --git a/src/ui/input_handler.rs b/src/ui/input_handler.rs index 874d8df..7c4fadd 100644 --- a/src/ui/input_handler.rs +++ b/src/ui/input_handler.rs @@ -7,6 +7,7 @@ use std::time::Duration; pub struct InputHandler { input_buffer: String, input_state: InputState, + text_insertion: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -28,16 +29,10 @@ impl InputHandler { if poll(poll_timeout)? { let read_event = read()?; if let Event::Key(key_event) = read_event { - match key_event { - KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - } => app.on_exit(), - KeyEvent { - code: KeyCode::Char(character), - .. - } => self.handle_char_input(character, app), - _ => self.handle_non_char_input(key_event.code, app), + if !self.text_insertion { + self.handle_key_in_normal_mode(key_event, app); + } else { + self.handle_key_in_text_insertion_mode(key_event, app); } } } @@ -45,6 +40,66 @@ impl InputHandler { Ok(()) } + fn handle_key_in_normal_mode(&mut self, key_event: KeyEvent, app: &mut A) { + match key_event { + KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + } => app.on_exit(), + KeyEvent { + code: KeyCode::Char(character), + .. + } => self.handle_char_input(character, app), + _ => self.handle_non_char_input(key_event.code, app), + } + } + + fn handle_key_in_text_insertion_mode( + &mut self, + key_event: KeyEvent, + app: &mut A, + ) { + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } + | KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + } + | KeyEvent { + code: KeyCode::F(5), + .. + } => { + self.text_insertion = false; + app.on_toggle_popup(); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers: modifier, + } => { + if modifier == KeyModifiers::SHIFT { + app.on_char_inserted(c.to_ascii_uppercase()); + } else if modifier == KeyModifiers::NONE { + app.on_char_inserted(c); + } + } + KeyEvent { + code: KeyCode::Backspace, + .. + } => app.on_char_removed(), + KeyEvent { + code: KeyCode::Enter, + .. + } => { + self.text_insertion = false; + app.on_search(); + app.on_toggle_popup(); + } + _ => (), + } + } + fn handle_char_input(&mut self, character: char, app: &mut A) { self.input_buffer.push(character); self.input_state = InputState::Valid; @@ -99,7 +154,10 @@ impl InputHandler { KeyCode::End => app.on_bottom(), KeyCode::Delete => app.on_remove_current_entry(), KeyCode::Enter => app.on_open_file(), - KeyCode::F(5) => app.on_search(), + KeyCode::F(5) => { + self.text_insertion = true; + app.on_toggle_popup(); + } KeyCode::Esc => { if matches!(self.input_state, InputState::Valid) || matches!(self.input_state, InputState::Invalid(_)) @@ -247,7 +305,7 @@ mod tests { #[test] fn search() { let mut app_mock = MockApplication::default(); - app_mock.expect_on_search().once().return_const(()); + app_mock.expect_on_toggle_popup().once().return_const(()); handle_key(KeyCode::F(5), &mut app_mock); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 28ab5e7..2047f4f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -7,4 +7,5 @@ pub mod result_list; mod context_viewer; mod input_handler; mod scroll_offset_list; +mod search_popup; pub mod theme; diff --git a/src/ui/search_popup.rs b/src/ui/search_popup.rs new file mode 100644 index 0000000..ca89d94 --- /dev/null +++ b/src/ui/search_popup.rs @@ -0,0 +1,105 @@ +use ratatui::{ + backend::CrosstermBackend, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::Style, + text::{Line, Text}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +#[derive(Default)] +pub struct SearchPopup { + visible: bool, + pattern: String, +} + +impl SearchPopup { + pub fn toggle(&mut self) { + self.visible = !self.visible; + } + + pub fn set_pattern(&mut self, pattern: String) { + self.pattern = pattern; + } + + pub fn get_pattern(&self) -> String { + self.pattern.clone() + } + + pub fn insert_char(&mut self, c: char) { + self.pattern.push(c); + } + + pub fn remove_char(&mut self) { + self.pattern.pop(); + } + + pub fn draw(&self, frame: &mut Frame>, style: Style) { + if !self.visible { + return; + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(style) + .title("Regex Pattern") + .title_alignment(Alignment::Center); + let popup_area = Self::get_popup_area(frame.size(), 50); + frame.render_widget(Clear, popup_area); + + frame.render_widget(block, popup_area); + + let mut text_area = popup_area; + text_area.y += 1; // one line below the border + text_area.x += 2; // two chars to the right + + let max_text_width = text_area.width as usize - 4; + let pattern = if self.pattern.len() > max_text_width { + format!( + "…{}", + &self.pattern[self.pattern.len() - max_text_width + 1..] + ) + } else { + self.pattern.clone() + }; + + let text = Text::from(Line::from(pattern.as_str())); + let pattern_text = Paragraph::new(text); + frame.render_widget(pattern_text, text_area); + frame.set_cursor( + std::cmp::min( + text_area.x + pattern.len() as u16, + text_area.x + text_area.width - 4, + ), + text_area.y, + ); + } + + fn get_popup_area(frame_size: Rect, width_percent: u16) -> Rect { + const POPUP_HEIGHT: u16 = 3; + let top_bottom_margin = (frame_size.height - POPUP_HEIGHT) / 2; + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(top_bottom_margin), + Constraint::Length(POPUP_HEIGHT), + Constraint::Length(top_bottom_margin), + ] + .as_ref(), + ) + .split(frame_size); + + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage((100 - width_percent) / 2), + Constraint::Percentage(width_percent), + Constraint::Percentage((100 - width_percent) / 2), + ] + .as_ref(), + ) + .split(popup_layout[1])[1] + } +} diff --git a/src/ui/theme/mod.rs b/src/ui/theme/mod.rs index b3895c9..afc6ee1 100644 --- a/src/ui/theme/mod.rs +++ b/src/ui/theme/mod.rs @@ -78,4 +78,9 @@ pub trait Theme { fn invalid_input_color(&self) -> Color { Color::Red } + + // Search popup style + fn search_popup_border(&self) -> Style { + Style::default().fg(Color::Green) + } }