From 5da07951036c17a9f03f38aa15a99b210fbd4c61 Mon Sep 17 00:00:00 2001
From: Patrick Auernig <patrick.auernig@mykolab.com>
Date: Wed, 27 Nov 2024 18:08:04 +0100
Subject: [PATCH] feat(tui): Implement simple search function

---
 Cargo.lock |  11 +++++
 Cargo.toml |   1 +
 src/tui.rs | 133 +++++++++++++++++++++++++++++++++++++++++++++--------
 3 files changed, 126 insertions(+), 19 deletions(-)

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<String>,
     selected_project: Option<PathBuf>,
 }
 
@@ -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<Message>, 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<Message>, 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<Message>) -> 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<Message>) -> 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);
 }