use std::cell::RefCell;
use std::sync::Arc;
use ratatui::text::Line;
use super::draw::{wrap_line_window, wrapped_row_count};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) struct TextPos {
pub(crate) line: usize,
pub(crate) col: usize,
}
impl TextPos {
pub(crate) fn new(line: usize, col: usize) -> Self {
Self { line, col }
}
}
struct LineRows {
cum: Vec<u32>,
lens: Vec<usize>,
}
impl LineRows {
fn build(char_lens: impl Iterator<Item = usize>, width: usize) -> Self {
let mut cum = vec![0u32];
let mut lens = Vec::new();
let mut total = 0u32;
for len in char_lens {
total += wrapped_row_count(len, width) as u32;
cum.push(total);
lens.push(len);
}
Self { cum, lens }
}
fn total_rows(&self) -> u32 {
(*self.cum.last().unwrap_or(&0)).max(1)
}
fn line_count(&self) -> usize {
self.cum.len().saturating_sub(1)
}
fn locate(&self, row: u32) -> (usize, u32) {
if self.cum.len() <= 1 {
return (0, 0);
}
let idx = self.cum.partition_point(|&c| c <= row);
let line = idx.saturating_sub(1).min(self.cum.len() - 2);
(line, row - self.cum[line])
}
}
pub(crate) struct PanelWrap {
source: Arc<str>,
line_ranges: Vec<(usize, usize)>,
rows: LineRows,
width: usize,
last_window: RefCell<Option<(u16, u16, Vec<Line<'static>>)>>,
}
impl PanelWrap {
pub(crate) fn build(source: Arc<str>, width: usize) -> Self {
let mut line_ranges = Vec::new();
let bytes = source.as_bytes();
let mut start = 0usize;
for (i, &b) in bytes.iter().enumerate() {
if b == b'\n' {
let mut end = i;
if end > start && bytes[end - 1] == b'\r' {
end -= 1;
}
line_ranges.push((start, end));
start = i + 1;
}
}
if start < bytes.len() || line_ranges.is_empty() {
line_ranges.push((start, bytes.len()));
}
let rows = LineRows::build(
line_ranges
.iter()
.map(|&(s, e)| source[s..e].chars().count()),
width,
);
Self {
source,
line_ranges,
rows,
width,
last_window: RefCell::new(None),
}
}
pub(crate) fn rebuild_if_needed(
cache: &mut Option<PanelWrap>,
source: &Arc<str>,
width: usize,
) {
let stale = match cache {
Some(c) => !Arc::ptr_eq(&c.source, source) || c.width != width,
None => true,
};
if stale {
*cache = Some(PanelWrap::build(Arc::clone(source), width));
}
}
pub(crate) fn line_count(&self) -> usize {
self.rows.line_count()
}
pub(crate) fn source(&self) -> &str {
&self.source
}
pub(crate) fn line_text(&self, idx: usize) -> &str {
let (s, e) = self.line_ranges[idx];
&self.source[s..e]
}
pub(crate) fn line_char_len(&self, idx: usize) -> usize {
self.rows.lens.get(idx).copied().unwrap_or(0)
}
pub(crate) fn total_rows(&self) -> u32 {
self.rows.total_rows()
}
pub(crate) fn visible_window(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
if height == 0 || self.line_count() == 0 {
return Vec::new();
}
if let Some((cached_scroll, cached_height, cached)) = self.last_window.borrow().as_ref()
&& *cached_scroll == scroll
&& *cached_height == height
{
return cached.clone();
}
let (start_line, row_in_line) = self.rows.locate(scroll as u32);
let height_usize = height as usize;
let mut out: Vec<Line<'static>> = Vec::with_capacity(height_usize);
let mut skip = row_in_line as usize;
for idx in start_line..self.line_count() {
if out.len() >= height_usize {
break;
}
let budget = height_usize - out.len();
out.extend(wrap_line_window(
self.line_text(idx),
self.width,
skip,
budget,
));
skip = 0;
}
out.truncate(height_usize);
*self.last_window.borrow_mut() = Some((scroll, height, out.clone()));
out
}
pub(crate) fn textpos_to_row_col(&self, pos: TextPos) -> (u32, usize) {
if self.line_count() == 0 {
return (0, 0);
}
let line = pos.line.min(self.line_count() - 1);
let len = self.line_char_len(line);
let col = pos.col.min(len);
if self.width == 0 {
return (self.rows.cum[line], col);
}
let rows_in_line = wrapped_row_count(len, self.width) as u32;
let row_in_line = ((col / self.width) as u32).min(rows_in_line.saturating_sub(1));
let col_in_row = col.saturating_sub(row_in_line as usize * self.width);
(self.rows.cum[line] + row_in_line, col_in_row)
}
pub(crate) fn row_col_to_textpos(&self, row: u32, col: usize) -> TextPos {
if self.line_count() == 0 {
return TextPos::new(0, 0);
}
let (line, row_in_line) = self.rows.locate(row);
let len = self.line_char_len(line);
let base = if self.width == 0 {
0
} else {
row_in_line as usize * self.width
};
TextPos::new(line, base.saturating_add(col).min(len))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn wrap(text: &str, width: usize) -> PanelWrap {
PanelWrap::build(Arc::from(text), width)
}
#[test]
fn splits_lines_like_str_lines_including_trailing_newline_and_crlf() {
let w = wrap("a\r\nb\nc", 10);
assert_eq!(w.line_count(), 3);
assert_eq!(w.line_text(0), "a");
assert_eq!(w.line_text(1), "b");
assert_eq!(w.line_text(2), "c");
let w2 = wrap("a\nb\n", 10);
assert_eq!(
w2.line_count(),
2,
"no trailing empty line after a final \\n, matching str::lines()"
);
}
#[test]
fn empty_body_has_one_line_and_one_row() {
let w = wrap("", 10);
assert_eq!(w.line_count(), 1);
assert_eq!(w.total_rows(), 1);
}
#[test]
fn total_rows_accounts_for_wrapping_long_lines() {
let w = wrap("0123456789ABCDE\n", 10);
assert_eq!(w.total_rows(), 2);
}
#[test]
fn row_col_and_textpos_roundtrip_for_a_wrapped_line() {
let w = wrap("0123456789ABCDE", 10); assert_eq!(w.row_col_to_textpos(0, 3), TextPos::new(0, 3));
assert_eq!(w.row_col_to_textpos(1, 2), TextPos::new(0, 12));
assert_eq!(w.textpos_to_row_col(TextPos::new(0, 3)), (0, 3));
assert_eq!(w.textpos_to_row_col(TextPos::new(0, 12)), (1, 2));
assert_eq!(w.textpos_to_row_col(TextPos::new(0, 15)), (1, 5));
}
#[test]
fn locate_binary_search_finds_the_right_line_for_a_huge_body() {
let body: String = (0..100_000).map(|i| format!("line {i}\n")).collect();
let w = wrap(&body, 20);
assert_eq!(w.row_col_to_textpos(50_000, 0), TextPos::new(50_000, 0));
}
#[test]
fn visible_window_only_wraps_the_requested_rows() {
let body: String = (0..1000).map(|i| format!("line {i}\n")).collect();
let w = wrap(&body, 20);
let rows = w.visible_window(500, 5);
assert_eq!(rows.len(), 5);
let text: Vec<String> = rows
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(
text,
vec!["line 500", "line 501", "line 502", "line 503", "line 504"]
);
}
#[test]
fn visible_window_is_correct_for_a_single_enormous_unbroken_line() {
let body: String = "abcdefghij".repeat(200_000); let w = wrap(&body, 10);
let top = w.visible_window(0, 3);
assert_eq!(top.len(), 3);
let row0: String = top[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(row0, "abcdefghij", "row 0 is chars [0, 10)");
let row2: String = top[2].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(
row2, "abcdefghij",
"row 2 (chars [20, 30)) lands mid-repeat but still aligned"
);
let mid = w.visible_window(50_000, 2);
assert_eq!(mid.len(), 2);
let mid_row: String = mid[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(mid_row, "abcdefghij");
let again = w.visible_window(50_000, 2);
let again_text: Vec<String> = again
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let mid_text: Vec<String> = mid
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(again_text, mid_text);
}
#[test]
fn visible_window_stays_fast_across_many_redraws_of_a_single_huge_line() {
use std::time::{Duration, Instant};
let body: String = "x".repeat(5_000_000);
let w = wrap(&body, 78);
let start = Instant::now();
for _ in 0..200 {
let rows = w.visible_window(0, 30);
assert_eq!(
rows.len(),
30,
"the first 30 wrapped rows of a 5,000,000-char line at width 78"
);
}
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"200 redraws of a single 5MB line took {elapsed:?} — expected a small fraction of a second"
);
}
#[test]
fn rebuild_if_needed_skips_rebuilding_on_an_unchanged_pointer_and_width() {
let source: Arc<str> = Arc::from("hello\nworld");
let mut cache: Option<PanelWrap> = None;
PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
let first_ptr = cache.as_ref().unwrap().source.as_ptr();
PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
assert_eq!(cache.as_ref().unwrap().source.as_ptr(), first_ptr);
PanelWrap::rebuild_if_needed(&mut cache, &source, 20);
assert_eq!(cache.as_ref().unwrap().width, 20);
let source2: Arc<str> = Arc::from("hello\nworld");
PanelWrap::rebuild_if_needed(&mut cache, &source2, 20);
assert!(Arc::ptr_eq(&cache.as_ref().unwrap().source, &source2));
}
}