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 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 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 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
280pub 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), Constraint::Min(8), Constraint::Length(3), ])
362 .split(f.area());
363
364 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 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 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}