use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::{Backend, CrosstermBackend},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
};
use std::io;
use std::path::PathBuf;
use crate::parser::{FileParseResult, parse_file};
use crate::tui::content::build_content;
use crate::verifier::{VerificationResult, Verifier};
pub struct CheckTui {
pub dir: PathBuf,
pub files: Vec<PathBuf>,
pub selected: usize,
pub focus_tree: bool,
current_file: Option<FileState>,
pub scroll_v: usize,
pub show_detail: bool,
}
struct FileState {
#[allow(dead_code)]
path: PathBuf,
parsed: FileParseResult,
results: Vec<VerificationResult>,
}
impl CheckTui {
pub fn new(dir: PathBuf) -> io::Result<Self> {
let files = collect_lore_files(&dir);
Ok(CheckTui {
dir,
files,
selected: 0,
focus_tree: true,
current_file: None,
scroll_v: 0,
show_detail: false,
})
}
pub fn run(&mut self) -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = self.run_app(&mut terminal);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
res
}
fn run_app<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
loop {
terminal.draw(|f| self.draw(f))?;
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('b') => self.focus_tree = !self.focus_tree,
KeyCode::Backspace => {
if let Some(parent) = self.dir.parent() {
self.dir = parent.to_path_buf();
self.files = collect_lore_files(&self.dir);
self.selected = 0;
self.current_file = None;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.focus_tree {
let max = if self.dir.parent().is_some() {
self.files.len()
} else {
self.files.len().saturating_sub(1)
};
self.selected = (self.selected + 1).min(max);
} else {
self.scroll_v = self.scroll_v.saturating_add(1);
}
}
KeyCode::Up | KeyCode::Char('k') => {
if self.focus_tree {
self.selected = self.selected.saturating_sub(1);
} else {
self.scroll_v = self.scroll_v.saturating_sub(1);
}
}
KeyCode::Enter => {
if self.focus_tree {
self.open_selected();
} else {
self.show_detail = !self.show_detail;
}
}
KeyCode::Esc => {
if !self.focus_tree {
self.show_detail = false;
}
}
_ => {}
}
}
}
}
fn open_selected(&mut self) {
if self.selected == 0 && self.dir.parent().is_some() {
self.dir = self.dir.parent().unwrap().to_path_buf();
self.files = collect_lore_files(&self.dir);
self.selected = 0;
self.current_file = None;
return;
}
let offset = if self.dir.parent().is_some() { 1 } else { 0 };
let idx = self.selected.saturating_sub(offset);
if idx >= self.files.len() {
return;
}
let path = self.files[idx].clone();
if path.is_dir() {
self.dir = path;
self.files = collect_lore_files(&self.dir);
self.selected = 0;
self.current_file = None;
return;
}
if let Ok(parsed) = parse_file(&path) {
let results = Verifier::new().verify_file(&parsed);
self.current_file = Some(FileState {
path,
parsed,
results,
});
self.scroll_v = 0;
self.focus_tree = false;
}
}
pub fn draw(&mut self, f: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([Constraint::Min(4), Constraint::Length(1)])
.split(f.size());
let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(chunks[0]);
self.draw_content(f, main[0]);
self.draw_tree(f, main[1]);
let footer = format!(" {} b:switch Enter:open q:quit ", self.dir.display());
f.render_widget(
Paragraph::new(footer).style(Style::default().fg(Color::Gray)),
chunks[1],
);
}
fn draw_tree(&mut self, f: &mut Frame, area: Rect) {
let items: Vec<ListItem> = self
.files
.iter()
.enumerate()
.map(|(i, p)| {
let name = p.file_name().unwrap_or_default().to_string_lossy();
let prefix = if p.is_dir() { "+ " } else { " " };
let style = if i == self.selected && self.focus_tree {
Style::default().bg(Color::DarkGray).fg(Color::Yellow)
} else if i == self.selected {
Style::default().bg(Color::DarkGray)
} else if p.is_dir() {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
ListItem::new(format!(" {}{}", prefix, name)).style(style)
})
.collect();
f.render_widget(
List::new(items).block(Block::default().borders(Borders::ALL).title(" Files ")),
area,
);
}
fn draw_content(&self, f: &mut Frame, area: Rect) {
if let Some(ref state) = self.current_file {
let widths = state
.parsed
.steps
.first()
.map_or(&[] as &[usize], |s| s.col_widths.as_slice());
let lines = build_content(
&state.parsed.ori,
&state.parsed.steps,
&state.results,
self.show_detail,
widths,
);
let max_v = lines.len().saturating_sub(area.height as usize);
let scroll = self.scroll_v.min(max_v);
f.render_widget(Paragraph::new(lines).scroll((scroll as u16, 0)), area);
} else {
f.render_widget(
Paragraph::new("Select a .lore file and press Enter")
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center),
area,
);
}
}
}
fn collect_lore_files(dir: &PathBuf) -> Vec<PathBuf> {
let mut files = vec![];
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() || path.extension().map_or(false, |e| e == "lore") {
files.push(path);
}
}
}
files.sort_by(|a, b| {
let ad = a.is_dir();
let bd = b.is_dir();
if ad != bd {
return bd.cmp(&ad);
}
a.file_name().cmp(&b.file_name())
});
files
}