From 71122e87ae4a498141ee33ce78eca9a7faa5b538 Mon Sep 17 00:00:00 2001 From: fromost Date: Thu, 16 Oct 2025 18:51:24 +0800 Subject: [PATCH] Add validation to textarea --- src/cli.rs | 3 +- src/config/mod.rs | 6 +- src/widgets/components/textarea.rs | 94 +++++++++++++++++++----------- src/widgets/popups/folder.rs | 27 ++++++++- src/widgets/views/main_view.rs | 14 +++-- 5 files changed, 102 insertions(+), 42 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 0856bb8..2311410 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -115,6 +115,7 @@ impl FolderAddCommand { .path_config .dlsite_paths .push(abs_path.to_str().unwrap().to_string()); - config.write_to_file(&APP_CONIFG_FILE_PATH.to_path_buf()) + config.save()?; + Ok(()) } } diff --git a/src/config/mod.rs b/src/config/mod.rs index db670ad..2dbe1da 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -50,7 +50,7 @@ impl ApplicationConfig { conf } - pub fn write_to_file(self, path: &PathBuf) -> Result<()> { + fn write_to_file(self, path: &PathBuf) -> Result<()> { let mut conf = Ini::new(); conf.with_section(Some("Basic")) .set("DBPath", self.basic_config.db_path) @@ -67,4 +67,8 @@ impl ApplicationConfig { conf.write_to_file(path)?; Ok(()) } + + pub fn save(&self) -> Result<()> { + self.clone().write_to_file(&APP_CONIFG_FILE_PATH.to_path_buf()) + } } diff --git a/src/widgets/components/textarea.rs b/src/widgets/components/textarea.rs index 15bcab8..5d6e2b4 100644 --- a/src/widgets/components/textarea.rs +++ b/src/widgets/components/textarea.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use color_eyre::Result; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use rat_cursor::HasScreenCursor; @@ -12,13 +13,20 @@ use tui_input::Input; #[derive(Clone)] pub struct TextArea { - input: Input, title: String, style: TextAreaStyle, + auto_scroll: bool, + validate_fn: Arc bool>, + pub state: TextAreaState, +} + +#[derive(Clone)] +pub struct TextAreaState { + pub is_active: bool, input_area: Option, scroll_offset: u16, - auto_scroll: bool, - pub active: bool, + input: Input, + is_valid: bool } #[derive(Clone)] @@ -28,7 +36,7 @@ pub enum TextAreaStyle { } impl StatefulWidget for TextArea { - type State = TextArea; + type State = TextAreaState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) where Self: Sized, @@ -48,20 +56,16 @@ impl StatefulWidget for TextArea { let label_text = Text::from(self.title); let label = Paragraph::new(label_text); - // let scroll_offset = if self.input.cursor() > chunks[1].width as usize { - // self.input.cursor() - chunks[1].width as usize - // } else { 0 }; - let input_text = Span::from(input_value.clone()).fg(Color::White); let input_line = Line::from(input_text); let paragraph = Paragraph::new(input_line) - .bg(Color::Green) - .scroll((0, self.scroll_offset)); + .bg(if state.is_valid { Color::Green } else { Color::Red }) + .scroll((0, state.scroll_offset)); - if self.input_area.is_none() { + if state.input_area.is_none() { state.input_area = Some(chunks[1]); } - else if let Some(area) = self.input_area && area != chunks[1] { + else if let Some(area) = self.state.input_area && area != chunks[1] { state.input_area = Some(chunks[1]); } @@ -73,48 +77,72 @@ impl StatefulWidget for TextArea { impl HasScreenCursor for TextArea { fn screen_cursor(&self) -> Option<(u16, u16)> { - if self.input_area.is_none() { + if self.state.input_area.is_none() { return None; } - let area = self.input_area.unwrap(); - let scroll = self.input.visual_scroll(1); - let x = self.input.visual_cursor().max(scroll) as u16 - self.scroll_offset; + let area = self.state.input_area.unwrap(); + let scroll = self.state.input.visual_scroll(1); + let x = self.state.input.visual_cursor().max(scroll) as u16 - self.state.scroll_offset; Some((area.x + x, area.y)) } } impl TextArea { - pub fn new(title: &str, placeholder_text: &str, style: Option, auto_scroll: Option) -> Self { + pub fn new(title: &str, + placeholder_text: &str, + validate_fn: fn(&str) -> bool, + style: Option, + auto_scroll: Option, + ) + -> Self + { + let func = Arc::new(validate_fn); Self { - input: Input::new(placeholder_text.to_string()), title: title.to_string(), - active: false, style: style.unwrap_or(TextAreaStyle::SingleLine), - input_area: None, auto_scroll: auto_scroll.unwrap_or(true), - scroll_offset: 0 + validate_fn: func, + state: TextAreaState { + input: Input::new(placeholder_text.to_string()), + is_active: false, + input_area: None, + scroll_offset: 0, + is_valid: false + } } } pub fn handle_input(&mut self, event: &Event) -> Result<()> { - let _ = self.input.handle_event(event); - + let _ = self.state.input.handle_event(event); + self.state.is_valid = (self.validate_fn)(self.state.input.value()); if let Event::Key(key) = event && !matches!(key.kind, KeyEventKind::Release) && - let Some(area) = self.input_area { - - let cursor_pos = self.input.cursor() as u16; - if self.scroll_offset > cursor_pos { - self.scroll_offset = cursor_pos; - } else if cursor_pos >= area.width + self.scroll_offset { - self.scroll_offset = cursor_pos - area.width; - } else if self.auto_scroll && self.scroll_offset > 0 && (key.code.is_delete() || key.code.is_backspace()) { - self.scroll_offset -= 1; + let Some(area) = self.state.input_area + { + let scroll_offset = self.state.scroll_offset; + let cursor_pos = self.state.input.cursor() as u16; + if scroll_offset> cursor_pos { + self.state.scroll_offset = cursor_pos; + } else if cursor_pos >= area.width + scroll_offset { + self.state.scroll_offset = cursor_pos - area.width; + } else if self.auto_scroll && scroll_offset > 0 && key.code.is_delete() { + self.state.scroll_offset -= 1; // HACK: with_cursor function requires to be owned so use handle event - let _ = self.input.handle_event(&Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::empty()))); + let _ = self.state.input.handle_event(&Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::empty()))); } } Ok(()) } + + pub fn get_value(&self) -> Option { + if self.state.is_valid { + return Some(self.state.input.value().to_string()); + } + None + } + + pub fn reset_value(&mut self) -> Result<()> { + Ok(self.state.input.reset()) + } } diff --git a/src/widgets/popups/folder.rs b/src/widgets/popups/folder.rs index 035f62e..1374123 100644 --- a/src/widgets/popups/folder.rs +++ b/src/widgets/popups/folder.rs @@ -1,3 +1,4 @@ +use std::path::Path; use crate::widgets::components::TextArea; use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; @@ -11,10 +12,30 @@ pub struct AddFolderPopup { impl AddFolderPopup { pub fn new() -> Self { - let mut textarea = TextArea::new("Folder Path", "", None, None); - textarea.active = true; + let mut textarea = TextArea::new( + "Folder Path", + "", + |x| { + let path = Path::new(x); + path.exists() && path.is_dir() + }, + None, + None + ); + textarea.state.is_active = true; Self { textarea } } + + pub fn get_folder_value(&mut self) -> Option { + let value = self.textarea.get_value(); + if value.is_none() { + return None; + } + if let Some(path) = value && !path.is_empty() { + return Some(path); + } + None + } } impl StatefulWidget for AddFolderPopup { @@ -37,6 +58,6 @@ impl StatefulWidget for AddFolderPopup { .direction(Direction::Vertical) .constraints(vec![Constraint::Length(1)]) .split(popup_area.inner(Margin::new(1, 1))); - self.textarea.render(chunks[0], buf, &mut state.textarea); + self.textarea.render(chunks[0], buf, &mut state.textarea.state); } } diff --git a/src/widgets/views/main_view.rs b/src/widgets/views/main_view.rs index 597d528..abd5566 100644 --- a/src/widgets/views/main_view.rs +++ b/src/widgets/views/main_view.rs @@ -1,5 +1,4 @@ use crate::config::types::ApplicationConfig; -use crate::constants::APP_CONIFG_FILE_PATH; use crate::widgets::popups::folder::AddFolderPopup; use crate::widgets::views::View; use crossterm::event::KeyCode::Char; @@ -43,9 +42,7 @@ impl MainView { fn quit(&mut self) -> color_eyre::Result<()> { if self.state.popup.is_none() { self.state.status = Status::Exiting; - self.app_config - .clone() - .write_to_file(&APP_CONIFG_FILE_PATH.to_path_buf())?; + self.app_config.save()?; } Ok(()) } @@ -71,6 +68,15 @@ impl View for MainView { self.state.status = Status::Running; self.state.popup = None; } + if let Some(any) = self.state.popup.as_mut() && + let Some(popup) = any.downcast_mut::() && + let Some(value) = popup.get_folder_value() && + key.code.is_enter() + { + self.app_config.path_config.dlsite_paths.push(value); + popup.textarea.reset_value()?; + self.app_config.save()?; + } if !matches!(self.state.status, Status::Popup) && matches!(key.kind, KeyEventKind::Press) { match key.code { Char('q') => self.quit()?,