use std::io::Result; use std::sync::Arc; use std::time::Duration; use crate::history_cell::HistoryCell; use crate::history_cell::UserHistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::render::Insets; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; use crate::style::user_message_style; use crate::tui; use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; use ratatui::layout::Rect; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::text::Text; use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; pub(crate) enum Overlay { Transcript(TranscriptOverlay), Static(StaticOverlay), } impl Overlay { pub(crate) fn new_transcript(cells: Vec>) -> Self { Self::Transcript(TranscriptOverlay::new(cells)) } pub(crate) fn new_static_with_lines(lines: Vec>, title: String) -> Self { Self::Static(StaticOverlay::with_title(lines, title)) } pub(crate) fn new_static_with_renderables( renderables: Vec>, title: String, ) -> Self { Self::Static(StaticOverlay::with_renderables(renderables, title)) } pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { match self { Overlay::Transcript(o) => o.handle_event(tui, event), Overlay::Static(o) => o.handle_event(tui, event), } } pub(crate) fn is_done(&self) -> bool { match self { Overlay::Transcript(o) => o.is_done(), Overlay::Static(o) => o.is_done(), } } } const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up); const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down); const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp); const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown); const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' ')); const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home); const KEY_END: KeyBinding = key_hint::plain(KeyCode::End); const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q')); const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc); const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter); const KEY_CTRL_T: KeyBinding = key_hint::ctrl(KeyCode::Char('t')); const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c')); // Common pager navigation hints rendered on the first line const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[ (&[KEY_UP, KEY_DOWN], "to scroll"), (&[KEY_PAGE_UP, KEY_PAGE_DOWN], "to page"), (&[KEY_HOME, KEY_END], "to jump"), ]; // Render a single line of key hints from (key(s), description) pairs. fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) { let mut spans: Vec> = vec![" ".into()]; let mut first = true; for (keys, desc) in pairs { if !first { spans.push(" ".into()); } for (i, key) in keys.iter().enumerate() { if i > 0 { spans.push("/".into()); } spans.push(Span::from(key)); } spans.push(" ".into()); spans.push(Span::from(desc.to_string())); first = false; } Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf); } /// Generic widget for rendering a pager view. struct PagerView { renderables: Vec>, scroll_offset: usize, title: String, last_content_height: Option, last_rendered_height: Option, /// If set, on next render ensure this chunk is visible. pending_scroll_chunk: Option, } impl PagerView { fn new(renderables: Vec>, title: String, scroll_offset: usize) -> Self { Self { renderables, scroll_offset, title, last_content_height: None, last_rendered_height: None, pending_scroll_chunk: None, } } fn content_height(&self, width: u16) -> usize { self.renderables .iter() .map(|c| c.desired_height(width) as usize) .sum() } fn render(&mut self, area: Rect, buf: &mut Buffer) { Clear.render(area, buf); self.render_header(area, buf); let content_area = self.content_area(area); self.update_last_content_height(content_area.height); let content_height = self.content_height(content_area.width); self.last_rendered_height = Some(content_height); // If there is a pending request to scroll a specific chunk into view, // satisfy it now that wrapping is up to date for this width. if let Some(idx) = self.pending_scroll_chunk.take() { self.ensure_chunk_visible(idx, content_area); } self.scroll_offset = self .scroll_offset .min(content_height.saturating_sub(content_area.height as usize)); self.render_content(content_area, buf); self.render_bottom_bar(area, content_area, buf, content_height); } fn render_header(&self, area: Rect, buf: &mut Buffer) { Span::from("/ ".repeat(area.width as usize / 2)) .dim() .render_ref(area, buf); let header = format!("/ {}", self.title); header.dim().render_ref(area, buf); } fn render_content(&self, area: Rect, buf: &mut Buffer) { let mut y = -(self.scroll_offset as isize); let mut drawn_bottom = area.y; for renderable in &self.renderables { let top = y; let height = renderable.desired_height(area.width) as isize; y += height; let bottom = y; if bottom < area.y as isize { continue; } if top > area.y as isize + area.height as isize { break; } if top < 0 { let drawn = render_offset_content(area, buf, &**renderable, (-top) as u16); drawn_bottom = drawn_bottom.max(area.y + drawn); } else { let draw_height = (height as u16).min(area.height.saturating_sub(top as u16)); let draw_area = Rect::new(area.x, area.y + top as u16, area.width, draw_height); renderable.render(draw_area, buf); drawn_bottom = drawn_bottom.max(draw_area.y.saturating_add(draw_area.height)); } } for y in drawn_bottom..area.bottom() { if area.width == 0 { break; } buf[(area.x, y)] = Cell::from('~'); for x in area.x + 1..area.right() { buf[(x, y)] = Cell::from(' '); } } } fn render_bottom_bar( &self, full_area: Rect, content_area: Rect, buf: &mut Buffer, total_len: usize, ) { let sep_y = content_area.bottom(); let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1); Span::from("─".repeat(sep_rect.width as usize)) .dim() .render_ref(sep_rect, buf); let percent = if total_len == 0 { 100 } else { let max_scroll = total_len.saturating_sub(content_area.height as usize); if max_scroll == 0 { 100 } else { (((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() as u8 } }; let pct_text = format!(" {percent}% "); let pct_w = pct_text.chars().count() as u16; let pct_x = sep_rect.x + sep_rect.width - pct_w - 1; Span::from(pct_text) .dim() .render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf); } fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { match key_event { e if KEY_UP.is_press(e) => { self.scroll_offset = self.scroll_offset.saturating_sub(1); } e if KEY_DOWN.is_press(e) => { self.scroll_offset = self.scroll_offset.saturating_add(1); } e if KEY_PAGE_UP.is_press(e) => { let area = self.content_area(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize); } e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) => { let area = self.content_area(tui.terminal.viewport_area); self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize); } e if KEY_HOME.is_press(e) => { self.scroll_offset = 0; } e if KEY_END.is_press(e) => { self.scroll_offset = usize::MAX; } _ => { return Ok(()); } } tui.frame_requester() .schedule_frame_in(Duration::from_millis(16)); Ok(()) } fn update_last_content_height(&mut self, height: u16) { self.last_content_height = Some(height as usize); } fn content_area(&self, area: Rect) -> Rect { let mut area = area; area.y = area.y.saturating_add(1); area.height = area.height.saturating_sub(2); area } } impl PagerView { fn is_scrolled_to_bottom(&self) -> bool { if self.scroll_offset == usize::MAX { return true; } let Some(height) = self.last_content_height else { return false; }; if self.renderables.is_empty() { return true; } let Some(total_height) = self.last_rendered_height else { return false; }; if total_height <= height { return true; } let max_scroll = total_height.saturating_sub(height); self.scroll_offset >= max_scroll } /// Request that the given text chunk index be scrolled into view on next render. fn scroll_chunk_into_view(&mut self, chunk_index: usize) { self.pending_scroll_chunk = Some(chunk_index); } fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) { if area.height == 0 || idx >= self.renderables.len() { return; } let first = self .renderables .iter() .take(idx) .map(|r| r.desired_height(area.width) as usize) .sum(); let last = first + self.renderables[idx].desired_height(area.width) as usize; let current_top = self.scroll_offset; let current_bottom = current_top.saturating_add(area.height.saturating_sub(1) as usize); if first < current_top { self.scroll_offset = first; } else if last > current_bottom { self.scroll_offset = last.saturating_sub(area.height.saturating_sub(1) as usize); } } } /// A renderable that caches its desired height. struct CachedRenderable { renderable: Box, height: std::cell::Cell>, last_width: std::cell::Cell>, } impl CachedRenderable { fn new(renderable: impl Into>) -> Self { Self { renderable: renderable.into(), height: std::cell::Cell::new(None), last_width: std::cell::Cell::new(None), } } } impl Renderable for CachedRenderable { fn render(&self, area: Rect, buf: &mut Buffer) { self.renderable.render(area, buf); } fn desired_height(&self, width: u16) -> u16 { if self.last_width.get() != Some(width) { let height = self.renderable.desired_height(width); self.height.set(Some(height)); self.last_width.set(Some(width)); } self.height.get().unwrap_or(0) } } struct CellRenderable { cell: Arc, style: Style, } impl Renderable for CellRenderable { fn render(&self, area: Rect, buf: &mut Buffer) { let p = Paragraph::new(Text::from(self.cell.transcript_lines(area.width))).style(self.style); p.render(area, buf); } fn desired_height(&self, width: u16) -> u16 { self.cell.desired_transcript_height(width) } } pub(crate) struct TranscriptOverlay { view: PagerView, cells: Vec>, highlight_cell: Option, is_done: bool, } impl TranscriptOverlay { pub(crate) fn new(transcript_cells: Vec>) -> Self { Self { view: PagerView::new( Self::render_cells(&transcript_cells, None), "T R A N S C R I P T".to_string(), usize::MAX, ), cells: transcript_cells, highlight_cell: None, is_done: false, } } fn render_cells( cells: &[Arc], highlight_cell: Option, ) -> Vec> { cells .iter() .enumerate() .flat_map(|(i, c)| { let mut v: Vec> = Vec::new(); let mut cell_renderable = if c.as_any().is::() { Box::new(CachedRenderable::new(CellRenderable { cell: c.clone(), style: if highlight_cell == Some(i) { user_message_style().reversed() } else { user_message_style() }, })) as Box } else { Box::new(CachedRenderable::new(CellRenderable { cell: c.clone(), style: Style::default(), })) as Box }; if !c.is_stream_continuation() && i > 0 { cell_renderable = Box::new(InsetRenderable::new( cell_renderable, Insets::tlbr(1, 0, 0, 0), )); } v.push(cell_renderable); v }) .collect() } pub(crate) fn insert_cell(&mut self, cell: Arc) { let follow_bottom = self.view.is_scrolled_to_bottom(); self.cells.push(cell); self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); if follow_bottom { self.view.scroll_offset = usize::MAX; } } pub(crate) fn set_highlight_cell(&mut self, cell: Option) { self.highlight_cell = cell; self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); if let Some(idx) = self.highlight_cell { self.view.scroll_chunk_into_view(idx); } } fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")]; if self.highlight_cell.is_some() { pairs.push((&[KEY_ENTER], "to edit message")); } render_key_hints(line2, buf, &pairs); } pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { let top_h = area.height.saturating_sub(3); let top = Rect::new(area.x, area.y, area.width, top_h); let bottom = Rect::new(area.x, area.y + top_h, area.width, 3); self.view.render(top, buf); self.render_hints(bottom, buf); } } impl TranscriptOverlay { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { match event { TuiEvent::Key(key_event) => match key_event { e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) || KEY_CTRL_T.is_press(e) => { self.is_done = true; Ok(()) } other => self.view.handle_key_event(tui, other), }, TuiEvent::Draw => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); })?; Ok(()) } _ => Ok(()), } } pub(crate) fn is_done(&self) -> bool { self.is_done } } pub(crate) struct StaticOverlay { view: PagerView, is_done: bool, } impl StaticOverlay { pub(crate) fn with_title(lines: Vec>, title: String) -> Self { let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); Self::with_renderables(vec![Box::new(CachedRenderable::new(paragraph))], title) } pub(crate) fn with_renderables(renderables: Vec>, title: String) -> Self { Self { view: PagerView::new(renderables, title, 0), is_done: false, } } fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); render_key_hints(line1, buf, PAGER_KEY_HINTS); let pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; render_key_hints(line2, buf, &pairs); } pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { let top_h = area.height.saturating_sub(3); let top = Rect::new(area.x, area.y, area.width, top_h); let bottom = Rect::new(area.x, area.y + top_h, area.width, 3); self.view.render(top, buf); self.render_hints(bottom, buf); } } impl StaticOverlay { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { match event { TuiEvent::Key(key_event) => match key_event { e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) => { self.is_done = true; Ok(()) } other => self.view.handle_key_event(tui, other), }, TuiEvent::Draw => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); })?; Ok(()) } _ => Ok(()), } } pub(crate) fn is_done(&self) -> bool { self.is_done } } fn render_offset_content( area: Rect, buf: &mut Buffer, renderable: &dyn Renderable, scroll_offset: u16, ) -> u16 { let height = renderable.desired_height(area.width); let mut tall_buf = Buffer::empty(Rect::new( 0, 0, area.width, height.min(area.height + scroll_offset), )); renderable.render(*tall_buf.area(), &mut tall_buf); let copy_height = area .height .min(tall_buf.area().height.saturating_sub(scroll_offset)); for y in 0..copy_height { let src_y = y + scroll_offset; for x in 0..area.width { buf[(area.x + x, area.y + y)] = tall_buf[(x, src_y)].clone(); } } copy_height } #[cfg(test)] mod tests { use super::*; use codex_core::protocol::ReviewDecision; use insta::assert_snapshot; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use crate::exec_cell::CommandOutput; use crate::history_cell; use crate::history_cell::HistoryCell; use crate::history_cell::new_patch_event; use codex_core::protocol::FileChange; use codex_protocol::parse_command::ParsedCommand; use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::text::Text; #[derive(Debug)] struct TestCell { lines: Vec>, } impl crate::history_cell::HistoryCell for TestCell { fn display_lines(&self, _width: u16) -> Vec> { self.lines.clone() } fn transcript_lines(&self, _width: u16) -> Vec> { self.lines.clone() } } fn paragraph_block(label: &str, lines: usize) -> Box { let text = Text::from( (0..lines) .map(|i| Line::from(format!("{label}{i}"))) .collect::>(), ); Box::new(Paragraph::new(text)) as Box } #[test] fn edit_prev_hint_is_visible() { let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { lines: vec![Line::from("hello")], })]); // Render into a small buffer and assert the backtrack hint is present let area = Rect::new(0, 0, 40, 10); let mut buf = Buffer::empty(area); overlay.render(area, &mut buf); // Flatten buffer to a string and check for the hint text let mut s = String::new(); for y in area.y..area.bottom() { for x in area.x..area.right() { s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); } s.push('\n'); } assert!( s.contains("edit prev"), "expected 'edit prev' hint in overlay footer, got: {s:?}" ); } #[test] fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines let mut overlay = TranscriptOverlay::new(vec![ Arc::new(TestCell { lines: vec![Line::from("alpha")], }), Arc::new(TestCell { lines: vec![Line::from("beta")], }), Arc::new(TestCell { lines: vec![Line::from("gamma")], }), ]); let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); assert_snapshot!(term.backend()); } fn buffer_to_text(buf: &Buffer, area: Rect) -> String { let mut out = String::new(); for y in area.y..area.bottom() { for x in area.x..area.right() { let symbol = buf[(x, y)].symbol(); if symbol.is_empty() { out.push(' '); } else { out.push(symbol.chars().next().unwrap_or(' ')); } } // Trim trailing spaces for stability. while out.ends_with(' ') { out.pop(); } out.push('\n'); } out } #[test] fn transcript_overlay_apply_patch_scroll_vt100_clears_previous_page() { let cwd = PathBuf::from("/repo"); let mut cells: Vec> = Vec::new(); let mut approval_changes = HashMap::new(); approval_changes.insert( PathBuf::from("foo.txt"), FileChange::Add { content: "hello\nworld\n".to_string(), }, ); let approval_cell: Arc = Arc::new(new_patch_event(approval_changes, &cwd)); cells.push(approval_cell); let mut apply_changes = HashMap::new(); apply_changes.insert( PathBuf::from("foo.txt"), FileChange::Add { content: "hello\nworld\n".to_string(), }, ); let apply_begin_cell: Arc = Arc::new(new_patch_event(apply_changes, &cwd)); cells.push(apply_begin_cell); let apply_end_cell: Arc = history_cell::new_approval_decision_cell(vec!["ls".into()], ReviewDecision::Approved) .into(); cells.push(apply_end_cell); let mut exec_cell = crate::exec_cell::new_active_exec_command( "exec-1".into(), vec!["bash".into(), "-lc".into(), "ls".into()], vec![ParsedCommand::Unknown { cmd: "ls".into() }], ); exec_cell.complete_call( "exec-1", CommandOutput { exit_code: 0, aggregated_output: "src\nREADME.md\n".into(), formatted_output: "src\nREADME.md\n".into(), }, Duration::from_millis(420), ); let exec_cell: Arc = Arc::new(exec_cell); cells.push(exec_cell); let mut overlay = TranscriptOverlay::new(cells); let area = Rect::new(0, 0, 80, 12); let mut buf = Buffer::empty(area); overlay.render(area, &mut buf); overlay.view.scroll_offset = 0; overlay.render(area, &mut buf); let snapshot = buffer_to_text(&buf, area); assert_snapshot!("transcript_overlay_apply_patch_scroll_vt100", snapshot); } #[test] fn transcript_overlay_keeps_scroll_pinned_at_bottom() { let mut overlay = TranscriptOverlay::new( (0..20) .map(|i| { Arc::new(TestCell { lines: vec![Line::from(format!("line{i}"))], }) as Arc }) .collect(), ); let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); assert!( overlay.view.is_scrolled_to_bottom(), "expected initial render to leave view at bottom" ); overlay.insert_cell(Arc::new(TestCell { lines: vec!["tail".into()], })); assert_eq!(overlay.view.scroll_offset, usize::MAX); } #[test] fn transcript_overlay_preserves_manual_scroll_position() { let mut overlay = TranscriptOverlay::new( (0..20) .map(|i| { Arc::new(TestCell { lines: vec![Line::from(format!("line{i}"))], }) as Arc }) .collect(), ); let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); overlay.view.scroll_offset = 0; overlay.insert_cell(Arc::new(TestCell { lines: vec!["tail".into()], })); assert_eq!(overlay.view.scroll_offset, 0); } #[test] fn static_overlay_snapshot_basic() { // Prepare a static overlay with a few lines and a title let mut overlay = StaticOverlay::with_title( vec!["one".into(), "two".into(), "three".into()], "S T A T I C".to_string(), ); let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); assert_snapshot!(term.backend()); } #[test] fn static_overlay_wraps_long_lines() { let mut overlay = StaticOverlay::with_title( vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()], "S T A T I C".to_string(), ); let mut term = Terminal::new(TestBackend::new(24, 8)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); assert_snapshot!(term.backend()); } #[test] fn pager_view_content_height_counts_renderables() { let pv = PagerView::new( vec![paragraph_block("a", 2), paragraph_block("b", 3)], "T".to_string(), 0, ); assert_eq!(pv.content_height(80), 5); } #[test] fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() { let mut pv = PagerView::new( vec![ paragraph_block("a", 1), paragraph_block("b", 3), paragraph_block("c", 3), ], "T".to_string(), 0, ); let area = Rect::new(0, 0, 20, 8); pv.scroll_offset = 0; let content_area = pv.content_area(area); pv.ensure_chunk_visible(2, content_area); let mut buf = Buffer::empty(area); pv.render(area, &mut buf); let rendered = buffer_to_text(&buf, area); assert!( rendered.contains("c0"), "expected chunk top in view: {rendered:?}" ); assert!( rendered.contains("c1"), "expected chunk middle in view: {rendered:?}" ); assert!( rendered.contains("c2"), "expected chunk bottom in view: {rendered:?}" ); } #[test] fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() { let mut pv = PagerView::new( vec![ paragraph_block("a", 2), paragraph_block("b", 3), paragraph_block("c", 3), ], "T".to_string(), 0, ); let area = Rect::new(0, 0, 20, 3); pv.scroll_offset = 6; pv.ensure_chunk_visible(0, area); assert_eq!(pv.scroll_offset, 0); } #[test] fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() { let mut pv = PagerView::new(vec![paragraph_block("a", 10)], "T".to_string(), 0); let area = Rect::new(0, 0, 20, 8); let mut buf = Buffer::empty(area); pv.render(area, &mut buf); assert!( !pv.is_scrolled_to_bottom(), "expected view to report not at bottom when offset < max" ); pv.scroll_offset = usize::MAX; pv.render(area, &mut buf); assert!( pv.is_scrolled_to_bottom(), "expected view to report at bottom after scrolling to end" ); } }