diff --git a/Cargo.lock b/Cargo.lock index c05becb..e09f1cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -443,6 +443,7 @@ dependencies = [ "crossterm", "ratatui", "serde", + "tui-input", ] [[package]] @@ -616,6 +617,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tui-input" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d1733c47f1a217b7deff18730ff7ca4ecafc5771368f715ab072d679a36114" +dependencies = [ + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "unicode-ident" version = "1.0.14" diff --git a/Cargo.toml b/Cargo.toml index 052477b..fdd7d35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ anyhow = "1.0" bincode = "1.3" ratatui = "0.29" crossterm = "0.28" +tui-input = "0.11" [dependencies.serde] version = "1.0" diff --git a/src/tui.rs b/src/tui.rs index f2b0a50..963011e 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -4,10 +4,11 @@ use std::time::Duration; use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEvent}; -use ratatui::layout::Constraint; +use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style}; -use ratatui::widgets::{Block, Borders, Cell, Row, Table, TableState}; +use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}; use ratatui::Frame; +use tui_input::{Input, InputRequest}; use crate::state::{clear_selected_project_file, write_selected_project_file}; use crate::Projects; @@ -20,13 +21,27 @@ enum Message { MoveDown, MoveUp, Confirm, + Search, + SearchUpdate, + ExitSearch, +} + + +#[derive(Debug, Default)] +enum Mode { + #[default] + Normal, + Search, } struct State { projects: Projects, + search: Input, + mode: Mode, should_exit: bool, project_table: TableState, + filtered_projects: Vec, selected_project: Option, } @@ -36,8 +51,11 @@ impl State { Self { projects, + search: Input::default(), + mode: Mode::default(), should_exit: false, project_table, + filtered_projects: Vec::new(), selected_project: None, } } @@ -66,7 +84,7 @@ pub fn run(projects: Projects) -> Result<()> { } if let Event::Key(key_event) = event::read()? { - handle_event(&mut msg_tx, key_event); + handle_event(&mut state, &mut msg_tx, key_event); } } @@ -80,12 +98,25 @@ pub fn run(projects: Projects) -> Result<()> { } -fn handle_event(tx: &mut mpsc::Sender, event: KeyEvent) { - let msg = match event.code { - KeyCode::Char('q') | KeyCode::Esc => Message::Exit, - KeyCode::Char(' ') | KeyCode::Enter => Message::Confirm, - KeyCode::Char('j') | KeyCode::Down => Message::MoveDown, - KeyCode::Char('k') | KeyCode::Up => Message::MoveUp, +fn handle_event(state: &mut State, tx: &mut mpsc::Sender, event: KeyEvent) { + let msg = match (event.code, &state.mode) { + (KeyCode::Char('q') | KeyCode::Esc, Mode::Normal) => Message::Exit, + (KeyCode::Char('j') | KeyCode::Down, Mode::Normal) => Message::MoveDown, + (KeyCode::Char('k') | KeyCode::Up, Mode::Normal) => Message::MoveUp, + (KeyCode::Char('/'), Mode::Normal) => Message::Search, + + (KeyCode::Esc, Mode::Search) => Message::ExitSearch, + (KeyCode::Char(c), Mode::Search) => { + state.search.handle(InputRequest::InsertChar(c)); + Message::SearchUpdate + } + (KeyCode::Backspace, Mode::Search) => { + state.search.handle(InputRequest::DeletePrevChar); + Message::SearchUpdate + } + + (KeyCode::Enter, _) => Message::Confirm, + _ => Message::Noop, }; @@ -118,12 +149,22 @@ fn handle_messages(state: &mut State, rx: &mut mpsc::Receiver) -> Resul } Message::Confirm => { if let Some(selected) = state.project_table.selected() { - if let Some(project_path) = state.projects.list.get(selected) { + if let Some(project_path) = state.filtered_projects.get(selected) { state.should_exit = true; - state.selected_project = Some(project_path.clone()); + state.selected_project = Some(PathBuf::from(project_path)); }; } } + Message::Search => { + state.mode = Mode::Search; + } + Message::ExitSearch => { + state.search = Input::default(); + state.mode = Mode::Normal; + } + Message::SearchUpdate => { + state.project_table.select_first(); + } _ => (), } @@ -133,19 +174,73 @@ fn handle_messages(state: &mut State, rx: &mut mpsc::Receiver) -> Resul fn draw(state: &mut State, frame: &mut Frame) { let block = Block::default().borders(Borders::ALL); + frame.render_widget(&block, frame.area()); - let rows = state.projects.list.iter().map(|project| { - let project_path = project.to_str().unwrap(); - Row::new([Cell::new(project_path)]) - }); + match state.mode { + Mode::Normal => { + let layout = Layout::vertical([Constraint::Fill(1)]); + let inner_area = block.inner(frame.area()); + let layout_rects = layout.split(inner_area); + draw_list(state, frame, layout_rects[0]); + } + Mode::Search => { + let layout = Layout::vertical([Constraint::Fill(1), Constraint::Max(2)]); + let inner_area = block.inner(frame.area()); + let layout_rects = layout.split(inner_area); + draw_list(state, frame, layout_rects[0]); + draw_search(state, frame, layout_rects[1]); + } + } +} + + +fn draw_search(state: &mut State, frame: &mut Frame, area: Rect) { + const PROMPT_PREFIX: &str = "Search: "; + + let search_input = &state.search; + + let scroll_offset = (0, search_input.visual_scroll(area.width as usize) as u16); + + let prompt = format!("{PROMPT_PREFIX}{}", search_input.value()); + let search = Paragraph::new(prompt).scroll(scroll_offset); + frame.render_widget(search, area); + + let cursor_pos = ( + area.x + (search_input.visual_cursor() + PROMPT_PREFIX.len()) as u16, + area.y, + ); + frame.set_cursor_position(cursor_pos) +} + + +fn draw_list(state: &mut State, frame: &mut Frame, area: Rect) { + state.filtered_projects = state + .projects + .list + .iter() + .filter_map(|project| { + let path_str = project.to_str().expect("invalid path string"); + + let search_value = state.search.value(); + + if path_str.contains(search_value) { + Some(path_str.to_string()) + } else { + None + } + }) + .collect(); + + let rows = state + .filtered_projects + .iter() + .map(|path| Row::new([Cell::new(path.as_str())])); let widths = [Constraint::Min(20)]; let table_highlight = Style::default().fg(Color::Blue); - let table = Table::new(rows, widths) - .block(block) - .row_highlight_style(table_highlight); + let table = Table::new(rows, widths).row_highlight_style(table_highlight); - frame.render_stateful_widget(table, frame.area(), &mut state.project_table); + frame.render_stateful_widget(table, area, &mut state.project_table); }