Skip to main content

reth_cli_commands/download/
tui.rs

1use crate::download::{
2    download_command,
3    manifest::{ComponentSelection, SnapshotComponentType, SnapshotManifest},
4    DownloadProgress, SelectionPreset,
5};
6use crossterm::{
7    event::{self, Event, KeyCode},
8    execute,
9    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
10};
11use ratatui::{
12    backend::CrosstermBackend,
13    layout::{Constraint, Direction, Layout},
14    style::{Color, Modifier, Style},
15    text::{Line, Span},
16    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
17    Frame, Terminal,
18};
19use std::{
20    collections::BTreeMap,
21    io,
22    time::{Duration, Instant},
23};
24
25/// Result of the interactive component selector.
26pub struct SelectorOutput {
27    /// User-confirmed selections with per-component ranges.
28    pub selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
29    /// Last preset action used in the TUI, if any.
30    pub preset: Option<SelectionPreset>,
31}
32
33/// All distance presets. Groups filter this to only valid options.
34const DISTANCE_PRESETS: [ComponentSelection; 6] = [
35    ComponentSelection::None,
36    ComponentSelection::Distance(64),
37    ComponentSelection::Distance(10_064),
38    ComponentSelection::Distance(100_000),
39    ComponentSelection::Distance(1_000_000),
40    ComponentSelection::All,
41];
42
43/// Presets for components that require at least 64 blocks (receipts).
44const RECEIPTS_PRESETS: [ComponentSelection; 5] = [
45    ComponentSelection::Distance(64),
46    ComponentSelection::Distance(10_064),
47    ComponentSelection::Distance(100_000),
48    ComponentSelection::Distance(1_000_000),
49    ComponentSelection::All,
50];
51
52/// Presets for components that require at least 10064 blocks (account/storage history).
53const HISTORY_PRESETS: [ComponentSelection; 4] = [
54    ComponentSelection::Distance(10_064),
55    ComponentSelection::Distance(100_000),
56    ComponentSelection::Distance(1_000_000),
57    ComponentSelection::All,
58];
59
60/// A display group bundles one or more component types into a single TUI row.
61struct DisplayGroup {
62    /// Display name shown in the TUI.
63    name: &'static str,
64    /// Underlying component types this group controls.
65    types: Vec<SnapshotComponentType>,
66    /// Whether this group is required and locked to All.
67    required: bool,
68    /// Valid presets for this group. Components with minimum distance requirements
69    /// exclude presets that would produce invalid prune configs.
70    presets: &'static [ComponentSelection],
71}
72
73/// Build the display groups from available components in the manifest.
74fn build_groups(manifest: &SnapshotManifest) -> Vec<DisplayGroup> {
75    let has = |ty: SnapshotComponentType| manifest.component(ty).is_some();
76
77    let mut groups = Vec::new();
78
79    if has(SnapshotComponentType::State) {
80        groups.push(DisplayGroup {
81            name: "State (mdbx)",
82            types: vec![SnapshotComponentType::State],
83            required: true,
84            presets: &DISTANCE_PRESETS,
85        });
86    }
87
88    if has(SnapshotComponentType::Headers) {
89        groups.push(DisplayGroup {
90            name: "Headers",
91            types: vec![SnapshotComponentType::Headers],
92            required: true,
93            presets: &DISTANCE_PRESETS,
94        });
95    }
96
97    if has(SnapshotComponentType::Transactions) {
98        groups.push(DisplayGroup {
99            name: "Transactions",
100            types: vec![SnapshotComponentType::Transactions],
101            required: false,
102            presets: &HISTORY_PRESETS,
103        });
104    }
105
106    if has(SnapshotComponentType::Receipts) {
107        groups.push(DisplayGroup {
108            name: "Receipts",
109            types: vec![SnapshotComponentType::Receipts],
110            required: false,
111            presets: &RECEIPTS_PRESETS,
112        });
113    }
114
115    // Bundle account + storage changesets as "State History"
116    let has_acc = has(SnapshotComponentType::AccountChangesets);
117    let has_stor = has(SnapshotComponentType::StorageChangesets);
118    if has_acc || has_stor {
119        let mut types = Vec::new();
120        if has_acc {
121            types.push(SnapshotComponentType::AccountChangesets);
122        }
123        if has_stor {
124            types.push(SnapshotComponentType::StorageChangesets);
125        }
126        groups.push(DisplayGroup {
127            name: "State History",
128            types,
129            required: false,
130            presets: &HISTORY_PRESETS,
131        });
132    }
133
134    groups
135}
136
137struct SelectorApp {
138    manifest: SnapshotManifest,
139    full_preset: BTreeMap<SnapshotComponentType, ComponentSelection>,
140    /// Display groups shown in the TUI.
141    groups: Vec<DisplayGroup>,
142    /// Current selection for each group.
143    selections: Vec<ComponentSelection>,
144    /// Last preset action invoked by user.
145    preset: Option<SelectionPreset>,
146    /// Current cursor position.
147    cursor: usize,
148    /// List state for ratatui.
149    list_state: ListState,
150}
151
152impl SelectorApp {
153    fn new(
154        manifest: SnapshotManifest,
155        full_preset: BTreeMap<SnapshotComponentType, ComponentSelection>,
156    ) -> Self {
157        let groups = build_groups(&manifest);
158
159        // Default to the minimal preset (matches --minimal prune config)
160        let selections = groups.iter().map(|g| g.types[0].minimal_selection()).collect();
161
162        let mut list_state = ListState::default();
163        list_state.select(Some(0));
164
165        Self {
166            manifest,
167            full_preset,
168            groups,
169            selections,
170            preset: Some(SelectionPreset::Minimal),
171            cursor: 0,
172            list_state,
173        }
174    }
175
176    fn cycle_right(&mut self) {
177        if let Some(group) = self.groups.get(self.cursor) {
178            if group.required {
179                return;
180            }
181            let presets = group.presets;
182            let current = self.selections[self.cursor];
183            let idx = presets.iter().position(|p| *p == current).unwrap_or(0);
184            self.selections[self.cursor] = presets[(idx + 1) % presets.len()];
185            self.preset = None;
186        }
187    }
188
189    fn cycle_left(&mut self) {
190        if let Some(group) = self.groups.get(self.cursor) {
191            if group.required {
192                return;
193            }
194            let presets = group.presets;
195            let current = self.selections[self.cursor];
196            let idx = presets.iter().position(|p| *p == current).unwrap_or(0);
197            self.selections[self.cursor] = presets[(idx + presets.len() - 1) % presets.len()];
198            self.preset = None;
199        }
200    }
201
202    fn select_all(&mut self) {
203        self.selections.fill(ComponentSelection::All);
204        self.preset = Some(SelectionPreset::Archive);
205    }
206
207    fn select_minimal(&mut self) {
208        for (i, group) in self.groups.iter().enumerate() {
209            self.selections[i] = group.types[0].minimal_selection();
210        }
211        self.preset = Some(SelectionPreset::Minimal);
212    }
213
214    fn select_full(&mut self) {
215        for (i, group) in self.groups.iter().enumerate() {
216            let mut selection = group.types[0].minimal_selection();
217            for ty in &group.types {
218                if let Some(sel) = self.full_preset.get(ty).copied() {
219                    selection = sel;
220                    break;
221                }
222            }
223            self.selections[i] = selection;
224        }
225        self.preset = Some(SelectionPreset::Full);
226    }
227
228    fn move_up(&mut self) {
229        if self.cursor > 0 {
230            self.cursor -= 1;
231        } else {
232            self.cursor = self.groups.len().saturating_sub(1);
233        }
234        self.list_state.select(Some(self.cursor));
235    }
236
237    fn move_down(&mut self) {
238        if self.cursor < self.groups.len() - 1 {
239            self.cursor += 1;
240        } else {
241            self.cursor = 0;
242        }
243        self.list_state.select(Some(self.cursor));
244    }
245
246    /// Build the flat component→selection map from grouped selections.
247    fn selection_map(&self) -> BTreeMap<SnapshotComponentType, ComponentSelection> {
248        let mut map = BTreeMap::new();
249        for (group, sel) in self.groups.iter().zip(&self.selections) {
250            for ty in &group.types {
251                map.insert(*ty, *sel);
252            }
253        }
254        map
255    }
256
257    /// Size for a single group, summing all component types in the group.
258    fn group_size(&self, group_idx: usize) -> u64 {
259        let sel = self.selections[group_idx];
260        let distance = match sel {
261            ComponentSelection::None => return 0,
262            ComponentSelection::All => None,
263            ComponentSelection::Distance(d) => Some(d),
264            ComponentSelection::Since(block) => Some(self.manifest.block - block + 1),
265        };
266        self.groups[group_idx]
267            .types
268            .iter()
269            .map(|ty| self.manifest.size_for_distance(*ty, distance))
270            .sum()
271    }
272
273    fn total_selected_size(&self) -> u64 {
274        (0..self.groups.len()).map(|i| self.group_size(i)).sum()
275    }
276}
277
278/// Runs the interactive component selector TUI.
279pub fn run_selector(
280    manifest: SnapshotManifest,
281    full_preset: &BTreeMap<SnapshotComponentType, ComponentSelection>,
282) -> eyre::Result<SelectorOutput> {
283    enable_raw_mode()?;
284    let mut stdout = io::stdout();
285    execute!(stdout, EnterAlternateScreen)?;
286    let backend = CrosstermBackend::new(stdout);
287    let mut terminal = Terminal::new(backend)?;
288
289    let mut app = SelectorApp::new(manifest, full_preset.clone());
290    let result = event_loop(&mut terminal, &mut app);
291
292    disable_raw_mode()?;
293    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
294    terminal.show_cursor()?;
295
296    result
297}
298
299fn event_loop(
300    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
301    app: &mut SelectorApp,
302) -> eyre::Result<SelectorOutput> {
303    let tick_rate = Duration::from_millis(100);
304    let mut last_tick = Instant::now();
305
306    loop {
307        terminal.draw(|f| render(f, app))?;
308
309        let timeout =
310            tick_rate.checked_sub(last_tick.elapsed()).unwrap_or_else(|| Duration::from_secs(0));
311
312        if crossterm::event::poll(timeout)? &&
313            let Event::Key(key) = event::read()? &&
314            key.kind == event::KeyEventKind::Press
315        {
316            match key.code {
317                KeyCode::Char('q') | KeyCode::Esc => {
318                    eyre::bail!("Download cancelled by user");
319                }
320                KeyCode::Enter => {
321                    return Ok(SelectorOutput {
322                        selections: app.selection_map(),
323                        preset: app.preset,
324                    });
325                }
326                KeyCode::Right | KeyCode::Char('l') | KeyCode::Char(' ') => app.cycle_right(),
327                KeyCode::Left | KeyCode::Char('h') => app.cycle_left(),
328                KeyCode::Char('a') => app.select_all(),
329                KeyCode::Char('f') => app.select_full(),
330                KeyCode::Char('m') => app.select_minimal(),
331                KeyCode::Up | KeyCode::Char('k') => app.move_up(),
332                KeyCode::Down | KeyCode::Char('j') => app.move_down(),
333                _ => {}
334            }
335        }
336
337        if last_tick.elapsed() >= tick_rate {
338            last_tick = Instant::now();
339        }
340    }
341}
342
343fn format_selection(sel: &ComponentSelection) -> String {
344    match sel {
345        ComponentSelection::All => "All".to_string(),
346        ComponentSelection::Distance(d) => format!("Last {d} blocks"),
347        ComponentSelection::Since(block) => format!("Since block {block}"),
348        ComponentSelection::None => "None".to_string(),
349    }
350}
351
352fn render(f: &mut Frame<'_>, app: &mut SelectorApp) {
353    let chunks = Layout::default()
354        .direction(Direction::Vertical)
355        .constraints([
356            Constraint::Length(3), // Header
357            Constraint::Min(8),    // Component list
358            Constraint::Length(3), // Footer
359        ])
360        .split(f.area());
361
362    // Header
363    let block_info = if app.manifest.block > 0 {
364        format!(" (block {})", app.manifest.block)
365    } else {
366        String::new()
367    };
368    let header = Paragraph::new(format!(" Select snapshot components to download{}", block_info))
369        .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
370        .block(Block::default().borders(Borders::ALL).title(download_command()));
371    f.render_widget(header, chunks[0]);
372
373    // Component list
374    let items: Vec<ListItem<'_>> = app
375        .groups
376        .iter()
377        .enumerate()
378        .map(|(i, group)| {
379            let sel = &app.selections[i];
380            let sel_str = format_selection(sel);
381
382            let size = app.group_size(i);
383            let size_str =
384                if size > 0 { DownloadProgress::format_size(size) } else { String::new() };
385
386            let required = if group.required { " (required)" } else { "" };
387
388            let at_max = *sel == *group.presets.last().unwrap_or(&ComponentSelection::All);
389            let at_min = *sel == group.presets[0];
390            let arrows = if group.required {
391                "   "
392            } else if at_max {
393                "◂  "
394            } else if at_min {
395                "  ▸"
396            } else {
397                "◂ ▸"
398            };
399
400            let style = if group.required {
401                Style::default().fg(Color::DarkGray)
402            } else if matches!(sel, ComponentSelection::None) {
403                Style::default().fg(Color::White)
404            } else {
405                Style::default().fg(Color::Green)
406            };
407
408            ListItem::new(Line::from(vec![
409                Span::styled(format!(" {:<22}", group.name), style),
410                Span::styled(
411                    format!("{arrows} {:<12}", sel_str),
412                    style.add_modifier(Modifier::BOLD),
413                ),
414                Span::styled(format!("{:>10}", size_str), style.add_modifier(Modifier::DIM)),
415                Span::styled(required.to_string(), Style::default().fg(Color::DarkGray)),
416            ]))
417        })
418        .collect();
419
420    let total_str = DownloadProgress::format_size(app.total_selected_size());
421    let list = List::new(items)
422        .block(
423            Block::default()
424                .borders(Borders::ALL)
425                .title(format!("Components — Total: {total_str}")),
426        )
427        .highlight_style(Style::default().add_modifier(Modifier::BOLD).bg(Color::DarkGray))
428        .highlight_symbol("▸ ");
429    f.render_stateful_widget(list, chunks[1], &mut app.list_state);
430
431    // Footer
432    let footer = Paragraph::new(
433        " [←/→] adjust  [m] minimal  [f] full  [a] archive  [Enter] confirm  [Esc] cancel",
434    )
435    .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
436    .block(Block::default().borders(Borders::ALL));
437    f.render_widget(footer, chunks[2]);
438}