Skip to content

Commit c4882fb

Browse files
feat(tui): resize terminal pane (#8996)
### 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
1 parent 1d4f77c commit c4882fb

File tree

7 files changed

+248
-45
lines changed

7 files changed

+248
-45
lines changed

crates/turborepo-ui/src/tui/app.rs

Lines changed: 89 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ use ratatui::{
1313
};
1414
use tracing::{debug, trace};
1515

16-
const PANE_SIZE_RATIO: f32 = 3.0 / 4.0;
1716
const FRAMERATE: Duration = Duration::from_millis(3);
17+
const RESIZE_DEBOUNCE_DELAY: Duration = Duration::from_millis(10);
1818

1919
use super::{
2020
event::{CacheResult, OutputLogs, TaskResult},
21-
input, AppReceiver, Error, Event, InputOptions, TaskTable, TerminalPane,
21+
input, AppReceiver, Debouncer, Error, Event, InputOptions, SizeInfo, TaskTable, TerminalPane,
2222
};
2323
use crate::tui::{
2424
task::{Task, TasksByStatus},
@@ -32,9 +32,7 @@ pub enum LayoutSections {
3232
}
3333

3434
pub struct App<W> {
35-
term_cols: u16,
36-
pane_rows: u16,
37-
pane_cols: u16,
35+
size: SizeInfo,
3836
tasks: BTreeMap<String, TerminalOutput<W>>,
3937
tasks_by_status: TasksByStatus,
4038
focus: LayoutSections,
@@ -53,15 +51,7 @@ pub enum Direction {
5351
impl<W> App<W> {
5452
pub fn new(rows: u16, cols: u16, tasks: Vec<String>) -> Self {
5553
debug!("tasks: {tasks:?}");
56-
let task_width_hint = TaskTable::width_hint(tasks.iter().map(|s| s.as_str()));
57-
58-
// Want to maximize pane width
59-
let ratio_pane_width = (f32::from(cols) * PANE_SIZE_RATIO) as u16;
60-
let full_task_width = cols.saturating_sub(task_width_hint);
61-
let pane_cols = full_task_width.max(ratio_pane_width);
62-
63-
// We use 2 rows for pane title and for the interaction info
64-
let rows = rows.saturating_sub(2).max(1);
54+
let size = SizeInfo::new(rows, cols, tasks.iter().map(|s| s.as_str()));
6555

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

72+
let pane_rows = size.pane_rows();
73+
let pane_cols = size.pane_cols();
74+
8275
Self {
83-
term_cols: cols,
84-
pane_rows: rows,
85-
pane_cols,
76+
size,
8677
done: false,
8778
focus: LayoutSections::TaskList,
8879
// Check if stdin is a tty that we should read input from
8980
tty_stdin: atty::is(atty::Stream::Stdin),
9081
tasks: tasks_by_status
9182
.task_names_in_displayed_order()
92-
.map(|task_name| (task_name.to_owned(), TerminalOutput::new(rows, cols, None)))
83+
.map(|task_name| {
84+
(
85+
task_name.to_owned(),
86+
TerminalOutput::new(pane_rows, pane_cols, None),
87+
)
88+
})
9389
.collect(),
9490
tasks_by_status,
9591
scroll: TableState::default().with_selected(selected_task_index),
@@ -250,9 +246,9 @@ impl<W> App<W> {
250246
let highlighted_task = self.active_task().to_owned();
251247
// Make sure all tasks have a terminal output
252248
for task in &tasks {
253-
self.tasks
254-
.entry(task.clone())
255-
.or_insert_with(|| TerminalOutput::new(self.pane_rows, self.pane_cols, None));
249+
self.tasks.entry(task.clone()).or_insert_with(|| {
250+
TerminalOutput::new(self.size.pane_rows(), self.size.pane_cols(), None)
251+
});
256252
}
257253
// Trim the terminal output to only tasks that exist in new list
258254
self.tasks.retain(|name, _| tasks.contains(name));
@@ -279,9 +275,9 @@ impl<W> App<W> {
279275
let highlighted_task = self.active_task().to_owned();
280276
// Make sure all tasks have a terminal output
281277
for task in &tasks {
282-
self.tasks
283-
.entry(task.clone())
284-
.or_insert_with(|| TerminalOutput::new(self.pane_rows, self.pane_cols, None));
278+
self.tasks.entry(task.clone()).or_insert_with(|| {
279+
TerminalOutput::new(self.size.pane_rows(), self.size.pane_cols(), None)
280+
});
285281
}
286282

287283
self.tasks_by_status
@@ -320,7 +316,7 @@ impl<W> App<W> {
320316
}
321317

322318
pub fn handle_mouse(&mut self, mut event: crossterm::event::MouseEvent) -> Result<(), Error> {
323-
let table_width = self.term_cols - self.pane_cols;
319+
let table_width = self.size.task_list_width();
324320
debug!("original mouse event: {event:?}, table_width: {table_width}");
325321
// Only handle mouse event if it happens inside of pane
326322
// We give a 1 cell buffer to make it easier to select the first column of a row
@@ -375,6 +371,15 @@ impl<W> App<W> {
375371
self.scroll.select(Some(0));
376372
self.selected_task_index = 0;
377373
}
374+
375+
pub fn resize(&mut self, rows: u16, cols: u16) {
376+
self.size.resize(rows, cols);
377+
let pane_rows = self.size.pane_rows();
378+
let pane_cols = self.size.pane_cols();
379+
self.tasks.values_mut().for_each(|term| {
380+
term.resize(pane_rows, pane_cols);
381+
})
382+
}
378383
}
379384

380385
impl<W: Write> App<W> {
@@ -409,7 +414,7 @@ impl<W: Write> App<W> {
409414
#[tracing::instrument(skip(self, output))]
410415
pub fn process_output(&mut self, task: &str, output: &[u8]) -> Result<(), Error> {
411416
let task_output = self.tasks.get_mut(task).unwrap();
412-
task_output.parser.process(output);
417+
task_output.process(output);
413418
Ok(())
414419
}
415420
}
@@ -442,15 +447,32 @@ fn run_app_inner<B: Backend + std::io::Write>(
442447
// Render initial state to paint the screen
443448
terminal.draw(|f| view(app, f))?;
444449
let mut last_render = Instant::now();
450+
let mut resize_debouncer = Debouncer::new(RESIZE_DEBOUNCE_DELAY);
445451
let mut callback = None;
446452
while let Some(event) = poll(app.input_options(), &receiver, last_render + FRAMERATE) {
447-
callback = update(app, event)?;
448-
if app.done {
449-
break;
450-
}
451-
if FRAMERATE <= last_render.elapsed() {
452-
terminal.draw(|f| view(app, f))?;
453-
last_render = Instant::now();
453+
let mut event = Some(event);
454+
let mut resize_event = None;
455+
if matches!(event, Some(Event::Resize { .. })) {
456+
resize_event = resize_debouncer.update(
457+
event
458+
.take()
459+
.expect("we just matched against a present value"),
460+
);
461+
}
462+
if let Some(resize) = resize_event.take().or_else(|| resize_debouncer.query()) {
463+
// If we got a resize event, make sure to update ratatui backend.
464+
terminal.autoresize()?;
465+
update(app, resize)?;
466+
}
467+
if let Some(event) = event {
468+
callback = update(app, event)?;
469+
if app.done {
470+
break;
471+
}
472+
if FRAMERATE <= last_render.elapsed() {
473+
terminal.draw(|f| view(app, f))?;
474+
last_render = Instant::now();
475+
}
454476
}
455477
}
456478

@@ -595,12 +617,15 @@ fn update(
595617
Event::RestartTasks { tasks } => {
596618
app.update_tasks(tasks);
597619
}
620+
Event::Resize { rows, cols } => {
621+
app.resize(rows, cols);
622+
}
598623
}
599624
Ok(None)
600625
}
601626

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

@@ -899,4 +924,34 @@ mod test {
899924
"selected b"
900925
);
901926
}
927+
928+
#[test]
929+
fn test_resize() {
930+
let mut app: App<Vec<u8>> = App::new(20, 24, vec!["a".to_string(), "b".to_string()]);
931+
let pane_rows = app.size.pane_rows();
932+
let pane_cols = app.size.pane_cols();
933+
for (name, task) in app.tasks.iter() {
934+
let (rows, cols) = task.size();
935+
assert_eq!(
936+
(rows, cols),
937+
(pane_rows, pane_cols),
938+
"size mismatch for {name}"
939+
);
940+
}
941+
942+
app.resize(20, 18);
943+
let new_pane_rows = app.size.pane_rows();
944+
let new_pane_cols = app.size.pane_cols();
945+
assert_eq!(pane_rows, new_pane_rows);
946+
assert_ne!(pane_cols, new_pane_cols);
947+
948+
for (name, task) in app.tasks.iter() {
949+
let (rows, cols) = task.size();
950+
assert_eq!(
951+
(rows, cols),
952+
(new_pane_rows, new_pane_cols),
953+
"size mismatch for {name}"
954+
);
955+
}
956+
}
902957
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
use std::time::{Duration, Instant};
2+
3+
pub struct Debouncer<T> {
4+
value: Option<T>,
5+
duration: Duration,
6+
start: Option<Instant>,
7+
}
8+
9+
impl<T> Debouncer<T> {
10+
/// Creates a new debouncer that will yield the latest value after the
11+
/// provided duration Duration is reset after the debouncer yields a
12+
/// value.
13+
pub fn new(duration: Duration) -> Self {
14+
Self {
15+
value: None,
16+
duration,
17+
start: None,
18+
}
19+
}
20+
21+
/// Returns a value if debouncer duration has elapsed.
22+
#[must_use]
23+
pub fn query(&mut self) -> Option<T> {
24+
if self
25+
.start
26+
.map_or(false, |start| start.elapsed() >= self.duration)
27+
{
28+
self.start = None;
29+
self.value.take()
30+
} else {
31+
None
32+
}
33+
}
34+
35+
/// Updates debouncer with given value. Returns a value if debouncer
36+
/// duration has elapsed.
37+
#[must_use]
38+
pub fn update(&mut self, value: T) -> Option<T> {
39+
self.insert_value(Some(value));
40+
self.query()
41+
}
42+
43+
fn insert_value(&mut self, value: Option<T>) {
44+
// If there isn't a start set, bump it
45+
self.start.get_or_insert_with(Instant::now);
46+
if let Some(value) = value {
47+
self.value = Some(value);
48+
}
49+
}
50+
}
51+
52+
#[cfg(test)]
53+
mod test {
54+
use super::*;
55+
56+
const DEFAULT_DURATION: Duration = Duration::from_millis(5);
57+
58+
#[test]
59+
fn test_yields_after_duration() {
60+
let mut debouncer = Debouncer::new(DEFAULT_DURATION);
61+
assert!(debouncer.update(1).is_none());
62+
assert!(debouncer.query().is_none());
63+
std::thread::sleep(DEFAULT_DURATION);
64+
assert_eq!(debouncer.query(), Some(1));
65+
assert!(debouncer.query().is_none());
66+
}
67+
68+
#[test]
69+
fn test_yields_latest() {
70+
let mut debouncer = Debouncer::new(DEFAULT_DURATION);
71+
assert!(debouncer.update(1).is_none());
72+
assert!(debouncer.update(2).is_none());
73+
assert!(debouncer.update(3).is_none());
74+
std::thread::sleep(DEFAULT_DURATION);
75+
assert_eq!(debouncer.update(4), Some(4));
76+
assert!(debouncer.query().is_none());
77+
}
78+
}

crates/turborepo-ui/src/tui/event.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ pub enum Event {
4141
RestartTasks {
4242
tasks: Vec<String>,
4343
},
44+
Resize {
45+
rows: u16,
46+
cols: u16,
47+
},
4448
}
4549

4650
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]

crates/turborepo-ui/src/tui/input.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub fn input(options: InputOptions) -> Result<Option<Event>, Error> {
3030
}
3131
_ => Ok(None),
3232
},
33+
crossterm::event::Event::Resize(cols, rows) => Ok(Some(Event::Resize { rows, cols })),
3334
_ => Ok(None),
3435
}
3536
} else {

crates/turborepo-ui/src/tui/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
mod app;
22
mod clipboard;
3+
mod debouncer;
34
pub mod event;
45
mod handle;
56
mod input;
67
mod pane;
8+
mod size;
79
mod spinner;
810
mod table;
911
mod task;
1012
mod term_output;
1113

1214
pub use app::{run_app, terminal_big_enough};
1315
use clipboard::copy_to_clipboard;
16+
use debouncer::Debouncer;
1417
use event::{Event, TaskResult};
1518
pub use handle::{AppReceiver, AppSender, TuiTask};
1619
use input::{input, InputOptions};
1720
pub use pane::TerminalPane;
21+
use size::SizeInfo;
1822
pub use table::TaskTable;
1923
pub use term_output::TerminalOutput;
2024

crates/turborepo-ui/src/tui/size.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
use crate::TaskTable;
2+
3+
const PANE_SIZE_RATIO: f32 = 3.0 / 4.0;
4+
5+
#[derive(Debug, Clone, Copy)]
6+
pub struct SizeInfo {
7+
task_width_hint: u16,
8+
rows: u16,
9+
cols: u16,
10+
}
11+
12+
impl SizeInfo {
13+
pub fn new<'a>(rows: u16, cols: u16, tasks: impl Iterator<Item = &'a str>) -> Self {
14+
let task_width_hint = TaskTable::width_hint(tasks);
15+
Self {
16+
rows,
17+
cols,
18+
task_width_hint,
19+
}
20+
}
21+
22+
pub fn resize(&mut self, rows: u16, cols: u16) {
23+
self.rows = rows;
24+
self.cols = cols;
25+
}
26+
27+
pub fn pane_rows(&self) -> u16 {
28+
self.rows
29+
// Account for header and footer in layout
30+
.saturating_sub(2)
31+
// Always allocate at least one row as vt100 crashes if emulating a zero area terminal
32+
.max(1)
33+
}
34+
35+
pub fn task_list_width(&self) -> u16 {
36+
self.cols - self.pane_cols()
37+
}
38+
39+
pub fn pane_cols(&self) -> u16 {
40+
// Want to maximize pane width
41+
let ratio_pane_width = (f32::from(self.cols) * PANE_SIZE_RATIO) as u16;
42+
let full_task_width = self.cols.saturating_sub(self.task_width_hint);
43+
full_task_width
44+
.max(ratio_pane_width)
45+
// We need to account for the left border of the pane
46+
.saturating_sub(1)
47+
}
48+
}

0 commit comments

Comments
 (0)