1pub mod admin;
4pub mod detail;
5pub mod keymap;
6pub mod renderer;
7pub mod search;
8pub mod table;
9
10use std::io::{self, IsTerminal, Stdout};
11use std::time::{Duration, Instant};
12
13use crossterm::event::{self, Event, KeyCode, KeyEvent};
14use crossterm::execute;
15use crossterm::terminal::{
16 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
17};
18use ratatui::backend::CrosstermBackend;
19use ratatui::layout::{Constraint, Direction, Layout, Rect};
20use ratatui::style::{Color, Modifier, Style};
21use ratatui::text::{Line, Span};
22use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
23use ratatui::Terminal;
24
25use crate::error::{CliError, Result};
26use crate::models::{Column, Row};
27
28use self::detail::DetailPanel;
29use self::keymap::{action_for_key, help_items, Action};
30use self::search::SearchState;
31use self::table::TableView;
32
33pub struct TuiApp<'a> {
35 table: TableView,
36 search: SearchState,
37 detail: DetailPanel,
38 show_help: bool,
39 connection_label: String,
40 row_count: usize,
41 processing: bool,
42 status_message: Option<String>,
43 context_message: Option<String>,
44 admin_launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
45 admin_requested: bool,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum EventResult {
51 Continue,
52 Exit,
53}
54
55impl<'a> TuiApp<'a> {
56 pub fn new(
57 columns: Vec<Column>,
58 rows: Vec<Row>,
59 connection_label: impl Into<String>,
60 processing: bool,
61 ) -> Self {
62 let row_count = rows.len();
63 let table = TableView::new(columns, rows);
64 let search = SearchState::default();
65 let detail = DetailPanel::default();
66 Self {
67 table,
68 search,
69 detail,
70 show_help: false,
71 connection_label: connection_label.into(),
72 row_count,
73 processing,
74 status_message: None,
75 context_message: None,
76 admin_launcher: None,
77 admin_requested: false,
78 }
79 }
80
81 pub fn with_admin_launcher(
82 mut self,
83 launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
84 ) -> Self {
85 self.admin_launcher = launcher;
86 self
87 }
88
89 pub fn with_status_message(mut self, message: impl Into<String>) -> Self {
90 self.status_message = Some(message.into());
91 self
92 }
93
94 pub fn with_context_message(mut self, message: Option<String>) -> Self {
95 self.context_message = message;
96 self
97 }
98
99 pub fn run(mut self) -> Result<()> {
100 if !is_tty() {
101 return Err(CliError::InvalidArgument(
102 "TUI requires a TTY. Run without --tui in batch mode.".to_string(),
103 ));
104 }
105 loop {
106 enable_raw_mode()?;
107 let mut stdout = io::stdout();
108 execute!(stdout, EnterAlternateScreen)?;
109
110 let backend = CrosstermBackend::new(stdout);
111 let mut terminal = Terminal::new(backend)?;
112 terminal.clear()?;
113
114 let tick_rate = Duration::from_millis(16);
115 let mut last_tick = Instant::now();
116 let mut processing_cleared = false;
117
118 loop {
119 terminal.draw(|frame| self.draw(frame))?;
120
121 if self.processing && !processing_cleared {
122 self.processing = false;
123 processing_cleared = true;
124 }
125
126 let timeout = tick_rate
127 .checked_sub(last_tick.elapsed())
128 .unwrap_or_else(|| Duration::from_secs(0));
129
130 if event::poll(timeout)? {
131 if let Event::Key(key) = event::read()? {
132 match self.handle_key(key)? {
133 EventResult::Exit => break,
134 EventResult::Continue => {}
135 }
136 }
137 }
138
139 if last_tick.elapsed() >= tick_rate {
140 last_tick = Instant::now();
141 }
142 }
143
144 cleanup_terminal(terminal)?;
145
146 if self.admin_requested {
147 self.admin_requested = false;
148 if let Some(launcher) = self.admin_launcher.as_mut() {
149 launcher()?;
150 continue;
151 }
152 }
153
154 return Ok(());
155 }
156 }
157
158 pub fn draw(&mut self, frame: &mut ratatui::Frame<'_>) {
159 let area = frame.size();
160 let mut constraints = Vec::new();
161 if self.context_message.is_some() {
162 constraints.push(Constraint::Length(3));
163 }
164 constraints.push(Constraint::Min(5));
165 if self.detail.is_visible() {
166 constraints.push(Constraint::Length(8));
167 } else {
168 constraints.push(Constraint::Length(0));
169 }
170 constraints.push(Constraint::Length(3));
171 let chunks = Layout::default()
172 .direction(Direction::Vertical)
173 .constraints(constraints)
174 .split(area);
175 let mut idx = 0;
176 if let Some(context) = self.context_message.as_ref() {
177 let header = Paragraph::new(context.clone())
178 .block(Block::default().borders(Borders::ALL).title("Command"))
179 .wrap(Wrap { trim: true });
180 frame.render_widget(header, chunks[idx]);
181 idx += 1;
182 }
183 let table_area = chunks[idx];
184 let detail_area = chunks[idx + 1];
185 let status_area = chunks[idx + 2];
186
187 self.table.render(frame, table_area, &self.search);
188
189 if self.detail.is_visible() {
190 if let Some(selected) = self.table.selected_row() {
191 self.detail
192 .render(frame, detail_area, self.table.columns(), selected);
193 } else {
194 self.detail.render_empty(frame, detail_area);
195 }
196 }
197
198 let admin_available = self.admin_launcher.is_some();
199 render_status(
200 frame,
201 status_area,
202 &self.search,
203 self.show_help,
204 &self.connection_label,
205 self.row_count,
206 self.processing,
207 self.status_message.as_deref(),
208 admin_available,
209 );
210
211 if self.show_help {
212 render_help(frame, area, admin_available);
213 }
214 }
215
216 pub fn handle_key(&mut self, key: KeyEvent) -> Result<EventResult> {
217 if self.show_help && key.code == KeyCode::Esc {
218 self.show_help = false;
219 return Ok(EventResult::Continue);
220 }
221 if let Some(action) = action_for_key(key, self.search.is_active()) {
222 return self.handle_action(action);
223 }
224 Ok(EventResult::Continue)
225 }
226
227 fn handle_action(&mut self, action: Action) -> Result<EventResult> {
228 match action {
229 Action::Quit => {
230 if self.show_help {
231 self.show_help = false;
232 return Ok(EventResult::Continue);
233 }
234 return Ok(EventResult::Exit);
235 }
236 Action::ToggleHelp => {
237 self.show_help = !self.show_help;
238 }
239 Action::MoveUp => self.table.move_up(),
240 Action::MoveDown => self.table.move_down(),
241 Action::MoveLeft => self.table.move_left(),
242 Action::MoveRight => self.table.move_right(),
243 Action::PageUp => self.table.page_up(),
244 Action::PageDown => self.table.page_down(),
245 Action::JumpTop => self.table.jump_top(),
246 Action::JumpBottom => self.table.jump_bottom(),
247 Action::ToggleDetail => self.detail.toggle(),
248 Action::SearchMode => self.search.activate(),
249 Action::SearchNext => {
250 let next = self.search.next_match(&self.table)?;
251 self.select_match(next);
252 }
253 Action::SearchPrev => {
254 let prev = self.search.prev_match(&self.table)?;
255 self.select_match(prev);
256 }
257 Action::InputChar(ch) => {
258 self.search.push_char(ch, &self.table)?;
259 self.select_match(self.search.current_match());
260 }
261 Action::Backspace => {
262 self.search.backspace(&self.table)?;
263 self.select_match(self.search.current_match());
264 }
265 Action::ConfirmSearch => {
266 self.search.deactivate();
267 self.select_match(self.search.current_match());
268 }
269 Action::CancelSearch => self.search.cancel(),
270 Action::DetailUp => self.detail.scroll_up(),
271 Action::DetailDown => self.detail.scroll_down(),
272 Action::OpenAdmin => {
273 if self.admin_launcher.is_some() {
274 self.admin_requested = true;
275 return Ok(EventResult::Exit);
276 }
277 }
278 }
279 Ok(EventResult::Continue)
280 }
281
282 fn select_match(&mut self, row: Option<usize>) {
283 if let Some(row) = row {
284 self.table.select_row(row);
285 }
286 }
287
288 #[allow(dead_code)]
289 pub fn selected_index(&self) -> Option<usize> {
290 self.table.selected_index()
291 }
292
293 #[allow(dead_code)]
294 pub fn is_detail_visible(&self) -> bool {
295 self.detail.is_visible()
296 }
297
298 #[allow(dead_code)]
299 pub fn is_help_visible(&self) -> bool {
300 self.show_help
301 }
302
303 #[allow(dead_code)]
304 pub fn take_admin_launcher(&mut self) -> Option<Box<dyn FnMut() -> Result<()> + 'a>> {
305 self.admin_launcher.take()
306 }
307
308 #[allow(dead_code)]
309 pub fn admin_requested(&self) -> bool {
310 self.admin_requested
311 }
312}
313
314#[allow(clippy::too_many_arguments)]
315fn render_status(
316 frame: &mut ratatui::Frame<'_>,
317 area: Rect,
318 search: &SearchState,
319 show_help: bool,
320 connection_label: &str,
321 row_count: usize,
322 processing: bool,
323 status_message: Option<&str>,
324 admin_available: bool,
325) {
326 let state_label = if processing { "processing" } else { "ready" };
327 let focus_label = if show_help {
328 "Help"
329 } else if search.is_active() || search.has_query() {
330 "Search"
331 } else {
332 "Table"
333 };
334 let action_label = if show_help {
335 "help"
336 } else if search.is_active() || search.has_query() {
337 "search"
338 } else {
339 "browse"
340 };
341 let highlight = Style::default()
342 .fg(Color::Yellow)
343 .add_modifier(Modifier::BOLD);
344
345 let mut spans = Vec::new();
346 let push_sep = |spans: &mut Vec<Span<'_>>| {
347 spans.push(Span::raw(" | "));
348 };
349
350 spans.push(Span::raw("Connection: "));
351 spans.push(Span::styled(connection_label.to_string(), highlight));
352 push_sep(&mut spans);
353 spans.push(Span::raw("Focus: "));
354 spans.push(Span::styled(focus_label.to_string(), highlight));
355 push_sep(&mut spans);
356 spans.push(Span::raw("Action: "));
357 spans.push(Span::styled(action_label.to_string(), highlight));
358 spans.push(Span::raw(format!(
359 " (Rows: {row_count}, Status: {state_label})"
360 )));
361 if search.is_active() || search.has_query() {
362 push_sep(&mut spans);
363 spans.push(Span::raw(format!("Query: /{}", search.query())));
364 }
365 push_sep(&mut spans);
366
367 let (ops_text, move_text) = if show_help {
368 ("?: close".to_string(), "-".to_string())
369 } else if search.is_active() {
370 (
371 "Enter: confirm, Esc: cancel".to_string(),
372 "n/N: next/prev".to_string(),
373 )
374 } else if search.has_query() {
375 ("/: search".to_string(), "n/N: next/prev".to_string())
376 } else {
377 let mut ops = vec!["Enter: detail", "/: search", "?: help", "q/Esc: quit"];
378 if admin_available {
379 ops.insert(2, "a: admin/back");
380 }
381 (ops.join(", "), "j/k, h/l, g/G, Ctrl+d/u".to_string())
382 };
383
384 spans.push(Span::styled(format!("Ops: {ops_text}"), highlight));
385 push_sep(&mut spans);
386 if move_text == "-" {
387 spans.push(Span::raw("Move: -"));
388 } else {
389 spans.push(Span::raw(format!("Move: {move_text}")));
390 }
391
392 if let Some(message) = status_message {
393 push_sep(&mut spans);
394 spans.push(Span::raw(message.to_string()));
395 }
396
397 let paragraph = Paragraph::new(Line::from(spans))
398 .block(Block::default().borders(Borders::ALL).title("Status"))
399 .style(Style::default().fg(Color::Gray))
400 .wrap(Wrap { trim: true });
401 frame.render_widget(paragraph, area);
402}
403
404fn render_help(frame: &mut ratatui::Frame<'_>, area: Rect, admin_available: bool) {
405 let help_width = area.width.saturating_sub(4).min(60);
406 let help_height = area.height.saturating_sub(4).min(18);
407 let rect = Rect::new(
408 area.x + (area.width.saturating_sub(help_width)) / 2,
409 area.y + (area.height.saturating_sub(help_height)) / 2,
410 help_width,
411 help_height,
412 );
413
414 let lines = help_items(admin_available)
415 .iter()
416 .map(|(key, desc)| format!("{key:<8} {desc}"))
417 .collect::<Vec<_>>()
418 .join("\n");
419
420 let help = Paragraph::new(lines)
421 .block(Block::default().borders(Borders::ALL).title("Help"))
422 .wrap(Wrap { trim: true });
423 frame.render_widget(help, rect);
424}
425
426fn cleanup_terminal(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
427 disable_raw_mode()?;
428 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
429 terminal.show_cursor()?;
430 Ok(())
431}
432
433pub fn is_tty() -> bool {
434 let forced = std::env::var("ALOPEX_TEST_TTY")
435 .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE"))
436 .unwrap_or(false);
437 forced || (std::io::stdout().is_terminal() && std::io::stdin().is_terminal())
438}