reth_cli_commands/download/
tui.rs1use 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
25pub struct SelectorOutput {
27 pub selections: BTreeMap<SnapshotComponentType, ComponentSelection>,
29 pub preset: Option<SelectionPreset>,
31}
32
33const 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
43const 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
52const 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
60struct DisplayGroup {
62 name: &'static str,
64 types: Vec<SnapshotComponentType>,
66 required: bool,
68 presets: &'static [ComponentSelection],
71}
72
73fn 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 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 groups: Vec<DisplayGroup>,
142 selections: Vec<ComponentSelection>,
144 preset: Option<SelectionPreset>,
146 cursor: usize,
148 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 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 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 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
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::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), Constraint::Min(8), Constraint::Length(3), ])
360 .split(f.area());
361
362 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 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 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}