blob: 2503d6c9ff9ec625f356db250d1db647b35d7605 [file] [log] [blame]
use std::{
io::{self, Write},
time::Duration,
};
use futures_lite::StreamExt;
use tui::layout::Rect;
use crate::{
render::tui::{draw, ticker},
Root, Throughput, WeakRoot,
};
/// Configure the terminal user interface
#[derive(Clone)]
pub struct Options {
/// The initial title to show for the whole window.
///
/// Can be adjusted later by sending `Event::SetTitle(…)`
/// into the event stream, see see [`tui::render_with_input(…events)`](./fn.render_with_input.html) function.
pub title: String,
/// The amount of frames to draw per second. If below 1.0, it determines the amount of seconds between the frame.
///
/// *e.g.* 1.0/4.0 is one frame every 4 seconds.
pub frames_per_second: f32,
/// If true, (default false), we will keep track of the previous progress state to derive
/// continuous throughput information from. Throughput will only show for units which have
/// explicitly enabled it, it is opt-in.
///
/// This comes at the cost of additional memory and CPU time.
pub throughput: bool,
/// If set, recompute the column width of the task tree only every given frame. Otherwise the width will be recomputed every frame.
///
/// Use this if there are many short-running tasks with varying names paired with high refresh rates of multiple frames per second to
/// stabilize the appearance of the TUI.
///
/// For example, setting the value to 40 will with a frame rate of 20 per second will recompute the column width to fit all task names
/// every 2 seconds.
pub recompute_column_width_every_nth_frame: Option<usize>,
/// The initial window size.
///
/// If unset, it will be retrieved from the current terminal.
pub window_size: Option<Rect>,
/// If true (default: true), we will stop running the TUI once the progress isn't available anymore (went out of scope).
pub stop_if_progress_missing: bool,
}
impl Default for Options {
fn default() -> Self {
Options {
title: "Progress Dashboard".into(),
frames_per_second: 10.0,
throughput: false,
recompute_column_width_every_nth_frame: None,
window_size: None,
stop_if_progress_missing: true,
}
}
}
/// A line as used in [`Event::SetInformation`](./enum.Event.html#variant.SetInformation)
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Line {
/// Set a title with the given text
Title(String),
/// Set a line of text with the given content
Text(String),
}
/// The variants represented here allow the user to control when the GUI can be shutdown.
#[derive(Debug, Clone, Copy)]
pub enum Interrupt {
/// Immediately exit the GUI event loop when there is an interrupt request.
///
/// This is the default when the event loop is entered.
Instantly,
/// Instead of exiting the event loop instantly, wait until the next Interrupt::Instantly
/// event is coming in.
Deferred,
}
#[derive(Clone, Copy)]
pub(crate) enum InterruptDrawInfo {
Instantly,
/// Boolean signals if interrupt is requested
Deferred(bool),
}
#[cfg(not(any(feature = "render-tui-crossterm", feature = "render-tui-termion")))]
compile_error!(
"Please set either the 'render-tui-crossterm' or 'render-tui-termion' feature whne using the 'render-tui'"
);
use crosstermion::crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
use crosstermion::{
input::{key_input_stream, Key},
terminal::{tui::new_terminal, AlternateRawScreen},
};
/// An event to be sent in the [`tui::render_with_input(…events)`](./fn.render_with_input.html) stream.
///
/// This way, the TUI can be instructed to draw frames or change the information to be displayed.
#[derive(Debug, Clone)]
pub enum Event {
/// Draw a frame
Tick,
/// Send any key - can be used to simulate user input, and is typically generated by the TUI's own input loop.
Input(Key),
/// Change the size of the window to the given rectangle.
///
/// Useful to embed the TUI into other terminal user interfaces that can resize dynamically.
SetWindowSize(Rect),
/// Set the title of the progress dashboard
SetTitle(String),
/// Provide a list of titles and lines to populate the side bar on the right.
SetInformation(Vec<Line>),
/// The way the GUI will respond to interrupt requests. See `Interrupt` for more information.
SetInterruptMode(Interrupt),
}
/// Returns a future that draws the terminal user interface indefinitely.
///
/// * `progress` is the progress tree whose information to visualize.
/// It will usually be changing constantly while the TUI holds it.
/// * `options` are configuring the TUI.
/// * `events` is a stream of `Event`s which manipulate the TUI while it is running
///
/// Failure may occour if there is no terminal to draw into.
pub fn render_with_input(
out: impl std::io::Write,
progress: impl WeakRoot,
options: Options,
events: impl futures_core::Stream<Item = Event> + Send + Unpin,
) -> Result<impl std::future::Future<Output = ()>, std::io::Error> {
let Options {
title,
frames_per_second,
window_size,
recompute_column_width_every_nth_frame,
throughput,
stop_if_progress_missing,
} = options;
let mut terminal = new_terminal(AlternateRawScreen::try_from(out)?)?;
terminal.hide_cursor()?;
let duration_per_frame = Duration::from_secs_f32(1.0 / frames_per_second);
let key_receive = key_input_stream();
let render_fut = async move {
let mut state = draw::State {
title,
duration_per_frame,
..draw::State::default()
};
if throughput {
state.throughput = Some(Throughput::default());
}
let mut interrupt_mode = InterruptDrawInfo::Instantly;
let (entries_cap, messages_cap) = progress
.upgrade()
.map(|p| (p.num_tasks(), p.messages_capacity()))
.unwrap_or_default();
let mut entries = Vec::with_capacity(entries_cap);
let mut messages = Vec::with_capacity(messages_cap);
let mut events = ticker(duration_per_frame)
.map(|_| Event::Tick)
.or(key_receive.map(Event::Input))
.or(events);
let mut tick = 0usize;
let store_task_size_every = recompute_column_width_every_nth_frame.unwrap_or(1).max(1);
while let Some(event) = events.next().await {
let mut skip_redraw = false;
match event {
Event::Tick => {}
Event::Input(key) if key.kind != KeyEventKind::Release => match key.code {
KeyCode::Char('c') | KeyCode::Char('[') if key.modifiers.contains(KeyModifiers::CONTROL) => {
match interrupt_mode {
InterruptDrawInfo::Instantly => break,
InterruptDrawInfo::Deferred(_) => interrupt_mode = InterruptDrawInfo::Deferred(true),
}
}
KeyCode::Esc | KeyCode::Char('q') => match interrupt_mode {
InterruptDrawInfo::Instantly => break,
InterruptDrawInfo::Deferred(_) => interrupt_mode = InterruptDrawInfo::Deferred(true),
},
KeyCode::Char('`') => state.hide_messages = !state.hide_messages,
KeyCode::Char('~') => state.messages_fullscreen = !state.messages_fullscreen,
KeyCode::Char('J') => state.message_offset = state.message_offset.saturating_add(1),
KeyCode::Char('D') => state.message_offset = state.message_offset.saturating_add(10),
KeyCode::Char('j') => state.task_offset = state.task_offset.saturating_add(1),
KeyCode::Char('d') => state.task_offset = state.task_offset.saturating_add(10),
KeyCode::Char('K') => state.message_offset = state.message_offset.saturating_sub(1),
KeyCode::Char('U') => state.message_offset = state.message_offset.saturating_sub(10),
KeyCode::Char('k') => state.task_offset = state.task_offset.saturating_sub(1),
KeyCode::Char('u') => state.task_offset = state.task_offset.saturating_sub(10),
KeyCode::Char('[') => state.hide_info = !state.hide_info,
KeyCode::Char('{') => state.maximize_info = !state.maximize_info,
_ => skip_redraw = true,
},
Event::Input(_) => skip_redraw = true,
Event::SetWindowSize(bound) => state.user_provided_window_size = Some(bound),
Event::SetTitle(title) => state.title = title,
Event::SetInformation(info) => state.information = info,
Event::SetInterruptMode(mode) => {
interrupt_mode = match mode {
Interrupt::Instantly => {
if let InterruptDrawInfo::Deferred(true) = interrupt_mode {
break;
}
InterruptDrawInfo::Instantly
}
Interrupt::Deferred => InterruptDrawInfo::Deferred(match interrupt_mode {
InterruptDrawInfo::Deferred(interrupt_requested) => interrupt_requested,
_ => false,
}),
};
}
}
if !skip_redraw {
tick += 1;
let progress = match progress.upgrade() {
Some(progress) => progress,
None if stop_if_progress_missing => break,
None => continue,
};
progress.sorted_snapshot(&mut entries);
if stop_if_progress_missing && entries.is_empty() {
break;
}
let terminal_window_size = terminal.pre_render().expect("pre-render to work");
let window_size = state
.user_provided_window_size
.or(window_size)
.unwrap_or(terminal_window_size);
let buf = terminal.current_buffer_mut();
if !state.hide_messages {
progress.copy_messages(&mut messages);
}
draw::all(&mut state, interrupt_mode, &entries, &messages, window_size, buf);
if tick == 1 || tick % store_task_size_every == 0 || state.last_tree_column_width.unwrap_or(0) == 0 {
state.next_tree_column_width = state.last_tree_column_width;
}
terminal.post_render().expect("post render to work");
}
}
// Make sure the terminal responds right away when this future stops, to reset back to the 'non-alternate' buffer
drop(terminal);
io::stdout().flush().ok();
};
Ok(render_fut)
}
/// An easy-to-use version of `render_with_input(…)` that does not allow state manipulation via an event stream.
pub fn render(
out: impl std::io::Write,
progress: impl WeakRoot,
config: Options,
) -> Result<impl std::future::Future<Output = ()>, std::io::Error> {
render_with_input(out, progress, config, futures_lite::stream::pending())
}