1use std::{
17 collections::{BTreeMap, VecDeque},
18 sync::mpsc,
19 thread,
20 time::{Duration, Instant},
21};
22
23use color_eyre::Result;
24use rand::distributions::{Distribution, Uniform};
25use ratatui::{
26 backend::Backend,
27 crossterm::event,
28 layout::{Constraint, Layout, Rect},
29 style::{Color, Modifier, Style},
30 symbols,
31 text::{Line, Span},
32 widgets::{Block, Gauge, LineGauge, List, ListItem, Paragraph, Widget},
33 Frame, Terminal, TerminalOptions, Viewport,
34};
35
36fn main() -> Result<()> {
37 color_eyre::install()?;
38 let mut terminal = ratatui::init_with_options(TerminalOptions {
39 viewport: Viewport::Inline(8),
40 });
41
42 let (tx, rx) = mpsc::channel();
43 input_handling(tx.clone());
44 let workers = workers(tx);
45 let mut downloads = downloads();
46
47 for w in &workers {
48 let d = downloads.next(w.id).unwrap();
49 w.tx.send(d).unwrap();
50 }
51
52 let app_result = run(&mut terminal, workers, downloads, rx);
53
54 ratatui::restore();
55
56 app_result
57}
58
59const NUM_DOWNLOADS: usize = 10;
60
61type DownloadId = usize;
62type WorkerId = usize;
63enum Event {
64 Input(event::KeyEvent),
65 Tick,
66 Resize,
67 DownloadUpdate(WorkerId, DownloadId, f64),
68 DownloadDone(WorkerId, DownloadId),
69}
70struct Downloads {
71 pending: VecDeque<Download>,
72 in_progress: BTreeMap<WorkerId, DownloadInProgress>,
73}
74
75impl Downloads {
76 fn next(&mut self, worker_id: WorkerId) -> Option<Download> {
77 match self.pending.pop_front() {
78 Some(d) => {
79 self.in_progress.insert(
80 worker_id,
81 DownloadInProgress {
82 id: d.id,
83 started_at: Instant::now(),
84 progress: 0.0,
85 },
86 );
87 Some(d)
88 }
89 None => None,
90 }
91 }
92}
93struct DownloadInProgress {
94 id: DownloadId,
95 started_at: Instant,
96 progress: f64,
97}
98struct Download {
99 id: DownloadId,
100 size: usize,
101}
102struct Worker {
103 id: WorkerId,
104 tx: mpsc::Sender<Download>,
105}
106
107fn input_handling(tx: mpsc::Sender<Event>) {
108 let tick_rate = Duration::from_millis(200);
109 thread::spawn(move || {
110 let mut last_tick = Instant::now();
111 loop {
112 let timeout = tick_rate.saturating_sub(last_tick.elapsed());
114 if event::poll(timeout).unwrap() {
115 match event::read().unwrap() {
116 event::Event::Key(key) => tx.send(Event::Input(key)).unwrap(),
117 event::Event::Resize(_, _) => tx.send(Event::Resize).unwrap(),
118 _ => {}
119 };
120 }
121 if last_tick.elapsed() >= tick_rate {
122 tx.send(Event::Tick).unwrap();
123 last_tick = Instant::now();
124 }
125 }
126 });
127}
128
129#[allow(clippy::cast_precision_loss, clippy::needless_pass_by_value)]
130fn workers(tx: mpsc::Sender<Event>) -> Vec<Worker> {
131 (0..4)
132 .map(|id| {
133 let (worker_tx, worker_rx) = mpsc::channel::<Download>();
134 let tx = tx.clone();
135 thread::spawn(move || {
136 while let Ok(download) = worker_rx.recv() {
137 let mut remaining = download.size;
138 while remaining > 0 {
139 let wait = (remaining as u64).min(10);
140 thread::sleep(Duration::from_millis(wait * 10));
141 remaining = remaining.saturating_sub(10);
142 let progress = (download.size - remaining) * 100 / download.size;
143 tx.send(Event::DownloadUpdate(id, download.id, progress as f64))
144 .unwrap();
145 }
146 tx.send(Event::DownloadDone(id, download.id)).unwrap();
147 }
148 });
149 Worker { id, tx: worker_tx }
150 })
151 .collect()
152}
153
154fn downloads() -> Downloads {
155 let distribution = Uniform::new(0, 1000);
156 let mut rng = rand::thread_rng();
157 let pending = (0..NUM_DOWNLOADS)
158 .map(|id| {
159 let size = distribution.sample(&mut rng);
160 Download { id, size }
161 })
162 .collect();
163 Downloads {
164 pending,
165 in_progress: BTreeMap::new(),
166 }
167}
168
169#[allow(clippy::needless_pass_by_value)]
170fn run(
171 terminal: &mut Terminal<impl Backend>,
172 workers: Vec<Worker>,
173 mut downloads: Downloads,
174 rx: mpsc::Receiver<Event>,
175) -> Result<()> {
176 let mut redraw = true;
177 loop {
178 if redraw {
179 terminal.draw(|frame| draw(frame, &downloads))?;
180 }
181 redraw = true;
182
183 match rx.recv()? {
184 Event::Input(event) => {
185 if event.code == event::KeyCode::Char('q') {
186 break;
187 }
188 }
189 Event::Resize => {
190 terminal.autoresize()?;
191 }
192 Event::Tick => {}
193 Event::DownloadUpdate(worker_id, _download_id, progress) => {
194 let download = downloads.in_progress.get_mut(&worker_id).unwrap();
195 download.progress = progress;
196 redraw = false;
197 }
198 Event::DownloadDone(worker_id, download_id) => {
199 let download = downloads.in_progress.remove(&worker_id).unwrap();
200 terminal.insert_before(1, |buf| {
201 Paragraph::new(Line::from(vec![
202 Span::from("Finished "),
203 Span::styled(
204 format!("download {download_id}"),
205 Style::default().add_modifier(Modifier::BOLD),
206 ),
207 Span::from(format!(
208 " in {}ms",
209 download.started_at.elapsed().as_millis()
210 )),
211 ]))
212 .render(buf.area, buf);
213 })?;
214 match downloads.next(worker_id) {
215 Some(d) => workers[worker_id].tx.send(d).unwrap(),
216 None => {
217 if downloads.in_progress.is_empty() {
218 terminal.insert_before(1, |buf| {
219 Paragraph::new("Done !").render(buf.area, buf);
220 })?;
221 break;
222 }
223 }
224 };
225 }
226 };
227 }
228 Ok(())
229}
230
231fn draw(frame: &mut Frame, downloads: &Downloads) {
232 let area = frame.area();
233
234 let block = Block::new().title(Line::from("Progress").centered());
235 frame.render_widget(block, area);
236
237 let vertical = Layout::vertical([Constraint::Length(2), Constraint::Length(4)]).margin(1);
238 let horizontal = Layout::horizontal([Constraint::Percentage(20), Constraint::Percentage(80)]);
239 let [progress_area, main] = vertical.areas(area);
240 let [list_area, gauge_area] = horizontal.areas(main);
241
242 let done = NUM_DOWNLOADS - downloads.pending.len() - downloads.in_progress.len();
244 #[allow(clippy::cast_precision_loss)]
245 let progress = LineGauge::default()
246 .filled_style(Style::default().fg(Color::Blue))
247 .label(format!("{done}/{NUM_DOWNLOADS}"))
248 .ratio(done as f64 / NUM_DOWNLOADS as f64);
249 frame.render_widget(progress, progress_area);
250
251 let items: Vec<ListItem> = downloads
253 .in_progress
254 .values()
255 .map(|download| {
256 ListItem::new(Line::from(vec![
257 Span::raw(symbols::DOT),
258 Span::styled(
259 format!(" download {:>2}", download.id),
260 Style::default()
261 .fg(Color::LightGreen)
262 .add_modifier(Modifier::BOLD),
263 ),
264 Span::raw(format!(
265 " ({}ms)",
266 download.started_at.elapsed().as_millis()
267 )),
268 ]))
269 })
270 .collect();
271 let list = List::new(items);
272 frame.render_widget(list, list_area);
273
274 #[allow(clippy::cast_possible_truncation)]
275 for (i, (_, download)) in downloads.in_progress.iter().enumerate() {
276 let gauge = Gauge::default()
277 .gauge_style(Style::default().fg(Color::Yellow))
278 .ratio(download.progress / 100.0);
279 if gauge_area.top().saturating_add(i as u16) > area.bottom() {
280 continue;
281 }
282 frame.render_widget(
283 gauge,
284 Rect {
285 x: gauge_area.left(),
286 y: gauge_area.top().saturating_add(i as u16),
287 width: gauge_area.width,
288 height: 1,
289 },
290 );
291 }
292}