diff --git a/src/tui.rs b/src/tui.rs index e5598fb..1030b1a 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,18 +1,19 @@ #![allow(clippy::struct_excessive_bools)] use crossterm::{ - cursor::MoveTo, - event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, - }, + event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, execute, - style::{ - Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor, Stylize, - }, - terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -use std::io::{self, Write}; -use std::time::Instant; +use ratatui::{ + backend::CrosstermBackend, + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph}, + Frame, Terminal, +}; +use std::io; #[derive(Debug, Clone)] pub struct TuiConfig { @@ -50,35 +51,27 @@ impl TuiConfig { if !self.output_dir.is_empty() { args.extend(["-o".to_string(), self.output_dir.clone()]); } - if self.subdomains { args.push("--subdomains".to_string()); } - if self.single { args.push("--single".to_string()); } - if self.no_scope { args.push("--no-scope".to_string()); } - if self.no_doc_conversion { args.push("--no-doc-conversion".to_string()); } - if self.delay_ms != 1000 { args.extend(["--delay-ms".to_string(), self.delay_ms.to_string()]); } - if let Some(ref t) = self.types { args.extend(["--types".to_string(), t.clone()]); } - if let Some(ref t) = self.exclude_types { args.extend(["--exclude-types".to_string(), t.clone()]); } - args } @@ -92,24 +85,16 @@ fn validate_comma_list(input: &str) -> Result<(), String> { if trimmed.is_empty() { return Ok(()); } - - let items: Vec<&str> = trimmed - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .collect(); - + let items: Vec<&str> = trimmed.split(',').map(str::trim).filter(|s| !s.is_empty()).collect(); if items.is_empty() { return Err("List cannot be empty".to_string()); } - for item in &items { let check = item.strip_prefix('.').unwrap_or(item); if check.is_empty() || !check.chars().all(char::is_alphanumeric) { return Err(format!("Invalid item '{item}': use extensions like 'pdf' or '.pdf'")); } } - Ok(()) } @@ -208,6 +193,14 @@ impl FieldIndex { | FieldIndex::NoDocConversion ) } + + fn subtitle(self) -> &'static str { + match self { + FieldIndex::DelayMs => "[enter positive integer]", + FieldIndex::TypesInclude | FieldIndex::TypesExclude => "[comma-separated, dots optional]", + _ => "", + } + } } const ALL_FIELDS: [FieldIndex; 10] = [ @@ -223,108 +216,191 @@ const ALL_FIELDS: [FieldIndex; 10] = [ FieldIndex::Submit, ]; +enum Action { + Continue, + Cancel, + Submit, +} + pub fn run_tui() -> io::Result> { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnableMouseCapture)?; + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; let mut config = TuiConfig::default(); let mut current_field = FieldIndex::StartUrl; let mut text_input = String::new(); let mut errors: Vec<(FieldIndex, String)> = Vec::new(); - let mut last_render = Instant::now(); load_field_into_input(&mut text_input, &config, current_field); loop { - if last_render.elapsed().as_millis() > 50 { - render_ui(&mut stdout, &config, current_field, &errors)?; - last_render = Instant::now(); - } + terminal.draw(|frame| ui(frame, &config, current_field, &errors))?; if event::poll(std::time::Duration::from_millis(50))? && let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { - handle_key_event( + let action = handle_key_event( &mut config, &mut current_field, &mut text_input, &mut errors, key.code, key.modifiers, - )?; + ); + match action { + Action::Continue => {} + Action::Cancel => { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + return Ok(None); + } + Action::Submit => { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + return Ok(Some(config)); + } + } } } } -fn render_ui(stdout: &mut io::Stdout, config: &TuiConfig, current_field: FieldIndex, errors: &[(FieldIndex, String)]) -> io::Result<()> { - execute!(stdout, Clear(ClearType::All), MoveTo(0, 0))?; +#[allow(clippy::too_many_lines)] +fn ui(frame: &mut Frame, config: &TuiConfig, current_field: FieldIndex, errors: &[(FieldIndex, String)]) { + let title_line = Line::from(Span::styled( + "Interactive Web Scraper Configuration", + Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD), + )); - writeln!(stdout, "{}", "Interactive Web Scraper Configuration".cyan().bold())?; - writeln!(stdout, "{}", "=".repeat(60).blue())?; - writeln!(stdout)?; - - for &field in &ALL_FIELDS { - let marker = if current_field == field { ">" } else { " " }; - let value_text = get_field_display(config, field); - let hint = field.hint(); - - if current_field == field { - execute!( - stdout, - SetBackgroundColor(Color::DarkGrey), - Print(format!("{marker} {:30}: ", field.label())), - ResetColor - )?; - } else { - write!(stdout, "{marker} {:30}: ", field.label())?; - } - - if !value_text.is_empty() { - write!(stdout, "{value_text}")?; - } else if !hint.is_empty() { - write!(stdout, "{}", hint.dark_grey())?; - } - - if let Some((_, err)) = errors.iter().find(|(f, _)| *f == field) { - execute!( - stdout, - SetForegroundColor(Color::Red), - Print(format!(" [err: {err}]")), - ResetColor - )?; - } - - writeln!(stdout)?; - - if field.is_boolean_field() { - let enabled = get_bool_field(config, field); - writeln!(stdout, " [{}]", - if enabled { "enabled" } else { "disabled" })?; - } else if field == FieldIndex::DelayMs { - writeln!(stdout, " [enter positive integer]")?; - } else if field == FieldIndex::TypesInclude || field == FieldIndex::TypesExclude { - writeln!(stdout, " [comma-separated, dots optional e.g. .pdf,xlsx]")?; - } - } - - writeln!(stdout)?; - writeln!(stdout, "{}", "Controls:".dark_grey())?; - writeln!(stdout, " Enter : Next field / Toggle boolean / Submit")?; - writeln!(stdout, " Tab : Next field")?; - writeln!(stdout, " Shift+Tab: Previous field")?; - writeln!(stdout, " Ctrl+C : Exit without running")?; - writeln!(stdout, " Esc : Exit without running")?; + let controls_lines: Vec = vec![ + Line::from(Span::styled("Controls:", Style::default().fg(Color::DarkGray))), + Line::from(" Enter : Next field / Toggle boolean / Submit"), + Line::from(" Tab : Next field"), + Line::from(" Shift+Tab: Previous field"), + Line::from(" Ctrl+C : Exit without running"), + Line::from(" Esc : Exit without running"), + ]; let cmd_preview = config.build_command(); - writeln!(stdout)?; - writeln!(stdout, "{}", "Command preview:".yellow())?; - writeln!(stdout, " {cmd_preview}", cmd_preview = cmd_preview.green())?; + let cmd_lines: Vec = vec![ + Line::from(Span::styled( + "Command preview:", + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled(format!(" {cmd_preview}"), Style::default().fg(Color::Yellow))), + ]; - stdout.flush()?; + let status_text = if config.start_url.is_empty() { + "Waiting for Start URL..." + } else { + "Ready. Press Enter on Submit to run." + }; + let status_style = if config.start_url.is_empty() { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Green) + }; + let status_line = Line::from(Span::styled(status_text, status_style)); - Ok(()) + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(1), + Constraint::Min(10), + Constraint::Length(6), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(frame.area()); + + frame.render_widget(Paragraph::new(title_line), chunks[0]); + + let field_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(std::iter::repeat_n(Constraint::Length(2), ALL_FIELDS.len()).collect::>()) + .split(chunks[1]); + + for (i, &field) in ALL_FIELDS.iter().enumerate() { + render_field(frame, field_chunks[i], config, current_field, field, errors); + } + + frame.render_widget(Paragraph::new(controls_lines), chunks[2]); + frame.render_widget(Paragraph::new(cmd_lines), chunks[3]); + + let error_line = if let Some((_, err)) = errors.iter().find(|(f, _)| *f == FieldIndex::Submit) { + Line::from(Span::styled(format!("[ERR: {err}]"), Style::default().fg(Color::Red))) + } else { + status_line + }; + frame.render_widget( + Paragraph::new(error_line).alignment(Alignment::Center), + chunks[4], + ); +} + +fn render_field(frame: &mut Frame, area: ratatui::layout::Rect, config: &TuiConfig, current_field: FieldIndex, field: FieldIndex, errors: &[(FieldIndex, String)]) { + let value_text = get_field_display(config, field); + let hint = field.hint(); + let is_active = current_field == field; + + let display_value = if !value_text.is_empty() { + value_text.as_str() + } else if !hint.is_empty() { + hint + } else { + "" + }; + + let bool_state = if field.is_boolean_field() { + format!(" [{}]", if get_bool_field(config, field) { "enabled" } else { "disabled" }) + } else { + String::new() + }; + + let prefix = if is_active { ">" } else { " " }; + let label = format!("{:30} ", format!("{}:", field.label())); + let subtitle = field.subtitle(); + + let value_style = if is_active { + Style::default() + } else if display_value.is_empty() || display_value == hint { + Style::default().fg(Color::DarkGray) + } else { + Style::default() + }; + + let mut spans = vec![ + Span::styled( + format!("{prefix} {label}"), + if is_active { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }, + ), + Span::styled(display_value.to_string(), value_style), + Span::styled(bool_state, Style::default().fg(Color::DarkGray)), + Span::styled(format!(" {subtitle}"), Style::default().fg(Color::DarkGray)), + ]; + + if let Some((_, err)) = errors.iter().find(|(f, _)| *f == field) { + spans.push(Span::styled(format!(" [ERR: {err}]"), Style::default().fg(Color::Red))); + } + + let line = Line::from(spans); + let block = if is_active { + Block::default().style(Style::default().bg(Color::DarkGray)) + } else { + Block::default() + }; + let widget = Paragraph::new(line).block(block); + frame.render_widget(widget, area); } fn get_bool_field(config: &TuiConfig, field: FieldIndex) -> bool { @@ -367,8 +443,12 @@ fn load_field_into_input(text_input: &mut String, config: &TuiConfig, field: Fie fn save_input_to_field(config: &mut TuiConfig, field: FieldIndex, text_input: &str) { match field { - FieldIndex::StartUrl => { config.start_url = text_input.to_string(); } - FieldIndex::OutputDir => { config.output_dir = text_input.to_string(); } + FieldIndex::StartUrl => { + config.start_url = text_input.to_string(); + } + FieldIndex::OutputDir => { + config.output_dir = text_input.to_string(); + } FieldIndex::DelayMs => { if let Ok(val) = text_input.trim().parse::() { config.delay_ms = val; @@ -376,11 +456,19 @@ fn save_input_to_field(config: &mut TuiConfig, field: FieldIndex, text_input: &s } FieldIndex::TypesInclude => { let trimmed = text_input.trim(); - config.types = if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }; + config.types = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; } FieldIndex::TypesExclude => { let trimmed = text_input.trim(); - config.exclude_types = if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }; + config.exclude_types = if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + }; } _ => {} } @@ -393,42 +481,43 @@ fn handle_key_event( errors: &mut Vec<(FieldIndex, String)>, code: KeyCode, modifiers: KeyModifiers, -) -> io::Result { +) -> Action { match code { KeyCode::Esc | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { - disable_raw_mode()?; - execute!(io::stdout(), DisableMouseCapture)?; - return Ok(false); + Action::Cancel } KeyCode::Tab => { save_input_to_field(config, *current_field, text_input); *current_field = current_field.next(); load_field_into_input(text_input, config, *current_field); errors.clear(); + Action::Continue } KeyCode::BackTab => { save_input_to_field(config, *current_field, text_input); *current_field = current_field.prev(); load_field_into_input(text_input, config, *current_field); errors.clear(); + Action::Continue } KeyCode::Enter => { - process_enter(config, current_field, text_input, errors); + process_enter(config, current_field, text_input, errors) } KeyCode::Char(ch) if current_field.is_text_field() => { text_input.push(ch); + Action::Continue } KeyCode::Backspace if current_field.is_text_field() => { text_input.pop(); + Action::Continue } KeyCode::Delete if current_field.is_text_field() => { text_input.clear(); + Action::Continue } - _ => {} + _ => Action::Continue, } - - Ok(true) } fn process_enter( @@ -436,19 +525,23 @@ fn process_enter( current_field: &mut FieldIndex, text_input: &mut String, errors: &mut Vec<(FieldIndex, String)>, -) { +) -> Action { match *current_field { FieldIndex::Subdomains => { config.subdomains = !config.subdomains; + Action::Continue } FieldIndex::Single => { config.single = !config.single; + Action::Continue } FieldIndex::NoScope => { config.no_scope = !config.no_scope; + Action::Continue } FieldIndex::NoDocConversion => { config.no_doc_conversion = !config.no_doc_conversion; + Action::Continue } FieldIndex::StartUrl => { save_input_to_field(config, *current_field, text_input); @@ -460,12 +553,14 @@ fn process_enter( *current_field = current_field.next(); load_field_into_input(text_input, config, *current_field); } + Action::Continue } FieldIndex::OutputDir | FieldIndex::DelayMs => { save_input_to_field(config, *current_field, text_input); errors.clear(); *current_field = current_field.next(); load_field_into_input(text_input, config, *current_field); + Action::Continue } FieldIndex::TypesInclude => { save_input_to_field(config, *current_field, text_input); @@ -481,6 +576,7 @@ fn process_enter( errors.push((FieldIndex::TypesInclude, e)); } } + Action::Continue } FieldIndex::TypesExclude => { save_input_to_field(config, *current_field, text_input); @@ -496,37 +592,36 @@ fn process_enter( errors.push((FieldIndex::TypesExclude, e)); } } + Action::Continue } - FieldIndex::Submit => { + FieldIndex::Submit => { if config.start_url.trim().is_empty() { errors.retain(|(f, _)| *f != FieldIndex::StartUrl); errors.push((FieldIndex::StartUrl, "Required".into())); - return; + return Action::Continue; } - if let Some(ref t) = config.types && let Err(e) = validate_comma_list(t) { errors.retain(|(f, _)| *f != FieldIndex::TypesInclude); errors.push((FieldIndex::TypesInclude, e)); - return; + return Action::Continue; } if let Some(ref t) = config.exclude_types && let Err(e) = validate_comma_list(t) { errors.retain(|(f, _)| *f != FieldIndex::TypesExclude); errors.push((FieldIndex::TypesExclude, e)); - return; + return Action::Continue; } - if config.types.is_some() && config.exclude_types.is_some() { - errors.push((FieldIndex::Submit, "types and exclude-types are mutually exclusive".into())); - return; + errors.push(( + FieldIndex::Submit, + "types and exclude-types are mutually exclusive".into(), + )); + return Action::Continue; } - - disable_raw_mode().unwrap(); - execute!(io::stdout(), DisableMouseCapture).unwrap(); - std::process::exit(0); + Action::Submit } } }