Skip to main content

ploit_malper/frontend/
tui.rs

1//! Interactive terminal UI helpers built on ratatui + crossterm.
2//!
3//! Everything in here is optional: commands fall back to the classic
4//! line-oriented output when the terminal is not interactive, so `--json` and
5//! piped invocations keep working unchanged.
6
7use std::io::{self, IsTerminal};
8
9use crossterm::cursor as cur;
10use crossterm::event::{
11    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
12};
13use crossterm::execute;
14use crossterm::terminal::{
15    disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen,
16};
17use ratatui::backend::CrosstermBackend;
18use ratatui::layout::{Alignment, Rect};
19use ratatui::style::{Color, Modifier, Style};
20use ratatui::text::{Line, Span};
21use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
22use ratatui::{Frame, Terminal};
23
24use crate::error::Result;
25
26type Backend = CrosstermBackend<io::Stdout>;
27type Term = Terminal<Backend>;
28
29/// True when the current stdin/stdout pair can host an interactive picker.
30/// Never auto-launches inside a pipe or non-tty context (tests, `--json`).
31pub fn interactive() -> bool {
32    io::stdin().is_terminal() && io::stdout().is_terminal()
33}
34
35/// Acquire raw mode + an alternate screen so the picker is self-contained.
36fn enter_terminal() -> Result<Term> {
37    enable_raw_mode()?;
38    let mut stdout = io::stdout();
39    execute!(stdout, EnterAlternateScreen, EnableMouseCapture, cur::Hide)?;
40    let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?;
41    terminal.clear()?;
42    Ok(terminal)
43}
44
45/// Restore the terminal after an interactive session, swallowing any error so
46/// the caller's real error is what surfaces.
47fn leave_terminal(mut terminal: Term) {
48    let _ = terminal.show_cursor();
49    let _ = terminal.flush();
50    let _ = execute!(
51        terminal.backend_mut(),
52        LeaveAlternateScreen,
53        DisableMouseCapture,
54        Clear(ClearType::All),
55        cur::Show
56    );
57    let _ = disable_raw_mode();
58}
59
60/// Blocking read of the next *press* key event (auto-repeat safe).
61fn read_key() -> Result<KeyEvent> {
62    loop {
63        if let Event::Key(key) = event::read()? {
64            if key.kind == KeyEventKind::Press {
65                return Ok(key);
66            }
67        }
68    }
69}
70
71/// Number of `ListItem` rows that fit between the top/bottom borders.
72fn list_height(rect: &Rect) -> u16 {
73    rect.height.saturating_sub(2).max(1)
74}
75
76/// Advance the picker cursor around a circular ring of `len` items.
77fn step_cursor(direction: isize, cursor: usize, len: usize) -> usize {
78    debug_assert!(len > 0);
79    ((cursor as isize + direction).rem_euclid(len as isize)) as usize
80}
81
82/// One row in the interactive selector.
83pub struct PickerRow {
84    /// Primary label, rendered with the selection marker circle.
85    pub text: String,
86    /// Optional secondary line (module rank / host); shown dim after `text`.
87    pub detail: Option<String>,
88    /// Whether the row starts selected.
89    pub selected: bool,
90}
91
92/// Interactive multi-select picker used by the exploit command. Renders the
93/// modules as a circular list: arrow keys (or j/k) move the cursor between the
94/// circles, `space` toggles the current one, `a` selects all and `n` clears
95/// selection, `enter` confirms and advances, `q`/`Esc` aborts.
96///
97/// Returns `Some(indices)` of the rows selected at confirm time, or `None`
98/// when the user aborted.
99pub fn pick(title: &str, rows: Vec<PickerRow>, first: usize) -> Result<Option<Vec<usize>>> {
100    if rows.is_empty() {
101        return Ok(Some(Vec::new()));
102    }
103    let cursor = if first < rows.len() { first } else { 0 };
104    let mut terminal = enter_terminal()?;
105    let result = run_picker(&mut terminal, title, rows, cursor);
106    leave_terminal(terminal);
107    result
108}
109
110fn run_picker(
111    terminal: &mut Term,
112    title: &str,
113    mut rows: Vec<PickerRow>,
114    mut cursor: usize,
115) -> Result<Option<Vec<usize>>> {
116    loop {
117        terminal.draw(|frame| draw_picker(frame, title, &rows, cursor))?;
118        let key = read_key()?;
119
120        match key.code {
121            KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
122            KeyCode::Char('j') | KeyCode::Down => {
123                cursor = step_cursor(1, cursor, rows.len());
124            }
125            KeyCode::Char('k') | KeyCode::Up => {
126                cursor = step_cursor(-1, cursor, rows.len());
127            }
128            KeyCode::Char(' ') => {
129                rows[cursor].selected = !rows[cursor].selected;
130            }
131            KeyCode::Char('a') => {
132                for row in rows.iter_mut() {
133                    row.selected = true;
134                }
135            }
136            KeyCode::Char('n') => {
137                for row in rows.iter_mut() {
138                    row.selected = false;
139                }
140            }
141            KeyCode::Enter => {
142                let chosen: Vec<usize> = rows
143                    .iter()
144                    .enumerate()
145                    .filter(|(_, r)| r.selected)
146                    .map(|(i, _)| i)
147                    .collect();
148                return Ok(Some(chosen));
149            }
150            _ => {}
151        }
152    }
153}
154
155fn draw_picker(frame: &mut Frame<'_>, title: &str, rows: &[PickerRow], cursor: usize) {
156    let outer = frame.area();
157    let block = Block::default()
158        .borders(Borders::ALL)
159        .title(Span::styled(
160            title,
161            Style::default().add_modifier(Modifier::BOLD),
162        ))
163        .title_alignment(Alignment::Center)
164        .border_style(Style::default().fg(Color::Cyan));
165
166    let inner = block.inner(outer);
167    frame.render_widget(block, outer);
168
169    let height = list_height(&inner);
170    let visible = (height as usize).min(rows.len());
171    let scroll = cursor
172        .saturating_sub(visible / 2)
173        .min(rows.len().saturating_sub(visible));
174
175    let items: Vec<ListItem> = rows
176        .iter()
177        .enumerate()
178        .skip(scroll)
179        .take(visible)
180        .map(|(i, row)| {
181            let marker = if row.selected { "●" } else { "○" };
182            let marker_style = if row.selected {
183                Style::default()
184                    .fg(Color::Green)
185                    .add_modifier(Modifier::BOLD)
186            } else {
187                Style::default().fg(Color::Gray)
188            };
189            let mut spans = vec![Span::styled(marker, marker_style), Span::raw(" ")];
190            if i == cursor {
191                spans.push(Span::styled(
192                    row.text.clone(),
193                    Style::default()
194                        .fg(Color::Black)
195                        .bg(Color::Cyan)
196                        .add_modifier(Modifier::BOLD),
197                ));
198            } else {
199                spans.push(Span::raw(row.text.clone()));
200            }
201            if let Some(detail) = &row.detail {
202                spans.push(Span::styled(
203                    format!(
204                        "  {}",
205                        crate::frontend::truncate(detail, inner.width.saturating_sub(6) as usize)
206                    ),
207                    Style::default().fg(Color::DarkGray),
208                ));
209            }
210            ListItem::new(Line::from(spans))
211        })
212        .collect();
213
214    let list_area = Rect {
215        x: inner.x,
216        y: inner.y + 1,
217        width: inner.width,
218        height,
219    };
220    frame.render_widget(List::new(items), list_area);
221
222    let footer = format!(
223        " {}/{}  ↑/k move ↓/j · space toggle · a all · n none · enter continue · q quit ",
224        cursor + 1,
225        rows.len()
226    );
227    frame.render_widget(
228        Paragraph::new(Span::styled(footer, Style::default().fg(Color::DarkGray)))
229            .alignment(Alignment::Center),
230        Rect {
231            x: inner.x,
232            y: inner.y + inner.height - 2,
233            width: inner.width,
234            height: 1,
235        },
236    );
237}
238
239/// One row in the tabular viewer (equal `length`-wise to `columns`).
240pub struct ViewerRow {
241    pub cells: Vec<String>,
242}
243
244impl ViewerRow {
245    pub fn new(cells: Vec<String>) -> Self {
246        ViewerRow { cells }
247    }
248}
249
250/// Interactive single-select viewer for tabular data (runs / findings). Shows
251/// a scrollable list of rows with a detail pane beneath the cursor row.
252/// `enter` returns `Some(index)`, `q`/`Esc` returns `None`.
253pub fn view_table(title: &str, columns: &[&str], rows: Vec<ViewerRow>) -> Result<Option<usize>> {
254    if rows.is_empty() {
255        return Ok(None);
256    }
257    let mut terminal = enter_terminal()?;
258    let result = run_viewer(&mut terminal, title, columns, rows, 0);
259    leave_terminal(terminal);
260    result
261}
262
263fn run_viewer(
264    terminal: &mut Term,
265    title: &str,
266    columns: &[&str],
267    rows: Vec<ViewerRow>,
268    mut cursor: usize,
269) -> Result<Option<usize>> {
270    loop {
271        terminal.draw(|frame| draw_viewer(frame, title, columns, &rows, cursor))?;
272        let key = read_key()?;
273        match key.code {
274            KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
275            KeyCode::Char('j') | KeyCode::Down => {
276                if cursor + 1 < rows.len() {
277                    cursor += 1;
278                }
279            }
280            KeyCode::Char('k') | KeyCode::Up => {
281                cursor = cursor.saturating_sub(1);
282            }
283            KeyCode::PageDown => cursor = (cursor + 10).min(rows.len() - 1),
284            KeyCode::PageUp => cursor = cursor.saturating_sub(10),
285            KeyCode::Home => cursor = 0,
286            KeyCode::End => cursor = rows.len() - 1,
287            KeyCode::Enter => return Ok(Some(cursor)),
288            _ => {}
289        }
290    }
291}
292
293fn draw_viewer(
294    frame: &mut Frame<'_>,
295    title: &str,
296    columns: &[&str],
297    rows: &[ViewerRow],
298    cursor: usize,
299) {
300    let outer = frame.area();
301    let block = Block::default()
302        .borders(Borders::ALL)
303        .title(Span::styled(
304            title,
305            Style::default().add_modifier(Modifier::BOLD),
306        ))
307        .title_alignment(Alignment::Center)
308        .border_style(Style::default().fg(Color::Magenta));
309
310    let inner = block.inner(outer);
311    frame.render_widget(block, outer);
312
313    let list_height = inner.height.saturating_sub(3).max(1);
314    let visible_rows = (list_height as usize).min(rows.len());
315    let scroll = cursor
316        .saturating_sub(visible_rows / 2)
317        .min(rows.len().saturating_sub(visible_rows));
318    let items: Vec<ListItem> = rows
319        .iter()
320        .enumerate()
321        .skip(scroll)
322        .take(visible_rows)
323        .map(|(i, row)| {
324            let mut line = String::new();
325            for (k, value) in row.cells.iter().enumerate() {
326                if k == 0 {
327                    line.push_str(&crate::frontend::truncate(value, 24));
328                } else if columns
329                    .get(k)
330                    .map(|c| matches!(*c, "RANK" | "CONF" | "PORT"))
331                    .unwrap_or(false)
332                {
333                    line.push_str(&format!("  {value:>8}"));
334                } else {
335                    line.push_str(&format!("  {}", crate::frontend::truncate(value, 22)));
336                }
337            }
338            let prefix = if i == cursor { "❯ " } else { "  " };
339            let styled_line = if i == cursor {
340                Line::from(vec![
341                    Span::styled(
342                        prefix,
343                        Style::default()
344                            .fg(Color::Yellow)
345                            .add_modifier(Modifier::BOLD),
346                    ),
347                    Span::styled(line.clone(), Style::default().fg(Color::Cyan)),
348                ])
349            } else {
350                Line::from(vec![
351                    Span::raw(prefix),
352                    Span::styled(line.clone(), Style::default().fg(Color::White)),
353                ])
354            };
355            let _ = &line;
356            ListItem::new(styled_line)
357        })
358        .collect();
359
360    let list_area = Rect {
361        x: inner.x + 1,
362        y: inner.y + 1,
363        width: inner.width.saturating_sub(2),
364        height: list_height,
365    };
366    frame.render_widget(List::new(items).highlight_symbol("▶ "), list_area);
367
368    // Detail pane for the current row.
369    let selected = &rows[cursor];
370    let detail_y = inner.y + list_height + 1;
371    let detail_height = outer.y + outer.height - 1 - detail_y;
372    let detail_text = columns
373        .iter()
374        .enumerate()
375        .map(|(k, name)| {
376            let value = selected.cells.get(k).cloned().unwrap_or_default();
377            format!("{name}: {}", crate::frontend::truncate(&value, 120))
378        })
379        .collect::<Vec<_>>()
380        .join("\n");
381    frame.render_widget(
382        Paragraph::new(detail_text)
383            .block(
384                Block::default()
385                    .borders(Borders::ALL)
386                    .title(Span::styled("detail", Style::default().fg(Color::DarkGray))),
387            )
388            .style(Style::default().fg(Color::Gray)),
389        Rect {
390            x: inner.x + 1,
391            y: detail_y,
392            width: inner.width.saturating_sub(2),
393            height: detail_height,
394        },
395    );
396
397    let footer = format!(
398        " {}/{}  ↑/k up ↓/j down  PageUp/PageDown  Enter select  q quit ",
399        cursor + 1,
400        rows.len()
401    );
402    frame.render_widget(
403        Paragraph::new(Span::styled(footer, Style::default().fg(Color::DarkGray)))
404            .alignment(Alignment::Center),
405        Rect {
406            x: inner.x,
407            y: inner.y + inner.height - 2,
408            width: inner.width,
409            height: 1,
410        },
411    );
412}
413
414#[cfg(test)]
415mod tests {
416    use super::step_cursor;
417
418    #[test]
419    fn cursor_wraps_forward() {
420        assert_eq!(step_cursor(1, 0, 3), 1);
421        assert_eq!(step_cursor(1, 2, 3), 0);
422        assert_eq!(step_cursor(1, 5, 6), 0);
423    }
424
425    #[test]
426    fn cursor_wraps_backward() {
427        assert_eq!(step_cursor(-1, 0, 3), 2);
428        assert_eq!(step_cursor(-1, 2, 3), 1);
429        assert_eq!(step_cursor(-1, 0, 1), 0);
430    }
431
432    #[test]
433    fn cursor_is_stable_for_single_item() {
434        assert_eq!(step_cursor(1, 0, 1), 0);
435        assert_eq!(step_cursor(-1, 0, 1), 0);
436    }
437}