1use std::cell::RefCell;
16use std::sync::Arc;
17
18use ratatui::text::Line;
19
20use crate::wrap::{wrap_line_window, wrapped_row_count};
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
27pub struct TextPos {
28 pub line: usize,
29 pub col: usize,
30}
31
32impl TextPos {
33 pub fn new(line: usize, col: usize) -> Self {
34 Self { line, col }
35 }
36}
37
38struct LineRows {
47 cum: Vec<u32>,
48 lens: Vec<usize>,
49}
50
51impl LineRows {
52 fn build(char_lens: impl Iterator<Item = usize>, width: usize) -> Self {
53 let mut cum = vec![0u32];
54 let mut lens = Vec::new();
55 let mut total = 0u32;
56 for len in char_lens {
57 total += wrapped_row_count(len, width) as u32;
58 cum.push(total);
59 lens.push(len);
60 }
61 Self { cum, lens }
62 }
63
64 fn total_rows(&self) -> u32 {
65 (*self.cum.last().unwrap_or(&0)).max(1)
66 }
67
68 fn line_count(&self) -> usize {
69 self.cum.len().saturating_sub(1)
70 }
71
72 fn locate(&self, row: u32) -> (usize, u32) {
76 if self.cum.len() <= 1 {
77 return (0, 0);
78 }
79 let idx = self.cum.partition_point(|&c| c <= row);
82 let line = idx.saturating_sub(1).min(self.cum.len() - 2);
83 (line, row - self.cum[line])
84 }
85}
86
87pub struct PanelWrap {
90 source: Arc<str>,
95 line_ranges: Vec<(usize, usize)>,
98 rows: LineRows,
99 width: usize,
100 last_window: RefCell<Option<(u16, u16, Vec<Line<'static>>)>>,
107}
108
109impl PanelWrap {
110 pub fn build(source: Arc<str>, width: usize) -> Self {
114 let mut line_ranges = Vec::new();
115 let bytes = source.as_bytes();
116 let mut start = 0usize;
117 for (i, &b) in bytes.iter().enumerate() {
118 if b == b'\n' {
119 let mut end = i;
120 if end > start && bytes[end - 1] == b'\r' {
121 end -= 1;
122 }
123 line_ranges.push((start, end));
124 start = i + 1;
125 }
126 }
127 if start < bytes.len() || line_ranges.is_empty() {
128 line_ranges.push((start, bytes.len()));
129 }
130 let rows = LineRows::build(
131 line_ranges
132 .iter()
133 .map(|&(s, e)| source[s..e].chars().count()),
134 width,
135 );
136 Self {
137 source,
138 line_ranges,
139 rows,
140 width,
141 last_window: RefCell::new(None),
142 }
143 }
144
145 pub fn rebuild_if_needed(cache: &mut Option<PanelWrap>, source: &Arc<str>, width: usize) {
150 let stale = match cache {
151 Some(c) => !Arc::ptr_eq(&c.source, source) || c.width != width,
152 None => true,
153 };
154 if stale {
155 *cache = Some(PanelWrap::build(Arc::clone(source), width));
156 }
157 }
158
159 pub fn line_count(&self) -> usize {
160 self.rows.line_count()
161 }
162
163 pub fn source(&self) -> &str {
167 &self.source
168 }
169
170 pub fn line_text(&self, idx: usize) -> &str {
171 let (s, e) = self.line_ranges[idx];
172 &self.source[s..e]
173 }
174
175 pub fn line_char_len(&self, idx: usize) -> usize {
176 self.rows.lens.get(idx).copied().unwrap_or(0)
177 }
178
179 pub fn total_rows(&self) -> u32 {
180 self.rows.total_rows()
181 }
182
183 pub fn visible_window(&self, scroll: u16, height: u16) -> Vec<Line<'static>> {
191 if height == 0 || self.line_count() == 0 {
192 return Vec::new();
193 }
194 if let Some((cached_scroll, cached_height, cached)) = self.last_window.borrow().as_ref()
195 && *cached_scroll == scroll
196 && *cached_height == height
197 {
198 return cached.clone();
199 }
200 let (start_line, row_in_line) = self.rows.locate(scroll as u32);
201 let height_usize = height as usize;
202 let mut out: Vec<Line<'static>> = Vec::with_capacity(height_usize);
203 let mut skip = row_in_line as usize;
204 for idx in start_line..self.line_count() {
205 if out.len() >= height_usize {
206 break;
207 }
208 let budget = height_usize - out.len();
209 out.extend(wrap_line_window(
210 self.line_text(idx),
211 self.width,
212 skip,
213 budget,
214 ));
215 skip = 0;
216 }
217 out.truncate(height_usize);
218 *self.last_window.borrow_mut() = Some((scroll, height, out.clone()));
219 out
220 }
221
222 pub fn textpos_to_row_col(&self, pos: TextPos) -> (u32, usize) {
227 if self.line_count() == 0 {
228 return (0, 0);
229 }
230 let line = pos.line.min(self.line_count() - 1);
231 let len = self.line_char_len(line);
232 let col = pos.col.min(len);
233 if self.width == 0 {
234 return (self.rows.cum[line], col);
235 }
236 let rows_in_line = wrapped_row_count(len, self.width) as u32;
237 let row_in_line = ((col / self.width) as u32).min(rows_in_line.saturating_sub(1));
238 let col_in_row = col.saturating_sub(row_in_line as usize * self.width);
239 (self.rows.cum[line] + row_in_line, col_in_row)
240 }
241
242 pub fn row_col_to_textpos(&self, row: u32, col: usize) -> TextPos {
247 if self.line_count() == 0 {
248 return TextPos::new(0, 0);
249 }
250 let (line, row_in_line) = self.rows.locate(row);
251 let len = self.line_char_len(line);
252 let base = if self.width == 0 {
253 0
254 } else {
255 row_in_line as usize * self.width
256 };
257 TextPos::new(line, base.saturating_add(col).min(len))
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 fn wrap(text: &str, width: usize) -> PanelWrap {
271 PanelWrap::build(Arc::from(text), width)
272 }
273
274 #[test]
275 fn splits_lines_like_str_lines_including_trailing_newline_and_crlf() {
276 let w = wrap("a\r\nb\nc", 10);
277 assert_eq!(w.line_count(), 3);
278 assert_eq!(w.line_text(0), "a");
279 assert_eq!(w.line_text(1), "b");
280 assert_eq!(w.line_text(2), "c");
281
282 let w2 = wrap("a\nb\n", 10);
283 assert_eq!(
284 w2.line_count(),
285 2,
286 "no trailing empty line after a final \\n, matching str::lines()"
287 );
288 }
289
290 #[test]
291 fn empty_body_has_one_line_and_one_row() {
292 let w = wrap("", 10);
293 assert_eq!(w.line_count(), 1);
294 assert_eq!(w.total_rows(), 1);
295 }
296
297 #[test]
298 fn total_rows_accounts_for_wrapping_long_lines() {
299 let w = wrap("0123456789ABCDE\n", 10);
301 assert_eq!(w.total_rows(), 2);
302 }
303
304 #[test]
305 fn row_col_and_textpos_roundtrip_for_a_wrapped_line() {
306 let w = wrap("0123456789ABCDE", 10); assert_eq!(w.row_col_to_textpos(0, 3), TextPos::new(0, 3));
308 assert_eq!(w.row_col_to_textpos(1, 2), TextPos::new(0, 12));
309 assert_eq!(w.textpos_to_row_col(TextPos::new(0, 3)), (0, 3));
310 assert_eq!(w.textpos_to_row_col(TextPos::new(0, 12)), (1, 2));
311 assert_eq!(w.textpos_to_row_col(TextPos::new(0, 15)), (1, 5));
313 }
314
315 #[test]
316 fn locate_binary_search_finds_the_right_line_for_a_huge_body() {
317 let body: String = (0..100_000).map(|i| format!("line {i}\n")).collect();
318 let w = wrap(&body, 20);
319 assert_eq!(w.row_col_to_textpos(50_000, 0), TextPos::new(50_000, 0));
322 }
323
324 #[test]
325 fn visible_window_only_wraps_the_requested_rows() {
326 let body: String = (0..1000).map(|i| format!("line {i}\n")).collect();
327 let w = wrap(&body, 20);
328 let rows = w.visible_window(500, 5);
329 assert_eq!(rows.len(), 5);
330 let text: Vec<String> = rows
331 .iter()
332 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
333 .collect();
334 assert_eq!(
335 text,
336 vec!["line 500", "line 501", "line 502", "line 503", "line 504"]
337 );
338 }
339
340 #[test]
347 fn visible_window_is_correct_for_a_single_enormous_unbroken_line() {
348 let body: String = "abcdefghij".repeat(200_000); let w = wrap(&body, 10);
350
351 let top = w.visible_window(0, 3);
352 assert_eq!(top.len(), 3);
353 let row0: String = top[0].spans.iter().map(|s| s.content.as_ref()).collect();
354 assert_eq!(row0, "abcdefghij", "row 0 is chars [0, 10)");
355 let row2: String = top[2].spans.iter().map(|s| s.content.as_ref()).collect();
356 assert_eq!(
357 row2, "abcdefghij",
358 "row 2 (chars [20, 30)) lands mid-repeat but still aligned"
359 );
360
361 let mid = w.visible_window(50_000, 2);
363 assert_eq!(mid.len(), 2);
364 let mid_row: String = mid[0].spans.iter().map(|s| s.content.as_ref()).collect();
365 assert_eq!(mid_row, "abcdefghij");
366
367 let again = w.visible_window(50_000, 2);
370 let again_text: Vec<String> = again
371 .iter()
372 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
373 .collect();
374 let mid_text: Vec<String> = mid
375 .iter()
376 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
377 .collect();
378 assert_eq!(again_text, mid_text);
379 }
380
381 #[test]
391 fn visible_window_stays_fast_across_many_redraws_of_a_single_huge_line() {
392 use std::time::{Duration, Instant};
393 let body: String = "x".repeat(5_000_000);
394 let w = wrap(&body, 78);
395
396 let start = Instant::now();
397 for _ in 0..200 {
398 let rows = w.visible_window(0, 30);
399 assert_eq!(
400 rows.len(),
401 30,
402 "the first 30 wrapped rows of a 5,000,000-char line at width 78"
403 );
404 }
405 let elapsed = start.elapsed();
406 assert!(
407 elapsed < Duration::from_secs(2),
408 "200 redraws of a single 5MB line took {elapsed:?} — expected a small fraction of a second"
409 );
410 }
411
412 #[test]
413 fn rebuild_if_needed_skips_rebuilding_on_an_unchanged_pointer_and_width() {
414 let source: Arc<str> = Arc::from("hello\nworld");
415 let mut cache: Option<PanelWrap> = None;
416 PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
417 let first_ptr = cache.as_ref().unwrap().source.as_ptr();
418 PanelWrap::rebuild_if_needed(&mut cache, &source, 10);
420 assert_eq!(cache.as_ref().unwrap().source.as_ptr(), first_ptr);
421 PanelWrap::rebuild_if_needed(&mut cache, &source, 20);
423 assert_eq!(cache.as_ref().unwrap().width, 20);
424 let source2: Arc<str> = Arc::from("hello\nworld");
427 PanelWrap::rebuild_if_needed(&mut cache, &source2, 20);
428 assert!(Arc::ptr_eq(&cache.as_ref().unwrap().source, &source2));
429 }
430}