refactor(tui): add retained conversation viewport

This commit is contained in:
Felipe Coury
2026-07-04 01:02:50 -03:00
parent 85f780f2a8
commit b537258fee
8 changed files with 678 additions and 27 deletions
+1 -1
View File
@@ -424,7 +424,7 @@ impl App {
if matches!(&event, TuiEvent::Draw | TuiEvent::Resize)
&& let Some(Overlay::Transcript(t)) = &mut self.overlay
{
let active_key = self.chat_widget.active_cell_transcript_key();
let active_key = self.chat_widget.active_cell_render_key();
let chat_widget = &self.chat_widget;
tui.draw(u16::MAX, |frame| {
let width = frame.area().width.max(1);
+41 -7
View File
@@ -10,7 +10,7 @@
//! visible immediately.
//!
//! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail
//! during draws using `active_cell_transcript_key()` and
//! during draws using `active_cell_render_key()` and
//! `active_cell_transcript_hyperlink_lines()`. The
//! cache key is designed to change when the active cell mutates in place or when its transcript
//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on
@@ -754,12 +754,12 @@ enum CodexOpTarget {
AppEvent,
}
/// Snapshot of active-cell state that affects transcript overlay rendering.
/// Snapshot of active-cell state that affects retained viewport rendering.
///
/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets
/// it cheaply decide when to recompute that tail as the active cell evolves.
/// Retained views keep a cached "live tail" for the in-flight cell; this key lets them cheaply
/// decide when to recompute that tail as the active cell evolves.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ActiveCellTranscriptKey {
pub(crate) struct ActiveCellRenderKey {
/// Cache-busting revision for in-place updates.
///
/// Many active cells are updated incrementally while streaming (for example when exec groups
@@ -1921,7 +1921,7 @@ impl ChatWidget {
/// If callers mutate the active cell's transcript output without bumping the revision (or
/// providing an appropriate animation tick), the overlay will keep showing a stale tail while
/// the main viewport updates.
pub(crate) fn active_cell_transcript_key(&self) -> Option<ActiveCellTranscriptKey> {
pub(crate) fn active_cell_render_key(&self) -> Option<ActiveCellRenderKey> {
let cell = self.transcript.active_cell.as_ref();
let hook_cell = self.active_hook_cell.as_ref();
let token_activity_cell = self.pending_token_activity_output();
@@ -1933,7 +1933,7 @@ impl ChatWidget {
{
return None;
}
Some(ActiveCellTranscriptKey {
Some(ActiveCellRenderKey {
revision: self.transcript.active_cell_revision,
is_stream_continuation: cell
.map(|cell| cell.is_stream_continuation())
@@ -1985,6 +1985,40 @@ impl ChatWidget {
(!lines.is_empty()).then_some(lines)
}
/// Returns the active cells' main-viewport lines for a given terminal width.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn active_cell_display_hyperlink_lines(
&self,
width: u16,
) -> Option<Vec<HyperlinkLine>> {
let mode = self.history_render_mode();
let mut lines = Vec::new();
let mut append = |cell: &dyn HistoryCell| {
let cell_lines = cell.display_hyperlink_lines_for_mode(width, mode);
if !cell_lines.is_empty() && !lines.is_empty() {
lines.push(HyperlinkLine::from(""));
}
lines.extend(cell_lines);
};
if let Some(cell) = self.transcript.active_cell.as_deref() {
append(cell);
}
if let Some(cell) = self
.active_hook_cell
.as_ref()
.filter(|cell| cell.should_render())
{
append(cell);
}
if let Some(cell) = self.pending_token_activity_output() {
append(cell);
}
if let Some(cell) = self.pending_rate_limit_reset_hint() {
append(cell);
}
(!lines.is_empty()).then_some(lines)
}
#[cfg(test)]
pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option<Vec<Line<'static>>> {
self.active_cell_transcript_hyperlink_lines(width)
+11 -10
View File
@@ -3,6 +3,16 @@
use super::*;
impl ChatWidget {
pub(crate) fn bottom_pane_renderable(&self) -> RenderableItem<'_> {
RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable {
bottom_pane: &self.bottom_pane,
right_reserve: self.ambient_pet_wrap_reserved_cols(),
}))
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
))
}
pub(super) fn as_renderable(&self) -> RenderableItem<'_> {
let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols();
let active_cell_renderable = match &self.transcript.active_cell {
@@ -46,16 +56,7 @@ impl ChatWidget {
})),
);
}
flex.push(
/*flex*/ 0,
RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable {
bottom_pane: &self.bottom_pane,
right_reserve: active_cell_right_reserve,
}))
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
);
flex.push(/*flex*/ 0, self.bottom_pane_renderable());
RenderableItem::Owned(Box::new(flex))
}
}
@@ -1957,6 +1957,29 @@ async fn ambient_pet_reduces_stream_width_and_composer_text_width() {
assert!(!row_tail_is_blank(&disabled_row, /*start_col*/ 69));
}
#[tokio::test]
async fn bottom_pane_renderable_can_be_laid_out_independently() {
use ratatui::Terminal;
let (chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let bottom_pane = chat.bottom_pane_renderable();
let width = 48;
let height = bottom_pane.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|frame| bottom_pane.render(frame.area(), frame.buffer_mut()))
.expect("render bottom pane");
assert_snapshot!(normalized_backend_snapshot(terminal.backend()), @r###"
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.5 default · /tmp/project "
"###);
}
fn buffer_row_containing(buffer: &ratatui::buffer::Buffer, text: &str) -> Option<String> {
(0..buffer.area.height)
.map(|y| {
@@ -3816,6 +3839,10 @@ async fn hidden_active_hook_does_not_add_transcript_separator() {
.active_cell_transcript_lines(/*width*/ 80)
.expect("active exec transcript lines")
.len();
let exec_only_display_line_count = chat
.active_cell_display_hyperlink_lines(/*width*/ 80)
.expect("active exec display lines")
.len();
handle_hook_started(
&mut chat,
@@ -3829,6 +3856,10 @@ async fn hidden_active_hook_does_not_add_transcript_separator() {
.active_cell_transcript_lines(/*width*/ 80)
.expect("active exec transcript lines");
assert_eq!(hidden_hook_transcript.len(), exec_only_line_count);
let hidden_hook_display = chat
.active_cell_display_hyperlink_lines(/*width*/ 80)
.expect("active exec display lines");
assert_eq!(hidden_hook_display.len(), exec_only_display_line_count);
reveal_running_hooks(&mut chat);
let visible_hook_lines = chat
+276
View File
@@ -0,0 +1,276 @@
//! Retained conversation rendering for an application-owned terminal viewport.
//!
//! This module intentionally owns only transcript projection and bottom-follow state. The app
//! decides when the terminal is owned and how much space remains after the bottom pane is laid
//! out. Committed cells use their main-viewport representation; the more detailed `Ctrl+T`
//! representation remains owned by `pager_overlay`.
use std::cell::Cell;
use std::sync::Arc;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use crate::chatwidget::ActiveCellRenderKey;
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
use crate::keymap::PagerKeymap;
use crate::pager_overlay::PagerContent;
use crate::render::Insets;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
use crate::terminal_hyperlinks::visible_lines;
pub(crate) struct ConversationViewport {
content: PagerContent,
cells: Vec<Arc<dyn HistoryCell>>,
render_mode: HistoryRenderMode,
live_tail_key: Option<LiveTailKey>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct LiveTailKey {
width: u16,
revision: u64,
is_stream_continuation: bool,
animation_tick: Option<u64>,
}
impl ConversationViewport {
pub(crate) fn new(
cells: Vec<Arc<dyn HistoryCell>>,
render_mode: HistoryRenderMode,
keymap: PagerKeymap,
) -> Self {
let renderables = Self::render_cells(&cells, render_mode);
Self {
content: PagerContent::new(renderables, keymap),
cells,
render_mode,
live_tail_key: None,
}
}
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
self.content.render(area, buf);
}
pub(crate) fn push_cell(&mut self, cell: Arc<dyn HistoryCell>) {
let follow_bottom = self.content.is_following_bottom();
let had_prior_cells = !self.cells.is_empty();
let tail_renderable = self.take_live_tail_renderable();
let renderable = Self::cell_renderable(
cell.clone(),
self.render_mode,
/*has_prior_cells*/ had_prior_cells,
);
self.cells.push(cell);
self.content.push(renderable);
if let Some(tail) = tail_renderable {
let tail = if !had_prior_cells
&& self
.live_tail_key
.is_some_and(|key| !key.is_stream_continuation)
{
Self::with_leading_spacing(tail)
} else {
tail
};
self.content.push(tail);
}
if follow_bottom {
self.content.scroll_to_bottom();
}
}
pub(crate) fn replace_cells(&mut self, cells: Vec<Arc<dyn HistoryCell>>) {
let follow_bottom = self.content.is_following_bottom();
self.take_live_tail_renderable();
self.live_tail_key = None;
self.cells = cells;
self.content
.replace(Self::render_cells(&self.cells, self.render_mode));
if follow_bottom {
self.content.scroll_to_bottom();
}
}
pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) {
if self.render_mode == render_mode {
return;
}
let follow_bottom = self.content.is_following_bottom();
self.take_live_tail_renderable();
self.live_tail_key = None;
self.render_mode = render_mode;
self.content
.replace(Self::render_cells(&self.cells, self.render_mode));
if follow_bottom {
self.content.scroll_to_bottom();
}
}
pub(crate) fn sync_live_tail(
&mut self,
width: u16,
active_key: Option<ActiveCellRenderKey>,
compute_lines: impl FnOnce(u16) -> Option<Vec<HyperlinkLine>>,
) {
let next_key = active_key.map(|key| LiveTailKey {
width,
revision: key.revision,
is_stream_continuation: key.is_stream_continuation,
animation_tick: key.animation_tick,
});
if self.live_tail_key == next_key {
return;
}
let follow_bottom = self.content.is_following_bottom();
self.take_live_tail_renderable();
self.live_tail_key = next_key;
if let Some(key) = next_key {
let lines = compute_lines(width).unwrap_or_default();
if !lines.is_empty() {
self.content.push(Self::live_tail_renderable(
lines,
!self.cells.is_empty(),
key.is_stream_continuation,
));
}
}
if follow_bottom {
self.content.scroll_to_bottom();
}
}
pub(crate) fn is_following_bottom(&self) -> bool {
self.content.is_following_bottom()
}
pub(crate) fn committed_cell_count(&self) -> usize {
self.cells.len()
}
fn render_cells(
cells: &[Arc<dyn HistoryCell>],
render_mode: HistoryRenderMode,
) -> Vec<Box<dyn Renderable>> {
cells
.iter()
.enumerate()
.map(|(index, cell)| {
Self::cell_renderable(
cell.clone(),
render_mode,
/*has_prior_cells*/ index > 0,
)
})
.collect()
}
fn cell_renderable(
cell: Arc<dyn HistoryCell>,
render_mode: HistoryRenderMode,
has_prior_cells: bool,
) -> Box<dyn Renderable> {
let is_stream_continuation = cell.is_stream_continuation();
let renderable: Box<dyn Renderable> = Box::new(ConversationCellRenderable {
cell,
render_mode,
cached_height: Cell::new(None),
});
if has_prior_cells && !is_stream_continuation {
Self::with_leading_spacing(renderable)
} else {
renderable
}
}
fn live_tail_renderable(
lines: Vec<HyperlinkLine>,
has_prior_cells: bool,
is_stream_continuation: bool,
) -> Box<dyn Renderable> {
let renderable: Box<dyn Renderable> = Box::new(HyperlinkLinesRenderable { lines });
if has_prior_cells && !is_stream_continuation {
Self::with_leading_spacing(renderable)
} else {
renderable
}
}
fn with_leading_spacing(renderable: Box<dyn Renderable>) -> Box<dyn Renderable> {
Box::new(InsetRenderable::new(
renderable,
Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
),
))
}
fn take_live_tail_renderable(&mut self) -> Option<Box<dyn Renderable>> {
(self.content.len() > self.cells.len()).then(|| self.content.pop())?
}
}
struct ConversationCellRenderable {
cell: Arc<dyn HistoryCell>,
render_mode: HistoryRenderMode,
cached_height: Cell<Option<(u16, u16)>>,
}
impl Renderable for ConversationCellRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
let hyperlink_lines = self
.cell
.display_hyperlink_lines_for_mode(area.width, self.render_mode);
Paragraph::new(Text::from(visible_lines(hyperlink_lines.clone())))
.wrap(Wrap { trim: false })
.render(area, buf);
mark_buffer_hyperlinks(buf, area, &hyperlink_lines, /*scroll_rows*/ 0);
}
fn desired_height(&self, width: u16) -> u16 {
if let Some((cached_width, height)) = self.cached_height.get()
&& cached_width == width
{
return height;
}
let height = self.cell.desired_height_for_mode(width, self.render_mode);
self.cached_height.set(Some((width, height)));
height
}
}
struct HyperlinkLinesRenderable {
lines: Vec<HyperlinkLine>,
}
impl Renderable for HyperlinkLinesRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
Paragraph::new(Text::from(visible_lines(self.lines.clone())))
.wrap(Wrap { trim: false })
.render(area, buf);
mark_buffer_hyperlinks(buf, area, &self.lines, /*scroll_rows*/ 0);
}
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(visible_lines(self.lines.clone())))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(/*default*/ 0)
}
}
#[cfg(test)]
#[path = "conversation_viewport_tests.rs"]
mod tests;
@@ -0,0 +1,235 @@
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use super::*;
use crate::history_cell::AgentMarkdownCell;
#[derive(Debug)]
struct TestCell {
display: &'static str,
raw: &'static str,
transcript: &'static str,
is_stream_continuation: bool,
}
impl HistoryCell for TestCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![self.display.into()]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![self.raw.into()]
}
fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![self.transcript.into()]
}
fn is_stream_continuation(&self) -> bool {
self.is_stream_continuation
}
}
fn cell(display: &'static str) -> Arc<dyn HistoryCell> {
Arc::new(TestCell {
display,
raw: display,
transcript: display,
is_stream_continuation: false,
})
}
fn viewport(cells: Vec<Arc<dyn HistoryCell>>) -> ConversationViewport {
ConversationViewport::new(
cells,
HistoryRenderMode::Rich,
crate::keymap::RuntimeKeymap::defaults().pager,
)
}
#[test]
fn renders_main_display_and_live_tail_without_pager_chrome() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(TestCell {
display: "compact display",
raw: "raw source",
transcript: "expanded transcript detail",
is_stream_continuation: false,
})];
let mut viewport = viewport(cells);
viewport.sync_live_tail(
/*width*/ 32,
Some(ActiveCellRenderKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(vec![HyperlinkLine::from("live tail")]),
);
let mut terminal =
Terminal::new(TestBackend::new(/*width*/ 32, /*height*/ 6)).expect("create terminal");
terminal
.draw(|frame| viewport.render(frame.area(), frame.buffer_mut()))
.expect("render conversation viewport");
assert_snapshot!(terminal.backend(), @r###"
"compact display "
" "
"live tail "
" "
" "
" "
"###);
}
#[test]
fn switches_between_rich_and_raw_cell_representations() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(TestCell {
display: "rich display",
raw: "raw source",
transcript: "expanded transcript detail",
is_stream_continuation: false,
})];
let mut viewport = viewport(cells);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 3,
);
let mut rich = Buffer::empty(area);
viewport.render(area, &mut rich);
viewport.set_render_mode(HistoryRenderMode::Raw);
let mut raw = Buffer::empty(area);
viewport.render(area, &mut raw);
assert!(buffer_text(&rich, area).contains("rich display"));
assert!(buffer_text(&raw, area).contains("raw source"));
assert!(!buffer_text(&raw, area).contains("expanded transcript detail"));
}
#[test]
fn append_keeps_live_tail_after_committed_cells() {
let mut viewport = viewport(Vec::new());
viewport.sync_live_tail(
/*width*/ 24,
Some(ActiveCellRenderKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(vec![HyperlinkLine::from("live tail")]),
);
viewport.push_cell(cell("committed"));
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 5,
);
let mut buffer = Buffer::empty(area);
viewport.render(area, &mut buffer);
let text = buffer_text(&buffer, area);
let committed = text
.find("committed")
.expect("committed cell should render");
let live_tail = text.find("live tail").expect("live tail should render");
assert!(committed < live_tail);
assert_eq!(viewport.committed_cell_count(), 1);
}
#[test]
fn replacing_cells_invalidates_and_respaces_the_live_tail() {
let key = ActiveCellRenderKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
};
let mut viewport = viewport(Vec::new());
viewport.sync_live_tail(/*width*/ 24, Some(key), |_| {
Some(vec![HyperlinkLine::from("live tail")])
});
viewport.replace_cells(vec![cell("replacement")]);
viewport.sync_live_tail(/*width*/ 24, Some(key), |_| {
Some(vec![HyperlinkLine::from("live tail")])
});
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 4,
);
let mut buffer = Buffer::empty(area);
viewport.render(area, &mut buffer);
let rows = buffer_text(&buffer, area);
assert_eq!(
rows.lines().map(str::trim_end).collect::<Vec<_>>(),
vec!["replacement", "", "live tail", ""]
);
}
#[test]
fn preserves_semantic_links_for_committed_and_live_content() {
let committed_destination = "https://example.com/committed";
let live_destination = "https://example.com/live";
let committed: Arc<dyn HistoryCell> = Arc::new(AgentMarkdownCell::new(
committed_destination.to_string(),
std::path::Path::new("/tmp"),
));
let live = AgentMarkdownCell::new(live_destination.to_string(), std::path::Path::new("/tmp"));
let mut viewport = viewport(vec![committed]);
viewport.sync_live_tail(
/*width*/ 28,
Some(ActiveCellRenderKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|width| Some(live.display_hyperlink_lines(width)),
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 28, /*height*/ 6,
);
let mut buffer = Buffer::empty(area);
viewport.render(area, &mut buffer);
let rendered = buffer_text(&buffer, area);
assert!(rendered.contains(&format!("\x1b]8;;{committed_destination}\x07")));
assert!(rendered.contains(&format!("\x1b]8;;{live_destination}\x07")));
}
#[test]
fn narrower_resize_stays_pinned_to_the_latest_cell() {
let mut viewport = viewport(vec![
cell("first long row that wraps"),
cell("second long row that wraps"),
cell("LATEST SENTINEL"),
]);
let wide = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 4,
);
viewport.render(wide, &mut Buffer::empty(wide));
let narrow = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 4,
);
let mut buffer = Buffer::empty(narrow);
viewport.render(narrow, &mut buffer);
assert!(viewport.is_following_bottom());
assert!(buffer_text(&buffer, narrow).contains("SENTINEL"));
}
fn buffer_text(buffer: &Buffer, area: Rect) -> String {
let mut rows = Vec::new();
for y in area.y..area.bottom() {
let mut row = String::new();
for x in area.x..area.right() {
row.push_str(buffer[(x, y)].symbol());
}
rows.push(row);
}
rows.join("\n")
}
+3
View File
@@ -105,6 +105,9 @@ mod clipboard_paste;
mod collaboration_modes;
mod color;
mod config_update;
// Staged renderer primitive; the owned-screen app path is wired in a follow-up change.
#[cfg_attr(not(test), allow(dead_code))]
mod conversation_viewport;
pub(crate) mod custom_terminal;
mod pets;
pub use custom_terminal::Terminal;
+80 -9
View File
@@ -10,7 +10,7 @@
//! stream-continuation flag (spacing), and an animation tick (time-based spinner/shimmer output).
//!
//! The transcript overlay live tail is kept in sync by `App` during draws: `App` supplies an
//! `ActiveCellTranscriptKey` and a function to compute the active cell transcript lines, and
//! `ActiveCellRenderKey` and a function to compute the active cell transcript lines, and
//! `TranscriptOverlay::sync_live_tail` uses the key to decide when the cached tail must be
//! recomputed. `ChatWidget` is responsible for producing a key that changes when the active cell
//! mutates in place or when its transcript output is time-dependent.
@@ -18,7 +18,7 @@
use std::io::Result;
use std::sync::Arc;
use crate::chatwidget::ActiveCellTranscriptKey;
use crate::chatwidget::ActiveCellRenderKey;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint;
@@ -169,7 +169,7 @@ impl PagerView {
.scroll_offset
.min(content_height.saturating_sub(content_area.height as usize));
self.render_content(content_area, buf);
self.render_content(content_area, buf, /*empty_row_marker*/ Some('~'));
self.render_bottom_bar(area, content_area, buf, content_height);
}
@@ -182,7 +182,7 @@ impl PagerView {
header.dim().render_ref(area, buf);
}
fn render_content(&self, area: Rect, buf: &mut Buffer) {
fn render_content(&self, area: Rect, buf: &mut Buffer, empty_row_marker: Option<char>) {
let mut y = -(self.scroll_offset as isize);
let mut drawn_bottom = area.y;
for renderable in &self.renderables {
@@ -207,11 +207,14 @@ impl PagerView {
}
}
let Some(empty_row_marker) = empty_row_marker else {
return;
};
for y in drawn_bottom..area.bottom() {
if area.width == 0 {
break;
}
buf[(area.x, y)] = Cell::from('~');
buf[(area.x, y)] = Cell::from(empty_row_marker);
for x in area.x + 1..area.right() {
buf[(x, y)] = Cell::from(' ');
}
@@ -311,6 +314,28 @@ impl PagerView {
area.height = area.height.saturating_sub(2);
area
}
/// Render only scrollable content, without pager header, footer, or empty-row markers.
///
/// This is the shared rendering primitive for application-owned views that provide their own
/// chrome. A view that was following the bottom stays pinned there when wrapping or the
/// available height changes.
fn render_content_only(&mut self, area: Rect, buf: &mut Buffer) {
let follow_bottom = self.is_scrolled_to_bottom();
Clear.render(area, buf);
self.update_last_content_height(area.height);
let content_height = self.content_height(area.width);
self.last_rendered_height = Some(content_height);
if let Some(idx) = self.pending_scroll_chunk.take() {
self.ensure_chunk_visible(idx, area);
} else if follow_bottom {
self.scroll_offset = usize::MAX;
}
self.scroll_offset = self
.scroll_offset
.min(content_height.saturating_sub(area.height as usize));
self.render_content(area, buf, /*empty_row_marker*/ None);
}
}
impl PagerView {
@@ -360,6 +385,52 @@ impl PagerView {
}
}
/// Scrollable content state shared by full-frame views that provide their own chrome.
pub(crate) struct PagerContent {
view: PagerView,
}
impl PagerContent {
pub(crate) fn new(renderables: Vec<Box<dyn Renderable>>, keymap: PagerKeymap) -> Self {
Self {
view: PagerView::new(
renderables,
/*title*/ String::new(),
/*scroll_offset*/ usize::MAX,
keymap,
),
}
}
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
self.view.render_content_only(area, buf);
}
pub(crate) fn replace(&mut self, renderables: Vec<Box<dyn Renderable>>) {
self.view.renderables = renderables;
}
pub(crate) fn push(&mut self, renderable: Box<dyn Renderable>) {
self.view.renderables.push(renderable);
}
pub(crate) fn pop(&mut self) -> Option<Box<dyn Renderable>> {
self.view.renderables.pop()
}
pub(crate) fn len(&self) -> usize {
self.view.renderables.len()
}
pub(crate) fn is_following_bottom(&self) -> bool {
self.view.is_scrolled_to_bottom()
}
pub(crate) fn scroll_to_bottom(&mut self) {
self.view.scroll_offset = usize::MAX;
}
}
/// A renderable that caches its desired height.
struct CachedRenderable {
renderable: Box<dyn Renderable>,
@@ -637,7 +708,7 @@ impl TranscriptOverlay {
pub(crate) fn sync_live_tail(
&mut self,
width: u16,
active_key: Option<ActiveCellTranscriptKey>,
active_key: Option<ActiveCellRenderKey>,
compute_lines: impl FnOnce(u16) -> Option<Vec<HyperlinkLine>>,
) {
let next_key = active_key.map(|key| LiveTailKey {
@@ -1095,7 +1166,7 @@ mod tests {
})]);
overlay.sync_live_tail(
/*width*/ 40,
Some(ActiveCellTranscriptKey {
Some(ActiveCellRenderKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
@@ -1124,7 +1195,7 @@ mod tests {
overlay.sync_live_tail(
area.width,
Some(ActiveCellTranscriptKey {
Some(ActiveCellRenderKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
@@ -1147,7 +1218,7 @@ mod tests {
})]);
let calls = std::cell::Cell::new(0usize);
let key = ActiveCellTranscriptKey {
let key = ActiveCellRenderKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,