Skip to content

Commit

Permalink
feat(tui): resize terminal pane (#8996)
Browse files Browse the repository at this point in the history
### Description

Resize the layout including the pane on terminal resize events.

We completely sidestep `vt100`'s inability to properly handle resize
events by storing a copy of task outputs and recreating the virtual
terminal from scratch on a resize. A start at adding this functionality
to vt100 can be found in [this
commit](6def8c8).

Generally speaking, this is inefficient, but there is a clear path
forward if performance here becomes an issue.

Each commit can be reviewed individually.

### Testing Instructions

Added a few basic unit tests. Manual testing works best to verify
terminal looks like how we want it to look.



https://github.com/user-attachments/assets/661495d1-1a4a-4aa8-91de-cc0c2face05d
  • Loading branch information
chris-olszewski authored Aug 14, 2024
1 parent 1d4f77c commit c4882fb
Show file tree
Hide file tree
Showing 7 changed files with 248 additions and 45 deletions.
123 changes: 89 additions & 34 deletions crates/turborepo-ui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ use ratatui::{
};
use tracing::{debug, trace};

const PANE_SIZE_RATIO: f32 = 3.0 / 4.0;
const FRAMERATE: Duration = Duration::from_millis(3);
const RESIZE_DEBOUNCE_DELAY: Duration = Duration::from_millis(10);

use super::{
event::{CacheResult, OutputLogs, TaskResult},
input, AppReceiver, Error, Event, InputOptions, TaskTable, TerminalPane,
input, AppReceiver, Debouncer, Error, Event, InputOptions, SizeInfo, TaskTable, TerminalPane,
};
use crate::tui::{
task::{Task, TasksByStatus},
Expand All @@ -32,9 +32,7 @@ pub enum LayoutSections {
}

pub struct App<W> {
term_cols: u16,
pane_rows: u16,
pane_cols: u16,
size: SizeInfo,
tasks: BTreeMap<String, TerminalOutput<W>>,
tasks_by_status: TasksByStatus,
focus: LayoutSections,
Expand All @@ -53,15 +51,7 @@ pub enum Direction {
impl<W> App<W> {
pub fn new(rows: u16, cols: u16, tasks: Vec<String>) -> Self {
debug!("tasks: {tasks:?}");
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(cols) * PANE_SIZE_RATIO) as u16;
let full_task_width = cols.saturating_sub(task_width_hint);
let pane_cols = full_task_width.max(ratio_pane_width);

// We use 2 rows for pane title and for the interaction info
let rows = rows.saturating_sub(2).max(1);
let size = SizeInfo::new(rows, cols, tasks.iter().map(|s| s.as_str()));

// Initializes with the planned tasks
// and will mutate as tasks change
Expand All @@ -79,17 +69,23 @@ impl<W> App<W> {
let has_user_interacted = false;
let selected_task_index: usize = 0;

let pane_rows = size.pane_rows();
let pane_cols = size.pane_cols();

Self {
term_cols: cols,
pane_rows: rows,
pane_cols,
size,
done: false,
focus: LayoutSections::TaskList,
// Check if stdin is a tty that we should read input from
tty_stdin: atty::is(atty::Stream::Stdin),
tasks: tasks_by_status
.task_names_in_displayed_order()
.map(|task_name| (task_name.to_owned(), TerminalOutput::new(rows, cols, None)))
.map(|task_name| {
(
task_name.to_owned(),
TerminalOutput::new(pane_rows, pane_cols, None),
)
})
.collect(),
tasks_by_status,
scroll: TableState::default().with_selected(selected_task_index),
Expand Down Expand Up @@ -250,9 +246,9 @@ impl<W> App<W> {
let highlighted_task = self.active_task().to_owned();
// Make sure all tasks have a terminal output
for task in &tasks {
self.tasks
.entry(task.clone())
.or_insert_with(|| TerminalOutput::new(self.pane_rows, self.pane_cols, None));
self.tasks.entry(task.clone()).or_insert_with(|| {
TerminalOutput::new(self.size.pane_rows(), self.size.pane_cols(), None)
});
}
// Trim the terminal output to only tasks that exist in new list
self.tasks.retain(|name, _| tasks.contains(name));
Expand All @@ -279,9 +275,9 @@ impl<W> App<W> {
let highlighted_task = self.active_task().to_owned();
// Make sure all tasks have a terminal output
for task in &tasks {
self.tasks
.entry(task.clone())
.or_insert_with(|| TerminalOutput::new(self.pane_rows, self.pane_cols, None));
self.tasks.entry(task.clone()).or_insert_with(|| {
TerminalOutput::new(self.size.pane_rows(), self.size.pane_cols(), None)
});
}

self.tasks_by_status
Expand Down Expand Up @@ -320,7 +316,7 @@ impl<W> App<W> {
}

pub fn handle_mouse(&mut self, mut event: crossterm::event::MouseEvent) -> Result<(), Error> {
let table_width = self.term_cols - self.pane_cols;
let table_width = self.size.task_list_width();
debug!("original mouse event: {event:?}, table_width: {table_width}");
// Only handle mouse event if it happens inside of pane
// We give a 1 cell buffer to make it easier to select the first column of a row
Expand Down Expand Up @@ -375,6 +371,15 @@ impl<W> App<W> {
self.scroll.select(Some(0));
self.selected_task_index = 0;
}

pub fn resize(&mut self, rows: u16, cols: u16) {
self.size.resize(rows, cols);
let pane_rows = self.size.pane_rows();
let pane_cols = self.size.pane_cols();
self.tasks.values_mut().for_each(|term| {
term.resize(pane_rows, pane_cols);
})
}
}

impl<W: Write> App<W> {
Expand Down Expand Up @@ -409,7 +414,7 @@ impl<W: Write> App<W> {
#[tracing::instrument(skip(self, output))]
pub fn process_output(&mut self, task: &str, output: &[u8]) -> Result<(), Error> {
let task_output = self.tasks.get_mut(task).unwrap();
task_output.parser.process(output);
task_output.process(output);
Ok(())
}
}
Expand Down Expand Up @@ -442,15 +447,32 @@ fn run_app_inner<B: Backend + std::io::Write>(
// Render initial state to paint the screen
terminal.draw(|f| view(app, f))?;
let mut last_render = Instant::now();
let mut resize_debouncer = Debouncer::new(RESIZE_DEBOUNCE_DELAY);
let mut callback = None;
while let Some(event) = poll(app.input_options(), &receiver, last_render + FRAMERATE) {
callback = update(app, event)?;
if app.done {
break;
}
if FRAMERATE <= last_render.elapsed() {
terminal.draw(|f| view(app, f))?;
last_render = Instant::now();
let mut event = Some(event);
let mut resize_event = None;
if matches!(event, Some(Event::Resize { .. })) {
resize_event = resize_debouncer.update(
event
.take()
.expect("we just matched against a present value"),
);
}
if let Some(resize) = resize_event.take().or_else(|| resize_debouncer.query()) {
// If we got a resize event, make sure to update ratatui backend.
terminal.autoresize()?;
update(app, resize)?;
}
if let Some(event) = event {
callback = update(app, event)?;
if app.done {
break;
}
if FRAMERATE <= last_render.elapsed() {
terminal.draw(|f| view(app, f))?;
last_render = Instant::now();
}
}
}

Expand Down Expand Up @@ -595,12 +617,15 @@ fn update(
Event::RestartTasks { tasks } => {
app.update_tasks(tasks);
}
Event::Resize { rows, cols } => {
app.resize(rows, cols);
}
}
Ok(None)
}

fn view<W>(app: &mut App<W>, f: &mut Frame) {
let cols = app.pane_cols;
let cols = app.size.pane_cols();
let horizontal = Layout::horizontal([Constraint::Fill(1), Constraint::Length(cols)]);
let [table, pane] = horizontal.areas(f.size());

Expand Down Expand Up @@ -899,4 +924,34 @@ mod test {
"selected b"
);
}

#[test]
fn test_resize() {
let mut app: App<Vec<u8>> = App::new(20, 24, vec!["a".to_string(), "b".to_string()]);
let pane_rows = app.size.pane_rows();
let pane_cols = app.size.pane_cols();
for (name, task) in app.tasks.iter() {
let (rows, cols) = task.size();
assert_eq!(
(rows, cols),
(pane_rows, pane_cols),
"size mismatch for {name}"
);
}

app.resize(20, 18);
let new_pane_rows = app.size.pane_rows();
let new_pane_cols = app.size.pane_cols();
assert_eq!(pane_rows, new_pane_rows);
assert_ne!(pane_cols, new_pane_cols);

for (name, task) in app.tasks.iter() {
let (rows, cols) = task.size();
assert_eq!(
(rows, cols),
(new_pane_rows, new_pane_cols),
"size mismatch for {name}"
);
}
}
}
78 changes: 78 additions & 0 deletions crates/turborepo-ui/src/tui/debouncer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
use std::time::{Duration, Instant};

pub struct Debouncer<T> {
value: Option<T>,
duration: Duration,
start: Option<Instant>,
}

impl<T> Debouncer<T> {
/// Creates a new debouncer that will yield the latest value after the
/// provided duration Duration is reset after the debouncer yields a
/// value.
pub fn new(duration: Duration) -> Self {
Self {
value: None,
duration,
start: None,
}
}

/// Returns a value if debouncer duration has elapsed.
#[must_use]
pub fn query(&mut self) -> Option<T> {
if self
.start
.map_or(false, |start| start.elapsed() >= self.duration)
{
self.start = None;
self.value.take()
} else {
None
}
}

/// Updates debouncer with given value. Returns a value if debouncer
/// duration has elapsed.
#[must_use]
pub fn update(&mut self, value: T) -> Option<T> {
self.insert_value(Some(value));
self.query()
}

fn insert_value(&mut self, value: Option<T>) {
// If there isn't a start set, bump it
self.start.get_or_insert_with(Instant::now);
if let Some(value) = value {
self.value = Some(value);
}
}
}

#[cfg(test)]
mod test {
use super::*;

const DEFAULT_DURATION: Duration = Duration::from_millis(5);

#[test]
fn test_yields_after_duration() {
let mut debouncer = Debouncer::new(DEFAULT_DURATION);
assert!(debouncer.update(1).is_none());
assert!(debouncer.query().is_none());
std::thread::sleep(DEFAULT_DURATION);
assert_eq!(debouncer.query(), Some(1));
assert!(debouncer.query().is_none());
}

#[test]
fn test_yields_latest() {
let mut debouncer = Debouncer::new(DEFAULT_DURATION);
assert!(debouncer.update(1).is_none());
assert!(debouncer.update(2).is_none());
assert!(debouncer.update(3).is_none());
std::thread::sleep(DEFAULT_DURATION);
assert_eq!(debouncer.update(4), Some(4));
assert!(debouncer.query().is_none());
}
}
4 changes: 4 additions & 0 deletions crates/turborepo-ui/src/tui/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ pub enum Event {
RestartTasks {
tasks: Vec<String>,
},
Resize {
rows: u16,
cols: u16,
},
}

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
Expand Down
1 change: 1 addition & 0 deletions crates/turborepo-ui/src/tui/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub fn input(options: InputOptions) -> Result<Option<Event>, Error> {
}
_ => Ok(None),
},
crossterm::event::Event::Resize(cols, rows) => Ok(Some(Event::Resize { rows, cols })),
_ => Ok(None),
}
} else {
Expand Down
4 changes: 4 additions & 0 deletions crates/turborepo-ui/src/tui/mod.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
mod app;
mod clipboard;
mod debouncer;
pub mod event;
mod handle;
mod input;
mod pane;
mod size;
mod spinner;
mod table;
mod task;
mod term_output;

pub use app::{run_app, terminal_big_enough};
use clipboard::copy_to_clipboard;
use debouncer::Debouncer;
use event::{Event, TaskResult};
pub use handle::{AppReceiver, AppSender, TuiTask};
use input::{input, InputOptions};
pub use pane::TerminalPane;
use size::SizeInfo;
pub use table::TaskTable;
pub use term_output::TerminalOutput;

Expand Down
48 changes: 48 additions & 0 deletions crates/turborepo-ui/src/tui/size.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
use crate::TaskTable;

const PANE_SIZE_RATIO: f32 = 3.0 / 4.0;

#[derive(Debug, Clone, Copy)]
pub struct SizeInfo {
task_width_hint: u16,
rows: u16,
cols: u16,
}

impl SizeInfo {
pub fn new<'a>(rows: u16, cols: u16, tasks: impl Iterator<Item = &'a str>) -> Self {
let task_width_hint = TaskTable::width_hint(tasks);
Self {
rows,
cols,
task_width_hint,
}
}

pub fn resize(&mut self, rows: u16, cols: u16) {
self.rows = rows;
self.cols = cols;
}

pub fn pane_rows(&self) -> u16 {
self.rows
// Account for header and footer in layout
.saturating_sub(2)
// Always allocate at least one row as vt100 crashes if emulating a zero area terminal
.max(1)
}

pub fn task_list_width(&self) -> u16 {
self.cols - self.pane_cols()
}

pub fn pane_cols(&self) -> u16 {
// Want to maximize pane width
let ratio_pane_width = (f32::from(self.cols) * PANE_SIZE_RATIO) as u16;
let full_task_width = self.cols.saturating_sub(self.task_width_hint);
full_task_width
.max(ratio_pane_width)
// We need to account for the left border of the pane
.saturating_sub(1)
}
}
Loading

0 comments on commit c4882fb

Please sign in to comment.