mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Render only visible rows in the transcript pager (#39063)
## Why Scrolling the transcript pager previously rendered a scratch buffer containing all rows through the visible viewport, including hidden content above it. This made rendering work and buffer size grow with the scroll offset. ## What changed - Add viewport-aware rendering for committed transcript cells and the live tail. - Forward scrolled rendering through cached and inset renderables while keeping the full-height fallback for renderables that do not support it. - Keep hyperlink metadata aligned with scrolled text. ## Testing Add coverage comparing viewport-aware output with the full-height fallback across wrapping widths, offsets, insets, styles, hyperlinks, and live tails. GitOrigin-RevId: 630c30c4a61969a50f2b34701ca1dd87788a4298
This commit is contained in:
@@ -15,13 +15,14 @@
|
||||
//! 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.
|
||||
|
||||
mod scrolling;
|
||||
|
||||
use std::io::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::chatwidget::ActiveCellTranscriptKey;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::SessionInfoCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
@@ -30,10 +31,7 @@ use crate::keymap::PagerKeymap;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::InsetRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::style::user_message_style;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
|
||||
use crate::terminal_hyperlinks::visible_lines_ref;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -41,7 +39,6 @@ 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;
|
||||
@@ -50,6 +47,9 @@ use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::Wrap;
|
||||
use scrolling::CellRenderable;
|
||||
use scrolling::HyperlinkLinesRenderable;
|
||||
use scrolling::render_offset_content;
|
||||
|
||||
pub(crate) enum Overlay {
|
||||
Transcript(TranscriptOverlay),
|
||||
@@ -417,6 +417,11 @@ impl Renderable for CachedRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.renderable.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
|
||||
self.renderable.render_scrolled(area, buf, scroll_offset)
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
if self.last_width.get() != Some(width) {
|
||||
let height = self.renderable.desired_height(width);
|
||||
@@ -427,56 +432,6 @@ impl Renderable for CachedRenderable {
|
||||
}
|
||||
}
|
||||
|
||||
struct CellRenderable {
|
||||
cell: Arc<dyn HistoryCell>,
|
||||
highlighted: bool,
|
||||
}
|
||||
|
||||
impl Renderable for CellRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let hyperlink_lines = self.cell.transcript_hyperlink_lines(area.width);
|
||||
let style = if self.cell.as_any().is::<UserHistoryCell>() {
|
||||
if self.highlighted {
|
||||
user_message_style().reversed()
|
||||
} else {
|
||||
user_message_style()
|
||||
}
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let p = Paragraph::new(Text::from(visible_lines_ref(&hyperlink_lines)))
|
||||
.style(style)
|
||||
.wrap(Wrap { trim: false });
|
||||
p.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &hyperlink_lines, /*scroll_rows*/ 0);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.cell.desired_transcript_height(width)
|
||||
}
|
||||
}
|
||||
|
||||
struct HyperlinkLinesRenderable {
|
||||
lines: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
impl Renderable for HyperlinkLinesRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
Paragraph::new(Text::from(visible_lines_ref(&self.lines)))
|
||||
.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_ref(&self.lines)))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(/*default*/ 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum TranscriptHistoryState {
|
||||
#[default]
|
||||
@@ -1055,33 +1010,6 @@ impl StaticOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
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::*;
|
||||
|
||||
123
codex-rs/tui/src/pager_overlay/scrolling.rs
Normal file
123
codex-rs/tui/src/pager_overlay/scrolling.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! Viewport-aware transcript rendering and the fallback for generic pager content.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::style::user_message_style;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
|
||||
use crate::terminal_hyperlinks::visible_lines_ref;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
/// Renders a committed history cell directly into the visible transcript viewport.
|
||||
pub(super) struct CellRenderable {
|
||||
pub(super) cell: Arc<dyn HistoryCell>,
|
||||
pub(super) highlighted: bool,
|
||||
}
|
||||
|
||||
impl Renderable for CellRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.render_scrolled(area, buf, /*scroll_offset*/ 0);
|
||||
}
|
||||
|
||||
/// Scroll visible text and hyperlink metadata together without rendering hidden rows.
|
||||
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
|
||||
let hyperlink_lines = self.cell.transcript_hyperlink_lines(area.width);
|
||||
let style = if self.cell.as_any().is::<UserHistoryCell>() {
|
||||
if self.highlighted {
|
||||
user_message_style().reversed()
|
||||
} else {
|
||||
user_message_style()
|
||||
}
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let p = Paragraph::new(Text::from(visible_lines_ref(&hyperlink_lines)))
|
||||
.style(style)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((scroll_offset, 0));
|
||||
p.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &hyperlink_lines, usize::from(scroll_offset));
|
||||
true
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.cell.desired_transcript_height(width)
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the optional in-flight transcript tail without allocating hidden rows.
|
||||
pub(super) struct HyperlinkLinesRenderable {
|
||||
pub(super) lines: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
impl Renderable for HyperlinkLinesRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.render_scrolled(area, buf, /*scroll_offset*/ 0);
|
||||
}
|
||||
|
||||
/// Keep live-tail hyperlinks aligned with the same visible rows as their text.
|
||||
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
|
||||
Paragraph::new(Text::from(visible_lines_ref(&self.lines)))
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((scroll_offset, 0))
|
||||
.render(area, buf);
|
||||
mark_buffer_hyperlinks(buf, area, &self.lines, usize::from(scroll_offset));
|
||||
true
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
Paragraph::new(Text::from(visible_lines_ref(&self.lines)))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(/*default*/ 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Render visible rows directly when supported, preserving the legacy scratch-buffer fallback.
|
||||
pub(super) fn render_offset_content(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
renderable: &dyn Renderable,
|
||||
scroll_offset: u16,
|
||||
) -> u16 {
|
||||
let height = renderable.desired_height(area.width);
|
||||
let copy_height = area.height.min(height.saturating_sub(scroll_offset));
|
||||
if copy_height == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let visible_area = Rect::new(area.x, area.y, area.width, copy_height);
|
||||
if renderable.render_scrolled(visible_area, buf, scroll_offset) {
|
||||
return copy_height;
|
||||
}
|
||||
|
||||
let mut tall_buf = Buffer::empty(Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
area.width,
|
||||
scroll_offset + copy_height,
|
||||
));
|
||||
renderable.render(*tall_buf.area(), &mut tall_buf);
|
||||
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)]
|
||||
#[path = "scrolling_tests.rs"]
|
||||
mod tests;
|
||||
284
codex-rs/tui/src/pager_overlay/scrolling_tests.rs
Normal file
284
codex-rs/tui/src/pager_overlay/scrolling_tests.rs
Normal file
@@ -0,0 +1,284 @@
|
||||
use super::super::CachedRenderable;
|
||||
use super::super::TranscriptOverlay;
|
||||
use super::CellRenderable;
|
||||
use super::HyperlinkLinesRenderable;
|
||||
use super::render_offset_content;
|
||||
use crate::chatwidget::ActiveCellTranscriptKey;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::InsetRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HyperlinkTestCell {
|
||||
lines: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
impl HistoryCell for HyperlinkTestCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.lines.clone())
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {
|
||||
self.lines.clone()
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
visible_lines(self.lines.clone())
|
||||
}
|
||||
|
||||
fn has_stable_transcript_height(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces the unchanged full-height fallback while preserving the wrapped renderable's output.
|
||||
struct LegacyOnlyRenderable {
|
||||
inner: Box<dyn Renderable>,
|
||||
}
|
||||
|
||||
impl Renderable for LegacyOnlyRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.inner.render(area, buf);
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.inner.desired_height(width)
|
||||
}
|
||||
}
|
||||
|
||||
fn scrolled_hyperlink_lines() -> Vec<HyperlinkLine> {
|
||||
let mut linked = HyperlinkLine::new(Line::from(vec![
|
||||
"prefix ".green(),
|
||||
"漢字 ".bold(),
|
||||
"ガ ".italic(),
|
||||
]));
|
||||
linked.push_span(
|
||||
"clickable 漢字 ガ".cyan().underlined(),
|
||||
Some("https://example.com/a/wrapped-private-destination"),
|
||||
);
|
||||
linked.push_span(
|
||||
" suffix words that continue wrapping".blue(),
|
||||
/*destination*/ None,
|
||||
);
|
||||
|
||||
vec![
|
||||
HyperlinkLine::new(Line::from("first styled row".magenta())),
|
||||
linked,
|
||||
HyperlinkLine::new(Line::default()),
|
||||
HyperlinkLine::new(Line::from("last 漢字 ガ row".red())),
|
||||
]
|
||||
}
|
||||
|
||||
fn scrolled_test_renderables(lines: &[HyperlinkLine]) -> Vec<(&'static str, Box<dyn Renderable>)> {
|
||||
let cell: Arc<dyn HistoryCell> = Arc::new(HyperlinkTestCell {
|
||||
lines: lines.to_vec(),
|
||||
});
|
||||
let user: Arc<dyn HistoryCell> = Arc::new(UserHistoryCell {
|
||||
message: "highlighted 漢字 user message that wraps\nsecond user line".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
});
|
||||
|
||||
vec![
|
||||
(
|
||||
"uncached history cell",
|
||||
Box::new(CellRenderable {
|
||||
cell: cell.clone(),
|
||||
highlighted: false,
|
||||
}),
|
||||
),
|
||||
(
|
||||
"highlighted cached user history cell",
|
||||
Box::new(CachedRenderable::new(CellRenderable {
|
||||
cell: user,
|
||||
highlighted: true,
|
||||
})),
|
||||
),
|
||||
(
|
||||
"inset cached history cell",
|
||||
Box::new(InsetRenderable::new(
|
||||
Box::new(CachedRenderable::new(CellRenderable {
|
||||
cell,
|
||||
highlighted: false,
|
||||
})) as Box<dyn Renderable>,
|
||||
Insets::tlbr(
|
||||
/*top*/ 2, /*left*/ 1, /*bottom*/ 1, /*right*/ 1,
|
||||
),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"live tail",
|
||||
Box::new(HyperlinkLinesRenderable {
|
||||
lines: lines.to_vec(),
|
||||
}),
|
||||
),
|
||||
(
|
||||
"inset cached live tail",
|
||||
Box::new(InsetRenderable::new(
|
||||
Box::new(CachedRenderable::new(HyperlinkLinesRenderable {
|
||||
lines: lines.to_vec(),
|
||||
})) as Box<dyn Renderable>,
|
||||
Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 1, /*bottom*/ 1, /*right*/ 1,
|
||||
),
|
||||
)),
|
||||
),
|
||||
(
|
||||
"unsupported paragraph fallback",
|
||||
Box::new(
|
||||
Paragraph::new(Text::from(visible_lines(lines.to_vec())))
|
||||
.wrap(Wrap { trim: false }),
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolled_transcript_renderables_match_full_height_fallback() {
|
||||
let lines = scrolled_hyperlink_lines();
|
||||
|
||||
for width in [5, 7, 13, 28] {
|
||||
for ((name, renderable), (_, legacy_inner)) in scrolled_test_renderables(&lines)
|
||||
.into_iter()
|
||||
.zip(scrolled_test_renderables(&lines))
|
||||
{
|
||||
let legacy = LegacyOnlyRenderable {
|
||||
inner: legacy_inner,
|
||||
};
|
||||
let height = renderable.desired_height(width);
|
||||
for offset in [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
height.saturating_sub(/*rhs*/ 2),
|
||||
height.saturating_sub(/*rhs*/ 1),
|
||||
height,
|
||||
] {
|
||||
for (x, y, visible_height) in [(0, 0, 4), (3, 2, 3)] {
|
||||
let area = Rect::new(x, y, width, visible_height);
|
||||
let full_area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
area.right().saturating_add(/*rhs*/ 2),
|
||||
area.bottom().saturating_add(/*rhs*/ 2),
|
||||
);
|
||||
let mut expected = Buffer::empty(full_area);
|
||||
let mut actual = Buffer::empty(full_area);
|
||||
|
||||
let expected_height =
|
||||
render_offset_content(area, &mut expected, &legacy, offset);
|
||||
let actual_height =
|
||||
render_offset_content(area, &mut actual, &*renderable, offset);
|
||||
|
||||
assert_eq!(
|
||||
(actual_height, actual),
|
||||
(expected_height, expected),
|
||||
"renderable={name}, width={width}, offset={offset}, area={area:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_scrolled_cells_and_live_tail_match_full_height_fallback() {
|
||||
let lines = scrolled_hyperlink_lines();
|
||||
let cell: Arc<dyn HistoryCell> = Arc::new(HyperlinkTestCell {
|
||||
lines: lines.clone(),
|
||||
});
|
||||
|
||||
for width in [7, 13, 28] {
|
||||
let cells: Vec<Arc<dyn HistoryCell>> = vec![
|
||||
Arc::new(PlainHistoryCell::new(vec![Line::from(
|
||||
"leading stable history",
|
||||
)])),
|
||||
cell.clone(),
|
||||
];
|
||||
let mut actual = TranscriptOverlay::new(cells.clone(), RuntimeKeymap::defaults().pager);
|
||||
let mut expected = TranscriptOverlay::new(cells, RuntimeKeymap::defaults().pager);
|
||||
for overlay in [&mut actual, &mut expected] {
|
||||
overlay.sync_live_tail(
|
||||
width,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
}),
|
||||
|_| Some(lines.clone()),
|
||||
);
|
||||
}
|
||||
expected.view.renderables = expected
|
||||
.view
|
||||
.renderables
|
||||
.into_iter()
|
||||
.map(|inner| Box::new(LegacyOnlyRenderable { inner }) as Box<dyn Renderable>)
|
||||
.collect();
|
||||
|
||||
let area = Rect::new(/*x*/ 2, /*y*/ 1, width, /*height*/ 10);
|
||||
let full_area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
area.right().saturating_add(/*rhs*/ 1),
|
||||
area.bottom().saturating_add(/*rhs*/ 1),
|
||||
);
|
||||
let total_height = actual.view.content_height(width);
|
||||
for offset in [0, 1, 3, total_height.saturating_sub(/*rhs*/ 2), usize::MAX] {
|
||||
actual.view.scroll_offset = offset;
|
||||
expected.view.scroll_offset = offset;
|
||||
let mut actual_buffer = Buffer::empty(full_area);
|
||||
let mut expected_buffer = Buffer::empty(full_area);
|
||||
|
||||
actual.render(area, &mut actual_buffer);
|
||||
expected.render(area, &mut expected_buffer);
|
||||
|
||||
assert_eq!(
|
||||
actual_buffer, expected_buffer,
|
||||
"width={width}, offset={offset}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_handles_offsets_near_maximum_height() {
|
||||
struct MaximumHeightRenderable;
|
||||
|
||||
impl Renderable for MaximumHeightRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let last_row = area.bottom().saturating_sub(/*rhs*/ 1);
|
||||
buf[(area.x, last_row)].set_symbol("x");
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
u16::MAX
|
||||
}
|
||||
}
|
||||
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 1, /*height*/ 2,
|
||||
);
|
||||
let mut actual = Buffer::empty(area);
|
||||
let mut expected = Buffer::empty(area);
|
||||
expected[(area.x, area.y)].set_symbol("x");
|
||||
let height = render_offset_content(area, &mut actual, &MaximumHeightRenderable, u16::MAX - 1);
|
||||
|
||||
assert_eq!((height, actual), (1, expected));
|
||||
}
|
||||
@@ -16,6 +16,13 @@ use crate::render::RectExt as _;
|
||||
pub trait Renderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||
fn desired_height(&self, width: u16) -> u16;
|
||||
/// Renders visible rows after `scroll_offset` when direct scrolling is supported.
|
||||
///
|
||||
/// Implementations returning `false` must leave `buf` unchanged so callers can use their
|
||||
/// existing full-height rendering fallback. Supporting wrappers must forward this method.
|
||||
fn render_scrolled(&self, _area: Rect, _buf: &mut Buffer, _scroll_offset: u16) -> bool {
|
||||
false
|
||||
}
|
||||
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
|
||||
None
|
||||
}
|
||||
@@ -44,6 +51,13 @@ impl<'a> Renderable for RenderableItem<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
|
||||
match self {
|
||||
RenderableItem::Owned(child) => child.render_scrolled(area, buf, scroll_offset),
|
||||
RenderableItem::Borrowed(child) => child.render_scrolled(area, buf, scroll_offset),
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
match self {
|
||||
RenderableItem::Owned(child) => child.cursor_pos(area),
|
||||
@@ -479,6 +493,46 @@ impl<'a> Renderable for InsetRenderable<'a> {
|
||||
+ self.insets.top
|
||||
+ self.insets.bottom
|
||||
}
|
||||
|
||||
/// Preserve clipped inset padding while forwarding only visible child rows.
|
||||
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
|
||||
let top_padding = self.insets.top.saturating_sub(scroll_offset);
|
||||
let child_width = area
|
||||
.width
|
||||
.saturating_sub(self.insets.left.saturating_add(self.insets.right));
|
||||
if child_width == 0 || top_padding >= area.height {
|
||||
return true;
|
||||
}
|
||||
|
||||
let child_offset = scroll_offset.saturating_sub(self.insets.top);
|
||||
// The fallback applies bottom padding to its clipped scratch buffer, even mid-scroll.
|
||||
let child_height = self
|
||||
.child
|
||||
.desired_height(child_width)
|
||||
.saturating_sub(child_offset)
|
||||
.min(
|
||||
area.height
|
||||
.saturating_sub(top_padding)
|
||||
.saturating_sub(self.insets.bottom),
|
||||
);
|
||||
if child_height == 0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let child_area = Rect::new(
|
||||
area.x.saturating_add(self.insets.left),
|
||||
area.y.saturating_add(top_padding),
|
||||
child_width,
|
||||
child_height,
|
||||
);
|
||||
if child_offset == 0 {
|
||||
self.child.render(child_area, buf);
|
||||
true
|
||||
} else {
|
||||
self.child.render_scrolled(child_area, buf, child_offset)
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.child.cursor_pos(area.inset(self.insets))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user