1use 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
29pub fn interactive() -> bool {
32 io::stdin().is_terminal() && io::stdout().is_terminal()
33}
34
35fn 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
45fn 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
60fn 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
71fn list_height(rect: &Rect) -> u16 {
73 rect.height.saturating_sub(2).max(1)
74}
75
76fn 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
82pub struct PickerRow {
84 pub text: String,
86 pub detail: Option<String>,
88 pub selected: bool,
90}
91
92pub 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
239pub 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
250pub 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 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}