Skip to content

Commit

Permalink
feat(ui): render ui to alternative screen (#8084)
Browse files Browse the repository at this point in the history
### Description

Changes our UI to render in the alternative screen (think `vim` or
`less`)

This provides a few benefits:
- We can render to the entire terminal without worrying about
over-allocating space and removing useful information from the screen.
- Users won't need to scroll up at the end of a run to see the task logs
- We no longer use `insert_before` for persisting task logs. This
function panics if there isn't an available row to render to (#8072) or
rendering terminal logs that have an area that exceeds `u16::MAX`
(#7843). Instead our log persisting story is a "simple"
`stdout.write_all()`.
 - Removes hacks added to avoid hitting #7843 

We write the logs in a row-wise fasion as `vt100` attempts to optimize
performance by using cursor moves to avoid necessary writes. This
creates problems when printing multiple terminal screens as the cursor
move coordinates will be incorrect.

### Testing Instructions



https://github.com/vercel/turbo/assets/4131117/9746dcd3-188e-48fd-a52d-21d95d0e6e43



Closes TURBO-2979
  • Loading branch information
chris-olszewski committed May 9, 2024
1 parent a3ca7ff commit c58d619
Show file tree
Hide file tree
Showing 12 changed files with 268 additions and 454 deletions.
3 changes: 2 additions & 1 deletion crates/turborepo-lib/src/task_graph/visitor.rs
Expand Up @@ -1134,7 +1134,8 @@ impl<W: Write> TaskOutput<W> {
pub fn finish(self, use_error: bool) -> std::io::Result<Option<Vec<u8>>> {
match self {
TaskOutput::Direct(client) => client.finish(use_error),
TaskOutput::UI(client) => Ok(Some(client.finish())),
TaskOutput::UI(client) if use_error => Ok(Some(client.failed())),
TaskOutput::UI(client) => Ok(Some(client.succeeded())),
}
}

Expand Down
4 changes: 2 additions & 2 deletions crates/turborepo-ui/examples/table.rs
Expand Up @@ -5,7 +5,7 @@ use crossterm::{
terminal::{disable_raw_mode, enable_raw_mode},
};
use ratatui::prelude::*;
use turborepo_ui::TaskTable;
use turborepo_ui::{tui::event::TaskResult, TaskTable};

enum Event {
Tick(u64),
Expand Down Expand Up @@ -61,7 +61,7 @@ fn run_app<B: Backend>(
table.tick();
}
Event::Start(task) => table.start_task(task).unwrap(),
Event::Finish(task) => table.finish_task(task).unwrap(),
Event::Finish(task) => table.finish_task(task, TaskResult::Success).unwrap(),
Event::Up => table.previous(),
Event::Down => table.next(),
Event::Stop => break,
Expand Down
115 changes: 37 additions & 78 deletions crates/turborepo-ui/src/tui/app.rs
Expand Up @@ -6,13 +6,10 @@ use std::{
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Layout},
widgets::Widget,
Frame, Terminal,
};
use tracing::debug;
use tui_term::widget::PseudoTerminal;

const DEFAULT_APP_HEIGHT: u16 = 60;
const PANE_SIZE_RATIO: f32 = 3.0 / 4.0;
const FRAMERATE: Duration = Duration::from_millis(3);

Expand Down Expand Up @@ -100,46 +97,47 @@ impl<I: std::io::Write> App<I> {
/// Handle the rendering of the `App` widget based on events received by
/// `receiver`
pub fn run_app(tasks: Vec<String>, receiver: AppReceiver) -> Result<(), Error> {
let (mut terminal, app_height) = startup()?;
let mut terminal = startup()?;
let size = terminal.size()?;

let pane_height = (f32::from(app_height) * PANE_SIZE_RATIO) as u16;
let app: App<Box<dyn io::Write + Send>> = App::new(pane_height, size.width, tasks);
// Figure out pane width?
let task_width_hint = TaskTable::width_hint(tasks.iter().map(|s| s.as_str()));
// Want to maximize pane width
let ratio_pane_width = (f32::from(size.width) * PANE_SIZE_RATIO) as u16;
let full_task_width = size.width.saturating_sub(task_width_hint);

let result = run_app_inner(&mut terminal, app, receiver);
let mut app: App<Box<dyn io::Write + Send>> =
App::new(size.height, full_task_width.max(ratio_pane_width), tasks);

cleanup(terminal)?;
let result = run_app_inner(&mut terminal, &mut app, receiver);

cleanup(terminal, app)?;

result
}

// Break out inner loop so we can use `?` without worrying about cleaning up the
// terminal.
fn run_app_inner<B: Backend>(
fn run_app_inner<B: Backend + std::io::Write>(
terminal: &mut Terminal<B>,
mut app: App<Box<dyn io::Write + Send>>,
app: &mut App<Box<dyn io::Write + Send>>,
receiver: AppReceiver,
) -> Result<(), Error> {
// Render initial state to paint the screen
terminal.draw(|f| view(&mut app, f))?;
terminal.draw(|f| view(app, f))?;
let mut last_render = Instant::now();

while let Some(event) = poll(app.interact, &receiver, last_render + FRAMERATE) {
if let Some(message) = update(terminal, &mut app, event)? {
persist_bytes(terminal, &message)?;
}
update(app, event)?;
if app.done {
break;
}
if FRAMERATE <= last_render.elapsed() {
terminal.draw(|f| view(&mut app, f))?;
terminal.draw(|f| view(app, f))?;
last_render = Instant::now();
}
}

let started_tasks = app.table.tasks_started().collect();
app.pane.render_remaining(started_tasks, terminal)?;

Ok(())
}

Expand All @@ -155,40 +153,46 @@ fn poll(interact: bool, receiver: &AppReceiver, deadline: Instant) -> Option<Eve
}

/// Configures terminal for rendering App
fn startup() -> io::Result<(Terminal<CrosstermBackend<Stdout>>, u16)> {
fn startup() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
crossterm::terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
crossterm::execute!(stdout, crossterm::event::EnableMouseCapture)?;
crossterm::execute!(
stdout,
crossterm::event::EnableMouseCapture,
crossterm::terminal::EnterAlternateScreen
)?;
let backend = CrosstermBackend::new(stdout);

// We need to reserve at least 1 line for writing persistent lines.
let height = DEFAULT_APP_HEIGHT.min(backend.size()?.height.saturating_sub(1));

let mut terminal = Terminal::with_options(
backend,
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Inline(height),
viewport: ratatui::Viewport::Fullscreen,
},
)?;
terminal.hide_cursor()?;

Ok((terminal, height))
Ok(terminal)
}

/// Restores terminal to expected state
fn cleanup<B: Backend + io::Write>(mut terminal: Terminal<B>) -> io::Result<()> {
fn cleanup<B: Backend + io::Write, I>(
mut terminal: Terminal<B>,
mut app: App<I>,
) -> io::Result<()> {
terminal.clear()?;
crossterm::execute!(
terminal.backend_mut(),
crossterm::event::DisableMouseCapture
crossterm::event::DisableMouseCapture,
crossterm::terminal::LeaveAlternateScreen,
)?;
let started_tasks = app.table.tasks_started().collect();
app.pane.render_remaining(started_tasks)?;
crossterm::terminal::disable_raw_mode()?;
terminal.show_cursor()?;
Ok(())
}

fn update<B: Backend>(
terminal: &mut Terminal<B>,
fn update(
app: &mut App<Box<dyn io::Write + Send>>,
event: Event,
) -> Result<Option<Vec<u8>>, Error> {
Expand All @@ -208,12 +212,8 @@ fn update<B: Backend>(
Event::Tick => {
app.table.tick();
}
Event::Log { message } => {
return Ok(Some(message));
}
Event::EndTask { task } => {
app.table.finish_task(&task)?;
app.pane.render_screen(&task, terminal)?;
Event::EndTask { task, result } => {
app.table.finish_task(&task, result)?;
}
Event::Up => {
app.previous();
Expand Down Expand Up @@ -244,50 +244,9 @@ fn update<B: Backend>(
}

fn view<I>(app: &mut App<I>, f: &mut Frame) {
let (term_height, _) = app.term_size();
let vertical = Layout::vertical([Constraint::Min(5), Constraint::Length(term_height)]);
let (_, width) = app.term_size();
let vertical = Layout::horizontal([Constraint::Fill(1), Constraint::Length(width)]);
let [table, pane] = vertical.areas(f.size());
app.table.stateful_render(f, table);
f.render_widget(&app.pane, pane);
}

/// Write provided bytes to a section of the screen that won't get rewritten
fn persist_bytes(terminal: &mut Terminal<impl Backend>, bytes: &[u8]) -> Result<(), Error> {
let size = terminal.size()?;
let mut parser = turborepo_vt100::Parser::new(size.height, size.width, 128);
parser.process(bytes);
let screen = parser.entire_screen();
let (height, _) = screen.size();
terminal.insert_before(height as u16, |buf| {
PseudoTerminal::new(&screen).render(buf.area, buf)
})?;
Ok(())
}

#[cfg(test)]
mod test {
use ratatui::{backend::TestBackend, buffer::Buffer};

use super::*;

#[test]
fn test_persist_bytes() {
let mut term = Terminal::with_options(
TestBackend::new(10, 7),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Inline(3),
},
)
.unwrap();
persist_bytes(&mut term, b"two\r\nlines").unwrap();
term.backend().assert_buffer(&Buffer::with_lines(vec![
"two ",
"lines ",
" ",
" ",
" ",
" ",
" ",
]));
}
}
10 changes: 7 additions & 3 deletions crates/turborepo-ui/src/tui/event.rs
Expand Up @@ -8,16 +8,14 @@ pub enum Event {
},
EndTask {
task: String,
result: TaskResult,
},
Status {
task: String,
status: String,
},
Stop,
Tick,
Log {
message: Vec<u8>,
},
Up,
Down,
ScrollUp,
Expand All @@ -33,6 +31,12 @@ pub enum Event {
},
}

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
pub enum TaskResult {
Success,
Failure,
}

#[cfg(test)]
mod test {
use super::*;
Expand Down
65 changes: 12 additions & 53 deletions crates/turborepo-ui/src/tui/handle.rs
Expand Up @@ -3,8 +3,7 @@ use std::{
time::Instant,
};

use super::Event;
use crate::LineWriter;
use super::{Event, TaskResult};

/// Struct for sending app events to TUI rendering
#[derive(Debug, Clone)]
Expand All @@ -25,17 +24,6 @@ pub struct TuiTask {
logs: Arc<Mutex<Vec<u8>>>,
}

/// Writer that will correctly render writes to the persisted part of the screen
pub struct PersistedWriter {
writer: LineWriter<PersistedWriterInner>,
}

/// Writer that will correctly render writes to the persisted part of the screen
#[derive(Debug, Clone)]
pub struct PersistedWriterInner {
handle: AppSender,
}

impl AppSender {
/// Create a new channel for sending app events.
///
Expand Down Expand Up @@ -99,11 +87,21 @@ impl TuiTask {
}

/// Mark the task as finished
pub fn finish(&self) -> Vec<u8> {
pub fn succeeded(&self) -> Vec<u8> {
self.finish(TaskResult::Success)
}

/// Mark the task as finished
pub fn failed(&self) -> Vec<u8> {
self.finish(TaskResult::Failure)
}

fn finish(&self, result: TaskResult) -> Vec<u8> {
self.handle
.primary
.send(Event::EndTask {
task: self.name.clone(),
result,
})
.ok();
self.logs.lock().expect("logs lock poisoned").clone()
Expand Down Expand Up @@ -132,20 +130,6 @@ impl TuiTask {
})
.ok();
}

/// Return a `PersistedWriter` which will properly write provided bytes to
/// a persisted section of the terminal.
///
/// Designed to be a drop in replacement for `io::stdout()`,
/// all calls such as `writeln!(io::stdout(), "hello")` should
/// pass in a PersistedWriter instead.
pub fn stdout(&self) -> PersistedWriter {
PersistedWriter {
writer: LineWriter::new(PersistedWriterInner {
handle: self.as_app().clone(),
}),
}
}
}

impl std::io::Write for TuiTask {
Expand All @@ -171,28 +155,3 @@ impl std::io::Write for TuiTask {
Ok(())
}
}

impl std::io::Write for PersistedWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.writer.write(buf)
}

fn flush(&mut self) -> std::io::Result<()> {
self.writer.flush()
}
}

impl std::io::Write for PersistedWriterInner {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let bytes = buf.to_vec();
self.handle
.primary
.send(Event::Log { message: bytes })
.map_err(|_| std::io::Error::new(std::io::ErrorKind::Other, "receiver dropped"))?;
Ok(buf.len())
}

fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
8 changes: 4 additions & 4 deletions crates/turborepo-ui/src/tui/mod.rs
@@ -1,15 +1,15 @@
mod app;
mod event;
pub mod event;
mod handle;
mod input;
mod pane;
mod spinner;
mod table;
mod task;
mod task_duration;

pub use app::run_app;
use event::Event;
pub use handle::{AppReceiver, AppSender, PersistedWriterInner, TuiTask};
use event::{Event, TaskResult};
pub use handle::{AppReceiver, AppSender, TuiTask};
use input::input;
pub use pane::TerminalPane;
pub use table::TaskTable;
Expand Down

0 comments on commit c58d619

Please sign in to comment.