Skip to content

Instantly share code, notes, and snippets.

@leiless
Last active October 19, 2022 03:40
Show Gist options
  • Save leiless/ff6887134f9a5950ae27083b5cbf151f to your computer and use it in GitHub Desktop.
Save leiless/ff6887134f9a5950ae27083b5cbf151f to your computer and use it in GitHub Desktop.
tui-rs examples/user_input.rs inline viewport version (git diff)
diff --git a/examples/user_input.rs b/examples/user_input.rs
index 330d502..22d2739 100644
--- a/examples/user_input.rs
+++ b/examples/user_input.rs
@@ -12,7 +12,7 @@
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
- terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+ terminal::{disable_raw_mode, enable_raw_mode},
};
use std::{error::Error, io};
use tui::{
@@ -21,7 +21,7 @@ use tui::{
style::{Color, Modifier, Style},
text::{Span, Spans, Text},
widgets::{Block, Borders, List, ListItem, Paragraph},
- Frame, Terminal,
+ Frame, Terminal, TerminalOptions, ViewportVariant,
};
use unicode_width::UnicodeWidthStr;
@@ -54,9 +54,14 @@ fn main() -> Result<(), Box<dyn Error>> {
// setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
- execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+ execute!(stdout, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
- let mut terminal = Terminal::new(backend)?;
+ let mut terminal = Terminal::with_options(
+ backend,
+ TerminalOptions {
+ viewport: ViewportVariant::Inline(10),
+ },
+ )?;
// create app and run it
let app = App::default();
@@ -64,9 +69,9 @@ fn main() -> Result<(), Box<dyn Error>> {
// restore terminal
disable_raw_mode()?;
+ terminal.clear()?;
execute!(
terminal.backend_mut(),
- LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
@@ -116,7 +121,6 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<(
fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
- .margin(2)
.constraints(
[
Constraint::Length(1),
@leiless
Copy link
Author

leiless commented Oct 19, 2022

GIF demo

a

@leiless
Copy link
Author

leiless commented Oct 19, 2022

tui-rs/examples/user_input.rs Full code (inline viewport version)

use rand::distributions::{Distribution, Uniform};
use std::{
    collections::{BTreeMap, VecDeque},
    error::Error,
    io,
    sync::mpsc,
    thread,
    time::{Duration, Instant},
};
use tracing::{event, span, Level};
use tui::{
    backend::{Backend, CrosstermBackend},
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    symbols,
    text::{Span, Spans},
    widgets::{Block, Gauge, LineGauge, List, ListItem, Paragraph, Widget},
    Frame, Terminal, TerminalOptions, ViewportVariant,
};

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>> {
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .with_writer(io::stderr)
        .init();

    crossterm::terminal::enable_raw_mode()?;
    let stdout = io::stdout();
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::with_options(
        backend,
        TerminalOptions {
            viewport: ViewportVariant::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;

        let span = span!(Level::INFO, "recv");
        let _guard = span.enter();
        match rx.recv()? {
            Event::Input(event) => {
                if event.code == crossterm::event::KeyCode::Char('q') {
                    break;
                }
            }
            Event::Resize => {
                event!(Level::INFO, "resize");
                terminal.resize()?;
            }
            Event::Tick => {
                event!(Level::INFO, "tick");
            }
            Event::DownloadUpdate(worker_id, download_id, progress) => {
                event!(
                    Level::INFO,
                    worker_id,
                    download_id,
                    progress,
                    "download update"
                );
                let download = downloads.in_progress.get_mut(&worker_id).unwrap();
                download.progress = progress;
                redraw = false
            }
            Event::DownloadDone(worker_id, download_id) => {
                event!(Level::INFO, worker_id, download_id, "download done");
                let download = downloads.in_progress.remove(&worker_id).unwrap();
                terminal.insert_before(1, |buf| {
                    Paragraph::new(Spans::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
        .iter()
        .map(|(_worker_id, download)| {
            ListItem::new(Spans::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,
            },
        );
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment