Skip to main content

reth_cli_commands/download/
tui.rs

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