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 reth_db_api::{
14    table::{Table, TableRow},
15    RawValue,
16};
17use std::{
18    io,
19    time::{Duration, Instant},
20};
21use tracing::error;
22
23/// Available keybindings for the [`DbListTUI`]
24static CMDS: [(&str, &str); 6] = [
25    ("q", "Quit"),
26    ("↑", "Entry above"),
27    ("↓", "Entry below"),
28    ("←", "Previous page"),
29    ("→", "Next page"),
30    ("G", "Go to a specific page"),
31];
32
33#[derive(Default, Eq, PartialEq)]
34pub(crate) enum ViewMode {
35    /// Normal list view mode
36    #[default]
37    Normal,
38    /// Currently wanting to go to a page
39    GoToPage,
40}
41
42enum Entries<T: Table> {
43    /// Pairs of [`Table::Key`] and [`RawValue<Table::Value>`]
44    RawValues(Vec<(T::Key, RawValue<T::Value>)>),
45    /// Pairs of [`Table::Key`] and [`Table::Value`]
46    Values(Vec<TableRow<T>>),
47}
48
49impl<T: Table> Entries<T> {
50    /// Creates new empty [Entries] as [`Entries::RawValues`] if `raw_values == true` and as
51    /// [`Entries::Values`] if `raw == false`.
52    const fn new_with_raw_values(raw_values: bool) -> Self {
53        if raw_values {
54            Self::RawValues(Vec::new())
55        } else {
56            Self::Values(Vec::new())
57        }
58    }
59
60    /// Sets the internal entries [Vec], converting the [`Table::Value`] into
61    /// [`RawValue<Table::Value>`] if needed.
62    fn set(&mut self, new_entries: Vec<TableRow<T>>) {
63        match self {
64            Self::RawValues(old_entries) => {
65                *old_entries =
66                    new_entries.into_iter().map(|(key, value)| (key, value.into())).collect()
67            }
68            Self::Values(old_entries) => *old_entries = new_entries,
69        }
70    }
71
72    /// Returns the length of internal [Vec].
73    fn len(&self) -> usize {
74        match self {
75            Self::RawValues(entries) => entries.len(),
76            Self::Values(entries) => entries.len(),
77        }
78    }
79
80    /// Returns an iterator over keys of the internal [Vec]. For both [`Entries::RawValues`] and
81    /// [`Entries::Values`], this iterator will yield [`Table::Key`].
82    const fn iter_keys(&self) -> EntriesKeyIter<'_, T> {
83        EntriesKeyIter { entries: self, index: 0 }
84    }
85}
86
87struct EntriesKeyIter<'a, T: Table> {
88    entries: &'a Entries<T>,
89    index: usize,
90}
91
92impl<'a, T: Table> Iterator for EntriesKeyIter<'a, T> {
93    type Item = &'a T::Key;
94
95    fn next(&mut self) -> Option<Self::Item> {
96        let item = match self.entries {
97            Entries::RawValues(values) => values.get(self.index).map(|(key, _)| key),
98            Entries::Values(values) => values.get(self.index).map(|(key, _)| key),
99        };
100        self.index += 1;
101
102        item
103    }
104}
105
106pub(crate) struct DbListTUI<F, T: Table>
107where
108    F: FnMut(usize, usize) -> Vec<TableRow<T>>,
109{
110    /// Fetcher for the next page of items.
111    ///
112    /// The fetcher is passed the index of the first item to fetch, and the number of items to
113    /// fetch from that item.
114    fetch: F,
115    /// Skip N indices of the key list in the DB.
116    skip: usize,
117    /// The amount of entries to show per page
118    count: usize,
119    /// The total number of entries in the database
120    total_entries: usize,
121    /// The current view mode
122    mode: ViewMode,
123    /// The current state of the input buffer
124    input: String,
125    /// The state of the key list.
126    list_state: ListState,
127    /// Entries to show in the TUI.
128    entries: Entries<T>,
129}
130
131impl<F, T: Table> DbListTUI<F, T>
132where
133    F: FnMut(usize, usize) -> Vec<TableRow<T>>,
134{
135    /// Create a new database list TUI
136    pub(crate) fn new(
137        fetch: F,
138        skip: usize,
139        count: usize,
140        total_entries: usize,
141        raw: bool,
142    ) -> Self {
143        Self {
144            fetch,
145            skip,
146            count,
147            total_entries,
148            mode: ViewMode::Normal,
149            input: String::new(),
150            list_state: ListState::default(),
151            entries: Entries::new_with_raw_values(raw),
152        }
153    }
154
155    /// Move to the next list selection
156    fn next(&mut self) {
157        self.list_state.select(Some(
158            self.list_state
159                .selected()
160                .map(|i| if i >= self.entries.len() - 1 { 0 } else { i + 1 })
161                .unwrap_or(0),
162        ));
163    }
164
165    /// Move to the previous list selection
166    fn previous(&mut self) {
167        self.list_state.select(Some(
168            self.list_state
169                .selected()
170                .map(|i| if i == 0 { self.entries.len() - 1 } else { i - 1 })
171                .unwrap_or(0),
172        ));
173    }
174
175    fn reset(&mut self) {
176        self.list_state.select(Some(0));
177    }
178
179    /// Fetch the next page of items
180    fn next_page(&mut self) {
181        if self.skip + self.count < self.total_entries {
182            self.skip += self.count;
183            self.fetch_page();
184        }
185    }
186
187    /// Fetch the previous page of items
188    fn previous_page(&mut self) {
189        if self.skip > 0 {
190            self.skip = self.skip.saturating_sub(self.count);
191            self.fetch_page();
192        }
193    }
194
195    /// Go to a specific page.
196    fn go_to_page(&mut self, page: usize) {
197        self.skip = (self.count * page).min(self.total_entries - self.count);
198        self.fetch_page();
199    }
200
201    /// Fetch the current page
202    fn fetch_page(&mut self) {
203        self.entries.set((self.fetch)(self.skip, self.count));
204        self.reset();
205    }
206
207    /// Show the [`DbListTUI`] in the terminal.
208    pub(crate) fn run(mut self) -> eyre::Result<()> {
209        // Setup backend
210        enable_raw_mode()?;
211        let mut stdout = io::stdout();
212        execute!(stdout, EnterAlternateScreen)?;
213        let backend = CrosstermBackend::new(stdout);
214        let mut terminal = Terminal::new(backend)?;
215
216        // Load initial page
217        self.fetch_page();
218
219        // Run event loop
220        let tick_rate = Duration::from_millis(250);
221        let res = event_loop(&mut terminal, &mut self, tick_rate);
222
223        // Restore terminal
224        disable_raw_mode()?;
225        execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
226        terminal.show_cursor()?;
227
228        // Handle errors
229        if let Err(err) = res {
230            error!("{err}");
231        }
232
233        Ok(())
234    }
235}
236
237/// Run the event loop
238fn event_loop<B: Backend, F, T: Table>(
239    terminal: &mut Terminal<B>,
240    app: &mut DbListTUI<F, T>,
241    tick_rate: Duration,
242) -> io::Result<()>
243where
244    F: FnMut(usize, usize) -> Vec<TableRow<T>>,
245    io::Error: From<B::Error>,
246{
247    let mut last_tick = Instant::now();
248    let mut running = true;
249    while running {
250        // Render
251        terminal.draw(|f| ui(f, app))?;
252
253        // Calculate timeout
254        let timeout =
255            tick_rate.checked_sub(last_tick.elapsed()).unwrap_or_else(|| Duration::from_secs(0));
256
257        // Poll events
258        if crossterm::event::poll(timeout)? {
259            running = !handle_event(app, event::read()?)?;
260        }
261
262        if last_tick.elapsed() >= tick_rate {
263            last_tick = Instant::now();
264        }
265    }
266
267    Ok(())
268}
269
270/// Handle incoming events
271fn handle_event<F, T: Table>(app: &mut DbListTUI<F, T>, event: Event) -> io::Result<bool>
272where
273    F: FnMut(usize, usize) -> Vec<TableRow<T>>,
274{
275    if app.mode == ViewMode::GoToPage {
276        if let Event::Key(key) = event {
277            match key.code {
278                KeyCode::Enter => {
279                    let input = std::mem::take(&mut app.input);
280                    if let Ok(page) = input.parse() {
281                        app.go_to_page(page);
282                    }
283                    app.mode = ViewMode::Normal;
284                }
285                KeyCode::Char(c) => {
286                    app.input.push(c);
287                }
288                KeyCode::Backspace => {
289                    app.input.pop();
290                }
291                KeyCode::Esc => app.mode = ViewMode::Normal,
292                _ => {}
293            }
294        }
295
296        return Ok(false)
297    }
298
299    match event {
300        Event::Key(key) => {
301            if key.kind == event::KeyEventKind::Press {
302                match key.code {
303                    KeyCode::Char('q') | KeyCode::Char('Q') => return Ok(true),
304                    KeyCode::Down => app.next(),
305                    KeyCode::Up => app.previous(),
306                    KeyCode::Right => app.next_page(),
307                    KeyCode::Left => app.previous_page(),
308                    KeyCode::Char('G') => {
309                        app.mode = ViewMode::GoToPage;
310                    }
311                    _ => {}
312                }
313            }
314        }
315        Event::Mouse(e) => match e.kind {
316            MouseEventKind::ScrollDown => app.next(),
317            MouseEventKind::ScrollUp => app.previous(),
318            // TODO: This click event can be triggered outside of the list widget.
319            MouseEventKind::Down(_) => {
320                let new_idx = (e.row as usize + app.list_state.offset()).saturating_sub(1);
321                if new_idx < app.entries.len() {
322                    app.list_state.select(Some(new_idx));
323                }
324            }
325            _ => {}
326        },
327        _ => {}
328    }
329
330    Ok(false)
331}
332
333/// Render the UI
334fn ui<F, T: Table>(f: &mut Frame<'_>, app: &mut DbListTUI<F, T>)
335where
336    F: FnMut(usize, usize) -> Vec<TableRow<T>>,
337{
338    let outer_chunks = Layout::default()
339        .direction(Direction::Vertical)
340        .constraints([Constraint::Percentage(95), Constraint::Percentage(5)].as_ref())
341        .split(f.area());
342
343    // Columns
344    {
345        let inner_chunks = Layout::default()
346            .direction(Direction::Horizontal)
347            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
348            .split(outer_chunks[0]);
349
350        let key_length = format!("{}", (app.skip + app.count).saturating_sub(1)).len();
351
352        let formatted_keys = app
353            .entries
354            .iter_keys()
355            .enumerate()
356            .map(|(i, k)| {
357                ListItem::new(format!("[{:0>width$}]: {k:?}", i + app.skip, width = key_length))
358            })
359            .collect::<Vec<_>>();
360
361        let key_list = List::new(formatted_keys)
362            .block(Block::default().borders(Borders::ALL).title(format!(
363                "Keys (Showing entries {}-{} out of {} entries)",
364                app.skip,
365                (app.skip + app.entries.len()).saturating_sub(1),
366                app.total_entries
367            )))
368            .style(Style::default().fg(Color::White))
369            .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::ITALIC))
370            .highlight_symbol("➜ ");
371        f.render_stateful_widget(key_list, inner_chunks[0], &mut app.list_state);
372
373        let value_display = Paragraph::new(
374            app.list_state
375                .selected()
376                .and_then(|selected| {
377                    let maybe_serialized = match &app.entries {
378                        Entries::RawValues(entries) => {
379                            entries.get(selected).map(|(_, v)| serde_json::to_string(v.raw_value()))
380                        }
381                        Entries::Values(entries) => {
382                            entries.get(selected).map(|(_, v)| serde_json::to_string_pretty(v))
383                        }
384                    };
385                    maybe_serialized.map(|ser| {
386                        ser.unwrap_or_else(|error| format!("Error serializing value: {error}"))
387                    })
388                })
389                .unwrap_or_else(|| "No value selected".to_string()),
390        )
391        .block(Block::default().borders(Borders::ALL).title("Value (JSON)"))
392        .wrap(Wrap { trim: false })
393        .alignment(Alignment::Left);
394        f.render_widget(value_display, inner_chunks[1]);
395    }
396
397    // Footer
398    let footer = match app.mode {
399        ViewMode::Normal => Paragraph::new(
400            CMDS.iter().map(|(k, v)| format!("[{k}] {v}")).collect::<Vec<_>>().join(" | "),
401        ),
402        ViewMode::GoToPage => Paragraph::new(format!(
403            "Go to page (max {}): {}",
404            app.total_entries / app.count,
405            app.input
406        )),
407    }
408    .block(Block::default().borders(Borders::ALL))
409    .alignment(match app.mode {
410        ViewMode::Normal => Alignment::Center,
411        ViewMode::GoToPage => Alignment::Left,
412    })
413    .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
414    f.render_widget(footer, outer_chunks[1]);
415}