mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
## What changed - Update the workspace, Bazel toolchain and lockfile, CI and release workflows, and Windows setup script from Rust `1.95.0` to `1.99.1`. - Adapt Rust code to the updated toolchain and Clippy lints, including `try_update` for atomics, `as_chunks` for fixed-size slices, and targeted allowances for generated tonic services. - Refresh the halfwidth sound mark rendering snapshot to use literal characters in debug output. GitOrigin-RevId: 5852029f687436dc814c2bca68141ee1f4a67d49
608 lines
20 KiB
Rust
608 lines
20 KiB
Rust
//! Static pager overlays and standalone transcript adapters.
|
|
//!
|
|
//! Static content retains its generic pager. Transcript previews share the main conversation
|
|
//! viewport, including its scrolling, selection, search and bounded text layouts.
|
|
|
|
mod scrolling;
|
|
mod transcript;
|
|
|
|
pub(crate) use transcript::TranscriptOverlay;
|
|
|
|
#[cfg(test)]
|
|
#[path = "pager_overlay/transcript_tests.rs"]
|
|
mod transcript_tests;
|
|
|
|
use std::io::Result;
|
|
use std::sync::Arc;
|
|
|
|
use crate::chatwidget::ActiveCellTranscriptKey;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::key_hint;
|
|
use crate::key_hint::KeyBinding;
|
|
use crate::key_hint::KeyBindingListExt;
|
|
use crate::key_hint::ShortcutHint;
|
|
use crate::keymap::PagerKeymap;
|
|
use crate::render::renderable::Renderable;
|
|
use crate::terminal_hyperlinks::HyperlinkLine;
|
|
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::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::Wrap;
|
|
use scrolling::render_offset_content;
|
|
|
|
pub(crate) enum Overlay {
|
|
Transcript(TranscriptOverlay),
|
|
Static(StaticOverlay),
|
|
Analytics(Box<crate::analytics::AnalyticsView>),
|
|
}
|
|
|
|
impl Overlay {
|
|
pub(crate) fn new_transcript(
|
|
cells: Vec<Arc<dyn HistoryCell>>,
|
|
keymap: PagerKeymap,
|
|
copy_on_select: bool,
|
|
mouse_scroll_speed: f64,
|
|
) -> Self {
|
|
let mut overlay = TranscriptOverlay::new(cells, keymap);
|
|
overlay.view.copy_on_select = copy_on_select;
|
|
overlay.view.mouse_scroll_speed = mouse_scroll_speed;
|
|
overlay.view.primary_selection = crate::clipboard_copy::primary::available();
|
|
Self::Transcript(overlay)
|
|
}
|
|
|
|
pub(crate) fn with_copy_on_select_clear_selection(mut self, enabled: bool) -> Self {
|
|
if let Self::Transcript(overlay) = &mut self {
|
|
overlay.view.copy_on_select_clear_selection = enabled;
|
|
}
|
|
self
|
|
}
|
|
|
|
pub(crate) fn new_static_with_lines(
|
|
lines: Vec<Line<'static>>,
|
|
title: String,
|
|
keymap: PagerKeymap,
|
|
) -> Self {
|
|
Self::Static(StaticOverlay::with_title(lines, title, keymap))
|
|
}
|
|
|
|
pub(crate) fn new_static_with_renderables(
|
|
renderables: Vec<Box<dyn Renderable>>,
|
|
title: String,
|
|
keymap: PagerKeymap,
|
|
) -> Self {
|
|
Self::Static(StaticOverlay::with_renderables(renderables, title, keymap))
|
|
}
|
|
|
|
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
|
let mouse_capture = match self {
|
|
Overlay::Transcript(_) | Overlay::Analytics(_) => tui::MouseCapture::Enabled,
|
|
Overlay::Static(_) => tui::MouseCapture::Disabled,
|
|
};
|
|
tui.set_mouse_capture(mouse_capture)?;
|
|
let result = match self {
|
|
Overlay::Transcript(o) => o.handle_event(tui, event).map(drop),
|
|
Overlay::Static(o) => o.handle_event(tui, event),
|
|
Overlay::Analytics(o) => o.handle_event(tui, event),
|
|
};
|
|
if result.is_err() || self.is_done() {
|
|
let restore = tui.set_mouse_capture(tui::MouseCapture::Inherit);
|
|
return result.and(restore);
|
|
}
|
|
result
|
|
}
|
|
|
|
pub(crate) fn is_done(&self) -> bool {
|
|
match self {
|
|
Overlay::Transcript(o) => o.is_done(),
|
|
Overlay::Static(o) => o.is_done(),
|
|
Overlay::Analytics(o) => o.is_done,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn first_or_empty(
|
|
keymap: &PagerKeymap,
|
|
action: &'static str,
|
|
bindings: &[KeyBinding],
|
|
) -> Vec<ShortcutHint> {
|
|
keymap.primary_hint(action, bindings).into_iter().collect()
|
|
}
|
|
|
|
// Render a single line of key hints from (key(s), description) pairs.
|
|
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<ShortcutHint>, &str)]) {
|
|
let mut spans: Vec<Span<'static>> = vec![" ".into()];
|
|
let mut first = true;
|
|
for (keys, desc) in pairs {
|
|
if !first {
|
|
spans.push(" · ".dim());
|
|
}
|
|
for (i, key) in keys.iter().enumerate() {
|
|
if i > 0 {
|
|
spans.extend(crate::key_hint::key_label_spans("/"));
|
|
}
|
|
spans.extend(key.spans());
|
|
}
|
|
spans.push(" ".into());
|
|
spans.push(Span::from(desc.to_string()));
|
|
first = false;
|
|
}
|
|
Paragraph::new(vec![Line::from(spans).dim()]).render(area, buf);
|
|
}
|
|
|
|
fn render_navigation_hints(area: Rect, buf: &mut Buffer, keymap: &PagerKeymap) {
|
|
let actions = [
|
|
("scroll_up", &keymap.scroll_up),
|
|
("scroll_down", &keymap.scroll_down),
|
|
("page_up", &keymap.page_up),
|
|
("page_down", &keymap.page_down),
|
|
("jump_top", &keymap.jump_top),
|
|
("jump_bottom", &keymap.jump_bottom),
|
|
];
|
|
const ACTIONS_PER_HINT: usize = 2;
|
|
let (action_groups, _) = actions.as_chunks::<ACTIONS_PER_HINT>();
|
|
let hints = action_groups
|
|
.iter()
|
|
.zip(["to scroll", "to page", "to jump"])
|
|
.map(|(actions, description)| {
|
|
(
|
|
actions
|
|
.iter()
|
|
.filter_map(|(action, bindings)| keymap.primary_hint(action, bindings))
|
|
.collect(),
|
|
description,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
render_key_hints(area, buf, &hints);
|
|
}
|
|
|
|
/// Generic widget for rendering a pager view.
|
|
struct PagerView {
|
|
renderables: Vec<Box<dyn Renderable>>,
|
|
scroll_offset: usize,
|
|
title: String,
|
|
keymap: PagerKeymap,
|
|
last_content_height: Option<usize>,
|
|
}
|
|
|
|
impl PagerView {
|
|
fn new(
|
|
renderables: Vec<Box<dyn Renderable>>,
|
|
title: String,
|
|
scroll_offset: usize,
|
|
keymap: PagerKeymap,
|
|
) -> Self {
|
|
Self {
|
|
renderables,
|
|
scroll_offset,
|
|
title,
|
|
keymap,
|
|
last_content_height: 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.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(area, buf);
|
|
let header = format!("/ {}", self.title);
|
|
header.dim().render(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, /*height*/ 1);
|
|
|
|
Span::from("─".repeat(sep_rect.width as usize))
|
|
.dim()
|
|
.render(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(Rect::new(pct_x, sep_rect.y, pct_w, /*height*/ 1), buf);
|
|
}
|
|
|
|
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
|
|
match key_event {
|
|
e if self.keymap.scroll_up.is_pressed(e) => {
|
|
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
|
}
|
|
e if self.keymap.scroll_down.is_pressed(e) => {
|
|
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
|
}
|
|
e if self.keymap.page_up.is_pressed(e) => {
|
|
let page_height = self.page_height(tui.terminal.viewport_area);
|
|
self.scroll_offset = self.scroll_offset.saturating_sub(page_height);
|
|
}
|
|
e if self.keymap.page_down.is_pressed(e) => {
|
|
let page_height = self.page_height(tui.terminal.viewport_area);
|
|
self.scroll_offset = self.scroll_offset.saturating_add(page_height);
|
|
}
|
|
e if self.keymap.half_page_down.is_pressed(e) => {
|
|
let half_page = self
|
|
.page_height(tui.terminal.viewport_area)
|
|
.saturating_add(1)
|
|
/ 2;
|
|
self.scroll_offset = self.scroll_offset.saturating_add(half_page);
|
|
}
|
|
e if self.keymap.half_page_up.is_pressed(e) => {
|
|
let half_page = self
|
|
.page_height(tui.terminal.viewport_area)
|
|
.saturating_add(1)
|
|
/ 2;
|
|
self.scroll_offset = self.scroll_offset.saturating_sub(half_page);
|
|
}
|
|
e if self.keymap.jump_top.is_pressed(e) => {
|
|
self.scroll_offset = 0;
|
|
}
|
|
e if self.keymap.jump_bottom.is_pressed(e) => {
|
|
self.scroll_offset = usize::MAX;
|
|
}
|
|
_ => {
|
|
return Ok(());
|
|
}
|
|
}
|
|
tui.frame_requester()
|
|
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns the height of one page in content rows.
|
|
///
|
|
/// Prefers the last rendered content height (excluding header/footer chrome);
|
|
/// if no render has occurred yet, falls back to the content area height
|
|
/// computed from the given viewport.
|
|
fn page_height(&self, viewport_area: Rect) -> usize {
|
|
self.last_content_height
|
|
.unwrap_or_else(|| self.content_area(viewport_area).height as usize)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
/// A renderable that caches its desired height.
|
|
struct CachedRenderable {
|
|
renderable: Box<dyn Renderable>,
|
|
height: std::cell::Cell<Option<u16>>,
|
|
last_width: std::cell::Cell<Option<u16>>,
|
|
}
|
|
|
|
impl CachedRenderable {
|
|
fn new(renderable: impl Into<Box<dyn Renderable>>) -> 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 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);
|
|
self.height.set(Some(height));
|
|
self.last_width.set(Some(width));
|
|
}
|
|
self.height.get().unwrap_or(0)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub(crate) enum TranscriptHistoryState {
|
|
#[default]
|
|
Idle,
|
|
LoadingOlder,
|
|
LoadingBeginning,
|
|
Partial,
|
|
Failed,
|
|
Complete,
|
|
}
|
|
|
|
impl TranscriptHistoryState {
|
|
pub(crate) fn has_unloaded_history(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::LoadingOlder | Self::LoadingBeginning | Self::Partial | Self::Failed
|
|
)
|
|
}
|
|
}
|
|
|
|
pub(crate) struct PagerAction {
|
|
pub(crate) key: KeyBinding,
|
|
pub(crate) label: &'static str,
|
|
pub(crate) run: Box<dyn FnOnce() + Send>,
|
|
}
|
|
|
|
pub(crate) struct StaticOverlay {
|
|
pub(crate) action: Option<PagerAction>,
|
|
view: PagerView,
|
|
is_done: bool,
|
|
}
|
|
|
|
impl StaticOverlay {
|
|
const HINTS_HEIGHT: u16 = 3;
|
|
|
|
pub(crate) fn with_title(
|
|
lines: Vec<Line<'static>>,
|
|
title: String,
|
|
keymap: PagerKeymap,
|
|
) -> Self {
|
|
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
|
|
Self::with_renderables(
|
|
vec![Box::new(CachedRenderable::new(paragraph))],
|
|
title,
|
|
keymap,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn with_renderables(
|
|
renderables: Vec<Box<dyn Renderable>>,
|
|
title: String,
|
|
keymap: PagerKeymap,
|
|
) -> Self {
|
|
Self {
|
|
view: PagerView::new(renderables, title, /*scroll_offset*/ 0, keymap),
|
|
is_done: false,
|
|
action: None,
|
|
}
|
|
}
|
|
|
|
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_navigation_hints(line1, buf, &self.view.keymap);
|
|
let mut pairs = vec![(
|
|
first_or_empty(&self.view.keymap, "close", &self.view.keymap.close),
|
|
"close",
|
|
)];
|
|
if let Some(action) = &self.action {
|
|
pairs.push((vec![action.key.into()], action.label));
|
|
}
|
|
render_key_hints(line2, buf, &pairs);
|
|
}
|
|
|
|
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
|
let top_h = area.height.saturating_sub(Self::HINTS_HEIGHT);
|
|
let top = Rect::new(area.x, area.y, area.width, top_h);
|
|
let bottom = Rect::new(area.x, area.y + top_h, area.width, Self::HINTS_HEIGHT);
|
|
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) => {
|
|
if self.view.keymap.close.is_pressed(key) {
|
|
self.is_done = true;
|
|
} else if key.kind == crossterm::event::KeyEventKind::Press
|
|
&& let Some(action) = self.action.take_if(|action| action.key.is_press(key))
|
|
{
|
|
self.is_done = true;
|
|
(action.run)();
|
|
} else {
|
|
return self.view.handle_key_event(tui, key);
|
|
}
|
|
Ok(())
|
|
}
|
|
TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => {
|
|
tui.draw(u16::MAX, |frame| {
|
|
self.render(frame.area(), frame.buffer);
|
|
})?;
|
|
Ok(())
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
pub(crate) fn is_done(&self) -> bool {
|
|
self.is_done
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use insta::assert_snapshot;
|
|
use pretty_assertions::assert_eq;
|
|
use ratatui::Terminal;
|
|
use ratatui::backend::TestBackend;
|
|
|
|
fn paragraph_block(label: &str, lines: usize) -> Box<dyn Renderable> {
|
|
let text = Text::from(
|
|
(0..lines)
|
|
.map(|i| Line::from(format!("{label}{i}")))
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
Box::new(Paragraph::new(text)) as Box<dyn Renderable>
|
|
}
|
|
|
|
fn default_pager_keymap() -> crate::keymap::PagerKeymap {
|
|
crate::keymap::RuntimeKeymap::defaults().pager
|
|
}
|
|
|
|
fn static_overlay(lines: Vec<Line<'static>>, title: &str) -> StaticOverlay {
|
|
StaticOverlay::with_title(lines, title.to_string(), default_pager_keymap())
|
|
}
|
|
|
|
fn pager_view(
|
|
renderables: Vec<Box<dyn Renderable>>,
|
|
title: &str,
|
|
scroll_offset: usize,
|
|
) -> PagerView {
|
|
PagerView::new(
|
|
renderables,
|
|
title.to_string(),
|
|
scroll_offset,
|
|
default_pager_keymap(),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn footer_hints_display_chords_without_internal_dispatch_keys() {
|
|
use codex_config::types::KeybindingSpec;
|
|
use codex_config::types::KeybindingsSpec;
|
|
use codex_config::types::TuiKeymap;
|
|
|
|
let mut config = TuiKeymap::default();
|
|
config.pager.page_up = Some(KeybindingsSpec::One(KeybindingSpec(
|
|
"ctrl-x page-up".to_string(),
|
|
)));
|
|
let keymap = crate::keymap::RuntimeKeymap::from_config(&config).expect("valid pager chord");
|
|
|
|
assert_eq!(
|
|
first_or_empty(&keymap.pager, "page_up", &keymap.pager.page_up),
|
|
vec![ShortcutHint::Chord {
|
|
prefix: key_hint::ctrl(KeyCode::Char('x')),
|
|
completion: key_hint::plain(KeyCode::PageUp),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
|
|
fn static_overlay_snapshot_basic() {
|
|
// Prepare a static overlay with a few lines and a title
|
|
let mut overlay = static_overlay(
|
|
vec!["one".into(), "two".into(), "three".into()],
|
|
"S T A T I C",
|
|
);
|
|
let mut term = Terminal::new(TestBackend::new(/*width*/ 40, /*height*/ 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 = static_overlay(
|
|
vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()],
|
|
"S T A T I C",
|
|
);
|
|
let mut term = Terminal::new(TestBackend::new(/*width*/ 24, /*height*/ 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 = pager_view(
|
|
vec![
|
|
paragraph_block("a", /*lines*/ 2),
|
|
paragraph_block("b", /*lines*/ 3),
|
|
],
|
|
"T",
|
|
/*scroll_offset*/ 0,
|
|
);
|
|
|
|
assert_eq!(pv.content_height(/*width*/ 80), 5);
|
|
}
|
|
}
|