alopex_cli/tui/
mod.rs

1//! TUI application module.
2
3pub mod detail;
4pub mod keymap;
5pub mod search;
6pub mod table;
7
8use std::io::{self, IsTerminal, Stdout};
9use std::time::{Duration, Instant};
10
11use crossterm::event::{self, Event, KeyEvent};
12use crossterm::execute;
13use crossterm::terminal::{
14    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
15};
16use ratatui::backend::CrosstermBackend;
17use ratatui::layout::{Constraint, Direction, Layout, Rect};
18use ratatui::style::{Color, Style};
19use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
20use ratatui::Terminal;
21
22use crate::error::{CliError, Result};
23use crate::models::{Column, Row};
24
25use self::detail::DetailPanel;
26use self::keymap::{action_for_key, help_items, Action};
27use self::search::SearchState;
28use self::table::TableView;
29
30/// TUI application state.
31pub struct TuiApp {
32    table: TableView,
33    search: SearchState,
34    detail: DetailPanel,
35    show_help: bool,
36    connection_label: String,
37    row_count: usize,
38    processing: bool,
39}
40
41/// Result of handling an input event.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum EventResult {
44    Continue,
45    Exit,
46}
47
48impl TuiApp {
49    pub fn new(
50        columns: Vec<Column>,
51        rows: Vec<Row>,
52        connection_label: impl Into<String>,
53        processing: bool,
54    ) -> Self {
55        let row_count = rows.len();
56        let table = TableView::new(columns, rows);
57        let search = SearchState::default();
58        let detail = DetailPanel::default();
59        Self {
60            table,
61            search,
62            detail,
63            show_help: false,
64            connection_label: connection_label.into(),
65            row_count,
66            processing,
67        }
68    }
69
70    pub fn run(mut self) -> Result<()> {
71        if !is_tty() {
72            return Err(CliError::InvalidArgument(
73                "TUI requires a TTY. Run without --tui in batch mode.".to_string(),
74            ));
75        }
76        enable_raw_mode()?;
77        let mut stdout = io::stdout();
78        execute!(stdout, EnterAlternateScreen)?;
79
80        let backend = CrosstermBackend::new(stdout);
81        let mut terminal = Terminal::new(backend)?;
82        terminal.clear()?;
83
84        let tick_rate = Duration::from_millis(16);
85        let mut last_tick = Instant::now();
86
87        loop {
88            terminal.draw(|frame| self.draw(frame))?;
89
90            let timeout = tick_rate
91                .checked_sub(last_tick.elapsed())
92                .unwrap_or_else(|| Duration::from_secs(0));
93
94            if event::poll(timeout)? {
95                if let Event::Key(key) = event::read()? {
96                    match self.handle_key(key)? {
97                        EventResult::Exit => break,
98                        EventResult::Continue => {}
99                    }
100                }
101            }
102
103            if last_tick.elapsed() >= tick_rate {
104                last_tick = Instant::now();
105            }
106        }
107
108        cleanup_terminal(terminal)
109    }
110
111    pub fn draw(&mut self, frame: &mut ratatui::Frame<'_>) {
112        let area = frame.size();
113        let (table_area, detail_area, status_area) = split_layout(area, self.detail.is_visible());
114
115        self.table.render(frame, table_area, &self.search);
116
117        if self.detail.is_visible() {
118            if let Some(selected) = self.table.selected_row() {
119                self.detail
120                    .render(frame, detail_area, self.table.columns(), selected);
121            } else {
122                self.detail.render_empty(frame, detail_area);
123            }
124        }
125
126        render_status(
127            frame,
128            status_area,
129            &self.search,
130            self.show_help,
131            &self.connection_label,
132            self.row_count,
133            self.processing,
134        );
135
136        if self.show_help {
137            render_help(frame, area);
138        }
139    }
140
141    pub fn handle_key(&mut self, key: KeyEvent) -> Result<EventResult> {
142        if let Some(action) = action_for_key(key, self.search.is_active()) {
143            return self.handle_action(action);
144        }
145        Ok(EventResult::Continue)
146    }
147
148    fn handle_action(&mut self, action: Action) -> Result<EventResult> {
149        match action {
150            Action::Quit => return Ok(EventResult::Exit),
151            Action::ToggleHelp => {
152                self.show_help = !self.show_help;
153            }
154            Action::MoveUp => self.table.move_up(),
155            Action::MoveDown => self.table.move_down(),
156            Action::MoveLeft => self.table.move_left(),
157            Action::MoveRight => self.table.move_right(),
158            Action::PageUp => self.table.page_up(),
159            Action::PageDown => self.table.page_down(),
160            Action::JumpTop => self.table.jump_top(),
161            Action::JumpBottom => self.table.jump_bottom(),
162            Action::ToggleDetail => self.detail.toggle(),
163            Action::SearchMode => self.search.activate(),
164            Action::SearchNext => {
165                let next = self.search.next_match(&self.table)?;
166                self.select_match(next);
167            }
168            Action::SearchPrev => {
169                let prev = self.search.prev_match(&self.table)?;
170                self.select_match(prev);
171            }
172            Action::InputChar(ch) => {
173                self.search.push_char(ch, &self.table)?;
174                self.select_match(self.search.current_match());
175            }
176            Action::Backspace => {
177                self.search.backspace(&self.table)?;
178                self.select_match(self.search.current_match());
179            }
180            Action::ConfirmSearch => {
181                self.search.deactivate();
182                self.select_match(self.search.current_match());
183            }
184            Action::CancelSearch => self.search.cancel(),
185            Action::DetailUp => self.detail.scroll_up(),
186            Action::DetailDown => self.detail.scroll_down(),
187        }
188        Ok(EventResult::Continue)
189    }
190
191    fn select_match(&mut self, row: Option<usize>) {
192        if let Some(row) = row {
193            self.table.select_row(row);
194        }
195    }
196
197    #[allow(dead_code)]
198    pub fn selected_index(&self) -> Option<usize> {
199        self.table.selected_index()
200    }
201
202    #[allow(dead_code)]
203    pub fn is_detail_visible(&self) -> bool {
204        self.detail.is_visible()
205    }
206
207    #[allow(dead_code)]
208    pub fn is_help_visible(&self) -> bool {
209        self.show_help
210    }
211}
212
213fn split_layout(area: Rect, show_detail: bool) -> (Rect, Rect, Rect) {
214    let chunks = if show_detail {
215        Layout::default()
216            .direction(Direction::Vertical)
217            .constraints([
218                Constraint::Min(5),
219                Constraint::Length(8),
220                Constraint::Length(3),
221            ])
222            .split(area)
223    } else {
224        Layout::default()
225            .direction(Direction::Vertical)
226            .constraints([
227                Constraint::Min(5),
228                Constraint::Length(0),
229                Constraint::Length(3),
230            ])
231            .split(area)
232    };
233
234    (chunks[0], chunks[1], chunks[2])
235}
236
237fn render_status(
238    frame: &mut ratatui::Frame<'_>,
239    area: Rect,
240    search: &SearchState,
241    show_help: bool,
242    connection_label: &str,
243    row_count: usize,
244    processing: bool,
245) {
246    let state_label = if processing { "processing" } else { "ready" };
247    let base_status =
248        format!("Connection: {connection_label} | Rows: {row_count} | Status: {state_label}");
249    let status_text = if show_help {
250        format!("{base_status} | Help: press ? to close")
251    } else if search.is_active() {
252        format!("{base_status} | /{}", search.query())
253    } else if search.has_query() {
254        format!("{base_status} | /{} (n/N)", search.query())
255    } else {
256        format!("{base_status} | q/Esc: quit | ?: help | /: search | Enter: detail")
257    };
258
259    let paragraph = Paragraph::new(status_text)
260        .block(Block::default().borders(Borders::ALL).title("Status"))
261        .style(Style::default().fg(Color::Gray))
262        .wrap(Wrap { trim: true });
263    frame.render_widget(paragraph, area);
264}
265
266fn render_help(frame: &mut ratatui::Frame<'_>, area: Rect) {
267    let help_width = area.width.saturating_sub(4).min(60);
268    let help_height = area.height.saturating_sub(4).min(18);
269    let rect = Rect::new(
270        area.x + (area.width.saturating_sub(help_width)) / 2,
271        area.y + (area.height.saturating_sub(help_height)) / 2,
272        help_width,
273        help_height,
274    );
275
276    let lines = help_items()
277        .iter()
278        .map(|(key, desc)| format!("{key:<8} {desc}"))
279        .collect::<Vec<_>>()
280        .join("\n");
281
282    let help = Paragraph::new(lines)
283        .block(Block::default().borders(Borders::ALL).title("Help"))
284        .wrap(Wrap { trim: true });
285    frame.render_widget(help, rect);
286}
287
288fn cleanup_terminal(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
289    disable_raw_mode()?;
290    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
291    terminal.show_cursor()?;
292    Ok(())
293}
294
295pub fn is_tty() -> bool {
296    std::io::stdout().is_terminal() && std::io::stdin().is_terminal()
297}