forked from NotAShelf/beer
grid: reflow scrollback and screen on resize
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I73b5d858eafc05026a6fff1eb67eea226a6a6964
This commit is contained in:
parent
6f1d4dd7f9
commit
b7ed08d44c
1 changed files with 186 additions and 23 deletions
209
src/grid.rs
209
src/grid.rs
|
|
@ -134,6 +134,24 @@ struct Cursor {
|
||||||
y: usize,
|
y: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One screen/scrollback row: its cells plus whether it soft-wrapped into the
|
||||||
|
/// next row (autowrap continuation, as opposed to a hard line break). The flag
|
||||||
|
/// is what lets resize rejoin and rewrap paragraphs.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
|
struct Line {
|
||||||
|
cells: Vec<Cell>,
|
||||||
|
wrapped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Line {
|
||||||
|
fn blank(cols: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
cells: vec![Cell::default(); cols],
|
||||||
|
wrapped: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A point in the combined scrollback+live coordinate space: `row` indexes
|
/// A point in the combined scrollback+live coordinate space: `row` indexes
|
||||||
/// scrollback lines first (oldest at 0), then the live screen.
|
/// scrollback lines first (oldest at 0), then the live screen.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
|
@ -163,7 +181,7 @@ struct SearchState {
|
||||||
pub struct Grid {
|
pub struct Grid {
|
||||||
cols: usize,
|
cols: usize,
|
||||||
rows: usize,
|
rows: usize,
|
||||||
lines: Vec<Vec<Cell>>,
|
lines: Vec<Line>,
|
||||||
cursor: Cursor,
|
cursor: Cursor,
|
||||||
saved: Cursor,
|
saved: Cursor,
|
||||||
/// Inclusive top/bottom rows of the scroll region.
|
/// Inclusive top/bottom rows of the scroll region.
|
||||||
|
|
@ -178,9 +196,9 @@ pub struct Grid {
|
||||||
wrap_pending: bool,
|
wrap_pending: bool,
|
||||||
tabs: Vec<bool>,
|
tabs: Vec<bool>,
|
||||||
/// Saved primary screen while the alternate screen is active.
|
/// Saved primary screen while the alternate screen is active.
|
||||||
alt_saved: Option<Vec<Vec<Cell>>>,
|
alt_saved: Option<Vec<Line>>,
|
||||||
/// Lines that have scrolled off the top of the main screen, newest last.
|
/// Lines that have scrolled off the top of the main screen, newest last.
|
||||||
scrollback: VecDeque<Vec<Cell>>,
|
scrollback: VecDeque<Line>,
|
||||||
/// How many lines the viewport is scrolled back from the live bottom.
|
/// How many lines the viewport is scrolled back from the live bottom.
|
||||||
view_offset: usize,
|
view_offset: usize,
|
||||||
cursor_shape: CursorShape,
|
cursor_shape: CursorShape,
|
||||||
|
|
@ -231,7 +249,7 @@ impl Grid {
|
||||||
Self {
|
Self {
|
||||||
cols,
|
cols,
|
||||||
rows,
|
rows,
|
||||||
lines: vec![vec![Cell::default(); cols]; rows],
|
lines: vec![Line::blank(cols); rows],
|
||||||
cursor: Cursor::default(),
|
cursor: Cursor::default(),
|
||||||
saved: Cursor::default(),
|
saved: Cursor::default(),
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
@ -269,14 +287,18 @@ impl Grid {
|
||||||
self.rows
|
self.rows
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resize the screen, clipping content (reflow comes later).
|
/// Resize the screen. On the main screen this reflows: soft-wrapped runs are
|
||||||
|
/// rejoined into logical lines and rewrapped to the new width, across both
|
||||||
|
/// scrollback and the live screen, keeping the cursor on its content. The
|
||||||
|
/// alternate screen just clips, since its apps repaint on resize.
|
||||||
pub fn resize(&mut self, cols: usize, rows: usize) {
|
pub fn resize(&mut self, cols: usize, rows: usize) {
|
||||||
let cols = cols.max(1);
|
let cols = cols.max(1);
|
||||||
let rows = rows.max(1);
|
let rows = rows.max(1);
|
||||||
for line in &mut self.lines {
|
if self.alt_saved.is_some() {
|
||||||
line.resize(cols, Cell::default());
|
self.clip_resize(cols, rows);
|
||||||
|
} else {
|
||||||
|
self.reflow_resize(cols, rows);
|
||||||
}
|
}
|
||||||
self.lines.resize(rows, vec![Cell::default(); cols]);
|
|
||||||
self.cols = cols;
|
self.cols = cols;
|
||||||
self.rows = rows;
|
self.rows = rows;
|
||||||
self.top = 0;
|
self.top = 0;
|
||||||
|
|
@ -285,11 +307,114 @@ impl Grid {
|
||||||
self.cursor.x = self.cursor.x.min(cols - 1);
|
self.cursor.x = self.cursor.x.min(cols - 1);
|
||||||
self.cursor.y = self.cursor.y.min(rows - 1);
|
self.cursor.y = self.cursor.y.min(rows - 1);
|
||||||
self.wrap_pending = false;
|
self.wrap_pending = false;
|
||||||
// Scrollback lines keep their old width (no reflow); snap to the live
|
|
||||||
// screen so the viewport never indexes a stale-width line.
|
|
||||||
self.view_offset = 0;
|
self.view_offset = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clip-resize the live screen and the saved primary (alternate screen).
|
||||||
|
fn clip_resize(&mut self, cols: usize, rows: usize) {
|
||||||
|
for line in &mut self.lines {
|
||||||
|
line.cells.resize(cols, Cell::default());
|
||||||
|
}
|
||||||
|
self.lines.resize(rows, Line::blank(cols));
|
||||||
|
if let Some(saved) = self.alt_saved.as_mut() {
|
||||||
|
for line in saved.iter_mut() {
|
||||||
|
line.cells.resize(cols, Cell::default());
|
||||||
|
}
|
||||||
|
saved.resize(rows, Line::blank(cols));
|
||||||
|
}
|
||||||
|
self.clear_selection();
|
||||||
|
self.clear_search();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reflow scrollback + live content to a new width, rewrapping soft-wrapped
|
||||||
|
/// paragraphs and repositioning the cursor onto its character.
|
||||||
|
fn reflow_resize(&mut self, cols: usize, rows: usize) {
|
||||||
|
let cursor_abs = self.scrollback.len() + self.cursor.y;
|
||||||
|
let total = self.scrollback.len() + self.lines.len();
|
||||||
|
|
||||||
|
// 1. Rejoin soft-wrapped rows into logical lines. Track which logical
|
||||||
|
// line the cursor falls in and its offset within that line.
|
||||||
|
let mut logicals: Vec<Vec<Cell>> = Vec::new();
|
||||||
|
let mut acc: Vec<Cell> = Vec::new();
|
||||||
|
let mut cur_logical = 0usize;
|
||||||
|
let mut cur_off = 0usize;
|
||||||
|
for abs in 0..total {
|
||||||
|
if abs == cursor_abs {
|
||||||
|
cur_logical = logicals.len();
|
||||||
|
cur_off = acc.len() + self.cursor.x;
|
||||||
|
}
|
||||||
|
let line = if abs < self.scrollback.len() {
|
||||||
|
&self.scrollback[abs]
|
||||||
|
} else {
|
||||||
|
&self.lines[abs - self.scrollback.len()]
|
||||||
|
};
|
||||||
|
acc.extend_from_slice(&line.cells);
|
||||||
|
if !line.wrapped {
|
||||||
|
logicals.push(std::mem::take(&mut acc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !acc.is_empty() {
|
||||||
|
logicals.push(acc);
|
||||||
|
}
|
||||||
|
// Drop trailing all-blank lines (empty screen below the content), but
|
||||||
|
// never above the cursor's line, so the cursor keeps its row.
|
||||||
|
let last_content = logicals
|
||||||
|
.iter()
|
||||||
|
.rposition(|l| l.iter().any(|c| *c != Cell::default()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
logicals.truncate(last_content.max(cur_logical) + 1);
|
||||||
|
|
||||||
|
// 2. Rewrap each logical line to the new width, recording where the
|
||||||
|
// cursor lands. Trailing blanks are dropped so a hard line does not
|
||||||
|
// rewrap its padding onto extra rows.
|
||||||
|
let mut new_lines: Vec<Line> = Vec::new();
|
||||||
|
let mut new_cursor_abs = 0usize;
|
||||||
|
let mut new_cursor_col = 0usize;
|
||||||
|
for (li, mut logical) in logicals.into_iter().enumerate() {
|
||||||
|
let trim = logical
|
||||||
|
.iter()
|
||||||
|
.rposition(|c| *c != Cell::default())
|
||||||
|
.map_or(0, |p| p + 1);
|
||||||
|
logical.truncate(trim);
|
||||||
|
let first = new_lines.len();
|
||||||
|
let chunks = logical.len().div_ceil(cols).max(1);
|
||||||
|
for ci in 0..chunks {
|
||||||
|
let start = ci * cols;
|
||||||
|
let end = (start + cols).min(logical.len());
|
||||||
|
let mut cells = logical.get(start..end).unwrap_or(&[]).to_vec();
|
||||||
|
cells.resize(cols, Cell::default());
|
||||||
|
new_lines.push(Line {
|
||||||
|
cells,
|
||||||
|
wrapped: ci + 1 < chunks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if li == cur_logical {
|
||||||
|
let off = cur_off.min(logical.len());
|
||||||
|
let chunk = (off / cols).min(chunks - 1);
|
||||||
|
new_cursor_abs = first + chunk;
|
||||||
|
new_cursor_col = (off - chunk * cols).min(cols - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. The last `rows` lines are the live screen; the rest is scrollback.
|
||||||
|
let live_start = new_lines.len().saturating_sub(rows);
|
||||||
|
let mut scrollback: VecDeque<Line> = new_lines.drain(0..live_start).collect();
|
||||||
|
let mut live = new_lines;
|
||||||
|
while live.len() < rows {
|
||||||
|
live.push(Line::blank(cols));
|
||||||
|
}
|
||||||
|
while scrollback.len() > SCROLLBACK_CAP {
|
||||||
|
scrollback.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cursor.y = new_cursor_abs.saturating_sub(live_start).min(rows - 1);
|
||||||
|
self.cursor.x = new_cursor_col;
|
||||||
|
self.lines = live;
|
||||||
|
self.scrollback = scrollback;
|
||||||
|
self.clear_selection();
|
||||||
|
self.clear_search();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cursor(&self) -> (usize, usize) {
|
pub fn cursor(&self) -> (usize, usize) {
|
||||||
(self.cursor.x, self.cursor.y)
|
(self.cursor.x, self.cursor.y)
|
||||||
}
|
}
|
||||||
|
|
@ -384,6 +509,7 @@ impl Grid {
|
||||||
}
|
}
|
||||||
if self.wrap_pending {
|
if self.wrap_pending {
|
||||||
self.cursor.x = 0;
|
self.cursor.x = 0;
|
||||||
|
self.lines[self.cursor.y].wrapped = true;
|
||||||
self.line_feed();
|
self.line_feed();
|
||||||
self.wrap_pending = false;
|
self.wrap_pending = false;
|
||||||
}
|
}
|
||||||
|
|
@ -391,6 +517,7 @@ impl Grid {
|
||||||
// A double-width glyph cannot straddle the right edge: wrap first.
|
// A double-width glyph cannot straddle the right edge: wrap first.
|
||||||
if self.autowrap {
|
if self.autowrap {
|
||||||
self.cursor.x = 0;
|
self.cursor.x = 0;
|
||||||
|
self.lines[self.cursor.y].wrapped = true;
|
||||||
self.line_feed();
|
self.line_feed();
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -404,12 +531,12 @@ impl Grid {
|
||||||
let mut cell = self.pen.clone();
|
let mut cell = self.pen.clone();
|
||||||
cell.c = c;
|
cell.c = c;
|
||||||
cell.flags.remove(Flags::WIDE_CONT);
|
cell.flags.remove(Flags::WIDE_CONT);
|
||||||
self.lines[y][x] = cell;
|
self.lines[y].cells[x] = cell;
|
||||||
if width == 2 && x + 1 < self.cols {
|
if width == 2 && x + 1 < self.cols {
|
||||||
let mut cont = self.pen.clone();
|
let mut cont = self.pen.clone();
|
||||||
cont.c = ' ';
|
cont.c = ' ';
|
||||||
cont.flags.insert(Flags::WIDE_CONT);
|
cont.flags.insert(Flags::WIDE_CONT);
|
||||||
self.lines[y][x + 1] = cont;
|
self.lines[y].cells[x + 1] = cont;
|
||||||
}
|
}
|
||||||
|
|
||||||
let advance = width;
|
let advance = width;
|
||||||
|
|
@ -429,7 +556,7 @@ impl Grid {
|
||||||
let (x, y) = (self.cursor.x, self.cursor.y);
|
let (x, y) = (self.cursor.x, self.cursor.y);
|
||||||
let end = self.cols;
|
let end = self.cols;
|
||||||
let blank = self.pen_blank();
|
let blank = self.pen_blank();
|
||||||
let row = &mut self.lines[y];
|
let row = &mut self.lines[y].cells;
|
||||||
for i in (x + n..end).rev() {
|
for i in (x + n..end).rev() {
|
||||||
row[i] = row[i - n].clone();
|
row[i] = row[i - n].clone();
|
||||||
}
|
}
|
||||||
|
|
@ -591,7 +718,7 @@ impl Grid {
|
||||||
// a DECSTBM region scroll (top > 0) or the alt screen does not.
|
// a DECSTBM region scroll (top > 0) or the alt screen does not.
|
||||||
if self.top == 0 && self.alt_saved.is_none() {
|
if self.top == 0 && self.alt_saved.is_none() {
|
||||||
for y in 0..n {
|
for y in 0..n {
|
||||||
let line = std::mem::replace(&mut self.lines[y], vec![Cell::default(); self.cols]);
|
let line = std::mem::replace(&mut self.lines[y], Line::blank(self.cols));
|
||||||
self.scrollback.push_back(line);
|
self.scrollback.push_back(line);
|
||||||
}
|
}
|
||||||
let mut evicted = 0;
|
let mut evicted = 0;
|
||||||
|
|
@ -632,9 +759,11 @@ impl Grid {
|
||||||
|
|
||||||
fn blank_row(&mut self, y: usize) {
|
fn blank_row(&mut self, y: usize) {
|
||||||
let blank = self.pen_blank();
|
let blank = self.pen_blank();
|
||||||
for cell in &mut self.lines[y] {
|
let line = &mut self.lines[y];
|
||||||
|
for cell in &mut line.cells {
|
||||||
*cell = blank.clone();
|
*cell = blank.clone();
|
||||||
}
|
}
|
||||||
|
line.wrapped = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- erase ---
|
// --- erase ---
|
||||||
|
|
@ -683,7 +812,7 @@ impl Grid {
|
||||||
|
|
||||||
fn erase_in_row(&mut self, y: usize, from: usize, to: usize) {
|
fn erase_in_row(&mut self, y: usize, from: usize, to: usize) {
|
||||||
let blank = self.pen_blank();
|
let blank = self.pen_blank();
|
||||||
for cell in &mut self.lines[y][from..to.min(self.cols)] {
|
for cell in &mut self.lines[y].cells[from..to.min(self.cols)] {
|
||||||
*cell = blank.clone();
|
*cell = blank.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -703,7 +832,7 @@ impl Grid {
|
||||||
let (x, y) = (self.cursor.x, self.cursor.y);
|
let (x, y) = (self.cursor.x, self.cursor.y);
|
||||||
let n = n.min(self.cols - x);
|
let n = n.min(self.cols - x);
|
||||||
let blank = self.pen_blank();
|
let blank = self.pen_blank();
|
||||||
let row = &mut self.lines[y];
|
let row = &mut self.lines[y].cells;
|
||||||
for i in x..self.cols {
|
for i in x..self.cols {
|
||||||
row[i] = if i + n < self.cols {
|
row[i] = if i + n < self.cols {
|
||||||
row[i + n].clone()
|
row[i + n].clone()
|
||||||
|
|
@ -752,7 +881,7 @@ impl Grid {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.view_offset = 0;
|
self.view_offset = 0;
|
||||||
let blank = vec![vec![Cell::default(); self.cols]; self.rows];
|
let blank = vec![Line::blank(self.cols); self.rows];
|
||||||
self.alt_saved = Some(std::mem::replace(&mut self.lines, blank));
|
self.alt_saved = Some(std::mem::replace(&mut self.lines, blank));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -795,9 +924,9 @@ impl Grid {
|
||||||
let start = self.scrollback.len() - self.view_offset;
|
let start = self.scrollback.len() - self.view_offset;
|
||||||
let idx = start + y;
|
let idx = start + y;
|
||||||
if idx < self.scrollback.len() {
|
if idx < self.scrollback.len() {
|
||||||
&self.scrollback[idx]
|
&self.scrollback[idx].cells
|
||||||
} else {
|
} else {
|
||||||
&self.lines[idx - self.scrollback.len()]
|
&self.lines[idx - self.scrollback.len()].cells
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -811,9 +940,9 @@ impl Grid {
|
||||||
/// Cells of an absolute row (scrollback first, then the live screen).
|
/// Cells of an absolute row (scrollback first, then the live screen).
|
||||||
fn abs_row(&self, row: usize) -> &[Cell] {
|
fn abs_row(&self, row: usize) -> &[Cell] {
|
||||||
if row < self.scrollback.len() {
|
if row < self.scrollback.len() {
|
||||||
&self.scrollback[row]
|
&self.scrollback[row].cells
|
||||||
} else {
|
} else {
|
||||||
&self.lines[row - self.scrollback.len()]
|
&self.lines[row - self.scrollback.len()].cells
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1171,6 +1300,7 @@ impl Grid {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn row_text(&self, y: usize) -> String {
|
pub fn row_text(&self, y: usize) -> String {
|
||||||
self.lines[y]
|
self.lines[y]
|
||||||
|
.cells
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|c| !c.flags.contains(Flags::WIDE_CONT))
|
.filter(|c| !c.flags.contains(Flags::WIDE_CONT))
|
||||||
.map(|c| c.c)
|
.map(|c| c.c)
|
||||||
|
|
@ -1180,7 +1310,7 @@ impl Grid {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cell(&self, x: usize, y: usize) -> &Cell {
|
pub fn cell(&self, x: usize, y: usize) -> &Cell {
|
||||||
&self.lines[y][x]
|
&self.lines[y].cells[x]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1374,6 +1504,39 @@ mod tests {
|
||||||
assert_eq!(g.search_query(), None);
|
assert_eq!(g.search_query(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reflow_rewraps_a_wrapped_paragraph() {
|
||||||
|
let mut g = Grid::new(4, 3);
|
||||||
|
for c in "abcdefgh".chars() {
|
||||||
|
g.print(c); // wraps: "abcd" | "efgh" | (cursor parked)
|
||||||
|
}
|
||||||
|
// Widen: the two wrapped rows rejoin into one logical line.
|
||||||
|
g.resize(8, 3);
|
||||||
|
assert_eq!(g.row_text(0), "abcdefgh");
|
||||||
|
assert_eq!(g.cursor(), (7, 0)); // cursor follows the content end
|
||||||
|
// Narrow back: it rewraps to the smaller width without losing text.
|
||||||
|
g.resize(4, 3);
|
||||||
|
assert_eq!(g.row_text(0), "abcd");
|
||||||
|
assert_eq!(g.row_text(1), "efgh");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reflow_preserves_hard_breaks() {
|
||||||
|
let mut g = Grid::new(10, 3);
|
||||||
|
for c in "one".chars() {
|
||||||
|
g.print(c);
|
||||||
|
}
|
||||||
|
g.carriage_return();
|
||||||
|
g.line_feed();
|
||||||
|
for c in "two".chars() {
|
||||||
|
g.print(c);
|
||||||
|
}
|
||||||
|
// A hard newline must not be rejoined when the width changes.
|
||||||
|
g.resize(6, 3);
|
||||||
|
assert_eq!(g.row_text(0), "one");
|
||||||
|
assert_eq!(g.row_text(1), "two");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scroll_region_limits_line_feed() {
|
fn scroll_region_limits_line_feed() {
|
||||||
let mut g = Grid::new(4, 4);
|
let mut g = Grid::new(4, 4);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue