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