Skip to main content

reth_cli_commands/db/
tui.rs

1use crossterm::{
2    event::{self, Event, KeyCode, MouseEventKind},
3    execute,
4    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
5};
6use ratatui::{
7    backend::{Backend, CrosstermBackend},
8    layout::{Alignment, Constraint, Direction, Layout},
9    style::{Color, Modifier, Style},
10    widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
11    Frame, Terminal,
12};
13use std::{
14    io,
15    time::{Duration, Instant},
16};
17use tracing::error;
18
19/// Available keybindings for the [`DbListTUI`]
20static CMDS: [(&str, &str); 6] = [
21    ("q", "Quit"),
22    ("↑", "Entry above"),
23    ("↓", "Entry below"),
24    ("←", "Previous page"),
25    ("→", "Next page"),
26    ("G", "Go to a specific page"),
27];
28
29#[derive(Default, Eq, PartialEq)]
30pub(crate) enum ViewMode {
31    /// Normal list view mode
32    #[default]
33    Normal,
34    /// Currently wanting to go to a page
35    GoToPage,
36}
37
38/// Fetches a page of entries as pairs of the formatted key and the value serialized as JSON.
39pub(crate) type PageFetcher<'a> = Box<dyn FnMut(usize, usize) -> Vec<(String, String)> + 'a>;
40
41pub(crate) struct DbListTUI<'a> {
42    /// Fetcher for the next page of items.
43    ///
44    /// The fetcher is passed the index of the first item to fetch, and the number of items to
45    /// fetch from that item.
46    fetch: PageFetcher<'a>,
47    /// Skip N indices of the key list in the DB.
48    skip: usize,
49    /// The amount of entries to show per page
50    count: usize,
51    /// The total number of entries in the database
52    total_entries: usize,
53    /// The current view mode
54    mode: ViewMode,
55    /// The current state of the input buffer
56    input: String,
57    /// The state of the key list.
58    list_state: ListState,
59    /// Entries to show in the TUI.
60    entries: Vec<(String, String)>,
61}
62
63impl<'a> DbListTUI<'a> {
64    /// Create a new database list TUI
65    pub(crate) fn new(
66        fetch: PageFetcher<'a>,
67        skip: usize,
68        count: usize,
69        total_entries: usize,
70    ) -> Self {
71        Self {
72            fetch,
73            skip,
74            count,
75            total_entries,
76            mode: ViewMode::Normal,
77            input: String::new(),
78            list_state: ListState::default(),
79            entries: Vec::new(),
80        }
81    }
82
83    /// Move to the next list selection
84    fn next(&mut self) {
85        self.list_state.select(Some(
86            self.list_state
87                .selected()
88                .map(|i| if i >= self.entries.len() - 1 { 0 } else { i + 1 })
89                .unwrap_or(0),
90        ));
91    }
92
93    /// Move to the previous list selection
94    fn previous(&mut self) {
95        self.list_state.select(Some(
96            self.list_state
97                .selected()
98                .map(|i| if i == 0 { self.entries.len() - 1 } else { i - 1 })
99                .unwrap_or(0),
100        ));
101    }
102
103    fn reset(&mut self) {
104        self.list_state.select(Some(0));
105    }
106
107    /// Fetch the next page of items
108    fn next_page(&mut self) {
109        if self.skip + self.count < self.total_entries {
110            self.skip += self.count;
111            self.fetch_page();
112        }
113    }
114
115    /// Fetch the previous page of items
116    fn previous_page(&mut self) {
117        if self.skip > 0 {
118            self.skip = self.skip.saturating_sub(self.count);
119            self.fetch_page();
120        }
121    }
122
123    /// Go to a specific page.
124    fn go_to_page(&mut self, page: usize) {
125        self.skip = (self.count * page).min(self.total_entries - self.count);
126        self.fetch_page();
127    }
128
129    /// Fetch the current page
130    fn fetch_page(&mut self) {
131        self.entries = (self.fetch)(self.skip, self.count);
132        self.reset();
133    }
134
135    /// Show the [`DbListTUI`] in the terminal.
136    pub(crate) fn run(mut self) -> eyre::Result<()> {
137        // Setup backend
138        enable_raw_mode()?;
139        let mut stdout = io::stdout();
140        execute!(stdout, EnterAlternateScreen)?;
141        let backend = CrosstermBackend::new(stdout);
142        let mut terminal = Terminal::new(backend)?;
143
144        // Load initial page
145        self.fetch_page();
146
147        // Run event loop
148        let tick_rate = Duration::from_millis(250);
149        let res = event_loop(&mut terminal, &mut self, tick_rate);
150
151        // Restore terminal
152        disable_raw_mode()?;
153        execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
154        terminal.show_cursor()?;
155
156        // Handle errors
157        if let Err(err) = res {
158            error!("{err}");
159        }
160
161        Ok(())
162    }
163}
164
165/// Run the event loop
166fn event_loop<B: Backend>(
167    terminal: &mut Terminal<B>,
168    app: &mut DbListTUI<'_>,
169    tick_rate: Duration,
170) -> io::Result<()>
171where
172    io::Error: From<B::Error>,
173{
174    let mut last_tick = Instant::now();
175    let mut running = true;
176    while running {
177        // Render
178        terminal.draw(|f| ui(f, app))?;
179
180        // Calculate timeout
181        let timeout =
182            tick_rate.checked_sub(last_tick.elapsed()).unwrap_or_else(|| Duration::from_secs(0));
183
184        // Poll events
185        if crossterm::event::poll(timeout)? {
186            running = !handle_event(app, event::read()?)?;
187        }
188
189        if last_tick.elapsed() >= tick_rate {
190            last_tick = Instant::now();
191        }
192    }
193
194    Ok(())
195}
196
197/// Handle incoming events
198fn handle_event(app: &mut DbListTUI<'_>, event: Event) -> io::Result<bool> {
199    if app.mode == ViewMode::GoToPage {
200        if let Event::Key(key) = event {
201            match key.code {
202                KeyCode::Enter => {
203                    let input = std::mem::take(&mut app.input);
204                    if let Ok(page) = input.parse() {
205                        app.go_to_page(page);
206                    }
207                    app.mode = ViewMode::Normal;
208                }
209                KeyCode::Char(c) => {
210                    app.input.push(c);
211                }
212                KeyCode::Backspace => {
213                    app.input.pop();
214                }
215                KeyCode::Esc => app.mode = ViewMode::Normal,
216                _ => {}
217            }
218        }
219
220        return Ok(false)
221    }
222
223    match event {
224        Event::Key(key) if key.kind == event::KeyEventKind::Press => match key.code {
225            KeyCode::Char('q') | KeyCode::Char('Q') => return Ok(true),
226            KeyCode::Down => app.next(),
227            KeyCode::Up => app.previous(),
228            KeyCode::Right => app.next_page(),
229            KeyCode::Left => app.previous_page(),
230            KeyCode::Char('G') => {
231                app.mode = ViewMode::GoToPage;
232            }
233            _ => {}
234        },
235        Event::Key(_) => {}
236        Event::Mouse(e) => match e.kind {
237            MouseEventKind::ScrollDown => app.next(),
238            MouseEventKind::ScrollUp => app.previous(),
239            // TODO: This click event can be triggered outside of the list widget.
240            MouseEventKind::Down(_) => {
241                let new_idx = (e.row as usize + app.list_state.offset()).saturating_sub(1);
242                if new_idx < app.entries.len() {
243                    app.list_state.select(Some(new_idx));
244                }
245            }
246            _ => {}
247        },
248        _ => {}
249    }
250
251    Ok(false)
252}
253
254/// Render the UI
255fn ui(f: &mut Frame<'_>, app: &mut DbListTUI<'_>) {
256    let outer_chunks = Layout::default()
257        .direction(Direction::Vertical)
258        .constraints([Constraint::Percentage(95), Constraint::Percentage(5)].as_ref())
259        .split(f.area());
260
261    // Columns
262    {
263        let inner_chunks = Layout::default()
264            .direction(Direction::Horizontal)
265            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
266            .split(outer_chunks[0]);
267
268        let key_length = format!("{}", (app.skip + app.count).saturating_sub(1)).len();
269
270        let formatted_keys = app
271            .entries
272            .iter()
273            .enumerate()
274            .map(|(i, (k, _))| {
275                ListItem::new(format!("[{:0>width$}]: {k}", i + app.skip, width = key_length))
276            })
277            .collect::<Vec<_>>();
278
279        let key_list = List::new(formatted_keys)
280            .block(Block::default().borders(Borders::ALL).title(format!(
281                "Keys (Showing entries {}-{} out of {} entries)",
282                app.skip,
283                (app.skip + app.entries.len()).saturating_sub(1),
284                app.total_entries
285            )))
286            .style(Style::default().fg(Color::White))
287            .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::ITALIC))
288            .highlight_symbol("➜ ");
289        f.render_stateful_widget(key_list, inner_chunks[0], &mut app.list_state);
290
291        let value_display = Paragraph::new(
292            app.list_state
293                .selected()
294                .and_then(|selected| app.entries.get(selected))
295                .map_or("No value selected", |(_, value)| value),
296        )
297        .block(Block::default().borders(Borders::ALL).title("Value (JSON)"))
298        .wrap(Wrap { trim: false })
299        .alignment(Alignment::Left);
300        f.render_widget(value_display, inner_chunks[1]);
301    }
302
303    // Footer
304    let footer = match app.mode {
305        ViewMode::Normal => Paragraph::new(
306            CMDS.iter().map(|(k, v)| format!("[{k}] {v}")).collect::<Vec<_>>().join(" | "),
307        ),
308        ViewMode::GoToPage => Paragraph::new(format!(
309            "Go to page (max {}): {}",
310            app.total_entries / app.count,
311            app.input
312        )),
313    }
314    .block(Block::default().borders(Borders::ALL))
315    .alignment(match app.mode {
316        ViewMode::Normal => Alignment::Center,
317        ViewMode::GoToPage => Alignment::Left,
318    })
319    .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
320    f.render_widget(footer, outer_chunks[1]);
321}