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