use super::{AbsolutePosition, Screen};
use crate::cell::Cell;
use crate::grid::Grid;
use crate::row::Row;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct LogicalLineOptions {
pub include_scrollback: bool,
pub trim_trailing_blanks: bool,
pub join_wrapped: bool,
}
pub(crate) struct GatheredRow<'screen> {
pub abs_row: u64,
pub row: &'screen Row,
}
pub struct LogicalLineSpan<'screen> {
pub(crate) rows: Vec<&'screen Row>,
pub start: AbsolutePosition,
pub end: AbsolutePosition,
pub(crate) cols: u16,
}
impl<'screen> LogicalLineSpan<'screen> {
#[must_use]
pub fn row_count(&self) -> usize {
self.rows.len()
}
pub fn cells(&self) -> impl Iterator<Item = (AbsolutePosition, &'screen Cell)> + '_ {
let start_row = self.start.row;
let cols = self.cols;
self.rows
.iter()
.enumerate()
.flat_map(move |(row_offset, row)| {
let abs_row = start_row + row_offset as u64;
(0..cols).filter_map(move |col| {
let cell = row.get(col)?;
if cell.is_wide_continuation() {
return None;
}
Some((AbsolutePosition { row: abs_row, col }, cell))
})
})
}
}
pub struct LogicalLines<'screen> {
rows: Vec<GatheredRow<'screen>>,
cursor: usize,
upper_bound: usize,
join_wrapped: bool,
cols: u16,
}
impl<'screen> Iterator for LogicalLines<'screen> {
type Item = LogicalLineSpan<'screen>;
fn next(&mut self) -> Option<Self::Item> {
if self.cursor >= self.upper_bound {
return None;
}
let start = self.cursor;
let mut end = start;
if self.join_wrapped {
while end + 1 < self.upper_bound && self.rows[end].row.wrapped() {
end += 1;
}
}
let span_rows: Vec<&Row> = self.rows[start..=end].iter().map(|g| g.row).collect();
let start_pos = AbsolutePosition {
row: self.rows[start].abs_row,
col: 0,
};
let end_pos = AbsolutePosition {
row: self.rows[end].abs_row,
col: last_populated_col(self.rows[end].row, self.cols),
};
self.cursor = end + 1;
Some(LogicalLineSpan {
rows: span_rows,
start: start_pos,
end: end_pos,
cols: self.cols,
})
}
}
fn last_populated_col(row: &Row, cols: u16) -> u16 {
(0..cols)
.rev()
.find(|&col| row.get(col).is_some_and(crate::cell::Cell::has_contents))
.unwrap_or(0)
}
pub(crate) fn gather_rows(grid: &Grid, include_scrollback: bool) -> Vec<GatheredRow<'_>> {
if include_scrollback {
let scrollback_count = grid.scrollback_available() as u64;
let base = grid.pushed_to_scrollback().saturating_sub(scrollback_count);
grid.scrollback_rows()
.chain(grid.drawing_rows())
.enumerate()
.map(|(j, row)| GatheredRow {
abs_row: base + j as u64,
row,
})
.collect()
} else {
let viewport_top = grid
.pushed_to_scrollback()
.saturating_sub(grid.scrollback() as u64);
grid.visible_rows()
.enumerate()
.map(|(j, row)| GatheredRow {
abs_row: viewport_top + j as u64,
row,
})
.collect()
}
}
pub(crate) fn from_rows<'a>(
rows: Vec<GatheredRow<'a>>,
upper_bound: usize,
join_wrapped: bool,
cols: u16,
) -> LogicalLines<'a> {
let upper_bound = upper_bound.min(rows.len());
LogicalLines {
rows,
cursor: 0,
upper_bound,
join_wrapped,
cols,
}
}
pub(crate) fn iter<'a>(screen: &'a Screen, opts: LogicalLineOptions) -> LogicalLines<'a> {
iter_grid(screen.grid(), opts)
}
pub(crate) fn iter_grid<'a>(grid: &'a Grid, opts: LogicalLineOptions) -> LogicalLines<'a> {
let cols = grid.size().cols;
let rows = gather_rows(grid, opts.include_scrollback);
let upper_bound = if opts.trim_trailing_blanks {
rows.iter()
.rposition(|gathered| {
(0..cols).any(|col| {
gathered
.row
.get(col)
.is_some_and(crate::cell::Cell::has_contents)
})
})
.map(|last| last + 1)
.unwrap_or(0)
} else {
rows.len()
};
from_rows(rows, upper_bound, opts.join_wrapped, cols)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Parser;
use crate::screen::TerminalSize;
#[test]
fn yields_one_span_per_row_when_join_disabled() {
let mut parser = Parser::new(TerminalSize { rows: 4, cols: 8 }, 0);
parser.process(b"abcdefghIJ");
let opts = LogicalLineOptions {
join_wrapped: false,
trim_trailing_blanks: true,
..Default::default()
};
let spans: Vec<_> = parser.screen().logical_lines(opts).collect();
assert_eq!(spans.len(), 2, "two rows have content");
assert_eq!(spans[0].rows.len(), 1);
assert_eq!(spans[1].rows.len(), 1);
}
#[test]
fn joins_wrapped_continuation_into_one_span() {
let mut parser = Parser::new(TerminalSize { rows: 4, cols: 8 }, 0);
parser.process(b"abcdefghIJ");
let opts = LogicalLineOptions {
join_wrapped: true,
trim_trailing_blanks: true,
..Default::default()
};
let spans: Vec<_> = parser.screen().logical_lines(opts).collect();
assert_eq!(spans.len(), 1, "wrapped pair forms one logical line");
assert_eq!(spans[0].rows.len(), 2);
}
#[test]
fn explicit_newline_breaks_wrap_join() {
let mut parser = Parser::new(TerminalSize { rows: 6, cols: 8 }, 0);
parser.process(b"abcdefghIJ\r\nnext");
let opts = LogicalLineOptions {
join_wrapped: true,
trim_trailing_blanks: true,
..Default::default()
};
let spans: Vec<_> = parser.screen().logical_lines(opts).collect();
assert_eq!(spans.len(), 2, "explicit \\n must end the logical line");
assert_eq!(spans[0].rows.len(), 2, "wrapped pair stays joined");
assert_eq!(spans[1].rows.len(), 1, "next is its own span");
}
#[test]
fn includes_scrollback_when_requested() {
let mut parser = Parser::new(TerminalSize { rows: 3, cols: 10 }, 100);
for i in 0..6 {
parser.process(format!("line{i}\r\n").as_bytes());
}
assert!(parser.screen().scrollback_available() >= 4);
let viewport_only = parser.screen().logical_lines(LogicalLineOptions {
trim_trailing_blanks: true,
..Default::default()
});
assert_eq!(viewport_only.count(), 2);
let with_history = parser.screen().logical_lines(LogicalLineOptions {
include_scrollback: true,
trim_trailing_blanks: true,
..Default::default()
});
assert_eq!(with_history.count(), 6);
}
#[test]
fn trim_trailing_blanks_drops_empty_rows() {
let mut parser = Parser::new(TerminalSize { rows: 5, cols: 10 }, 0);
parser.process(b"hello");
let trimmed = parser
.screen()
.logical_lines(LogicalLineOptions {
trim_trailing_blanks: true,
..Default::default()
})
.count();
assert_eq!(trimmed, 1);
let untrimmed = parser
.screen()
.logical_lines(LogicalLineOptions {
trim_trailing_blanks: false,
..Default::default()
})
.count();
assert_eq!(untrimmed, 5);
}
#[test]
fn from_rows_caps_at_caller_upper_bound() {
let mut parser = Parser::new(TerminalSize { rows: 5, cols: 10 }, 0);
parser.process(b"hello\r\nworld\r\n!");
let cols = parser.screen().grid().size().cols;
let rows = gather_rows(parser.screen().grid(), false);
let iter = from_rows(rows, 2, false, cols);
let spans: Vec<_> = iter.collect();
assert_eq!(spans.len(), 2, "caller-supplied bound caps the walk");
}
#[test]
fn single_row_span_carries_viewport_row_position() {
let mut parser = Parser::new(TerminalSize { rows: 4, cols: 10 }, 0);
parser.process(b"hello");
let opts = LogicalLineOptions {
trim_trailing_blanks: true,
..Default::default()
};
let spans: Vec<_> = parser.screen().logical_lines(opts).collect();
assert_eq!(spans.len(), 1);
let span = &spans[0];
let abs_row_zero = parser
.screen()
.visible_to_absolute(crate::screen::Position { row: 0, col: 0 })
.expect("viewport row 0 maps to an absolute row");
assert_eq!(span.start, abs_row_zero);
assert_eq!(span.end.row, abs_row_zero.row);
assert_eq!(span.end.col, 4, "rightmost populated col of \"hello\" is 4");
}
#[test]
fn wrapped_two_row_span_covers_both_absolute_rows() {
let mut parser = Parser::new(TerminalSize { rows: 4, cols: 8 }, 0);
parser.process(b"abcdefghIJ");
let opts = LogicalLineOptions {
join_wrapped: true,
trim_trailing_blanks: true,
..Default::default()
};
let spans: Vec<_> = parser.screen().logical_lines(opts).collect();
assert_eq!(spans.len(), 1);
let span = &spans[0];
let row0 = parser
.screen()
.visible_to_absolute(crate::screen::Position { row: 0, col: 0 })
.unwrap();
let row1 = parser
.screen()
.visible_to_absolute(crate::screen::Position { row: 1, col: 0 })
.unwrap();
assert_eq!(span.start.row, row0.row);
assert_eq!(span.start.col, 0);
assert_eq!(span.end.row, row1.row);
assert_eq!(span.end.col, 1, "\"IJ\" populates cols 0..=1 on row 1");
}
#[test]
fn scrollback_walk_yields_monotonic_contiguous_rows() {
let mut parser = Parser::new(TerminalSize { rows: 3, cols: 10 }, 100);
for i in 0..6 {
parser.process(format!("line{i}\r\n").as_bytes());
}
let opts = LogicalLineOptions {
include_scrollback: true,
trim_trailing_blanks: true,
..Default::default()
};
let spans: Vec<_> = parser.screen().logical_lines(opts).collect();
assert_eq!(spans.len(), 6);
let oldest_scrollback = parser.screen().grid().pushed_to_scrollback()
- parser.screen().scrollback_available() as u64;
let pushed = parser.screen().grid().pushed_to_scrollback();
let mut crossed_boundary = false;
for (i, span) in spans.iter().enumerate() {
let expected_row = oldest_scrollback + i as u64;
assert_eq!(
span.start.row, expected_row,
"span {i} should start at absolute row {expected_row}"
);
assert_eq!(span.end.row, expected_row);
if span.start.row >= pushed {
crossed_boundary = true;
}
}
assert!(
crossed_boundary,
"walk must include both scrollback and drawing rows"
);
}
#[test]
fn wrap_across_scrollback_boundary_yields_one_joined_span() {
let mut parser = Parser::new(TerminalSize { rows: 2, cols: 8 }, 100);
parser.process(b"abcdefghIJ\r\nnext");
assert_eq!(parser.screen().scrollback_available(), 1);
let scrollback_row = parser.screen().grid().pushed_to_scrollback() - 1;
let opts = LogicalLineOptions {
include_scrollback: true,
join_wrapped: true,
trim_trailing_blanks: true,
};
let spans: Vec<_> = parser.screen().logical_lines(opts).collect();
assert_eq!(
spans.len(),
2,
"wrap straddling the boundary plus a trailing line yields two spans"
);
let wrap_span = &spans[0];
assert_eq!(wrap_span.rows.len(), 2, "wrap is joined into one span");
assert_eq!(wrap_span.start.row, scrollback_row);
assert_eq!(wrap_span.start.col, 0);
assert_eq!(wrap_span.end.row, scrollback_row + 1);
assert_eq!(
wrap_span.end.col, 1,
"\"IJ\" populates cols 0..=1 on the final row"
);
let next_span = &spans[1];
assert_eq!(next_span.rows.len(), 1);
assert_eq!(next_span.start.row, scrollback_row + 2);
assert_eq!(next_span.end.row, scrollback_row + 2);
}
}