diff --git a/src/autocomplete.rs b/src/autocomplete.rs deleted file mode 100644 index 875dffa..0000000 --- a/src/autocomplete.rs +++ /dev/null @@ -1,28 +0,0 @@ -#[derive(Debug)] -pub struct Autocomplete<'a> { - possibles: Vec<&'a str>, -} - -impl<'a> Autocomplete<'a> { - pub fn from(mut possibles: Vec<&'a str>) -> Self { - possibles.sort(); - Self { possibles } - } - - pub fn get_completion(&self, current: &str) -> Option { - // Only want to attempt completion for commands with at least 2 letters - // as 1 letter commands are possible and it could create confusion - if current.len() < 2 { - return None; - } - - for possible in &self.possibles { - if possible.starts_with(current) { - let candidate = String::from(*possible); - return Some(candidate); - } - } - - None - } -} diff --git a/src/components/input_field.rs b/src/components/command_input.rs similarity index 67% rename from src/components/input_field.rs rename to src/components/command_input.rs index 4a8c8cd..5490a1d 100644 --- a/src/components/input_field.rs +++ b/src/components/command_input.rs @@ -1,25 +1,19 @@ use color_eyre::eyre::{Context, Result}; use itertools::min; -use ratatui::{ - layout::{Margin, Rect}, - prelude::*, - style::Style, - text::{Line, Span}, - widgets::{Block, Padding, Paragraph}, - Frame, -}; +use ratatui::{layout::Rect, Frame}; use std::{collections::VecDeque, fmt::Debug}; use tokio::sync::mpsc::Sender; use crate::{ - autocomplete::Autocomplete, context::AppContext, - events::transition::send_transition, - events::{message::MessageResponse, Key, Message, Transition}, + events::{message::MessageResponse, transition::send_transition, Key, Message, Transition}, traits::Component, + widgets::text_input::{Autocomplete, TextInput, TextInputState}, }; +use super::text_input_wrapper::TextInputWrapper; + const QUIT: &str = "quit"; const Q: &str = "q"; const IMAGE: &str = "image"; @@ -28,76 +22,70 @@ const CONTAINER: &str = "container"; const CONTAINERS: &str = "containers"; #[derive(Debug)] -pub struct InputField { - input: String, - prompt: String, +pub struct CommandInput { tx: Sender>, - candidate: Option, - ac: Autocomplete<'static>, history: History, + text_input: TextInputWrapper, } -impl InputField { +impl CommandInput { pub fn new(tx: Sender>, prompt: String) -> Self { + let ac: Autocomplete = + Autocomplete::from(vec![QUIT, Q, IMAGE, IMAGES, CONTAINER, CONTAINERS]); Self { - input: String::new(), - prompt, tx, - candidate: None, - ac: Autocomplete::from(vec![QUIT, Q, IMAGE, IMAGES, CONTAINER, CONTAINERS]), history: History::new(), + text_input: TextInputWrapper::new(prompt, Some(ac)), } } pub fn initialise(&mut self) { - self.input = String::new(); - self.candidate = None; + self.text_input.reset(); self.history.reset_idx(); } pub async fn update(&mut self, message: Key) -> Result { + // NB - for now the CommandInput is over-riding bits of the TextInputWrapper + // This should probably be fixed by a generic TextInput widget over a + // trait which defines the interaction between the widget and its state struct + // for now unlikely we'll need history for anything else, so autocomplete + // as an optional first class citizen is fine & we'll see what happens + // + // Similarly, it could be that autocomplete varies, or we want different types + // of autocomplete, which would trigger a refactor? match message { - Key::Char(c) => { + Key::Char(_) => { self.history.reset_idx(); - self.input.push(c); - let input = &self.input; - self.candidate = self.ac.get_completion(input); - } - Key::Tab => { - if let Some(candidate) = &self.candidate { - self.input.clone_from(candidate) - } + self.text_input.update(message)?; } Key::Up => { - self.history.conditional_set_working_buffer(&self.input); + let input_value = self.text_input.get_value(); + self.history.conditional_set_working_buffer(&input_value); if let Some(v) = &self.history.next() { - self.input.clone_from(v); + self.text_input.set_input(v.clone()); } } Key::Down => { if let Some(v) = &self.history.previous() { - self.input.clone_from(v); + self.text_input.set_input(v.clone()); } } - Key::Backspace => { - self.input.pop(); - } Key::Enter => { - self.history.add_value(&self.input); + self.history.add_value(&self.text_input.get_value()); self.history.reset_idx(); self.submit() .await .context("unable to submit user command")?; } - _ => return Ok(MessageResponse::NotConsumed), + _ => return self.text_input.update(message), } Ok(MessageResponse::Consumed) } async fn submit(&mut self) -> Result<()> { - let transition = match &*self.input { + let transition = match &*self.text_input.get_value() { Q | QUIT => Some(Transition::Quit), IMAGE | IMAGES => Some(Transition::ToImagePage(AppContext::default())), CONTAINER | CONTAINERS => Some(Transition::ToContainerPage(AppContext::default())), @@ -122,31 +110,9 @@ impl InputField { } } -impl Component for InputField { +impl Component for CommandInput { fn draw(&mut self, f: &mut Frame<'_>, area: Rect) { - let block = Block::bordered() - .border_type(ratatui::widgets::BorderType::Plain) - .padding(Padding::left(300)); - - f.render_widget(block, area); - - let inner_body_margin = Margin::new(2, 1); - let body_inner = area.inner(inner_body_margin); - - let mut input_text = vec![ - Span::styled::(format!("{} ", self.prompt), Style::new().green()), - Span::raw(self.input.clone()), - ]; - - if let Some(candidate) = &self.candidate { - if let Some(delta) = candidate.strip_prefix(&self.input as &str) { - input_text - .push(Span::raw(delta).style(Style::default().add_modifier(Modifier::DIM))) - } - } - - let p = Paragraph::new(Line::from(input_text)); - f.render_widget(p, body_inner) + self.text_input.draw(f, area); } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 25f67ce..9fe6c84 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,7 +1,8 @@ pub mod alert_modal; pub mod boolean_modal; +pub mod command_input; pub mod footer; pub mod header; pub mod help; -pub mod input_field; pub mod resize_notice; +pub mod text_input_wrapper; diff --git a/src/components/text_input_wrapper.rs b/src/components/text_input_wrapper.rs new file mode 100644 index 0000000..5d01536 --- /dev/null +++ b/src/components/text_input_wrapper.rs @@ -0,0 +1,53 @@ +use color_eyre::eyre::Result; + +use crate::{ + events::{message::MessageResponse, Key}, + traits::Component, + widgets::text_input::{Autocomplete, TextInput, TextInputState}, +}; + +/// Component which TextInput widget providing baked in keyboard input handling +#[derive(Debug)] +pub struct TextInputWrapper { + prompt: String, + state: TextInputState, +} + +impl TextInputWrapper { + pub fn new(prompt: String, autocomplete: Option>) -> Self { + let state = TextInputState::new(autocomplete); + Self { prompt, state } + } + + pub fn reset(&mut self) { + self.state.reset() + } + + pub fn update(&mut self, message: Key) -> Result { + match message { + Key::Char(c) => self.state.push_character(c), + Key::Tab => self.state.accept_autocomplete_candidate(), + Key::Backspace => { + self.state.pop_character(); + } + _ => return Ok(MessageResponse::NotConsumed), + } + + Ok(MessageResponse::Consumed) + } + + pub fn get_value(&mut self) -> String { + self.state.get_value().clone() + } + + pub fn set_input(&mut self, value: String) { + self.state.set_input(value); + } +} + +impl Component for TextInputWrapper { + fn draw(&mut self, f: &mut ratatui::Frame<'_>, area: ratatui::prelude::Rect) { + let text_input = TextInput::new(Some(self.prompt.clone())); + f.render_stateful_widget(text_input, area, &mut self.state); + } +} diff --git a/src/lib.rs b/src/lib.rs index 358e606..7da0fad 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,3 @@ -pub mod autocomplete; pub mod callbacks; pub mod components; pub mod config; diff --git a/src/ui/app.rs b/src/ui/app.rs index de1c97c..70b9f1f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -11,9 +11,9 @@ use tokio::sync::mpsc::Sender; use crate::{ components::{ alert_modal::{AlertModal, ModalState}, + command_input::CommandInput, footer::Footer, header::Header, - input_field::InputField, resize_notice::ResizeScreen, }, config::Config, @@ -38,7 +38,7 @@ pub struct App { title: Header, page_manager: PageManager, footer: Footer, - input_field: InputField, + input_field: CommandInput, modal: Option>, } @@ -65,7 +65,7 @@ impl App { title: Header::new(config.clone()), page_manager: body, footer: Footer::new(config.clone()), - input_field: InputField::new(tx, config.prompt), + input_field: CommandInput::new(tx, config.prompt), modal: None, }; Ok(app) diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 6738a0f..d346d1b 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -1 +1,2 @@ pub mod modal; +pub mod text_input; diff --git a/src/widgets/text_input.rs b/src/widgets/text_input.rs new file mode 100644 index 0000000..6e154b8 --- /dev/null +++ b/src/widgets/text_input.rs @@ -0,0 +1,141 @@ +use ratatui::{ + layout::Margin, + prelude::*, + style::Style, + text::{Line, Span}, + widgets::{Block, Padding, Paragraph}, +}; +use std::fmt::Debug; + +use ratatui::widgets::{StatefulWidget, Widget}; + +pub struct TextInput { + prompt: Option, +} + +impl TextInput { + pub fn new(prompt: Option) -> Self { + Self { prompt } + } +} + +impl StatefulWidget for TextInput { + type State = TextInputState; + + fn render( + self, + area: ratatui::prelude::Rect, + buf: &mut ratatui::prelude::Buffer, + state: &mut Self::State, + ) { + let block = Block::bordered() + .border_type(ratatui::widgets::BorderType::Plain) + .padding(Padding::left(300)); + + block.render(area, buf); + + let inner_body_margin = Margin::new(2, 1); + let body_inner = area.inner(inner_body_margin); + + let mut input_text = vec![]; + if let Some(prompt) = self.prompt.clone() { + input_text.push(Span::styled::( + format!("{} ", prompt), + Style::new().green(), + )); + } + input_text.push(Span::raw(&state.value)); + + if let Some(candidate) = &state.candidate { + if let Some(delta) = candidate.strip_prefix(&state.value as &str) { + input_text + .push(Span::raw(delta).style(Style::default().add_modifier(Modifier::DIM))) + } + } + + let p = Paragraph::new(Line::from(input_text)); + p.render(body_inner, buf) + } +} + +#[derive(Debug)] +pub struct TextInputState { + value: String, + candidate: Option, + autocomplete: Option>, +} + +impl TextInputState { + pub fn new(autocomplete: Option>) -> Self { + Self { + value: String::new(), + candidate: None, + autocomplete, + } + } + + pub fn reset(&mut self) { + self.value = String::new(); + self.candidate = None; + } + + pub fn set_input(&mut self, value: String) { + self.value = value; + self.update_autocomplete(); + } + + pub fn get_value(&mut self) -> String { + self.value.clone() + } + + pub fn push_character(&mut self, c: char) { + self.value.push(c); + self.update_autocomplete(); + } + + pub fn pop_character(&mut self) { + self.value.pop(); + self.update_autocomplete(); + } + + pub fn accept_autocomplete_candidate(&mut self) { + if let Some(c) = &self.candidate { + self.value.clone_from(c) + } + } + + fn update_autocomplete(&mut self) { + if let Some(ac) = &self.autocomplete { + self.candidate = ac.get_completion(&self.value) + } + } +} + +#[derive(Debug)] +pub struct Autocomplete<'a> { + possibles: Vec<&'a str>, +} + +impl<'a> Autocomplete<'a> { + pub fn from(mut possibles: Vec<&'a str>) -> Self { + possibles.sort(); + Self { possibles } + } + + pub fn get_completion(&self, current: &str) -> Option { + // Only want to attempt completion for commands with at least 2 letters + // as 1 letter commands are possible and it could create confusion + if current.len() < 2 { + return None; + } + + for possible in &self.possibles { + if possible.starts_with(current) { + let candidate = String::from(*possible); + return Some(candidate); + } + } + + None + } +}