use super::app::{App, View};
use super::ui;
use crate::diagnostic::DiagnosticResult;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::backend::CrosstermBackend;
use ratatui::prelude::*;
use std::io::Stdout;
use std::time::Duration;
pub fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
) -> DiagnosticResult<()> {
loop {
terminal
.draw(|frame| ui::draw(frame, app))
.map_err(|err| super::terminal_error("draw TUI frame", err))?;
if event::poll(Duration::from_millis(100))
.map_err(|err| super::terminal_error("poll terminal event", err))?
&& let Event::Key(key) =
event::read().map_err(|err| super::terminal_error("read terminal event", err))?
{
handle_key(app, key);
}
if app.should_quit {
break;
}
}
Ok(())
}
pub(super) fn handle_key(app: &mut App, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
if matches!(key.code, KeyCode::Char('?')) {
app.show_help = !app.show_help;
return;
}
if app.show_help {
if matches!(key.code, KeyCode::Esc) {
app.show_help = false;
}
return;
}
match app.view {
View::Dashboard => handle_dashboard_keys(app, key),
View::RfcList
| View::ClauseList
| View::AdrList
| View::WorkList
| View::GuardList
| View::ReleaseList
| View::TagList
| View::LoopList
| View::DiagnosticList => {
if app.filter_mode {
handle_filter_input(app, key);
} else {
handle_list_keys(app, key);
}
}
View::Search => {
if app.search_mode {
handle_search_input(app, key);
} else if app.filter_mode {
handle_filter_input(app, key);
} else {
handle_search_keys(app, key);
}
}
View::LoopDetail(_) => handle_loop_detail_keys(app, key),
View::RfcDetail(_) => handle_rfc_detail_keys(app, key),
View::AdrDetail(_)
| View::WorkDetail(_)
| View::GuardDetail(_)
| View::ClauseDetail(_, _) => {
handle_detail_keys(app, key);
}
}
}
fn is_ctrl(key: &KeyEvent) -> bool {
key.modifiers.contains(KeyModifiers::CONTROL)
}
fn handle_dashboard_keys(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('1') | KeyCode::Char('r') => app.go_to(View::RfcList),
KeyCode::Char('2') | KeyCode::Char('c') => app.go_to(View::ClauseList),
KeyCode::Char('3') | KeyCode::Char('a') => app.go_to(View::AdrList),
KeyCode::Char('4') | KeyCode::Char('w') => app.go_to(View::WorkList),
KeyCode::Char('5') | KeyCode::Char('g') => app.go_to(View::GuardList),
KeyCode::Char('6') | KeyCode::Char('s') => {
app.go_to(View::Search);
app.enter_search_mode();
}
KeyCode::Char('7') | KeyCode::Char('l') => app.go_to(View::LoopList),
KeyCode::Char('8') | KeyCode::Char('d') => app.go_to(View::DiagnosticList),
KeyCode::Char('9') => app.go_to(View::ReleaseList),
KeyCode::Char('t') => app.go_to(View::TagList),
KeyCode::Esc => app.should_quit = true,
_ => {}
}
}
fn handle_list_keys(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('j') | KeyCode::Down => app.select_next(),
KeyCode::Char('k') | KeyCode::Up => app.select_prev(),
KeyCode::Char('g') => app.select_top(),
KeyCode::Char('G') => app.select_bottom(),
KeyCode::Char('d') if is_ctrl(&key) => app.select_half_page_down(),
KeyCode::Char('u') if is_ctrl(&key) => app.select_half_page_up(),
KeyCode::PageDown => app.select_half_page_down(),
KeyCode::PageUp => app.select_half_page_up(),
KeyCode::Char('n') if app.filter_active() => app.select_next(),
KeyCode::Char('p') if app.filter_active() => app.select_prev(),
KeyCode::Enter => app.enter_detail(),
KeyCode::Esc => app.go_back(),
KeyCode::Char('/') => {
app.clear_filter();
app.enter_filter_mode();
}
_ => {}
}
}
fn handle_search_keys(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('j') | KeyCode::Down => app.select_next(),
KeyCode::Char('k') | KeyCode::Up => app.select_prev(),
KeyCode::Char('g') => app.select_top(),
KeyCode::Char('G') => app.select_bottom(),
KeyCode::Enter => app.enter_detail(),
KeyCode::Char('/') | KeyCode::Char('e') => app.enter_search_mode(),
KeyCode::Esc => app.go_back(),
_ => {}
}
}
fn handle_search_input(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => app.exit_search_mode(),
KeyCode::Enter => {
app.exit_search_mode();
app.submit_search();
}
KeyCode::Backspace => app.pop_search_char(),
KeyCode::Char(ch) => app.push_search_char(ch),
_ => {}
}
}
fn handle_rfc_detail_keys(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('j') | KeyCode::Down => app.clause_next(),
KeyCode::Char('k') | KeyCode::Up => app.clause_prev(),
KeyCode::Enter => app.enter_clause_detail(),
KeyCode::Esc => app.go_back(),
_ => {}
}
}
fn handle_detail_keys(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('j') | KeyCode::Down => app.scroll_down(),
KeyCode::Char('k') | KeyCode::Up => app.scroll_up(),
KeyCode::Char('d') if is_ctrl(&key) => app.scroll_half_page_down(),
KeyCode::Char('u') if is_ctrl(&key) => app.scroll_half_page_up(),
KeyCode::PageDown => app.scroll_page_down(),
KeyCode::PageUp => app.scroll_page_up(),
KeyCode::Esc => app.go_back(),
_ => {}
}
}
fn handle_loop_detail_keys(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('j') | KeyCode::Down => app.loop_item_next(),
KeyCode::Char('k') | KeyCode::Up => app.loop_item_prev(),
KeyCode::Esc => app.go_back(),
_ => {}
}
}
fn handle_filter_input(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
app.clear_filter();
app.exit_filter_mode();
}
KeyCode::Enter => app.exit_filter_mode(),
KeyCode::Backspace => app.pop_filter_char(),
KeyCode::Char(ch) => app.push_filter_char(ch),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
ClauseEntry, ClauseKind, ClauseSpec, ClauseStatus, ProjectIndex, RfcIndex, RfcPhase,
RfcSpec, RfcStatus, WorkItemContent, WorkItemEntry, WorkItemMeta, WorkItemSpec,
WorkItemStatus, WorkItemVerification,
};
use std::path::PathBuf;
#[test]
fn handle_key_routes_dashboard_and_help_overlay() {
let mut app = App::new(ProjectIndex::default());
handle_key(&mut app, key(KeyCode::Char('s')));
assert_eq!(app.view, View::Search);
assert!(app.search_mode);
handle_key(&mut app, key(KeyCode::Char('?')));
assert!(app.show_help);
handle_key(&mut app, key(KeyCode::Char('q')));
assert!(!app.should_quit);
handle_key(&mut app, key(KeyCode::Esc));
assert!(!app.show_help);
}
#[test]
fn handle_key_routes_list_filter_and_selection() {
let mut app = App::new(project_index());
app.go_to(View::WorkList);
handle_key(&mut app, key(KeyCode::Char('j')));
assert_eq!(app.selected, 1);
handle_key(&mut app, key(KeyCode::Char('g')));
assert_eq!(app.selected, 0);
handle_key(&mut app, key(KeyCode::Char('G')));
assert_eq!(app.selected, 1);
handle_key(&mut app, key(KeyCode::Char('/')));
assert!(app.filter_mode);
handle_key(&mut app, key(KeyCode::Char('a')));
assert_eq!(app.filter_query, "a");
handle_key(&mut app, key(KeyCode::Backspace));
assert!(app.filter_query.is_empty());
handle_key(&mut app, key(KeyCode::Enter));
assert!(!app.filter_mode);
handle_key(&mut app, key(KeyCode::Enter));
assert_eq!(app.view, View::WorkDetail(1));
}
#[test]
fn handle_key_routes_search_modes() {
let mut app = App::new(ProjectIndex::default());
app.go_to(View::Search);
handle_key(&mut app, key(KeyCode::Char('e')));
assert!(app.search_mode);
handle_key(&mut app, key(KeyCode::Char('r')));
handle_key(&mut app, key(KeyCode::Char('f')));
assert_eq!(app.search_query, "rf");
handle_key(&mut app, key(KeyCode::Backspace));
assert_eq!(app.search_query, "r");
handle_key(&mut app, key(KeyCode::Esc));
assert!(!app.search_mode);
handle_key(&mut app, key(KeyCode::Esc));
assert_eq!(app.view, View::Dashboard);
}
#[test]
fn handle_key_routes_detail_loop_and_rfc_detail_keys() {
let mut app = App::new(project_index());
app.view = View::WorkDetail(0);
app.content_height = 6;
handle_key(&mut app, key(KeyCode::Char('j')));
assert_eq!(app.scroll, 1);
handle_key(&mut app, ctrl_key(KeyCode::Char('d')));
assert_eq!(app.scroll, 4);
handle_key(&mut app, key(KeyCode::Esc));
assert_eq!(app.view, View::WorkList);
app.view = View::RfcDetail(0);
handle_key(&mut app, key(KeyCode::Enter));
assert_eq!(app.view, View::ClauseDetail(0, 0));
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl_key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
fn project_index() -> ProjectIndex {
ProjectIndex {
rfcs: vec![RfcIndex {
rfc: RfcSpec {
rfc_id: "RFC-0001".to_string(),
title: "RFC".to_string(),
version: "0.1.0".to_string(),
status: RfcStatus::Normative,
phase: RfcPhase::Impl,
owners: vec![],
created: "2026-06-07".to_string(),
updated: None,
supersedes: None,
refs: vec![],
tags: vec![],
sections: vec![],
changelog: vec![],
signature: None,
},
clauses: vec![ClauseEntry {
spec: ClauseSpec {
clause_id: "C-TEST".to_string(),
title: "Clause".to_string(),
kind: ClauseKind::Normative,
status: ClauseStatus::Active,
text: "Clause text".to_string(),
anchors: vec![],
superseded_by: None,
since: None,
tags: vec![],
},
path: PathBuf::from("gov/rfc/RFC-0001/clauses/C-TEST.toml"),
}],
path: PathBuf::from("gov/rfc/RFC-0001/rfc.toml"),
}],
adrs: vec![],
work_items: vec![
work_item("WI-2026-06-07-001", "Alpha"),
work_item("WI-2026-06-07-002", "Beta"),
],
}
}
fn work_item(id: &str, title: &str) -> WorkItemEntry {
WorkItemEntry {
spec: WorkItemSpec {
govctl: WorkItemMeta::new(id, title, WorkItemStatus::Active),
content: WorkItemContent::default(),
verification: WorkItemVerification::default(),
},
path: PathBuf::from(format!("gov/work/{id}.toml")),
}
}
}