use crossterm::{ event::{self, Event, KeyCode, KeyEventKind}, terminal::{disable_raw_mode, enable_raw_mode, Clear}, ExecutableCommand, }; use ratatui::{ backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span, Text}, widgets::{ Block, Borders, Cell, Gauge, List, ListItem, Paragraph, Row, Table, Tabs, Widget, }, Frame, Terminal, }; use std::io::Stdout; use std::path::PathBuf; use crate::models::{AnalysisSummary, ByLanguage, ByPriority, Priority, TechDebtItem}; mod app; pub use app::TuiApp; #[derive(Debug, Clone, PartialEq)] pub enum View { Dashboard, List, Detail, Export, } #[derive(Debug, Clone, PartialEq)] enum SortOrder { Priority, File, Line, Keyword, } pub struct TuiState { pub items: Vec, pub current_view: View, pub selected_index: usize, pub filter_text: String, pub filter_priority: Option, pub sort_order: SortOrder, pub show_help: bool, pub path: PathBuf, } impl TuiState { pub fn new(items: Vec, path: PathBuf) -> Self { Self { items, current_view: View::Dashboard, selected_index: 0, filter_text: String::new(), filter_priority: None, sort_order: SortOrder::Priority, show_help: false, path, } } pub fn filtered_items(&self) -> Vec<&TechDebtItem> { let mut items: Vec<&TechDebtItem> = self.items.iter().collect(); if !self.filter_text.is_empty() { items.retain(|item| { item.content.to_lowercase().contains(&self.filter_text.to_lowercase()) || item .location .path .to_string_lossy() .to_lowercase() .contains(&self.filter_text.to_lowercase()) }); } if let Some(priority) = self.filter_priority { items.retain(|item| item.priority == priority); } match self.sort_order { SortOrder::Priority => items.sort_by(|a, b| b.priority.cmp(&a.priority)), SortOrder::File => items.sort_by(|a, b| { a.location .path .cmp(&b.location.path) .then_with(|| a.location.line.cmp(&b.location.line)) }), SortOrder::Line => items.sort_by(|a, b| a.location.line.cmp(&b.location.line)), SortOrder::Keyword => items.sort_by(|a, b| a.keyword.cmp(&b.keyword)), } items } pub fn summary(&self) -> AnalysisSummary { let by_priority = ByPriority::from_items(&self.items); let by_language = ByLanguage::from_items(&self.items); let complexity_distribution = crate::models::ComplexityDistribution::from_items(&self.items); AnalysisSummary { total_items: self.items.len(), by_priority, by_language, complexity_distribution, } } } fn draw_dashboard(f: &mut Frame>, state: &TuiState, area: Rect) { let summary = state.summary(); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ]) .split(area); let title = Paragraph::new("TECHDEBT TRACKER - Dashboard") .style(Style::default().fg(Color::Cyan).bold()); f.render_widget(title, chunks[0]); let main_content = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(chunks[1]); let left_content = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(4), Constraint::Length(4), Constraint::Length(4), Constraint::Min(0), ]) .split(main_content[0]); let right_content = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(4), Constraint::Length(4), Constraint::Min(0), ]) .split(main_content[1]); let priority_block = Block::default() .title("Priority Breakdown") .borders(Borders::ALL) .style(Style::default().fg(Color::White)); f.render_widget(priority_block, left_content[0]); let priority_content = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ]) .split(left_content[0].inner); let critical_gauge = Gauge::default() .gauge_style(Style::default().fg(Color::Red)) .label(&format!("Critical: {}", summary.by_priority.critical)) .ratio(if summary.total_items > 0 { summary.by_priority.critical as f64 / summary.total_items as f64 } else { 0.0 }); f.render_widget(critical_gauge, priority_content[0]); let high_gauge = Gauge::default() .gauge_style(Style::default().fg(Color::Yellow)) .label(&format!("High: {}", summary.by_priority.high)) .ratio(if summary.total_items > 0 { summary.by_priority.high as f64 / summary.total_items as f64 } else { 0.0 }); f.render_widget(high_gauge, priority_content[1]); let medium_gauge = Gauge::default() .gauge_style(Style::default().fg(Color::Blue)) .label(&format!("Medium: {}", summary.by_priority.medium)) .ratio(if summary.total_items > 0 { summary.by_priority.medium as f64 / summary.total_items as f64 } else { 0.0 }); f.render_widget(medium_gauge, priority_content[2]); let low_gauge = Gauge::default() .gauge_style(Style::default().fg(Color::Green)) .label(&format!("Low: {}", summary.by_priority.low)) .ratio(if summary.total_items > 0 { summary.by_priority.low as f64 / summary.total_items as f64 } else { 0.0 }); f.render_widget(low_gauge, priority_content[3]); let total_block = Block::default() .title("Total Items") .borders(Borders::ALL) .style(Style::default().fg(Color::White)); f.render_widget(total_block, left_content[1]); let total_text = Paragraph::new(format!("{}", summary.total_items)) .style(Style::default().fg(Color::Cyan).bold()); f.render_widget(total_text, left_content[1].inner); let complexity_block = Block::default() .title("Complexity Distribution") .borders(Borders::ALL) .style(Style::default().fg(Color::White)); f.render_widget(complexity_block, left_content[2]); let complexity_text = format!( "Low: {} Medium: {} High: {} Critical: {}", summary.complexity_distribution.low, summary.complexity_distribution.medium, summary.complexity_distribution.high, summary.complexity_distribution.critical ); let complexity_para = Paragraph::new(complexity_text); f.render_widget(complexity_para, left_content[2].inner); let language_block = Block::default() .title("By Language") .borders(Borders::ALL) .style(Style::default().fg(Color::White)); f.render_widget(language_block, right_content[0]); let lang_rows: Vec = summary .by_language .items .iter() .take(5) .map(|lang| { Row::new(vec![ Cell::from(lang.language.clone()), Cell::from(lang.count.to_string()), ]) }) .collect(); let lang_table = Table::new(lang_rows) .widths(&[Constraint::Percentage(60), Constraint::Percentage(40)]) .column_spacing(1); f.render_widget(lang_table, right_content[0].inner); let help_block = Block::default() .title("Navigation Help") .borders(Borders::ALL) .style(Style::default().fg(Color::White)); f.render_widget(help_block, right_content[1]); let help_text = "↑↓: Navigate | Tab: Switch View | q: Quit | /: Filter"; let help_para = Paragraph::new(help_text); f.render_widget(help_para, right_content[1].inner); let status_bar = Line::from(vec![ Span::styled(" Press ", Style::default().fg(Color::Gray)), Span::styled("TAB", Style::default().fg(Color::Yellow).bold()), Span::styled(" to view items | ", Style::default().fg(Color::Gray)), Span::styled("q", Style::default().fg(Color::Yellow).bold()), Span::styled(" to quit", Style::default().fg(Color::Gray)), ]); f.render_widget(status_bar, chunks[2]); } fn draw_list(f: &mut Frame>, state: &TuiState, area: Rect) { let filtered = state.filtered_items(); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ]) .split(area); let filter_text = if state.filter_text.is_empty() { format!("Filter ({} items) - Press / to filter", filtered.len()) } else { format!( "Filter: {} - {} results (Press / to edit, ESC to clear)", state.filter_text, filtered.len() ) }; let filter_block = Paragraph::new(filter_text) .style(Style::default().fg(Color::Yellow)); f.render_widget(filter_block, chunks[0]); let list_items: Vec = filtered .iter() .enumerate() .skip(state.selected_index.saturating_sub(10)) .take(chunks[1].height as usize - 2) .map(|(idx, item)| { let priority_color = match item.priority { Priority::Critical => Color::Red, Priority::High => Color::Yellow, Priority::Medium => Color::Blue, Priority::Low => Color::Green, }; let content = format!( "[{}] {}:{} - {} ({}/10) - {}", item.keyword, item.location.path.file_name().unwrap_or_default().to_string_lossy(), item.location.line, item.content.chars().take(40).collect::(), item.complexity_score, item.metadata.language ); let style = if *idx == state.selected_index { Style::default() .fg(priority_color) .bg(Color::DarkGray) .add_modifier(Modifier::BOLD) } else { Style::default().fg(priority_color) }; ListItem::new(content).style(style) }) .collect(); let list = List::new(list_items).block( Block::default() .title(format!("Items ({})", filtered.len())) .borders(Borders::ALL), ); f.render_widget(list, chunks[1]); let status_bar = Line::from(vec![ Span::styled(" ↑↓: Navigate | ", Style::default().fg(Color::Gray)), Span::styled("ENTER", Style::default().fg(Color::Yellow).bold()), Span::styled(": View Detail | ", Style::default().fg(Color::Gray)), Span::styled("TAB", Style::default().fg(Color::Yellow).bold()), Span::styled(": Dashboard | ", Style::default().fg(Color::Gray)), Span::styled("f", Style::default().fg(Color::Yellow).bold()), Span::styled(": Filter | ", Style::default().fg(Color::Gray)), Span::styled("s", Style::default().fg(Color::Yellow).bold()), Span::styled(": Sort | ", Style::default().fg(Color::Gray)), Span::styled("q", Style::default().fg(Color::Yellow).bold()), Span::styled(": Quit", Style::default().fg(Color::Gray)), ]); f.render_widget(status_bar, chunks[2]); } fn draw_detail(f: &mut Frame>, state: &TuiState, area: Rect) { let filtered = state.filtered_items(); if filtered.is_empty() { let para = Paragraph::new("No items selected"); f.render_widget(para, area); return; } let item = if let Some(item) = filtered.get(state.selected_index) { item } else { state.selected_index = 0; if let Some(item) = filtered.get(0) { item } else { return; } }; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ]) .split(area); let title = format!( "Detail - {} at {}:{}", item.keyword, item.location.path.display(), item.location.line ); let title_block = Paragraph::new(title) .style(Style::default().fg(Color::Cyan).bold()); f.render_widget(title_block, chunks[0]); let detail_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(chunks[1]); let left_details = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(2), Constraint::Length(2), Constraint::Length(2), Constraint::Length(2), Constraint::Min(0), ]) .split(detail_chunks[0]); let priority_color = match item.priority { Priority::Critical => Color::Red, Priority::High => Color::Yellow, Priority::Medium => Color::Blue, Priority::Low => Color::Green, }; let priority_line = Line::from(vec![ Span::styled("Priority: ", Style::default().fg(Color::Gray)), Span::styled(item.priority.as_str(), Style::default().fg(priority_color).bold()), ]); f.render_widget(priority_line, left_details[0]); let complexity_line = Line::from(vec![ Span::styled("Complexity: ", Style::default().fg(Color::Gray)), Span::styled( format!("{}/10", item.complexity_score), Style::default() .fg(match item.complexity_score { 1..=3 => Color::Green, 4..=6 => Color::Yellow, 7..=8 => Color::Red, _ => Color::Magenta, }) .bold(), ), ]); f.render_widget(complexity_line, left_details[1]); let language_line = Line::from(vec![ Span::styled("Language: ", Style::default().fg(Color::Gray)), Span::styled(&item.metadata.language, Style::default().fg(Color::Cyan)), ]); f.render_widget(language_line, left_details[2]); let type_line = Line::from(vec![ Span::styled("Type: ", Style::default().fg(Color::Gray)), Span::styled( format!("{:?}", item.comment_type), Style::default().fg(Color::Blue), ), ]); f.render_widget(type_line, left_details[3]); let content_block = Block::default().title("Content").borders(Borders::ALL); f.render_widget(content_block, left_details[4]); let content_para = Paragraph::new(&item.content) .wrap(ratatui::text::Wrap { trim: true }); f.render_widget(content_para, left_details[4].inner); let right_details = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(2), Constraint::Length(2), Constraint::Length(2), Constraint::Min(0), ]) .split(detail_chunks[1]); let path_line = Line::from(vec![ Span::styled("File: ", Style::default().fg(Color::Gray)), Span::styled( item.location.path.display().to_string(), Style::default().fg(Color::White), ), ]); f.render_widget(path_line, right_details[0]); let location_line = Line::from(vec![ Span::styled("Line: ", Style::default().fg(Color::Gray)), Span::styled( item.location.line.to_string(), Style::default().fg(Color::White), ), Span::styled(" | Column: ", Style::default().fg(Color::Gray)), Span::styled( item.location.column.to_string(), Style::default().fg(Color::White), ), ]); f.render_widget(location_line, right_details[1]); let question_line = Line::from(vec![ Span::styled("Contains Question: ", Style::default().fg(Color::Gray)), Span::styled( if item.metadata.is_question { "Yes" } else { "No" }, Style::default().fg(if item.metadata.is_question { Color::Yellow } else { Color::Green }), ), ]); f.render_widget(question_line, right_details[2]); let word_count_line = Line::from(vec![ Span::styled("Word Count: ", Style::default().fg(Color::Gray)), Span::styled(item.metadata.word_count.to_string(), Style::default().fg(Color::White)), ]); f.render_widget(word_count_line, right_details[3]); let status_bar = Line::from(vec![ Span::styled(" ←: Back | ", Style::default().fg(Color::Gray)), Span::styled("e", Style::default().fg(Color::Yellow).bold()), Span::styled(": Export | ", Style::default().fg(Color::Gray)), Span::styled("q", Style::default().fg(Color::Yellow).bold()), Span::styled(": Quit", Style::default().fg(Color::Gray)), ]); f.render_widget(status_bar, chunks[2]); } pub fn render(f: &mut Frame>, state: &TuiState) { let area = f.size(); let block = Block::default() .style(Style::default().bg(Color::Black)) .borders(Borders::NONE); f.render_widget(block, area); match state.current_view { View::Dashboard => draw_dashboard(f, state, area), View::List => draw_list(f, state, area), View::Detail => draw_detail(f, state, area), View::Export => { let para = Paragraph::new("Export feature - Coming soon!"); f.render_widget(para, area); } } }