pub mod actions;
pub mod app;
pub mod detail_actions;
pub mod detail_page;
pub mod detail_pages;
pub mod detail_view;
pub mod filter;
pub mod grouping;
pub mod layout;
pub mod list_actions;
pub mod list_state;
pub mod list_view;
pub mod nav_actions;
pub mod nav_state;
pub mod navigation;
pub mod page_availability;
pub mod query_state;
pub mod search;
pub mod sort;
pub mod view_mode;
use anyhow::Result;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use crate::priority::view::PreparedDebtView;
use crate::priority::UnifiedAnalysis;
use app::ResultsApp;
#[inline]
fn is_quit_key(key: &KeyEvent) -> bool {
key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)
}
fn poll_key_event(timeout_ms: u64) -> Result<Option<KeyEvent>> {
if !event::poll(std::time::Duration::from_millis(timeout_ms))? {
return Ok(None);
}
match event::read()? {
Event::Key(key) => Ok(Some(key)),
_ => Ok(None),
}
}
pub struct ResultsExplorer {
terminal: Terminal<CrosstermBackend<io::Stdout>>,
app: ResultsApp,
}
impl ResultsExplorer {
pub fn new(analysis: UnifiedAnalysis) -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
crate::observability::set_tui_active(true);
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
let app = ResultsApp::new(analysis);
Ok(Self { terminal, app })
}
pub fn from_prepared_view(view: PreparedDebtView, analysis: UnifiedAnalysis) -> Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
crate::observability::set_tui_active(true);
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
let app = ResultsApp::from_prepared_view(view, analysis);
Ok(Self { terminal, app })
}
pub fn run(&mut self) -> Result<()> {
loop {
self.app.expire_status_message();
self.render_frame()?;
if self.process_next_event()? {
break;
}
}
self.cleanup()
}
fn render_frame(&mut self) -> Result<()> {
if self.app.take_needs_redraw() {
self.terminal.clear()?;
}
self.terminal.draw(|f| self.app.render(f))?;
Ok(())
}
fn process_next_event(&mut self) -> Result<bool> {
let Some(key) = poll_key_event(100)? else {
return Ok(false);
};
if is_quit_key(&key) {
return Ok(true);
}
self.app.handle_key(key)
}
fn cleanup(&mut self) -> Result<()> {
disable_raw_mode()?;
execute!(
self.terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
self.terminal.show_cursor()?;
crate::observability::set_tui_active(false);
Ok(())
}
}
impl Drop for ResultsExplorer {
fn drop(&mut self) {
let _ = self.cleanup();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEventKind;
fn make_key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}
}
#[test]
fn is_quit_key_detects_ctrl_c() {
let key = make_key_event(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(is_quit_key(&key));
}
#[test]
fn is_quit_key_rejects_plain_c() {
let key = make_key_event(KeyCode::Char('c'), KeyModifiers::NONE);
assert!(!is_quit_key(&key));
}
#[test]
fn is_quit_key_rejects_other_keys_with_ctrl() {
let key = make_key_event(KeyCode::Char('q'), KeyModifiers::CONTROL);
assert!(!is_quit_key(&key));
}
#[test]
fn is_quit_key_rejects_escape() {
let key = make_key_event(KeyCode::Esc, KeyModifiers::NONE);
assert!(!is_quit_key(&key));
}
#[test]
fn is_quit_key_handles_ctrl_c_with_shift() {
let key = make_key_event(
KeyCode::Char('c'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
);
assert!(is_quit_key(&key));
}
}