alopex_cli/tui/
mod.rs

1//! TUI application module.
2
3pub 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
33/// TUI application state.
34pub 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/// Result of handling an input event.
49#[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}