inline/
inline.rs

1//! # [Ratatui] Inline example
2//!
3//! The latest version of this example is available in the [examples] folder in the repository.
4//!
5//! Please note that the examples are designed to be run against the `main` branch of the Github
6//! repository. This means that you may not be able to compile with the latest release version on
7//! crates.io, or the one that you have installed locally.
8//!
9//! See the [examples readme] for more information on finding examples that match the version of the
10//! library you are using.
11//!
12//! [Ratatui]: https://github.com/ratatui/ratatui
13//! [examples]: https://github.com/ratatui/ratatui/blob/main/examples
14//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md
15
16use 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            // poll for tick rate duration, if no events, sent tick event.
113            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    // total progress
243    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    // in progress downloads
252    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}