reth_cli_commands/download/
tui.rs1use 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
24pub struct SelectorOutput {
26 pub selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
28 pub preset: Option<SelectionPreset>,
30}
31
32const 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
42const 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
51const 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
59struct DisplayGroup {
61 name: &'static str,
63 types: Vec<SnapshotComponentType>,
65 required: bool,
67 presets: &'static [ComponentSelection],
70}
71
72fn 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 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 groups: Vec<DisplayGroup>,
141 selections: Vec<ComponentSelection>,
143 preset: Option<SelectionPreset>,
145 cursor: usize,
147 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 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 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 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
278pub 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), Constraint::Min(8), Constraint::Length(3), ])
359 .split(f.area());
360
361 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 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 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}