1use ratatui::text::Line;
11use std::collections::VecDeque;
12
13const MAX_CACHE_LINES: usize = 2500;
16
17pub struct ScrollBuffer {
19 lines: VecDeque<Line<'static>>,
21
22 gutter_widths: VecDeque<u16>,
25
26 scroll_offset: usize,
29
30 sticky_bottom: bool,
34
35 oldest_message_id: Option<i64>,
39
40 cached_term_width: usize,
44}
45
46impl ScrollBuffer {
47 pub fn new(capacity: usize) -> Self {
48 let cap = capacity.min(4096);
49 Self {
50 lines: VecDeque::with_capacity(cap),
51 gutter_widths: VecDeque::with_capacity(cap),
52 scroll_offset: 0,
53 sticky_bottom: true,
54 oldest_message_id: None,
55 cached_term_width: 80,
56 }
57 }
58
59 pub fn push(&mut self, line: Line<'static>) {
65 self.lines.push_back(line);
66 self.gutter_widths.push_back(0);
67 self.enforce_capacity();
68
69 if self.sticky_bottom {
71 self.scroll_offset = 0;
72 }
73 }
74
75 pub fn push_with_gutter(&mut self, line: Line<'static>, gutter_width: u16) {
77 self.lines.push_back(line);
78 self.gutter_widths.push_back(gutter_width);
79 self.enforce_capacity();
80
81 if self.sticky_bottom {
82 self.scroll_offset = 0;
83 }
84 }
85
86 pub fn push_lines(&mut self, lines: impl IntoIterator<Item = Line<'static>>) {
88 for line in lines {
89 self.lines.push_back(line);
90 self.gutter_widths.push_back(0);
91 }
92 self.enforce_capacity();
93
94 if self.sticky_bottom {
95 self.scroll_offset = 0;
96 }
97 }
98
99 pub fn scroll_up(&mut self, n: usize, term_width: usize, viewport_height: usize) {
101 self.cached_term_width = term_width;
102 let total = self.total_visual_lines(term_width);
103 let max_offset = total.saturating_sub(viewport_height);
104 self.scroll_offset = (self.scroll_offset + n).min(max_offset);
105 self.sticky_bottom = false;
106 }
107
108 pub fn scroll_down(&mut self, n: usize) {
111 self.scroll_offset = self.scroll_offset.saturating_sub(n);
112 if self.scroll_offset == 0 {
113 self.sticky_bottom = true;
114 }
115 }
116
117 pub fn clamp_offset(&mut self, term_width: usize, viewport_height: usize) {
121 self.cached_term_width = term_width;
122 let total = self.total_visual_lines(term_width);
123 let max_offset = total.saturating_sub(viewport_height);
124 if self.scroll_offset > max_offset {
125 self.scroll_offset = max_offset;
126 }
127 if self.scroll_offset == 0 {
128 self.sticky_bottom = true;
129 }
130 }
131
132 pub fn scroll_to_bottom(&mut self) {
134 self.scroll_offset = 0;
135 self.sticky_bottom = true;
136 }
137
138 pub fn scroll_to_top(&mut self, term_width: usize, viewport_height: usize) {
140 self.cached_term_width = term_width;
141 if !self.lines.is_empty() {
142 let total = self.total_visual_lines(term_width);
143 self.scroll_offset = total.saturating_sub(viewport_height);
144 self.sticky_bottom = false;
145 }
146 }
147
148 #[allow(dead_code)] pub fn at_top(&self, term_width: usize, viewport_height: usize) -> bool {
152 if self.lines.is_empty() {
153 return false;
154 }
155 let total = self.total_visual_lines(term_width);
156 let max_offset = total.saturating_sub(viewport_height);
157 self.scroll_offset >= max_offset && max_offset > 0
158 }
159
160 pub fn all_lines(&self) -> impl Iterator<Item = &Line<'static>> {
166 self.lines.iter()
167 }
168
169 pub fn gutter_widths(&self) -> &VecDeque<u16> {
173 &self.gutter_widths
174 }
175
176 pub fn total_visual_lines(&self, term_width: usize) -> usize {
179 let w = term_width.max(1);
180 self.lines.iter().map(|l| visual_height(l, w)).sum()
181 }
182
183 pub fn paragraph_scroll(&self, viewport_height: usize, term_width: usize) -> (u16, u16) {
189 let total = self.total_visual_lines(term_width);
190 let from_top = total
191 .saturating_sub(viewport_height)
192 .saturating_sub(self.scroll_offset);
193 (from_top.min(u16::MAX as usize) as u16, 0)
195 }
196
197 pub fn len(&self) -> usize {
199 self.lines.len()
200 }
201
202 #[allow(dead_code)]
204 pub fn is_empty(&self) -> bool {
205 self.lines.is_empty()
206 }
207
208 pub fn offset(&self) -> usize {
210 self.scroll_offset
211 }
212
213 pub fn is_sticky(&self) -> bool {
215 self.sticky_bottom
216 }
217
218 #[allow(dead_code)] pub fn oldest_message_id(&self) -> Option<i64> {
221 self.oldest_message_id
222 }
223
224 pub fn set_oldest_message_id(&mut self, id: i64) {
226 self.oldest_message_id = Some(id);
227 }
228
229 #[allow(dead_code)]
231 pub fn clear(&mut self) {
232 self.lines.clear();
233 self.gutter_widths.clear();
234 self.scroll_offset = 0;
235 self.sticky_bottom = true;
236 }
237
238 pub fn last_code_block(&self) -> Option<String> {
243 let mut end_fence = None;
244 let mut start_fence = None;
245
246 for (i, line) in self.lines.iter().enumerate().rev() {
248 let text = line_text(line);
249 let trimmed = text.trim();
250
251 if trimmed == "```" || trimmed.starts_with("```") {
252 if end_fence.is_none() {
253 end_fence = Some(i);
255 } else {
256 start_fence = Some(i);
258 break;
259 }
260 }
261 }
262
263 match (start_fence, end_fence) {
264 (Some(start), Some(end)) if start < end => {
265 let code: Vec<String> = (start + 1..end)
266 .map(|i| line_text(&self.lines[i]))
267 .collect();
268 Some(code.join("\n"))
269 }
270 _ => None,
271 }
272 }
273
274 pub fn last_response(&self) -> Option<String> {
282 let mut sep_idx = None;
283
284 for (i, line) in self.lines.iter().enumerate().rev() {
285 let text = line_text(line);
286 let trimmed = text.trim();
287 if trimmed.chars().all(|c| c == '─') && trimmed.chars().count() == 3 {
289 sep_idx = Some(i);
290 break;
291 }
292 }
293
294 sep_idx.map(|start| {
295 let response: Vec<String> = (start + 1..self.lines.len())
296 .map(|i| line_text(&self.lines[i]))
297 .collect();
298 response.join("\n").trim().to_string()
299 })
300 }
301
302 fn enforce_capacity(&mut self) {
308 let w = self.cached_term_width.max(1);
309 while self.lines.len() > MAX_CACHE_LINES {
310 if let Some(evicted) = self.lines.pop_front()
311 && self.scroll_offset > 0
312 {
313 let vis = visual_height(&evicted, w);
314 self.scroll_offset = self.scroll_offset.saturating_sub(vis);
315 }
316 self.gutter_widths.pop_front();
317 }
318 }
319
320 #[allow(dead_code)] pub fn prepend_lines(&mut self, lines: impl IntoIterator<Item = Line<'static>>) {
326 let lines: Vec<_> = lines.into_iter().collect();
327 let count = lines.len();
328 for line in lines.into_iter().rev() {
329 self.lines.push_front(line);
330 self.gutter_widths.push_front(0);
331 }
332 self.scroll_offset += count;
335 self.enforce_capacity();
336 }
337}
338
339fn line_text(line: &Line<'_>) -> String {
341 line.spans.iter().map(|s| s.content.as_ref()).collect()
342}
343
344fn visual_height(line: &Line<'_>, term_width: usize) -> usize {
349 crate::wrap_util::visual_line_count(&line_text(line), term_width)
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use ratatui::text::Span;
356
357 const W: usize = 80; const H: usize = 50; fn make_line(text: &str) -> Line<'static> {
361 Line::from(Span::raw(text.to_string()))
362 }
363
364 fn visible_text(buf: &ScrollBuffer, height: usize) -> Vec<String> {
367 let lines: Vec<Line<'_>> = buf.all_lines().cloned().collect();
368 let total_visual = buf.total_visual_lines(W);
369 let from_top = total_visual
370 .saturating_sub(height)
371 .saturating_sub(buf.offset());
372 lines
374 .iter()
375 .skip(from_top)
376 .take(height)
377 .map(line_text)
378 .collect()
379 }
380
381 #[test]
382 fn test_push_and_visible() {
383 let mut buf = ScrollBuffer::new(2500);
384 for i in 0..10 {
385 buf.push(make_line(&format!("line {i}")));
386 }
387 assert_eq!(buf.len(), 10);
388
389 let visible = visible_text(&buf, 3);
391 assert_eq!(visible.len(), 3);
392 assert_eq!(visible[0], "line 7");
393 assert_eq!(visible[1], "line 8");
394 assert_eq!(visible[2], "line 9");
395 }
396
397 #[test]
398 fn test_sticky_bottom() {
399 let mut buf = ScrollBuffer::new(2500);
400 for i in 0..5 {
401 buf.push(make_line(&format!("line {i}")));
402 }
403 assert!(buf.is_sticky());
404 assert_eq!(buf.offset(), 0);
405
406 buf.push(make_line("line 5"));
408 assert_eq!(buf.offset(), 0);
409 let visible = visible_text(&buf, 2);
410 assert_eq!(visible[1], "line 5");
411 }
412
413 #[test]
414 fn test_scroll_up_breaks_sticky() {
415 let mut buf = ScrollBuffer::new(2500);
416 for i in 0..10 {
417 buf.push(make_line(&format!("line {i}")));
418 }
419
420 buf.scroll_up(3, W, 5);
422 assert!(!buf.is_sticky());
423 assert_eq!(buf.offset(), 3);
424 }
425
426 #[test]
427 fn test_scroll_down_restores_sticky() {
428 let mut buf = ScrollBuffer::new(2500);
429 for i in 0..10 {
430 buf.push(make_line(&format!("line {i}")));
431 }
432
433 buf.scroll_up(5, W, 5);
434 assert!(!buf.is_sticky());
435
436 buf.scroll_down(5);
437 assert!(buf.is_sticky());
438 assert_eq!(buf.offset(), 0);
439 }
440
441 #[test]
442 fn test_scroll_up_clamped() {
443 let mut buf = ScrollBuffer::new(2500);
444 for i in 0..5 {
445 buf.push(make_line(&format!("line {i}")));
446 }
447
448 buf.scroll_up(100, W, 3);
450 assert_eq!(buf.offset(), 2);
451 }
452
453 #[test]
454 fn test_eviction() {
455 let mut buf = ScrollBuffer::new(2500);
456 for i in 0..MAX_CACHE_LINES + 100 {
457 buf.push(make_line(&format!("line {i}")));
458 }
459 assert_eq!(buf.len(), MAX_CACHE_LINES);
460 let visible = visible_text(&buf, 1);
462 assert_eq!(visible[0], format!("line {}", MAX_CACHE_LINES + 99));
463 }
464
465 #[test]
466 fn test_empty_buffer() {
467 let buf = ScrollBuffer::new(2500);
468 assert_eq!(buf.all_lines().count(), 0);
469 assert_eq!(buf.total_visual_lines(80), 0);
470 }
471
472 #[test]
473 fn test_scroll_to_top_and_bottom() {
474 let mut buf = ScrollBuffer::new(2500);
475 for i in 0..20 {
476 buf.push(make_line(&format!("line {i}")));
477 }
478
479 buf.scroll_to_top(W, 10);
481 assert!(!buf.is_sticky());
482 assert_eq!(buf.offset(), 10);
483
484 buf.scroll_to_bottom();
485 assert!(buf.is_sticky());
486 assert_eq!(buf.offset(), 0);
487 }
488
489 #[test]
490 fn test_last_code_block() {
491 let mut buf = ScrollBuffer::new(2500);
492 buf.push(make_line("some text"));
493 buf.push(make_line("```rust"));
494 buf.push(make_line(" fn main() {}"));
495 buf.push(make_line(" let x = 42;"));
496 buf.push(make_line("```"));
497 buf.push(make_line("more text"));
498
499 let code = buf.last_code_block().unwrap();
500 assert_eq!(code, " fn main() {}\n let x = 42;");
501 }
502
503 #[test]
504 fn test_last_code_block_none() {
505 let mut buf = ScrollBuffer::new(2500);
506 buf.push(make_line("no code here"));
507 assert!(buf.last_code_block().is_none());
508 }
509
510 #[test]
511 fn test_last_response() {
512 let mut buf = ScrollBuffer::new(2500);
513 buf.push(make_line("user message"));
514 buf.push(make_line(" ───"));
515 buf.push(make_line(" response line 1"));
516 buf.push(make_line(" response line 2"));
517
518 let response = buf.last_response().unwrap();
519 assert!(response.contains("response line 1"));
520 assert!(response.contains("response line 2"));
521 }
522
523 #[test]
526 fn test_last_response_skips_markdown_hr() {
527 let mut buf = ScrollBuffer::new(2500);
528 buf.push(make_line("user message"));
529 buf.push(make_line(" ───")); buf.push(make_line(" first paragraph"));
531 let hr = format!(" {}", "─".repeat(60));
533 buf.push(make_line(&hr));
534 buf.push(make_line(" second paragraph"));
535
536 let response = buf.last_response().unwrap();
537 assert!(
539 response.contains("first paragraph"),
540 "Should include content before HR: {response}"
541 );
542 assert!(
543 response.contains("second paragraph"),
544 "Should include content after HR: {response}"
545 );
546 }
547
548 #[test]
549 fn test_push_lines_batch() {
550 let mut buf = ScrollBuffer::new(2500);
551 let batch: Vec<Line<'static>> = (0..5).map(|i| make_line(&format!("line {i}"))).collect();
552 buf.push_lines(batch);
553 assert_eq!(buf.len(), 5);
554 }
555
556 #[test]
557 fn test_eviction_adjusts_scroll_offset() {
558 let mut buf = ScrollBuffer::new(2500);
559 for i in 0..MAX_CACHE_LINES {
561 buf.push(make_line(&format!("line {i}")));
562 }
563 buf.scroll_up(100, W, H);
565 let offset_before = buf.offset();
566
567 for i in 0..50 {
569 buf.push(make_line(&format!("new {i}")));
570 }
571
572 assert!(buf.offset() < offset_before);
574 assert_eq!(buf.len(), MAX_CACHE_LINES);
575 }
576
577 #[test]
580 fn test_visual_height_short_line() {
581 let line = make_line("hello"); assert_eq!(visual_height(&line, 80), 1);
583 }
584
585 #[test]
586 fn test_visual_height_wrapping_line() {
587 let line = make_line(&"x".repeat(160));
589 assert_eq!(visual_height(&line, 80), 2);
590 }
591
592 #[test]
593 fn test_visual_height_empty_line() {
594 let line = make_line("");
595 assert_eq!(visual_height(&line, 80), 1);
596 }
597
598 #[test]
601 fn test_visual_height_word_wrap_breaks_before_word() {
602 let text = format!("{} foobar", "a".repeat(76));
606 let line = make_line(&text);
607 assert_eq!(visual_height(&line, 80), 2);
608 }
609
610 #[test]
611 fn test_visual_height_word_wrap_longer_word() {
612 let text = format!("{} hello", "x".repeat(78));
617 let line = make_line(&text);
618 assert_eq!(visual_height(&line, 80), 2);
619 }
620
621 #[test]
622 fn test_visual_height_word_wrap_three_rows() {
623 let text = format!("{} {} end", "a".repeat(75), "b".repeat(75));
626 let line = make_line(&text);
627 assert_eq!(visual_height(&line, 80), 2);
635 }
636
637 #[test]
638 fn test_visual_height_single_word_longer_than_width() {
639 let line = make_line(&"x".repeat(200));
642 assert_eq!(visual_height(&line, 80), 3);
643 }
644
645 #[test]
646 fn test_visual_height_exact_width() {
647 let line = make_line(&"x".repeat(80));
649 assert_eq!(visual_height(&line, 80), 1);
650 }
651
652 #[test]
653 fn test_visual_height_exact_width_plus_one() {
654 let line = make_line(&"x".repeat(81));
655 assert_eq!(visual_height(&line, 80), 2);
656 }
657
658 #[test]
659 fn test_total_visual_lines() {
660 let mut buf = ScrollBuffer::new(2500);
661 buf.push(make_line("short")); buf.push(make_line(&"x".repeat(160))); buf.push(make_line("")); assert_eq!(buf.total_visual_lines(80), 4);
665 }
666
667 #[test]
668 fn test_paragraph_scroll_at_bottom() {
669 let mut buf = ScrollBuffer::new(2500);
670 for i in 0..20 {
671 buf.push(make_line(&format!("line {i}")));
672 }
673 let (row, _) = buf.paragraph_scroll(10, 80);
676 assert_eq!(row, 10);
677 }
678
679 #[test]
680 fn test_paragraph_scroll_at_top() {
681 let mut buf = ScrollBuffer::new(2500);
682 for i in 0..20 {
683 buf.push(make_line(&format!("line {i}")));
684 }
685 buf.scroll_to_top(80, 10);
686 let (row, _) = buf.paragraph_scroll(10, 80);
689 assert_eq!(row, 0);
690 }
691
692 #[test]
697 fn test_clamp_offset_after_resize() {
698 let mut buf = ScrollBuffer::new(2500);
699 for i in 0..20 {
700 buf.push(make_line(&format!("line {i}")));
701 }
702 buf.scroll_to_top(W, 10);
704 assert_eq!(buf.offset(), 10);
705 assert!(!buf.is_sticky());
706
707 buf.clamp_offset(W, 18);
709 assert_eq!(buf.offset(), 2);
710 }
711
712 #[test]
713 fn test_clamp_offset_restores_sticky() {
714 let mut buf = ScrollBuffer::new(2500);
715 for i in 0..5 {
716 buf.push(make_line(&format!("line {i}")));
717 }
718 buf.scroll_up(3, W, 3);
720 assert!(!buf.is_sticky());
721
722 buf.clamp_offset(W, 10); assert_eq!(buf.offset(), 0);
724 assert!(buf.is_sticky());
725 }
726
727 #[test]
732 fn test_last_code_block_rendered() {
733 use ratatui::style::{Color, Style};
734 let dim = Style::default().fg(Color::DarkGray);
735
736 let mut buf = ScrollBuffer::new(2500);
737 buf.push(Line::from(vec![
738 Span::raw(" "),
739 Span::styled("Some intro text".to_string(), Style::default()),
740 ]));
741 buf.push(Line::from(vec![
742 Span::raw(" "),
743 Span::styled("```rust".to_string(), dim),
744 ]));
745 buf.push(Line::from(vec![
746 Span::raw(" "),
747 Span::styled(
748 "fn main() {}".to_string(),
749 Style::default().fg(Color::Green),
750 ),
751 ]));
752 buf.push(Line::from(vec![
753 Span::raw(" "),
754 Span::styled("```".to_string(), dim),
755 ]));
756 buf.push(Line::from(vec![
757 Span::raw(" "),
758 Span::styled("After block".to_string(), Style::default()),
759 ]));
760
761 let code = buf.last_code_block();
762 assert!(
763 code.is_some(),
764 "Should detect code block in rendered content"
765 );
766 let code = code.unwrap();
767 assert!(code.contains("fn main()"), "Code block content: {code}");
768 }
769
770 #[test]
774 fn test_last_response_rendered() {
775 use ratatui::style::{Color, Style};
776 let dim = Style::default().fg(Color::DarkGray);
777
778 let mut buf = ScrollBuffer::new(2500);
779 buf.push(Line::from(vec![Span::raw("user question".to_string())]));
780 buf.push(Line::styled(" ───".to_string(), dim));
781 buf.push(Line::from(vec![
782 Span::raw(" "),
783 Span::styled("The answer is 42.".to_string(), Style::default()),
784 ]));
785
786 let resp = buf.last_response();
787 assert!(
788 resp.is_some(),
789 "Should detect response separator in rendered content"
790 );
791 let resp = resp.unwrap();
792 assert!(
793 resp.contains("The answer is 42"),
794 "Response content: {resp}"
795 );
796 }
797
798 #[test]
799 fn test_prepend_lines() {
800 let mut buf = ScrollBuffer::new(2500);
801 buf.push(make_line("current"));
802 buf.scroll_up(0, W, H); let old_lines = vec![make_line("old1"), make_line("old2")];
805 buf.prepend_lines(old_lines);
806
807 assert_eq!(buf.len(), 3);
808 assert_eq!(buf.offset(), 2);
810 let first = line_text(buf.all_lines().next().unwrap());
812 assert_eq!(first, "old1");
813 }
814}