mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-11-06 08:15:35 +00:00
fix(terminal): a taller pane with room on screen leaves scrollback where it is
The grid fills new rows from scrollback whenever a pane grows taller, so zooming the font back out after `clear` — or any growth of a pane whose screen had room — put the cleared output back above the prompt, and a window relaunch replaying those resizes did the same. The keep-the-top rule now runs both ways: while the screen has blank rows below the cursor, its old top line stays on row 0, whether a narrowing pushed it up or a growth pulled scrollback down over it. A full screen still grows into its scrollback the way it always did.
This commit is contained in:
@@ -39,11 +39,14 @@ use super::size::TermSize;
|
||||
/// prompt that will repaint itself for the new width — the caller's shell
|
||||
/// integration state; everything else resizes exactly as `Term::resize` does.
|
||||
pub(super) fn resize<T: EventListener>(term: &mut Term<T>, size: TermSize, shell_redraws: bool) {
|
||||
// Asked before the prompt below is cleared for the shell to redraw, which
|
||||
// would make room under any prompt.
|
||||
let room = room_below_the_cursor(term);
|
||||
// Rows alone reflow nothing, and the alternate screen is a full-screen
|
||||
// program's, which repaints the whole of it.
|
||||
let reflows = size.cols.max(MIN_COLUMNS) != term.columns();
|
||||
if !shell_redraws || !reflows || term.mode().contains(TermMode::ALT_SCREEN) {
|
||||
resize_keeping_the_top(term, size);
|
||||
resize_keeping_the_top(term, size, room);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,7 +59,7 @@ pub(super) fn resize<T: EventListener>(term: &mut Term<T>, size: TermSize, shell
|
||||
term.grid_mut().cursor.point = Point::new(start, Column(0));
|
||||
term.grid_mut().cursor.input_needs_wrap = false;
|
||||
term.clear_screen(ClearMode::Below);
|
||||
resize_keeping_the_top(term, size);
|
||||
resize_keeping_the_top(term, size, room);
|
||||
|
||||
// Linefeeds rather than a jump: a line that sat low on the screen has to
|
||||
// scroll the output above it up, as the shell's own layout would have.
|
||||
@@ -67,38 +70,41 @@ pub(super) fn resize<T: EventListener>(term: &mut Term<T>, size: TermSize, shell
|
||||
term.grid_mut().cursor.point.column = cursor.column.min(last);
|
||||
}
|
||||
|
||||
/// `Term::resize`, except that a narrowing keeps the screen anchored at its
|
||||
/// top while there is blank room below the cursor.
|
||||
/// `Term::resize`, except that a screen with room to spare below the cursor
|
||||
/// stays anchored at its top.
|
||||
///
|
||||
/// The grid reflows anchored at the bottom: every line that wraps onto one
|
||||
/// more row pushes the top row of the screen into scrollback, however much
|
||||
/// empty space sits under the cursor. Splitting a pane with a few lines of
|
||||
/// output in it scrolled its first lines out of sight above a screen that was
|
||||
/// mostly blank. Terminal.app, iTerm2 and kitty keep such a screen anchored at
|
||||
/// the top, and so does this.
|
||||
/// The grid anchors a resize at the bottom both ways. Narrowed, every line
|
||||
/// that wraps onto one more row pushes the top of the screen into scrollback,
|
||||
/// however much empty space sits under the cursor: splitting a pane with a few
|
||||
/// lines of output in it scrolled its first lines out of sight above a screen
|
||||
/// that was mostly blank. Made taller, it fills the new rows from scrollback
|
||||
/// above the cursor: zooming out after `clear` brought the cleared output
|
||||
/// back. Terminal.app, iTerm2 and kitty do neither while the screen has room,
|
||||
/// and so does this — the screen's old top line stays on row 0. A full screen
|
||||
/// resizes exactly as the grid does it, scrollback and all.
|
||||
///
|
||||
/// What was pushed is measured by where the screen's old top line went, not by
|
||||
/// how much history grew: lines already in scrollback re-wrap too, and taking
|
||||
/// their growth for the screen's pulled back output a `clear` had put away.
|
||||
/// Reflow keeps logical lines whole, so counting them from the cursor up to
|
||||
/// the old top before, and as many back up from the cursor after, finds it.
|
||||
/// Where the old top went is found rather than reckoned from the history's
|
||||
/// size, which lines already in scrollback change by re-wrapping. Reflow keeps
|
||||
/// logical lines whole, so counting them from the cursor up to the screen's
|
||||
/// top before, and as many back up from the cursor after, finds it.
|
||||
///
|
||||
/// Taking rows off the bottom and adding them back is how the grid itself
|
||||
/// makes the room — shrinking drops blank rows under the cursor, and growing
|
||||
/// pulls from history first. Not under ConPTY (Windows), whose grid anchors
|
||||
/// rows to what conhost last painted and pulls nothing back on growth.
|
||||
fn resize_keeping_the_top<T: EventListener>(term: &mut Term<T>, size: TermSize) {
|
||||
let narrows = size.cols.max(MIN_COLUMNS) < term.columns();
|
||||
if !narrows || cfg!(windows) || term.mode().contains(TermMode::ALT_SCREEN) {
|
||||
/// Rows pushed above the screen come back by taking blank rows off the bottom
|
||||
/// and adding them again — shrinking drops blank rows under the cursor, and
|
||||
/// growing pulls from history first. Rows pulled onto it go back up with the
|
||||
/// content scrolled over them. Not under ConPTY (Windows), whose grid anchors
|
||||
/// rows to what conhost last painted.
|
||||
fn resize_keeping_the_top<T: EventListener>(term: &mut Term<T>, size: TermSize, room: bool) {
|
||||
if !room || cfg!(windows) || term.mode().contains(TermMode::ALT_SCREEN) {
|
||||
term.resize(size);
|
||||
return;
|
||||
}
|
||||
let cursor = term.grid().cursor.point.line;
|
||||
// The screen's top row counts as a line of its own even when it continues
|
||||
// one from scrollback: it is where the screen started.
|
||||
let lines_on_screen = 1 + (1..=cursor.0)
|
||||
.filter(|&line| starts_a_line(term, Line(line)))
|
||||
.count();
|
||||
let lines_on_screen = 1
|
||||
+ (1..=cursor.0)
|
||||
.filter(|&line| starts_a_line(term, Line(line)))
|
||||
.count();
|
||||
term.resize(size);
|
||||
|
||||
let top = -(term.grid().history_size() as i32);
|
||||
@@ -113,23 +119,39 @@ fn resize_keeping_the_top<T: EventListener>(term: &mut Term<T>, size: TermSize)
|
||||
}
|
||||
line -= 1;
|
||||
}
|
||||
let pushed = match seen == lines_on_screen && line < 0 {
|
||||
true => line.unsigned_abs() as usize,
|
||||
false => return,
|
||||
};
|
||||
let rows = term.screen_lines();
|
||||
let below_cursor = term.grid().cursor.point.line.0 as usize + 1..rows;
|
||||
let blank = below_cursor
|
||||
.rev()
|
||||
.take_while(|&line| term.grid()[Line(line as i32)].is_clear())
|
||||
.count();
|
||||
let back = pushed.min(blank);
|
||||
if back == 0 {
|
||||
if seen != lines_on_screen {
|
||||
return;
|
||||
}
|
||||
let cols = term.columns();
|
||||
term.resize(TermSize::new(cols, rows - back));
|
||||
term.resize(TermSize::new(cols, rows));
|
||||
let rows = term.screen_lines();
|
||||
if line < 0 {
|
||||
// Pushed off the top: pull back as many as there are blank rows for.
|
||||
let below_cursor = term.grid().cursor.point.line.0 as usize + 1..rows;
|
||||
let blank = below_cursor
|
||||
.rev()
|
||||
.take_while(|&line| term.grid()[Line(line as i32)].is_clear())
|
||||
.count();
|
||||
let back = (line.unsigned_abs() as usize).min(blank);
|
||||
if back == 0 {
|
||||
return;
|
||||
}
|
||||
let cols = term.columns();
|
||||
term.resize(TermSize::new(cols, rows - back));
|
||||
term.resize(TermSize::new(cols, rows));
|
||||
} else if line > 0 {
|
||||
// Pulled down from scrollback: send it back up.
|
||||
let pulled = line as usize;
|
||||
term.scroll_up(pulled);
|
||||
let cursor = &mut term.grid_mut().cursor.point.line;
|
||||
*cursor = Line((cursor.0 - line).max(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether there are blank rows under the cursor — a screen the output has
|
||||
/// not filled yet.
|
||||
fn room_below_the_cursor<T>(term: &Term<T>) -> bool {
|
||||
let rows = term.screen_lines() as i32;
|
||||
let cursor = term.grid().cursor.point.line.0;
|
||||
cursor + 1 < rows && term.grid()[Line(rows - 1)].is_clear()
|
||||
}
|
||||
|
||||
/// Whether `line` begins a line of text rather than continuing the one above
|
||||
@@ -365,6 +387,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The other way round: made taller, the grid fills the new rows from
|
||||
/// scrollback above the cursor, so zooming out after `clear` put the
|
||||
/// cleared output back on the screen.
|
||||
#[test]
|
||||
fn growing_a_screen_with_room_brings_nothing_back() {
|
||||
let (mut term, mut parser) = pane(40);
|
||||
for _ in 0..20 {
|
||||
parser.advance(&mut term, b"old output\r\n");
|
||||
}
|
||||
parser.advance(&mut term, b"\x1b[H\x1b[2J$ ");
|
||||
|
||||
resize(&mut term, TermSize::new(40, 20), false);
|
||||
|
||||
let rows = screen(&term);
|
||||
assert_eq!(rows[0], "$", "{rows:#?}");
|
||||
assert!(rows[1..].iter().all(String::is_empty), "{rows:#?}");
|
||||
assert_eq!(term.grid().cursor.point.line, Line(0));
|
||||
}
|
||||
|
||||
/// A full screen still grows the way the grid grows it: the rows above
|
||||
/// come down out of scrollback and the cursor stays at the bottom.
|
||||
#[test]
|
||||
fn growing_a_full_screen_shows_more_scrollback() {
|
||||
let (mut term, mut parser) = pane(40);
|
||||
for i in 0..30 {
|
||||
parser.advance(&mut term, format!("line {i}\r\n").as_bytes());
|
||||
}
|
||||
parser.advance(&mut term, b"$ ");
|
||||
|
||||
resize(&mut term, TermSize::new(40, 16), false);
|
||||
|
||||
let rows = screen(&term);
|
||||
assert_eq!(rows[0], "line 15", "{rows:#?}");
|
||||
assert_eq!(rows[15], "$");
|
||||
}
|
||||
|
||||
/// A full screen has nowhere to put the extra rows but history, exactly as
|
||||
/// before — and what is taken back never outnumbers the blank rows.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user