//! Ratatui TUI for interactive ticket management. use color_eyre::eyre::Result; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, style::{Modifier, Style, Stylize}, text::{Line, Span}, widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState}, }; use rusqlite::Connection; use crate::db::{self, ListFilter}; use crate::types::{Channel, Priority, Status, Ticket}; // -- View state --------------------------------------------------------------- enum View { List, Detail, Create, Edit, } #[derive(Clone, Copy, PartialEq, Eq)] enum EditFocus { Title, Body, } struct EditBuf { id: String, title: String, body: String, focus: EditFocus, } enum InputMode { Normal, Search, } // -- App ---------------------------------------------------------------------- struct App { conn: Connection, node_id: String, tickets: Vec, table_state: TableState, view: View, input_mode: InputMode, status_filter: Option, priority_filter: Option, channel_filter: Option, source_filter: Option, all_sources: Vec, search_query: String, create_input: String, edit_buf: Option, running: bool, } impl App { fn new(conn: Connection, node_id: String) -> Result { let mut app = Self { conn, node_id, tickets: Vec::new(), table_state: TableState::default(), view: View::List, input_mode: InputMode::Normal, status_filter: None, priority_filter: None, channel_filter: None, source_filter: None, all_sources: Vec::new(), search_query: String::new(), create_input: String::new(), edit_buf: None, running: true, }; app.reload_sources()?; app.refresh()?; if !app.tickets.is_empty() { app.table_state.select(Some(0)); } Ok(app) } fn refresh(&mut self) -> Result<()> { let search = if self.search_query.is_empty() { None } else { Some(self.search_query.as_str()) }; self.tickets = db::list_tickets( &self.conn, &ListFilter { status: self.status_filter, priority: self.priority_filter, channel: self.channel_filter, source: self.source_filter.as_deref(), search, }, )?; // Keep selection in bounds if self.tickets.is_empty() { self.table_state.select(None); } else if let Some(i) = self.table_state.selected() { if i >= self.tickets.len() { self.table_state.select(Some(self.tickets.len() - 1)); } } Ok(()) } fn selected_ticket(&self) -> Option<&Ticket> { self.table_state.selected().and_then(|i| self.tickets.get(i)) } fn set_selected_status(&mut self, status: Status) -> Result<()> { if let Some(ticket) = self.selected_ticket() { let id = ticket.id.clone(); db::update_status(&self.conn, &id, status)?; self.refresh()?; } Ok(()) } fn cycle_status_filter(&mut self) -> Result<()> { self.status_filter = match self.status_filter { None => Some(Status::Open), Some(Status::Open) => Some(Status::InProgress), Some(Status::InProgress) => Some(Status::Resolved), Some(Status::Resolved) => Some(Status::Closed), Some(Status::Closed) => None, }; self.refresh() } fn cycle_priority_filter(&mut self) -> Result<()> { self.priority_filter = match self.priority_filter { None => Some(Priority::Critical), Some(Priority::Critical) => Some(Priority::High), Some(Priority::High) => Some(Priority::Medium), Some(Priority::Medium) => Some(Priority::Low), Some(Priority::Low) => None, }; self.refresh() } fn cycle_source_filter(&mut self) -> Result<()> { self.reload_sources()?; self.source_filter = match &self.source_filter { None if !self.all_sources.is_empty() => Some(self.all_sources[0].clone()), Some(current) => { let idx = self.all_sources.iter().position(|s| s == current); match idx { Some(i) if i + 1 < self.all_sources.len() => { Some(self.all_sources[i + 1].clone()) } _ => None, } } None => None, }; self.refresh() } fn reload_sources(&mut self) -> Result<()> { let all = db::list_tickets(&self.conn, &ListFilter::default())?; let mut sources: Vec = all .iter() .filter_map(|t| t.source.clone()) .collect(); sources.sort(); sources.dedup(); self.all_sources = sources; Ok(()) } fn cycle_channel_filter(&mut self) -> Result<()> { self.channel_filter = match self.channel_filter { None => Some(Channel::System), Some(Channel::System) => Some(Channel::Request), Some(Channel::Request) => Some(Channel::Task), Some(Channel::Task) => None, }; self.refresh() } fn enter_edit(&mut self) { if let Some(t) = self.selected_ticket() { self.edit_buf = Some(EditBuf { id: t.id.clone(), title: t.title.clone(), body: t.body.clone().unwrap_or_default(), focus: EditFocus::Title, }); self.view = View::Edit; } } fn submit_edit(&mut self) -> Result<()> { if let Some(buf) = self.edit_buf.take() { let title = buf.title.trim(); if !title.is_empty() { let body = if buf.body.is_empty() { None } else { Some(buf.body.as_str()) }; db::update_ticket(&self.conn, &buf.id, title, body)?; self.refresh()?; } } self.view = View::Detail; Ok(()) } fn submit_create(&mut self) -> Result<()> { let title = self.create_input.trim().to_string(); if !title.is_empty() { db::create_ticket( &self.conn, &crate::types::NewTicket { title, body: None, priority: Priority::Medium, channel: Channel::Task, source: Some("manual".into()), source_ref: None, }, &self.node_id, )?; self.refresh()?; } self.create_input.clear(); self.view = View::List; Ok(()) } } // -- Entry point -------------------------------------------------------------- pub fn run(conn: Connection, node_id: String) -> Result<()> { let mut terminal = ratatui::init(); let mut app = App::new(conn, node_id)?; while app.running { terminal.draw(|f| render(&mut app, f))?; if event::poll(std::time::Duration::from_millis(250))? { if let Event::Key(key) = event::read()? { if key.kind != KeyEventKind::Press { continue; } handle_key(&mut app, key.code)?; } } } ratatui::restore(); Ok(()) } // -- Input handling ----------------------------------------------------------- fn handle_key(app: &mut App, key: KeyCode) -> Result<()> { // Search input mode captures all keys if matches!(app.input_mode, InputMode::Search) { match key { KeyCode::Esc => { app.search_query.clear(); app.input_mode = InputMode::Normal; app.refresh()?; } KeyCode::Enter => { app.input_mode = InputMode::Normal; } KeyCode::Backspace => { app.search_query.pop(); app.refresh()?; } KeyCode::Char(c) => { app.search_query.push(c); app.refresh()?; } _ => {} } return Ok(()); } // Create input mode if matches!(app.view, View::Create) { match key { KeyCode::Esc => { app.create_input.clear(); app.view = View::List; } KeyCode::Enter => app.submit_create()?, KeyCode::Backspace => { app.create_input.pop(); } KeyCode::Char(c) => app.create_input.push(c), _ => {} } return Ok(()); } // Edit input mode if matches!(app.view, View::Edit) { match key { KeyCode::Esc => { app.edit_buf = None; app.view = View::Detail; } KeyCode::Tab | KeyCode::BackTab => { if let Some(buf) = app.edit_buf.as_mut() { buf.focus = match buf.focus { EditFocus::Title => EditFocus::Body, EditFocus::Body => EditFocus::Title, }; } } KeyCode::Enter => { if let Some(buf) = app.edit_buf.as_mut() { match buf.focus { EditFocus::Title => buf.focus = EditFocus::Body, EditFocus::Body => { app.submit_edit()?; } } } } KeyCode::Backspace => { if let Some(buf) = app.edit_buf.as_mut() { match buf.focus { EditFocus::Title => { buf.title.pop(); } EditFocus::Body => { buf.body.pop(); } } } } KeyCode::Char(c) => { if let Some(buf) = app.edit_buf.as_mut() { match buf.focus { EditFocus::Title => buf.title.push(c), EditFocus::Body => buf.body.push(c), } } } _ => {} } return Ok(()); } match app.view { View::List => match key { KeyCode::Char('q') => app.running = false, KeyCode::Char('j') | KeyCode::Down => { let next = app.table_state.selected().map_or(0, |i| { (i + 1).min(app.tickets.len().saturating_sub(1)) }); app.table_state.select(Some(next)); } KeyCode::Char('k') | KeyCode::Up => { let prev = app.table_state.selected().map_or(0, |i| i.saturating_sub(1)); app.table_state.select(Some(prev)); } KeyCode::Enter => { if app.selected_ticket().is_some() { app.view = View::Detail; } } KeyCode::Char('o') => app.set_selected_status(Status::Open)?, KeyCode::Char('i') => app.set_selected_status(Status::InProgress)?, KeyCode::Char('r') => app.set_selected_status(Status::Resolved)?, KeyCode::Char('c') => app.set_selected_status(Status::Closed)?, KeyCode::Char('n') => { app.create_input.clear(); app.view = View::Create; } KeyCode::Char('f') => app.cycle_status_filter()?, KeyCode::Char('p') => app.cycle_priority_filter()?, KeyCode::Char('s') => app.cycle_source_filter()?, KeyCode::Char('t') => app.cycle_channel_filter()?, KeyCode::Char('/') => { app.search_query.clear(); app.input_mode = InputMode::Search; } _ => {} }, View::Detail => match key { KeyCode::Esc | KeyCode::Char('q') => app.view = View::List, KeyCode::Char('o') => { app.set_selected_status(Status::Open)?; } KeyCode::Char('i') => { app.set_selected_status(Status::InProgress)?; } KeyCode::Char('r') => { app.set_selected_status(Status::Resolved)?; } KeyCode::Char('c') => { app.set_selected_status(Status::Closed)?; } KeyCode::Char('e') => app.enter_edit(), _ => {} }, View::Create | View::Edit => {} // handled above } Ok(()) } // -- Rendering ---------------------------------------------------------------- fn render(app: &mut App, f: &mut Frame) { let [title_area, main_area, status_area] = Layout::vertical([ Constraint::Length(1), Constraint::Fill(1), Constraint::Length(1), ]) .areas(f.area()); render_title_bar(app, f, title_area); match app.view { View::List => render_list(app, f, main_area), View::Detail => render_detail(app, f, main_area), View::Create => { render_list(app, f, main_area); render_create_popup(app, f, f.area()); } View::Edit => { render_detail(app, f, main_area); render_edit_popup(app, f, f.area()); } } render_status_bar(app, f, status_area); } fn render_title_bar(app: &App, f: &mut Frame, area: Rect) { let mut spans = vec![Span::styled(" WAM ", Style::new().bold().reversed())]; // Show active filters let mut filters: Vec = Vec::new(); if let Some(status) = app.status_filter { filters.push(format!("status:{status}")); } if let Some(priority) = app.priority_filter { filters.push(format!("pri:{priority}")); } if let Some(channel) = app.channel_filter { filters.push(format!("ch:{channel}")); } if let Some(ref source) = app.source_filter { filters.push(format!("src:{source}")); } if !filters.is_empty() { spans.push(Span::raw(" ")); spans.push(Span::styled(filters.join(" "), Style::new().bold())); } if matches!(app.input_mode, InputMode::Search) { spans.push(Span::raw(" /")); spans.push(Span::styled(&app.search_query, Style::new().bold().underlined())); } else if !app.search_query.is_empty() { spans.push(Span::raw(" search: ")); spans.push(Span::styled(&app.search_query, Style::new().bold())); } let count = format!(" {} tickets ", app.tickets.len()); spans.push(Span::raw(count)); f.render_widget(Line::from(spans), area); } fn render_list(app: &mut App, f: &mut Frame, area: Rect) { let header = Row::new(["Pri", "Title", "Ch", "Source", "Status", "Node", "Age"]) .style(Style::new().bold().underlined()); let rows: Vec = app .tickets .iter() .map(|t| { let pri_style = Style::new().fg(t.priority.color()); Row::new([ Cell::from(format!(" {} ", t.priority.to_string().chars().next().unwrap_or(' '))) .style(pri_style), Cell::from(t.title.as_str()), Cell::from(t.channel.to_string()), Cell::from(t.source.as_deref().unwrap_or("-")), Cell::from(format!("{} {}", t.status.indicator(), t.status)), Cell::from(t.short_node()), Cell::from(t.age()), ]) }) .collect(); let widths = [ Constraint::Length(5), Constraint::Fill(1), Constraint::Length(8), Constraint::Length(14), Constraint::Length(14), Constraint::Length(10), Constraint::Length(5), ]; let table = Table::new(rows, widths) .header(header) .row_highlight_style(Style::new().add_modifier(Modifier::REVERSED)); f.render_stateful_widget(table, area, &mut app.table_state); } fn render_detail(app: &App, f: &mut Frame, area: Rect) { let ticket = match app.selected_ticket() { Some(t) => t, None => { f.render_widget(Paragraph::new("No ticket selected"), area); return; } }; let mut lines = vec![ Line::from(vec![ Span::styled("ID: ", Style::new().bold()), Span::raw(&ticket.id), ]), Line::from(vec![ Span::styled("Title: ", Style::new().bold()), Span::raw(&ticket.title), ]), Line::from(vec![ Span::styled("Channel: ", Style::new().bold()), Span::raw(ticket.channel.to_string()), ]), Line::from(vec![ Span::styled("Priority: ", Style::new().bold()), Span::styled(ticket.priority.to_string(), Style::new().fg(ticket.priority.color())), ]), Line::from(vec![ Span::styled("Status: ", Style::new().bold()), Span::raw(format!("{} {}", ticket.status.indicator(), ticket.status)), ]), Line::from(vec![ Span::styled("Node: ", Style::new().bold()), Span::raw(&ticket.node_id), ]), Line::from(vec![ Span::styled("Source: ", Style::new().bold()), Span::raw(ticket.source.as_deref().unwrap_or("-")), ]), Line::from(vec![ Span::styled("Ref: ", Style::new().bold()), Span::raw(ticket.source_ref.as_deref().unwrap_or("-")), ]), Line::from(vec![ Span::styled("Created: ", Style::new().bold()), Span::raw(ticket.created_at.format("%Y-%m-%d %H:%M UTC").to_string()), ]), Line::from(vec![ Span::styled("Updated: ", Style::new().bold()), Span::raw(ticket.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()), ]), ]; if let Some(ref resolved) = ticket.resolved_at { lines.push(Line::from(vec![ Span::styled("Resolved: ", Style::new().bold()), Span::raw(resolved.format("%Y-%m-%d %H:%M UTC").to_string()), ])); } lines.push(Line::raw("")); if let Some(ref body) = ticket.body { lines.push(Line::styled("--- Body ---", Style::new().bold())); for line in body.lines() { lines.push(Line::raw(line)); } } else { lines.push(Line::styled("(no body)", Style::new().dim())); } let block = Block::default() .borders(Borders::ALL) .title(" Ticket Detail "); f.render_widget(Paragraph::new(lines).block(block), area); } fn render_create_popup(app: &App, f: &mut Frame, area: Rect) { let popup_width = 50.min(area.width.saturating_sub(4)); let popup_height = 5; let popup_area = Rect { x: (area.width.saturating_sub(popup_width)) / 2, y: (area.height.saturating_sub(popup_height)) / 2, width: popup_width, height: popup_height, }; f.render_widget(Clear, popup_area); let block = Block::default() .borders(Borders::ALL) .title(" New Ticket "); let inner = block.inner(popup_area); f.render_widget(block, popup_area); let lines = vec![ Line::from(vec![ Span::raw("Title: "), Span::styled(&app.create_input, Style::new().underlined()), Span::styled("_", Style::new().rapid_blink()), ]), Line::raw(""), Line::styled("Enter: create Esc: cancel", Style::new().dim()), ]; f.render_widget(Paragraph::new(lines), inner); } fn render_edit_popup(app: &App, f: &mut Frame, area: Rect) { let buf = match app.edit_buf.as_ref() { Some(b) => b, None => return, }; let popup_width = 70.min(area.width.saturating_sub(4)); let popup_height = 10.min(area.height.saturating_sub(4)); let popup_area = Rect { x: (area.width.saturating_sub(popup_width)) / 2, y: (area.height.saturating_sub(popup_height)) / 2, width: popup_width, height: popup_height, }; f.render_widget(Clear, popup_area); let block = Block::default() .borders(Borders::ALL) .title(" Edit Ticket "); let inner = block.inner(popup_area); f.render_widget(block, popup_area); let title_focused = buf.focus == EditFocus::Title; let body_focused = buf.focus == EditFocus::Body; let cursor = "_"; let mut lines = vec![ Line::from(vec![ Span::styled(if title_focused { "> Title: " } else { " Title: " }, Style::new().bold()), Span::styled(&buf.title, Style::new().underlined()), if title_focused { Span::styled(cursor, Style::new().rapid_blink()) } else { Span::raw("") }, ]), Line::raw(""), Line::from(vec![ Span::styled(if body_focused { "> Body: " } else { " Body: " }, Style::new().bold()), ]), ]; let body_display = if buf.body.is_empty() && !body_focused { Line::styled(" (empty)", Style::new().dim()) } else { Line::from(vec![ Span::raw(" "), Span::raw(&buf.body), if body_focused { Span::styled(cursor, Style::new().rapid_blink()) } else { Span::raw("") }, ]) }; lines.push(body_display); lines.push(Line::raw("")); lines.push(Line::styled( "Tab: switch field Enter: next/save Esc: cancel", Style::new().dim(), )); f.render_widget(Paragraph::new(lines), inner); } fn render_status_bar(app: &App, f: &mut Frame, area: Rect) { let hints = match app.view { View::List => match app.input_mode { InputMode::Search => "Enter: apply Esc: clear Type to search", InputMode::Normal => "j/k:nav Enter:open n:new o/i/r/c:status f:status p:pri t:channel s:src /:search q:quit", }, View::Detail => "o/i/r/c:status e:edit Esc:back", View::Create => "Enter:create Esc:cancel", View::Edit => "Tab:switch Enter:next/save Esc:cancel", }; f.render_widget( Line::from(Span::styled(format!(" {hints}"), Style::new().dim())), area, ); }