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            ComponentSelection::Since(block) => Some(self.manifest.block - block + 1),
266        };
267        self.groups[group_idx]
268            .types
269            .iter()
270            .map(|ty| self.manifest.size_for_distance(*ty, distance))
271            .sum()
272    }
273
274    fn total_selected_size(&self) -> u64 {
275        (0..self.groups.len()).map(|i| self.group_size(i)).sum()
276    }
277}
278
279/// Runs the interactive component selector TUI.
280pub fn run_selector(
281    manifest: SnapshotManifest,
282    full_preset: &BTreeMap<SnapshotComponentType, ComponentSelection>,
283) -> eyre::Result<SelectorOutput> {
284    enable_raw_mode()?;
285    let mut stdout = io::stdout();
286    execute!(stdout, EnterAlternateScreen)?;
287    let backend = CrosstermBackend::new(stdout);
288    let mut terminal = Terminal::new(backend)?;
289
290    let mut app = SelectorApp::new(manifest, full_preset.clone());
291    let result = event_loop(&mut terminal, &mut app);
292
293    disable_raw_mode()?;
294    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
295    terminal.show_cursor()?;
296
297    result
298}
299
300fn event_loop(
301    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
302    app: &mut SelectorApp,
303) -> eyre::Result<SelectorOutput> {
304    let tick_rate = Duration::from_millis(100);
305    let mut last_tick = Instant::now();
306
307    loop {
308        terminal.draw(|f| render(f, app))?;
309
310        let timeout =
311            tick_rate.checked_sub(last_tick.elapsed()).unwrap_or_else(|| Duration::from_secs(0));
312
313        if crossterm::event::poll(timeout)? &&
314            let Event::Key(key) = event::read()? &&
315            key.kind == event::KeyEventKind::Press
316        {
317            match key.code {
318                KeyCode::Char('q') | KeyCode::Esc => {
319                    eyre::bail!("Download cancelled by user");
320                }
321                KeyCode::Enter => {
322                    return Ok(SelectorOutput {
323                        selections: app.selection_map(),
324                        preset: app.preset,
325                    });
326                }
327                KeyCode::Right | KeyCode::Char('l') | KeyCode::Char(' ') => app.cycle_right(),
328                KeyCode::Left | KeyCode::Char('h') => app.cycle_left(),
329                KeyCode::Char('a') => app.select_all(),
330                KeyCode::Char('f') => app.select_full(),
331                KeyCode::Char('m') => app.select_minimal(),
332                KeyCode::Up | KeyCode::Char('k') => app.move_up(),
333                KeyCode::Down | KeyCode::Char('j') => app.move_down(),
334                _ => {}
335            }
336        }
337
338        if last_tick.elapsed() >= tick_rate {
339            last_tick = Instant::now();
340        }
341    }
342}
343
344fn format_selection(sel: &ComponentSelection) -> String {
345    match sel {
346        ComponentSelection::All => "All".to_string(),
347        ComponentSelection::Distance(d) => format!("Last {d} blocks"),
348        ComponentSelection::Since(block) => format!("Since block {block}"),
349        ComponentSelection::None => "None".to_string(),
350    }
351}
352
353fn render(f: &mut Frame<'_>, app: &mut SelectorApp) {
354    let chunks = Layout::default()
355        .direction(Direction::Vertical)
356        .constraints([
357            Constraint::Length(3), // Header
358            Constraint::Min(8),    // Component list
359            Constraint::Length(3), // Footer
360        ])
361        .split(f.area());
362
363    // Header
364    let block_info = if app.manifest.block > 0 {
365        format!(" (block {})", app.manifest.block)
366    } else {
367        String::new()
368    };
369    let header = Paragraph::new(format!(" Select snapshot components to download{}", block_info))
370        .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
371        .block(Block::default().borders(Borders::ALL).title("reth download"));
372    f.render_widget(header, chunks[0]);
373
374    // Component list
375    let items: Vec<ListItem<'_>> = app
376        .groups
377        .iter()
378        .enumerate()
379        .map(|(i, group)| {
380            let sel = &app.selections[i];
381            let sel_str = format_selection(sel);
382
383            let size = app.group_size(i);
384            let size_str =
385                if size > 0 { DownloadProgress::format_size(size) } else { String::new() };
386
387            let required = if group.required { " (required)" } else { "" };
388
389            let at_max = *sel == *group.presets.last().unwrap_or(&ComponentSelection::All);
390            let at_min = *sel == group.presets[0];
391            let arrows = if group.required {
392                "   "
393            } else if at_max {
394                "◂  "
395            } else if at_min {
396                "  ▸"
397            } else {
398                "◂ ▸"
399            };
400
401            let style = if group.required {
402                Style::default().fg(Color::DarkGray)
403            } else if matches!(sel, ComponentSelection::None) {
404                Style::default().fg(Color::White)
405            } else {
406                Style::default().fg(Color::Green)
407            };
408
409            ListItem::new(Line::from(vec![
410                Span::styled(format!(" {:<22}", group.name), style),
411                Span::styled(
412                    format!("{arrows} {:<12}", sel_str),
413                    style.add_modifier(Modifier::BOLD),
414                ),
415                Span::styled(format!("{:>10}", size_str), style.add_modifier(Modifier::DIM)),
416                Span::styled(required.to_string(), Style::default().fg(Color::DarkGray)),
417            ]))
418        })
419        .collect();
420
421    let total_str = DownloadProgress::format_size(app.total_selected_size());
422    let list = List::new(items)
423        .block(
424            Block::default()
425                .borders(Borders::ALL)
426                .title(format!("Components — Total: {total_str}")),
427        )
428        .highlight_style(Style::default().add_modifier(Modifier::BOLD).bg(Color::DarkGray))
429        .highlight_symbol("▸ ");
430    f.render_stateful_widget(list, chunks[1], &mut app.list_state);
431
432    // Footer
433    let footer = Paragraph::new(
434        " [←/→] adjust  [m] minimal  [f] full  [a] archive  [Enter] confirm  [Esc] cancel",
435    )
436    .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
437    .block(Block::default().borders(Borders::ALL));
438    f.render_widget(footer, chunks[2]);
439}