diff --git a/CREDITS b/CREDITS index 7c041bbf..e6fcf7a9 100644 --- a/CREDITS +++ b/CREDITS @@ -992,3 +992,27 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================ + +tui-textarea +https://github.com/rhysd/tui-textarea +---------------------------------------------------------------- +the MIT License +Copyright (c) 2022 rhysd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR +THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================ diff --git a/Cargo.lock b/Cargo.lock index 58ea8aa2..828efff8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,7 @@ dependencies = [ "ratatui", "regex", "tui-term", + "tui-textarea", "uuid", "vt100", ] @@ -722,6 +723,17 @@ dependencies = [ "vt100", ] +[[package]] +name = "tui-textarea" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width", +] + [[package]] name = "unicode-ident" version = "1.0.8" @@ -736,9 +748,9 @@ checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" [[package]] name = "unicode-width" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" [[package]] name = "utf8parse" diff --git a/Cargo.toml b/Cargo.toml index 005ea78d..6789dd3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,3 +19,4 @@ portable-pty = "0.8.1" tui-term = "0.1.6" ratatui = "0.25.0" anyhow = "1.0.78" +tui-textarea = "0.4.0" diff --git a/Makefile b/Makefile index 01780f0e..4df2857e 100644 --- a/Makefile +++ b/Makefile @@ -26,10 +26,6 @@ tools: run: @cargo run -.PHONY: run-ratatui -run-ratatui: - @RUST_BACKTRACE=full cargo run -- -r - .PHONY: build build: @cargo build diff --git a/src/models/makefile.rs b/src/models/makefile.rs index bcd634bc..5849f248 100644 --- a/src/models/makefile.rs +++ b/src/models/makefile.rs @@ -4,7 +4,7 @@ use regex::Regex; use std::path::{Path, PathBuf}; /// Makefile represents a Makefile. -#[derive(Clone)] +#[derive(Clone, Debug, PartialEq)] pub struct Makefile { pub path: PathBuf, include_files: Vec, @@ -96,6 +96,19 @@ impl Makefile { (None, None) } + + #[cfg(test)] + pub fn new_for_test() -> Makefile { + Makefile { + path: Path::new("test").to_path_buf(), + include_files: vec![], + targets: Targets(vec![ + "target0".to_string(), + "target1".to_string(), + "target2".to_string(), + ]), + } + } } /// The path should be relative path from current directory where make command is executed. diff --git a/src/usecase/fzf_make/app.rs b/src/usecase/fzf_make/app.rs index 23d1bb7e..431eaf31 100644 --- a/src/usecase/fzf_make/app.rs +++ b/src/usecase/fzf_make/app.rs @@ -2,9 +2,9 @@ use crate::models::makefile::Makefile; use super::ui::ui; use anyhow::{anyhow, Result}; -use colored::*; +use colored::Colorize; use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture, KeyCode, KeyModifiers}, + event::{DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -19,8 +19,16 @@ use std::{ io::{self, Stderr}, panic, process, }; +use tui_textarea::TextArea; -#[derive(Clone)] +#[derive(Clone, PartialEq, Debug)] +pub enum AppState { + SelectingTarget, + ExecuteTarget(Option), + ShouldQuite, +} + +#[derive(Clone, PartialEq, Debug)] pub enum CurrentPane { Main, History, @@ -39,54 +47,48 @@ impl CurrentPane { enum Message { MoveToNextPane, Quit, - KeyInput(String), - Backspace, // TODO: Delegate to rhysd/tui-textarea - DeleteAll, + SearchTextAreaKeyInput(KeyEvent), Next, Previous, ExecuteTarget, } -#[derive(Clone)] -pub struct Model { +#[derive(Clone, PartialEq, Debug)] +pub struct Model<'a> { + pub app_state: AppState, pub current_pane: CurrentPane, - pub key_input: String, pub makefile: Makefile, - // TODO: It is better make `should_quit` like following `quit || notQuuitYe || executeTarget (String)`. - pub should_quit: bool, pub targets_list_state: ListState, - pub selected_target: Option, + pub search_text_area: TextArea_<'a>, } -impl Model { +#[derive(Clone, Debug)] +pub struct TextArea_<'a>(pub TextArea<'a>); + +impl<'a> PartialEq for TextArea_<'a> { + // for testing + fn eq(&self, other: &Self) -> bool { + self.0.lines().join("") == other.0.lines().join("") + } +} + +impl Model<'_> { pub fn new() -> Result { let makefile = match Makefile::create_makefile() { Err(e) => return Err(e), Ok(f) => f, }; - Ok(Model { - key_input: String::new(), + app_state: AppState::SelectingTarget, current_pane: CurrentPane::Main, - should_quit: false, makefile: makefile.clone(), targets_list_state: ListState::with_selected(ListState::default(), Some(0)), - selected_target: None, + search_text_area: TextArea_(TextArea::default()), }) } - pub fn update_key_input(&self, key_input: String) -> String { - self.key_input.clone() + &key_input - } - - pub fn pop(&self) -> String { - let mut origin = self.key_input.clone(); - origin.pop(); - origin - } - pub fn narrow_down_targets(&self) -> Vec { - if self.key_input.is_empty() { + if self.search_text_area.0.is_empty() { return self.makefile.to_targets_string(); } @@ -96,7 +98,7 @@ impl Model { .to_targets_string() .into_iter() .map(|target| { - let mut key_input = self.key_input.clone(); + let mut key_input = self.search_text_area.0.lines().join(""); key_input.retain(|c| !c.is_whitespace()); match matcher.fuzzy_indices(&target, key_input.as_str()) { Some((score, _)) => (Some(score), target), @@ -143,12 +145,33 @@ impl Model { self.targets_list_state.select(Some(i)); } - fn reset(&mut self) { + fn reset_selection(&mut self) { if self.makefile.to_targets_string().is_empty() { self.targets_list_state.select(None); } self.targets_list_state.select(Some(0)); } + + fn selected_target(&self) -> Option { + self.targets_list_state + .selected() + .map(|i| self.narrow_down_targets()[i].clone()) + } + + pub fn should_quit(&self) -> bool { + self.app_state == AppState::ShouldQuite + } + + pub fn is_target_selected(&self) -> bool { + matches!(self.app_state, AppState::ExecuteTarget(Some(_))) + } + + pub fn target_to_execute(&self) -> Option { + match self.app_state.clone() { + AppState::ExecuteTarget(Some(target)) => Some(target.clone()), + _ => None, + } + } } pub fn main() -> Result<()> { @@ -211,42 +234,37 @@ fn run(terminal: &mut Terminal, mut model: Model) -> Result { update(&mut model, message); - if model.should_quit || model.selected_target.is_some() { + if model.should_quit() || model.is_target_selected() { break; } } Err(_) => break, } } - Ok(model.selected_target) + Ok(model.target_to_execute()) } fn handle_event(model: &Model) -> io::Result> { let message = if crossterm::event::poll(std::time::Duration::from_millis(2000))? { if let crossterm::event::Event::Key(key) = crossterm::event::read()? { - match key.code { - KeyCode::Tab => Some(Message::MoveToNextPane), - KeyCode::Esc => Some(Message::Quit), - _ => match model.current_pane { - CurrentPane::Main => match key.code { - KeyCode::Backspace => Some(Message::Backspace), - KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Some(Message::Backspace) - } - KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Some(Message::DeleteAll) - } - KeyCode::Down => Some(Message::Next), - KeyCode::Up => Some(Message::Previous), - KeyCode::Enter => Some(Message::ExecuteTarget), - KeyCode::Char(char) => Some(Message::KeyInput(char.to_string())), - _ => None, - }, - CurrentPane::History => match key.code { - KeyCode::Char('q') => Some(Message::Quit), - _ => None, + match model.app_state { + AppState::SelectingTarget => match key.code { + KeyCode::Tab => Some(Message::MoveToNextPane), + KeyCode::Esc => Some(Message::Quit), + _ => match model.current_pane { + CurrentPane::Main => match key.code { + KeyCode::Down => Some(Message::Next), + KeyCode::Up => Some(Message::Previous), + KeyCode::Enter => Some(Message::ExecuteTarget), + _ => Some(Message::SearchTextAreaKeyInput(key)), + }, + CurrentPane::History => match key.code { + KeyCode::Char('q') => Some(Message::Quit), + _ => None, + }, }, }, + _ => None, } } else { return Ok(None); @@ -257,27 +275,23 @@ fn handle_event(model: &Model) -> io::Result> { Ok(message) } -// TODO: Add UT fn update(model: &mut Model, message: Option) { match message { Some(Message::MoveToNextPane) => match model.current_pane { CurrentPane::Main => model.current_pane = CurrentPane::History, CurrentPane::History => model.current_pane = CurrentPane::Main, }, - Some(Message::Quit) => model.should_quit = true, - Some(Message::KeyInput(key_input)) => { - model.key_input = model.update_key_input(key_input); - model.reset(); - } - Some(Message::Backspace) => model.key_input = model.pop(), - Some(Message::DeleteAll) => model.key_input = String::new(), + Some(Message::Quit) => model.app_state = AppState::ShouldQuite, Some(Message::Next) => model.next(), Some(Message::Previous) => model.previous(), Some(Message::ExecuteTarget) => { - model.selected_target = model - .targets_list_state - .selected() - .map(|i| model.narrow_down_targets()[i].clone()); + model.app_state = AppState::ExecuteTarget(model.selected_target()); + } + Some(Message::SearchTextAreaKeyInput(key_event)) => { + if let KeyCode::Char(_) = key_event.code { + model.reset_selection(); + }; + model.search_text_area.0.input(key_event); } None => {} } @@ -300,3 +314,158 @@ fn shutdown_terminal(terminal: &mut Terminal>) -> Resul Ok(()) } + +#[cfg(test)] +mod test { + use super::*; + use tui_textarea::TextArea; + + fn init_model<'a>() -> Model<'a> { + Model { + app_state: AppState::SelectingTarget, + current_pane: CurrentPane::Main, + makefile: Makefile::new_for_test(), + targets_list_state: ListState::with_selected(ListState::default(), Some(0)), + search_text_area: TextArea_(TextArea::default()), + } + } + + #[test] + fn update_test() { + struct Case<'a> { + title: &'static str, + model: Model<'a>, + message: Option, + expect_model: Model<'a>, + } + let cases: Vec = vec![ + Case { + title: "MoveToNextPane(Main -> History)", + model: init_model(), + message: Some(Message::MoveToNextPane), + expect_model: Model { + current_pane: CurrentPane::History, + ..init_model() + }, + }, + Case { + title: "MoveToNextPane(History -> Main)", + model: Model { + current_pane: CurrentPane::History, + ..init_model() + }, + message: Some(Message::MoveToNextPane), + expect_model: Model { + current_pane: CurrentPane::Main, + ..init_model() + }, + }, + Case { + title: "Quit", + model: init_model(), + message: Some(Message::Quit), + expect_model: Model { + app_state: AppState::ShouldQuite, + ..init_model() + }, + }, + Case { + title: "SearchTextAreaKeyInput(a)", + model: init_model(), + message: Some(Message::SearchTextAreaKeyInput(KeyEvent::from( + KeyCode::Char('a'), + ))), + expect_model: Model { + search_text_area: { + let mut text_area = TextArea::default(); + text_area.input(KeyEvent::from(KeyCode::Char('a'))); + TextArea_(text_area) + }, + ..init_model() + }, + }, + Case { + title: "Next(0 -> 1)", + model: init_model(), + message: Some(Message::Next), + expect_model: Model { + targets_list_state: ListState::with_selected(ListState::default(), Some(1)), + ..init_model() + }, + }, + Case { + title: "Next(2 -> 0)", + model: Model { + targets_list_state: ListState::with_selected(ListState::default(), Some(2)), + ..init_model() + }, + message: Some(Message::Next), + expect_model: Model { + targets_list_state: ListState::with_selected(ListState::default(), Some(0)), + ..init_model() + }, + }, + Case { + title: "Previous(1 -> 0)", + model: Model { + targets_list_state: ListState::with_selected(ListState::default(), Some(1)), + ..init_model() + }, + message: Some(Message::Previous), + expect_model: Model { + targets_list_state: ListState::with_selected(ListState::default(), Some(0)), + ..init_model() + }, + }, + Case { + title: "Previous(0 -> 2)", + model: Model { + targets_list_state: ListState::with_selected(ListState::default(), Some(0)), + ..init_model() + }, + message: Some(Message::Previous), + expect_model: Model { + targets_list_state: ListState::with_selected(ListState::default(), Some(2)), + ..init_model() + }, + }, + Case { + title: "ExecuteTarget", + model: Model { ..init_model() }, + message: Some(Message::ExecuteTarget), + expect_model: Model { + app_state: AppState::ExecuteTarget(Some("target0".to_string())), + ..init_model() + }, + }, + Case { + title: "After Next, if char was inputted, select should be reset", + model: Model { + targets_list_state: ListState::with_selected(ListState::default(), Some(1)), + ..init_model() + }, + message: Some(Message::SearchTextAreaKeyInput(KeyEvent::from( + KeyCode::Char('a'), + ))), + expect_model: Model { + targets_list_state: ListState::with_selected(ListState::default(), Some(0)), + search_text_area: { + let mut text_area = TextArea::default(); + text_area.input(KeyEvent::from(KeyCode::Char('a'))); + TextArea_(text_area) + }, + ..init_model() + }, + }, + ]; + + for mut case in cases { + update(&mut case.model, case.message); + assert_eq!( + case.expect_model, case.model, + "\nFailed: 🚨{:?}🚨\n", + case.title, + ); + } + } +} diff --git a/src/usecase/fzf_make/ui.rs b/src/usecase/fzf_make/ui.rs index 2e81f69f..16a4025c 100644 --- a/src/usecase/fzf_make/ui.rs +++ b/src/usecase/fzf_make/ui.rs @@ -167,11 +167,22 @@ fn render_targets_block(model: &mut Model, f: &mut Frame, chunk: ratatui::layout } fn render_input_block(model: &mut Model, f: &mut Frame, chunk: ratatui::layout::Rect) { - f.render_widget( - // NOTE: To show cursor, use rhysd/tui-textarea - input_block(" Input ", &model.key_input, model.current_pane.is_main()), - chunk, - ); + let fg_color = if model.current_pane.is_main() { + fg_color_selected() + } else { + fg_color_not_selected() + }; + + let block = Block::default() + .title(" Input ") + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .border_style(Style::default().fg(fg_color)) + .style(Style::default()) + .padding(ratatui::widgets::Padding::new(2, 2, 0, 0)); + + model.search_text_area.0.set_block(block); + f.render_widget(model.search_text_area.0.widget(), chunk); } fn render_history_block(model: &mut Model, f: &mut Frame, chunk: ratatui::layout::Rect) { @@ -205,25 +216,6 @@ fn render_key_bindings_block(model: &mut Model, f: &mut Frame, chunk: ratatui::l f.render_widget(key_notes_footer, chunk); } -fn input_block<'a>(title: &'a str, target_input: &'a str, is_current: bool) -> Paragraph<'a> { - let fg_color = if is_current { - fg_color_selected() - } else { - fg_color_not_selected() - }; - - Paragraph::new(Line::from(target_input)) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded) - .border_style(Style::default().fg(fg_color)) - .style(Style::default()) - .padding(ratatui::widgets::Padding::new(2, 0, 0, 0)), - ) - .style(Style::default()) -} fn targets_block(title: &str, narrowed_down_targets: Vec, is_current: bool) -> List<'_> { let fg_color = if is_current { fg_color_selected()