mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-25 22:20:31 +00:00
728f82c084
* refactor: add Line type to replace Spans `Line` is a significantly better name over `Spans` as the plural causes confusion and the type really is a representation of a line of text made up of spans. This is a backwards compatible version of the approach from https://github.com/tui-rs-revival/ratatui/pull/175 There is a significant amount of code that uses the Spans type and methods, so instead of just renaming it, we add a new type and replace parameters that accepts a `Spans` with a parameter that accepts `Into<Line>`. Note that the examples have been intentionally left using `Spans` in this commit to demonstrate the compiler warnings that will be emitted in existing code. Implementation notes: - moves the Spans code to text::spans and publicly reexports on the text module. This makes the test in that module only relevant to the Spans type. - adds a line module with a copy of the code and tests from Spans with a single addition: `impl<'a> From<Spans<'a>> for Line<'a>` - adds tests for `Spans` (created and checked before refactoring) - adds the same tests for `Line` - updates all widget methods that accept and store Spans to instead store `Line` and accept `Into<Line>` * refactor: move text::Masked to text::masked::Masked Re-exports the Masked type at text::Masked * refactor: replace Spans with Line in tests/examples/docs
292 lines
8.8 KiB
Rust
292 lines
8.8 KiB
Rust
use rand::distributions::{Distribution, Uniform};
|
|
use ratatui::{
|
|
backend::{Backend, CrosstermBackend},
|
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
|
style::{Color, Modifier, Style},
|
|
symbols,
|
|
text::{Line, Span},
|
|
widgets::{Block, Gauge, LineGauge, List, ListItem, Paragraph, Widget},
|
|
Frame, Terminal, TerminalOptions, Viewport,
|
|
};
|
|
use std::{
|
|
collections::{BTreeMap, VecDeque},
|
|
error::Error,
|
|
io,
|
|
sync::mpsc,
|
|
thread,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
const NUM_DOWNLOADS: usize = 10;
|
|
|
|
type DownloadId = usize;
|
|
type WorkerId = usize;
|
|
|
|
enum Event {
|
|
Input(crossterm::event::KeyEvent),
|
|
Tick,
|
|
Resize,
|
|
DownloadUpdate(WorkerId, DownloadId, f64),
|
|
DownloadDone(WorkerId, DownloadId),
|
|
}
|
|
|
|
struct Downloads {
|
|
pending: VecDeque<Download>,
|
|
in_progress: BTreeMap<WorkerId, DownloadInProgress>,
|
|
}
|
|
|
|
impl Downloads {
|
|
fn next(&mut self, worker_id: WorkerId) -> Option<Download> {
|
|
match self.pending.pop_front() {
|
|
Some(d) => {
|
|
self.in_progress.insert(
|
|
worker_id,
|
|
DownloadInProgress {
|
|
id: d.id,
|
|
started_at: Instant::now(),
|
|
progress: 0.0,
|
|
},
|
|
);
|
|
Some(d)
|
|
}
|
|
None => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DownloadInProgress {
|
|
id: DownloadId,
|
|
started_at: Instant,
|
|
progress: f64,
|
|
}
|
|
|
|
struct Download {
|
|
id: DownloadId,
|
|
size: usize,
|
|
}
|
|
|
|
struct Worker {
|
|
id: WorkerId,
|
|
tx: mpsc::Sender<Download>,
|
|
}
|
|
|
|
fn main() -> Result<(), Box<dyn Error>> {
|
|
crossterm::terminal::enable_raw_mode()?;
|
|
let stdout = io::stdout();
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::with_options(
|
|
backend,
|
|
TerminalOptions {
|
|
viewport: Viewport::Inline(8),
|
|
},
|
|
)?;
|
|
|
|
let (tx, rx) = mpsc::channel();
|
|
input_handling(tx.clone());
|
|
let workers = workers(tx);
|
|
let mut downloads = downloads();
|
|
|
|
for w in &workers {
|
|
let d = downloads.next(w.id).unwrap();
|
|
w.tx.send(d).unwrap();
|
|
}
|
|
|
|
run_app(&mut terminal, workers, downloads, rx)?;
|
|
|
|
crossterm::terminal::disable_raw_mode()?;
|
|
terminal.clear()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn input_handling(tx: mpsc::Sender<Event>) {
|
|
let tick_rate = Duration::from_millis(200);
|
|
thread::spawn(move || {
|
|
let mut last_tick = Instant::now();
|
|
loop {
|
|
// poll for tick rate duration, if no events, sent tick event.
|
|
let timeout = tick_rate
|
|
.checked_sub(last_tick.elapsed())
|
|
.unwrap_or_else(|| Duration::from_secs(0));
|
|
if crossterm::event::poll(timeout).unwrap() {
|
|
match crossterm::event::read().unwrap() {
|
|
crossterm::event::Event::Key(key) => tx.send(Event::Input(key)).unwrap(),
|
|
crossterm::event::Event::Resize(_, _) => tx.send(Event::Resize).unwrap(),
|
|
_ => {}
|
|
};
|
|
}
|
|
if last_tick.elapsed() >= tick_rate {
|
|
tx.send(Event::Tick).unwrap();
|
|
last_tick = Instant::now();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn workers(tx: mpsc::Sender<Event>) -> Vec<Worker> {
|
|
(0..4)
|
|
.map(|id| {
|
|
let (worker_tx, worker_rx) = mpsc::channel::<Download>();
|
|
let tx = tx.clone();
|
|
thread::spawn(move || {
|
|
while let Ok(download) = worker_rx.recv() {
|
|
let mut remaining = download.size;
|
|
while remaining > 0 {
|
|
let wait = (remaining as u64).min(10);
|
|
thread::sleep(Duration::from_millis(wait * 10));
|
|
remaining = remaining.saturating_sub(10);
|
|
let progress = (download.size - remaining) * 100 / download.size;
|
|
tx.send(Event::DownloadUpdate(id, download.id, progress as f64))
|
|
.unwrap();
|
|
}
|
|
tx.send(Event::DownloadDone(id, download.id)).unwrap();
|
|
}
|
|
});
|
|
Worker { id, tx: worker_tx }
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn downloads() -> Downloads {
|
|
let distribution = Uniform::new(0, 1000);
|
|
let mut rng = rand::thread_rng();
|
|
let pending = (0..NUM_DOWNLOADS)
|
|
.map(|id| {
|
|
let size = distribution.sample(&mut rng);
|
|
Download { id, size }
|
|
})
|
|
.collect();
|
|
Downloads {
|
|
pending,
|
|
in_progress: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
fn run_app<B: Backend>(
|
|
terminal: &mut Terminal<B>,
|
|
workers: Vec<Worker>,
|
|
mut downloads: Downloads,
|
|
rx: mpsc::Receiver<Event>,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
let mut redraw = true;
|
|
loop {
|
|
if redraw {
|
|
terminal.draw(|f| ui(f, &downloads))?;
|
|
}
|
|
redraw = true;
|
|
|
|
match rx.recv()? {
|
|
Event::Input(event) => {
|
|
if event.code == crossterm::event::KeyCode::Char('q') {
|
|
break;
|
|
}
|
|
}
|
|
Event::Resize => {
|
|
terminal.autoresize()?;
|
|
}
|
|
Event::Tick => {}
|
|
Event::DownloadUpdate(worker_id, _download_id, progress) => {
|
|
let download = downloads.in_progress.get_mut(&worker_id).unwrap();
|
|
download.progress = progress;
|
|
redraw = false
|
|
}
|
|
Event::DownloadDone(worker_id, download_id) => {
|
|
let download = downloads.in_progress.remove(&worker_id).unwrap();
|
|
terminal.insert_before(1, |buf| {
|
|
Paragraph::new(Line::from(vec![
|
|
Span::from("Finished "),
|
|
Span::styled(
|
|
format!("download {}", download_id),
|
|
Style::default().add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::from(format!(
|
|
" in {}ms",
|
|
download.started_at.elapsed().as_millis()
|
|
)),
|
|
]))
|
|
.render(buf.area, buf);
|
|
})?;
|
|
match downloads.next(worker_id) {
|
|
Some(d) => workers[worker_id].tx.send(d).unwrap(),
|
|
None => {
|
|
if downloads.in_progress.is_empty() {
|
|
terminal.insert_before(1, |buf| {
|
|
Paragraph::new("Done !").render(buf.area, buf);
|
|
})?;
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
};
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ui<B: Backend>(f: &mut Frame<B>, downloads: &Downloads) {
|
|
let size = f.size();
|
|
|
|
let block = Block::default()
|
|
.title("Progress")
|
|
.title_alignment(Alignment::Center);
|
|
f.render_widget(block, size);
|
|
|
|
let chunks = Layout::default()
|
|
.constraints(vec![Constraint::Length(2), Constraint::Length(4)])
|
|
.margin(1)
|
|
.split(size);
|
|
|
|
// total progress
|
|
let done = NUM_DOWNLOADS - downloads.pending.len() - downloads.in_progress.len();
|
|
let progress = LineGauge::default()
|
|
.gauge_style(Style::default().fg(Color::Blue))
|
|
.label(format!("{}/{}", done, NUM_DOWNLOADS))
|
|
.ratio(done as f64 / NUM_DOWNLOADS as f64);
|
|
f.render_widget(progress, chunks[0]);
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints(vec![Constraint::Percentage(20), Constraint::Percentage(80)])
|
|
.split(chunks[1]);
|
|
|
|
// in progress downloads
|
|
let items: Vec<ListItem> = downloads
|
|
.in_progress
|
|
.values()
|
|
.map(|download| {
|
|
ListItem::new(Line::from(vec![
|
|
Span::raw(symbols::DOT),
|
|
Span::styled(
|
|
format!(" download {:>2}", download.id),
|
|
Style::default()
|
|
.fg(Color::LightGreen)
|
|
.add_modifier(Modifier::BOLD),
|
|
),
|
|
Span::raw(format!(
|
|
" ({}ms)",
|
|
download.started_at.elapsed().as_millis()
|
|
)),
|
|
]))
|
|
})
|
|
.collect();
|
|
let list = List::new(items);
|
|
f.render_widget(list, chunks[0]);
|
|
|
|
for (i, (_, download)) in downloads.in_progress.iter().enumerate() {
|
|
let gauge = Gauge::default()
|
|
.gauge_style(Style::default().fg(Color::Yellow))
|
|
.ratio(download.progress / 100.0);
|
|
if chunks[1].top().saturating_add(i as u16) > size.bottom() {
|
|
continue;
|
|
}
|
|
f.render_widget(
|
|
gauge,
|
|
Rect {
|
|
x: chunks[1].left(),
|
|
y: chunks[1].top().saturating_add(i as u16),
|
|
width: chunks[1].width,
|
|
height: 1,
|
|
},
|
|
);
|
|
}
|
|
}
|